diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 176f7d6ed..7ad39f07e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -6,7 +6,7 @@ workflow: default: image: - name: fpco/stack-build:lts-17.15 + name: fpco/stack-build:lts-18.0 variables: STACK_ROOT: "${CI_PROJECT_DIR}/.stack" diff --git a/CHANGELOG.md b/CHANGELOG.md index e679e1592..fe60e202e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,139 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [25.25.0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/v25.24.1...v25.25.0) (2022-01-21) + + +### Features + +* **communication:** support attachments in course/tutorial comm's ([5bd9ea8](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5bd9ea85e8f0e4387cf47116bf42c4441bdbe8b3)) +* **file-field:** cumulative size limit ([b749039](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b749039636c61157b5fc0bea9848ab9828ee671c)) + +## [25.24.1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/v25.24.0...v25.24.1) (2021-12-29) + + +### Bug Fixes + +* **courses:** enhanced description of study modules ([89fadb2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/89fadb242037151ea792667cab85fc502b135f57)) + +## [25.24.0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/v25.23.0...v25.24.0) (2021-12-28) + + +### Features + +* **course:** show study module on course overview page ([dbc5e99](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/dbc5e99109285d4427832820a77a6b47a8098f62)) +* **course:** study modules as new course property ([cb00de7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cb00de7960c91d87f5f8fb7ecb29dd15cb61a5a3)) + +## [25.23.0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/v25.22.4...v25.23.0) (2021-12-14) + + +### Features + +* **check-all:** added shift click functionality ([da1c8b5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/da1c8b54510ee1436fefe97ba32372a08299b83e)) +* **checkrange:** added tooltip ([ce6f09d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ce6f09dd857f53dc8c350d7d29b2164c78645b59)) +* **checkrange:** new util checkrange ([337bf73](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/337bf73067f2b98450d0388a1c064f0d2f9c456c)) +* **checkrange:** unchecking a range is possible ([154f2e3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/154f2e35cc0e154ff80002b2e0aff3a76afa1ed6)) +* **erweiterung such-filter usersr:** first try ([da3b339](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/da3b3391bd5aa9990dfb2818847cf8524ee68a9d)) +* **messages:** added frontend translation class ([61c773f](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/61c773f51cddb65dd0529f17799cbf7871023137)) +* **tooltips:** added translatable tooltip ([e74b610](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e74b61065a5de811bd411c0e863fddf9b9baada0)) + + +### Bug Fixes + +* **check-all:** correct constructor argument ([02ce82e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/02ce82e9d2026730fd4716a2c0b070c38a6fc53f)) +* **frontend-tooltips:** icon is shown ([86ee2fb](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/86ee2fb14c05e3b6a78c6c51bf961b6c41d3e5c5)) +* **modal:** modals are never destroyed ([7dbe1ac](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7dbe1ac08aacbda3b145a0da394706273dd6c639)) +* **modal:** modals are never destroyed ([53dab90](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/53dab90810675f743ece284883da9c4c0e84270e)) + +## [25.22.4](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/v25.22.3...v25.22.4) (2021-10-26) + + +### Bug Fixes + +* **routes:** make access to workflows free ([29c54db](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/29c54db06f01659a3a6419009964a85cd11d5441)) + +## [25.22.3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/v25.22.2...v25.22.3) (2021-10-21) + + +### Bug Fixes + +* **navigation:** always link workflows nav to instances ([adf9709](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/adf9709567d9a320f2c17d3c5dde940c2f9d8862)) + +## [25.22.2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/v25.22.1...v25.22.2) (2021-10-13) + +## [25.22.1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/v25.22.0...v25.22.1) (2021-10-02) + + +### Bug Fixes + +* **course-admins:** display course admins as admins instead of assistants ([f1fe444](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f1fe4447fbe7e96e55aaf284c7083338b5135ab6)) + +## [25.22.0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/v25.21.0...v25.22.0) (2021-08-30) + + +### Features + +* **event-manager:** added method to register a list of listeners ([1a8fb23](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1a8fb230441f73b9b1fe593f4df1005a06d9628e)) +* **event-manager:** mutation observers can be managed via the event manager ([34b4f48](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/34b4f48386c8fff4569b12eb3f7919e7c77d33c0)) +* **http-client:** added possibility to remove specific interceptors ([0823df3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0823df33b5f157af290aca9a65f1df429048e53b)) +* **tutoriumsdaten:** application restore ([d4a73e6](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d4a73e699a399b02cadcea03e614c789671ee6d1)) +* **tutoriumsdaten:** firts draft ([e972788](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/e972788f540a9ce6c3fdf841313057b62a579d72)) +* **tutoriumsdaten:** termin ([ebcb234](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/ebcb23429ff09c56ba4a00a6cb8f082ee83e1fe8)) +* **util_registry:** impelmented destroyAll(scope) method in the utilRegistry ([f1ef2e5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f1ef2e5ec776ad8a1fb7737eb0ed4f79218afd61)) +* implemented an event manager ([c1c3536](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c1c35369d1ae017971e6d8cbafc06844f02fd00d)) + + +### Bug Fixes + +* **async-form:** destroy all after response is processed ([14a16c7](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/14a16c7283483ff22ce22b070a430a61d24a7f35)) +* **communication-recipients:** fixed undefined error with context and a few minor issues ([03ac803](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/03ac80342e0f3fcb1db2adf34f86a2c0d8fabf0f)) +* **enter-is-tab.js:** implemented destroy method in enter-is-tab Util ([d1b9952](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/d1b995269060c670d85f715671bfc9947b9f3e9a)) +* **hide-columns:** removed clear storage from destroy method ([b3b0d65](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b3b0d6585068ecbc665e819b31c94f4d96a0fec5)) +* **hide-colums:** small fix ([50a3ac1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/50a3ac1790f26c1e6f983d3687352d7811173110)) +* **http-client:** strict equality check ([5078b56](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5078b56c16513f3b289bc4824ce0c7914b1a220d)) +* **interactive-fieldset:** small fix ([4c2c683](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4c2c68327e75f5f51271853159c232fdd7bba21e)) +* **navigate-away-promp:** removed unnecessary destroyAll ([7a07159](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7a0715906c8336ed64bbfabdf614746ade9f661f)) +* **password:** added cleanUP ([204ce39](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/204ce39f7c2f9c405971aa59da068a5389dda417)) +* **show-hide:** storage manager is not cleared ([9453689](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/945368972da36f4ff0da99f38c71ea9a1c7157d7)) +* **storage-manager:** clear is working without options as well ([f1c50e1](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/f1c50e137f4f11140c1171dd9d86bc5511b9e771)) +* **tooltips:** correct regex match ([c8d36ea](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c8d36ea52dbef0a2e1aaec6aedaf7edd524975dc)) +* **tooltips:** removed else case ([8d0241e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/8d0241e727efb5051e5848f2a0c835d7a8d3ae6b)) +* **util-registry:** filtering activeUtilInstances when a util is destroyed ([5b4ac75](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/5b4ac7587438e7adb21e0ff3d9d629b6ff00263a)) +* **util-registry:** handle negative indices correctly ([cbc03f5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cbc03f57c56df9c96a84da15a68d26904b75a65f)) +* fixed a few minor issues ([6320cd9](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6320cd927a84445f056dc782fb440d276cb26009)) +* prompt not shwowing up after submit/close ([abe8415](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/abe84156d508dca8fce549b24d5902d24afc0dbf)) +* smaller fixes and typos ([1f978e6](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1f978e65a82a91fb728a7ee2970a4fd9e6beb521)) + +## [25.21.0](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/v25.20.2...v25.21.0) (2021-08-20) + + +### Features + +* **corrections-r:** allow csv exporting one line per submittor ([7aadb66](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/7aadb6662bc8db76436f8d41ded7156acb98418e)) +* **corrections-r:** authorship statement state ([51522ef](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/51522efc7c9915115e0d8791320a03e35d2933c8)) +* **corrections-r:** csv export ([2a6248e](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2a6248e3d5d4f4de5f1c7d6c6bcf092dc9873a2e)), closes [#705](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/issues/705) +* **corrections-r:** filter/sort by pseudonym ([153af8c](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/153af8c6b4042430bb4bc120fa5c24a5d114e4c1)) +* **corrections-r:** json export ([fe8e4bb](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/fe8e4bbd4f6a8b1b1c54808ebc96ee675a078648)) +* **course admin:** application restore ([cb4ed8d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/cb4ed8d9887e521f47689c118baf439846cd4514)) +* **course admin:** done ([15689c5](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/15689c597ef407583b01dabc9f7631e9dc90b009)) +* **course admin:** no new-line ([0a6a174](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/0a6a1749d351e626383e513293af280f78552009)) +* **lecturer type:** aenderung ([89e1d67](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/89e1d675c3be0fec106e84920184a8c95dfa6346)) +* **link password time:** application restore ([6d536c3](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/6d536c39bd9f3117f18d2e52c93f178aea4a002d)) +* **link password time:** done ([4490e9a](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/4490e9ad20c55153e81a344c7dbf7813cb219108)) +* **link password time:** done ([2321216](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/2321216b0f4f194c7cd8b47eb020819d6aa1f2e5)) +* **link password time:** new time format ([df2a9bc](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/df2a9bc20fe9f958cbee98315b644ec2fcba0630)) +* **link password time:** restore application ([c5c5417](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/c5c541709b5053c08d21bdd753bb99df574c6c5b)) +* **link password time:** restore application ([85006ff](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/85006ff389188b56a8b61943621c190c9a9503b7)) +* **sorting tutorial table:** application restore ([9dc12de](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/9dc12de056e73736659c053b0eabef66ca524047)) +* **sorting tutorial table:** done ([482241d](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/482241d033c32c52c31ea20920a4fec07ba975dd)) +* **tutor tabel sorting:** dbt sorting tutors added ([b1787cd](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/b1787cd77e8a643accc0ef54cc18c87df215680c)) + + +### Bug Fixes + +* **corrections-r:** allow filtering by matriculation ([1b6b781](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/1b6b781e82c39bc29c8984c587ac836f0da77a02)) +* **csv:** less quoting in semicolon separated lists ([42f1eab](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/commit/42f1eabb2c984a7d30ea8b90710c68aff8af9f97)) + ## [25.20.2](https://gitlab2.rz.ifi.lmu.de/uni2work/uni2work/compare/v25.20.1...v25.20.2) (2021-08-16) diff --git a/config/settings.yml b/config/settings.yml index 6a7827636..db5ad1c3d 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -294,3 +294,5 @@ bot-mitigations: - only-logged-in-table-sorting volatile-cluster-settings-cache-time: 10 + +communication-attachments-max-size: 20971520 # 20MiB diff --git a/frontend/src/lib/event-manager/event-manager.js b/frontend/src/lib/event-manager/event-manager.js new file mode 100644 index 000000000..a69965a6f --- /dev/null +++ b/frontend/src/lib/event-manager/event-manager.js @@ -0,0 +1,103 @@ + +export const EVENT_TYPE = { + CLICK : 'click', + KEYDOWN : 'keydown', + INVALID : 'invalid', + CHANGE : 'change', + MOUSE_OVER : 'mouseover', + MOUSE_OUT : 'mouseout', + SUBMIT : 'submit', + INPUT : 'input', + FOCUS_OUT : 'focusout', + BEFOREUNLOAD : 'beforeunload', + HASH_CHANGE : 'hashchange', +}; + + + +export class EventManager { + _registeredListeners; + _mutationObservers; + + + constructor() { + this._registeredListeners = []; + this._mutationObservers = []; + } + + registerNewListener(eventWrapper) { + this._debugLog('registerNewListener', eventWrapper); + let element = eventWrapper.element; + element.addEventListener(eventWrapper.eventType, eventWrapper.eventHandler, eventWrapper.options); + this._registeredListeners.push(eventWrapper); + } + + registerListeners(eventWrappers) { + eventWrappers.forEach((eventWrapper) => this.registerNewListener(eventWrapper)); + } + + registerNewMutationObserver(callback, domNode, config) { + let observer = new MutationObserver(callback); + observer.observe(domNode, config); + this._mutationObservers.push(observer); + } + + removeAllEventListenersFromUtil() { + this._debugLog('removeAllEventListenersFromUtil',); + for (let eventWrapper of this._registeredListeners) { + let element = eventWrapper.element; + element.removeEventListener(eventWrapper.eventType, eventWrapper.eventHandler); + } + this._registeredListeners = []; + } + + removeAllObserversFromUtil() { + this._mutationObservers.forEach((observer) => observer.disconnect()); + this.mutationObservers = []; + } + + cleanUp() { + this.removeAllObserversFromUtil(); + this.removeAllEventListenersFromUtil(); + } + + + + _debugLog() {} + //_debugLog(fName, ...args) { + // console.log(`[DEBUGLOG] EventManager.${fName}`, { args: args, instance: this }); + //} +} + +export class EventWrapper { + _eventType; + _eventHandler; + _element; + _options + + constructor(_eventType, _eventHandler, _element, _options) { + if(!_eventType || !_eventHandler || !_element) { + throw new Error('Not enough arguments!'); + } + this._eventType = _eventType; + this._eventHandler = _eventHandler; + this._element = _element; + this._options = _options; + } + + get eventType() { + return this._eventType; + } + + get eventHandler() { + return this._eventHandler; + } + + get element() { + return this._element; + } + + get options() { + return this._options; + } +} \ No newline at end of file diff --git a/frontend/src/lib/storage-manager/storage-manager.js b/frontend/src/lib/storage-manager/storage-manager.js index f2ee68589..d9ef366ec 100644 --- a/frontend/src/lib/storage-manager/storage-manager.js +++ b/frontend/src/lib/storage-manager/storage-manager.js @@ -186,14 +186,14 @@ export class StorageManager { } } - clear(options) { + clear(options=this._options) { this._debugLog('clear', options); if (options && options.location !== undefined && !Object.values(LOCATION).includes(options.location)) { throw new Error('StorageManager.clear called with unsupported location option'); } - const locations = options && options.location !== undefined ? [options.location] : LOCATION_SHADOWING; + const locations = ((options !== undefined) && options.location !== undefined)? [options.location] : this._location_shadowing; for (const location of locations) { switch (location) { @@ -204,7 +204,10 @@ export class StorageManager { case LOCATION.WINDOW: return this._clearWindow(); case LOCATION.HISTORY: - return this._clearHistory(options && options.history); + if(options && options.history) + return this._clearHistory(options.history); + else + return; default: console.error('StorageManager.clear cannot clear with unsupported location'); } diff --git a/frontend/src/lib/tooltips/frontend-tooltips.js b/frontend/src/lib/tooltips/frontend-tooltips.js new file mode 100644 index 000000000..75535ddae --- /dev/null +++ b/frontend/src/lib/tooltips/frontend-tooltips.js @@ -0,0 +1,22 @@ +export class FrontendTooltips { + + static addToolTip(element, text) { + let tooltipWrap = document.createElement('span'); + tooltipWrap.className = 'tooltip'; + + let tooltipContent = document.createElement('span'); + tooltipContent.className = 'tooltip__content'; + tooltipContent.appendChild(document.createTextNode(text)); + tooltipWrap.append(tooltipContent); + + let tooltipHandle = document.createElement('span'); + tooltipHandle.className = 'tooltip__handle'; + let icon = document.createElement('i'); + icon.classList.add('fas'); + icon.classList.add('fa-question-circle'); + tooltipHandle.append(icon); + tooltipWrap.append(tooltipHandle); + + element.append(tooltipWrap); + } +} \ No newline at end of file diff --git a/frontend/src/messages.js b/frontend/src/messages.js new file mode 100644 index 000000000..1f1ce3574 --- /dev/null +++ b/frontend/src/messages.js @@ -0,0 +1,17 @@ +export class Translations { + static translations = { + 'checkrangeTooltip' : { + 'de' : 'Shift-Klick, um mehrere Zellen zu markieren.', + 'en' : 'Shift-click to mark multiple cells.', + }, + }; + + static getTranslation(key, language) { + let json = Translations.translations[key]; + if(language === 'en') { + return json.en; + } else { + return json.de; + } + } +}; \ No newline at end of file diff --git a/frontend/src/services/http-client/http-client.js b/frontend/src/services/http-client/http-client.js index 274f86cdb..81f72485e 100644 --- a/frontend/src/services/http-client/http-client.js +++ b/frontend/src/services/http-client/http-client.js @@ -15,6 +15,18 @@ export class HttpClient { } } + removeResponseInterceptor(interceptor) { + //performs a reference check. if the interceptor is bound, when adding it, the reference of the bound function needs to be the same when removing it later. + + if (typeof interceptor !== 'function') { + throw new Error(`Cannot remove Interceptor ${interceptor}, because it is not of type function`); + } + if(this._responseInterceptors.filter(el => el == interceptor).length === 0) { + throw new Error(`Could not find Response Interceptor ${interceptor}.`); + } + this._responseInterceptors = this._responseInterceptors.filter(el => el !== interceptor); + } + _baseUrl; setBaseUrl(baseUrl) { diff --git a/frontend/src/services/http-client/http-client.spec.js b/frontend/src/services/http-client/http-client.spec.js index a0f76584d..594f7096c 100644 --- a/frontend/src/services/http-client/http-client.spec.js +++ b/frontend/src/services/http-client/http-client.spec.js @@ -75,7 +75,7 @@ describe('HttpClient', () => { expect(httpClient._responseInterceptors.length).toBe(2); }); - describe('get called', () => { + describe('get called and removed', () => { let intercepted1; let intercepted2; const interceptors = { @@ -111,6 +111,14 @@ describe('HttpClient', () => { done(); }); }); + + it('can be removed', () => { + expect(httpClient._responseInterceptors.length).toBe(2); + httpClient.removeResponseInterceptor(interceptors.interceptor1); + expect(httpClient._responseInterceptors.length).toBe(1); + expect(() => {httpClient.removeResponseInterceptor(interceptors.interceptor1);}).toThrow(); + expect(httpClient._responseInterceptors.length).toBe(1); + }); }); }); }); diff --git a/frontend/src/services/util-registry/util-registry.js b/frontend/src/services/util-registry/util-registry.js index 65e9326cc..2b0005a35 100644 --- a/frontend/src/services/util-registry/util-registry.js +++ b/frontend/src/services/util-registry/util-registry.js @@ -4,8 +4,8 @@ const DEBUG_MODE = /localhost/.test(window.location.href) ? 1 : 0; export class UtilRegistry { - _registeredUtils = new Array(); - _activeUtilInstances = new Array(); + _registeredUtilClasses = new Array(); //{utilClass} + _activeUtilInstancesWrapped = new Array(); //{utilClass, scope, element, instance} _appInstance; /** @@ -33,7 +33,7 @@ export class UtilRegistry { console.log('registering util "' + util.name + '"'); console.log({ util }); } - this._registeredUtils.push(util); + this._registeredUtilClasses.push(util); } deregister(name, destroy) { @@ -44,7 +44,7 @@ export class UtilRegistry { this._destroyUtilInstances(name); } - this._registeredUtils.splice(utilIndex, 1); + this._registeredUtilClasses.splice(utilIndex, 1); } } @@ -54,7 +54,7 @@ export class UtilRegistry { initAll(scope = document.body) { let startedInstances = new Array(); - const setupInstances = this._registeredUtils.map((util) => this.setup(util, scope)).flat(); + const setupInstances = this._registeredUtilClasses.map((util) => this.setup(util, scope)).flat(); const orderedInstances = setupInstances.filter(_isStartOrdered); @@ -97,6 +97,20 @@ export class UtilRegistry { return startedInstances; } + destroyAll(scope = document.body) { + let utilsInScope = this._getUtilInstancesWithinScope(scope); + + utilsInScope.forEach((util) => { + if(DEBUG_MODE > 2) { + console.log('Destroying Util: ', {util}); + } + util.destroy(); + this._activeUtilInstancesWrapped = this._activeUtilInstancesWrapped.filter(utilWrapped => { + return utilWrapped.element === util._element; + }); + }); + } + setup(util, scope = document.body) { if (DEBUG_MODE > 2) { console.log('setting up util', { util }); @@ -130,12 +144,12 @@ export class UtilRegistry { }); } - this._activeUtilInstances.push(...instances); + this._activeUtilInstancesWrapped.push(...instances); return instances; } find(name) { - return this._registeredUtils.find((util) => util.name === name); + return this._registeredUtilClasses.find((util) => util.name === name); } _findUtilElements(util, scope) { @@ -146,24 +160,33 @@ export class UtilRegistry { } _findUtilIndex(name) { - return this._registeredUtils.findIndex((util) => util.name === name); + return this._registeredUtilClasses.findIndex((util) => util.name === name); + } + + _getUtilInstancesWithinScope(scope) { + let utilInstances = []; + + for (let activeUtilInstance of this._activeUtilInstancesWrapped) { + let util = activeUtilInstance.util; + if(this._findUtilElements(util, scope).length > 0) { + utilInstances.push(activeUtilInstance.instance); + } + } + return utilInstances; } _destroyUtilInstances(name) { - this._activeUtilInstances + this._activeUtilInstancesWrapped .map((util, index) => ({ util: util, index: index, })) - .filter((activeUtil) => activeUtil.util.name === name) + .filter((activeUtil) => activeUtil.util.util.name === name) .forEach((activeUtil) => { // destroy util instance - activeUtil.util.destroy(); - delete this._activeUtilInstances[activeUtil.index]; + activeUtil.util.instance.destroy(); + this._activeUtilInstancesWrapped = this._activeUtilInstancesWrapped.splice(activeUtil.index, 1); }); - - // get rid of now empty array slots - this._activeUtilInstances = this._activeUtilInstances.filter((util) => !!util); } } diff --git a/frontend/src/services/util-registry/util-registry.spec.js b/frontend/src/services/util-registry/util-registry.spec.js index 07b9e2627..18506be99 100644 --- a/frontend/src/services/util-registry/util-registry.spec.js +++ b/frontend/src/services/util-registry/util-registry.spec.js @@ -24,24 +24,6 @@ describe('UtilRegistry', () => { }); }); - describe('deregister()', () => { - it('should remove util', () => { - // register util - utilRegistry.register(TestUtil1); - let foundUtil = utilRegistry.find(TestUtil1.name); - expect(foundUtil).toBeTruthy(); - - // deregister util - utilRegistry.deregister(TestUtil1.name); - foundUtil = utilRegistry.find(TestUtil1.name); - expect(foundUtil).toBeFalsy(); - }); - - it('should destroy util instances if requested', () => { - pending('TBD'); - }); - }); - describe('setup()', () => { it('should catch errors thrown by the utility', () => { @@ -107,6 +89,51 @@ describe('UtilRegistry', () => { }); }); + describe('deregister()', () => { + let testScope; + let testElement1; + let testElement2; + + beforeEach(() => { + testScope = document.createElement('div'); + testElement1 = document.createElement('div'); + testElement2 = document.createElement('div'); + testElement1.classList.add('util1'); + testElement2.classList.add('util1'); + testScope.appendChild(testElement1); + testScope.appendChild(testElement2); + }); + + it('should remove util', () => { + // register util + utilRegistry.register(TestUtil1); + let foundUtil = utilRegistry.find(TestUtil1.name); + expect(foundUtil).toBeTruthy(); + + // deregister util + utilRegistry.deregister(TestUtil1.name); + foundUtil = utilRegistry.find(TestUtil1.name); + expect(foundUtil).toBeFalsy(); + }); + + it('should destroy util instances if requested', () => { + utilRegistry.register(TestUtil1); + let foundUtil = utilRegistry.find(TestUtil1.name); + expect(foundUtil).toBeTruthy(); + + utilRegistry.setup(TestUtil1, testScope); + let firstActiveUtil = utilRegistry._activeUtilInstancesWrapped[0]; + expect(utilRegistry._activeUtilInstancesWrapped.length).toEqual(2); + expect(utilRegistry._activeUtilInstancesWrapped[0].element).toEqual(testElement1); + + spyOn(firstActiveUtil.instance, 'destroy'); + + utilRegistry.deregister(TestUtil1.name, true); + expect(utilRegistry._activeUtilInstancesWrapped[0]).toBeFalsy(); + expect(firstActiveUtil.instance.destroy).toHaveBeenCalled(); + }); + }); + describe('initAll()', () => { it('should setup all the utilities', () => { spyOn(utilRegistry, 'setup'); @@ -172,6 +199,44 @@ describe('UtilRegistry', () => { }); }); }); + + describe('destroyAll()', () => { + let testScope; + let testElement; + let firstUtil; + + beforeEach( () => { + testScope = document.createElement('div'); + testElement = document.createElement('div'); + testElement.classList.add('util3'); + testScope.appendChild(testElement); + + utilRegistry.register(TestUtil3); + utilRegistry.initAll(testScope); + + firstUtil = utilRegistry._activeUtilInstancesWrapped[0]; + spyOn(firstUtil.instance, 'destroy'); + }); + + it('Util should be destroyed', () => { + utilRegistry.destroyAll(testScope); + expect(utilRegistry._activeUtilInstancesWrapped.length).toBe(0); + expect(firstUtil.instance.destroy).toHaveBeenCalled(); + }); + + it('Util out of scope should not be destroyed', () => { + let outOfScope = document.createElement('div'); + expect(utilRegistry._activeUtilInstancesWrapped.length).toEqual(1); + + utilRegistry.destroyAll(outOfScope); + + expect(utilRegistry._activeUtilInstancesWrapped.length).toEqual(1); + expect(utilRegistry._activeUtilInstancesWrapped[0]).toBe(firstUtil); + expect(firstUtil.instance.destroy).not.toHaveBeenCalled(); + + }); + }); + }); // test utilities @@ -181,6 +246,8 @@ class TestUtil1 { this.element = element; this.app = app; } + + destroy() {} } @Utility({ selector: '#util2' }) @@ -190,6 +257,7 @@ class TestUtil2 { } class TestUtil3 { constructor() {} start() {} + destroy() {} } @Utility({ selector: '#throws' }) diff --git a/frontend/src/utils/alerts/alerts.js b/frontend/src/utils/alerts/alerts.js index dcecc915b..a88fd1f19 100644 --- a/frontend/src/utils/alerts/alerts.js +++ b/frontend/src/utils/alerts/alerts.js @@ -1,4 +1,5 @@ import { Utility } from '../../core/utility'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; import './alerts.sass'; const ALERTS_INITIALIZED_CLASS = 'alerts--initialized'; @@ -32,6 +33,9 @@ export class Alerts { _element; _app; + _eventManager; + _boundResponseInterceptor; + constructor(element, app) { if (!element) { throw new Error('Alerts util has to be called with an element!'); @@ -40,6 +44,9 @@ export class Alerts { this._element = element; this._app = app; + this._eventManager = new EventManager(); + this._boundResponseInterceptor = this._responseInterceptor.bind(this); + if (this._element.classList.contains(ALERTS_INITIALIZED_CLASS)) { return false; } @@ -47,21 +54,30 @@ export class Alerts { this._togglerElement = this._element.querySelector('.' + ALERTS_TOGGLER_CLASS); this._alertElements = this._gatherAlertElements(); - if (this._togglerElement) { - this._initToggler(); - } - - this._initAlerts(); - - // register http client interceptor to filter out Alerts Header - this._setupHttpInterceptor(); - // mark initialized this._element.classList.add(ALERTS_INITIALIZED_CLASS); } + start() { + if (this._togglerElement) { + this._initToggler(); + } + this._initAlerts(); + + // register http client interceptor to filter out Alerts Header + this._setupHttpInterceptor(); + } + destroy() { - console.log('TBD: Destroy Alert'); + this._eventManager.cleanUp(); + this._app.httpClient.removeResponseInterceptor(this._boundResponseInterceptor); + + if(this._alertElements) { + this._alertElements.forEach(element => element.remove()); + } + + if(this._element.classList.contains(ALERTS_INITIALIZED_CLASS)) + this._element.classList.remove(ALERTS_INITIALIZED_CLASS); } _gatherAlertElements() { @@ -71,10 +87,12 @@ export class Alerts { } _initToggler() { - this._togglerElement.addEventListener('click', () => { + let clickListenerToggler = new EventWrapper(EVENT_TYPE.CLICK, () => { this._alertElements.forEach((alertEl) => this._toggleAlert(alertEl, true)); this._togglerElement.classList.remove(ALERTS_TOGGLER_VISIBLE_CLASS); - }); + }, this._togglerElement); + + this._eventManager.registerNewListener(clickListenerToggler); } _initAlerts() { @@ -88,9 +106,11 @@ export class Alerts { } const closeEl = alertElement.querySelector('.' + ALERT_CLOSER_CLASS); - closeEl.addEventListener('click', () => { + const closeAlertEvent = new EventWrapper(EVENT_TYPE.CLICK, (() => { this._toggleAlert(alertElement); - }); + }).bind(this), closeEl); + + this._eventManager.registerNewListener(closeAlertEvent); if (autoHideDelay > 0 && alertElement.matches(ALERT_AUTOCLOSING_MATCHER)) { window.setTimeout(() => this._toggleAlert(alertElement), autoHideDelay * 1000); @@ -118,7 +138,7 @@ export class Alerts { } _setupHttpInterceptor() { - this._app.httpClient.addResponseInterceptor(this._responseInterceptor.bind(this)); + this._app.httpClient.addResponseInterceptor(this._boundResponseInterceptor); } _elevateAlerts() { diff --git a/frontend/src/utils/alerts/alerts.spec.js b/frontend/src/utils/alerts/alerts.spec.js index 0b4749e97..db14c7361 100644 --- a/frontend/src/utils/alerts/alerts.spec.js +++ b/frontend/src/utils/alerts/alerts.spec.js @@ -1,8 +1,9 @@ -import { Alerts } from './alerts'; +import { Alerts, ALERTS_INITIALIZED_CLASS } from './alerts'; const MOCK_APP = { httpClient: { addResponseInterceptor: () => {}, + removeResponseInterceptor: () => {}, }, }; @@ -19,6 +20,12 @@ describe('Alerts', () => { expect(alerts).toBeTruthy(); }); + it('should destory alerts', () => { + alerts.destroy(); + expect(alerts._eventManager._registeredListeners.length).toBe(0); + expect(alerts._element.classList).not.toContain(ALERTS_INITIALIZED_CLASS); + }); + it('should throw if called without an element', () => { expect(() => { new Alerts(); diff --git a/frontend/src/utils/asidenav/asidenav.js b/frontend/src/utils/asidenav/asidenav.js index 6bf425ffa..324fc3fd8 100644 --- a/frontend/src/utils/asidenav/asidenav.js +++ b/frontend/src/utils/asidenav/asidenav.js @@ -1,4 +1,5 @@ import { Utility } from '../../core/utility'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; import './asidenav.sass'; const FAVORITES_BTN_CLASS = 'navbar__list-item--favorite'; @@ -15,6 +16,7 @@ export class Asidenav { _element; _asidenavSubmenus; + _eventManager; constructor(element) { if (!element) { @@ -23,6 +25,8 @@ export class Asidenav { this._element = element; + this._eventManager = new EventManager(); + if (this._element.classList.contains(ASIDENAV_INITIALIZED_CLASS)) { return false; } @@ -35,19 +39,24 @@ export class Asidenav { } destroy() { - this._asidenavSubmenus.forEach((union) => { - union.listItem.removeEventListener(union.hoverHandler); - }); + + this._eventManager.cleanUp(); + + if(this._element.classList.contains(ASIDENAV_INITIALIZED_CLASS)) + this._element.classList.remove(ASIDENAV_INITIALIZED_CLASS); } _initFavoritesButton() { const favoritesBtn = document.querySelector('.' + FAVORITES_BTN_CLASS); if (favoritesBtn) { - favoritesBtn.addEventListener('click', (event) => { + + const favoritesButtonEvent = new EventWrapper(EVENT_TYPE.CLICK, (event) => { favoritesBtn.classList.toggle(FAVORITES_BTN_ACTIVE_CLASS); this._element.classList.toggle(ASIDENAV_EXPANDED_CLASS); event.preventDefault(); - }, true); + }, favoritesBtn, true); + + this._eventManager.registerNewListener(favoritesButtonEvent); } } @@ -62,7 +71,8 @@ export class Asidenav { this._asidenavSubmenus.forEach((union) => { union.hoverHandler = this._createMouseoverHandler(union); - union.listItem.addEventListener('mouseover', union.hoverHandler); + let currentHoverEvent = new EventWrapper(EVENT_TYPE.MOUSE_OVER, union.hoverHandler, union.listItem); + this._eventManager.registerNewListener(currentHoverEvent); }); } diff --git a/frontend/src/utils/asidenav/asidenav.spec.js b/frontend/src/utils/asidenav/asidenav.spec.js index bdc7aee68..3bd468b28 100644 --- a/frontend/src/utils/asidenav/asidenav.spec.js +++ b/frontend/src/utils/asidenav/asidenav.spec.js @@ -1,4 +1,4 @@ -import { Asidenav } from './asidenav'; +import { Asidenav, ASIDENAV_INITIALIZED_CLASS } from './asidenav'; describe('Asidenav', () => { @@ -13,6 +13,12 @@ describe('Asidenav', () => { expect(asidenav).toBeTruthy(); }); + it('should destory asidenav', () => { + asidenav.destroy(); + expect(asidenav._eventManager._registeredListeners.length).toBe(0); + expect(asidenav._element.classList).not.toContain(ASIDENAV_INITIALIZED_CLASS); + }); + it('should throw if called without an element', () => { expect(() => { new Asidenav(); diff --git a/frontend/src/utils/async-form/async-form.js b/frontend/src/utils/async-form/async-form.js index 6c0da95c0..482cdb634 100644 --- a/frontend/src/utils/async-form/async-form.js +++ b/frontend/src/utils/async-form/async-form.js @@ -1,5 +1,6 @@ import { Utility } from '../../core/utility'; import { Datepicker } from '../form/datepicker'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; import './async-form.sass'; const ASYNC_FORM_INITIALIZED_CLASS = 'check-all--initialized'; @@ -20,6 +21,8 @@ export class AsyncForm { _element; _app; + _eventManager; + constructor(element, app) { if (!element) { throw new Error('Async Form Utility cannot be setup without an element!'); @@ -28,17 +31,23 @@ export class AsyncForm { this._element = element; this._app = app; + this._eventManager = new EventManager(); + if (this._element.classList.contains(ASYNC_FORM_INITIALIZED_CLASS)) { return false; } - this._element.addEventListener('submit', this._submitHandler); + const submitEvent = new EventWrapper(EVENT_TYPE.SUBMIT, this._submitHandler.bind(this), this._element); + this._eventManager.registerNewListener(submitEvent); this._element.classList.add(ASYNC_FORM_INITIALIZED_CLASS); } destroy() { - // TODO + this._eventManager.cleanUp(); + + if(this._element.classList.contains(ASYNC_FORM_INITIALIZED_CLASS)) + this._element.classList.remove(ASYNC_FORM_INITIALIZED_CLASS); } _processResponse(response) { @@ -51,6 +60,7 @@ export class AsyncForm { setTimeout(() => { parentElement.insertBefore(responseElement, this._element); this._element.remove(); + this._app.utilRegistry.destroyAll(this._element); }, delay); } @@ -91,7 +101,6 @@ export class AsyncForm { ).catch(() => { const failureMessage = this._app.i18n.get('asyncFormFailure'); this._processResponse({ content: failureMessage }); - this._element.classList.remove(ASYNC_FORM_LOADING_CLASS); }); } diff --git a/frontend/src/utils/async-form/async-form.spec.js b/frontend/src/utils/async-form/async-form.spec.js index f01280b8a..aeb7ce4ba 100644 --- a/frontend/src/utils/async-form/async-form.spec.js +++ b/frontend/src/utils/async-form/async-form.spec.js @@ -1,4 +1,4 @@ -import { AsyncForm } from './async-form'; +import { AsyncForm, ASYNC_FORM_INITIALIZED_CLASS } from './async-form'; describe('AsyncForm', () => { @@ -13,6 +13,12 @@ describe('AsyncForm', () => { expect(asyncForm).toBeTruthy(); }); + it('should destroy asyncForm', () => { + asyncForm.destroy(); + expect(asyncForm._eventManager._registeredListeners.length).toBe(0); + expect(asyncForm._element.classList).not.toContain(ASYNC_FORM_INITIALIZED_CLASS); + }); + it('should throw if called without an element', () => { expect(() => { new AsyncForm(); diff --git a/frontend/src/utils/async-table/async-table.js b/frontend/src/utils/async-table/async-table.js index 93d3bcf99..1734de1b1 100644 --- a/frontend/src/utils/async-table/async-table.js +++ b/frontend/src/utils/async-table/async-table.js @@ -2,6 +2,7 @@ import { Utility } from '../../core/utility'; import { StorageManager, LOCATION } from '../../lib/storage-manager/storage-manager'; import { Datepicker } from '../form/datepicker'; import { HttpClient } from '../../services/http-client/http-client'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; import * as debounce from 'lodash.debounce'; import * as throttle from 'lodash.throttle'; import './async-table-filter.sass'; @@ -30,6 +31,8 @@ export class AsyncTable { _element; _app; + _eventManager; + _asyncTableHeader; _asyncTableId; @@ -66,6 +69,8 @@ export class AsyncTable { this._element = element; this._app = app; + this._eventManager = new EventManager(); + if (this._element.classList.contains(ASYNC_TABLE_INITIALIZED_CLASS)) { return false; } @@ -144,7 +149,11 @@ export class AsyncTable { } destroy() { - console.log('TBD: Destroy AsyncTable'); + this._windowStorage.clear(this._windowStorage._options); + this._eventManager.cleanUp(); + this._active = false; + if (this._element.classList.contains(ASYNC_TABLE_INITIALIZED_CLASS)) + this._element.classList.remove(ASYNC_TABLE_INITIALIZED_CLASS); } _startSortableHeaders() { @@ -156,7 +165,8 @@ export class AsyncTable { this._windowStorage.save('horizPos', (this._scrollTable || {}).scrollLeft); this._linkClickHandler(event); }; - th.element.addEventListener('click', th.clickHandler); + const linkClickEvent = new EventWrapper(EVENT_TYPE.CLICK, th.clickHandler.bind(this), th.element); + this._eventManager.registerNewListener(linkClickEvent); }); } @@ -179,7 +189,9 @@ export class AsyncTable { } this._linkClickHandler(event); }; - link.element.addEventListener('click', link.clickHandler); + + const clickEvent = new EventWrapper(EVENT_TYPE.CLICK, link.clickHandler.bind(this), link.element); + this._eventManager.registerNewListener(clickEvent); }); } } @@ -190,7 +202,8 @@ export class AsyncTable { if (this._pagesizeForm) { const pagesizeSelect = this._pagesizeForm.querySelector('[name=' + this._asyncTableId + '-pagesize]'); - pagesizeSelect.addEventListener('change', this._changePagesizeHandler); + const pageSizeChangeEvent = new EventWrapper(EVENT_TYPE.CHANGE, this._changePagesizeHandler.bind(this), pagesizeSelect); + this._eventManager.registerNewListener(pageSizeChangeEvent); } } @@ -227,17 +240,7 @@ export class AsyncTable { const debouncedUpdateFromTableFilter = throttle((() => this._updateFromTableFilter(tableFilterForm)).bind(this), FILTER_DEBOUNCE, { leading: true, trailing: false }); [...this._tableFilterInputs.search, ...this._tableFilterInputs.input].forEach((input) => { - const submitLockObserver = new MutationObserver((mutations, observer) => { - for (const mutation of mutations) { - // if the submit lock has been released, trigger an update and disconnect this observer - if (mutation.target === input && mutation.attributeName === ATTR_SUBMIT_LOCKED && mutation.oldValue === 'true' && mutation.target.getAttribute(mutation.attributeName) === 'false') { - debouncedUpdateFromTableFilter(); - observer.disconnect(); - break; - } - } - }); - this._cancelPendingUpdates.push(() => { submitLockObserver.disconnect(); }); + this._cancelPendingUpdates.push(() => { this._eventManager.removeAllObserversFromUtil();}); const debouncedInput = debounce(() => { const submitLockedAttr = input.getAttribute(ATTR_SUBMIT_LOCKED); @@ -246,7 +249,16 @@ export class AsyncTable { debouncedUpdateFromTableFilter(); } else if (submitLockedAttr === 'true') { // observe the submit lock of the input element - submitLockObserver.observe(input, { + this._eventManager.registerNewMutationObserver(((mutations, observer) => { + for (const mutation of mutations) { + // if the submit lock has been released, trigger an update and disconnect this observer + if (mutation.target === input && mutation.attributeName === ATTR_SUBMIT_LOCKED && mutation.oldValue === 'true' && mutation.target.getAttribute(mutation.attributeName) === 'false') { + debouncedUpdateFromTableFilter(); + observer.disconnect(); + break; + } + } + }).bind(this), input, { attributes: true, attributeFilter: [ATTR_SUBMIT_LOCKED], attributeOldValue: true, @@ -254,33 +266,42 @@ export class AsyncTable { } }, INPUT_DEBOUNCE); this._cancelPendingUpdates.push(debouncedInput.cancel); - - input.addEventListener('input', () => { + + const inputHandler =() => { this._ignoreRequest = true; debouncedInput(); - }); + }; + const inputEvent = new EventWrapper(EVENT_TYPE.INPUT, inputHandler.bind(this), input ); + this._eventManager.registerNewListener(inputEvent); }); this._tableFilterInputs.change.forEach((input) => { - input.addEventListener('change', () => { + + const changeHandler = () => { //if (this._element.classList.contains(ASYNC_TABLE_LOADING_CLASS)) this._ignoreRequest = true; debouncedUpdateFromTableFilter(); - }); + }; + const changeEvent = new EventWrapper(EVENT_TYPE.CHANGE, changeHandler.bind(this), input); + this._eventManager.registerNewListener(changeEvent); }); this._tableFilterInputs.select.forEach((input) => { - input.addEventListener('change', () => { + const selectChangeHandler = () => { this._ignoreRequest = true; debouncedUpdateFromTableFilter(); - }); + }; + const selectEvent = new EventWrapper(EVENT_TYPE.CHANGE, selectChangeHandler.bind(this), input); + this._eventManager.registerNewListener(selectEvent); }); - tableFilterForm.addEventListener('submit', (event) =>{ + const submitEventHandler = (event) =>{ event.preventDefault(); this._ignoreRequest = true; debouncedUpdateFromTableFilter(); - }); + }; + const submitEvent = new EventWrapper(EVENT_TYPE.SUBMIT, submitEventHandler.bind(this), tableFilterForm); + this._eventManager.registerNewListener(submitEvent); } _updateFromTableFilter(tableFilterForm) { @@ -425,6 +446,8 @@ export class AsyncTable { this._active = false; this._element.classList.remove(ASYNC_TABLE_INITIALIZED_CLASS); this._element.dataset['currentTableUrl'] = url.href; + + this._app.utilRegistry.destroyAll(this._element); // update table with new this._element.innerHTML = response.element.innerHTML; @@ -440,7 +463,7 @@ export class AsyncTable { } _debugLog() {} - // _debugLog(fName, ...args) { + //_debugLog(fName, ...args) { // console.log(`[DEBUGLOG] AsyncTable.${fName}`, { args: args, instance: this }); // } } diff --git a/frontend/src/utils/async-table/async-table.spec.js b/frontend/src/utils/async-table/async-table.spec.js index 7f008ac49..de5dc9b98 100644 --- a/frontend/src/utils/async-table/async-table.spec.js +++ b/frontend/src/utils/async-table/async-table.spec.js @@ -1,4 +1,4 @@ -import { AsyncTable } from './async-table'; +import { AsyncTable, ASYNC_TABLE_INITIALIZED_CLASS } from './async-table'; const AppTestMock = { httpClient: { @@ -50,4 +50,11 @@ describe('AsyncTable', () => { new AsyncTable(); }).toThrow(); }); + + it('should destroy Async Table', () => { + asyncTable.start(); + asyncTable.destroy(); + expect(asyncTable._eventManager._registeredListeners.length).toBe(0); + expect(asyncTable._element.classList).not.toContain(ASYNC_TABLE_INITIALIZED_CLASS); + }); }); diff --git a/frontend/src/utils/check-all/check-all.js b/frontend/src/utils/check-all/check-all.js index b9796eeb5..83c3a5070 100644 --- a/frontend/src/utils/check-all/check-all.js +++ b/frontend/src/utils/check-all/check-all.js @@ -2,6 +2,7 @@ const DEBUG_MODE = /localhost/.test(window.location.href) ? 0 : 0; import { Utility } from '../../core/utility'; import { TableIndices } from '../../lib/table/table'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; const CHECKBOX_SELECTOR = '[type="checkbox"]'; @@ -13,11 +14,15 @@ const CHECK_ALL_INITIALIZED_CLASS = 'check-all--initialized'; export class CheckAll { _element; + _eventManager; + _columns = new Array(); _checkAllColumns = new Array(); _tableIndices; + _lastCheckedCell = null; + constructor(element, app) { if (!element) { throw new Error('Check All utility cannot be setup without an element!'); @@ -25,6 +30,8 @@ export class CheckAll { this._element = element; + this._eventManager = new EventManager(); + if (this._element.classList.contains(CHECK_ALL_INITIALIZED_CLASS)) { return false; } @@ -36,12 +43,25 @@ export class CheckAll { if (DEBUG_MODE > 0) console.log(this._columns); - this._findCheckboxColumns().forEach(columnId => this._checkAllColumns.push(new CheckAllColumn(this._element, app, this._columns[columnId]))); + let checkboxColumns = this._findCheckboxColumns(); + + checkboxColumns.forEach(columnId => this._checkAllColumns.push(new CheckAllColumn(this._element, app, this._columns[columnId], this._eventManager))); // mark initialized this._element.classList.add(CHECK_ALL_INITIALIZED_CLASS); } + destroy() { + this._eventManager.cleanUp(); + this._checkAllColumns.forEach((column) => { + if (column._checkAllCheckBox !== undefined) + column._checkAllCheckBox.remove(); + }); + + if(this._element.classList.contains(CHECK_ALL_INITIALIZED_CLASS)) + this._element.classList.remove(CHECK_ALL_INITIALIZED_CLASS); + } + _gatherColumns() { for (const rowIndex of Array(this._tableIndices.maxRow + 1).keys()) { for (const colIndex of Array(this._tableIndices.maxCol + 1).keys()) { @@ -81,14 +101,17 @@ class CheckAllColumn { _app; _table; _column; + + _eventManager _checkAllCheckbox; _checkboxId = 'check-all-checkbox-' + Math.floor(Math.random() * 100000); - constructor(table, app, column) { + constructor(table, app, column, eventManager) { this._column = column; this._table = table; this._app = app; + this._eventManager = eventManager; const th = this._column.filter(element => element.tagName == 'TH')[0]; if (!th) @@ -97,12 +120,14 @@ class CheckAllColumn { this._checkAllCheckbox = document.createElement('input'); this._checkAllCheckbox.setAttribute('type', 'checkbox'); this._checkAllCheckbox.setAttribute('id', this._checkboxId); + th.insertBefore(this._checkAllCheckbox, th.firstChild); // set up new checkbox this._app.utilRegistry.initAll(th); - this._checkAllCheckbox.addEventListener('input', this._onCheckAllCheckboxInput.bind(this)); + const checkBoxInputEvent = new EventWrapper(EVENT_TYPE.INPUT, this._onCheckAllCheckboxInput.bind(this), this._checkAllCheckbox); + this._eventManager.registerNewListener(checkBoxInputEvent); this._setupCheckboxListeners(); } @@ -113,9 +138,10 @@ class CheckAllColumn { _setupCheckboxListeners() { this._column .flatMap(cell => cell.tagName == 'TH' ? new Array() : Array.from(cell.querySelectorAll(CHECKBOX_SELECTOR))) - .forEach(checkbox => - checkbox.addEventListener('input', this._updateCheckAllCheckboxState.bind(this)) - ); + .forEach(checkbox => { + const checkBoxUpdateEvent = new EventWrapper(EVENT_TYPE.INPUT, this._updateCheckAllCheckboxState.bind(this), checkbox); + this._eventManager.registerNewListener(checkBoxUpdateEvent); + }); } _updateCheckAllCheckboxState() { diff --git a/frontend/src/utils/check-all/check-all.spec.js b/frontend/src/utils/check-all/check-all.spec.js index 9b8d30f77..431dd5993 100644 --- a/frontend/src/utils/check-all/check-all.spec.js +++ b/frontend/src/utils/check-all/check-all.spec.js @@ -1,4 +1,4 @@ -import { CheckAll } from './check-all'; +import { CheckAll, CHECK_ALL_INITIALIZED_CLASS } from './check-all'; const MOCK_APP = { utilRegistry: { @@ -24,4 +24,11 @@ describe('CheckAll', () => { new CheckAll(); }).toThrow(); }); + + it('should destroy CheckAll', () => { + checkAll.destroy(); + expect(checkAll._eventManager._registeredListeners.length).toBe(0); + expect(checkAll._element.classList).not.toEqual(jasmine.arrayContaining([CHECK_ALL_INITIALIZED_CLASS])); + }); + }); diff --git a/frontend/src/utils/course-teaser/course-teaser.js b/frontend/src/utils/course-teaser/course-teaser.js index 0419dcbda..31d8cf225 100644 --- a/frontend/src/utils/course-teaser/course-teaser.js +++ b/frontend/src/utils/course-teaser/course-teaser.js @@ -1,4 +1,5 @@ import { Utility } from '../../core/utility'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; import './course-teaser.sass'; const COURSE_TEASER_INITIALIZED_CLASS = 'course-teaser--initialized'; @@ -12,16 +13,30 @@ const COURSE_TEASER_CHEVRON_CLASS = 'course-teaser__chevron'; export class CourseTeaser { _element; + _eventManager constructor(element) { if (!element) { throw new Error('CourseTeaser utility cannot be setup without an element!'); } + this._eventManager = new EventManager(); if (element.classList.contains(COURSE_TEASER_INITIALIZED_CLASS)) { return false; } this._element = element; - element.addEventListener('click', e => this._onToggleExpand(e)); + const clickHandler = e => this._onToggleExpand(e); + const clickEvent = new EventWrapper(EVENT_TYPE.CLICK, clickHandler.bind(this), element); + this._eventManager.registerNewListener(clickEvent); + } + + destroy() { + this._eventManager.cleanUp(); + if(this._element.classList.contains(COURSE_TEASER_EXPANDED_CLASS)) { + this._element.classList.remove(COURSE_TEASER_EXPANDED_CLASS); + } + if (this._element.classList.contains(COURSE_TEASER_INITIALIZED_CLASS)) { + this._element.classList.remove(COURSE_TEASER_INITIALIZED_CLASS); + } } _onToggleExpand(event) { diff --git a/frontend/src/utils/exam-correct/exam-correct.js b/frontend/src/utils/exam-correct/exam-correct.js index cf23c911d..f078f9825 100644 --- a/frontend/src/utils/exam-correct/exam-correct.js +++ b/frontend/src/utils/exam-correct/exam-correct.js @@ -1,5 +1,6 @@ import { Utility } from '../../core/utility'; import { StorageManager, LOCATION } from '../../lib/storage-manager/storage-manager'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; import { HttpClient } from '../../services/http-client/http-client'; import moment from 'moment'; @@ -58,6 +59,7 @@ export class ExamCorrect { _lastColumnIndex; _storageManager; + _eventManager; constructor(element, app) { if (!element) { @@ -71,6 +73,8 @@ export class ExamCorrect { this._element = element; this._app = app; + this._eventManager = new EventManager(); + // TODO work in progress // this._storageManager = new StorageManager('EXAM_CORRECT', '0.0.0', { location: LOCATION.SESSION, encryption: { all: { tag: 'exam-correct', exam: this._element.getAttribute('uw-exam-correct') } } }); this._storageManager = new StorageManager('EXAM_CORRECT', '0.0.0', { location: LOCATION.WINDOW }); @@ -88,20 +92,28 @@ export class ExamCorrect { this._resultPassSelect = resultDetailCell && resultDetailCell.querySelector('select.uw-exam-correct__pass'); this._partDeleteBoxes = [...this._element.querySelectorAll('input.uw-exam-correct--delete-exam-part')]; - if (this._sendBtn) - this._sendBtn.addEventListener('click', this._sendCorrectionHandler.bind(this)); - else console.error('ExamCorrect utility could not detect send button!'); + if (this._sendBtn){ + const sendClickEvent = new EventWrapper(EVENT_TYPE.CLICK, this._sendCorrectionHandler.bind(this), this._sendBtn); + this._eventManager.registerNewListener(sendClickEvent); + } else { + console.error('ExamCorrect utility could not detect send button!'); + } - if (this._userInput) - this._userInput.addEventListener('focusout', this._validateUserInput.bind(this)); - else throw new Error('ExamCorrect utility could not detect user input!'); + if (this._userInput) { + const focusOutEvent = new EventWrapper(EVENT_TYPE.FOCUS_OUT, this._validateUserInput.bind(this), this._userInput); + this._eventManager.registerNewListener(focusOutEvent); + } else { + throw new Error('ExamCorrect utility could not detect user input!'); + } for (let deleteBox of this._partDeleteBoxes) { - deleteBox.addEventListener('change', (() => { this._updatePartDeleteDisabled(deleteBox); }).bind(this)); + const deleteBoxChangeEvent = new EventWrapper(EVENT_TYPE.CHANGE, (() => { this._updatePartDeleteDisabled(deleteBox); }).bind(this), deleteBox); + this._eventManger.registerNewListener(deleteBoxChangeEvent); } for (let input of [this._userInput, ...this._partInputs]) { - input.addEventListener('keypress', this._inputKeypress.bind(this)); + const inputKeyDownEvent = new EventWrapper(EVENT_TYPE.KEYDOWN, this._inputKeypress.bind(this), input); + this._eventManager.registerNewListener(inputKeyDownEvent); } if (!this._userInputStatus) { @@ -125,23 +137,25 @@ export class ExamCorrect { ); if (this._resultSelect && this._resultGradeSelect) { - this._resultSelect.addEventListener('change', () => { + const resultSelectEvent = new EventWrapper(EVENT_TYPE.CHANGE, (() => { if (this._resultSelect.value !== 'grade') this._resultGradeSelect.classList.add('grade-hidden'); else this._resultGradeSelect.classList.remove('grade-hidden'); - }); + }).bind(this), this._resultSelect ); + this._eventManager.registerNewListener(resultSelectEvent); if (this._resultSelect.value !== 'grade') this._resultGradeSelect.classList.add('grade-hidden'); } if (this._resultSelect && this._resultPassSelect) { - this._resultSelect.addEventListener('change', () => { + const resultPassSelectEvent = new EventWrapper(EVENT_TYPE.CHANGE, (() => { if (this._resultSelect.value !== 'pass') this._resultPassSelect.classList.add('pass-hidden'); else this._resultPassSelect.classList.remove('pass-hidden'); - }); + }).bind(this), this._resultSelect); + this._eventManager.registerNewListener(resultPassSelectEvent); if (this._resultSelect.value !== 'pass') this._resultPassSelect.classList.add('pass-hidden'); @@ -158,9 +172,7 @@ export class ExamCorrect { } destroy() { - this._sendBtn.removeEventListener('click', this._sendCorrectionHandler); - this._userInput.removeEventListener('change', this._validateUserInput); - // TODO destroy handlers on user input candidate elements + this._eventManager.cleanUp(); } _updatePartDeleteDisabled(deleteBox) { diff --git a/frontend/src/utils/form/auto-submit-button.js b/frontend/src/utils/form/auto-submit-button.js index bf7544d30..77e942f28 100644 --- a/frontend/src/utils/form/auto-submit-button.js +++ b/frontend/src/utils/form/auto-submit-button.js @@ -9,8 +9,10 @@ const AUTO_SUBMIT_BUTTON_HIDDEN_CLASS = 'hidden'; selector: AUTO_SUBMIT_BUTTON_UTIL_SELECTOR, }) export class AutoSubmitButton { + _element; constructor(element) { + this._element = element; if (!element) { throw new Error('Auto Submit Button utility needs to be passed an element!'); } @@ -24,6 +26,7 @@ export class AutoSubmitButton { } destroy() { - // TODO + this._element.classList.remove(AUTO_SUBMIT_BUTTON_INITIALIZED_CLASS); + this._element.classList.remove(AUTO_SUBMIT_BUTTON_HIDDEN_CLASS); } } diff --git a/frontend/src/utils/form/auto-submit-button.spec.js b/frontend/src/utils/form/auto-submit-button.spec.js new file mode 100644 index 000000000..b1436c867 --- /dev/null +++ b/frontend/src/utils/form/auto-submit-button.spec.js @@ -0,0 +1,27 @@ +import { AutoSubmitButton, AUTO_SUBMIT_BUTTON_INITIALIZED_CLASS, AUTO_SUBMIT_BUTTON_HIDDEN_CLASS } from './auto-submit-button.js'; + +describe('Auto-submit-button', () => { + + let autoSubmitButton; + + beforeEach(() => { + const element = document.createElement('div'); + autoSubmitButton = new AutoSubmitButton(element); + }); + + it('should create', () => { + expect(autoSubmitButton).toBeTruthy(); + }); + + it('should destory auto-submit-button', () => { + autoSubmitButton.destroy(); + expect(autoSubmitButton._element.classList).not.toContain(AUTO_SUBMIT_BUTTON_INITIALIZED_CLASS); + expect(autoSubmitButton._element.classList).not.toContain(AUTO_SUBMIT_BUTTON_HIDDEN_CLASS); + }); + + it('should throw if called without an element', () => { + expect(() => { + new AutoSubmitButton(); + }).toThrow(); + }); + }); \ No newline at end of file diff --git a/frontend/src/utils/form/auto-submit-input.js b/frontend/src/utils/form/auto-submit-input.js index f442f2960..291a1d3aa 100644 --- a/frontend/src/utils/form/auto-submit-input.js +++ b/frontend/src/utils/form/auto-submit-input.js @@ -1,5 +1,6 @@ import * as debounce from 'lodash.debounce'; import { Utility } from '../../core/utility'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; export const AUTO_SUBMIT_INPUT_UTIL_SELECTOR = '[uw-auto-submit-input]'; @@ -12,6 +13,8 @@ export class AutoSubmitInput { _element; + _eventManager; + _form; _debouncedHandler; @@ -22,6 +25,8 @@ export class AutoSubmitInput { this._element = element; + this._eventManager = new EventManager(); + if (this._element.classList.contains(AUTO_SUBMIT_INPUT_INITIALIZED_CLASS)) { return false; } @@ -33,12 +38,16 @@ export class AutoSubmitInput { this._debouncedHandler = debounce(this.autoSubmit, 500); - this._element.addEventListener('input', this._debouncedHandler); + const inputEvent = new EventWrapper(EVENT_TYPE.INPUT, this._debouncedHandler.bind(this), this._element); + this._eventManager.registerNewListener(inputEvent); + this._element.classList.add(AUTO_SUBMIT_INPUT_INITIALIZED_CLASS); } destroy() { - this._element.removeEventListener('input', this._debouncedHandler); + this._eventManager.cleanUp(); + if(this._element.classList.contains(AUTO_SUBMIT_INPUT_INITIALIZED_CLASS)) + this._element.classList.remove(AUTO_SUBMIT_INPUT_INITIALIZED_CLASS); } autoSubmit = () => { diff --git a/frontend/src/utils/form/auto-submit-input.spec.js b/frontend/src/utils/form/auto-submit-input.spec.js new file mode 100644 index 000000000..26c59cdd2 --- /dev/null +++ b/frontend/src/utils/form/auto-submit-input.spec.js @@ -0,0 +1,30 @@ +import { AutoSubmitInput, AUTO_SUBMIT_INPUT_INITIALIZED_CLASS } from './auto-submit-input.js'; + +describe('Auto-submit-input', () => { + + let autoSubmitInput; + + beforeEach(() => { + const form = document.createElement('form'); + const element = document.createElement('input'); + element.setAttribute('type', 'text'); + form.append(element); + autoSubmitInput = new AutoSubmitInput(element); + }); + + it('should create', () => { + expect(autoSubmitInput).toBeTruthy(); + }); + + it('should destory auto-submit-button', () => { + autoSubmitInput.destroy(); + expect(autoSubmitInput._eventManager._registeredListeners.length).toBe(0); + expect(autoSubmitInput._element.classList).not.toEqual(jasmine.arrayContaining([AUTO_SUBMIT_INPUT_INITIALIZED_CLASS])); + }); + + it('should throw if called without an element', () => { + expect(() => { + new AutoSubmitInput(); + }).toThrow(); + }); + }); \ No newline at end of file diff --git a/frontend/src/utils/form/communication-recipients.js b/frontend/src/utils/form/communication-recipients.js index 2a9367f47..b6d754e09 100644 --- a/frontend/src/utils/form/communication-recipients.js +++ b/frontend/src/utils/form/communication-recipients.js @@ -1,4 +1,5 @@ import { Utility } from '../../core/utility'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; const MASS_INPUT_SELECTOR = '.massinput'; const RECIPIENT_CATEGORIES_SELECTOR = '.recipient-categories'; @@ -14,62 +15,81 @@ const RECIPIENT_CATEGORY_CHECKED_COUNTER_SELECTOR = '.recipient-category__checke }) export class CommunicationRecipients { massInputElement; + _element; + + _eventManager; constructor(element) { if (!element) { throw new Error('Communication Recipient utility cannot be setup without an element!'); } - - this.massInputElement = element.closest(MASS_INPUT_SELECTOR); + this._element = element; + this._eventManager = new EventManager(); + this.massInputElement = this._element.closest(MASS_INPUT_SELECTOR); this.setupRecipientCategories(); - const recipientObserver = new MutationObserver(this.setupRecipientCategories.bind(this)); - recipientObserver.observe(this.massInputElement, { childList: true }); + this._eventManager.registerNewMutationObserver(this.setupRecipientCategories.bind(this), this.massInputElement, { childList: true }); } setupRecipientCategories() { - Array.from(this.massInputElement.querySelectorAll(RECIPIENT_CATEGORY_SELECTOR)).forEach(setupRecipientCategory); + Array.from(this.massInputElement.querySelectorAll(RECIPIENT_CATEGORY_SELECTOR)).forEach(this.setupRecipientCategory.bind(this)); } -} -function setupRecipientCategory(recipientCategoryElement) { - const categoryCheckbox = recipientCategoryElement.querySelector(RECIPIENT_CATEGORY_CHECKBOX_SELECTOR); - const categoryOptions = recipientCategoryElement.querySelector(RECIPIENT_CATEGORY_OPTIONS_SELECTOR); + _removeCheckedCounter() { + let checkedCounters = this._element.querySelectorAll(RECIPIENT_CATEGORY_CHECKED_COUNTER_SELECTOR); + checkedCounters.forEach((checkedCounter) => { + checkedCounter.innerHTML = ''; + }); + } - if (categoryOptions) { - const categoryCheckboxes = Array.from(categoryOptions.querySelectorAll('[type="checkbox"]')); - const toggleAllCheckbox = recipientCategoryElement.querySelector(RECIPIENT_CATEGORY_TOGGLE_ALL_SELECTOR); + destroy() { + this._eventManager.cleanUp(); + this._removeCheckedCounter(); + } - // setup category checkbox to toggle all child checkboxes if changed - categoryCheckbox.addEventListener('change', () => { - categoryCheckboxes.filter(checkbox => !checkbox.disabled).forEach(checkbox => { - checkbox.checked = categoryCheckbox.checked; - }); - updateCheckedCounter(recipientCategoryElement, categoryCheckboxes); - updateToggleAllCheckbox(toggleAllCheckbox, categoryCheckboxes); - }); - // update counter and toggle checkbox initially - updateCheckedCounter(recipientCategoryElement, categoryCheckboxes); - updateToggleAllCheckbox(toggleAllCheckbox, categoryCheckboxes); - - // register change listener for individual checkboxes - categoryCheckboxes.forEach(checkbox => { - checkbox.addEventListener('change', () => { - updateCheckedCounter(recipientCategoryElement, categoryCheckboxes); - updateToggleAllCheckbox(toggleAllCheckbox, categoryCheckboxes); - }); - }); - - // register change listener for toggle all checkbox - if (toggleAllCheckbox) { - toggleAllCheckbox.addEventListener('change', () => { + setupRecipientCategory(recipientCategoryElement) { + const categoryCheckbox = recipientCategoryElement.querySelector(RECIPIENT_CATEGORY_CHECKBOX_SELECTOR); + const categoryOptions = recipientCategoryElement.querySelector(RECIPIENT_CATEGORY_OPTIONS_SELECTOR); + + if (categoryOptions) { + const categoryCheckboxes = Array.from(categoryOptions.querySelectorAll('[type="checkbox"]')); + const toggleAllCheckbox = recipientCategoryElement.querySelector(RECIPIENT_CATEGORY_TOGGLE_ALL_SELECTOR); + + // setup category checkbox to toggle all child checkboxes if changed + const categoryToggleEvent = new EventWrapper(EVENT_TYPE.CHANGE,(() => { categoryCheckboxes.filter(checkbox => !checkbox.disabled).forEach(checkbox => { - checkbox.checked = toggleAllCheckbox.checked; + checkbox.checked = categoryCheckbox.checked; }); updateCheckedCounter(recipientCategoryElement, categoryCheckboxes); + updateToggleAllCheckbox(toggleAllCheckbox, categoryCheckboxes); + }).bind(this), categoryCheckbox ); + this._eventManager.registerNewListener(categoryToggleEvent); + + // update counter and toggle checkbox initially + updateCheckedCounter(recipientCategoryElement, categoryCheckboxes); + updateToggleAllCheckbox(toggleAllCheckbox, categoryCheckboxes); + + // register change listener for individual checkboxes + categoryCheckboxes.forEach(checkbox => { + const individualToggleEvent = new EventWrapper(EVENT_TYPE.CHANGE, (() => { + updateCheckedCounter(recipientCategoryElement, categoryCheckboxes); + updateToggleAllCheckbox(toggleAllCheckbox, categoryCheckboxes); + }).bind(this), checkbox); + this._eventManager.registerNewListener(individualToggleEvent); }); + + // register change listener for toggle all checkbox + if (toggleAllCheckbox) { + const toggleAllEvent = new EventWrapper(EVENT_TYPE.CHANGE, (() => { + categoryCheckboxes.filter(checkbox => !checkbox.disabled).forEach(checkbox => { + checkbox.checked = toggleAllCheckbox.checked; + }); + updateCheckedCounter(recipientCategoryElement, categoryCheckboxes); + }).bind(this), toggleAllCheckbox); + this._eventManager.registerNewListener(toggleAllEvent); + } } } } diff --git a/frontend/src/utils/form/datepicker.js b/frontend/src/utils/form/datepicker.js index 21b09eb19..c6273c07b 100644 --- a/frontend/src/utils/form/datepicker.js +++ b/frontend/src/utils/form/datepicker.js @@ -2,6 +2,7 @@ import datetime from 'tail.datetime'; import './datepicker.css'; import { Utility } from '../../core/utility'; import moment from 'moment'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; import * as defer from 'lodash.defer'; @@ -82,6 +83,8 @@ export class Datepicker { initialValue; _locale; + _eventManager; + _unloadIsDueToSubmit = false; constructor(element) { @@ -102,6 +105,8 @@ export class Datepicker { this._element = element; + this._eventManager = new EventManager(); + // store the previously set type to select the input format this.elementType = this._element.getAttribute('type'); @@ -179,23 +184,22 @@ export class Datepicker { } // reregister change event to prevent event loop - this._element.addEventListener('change', setDatepickerDate, { once: true }); }; // change the selected date in the tail.datetime instance if the value of the input element is changed - this._element.addEventListener('change', setDatepickerDate, { once: true }); - + const changeSelectedDateEvent = new EventWrapper(EVENT_TYPE.CHANGE, setDatepickerDate.bind(this), this._element, { once: true }); + this._eventManager.registerNewListener(changeSelectedDateEvent); // create a mutation observer that observes the datepicker instance class and sets // the datepicker-open DOM attribute of the input element if the datepicker has been opened - const datepickerInstanceObserver = new MutationObserver((mutations) => { + let callback = (mutations) => { for (const mutation of mutations) { if (!mutation.oldValue.includes(DATEPICKER_OPEN_CLASS) && this.datepickerInstance.dt.getAttribute('class').includes(DATEPICKER_OPEN_CLASS)) { this._element.setAttribute(ATTR_DATEPICKER_OPEN, true); break; } } - }); - datepickerInstanceObserver.observe(this.datepickerInstance.dt, { + }; + this._eventManager.registerNewMutationObserver(callback.bind(this), this.datepickerInstance.dt, { attributes: true, attributeFilter: ['class'], attributeOldValue: true, @@ -203,38 +207,44 @@ export class Datepicker { // close the instance on focusout of any element if another input is focussed that is neither the timepicker nor _element - window.addEventListener('focusout', event => { + const focusOutEvent = new EventWrapper(EVENT_TYPE.FOCUS_OUT,(event => { const hasFocus = event.relatedTarget !== null; const focussedIsNotTimepicker = !this.datepickerInstance.dt.contains(event.relatedTarget); const focussedIsNotElement = event.relatedTarget !== this._element; const focussedIsInDocument = window.document.contains(event.relatedTarget); if (hasFocus && focussedIsNotTimepicker && focussedIsNotElement && focussedIsInDocument) this.closeDatepickerInstance(); - }); + }).bind(this), window ); + this._eventManager.registerNewListener(focusOutEvent); // close the instance on click on any element outside of the datepicker (except the input element itself) - window.addEventListener('click', event => { + const clickOutsideEvent = new EventWrapper(EVENT_TYPE.CLICK, (event => { const targetIsOutside = !this.datepickerInstance.dt.contains(event.target) && event.target !== this.datepickerInstance.dt; const targetIsInDocument = window.document.contains(event.target); const targetIsNotElement = event.target !== this._element; if (targetIsOutside && targetIsInDocument && targetIsNotElement) this.closeDatepickerInstance(); - }); + }).bind(this), window); + this._eventManager.registerNewListener(clickOutsideEvent); // close the instance on escape keydown events - this._element.addEventListener('keydown', event => { + const escapeCloseEvent = new EventWrapper(EVENT_TYPE.KEYDOWN, (event => { if (event.keyCode === KEYCODE_ESCAPE) { this.closeDatepickerInstance(); } - }); + }).bind(this), this._element); + this._eventManager.registerNewListener(escapeCloseEvent); // format the date value of the form input element of this datepicker before form submission - this._element.form.addEventListener('submit', this._submitHandler.bind(this)); + const submitEvent = new EventWrapper(EVENT_TYPE.SUBMIT, this._submitHandler.bind(this), this._element.form); + this._eventManager.registerNewListener(submitEvent); } destroy() { this.datepickerInstance.remove(); + this._eventManager.cleanUp(); + this._element.classList.remove(DATEPICKER_INITIALIZED_CLASS); } diff --git a/frontend/src/utils/form/enter-is-tab.js b/frontend/src/utils/form/enter-is-tab.js index 4e171c87b..b3097d50d 100644 --- a/frontend/src/utils/form/enter-is-tab.js +++ b/frontend/src/utils/form/enter-is-tab.js @@ -1,4 +1,5 @@ import { Utility } from '../../core/utility'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; const ENTER_IS_TAB_INITIALIZED_CLASS = 'enter-as-tab--initialized'; const AREA_SELECTOR = 'input, textarea'; @@ -10,6 +11,8 @@ const AREA_SELECTOR = 'input, textarea'; export class EnterIsTab { _element; + _eventManager; + constructor(element) { if(!element) { @@ -17,6 +20,8 @@ export class EnterIsTab { } this._element = element; + + this._eventManager = new EventManager(); if (this._element.classList.contains(ENTER_IS_TAB_INITIALIZED_CLASS)) { return false; @@ -27,27 +32,32 @@ export class EnterIsTab { start() { - this._element.addEventListener('keydown', (e) => { - if(e.key === 'Enter') { - e.preventDefault(); - let currentInputFieldId = this._element.id; - let inputAreas = document.querySelectorAll(AREA_SELECTOR); - let nextInputArea = null; - for (let i = 0; i < inputAreas.length; i++) { - if(inputAreas[i].id === currentInputFieldId) { - nextInputArea = inputAreas[i+1]; - break; - } - } - - if(nextInputArea) { - nextInputArea.focus(); + let eventWrapper = new EventWrapper(EVENT_TYPE.KEYDOWN, this._captureEnter.bind(this), this._element); + this._eventManager.registerNewListener(eventWrapper); + } + + _captureEnter (e) { + if(e.key === 'Enter') { + e.preventDefault(); + let currentInputFieldId = this._element.id; + let inputAreas = document.querySelectorAll(AREA_SELECTOR); + let nextInputArea = null; + for (let i = 0; i < inputAreas.length; i++) { + if(inputAreas[i].id === currentInputFieldId) { + nextInputArea = inputAreas[i+1]; + break; } } - }); + + if(nextInputArea) { + nextInputArea.focus(); + } + } } destroy() { - console.log('TBD: Destroy EnterIsTab'); + this._eventManager.cleanUp(); + if(this._element.classList.contains(ENTER_IS_TAB_INITIALIZED_CLASS)) + this._element.classList.remove(ENTER_IS_TAB_INITIALIZED_CLASS); } } \ No newline at end of file diff --git a/frontend/src/utils/form/enter-is-tab.md b/frontend/src/utils/form/enter-is-tab.md new file mode 100644 index 000000000..67fd7e2c4 --- /dev/null +++ b/frontend/src/utils/form/enter-is-tab.md @@ -0,0 +1,8 @@ +# Enter is Tab Utility +When the user presses enter on a form that uses this utility, the enter is converted to a tab in order to not send the form. + +## Attribute: +`uw-enter-as-tab` + +## Example usage: + \ No newline at end of file diff --git a/frontend/src/utils/form/form-error-remover.js b/frontend/src/utils/form/form-error-remover.js index 1b1509c2d..f05e96fae 100644 --- a/frontend/src/utils/form/form-error-remover.js +++ b/frontend/src/utils/form/form-error-remover.js @@ -1,4 +1,5 @@ import { Utility } from '../../core/utility'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; const FORM_ERROR_REMOVER_INITIALIZED_CLASS = 'form-error-remover--initialized'; const FORM_ERROR_REMOVER_INPUTS_SELECTOR = 'input:not([type="hidden"]), textarea, select'; @@ -13,6 +14,8 @@ export class FormErrorRemover { _element; + _eventManager; + constructor(element) { if (!element) throw new Error('Form Error Remover utility needs to be passed an element!'); @@ -24,6 +27,7 @@ export class FormErrorRemover { return; this._element = element; + this._eventManager = new EventManager(); this._element.classList.add(FORM_ERROR_REMOVER_INITIALIZED_CLASS); } @@ -35,11 +39,18 @@ export class FormErrorRemover { const inputElements = Array.from(this._element.querySelectorAll(FORM_ERROR_REMOVER_INPUTS_SELECTOR)); inputElements.forEach((inputElement) => { - inputElement.addEventListener('input', () => { + const inputEvent = new EventWrapper(EVENT_TYPE.INPUT, (() => { if (!inputElement.willValidate || inputElement.validity.vaild) { FORM_GROUP_WITH_ERRORS_CLASSES.forEach(c => { this._element.classList.remove(c); }); } - }); + }).bind(this), inputElement); + this._eventManager.registerNewListener(inputEvent); }); } + + destroy() { + this._eventManager.cleanUp(); + this._element.classList.remove(FORM_ERROR_REMOVER_INITIALIZED_CLASS); + } + } diff --git a/frontend/src/utils/form/form-error-reporter.js b/frontend/src/utils/form/form-error-reporter.js index dff3f00d2..ed3012b26 100644 --- a/frontend/src/utils/form/form-error-reporter.js +++ b/frontend/src/utils/form/form-error-reporter.js @@ -1,5 +1,6 @@ import { Utility } from '../../core/utility'; import * as defer from 'lodash.defer'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; const FORM_ERROR_REPORTER_INITIALIZED_CLASS = 'form-error-remover--initialized'; @@ -10,12 +11,16 @@ export class FormErrorReporter { _element; _err; + _eventManager; + constructor(element) { if (!element) throw new Error('Form Error Reporter utility needs to be passed an element!'); this._element = element; + this._eventManager = new EventManager(); + if (this._element.classList.contains(FORM_ERROR_REPORTER_INITIALIZED_CLASS)) return; @@ -24,11 +29,23 @@ export class FormErrorReporter { start() { if (this._element.willValidate) { - this._element.addEventListener('invalid', this.report.bind(this)); - this._element.addEventListener('change', () => { defer(this.report.bind(this)); } ); + let invalidElementEvent = new EventWrapper(EVENT_TYPE.INVALID, this.report.bind(this), this._element); + this._eventManager.registerNewListener(invalidElementEvent); + + let changedElementEvent = new EventWrapper(EVENT_TYPE.CHANGE, () => { defer(this.report.bind(this)); }, this._element); + this._eventManager.registerNewListener(changedElementEvent); } } + destroy() { + this._eventManager.cleanUp(); + + this._removeError(); + + if(this._element.classList.contains(FORM_ERROR_REPORTER_INITIALIZED_CLASS)) + this._element.classList.remove(FORM_ERROR_REPORTER_INITIALIZED_CLASS); + } + report() { const msg = this._element.validity.valid ? null : this._element.validationMessage; @@ -37,10 +54,7 @@ export class FormErrorReporter { if (!target) return; - if (this._err && this._err.parentNode) { - this._err.parentNode.removeChild(this._err); - this._err = undefined; - } + this._removeError(); if (!msg) { target.classList.remove('standalone-field--has-error', 'form-group--has-error'); @@ -65,4 +79,11 @@ export class FormErrorReporter { } } } + + _removeError() { + if (this._err && this._err.parentNode) { + this._err.parentNode.removeChild(this._err); + this._err = undefined; + } + } } diff --git a/frontend/src/utils/form/interactive-fieldset.js b/frontend/src/utils/form/interactive-fieldset.js index 7e912ab90..2c8ff4eb9 100644 --- a/frontend/src/utils/form/interactive-fieldset.js +++ b/frontend/src/utils/form/interactive-fieldset.js @@ -1,4 +1,5 @@ import { Utility } from '../../core/utility'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; const INTERACTIVE_FIELDSET_UTIL_TARGET_SELECTOR = '.interactive-fieldset__target'; @@ -15,6 +16,8 @@ export class InteractiveFieldset { _element; + _eventManager; + conditionalInput; conditionalValue; target; @@ -28,6 +31,8 @@ export class InteractiveFieldset { this._element = element; + this._eventManager = new EventManager(); + if (this._element.classList.contains(INTERACTIVE_FIELDSET_INITIALIZED_CLASS)) { return false; } @@ -62,13 +67,11 @@ export class InteractiveFieldset { this.childInputs = Array.from(this._element.querySelectorAll(INTERACTIVE_FIELDSET_CHILD_SELECTOR)).filter(child => child.closest('[uw-interactive-fieldset]') === this._element); // add event listener - const observer = new MutationObserver(this._updateVisibility.bind(this)); - observer.observe(this.conditionalInput, { attributes: true, attributeFilter: ['data-interactive-fieldset-hidden'] }); - this.conditionalInput.addEventListener('input', this._updateVisibility.bind(this)); - + this._eventManager.registerNewMutationObserver(this._updateVisibility.bind(this), this.conditionalInput, { attributes: true, attributeFilter: ['data-interactive-fieldset-hidden'] }); + const inputEvent = new EventWrapper(EVENT_TYPE.INPUT, this._updateVisibility.bind(this), this.conditionalInput); + this._eventManager.registerNewListener(inputEvent); // mark as initialized this._element.classList.add(INTERACTIVE_FIELDSET_INITIALIZED_CLASS); - } start() { @@ -77,7 +80,8 @@ export class InteractiveFieldset { } destroy() { - // TODO + this._eventManager.cleanUp(); + this._element.classList.remove(INTERACTIVE_FIELDSET_INITIALIZED_CLASS); } _updateVisibility() { diff --git a/frontend/src/utils/form/navigate-away-prompt.js b/frontend/src/utils/form/navigate-away-prompt.js index 8890e198e..69c430853 100644 --- a/frontend/src/utils/form/navigate-away-prompt.js +++ b/frontend/src/utils/form/navigate-away-prompt.js @@ -1,4 +1,5 @@ import { Utility } from '../../core/utility'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; import { AUTO_SUBMIT_BUTTON_UTIL_SELECTOR } from './auto-submit-button'; import { AUTO_SUBMIT_INPUT_UTIL_SELECTOR } from './auto-submit-input'; @@ -26,16 +27,21 @@ const NAVIGATE_AWAY_PROMPT_UTIL_OPTOUT = '[uw-no-navigate-away-prompt]'; export class NavigateAwayPrompt { _element; + _app; + + _eventManager; _initFormData; _unloadDueToSubmit = false; - constructor(element) { + constructor(element, app) { if (!element) { throw new Error('Navigate Away Prompt utility needs to be passed an element!'); } this._element = element; + this._app = app; + this._eventManager = new EventManager(); if (this._element.classList.contains(NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS)) { return; @@ -65,15 +71,18 @@ export class NavigateAwayPrompt { return; this._initFormData = new FormData(this._element); - window.addEventListener('beforeunload', this._beforeUnloadHandler.bind(this)); + const beforeUnloadEvent = new EventWrapper(EVENT_TYPE.BEFOREUNLOAD, this._beforeUnloadHandler.bind(this), window); + this._eventManager.registerNewListener(beforeUnloadEvent); - this._element.addEventListener('submit', () => { + const submitEvent = new EventWrapper(EVENT_TYPE.SUBMIT, (() => { this._unloadDueToSubmit = true; defer(() => { this._unloadDueToSubmit = false; } ); // Restore state after event loop is settled - }); + }).bind(this), this._element); + this._eventManager.registerNewListener(submitEvent); } destroy() { + this._eventManager.cleanUp(); this._element.classList.remove(NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS); } @@ -98,7 +107,7 @@ export class NavigateAwayPrompt { // allow the event to happen if the form was not touched by the // user (i.e. if the current FormData is equal to the initial FormData) // or the unload event was initiated by a form submit - if (!formDataHasChanged || this._unloadDueToSubmit) + if (!formDataHasChanged || this.unloadDueToSubmit || this._parentModalIsClosed()) return; // cancel the unload event. This is the standard to force the prompt to appear. @@ -108,4 +117,13 @@ export class NavigateAwayPrompt { // for all non standard compliant browsers we return a truthy value to activate the prompt. return true; } + + _parentModalIsClosed() { + const parentModal = this._element.closest('.modal'); + if (!parentModal) + return false; + + const modalClosed = !parentModal.classList.contains('modal--open'); + return modalClosed; + } } diff --git a/frontend/src/utils/form/reactive-submit-button.js b/frontend/src/utils/form/reactive-submit-button.js index e46eed77e..c5bc3c642 100644 --- a/frontend/src/utils/form/reactive-submit-button.js +++ b/frontend/src/utils/form/reactive-submit-button.js @@ -1,4 +1,5 @@ import { Utility } from '../../core/utility'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; const REACTIVE_SUBMIT_BUTTON_INITIALIZED_CLASS = 'reactive-submit-button--initialized'; @@ -12,12 +13,15 @@ export class ReactiveSubmitButton { _requiredInputs; _submitButton; + _eventManager; + constructor(element) { if (!element) { throw new Error('Reactive Submit Button utility cannot be setup without an element!'); } this._element = element; + this._eventManager = new EventManager(); if (this._element.classList.contains(REACTIVE_SUBMIT_BUTTON_INITIALIZED_CLASS)) { return false; @@ -51,16 +55,18 @@ export class ReactiveSubmitButton { } destroy() { - // TODO + this._eventManager.removeAllEventListenersFromUtil(); + this._element.classList.remove(REACTIVE_SUBMIT_BUTTON_INITIALIZED_CLASS); } setupInputs() { this._requiredInputs.forEach((el) => { const checkbox = el.getAttribute('type') === 'checkbox'; - const eventType = checkbox ? 'change' : 'input'; - el.addEventListener(eventType, () => { + const eventType = checkbox ? EVENT_TYPE.CHANGE : EVENT_TYPE.INPUT; + const valEvent = new EventWrapper(eventType,(() => { this.updateButtonState(); - }); + }).bind(this), el ); + this._eventManager.registerNewListener(valEvent); }); } diff --git a/frontend/src/utils/hide-columns/hide-columns.js b/frontend/src/utils/hide-columns/hide-columns.js index 7661ac92b..79390402a 100644 --- a/frontend/src/utils/hide-columns/hide-columns.js +++ b/frontend/src/utils/hide-columns/hide-columns.js @@ -1,5 +1,7 @@ import { Utility } from '../../core/utility'; import { StorageManager, LOCATION } from '../../lib/storage-manager/storage-manager'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; + import './hide-columns.sass'; import { TableIndices } from '../../lib/table/table'; @@ -29,6 +31,7 @@ const HIDE_COLUMNS_INITIALIZED = 'uw-hide-columns--initialized'; export class HideColumns { _storageManager = new StorageManager('HIDE_COLUMNS', '1.1.0', { location: LOCATION.LOCAL }); + _eventManager; _element; _elementWrapper; @@ -36,8 +39,6 @@ export class HideColumns { _autoHide; - _mutationObserver; - _tableIndices; headerToHider = new Map(); @@ -62,6 +63,7 @@ export class HideColumns { return false; this._element = element; + this._eventManager = new EventManager(); this._tableIndices = new TableIndices(this._element); @@ -82,12 +84,17 @@ export class HideColumns { [...this._element.querySelectorAll('th')].filter(th => !th.hasAttribute(HIDE_COLUMNS_NO_HIDE)).forEach(th => this.setupHideButton(th)); - this._mutationObserver = new MutationObserver(this._tableMutated.bind(this)); - this._mutationObserver.observe(this._element, { childList: true, subtree: true }); + this._eventManager.registerNewMutationObserver(this._tableMutated.bind(this), this._element, { childList: true, subtree: true }); this._element.classList.add(HIDE_COLUMNS_INITIALIZED); } + destroy() { + this._eventManager.cleanUp(); + this._tableUtilContainer.remove(); + this._element.classList.remove(HIDE_COLUMNS_INITIALIZED); + } + setupHideButton(th) { const preHidden = this.isHiddenTH(th); @@ -104,34 +111,41 @@ export class HideColumns { this.addHeaderHider(th, hider); - th.addEventListener('mouseover', () => { + const mouseOverEvent = new EventWrapper(EVENT_TYPE.MOUSE_OVER, (() => { hider.classList.add(TABLE_HIDER_VISIBLE_CLASS); - }); - th.addEventListener('mouseout', () => { + }).bind(this), th); + this._eventManager.registerNewListener(mouseOverEvent); + + const mouseOutEvent = new EventWrapper(EVENT_TYPE.MOUSE_OUT, (() => { if (hider.classList.contains(TABLE_HIDER_CLASS)) { hider.classList.remove(TABLE_HIDER_VISIBLE_CLASS); } - }); + }).bind(this), th); + this._eventManager.registerNewListener(mouseOutEvent); - hider.addEventListener('click', (event) => { + const hideClickEvent = new EventWrapper(EVENT_TYPE.CLICK, ((event) => { event.preventDefault(); event.stopPropagation(); this.switchColumnDisplay(th); // this._tableHiderContainer.getElementsByClassName(TABLE_HIDER_CLASS).forEach(hider => this.hideHiderBehindHeader(hider)); - }); + }).bind(this), hider); + this._eventManager.registerNewListener(hideClickEvent); - hider.addEventListener('mouseover', () => { + const mouseOverHider = new EventWrapper(EVENT_TYPE.MOUSE_OVER, (() => { hider.classList.add(TABLE_HIDER_VISIBLE_CLASS); const currentlyHidden = this.hiderStatus(th); this.updateHiderIcon(hider, !currentlyHidden); - }); - hider.addEventListener('mouseout', () => { + }).bind(this), hider); + this._eventManager.registerNewListener(mouseOverHider); + + const mouseOutHider = new EventWrapper(EVENT_TYPE.MOUSE_OUT, (() => { if (hider.classList.contains(TABLE_HIDER_CLASS)) { hider.classList.remove(TABLE_HIDER_VISIBLE_CLASS); } const currentlyHidden = this.hiderStatus(th); this.updateHiderIcon(hider, currentlyHidden); - }); + }).bind(this), hider); + this._eventManager.registerNewListener(mouseOutHider); new ResizeObserver(() => { this.repositionHider(hider); }).observe(th); diff --git a/frontend/src/utils/inputs/checkbox.js b/frontend/src/utils/inputs/checkbox.js index 9b1b541ff..ebf721667 100644 --- a/frontend/src/utils/inputs/checkbox.js +++ b/frontend/src/utils/inputs/checkbox.js @@ -9,43 +9,54 @@ const CHECKBOX_INITIALIZED_CLASS = 'checkbox--initialized'; selector: 'input[type="checkbox"]:not([uw-no-checkbox]), input[type="radio"]:not([uw-no-radiobox])', }) export class Checkbox { + + _element; + _wrapperEl; + constructor(element) { if (!element) { throw new Error('Checkbox utility cannot be setup without an element!'); } + this._element = element; - const isRadio = element.type === 'radio'; + const isRadio = this._element.type === 'radio'; const box_class = isRadio ? RADIOBOX_CLASS : CHECKBOX_CLASS; - if (isRadio && element.closest('.radio-group')) { + if (isRadio && this._element.closest('.radio-group')) { // Don't initialize radiobox, if radio is part of a group return false; } - if (element.classList.contains(CHECKBOX_INITIALIZED_CLASS)) { + if (this._element.classList.contains(CHECKBOX_INITIALIZED_CLASS)) { // throw new Error('Checkbox utility already initialized!'); return false; } - if (element.parentElement.classList.contains(box_class)) { + if (this._element.parentElement.classList.contains(box_class)) { // throw new Error('Checkbox element\'s wrapper already has class '' + box_class + ''!'); return false; } - const siblingEl = element.nextSibling; - const parentEl = element.parentElement; + const siblingEl = this._element.nextSibling; + const parentEl = this._element.parentElement; - const wrapperEl = document.createElement('div'); - wrapperEl.classList.add(box_class); + this._wrapperEl = document.createElement('div'); + this._wrapperEl.classList.add(box_class); const labelEl = document.createElement('label'); labelEl.setAttribute('for', element.id); - wrapperEl.appendChild(element); - wrapperEl.appendChild(labelEl); + this._wrapperEl.appendChild(element); + this._wrapperEl.appendChild(labelEl); - parentEl.insertBefore(wrapperEl, siblingEl); + parentEl.insertBefore(this._wrapperEl, siblingEl); - element.classList.add(CHECKBOX_INITIALIZED_CLASS); + this._element.classList.add(CHECKBOX_INITIALIZED_CLASS); + } + + destroy() { + if (this._wrapperEl !== undefined) + this._wrapperEl.remove(); + this._element.classList.remove(CHECKBOX_INITIALIZED_CLASS); } } diff --git a/frontend/src/utils/inputs/checkrange.js b/frontend/src/utils/inputs/checkrange.js new file mode 100644 index 000000000..ee6441b95 --- /dev/null +++ b/frontend/src/utils/inputs/checkrange.js @@ -0,0 +1,125 @@ +import { Utility } from '../../core/utility'; +import { TableIndices } from '../../lib/table/table'; +import { FrontendTooltips } from '../../lib/tooltips/frontend-tooltips'; +import { Translations } from '../../messages'; + + +const CHECKRANGE_INITIALIZED_CLASS = 'checkrange--initialized'; +const CHECKBOX_SELECTOR = '[type="checkbox"]'; + + +@Utility({ + selector: 'table:not([uw-no-check-all])', + }) +export class CheckRange { + _lastCheckedCell = null; + _element; + _tableIndices + _columns = new Array(); + + constructor(element) { + if(!element) { + throw new Error('Check Range Utility cannot be setup without an element'); + } + + this._element = element; + + if (this._element.classList.contains(CHECKRANGE_INITIALIZED_CLASS)) + return false; + + this._tableIndices = new TableIndices(this._element); + + this._gatherColumns(); + + let checkboxColumns = this._findCheckboxColumns(); + + checkboxColumns.forEach(columnId => this._setUpShiftClickOnColumn(columnId)); + + this._element.classList.add(CHECKRANGE_INITIALIZED_CLASS); + } + + _setUpShiftClickOnColumn(columnId) { + if (!this._columns || columnId < 0 || columnId >= this._columns.length) return; + let column = this._columns[columnId]; + let language = document.documentElement.lang; + let toolTipMessage = Translations.getTranslation('checkrangeTooltip', language); + FrontendTooltips.addToolTip(column[0], toolTipMessage); + column.forEach(el => el.addEventListener('click', (ev) => { + + if(ev.shiftKey && this.lastCheckedCell !== null) { + let lastClickedIndex = this._tableIndices.rowIndex(this._lastCheckedCell); + let currentCellIndex = this._tableIndices.rowIndex(el); + let cell = this._columns[columnId][currentCellIndex]; + if(currentCellIndex > lastClickedIndex) + this._handleCellsInBetween(cell, lastClickedIndex, currentCellIndex, columnId); + else + this._handleCellsInBetween(cell, currentCellIndex, lastClickedIndex, columnId); + } else { + this._lastCheckedCell = el; + } + })); + } + + _handleCellsInBetween(cell, firstRowIndex, lastRowIndex, columnId) { + if(this._isChecked(cell)) { + this._uncheckMultipleCells(firstRowIndex, lastRowIndex, columnId); + } else { + this._checkMultipleCells(firstRowIndex, lastRowIndex, columnId); + } + } + + _checkMultipleCells(firstRowIndex, lastRowIndex, columnId) { + for(let i=firstRowIndex; i<=lastRowIndex; i++) { + let cell = this._columns[columnId][i]; + if (cell.tagName !== 'TH') { + cell.querySelector(CHECKBOX_SELECTOR).checked = true; + } + } + } + + _uncheckMultipleCells(firstRowIndex, lastRowIndex, columnId) { + for(let i=firstRowIndex; i<=lastRowIndex; i++) { + let cell = this._columns[columnId][i]; + if (cell.tagName !== 'TH') { + cell.querySelector(CHECKBOX_SELECTOR).checked = false; + } + } + } + + _isChecked(cell) { + return cell.querySelector(CHECKBOX_SELECTOR).checked; + } + + + _gatherColumns() { + for (const rowIndex of Array(this._tableIndices.maxRow + 1).keys()) { + for (const colIndex of Array(this._tableIndices.maxCol + 1).keys()) { + + const cell = this._tableIndices.getCell(rowIndex, colIndex); + + if (!cell) + continue; + + if (!this._columns[colIndex]) + this._columns[colIndex] = new Array(); + + this._columns[colIndex][rowIndex] = cell; + } + } + } + + _findCheckboxColumns() { + let checkboxColumnIds = new Array(); + this._columns.forEach((col, i) => { + if (this._isCheckboxColumn(col)) { + checkboxColumnIds.push(i); + } + }); + return checkboxColumnIds; + } + + _isCheckboxColumn(col) { + return col.every(cell => cell.tagName == 'TH' || cell.querySelector(CHECKBOX_SELECTOR)) + && col.some(cell => cell.querySelector(CHECKBOX_SELECTOR)); + } +} \ No newline at end of file diff --git a/frontend/src/utils/inputs/checkrange.md b/frontend/src/utils/inputs/checkrange.md new file mode 100644 index 000000000..894a3bd4f --- /dev/null +++ b/frontend/src/utils/inputs/checkrange.md @@ -0,0 +1,5 @@ +# Checkrange Utility +Is set on the table header of a specific row. Remembers the last checked checkbox. When the users shift-clicks another checkbox in the same row, all checkboxes in between are also checked. + +# Attribute: table:not([uw-no-check-all] +(will be setup on all tables which use the util check-all) \ No newline at end of file diff --git a/frontend/src/utils/inputs/file-input.js b/frontend/src/utils/inputs/file-input.js index e84d2ce26..300a15fa4 100644 --- a/frontend/src/utils/inputs/file-input.js +++ b/frontend/src/utils/inputs/file-input.js @@ -1,4 +1,5 @@ import { Utility } from '../../core/utility'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; import './file-input.sass'; const FILE_INPUT_CLASS = 'file-input'; @@ -19,6 +20,8 @@ export class FileInput { _fileList; _label; + _eventManager; + constructor(element, app) { if (!element) { throw new Error('FileInput utility cannot be setup without an element!'); @@ -27,6 +30,8 @@ export class FileInput { this._element = element; this._app = app; + this._eventManager = new EventManager(); + if (this._element.classList.contains(FILE_INPUT_INITIALIZED_CLASS)) { throw new Error('FileInput utility already initialized!'); } @@ -40,11 +45,11 @@ export class FileInput { this._label = this._createFileLabel(); this._updateLabel(); - // add change listener - this._element.addEventListener('change', () => { + const changeInputEv = new EventWrapper(EVENT_TYPE.CHANGE,(() => { this._updateLabel(); this._renderFileList(); - }); + }).bind(this), this._element ); + this._eventManager.registerNewListener(changeInputEv); // add util class for styling and mark as initialized this._element.classList.add(FILE_INPUT_CLASS); @@ -52,7 +57,10 @@ export class FileInput { } destroy() { - // TODO + this._eventManager.cleanUp(); + this._fileList.remove(); + this._label.remove(); + this._element.classList.remove(FILE_INPUT_INITIALIZED_CLASS); } _renderFileList() { diff --git a/frontend/src/utils/inputs/file-max-size.js b/frontend/src/utils/inputs/file-max-size.js index 653cca287..d0b8e75f5 100644 --- a/frontend/src/utils/inputs/file-max-size.js +++ b/frontend/src/utils/inputs/file-max-size.js @@ -1,4 +1,5 @@ import { Utility } from '../../core/utility'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; const FILE_MAX_SIZE_INITIALIZED_CLASS = 'file-max-size--initialized'; @@ -9,6 +10,8 @@ export class FileMaxSize { _element; _app; + _eventManager; + constructor(element, app) { if (!element) throw new Error('FileMaxSize utility cannot be setup without an element!'); @@ -16,6 +19,8 @@ export class FileMaxSize { this._element = element; this._app = app; + this._eventManager = new EventManager(); + if (this._element.classList.contains(FILE_MAX_SIZE_INITIALIZED_CLASS)) { throw new Error('FileMaxSize utility already initialized!'); } @@ -24,7 +29,13 @@ export class FileMaxSize { } start() { - this._element.addEventListener('change', this._change.bind(this)); + const changeEv = new EventWrapper(EVENT_TYPE.CHANGE, this._change.bind(this), this._element); + this._eventManager.registerNewListener(changeEv); + } + + destroy() { + this._eventManager.cleanUp(); + this._element.classList.remove(FILE_MAX_SIZE_INITIALIZED_CLASS); } _change() { diff --git a/frontend/src/utils/inputs/inputs.js b/frontend/src/utils/inputs/inputs.js index a072c2196..13d241895 100644 --- a/frontend/src/utils/inputs/inputs.js +++ b/frontend/src/utils/inputs/inputs.js @@ -2,6 +2,7 @@ import { Checkbox } from './checkbox'; import { FileInput } from './file-input'; import { FileMaxSize } from './file-max-size'; import { Password } from './password'; +import { CheckRange } from './checkrange'; import './inputs.sass'; import './radio-group.sass'; @@ -11,4 +12,5 @@ export const InputUtils = [ FileInput, FileMaxSize, Password, + CheckRange, ]; diff --git a/frontend/src/utils/inputs/password.js b/frontend/src/utils/inputs/password.js index 2bb750802..0659ab57e 100644 --- a/frontend/src/utils/inputs/password.js +++ b/frontend/src/utils/inputs/password.js @@ -1,4 +1,5 @@ import { Utility } from '../../core/utility'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; const PASSWORD_INITIALIZED_CLASS = 'password-input--initialized'; @@ -9,6 +10,9 @@ export class Password { _element; _iconEl; _toggleContainerEl; + _wrapperEl; + + _eventManager; constructor(element) { if (!element) @@ -18,25 +22,26 @@ export class Password { return false; this._element = element; + this._eventManager = new EventManager(); this._element.classList.add('password-input__input'); const siblingEl = this._element.nextSibling; const parentEl = this._element.parentElement; - const wrapperEl = document.createElement('div'); - wrapperEl.classList.add('password-input__wrapper'); - wrapperEl.appendChild(this._element); + this._wrapperEl = document.createElement('div'); + this._wrapperEl.classList.add('password-input__wrapper'); + this._wrapperEl.appendChild(this._element); this._toggleContainerEl = document.createElement('div'); this._toggleContainerEl.classList.add('password-input__toggle'); - wrapperEl.appendChild(this._toggleContainerEl); + this._wrapperEl.appendChild(this._toggleContainerEl); this._iconEl = document.createElement('i'); this._iconEl.classList.add('fas', 'fa-fw'); this._toggleContainerEl.appendChild(this._iconEl); - parentEl.insertBefore(wrapperEl, siblingEl); + parentEl.insertBefore(this._wrapperEl, siblingEl); this._element.classList.add(PASSWORD_INITIALIZED_CLASS); } @@ -44,17 +49,31 @@ export class Password { start() { this.updateVisibleIcon(this.isVisible()); - this._toggleContainerEl.addEventListener('mouseover', () => { + const mouseOverEv = new EventWrapper(EVENT_TYPE.MOUSE_OVER, (() => { this.updateVisibleIcon(!this.isVisible()); - }); - this._toggleContainerEl.addEventListener('mouseout', () => { + }).bind(this), this._toggleContainerEl); + + const mouseOutEv = new EventWrapper(EVENT_TYPE.MOUSE_OUT, (() => { this.updateVisibleIcon(this.isVisible()); - }); - this._toggleContainerEl.addEventListener('click', (event) => { + }).bind(this), this._toggleContainerEl); + + const clickEv = new EventWrapper(EVENT_TYPE.CLICK, ((event) => { event.preventDefault(); event.stopPropagation(); this.setVisible(!this.isVisible()); - }); + }).bind(this), this._toggleContainerEl ); + + this._eventManager.registerListeners([mouseOverEv, mouseOutEv, clickEv]); + } + + destroy() { + this._eventManager.cleanUp(); + this._iconEl.remove(); + this._toggleContainerEl.remove(); + this._wrapperEl.remove(); + this._iconEl.remove(); + + this._element.classList.remove(PASSWORD_INITIALIZED_CLASS); } isVisible() { diff --git a/frontend/src/utils/inputs/radio.js b/frontend/src/utils/inputs/radio.js index 38a3f0f2f..311f3fb53 100644 --- a/frontend/src/utils/inputs/radio.js +++ b/frontend/src/utils/inputs/radio.js @@ -9,39 +9,51 @@ const RADIO_INITIALIZED_CLASS = 'radio--initialized'; }) export class Radio { + _element; + _wrapperEl; + _labelEl; + constructor(element) { if (!element) { throw new Error('Radio utility cannot be setup without an element!'); } - if (element.closest('.radio-group')) { + this._element = element; + + if (this._element.closest('.radio-group')) { return false; } - if (element.classList.contains(RADIO_INITIALIZED_CLASS)) { + if (this._element.classList.contains(RADIO_INITIALIZED_CLASS)) { // throw new Error('Radio utility already initialized!'); return false; } - if (element.parentElement.classList.contains(RADIO_CLASS)) { + if (this._element.parentElement.classList.contains(RADIO_CLASS)) { // throw new Error('Radio element\'s wrapper already has class '' + RADIO_CLASS + ''!'); return false; } - const siblingEl = element.nextSibling; - const parentEl = element.parentElement; + const siblingEl = this._element.nextSibling; + const parentEl = this._element.parentElement; - const wrapperEl = document.createElement('div'); - wrapperEl.classList.add(RADIO_CLASS); + this._wrapperEl = document.createElement('div'); + this._wrapperEl.classList.add(RADIO_CLASS); - const labelEl = document.createElement('label'); - labelEl.setAttribute('for', element.id); + this._labelEl = document.createElement('label'); + this._labelEl.setAttribute('for', this._element.id); - wrapperEl.appendChild(element); - wrapperEl.appendChild(labelEl); + this._wrapperEl.appendChild(this._element); + this._wrapperEl.appendChild(this._labelEl); - parentEl.insertBefore(wrapperEl, siblingEl); + parentEl.insertBefore(this._wrapperEl, siblingEl); - element.classList.add(RADIO_INITIALIZED_CLASS); + this._element.classList.add(RADIO_INITIALIZED_CLASS); + } + + destroy() { + this._labelEl.remove(); + this._wrapperEl.remove(); + this._element.classList.remove(RADIO_INITIALIZED_CLASS); } } diff --git a/frontend/src/utils/mass-input/mass-input.js b/frontend/src/utils/mass-input/mass-input.js index aaa5f7d0c..4969e8c14 100644 --- a/frontend/src/utils/mass-input/mass-input.js +++ b/frontend/src/utils/mass-input/mass-input.js @@ -2,6 +2,7 @@ import { Utility } from '../../core/utility'; import { Datepicker } from '../form/datepicker'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; import './mass-input.sass'; const MASS_INPUT_CELL_SELECTOR = '.massinput__cell'; @@ -29,6 +30,8 @@ export class MassInput { _changedAdd = new Array(); + _eventManager; + constructor(element, app) { if (!element) { throw new Error('Mass Input utility cannot be setup without an element!'); @@ -37,6 +40,8 @@ export class MassInput { this._element = element; this._app = app; + this._eventManager = new EventManager(); + if (global !== undefined) this._global = global; else if (window !== undefined) @@ -64,9 +69,10 @@ export class MassInput { buttons.forEach((button) => { this._setupSubmitButton(button); }); - - this._massInputForm.addEventListener('submit', this._massInputFormSubmitHandler.bind(this)); - this._massInputForm.addEventListener('keypress', this._keypressHandler.bind(this)); + + const submitEv = new EventWrapper(EVENT_TYPE.SUBMIT, this._massInputFormSubmitHandler.bind(this), this._massInputForm); + const keyPressEv = new EventWrapper(EVENT_TYPE.KEYDOWN, this._keypressHandler.bind(this), this._massInputForm); + this._eventManager.registerListeners([submitEv, keyPressEv]); Array.from(this._element.querySelectorAll(MASS_INPUT_ADD_CELL_SELECTOR)).forEach(this._setupChangedHandlers.bind(this)); @@ -76,14 +82,16 @@ export class MassInput { destroy() { this._reset(); + this._eventManager.cleanUp(); + this._element.classList.remove(MASS_INPUT_INITIALIZED_CLASS); } _setupChangedHandlers(addCell) { Array.from(addCell.querySelectorAll(MASS_INPUT_ADD_CHANGE_FIELD_SELECTOR)).forEach(inputElem => { if (inputElem.closest('[uw-mass-input]') !== this._element) return; - - inputElem.addEventListener('change', () => { this._changedAdd.push(addCell); }); + const changeEv = new EventWrapper(EVENT_TYPE.CHANGE, (() => { this._changedAdd.push(addCell); }).bind(this), inputElem); + this._eventManager.registerNewListener(changeEv); }); } @@ -207,13 +215,13 @@ export class MassInput { _setupSubmitButton(button) { button.setAttribute('type', 'button'); button.classList.add(MASS_INPUT_SUBMIT_BUTTON_CLASS); - button.addEventListener('click', this._massInputFormSubmitHandler); + const buttonClickEv = new EventWrapper(EVENT_TYPE.CLICK, this._massInputFormSubmitHandler.bind(this), button); + this._eventManager.registerNewListener(buttonClickEv); } _resetSubmitButton(button) { button.setAttribute('type', 'submit'); button.classList.remove(MASS_INPUT_SUBMIT_BUTTON_CLASS); - button.removeEventListener('click', this._massInputFormSubmitHandler); } _processResponse(responseElement) { @@ -268,9 +276,6 @@ export class MassInput { } _reset() { - this._element.classList.remove(MASS_INPUT_INITIALIZED_CLASS); - this._massInputForm.removeEventListener('submit', this._massInputFormSubmitHandler); - this._massInputForm.removeEventListener('keypress', this._keypressHandler); const buttons = this._getMassInputSubmitButtons(); buttons.forEach((button) => { diff --git a/frontend/src/utils/modal/modal.js b/frontend/src/utils/modal/modal.js index c67b13ac7..6b8e28a17 100644 --- a/frontend/src/utils/modal/modal.js +++ b/frontend/src/utils/modal/modal.js @@ -1,4 +1,5 @@ import { Utility } from '../../core/utility'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; import './modal.sass'; const MODAL_HEADERS = { @@ -28,12 +29,16 @@ const MODALS_WRAPPER_OPEN_CLASS = 'modals-wrapper--open'; }) export class Modal { + _eventManager + _element; _app; _modalsWrapper; _modalOverlay; _modalUrl; + _triggerElement; + _closerElement; constructor(element, app) { if (!element) { @@ -42,6 +47,7 @@ export class Modal { this._element = element; this._app = app; + this._eventManager = new EventManager(); if (this._element.classList.contains(MODAL_INITIALIZED_CLASS)) { return false; @@ -66,7 +72,7 @@ export class Modal { } destroy() { - // TODO + throw new Error('Destroying modals is not possible.'); } _ensureModalWrapper() { @@ -92,23 +98,26 @@ export class Modal { if (!triggerSelector.startsWith('#')) { triggerSelector = '#' + triggerSelector; } - const triggerElement = document.querySelector(triggerSelector); + this._triggerElement = document.querySelector(triggerSelector); - if (!triggerElement) { + if (!this._triggerElement) { throw new Error('Trigger element for Modal not found: "' + triggerSelector + '"'); } - triggerElement.classList.add(MODAL_TRIGGER_CLASS); - triggerElement.addEventListener('click', this._onTriggerClicked, false); - this._modalUrl = triggerElement.getAttribute('href'); + this._triggerElement.classList.add(MODAL_TRIGGER_CLASS); + const triggerEvent = new EventWrapper(EVENT_TYPE.CLICK, this._onTriggerClicked.bind(this), this._triggerElement, false); + this._eventManager.registerNewListener(triggerEvent); + this._modalUrl = this._triggerElement.getAttribute('href'); } _setupCloser() { - const closerElement = document.createElement('div'); - this._element.insertBefore(closerElement, null); - closerElement.classList.add(MODAL_CLOSER_CLASS); - closerElement.addEventListener('click', this._onCloseClicked, false); - this._modalOverlay.addEventListener('click', this._onCloseClicked, false); + this._closerElement = document.createElement('div'); + this._element.insertBefore(this._closerElement, null); + this._closerElement.classList.add(MODAL_CLOSER_CLASS); + const closerElEvent = new EventWrapper(EVENT_TYPE.CLICK, this._onCloseClicked.bind(this), this._closerElement, false); + this._eventManager.registerNewListener(closerElEvent); + const overlayClose = new EventWrapper(EVENT_TYPE.CLICK, this._onCloseClicked.bind(this), this._modalOverlay, false); + this._eventManager.registerNewListener(overlayClose); } _onTriggerClicked = (event) => { diff --git a/frontend/src/utils/navbar/navbar.js b/frontend/src/utils/navbar/navbar.js index f31ba77bd..08c11428c 100644 --- a/frontend/src/utils/navbar/navbar.js +++ b/frontend/src/utils/navbar/navbar.js @@ -1,4 +1,5 @@ import { Utility } from '../../core/utility'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; import './navbar.sass'; import * as throttle from 'lodash.throttle'; @@ -18,6 +19,8 @@ export class NavHeaderContainerUtil { _throttleUpdateWasOpen; + _eventManager; + constructor(element) { if (!element) { throw new Error('Navbar Header Container utility needs to be passed an element!'); @@ -29,6 +32,9 @@ export class NavHeaderContainerUtil { this._element = element; this.radioButton = document.getElementById(`${this._element.id}-radio`); + + this._eventManager = new EventManager(); + if (!this.radioButton) { throw new Error('Navbar Header Container utility could not find associated radio button!'); } @@ -58,8 +64,9 @@ export class NavHeaderContainerUtil { if (!this.container) return; - window.addEventListener('click', this.clickHandler.bind(this)); - this.radioButton.addEventListener('change', this.throttleUpdateWasOpen.bind(this)); + const clickEv = new EventWrapper(EVENT_TYPE.CLICK, this.clickHandler.bind(this), window); + const changeEv = new EventWrapper(EVENT_TYPE.CHANGE, this.throttleUpdateWasOpen.bind(this), this.radioButton); + this._eventManager.registerListeners([clickEv, changeEv]); } clickHandler() { @@ -81,7 +88,10 @@ export class NavHeaderContainerUtil { this.wasOpen = this.isOpen(); } - destroy() { /* TODO */ } + destroy() { + this._eventManager.cleanUp(); + this._element.classList.remove(HEADER_CONTAINER_INITIALIZED_CLASS); + } } diff --git a/frontend/src/utils/pageactions/pageactions.js b/frontend/src/utils/pageactions/pageactions.js index 7c2334b6e..88084636b 100644 --- a/frontend/src/utils/pageactions/pageactions.js +++ b/frontend/src/utils/pageactions/pageactions.js @@ -1,4 +1,5 @@ import { Utility } from '../../core/utility'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; import './pageactions.sass'; import * as throttle from 'lodash.throttle'; @@ -17,9 +18,12 @@ export class PageActionSecondaryUtil { closeButton; container; wasOpen; + _closer; _throttleUpdateWasOpen; + _eventManager; + constructor(element) { if (!element) { throw new Error('Pageaction Secondary utility needs to be passed an element!'); @@ -31,6 +35,8 @@ export class PageActionSecondaryUtil { this._element = element; + this._eventManager = new EventManager(); + const childContainer = this._element.querySelector('.pagenav-item__children'); if (!childContainer) { @@ -43,7 +49,7 @@ export class PageActionSecondaryUtil { const links = Array.from(this._element.querySelectorAll('.pagenav-item__link')).filter(l => !childContainer.contains(l)); if (!links || Array.from(links).length !== 1) { - throw new Error('Pageaction Secondary utility could not find associated link!'); + throw new Error('Pageaction Secondary utility could not find associated link!'); } this.navIdent = links[0].id; } @@ -71,9 +77,9 @@ export class PageActionSecondaryUtil { throw new Error('Pageaction Secondary utility could not find associated container!'); } - const closer = this._element.querySelector('.pagenav-item__close-label'); - if (closer) { - closer.classList.add('pagenav-item__close-label--hidden'); + this._closer = this._element.querySelector('.pagenav-item__close-label'); + if (this._closer) { + this._closer.classList.add('pagenav-item__close-label--hidden'); } this.updateWasOpen(); @@ -85,12 +91,12 @@ export class PageActionSecondaryUtil { start() { if (!this.container) return; - - window.addEventListener('click', this.clickHandler.bind(this)); - this.radioButton.addEventListener('change', this.throttleUpdateWasOpen.bind(this)); + const windowClickEv = new EventWrapper(EVENT_TYPE.CLICK, ((event) => this.clickHandler(event)).bind(this), window); + const radioButtonChangeEv = new EventWrapper(EVENT_TYPE.CHANGE, this.throttleUpdateWasOpen.bind(this), this.radioButton); + this._eventManager.registerListeners([windowClickEv, radioButtonChangeEv]); } - clickHandler() { + clickHandler(event) { if (!this.container.contains(event.target) && window.document.contains(event.target) && this.wasOpen) { this.close(); } @@ -109,7 +115,12 @@ export class PageActionSecondaryUtil { this.wasOpen = this.isOpen(); } - destroy() { /* TODO */ } + destroy() { + this._eventManager.cleanUp(); + if(this._closer && this._closer.classList.contains('pagenav-item__close-label--hidden')) + this._closer.classList.remove('pagenav-item__close-label--hidden'); + this._element.classList.remove(PAGEACTION_SECONDARY_INITIALIZED_CLASS); + } } diff --git a/frontend/src/utils/show-hide/show-hide.js b/frontend/src/utils/show-hide/show-hide.js index 3419ee1f4..5739baac9 100644 --- a/frontend/src/utils/show-hide/show-hide.js +++ b/frontend/src/utils/show-hide/show-hide.js @@ -1,5 +1,6 @@ import { Utility } from '../../core/utility'; import { StorageManager, LOCATION } from '../../lib/storage-manager/storage-manager'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; import './show-hide.sass'; const SHOW_HIDE_LOCAL_STORAGE_KEY = 'SHOW_HIDE'; @@ -16,6 +17,7 @@ export class ShowHide { _showHideId; _element; + _eventManager; _storageManager = new StorageManager(SHOW_HIDE_LOCAL_STORAGE_KEY, '1.0.0', { location: LOCATION.LOCAL }); constructor(element) { @@ -24,13 +26,15 @@ export class ShowHide { } this._element = element; + this._eventManager = new EventManager(); if (this._element.classList.contains(SHOW_HIDE_INITIALIZED_CLASS)) { return false; } // register click listener - this._addClickListener(); + const clickEv = new EventWrapper(EVENT_TYPE.CLICK, this._clickHandler.bind(this), this._element); + this._eventManager.registerNewListener(clickEv); // param showHideId if (this._element.dataset.showHideId) { @@ -58,17 +62,18 @@ export class ShowHide { } this._checkHash(); - - window.addEventListener('hashchange', this._checkHash.bind(this)); + const hashChangeEv = new EventWrapper(EVENT_TYPE.HASH_CHANGE, this._checkHash.bind(this), window); + this._eventManager.registerNewListener(hashChangeEv); // mark as initialized this._element.classList.add(SHOW_HIDE_INITIALIZED_CLASS, SHOW_HIDE_TOGGLE_CLASS); } - destroy() {} - - _addClickListener() { - this._element.addEventListener('click', this._clickHandler.bind(this)); + destroy() { + this._eventManager.cleanUp(); + if (this._element.parentElement.classList.contains(SHOW_HIDE_COLLAPSED_CLASS)) + this._element.parentElement.classList.remove(SHOW_HIDE_COLLAPSED_CLASS); + this._element.classList.remove(SHOW_HIDE_INITIALIZED_CLASS, SHOW_HIDE_TOGGLE_CLASS); } _show() { diff --git a/frontend/src/utils/sort-table/sort-table.js b/frontend/src/utils/sort-table/sort-table.js index 3c43a9ee2..639664685 100644 --- a/frontend/src/utils/sort-table/sort-table.js +++ b/frontend/src/utils/sort-table/sort-table.js @@ -21,7 +21,7 @@ export class SortTable { } destroy() { - console.log('TBD destroy SortTable'); + this._storageManager.clear(); } } diff --git a/frontend/src/utils/tooltips/tooltips.js b/frontend/src/utils/tooltips/tooltips.js index 12d628f35..99b144f7e 100644 --- a/frontend/src/utils/tooltips/tooltips.js +++ b/frontend/src/utils/tooltips/tooltips.js @@ -1,6 +1,7 @@ import { Utility } from '../../core/utility'; import './tooltips.sass'; import { MovementObserver } from '../../lib/movement-observer/movement-observer'; +import { EventManager, EventWrapper, EVENT_TYPE } from '../../lib/event-manager/event-manager'; const TOOLTIP_CLASS = 'tooltip'; const TOOLTIP_INITIALIZED_CLASS = 'tooltip--initialized'; @@ -17,6 +18,7 @@ export class Tooltip { _content; _movementObserver; + _eventManager; _openedPersistent = false; @@ -45,16 +47,19 @@ export class Tooltip { this._element = element; this._handle = element.querySelector('.tooltip__handle') || element; + this._eventManager = new EventManager(); + this._movementObserver = new MovementObserver(this._handle, { leadingCallback: this.close.bind(this) }); element.classList.add(TOOLTIP_INITIALIZED_CLASS); } start() { - this._element.addEventListener('mouseover', () => { this.open(false); }); - this._element.addEventListener('mouseout', this._leave.bind(this)); - this._content.addEventListener('mouseout', this._leave.bind(this)); - this._element.addEventListener('click', this._click.bind(this)); + const mouseOverEv = new EventWrapper(EVENT_TYPE.MOUSE_OVER, (() => { this.open(false); }).bind(this), this._element); + const mouseOutEv = new EventWrapper(EVENT_TYPE.MOUSE_OUT, (this._leave.bind(this)).bind(this), this._element); + const contentMouseOut = new EventWrapper(EVENT_TYPE.MOUSE_OUT, (this._leave.bind(this)).bind(this), this._content); + const clickEv = new EventWrapper(EVENT_TYPE.CLICK, this._click.bind(this), this._element); + this._eventManager.registerListeners([mouseOverEv, mouseOutEv, contentMouseOut, clickEv]); } open(persistent) { @@ -183,5 +188,10 @@ export class Tooltip { } - destroy() {} + destroy() { + this._eventManager.cleanUp(); + this._movementObserver.unobserve(); + const toolTipsRegex = RegExp(/\btooltip--.+\b/, 'g'); + this._element.className = this._element.className.replace(toolTipsRegex, ''); + } }; diff --git a/messages/uniworx/categories/courses/courses/de-de-formal.msg b/messages/uniworx/categories/courses/courses/de-de-formal.msg index eb106d048..2e1880882 100644 --- a/messages/uniworx/categories/courses/courses/de-de-formal.msg +++ b/messages/uniworx/categories/courses/courses/de-de-formal.msg @@ -187,6 +187,7 @@ LecturerFor: Dozent:in LecturersFor: Dozierende AssistantFor: Assistent:in AssistantsFor: Assistent:innen +CourseAdminFor: Kursadministration TutorsFor n@Int: #{pluralDE n "Tutor:in" "Tutor:innen"} CorrectorsFor n@Int: #{pluralDE n "Korrektor:in" "Korrektor:innen"} CourseParticipantsHeading: Kursteilnehmer:innen @@ -277,4 +278,5 @@ MailSubjectLecturerInvitation tid@TermId ssh@SchoolId csh@CourseShorthand: [#{ti LecturerInvitationAccepted lType@Text csh@CourseShorthand: Sie wurden als #{lType} für #{csh} eingetragen CourseExamRegistrationTime: Angemeldet seit CourseParticipantStateIsActiveFilter: Ansicht -CourseApply: Zum Kurs bewerben \ No newline at end of file +CourseApply: Zum Kurs bewerben +CourseAdministrator: Kursadministrator:in \ No newline at end of file diff --git a/messages/uniworx/categories/courses/courses/en-eu.msg b/messages/uniworx/categories/courses/courses/en-eu.msg index d3e3d709f..c4eda4efc 100644 --- a/messages/uniworx/categories/courses/courses/en-eu.msg +++ b/messages/uniworx/categories/courses/courses/en-eu.msg @@ -187,6 +187,7 @@ LecturerFor: Lecturer LecturersFor: Lecturers AssistantFor: Assistant AssistantsFor: Assistants +CourseAdminFor: Course administration TutorsFor n: #{pluralEN n "Tutor" "Tutors"} CorrectorsFor n: #{pluralEN n "Corrector" "Correctors"} CourseParticipantsHeading: Course participants @@ -277,3 +278,4 @@ LecturerInvitationAccepted lType csh: You were registered as #{lType} for #{csh} CourseExamRegistrationTime: Registered since CourseParticipantStateIsActiveFilter: View CourseApply: Apply for course +CourseAdministrator: Course administrator \ No newline at end of file diff --git a/messages/uniworx/categories/courses/submission/de-de-formal.msg b/messages/uniworx/categories/courses/submission/de-de-formal.msg index 165cfe9a9..f2eedb946 100644 --- a/messages/uniworx/categories/courses/submission/de-de-formal.msg +++ b/messages/uniworx/categories/courses/submission/de-de-formal.msg @@ -68,6 +68,7 @@ Corrected: Korrigiert HeadingSubmissionEditHead tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{tid}-#{ssh}-#{csh} #{sheetName}: Abgabe editieren/anlegen SubmissionUsers: Studenten AssignedTime: Zuteilung +SubmissionPseudonym !ident-ok: Pseudonym Pseudonyms: Pseudonyme CourseCorrectionsTitle: Korrekturen für diesen Kurs SubmissionArchiveName: abgaben @@ -185,6 +186,7 @@ UploadModeExtensionRestrictionTip: Komma-separiert. Wenn keine Dateiendungen ang UploadModeExtensionRestrictionMultipleTip: Einschränkung von Dateiendungen erfolgt für alle hochgeladenen Dateien, auch innerhalb von ZIP-Archiven. FileUploadMaxSize maxSize@Text: Datei darf maximal #{maxSize} groß sein FileUploadMaxSizeMultiple maxSize@Text: Dateien dürfen jeweils maximal #{maxSize} groß sein +FileUploadCumulativeMaxSize maxSize@Text: Dateien dürfen insgesamt maximal #{maxSize} groß sein InvalidPseudonym pseudonym@Text: Invalides Pseudonym "#{pseudonym}" InvalidPseudonymSubmissionIgnored oPseudonyms@Text iPseudonym@Text: Abgabe mit Pseudonymen „#{oPseudonyms}“ wurde ignoriert, da „#{iPseudonym}“ nicht automatisiert zu einem validen Pseudonym korrigiert werden konnte. @@ -227,4 +229,37 @@ SubmissionColumnAuthorshipStatementTime: Zeitstempel SubmissionColumnAuthorshipStatementWording: Wortlaut SubmissionFilterAuthorshipStatementCurrent: Aktueller Wortlaut -SubmissionNoUsers: Diese Abgabe hat keine assoziierten Benutzer! \ No newline at end of file +SubmissionNoUsers: Diese Abgabe hat keine assoziierten Benutzer! + +CsvColumnCorrectionTerm: Semester des Kurses der Abgabe +CsvColumnCorrectionSchool: Institut des Kurses der Abgabe +CsvColumnCorrectionCourse: Kürzel des Kurses der Abgabe +CsvColumnCorrectionSheet: Name des Übungsblatts der Abgabe +CsvColumnCorrectionSubmission: Nummer der Abgabe (uwa…) +CsvColumnCorrectionSurname: Nachnamen der Abgebenden als Semikolon (;) separierte Liste +CsvColumnCorrectionFirstName: Vornamen der Abgebenden als Semikolon (;) separierte Liste +CsvColumnCorrectionName: Volle Namen der Abgebenden als Semikolon (;) separierte Liste +CsvColumnCorrectionMatriculation: Matrikelnummern der Abgebenden als Semikolon (;) separierte Liste +CsvColumnCorrectionEmail: E-Mail Adressen der Abgebenden als Semikolon (;) separierte Liste +CsvColumnCorrectionPseudonym: Abgabe-Pseudonyme der Abgebenden als Semikolon (;) separierte Liste +CsvColumnCorrectionSubmissionGroup: Feste Abgabegruppen der Abgebenden als Semikolon (;) separierte Liste +CsvColumnCorrectionAuthorshipStatementState: Zustände der Eigenständigkeitserklärungen ("#{toPathPiece ASMissing}", "#{toPathPiece ASOldStatement}" oder "#{toPathPiece ASExists}") als Semikolon (;) separierte Liste +CsvColumnCorrectionCorrectorName: Voller Name des Korrektors der Abgabe +CsvColumnCorrectionCorrectorEmail: E-Mail Adresse des Korrektors der Abgabe +CsvColumnCorrectionRatingDone: Bewertung abgeschlossen ("t"/"f") +CsvColumnCorrectionRatedAt: Zeitpunkt der Bewertung (ISO 8601) +CsvColumnCorrectionAssigned: Zeitpunkt der Zuteilung des Korrektors (ISO 8601) +CsvColumnCorrectionLastEdit: Zeitpunkt der letzten Änderung der Abgabe (ISO 8601) +CsvColumnCorrectionRatingPoints: Erreichte Punktezahl (Für “_{MsgSheetGradingPassBinary}” entspricht 0 “_{MsgRatingNotPassed}” und alles andere “_{MsgRatingPassed}”) +CsvColumnCorrectionRatingComment: Bewertungskommentar +CorrectionCsvSingleSubmittors: Eine Zeile pro Abgebende:n +CorrectionCsvSingleSubmittorsTip: Sollen Abgaben mit mehreren Abgebenden mehrfach vorkommen, sodass jeweils eine Zeile pro Abgebende:n enthalten ist, statt mehrere Abgebende in einer Zeile zusammenzufassen? + +CorrectionTableCsvNameSheetCorrections tid@TermId ssh@SchoolId csh@CourseShorthand shn@SheetName: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase shn}-abgaben +CorrectionTableCsvSheetNameSheetCorrections tid@TermId ssh@SchoolId csh@CourseShorthand shn@SheetName: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase shn} Abgaben +CorrectionTableCsvNameCourseCorrections tid@TermId ssh@SchoolId csh@CourseShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-abgaben +CorrectionTableCsvSheetNameCourseCorrections tid@TermId ssh@SchoolId csh@CourseShorthand: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh} Abgaben +CorrectionTableCsvNameCorrections: abgaben +CorrectionTableCsvSheetNameCorrections: Abgaben +CorrectionTableCsvNameCourseUserCorrections tid@TermId ssh@SchoolId csh@CourseShorthand displayName@Text: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldCase displayName}-abgaben +CorrectionTableCsvSheetNameCourseUserCorrections tid@TermId ssh@SchoolId csh@CourseShorthand displayName@Text: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldCase displayName} Abgaben \ No newline at end of file diff --git a/messages/uniworx/categories/courses/submission/en-eu.msg b/messages/uniworx/categories/courses/submission/en-eu.msg index f9efeb3a0..2d0ffb872 100644 --- a/messages/uniworx/categories/courses/submission/en-eu.msg +++ b/messages/uniworx/categories/courses/submission/en-eu.msg @@ -66,6 +66,7 @@ Corrected: Marked HeadingSubmissionEditHead tid ssh csh sheetName: #{tid}-#{ssh}-#{csh} #{sheetName}: Edit/Create submission SubmissionUsers: Submittors AssignedTime: Assigned +SubmissionPseudonym !ident-ok: Pseudonym Pseudonyms: Pseudonyms CourseCorrectionsTitle: Corrections for this course SubmissionArchiveName: submissions @@ -185,6 +186,8 @@ UploadModeExtensionRestrictionTip: Comma-separated. If no file extensions are sp UploadModeExtensionRestrictionMultipleTip: Checks for valid file extension are performed for all uploaded files, including those packed within zip-archives. FileUploadMaxSize maxSize: File may be up to #{maxSize} in size FileUploadMaxSizeMultiple maxSize: Files may each be up to #{maxSize} in size +FileUploadCumulativeMaxSize maxSize: Files may be no larger than #{maxSize} in total + InvalidPseudonym pseudonym: Invalid pseudonym “#{pseudonym}” InvalidPseudonymSubmissionIgnored oPseudonyms iPseudonym: The submission with pseudonyms “#{oPseudonyms}” has been ignored since “#{iPseudonym}” could not be automatically corrected to be a valid pseudonym. PseudonymAutocorrections: Suggestions: @@ -227,3 +230,36 @@ SubmissionColumnAuthorshipStatementWording: Wording SubmissionFilterAuthorshipStatementCurrent: Current wording SubmissionNoUsers: This submission has no associated users! + +CsvColumnCorrectionTerm: Term of the course of the submission +CsvColumnCorrectionSchool: School of the course of the submission +CsvColumnCorrectionCourse: Shorthand of the course of the submission +CsvColumnCorrectionSheet: Name of the sheet of the submission +CsvColumnCorrectionSubmission: Number of the submission (uwa…) +CsvColumnCorrectionSurname: Submittor's surnames, separated by semicolon (;) +CsvColumnCorrectionFirstName: Submittor's first names, separated by semicolon (;) +CsvColumnCorrectionName: Submittor's full names, separated by semicolon (;) +CsvColumnCorrectionMatriculation: Submittor's matriculations, separated by semicolon (;) +CsvColumnCorrectionEmail: Submittor's email addresses, separated by semicolon (;) +CsvColumnCorrectionPseudonym: Submittor's submission pseudonyms, separated by semicolon (;) +CsvColumnCorrectionSubmissionGroup: Submittor's submisson groups, separated by semicolon (;) +CsvColumnCorrectionAuthorshipStatementState: States of the statements of authorship ("#{toPathPiece ASMissing}", "#{toPathPiece ASOldStatement}", or "#{toPathPiece ASExists}"), separated by semicolon (;) +CsvColumnCorrectionCorrectorName: Full name of the corrector of the submission +CsvColumnCorrectionCorrectorEmail: Email address of the corrector of the submission +CsvColumnCorrectionRatingDone: Rating done ("t"/"f") +CsvColumnCorrectionRatedAt: Timestamp of rating (ISO 8601) +CsvColumnCorrectionAssigned: Timestamp of when corrector was assigned (ISO 8601) +CsvColumnCorrectionLastEdit: Timestamp of the last edit of the submission (ISO 8601) +CsvColumnCorrectionRatingPoints: Achieved points (for “_{MsgSheetGradingPassBinary}” 0 means “_{MsgRatingNotPassed}”, everything else means “_{MsgRatingPassed}”) +CsvColumnCorrectionRatingComment: Rating comment +CorrectionCsvSingleSubmittors: One row per submittor +CorrectionCsvSingleSubmittorsTip: Should submissions with multiple submittors be split into multiple rows, such that there is one row per submittor instead of having multiple submittors within one row? + +CorrectionTableCsvNameSheetCorrections tid ssh csh shn: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase shn}-submissions +CorrectionTableCsvSheetNameSheetCorrections tid ssh csh shn: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldedCase shn} Submissions +CorrectionTableCsvNameCourseCorrections tid ssh csh: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-submissions +CorrectionTableCsvSheetNameCourseCorrections tid ssh csh: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh} Submissions +CorrectionTableCsvNameCorrections: submissions +CorrectionTableCsvSheetNameCorrections: Submissions +CorrectionTableCsvNameCourseUserCorrections tid ssh csh displayName: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldCase displayName}-submissions +CorrectionTableCsvSheetNameCourseUserCorrections tid ssh csh displayName: #{foldCase (termToText (unTermKey tid))}-#{foldedCase (unSchoolKey ssh)}-#{foldedCase csh}-#{foldCase displayName} Submissions diff --git a/messages/uniworx/categories/jobs_handler/de-de-formal.msg b/messages/uniworx/categories/jobs_handler/de-de-formal.msg index 9486409fa..8fd6899f6 100644 --- a/messages/uniworx/categories/jobs_handler/de-de-formal.msg +++ b/messages/uniworx/categories/jobs_handler/de-de-formal.msg @@ -17,3 +17,4 @@ InvitationAcceptDecline: Einladung annehmen/ablehnen InvitationFromTip displayName@Text: Sie erhalten diese Einladung, weil #{displayName} ihren Versand in Uni2work ausgelöst hat. InvitationFromTipAnonymous: Sie erhalten diese Einladung, weil ein nicht eingeloggter Benutzer/eine nichteingeloggte Benutzerin ihren Versand in Uni2work ausgelöst hat. InvitationUniWorXTip: Uni2work ist ein webbasiertes Lehrverwaltungssystem der LMU München. +LinkActiveUntil time@Text: Der Link ist nur bis #{time} aktiv! \ No newline at end of file diff --git a/messages/uniworx/categories/jobs_handler/en-eu.msg b/messages/uniworx/categories/jobs_handler/en-eu.msg index 6df1adf0c..d98247824 100644 --- a/messages/uniworx/categories/jobs_handler/en-eu.msg +++ b/messages/uniworx/categories/jobs_handler/en-eu.msg @@ -17,3 +17,4 @@ InvitationAcceptDecline: Accept/Decline invitation InvitationFromTip displayName: You are receiving this invitation because #{displayName} has caused it to be sent from within Uni2work. InvitationFromTipAnonymous: You are receiving this invitiation because an user who didn't log in has caused it to be send from within Uni2work. InvitationUniWorXTip: Uni2work is a web based teaching management system at LMU Munich. +LinkActiveUntil time@Text: The link is only active until #{time}! \ No newline at end of file diff --git a/messages/uniworx/utils/utils/de-de-formal.msg b/messages/uniworx/utils/utils/de-de-formal.msg index 1464f36ae..6e80466d9 100644 --- a/messages/uniworx/utils/utils/de-de-formal.msg +++ b/messages/uniworx/utils/utils/de-de-formal.msg @@ -17,6 +17,8 @@ RGTutorialParticipants tutn@TutorialName: Tutorium-Teilnehmer:innen (#{tutn}) RGExamRegistered examn@ExamName: Angemeldet zur Prüfung „#{examn}“ RGSheetSubmittor shn@SheetName: Abgebende für das Übungsblatt „#{shn}“ CommSubject: Betreff +CommAttachments: Anhänge +CommAttachmentsTip: Im Allgemeinen ist es vorzuziehen Dateien, die Sie mit den Empfängern teilen möchten, als Material hochzuladen (und ggf. in der Nachricht zu verlinken). So ist die Datei für die Empfänger dauerhaft abrufbar und auch Personen, die sich z.B. erst später zum Kurs anmelden, haben Zugriff auf die Datei. CommSuccess n@Int: Nachricht wurde an #{n} Empfänger versandt CommTestSuccess: Nachricht wurde zu Testzwecken nur an Sie selbst versandt @@ -53,6 +55,7 @@ UploadSpecificFileMaxSizeNegative: Maximale Dateigröße darf nicht negativ sein UploadSpecificFileEmptyOk: Leere Uploads erlauben UnknownPseudonymWord pseudonymWord@Text: Unbekanntes Pseudonym-Wort "#{pseudonymWord}" GenericFileFieldFileTooLarge file@FilePath: „#{file}“ ist zu groß +GenericFileFieldCumulativeTooLarge: Hochgeladene Dateien sind zu groß GenericFileFieldInvalidExtension file@FilePath: „#{file}” hat keine zulässige Dateiendung OnlyUploadOneFile: Bitte nur eine Datei hochladen. UploadAtLeastOneNonemptyFile: Bitte mindestens eine nichtleere Datei hochladen. diff --git a/messages/uniworx/utils/utils/en-eu.msg b/messages/uniworx/utils/utils/en-eu.msg index 1539fdf4c..652674005 100644 --- a/messages/uniworx/utils/utils/en-eu.msg +++ b/messages/uniworx/utils/utils/en-eu.msg @@ -17,6 +17,8 @@ RGTutorialParticipants tutn: Tutorial participants (#{tutn}) RGExamRegistered examn: Registered for exam “#{examn}” RGSheetSubmittor shn: Submitted for exercise sheet “#{shn}” CommSubject: Subject +CommAttachments: Attachments +CommAttachmentsTip: In general it is preferable to upload files as course material instead of sending them as attachments. You can then link to the material from the message. The file is then permanently accessable to the recipients and to persons that, for example, register for the Course at a later date. CommSuccess n: Message was sent to #{n} #{pluralEN n "recipient" "recipients"} CommTestSuccess: Message was sent only to yourself for testing purposes @@ -53,6 +55,7 @@ UploadSpecificFileMaxSizeNegative: Maximum filesize may not be negative UploadSpecificFileEmptyOk: Allow empty uploads UnknownPseudonymWord pseudonymWord: Invalid pseudonym-word “#{pseudonymWord}” GenericFileFieldFileTooLarge file: “#{file}” is too large +GenericFileFieldCumulativeTooLarge: Uploaded files are too large GenericFileFieldInvalidExtension file: “#{file}” does not have an acceptable file extension OnlyUploadOneFile: Please only upload one file UploadAtLeastOneNonemptyFile: Please upload at least one nonempty file. diff --git a/package-lock.json b/package-lock.json index f69da8c57..cc145124e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "uni2work", - "version": "25.20.2", + "version": "25.25.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 180c83d28..afc057afd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "uni2work", - "version": "25.20.2", + "version": "25.25.0", "description": "", "keywords": [], "author": "", diff --git a/package.yaml b/package.yaml index a31beffbb..d1f284c52 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: uniworx -version: 25.20.2 +version: 25.25.0 dependencies: - base - yesod @@ -123,6 +123,7 @@ dependencies: - http-client - jose-jwt - mono-traversable + - mono-traversable-keys - lens-aeson - systemd - streaming-commons diff --git a/routes b/routes index e465eb1e8..e9cb6e769 100644 --- a/routes +++ b/routes @@ -78,7 +78,7 @@ /global-workflows/instances/#WorkflowInstanceName GlobalWorkflowInstanceR: /edit GWIEditR GET POST /delete GWIDeleteR GET POST - /workflows GWIWorkflowsR GET !¬empty + /workflows GWIWorkflowsR GET !free /initiate GWIInitiateR GET POST !workflow /update GWIUpdateR POST /global-workflows GlobalWorkflowWorkflowListR GET !free @@ -147,7 +147,7 @@ /workflows/instances/#WorkflowInstanceName SchoolWorkflowInstanceR: /edit SWIEditR GET POST /delete SWIDeleteR GET POST - /workflows SWIWorkflowsR GET !¬empty + /workflows SWIWorkflowsR GET !free /initiate SWIInitiateR GET POST !workflow /update SWIUpdateR POST /workflows SchoolWorkflowWorkflowListR GET !free diff --git a/src/Data/Scientific/Instances.hs b/src/Data/Scientific/Instances.hs index cee91482d..87b079e7e 100644 --- a/src/Data/Scientific/Instances.hs +++ b/src/Data/Scientific/Instances.hs @@ -9,7 +9,17 @@ import Data.Scientific import Web.PathPieces +import Text.ParserCombinators.ReadP (readP_to_S) + +import Control.Monad.Fail + instance PathPiece Scientific where toPathPiece = pack . formatScientific Fixed Nothing - fromPathPiece = readFromPathPiece + + fromPathPiece = disambiguate . readP_to_S scientificP . unpack + where + disambiguate strs = case filter (\(_, rStr) -> null rStr) strs of + [(x, _)] -> pure x + _other -> fail "fromPathPiece Scientific: Ambiguous parse" + diff --git a/src/Data/Word/Word24/Instances.hs b/src/Data/Word/Word24/Instances.hs index e1d6add1a..b80cdc620 100644 --- a/src/Data/Word/Word24/Instances.hs +++ b/src/Data/Word/Word24/Instances.hs @@ -12,6 +12,8 @@ import System.Random (Random(..)) import Data.Aeson (FromJSON(..), ToJSON(..)) import qualified Data.Aeson.Types as Aeson +import Web.PathPieces + import Data.Word.Word24 import Control.Lens @@ -19,6 +21,7 @@ import Control.Lens import Control.Monad.Fail import qualified Data.Scientific as Scientific +import Data.Scientific.Instances () import Data.Binary import Data.Bits @@ -51,6 +54,10 @@ instance FromJSON Word24 where instance ToJSON Word24 where toJSON = Aeson.Number . fromIntegral +instance PathPiece Word24 where + toPathPiece p = toPathPiece (fromIntegral p :: Word32) + fromPathPiece = Scientific.toBoundedInteger <=< fromPathPiece + -- | Big Endian instance Binary Word24 where diff --git a/src/Foundation/I18n.hs b/src/Foundation/I18n.hs index 9dc051554..fa357c6f8 100644 --- a/src/Foundation/I18n.hs +++ b/src/Foundation/I18n.hs @@ -308,6 +308,8 @@ embedRenderMessageVariant ''UniWorX ''ADInvalidCredentials ("InvalidCredentials" embedRenderMessage ''UniWorX ''SchoolAuthorshipStatementMode id embedRenderMessage ''UniWorX ''SheetAuthorshipStatementMode id +embedRenderMessage ''UniWorX ''AuthorshipStatementSubmissionState $ concat . ("SubmissionAuthorshipStatementState" :) . drop 1 . splitCamel + newtype ShortSex = ShortSex Sex embedRenderMessageVariant ''UniWorX ''ShortSex ("Short" <>) @@ -424,7 +426,6 @@ instance ToMessage Natural where toMessage = tshow instance ToMessage Word64 where toMessage = tshow - instance HasResolution a => ToMessage (Fixed a) where toMessage = toMessage . showFixed True diff --git a/src/Foundation/Navigation.hs b/src/Foundation/Navigation.hs index b4fdaec16..491b4766e 100644 --- a/src/Foundation/Navigation.hs +++ b/src/Foundation/Navigation.hs @@ -579,8 +579,8 @@ navLinkAccess NavLink{..} = case navAccess' of defaultLinks :: ( MonadHandler m , HandlerSite m ~ UniWorX - , MonadThrow m - , WithRunDB SqlReadBackend (HandlerFor UniWorX) m + -- , MonadThrow m + -- , WithRunDB SqlReadBackend (HandlerFor UniWorX) m , BearerAuthSite UniWorX ) => m [Nav] defaultLinks = fmap catMaybes . mapM runMaybeT $ -- Define the menu items of the header. @@ -775,12 +775,14 @@ defaultLinks = fmap catMaybes . mapM runMaybeT $ -- Define the menu items of the , do guardVolatile clusterVolatileWorkflowsEnabled - authCtx <- getAuthContext - (haveInstances, haveWorkflows) <- lift . memcachedBy (Just . Right $ 2 * diffMinute) (NavCacheHaveTopWorkflowsInstances authCtx) . useRunDB $ (,) - <$> haveTopWorkflowInstances - <*> haveTopWorkflowWorkflows + -- authCtx <- getAuthContext + -- (haveInstances, haveWorkflows) <- lift . memcachedBy (Just . Right $ 2 * diffMinute) (NavCacheHaveTopWorkflowsInstances authCtx) . useRunDB $ (,) + -- <$> haveTopWorkflowInstances + -- <*> haveTopWorkflowWorkflows - if | haveInstances -> return NavHeader + mUserId <- maybeAuthId + -- if | haveInstances -> return NavHeader + if | isJust mUserId -> return NavHeader { navHeaderRole = NavHeaderPrimary , navIcon = IconMenuWorkflows , navLink = NavLink @@ -792,18 +794,18 @@ defaultLinks = fmap catMaybes . mapM runMaybeT $ -- Define the menu items of the , navForceActive = False } } - | haveWorkflows -> return NavHeader - { navHeaderRole = NavHeaderPrimary - , navIcon = IconMenuWorkflows - , navLink = NavLink - { navLabel = MsgMenuTopWorkflowWorkflowListHeader - , navRoute = TopWorkflowWorkflowListR - , navAccess' = NavAccessTrue - , navType = NavTypeLink { navModal = False } - , navQuick' = mempty - , navForceActive = False - } - } + -- | haveWorkflows -> return NavHeader + -- { navHeaderRole = NavHeaderPrimary + -- , navIcon = IconMenuWorkflows + -- , navLink = NavLink + -- { navLabel = MsgMenuTopWorkflowWorkflowListHeader + -- , navRoute = TopWorkflowWorkflowListR + -- , navAccess' = NavAccessTrue + -- , navType = NavTypeLink { navModal = False } + -- , navQuick' = mempty + -- , navForceActive = False + -- } + -- } | otherwise -> mzero , return NavHeaderContainer { navHeaderRole = NavHeaderPrimary @@ -2757,34 +2759,35 @@ haveWorkflowWorkflows rScope = hoist liftHandler . withReaderT (projectBackend @ lift $ anyM roles evalRole -haveTopWorkflowInstances, haveTopWorkflowWorkflows +-- haveTopWorkflowInstances, +haveTopWorkflowWorkflows :: ( MonadHandler m, HandlerSite m ~ UniWorX , BackendCompatible SqlReadBackend backend , BearerAuthSite UniWorX ) => ReaderT backend m Bool -haveTopWorkflowInstances = hoist liftHandler . withReaderT (projectBackend @SqlReadBackend) . $cachedHere . maybeT (return False) $ do - roles <- memcachedBy @(Set ((RouteWorkflowScope, WorkflowInstanceName), WorkflowRole UserId)) (Just $ Right diffDay) NavCacheHaveTopWorkflowInstancesRoles $ do - let - getInstances = E.selectSource . E.from $ \workflowInstance -> do - E.where_ . isTopWorkflowScopeSql $ workflowInstance E.^. WorkflowInstanceScope - return workflowInstance - instanceRoles (Entity _ WorkflowInstance{..}) = do - rScope <- toRouteWorkflowScope $ _DBWorkflowScope # workflowInstanceScope - wiGraph <- lift $ getSharedIdWorkflowGraph workflowInstanceGraph - return . Set.mapMonotonic ((rScope, workflowInstanceName), ) . fold $ do - WGN{..} <- wiGraph ^.. _wgNodes . folded - WorkflowGraphEdgeInitial{..} <- wgnEdges ^.. folded - return wgeActors - runConduit $ transPipe lift getInstances .| C.foldMapM instanceRoles - - let - evalRole :: _ -> ReaderT SqlReadBackend (HandlerFor UniWorX) Bool - evalRole ((rScope, win), role) = do - let route = _WorkflowScopeRoute # (rScope, WorkflowInstanceR win WIInitiateR) - is _Authorized <$> hasWorkflowRole Nothing role route False - - lift $ anyM roles evalRole +-- haveTopWorkflowInstances = hoist liftHandler . withReaderT (projectBackend @SqlReadBackend) . $cachedHere . maybeT (return False) $ do +-- roles <- memcachedBy @(Set ((RouteWorkflowScope, WorkflowInstanceName), WorkflowRole UserId)) (Just $ Right diffDay) NavCacheHaveTopWorkflowInstancesRoles $ do +-- let +-- getInstances = E.selectSource . E.from $ \workflowInstance -> do +-- E.where_ . isTopWorkflowScopeSql $ workflowInstance E.^. WorkflowInstanceScope +-- return workflowInstance +-- instanceRoles (Entity _ WorkflowInstance{..}) = do +-- rScope <- toRouteWorkflowScope $ _DBWorkflowScope # workflowInstanceScope +-- wiGraph <- lift $ getSharedIdWorkflowGraph workflowInstanceGraph +-- return . Set.mapMonotonic ((rScope, workflowInstanceName), ) . fold $ do +-- WGN{..} <- wiGraph ^.. _wgNodes . folded +-- WorkflowGraphEdgeInitial{..} <- wgnEdges ^.. folded +-- return wgeActors +-- runConduit $ transPipe lift getInstances .| C.foldMapM instanceRoles +-- +-- let +-- evalRole :: _ -> ReaderT SqlReadBackend (HandlerFor UniWorX) Bool +-- evalRole ((rScope, win), role) = do +-- let route = _WorkflowScopeRoute # (rScope, WorkflowInstanceR win WIInitiateR) +-- is _Authorized <$> hasWorkflowRole Nothing role route False +-- +-- lift $ anyM roles evalRole haveTopWorkflowWorkflows = hoist liftHandler . withReaderT (projectBackend @SqlReadBackend) . $cachedHere . maybeT (return False) $ do roles <- memcachedBy (Just $ Right diffDay) NavCacheHaveTopWorkflowWorkflowsRoles $ do let diff --git a/src/Foundation/Yesod/StaticContent.hs b/src/Foundation/Yesod/StaticContent.hs index a60ace7ff..057c7b873 100644 --- a/src/Foundation/Yesod/StaticContent.hs +++ b/src/Foundation/Yesod/StaticContent.hs @@ -27,10 +27,10 @@ addStaticContent ext _mime content = do for ((,) <$> appWidgetMemcached <*> appWidgetMemcachedConf appSettings') $ \(mConn, WidgetMemcachedConf{ widgetMemcachedConf = MemcachedConf { memcachedExpiry }, widgetMemcachedBaseUrl }) -> do let expiry = maybe 0 ceiling memcachedExpiry touch = liftIO $ Memcached.touch expiry (encodeUtf8 $ pack fileName) mConn - add = liftIO $ Memcached.add zeroBits expiry (encodeUtf8 $ pack fileName) content mConn + addItem = liftIO $ Memcached.add zeroBits expiry (encodeUtf8 $ pack fileName) content mConn absoluteLink = unpack widgetMemcachedBaseUrl > fileName catchIf Memcached.isKeyNotFound touch . const $ - handleIf Memcached.isKeyExists (const $ return ()) add + handleIf Memcached.isKeyExists (const $ return ()) addItem return . Left $ pack absoluteLink where -- Generate a unique filename based on the content itself, this is used diff --git a/src/Handler/Course/Edit.hs b/src/Handler/Course/Edit.hs index fb426ca94..6ef1789ea 100644 --- a/src/Handler/Course/Edit.hs +++ b/src/Handler/Course/Edit.hs @@ -29,29 +29,29 @@ import qualified Data.Conduit.List as C data CourseForm = CourseForm - { cfCourseId :: Maybe CourseId - , cfName :: CourseName - , cfShort :: CourseShorthand - , cfSchool :: SchoolId - , cfTerm :: TermId - , cfDesc :: Maybe StoredMarkup - , cfLink :: Maybe URI - , cfVisFrom :: Maybe UTCTime - , cfVisTo :: Maybe UTCTime - , cfMatFree :: Bool - , cfAllocation :: Maybe AllocationCourseForm + { cfCourseId :: Maybe CourseId + , cfName :: CourseName + , cfShort :: CourseShorthand + , cfSchool :: SchoolId + , cfTerm :: TermId + , cfDesc :: Maybe StoredMarkup + , cfLink :: Maybe URI + , cfVisFrom :: Maybe UTCTime + , cfVisTo :: Maybe UTCTime + , cfMatFree :: Bool + , cfAllocation :: Maybe AllocationCourseForm , cfAppRequired :: Bool , cfAppInstructions :: Maybe StoredMarkup , cfAppInstructionFiles :: Maybe FileUploads , cfAppText :: Bool , cfAppFiles :: UploadMode , cfAppRatingsVisible :: Bool - , cfCapacity :: Maybe Int - , cfSecret :: Maybe Text - , cfRegFrom :: Maybe UTCTime - , cfRegTo :: Maybe UTCTime - , cfDeRegUntil :: Maybe UTCTime - , cfLecturers :: [Either (UserEmail, Maybe LecturerType) (UserId, LecturerType)] + , cfCapacity :: Maybe Int + , cfSecret :: Maybe Text + , cfRegFrom :: Maybe UTCTime + , cfRegTo :: Maybe UTCTime + , cfDeRegUntil :: Maybe UTCTime + , cfLecturers :: [Either (UserEmail, Maybe LecturerType) (UserId, LecturerType)] } data AllocationCourseForm = AllocationCourseForm @@ -278,30 +278,28 @@ makeCourseForm miButtonAction template = identifyForm FIDcourse . validateFormDB hoist (censorM $ traverseOf _head addTip) $ optionalActionW' (bool mforcedJust mpopt mayChange) allocationForm' (fslI MsgCourseAllocationParticipate) (is _Just . cfAllocation <$> template) - -- let autoUnzipInfo = [|Entpackt hochgeladene Zip-Dateien (*.zip) automatisch und fügt den Inhalt dem Stamm-Verzeichnis der Abgabe hinzu. TODO|] - multipleSchoolsMsg <- messageI Warning MsgCourseSchoolMultipleTip multipleTermsMsg <- messageI Warning MsgCourseSemesterMultipleTip (result, widget) <- flip (renderAForm FormStandard) html $ CourseForm (cfCourseId =<< template) - <$> areq (textField & cfStrip & cfCI) (fslI MsgCourseName) (cfName <$> template) + <$> areq (textField & cfStrip & cfCI) (fslI MsgCourseName) (cfName <$> template) <*> areq (textField & cfStrip & cfCI) (fslpI MsgCourseShorthand "ProMo, LinAlg1, AlgoDat, Ana2, EiP, …" -- & addAttr "disabled" "disabled" - & setTooltip MsgCourseShorthandUnique) (cfShort <$> template) + & setTooltip MsgCourseShorthandUnique) (cfShort <$> template) <* bool (pure ()) (aformMessage multipleSchoolsMsg) (length userSchools > 1) - <*> areq (schoolFieldFor userSchools) (fslI MsgCourseSchool) (cfSchool <$> template) + <*> areq (schoolFieldFor userSchools) (fslI MsgCourseSchool) (cfSchool <$> template) <* bool (pure ()) (aformMessage multipleTermsMsg) (length userTerms > 1) - <*> areq termsField (fslI MsgCourseSemester) (cfTerm <$> template) + <*> areq termsField (fslI MsgCourseSemester) (cfTerm <$> template) <*> aopt htmlField (fslpI MsgCourseDescription (mr MsgCourseDescriptionPlaceholder)) - (cfDesc <$> template) + (cfDesc <$> template) <*> aopt urlField (fslpI MsgCourseHomepageExternal (mr MsgCourseHomepageExternalPlaceholder)) - (cfLink <$> template) + (cfLink <$> template) <*> aopt utcTimeField (fslpI MsgCourseVisibleFrom (mr MsgCourseDate) - & setTooltip MsgCourseVisibleFromTip) (deepAlt (cfVisFrom <$> template) newVisFrom) + & setTooltip MsgCourseVisibleFromTip) (deepAlt (cfVisFrom <$> template) newVisFrom) <*> aopt utcTimeField (fslpI MsgCourseVisibleTo (mr MsgCourseDate) - & setTooltip MsgCourseVisibleToTip) (cfVisTo <$> template) - <*> apopt checkBoxField (fslI MsgCourseMaterialFree) (cfMatFree <$> template) + & setTooltip MsgCourseVisibleToTip) (cfVisTo <$> template) + <*> apopt checkBoxField (fslI MsgCourseMaterialFree) (cfMatFree <$> template) <* aformSection MsgCourseFormSectionRegistration <*> allocationForm <*> apopt checkBoxField (fslI MsgCourseApplicationRequired & setTooltip MsgCourseApplicationRequiredTip) (cfAppRequired <$> template) diff --git a/src/Handler/Course/Show.hs b/src/Handler/Course/Show.hs index fab484c0b..1f25a0b29 100644 --- a/src/Handler/Course/Show.hs +++ b/src/Handler/Course/Show.hs @@ -30,7 +30,7 @@ getCShowR :: TermId -> SchoolId -> CourseShorthand -> Handler Html getCShowR tid ssh csh = do mbAid <- maybeAuthId now <- liftIO getCurrentTime - (cid,course,courseVisible,schoolName,participants,registration,lecturers,assistants,correctors,tutors,mAllocation,mApplicationTemplate,mApplication,news,events,submissionGroup,hasAllocationRegistrationOpen,mayReRegister,(mayViewSheets, mayViewAnySheet), (mayViewMaterials, mayViewAnyMaterial)) <- runDB . maybeT notFound $ do + (cid,course,courseVisible,schoolName,participants,registration,lecturers,assistants,administrators,correctors,tutors,mAllocation,mApplicationTemplate,mApplication,news,events,submissionGroup,hasAllocationRegistrationOpen,mayReRegister,(mayViewSheets, mayViewAnySheet), (mayViewMaterials, mayViewAnyMaterial)) <- runDB . maybeT notFound $ do [(E.Entity cid course, E.Value courseVisible, E.Value schoolName, E.Value participants, fmap entityVal -> registration, E.Value hasAllocationRegistrationOpen)] <- lift . E.select . E.from $ \((school `E.InnerJoin` course) `E.LeftOuterJoin` allocation `E.LeftOuterJoin` participant) -> do @@ -62,10 +62,10 @@ getCShowR tid ssh csh = do E.orderBy [ E.asc $ user E.^. UserSurname, E.asc $ user E.^. UserDisplayName ] return ( lecturer E.^. LecturerType , user E.^. UserDisplayEmail, user E.^. UserDisplayName, user E.^. UserSurname) - let partStaff :: (LecturerType, UserEmail, Text, Text) -> Either (UserEmail, Text, Text) (UserEmail, Text, Text) - partStaff (CourseLecturer ,name,surn,mail) = Right (name,surn,mail) - partStaff (_courseAssistant,name,surn,mail) = Left (name,surn,mail) - (assistants,lecturers) = partitionWith partStaff $ map $(unValueN 4) staff + let + (administrators', regularStaff) = partition ((==) CourseAdministrator . view _1) $ map (\(E.Value lecType, E.Value lecName, E.Value lecSurn, E.Value lecMail) -> (lecType,(lecName,lecSurn,lecMail))) staff + (lecturers', assistants') = partition ((==) CourseLecturer . view _1) regularStaff + (administrators, lecturers, assistants) = (view _2 <$> administrators', view _2 <$> lecturers', view _2 <$> assistants') correctors <- fmap (map $(unValueN 3)) . lift . E.select $ E.from $ \(sheet `E.InnerJoin` sheetCorrector `E.InnerJoin` user) -> E.distinctOnOrderBy [E.asc $ user E.^. UserSurname, E.asc $ user E.^. UserDisplayName, E.asc $ user E.^. UserEmail ] $ do E.on $ sheetCorrector E.^. SheetCorrectorUser E.==. user E.^. UserId E.on $ sheetCorrector E.^. SheetCorrectorSheet E.==. sheet E.^. SheetId @@ -142,7 +142,7 @@ getCShowR tid ssh csh = do return $ material E.^. MaterialName mayViewAnyMaterial <- lift . anyM materials $ \(E.Value mnm) -> hasReadAccessTo $ CMaterialR tid ssh csh mnm MShowR - return (cid,course,courseVisible,schoolName,participants,registration,lecturers,assistants,correctors,tutors,mAllocation,mApplicationTemplate,mApplication,news,events,submissionGroup,hasAllocationRegistrationOpen,mayReRegister, (mayViewSheets, mayViewAnySheet), (mayViewMaterials, mayViewAnyMaterial)) + return (cid,course,courseVisible,schoolName,participants,registration,lecturers,assistants,administrators,correctors,tutors,mAllocation,mApplicationTemplate,mApplication,news,events,submissionGroup,hasAllocationRegistrationOpen,mayReRegister, (mayViewSheets, mayViewAnySheet), (mayViewMaterials, mayViewAnyMaterial)) let mDereg' = maybe id min (allocationOverrideDeregister =<< mAllocation) <$> courseDeregisterUntil course mDereg <- traverse (formatTime SelFormatDateTime) mDereg' diff --git a/src/Handler/Course/User.hs b/src/Handler/Course/User.hs index 30ef678c2..7ef122422 100644 --- a/src/Handler/Course/User.hs +++ b/src/Handler/Course/User.hs @@ -240,10 +240,11 @@ courseUserNoteSection (Entity cid Course{..}) (Entity uid _) = do courseUserSubmissionsSection :: Entity Course -> Entity User -> MaybeT Handler Widget -courseUserSubmissionsSection (Entity cid Course{..}) (Entity uid _) = do +courseUserSubmissionsSection (Entity cid Course{..}) (Entity uid User{..}) = do guardM . lift . hasWriteAccessTo $ CourseR courseTerm courseSchool courseShorthand CCorrectionsR - let whereClause = (E.&&.) <$> courseIs cid <*> userIs uid + let whereClause :: CorrectionTableWhere + whereClause = (E.&&.) <$> courseIs cid <*> userIs uid colonnade = mconcat -- should match getSSubsR for consistent UX [ colSelect , colSheet @@ -256,18 +257,24 @@ courseUserSubmissionsSection (Entity cid Course{..}) (Entity uid _) = do , colCorrector , colAssigned ] -- Continue here - filterUI = Just $ \mPrev -> mconcat - [ prismAForm (singletonFilter "user-name-email") mPrev $ aopt textField (fslI MsgCourseCourseMembers) - , prismAForm (singletonFilter "user-matriclenumber") mPrev $ aopt textField (fslI MsgTableMatrikelNr) - -- "pseudonym" TODO DB only stores Word24 - , Map.singleton "sheet-search" . maybeToList <$> aopt textField (fslI MsgTableSheet) (Just <$> listToMaybe =<< ((Map.lookup "sheet-search" =<< mPrev) <|> (Map.lookup "sheet" =<< mPrev))) - , prismAForm (singletonFilter "corrector-name-email") mPrev $ aopt textField (fslI MsgTableCorrector) - , prismAForm (singletonFilter "isassigned" . maybePrism _PathPiece) mPrev $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgTableHasCorrector) - , prismAForm (singletonFilter "israted" . maybePrism _PathPiece) mPrev $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgTableRatingTime) - , prismAForm (singletonFilter "submission") mPrev $ aopt (lift `hoistField` textField) (fslI MsgTableSubmission) + filterUI = Just $ mconcat + [ filterUIUserNameEmail + , filterUIUserMatrikelnummer + , filterUIPseudonym + , filterUISheetSearch + , filterUICorrectorNameEmail + , filterUIIsAssigned + , filterUIIsRated + , filterUISubmission ] psValidator = def & defaultPagesize PagesizeAll -- Assisstant always want to see them all at once anyway - (cWdgt, statistics) <- lift . correctionsR' whereClause colonnade filterUI psValidator $ Map.fromList + csvSettings = Just CorrectionTableCsvSettings + { cTableCsvQualification = CorrectionTableCsvQualifySheet + , cTableCsvName = MsgCorrectionTableCsvNameCourseUserCorrections courseTerm courseSchool courseShorthand userDisplayName + , cTableCsvSheetName = MsgCorrectionTableCsvSheetNameCourseUserCorrections courseTerm courseSchool courseShorthand userDisplayName + , cTableShowCorrector = True + } + (cWdgt, statistics) <- lift . correctionsR' whereClause colonnade filterUI csvSettings psValidator $ Map.fromList [ downloadAction , assignAction (Left cid) , deleteAction diff --git a/src/Handler/Course/Users.hs b/src/Handler/Course/Users.hs index 2a12a905c..0d25a488b 100644 --- a/src/Handler/Course/Users.hs +++ b/src/Handler/Course/Users.hs @@ -197,17 +197,13 @@ instance Csv.ToNamedRecord UserTableCsv where , "email" Csv..= csvUserEmail , "study-features" Csv..= csvUserStudyFeatures , "submission-group" Csv..= csvUserSubmissionGroup - ] ++ - [ let tutsStr = Text.intercalate "; " . map CI.original $ csvUserTutorials ^. _1 - in "tutorial" Csv..= tutsStr + , "tutorial" Csv..= CsvSemicolonList (csvUserTutorials ^. _1) ] ++ [ encodeUtf8 (CI.foldedCase regGroup) Csv..= (CI.original <$> mTut) | (regGroup, mTut) <- Map.toList $ csvUserTutorials ^. _2 ] ++ - [ let examsStr = Text.intercalate "; " $ map CI.original csvUserExams - in "exams" Csv..= examsStr - ] ++ - [ "registration" Csv..= csvUserRegistration + [ "exams" Csv..= CsvSemicolonList csvUserExams + , "registration" Csv..= csvUserRegistration ] ++ [ encodeUtf8 (CI.foldedCase shn) Csv..= res | (shn, res) <- Map.toList csvUserSheets diff --git a/src/Handler/Submission/Grade.hs b/src/Handler/Submission/Grade.hs index 88b181f50..d805b574e 100644 --- a/src/Handler/Submission/Grade.hs +++ b/src/Handler/Submission/Grade.hs @@ -19,7 +19,8 @@ getCorrectionsGradeR, postCorrectionsGradeR :: Handler Html getCorrectionsGradeR = postCorrectionsGradeR postCorrectionsGradeR = do uid <- requireAuthId - let whereClause = ratedBy uid + let whereClause :: CorrectionTableWhere + whereClause = ratedBy uid displayColumns = mconcat -- should match getSSubsR for consistent UX [ -- dbRow, colSchool @@ -37,15 +38,16 @@ postCorrectionsGradeR = do , colMaxPointsField , colCommentField ] -- Continue here - filterUI = Just $ \mPrev -> mconcat - [ prismAForm (singletonFilter "course" ) mPrev $ aopt (lift `hoistField` selectField courseOptions) (fslI MsgTableCourse) - , prismAForm (singletonFilter "term" ) mPrev $ aopt (lift `hoistField` selectField termOptions) (fslI MsgTableTerm) - , prismAForm (singletonFilter "school" ) mPrev $ aopt (lift `hoistField` selectField schoolOptions) (fslI MsgTableCourseSchool) - , Map.singleton "sheet-search" . maybeToList <$> aopt (lift `hoistField` textField) (fslI MsgTableSheet) (Just <$> listToMaybe =<< ((Map.lookup "sheet-search" =<< mPrev) <|> (Map.lookup "sheet" =<< mPrev))) - , prismAForm (singletonFilter "israted" . maybePrism _PathPiece) mPrev $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgTableRatingTime) - , prismAForm (singletonFilter "rating-visible" . maybePrism _PathPiece) mPrev $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgRatingDone) - , prismAForm (singletonFilter "rating" . maybePrism _PathPiece) mPrev $ aopt (lift `hoistField` pointsField) (fslI MsgColumnRatingPoints) - , Map.singleton "comment" . maybeToList <$> aopt (lift `hoistField` textField) (fslI MsgRatingComment) (Just <$> listToMaybe =<< (Map.lookup "comment" =<< mPrev)) + filterUI = Just $ mconcat + [ filterUICourse courseOptions + , filterUITerm termOptions + , filterUISchool schoolOptions + , filterUISheetSearch + , filterUIPseudonym + , filterUIIsRated + -- , flip (prismAForm $ singletonFilter "rating-visible" . maybePrism _PathPiece) $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgRatingDone) + , filterUIRating + , filterUIComment ] courseOptions = runDB $ do courses <- selectList [] [Asc CourseShorthand] >>= filterM (\(Entity _ Course{..}) -> (== Authorized) <$> evalAccessCorrector courseTerm courseSchool courseShorthand) @@ -60,9 +62,9 @@ postCorrectionsGradeR = do & restrictAnonymous & restrictCorrector & defaultSorting [SortDescBy "ratingtime"] :: PSValidator (MForm (HandlerFor UniWorX)) (FormResult (DBFormResult SubmissionId (Bool, Maybe Points, Maybe Text) CorrectionTableData)) - unFormResult = getDBFormResult $ \DBRow{ dbrOutput = (Entity _ sub@Submission{..}, _, _, _, _, _, _, _) } -> (submissionRatingDone sub, submissionRatingPoints, submissionRatingComment) + unFormResult = getDBFormResult $ \(view $ resultSubmission . _entityVal -> sub@Submission{..}) -> (submissionRatingDone sub, submissionRatingPoints, submissionRatingComment) - (fmap unFormResult -> tableRes, table) <- runDB $ makeCorrectionsTable whereClause displayColumns filterUI psValidator $ def + (fmap unFormResult -> tableRes, table) <- runDB $ makeCorrectionsTable whereClause displayColumns filterUI Nothing psValidator $ def { dbParamsFormAction = Just $ SomeRoute CorrectionsGradeR } diff --git a/src/Handler/Submission/Helper.hs b/src/Handler/Submission/Helper.hs index c78335edf..3b6521f1b 100644 --- a/src/Handler/Submission/Helper.hs +++ b/src/Handler/Submission/Helper.hs @@ -31,18 +31,6 @@ import Handler.Submission.SubmissionUserInvite import qualified Data.Conduit.Combinators as C -data AuthorshipStatementSubmissionState - = ASExists - | ASOldStatement - | ASMissing - deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable) - deriving anyclass (Universe, Finite) - -nullaryPathPiece ''AuthorshipStatementSubmissionState $ camelToPathPiece' 1 - -embedRenderMessage ''UniWorX ''AuthorshipStatementSubmissionState $ concat . ("SubmissionAuthorshipStatementState" :) . drop 1 . splitCamel - - makeSubmissionForm :: forall m. (MonadHandler m, HandlerSite m ~ UniWorX, MonadThrow m) => CourseId -> SheetId -> Maybe (Entity AuthorshipStatementDefinition) -> Maybe SubmissionId -> UploadMode -> SheetGroup -> Maybe FileUploads -> Bool -> Set (Either UserEmail UserId) -> (Markup -> MForm (ReaderT SqlBackend m) (FormResult (Maybe FileUploads, Set (Either UserEmail UserId), Maybe AuthorshipStatementDefinitionId), Widget)) @@ -606,28 +594,10 @@ submissionHelper tid ssh csh shn mcid = do subUsers <- maybeT (return []) $ do subId <- hoistMaybe msmid - let - getUserAuthorshipStatement :: UserId - -> DB AuthorshipStatementSubmissionState - getUserAuthorshipStatement uid = runConduit $ - getStmts - .| fmap toRes (execWriterC . C.mapM_ $ tell . toPoint) - where - getStmts = E.selectSource . E.from $ \authorshipStatementSubmission -> do - E.where_ $ authorshipStatementSubmission E.^. AuthorshipStatementSubmissionSubmission E.==. E.val subId - E.&&. authorshipStatementSubmission E.^. AuthorshipStatementSubmissionUser E.==. E.val uid - return authorshipStatementSubmission - toPoint :: Entity AuthorshipStatementSubmission -> Maybe Any - toPoint (Entity _ AuthorshipStatementSubmission{..}) = Just . Any $ fmap entityKey mASDefinition == Just authorshipStatementSubmissionStatement - toRes :: Maybe Any -> AuthorshipStatementSubmissionState - toRes = \case - Just (Any True) -> ASExists - Just (Any False) -> ASOldStatement - Nothing -> ASMissing lift $ buddies & bool id (maybe id (Set.insert . Right) muid) isOwner & Set.toList - & mapMOf (traverse . _Right) (\uid -> (,,) <$> (encrypt uid :: DB CryptoUUIDUser) <*> getJust uid <*> getUserAuthorshipStatement uid) + & mapMOf (traverse . _Right) (\uid -> (,,) <$> (encrypt uid :: DB CryptoUUIDUser) <*> getJust uid <*> getUserAuthorshipStatement mASDefinition subId uid) & fmap (sortOn . over _Right $ (,,,) <$> views _2 userSurname <*> views _2 userDisplayName <*> views _2 userEmail <*> view _1) subUsersVisible <- orM diff --git a/src/Handler/Submission/List.hs b/src/Handler/Submission/List.hs index 345cadd99..d9976e95c 100644 --- a/src/Handler/Submission/List.hs +++ b/src/Handler/Submission/List.hs @@ -1,3 +1,6 @@ +{-# OPTIONS_GHC -fno-warn-redundant-constraints #-} +{-# OPTIONS_GHC -fno-warn-unused-top-binds #-} + module Handler.Submission.List ( getCorrectionsR, postCorrectionsR , getCCorrectionsR, postCCorrectionsR @@ -5,10 +8,13 @@ module Handler.Submission.List , correctionsR' , restrictAnonymous, restrictCorrector , ratedBy, courseIs, sheetIs, userIs - , colTerm, colSchool, colCourse, colSheet, colCorrector, colSubmissionLink, colSelect, colSubmittors, colSMatrikel, colRating, colAssigned, colRated, colPseudonyms, colRatedField, colPointsField, colMaxPointsField, colCommentField, colLastEdit, colSGroups + , resultSubmission + , colTerm, colSchool, colCourse, colSheet, colCorrector, colSubmissionLink, colSelect, colSubmittors, colSMatrikel, colRating, colAssigned, colRated, colPseudonyms, colRatedField, colPointsField, colMaxPointsField, colCommentField, colLastEdit, colSGroups, colAuthorshipStatementState + , filterUICourse, filterUITerm, filterUISchool, filterUISheetSearch, filterUIIsRated, filterUISubmission, filterUIUserNameEmail, filterUIUserMatrikelnummer, filterUICorrectorNameEmail, filterUIIsAssigned, filterUISubmissionGroup, filterUIRating, filterUIComment, filterUIPseudonym, filterUIAuthorshipStatementState , makeCorrectionsTable - , CorrectionTableData + , CorrectionTableData, CorrectionTableWhere , ActionCorrections(..), downloadAction, deleteAction, assignAction, autoAssignAction + , CorrectionTableCsvQualification(..), CorrectionTableCsvSettings(..) ) where import Import hiding (link) @@ -18,7 +24,6 @@ import Handler.Utils.Submission import Handler.Utils.SheetType import Handler.Utils.Delete -import Data.List as List (foldr) import qualified Data.Set as Set import qualified Data.Map.Strict as Map @@ -28,7 +33,8 @@ import qualified Data.CaseInsensitive as CI import Database.Esqueleto.Utils.TH import qualified Database.Esqueleto.Legacy as E import qualified Database.Esqueleto.Utils as E -import qualified Database.Esqueleto.Internal.Internal as IE (From) + +import qualified Data.Conduit.Combinators as C import Text.Hamlet (ihamletFile) @@ -36,399 +42,741 @@ import Database.Persist.Sql (updateWhereCount) import Data.List (genericLength) +import qualified Data.Csv as Csv -newtype CorrectionTableFilterProj = CorrectionTableFilterProj + +data CorrectionTableFilterProj = CorrectionTableFilterProj { corrProjFilterSubmission :: Maybe (Set [CI Char]) + , corrProjFilterPseudonym :: Maybe (Set [CI Char]) + , corrProjFilterAuthorshipStatementState :: Last AuthorshipStatementSubmissionState } - + instance Default CorrectionTableFilterProj where def = CorrectionTableFilterProj { corrProjFilterSubmission = Nothing + , corrProjFilterPseudonym = Nothing + , corrProjFilterAuthorshipStatementState = Last Nothing } makeLenses_ ''CorrectionTableFilterProj -type CorrectionTableExpr = (E.SqlExpr (Entity Course) `E.InnerJoin` E.SqlExpr (Entity Sheet) `E.InnerJoin` E.SqlExpr (Entity Submission)) `E.LeftOuterJoin` E.SqlExpr (Maybe (Entity User)) -type CorrectionTableWhere = CorrectionTableExpr -> E.SqlExpr (E.Value Bool) -type CorrectionTableData = DBRow (Entity Submission, Entity Sheet, (CourseName, CourseShorthand, Key Term, Key School), Maybe (Entity User), Maybe UTCTime, Map UserId (User, Maybe Pseudonym, Maybe SubmissionGroupName), CryptoFileNameSubmission, Bool {- Access to non-anonymous submission data -}) -correctionsTableQuery :: CorrectionTableWhere -> (CorrectionTableExpr -> v) -> CorrectionTableExpr -> E.SqlQuery v -correctionsTableQuery whereClause returnStatement t@((course `E.InnerJoin` sheet `E.InnerJoin` submission) `E.LeftOuterJoin` corrector) = do - E.on $ corrector E.?. UserId E.==. submission E.^. SubmissionRatingBy - E.on $ sheet E.^. SheetId E.==. submission E.^. SubmissionSheet - E.on $ course E.^. CourseId E.==. sheet E.^. SheetCourse - E.where_ $ whereClause t - return $ returnStatement t +type CorrectionTableExpr = ( E.SqlExpr (Entity Course) + `E.InnerJoin` E.SqlExpr (Entity Sheet) + `E.InnerJoin` E.SqlExpr (Entity Submission) + ) + `E.LeftOuterJoin` E.SqlExpr (Maybe (Entity User)) +type CorrectionTableWhere = forall m. MonadReader CorrectionTableExpr m => m (E.SqlExpr (E.Value Bool)) +type CorrectionTableCourseData = (CourseName, CourseShorthand, TermId, SchoolId) +type CorrectionTableUserData = (User, Maybe Pseudonym, Maybe SubmissionGroupName, Maybe AuthorshipStatementSubmissionState) +type CorrectionTableData = DBRow ( Entity Submission + , Entity Sheet + , CorrectionTableCourseData + , Maybe (Entity User) + , Maybe UTCTime + , Map UserId CorrectionTableUserData + , CryptoFileNameSubmission + , Bool {- Access to non-anonymous submission data -} + , Maybe AuthorshipStatementSubmissionState + ) -lastEditQuery :: IE.From (E.SqlExpr (Entity SubmissionEdit)) - => E.SqlExpr (Entity Submission) -> E.SqlExpr (E.Value (Maybe UTCTime)) -lastEditQuery submission = E.subSelectMaybe $ E.from $ \edit -> do - E.where_ $ edit E.^. SubmissionEditSubmission E.==. submission E.^. SubmissionId - return $ E.max_ $ edit E.^. SubmissionEditTime -queryCourse :: CorrectionTableExpr -> E.SqlExpr (Entity Course) -queryCourse = $(sqlIJproj 3 1) . $(sqlLOJproj 2 1) +queryCourse :: Getter CorrectionTableExpr (E.SqlExpr (Entity Course)) +queryCourse = to $ $(sqlIJproj 3 1) . $(sqlLOJproj 2 1) -querySubmission :: CorrectionTableExpr -> E.SqlExpr (Entity Submission) -querySubmission = $(sqlIJproj 3 3) . $(sqlLOJproj 2 1) +querySheet :: Getter CorrectionTableExpr (E.SqlExpr (Entity Sheet)) +querySheet = to $ $(sqlIJproj 3 2) . $(sqlLOJproj 2 1) -queryCorrector :: CorrectionTableExpr -> E.SqlExpr (Maybe (Entity User)) -queryCorrector = $(sqlLOJproj 2 2) +querySubmission :: Getter CorrectionTableExpr (E.SqlExpr (Entity Submission)) +querySubmission = to $ $(sqlIJproj 3 3) . $(sqlLOJproj 2 1) + +queryCorrector :: Getter CorrectionTableExpr (E.SqlExpr (Maybe (Entity User))) +queryCorrector = to $(sqlLOJproj 2 2) + +queryLastEdit :: Getter CorrectionTableExpr (E.SqlExpr (E.Value (Maybe UTCTime))) +queryLastEdit = querySubmission . submissionLastEdit + where + submissionLastEdit = to $ \submission -> E.subSelectMaybe . E.from $ \edit -> do + E.where_ $ edit E.^. SubmissionEditSubmission E.==. submission E.^. SubmissionId + return $ E.max_ $ edit E.^. SubmissionEditTime + + +resultSubmission :: Lens' CorrectionTableData (Entity Submission) +resultSubmission = _dbrOutput . _1 + +resultSheet :: Lens' CorrectionTableData (Entity Sheet) +resultSheet = _dbrOutput . _2 + +resultCourseName :: Lens' CorrectionTableData CourseName +resultCourseName = _dbrOutput . _3 . _1 + +resultCourseShorthand :: Lens' CorrectionTableData CourseShorthand +resultCourseShorthand = _dbrOutput . _3 . _2 + +resultCourseTerm :: Lens' CorrectionTableData TermId +resultCourseTerm = _dbrOutput . _3 . _3 + +resultCourseSchool :: Lens' CorrectionTableData SchoolId +resultCourseSchool = _dbrOutput . _3 . _4 + +resultCorrector :: Traversal' CorrectionTableData (Entity User) +resultCorrector = _dbrOutput . _4 . _Just + +resultLastEdit :: Traversal' CorrectionTableData UTCTime +resultLastEdit = _dbrOutput . _5 . _Just + +resultSubmittors :: IndexedTraversal' UserId CorrectionTableData CorrectionTableUserData +resultSubmittors = _dbrOutput . _6 . itraversed + +resultUserUser :: Lens' CorrectionTableUserData User +resultUserUser = _1 + +resultUserPseudonym :: Traversal' CorrectionTableUserData Pseudonym +resultUserPseudonym = _2 . _Just + +resultUserSubmissionGroup :: Traversal' CorrectionTableUserData SubmissionGroupName +resultUserSubmissionGroup = _3 . _Just + +resultUserAuthorshipStatementState :: Traversal' CorrectionTableUserData AuthorshipStatementSubmissionState +resultUserAuthorshipStatementState = _4 . _Just + +resultCryptoID :: Lens' CorrectionTableData CryptoFileNameSubmission +resultCryptoID = _dbrOutput . _7 + +resultNonAnonymousAccess :: Lens' CorrectionTableData Bool +resultNonAnonymousAccess = _dbrOutput . _8 + +resultASState :: Lens' CorrectionTableData (Maybe AuthorshipStatementSubmissionState) +resultASState = _dbrOutput . _9 + + +data CorrectionTableCsv = CorrectionTableCsv + { csvCorrectionTerm :: Maybe TermIdentifier + , csvCorrectionSchool :: Maybe SchoolShorthand + , csvCorrectionCourse :: Maybe CourseShorthand + , csvCorrectionSheet :: Maybe SheetName + , csvCorrectionSubmission :: Maybe (CI Text) + , csvCorrectionLastEdit :: Maybe UTCTime + , csvCorrectionSurname :: Maybe [Maybe UserSurname] + , csvCorrectionFirstName :: Maybe [Maybe UserFirstName] + , csvCorrectionName :: Maybe [Maybe UserDisplayName] + , csvCorrectionMatriculation :: Maybe [Maybe UserMatriculation] + , csvCorrectionEmail :: Maybe [Maybe UserEmail] + , csvCorrectionPseudonym :: Maybe [Maybe Pseudonym] + , csvCorrectionSubmissionGroup :: Maybe [Maybe SubmissionGroupName] + , csvCorrectionAuthorshipStatementState :: Maybe [Maybe AuthorshipStatementSubmissionState] + , csvCorrectionAssigned :: Maybe UTCTime + , csvCorrectionCorrectorName :: Maybe UserDisplayName + , csvCorrectionCorrectorEmail :: Maybe UserEmail + , csvCorrectionRatingDone :: Maybe Bool + , csvCorrectionRatedAt :: Maybe UTCTime + , csvCorrectionRatingPoints :: Maybe Points + , csvCorrectionRatingComment :: Maybe Text + } deriving (Generic) +makeLenses_ ''CorrectionTableCsv + +correctionTableCsvOptions :: Csv.Options +correctionTableCsvOptions = Csv.defaultOptions { Csv.fieldLabelModifier = camelToPathPiece' 2 } + +instance Csv.ToNamedRecord CorrectionTableCsv where + toNamedRecord CorrectionTableCsv{..} = Csv.namedRecord + [ "term" Csv..= csvCorrectionTerm + , "school" Csv..= csvCorrectionSchool + , "course" Csv..= csvCorrectionCourse + , "sheet" Csv..= csvCorrectionSheet + , "submission" Csv..= csvCorrectionSubmission + , "last-edit" Csv..= csvCorrectionLastEdit + , "surname" Csv..= maybe mempty (Csv.toField . CsvSemicolonList) csvCorrectionSurname + , "first-name" Csv..= maybe mempty (Csv.toField . CsvSemicolonList) csvCorrectionFirstName + , "name" Csv..= maybe mempty (Csv.toField . CsvSemicolonList) csvCorrectionName + , "matriculation" Csv..= maybe mempty (Csv.toField . CsvSemicolonList . mkEmpty) csvCorrectionMatriculation + , "email" Csv..= maybe mempty (Csv.toField . CsvSemicolonList) csvCorrectionEmail + , "pseudonym" Csv..= maybe mempty (Csv.toField . CsvSemicolonList . mkEmpty) csvCorrectionPseudonym + , "submission-group" Csv..= maybe mempty (Csv.toField . CsvSemicolonList . mkEmpty) csvCorrectionSubmissionGroup + , "authorship-statement-state" Csv..= maybe mempty (Csv.toField . CsvSemicolonList . mkEmpty) csvCorrectionAuthorshipStatementState + , "assigned" Csv..= csvCorrectionAssigned + , "corrector-name" Csv..= csvCorrectionCorrectorName + , "corrector-email" Csv..= csvCorrectionCorrectorEmail + , "rating-done" Csv..= csvCorrectionRatingDone + , "rated-at" Csv..= csvCorrectionRatedAt + , "rating-points" Csv..= csvCorrectionRatingPoints + , "rating-comment" Csv..= csvCorrectionRatingComment + ] + where + mkEmpty = \case + [Nothing] -> [] + x -> x + +instance Csv.DefaultOrdered CorrectionTableCsv where + headerOrder = Csv.genericHeaderOrder correctionTableCsvOptions + +instance Csv.FromNamedRecord CorrectionTableCsv where + parseNamedRecord csv + = CorrectionTableCsv + <$> csv .:?? "term" + <*> csv .:?? "school" + <*> csv .:?? "course" + <*> csv .:?? "sheet" + <*> csv .:?? "submission" + <*> csv .:?? "last-edit" + <*> fmap (fmap unCsvSemicolonList) (csv .:?? "surname") + <*> fmap (fmap unCsvSemicolonList) (csv .:?? "first-name") + <*> fmap (fmap unCsvSemicolonList) (csv .:?? "name") + <*> fmap (fmap unCsvSemicolonList) (csv .:?? "matriculation") + <*> fmap (fmap unCsvSemicolonList) (csv .:?? "email") + <*> fmap (fmap unCsvSemicolonList) (csv .:?? "pseudonym") + <*> fmap (fmap unCsvSemicolonList) (csv .:?? "submission-group") + <*> fmap (fmap unCsvSemicolonList) (csv .:?? "authorship-statement-state") + <*> csv .:?? "assigned" + <*> csv .:?? "corrector-name" + <*> csv .:?? "corrector-email" + <*> csv .:?? "rating-done" + <*> csv .:?? "rated-at" + <*> csv .:?? "rating-points" + <*> csv .:?? "rating-comment" + +instance CsvColumnsExplained CorrectionTableCsv where + csvColumnsExplanations = genericCsvColumnsExplanations correctionTableCsvOptions $ Map.fromList + [ ('csvCorrectionTerm , MsgCsvColumnCorrectionTerm) + , ('csvCorrectionSchool , MsgCsvColumnCorrectionSchool) + , ('csvCorrectionCourse , MsgCsvColumnCorrectionCourse) + , ('csvCorrectionSheet , MsgCsvColumnCorrectionSheet) + , ('csvCorrectionSubmission , MsgCsvColumnCorrectionSubmission) + , ('csvCorrectionLastEdit , MsgCsvColumnCorrectionLastEdit) + , ('csvCorrectionSurname , MsgCsvColumnCorrectionSurname) + , ('csvCorrectionFirstName , MsgCsvColumnCorrectionFirstName) + , ('csvCorrectionName , MsgCsvColumnCorrectionName) + , ('csvCorrectionMatriculation , MsgCsvColumnCorrectionMatriculation) + , ('csvCorrectionEmail , MsgCsvColumnCorrectionEmail) + , ('csvCorrectionPseudonym , MsgCsvColumnCorrectionPseudonym) + , ('csvCorrectionSubmissionGroup, MsgCsvColumnCorrectionSubmissionGroup) + , ('csvCorrectionAuthorshipStatementState, MsgCsvColumnCorrectionAuthorshipStatementState) + , ('csvCorrectionAssigned , MsgCsvColumnCorrectionAssigned) + , ('csvCorrectionCorrectorName , MsgCsvColumnCorrectionCorrectorName) + , ('csvCorrectionCorrectorEmail , MsgCsvColumnCorrectionCorrectorEmail) + , ('csvCorrectionRatingDone , MsgCsvColumnCorrectionRatingDone) + , ('csvCorrectionRatedAt , MsgCsvColumnCorrectionRatedAt) + , ('csvCorrectionRatingPoints , MsgCsvColumnCorrectionRatingPoints) + , ('csvCorrectionRatingComment , MsgCsvColumnCorrectionRatingComment) + ] + +data CorrectionTableCsvQualification + = CorrectionTableCsvNoQualification + | CorrectionTableCsvQualifySheet + | CorrectionTableCsvQualifyCourse + deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable) + deriving anyclass (Universe, Finite) + +correctionTableCsvHeader :: Bool -- ^ @showCorrector@ + -> CorrectionTableCsvQualification -> Csv.Header +correctionTableCsvHeader showCorrector qual = Csv.header $ catMaybes + [ guardOn (qual >= CorrectionTableCsvQualifyCourse) "term" + , guardOn (qual >= CorrectionTableCsvQualifyCourse) "school" + , guardOn (qual >= CorrectionTableCsvQualifyCourse) "course" + , guardOn (qual >= CorrectionTableCsvQualifySheet) "sheet" + , pure "submission" + , pure "last-edit" + , pure "surname" + , pure "first-name" + , pure "name" + , pure "matriculation" + , pure "email" + , pure "pseudonym" + , pure "submission-group" + , pure "authorship-statement-state" + , pure "assigned" + , guardOn showCorrector "corrector-name" + , guardOn showCorrector "corrector-email" + , pure "rating-done" + , pure "rated-at" + , pure "rating-points" + , pure "rating-comment" + ] + +data CorrectionTableCsvSettings = forall filename sheetName. + ( RenderMessage UniWorX filename, RenderMessage UniWorX sheetName + ) => CorrectionTableCsvSettings + { cTableCsvQualification :: CorrectionTableCsvQualification + , cTableCsvName :: filename + , cTableCsvSheetName :: sheetName + , cTableShowCorrector :: Bool + } + +newtype CorrectionTableCsvExportData = CorrectionTableCsvExportData + { csvCorrectionSingleSubmittors :: Bool + } deriving (Eq, Ord, Read, Show, Generic, Typeable) +instance Default CorrectionTableCsvExportData where + def = CorrectionTableCsvExportData False + +data CorrectionTableJson = CorrectionTableJson + { jsonCorrectionTerm :: TermIdentifier + , jsonCorrectionSchool :: SchoolShorthand + , jsonCorrectionCourse :: CourseShorthand + , jsonCorrectionSheet :: SheetName + , jsonCorrectionLastEdit :: Maybe UTCTime + , jsonCorrectionSubmittors :: Maybe [CorrectionTableSubmittorJson] + , jsonCorrectionAssigned :: Maybe UTCTime + , jsonCorrectionCorrectorName :: Maybe UserDisplayName + , jsonCorrectionCorrectorEmail :: Maybe UserEmail + , jsonCorrectionRatingDone :: Bool + , jsonCorrectionRatedAt :: Maybe UTCTime + , jsonCorrectionRatingPoints :: Maybe Points + , jsonCorrectionRatingComment :: Maybe Text + } deriving (Generic) + +data CorrectionTableSubmittorJson = CorrectionTableSubmittorJson + { jsonCorrectionSurname :: UserSurname + , jsonCorrectionFirstName :: UserFirstName + , jsonCorrectionName :: UserDisplayName + , jsonCorrectionMatriculation :: Maybe UserMatriculation + , jsonCorrectionEmail :: UserEmail + , jsonCorrectionPseudonym :: Maybe Pseudonym + , jsonCorrectionSubmissionGroup :: Maybe SubmissionGroupName + , jsonCorrectionAuthorshipStatementState :: Maybe AuthorshipStatementSubmissionState + } deriving (Generic) + +deriveToJSON defaultOptions + { fieldLabelModifier = camelToPathPiece' 2 + } ''CorrectionTableSubmittorJson + +deriveToJSON defaultOptions + { fieldLabelModifier = camelToPathPiece' 2 + } ''CorrectionTableJson -- Where Clauses ratedBy :: UserId -> CorrectionTableWhere -ratedBy uid ((_course `E.InnerJoin` _sheet `E.InnerJoin` submission) `E.LeftOuterJoin` _corrector) = submission E.^. SubmissionRatingBy E.==. E.just (E.val uid) +ratedBy uid = views querySubmission $ (E.==. E.justVal uid) . (E.^. SubmissionRatingBy) courseIs :: CourseId -> CorrectionTableWhere -courseIs cid (( course `E.InnerJoin` _sheet `E.InnerJoin` _submission) `E.LeftOuterJoin` _corrector) = course E.^. CourseId E.==. E.val cid +courseIs cid = views queryCourse $ (E.==. E.val cid) . (E.^. CourseId) sheetIs :: Key Sheet -> CorrectionTableWhere -sheetIs shid ((_course `E.InnerJoin` sheet `E.InnerJoin` _submission) `E.LeftOuterJoin` _corrector) = sheet E.^. SheetId E.==. E.val shid +sheetIs shid = views querySheet $ (E.==. E.val shid) . (E.^. SheetId) userIs :: Key User -> CorrectionTableWhere -userIs uid ((_course `E.InnerJoin` _sheet `E.InnerJoin` submission) `E.LeftOuterJoin` _corrector) = E.exists . E.from $ \submissionUser -> +userIs uid = views querySubmission $ \submission -> E.exists . E.from $ \submissionUser -> E.where_ $ submissionUser E.^. SubmissionUserSubmission E.==. submission E.^. SubmissionId E.&&. submissionUser E.^. SubmissionUserUser E.==. E.val uid + -- Columns colTerm :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) -colTerm = sortable (Just "term") (i18nCell MsgTableTerm) - $ \DBRow{ dbrOutput } -> - textCell $ termToText $ unTermKey $ dbrOutput ^. _3 . _3 -- kurze Semsterkürzel +colTerm = sortable (Just "term") (i18nCell MsgTableTerm) . views (resultCourseTerm . _TermId) $ textCell . termToText colSchool :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) -colSchool = sortable (Just "school") (i18nCell MsgTableCourseSchool) - $ \DBRow{ dbrOutput } -> let course = dbrOutput ^. _3 in - anchorCell (TermSchoolCourseListR (course ^. _3) (course ^. _4)) [whamlet|#{unSchoolKey (course ^. _4)}|] +colSchool = sortable (Just "school") (i18nCell MsgTableCourseSchool) $ \x -> + let tid = x ^. resultCourseTerm + ssh = x ^. resultCourseSchool + in anchorCell (TermSchoolCourseListR tid ssh) + (ssh ^. _SchoolId) colCourse :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) -colCourse = sortable (Just "course") (i18nCell MsgTableCourse) - $ \DBRow{ dbrOutput=(_, _, (_,csh,tid,sid),_ , _, _, _, _) } -> courseCellCL (tid,sid,csh) +colCourse = sortable (Just "course") (i18nCell MsgTableCourse) $ views ($(multifocusG 3) resultCourseTerm resultCourseSchool resultCourseShorthand) courseCellCL colSheet :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) -colSheet = sortable (Just "sheet") (i18nCell MsgTableSheet) $ \row -> - let sheet = row ^. _dbrOutput . _2 - course= row ^. _dbrOutput . _3 - tid = course ^. _3 - ssh = course ^. _4 - csh = course ^. _2 - shn = sheetName $ entityVal sheet - in anchorCell (CSheetR tid ssh csh shn SShowR) [whamlet|_{shn}|] +colSheet = sortable (Just "sheet") (i18nCell MsgTableSheet) $ \x -> + let tid = x ^. resultCourseTerm + ssh = x ^. resultCourseSchool + csh = x ^. resultCourseShorthand + shn = x ^. resultSheet . _entityVal . _sheetName + in anchorCell (CSheetR tid ssh csh shn SShowR) shn colCorrector :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) -colCorrector = sortable (Just "corrector") (i18nCell MsgTableCorrector) $ \case - DBRow{ dbrOutput = (_, _, _, Nothing , _, _, _, _) } -> cell mempty - DBRow{ dbrOutput = (_, _, _, Just (Entity _ User{..}), _, _, _, _) } -> userCell userDisplayName userSurname +colCorrector = sortable (Just "corrector") (i18nCell MsgTableCorrector) $ \x -> + maybeCell (x ^? resultCorrector) $ \(Entity _ User{..}) -> userCell userDisplayName userSurname colSubmissionLink :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) -colSubmissionLink = sortable (Just "submission") (i18nCell MsgTableSubmission) - $ \DBRow{ dbrOutput=(_, sheet, course, _, _,_, cid, _) } -> - let csh = course ^. _2 - tid = course ^. _3 - ssh = course ^. _4 - shn = sheetName $ entityVal sheet - in anchorCellC $cacheIdentHere (CSubmissionR tid ssh csh shn cid SubShowR) (toPathPiece cid) +colSubmissionLink = sortable (Just "submission") (i18nCell MsgTableSubmission) $ \x -> + let tid = x ^. resultCourseTerm + ssh = x ^. resultCourseSchool + csh = x ^. resultCourseShorthand + shn = x ^. resultSheet . _entityVal . _sheetName + subCID = x ^. resultCryptoID + in anchorCellC $cacheIdentHere (CSubmissionR tid ssh csh shn subCID SubShowR) (toPathPiece subCID) colSelect :: forall act h epId. (Semigroup act, Monoid act, Headedness h, Ord epId) => Colonnade h CorrectionTableData (DBCell _ (FormResult (act, DBFormResult CryptoFileNameSubmission Bool CorrectionTableData), SheetTypeSummary epId)) -colSelect = dbSelect (_1 . applying _2) id $ \DBRow{ dbrOutput=(_, _, _, _, _, _, cid, _) } -> return cid +colSelect = dbSelect (_1 . applying _2) id $ views resultCryptoID return + colSubmittors :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) -colSubmittors = sortable (Just "submittors") (i18nCell MsgSubmissionUsers) $ \DBRow{ dbrOutput=(_, _, course, _, _, users, _, hasAccess) } -> - let - csh = course ^. _2 - tid = course ^. _3 - ssh = course ^. _4 - link cid = CourseR tid ssh csh $ CUserR cid - protoCell = listCell (Map.toList users) $ \(userId, (User{..}, mPseudo, _)) -> - anchorCellCM $cacheIdentHere (link <$> encrypt userId) $ case mPseudo of - Nothing -> nameWidget userDisplayName userSurname - Just p -> [whamlet|^{nameWidget userDisplayName userSurname} (#{review _PseudonymText p})|] - in if | hasAccess -> protoCell & cellAttrs <>~ [("class", "list--inline list--comma-separated")] - | otherwise -> mempty +colSubmittors = sortable (Just "submittors") (i18nCell MsgSubmissionUsers) $ \x -> + let tid = x ^. resultCourseTerm + ssh = x ^. resultCourseSchool + csh = x ^. resultCourseShorthand + link uCID = CourseR tid ssh csh $ CUserR uCID + protoCell = listCell (sortOn (view $ _2 . resultUserUser . $(multifocusG 2) _userSurname _userDisplayName) $ itoListOf resultSubmittors x) $ \(encrypt -> mkUCID, u) -> + let User{..} = u ^. resultUserUser + mPseudo = u ^? resultUserPseudonym + in anchorCellCM $cacheIdentHere (link <$> mkUCID) + [whamlet| + $newline never + ^{nameWidget userDisplayName userSurname} + $maybe p <- mPseudo + \ (#{review _PseudonymText p}) + |] + in guardMonoid (x ^. resultNonAnonymousAccess) $ + protoCell & cellAttrs <>~ [("class", "list--inline list--comma-separated")] colSMatrikel :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) -colSMatrikel = sortable (Just "submittors-matriculation") (i18nCell MsgTableMatrikelNr) $ \DBRow{ dbrOutput=(_, _, (_, csh, tid, ssh), _, _, users, _, hasAccess) } -> - let protoCell = listCell (Map.toList $ Map.mapMaybe (\x@(User{userMatrikelnummer}, _, _) -> (x,) <$> assertM (not . null) userMatrikelnummer) users) $ \(userId, (_, matr)) -> anchorCellCM $cacheIdentHere (CourseR tid ssh csh . CUserR <$> encrypt userId) matr - in if | hasAccess -> protoCell & cellAttrs <>~ [("class", "list--inline list--comma-separated")] - | otherwise -> mempty +colSMatrikel = sortable (Just "submittors-matriculation") (i18nCell MsgTableMatrikelNr) $ \x -> + let protoCell = listCell (sort $ x ^.. resultSubmittors . resultUserUser . _userMatrikelnummer . _Just) wgtCell + in guardMonoid (x ^. resultNonAnonymousAccess) $ + protoCell & cellAttrs <>~ [("class", "list--inline list--comma-separated")] colSGroups :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) -colSGroups = sortable (Just "submittors-group") (i18nCell MsgTableSubmissionGroup) $ \DBRow{ dbrOutput=(_, Entity _ Sheet{..}, _, _, _, users, _, hasAccess) } -> - let protoCell = listCell (nubOrdOn (view _2) . Map.toList $ Map.mapMaybe (view _3) users) $ \(_, sGroup) -> cell $ toWidget sGroup - in if | hasAccess - , is _RegisteredGroups sheetGrouping - -> protoCell & cellAttrs <>~ [("class", "list--inline list--comma-separated")] - | otherwise - -> mempty +colSGroups = sortable (Just "submittors-group") (i18nCell MsgTableSubmissionGroup) $ \x -> + let protoCell = listCell (setOf (resultSubmittors . resultUserSubmissionGroup) x) wgtCell + in guardMonoid (x ^. resultNonAnonymousAccess) $ + protoCell & cellAttrs <>~ [("class", "list--inline list--comma-separated")] -colRating :: forall m a. IsDBTable m (a, SheetTypeSummary SqlBackendKey) => Colonnade Sortable CorrectionTableData (DBCell m (a, SheetTypeSummary SqlBackendKey)) -colRating = sortable (Just "rating") (i18nCell MsgTableRating) $ \DBRow{ dbrOutput=(Entity subId sub@Submission{..}, Entity _ Sheet{..}, course, _, _, _, _, _) } -> - let csh = course ^. _2 - tid = course ^. _3 - ssh = course ^. _4 - -- shn = sheetName +colRating :: forall m a a'. (IsDBTable m a, a ~ (a', SheetTypeSummary SqlBackendKey)) => Colonnade Sortable CorrectionTableData (DBCell m a) +colRating = colRating' _2 - mkRoute = do - cid <- encrypt subId - return $ CSubmissionR tid ssh csh sheetName cid CorrectionR - in mconcat - [ anchorCellCM $cacheIdentHere mkRoute $(widgetFile "widgets/rating/rating") - , writerCell $ do - let - summary :: SheetTypeSummary SqlBackendKey - summary = sheetTypeSum sheetType $ submissionRatingPoints <* guard (submissionRatingDone sub) - scribe (_2 :: Lens' (a, SheetTypeSummary SqlBackendKey) (SheetTypeSummary SqlBackendKey)) summary - ] +colRating' :: forall m a. IsDBTable m a => ASetter' a (SheetTypeSummary SqlBackendKey) -> Colonnade Sortable CorrectionTableData (DBCell m a) +colRating' l = sortable (Just "rating") (i18nCell MsgTableRating) $ \x -> + let tid = x ^. resultCourseTerm + ssh = x ^. resultCourseSchool + csh = x ^. resultCourseShorthand + shn = x ^. resultSheet . _entityVal . _sheetName + cID = x ^. resultCryptoID + sub@Submission{..} = x ^. resultSubmission . _entityVal + Sheet{..} = x ^. resultSheet . _entityVal + + mkRoute = return $ CSubmissionR tid ssh csh shn cID CorrectionR + in mconcat + [ anchorCellCM $cacheIdentHere mkRoute $(widgetFile "widgets/rating/rating") + , writerCell $ do + let summary :: SheetTypeSummary SqlBackendKey + summary = sheetTypeSum sheetType $ submissionRatingPoints <* guard (submissionRatingDone sub) + scribe l summary + ] colAssigned :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) -colAssigned = sortable (Just "assignedtime") (i18nCell MsgAssignedTime) $ \DBRow{ dbrOutput=(Entity _subId Submission{..}, _sheet, _course, _, _, _, _, _) } -> - maybe mempty dateTimeCell submissionRatingAssigned +colAssigned = sortable (Just "assignedtime") (i18nCell MsgAssignedTime) $ \x -> maybeCell (x ^? resultSubmission . _entityVal . _submissionRatingAssigned . _Just) dateTimeCell colRated :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) -colRated = sortable (Just "ratingtime") (i18nCell MsgTableRatingTime) $ \DBRow{ dbrOutput=(Entity _subId Submission{..}, _sheet, _course, _, _, _, _, _) } -> - maybe mempty dateTimeCell submissionRatingTime +colRated = sortable (Just "ratingtime") (i18nCell MsgTableRatingTime) $ \x -> maybeCell (x ^? resultSubmission . _entityVal . _submissionRatingTime . _Just) dateTimeCell colPseudonyms :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) -colPseudonyms = sortable Nothing (i18nCell MsgPseudonyms) $ \DBRow{ dbrOutput=(_, _, _, _, _, users, _, _) } -> let - lCell = listCell (catMaybes $ view (_2 . _2) <$> Map.toList users) $ \pseudo -> - cell [whamlet|#{review _PseudonymText pseudo}|] - in lCell & cellAttrs <>~ [("class", "list--inline list--comma-separated")] +colPseudonyms = sortable Nothing (i18nCell MsgPseudonyms) $ \x -> + let protoCell = listCell (sort $ x ^.. resultSubmittors . resultUserPseudonym . re _PseudonymText) wgtCell + in protoCell & cellAttrs <>~ [("class", "list--inline list--comma-separated")] -colRatedField :: Colonnade Sortable CorrectionTableData (DBCell _ (FormResult (DBFormResult SubmissionId (Bool, a, b) CorrectionTableData))) -colRatedField = sortable Nothing (i18nCell MsgRatingDone) $ formCell id - (\DBRow{ dbrOutput=(Entity subId _, _, _, _, _, _, _, _) } -> return subId) - (\DBRow{ dbrOutput=(Entity _ (submissionRatingDone -> done), _, _, _, _, _, _, _) } mkUnique -> over (_1.mapped) (_1 .~) . over _2 fvWidget <$> mreq checkBoxField (fsUniq mkUnique "rated") (Just done)) +colRatedField :: a' ~ (Bool, a, b) => Colonnade Sortable CorrectionTableData (DBCell _ (FormResult (DBFormResult SubmissionId a' CorrectionTableData))) +colRatedField = colRatedField' _1 -colPointsField :: Colonnade Sortable CorrectionTableData (DBCell _ (FormResult (DBFormResult SubmissionId (a, Maybe Points, b) CorrectionTableData))) -colPointsField = sortable (Just "rating") (i18nCell MsgColumnRatingPoints) $ formCell id - (\DBRow{ dbrOutput=(Entity subId _, _, _, _, _, _, _, _) } -> return subId) - (\DBRow{ dbrOutput=(Entity _ Submission{..}, Entity _ Sheet{..}, _, _, _, _, _, _) } mkUnique -> case sheetType of - NotGraded -> pure $ over (_1.mapped) (_2 .~) (FormSuccess Nothing, mempty) - _other -> over (_1.mapped) (_2 .~) . over _2 fvWidget <$> mopt (pointsFieldMax $ preview (_grading . _maxPoints) sheetType) (fsUniq mkUnique "points") (Just submissionRatingPoints) +colRatedField' :: ASetter' a Bool -> Colonnade Sortable CorrectionTableData (DBCell _ (FormResult (DBFormResult SubmissionId a CorrectionTableData))) +colRatedField' l = sortable Nothing (i18nCell MsgRatingDone) $ formCell id + (views (resultSubmission . _entityKey) return) + (\(views (resultSubmission . _entityVal) submissionRatingDone -> done) mkUnique -> over (_1.mapped) (l .~) . over _2 fvWidget <$> mreq checkBoxField (fsUniq mkUnique "rated") (Just done)) + +colPointsField :: a' ~ (a, Maybe Points, b) => Colonnade Sortable CorrectionTableData (DBCell _ (FormResult (DBFormResult SubmissionId a' CorrectionTableData))) +colPointsField = colPointsField' _2 + +colPointsField' :: ASetter' a (Maybe Points) -> Colonnade Sortable CorrectionTableData (DBCell _ (FormResult (DBFormResult SubmissionId a CorrectionTableData))) +colPointsField' l = sortable (Just "rating") (i18nCell MsgColumnRatingPoints) $ formCell id + (views (resultSubmission . _entityKey) return) + (\(view $ $(multifocusG 2) (resultSubmission . _entityVal) (resultSheet . _entityVal) -> (Submission{..}, Sheet{..})) mkUnique -> case sheetType of + NotGraded -> pure $ over (_1.mapped) (l .~) (FormSuccess Nothing, mempty) + _other -> over (_1.mapped) (l .~) . over _2 fvWidget <$> mopt (pointsFieldMax $ preview (_grading . _maxPoints) sheetType) (fsUniq mkUnique "points") (Just submissionRatingPoints) ) -colMaxPointsField :: _ => Colonnade Sortable CorrectionTableData (DBCell m (FormResult (DBFormResult SubmissionId (a, Maybe Points, b) CorrectionTableData))) -colMaxPointsField = sortable (Just "sheet-type") (i18nCell MsgTableSheetType) $ \DBRow{ dbrOutput=(_, Entity _ Sheet{sheetCourse, sheetType}, _, _, _, _, _, _) } -> cell $ do +colMaxPointsField :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) +colMaxPointsField = sortable (Just "sheet-type") (i18nCell MsgTableSheetType) $ \x -> cell $ do + let Sheet{..} = x ^. resultSheet . _entityVal sheetTypeDesc <- liftHandler . runDB $ sheetTypeDescription sheetCourse sheetType - tr <- getTranslate - toWidget $ sheetTypeDesc tr + toWidget . sheetTypeDesc =<< getTranslate -colCommentField :: Colonnade Sortable CorrectionTableData (DBCell _ (FormResult (DBFormResult SubmissionId (a, b, Maybe Text) CorrectionTableData))) -colCommentField = sortable (Just "comment") (i18nCell MsgRatingComment) $ (cellAttrs <>~ [("style","width:60%")]) <$> formCell id - (\DBRow{ dbrOutput=(Entity subId _, _, _, _, _, _, _, _) } -> return subId) - (\DBRow{ dbrOutput=(Entity _ Submission{..}, _, _, _, _, _, _, _) } mkUnique -> over (_1.mapped) ((_3 .~) . assertM (not . null) . fmap (Text.strip . unTextarea)) . over _2 fvWidget <$> mopt textareaField (fsUniq mkUnique "comment") (Just $ Textarea <$> submissionRatingComment)) +colCommentField :: a' ~ (a, b, Maybe Text) => Colonnade Sortable CorrectionTableData (DBCell _ (FormResult (DBFormResult SubmissionId a' CorrectionTableData))) +colCommentField = colCommentField' _3 + +colCommentField' :: ASetter' a (Maybe Text) -> Colonnade Sortable CorrectionTableData (DBCell _ (FormResult (DBFormResult SubmissionId a CorrectionTableData))) +colCommentField' l = sortable (Just "comment") (i18nCell MsgRatingComment) $ (cellAttrs <>~ [("style","width:60%")]) <$> formCell id + (views (resultSubmission . _entityKey) return) + (\(view (resultSubmission . _entityVal) -> Submission{..}) mkUnique -> over (_1.mapped) ((l .~) . assertM (not . null) . fmap (Text.strip . unTextarea)) . over _2 fvWidget <$> mopt textareaField (fsUniq mkUnique "comment") (Just $ Textarea <$> submissionRatingComment)) colLastEdit :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) -colLastEdit = sortable (Just "last-edit") (i18nCell MsgTableLastEdit) $ - \DBRow{ dbrOutput=(_, _, _, _, mbLastEdit, _, _, _) } -> maybe mempty dateTimeCell mbLastEdit +colLastEdit = sortable (Just "last-edit") (i18nCell MsgTableLastEdit) $ \x -> maybeCell (guardOnM (x ^. resultNonAnonymousAccess) $ x ^? resultLastEdit) dateTimeCell + +colAuthorshipStatementState :: forall m a. IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) +colAuthorshipStatementState = sortable (Just "as-state") (i18nCell MsgSubmissionUserAuthorshipStatementState) $ \x -> + let heatC :: AuthorshipStatementSubmissionState -> DBCell m a -> DBCell m a + heatC s c + = c + & cellAttrs %~ addAttrsClass "heated" + & cellAttrs <>~ pure ("style", [st|--hotness: #{tshow (boolHeat (s /= ASExists))}|]) + tid = x ^. resultCourseTerm + ssh = x ^. resultCourseSchool + csh = x ^. resultCourseShorthand + shn = x ^. resultSheet . _entityVal . _sheetName + cID = x ^. resultCryptoID + + asRoute = CSubmissionR tid ssh csh shn cID SubAuthorshipStatementsR + in maybeCell (guardOnM (x ^. resultNonAnonymousAccess) $ x ^. resultASState) (\s -> heatC s $ anchorCell asRoute (i18n s :: Widget)) + + +filterUICourse :: Handler (OptionList Text) -> DBFilterUI +filterUICourse courseOptions = flip (prismAForm $ singletonFilter "course") $ aopt (lift `hoistField` selectField courseOptions) (fslI MsgTableCourse) + +filterUITerm :: Handler (OptionList Text) -> DBFilterUI +filterUITerm termOptions = flip (prismAForm $ singletonFilter "term") $ aopt (lift `hoistField` selectField termOptions) (fslI MsgTableTerm) + +filterUISchool :: Handler (OptionList Text) -> DBFilterUI +filterUISchool schoolOptions = flip (prismAForm $ singletonFilter "school") $ aopt (lift `hoistField` selectField schoolOptions) (fslI MsgTableCourseSchool) + +filterUISheetSearch :: DBFilterUI +filterUISheetSearch mPrev = singletonMap "sheet-search" . maybeToList <$> aopt (lift `hoistField` textField) (fslI MsgTableSheet) (Just <$> listToMaybe =<< ((Map.lookup "sheet-search" =<< mPrev) <|> (Map.lookup "sheet" =<< mPrev))) + +filterUIIsRated :: DBFilterUI +filterUIIsRated = flip (prismAForm $ singletonFilter "israted" . maybePrism _PathPiece) $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgTableRatingTime) + +filterUISubmission :: DBFilterUI +filterUISubmission = flip (prismAForm $ singletonFilter "submission") $ aopt (lift `hoistField` textField) (fslI MsgTableSubmission) + +filterUIPseudonym :: DBFilterUI +filterUIPseudonym = flip (prismAForm $ singletonFilter "pseudonym") $ aopt (lift `hoistField` textField) (fslI MsgSubmissionPseudonym) + +filterUIUserNameEmail :: DBFilterUI +filterUIUserNameEmail = flip (prismAForm $ singletonFilter "user-name-email") $ aopt textField (fslI MsgTableCourseMembers) + +filterUIUserMatrikelnummer :: DBFilterUI +filterUIUserMatrikelnummer = flip (prismAForm $ singletonFilter "user-matriclenumber") $ aopt textField (fslI MsgTableMatrikelNr) + +filterUICorrectorNameEmail :: DBFilterUI +filterUICorrectorNameEmail = flip (prismAForm $ singletonFilter "corrector-name-email") $ aopt textField (fslI MsgTableCorrector) + +filterUIIsAssigned :: DBFilterUI +filterUIIsAssigned = flip (prismAForm $ singletonFilter "isassigned" . maybePrism _PathPiece) $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgTableHasCorrector) + +filterUISubmissionGroup :: DBFilterUI +filterUISubmissionGroup = flip (prismAForm $ singletonFilter "submission-group") $ aopt textField (fslI MsgTableSubmissionGroup) + +filterUIRating :: DBFilterUI +filterUIRating = flip (prismAForm $ singletonFilter "rating" . maybePrism _PathPiece) $ aopt (lift `hoistField` pointsField) (fslI MsgColumnRatingPoints) + +filterUIComment :: DBFilterUI +filterUIComment mPrev = singletonMap "comment" . maybeToList <$> aopt (lift `hoistField` textField) (fslI MsgRatingComment) (Just <$> listToMaybe =<< (Map.lookup "comment" =<< mPrev)) + +filterUIAuthorshipStatementState :: DBFilterUI +filterUIAuthorshipStatementState = flip (prismAForm $ singletonFilter "as-state" . maybePrism _PathPiece) $ aopt (selectField' (Just $ SomeMessage MsgTableNoFilter) optionsFinite :: Field _ AuthorshipStatementSubmissionState) (fslI MsgSubmissionUserAuthorshipStatementState) makeCorrectionsTable :: ( IsDBTable m x, ToSortable h, Functor h ) - => CorrectionTableWhere -> Colonnade h CorrectionTableData (DBCell m x) -> _ -> PSValidator m x -> DBParams m x -> DB (DBResult m x) -makeCorrectionsTable whereClause dbtColonnade dbtFilterUI psValidator dbtParams = do - let dbtSQLQuery :: CorrectionTableExpr -> E.SqlQuery _ - dbtSQLQuery = correctionsTableQuery whereClause - (\((course `E.InnerJoin` sheet `E.InnerJoin` submission) `E.LeftOuterJoin` corrector) -> - let crse = ( course E.^. CourseName :: E.SqlExpr (E.Value CourseName) - , course E.^. CourseShorthand - , course E.^. CourseTerm - , course E.^. CourseSchool :: E.SqlExpr (E.Value SchoolId) - ) - in (submission, sheet, crse, corrector, lastEditQuery submission) - ) - dbtProj = (views _dbtProjRow . set _dbrOutput) =<< do - (submission@(Entity sId _), sheet@(Entity shId Sheet{..}), (E.Value courseName, E.Value courseShorthand, E.Value courseTerm, E.Value courseSchool), mCorrector, E.Value mbLastEdit) <- view $ _dbtProjRow . _dbrOutput - cid <- encrypt sId - forMM_ (view $ _dbtProjFilter . _corrProjFilterSubmission) $ \criteria -> - let haystack = map CI.mk . unpack $ toPathPiece cid - in guard $ any (`isInfixOf` haystack) criteria + => CorrectionTableWhere -> Colonnade h CorrectionTableData (DBCell m x) -> _ -> Maybe CorrectionTableCsvSettings -> PSValidator m x -> DBParams m x -> DB (DBResult m x) +makeCorrectionsTable whereClause dbtColonnade dbtFilterUI' mCSVSettings psValidator dbtParams + = let dbtSQLQuery = runReaderT $ do + course <- view queryCourse + sheet <- view querySheet + submission <- view querySubmission + corrector <- view queryCorrector - submittors <- lift . lift . E.select . E.from $ \((submissionUser `E.InnerJoin` user) `E.LeftOuterJoin` pseudonym) -> do - E.on $ pseudonym E.?. SheetPseudonymUser E.==. E.just (user E.^. UserId) - E.&&. pseudonym E.?. SheetPseudonymSheet E.==. E.just (E.val shId) - E.on $ submissionUser E.^. SubmissionUserUser E.==. user E.^. UserId - E.where_ $ submissionUser E.^. SubmissionUserSubmission E.==. E.val sId - E.orderBy [E.asc $ user E.^. UserSurname, E.asc $ user E.^. UserDisplayName] - let submissionGroup' = E.subSelectMaybe . E.from $ \(submissionGroup `E.InnerJoin` submissionGroupUser) -> do - E.on $ submissionGroup E.^. SubmissionGroupId E.==. submissionGroupUser E.^. SubmissionGroupUserSubmissionGroup - E.where_ $ submissionGroup E.^. SubmissionGroupCourse E.==. E.val sheetCourse - E.where_ $ submissionGroupUser E.^. SubmissionGroupUserUser E.==. user E.^. UserId - return . E.just $ submissionGroup E.^. SubmissionGroupName + lift $ do + E.on $ corrector E.?. UserId E.==. submission E.^. SubmissionRatingBy + E.on $ sheet E.^. SheetId E.==. submission E.^. SubmissionSheet + E.on $ course E.^. CourseId E.==. sheet E.^. SheetCourse - return (user, pseudonym E.?. SheetPseudonymPseudonym, submissionGroup') - let - submittorMap = List.foldr (\(Entity userId user, E.Value pseudo, E.Value sGroup) -> Map.insert userId (user, pseudo, sGroup)) Map.empty submittors - nonAnonymousAccess <- lift . lift $ or2M - (return $ not sheetAnonymousCorrection) - (hasReadAccessTo $ CourseR courseTerm courseSchool courseShorthand CCorrectionsR) + lastEdit <- view queryLastEdit - return (submission, sheet, (courseName, courseShorthand, courseTerm, courseSchool), mCorrector, mbLastEdit, submittorMap, cid, nonAnonymousAccess) - dbTable psValidator DBTable - { dbtSQLQuery - , dbtRowKey = \((_ `E.InnerJoin` _ `E.InnerJoin` submission) `E.LeftOuterJoin` _ :: CorrectionTableExpr) -> submission E.^. SubmissionId - , dbtColonnade - , dbtProj - , dbtSorting = Map.fromList - [ ( "term" - , SortColumn $ \((course `E.InnerJoin` _ `E.InnerJoin` _) `E.LeftOuterJoin` _) -> course E.^. CourseTerm - ) - , ( "school" - , SortColumn $ \((course `E.InnerJoin` _ `E.InnerJoin` _) `E.LeftOuterJoin` _) -> course E.^. CourseSchool - ) - , ( "course" - , SortColumn $ \((course `E.InnerJoin` _ `E.InnerJoin` _) `E.LeftOuterJoin` _) -> course E.^. CourseShorthand - ) - , ( "sheet" - , SortColumn $ \((_ `E.InnerJoin` sheet `E.InnerJoin` _) `E.LeftOuterJoin` _) -> sheet E.^. SheetName - ) - , ( "corrector" - , SortColumn $ \((_ `E.InnerJoin` _ `E.InnerJoin` _) `E.LeftOuterJoin` corrector) -> corrector E.?. UserSurname - ) - , ( "rating" - , SortColumn $ \((_ `E.InnerJoin` _ `E.InnerJoin` submission) `E.LeftOuterJoin` _) -> submission E.^. SubmissionRatingPoints - ) - , ( "sheet-type" - , SortColumns $ \((_ `E.InnerJoin` sheet `E.InnerJoin` _) `E.LeftOuterJoin` _) -> - [ SomeExprValue ((sheet E.^. SheetType) E.->. "type" :: E.SqlExpr (E.Value Value)) - , SomeExprValue (((sheet E.^. SheetType) E.->. "grading" :: E.SqlExpr (E.Value Value)) E.->. "max" :: E.SqlExpr (E.Value Value)) - , SomeExprValue (((sheet E.^. SheetType) E.->. "grading" :: E.SqlExpr (E.Value Value)) E.->. "passing" :: E.SqlExpr (E.Value Value)) + let crse = ( course E.^. CourseName + , course E.^. CourseShorthand + , course E.^. CourseTerm + , course E.^. CourseSchool + ) + + lift . E.where_ =<< whereClause + + return (submission, sheet, crse, corrector, lastEdit) + dbtProj = (views _dbtProjRow . set _dbrOutput) =<< do + (submission@(Entity sId _), sheet@(Entity shId Sheet{..}), (E.Value courseName, E.Value courseShorthand, E.Value courseTerm, E.Value courseSchool), mCorrector, E.Value mbLastEdit) <- view $ _dbtProjRow . _dbrOutput + + cid <- encrypt sId + forMM_ (view $ _dbtProjFilter . _corrProjFilterSubmission) $ \criteria -> + let haystack = map CI.mk . unpack $ toPathPiece cid + in guard $ any (`isInfixOf` haystack) criteria + + + submittors <- lift . lift . E.select . E.from $ \((submissionUser `E.InnerJoin` user) `E.LeftOuterJoin` pseudonym) -> do + E.on $ pseudonym E.?. SheetPseudonymUser E.==. E.just (user E.^. UserId) + E.&&. pseudonym E.?. SheetPseudonymSheet E.==. E.just (E.val shId) + E.on $ submissionUser E.^. SubmissionUserUser E.==. user E.^. UserId + E.where_ $ submissionUser E.^. SubmissionUserSubmission E.==. E.val sId + E.orderBy [E.asc $ user E.^. UserSurname, E.asc $ user E.^. UserDisplayName] + let submissionGroup' = E.subSelectMaybe . E.from $ \(submissionGroup `E.InnerJoin` submissionGroupUser) -> do + E.on $ submissionGroup E.^. SubmissionGroupId E.==. submissionGroupUser E.^. SubmissionGroupUserSubmissionGroup + E.where_ $ submissionGroup E.^. SubmissionGroupCourse E.==. E.val sheetCourse + E.where_ $ submissionGroupUser E.^. SubmissionGroupUserUser E.==. user E.^. UserId + return . E.just $ submissionGroup E.^. SubmissionGroupName + + return (user, pseudonym E.?. SheetPseudonymPseudonym, submissionGroup') + + mASDefinition <- lift . lift . $cachedHereBinary shId $ getSheetAuthorshipStatement sheet + (submittorMap, fmap getMax -> asState) <- runWriterT . flip foldMapM submittors $ \(Entity userId user, E.Value pseudo, E.Value sGroup) -> do + asState <- for mASDefinition $ \_ -> lift . lift . lift $ getUserAuthorshipStatement mASDefinition sId userId + tell $ Max <$> asState + return $ Map.singleton userId (user, pseudo, sGroup, asState) + + forMM_ (preview $ _dbtProjFilter . _corrProjFilterAuthorshipStatementState . _Wrapped . _Just) $ \criterion -> + guard $ asState == Just criterion + + forMM_ (view $ _dbtProjFilter . _corrProjFilterPseudonym) $ \criteria -> + let haystacks = setOf (folded . resultUserPseudonym . re _PseudonymText . to (map CI.mk . unpack)) submittorMap + in guard $ any (\haystack -> any (`isInfixOf` haystack) criteria) haystacks + + nonAnonymousAccess <- lift . lift $ or2M + (return $ not sheetAnonymousCorrection) + (hasReadAccessTo $ CourseR courseTerm courseSchool courseShorthand CCorrectionsR) + + return (submission, sheet, (courseName, courseShorthand, courseTerm, courseSchool), mCorrector, mbLastEdit, submittorMap, cid, nonAnonymousAccess, asState) + dbtRowKey = views querySubmission (E.^. SubmissionId) + dbtSorting = mconcat + [ singletonMap "term" . SortColumn $ views queryCourse (E.^. CourseTerm) + , singletonMap "school" . SortColumn $ views queryCourse (E.^. CourseSchool) + , singletonMap "course" . SortColumn $ views queryCourse (E.^. CourseShorthand) + , singletonMap "sheet" . SortColumn $ views querySheet (E.^. SheetName) + , singletonMap "corrector" . SortColumns $ \x -> + [ SomeExprValue (views queryCorrector (E.?. UserSurname) x) + , SomeExprValue (views queryCorrector (E.?. UserDisplayName) x) + ] + , singletonMap "rating" . SortColumn $ views querySubmission (E.^. SubmissionRatingPoints) + , singletonMap "sheet-type" . SortColumns $ \(view querySheet -> sheet) -> + [ SomeExprValue ((sheet E.^. SheetType) E.->. "type" :: E.SqlExpr (E.Value Value)) + , SomeExprValue (((sheet E.^. SheetType) E.->. "grading" :: E.SqlExpr (E.Value Value)) E.->. "max" :: E.SqlExpr (E.Value Value)) + , SomeExprValue (((sheet E.^. SheetType) E.->. "grading" :: E.SqlExpr (E.Value Value)) E.->. "passing" :: E.SqlExpr (E.Value Value)) + ] + , singletonMap "israted" . SortColumn $ views querySubmission $ E.not_ . E.isNothing . (E.^. SubmissionRatingTime) + , singletonMap "ratingtime" . SortColumn $ views querySubmission (E.^. SubmissionRatingTime) + , singletonMap "assignedtime" . SortColumn $ views querySubmission (E.^. SubmissionRatingAssigned) + , singletonMap "submittors" . SortProjected . comparing $ \x -> guardOn @Maybe (x ^. resultNonAnonymousAccess) $ setOf (resultSubmittors . resultUserUser . $(multifocusG 2) _userSurname _userDisplayName) x + , singletonMap "submittors-matriculation" . SortProjected . comparing $ \x -> guardOn @Maybe (x ^. resultNonAnonymousAccess) $ setOf (resultSubmittors . resultUserUser . _userMatrikelnummer . _Just) x + , singletonMap "submittors-group" . SortProjected . comparing $ \x -> guardOn @Maybe (x ^. resultNonAnonymousAccess) $ setOf (resultSubmittors . resultUserSubmissionGroup) x + , singletonMap "submittors-pseudonyms" . SortProjected . comparing $ \x -> setOf (resultSubmittors . resultUserPseudonym . re _PseudonymText) x + , singletonMap "comment" . SortColumn $ views querySubmission (E.^. SubmissionRatingComment) -- sorting by comment specifically requested by correctors to easily see submissions to be done + , singletonMap "last-edit" . SortColumn $ view queryLastEdit + , singletonMap "submission" . SortProjected . comparing $ views resultCryptoID toPathPiece + , singletonMap "as-state" . SortProjected . comparing $ view resultASState ] - ) - , ( "israted" - , SortColumn $ \((_ `E.InnerJoin` _ `E.InnerJoin` submission) `E.LeftOuterJoin` _) -> E.not_ . E.isNothing $ submission E.^. SubmissionRatingTime - ) - , ( "ratingtime" - , SortColumn $ \((_ `E.InnerJoin` _ `E.InnerJoin` submission) `E.LeftOuterJoin` _) -> submission E.^. SubmissionRatingTime - ) - , ( "assignedtime" - , SortColumn $ \((_ `E.InnerJoin` _ `E.InnerJoin` submission) `E.LeftOuterJoin` _) -> submission E.^. SubmissionRatingAssigned - ) - , ( "submittors" - , SortProjected . comparing $ \DBRow{ dbrOutput = (_, _, _, _, _, submittors, _, hasAccess) } -> guardOn @Maybe hasAccess . fmap ((userSurname &&& userDisplayName) . view _1) $ Map.elems submittors - ) - , ( "submittors-matriculation" - , SortProjected . comparing $ \DBRow{ dbrOutput = (_, _, _, _, _, submittors, _, hasAccess) } -> guardOn @Maybe hasAccess . fmap (view $ _1 . _userMatrikelnummer) $ Map.elems submittors - ) - , ( "submittors-group" - , SortProjected . comparing $ \DBRow{ dbrOutput = (_, _, _, _, _, submittors, _, hasAccess) } -> guardOn @Maybe hasAccess . fmap (view _3) $ Map.elems submittors - ) - , ( "comment" -- sorting by comment specifically requested by correctors to easily see submissions to be done - , SortColumn $ \((_ `E.InnerJoin` _ `E.InnerJoin` submission) `E.LeftOuterJoin` _) -> submission E.^. SubmissionRatingComment - ) - , ( "last-edit" - , SortColumn $ \((_course `E.InnerJoin` _sheet `E.InnerJoin` submission) `E.LeftOuterJoin` _corrector) -> lastEditQuery submission - ) - , ( "submission" - , SortProjected . comparing $ toPathPiece . view (_dbrOutput . _7) - ) - ] - , dbtFilter = Map.fromList - [ ( "term" - , FilterColumn $ \((course `E.InnerJoin` _ `E.InnerJoin` _) `E.LeftOuterJoin` _ :: CorrectionTableExpr) tids -> if - | Set.null tids -> E.val True :: E.SqlExpr (E.Value Bool) - | otherwise -> course E.^. CourseTerm `E.in_` E.valList (Set.toList tids) - ) - , ( "school" - , FilterColumn $ \((course `E.InnerJoin` _ `E.InnerJoin` _) `E.LeftOuterJoin` _ :: CorrectionTableExpr) sids -> if - | Set.null sids -> E.val True :: E.SqlExpr (E.Value Bool) - | otherwise -> course E.^. CourseSchool `E.in_` E.valList (Set.toList sids) - ) - , ( "course" - , FilterColumn $ \((course `E.InnerJoin` _ `E.InnerJoin` _) `E.LeftOuterJoin` _ :: CorrectionTableExpr) cshs -> if - | Set.null cshs -> E.val True :: E.SqlExpr (E.Value Bool) - | otherwise -> course E.^. CourseShorthand `E.in_` E.valList (Set.toList cshs) - ) - , ( "sheet" - , FilterColumn $ \((_ `E.InnerJoin` sheet `E.InnerJoin` _) `E.LeftOuterJoin` _ :: CorrectionTableExpr) shns -> if - | Set.null shns -> E.val True :: E.SqlExpr (E.Value Bool) - | otherwise -> sheet E.^. SheetName `E.in_` E.valList (Set.toList shns) - ) - , ( "sheet-search" - , FilterColumn $ \((_ `E.InnerJoin` sheet `E.InnerJoin` _) `E.LeftOuterJoin` _ :: CorrectionTableExpr) shns -> case getLast (shns :: Last (CI Text)) of - Nothing -> E.val True :: E.SqlExpr (E.Value Bool) - Just needle -> sheet E.^. SheetName `E.ilike` (E.%) E.++. E.val needle E.++. (E.%) - ) - , ( "corrector" - , FilterColumn $ \((_ `E.InnerJoin` _ `E.InnerJoin` _) `E.LeftOuterJoin` corrector :: CorrectionTableExpr) emails -> if - | Set.null emails -> E.val True :: E.SqlExpr (E.Value Bool) - | otherwise -> corrector E.?. UserEmail `E.in_` E.justList (E.valList . catMaybes $ Set.toList emails) - E.||. (if Nothing `Set.member` emails then E.isNothing (corrector E.?. UserEmail) else E.val False) - ) - , ( "isassigned" - , FilterColumn $ \((_ `E.InnerJoin` _ `E.InnerJoin` submission) `E.LeftOuterJoin` _ :: CorrectionTableExpr) criterion -> case getLast (criterion :: Last Bool) of - Nothing -> E.val True :: E.SqlExpr (E.Value Bool) - Just True -> E.isJust $ submission E.^. SubmissionRatingBy - Just False-> E.isNothing $ submission E.^. SubmissionRatingBy - ) - , ( "israted" - , FilterColumn $ \((_ `E.InnerJoin` _ `E.InnerJoin` submission) `E.LeftOuterJoin` _ :: CorrectionTableExpr) criterion -> case getLast (criterion :: Last Bool) of - Nothing -> E.val True :: E.SqlExpr (E.Value Bool) - Just True -> E.isJust $ submission E.^. SubmissionRatingTime - Just False-> E.isNothing $ submission E.^. SubmissionRatingTime - ) - , ( "corrector-name-email" -- corrector filter does not work for text-filtering - , FilterColumn $ E.anyFilter - [ E.mkContainsFilterWith Just $ queryCorrector >>> (E.?. UserSurname) - , E.mkContainsFilterWith Just $ queryCorrector >>> (E.?. UserDisplayName) - , E.mkContainsFilterWith (Just . CI.mk) $ queryCorrector >>> (E.?. UserEmail) + dbtFilter = mconcat + [ singletonMap "term" . FilterColumn . E.mkExactFilter $ views queryCourse (E.^. CourseTerm) + , singletonMap "school" . FilterColumn . E.mkExactFilter $ views queryCourse (E.^. CourseSchool) + , singletonMap "course" . FilterColumn . E.mkExactFilter $ views queryCourse (E.^. CourseShorthand) + , singletonMap "sheet" . FilterColumn . E.mkExactFilter $ views querySheet (E.^. SheetName) + , singletonMap "sheet-search" . FilterColumn . E.mkContainsFilter $ views querySheet (E.^. SheetName) + , singletonMap "corrector" . FilterColumn . E.mkExactFilterWith Just $ views queryCorrector (E.?. UserIdent) + , singletonMap "isassigned" . FilterColumn . E.mkExactFilterLast $ views querySubmission (E.isJust . (E.^. SubmissionRatingBy)) + , singletonMap "israted" . FilterColumn . E.mkExactFilterLast $ views querySubmission sqlSubmissionRatingDone + , singletonMap "corrector-name-email" . FilterColumn $ E.anyFilter + [ E.mkContainsFilterWith Just $ views queryCorrector (E.?. UserSurname) + , E.mkContainsFilterWith Just $ views queryCorrector (E.?. UserDisplayName) + , E.mkContainsFilterWith (Just . CI.mk) $ views queryCorrector (E.?. UserEmail) + , E.mkContainsFilterWith (Just . CI.mk) $ views queryCorrector (E.?. UserIdent) + , E.mkContainsFilterWith (Just . CI.mk) $ views queryCorrector (E.?. UserDisplayEmail) ] - ) - , ( "user-name-email" - , FilterColumn $ E.mkExistsFilter $ \table needle -> E.from $ \(submissionUser `E.InnerJoin` user) -> do + , singletonMap "user-name-email" . FilterColumn $ E.mkExistsFilter $ \row needle -> E.from $ \(submissionUser `E.InnerJoin` user) -> do E.on $ submissionUser E.^. SubmissionUserUser E.==. user E.^. UserId - E.where_ $ querySubmission table E.^. SubmissionId E.==. submissionUser E.^. SubmissionUserSubmission - E.where_ $ (\f -> f user $ Set.singleton needle) $ E.anyFilter + E.where_ $ dbtRowKey row E.==. submissionUser E.^. SubmissionUserSubmission + E.where_ $ E.anyFilter [ E.mkContainsFilter (E.^. UserSurname) , E.mkContainsFilter (E.^. UserDisplayName) , E.mkContainsFilterWith CI.mk (E.^. UserEmail) - ] - ) - , ( "user-matriclenumber" - , FilterColumn $ E.mkExistsFilter $ \table needle -> E.from $ \(submissionUser `E.InnerJoin` user) -> do + , E.mkContainsFilterWith CI.mk (E.^. UserIdent) + , E.mkContainsFilterWith CI.mk (E.^. UserDisplayEmail) + ] user (Set.singleton needle) + , singletonMap "user-matriclenumber" . FilterColumn $ E.mkExistsFilter $ \row needle -> E.from $ \(submissionUser `E.InnerJoin` user) -> do E.on $ submissionUser E.^. SubmissionUserUser E.==. user E.^. UserId - E.where_ $ querySubmission table E.^. SubmissionId E.==. submissionUser E.^. SubmissionUserSubmission - E.where_ $ (\f -> f user $ Set.singleton needle) $ - E.mkContainsFilter (E.^. UserMatrikelnummer) - ) - , ( "submission-group" - , FilterColumn $ E.mkExistsFilter $ \table needle -> E.from $ \(submissionGroup `E.InnerJoin` submissionGroupUser) -> do + E.where_ $ dbtRowKey row E.==. submissionUser E.^. SubmissionUserSubmission + E.where_ $ E.mkContainsFilterWith Just (E.^. UserMatrikelnummer) user (Set.singleton needle) + , singletonMap "submission-group" . FilterColumn $ E.mkExistsFilter $ \row needle -> E.from $ \(submissionGroup `E.InnerJoin` submissionGroupUser `E.InnerJoin` submissionUser) -> do + E.on $ submissionUser E.^. SubmissionUserUser E.==. submissionGroupUser E.^. SubmissionGroupUserUser E.on $ submissionGroup E.^. SubmissionGroupId E.==. submissionGroupUser E.^. SubmissionGroupUserSubmissionGroup - E.where_ $ queryCourse table E.^. CourseId E.==. submissionGroup E.^. SubmissionGroupCourse - E.where_ $ (\f -> f submissionGroup $ Set.singleton needle) $ - E.mkContainsFilter (E.^. SubmissionGroupName) - ) - , ( "rating-visible" - , FilterColumn $ \((_ `E.InnerJoin` _ `E.InnerJoin` submission) `E.LeftOuterJoin` _ :: CorrectionTableExpr) criterion -> case getLast (criterion :: Last Bool) of - Nothing -> E.val True :: E.SqlExpr (E.Value Bool) - Just True -> E.isJust $ submission E.^. SubmissionRatingTime - Just False-> E.isNothing $ submission E.^. SubmissionRatingTime - ) - , ( "rating" - , FilterColumn $ \((_ `E.InnerJoin` _ `E.InnerJoin` submission) `E.LeftOuterJoin` _ :: CorrectionTableExpr) pts -> if - | Set.null pts -> E.val True :: E.SqlExpr (E.Value Bool) - | otherwise -> E.maybe (E.val False :: E.SqlExpr (E.Value Bool)) (\p -> p `E.in_` E.valList (Set.toList pts)) (submission E.^. SubmissionRatingPoints) - ) - , ( "comment" - , FilterColumn $ \((_ `E.InnerJoin` _ `E.InnerJoin` submission) `E.LeftOuterJoin` _ :: CorrectionTableExpr) comm -> case getLast (comm :: Last Text) of - Nothing -> E.val True :: E.SqlExpr (E.Value Bool) - Just needle -> E.maybe (E.val False :: E.SqlExpr (E.Value Bool)) (E.isInfixOf $ E.val needle) (submission E.^. SubmissionRatingComment) - ) - , ( "submission" - , FilterProjected (_corrProjFilterSubmission ?~) - -- , FilterProjected $ \(DBRow{..} :: CorrectionTableData) (criteria :: Set Text) -> - -- let cid = map CI.mk . unpack . toPathPiece $ dbrOutput ^. _7 - -- criteria' = map CI.mk . unpack <$> Set.toList criteria - -- in any (`isInfixOf` cid) criteria' - ) - ] - , dbtFilterUI = fromMaybe mempty dbtFilterUI - , dbtStyle = def { dbsFilterLayout = maybe (\_ _ _ -> id) (const defaultDBSFilterLayout) dbtFilterUI } - , dbtParams - , dbtIdent = "corrections" :: Text - , dbtCsvEncode = noCsvEncode - , dbtCsvDecode = Nothing - , dbtExtraReps = [] - } + E.where_ $ (row ^. queryCourse) E.^. CourseId E.==. submissionGroup E.^. SubmissionGroupCourse + E.&&. dbtRowKey row E.==. submissionUser E.^. SubmissionUserSubmission + E.where_ $ E.mkContainsFilter (E.^. SubmissionGroupName) submissionGroup (Set.singleton needle) + , singletonMap "rating-visible" . FilterColumn . E.mkExactFilterLast $ views querySubmission sqlSubmissionRatingDone -- TODO: Identical with israted? + , singletonMap "rating" . FilterColumn . E.mkExactFilterWith Just $ views querySubmission (E.^. SubmissionRatingPoints) + , singletonMap "comment" . FilterColumn . E.mkContainsFilterWith Just $ views querySubmission (E.^. SubmissionRatingComment) + , singletonMap "submission" $ FilterProjected (_corrProjFilterSubmission ?~) + , singletonMap "pseudonym" $ FilterProjected (_corrProjFilterPseudonym ?~) + , singletonMap "as-state" $ FilterProjected (_corrProjFilterAuthorshipStatementState <>~) + ] + dbtFilterUI = fromMaybe mempty dbtFilterUI' + dbtStyle = def { dbsFilterLayout = maybe (\_ _ _ -> id) (const defaultDBSFilterLayout) dbtFilterUI' } + dbtIdent = "corrections" :: Text + dbtCsvEncode = do + CorrectionTableCsvSettings{..} <- mCSVSettings + return DBTCsvEncode + { dbtCsvExportForm = CorrectionTableCsvExportData + <$> apopt checkBoxField (fslI MsgCorrectionCsvSingleSubmittors & setTooltip MsgCorrectionCsvSingleSubmittorsTip) (Just $ csvCorrectionSingleSubmittors def) + , dbtCsvNoExportData = Nothing + , dbtCsvDoEncode = \CorrectionTableCsvExportData{..} -> awaitForever $ \(_, row) -> runReaderC row $ do + submittors <- asks $ sortOn (view $ resultUserUser . $(multifocusG 2) _userSurname _userDisplayName) . toListOf resultSubmittors + forM_ (bool pure (map pure) csvCorrectionSingleSubmittors submittors) $ \submittors' -> transPipe (withReaderT (, submittors')) $ do + let guardNonAnonymous = runMaybeT . guardMOnM (view $ _1 . resultNonAnonymousAccess) . MaybeT + yieldM $ CorrectionTableCsv + <$> preview (_1 . resultCourseTerm . _TermId) + <*> preview (_1 . resultCourseSchool . _SchoolId) + <*> preview (_1 . resultCourseShorthand) + <*> preview (_1 . resultSheet . _entityVal . _sheetName) + <*> preview (_1 . resultCryptoID . re (_CI . _PathPiece)) + <*> guardNonAnonymous (preview $ _1 . resultLastEdit) + <*> guardNonAnonymous (previews _2 (toListOf $ folded . resultUserUser . _userSurname . re _Just)) + <*> guardNonAnonymous (previews _2 (toListOf $ folded . resultUserUser . _userFirstName . re _Just)) + <*> guardNonAnonymous (previews _2 (toListOf $ folded . resultUserUser . _userDisplayName . re _Just)) + <*> guardNonAnonymous (previews _2 (toListOf $ folded . resultUserUser . _userMatrikelnummer)) + <*> guardNonAnonymous (previews _2 (toListOf $ folded . resultUserUser . _userEmail . re _Just)) + <*> guardNonAnonymous (previews _2 (toListOf $ folded . pre resultUserPseudonym)) + <*> guardNonAnonymous (previews _2 (toListOf $ folded . pre resultUserSubmissionGroup)) + <*> guardNonAnonymous (previews _2 (toListOf $ folded . pre resultUserAuthorshipStatementState)) + <*> preview (_1 . resultSubmission . _entityVal . _submissionRatingAssigned . _Just) + <*> preview (_1 . resultCorrector . _entityVal . _userDisplayName) + <*> preview (_1 . resultCorrector . _entityVal . _userEmail) + <*> preview (_1 . resultSubmission . _entityVal . to submissionRatingDone) + <*> preview (_1 . resultSubmission . _entityVal . _submissionRatingTime . _Just) + <*> preview (_1 . resultSubmission . _entityVal . _submissionRatingPoints . _Just) + <*> preview (_1 . resultSubmission . _entityVal . _submissionRatingComment . _Just) + , dbtCsvName = cTableCsvName, dbtCsvSheetName = cTableCsvSheetName + , dbtCsvHeader = \_ -> return $ correctionTableCsvHeader cTableShowCorrector cTableCsvQualification + , dbtCsvExampleData = Nothing + } + dbtCsvDecode = Nothing + dbtExtraReps = maybe id (\CorrectionTableCsvSettings{..} -> withCsvExtraRep cTableCsvSheetName (def :: CorrectionTableCsvExportData) dbtCsvEncode) mCSVSettings + [ DBTExtraRep $ toPrettyJSON <$> repCorrectionJson, DBTExtraRep $ toYAML <$> repCorrectionJson + ] + + repCorrectionJson :: ConduitT (E.Value SubmissionId, CorrectionTableData) Void DB (Map CryptoFileNameSubmission CorrectionTableJson) + repCorrectionJson = C.foldMap $ \(_, res) -> Map.singleton (res ^. resultCryptoID) $ mkCorrectionTableJson res + where + mkCorrectionTableJson :: CorrectionTableData -> CorrectionTableJson + mkCorrectionTableJson res' = flip runReader res' $ do + let guardNonAnonymous :: Reader CorrectionTableData (Maybe a) -> Reader CorrectionTableData (Maybe a) + guardNonAnonymous = runMaybeT . guardMOnM (view resultNonAnonymousAccess) . MaybeT + mkCorrectionTableSubmittorJson :: Reader CorrectionTableData (Maybe [CorrectionTableSubmittorJson]) + mkCorrectionTableSubmittorJson = Just <$> do + submittors <- asks $ sortOn (view $ resultUserUser . $(multifocusG 2) _userSurname _userDisplayName) . toListOf resultSubmittors + forM submittors $ \submittor -> lift . flip runReaderT submittor $ + CorrectionTableSubmittorJson + <$> view (resultUserUser . _userSurname) + <*> view (resultUserUser . _userFirstName) + <*> view (resultUserUser . _userDisplayName) + <*> view (resultUserUser . _userMatrikelnummer) + <*> view (resultUserUser . _userEmail) + <*> preview resultUserPseudonym + <*> preview resultUserSubmissionGroup + <*> preview resultUserAuthorshipStatementState + CorrectionTableJson + <$> view (resultCourseTerm . _TermId) + <*> view (resultCourseSchool . _SchoolId) + <*> view resultCourseShorthand + <*> view (resultSheet . _entityVal . _sheetName) + <*> guardNonAnonymous (preview resultLastEdit) + <*> guardNonAnonymous mkCorrectionTableSubmittorJson + <*> preview (resultSubmission . _entityVal . _submissionRatingAssigned . _Just) + <*> preview (resultCorrector . _entityVal . _userDisplayName) + <*> preview (resultCorrector . _entityVal . _userEmail) + <*> view (resultSubmission . _entityVal . to submissionRatingDone) + <*> preview (resultSubmission . _entityVal . _submissionRatingTime . _Just) + <*> preview (resultSubmission . _entityVal . _submissionRatingPoints . _Just) + <*> preview (resultSubmission . _entityVal . _submissionRatingComment . _Just) + in dbTable psValidator DBTable{..} data ActionCorrections = CorrDownload | CorrSetCorrector @@ -447,16 +795,16 @@ data ActionCorrectionsData = CorrDownloadData SubmissionDownloadAnonymous Submis | CorrAutoSetCorrectorData SheetId | CorrDeleteData -correctionsR :: _ -> _ -> _ -> _ -> Map ActionCorrections (AForm (HandlerFor UniWorX) ActionCorrectionsData) -> Handler TypedContent -correctionsR whereClause displayColumns dbtFilterUI psValidator actions = do - (table, statistics) <- correctionsR' whereClause displayColumns dbtFilterUI psValidator actions +correctionsR :: CorrectionTableWhere -> _ -> _ -> Maybe CorrectionTableCsvSettings -> _ -> Map ActionCorrections (AForm (HandlerFor UniWorX) ActionCorrectionsData) -> Handler TypedContent +correctionsR whereClause displayColumns dbtFilterUI csvSettings psValidator actions = do + (table, statistics) <- correctionsR' whereClause displayColumns dbtFilterUI csvSettings psValidator actions fmap toTypedContent . defaultLayout $ do setTitleI MsgCourseCorrectionsTitle $(widgetFile "corrections") -correctionsR' :: _ -> _ -> _ -> _ -> Map ActionCorrections (AForm (HandlerFor UniWorX) ActionCorrectionsData) -> Handler (Widget, SheetTypeSummary SqlBackendKey) -correctionsR' whereClause displayColumns dbtFilterUI psValidator actions = do +correctionsR' :: CorrectionTableWhere -> _ -> _ -> Maybe CorrectionTableCsvSettings -> _ -> Map ActionCorrections (AForm (HandlerFor UniWorX) ActionCorrectionsData) -> Handler (Widget, SheetTypeSummary SqlBackendKey) +correctionsR' whereClause displayColumns dbtFilterUI csvSettings psValidator actions = do currentRoute <- fromMaybe (error "correctionsR called from 404-handler") <$> getCurrentRoute -- This should never be called from a 404 handler postDeleteR $ \drRecords -> (submissionDeleteRoute drRecords) @@ -465,7 +813,7 @@ correctionsR' whereClause displayColumns dbtFilterUI psValidator actions = do } ((actionRes', statistics), table) <- runDB $ - makeCorrectionsTable whereClause displayColumns dbtFilterUI psValidator DBParamsForm + makeCorrectionsTable whereClause displayColumns dbtFilterUI csvSettings psValidator DBParamsForm { dbParamsFormMethod = POST , dbParamsFormAction = Just $ SomeRoute currentRoute , dbParamsFormAttrs = [] @@ -605,7 +953,12 @@ restrictAnonymous :: PSValidator m x -> PSValidator m x restrictAnonymous = restrictFilter (\k _ -> k /= "user-matriclenumber") . restrictFilter (\k _ -> k /= "user-name-email") . restrictFilter (\k _ -> k /= "submission-group") + . restrictFilter (\k _ -> k /= "as-state") + . restrictSorting (\k _ -> k /= "submittors") + . restrictSorting (\k _ -> k /= "submittors-matriculation") + . restrictSorting (\k _ -> k /= "submittors-group") . restrictSorting (\k _ -> k /= "last-edit") + . restrictSorting (\k _ -> k /= "as-state") restrictCorrector :: PSValidator m x -> PSValidator m x restrictCorrector = restrictFilter (\k _ -> k /= "corrector") @@ -654,7 +1007,8 @@ getCorrectionsR, postCorrectionsR :: Handler TypedContent getCorrectionsR = postCorrectionsR postCorrectionsR = do uid <- requireAuthId - let whereClause = ratedBy uid + let whereClause :: CorrectionTableWhere + whereClause = ratedBy uid colonnade = mconcat [ colSelect , colSchool @@ -670,13 +1024,14 @@ postCorrectionsR = do , colRating , colRated ] -- Continue here - filterUI = Just $ \mPrev -> mconcat - [ prismAForm (singletonFilter "course" ) mPrev $ aopt (lift `hoistField` selectField courseOptions) (fslI MsgTableCourse) - , prismAForm (singletonFilter "term" ) mPrev $ aopt (lift `hoistField` selectField termOptions) (fslI MsgTableTerm) - , prismAForm (singletonFilter "school" ) mPrev $ aopt (lift `hoistField` selectField schoolOptions) (fslI MsgTableCourseSchool) - , Map.singleton "sheet-search" . maybeToList <$> aopt (lift `hoistField` textField) (fslI MsgTableSheet) (Just <$> listToMaybe =<< ((Map.lookup "sheet-search" =<< mPrev) <|> (Map.lookup "sheet" =<< mPrev))) - , prismAForm (singletonFilter "israted" . maybePrism _PathPiece) mPrev $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgTableRatingTime) - , prismAForm (singletonFilter "submission") mPrev $ aopt (lift `hoistField` textField) (fslI MsgTableSubmission) + filterUI = Just $ mconcat + [ filterUIPseudonym + , filterUICourse courseOptions + , filterUITerm termOptions + , filterUISchool schoolOptions + , filterUISheetSearch + , filterUIIsRated + , filterUISubmission ] courseOptions = runDB $ do courses <- selectList [] [Asc CourseShorthand] >>= filterM (\(Entity _ Course{..}) -> (== Authorized) <$> evalAccessCorrector courseTerm courseSchool courseShorthand) @@ -693,41 +1048,65 @@ postCorrectionsR = do & restrictAnonymous & defaultSorting [SortDescBy "ratingtime", SortAscBy "assignedtime" ] & defaultFilter (singletonMap "israted" [toPathPiece False]) - correctionsR whereClause colonnade filterUI psValidator $ Map.fromList + + csvSettings = Just CorrectionTableCsvSettings + { cTableCsvQualification = CorrectionTableCsvQualifyCourse + , cTableCsvName = MsgCorrectionTableCsvNameCorrections + , cTableCsvSheetName = MsgCorrectionTableCsvSheetNameCorrections + , cTableShowCorrector = False + } + correctionsR whereClause colonnade filterUI csvSettings psValidator $ Map.fromList [ downloadAction ] getCCorrectionsR, postCCorrectionsR :: TermId -> SchoolId -> CourseShorthand -> Handler TypedContent getCCorrectionsR = postCCorrectionsR postCCorrectionsR tid ssh csh = do - Entity cid _ <- runDB $ getBy404 $ TermSchoolCourseShort tid ssh csh - let whereClause = courseIs cid - colonnade = mconcat -- should match getSSubsR for consistent UX - [ colSelect - , colSheet - , colSMatrikel - , colSubmittors - , colSGroups - , colSubmissionLink - , colLastEdit - , colRating - , colRated - , colCorrector - , colAssigned + (Entity cid _, doSubmissionGroups, doAuthorshipStatements) <- runDB $ do + course@(Entity cid _) <- getBy404 $ TermSchoolCourseShort tid ssh csh + doSubmissionGroups <- exists [SubmissionGroupCourse ==. cid] + doAuthorshipStatements <- runConduit $ + (E.selectSource . E.from $ \sheet -> sheet <$ E.where_ (sheet E.^. SheetCourse E.==. E.val cid)) + .| C.mapM getSheetAuthorshipStatement + .| C.map (is _Just) + .| C.or + return (course, doSubmissionGroups, doAuthorshipStatements) + let whereClause :: CorrectionTableWhere + whereClause = courseIs cid + colonnade = mconcat $ catMaybes -- should match getSSubsR for consistent UX + [ pure colSelect + , pure colSheet + , pure colSMatrikel + , pure colSubmittors + , guardOn doSubmissionGroups colSGroups + , pure colSubmissionLink + , pure colLastEdit + , guardOn doAuthorshipStatements colAuthorshipStatementState + , pure colRating + , pure colRated + , pure colCorrector + , pure colAssigned ] -- Continue here - filterUI = Just $ \mPrev -> mconcat - [ prismAForm (singletonFilter "user-name-email") mPrev $ aopt textField (fslI MsgTableCourseMembers) - , prismAForm (singletonFilter "user-matriclenumber") mPrev $ aopt textField (fslI MsgTableMatrikelNr) - -- "pseudonym" TODO DB only stores Word24 - , Map.singleton "sheet-search" . maybeToList <$> aopt textField (fslI MsgTableSheet) (Just <$> listToMaybe =<< ((Map.lookup "sheet-search" =<< mPrev) <|> (Map.lookup "sheet" =<< mPrev))) - , prismAForm (singletonFilter "corrector-name-email") mPrev $ aopt textField (fslI MsgTableCorrector) - , prismAForm (singletonFilter "isassigned" . maybePrism _PathPiece) mPrev $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgTableHasCorrector) - , prismAForm (singletonFilter "israted" . maybePrism _PathPiece) mPrev $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgTableRatingTime) - , prismAForm (singletonFilter "submission-group") mPrev $ aopt textField (fslI MsgTableSubmissionGroup) - , prismAForm (singletonFilter "submission") mPrev $ aopt (lift `hoistField` textField) (fslI MsgTableSubmission) + filterUI = Just $ mconcat + [ filterUISheetSearch + , filterUIUserNameEmail + , filterUIUserMatrikelnummer + , filterUIPseudonym + , filterUISubmissionGroup + , filterUIAuthorshipStatementState + , filterUICorrectorNameEmail + , filterUIIsAssigned + , filterUIIsRated + , filterUISubmission ] psValidator = def & defaultPagesize PagesizeAll -- Assisstant always want to see them all at once anyway - correctionsR whereClause colonnade filterUI psValidator $ Map.fromList + csvSettings = Just CorrectionTableCsvSettings + { cTableCsvQualification = CorrectionTableCsvQualifySheet + , cTableCsvName = MsgCorrectionTableCsvNameCourseCorrections tid ssh csh + , cTableCsvSheetName = MsgCorrectionTableCsvSheetNameCourseCorrections tid ssh csh + , cTableShowCorrector = True + } + correctionsR whereClause colonnade filterUI csvSettings psValidator $ Map.fromList [ downloadAction , assignAction (Left cid) , deleteAction @@ -736,31 +1115,45 @@ postCCorrectionsR tid ssh csh = do getSSubsR, postSSubsR :: TermId -> SchoolId -> CourseShorthand -> SheetName -> Handler TypedContent getSSubsR = postSSubsR postSSubsR tid ssh csh shn = do - shid <- runDB $ fetchSheetId tid ssh csh shn - let whereClause = sheetIs shid - colonnade = mconcat -- should match getCCorrectionsR for consistent UX - [ colSelect - , colSMatrikel - , colSubmittors - , colSubmissionLink - , colLastEdit - , colRating - , colRated - , colCorrector - , colAssigned + (shid, doSubmissionGroups, doAuthorshipStatements) <- runDB $ do + sheet@(Entity shid Sheet{..}) <- fetchSheet tid ssh csh shn + doSubmissionGroups <- exists [SubmissionGroupCourse ==. sheetCourse] + doAuthorshipStatements <- is _Just <$> getSheetAuthorshipStatement sheet + return (shid, doSubmissionGroups, doAuthorshipStatements) + let whereClause :: CorrectionTableWhere + whereClause = sheetIs shid + colonnade = mconcat $ catMaybes -- should match getCCorrectionsR for consistent UX + [ pure colSelect + , pure colSMatrikel + , pure colSubmittors + , guardOn doSubmissionGroups colSGroups + , pure colSubmissionLink + , pure colLastEdit + , guardOn doAuthorshipStatements colAuthorshipStatementState + , pure colRating + , pure colRated + , pure colCorrector + , pure colAssigned ] - filterUI = Just $ \mPrev -> mconcat - [ prismAForm (singletonFilter "user-name-email") mPrev $ aopt textField (fslI MsgTableCourseMembers) - , prismAForm (singletonFilter "user-matriclenumber") mPrev $ aopt textField (fslI MsgTableMatrikelNr) - , prismAForm (singletonFilter "corrector-name-email") mPrev $ aopt textField (fslI MsgTableCorrector) - , prismAForm (singletonFilter "isassigned" . maybePrism _PathPiece) mPrev $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgTableHasCorrector) - , prismAForm (singletonFilter "israted" . maybePrism _PathPiece) mPrev $ aopt (boolField . Just $ SomeMessage MsgBoolIrrelevant) (fslI MsgTableRatingTime) - , prismAForm (singletonFilter "submission-group") mPrev $ aopt textField (fslI MsgTableSubmissionGroup) - , prismAForm (singletonFilter "submission") mPrev $ aopt (lift `hoistField` textField) (fslI MsgTableSubmission) - -- "pseudonym" TODO DB only stores Word24 + filterUI = Just $ mconcat + [ filterUIUserNameEmail + , filterUIUserMatrikelnummer + , filterUIPseudonym + , filterUISubmissionGroup + , filterUIAuthorshipStatementState + , filterUICorrectorNameEmail + , filterUIIsAssigned + , filterUIIsRated + , filterUISubmission ] psValidator = def & defaultPagesize PagesizeAll -- Assisstant always want to see them all at once anyway - correctionsR whereClause colonnade filterUI psValidator $ Map.fromList + csvSettings = Just CorrectionTableCsvSettings + { cTableCsvQualification = CorrectionTableCsvNoQualification + , cTableCsvName = MsgCorrectionTableCsvNameSheetCorrections tid ssh csh shn + , cTableCsvSheetName = MsgCorrectionTableCsvSheetNameSheetCorrections tid ssh csh shn + , cTableShowCorrector = True + } + correctionsR whereClause colonnade filterUI csvSettings psValidator $ Map.fromList [ downloadAction , assignAction (Right shid) , autoAssignAction shid diff --git a/src/Handler/Tutorial/List.hs b/src/Handler/Tutorial/List.hs index 39f67c0e8..9043cb08d 100644 --- a/src/Handler/Tutorial/List.hs +++ b/src/Handler/Tutorial/List.hs @@ -42,7 +42,7 @@ getCTutorialListR tid ssh csh = do dbtColonnade = dbColonnade $ mconcat [ sortable (Just "type") (i18nCell MsgTableTutorialType) $ \(view $ resultTutorial . _entityVal -> Tutorial{..}) -> textCell $ CI.original tutorialType , sortable (Just "name") (i18nCell MsgTableTutorialName) $ \(view $ resultTutorial . _entityVal -> Tutorial{..}) -> anchorCell (CTutorialR tid ssh csh tutorialName TUsersR) [whamlet|#{tutorialName}|] - , sortable Nothing (i18nCell MsgTableTutorialTutors) $ \(view $ resultTutorial . _entityKey -> tutid) -> sqlCell $ do + , sortable (Just "tutors") (i18nCell MsgTableTutorialTutors) $ \(view $ resultTutorial . _entityKey -> tutid) -> sqlCell $ do tutors <- fmap (map $(unValueN 3)) . E.select . E.from $ \(tutor `E.InnerJoin` user) -> do E.on $ tutor E.^. TutorUser E.==. user E.^. UserId E.where_ $ tutor E.^. TutorTutorial E.==. E.val tutid @@ -71,6 +71,12 @@ getCTutorialListR tid ssh csh = do dbtSorting = Map.fromList [ ("type", SortColumn $ \tutorial -> tutorial E.^. TutorialType ) , ("name", SortColumn $ \tutorial -> tutorial E.^. TutorialName ) + , ( "tutors" + , SortColumn $ \tutorial -> E.subSelectMaybe . E.from $ \(tutor `E.InnerJoin` user) -> do + E.on $ tutor E.^. TutorUser E.==. user E.^. UserId + E.where_ $ tutorial E.^. TutorialId E.==. tutor E.^. TutorTutorial + return . E.min_ $ user E.^. UserSurname + ) , ("participants", SortColumn $ \tutorial -> let participantCount :: E.SqlExpr (E.Value Int) participantCount = E.subSelectCount . E.from $ \tutorialParticipant -> E.where_ $ tutorialParticipant E.^. TutorialParticipantTutorial E.==. tutorial E.^. TutorialId diff --git a/src/Handler/Tutorial/Users.hs b/src/Handler/Tutorial/Users.hs index f8215a0d9..eb15e4e84 100644 --- a/src/Handler/Tutorial/Users.hs +++ b/src/Handler/Tutorial/Users.hs @@ -67,6 +67,12 @@ postTUsersR tid ssh csh tutn = do ] addMessageI Success $ MsgTutorialUsersDeregistered nrDel redirect $ CTutorialR tid ssh csh tutn TUsersR + + tutors <- runDB $ + E.select $ E.from $ \(tutor `E.InnerJoin` user) -> do + E.on $ tutor E.^. TutorUser E.==. user E.^. UserId + E.where_ $ tutor E.^. TutorTutorial E.==. E.val tutid + return user let heading = prependCourseTitle tid ssh csh $ CI.original tutorialName siteLayoutMsg heading $ do diff --git a/src/Handler/Users.hs b/src/Handler/Users.hs index 29963c64e..fd9d5823d 100644 --- a/src/Handler/Users.hs +++ b/src/Handler/Users.hs @@ -167,6 +167,15 @@ postUsersR = do -- Set.foldr (\needle acc -> acc E.||. (user E.^. UserDisplayName) `E.hasInfix` needle) eFalse (criterion :: Set.Set Text) E.any (\c -> user E.^. UserDisplayName `E.hasInfix` E.val c) criteria ) + , ( "user-ident", FilterColumn $ \user criterion -> case getLast (criterion :: Last Text) of + Nothing -> E.val True :: E.SqlExpr (E.Value Bool) + Just needle -> E.castString (user E.^. UserIdent) `E.ilike` (E.%) E.++. E.val needle E.++. (E.%) + ) + , ( "user-email", FilterColumn $ \user criterion -> case getLast (criterion :: Last Text) of + Nothing -> E.val True :: E.SqlExpr (E.Value Bool) + Just needle -> (E.castString (user E.^. UserEmail) `E.ilike` (E.%) E.++. E.val needle E.++. (E.%)) + E.||. (E.castString (user E.^. UserDisplayEmail) `E.ilike` (E.%) E.++. E.val needle E.++. (E.%)) +) , ( "matriculation", FilterColumn $ \user (criteria :: Set.Set Text) -> if | Set.null criteria -> E.true -- TODO: why can this be eFalse and work still? | otherwise -> E.any (\c -> user E.^. UserMatrikelnummer `E.hasInfix` E.val c) criteria @@ -192,6 +201,8 @@ postUsersR = do ] , dbtFilterUI = \mPrev -> mconcat [ prismAForm (singletonFilter "user-search") mPrev $ aopt textField (fslI MsgName) + , prismAForm (singletonFilter "user-ident") mPrev $ aopt textField (fslI MsgAdminUserIdent) + , prismAForm (singletonFilter "user-email") mPrev $ aopt textField (fslI MsgAdminUserEmail) -- , prismAForm (singletonFilter "matriculation" ) mPrev $ aopt textField (fslI MsgTableMatrikelNr) , prismAForm (singletonFilter "matriculation") mPrev $ aopt matriculationField (fslI MsgTableMatrikelNr) , prismAForm (singletonFilter "auth-ldap" . maybePrism _PathPiece) mPrev $ aopt (lift `hoistField` selectFieldList [(MsgAuthPWHash "", False), (MsgAuthLDAP, True)]) (fslI MsgAuthMode) diff --git a/src/Handler/Utils/Communication.hs b/src/Handler/Utils/Communication.hs index ca32a1b71..39e1681ce 100644 --- a/src/Handler/Utils/Communication.hs +++ b/src/Handler/Utils/Communication.hs @@ -78,16 +78,16 @@ data CommunicationRoute = CommunicationRoute data Communication = Communication { cRecipients :: Set (Either UserEmail UserId) - , cSubject :: Maybe Text - , cBody :: Html + , cContent :: CommunicationContent } +makeLenses_ ''Communication + crJobsCourseCommunication, crTestJobsCourseCommunication :: CourseId -> Communication -> ConduitT () Job (YesodDB UniWorX) () crJobsCourseCommunication jCourse Communication{..} = do jSender <- requireAuthId - let jSubject = cSubject - jMailContent = cBody + let jMailContent = cContent allRecipients = Set.toList $ Set.insert (Right jSender) cRecipients jMailObjectUUID <- liftIO getRandom jAllRecipientAddresses <- lift . fmap Set.fromList . forM allRecipients $ \case @@ -99,7 +99,7 @@ crTestJobsCourseCommunication jCourse comm = do jSender <- requireAuthId MsgRenderer mr <- getMsgRenderer - let comm' = comm { cSubject = Just . mr . MsgCommCourseTestSubject . fromMaybe (mr MsgUtilCommCourseSubject) $ cSubject comm } + let comm' = comm & _cContent . _ccSubject %~ Just . mr . MsgCommCourseTestSubject . fromMaybe (mr MsgUtilCommCourseSubject) crJobsCourseCommunication jCourse comm' .| C.filter ((== Right jSender) . jRecipientEmail) @@ -206,11 +206,24 @@ commR CommunicationRoute{..} = do recipientsListMsg <- messageI Info MsgCommRecipientsList + attachmentsMaxSize <- getsYesod $ view _appCommunicationAttachmentsMaxSize + let attachmentField = genericFileField $ return FileField + { fieldIdent = Nothing + , fieldUnpackZips = FileFieldUserOption True False + , fieldMultiple = True + , fieldRestrictExtensions = Nothing + , fieldAdditionalFiles = _FileReferenceFileReferenceTitleMap # Map.empty + , fieldMaxFileSize = Nothing, fieldMaxCumulativeSize = attachmentsMaxSize + , fieldAllEmptyOk = True + } ((commRes,commWdgt),commEncoding) <- runFormPost . identifyForm FIDCommunication . withButtonForm' universeF . renderAForm FormStandard $ Communication <$> recipientAForm <* aformMessage recipientsListMsg - <*> aopt textField (fslI MsgCommSubject & addAttr "uw-enter-as-tab" "") Nothing - <*> (markupOutput <$> areq htmlField (fslI MsgCommBody) Nothing) + <*> ( CommunicationContent + <$> aopt textField (fslI MsgCommSubject & addAttr "uw-enter-as-tab" "") Nothing + <*> (markupOutput <$> areq htmlField (fslI MsgCommBody) Nothing) + <*> fmap fold (aopt (convertFieldM (runConduit . (.| C.foldMap Set.singleton)) yieldMany attachmentField) (fslI MsgCommAttachments & setTooltip MsgCommAttachmentsTip) Nothing) + ) formResult commRes $ \case (comm, BtnCommunicationSend) -> do runDBJobs . runConduit $ transPipe (mapReaderT lift) (crJobs comm) .| sinkDBJobs diff --git a/src/Handler/Utils/Files.hs b/src/Handler/Utils/Files.hs index 83b5f7552..98b1e602e 100644 --- a/src/Handler/Utils/Files.hs +++ b/src/Handler/Utils/Files.hs @@ -1,3 +1,5 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} + module Handler.Utils.Files ( sourceFile, sourceFile' , sourceFiles, sourceFiles' @@ -9,6 +11,7 @@ module Handler.Utils.Files import Import.NoFoundation hiding (First(..)) import Foundation.Type +import Foundation.DB import Utils.Metrics import Data.Monoid (First(..)) @@ -181,6 +184,11 @@ sourceFiles' = C.map sourceFile' sourceFile' :: forall file. (HasFileReference file, YesodPersistBackend UniWorX ~ SqlBackend) => file -> DBFile sourceFile' = sourceFile . view (_FileReference . _1) + +instance (YesodMail UniWorX, YesodPersistBackend UniWorX ~ SqlBackend) => ToMailPart UniWorX FileReference where + toMailPart = toMailPart <=< liftHandler . runDBRead . withReaderT projectBackend . toPureFile . sourceFile' + + respondFileConditional :: (MonadThrow m, MonadHandler m, HandlerSite m ~ UniWorX, YesodPersistBackend UniWorX ~ SqlBackend, YesodPersistRunner UniWorX) => Maybe UTCTime -> MimeType -> FileReference diff --git a/src/Handler/Utils/Form.hs b/src/Handler/Utils/Form.hs index c4bf6e2f1..aa56f021a 100644 --- a/src/Handler/Utils/Form.hs +++ b/src/Handler/Utils/Form.hs @@ -998,15 +998,20 @@ genericFileField mkOpts = Field{..} = not (permittedExtension opts fName) && (not doUnpack || ((/=) `on` simpleContentType) (mimeLookup fName) typeZip) - whenIsJust fieldMaxFileSize $ \maxSize -> forM_ files $ \fInfo -> do - fLength <- runConduit $ fileSource fInfo .| C.takeE (fromIntegral $ succ maxSize) .| C.lengthE - when (fLength > maxSize) $ do - when (is _Just mIdent) $ - liftHandler . runDB . runConduit $ - mapM_ (transPipe lift . handleFile) files - .| handleUpload opts mIdent - .| C.sinkNull - throwE . SomeMessage . MsgGenericFileFieldFileTooLarge . unpack $ fileName fInfo + whenIsJust (ignoreNothing min fieldMaxFileSize fieldMaxCumulativeSize) $ \takeSize -> + flip evalAccumT mempty . forM_ files $ \fInfo -> do + fLength <- lift . runConduit $ fileSource fInfo .| C.takeE (fromIntegral $ succ takeSize) .| C.lengthE + add $ Sum fLength + Sum cummSize <- look + when (NTop (Just cummSize) > NTop fieldMaxCumulativeSize || NTop (Just fLength) > NTop fieldMaxFileSize) $ do + when (is _Just mIdent) $ + lift . liftHandler . runDB . runConduit $ + mapM_ (transPipe lift . handleFile) files + .| handleUpload opts mIdent + .| C.sinkNull + when (NTop (Just fLength) > NTop fieldMaxFileSize) $ do + lift . throwE . SomeMessage . MsgGenericFileFieldFileTooLarge . unpack $ fileName fInfo + lift . throwE $ SomeMessage MsgGenericFileFieldCumulativeTooLarge if | invExt : _ <- filter invalidUploadExtension uploadedFilenames -> do @@ -1125,7 +1130,7 @@ fileFieldMultiple = genericFileField $ return FileField , fieldMultiple = True , fieldRestrictExtensions = Nothing , fieldAdditionalFiles = _FileReferenceFileReferenceTitleMap # Map.empty - , fieldMaxFileSize = Nothing + , fieldMaxFileSize = Nothing, fieldMaxCumulativeSize = Nothing , fieldAllEmptyOk = True } @@ -1145,7 +1150,7 @@ singleFileField prev = genericFileField $ do [ (fileReferenceTitle, (fileReferenceContent, fileReferenceModified, FileFieldUserOption False True)) | FileReference{..} <- Set.toList permitted ] - , fieldMaxFileSize = Nothing + , fieldMaxFileSize = Nothing, fieldMaxCumulativeSize = Nothing , fieldAllEmptyOk = True } @@ -1161,7 +1166,7 @@ specificFileField UploadSpecificFile{..} mPrev = convertField (.| fixupFileTitle [ (fileReferenceTitle, (fileReferenceContent, fileReferenceModified, FileFieldUserOption False True)) | FileReference{..} <- Set.toList previous ] - , fieldMaxFileSize = specificFileMaxSize + , fieldMaxFileSize = specificFileMaxSize, fieldMaxCumulativeSize = Nothing , fieldAllEmptyOk = specificFileEmptyOk } where @@ -1189,7 +1194,7 @@ zipFileField' doUnpack permittedExtensions emptyOk mPrev = genericFileField $ do [ (fileReferenceTitle, (fileReferenceContent, fileReferenceModified, FileFieldUserOption False True)) | FileReference{..} <- Set.toList previous ] - , fieldMaxFileSize = Nothing + , fieldMaxFileSize = Nothing, fieldMaxCumulativeSize = Nothing , fieldAllEmptyOk = emptyOk } @@ -1232,7 +1237,7 @@ multiFileField mkPermitted = genericFileField $ mkField <$> mkPermitted [ (fileReferenceTitle, (fileReferenceContent, fileReferenceModified, FileFieldUserOption False True)) | FileReference{..} <- Set.toList permitted ] - , fieldMaxFileSize = Nothing + , fieldMaxFileSize = Nothing, fieldMaxCumulativeSize = Nothing , fieldAllEmptyOk = True } diff --git a/src/Handler/Utils/StudyFeatures.hs b/src/Handler/Utils/StudyFeatures.hs index e89b05c47..ef0d0a2e6 100644 --- a/src/Handler/Utils/StudyFeatures.hs +++ b/src/Handler/Utils/StudyFeatures.hs @@ -22,8 +22,6 @@ import Handler.Utils.StudyFeatures.Parse import qualified Data.Csv as Csv -import qualified Data.ByteString as ByteString - import qualified Data.Set as Set import Data.RFC5051 (compareUnicode) @@ -65,7 +63,7 @@ instance Csv.ToField UserTableStudyFeature where [] $ ShortStudyFieldType userTableFieldType instance Csv.ToField UserTableStudyFeatures where - toField = ByteString.intercalate "; " . map Csv.toField . view _UserTableStudyFeatures + toField = Csv.toField . CsvSemicolonList . view _UserTableStudyFeatures userTableStudyFeatureSort :: UserTableStudyFeature -> UserTableStudyFeature diff --git a/src/Handler/Utils/Submission.hs b/src/Handler/Utils/Submission.hs index 1d5e5ab7a..b59d1d723 100644 --- a/src/Handler/Utils/Submission.hs +++ b/src/Handler/Utils/Submission.hs @@ -11,6 +11,8 @@ module Handler.Utils.Submission , submissionMatchesSheet , submissionDeleteRoute , correctionInvisibleWidget + , AuthorshipStatementSubmissionState(..) + , getUserAuthorshipStatement, getSubmissionAuthorshipStatement ) where import Import hiding (joinPath) @@ -36,6 +38,7 @@ import Handler.Utils import qualified Handler.Utils.Rating as Rating (extractRatings) import Handler.Utils.Delete +import Database.Persist.Sql (SqlBackendCanRead) import qualified Database.Esqueleto.Legacy as E import qualified Database.Esqueleto.Utils.TH as E @@ -976,3 +979,44 @@ correctionInvisibleWidget tid ssh csh shn cID (Entity subId sub) = runMaybeT $ d tellPoint CorrectionInvisibleExamUnfinished return $ notification NotificationBroad =<< messageIconWidget Warning IconInvisible $(widgetFile "submission-correction-invisible") + + +getUserAuthorshipStatement :: ( MonadResource m + , IsSqlBackend backend, SqlBackendCanRead backend + ) + => Maybe (Entity AuthorshipStatementDefinition) -- ^ Currently expected authorship statement; see `getSheetAuthorshipStatement` + -> SubmissionId + -> UserId + -> ReaderT backend m AuthorshipStatementSubmissionState +getUserAuthorshipStatement mASDefinition subId uid = runConduit $ + getStmts + .| fmap toRes (execWriterC . C.mapM_ $ tell . toPoint) + where + getStmts = E.selectSource . E.from $ \authorshipStatementSubmission -> do + E.where_ $ authorshipStatementSubmission E.^. AuthorshipStatementSubmissionSubmission E.==. E.val subId + E.&&. authorshipStatementSubmission E.^. AuthorshipStatementSubmissionUser E.==. E.val uid + return authorshipStatementSubmission + toPoint :: Entity AuthorshipStatementSubmission -> Maybe Any + toPoint (Entity _ AuthorshipStatementSubmission{..}) = Just . Any $ fmap entityKey mASDefinition == Just authorshipStatementSubmissionStatement + toRes :: Maybe Any -> AuthorshipStatementSubmissionState + toRes = \case + Just (Any True) -> ASExists + Just (Any False) -> ASOldStatement + Nothing -> ASMissing + +getSubmissionAuthorshipStatement :: ( MonadResource m + , IsSqlBackend backend, SqlBackendCanRead backend + ) + => Maybe (Entity AuthorshipStatementDefinition) -- ^ Currently expected authorship statement; see `getSheetAuthorshipStatement` + -> SubmissionId + -> ReaderT backend m AuthorshipStatementSubmissionState +getSubmissionAuthorshipStatement mASDefinition subId = fmap (fromMaybe ASMissing) . runConduit $ + sourceSubmissionUsers + .| C.map E.unValue + .| C.mapM getUserAuthorshipStatement' + .| C.maximum + where + getUserAuthorshipStatement' = getUserAuthorshipStatement mASDefinition subId + sourceSubmissionUsers = E.selectSource . E.from $ \submissionUser -> do + E.where_ $ submissionUser E.^. SubmissionUserSubmission E.==. E.val subId + return $ submissionUser E.^. SubmissionUserUser diff --git a/src/Handler/Utils/Table/Pagination.hs b/src/Handler/Utils/Table/Pagination.hs index 7c3cc3b3f..bc0ad7619 100644 --- a/src/Handler/Utils/Table/Pagination.hs +++ b/src/Handler/Utils/Table/Pagination.hs @@ -45,7 +45,8 @@ module Handler.Utils.Table.Pagination , maybeAnchorCellM, maybeAnchorCellM', maybeLinkEitherCellM' , anchorCellC, anchorCellCM, anchorCellCM', linkEitherCellCM', maybeLinkEitherCellCM' , cellTooltip - , listCell, listCell' + , listCell, listCell', listCellOf, listCellOf' + , ilistCell, ilistCell', ilistCellOf, ilistCellOf' , formCell, DBFormResult(..), getDBFormResult , dbSelect , (&) @@ -1170,7 +1171,6 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db && all (is _Just) filterSql psLimit' = bool PagesizeAll psLimit selectPagesize - rows' <- E.select . E.from $ \t -> do res <- dbtSQLQuery t @@ -1183,10 +1183,10 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db Nothing | PagesizeLimit l <- psLimit' , selectPagesize + , hasn't (_FormSuccess . _DBCsvExport) csvMode -> do - unless (has (_FormSuccess . _DBCsvExport) csvMode) $ - E.limit l - E.offset (psPage * l) + E.limit l + E.offset $ psPage * l Just ps -> E.where_ $ dbtRowKey t `E.sqlIn` ps _other -> return () Map.foldr (\fc expr -> maybe (return ()) (E.where_ . ($ t)) fc >> expr) (return ()) filterSql @@ -1793,12 +1793,30 @@ listCell :: (IsDBTable m a, MonoFoldable mono) => mono -> (Element mono -> DBCel listCell = listCell' . return listCell' :: (IsDBTable m a, MonoFoldable mono) => WriterT a m mono -> (Element mono -> DBCell m a) -> DBCell m a -listCell' mkXS mkCell = review dbCell . ([], ) $ do +listCell' mkXS mkCell = ilistCell' (otoList <$> mkXS) $ const mkCell + +ilistCell :: (IsDBTable m a, MonoFoldableWithKey mono) => mono -> (MonoKey mono -> Element mono -> DBCell m a) -> DBCell m a +ilistCell = ilistCell' . return + +ilistCell' :: (IsDBTable m a, MonoFoldableWithKey mono) => WriterT a m mono -> (MonoKey mono -> Element mono -> DBCell m a) -> DBCell m a +ilistCell' mkXS mkCell = review dbCell . ([], ) $ do xs <- mkXS - cells <- forM (toList xs) $ - \(view dbCell . mkCell -> (attrs, mkWidget)) -> (attrs, ) <$> mkWidget + cells <- forM (otoKeyedList xs) $ + \(view dbCell . uncurry mkCell -> (attrs, mkWidget)) -> (attrs, ) <$> mkWidget return $(widgetFile "table/cell/list") +listCellOf :: IsDBTable m a' => Getting (Endo [a]) s a -> s -> (a -> DBCell m a') -> DBCell m a' +listCellOf l x = listCell (x ^.. l) + +listCellOf' :: IsDBTable m a' => Getting (Endo [a]) s a -> WriterT a' m s -> (a -> DBCell m a') -> DBCell m a' +listCellOf' l mkX = listCell' (toListOf l <$> mkX) + +ilistCellOf :: IsDBTable m a' => IndexedGetting i (Endo [(i, a)]) s a -> s -> (i -> a -> DBCell m a') -> DBCell m a' +ilistCellOf l x = listCell (itoListOf l x) . uncurry + +ilistCellOf' :: IsDBTable m a' => IndexedGetting i (Endo [(i, a)]) s a -> WriterT a' m s -> (i -> a -> DBCell m a') -> DBCell m a' +ilistCellOf' l mkX = listCell' (itoListOf l <$> mkX) . uncurry + newtype DBFormResult i a r = DBFormResult (Map i (r, a -> a)) instance Functor (DBFormResult i a) where diff --git a/src/Handler/Utils/Workflow/Form.hs b/src/Handler/Utils/Workflow/Form.hs index 8dfc47982..0ac389ebc 100644 --- a/src/Handler/Utils/Workflow/Form.hs +++ b/src/Handler/Utils/Workflow/Form.hs @@ -70,6 +70,7 @@ instance ToJSON (FileField FileIdent) where , pure $ "multiple" JSON..= fieldMultiple , pure $ "restrict-extensions" JSON..= fieldRestrictExtensions , pure $ "max-file-size" JSON..= fieldMaxFileSize + , pure $ "max-cumulative-size" JSON..= fieldMaxCumulativeSize , pure $ "additional-files" JSON..= addFiles' ] where addFiles' = unFileIdentFileReferenceTitleMap fieldAdditionalFiles <&> \FileIdentFileReferenceTitleMapElem{..} -> JSON.object @@ -83,6 +84,7 @@ instance FromJSON (FileField FileIdent) where fieldMultiple <- o JSON..: "multiple" fieldRestrictExtensions <- o JSON..:? "restrict-extensions" fieldMaxFileSize <- o JSON..:? "max-file-size" + fieldMaxCumulativeSize <- o JSON..:? "max-cumulative-size" fieldAllEmptyOk <- o JSON..:? "all-empty-ok" JSON..!= True addFiles' <- o JSON..:? "additional-files" JSON..!= mempty fieldAdditionalFiles <- fmap FileIdentFileReferenceTitleMap . for addFiles' $ JSON.withObject "FileIdentFileReferenceTitleMapElem" $ \o' -> do diff --git a/src/Import/NoModel.hs b/src/Import/NoModel.hs index c83f7a041..fc51f0302 100644 --- a/src/Import/NoModel.hs +++ b/src/Import/NoModel.hs @@ -24,6 +24,7 @@ import ClassyPrelude.Yesod as Import , authorizationCheck , mkMessage, mkMessageFor, mkMessageVariant , YesodBreadcrumbs(..) + , MonoZip(..), ozipWith ) import UnliftIO.Async.Utils as Import @@ -124,6 +125,11 @@ import Control.Monad.Trans.State as Import ( State, runState, mapState, withState , StateT(..), mapStateT, withStateT ) +import Control.Monad.Trans.Accum as Import + ( Accum, runAccum, mapAccum + , AccumT, runAccumT, execAccumT, evalAccumT, mapAccumT + , look, looks, add + ) import Control.Monad.State.Class as Import (MonadState(state)) import Control.Monad.Trans.Writer.Lazy as Import ( Writer, runWriter, mapWriter, execWriter @@ -248,6 +254,8 @@ import Data.Scientific as Import (Scientific, formatScientific) import Data.MultiSet as Import (MultiSet) +import Data.MonoTraversable.Keys as Import + import Control.Monad.Trans.RWS (RWST) diff --git a/src/Jobs/Handler/Files.hs b/src/Jobs/Handler/Files.hs index 7ab592eb1..de6787c0d 100644 --- a/src/Jobs/Handler/Files.hs +++ b/src/Jobs/Handler/Files.hs @@ -47,6 +47,9 @@ import qualified Data.Foldable as F import qualified Control.Monad.State.Class as State +import Jobs.Types +import Data.Aeson.Lens (_JSON) + dispatchJobPruneSessionFiles :: JobHandler UniWorX dispatchJobPruneSessionFiles = JobHandlerAtomicWithFinalizer act fin @@ -83,6 +86,9 @@ workflowFileReferences = Map.fromList $ over (traverse . _1) nameToPathPiece , (''WorkflowWorkflow, E.selectSource (E.from $ pure . (E.^. WorkflowWorkflowState )) .| awaitForever (mapMOf_ (typesCustom @WorkflowChildren . _fileReferenceContent . _Just) yield . E.unValue)) ] +jobFileReferences :: MonadResource m => ConduitT () FileContentReference (SqlPersistT m) () +jobFileReferences = E.selectSource (E.from $ pure . (E.^. QueuedJobContent)) .| C.mapMaybe (preview _JSON . E.unValue) .| awaitForever (mapMOf_ (typesCustom @JobChildren @Job @Job @FileContentReference @FileContentReference) yield) + dispatchJobDetectMissingFiles :: JobHandler UniWorX dispatchJobDetectMissingFiles = JobHandlerAtomicDeferrableWithFinalizer act fin @@ -103,8 +109,10 @@ dispatchJobDetectMissingFiles = JobHandlerAtomicDeferrableWithFinalizer act fin E.distinctOnOrderBy [E.asc ref] $ return ref transPipe lift (E.selectSource fileReferencesQuery) .| C.mapMaybe E.unValue .| C.mapM_ (insertRef refKind) - iforM_ workflowFileReferences $ \refKind refSource -> - transPipe (lift . withReaderT projectBackend) (refSource .| C.filterM (\ref -> not <$> exists [FileContentEntryHash ==. ref])) .| C.mapM_ (insertRef refKind) + let useRefSource refKind refSource = transPipe (lift . withReaderT projectBackend) (refSource .| C.filterM (\ref -> not <$> exists [FileContentEntryHash ==. ref])) .| C.mapM_ (insertRef refKind) + iforM_ workflowFileReferences useRefSource + useRefSource (nameToPathPiece ''Job) jobFileReferences + let allMissingDb :: Set Minio.Object allMissingDb = setOf (folded . folded . re minioFileReference) missingDb @@ -204,14 +212,16 @@ dispatchJobPruneUnreferencedFiles numIterations epoch iteration = JobHandlerAtom return $ E.any E.exists (fileReferences $ fileContentEntry E.^. FileContentEntryHash) E.where_ $ chunkIdFilter unreferencedChunkHash - let unmarkWorkflowFiles (otoList -> fRefs) = E.delete . E.from $ \fileContentChunkUnreferenced -> do + let unmarkSourceFiles (otoList -> fRefs) = E.delete . E.from $ \fileContentChunkUnreferenced -> do let unreferencedChunkHash = E.unKey $ fileContentChunkUnreferenced E.^. FileContentChunkUnreferencedHash E.where_ . E.subSelectOr . E.from $ \fileContentEntry -> do E.where_ $ fileContentEntry E.^. FileContentEntryChunkHash E.==. unreferencedChunkHash return $ fileContentEntry E.^. FileContentEntryHash `E.in_` E.valList fRefs E.where_ $ chunkIdFilter unreferencedChunkHash + unmarkRefSource refSource = runConduit $ refSource .| C.map Seq.singleton .| C.chunksOfE chunkSize .| C.mapM_ unmarkSourceFiles chunkSize = 100 - in runConduit $ sequence_ workflowFileReferences .| C.map Seq.singleton .| C.chunksOfE chunkSize .| C.mapM_ unmarkWorkflowFiles + unmarkRefSource $ sequence_ workflowFileReferences + unmarkRefSource jobFileReferences let getEntryCandidates = E.selectSource . E.from $ \fileContentEntry -> do diff --git a/src/Jobs/Handler/SendCourseCommunication.hs b/src/Jobs/Handler/SendCourseCommunication.hs index 712fd4beb..7a3433645 100644 --- a/src/Jobs/Handler/SendCourseCommunication.hs +++ b/src/Jobs/Handler/SendCourseCommunication.hs @@ -20,10 +20,9 @@ dispatchJobSendCourseCommunication :: Either UserEmail UserId -> CourseId -> UserId -> UUID - -> Maybe Text - -> Html + -> CommunicationContent -> JobHandler UniWorX -dispatchJobSendCourseCommunication jRecipientEmail jAllRecipientAddresses jCourse jSender jMailObjectUUID jSubject jMailContent = JobHandlerException $ do +dispatchJobSendCourseCommunication jRecipientEmail jAllRecipientAddresses jCourse jSender jMailObjectUUID CommunicationContent{..} = JobHandlerException $ do (sender, Course{..}) <- runDB $ (,) <$> getJust jSender <*> getJust jCourse @@ -34,8 +33,9 @@ dispatchJobSendCourseCommunication jRecipientEmail jAllRecipientAddresses jCours _mailFrom .= userAddressFrom sender addMailHeader "Cc" [st|#{mr MsgCommUndisclosedRecipients}:;|] addMailHeader "Auto-Submitted" "no" - setSubjectI . prependCourseTitle courseTerm courseSchool courseShorthand $ maybe (SomeMessage MsgCommCourseSubject) SomeMessage jSubject + setSubjectI . prependCourseTitle courseTerm courseSchool courseShorthand $ maybe (SomeMessage MsgCommCourseSubject) SomeMessage ccSubject addHtmlMarkdownAlternatives ($(ihamletFile "templates/mail/courseCommunication.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX)) + forM_ ccAttachments $ addPart' . toMailPart when (jRecipientEmail == Right jSender) $ addPart' $ do partIsAttachmentCsv MsgCommAllRecipients diff --git a/src/Jobs/Handler/SendPasswordReset.hs b/src/Jobs/Handler/SendPasswordReset.hs index 0832b4453..5e73b8949 100644 --- a/src/Jobs/Handler/SendPasswordReset.hs +++ b/src/Jobs/Handler/SendPasswordReset.hs @@ -34,7 +34,7 @@ dispatchJobSendPasswordReset jRecipient = JobHandlerException . userMailT jRecip let resetBearer = resetBearer' & bearerRestrict (UserPasswordR cID) (decodeUtf8 . Base64.encode . BA.convert $ computeUserAuthenticationDigest userAuthentication) encodedBearer <- encodeBearer resetBearer - + resetUrl <- toTextUrl (UserPasswordR cID, [(toPathPiece GetBearer, toPathPiece encodedBearer)]) - + activeTime <- formatTimeMail SelFormatDateTime tomorrowEndOfDay addHtmlMarkdownAlternatives ($(ihamletFile "templates/mail/passwordReset.hamlet") :: HtmlUrlI18n (SomeMessage UniWorX) (Route UniWorX)) diff --git a/src/Jobs/Types.hs b/src/Jobs/Types.hs index d01f0c532..067a1ccb1 100644 --- a/src/Jobs/Types.hs +++ b/src/Jobs/Types.hs @@ -44,7 +44,7 @@ import Cron (CronNextMatch(..), _MatchAsap, _MatchAt, _MatchNone) import System.Clock (getTime, Clock(Monotonic), TimeSpec) import GHC.Conc (unsafeIOToSTM) -import Data.Generics.Product.Types (Children, ChGeneric) +import Data.Generics.Product.Types (Children, ChGeneric, HasTypesCustom(..)) {-# ANN module ("HLint: ignore Use newtype instead of data" :: String) #-} @@ -67,8 +67,7 @@ data Job , jCourse :: CourseId , jSender :: UserId , jMailObjectUUID :: UUID - , jSubject :: Maybe Text - , jMailContent :: Html + , jMailContent :: CommunicationContent } | JobInvitation { jInviter :: Maybe UserId , jInvitee :: UserEmail @@ -171,10 +170,14 @@ type family ChildrenJobChildren a where ChildrenJobChildren UUID = '[] ChildrenJobChildren (Key a) = '[] ChildrenJobChildren (CI a) = '[] - ChildrenJobChildren (Set a) = '[] + ChildrenJobChildren (Set v) = '[v] ChildrenJobChildren MailContext = '[] + ChildrenJobChildren (Digest a) = '[] ChildrenJobChildren a = Children ChGeneric a + +instance (Ord b', HasTypesCustom JobChildren a' b' a b) => HasTypesCustom JobChildren (Set a') (Set b') a b where + typesCustom = iso Set.toList Set.fromList . traverse . typesCustom @JobChildren classifyJob :: Job -> String diff --git a/src/Mail.hs b/src/Mail.hs index 827467b8e..4a3d560fb 100644 --- a/src/Mail.hs +++ b/src/Mail.hs @@ -42,6 +42,7 @@ import Data.Kind (Type) import Model.Types.Languages import Model.Types.Csv +import Model.Types.File import Network.Mail.Mime hiding (addPart, addAttachment) import qualified Network.Mail.Mime as Mime (addPart) @@ -89,7 +90,7 @@ import qualified Data.Binary as Binary import "network-bsd" Network.BSD (getHostName) import Data.Time.Zones (utcTZ, utcToLocalTimeTZ, timeZoneForUTCTime) -import Data.Time.LocalTime (ZonedTime(..), TimeZone(..)) +import Data.Time.LocalTime (ZonedTime(..), TimeZone(..), utcToZonedTime, utc) import Data.Time.Format (rfc822DateFormat) import Network.HaskellNet.SMTP (SMTPConnection) @@ -123,6 +124,12 @@ import Language.Haskell.TH (nameBase) import Network.Mail.Mime.Instances() +import Data.Char (isLatin1) +import Data.Text.Lazy.Encoding (decodeUtf8') +import System.FilePath (takeFileName) +import Network.HTTP.Types.Header (hETag) +import Web.HttpApiData (ToHttpApiData(toHeader)) + makeLenses_ ''Address makeLenses_ ''Mail @@ -346,6 +353,20 @@ instance YesodMail site => ToMailPart site Html where _partEncoding .= QuotedPrintableText _partContent .= PartContent (renderMarkup html) +instance YesodMail site => ToMailPart site PureFile where + toMailPart file@File{fileTitle, fileModified} = do + _partDisposition .= AttachmentDisposition (pack $ takeFileName fileTitle) + _partType .= decodeUtf8 (mimeLookup $ pack fileTitle) + let + content :: LBS.ByteString + content = file ^. _pureFileContent . _Just + isLatin = either (const False) (all isLatin1) $ decodeUtf8' content + _partEncoding .= bool Base64 QuotedPrintableText isLatin + _partContent .= PartContent content + forM_ (file ^. _FileReference . _1 . _fileReferenceContent) $ \fRefContent -> + replaceMailHeader (CI.original hETag) . Just . decodeUtf8 . toHeader $ etagFileReference # fRefContent + replaceMailHeader (CI.original hLastModified) . Just . pack . formatTime defaultTimeLocale rfc822DateFormat $ utcToZonedTime utc fileModified + instance (ToMailPart site a, RenderMessage site msg) => ToMailPart site (Hamlet.Translate msg -> a) where type MailPartReturn site (Hamlet.Translate msg -> a) = MailPartReturn site a toMailPart act = do diff --git a/src/Model/Types.hs b/src/Model/Types.hs index 9a69e1bbb..ebeb35dde 100644 --- a/src/Model/Types.hs +++ b/src/Model/Types.hs @@ -24,3 +24,4 @@ import Model.Types.Markup as Types import Model.Types.Room as Types import Model.Types.Csv as Types import Model.Types.Upload as Types +import Model.Types.Communication as Types diff --git a/src/Model/Types/Communication.hs b/src/Model/Types/Communication.hs new file mode 100644 index 000000000..b21f3e101 --- /dev/null +++ b/src/Model/Types/Communication.hs @@ -0,0 +1,21 @@ +module Model.Types.Communication + ( CommunicationContent(..), _ccSubject, _ccBody, _ccAttachments + ) where + +import Import.NoModel +import Model.Types.File + +import Utils.Lens.TH + + +data CommunicationContent = CommunicationContent + { ccSubject :: Maybe Text + , ccBody :: Html + , ccAttachments :: Set FileReference + } deriving stock (Eq, Ord, Show, Read, Generic, Typeable) + deriving anyclass (Hashable, NFData) + +deriveJSON defaultOptions + { constructorTagModifier = camelToPathPiece' 1 + } ''CommunicationContent +makeLenses_ ''CommunicationContent diff --git a/src/Model/Types/Course.hs b/src/Model/Types/Course.hs index e9779a486..7ede7a7e7 100644 --- a/src/Model/Types/Course.hs +++ b/src/Model/Types/Course.hs @@ -14,7 +14,7 @@ import Model.Types.TH.PathPiece import Utils.Lens.TH -data LecturerType = CourseLecturer | CourseAssistant +data LecturerType = CourseLecturer | CourseAssistant | CourseAdministrator deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable) deriving (Universe, Finite, NFData) diff --git a/src/Model/Types/DateTime.hs b/src/Model/Types/DateTime.hs index 76d427ed9..8f9a3bd28 100644 --- a/src/Model/Types/DateTime.hs +++ b/src/Model/Types/DateTime.hs @@ -133,6 +133,8 @@ instance ToJSON TermIdentifier where instance FromJSON TermIdentifier where parseJSON = withText "Term" $ either (fail . Text.unpack) return . termFromText +pathPieceCsv ''TermIdentifier + {- Must be defined in a later module: termField :: Field (HandlerT UniWorX IO) TermIdentifier termField = checkMMap (return . termFromText) termToText textField diff --git a/src/Model/Types/File.hs b/src/Model/Types/File.hs index 0a3819c28..2d26ae6ce 100644 --- a/src/Model/Types/File.hs +++ b/src/Model/Types/File.hs @@ -18,7 +18,24 @@ module Model.Types.File , _fieldIdent, _fieldUnpackZips, _fieldMultiple, _fieldRestrictExtensions, _fieldAdditionalFiles, _fieldMaxFileSize ) where -import Import.NoModel +import ClassyPrelude.Yesod hiding (snoc, (.=), getMessageRender, derivePersistFieldJSON, Proxy(..)) +import Crypto.Hash (Digest, SHA3_512) +import Language.Haskell.TH.Syntax (Lift) +import Data.Binary (Binary) +import Crypto.Hash.Instances () +import Data.Proxy (Proxy(..)) +import Control.Lens +import Utils.HttpConditional +import Data.Binary.Instances.Time () +import Data.Time.Clock.Instances () +import Data.Aeson.TH +import Utils +import Data.Kind (Type) +import Data.Universe +import Numeric.Natural +import Network.Mime +import Control.Monad.Morph +import Data.NonNull.Instances () import Database.Persist.Sql (PersistFieldSql(..)) import Web.HttpApiData (ToHttpApiData, FromHttpApiData) @@ -204,7 +221,6 @@ instance HasFileReference FileReference where instance HasFileReference PureFile where newtype FileReferenceResidual PureFile = PureFileResidual { unPureFileResidual :: Maybe ByteString } deriving (Eq, Ord, Read, Show, Generic, Typeable) - deriving newtype (ToJSON, FromJSON) deriving anyclass (NFData) _FileReference = iso toFileReference fromFileReference @@ -293,7 +309,7 @@ data FileField fileid = FileField , fieldUnpackZips :: FileFieldUserOption Bool , fieldMultiple :: Bool , fieldRestrictExtensions :: Maybe (NonNull (Set Extension)) - , fieldMaxFileSize :: Maybe Natural + , fieldMaxFileSize, fieldMaxCumulativeSize :: Maybe Natural , fieldAdditionalFiles :: FileReferenceTitleMap fileid (FileFieldUserOption Bool) , fieldAllEmptyOk :: Bool } @@ -311,6 +327,7 @@ instance ToJSON (FileField FileReference) where , pure $ "multiple" JSON..= fieldMultiple , pure $ "restrict-extensions" JSON..= fieldRestrictExtensions , pure $ "max-file-size" JSON..= fieldMaxFileSize + , pure $ "max-cumulative-size" JSON..= fieldMaxCumulativeSize , pure $ "additional-files" JSON..= addFiles' , pure $ "all-empty-ok" JSON..= fieldAllEmptyOk ] @@ -326,6 +343,7 @@ instance FromJSON (FileField FileReference) where fieldMultiple <- o JSON..: "multiple" fieldRestrictExtensions <- o JSON..:? "restrict-extensions" fieldMaxFileSize <- o JSON..:? "max-file-size" + fieldMaxCumulativeSize <- o JSON..:? "max-cumulative-size" fieldAllEmptyOk <- o JSON..:? "all-empty-ok" JSON..!= True addFiles' <- o JSON..:? "additional-files" JSON..!= mempty fieldAdditionalFiles <- fmap FileReferenceFileReferenceTitleMap . for addFiles' $ JSON.withObject "FileReferenceFileReferenceTitleMapElem" $ \o' -> do diff --git a/src/Model/Types/Submission.hs b/src/Model/Types/Submission.hs index dc82ff2a1..411e7b8b7 100644 --- a/src/Model/Types/Submission.hs +++ b/src/Model/Types/Submission.hs @@ -130,3 +130,27 @@ pseudonymWords = folding pseudonymFragments :: Fold Text [PseudonymWord] pseudonymFragments = folding $ mapM (toListOf pseudonymWords) . (\l -> guard (length l == 2) *> l) . filter (not . null) . Text.split (\(CI.mk -> c) -> not $ Set.member c pseudonymCharacters) + + +instance PathPiece Pseudonym where + toPathPiece = review _PseudonymText + fromPathPiece t + | Just p <- t ^? _PseudonymText = Just p + | Just n <- fromPathPiece t = Just $ Pseudonym n + | otherwise = Nothing + +pathPieceCsv ''Pseudonym + + +data AuthorshipStatementSubmissionState + = ASMissing + | ASOldStatement + | ASExists + deriving (Eq, Read, Show, Enum, Bounded, Generic, Typeable) + deriving anyclass (Universe, Finite) + +deriving stock instance Ord AuthorshipStatementSubmissionState -- ^ Larger roughly encodes better; summaries are taken with `max` + +nullaryPathPiece ''AuthorshipStatementSubmissionState $ camelToPathPiece' 1 +pathPieceCsv ''AuthorshipStatementSubmissionState +pathPieceJSON ''AuthorshipStatementSubmissionState diff --git a/src/Settings.hs b/src/Settings.hs index 4e672e231..c4f977eb9 100644 --- a/src/Settings.hs +++ b/src/Settings.hs @@ -230,6 +230,8 @@ data AppSettings = AppSettings , appVolatileClusterSettingsCacheTime :: DiffTime , appJobMaxFlush :: Maybe Natural + + , appCommunicationAttachmentsMaxSize :: Maybe Natural } deriving Show data JobMode = JobsLocal { jobsAcceptOffload :: Bool } @@ -700,6 +702,8 @@ instance FromJSON AppSettings where appJobMaxFlush <- o .:? "job-max-flush" + appCommunicationAttachmentsMaxSize <- o .:? "communication-attachments-max-size" + return AppSettings{..} where isValidARCConf ARCConf{..} = arccMaximumWeight > 0 diff --git a/src/Utils/Csv.hs b/src/Utils/Csv.hs index 7070720b1..850ef9af1 100644 --- a/src/Utils/Csv.hs +++ b/src/Utils/Csv.hs @@ -10,6 +10,7 @@ module Utils.Csv , toCsvRendered , toDefaultOrderedCsvRendered , csvRenderedToXlsx, Xlsx, Xlsx.fromXlsx + , CsvSemicolonList(..) ) where import ClassyPrelude hiding (lookup) @@ -39,6 +40,19 @@ import qualified Data.Text as Text import qualified Data.Text.Encoding as Text import qualified Data.CaseInsensitive as CI +import qualified Data.Binary.Builder as Builder +import qualified Data.ByteString.Lazy as LBS +import qualified Data.Attoparsec.ByteString as Attoparsec + +import qualified Data.Csv.Parser as Csv +import qualified Data.Csv.Builder as Csv + +import qualified Data.Vector as Vector + +import Data.Char (ord) + +import Control.Monad.Fail + deriving instance Typeable CsvParseError instance Exception CsvParseError @@ -114,3 +128,27 @@ csvRenderedToXlsx sheetName CsvRendered{..} = def & Xlsx.atSheet sheetName ?~ (d addValues = flip foldMap (zip [2..] csvRenderedData) $ \(r, nr) -> flip foldMap (zip [1..] $ toList csvRenderedHeader) $ \(c, hBS) -> case HashMap.lookup hBS nr of Nothing -> mempty Just vBS -> Endo $ Xlsx.cellValueAtRC (r, c) ?~ Xlsx.CellText (decodeUtf8 vBS) + + +newtype CsvSemicolonList a = CsvSemicolonList { unCsvSemicolonList :: [a] } + deriving stock (Read, Show, Generic, Typeable) + deriving newtype (Eq, Ord) + +instance ToField a => ToField (CsvSemicolonList a) where + toField (CsvSemicolonList xs) = dropEnd 2 . LBS.toStrict . Builder.toLazyByteString $ Csv.encodeRecordWith encOpts fs + where + fs = map toField xs + encOpts = defaultEncodeOptions + { encDelimiter = fromIntegral $ ord ';' + , encQuoting = case fs of + [fStr] | null fStr -> QuoteAll + _other -> QuoteMinimal + , encUseCrLf = True + } + +instance FromField a => FromField (CsvSemicolonList a) where + parseField f + | null f = pure $ CsvSemicolonList [] + | otherwise = fmap CsvSemicolonList . mapM parseField . Vector.toList <=< either fail return $ Attoparsec.parseOnly (Csv.record sep) f + where + sep = fromIntegral $ ord ';' diff --git a/templates/course.hamlet b/templates/course.hamlet index 2205d1f73..de6452829 100644 --- a/templates/course.hamlet +++ b/templates/course.hamlet @@ -93,6 +93,13 @@ $# #{summary}
- Bitte bedenken Sie beim Stellen Ihrer Anfrage, dass das # - Uni2work-Kernteam aktuell aus Sarah Vaupel und Gregor Kleen besteht # - und zwei Personen nicht hinreichend sind um in allen Fällen eine # - zeitnahe Bearbeitung Ihres Anliegens zu garantieren. + Bitte bedenken Sie beim Stellen Ihrer Anfrage, dass das Uni2work-Kernteam aus # + Sarah Vaupel # + besteht und # + eine Person nicht hinreichend ist, # + um in allen Fällen eine zeitnahe Bearbeitung Ihres Anliegens zu garantieren.
Falls sich Ihr Anliegen auf eine konkrete Veranstaltung bezieht, # ziehen Sie bitte auch in Betracht (insbesondere bei zeitkritischen # - Anliegen wie z.B. Abgaben) sich direkt an die Kursverwalter zu # - wenden. + Anliegen wie z.B. Abgaben) sich direkt an die Kursverwalter zu wenden. diff --git a/templates/i18n/help-instructions/en-eu.hamlet b/templates/i18n/help-instructions/en-eu.hamlet index 65e205bae..fe2b19102 100644 --- a/templates/i18n/help-instructions/en-eu.hamlet +++ b/templates/i18n/help-instructions/en-eu.hamlet @@ -2,14 +2,16 @@ $newline never
- When formulating your request please consider that the Uni2work core # - team currently consists of Sarah Vaupel and Gregor Kleen and that # - two people are not enough to guarantee a timely answer in all cases. + When formulating your request, please consider that the Uni2work core team consists of # + Sarah Vaupel # + and that # + one person is # + not enough to guarantee a timely answer in all cases.
If your request is related to a specific course, please also # consider contacting the relevant course administrators as well. # - Especially if your request is time sensitive (e.g. submitting for an # - exercise sheet). + Especially if your request is time sensitive (e.g. submitting for # + an exercise sheet). diff --git a/templates/i18n/imprint/de-de-formal.hamlet b/templates/i18n/imprint/de-de-formal.hamlet index d415a5780..af4c29eca 100644 --- a/templates/i18n/imprint/de-de-formal.hamlet +++ b/templates/i18n/imprint/de-de-formal.hamlet @@ -3,12 +3,12 @@ $newline never