Merge branch 'master' into feat/external-apis

This commit is contained in:
Gregor Kleen 2022-01-24 13:07:02 +01:00
commit 605b7758e6
116 changed files with 2697 additions and 911 deletions

View File

@ -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"

View File

@ -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)

View File

@ -294,3 +294,5 @@ bot-mitigations:
- only-logged-in-table-sorting
volatile-cluster-settings-cache-time: 10
communication-attachments-max-size: 20971520 # 20MiB

View 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;
}
}

View File

@ -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');
}

View 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
View 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;
}
}
};

View File

@ -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) {

View File

@ -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);
});
});
});
});

View File

@ -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);
}
}

View File

@ -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' })

View File

@ -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() {

View File

@ -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();

View File

@ -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);
});
}

View File

@ -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();

View File

@ -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);
});
}

View File

@ -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();

View File

@ -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 });
// }
}

View File

@ -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);
});
});

View File

@ -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() {

View File

@ -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]));
});
});

View File

@ -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) {

View File

@ -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) {

View File

@ -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);
}
}

View 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();
});
});

View File

@ -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 = () => {

View 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();
});
});

View File

@ -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);
}
}
}
}

View File

@ -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);
}

View File

@ -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);
}
}

View 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">

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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() {

View File

@ -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;
}
}

View File

@ -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);
});
}

View File

@ -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);

View File

@ -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);
}
}

View 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));
}
}

View 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)

View File

@ -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() {

View File

@ -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() {

View File

@ -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,
];

View File

@ -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() {

View File

@ -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);
}
}

View File

@ -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) => {

View File

@ -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) => {

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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() {

View File

@ -21,7 +21,7 @@ export class SortTable {
}
destroy() {
console.log('TBD destroy SortTable');
this._storageManager.clear();
}
}

View File

@ -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, '');
}
};

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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!

View File

@ -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}!

View File

@ -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.

View File

@ -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
View File

@ -1,6 +1,6 @@
{
"name": "uni2work",
"version": "25.20.2",
"version": "25.25.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "uni2work",
"version": "25.20.2",
"version": "25.25.0",
"description": "",
"keywords": [],
"author": "",

View File

@ -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
View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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

View 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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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