Merge branch 'master' into feat/external-apis
This commit is contained in:
commit
605b7758e6
@ -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"
|
||||
|
||||
133
CHANGELOG.md
133
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)
|
||||
|
||||
|
||||
|
||||
@ -294,3 +294,5 @@ bot-mitigations:
|
||||
- only-logged-in-table-sorting
|
||||
|
||||
volatile-cluster-settings-cache-time: 10
|
||||
|
||||
communication-attachments-max-size: 20971520 # 20MiB
|
||||
|
||||
103
frontend/src/lib/event-manager/event-manager.js
Normal file
103
frontend/src/lib/event-manager/event-manager.js
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
|
||||
22
frontend/src/lib/tooltips/frontend-tooltips.js
Normal file
22
frontend/src/lib/tooltips/frontend-tooltips.js
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
17
frontend/src/messages.js
Normal file
17
frontend/src/messages.js
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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' })
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 });
|
||||
// }
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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]));
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
27
frontend/src/utils/form/auto-submit-button.spec.js
Normal file
27
frontend/src/utils/form/auto-submit-button.spec.js
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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 = () => {
|
||||
|
||||
30
frontend/src/utils/form/auto-submit-input.spec.js
Normal file
30
frontend/src/utils/form/auto-submit-input.spec.js
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
8
frontend/src/utils/form/enter-is-tab.md
Normal file
8
frontend/src/utils/form/enter-is-tab.md
Normal file
@ -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:
|
||||
<input type="text" value="" uw-enter-as-tab="" class=" enter-as-tab--initialized">
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
125
frontend/src/utils/inputs/checkrange.js
Normal file
125
frontend/src/utils/inputs/checkrange.js
Normal file
@ -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));
|
||||
}
|
||||
}
|
||||
5
frontend/src/utils/inputs/checkrange.md
Normal file
5
frontend/src/utils/inputs/checkrange.md
Normal file
@ -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)
|
||||
@ -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() {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -21,7 +21,7 @@ export class SortTable {
|
||||
}
|
||||
|
||||
destroy() {
|
||||
console.log('TBD destroy SortTable');
|
||||
this._storageManager.clear();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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, '');
|
||||
}
|
||||
};
|
||||
|
||||
@ -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
|
||||
CourseApply: Zum Kurs bewerben
|
||||
CourseAdministrator: Kursadministrator:in
|
||||
@ -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
|
||||
@ -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!
|
||||
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
|
||||
@ -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
|
||||
|
||||
@ -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!
|
||||
@ -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}!
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "uni2work",
|
||||
"version": "25.20.2",
|
||||
"version": "25.25.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "uni2work",
|
||||
"version": "25.20.2",
|
||||
"version": "25.25.0",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
@ -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
|
||||
|
||||
4
routes
4
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
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
23
src/Mail.hs
23
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
|
||||
|
||||
@ -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
|
||||
|
||||
21
src/Model/Types/Communication.hs
Normal file
21
src/Model/Types/Communication.hs
Normal file
@ -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
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 ';'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user