Merge branch 'master' into 205-klausuren

This commit is contained in:
Gregor Kleen 2019-05-29 14:46:12 +02:00
commit 5fdad339e3
77 changed files with 17083 additions and 3981 deletions

11
.babelrc Normal file
View File

@ -0,0 +1,11 @@
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "usage"
}
]
],
"plugins": ["@babel/plugin-proposal-class-properties"]
}

24
.eslintrc.json Normal file
View File

@ -0,0 +1,24 @@
{
"env": {
"browser": true,
"es6": true,
"jasmine": true
},
"extends": "eslint:recommended",
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly",
"flatpickr": "readonly",
"$": "readonly"
},
"parser": "babel-eslint",
"parserOptions": {
"ecmaVersion": 2018
},
"rules": {
"no-console": "off",
"no-extra-semi": "off",
"semi": ["error", "always"],
"comma-dangle": ["error", "always-multiline"]
}
}

4
.gitignore vendored
View File

@ -1,6 +1,8 @@
dist*
static/bundles/
static/tmp/
static/combined/
node_modules/
*.hi
*.o
*.sqlite3
@ -31,4 +33,4 @@ src/Handler/Course.SnapCustom.hs
.stack-work-*
.directory
tags
test.log
test.log

View File

@ -0,0 +1,2 @@
import './fetch';
import './url-search-params';

25
frontend/src/app.js Normal file
View File

@ -0,0 +1,25 @@
import { HttpClient } from './services/http-client/http-client';
import { HtmlHelpers } from './services/html-helpers/html-helpers';
import { I18n } from './services/i18n/i18n';
import { UtilRegistry } from './services/util-registry/util-registry';
export class App {
httpClient = new HttpClient();
htmlHelpers = new HtmlHelpers();
i18n = new I18n();
utilRegistry = new UtilRegistry();
constructor() {
this.utilRegistry.setApp(this);
}
registerUtilities(utils) {
if (!Array.isArray(utils)) {
throw new Error('Utils are expected to be passed as array!');
}
utils.forEach((util) => {
this.utilRegistry.register(util);
});
}
}

52
frontend/src/app.spec.js Normal file
View File

@ -0,0 +1,52 @@
import { App } from "./app";
const TEST_UTILS = [
{ name: 'util1' },
{ name: 'util2' },
];
describe('App', () => {
let app;
beforeEach(() => {
app = new App();
});
it('should create', () => {
expect(app).toBeTruthy();
});
describe('provides services', () => {
it('HttpClient as httpClient', () => {
expect(app.httpClient).toBeTruthy();
});
it('HtmlHelpers as htmlHelpers', () => {
expect(app.htmlHelpers).toBeTruthy();
});
it('I18n as i18n', () => {
expect(app.i18n).toBeTruthy();
});
it('UtilRegistry as utilRegistry', () => {
expect(app.utilRegistry).toBeTruthy();
});
});
describe('registerUtilities()', () => {
it('should register the given utilities', () => {
spyOn(app.utilRegistry, 'register');
app.registerUtilities(TEST_UTILS);
expect(app.utilRegistry.register.calls.count()).toBe(TEST_UTILS.length);
expect(app.utilRegistry.register.calls.argsFor(0)).toEqual([TEST_UTILS[0]]);
expect(app.utilRegistry.register.calls.argsFor(1)).toEqual([TEST_UTILS[1]]);
});
it('should throw an error if not passed an array of utilities', () => {
expect(() => {
app.registerUtilities({});
}).toThrow();
});
});
});

24
frontend/src/main.js Normal file
View File

@ -0,0 +1,24 @@
import { App } from './app';
import { Utils } from './utils/utils';
export const app = new App();
app.registerUtilities(Utils);
// attach the app to window to be able to get a hold of the
// app instance from the shakespearean templates
window.App = app;
// dont know where to put this currently...
// interceptor to throw an error if an http response does not match the expected content-type
// function contentTypeInterceptor(response, options) {
// if (!options || !options.headers.get('Accept')) {
// return;
// }
// const contentType = response.headers.get("content-type");
// if (!contentType.match(options.accept)) {
// throw new Error('Server returned with "' + contentType + '" when "' + options.accept + '" was expected');
// }
// }
// window.HttpClient.addResponseInterceptor(contentTypeInterceptor);

View File

@ -0,0 +1,41 @@
export class HtmlHelpers {
// `parseResponse` takes a raw HttpClient response and an options object.
// Returns an object with `element` being an contextual fragment of the
// HTML in the response and `ifPrefix` being the prefix that was used to
// "unique-ify" the ids of the received HTML.
// Original Response IDs can optionally be kept by adding `keepIds: true`
// to the `options` object.
parseResponse(response, options = {}) {
return response.text()
.then(
(responseText) => {
const docFrag = document.createRange().createContextualFragment(responseText);
let idPrefix = '';
if (!options.keepIds) {
idPrefix = this._getIdPrefix();
this._prefixIds(docFrag, idPrefix);
}
return Promise.resolve({ idPrefix, element: docFrag });
},
Promise.reject,
).catch(console.error);
}
_prefixIds(element, idPrefix) {
const idAttrs = ['id', 'for', 'data-conditional-input', 'data-modal-trigger'];
idAttrs.forEach((attr) => {
Array.from(element.querySelectorAll('[' + attr + ']')).forEach((input) => {
const value = idPrefix + input.getAttribute(attr);
input.setAttribute(attr, value);
});
});
}
_getIdPrefix() {
// leading 'r'(andom) to overcome the fact that IDs
// starting with a numeric value are not valid in CSS
return 'r' + Math.floor(Math.random() * 100000) + '__';
}
}

View File

@ -0,0 +1,56 @@
import { HtmlHelpers } from "./html-helpers";
describe('HtmlHelpers', () => {
let htmlHelpers;
beforeEach(() => {
htmlHelpers = new HtmlHelpers();
});
it('should create', () => {
expect(htmlHelpers).toBeTruthy();
});
describe('parseResponse()', () => {
let fakeHttpResponse;
beforeEach(() => {
fakeHttpResponse = {
text: () => Promise.resolve('<div id="test-div">Test</div>'),
};
});
it('should return a promise with idPrefix and element', (done) => {
htmlHelpers.parseResponse(fakeHttpResponse).then(result => {
expect(result.idPrefix).toBeDefined();
expect(result.element).toBeDefined();
expect(result.element.textContent).toMatch('Test');
done();
});
});
it('should nudge IDs', (done) => {
htmlHelpers.parseResponse(fakeHttpResponse).then(result => {
expect(result.idPrefix).toBeDefined();
expect(result.element).toBeDefined();
const elementWithOrigId = result.element.querySelector('#test-div');
expect(elementWithOrigId).toBeFalsy();
const elementWithNudgedId = result.element.querySelector('#' + result.idPrefix + 'test-div');
expect(elementWithNudgedId).toBeTruthy();
done();
});
});
it('should not nudge IDs with option "keepIds"', (done) => {
const options = { keepIds: true };
htmlHelpers.parseResponse(fakeHttpResponse, options).then(result => {
expect(result.idPrefix).toBe('');
expect(result.element).toBeDefined();
const elementWithOrigId = result.element.querySelector('#test-div');
expect(elementWithOrigId).toBeTruthy();
done();
});
});
});
});

View File

@ -0,0 +1,41 @@
export class HttpClient {
static ACCEPT = {
TEXT_HTML: 'text/html',
JSON: 'application/json',
};
_responseInterceptors = [];
addResponseInterceptor(interceptor) {
if (typeof interceptor === 'function') {
this._responseInterceptors.push(interceptor);
}
}
get(args) {
args.method = 'GET';
return this._fetch(args);
}
post(args) {
args.method = 'POST';
return this._fetch(args);
}
_fetch(options) {
const requestOptions = {
credentials: 'same-origin',
...options,
};
return fetch(options.url, requestOptions)
.then(
(response) => {
this._responseInterceptors.forEach((interceptor) => interceptor(response, options));
return Promise.resolve(response);
},
Promise.reject,
).catch(console.error);
}
}

View File

@ -0,0 +1,116 @@
import { HttpClient } from "./http-client";
const TEST_URL = 'http://example.com';
const FAKE_RESPONSE = {
data: 'data',
};
describe('HttpClient', () => {
let httpClient;
beforeEach(() => {
httpClient = new HttpClient();
// setup and spy on fake fetch API
spyOn(window, 'fetch').and.returnValue(Promise.resolve(FAKE_RESPONSE));
});
it('should create', () => {
expect(httpClient).toBeTruthy();
});
describe('get()', () => {
let params;
beforeEach(() => {
params = {
url: TEST_URL,
};
});
it('should GET the given url', () => {
httpClient.get(params);
expect(window.fetch).toHaveBeenCalledWith(params.url, jasmine.objectContaining({ method: 'GET' }));
});
it('should return a promise', (done) => {
const result = httpClient.get(params);
result.then((response) => {
expect(response).toEqual(FAKE_RESPONSE);
done();
});
});
});
describe('post()', () => {
let params;
beforeEach(() => {
params = {
url: TEST_URL,
};
});
it('should POST the given url', () => {
httpClient.post(params);
expect(window.fetch).toHaveBeenCalledWith(params.url, jasmine.objectContaining({ method: 'POST' }));
});
it('should return a promise', (done) => {
const result = httpClient.post(params);
result.then((response) => {
expect(response).toEqual(FAKE_RESPONSE);
done();
});
});
});
describe('Response Interceptors', () => {
it('can be added', () => {
const interceptor = () => {};
expect(httpClient._responseInterceptors.length).toBe(0);
httpClient.addResponseInterceptor(interceptor);
expect(httpClient._responseInterceptors.length).toBe(1);
httpClient.addResponseInterceptor(interceptor);
expect(httpClient._responseInterceptors.length).toBe(2);
});
describe('get called', () => {
let intercepted1;
let intercepted2;
const interceptors = {
interceptor1: () => intercepted1 = true,
interceptor2: () => intercepted2 = true,
};
beforeEach(() => {
intercepted1 = false;
intercepted2 = false;
spyOn(interceptors, 'interceptor1').and.callThrough();
spyOn(interceptors, 'interceptor2').and.callThrough();
httpClient.addResponseInterceptor(interceptors.interceptor1);
httpClient.addResponseInterceptor(interceptors.interceptor2);
});
it('for GET requests', (done) => {
httpClient.get({ url: TEST_URL }).then(() => {
expect(intercepted1).toBeTruthy();
expect(intercepted2).toBeTruthy();
expect(interceptors.interceptor1).toHaveBeenCalledWith(FAKE_RESPONSE, jasmine.any(Object));
expect(interceptors.interceptor2).toHaveBeenCalledWith(FAKE_RESPONSE, jasmine.any(Object));
done();
});
});
it('for POST requests', (done) => {
httpClient.post({ url: TEST_URL }).then(() => {
expect(intercepted1).toBeTruthy();
expect(intercepted2).toBeTruthy();
expect(interceptors.interceptor1).toHaveBeenCalledWith(FAKE_RESPONSE, jasmine.any(Object));
expect(interceptors.interceptor2).toHaveBeenCalledWith(FAKE_RESPONSE, jasmine.any(Object));
done();
});
});
});
});
});

View File

@ -0,0 +1,32 @@
/**
* I18n
*
* This module stores and serves translated strings, according to the users language settings.
*
* Translations are stored in /messages/frontend/*.msg.
*
* To make additions to any of these files accessible to JavaScrip Utilities
* you need to add them to the respective *.msg file and to the list of FrontendMessages
* in /src/Utils/Frontend/I18n.hs.
*
*/
export class I18n {
translations = {};
add(id, translation) {
this.translations[id] = translation;
}
addMany(manyTranslations) {
Object.keys(manyTranslations).forEach((key) => this.add(key, manyTranslations[key]));
}
get(id) {
if (!this.translations[id]) {
throw new Error('I18N Error: Translation missing for »' + id + '«!');
}
return this.translations[id];
}
}

View File

@ -0,0 +1,51 @@
import { I18n } from "./i18n";
describe('I18n', () => {
let i18n;
beforeEach(() => {
i18n = new I18n();
});
// helper function
function expectTranslation(id, value) {
expect(i18n.translations[id]).toMatch(value);
}
it('should create', () => {
expect(i18n).toBeTruthy();
});
describe('add()', () => {
it('should add the translation', () => {
i18n.add('id1', 'translated-id1');
expectTranslation('id1', 'translated-id1');
});
});
describe('addMany()', () => {
it('should add many translations', () => {
i18n.addMany({
id1: 'translated-id1',
id2: 'translated-id2',
id3: 'translated-id3',
});
expectTranslation('id1', 'translated-id1');
expectTranslation('id2', 'translated-id2');
expectTranslation('id3', 'translated-id3');
});
});
describe('get()', () => {
it('should return stored translations', () => {
i18n.translations.id1 = 'something';
expect(i18n.get('id1')).toMatch('something');
});
it('should throw error if translation is missing', () => {
expect(() => {
i18n.get('id1');
}).toThrow();
});
});
});

View File

@ -0,0 +1,122 @@
const DEBUG_MODE = /localhost/.test(window.location.href) && 0;
export class UtilRegistry {
_registeredUtils = [];
_activeUtilInstances = [];
_appInstance;
constructor() {
document.addEventListener('DOMContentLoaded', () => this.setupAll());
}
/**
* function registerUtil
*
* utils need to have at least these properties:
* name: string | utils name, e.g. 'example'
* selector: string | utils selector, e.g. '[uw-example]'
* setup: Function | utils setup function, see below
*
* setup function must return instance object with at least these properties:
* name: string | utils name
* element: HTMLElement | element the util is applied to
* destroy: Function | function to destroy the util and remove any listeners
*
* @param util Object Utility that should be added to the registry
*/
register(util) {
if (DEBUG_MODE > 2) {
console.log('registering util "' + util.name + '"');
console.log({ util });
}
this._registeredUtils.push(util);
}
deregister(name, destroy) {
const utilIndex = this._findUtilIndex(name);
if (utilIndex >= 0) {
if (destroy === true) {
this._destroyUtilInstances(name);
}
this._registeredUtils.splice(utilIndex, 1);
}
}
setApp(appInstance) {
this._appInstance = appInstance;
}
setupAll(scope) {
if (DEBUG_MODE > 1) {
console.info('registered js utilities:');
console.table(this._registeredUtils);
}
this._registeredUtils.forEach((util) => this.setup(util, scope));
}
setup(util, scope = document.body) {
if (DEBUG_MODE > 2) {
console.log('setting up util', { util });
}
if (util && typeof util.setup === 'function') {
const elements = this._findUtilElements(util, scope);
elements.forEach((element) => {
let utilInstance = null;
try {
utilInstance = util.setup(element, this._appInstance);
} catch(err) {
if (DEBUG_MODE > 0) {
console.warn('Error while trying to initialize a utility!', { util , element, err });
}
}
if (utilInstance) {
if (DEBUG_MODE > 2) {
console.info('Got utility instance for utility "' + util.name + '"', { utilInstance });
}
this._activeUtilInstances.push(utilInstance);
}
});
}
}
find(name) {
return this._registeredUtils.find((util) => util.name === name);
}
_findUtilElements(util, scope) {
if (scope && scope.matches(util.selector)) {
return [scope];
}
return Array.from(scope.querySelectorAll(util.selector));
}
_findUtilIndex(name) {
return this._registeredUtils.findIndex((util) => util.name === name);
}
_destroyUtilInstances(name) {
this._activeUtilInstances
.map((util, index) => ({
util: util,
index: index,
}))
.filter((activeUtil) => activeUtil.util.name === name)
.forEach((activeUtil) => {
// destroy util instance
activeUtil.util.destroy();
delete this._activeUtilInstances[activeUtil.index];
});
// get rid of now empty array slots
this._activeUtilInstances = this._activeUtilInstances.filter((util) => !!util);
}
}

View File

@ -0,0 +1,132 @@
import { UtilRegistry } from "./util-registry";
const TEST_UTILS = [{
name: 'util1',
selector: '#some-id',
setup: () => {},
}, {
name: 'util2',
selector: '[uw-util]',
setup: () => {},
}];
describe('UtilRegistry', () => {
let utilRegistry;
beforeEach(() => {
utilRegistry = new UtilRegistry();
});
it('should create', () => {
expect(utilRegistry).toBeTruthy();
});
it('should setup all utlites when page is done loading', () => {
spyOn(utilRegistry, 'setupAll');
document.dispatchEvent(new Event('DOMContentLoaded'));
expect(utilRegistry.setupAll).toHaveBeenCalled();
});
describe('register()', () => {
it('should allow to add utilities', () => {
utilRegistry.register(TEST_UTILS[0]);
const foundUtil = utilRegistry.find(TEST_UTILS[0].name);
expect(foundUtil).toEqual(TEST_UTILS[0]);
});
});
describe('deregister()', () => {
it('should remove util', () => {
// register util
utilRegistry.register(TEST_UTILS[0]);
let foundUtil = utilRegistry.find('util1');
expect(foundUtil).toBeTruthy();
// deregister util
utilRegistry.deregister(TEST_UTILS[0].name);
foundUtil = utilRegistry.find('util1');
expect(foundUtil).toBeFalsy();
});
it('should destroy util instances if requested', () => {
pending('TBD');
});
});
describe('setup()', () => {
it('should catch errors thrown by the utility', () => {
spyOn(TEST_UTILS[0], 'setup').and.throwError('some error');
expect(() => {
utilRegistry.setup(TEST_UTILS[0]);
}).not.toThrow();
});
it('should pass the app instance', () => {
const scope = document.createElement('div');
const utilElement = document.createElement('div');
utilElement.id = 'some-id';
scope.appendChild(utilElement);
const fakeApp = { fn: () => {} };
utilRegistry.setApp(fakeApp);
spyOn(TEST_UTILS[0], 'setup');
utilRegistry.setup(TEST_UTILS[0], scope);
expect(TEST_UTILS[0].setup).toHaveBeenCalledWith(utilElement, fakeApp);
});
describe('given no scope', () => {
it('should use fallback scope', () => {
spyOn(TEST_UTILS[0], 'setup');
utilRegistry.setup(TEST_UTILS[0]);
expect(TEST_UTILS[0].setup).not.toHaveBeenCalled();
});
});
describe('given a scope', () => {
let scope;
let utilElement1;
let utilElement2;
beforeEach(() => {
scope = document.createElement('div');
utilElement1 = document.createElement('div');
utilElement2 = document.createElement('div');
utilElement1.setAttribute('uw-util', '');
utilElement2.setAttribute('uw-util', '');
scope.appendChild(utilElement1);
scope.appendChild(utilElement2);
});
it('should call the utilities\' setup function for each matching element', () => {
spyOn(TEST_UTILS[1], 'setup');
utilRegistry.setup(TEST_UTILS[1], scope);
// 2 matching elements in scope
expect(TEST_UTILS[1].setup.calls.count()).toBe(2);
expect(TEST_UTILS[1].setup.calls.argsFor(0)).toEqual([utilElement1, undefined]);
expect(TEST_UTILS[1].setup.calls.argsFor(1)).toEqual([utilElement2, undefined]);
});
});
});
describe('setupAll()', () => {
it('should setup all the utilities', () => {
spyOn(utilRegistry, 'setup');
utilRegistry.register(TEST_UTILS[0]);
utilRegistry.register(TEST_UTILS[1]);
utilRegistry.setupAll();
expect(utilRegistry.setup.calls.count()).toBe(2);
expect(utilRegistry.setup.calls.argsFor(0)).toEqual([TEST_UTILS[0], undefined]);
expect(utilRegistry.setup.calls.argsFor(1)).toEqual([TEST_UTILS[1], undefined]);
});
it('should pass the given scope', () => {
spyOn(utilRegistry, 'setup');
utilRegistry.register(TEST_UTILS[0]);
const scope = document.createElement('div');
utilRegistry.setupAll(scope);
expect(utilRegistry.setup).toHaveBeenCalledWith(TEST_UTILS[0], scope);
});
});
});

View File

@ -0,0 +1,206 @@
import './alerts.scss';
/**
*
* Alerts Utility
* makes alerts interactive
*
* Attribute: uw-alerts
*
* Types of alerts:
* [default]
* Regular Info Alert
* Disappears automatically after 30 seconds
* Disappears after x seconds if explicitly specified via data-decay='x'
* Can be told not to disappear with data-decay='0'
*
* [success]
* Currently no special visual appearance
* Disappears automatically after 30 seconds
*
* [warning]
* Will be coloured warning-orange regardless of user's selected theme
* Does not disappear
*
* [error]
* Will be coloured error-red regardless of user's selected theme
* Does not disappear
*
* Example usage:
* <div .alerts uw-alerts>
* <div .alerts__toggler>
* <div .alert.alert-info>
* <div .alert__closer>
* <div .alert__icon>
* <div .alert__content>
* This is some information
*
*/
var ALERTS_UTIL_NAME = 'alerts';
var ALERTS_UTIL_SELECTOR = '[uw-alerts]';
var ALERTS_INITIALIZED_CLASS = 'alerts--initialized';
var ALERTS_ELEVATED_CLASS = 'alerts--elevated';
var ALERTS_TOGGLER_CLASS = 'alerts__toggler';
var ALERTS_TOGGLER_VISIBLE_CLASS = 'alerts__toggler--visible';
var ALERTS_TOGGLER_APPEAR_DELAY = 120;
var ALERT_CLASS = 'alert';
var ALERT_INITIALIZED_CLASS = 'alert--initialized';
var ALERT_CLOSER_CLASS = 'alert__closer';
var ALERT_ICON_CLASS = 'alert__icon';
var ALERT_CONTENT_CLASS = 'alert__content';
var ALERT_INVISIBLE_CLASS = 'alert--invisible';
var ALERT_AUTO_HIDE_DELAY = 10;
var ALERT_AUTOCLOSING_MATCHER = '.alert-info, .alert-success';
var alertsUtil = function(element, app) {
var togglerCheckRequested = false;
var togglerElement;
var alertElements;
function init() {
if (!element) {
throw new Error('Alerts util has to be called with an element!');
}
if (element.classList.contains(ALERTS_INITIALIZED_CLASS)) {
return false;
}
togglerElement = element.querySelector('.' + ALERTS_TOGGLER_CLASS);
alertElements = gatherAlertElements();
initToggler();
initAlerts();
// register http client interceptor to filter out Alerts Header
setupHttpInterceptor();
// mark initialized
element.classList.add(ALERTS_INITIALIZED_CLASS);
return {
name: ALERTS_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function gatherAlertElements() {
return Array.from(element.querySelectorAll('.' + ALERT_CLASS)).filter(function(alert) {
return !alert.classList.contains(ALERT_INITIALIZED_CLASS);
});
}
function initToggler() {
togglerElement.addEventListener('click', function() {
alertElements.forEach(function(alertEl) {
toggleAlert(alertEl, true);
});
togglerElement.classList.remove(ALERTS_TOGGLER_VISIBLE_CLASS);
});
}
function initAlerts() {
alertElements.forEach(initAlert);
}
function initAlert(alertElement) {
var autoHideDelay = ALERT_AUTO_HIDE_DELAY;
if (alertElement.dataset.decay) {
autoHideDelay = parseInt(alertElement.dataset.decay, 10);
}
var closeEl = alertElement.querySelector('.' + ALERT_CLOSER_CLASS);
closeEl.addEventListener('click', function() {
toggleAlert(alertElement);
});
if (autoHideDelay > 0 && alertElement.matches(ALERT_AUTOCLOSING_MATCHER)) {
window.setTimeout(function() {
toggleAlert(alertElement);
}, autoHideDelay * 1000);
}
}
function toggleAlert(alertEl, visible) {
alertEl.classList.toggle(ALERT_INVISIBLE_CLASS, !visible);
checkToggler();
}
function checkToggler() {
if (togglerCheckRequested) {
return;
}
var alertsHidden = alertElements.reduce(function(acc, alert) {
return acc && alert.classList.contains(ALERT_INVISIBLE_CLASS);
}, true);
window.setTimeout(function() {
togglerElement.classList.toggle(ALERTS_TOGGLER_VISIBLE_CLASS, alertsHidden);
togglerCheckRequested = false;
}, ALERTS_TOGGLER_APPEAR_DELAY);
}
function setupHttpInterceptor() {
app.httpClient.addResponseInterceptor(responseInterceptor.bind(this));
}
function elevateAlerts() {
element.classList.add(ALERTS_ELEVATED_CLASS);
}
function responseInterceptor(response) {
var alerts;
for (var header of response.headers) {
if (header[0] === 'alerts') {
var decodedHeader = decodeURIComponent(header[1]);
alerts = JSON.parse(decodedHeader);
break;
}
}
if (alerts) {
alerts.forEach(function(alert) {
var alertElement = createAlertElement(alert.status, alert.content);
element.appendChild(alertElement);
alertElements.push(alertElement);
initAlert(alertElement);
});
elevateAlerts();
}
}
function createAlertElement(type, content) {
var alertElement = document.createElement('div');
alertElement.classList.add(ALERT_CLASS, 'alert-' + type);
var alertCloser = document.createElement('div');
alertCloser.classList.add(ALERT_CLOSER_CLASS);
var alertIcon = document.createElement('div');
alertIcon.classList.add(ALERT_ICON_CLASS);
var alertContent = document.createElement('div');
alertContent.classList.add(ALERT_CONTENT_CLASS);
alertContent.innerHTML = content;
alertElement.appendChild(alertCloser);
alertElement.appendChild(alertIcon);
alertElement.appendChild(alertContent);
return alertElement;
}
return init();
};
export default {
name: ALERTS_UTIL_NAME,
selector: ALERTS_UTIL_SELECTOR,
setup: alertsUtil,
};

View File

@ -0,0 +1,8 @@
import alerts from "./alerts";
describe('Alerts', () => {
it('should be called alerts', () => {
expect(alerts.name).toMatch('alerts');
});
});

View File

@ -0,0 +1,105 @@
import './asidenav.scss';
/**
*
* Asidenav Utility
* Correctly positions hovered asidenav submenus and handles the favorites button on mobile
*
* Attribute: uw-asidenav
*
* Example usage:
* <div uw-asidenav>
* <div .asidenav>
* <div .asidenav__box>
* <ul .asidenav__list.list--iconless>
* <li .asidenav__list-item>
* <a .asidenav__link-wrapper href="#">
* <div .asidenav__link-shorthand>EIP
* <div .asidenav__link-label>Einführung in die Programmierung
* <div .asidenav__nested-list-wrapper>
* <ul .asidenav__nested-list.list--iconless>
* Übungsblätter
* ...
*
*/
var ASIDENAV_UTIL_NAME = 'asidenav';
var ASIDENAV_UTIL_SELECTOR = '[uw-asidenav]';
var FAVORITES_BTN_CLASS = 'navbar__list-item--favorite';
var FAVORITES_BTN_ACTIVE_CLASS = 'navbar__list-item--active';
var ASIDENAV_INITIALIZED_CLASS = 'asidenav--initialized';
var ASIDENAV_EXPANDED_CLASS = 'main__aside--expanded';
var ASIDENAV_LIST_ITEM_CLASS = 'asidenav__list-item';
var ASIDENAV_SUBMENU_CLASS = 'asidenav__nested-list-wrapper';
var asidenavUtil = function(element) {
function init() {
if (!element) {
throw new Error('Asidenav utility cannot be setup without an element!');
}
if (element.classList.contains(ASIDENAV_INITIALIZED_CLASS)) {
return false;
}
initFavoritesButton();
initAsidenavSubmenus();
// mark initialized
element.classList.add(ASIDENAV_INITIALIZED_CLASS);
return {
name: ASIDENAV_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function initFavoritesButton() {
var favoritesBtn = document.querySelector('.' + FAVORITES_BTN_CLASS);
favoritesBtn.addEventListener('click', function(event) {
favoritesBtn.classList.toggle(FAVORITES_BTN_ACTIVE_CLASS);
element.classList.toggle(ASIDENAV_EXPANDED_CLASS);
event.preventDefault();
}, true);
}
function initAsidenavSubmenus() {
var asidenavLinksWithSubmenus = Array.from(element.querySelectorAll('.' + ASIDENAV_LIST_ITEM_CLASS))
.map(function(listItem) {
var submenu = listItem.querySelector('.' + ASIDENAV_SUBMENU_CLASS);
return { listItem, submenu };
}).filter(function(union) {
return union.submenu !== null;
});
asidenavLinksWithSubmenus.forEach(function(union) {
union.listItem.addEventListener('mouseover', createMouseoverHandler(union));
});
}
function createMouseoverHandler(union) {
return function mouseoverHanlder() {
var rectListItem = union.listItem.getBoundingClientRect();
var rectSubMenu = union.submenu.getBoundingClientRect();
union.submenu.style.left = (rectListItem.left + rectListItem.width) + 'px';
if (window.innerHeight - rectListItem.top < rectSubMenu.height) {
union.submenu.style.top = (rectListItem.top + rectListItem.height - rectSubMenu.height) + 'px';
} else {
union.submenu.style.top = rectListItem.top + 'px';
}
};
}
return init();
};
export default {
name: ASIDENAV_UTIL_NAME,
selector: ASIDENAV_UTIL_SELECTOR,
setup: asidenavUtil,
};

View File

@ -0,0 +1,117 @@
import './async-form.scss';
/**
*
* Async Form Utility
* prevents form submissions from reloading the page but instead firing an AJAX request
*
* Attribute: uw-async-form
* (works only on <form> elements)
*
* Example usage:
* <form uw-async-form method='POST' action='...'>
* ...
*
* Internationalization:
* This utility expects the following translations to be available:
* asyncFormFailure: text that gets shown if an async form request fails
* example: "Oops. Something went wrong."
*/
var ASYNC_FORM_UTIL_NAME = 'asyncForm';
var ASYNC_FORM_UTIL_SELECTOR = 'form[uw-async-form]';
var ASYNC_FORM_INITIALIZED_CLASS = 'check-all--initialized';
var ASYNC_FORM_RESPONSE_CLASS = 'async-form__response';
var ASYNC_FORM_LOADING_CLASS = 'async-form--loading';
var ASYNC_FORM_MIN_DELAY = 600;
var MODAL_SELECTOR = '.modal';
var MODAL_HEADER_KEY = 'Is-Modal';
var MODAL_HEADER_VALUE = 'True';
var asyncFormUtil = function(element, app) {
var lastRequestTimestamp = 0;
function init() {
if (!element) {
throw new Error('Async Form Utility cannot be setup without an element!');
}
if (element.classList.contains(ASYNC_FORM_INITIALIZED_CLASS)) {
return false;
}
element.addEventListener('submit', submitHandler);
element.classList.add(ASYNC_FORM_INITIALIZED_CLASS);
return {
name: ASYNC_FORM_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function processResponse(response) {
var responseElement = makeResponseElement(response.content, response.status);
var parentElement = element.parentElement;
// make sure there is a delay between click and response
var delay = Math.max(0, ASYNC_FORM_MIN_DELAY + lastRequestTimestamp - Date.now());
setTimeout(function() {
parentElement.insertBefore(responseElement, element);
element.remove();
}, delay);
}
function makeResponseElement(content, status) {
var responseElement = document.createElement('div');
status = status || 'info';
responseElement.classList.add(ASYNC_FORM_RESPONSE_CLASS);
responseElement.classList.add(ASYNC_FORM_RESPONSE_CLASS + '--' + status);
responseElement.innerHTML = content;
return responseElement;
}
function submitHandler(event) {
event.preventDefault();
element.classList.add(ASYNC_FORM_LOADING_CLASS);
lastRequestTimestamp = Date.now();
var url = element.getAttribute('action');
var headers = { };
var body = new FormData(element);
var isModal = element.closest(MODAL_SELECTOR);
if (isModal) {
headers[MODAL_HEADER_KEY] = MODAL_HEADER_VALUE;
}
app.httpClient.post({
url: url,
headers: headers,
body: body,
}).then(function(response) {
return response.json();
}).then(function(response) {
processResponse(response[0]);
}).catch(function() {
var failureMessage = app.i18n.get('asyncFormFailure');
processResponse({ content: failureMessage });
element.classList.remove(ASYNC_FORM_LOADING_CLASS);
});
}
return init();
};
export default {
name: ASYNC_FORM_UTIL_NAME,
selector: ASYNC_FORM_UTIL_SELECTOR,
setup: asyncFormUtil,
};

View File

@ -0,0 +1,421 @@
import { HttpClient } from '../../services/http-client/http-client';
import './async-table.scss';
import './async-table-filter.scss';
/**
*
* Async Table Utility
* makes table filters, sorting and pagination behave asynchronously via AJAX calls
*
* Attribute: uw-async-table
*
* Example usage:
* (regular table)
*/
var INPUT_DEBOUNCE = 600;
var HEADER_HEIGHT = 80;
var ASYNC_TABLE_UTIL_NAME = 'asyncTable';
var ASYNC_TABLE_UTIL_SELECTOR = '[uw-async-table]';
var ASYNC_TABLE_LOCAL_STORAGE_KEY = 'ASYNC_TABLE';
var ASYNC_TABLE_SCROLLTABLE_SELECTOR = '.scrolltable';
var ASYNC_TABLE_INITIALIZED_CLASS = 'async-table--initialized';
var ASYNC_TABLE_LOADING_CLASS = 'async-table--loading';
var ASYNC_TABLE_FILTER_FORM_SELECTOR = '.table-filter-form';
var ASYNC_TABLE_FILTER_FORM_ID_SELECTOR = '[name="form-identifier"]';
var asyncTableUtil = function(element, app) {
var asyncTableHeader;
var asyncTableId;
var ths = [];
var pageLinks = [];
var pagesizeForm;
var scrollTable;
var cssIdPrefix = '';
var tableFilterInputs = {
search: [],
input: [],
change: [],
select: [],
};
function init() {
if (!element) {
throw new Error('Async Table utility cannot be setup without an element!');
}
if (element.classList.contains(ASYNC_TABLE_INITIALIZED_CLASS)) {
return false;
}
// param asyncTableDbHeader
if (element.dataset.asyncTableDbHeader !== undefined) {
asyncTableHeader = element.dataset.asyncTableDbHeader;
}
var rawTableId = element.querySelector('table').id;
cssIdPrefix = findCssIdPrefix(rawTableId);
asyncTableId = rawTableId.replace(cssIdPrefix, '');
// find scrolltable wrapper
scrollTable = element.querySelector(ASYNC_TABLE_SCROLLTABLE_SELECTOR);
if (!scrollTable) {
throw new Error('Async Table cannot be set up without a scrolltable element!');
}
setupSortableHeaders();
setupPagination();
setupPageSizeSelect();
setupTableFilter();
processLocalStorage();
// clear currentTableUrl from previous requests
setLocalStorageParameter('currentTableUrl', null);
// mark initialized
element.classList.add(ASYNC_TABLE_INITIALIZED_CLASS);
return {
name: ASYNC_TABLE_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function setupSortableHeaders() {
ths = Array.from(scrollTable.querySelectorAll('th.sortable')).map(function(th) {
return { element: th };
});
ths.forEach(function(th) {
th.clickHandler = function(event) {
setLocalStorageParameter('horizPos', (scrollTable || {}).scrollLeft);
linkClickHandler(event);
};
th.element.addEventListener('click', th.clickHandler);
});
}
function setupPagination() {
var pagination = element.querySelector('#' + cssIdPrefix + asyncTableId + '-pagination');
if (pagination) {
pageLinks = Array.from(pagination.querySelectorAll('.page-link')).map(function(link) {
return { element: link };
});
pageLinks.forEach(function(link) {
link.clickHandler = function(event) {
var tableBoundingRect = scrollTable.getBoundingClientRect();
if (tableBoundingRect.top < HEADER_HEIGHT) {
var scrollTo = {
top: (scrollTable.offsetTop || 0) - HEADER_HEIGHT,
left: scrollTable.offsetLeft || 0,
behavior: 'smooth',
};
setLocalStorageParameter('scrollTo', scrollTo);
}
linkClickHandler(event);
};
link.element.addEventListener('click', link.clickHandler);
});
}
}
function setupPageSizeSelect() {
// pagesize form
pagesizeForm = element.querySelector('#' + cssIdPrefix + asyncTableId + '-pagesize-form');
if (pagesizeForm) {
var pagesizeSelect = pagesizeForm.querySelector('[name=' + asyncTableId + '-pagesize]');
pagesizeSelect.addEventListener('change', changePagesizeHandler);
}
}
function setupTableFilter() {
var tableFilterForm = element.querySelector(ASYNC_TABLE_FILTER_FORM_SELECTOR);
if (tableFilterForm) {
gatherTableFilterInputs(tableFilterForm);
addTableFilterEventListeners(tableFilterForm);
}
}
function gatherTableFilterInputs(tableFilterForm) {
Array.from(tableFilterForm.querySelectorAll('input[type="search"]')).forEach(function(input) {
tableFilterInputs.search.push(input);
});
Array.from(tableFilterForm.querySelectorAll('input[type="text"]')).forEach(function(input) {
tableFilterInputs.input.push(input);
});
Array.from(tableFilterForm.querySelectorAll('input:not([type="text"]):not([type="search"])')).forEach(function(input) {
tableFilterInputs.change.push(input);
});
Array.from(tableFilterForm.querySelectorAll('select')).forEach(function(input) {
tableFilterInputs.select.push(input);
});
}
function addTableFilterEventListeners(tableFilterForm) {
tableFilterInputs.search.forEach(function(input) {
var debouncedInput = debounce(function() {
if (input.value.length === 0 || input.value.length > 2) {
updateFromTableFilter(tableFilterForm);
}
}, INPUT_DEBOUNCE);
input.addEventListener('input', debouncedInput);
});
tableFilterInputs.input.forEach(function(input) {
var debouncedInput = debounce(function() {
if (input.value.length === 0 || input.value.length > 2) {
updateFromTableFilter(tableFilterForm);
}
}, INPUT_DEBOUNCE);
input.addEventListener('input', debouncedInput);
});
tableFilterInputs.change.forEach(function(input) {
input.addEventListener('change', function() {
updateFromTableFilter(tableFilterForm);
});
});
tableFilterInputs.select.forEach(function(input) {
input.addEventListener('change', function() {
updateFromTableFilter(tableFilterForm);
});
});
tableFilterForm.addEventListener('submit', function(event) {
event.preventDefault();
updateFromTableFilter(tableFilterForm);
});
}
function updateFromTableFilter(tableFilterForm) {
var url = serializeTableFilterToURL();
var callback = null;
var focusedInput = tableFilterForm.querySelector(':focus, :active');
// focus previously focused input
if (focusedInput && focusedInput.selectionStart !== null) {
var selectionStart = focusedInput.selectionStart;
// remove the following part of the id to get rid of the random
// (yet somewhat structured) prefix we got from nudging.
var prefix = findCssIdPrefix(focusedInput.id);
var focusId = focusedInput.id.replace(prefix, '');
callback = function(wrapper) {
var idPrefix = getLocalStorageParameter('cssIdPrefix');
var toBeFocused = wrapper.querySelector('#' + idPrefix + focusId);
if (toBeFocused) {
toBeFocused.focus();
toBeFocused.selectionStart = selectionStart;
}
};
}
updateTableFrom(url, callback);
}
function serializeTableFilterToURL() {
var url = new URL(getLocalStorageParameter('currentTableUrl') || window.location.href);
var formIdElement = element.querySelector(ASYNC_TABLE_FILTER_FORM_ID_SELECTOR);
if (!formIdElement) {
// cannot serialize the filter form without an identifier
return;
}
url.searchParams.set('form-identifier', formIdElement.value);
url.searchParams.set('_hasdata', 'true');
url.searchParams.set(asyncTableId + '-page', '0');
tableFilterInputs.search.forEach(function(input) {
url.searchParams.set(input.name, input.value);
});
tableFilterInputs.input.forEach(function(input) {
url.searchParams.set(input.name, input.value);
});
tableFilterInputs.change.forEach(function(input) {
if (input.checked) {
url.searchParams.set(input.name, input.value);
}
});
tableFilterInputs.select.forEach(function(select) {
var options = Array.from(select.querySelectorAll('option'));
var selected = options.find(function(option) { return option.selected; });
if (selected) {
url.searchParams.set(select.name, selected.value);
}
});
return url;
}
function processLocalStorage() {
var scrollTo = getLocalStorageParameter('scrollTo');
if (scrollTo && scrollTable) {
window.scrollTo(scrollTo);
}
setLocalStorageParameter('scrollTo', null);
var horizPos = getLocalStorageParameter('horizPos');
if (horizPos && scrollTable) {
scrollTable.scrollLeft = horizPos;
}
setLocalStorageParameter('horizPos', null);
}
function removeListeners() {
ths.forEach(function(th) {
th.element.removeEventListener('click', th.clickHandler);
});
pageLinks.forEach(function(link) {
link.element.removeEventListener('click', link.clickHandler);
});
if (pagesizeForm) {
var pagesizeSelect = pagesizeForm.querySelector('[name=' + asyncTableId + '-pagesize]');
pagesizeSelect.removeEventListener('change', changePagesizeHandler);
}
}
function linkClickHandler(event) {
event.preventDefault();
var url = getClickDestination(event.target);
if (!url.match(/^http/)) {
url = window.location.origin + window.location.pathname + url;
}
updateTableFrom(url);
}
function getClickDestination(el) {
if (!el.matches('a') && !el.querySelector('a')) {
return '';
}
return el.getAttribute('href') || el.querySelector('a').getAttribute('href');
}
function changePagesizeHandler(event) {
var paginationParamKey = asyncTableId + '-pagination';
var pagesizeParamKey = asyncTableId + '-pagesize';
var pageParamKey = asyncTableId + '-page';
var paginationParamEl = pagesizeForm.querySelector('[name="' + paginationParamKey + '"]');
var url = new URL(getLocalStorageParameter('currentTableUrl') || window.location.href);
url.searchParams.set(pagesizeParamKey, event.target.value);
url.searchParams.set(pageParamKey, 0);
if (paginationParamEl) {
var encodedValue = encodeURIComponent(paginationParamEl.value);
url.searchParams.set(paginationParamKey, encodedValue);
}
updateTableFrom(url.href);
}
// fetches new sorted element from url with params and replaces contents of current element
function updateTableFrom(url, callback) {
element.classList.add(ASYNC_TABLE_LOADING_CLASS);
var headers = {
'Accept': HttpClient.ACCEPT.TEXT_HTML,
[asyncTableHeader]: asyncTableId,
};
app.httpClient.get({
url: url,
headers: headers,
}).then(function(response) {
return app.htmlHelpers.parseResponse(response);
}).then(function(response) {
setLocalStorageParameter('currentTableUrl', url.href);
// reset table
removeListeners();
element.classList.remove(ASYNC_TABLE_INITIALIZED_CLASS);
// update table with new
updateWrapperContents(response);
app.utilRegistry.setupAll(element);
if (callback && typeof callback === 'function') {
setLocalStorageParameter('cssIdPrefix', response.idPrefix);
callback(element);
setLocalStorageParameter('cssIdPrefix', '');
}
}).catch(function(err) {
console.error(err);
}).finally(function() {
element.classList.remove(ASYNC_TABLE_LOADING_CLASS);
});
}
function updateWrapperContents(response) {
var newPage = document.createElement('div');
newPage.appendChild(response.element);
var newWrapperContents = newPage.querySelector('#' + response.idPrefix + element.id);
element.innerHTML = newWrapperContents.innerHTML;
}
return init();
};
// returns any random nudged prefix found in the given id
function findCssIdPrefix(id) {
var matcher = /r\d*?__/;
var maybePrefix = id.match(matcher);
if (maybePrefix && maybePrefix[0]) {
return maybePrefix[0];
}
return '';
}
function setLocalStorageParameter(key, value) {
var currentLSState = JSON.parse(window.localStorage.getItem(ASYNC_TABLE_LOCAL_STORAGE_KEY)) || {};
if (value !== null) {
currentLSState[key] = value;
} else {
delete currentLSState[key];
}
window.localStorage.setItem(ASYNC_TABLE_LOCAL_STORAGE_KEY, JSON.stringify(currentLSState));
}
function getLocalStorageParameter(key) {
var currentLSState = JSON.parse(window.localStorage.getItem(ASYNC_TABLE_LOCAL_STORAGE_KEY)) || {};
return currentLSState[key];
}
// debounce function, taken from Underscore.js
function debounce(func, wait, immediate) {
var timeout;
return function() {
var context = this, args = arguments;
var later = function() {
timeout = null;
if (!immediate) func.apply(context, args);
};
var callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
}
export default {
name: ASYNC_TABLE_UTIL_NAME,
selector: ASYNC_TABLE_UTIL_SELECTOR,
setup: asyncTableUtil,
};

View File

@ -0,0 +1,141 @@
/**
*
* Check All Checkbox Utility
* adds a Check All Checkbox above columns with only checkboxes
*
* Attribute: [none]
* (will be set up automatically on tables)
*
* Example usage:
* (table with one column thats only checkboxes)
*/
var CHECK_ALL_UTIL_NAME = 'checkAll';
var CHECK_ALL_UTIL_SELECTOR = 'table';
var CHECKBOX_SELECTOR = '[type="checkbox"]';
var CHECK_ALL_INITIALIZED_CLASS = 'check-all--initialized';
var checkAllUtil = function(element, app) {
var columns = [];
var checkboxColumn = [];
var checkAllCheckbox = null;
function init() {
if (!element) {
throw new Error('Check All utility cannot be setup without an element!');
}
if (element.classList.contains(CHECK_ALL_INITIALIZED_CLASS)) {
return false;
}
gatherColumns();
setupCheckAllCheckbox();
// mark initialized
element.classList.add(CHECK_ALL_INITIALIZED_CLASS);
return {
name: CHECK_ALL_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function getCheckboxId() {
return 'check-all-checkbox-' + Math.floor(Math.random() * 100000);
}
function gatherColumns() {
var rows = Array.from(element.querySelectorAll('tr'));
var cols = [];
rows.forEach(function(tr) {
var cells = Array.from(tr.querySelectorAll('td'));
cells.forEach(function(cell, cellIndex) {
if (!cols[cellIndex]) {
cols[cellIndex] = [];
}
cols[cellIndex].push(cell);
});
});
columns = cols;
}
function findCheckboxColumn(columns) {
var checkboxColumnId = null;
columns.forEach(function(col, i) {
if (isCheckboxColumn(col)) {
checkboxColumnId = i;
}
});
return checkboxColumnId;
}
function isCheckboxColumn(col) {
var onlyCheckboxes = true;
col.forEach(function(cell) {
if (onlyCheckboxes && !cell.querySelector(CHECKBOX_SELECTOR)) {
onlyCheckboxes = false;
}
});
return onlyCheckboxes;
}
function setupCheckAllCheckbox() {
var checkboxColumnId = findCheckboxColumn(columns);
if (checkboxColumnId === null) {
return;
}
checkboxColumn = columns[checkboxColumnId];
var firstRow = element.querySelector('tr');
var th = Array.from(firstRow.querySelectorAll('th, td'))[checkboxColumnId];
checkAllCheckbox = document.createElement('input');
checkAllCheckbox.setAttribute('type', 'checkbox');
checkAllCheckbox.setAttribute('id', getCheckboxId());
th.insertBefore(checkAllCheckbox, th.firstChild);
// manually set up new checkbox
app.utilRegistry.setup(app.utilRegistry.find('checkbox'), th);
checkAllCheckbox.addEventListener('input', onCheckAllCheckboxInput);
setupCheckboxListeners();
}
function onCheckAllCheckboxInput() {
toggleAll(checkAllCheckbox.checked);
}
function setupCheckboxListeners() {
checkboxColumn
.map(function(cell) {
return cell.querySelector(CHECKBOX_SELECTOR);
})
.forEach(function(checkbox) {
checkbox.addEventListener('input', updateCheckAllCheckboxState);
});
}
function updateCheckAllCheckboxState() {
var allChecked = checkboxColumn.every(function(cell) {
return cell.querySelector(CHECKBOX_SELECTOR).checked;
});
checkAllCheckbox.checked = allChecked;
}
function toggleAll(checked) {
checkboxColumn.forEach(function(cell) {
cell.querySelector(CHECKBOX_SELECTOR).checked = checked;
});
}
return init();
};
export default {
name: CHECK_ALL_UTIL_NAME,
selector: CHECK_ALL_UTIL_SELECTOR,
setup: checkAllUtil,
};

View File

@ -0,0 +1,606 @@
import flatpickr from 'flatpickr';
import './form.scss';
/**
*
* Reactive Submit Button Utility
* disables a forms LAST sumit button as long as the required inputs are invalid
* (only checks if the value of the inputs are not empty)
*
* Attribute: [none]
* (automatically setup on all form tags)
*
* Params:
* data-formnorequired: string
* If present the submit button will never get disabled
*
* Example usage:
* <form uw-reactive-submit-button>
* <input type="text" required>
* <button type="submit">
* </form>
*/
var REACTIVE_SUBMIT_BUTTON_UTIL_NAME = 'reactiveSubmitButton';
// var REACTIVE_SUBMIT_BUTTON_UTIL_SELECTOR = 'form';
var REACTIVE_SUBMIT_BUTTON_INITIALIZED_CLASS = 'reactive-submit-button--initialized';
/* eslint-disable-next-line */
var reactiveSubmitButtonUtil = function(element) {
var requiredInputs;
var submitButton;
function init() {
if (!element) {
throw new Error('Reactive Submit Button utility cannot be setup without an element!');
}
if (element.classList.contains(REACTIVE_SUBMIT_BUTTON_INITIALIZED_CLASS)) {
return false;
}
// abort if form has param data-formnorequired
if (element.dataset.formnorequired !== undefined) {
throw new Error('Form has formnorequired data attribute. Will skip setup of reactive submit button.');
}
requiredInputs = Array.from(element.querySelectorAll('[required]'));
if (!requiredInputs) {
// abort if form has no required inputs
throw new Error('Submit button has formnorequired data attribute. Will skip setup of reactive submit button.');
}
var submitButtons = Array.from(element.querySelectorAll('[type="submit"]'));
if (!submitButtons || !submitButtons.length) {
throw new Error('Reactive Submit Button utility couldn\'t find any submit buttons!');
}
submitButton = submitButtons.reverse()[0];
// abort if form has param data-formnorequired
if (submitButton.dataset.formnorequired !== undefined) {
return false;
}
setupInputs();
updateButtonState();
element.classList.add(REACTIVE_SUBMIT_BUTTON_INITIALIZED_CLASS);
return {
name: REACTIVE_SUBMIT_BUTTON_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function setupInputs() {
requiredInputs.forEach(function(el) {
var checkbox = el.getAttribute('type') === 'checkbox';
var eventType = checkbox ? 'change' : 'input';
el.addEventListener(eventType, function() {
updateButtonState();
});
});
}
function updateButtonState() {
if (inputsValid()) {
submitButton.removeAttribute('disabled');
} else {
submitButton.setAttribute('disabled', 'true');
}
}
function inputsValid() {
var done = true;
requiredInputs.forEach(function(inp) {
var len = inp.value.trim().length;
if (done && len === 0) {
done = false;
}
});
return done;
}
return init();
};
// skipping reactiveButtonUtil (for now)
// the button did not properly re-enable after filling out a form for some safari users.
// if maybe in the future there is going to be a proper way of (asynchronously) and
// meaningfully validating forms this can be re-activated by commenting in the next few lines
// eport const reactiveSubmitButton = {
// name: REACTIVE_SUBMIT_BUTTON_UTIL_NAME,
// selector: REACTIVE_SUBMIT_BUTTON_UTIL_SELECTOR,
// setup: reactiveSubmitButtonUtil,
// };
/**
*
* Interactive Fieldset Utility
* shows/hides inputs based on value of particular input
*
* Attribute: uw-interactive-fieldset
*
* Params:
* data-conditional-input: string
* Selector for the input that this fieldset watches for changes
* data-conditional-value: string
* The value the conditional input needs to be set to for this fieldset to be shown
* Can be omitted if conditionalInput is a checkbox
*
* Example usage:
* ## example with text input
* <input id="input-0" type="text">
* <fieldset uw-interactive-fieldset data-conditional-input="#input-0" data-conditional-value="yes">...</fieldset>
* <fieldset uw-interactive-fieldset data-conditional-input="#input-0" data-conditional-value="no">...</fieldset>
* ## example with <select>
* <select id="select-0">
* <option value="0">Zero
* <option value="1">One
* <fieldset uw-interactive-fieldset data-conditional-input="#select-0" data-conditional-value="0">...</fieldset>
* <fieldset uw-interactive-fieldset data-conditional-input="#select-0" data-conditional-value="1">...</fieldset>
* ## example with checkbox
* <input id="checkbox-0" type="checkbox">
* <input id="checkbox-1" type="checkbox">
* <fieldset uw-interactive-fieldset data-conditional-input="#checkbox-0">...</fieldset>
* <fieldset uw-interactive-fieldset data-conditional-input="#checkbox-1">...</fieldset>
*/
var INTERACTIVE_FIELDSET_UTIL_NAME = 'interactiveFieldset';
var INTERACTIVE_FIELDSET_UTIL_SELECTOR = '[uw-interactive-fieldset]';
var INTERACTIVE_FIELDSET_UTIL_TARGET_SELECTOR = '.interactive-fieldset__target';
var INTERACTIVE_FIELDSET_INITIALIZED_CLASS = 'interactive-fieldset--initialized';
var INTERACTIVE_FIELDSET_CHILD_SELECTOR = 'input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled])';
var interactiveFieldsetUtil = function(element) {
var conditionalInput;
var conditionalValue;
var target;
var childInputs;
function init() {
if (!element) {
throw new Error('Interactive Fieldset utility cannot be setup without an element!');
}
if (element.classList.contains(INTERACTIVE_FIELDSET_INITIALIZED_CLASS)) {
return false;
}
// param conditionalInput
if (!element.dataset.conditionalInput) {
throw new Error('Interactive Fieldset needs a selector for a conditional input!');
}
conditionalInput = document.querySelector('#' + element.dataset.conditionalInput);
if (!conditionalInput) {
// abort if form has no required inputs
throw new Error('Couldn\'t find the conditional input. Aborting setup for interactive fieldset.');
}
// param conditionalValue
if (!element.dataset.conditionalValue && !isCheckbox()) {
throw new Error('Interactive Fieldset needs a conditional value!');
}
conditionalValue = element.dataset.conditionalValue;
target = element.closest(INTERACTIVE_FIELDSET_UTIL_TARGET_SELECTOR);
if (!target || element.matches(INTERACTIVE_FIELDSET_UTIL_TARGET_SELECTOR)) {
target = element;
}
childInputs = Array.from(element.querySelectorAll(INTERACTIVE_FIELDSET_CHILD_SELECTOR));
// add event listener
var observer = new MutationObserver(() => updateVisibility());
observer.observe(conditionalInput, { attributes: true, attributeFilter: ['disabled'] });
conditionalInput.addEventListener('input', updateVisibility);
// initial visibility update
updateVisibility();
// mark as initialized
element.classList.add(INTERACTIVE_FIELDSET_INITIALIZED_CLASS);
return {
name: INTERACTIVE_FIELDSET_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function updateVisibility() {
var active = matchesConditionalValue() && !conditionalInput.disabled;
target.classList.toggle('hidden', !active);
childInputs.forEach(function(el) {
el.disabled = !active;
if (el._flatpickr) {
el._flatpickr.altInput.disabled = !active;
}
});
}
function matchesConditionalValue() {
if (isCheckbox()) {
return conditionalInput.checked === true;
}
return conditionalInput.value === conditionalValue;
}
function isCheckbox() {
return conditionalInput.getAttribute('type') === 'checkbox';
}
return init();
};
export const interactiveFieldset = {
name: INTERACTIVE_FIELDSET_UTIL_NAME,
selector: INTERACTIVE_FIELDSET_UTIL_SELECTOR,
setup: interactiveFieldsetUtil,
};
/**
*
* Navigate Away Prompt Utility
* This utility asks the user if (s)he really wants to navigate away
* from a page containing a form if (s)he already touched an input.
* Form-Submits will not trigger the prompt.
* Utility will ignore forms that contain auto submit elements (buttons, inputs).
*
* Attribute: [none]
* (automatically setup on all form tags that dont automatically submit, see AutoSubmitButtonUtil)
* Does not setup on forms that have uw-no-navigate-away-prompt
*
* Example usage:
* (any page with a form)
*/
var NAVIGATE_AWAY_PROMPT_UTIL_NAME = 'navigateAwayPrompt';
var NAVIGATE_AWAY_PROMPT_UTIL_SELECTOR = 'form';
var NAVIGATE_AWAY_PROMPT_UTIL_OPTOUT = '[uw-no-navigate-away-prompt]';
var NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS = 'navigate-away-prompt--initialized';
var navigateAwayPromptUtil = function(element) {
var touched = false;
var unloadDueToSubmit = false;
function init() {
if (!element) {
throw new Error('Navigate Away Prompt utility needs to be passed an element!');
}
if (element.classList.contains(NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS)) {
return false;
}
// ignore forms that get submitted automatically
if (element.querySelector(AUTO_SUBMIT_BUTTON_UTIL_SELECTOR) || element.querySelector(AUTO_SUBMIT_INPUT_UTIL_SELECTOR)) {
return false;
}
if (element.matches(NAVIGATE_AWAY_PROMPT_UTIL_OPTOUT)) {
return false;
}
window.addEventListener('beforeunload', beforeUnloadHandler);
element.addEventListener('submit', function() {
unloadDueToSubmit = true;
});
element.addEventListener('change', function() {
touched = true;
unloadDueToSubmit = false;
});
// mark initialized
element.classList.add(NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS);
return {
name: NAVIGATE_AWAY_PROMPT_UTIL_NAME,
element: element,
destroy: function() {
window.removeEventListener('beforeunload', beforeUnloadHandler);
},
};
}
function beforeUnloadHandler(event) {
// allow the event to happen if the form was not touched by the
// user or the unload event was initiated by a form submit
if (!touched || unloadDueToSubmit) {
return false;
}
// cancel the unload event. This is the standard to force the prompt to appear.
event.preventDefault();
// for all non standard compliant browsers we return a truthy value to activate the prompt.
return true;
}
return init();
};
export const navigateAwayPrompt = {
name: NAVIGATE_AWAY_PROMPT_UTIL_NAME,
selector: NAVIGATE_AWAY_PROMPT_UTIL_SELECTOR,
setup: navigateAwayPromptUtil,
};
/**
*
* Auto Submit Button Utility
* Hides submit buttons in forms that are submitted programmatically
* We hide the button using JavaScript so no-js users will still be able to submit the form
*
* Attribute: uw-auto-submit-button
*
* Example usage:
* <button type="submit" uw-auto-submit-button>Submit
*/
var AUTO_SUBMIT_BUTTON_UTIL_NAME = 'autoSubmitButton';
var AUTO_SUBMIT_BUTTON_UTIL_SELECTOR = '[uw-auto-submit-button]';
var AUTO_SUBMIT_BUTTON_INITIALIZED_CLASS = 'auto-submit-button--initialized';
var AUTO_SUBMIT_BUTTON_HIDDEN_CLASS = 'hidden';
var autoSubmitButtonUtil = function(element) {
if (!element) {
throw new Error('Auto Submit Button utility needs to be passed an element!');
}
if (element.classList.contains(AUTO_SUBMIT_BUTTON_INITIALIZED_CLASS)) {
return false;
}
// hide and mark initialized
element.classList.add(AUTO_SUBMIT_BUTTON_HIDDEN_CLASS, AUTO_SUBMIT_BUTTON_INITIALIZED_CLASS);
return {
name: AUTO_SUBMIT_BUTTON_UTIL_NAME,
element: element,
destroy: function() {},
};
};
export const autoSubmitButton = {
name: AUTO_SUBMIT_BUTTON_UTIL_NAME,
selector: AUTO_SUBMIT_BUTTON_UTIL_SELECTOR,
setup: autoSubmitButtonUtil,
};
/**
*
* Auto Submit Input Utility
* Programmatically submits forms when a certain input changes value
*
* Attribute: uw-auto-submit-input
*
* Example usage:
* <input type="text" uw-auto-submit-input />
*/
var AUTO_SUBMIT_INPUT_UTIL_NAME = 'autoSubmitInput';
var AUTO_SUBMIT_INPUT_UTIL_SELECTOR = '[uw-auto-submit-input]';
var AUTO_SUBMIT_INPUT_INITIALIZED_CLASS = 'auto-submit-input--initialized';
var autoSubmitInputUtil = function(element) {
var form;
var debouncedHandler;
function autoSubmit() {
form.submit();
}
function init() {
if (!element) {
throw new Error('Auto Submit Input utility needs to be passed an element!');
}
if (element.classList.contains(AUTO_SUBMIT_INPUT_INITIALIZED_CLASS)) {
return false;
}
form = element.form;
if (!form) {
throw new Error('Could not determine associated form for auto submit input');
}
debouncedHandler = debounce(autoSubmit, 500);
element.addEventListener('input', debouncedHandler);
element.classList.add(AUTO_SUBMIT_INPUT_INITIALIZED_CLASS);
return {
name: AUTO_SUBMIT_INPUT_UTIL_NAME,
element: element,
destroy: function() {
element.removeEventListener('input', debouncedHandler);
},
};
}
return init();
};
export const autoSubmitInput = {
name: AUTO_SUBMIT_INPUT_UTIL_NAME,
selector: AUTO_SUBMIT_INPUT_UTIL_SELECTOR,
setup: autoSubmitInputUtil,
};
/**
*
* Form Error Remover Utility
* Removes errors from inputs when they are focused
*
* Attribute: [none]
* (automatically setup on all form tags)
*
* Example usage:
* (any regular form that can show input errors)
*/
var FORM_ERROR_REMOVER_UTIL_NAME = 'formErrorRemover';
var FORM_ERROR_REMOVER_UTIL_SELECTOR = 'form';
var FORM_ERROR_REMOVER_INITIALIZED_CLASS = 'form-error-remover--initialized';
var FORM_ERROR_REMOVER_INPUTS_SELECTOR = 'input:not([type="hidden"]), textarea, select';
var FORM_GROUP_SELECTOR = '.form-group';
var FORM_GROUP_WITH_ERRORS_CLASS = 'form-group--has-error';
var formErrorRemoverUtil = function(element) {
var formGroups;
function init() {
if (!element) {
throw new Error('Form Error Remover utility needs to be passed an element!');
}
if (element.classList.contains(FORM_ERROR_REMOVER_INITIALIZED_CLASS)) {
return false;
}
// find form groups
formGroups = Array.from(element.querySelectorAll(FORM_GROUP_SELECTOR));
formGroups.forEach(function(formGroup) {
if (!formGroup.classList.contains(FORM_GROUP_WITH_ERRORS_CLASS)) {
return;
}
var inputElements = Array.from(formGroup.querySelectorAll(FORM_ERROR_REMOVER_INPUTS_SELECTOR));
if (!inputElements) {
return false;
}
inputElements.forEach(function(inputElement) {
inputElement.addEventListener('input', function() {
formGroup.classList.remove(FORM_GROUP_WITH_ERRORS_CLASS);
});
});
});
// mark initialized
element.classList.add(FORM_ERROR_REMOVER_INITIALIZED_CLASS);
return {
name: FORM_ERROR_REMOVER_UTIL_NAME,
element: element,
destroy: function() {},
};
}
return init();
};
export const formErrorRemover = {
name: FORM_ERROR_REMOVER_UTIL_NAME,
selector: FORM_ERROR_REMOVER_UTIL_SELECTOR,
setup: formErrorRemoverUtil,
};
/**
*
* Datepicker Utility
* Provides UI for entering dates and times
*
* Attribute: [none]
* (automatically setup on all relevant input tags)
*
* Example usage:
* (any form that uses inputs of type date, time, or datetime-local)
*/
var DATEPICKER_UTIL_NAME = 'datepicker';
var DATEPICKER_UTIL_SELECTOR = 'input[type="date"], input[type="time"], input[type="datetime-local"]';
var DATEPICKER_INITIALIZED_CLASS = 'datepicker--initialized';
var DATEPICKER_CONFIG = {
"datetime-local": {
enableTime: true,
altInput: true,
altFormat: "j. F Y, H:i", // maybe interpolate these formats for locale
dateFormat: "Y-m-dTH:i",
time_24hr: true,
},
"date": {
altFormat: "j. F Y",
dateFormat: "Y-m-d",
altInput: true,
},
"time": {
enableTime: true,
noCalendar: true,
altFormat: "H:i",
dateFormat: "H:i",
altInput: true,
time_24hr: true,
},
};
var datepickerUtil = function(element) {
var flatpickrInstance;
function init() {
if (!element) {
throw new Error('Datepicker utility needs to be passed an element!');
}
if (element.classList.contains(DATEPICKER_INITIALIZED_CLASS)) {
return false;
}
var flatpickrConfig = DATEPICKER_CONFIG[element.getAttribute("type")];
if (!flatpickrConfig) {
throw new Error('Datepicker utility called on unsupported element!');
}
flatpickrInstance = flatpickr(element, flatpickrConfig);
// mark initialized
element.classList.add(DATEPICKER_INITIALIZED_CLASS);
return {
name: DATEPICKER_UTIL_NAME,
element: element,
destroy: function() { flatpickrInstance.destroy(); },
};
}
return init();
};
export const datepicker = {
name: DATEPICKER_UTIL_NAME,
selector: DATEPICKER_UTIL_SELECTOR,
setup: datepickerUtil,
};
// debounce function, taken from Underscore.js
function debounce(func, wait, immediate) {
var timeout;
return function() {
var context = this, args = arguments;
var later = function() {
timeout = null;
if (!immediate) func.apply(context, args);
};
var callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
}

View File

@ -0,0 +1,194 @@
import './checkbox.scss';
import './radio.scss';
import './inputs.scss';
/**
*
* FileInput Utility
* wraps native file input
*
* Attribute: uw-file-input
* (element must be an input of type='file')
*
* Example usage:
* <input type='file' uw-file-input>
*
* Internationalization:
* This utility expects the following translations to be available:
* »filesSelected«: label of multi-input button after selection
* example: "Dateien ausgewählt" (will be prepended by number of selected files)
* »selectFile«: label of single-input button before selection
* example: "Datei auswählen"
* »selectFiles«: label of multi-input button before selection
* example: "Datei(en) auswählen"
*
*/
var FILE_INPUT_UTIL_NAME = 'fileInput';
var FILE_INPUT_UTIL_SELECTOR = 'input[type="file"][uw-file-input]';
var FILE_INPUT_CLASS = 'file-input';
var FILE_INPUT_INITIALIZED_CLASS = 'file-input--initialized';
var FILE_INPUT_LIST_CLASS = 'file-input__list';
var FILE_INPUT_UNPACK_CHECKBOX_CLASS = 'file-input__unpack';
var FILE_INPUT_LABEL_CLASS = 'file-input__label';
var fileInputUtil = function(element, app) {
var isMultiFileInput = false;
var fileList;
var label;
function init() {
if (!element) {
throw new Error('FileInput utility cannot be setup without an element!');
}
if (element.classList.contains(FILE_INPUT_INITIALIZED_CLASS)) {
throw new Error('FileInput utility already initialized!');
}
// check if is multi-file input
isMultiFileInput = element.hasAttribute('multiple');
if (isMultiFileInput) {
fileList = createFileList();
}
label = createFileLabel();
updateLabel();
// add change listener
element.addEventListener('change', function() {
updateLabel();
renderFileList();
});
// add util class for styling and mark as initialized
element.classList.add(FILE_INPUT_CLASS);
element.classList.add(FILE_INPUT_INITIALIZED_CLASS);
return {
name: FILE_INPUT_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function renderFileList() {
if (!fileList) {
return;
}
var files = element.files;
fileList.innerHTML = '';
Array.from(files).forEach(function(file) {
var fileDisplayEl = document.createElement('li');
fileDisplayEl.innerHTML = file.name;
fileList.appendChild(fileDisplayEl);
});
}
function createFileList() {
var list = document.createElement('ol');
list.classList.add(FILE_INPUT_LIST_CLASS);
var unpackEl = element.parentElement.querySelector('.' + FILE_INPUT_UNPACK_CHECKBOX_CLASS);
if (unpackEl) {
element.parentElement.insertBefore(list, unpackEl);
} else {
element.parentElement.appendChild(list);
}
return list;
}
function createFileLabel() {
var label = document.createElement('label');
label.classList.add(FILE_INPUT_LABEL_CLASS);
label.setAttribute('for', element.id);
element.parentElement.insertBefore(label, element);
return label;
}
function updateLabel() {
var files = element.files;
if (files && files.length) {
label.innerText = isMultiFileInput ? files.length + ' ' + app.i18n.get('filesSelected') : files[0].name;
} else {
label.innerText = isMultiFileInput ? app.i18n.get('selectFiles') : app.i18n.get('selectFile');
}
}
return init();
};
export const fileInput = {
name: FILE_INPUT_UTIL_NAME,
selector: FILE_INPUT_UTIL_SELECTOR,
setup: fileInputUtil,
};
/**
*
* Checkbox Utility
* wraps native checkbox
*
* Attribute: (none)
* (element must be an input of type="checkbox")
*
* Example usage:
* <input type="checkbox">
*
*/
var CHECKBOX_UTIL_NAME = 'checkbox';
var CHECKBOX_UTIL_SELECTOR = 'input[type="checkbox"]';
var CHECKBOX_CLASS = 'checkbox';
var CHECKBOX_INITIALIZED_CLASS = 'checkbox--initialized';
var checkboxUtil = function(element) {
function init() {
if (!element) {
throw new Error('Checkbox utility cannot be setup without an element!');
}
if (element.classList.contains(CHECKBOX_INITIALIZED_CLASS)) {
// throw new Error('Checkbox utility already initialized!');
return false;
}
if (element.parentElement.classList.contains(CHECKBOX_CLASS)) {
// throw new Error('Checkbox element\'s wrapper already has class "' + CHECKBOX_CLASS + '"!');
return false;
}
var siblingEl = element.nextSibling;
var parentEl = element.parentElement;
var wrapperEl = document.createElement('div');
wrapperEl.classList.add(CHECKBOX_CLASS);
var labelEl = document.createElement('label');
labelEl.setAttribute('for', element.id);
wrapperEl.appendChild(element);
wrapperEl.appendChild(labelEl);
parentEl.insertBefore(wrapperEl, siblingEl);
element.classList.add(CHECKBOX_INITIALIZED_CLASS);
return {
name: CHECKBOX_UTIL_NAME,
element: element,
destroy: function() {},
};
}
return init();
};
export const checkbox = {
name: CHECKBOX_UTIL_NAME,
selector: CHECKBOX_UTIL_SELECTOR,
setup: checkboxUtil,
};

View File

@ -0,0 +1,218 @@
/**
*
* Mass Input Utility
* allows form shapes to be manipulated asynchronously:
* will asynchronously submit the containing form and replace the contents
* of the mass input element with the one from the BE response
* The utility will only trigger an AJAX request if the mass input element has
* an active/focused element whilst the form is being submitted.
*
* Attribute: uw-mass-input
*
* Example usage:
* <form method="POST" action="...">
* <input type="text">
* <div uw-mass-input>
* <input type="text">
* <button type="submit">
*/
var MASS_INPUT_UTIL_NAME = 'massInput';
var MASS_INPUT_UTIL_SELECTOR = '[uw-mass-input]';
var MASS_INPUT_CELL_SELECTOR = '.massinput__cell';
var MASS_INPUT_ADD_CELL_SELECTOR = '.massinput__cell--add';
var MASS_INPUT_SUBMIT_BUTTON_CLASS = 'massinput__submit-button';
var MASS_INPUT_INITIALIZED_CLASS = 'mass-input--initialized';
var massInputUtil = function(element, app) {
var massInputId;
var massInputFormSubmitHandler;
var massInputForm;
function init() {
if (!element) {
throw new Error('Mass Input utility cannot be setup without an element!');
}
if (element.classList.contains(MASS_INPUT_INITIALIZED_CLASS)) {
return false;
}
massInputId = element.dataset.massInputIdent || '_';
massInputForm = element.closest('form');
if (!massInputForm) {
throw new Error('Mass Input utility cannot be setup without being wrapped in a <form>!');
}
massInputFormSubmitHandler = makeSubmitHandler();
// setup submit buttons inside this massinput so browser
// uses correct submit button for form submission.
var buttons = getMassInputSubmitButtons();
buttons.forEach(function(button) {
setupSubmitButton(button);
});
massInputForm.addEventListener('submit', massInputFormSubmitHandler);
massInputForm.addEventListener('keypress', keypressHandler);
// mark initialized
element.classList.add(MASS_INPUT_INITIALIZED_CLASS);
return {
name: MASS_INPUT_UTIL_NAME,
element: element,
destroy: function() {
reset();
},
};
}
function makeSubmitHandler() {
var method = massInputForm.getAttribute('method') || 'POST';
var url = massInputForm.getAttribute('action') || window.location.href;
var enctype = massInputForm.getAttribute('enctype') || 'application/json';
var requestFn;
if (app.httpClient[method.toLowerCase()]) {
requestFn = app.httpClient[method.toLowerCase()].bind(app.httpClient);
}
return function(event) {
var activeElement;
// check if event occured from either a mass input add/delete button or
// from inside one of massinput's inputs (i.e. a child is focused/active)
activeElement = element.querySelector(':focus, :active');
if (!activeElement) {
return false;
}
// find the according massinput cell thats hosts the element that triggered the submit
var massInputCell = activeElement.closest(MASS_INPUT_CELL_SELECTOR);
if (!massInputCell) {
return false;
}
var submitButton = massInputCell.querySelector('.' + MASS_INPUT_SUBMIT_BUTTON_CLASS);
if (!submitButton) {
return false;
}
var isAddCell = massInputCell.matches(MASS_INPUT_ADD_CELL_SELECTOR);
var submitButtonIsActive = submitButton.matches(':focus, :active');
// if the cell is not an add cell the active element must at least be the cells submit button
if (!isAddCell && !submitButtonIsActive) {
return false;
}
event.preventDefault();
var requestBody = serializeForm(submitButton, enctype);
if (requestFn && requestBody) {
var headers = {'Mass-Input-Shortcircuit': massInputId};
if (enctype !== 'multipart/form-data')
headers['Content-Type'] = enctype;
requestFn({
url: url,
headers: headers,
body: requestBody,
}).then(function(response) {
return app.htmlHelpers.parseResponse(response);
}).then(function(response) {
processResponse(response.element);
if (isAddCell) {
reFocusAddCell();
}
});
}
};
}
function keypressHandler(event) {
if (event.keyCode !== 13) {
return false;
}
if (massInputFormSubmitHandler) {
return massInputFormSubmitHandler(event);
}
}
function getMassInputSubmitButtons() {
return Array.from(element.querySelectorAll('button[type="submit"][name][value], .' + MASS_INPUT_SUBMIT_BUTTON_CLASS));
}
function setupSubmitButton(button) {
button.setAttribute('type', 'button');
button.classList.add(MASS_INPUT_SUBMIT_BUTTON_CLASS);
button.addEventListener('click', massInputFormSubmitHandler);
}
function resetSubmitButton(button) {
button.setAttribute('type', 'submit');
button.classList.remove(MASS_INPUT_SUBMIT_BUTTON_CLASS);
button.removeEventListener('click', massInputFormSubmitHandler);
}
function processResponse(responseElement) {
element.innerHTML = "";
element.appendChild(responseElement);
reset();
app.utilRegistry.setupAll(element);
}
function serializeForm(submitButton, enctype) {
var formData = new FormData(massInputForm);
// manually add name and value of submit button to formData
formData.append(submitButton.name, submitButton.value);
if (enctype === 'application/x-www-form-urlencoded') {
return new URLSearchParams(formData);
} else if (enctype === 'multipart/form-data') {
return formData;
} else {
throw new Error('Unsupported form enctype: ' + enctype);
}
}
function reFocusAddCell() {
var addCell = element.querySelector(MASS_INPUT_ADD_CELL_SELECTOR);
if (!addCell) {
return false;
}
var addCellInput = addCell.querySelector('input:not([type="hidden"])');
if (addCellInput) {
// Clearing of add-inputs is done in the backend
addCellInput.focus();
}
}
function reset() {
element.classList.remove(MASS_INPUT_INITIALIZED_CLASS);
massInputForm.removeEventListener('submit', massInputFormSubmitHandler);
massInputForm.removeEventListener('keypress', keypressHandler);
var buttons = getMassInputSubmitButtons();
buttons.forEach(function(button) {
resetSubmitButton(button);
});
}
return init();
};
export default {
name: MASS_INPUT_UTIL_NAME,
selector: MASS_INPUT_UTIL_SELECTOR,
setup: massInputUtil,
};

View File

@ -0,0 +1,203 @@
import './modal.scss';
/**
*
* Modal Utility
*
* Attribute: uw-modal
*
* Params:
* data-modal-trigger: string
* Selector for the element that toggles the modal.
* If trigger element has "href" attribute the modal will be dynamically loaded from the referenced page
* data-modal-closeable: boolean property
* If the param is present the modal will have a close-icon and can also be closed by clicking anywhere on the overlay
*
* Example usage:
* <div uw-modal data-modal-trigger='#trigger' data-modal-closeable>This is the modal content
* <div id='trigger'>Click me to open the modal
*/
var MODAL_UTIL_NAME = 'modal';
var MODAL_UTIL_SELECTOR = '[uw-modal]';
var MODAL_HEADERS = {
'Is-Modal': 'True',
};
var MODAL_INITIALIZED_CLASS = 'modal--initialized';
var MODAL_CLASS = 'modal';
var MODAL_OPEN_CLASS = 'modal--open';
var MODAL_TRIGGER_CLASS = 'modal__trigger';
var MODAL_CONTENT_CLASS = 'modal__content';
var MODAL_OVERLAY_CLASS = 'modal__overlay';
var MODAL_OVERLAY_OPEN_CLASS = 'modal__overlay--open';
var MODAL_CLOSER_CLASS = 'modal__closer';
var MAIN_CONTENT_CLASS = 'main__content-body';
// one singleton wrapper to keep all the modals to avoid CSS bug
// with blurry text due to `transform: translate(-50%, -50%)`
// will be created (and reused) for the first modal that gets initialized
var MODALS_WRAPPER_CLASS = 'modals-wrapper';
var MODALS_WRAPPER_SELECTOR = '.' + MODALS_WRAPPER_CLASS;
var MODALS_WRAPPER_OPEN_CLASS = 'modals-wrapper--open';
var modalUtil = function(element, app) {
var modalsWrapper;
var modalOverlay;
var modalUrl;
function _init() {
if (!element) {
throw new Error('Modal utility cannot be setup without an element!');
}
if (element.classList.contains(MODAL_INITIALIZED_CLASS)) {
return false;
}
ensureModalWrapper();
// param modalTrigger
if (!element.dataset.modalTrigger) {
throw new Error('Modal utility cannot be setup without a trigger element!');
} else {
setupTrigger();
}
// param modalCloseable
if (element.dataset.modalCloseable !== undefined) {
setupCloser();
}
// mark as initialized and add modal class for styling
element.classList.add(MODAL_INITIALIZED_CLASS, MODAL_CLASS);
return {
name: MODAL_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function ensureModalWrapper() {
modalsWrapper = document.querySelector(MODALS_WRAPPER_SELECTOR);
if (!modalsWrapper) {
// create modal wrapper
modalsWrapper = document.createElement('div');
modalsWrapper.classList.add(MODALS_WRAPPER_CLASS);
document.body.appendChild(modalsWrapper);
}
modalOverlay = modalsWrapper.querySelector('.' + MODAL_OVERLAY_CLASS);
if (!modalOverlay) {
// create modal overlay
modalOverlay = document.createElement('div');
modalOverlay.classList.add(MODAL_OVERLAY_CLASS);
modalsWrapper.appendChild(modalOverlay);
}
}
function setupTrigger() {
var triggerSelector = element.dataset.modalTrigger;
if (!triggerSelector.startsWith('#')) {
triggerSelector = '#' + triggerSelector;
}
var triggerElement = document.querySelector(triggerSelector);
if (!triggerElement) {
throw new Error('Trigger element for Modal not found: "' + triggerSelector + '"');
}
triggerElement.classList.add(MODAL_TRIGGER_CLASS);
triggerElement.addEventListener('click', onTriggerClicked, false);
modalUrl = triggerElement.getAttribute('href');
}
function setupCloser() {
var closerElement = document.createElement('div');
element.insertBefore(closerElement, null);
closerElement.classList.add(MODAL_CLOSER_CLASS);
closerElement.addEventListener('click', onCloseClicked, false);
modalOverlay.addEventListener('click', onCloseClicked, false);
}
function onTriggerClicked(event) {
event.preventDefault();
open();
}
function onCloseClicked(event) {
event.preventDefault();
close();
}
function onKeyUp(event) {
if (event.key === 'Escape') {
close();
}
}
function open() {
element.classList.add(MODAL_OPEN_CLASS);
modalOverlay.classList.add(MODAL_OVERLAY_OPEN_CLASS);
modalsWrapper.classList.add(MODALS_WRAPPER_OPEN_CLASS);
modalsWrapper.appendChild(element);
if (modalUrl) {
fillModal(modalUrl);
}
document.addEventListener('keyup', onKeyUp);
}
function close() {
modalOverlay.classList.remove(MODAL_OVERLAY_OPEN_CLASS);
element.classList.remove(MODAL_OPEN_CLASS);
modalsWrapper.classList.remove(MODALS_WRAPPER_OPEN_CLASS);
document.removeEventListener('keyup', onKeyUp);
};
function fillModal(url) {
app.httpClient.get({
url: url,
headers: MODAL_HEADERS,
}).then(function(response) {
return app.htmlHelpers.parseResponse(response);
}).then(function(response) {
processResponse(response.element);
});
}
function processResponse(responseElement) {
var modalContent = document.createElement('div');
modalContent.classList.add(MODAL_CONTENT_CLASS);
var contentBody = responseElement.querySelector('.' + MAIN_CONTENT_CLASS);
if (contentBody) {
modalContent.innerHTML = contentBody.innerHTML;
}
var previousModalContent = element.querySelector('.' + MODAL_CONTENT_CLASS);
if (previousModalContent) {
previousModalContent.remove();
}
element.insertBefore(modalContent, null);
// setup any newly arrived utils
app.utilRegistry.setupAll(element);
}
return _init();
};
export default {
name: MODAL_UTIL_NAME,
selector: MODAL_UTIL_SELECTOR,
setup: modalUtil,
};

View File

@ -0,0 +1,109 @@
import './show-hide.scss';
var SHOW_HIDE_UTIL_NAME = 'showHide';
var SHOW_HIDE_UTIL_SELECTOR = '[uw-show-hide]';
var SHOW_HIDE_LOCAL_STORAGE_KEY = 'SHOW_HIDE';
var SHOW_HIDE_INITIALIZED_CLASS = 'show-hide--initialized';
var SHOW_HIDE_COLLAPSED_CLASS = 'show-hide--collapsed';
var SHOW_HIDE_TOGGLE_CLASS = 'show-hide__toggle';
var SHOW_HIDE_TOGGLE_RIGHT_CLASS = 'show-hide__toggle--right';
/**
*
* ShowHide Utility
*
* Attribute: uw-show-hide
*
* Params: (all optional)
* data-show-hide-id: string
* If this param is given the state of the utility will be persisted in the clients local storage.
* data-show-hide-collapsed: boolean property
* If this param is present the ShowHide utility will be collapsed. This value will be overruled by any value stored in the LocalStorage.
* data-show-hide-align: 'right'
* Where to put the arrow that marks the element as a ShowHide toggle. Left of toggle by default.
*
* Example usage:
* <div>
* <div uw-show-hide>Click me
* <div>This will be toggled
* <div>This will be toggled as well
*/
var showHideUtil = function(element) {
var showHideId;
function init() {
if (!element) {
throw new Error('ShowHide utility cannot be setup without an element!');
}
if (element.classList.contains(SHOW_HIDE_INITIALIZED_CLASS)) {
return false;
}
// register click listener
addClickListener();
// param showHideId
if (element.dataset.showHideId) {
showHideId = element.dataset.showHideId;
}
// param showHideCollapsed
var collapsed = false;
if (element.dataset.showHideCollapsed !== undefined) {
collapsed = true;
}
if (showHideId) {
var localStorageCollapsed = getLocalStorage()[showHideId];
if (typeof localStorageCollapsed !== 'undefined') {
collapsed = localStorageCollapsed;
}
}
element.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS, collapsed);
// param showHideAlign
var alignment = element.dataset.showHideAlign;
if (alignment === 'right') {
element.classList.add(SHOW_HIDE_TOGGLE_RIGHT_CLASS);
}
// mark as initialized
element.classList.add(SHOW_HIDE_INITIALIZED_CLASS, SHOW_HIDE_TOGGLE_CLASS);
return {
name: SHOW_HIDE_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function addClickListener() {
element.addEventListener('click', function clickListener() {
var newState = element.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS);
if (showHideId) {
setLocalStorage(showHideId, newState);
}
});
}
function setLocalStorage(id, state) {
var lsData = getLocalStorage();
lsData[id] = state;
window.localStorage.setItem(SHOW_HIDE_LOCAL_STORAGE_KEY, JSON.stringify(lsData));
}
function getLocalStorage() {
return JSON.parse(window.localStorage.getItem(SHOW_HIDE_LOCAL_STORAGE_KEY)) || {};
}
return init();
};
export default {
name: SHOW_HIDE_UTIL_NAME,
selector: SHOW_HIDE_UTIL_SELECTOR,
setup: showHideUtil,
};

View File

@ -1,3 +1,5 @@
import './tabber.scss';
(function($) {
document.addEventListener('DOMContentLoaded', function() {
@ -26,7 +28,7 @@
}
tab.hide();
var loaded = false;
tabs.push({index: i, name: tabName, file: tabFile, dom: tab, opener: $opener, loaded: false});
tabs.push({index: i, name: tabName, file: tabFile, dom: tab, opener: $opener, loaded: loaded });
});
$this.on('click', 'a[href^="#"]', function(event) {

View File

@ -0,0 +1,7 @@
import './tooltips.scss';
export default {
name: 'tooltips',
selector: '[not-something-that-would-be-found]',
setup: () => {},
};

View File

@ -0,0 +1,38 @@
import alerts from './alerts/alerts';
import asidenav from './asidenav/asidenav';
import asyncForm from './async-form/async-form';
import asyncTable from './async-table/async-table';
import checkAll from './check-all/check-all';
import massInput from './mass-input/mass-input';
import { fileInput, checkbox } from './inputs/inputs';
import modal from './modal/modal';
import showHide from './show-hide/show-hide';
import {
interactiveFieldset,
navigateAwayPrompt,
autoSubmitButton,
autoSubmitInput,
formErrorRemover,
datepicker,
} from './form/form';
import tooltips from './tooltips/tooltips';
export const Utils = [
alerts,
asidenav,
asyncForm,
asyncTable,
checkAll,
massInput,
fileInput,
checkbox,
modal,
showHide,
interactiveFieldset,
navigateAwayPrompt,
autoSubmitButton,
autoSubmitInput,
formErrorRemover,
datepicker,
tooltips,
];

2
frontend/vendor/main.js vendored Normal file
View File

@ -0,0 +1,2 @@
import './fontawesome.css';
import './flatpickr.css';

80
karma.conf.js Normal file
View File

@ -0,0 +1,80 @@
/* eslint-disable */
module.exports = function(config) {
config.set({
//root path location to resolve paths defined in files and exclude
basePath: '',
//files/patterns to load in the browser
files: ['frontend/src/**/*.spec.js'],
//executes the tests whenever one of watched files changes
autoWatch: true,
//if true, Karma will run tests and then exit browser
singleRun: true,
//if true, Karma fails on running empty test-suites
failOnEmptyTestSuite:false,
//reduce the kind of information passed to the bash
logLevel: config.LOG_WARN, //config.LOG_DISABLE, config.LOG_ERROR, config.LOG_INFO, config.LOG_DEBUG
//list of frameworks you want to use, only jasmine is installed automatically
frameworks: ['jasmine'],
//list of browsers to launch and capture
browsers: ['ChromeHeadless'],
//list of reporters to use
reporters: ['mocha','kjhtml'],
client: {
jasmine:{
//tells jasmine to run specs in semi random order, false is default
random: false
}
},
/* karma-webpack config
pass your webpack configuration for karma
add `babel-loader` to the webpack configuration to make the ES6+ code readable to the browser */
webpack: {
module: {
rules: [
{
test: /\.js$/i,
exclude:/(node_modules)/,
loader:'babel-loader',
options:{
presets:['@babel/preset-env']
}
},
{
test: /\.(css|scss)$/i,
loader:'null-loader',
}
]
}
},
preprocessors: {
//add webpack as preprocessor to support require() in test-suits .js files
'./frontend/src/**/*.js': ['webpack']
},
webpackMiddleware: {
//turn off webpack bash output when run the tests
noInfo: true,
stats: 'errors-only'
},
customLaunchers: {
ChromeHeadless: {
base: 'Chrome',
flags: [
'--headless',
'--disable-gpu',
'--no-sandbox',
// Without a remote debugging port, Google Chrome exits immediately.
'--remote-debugging-port=9222',
],
}
},
/*karma-mocha-reporter config*/
mochaReporter: {
output: 'noFailures' //full, autowatch, minimal
}
});
};

13601
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

73
package.json Normal file
View File

@ -0,0 +1,73 @@
{
"name": "uni2work",
"version": "1.0.0",
"description": "",
"keywords": [],
"author": "",
"license": "ISC",
"scripts": {
"start": "run-p frontend:build:watch yesod:start",
"test": "run-s frontend:test yesod:test",
"lint": "run-s frontend:lint yesod:lint",
"yesod:db": "./db.sh",
"yesod:start": "./start.sh",
"yesod:lint": "./hlint.sh",
"yesod:test": "./test.sh",
"frontend:lint": "eslint frontend/src",
"frontend:test": "karma start --conf karma.conf.js",
"frontend:test:watch": "karma start --conf karma.conf.js --single-run false",
"frontend:build": "webpack",
"frontend:build:watch": "webpack --watch"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"frontend/src/**/*.js": [
"eslint",
"git add"
]
},
"browserslist": [
"defaults"
],
"devDependencies": {
"@babel/cli": "^7.4.4",
"@babel/core": "^7.4.5",
"@babel/plugin-proposal-class-properties": "^7.4.4",
"@babel/preset-env": "^7.4.5",
"autoprefixer": "^9.5.1",
"babel-core": "^6.26.3",
"babel-eslint": "^10.0.1",
"babel-loader": "^8.0.6",
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"babel-preset-es2015": "^6.24.1",
"css-loader": "^2.1.1",
"eslint": "^5.16.0",
"extract-text-webpack-plugin": "^4.0.0-beta.0",
"husky": "^2.3.0",
"jasmine-core": "^3.4.0",
"karma": "^4.1.0",
"karma-chrome-launcher": "^2.2.0",
"karma-cli": "^2.0.0",
"karma-jasmine": "^2.0.1",
"karma-jasmine-html-reporter": "^1.4.2",
"karma-mocha-reporter": "^2.2.5",
"karma-webpack": "^3.0.5",
"lint-staged": "^8.1.7",
"node-sass": "^4.12.0",
"npm-run-all": "^4.1.5",
"null-loader": "^2.0.0",
"postcss-loader": "^3.0.0",
"sass-loader": "^7.1.0",
"style-loader": "^0.23.1",
"uglifyjs-webpack-plugin": "^2.1.3",
"webpack": "^4.32.2",
"webpack-cli": "^3.3.2"
},
"dependencies": {
"flatpickr": "^4.5.7"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
/* eslint-disable */
module.exports = {
plugins: [
require('autoprefixer')
]
}

View File

@ -57,7 +57,7 @@ dummyLogin = AuthPlugin{..}
{ formMethod = POST
, formAction = Just . SomeRoute . toMaster $ PluginR "dummy" []
, formEncoding = loginEnctype
, formAttrs = []
, formAttrs = [("uw-no-navigate-away-prompt","")]
, formSubmit = FormSubmit
, formAnchor = Just "login--dummy" :: Maybe Text
}

View File

@ -117,7 +117,7 @@ campusLogin conf@LdapConf{..} pool = AuthPlugin{..}
{ formMethod = POST
, formAction = Just . SomeRoute . toMaster $ PluginR "LDAP" []
, formEncoding = loginEnctype
, formAttrs = []
, formAttrs = [("uw-no-navigate-away-prompt","")]
, formSubmit = FormSubmit
, formAnchor = Just "login--campus" :: Maybe Text
}

View File

@ -93,7 +93,7 @@ hashLogin pwHashAlgo = AuthPlugin{..}
{ formMethod = POST
, formAction = Just . SomeRoute . toMaster $ PluginR "PWHash" []
, formEncoding = loginEnctype
, formAttrs = []
, formAttrs = [("uw-no-navigate-away-prompt","")]
, formSubmit = FormSubmit
, formAnchor = Just "login--hash" :: Maybe Text
}

View File

@ -1322,46 +1322,15 @@ siteLayout' headingOverride widget = do
frontendI18n = toJSON (mr :: FrontendMessage -> Text)
pc <- widgetToPageContent $ do
-- 3rd party
addScript $ StaticR js_vendor_flatpickr_js
addScript $ StaticR js_vendor_zepto_js
addStylesheet $ StaticR css_vendor_flatpickr_css
addStylesheet $ StaticR css_vendor_fontawesome_css
-- fonts
addStylesheet $ StaticR css_fonts_css
-- polyfills
addScript $ StaticR js_polyfills_fetchPolyfill_js
addScript $ StaticR js_polyfills_urlPolyfill_js
-- JavaScript services
addScript $ StaticR js_services_utilRegistry_js
addScript $ StaticR js_services_htmlHelpers_js
addScript $ StaticR js_services_httpClient_js
addScript $ StaticR js_services_i18n_js
-- JavaScript utils
addScript $ StaticR js_utils_alerts_js
addScript $ StaticR js_utils_asidenav_js
addScript $ StaticR js_utils_asyncForm_js
addScript $ StaticR js_utils_asyncTable_js
addScript $ StaticR js_utils_checkAll_js
addScript $ StaticR js_utils_form_js
addScript $ StaticR js_utils_inputs_js
addScript $ StaticR js_utils_massInput_js
addScript $ StaticR js_utils_modal_js
addScript $ StaticR js_utils_showHide_js
-- addScript $ StaticR js_utils_tabber_js
addStylesheet $ StaticR css_utils_alerts_scss
addStylesheet $ StaticR css_utils_asidenav_scss
addStylesheet $ StaticR css_utils_asyncForm_scss
addStylesheet $ StaticR css_utils_asyncTable_scss
addStylesheet $ StaticR css_utils_asyncTableFilter_scss
addStylesheet $ StaticR css_utils_checkbox_scss
addStylesheet $ StaticR css_utils_form_scss
addStylesheet $ StaticR css_utils_inputs_scss
addStylesheet $ StaticR css_utils_modal_scss
addStylesheet $ StaticR css_utils_radio_scss
addStylesheet $ StaticR css_utils_showHide_scss
addStylesheet $ StaticR css_utils_tabber_scss
addStylesheet $ StaticR css_utils_tooltip_scss
addStylesheet $ StaticR fonts_fonts_css
-- SCSS
addStylesheet $ StaticR bundles_css_vendor_css
addStylesheet $ StaticR bundles_css_main_css
-- JavaScript
addScript $ StaticR bundles_js_polyfills_js
addScript $ StaticR bundles_js_vendor_js
addScript $ StaticR bundles_js_main_js
-- widgets
$(widgetFile "default-layout")
withUrlRenderer $(hamletFile "templates/default-layout-wrapper.hamlet")

View File

@ -420,8 +420,8 @@ uploadModeForm prev = multiActionA actions (fslI MsgSheetUploadMode) (classifyUp
let iStart = maybe 0 (succ . fst) $ Map.lookupMax oldRess
in pure $ Map.singleton iStart fileRes
return (addRes', formWidget')
miCell _ initFile initFile' nudge csrf =
sFileForm nudge (Just $ fromMaybe initFile initFile') csrf
miCell _ initFile _ nudge csrf =
sFileForm nudge (Just initFile) csrf
miDelete = miDeleteList
miAllowAdd _ _ _ = True
miAddEmpty _ _ _ = Set.empty

View File

@ -9,6 +9,7 @@ module Handler.Utils.Form.MassInput
, massInputA, massInputW
, massInputList
, massInputAccum, massInputAccumA, massInputAccumW
, massInputAccumEdit, massInputAccumEditA, massInputAccumEditW
, ListLength(..), ListPosition(..), miDeleteList
, EnumLiveliness(..), EnumPosition(..)
, MapLiveliness(..)
@ -564,6 +565,83 @@ massInputAccumW :: forall handler cellData ident.
massInputAccumW miAdd' miCell' miButtonAction' miLayout' miIdent' fSettings fRequired mPrev
= mFormToWForm $ massInputAccum miAdd' miCell' miButtonAction' miLayout' miIdent' fSettings fRequired mPrev mempty
-- | Wrapper around `massInput` for the common case, that we just want a list of data with existing data modified the same way as new data is added
massInputAccumEdit :: forall handler cellData ident.
( MonadHandler handler, HandlerSite handler ~ UniWorX
, MonadLogger handler
, ToJSON cellData, FromJSON cellData
, PathPiece ident
)
=> ((Text -> Text) -> FieldView UniWorX -> (Markup -> MForm handler (FormResult ([cellData] -> FormResult [cellData]), Widget)))
-> ((Text -> Text) -> cellData -> (Markup -> MForm handler (FormResult cellData, Widget)))
-> (forall p. PathPiece p => p -> Maybe (SomeRoute UniWorX))
-> MassInputLayout ListLength cellData cellData
-> ident
-> FieldSettings UniWorX
-> Bool
-> Maybe [cellData]
-> (Markup -> MForm handler (FormResult [cellData], FieldView UniWorX))
massInputAccumEdit miAdd' miCell' miButtonAction miLayout miIdent fSettings fRequired mPrev csrf
= over (_1 . mapped) (map snd . Map.elems) <$> massInput MassInput{..} fSettings fRequired (Map.fromList . zip [0..] . map (\x -> (x, x)) <$> mPrev) csrf
where
miAdd :: ListPosition -> Natural
-> (Text -> Text) -> FieldView UniWorX
-> Maybe (Markup -> MForm handler (FormResult (Map ListPosition cellData -> FormResult (Map ListPosition cellData)), Widget))
miAdd _pos _dim nudge submitView = Just $ \csrf' -> over (_1 . mapped) doAdd <$> miAdd' nudge submitView csrf'
doAdd :: ([cellData] -> FormResult [cellData]) -> (Map ListPosition cellData -> FormResult (Map ListPosition cellData))
doAdd f prevData = Map.fromList . zip [startKey..] <$> f prevElems
where
prevElems = Map.elems prevData
startKey = maybe 0 succ $ fst <$> Map.lookupMax prevData
miCell :: ListPosition -> cellData -> Maybe cellData -> (Text -> Text)
-> (Markup -> MForm handler (FormResult cellData, Widget))
miCell _pos dat _mPrev nudge = miCell' nudge dat
miDelete = miDeleteList
miAllowAdd _ _ _ = True
miAddEmpty _ _ _ = Set.empty
massInputAccumEditA :: forall handler cellData ident.
( MonadHandler handler, HandlerSite handler ~ UniWorX
, MonadLogger handler
, ToJSON cellData, FromJSON cellData
, PathPiece ident
)
=> ((Text -> Text) -> FieldView UniWorX -> (Markup -> MForm handler (FormResult ([cellData] -> FormResult [cellData]), Widget)))
-> ((Text -> Text) -> cellData -> (Markup -> MForm handler (FormResult cellData, Widget)))
-> (forall p. PathPiece p => p -> Maybe (SomeRoute UniWorX))
-> MassInputLayout ListLength cellData cellData
-> ident
-> FieldSettings UniWorX
-> Bool
-> Maybe [cellData]
-> AForm handler [cellData]
massInputAccumEditA miAdd' miCell' miButtonAction' miLayout' miIdent' fSettings fRequired mPrev
= formToAForm $ over _2 pure <$> massInputAccumEdit miAdd' miCell' miButtonAction' miLayout' miIdent' fSettings fRequired mPrev mempty
massInputAccumEditW :: forall handler cellData ident.
( MonadHandler handler, HandlerSite handler ~ UniWorX
, MonadLogger handler
, ToJSON cellData, FromJSON cellData
, PathPiece ident
)
=> ((Text -> Text) -> FieldView UniWorX -> (Markup -> MForm handler (FormResult ([cellData] -> FormResult [cellData]), Widget)))
-> ((Text -> Text) -> cellData -> (Markup -> MForm handler (FormResult cellData, Widget)))
-> (forall p. PathPiece p => p -> Maybe (SomeRoute UniWorX))
-> MassInputLayout ListLength cellData cellData
-> ident
-> FieldSettings UniWorX
-> Bool
-> Maybe [cellData]
-> WForm handler (FormResult [cellData])
massInputAccumEditW miAdd' miCell' miButtonAction' miLayout' miIdent' fSettings fRequired mPrev
= mFormToWForm $ massInputAccumEdit miAdd' miCell' miButtonAction' miLayout' miIdent' fSettings fRequired mPrev mempty
massInputA :: forall handler cellData cellResult liveliness.
( MonadHandler handler, HandlerSite handler ~ UniWorX

View File

@ -129,7 +129,7 @@ assignSubmissions sid restriction = do
(E.Value sheetId, Entity subId Submission{..}, _) <- submissionDataRaw
guard $ sheetId == sid
case restriction of
Just restriction' ->
Just restriction' ->
guard $ subId `Set.member` restriction'
Nothing ->
guard $ is _Nothing submissionRatingBy
@ -146,7 +146,7 @@ assignSubmissions sid restriction = do
=> (Map SubmissionId a -> b)
-> m b
withSubmissionData f = f <$> (mappend <$> ask <*> State.get)
-- | How many additional submission should the given corrector be assigned, if possible?
calculateDeficit :: UserId -> Map SubmissionId (Maybe UserId, Map UserId _, SheetId) -> Rational
calculateDeficit corrector submissionState = getSum $ foldMap Sum deficitBySheet
@ -178,7 +178,7 @@ assignSubmissions sid restriction = do
, fromMaybe 0 $ do
guard $ corrState /= CorrectorExcused
return . negate $ (byProportion / proportionSum) * fromIntegral sheetSize
]
]
| otherwise
= assigned
return $ negate extra
@ -257,6 +257,7 @@ submissionMultiArchive (Set.toList -> ids) = do
execWriter . forM ratedSubmissions $ \(_rating,_submission,(shn,csh,ssh,tid)) ->
tell (Set.singleton shn, Set.singleton csh, Set.singleton ssh, Set.singleton tid)
setContentDisposition' $ Just "submissions.zip"
(<* cleanup) . respondSource "application/zip" . transPipe (runDBRunner dbrunner) $ do
let
fileEntitySource' :: (Rating, Entity Submission, (SheetName,CourseShorthand,SchoolId,TermId)) -> Source (YesodDB UniWorX) File

View File

@ -13,6 +13,7 @@ module Handler.Utils.Table.Pagination
, PagesizeLimit(..)
, PaginationSettings(..), PaginationInput(..), piIsUnset
, PSValidator(..)
, defaultPagesize
, defaultFilter, defaultSorting
, restrictFilter, restrictSorting
, ToSortable(..), Sortable(..)
@ -314,6 +315,13 @@ defaultSorting psSorting (runPSValidator -> f) = PSValidator $ \dbTable' -> inje
Just _ -> id
Nothing -> set (_2._psSorting) psSorting
defaultPagesize :: PagesizeLimit -> PSValidator m x -> PSValidator m x
defaultPagesize psLimit (runPSValidator -> f) = PSValidator $ \dbTable' -> injectDefault <*> f dbTable'
where
injectDefault x = case x >>= piLimit of
Just _ -> id
Nothing -> set (_2._psLimit) psLimit
restrictFilter :: (FilterKey -> [Text] -> Bool) -> PSValidator m x -> PSValidator m x
restrictFilter restrict (runPSValidator -> f) = PSValidator $ \dbTable' ps -> over _2 restrict' $ f dbTable' ps
where

View File

@ -1,50 +0,0 @@
(function () {
'use strict';
window.HtmlHelpers = (function() {
// `parseResponse` takes a raw HttpClient response and an options object.
// Returns an object with `element` being an contextual fragment of the
// HTML in the response and `ifPrefix` being the prefix that was used to
// "unique-ify" the ids of the received HTML.
// Original Response IDs can optionally be kept by adding `keepIds: true`
// to the `options` object.
function parseResponse(response, options) {
options = options || {};
return response.text().then(function (responseText) {
var docFrag = document.createRange().createContextualFragment(responseText);
var idPrefix;
if (!options.keepIds) {
idPrefix = _getIdPrefix();
_prefixIds(docFrag, idPrefix);
}
return Promise.resolve({ idPrefix: idPrefix, element: docFrag });
},
function (error) {
return Promise.reject(error);
}).catch(function (error) { console.error(error); });
}
function _prefixIds(element, idPrefix) {
var idAttrs = ['id', 'for', 'data-conditional-input', 'data-modal-trigger'];
idAttrs.forEach(function(attr) {
Array.from(element.querySelectorAll('[' + attr + ']')).forEach(function(input) {
var value = idPrefix + input.getAttribute(attr);
input.setAttribute(attr, value);
});
});
}
function _getIdPrefix() {
// leading 'r'(andom) to overcome the fact that IDs
// starting with a numeric value are not valid in CSS
return 'r' + Math.floor(Math.random() * 100000) + '__';
}
return {
parseResponse: parseResponse,
}
})();
})();

View File

@ -1,72 +0,0 @@
(function () {
'use strict';
window.HttpClient = (function() {
var _responseInterceptors = [];
function addResponseInterceptor(interceptor) {
if (typeof interceptor === 'function') {
_responseInterceptors.push(interceptor);
}
}
function _fetch(options) {
var requestOptions = {
credentials: 'same-origin',
headers: { },
method: options.method,
body: options.body,
};
Object.keys(options.headers).forEach(function(headerKey) {
requestOptions.headers[headerKey] = options.headers[headerKey];
});
return fetch(options.url, requestOptions).then(
function(response) {
_responseInterceptors.forEach(function(interceptor) {
interceptor(response, options);
});
return Promise.resolve(response);
},
function(error) {
return Promise.reject(error);
}
).catch(function(error) {
console.error(error);
});
}
return {
get: function(args) {
args.method = 'GET';
return _fetch(args);
},
post: function(args) {
args.method = 'POST';
return _fetch(args);
},
addResponseInterceptor: addResponseInterceptor,
ACCEPT: {
TEXT_HTML: 'text/html',
JSON: 'application/json',
},
}
})();
// HttpClient ships with its own little interceptor to throw an error
// if the response does not match the expected content-type
function contentTypeInterceptor(response, options) {
if (!options || !options.accept) {
return;
}
var contentType = response.headers.get("content-type");
if (!contentType.match(options.accept)) {
throw new Error('Server returned with "' + contentType + '" when "' + options.accept + '" was expected');
}
}
HttpClient.addResponseInterceptor(contentTypeInterceptor);
})();

View File

@ -1,44 +0,0 @@
(function() {
'use strict';
/**
* I18n
*
* This module stores and serves translated strings, according to the users language settings.
*
* Translations are stored in /messages/frontend/*.msg.
*
* To make additions to any of these files accessible to JavaScrip Utilities
* you need to add them to the respective *.msg file and to the list of FrontendMessages
* in /src/Utils/Frontend/I18n.hs.
*
*/
window.I18n = (function() {
var translations = {};
function addTranslation(id, translation) {
translations[id] = translation;
}
function addManyTranslations(manyTranslations) {
Object.keys(manyTranslations).forEach(function(key) {
addTranslation(key, manyTranslations[key]);
});
}
function getTranslation(id) {
if (!translations[id]) {
throw new Error('I18N Error: Translation missing for »' + id + '«!');
}
return translations[id];
}
// public API
return {
add: addTranslation,
addMany: addManyTranslations,
get: getTranslation,
};
})();
})();

View File

@ -1,147 +0,0 @@
(function() {
'use strict';
var registeredUtils = [];
var activeUtilInstances = [];
var DEBUG_MODE = /localhost/.test(window.location.href) && 0;
// Registry
// (revealing module pattern)
window.UtilRegistry = (function() {
/**
* function registerUtil
*
* utils need to have at least these properties:
* name: string | utils name, e.g. 'example'
* selector: string | utils selector, e.g. '[uw-example]'
* setup: Function | utils setup function, see below
*
* setup function must return instance object with at least these properties:
* name: string | utils name
* element: HTMLElement | element the util is applied to
* destroy: Function | function to destroy the util and remove any listeners
*
* @param util Object Utility that should be added to the registry
*/
function registerUtil(util) {
if (DEBUG_MODE > 2) {
console.log('registering util "' + util.name + '"');
console.log({ util });
}
registeredUtils.push(util);
}
function deregisterUtil(name, destroy) {
var utilIndex = _findUtilIndex(name);
if (utilIndex >= 0) {
if (destroy === true) {
_destroyUtilInstances(name);
}
registeredUtils.splice(utilIndex, 1);
}
}
function setupAllUtils(scope) {
if (DEBUG_MODE > 1) {
console.info('registered js utilities:');
console.table(registeredUtils);
}
registeredUtils.forEach(function(util) {
setupUtil(util, scope);
});
}
function setupUtil(util, scope) {
if (DEBUG_MODE > 2) {
console.log('setting up util', { util });
}
scope = scope || document.body;
if (util && typeof util.setup === 'function') {
const elements = _findUtilElements(util, scope);
elements.forEach(function(element) {
var utilInstance = null;
try {
utilInstance = util.setup(element);
} catch(err) {
if (DEBUG_MODE > 0) {
console.warn('Error while trying to initialize a utility!', { util , element, err });
}
}
if (utilInstance) {
if (DEBUG_MODE > 2) {
console.info('Got utility instance for utility "' + util.name + '"', { utilInstance });
}
activeUtilInstances.push(utilInstance);
}
});
}
}
function findUtil(name) {
return registeredUtils.find(function(util) {
return util.name === name;
});
}
function _findUtilElements(util, scope) {
if (scope && scope.matches(util.selector)) {
return [scope];
}
return Array.from(scope.querySelectorAll(util.selector));
}
function _findUtilIndex(name) {
return registeredUtils.findIndex(function(util) {
return util.name === name;
});
}
function _destroyUtilInstances(name) {
activeUtilInstances
.map(function(util, index) {
return {
util: util,
index: index,
};
}).filter(function(activeUtil) {
// find utils instances to destroy
return activeUtil.util.name === name;
}).forEach(function(activeUtil) {
// destroy util instance
activeUtil.util.destroy();
delete activeUtilInstances[activeUtil.index];
});
// get rid of now empty array slots
activeUtilInstances = activeUtilInstances.filter(function(util) {
return !!util;
})
}
// public API
return {
register: registerUtil,
deregister: deregisterUtil,
setupAll: setupAllUtils,
setup: setupUtil,
find: findUtil,
}
})();
document.addEventListener('DOMContentLoaded', function() {
window.UtilRegistry.setupAll();
});
})();

View File

@ -1,212 +0,0 @@
(function() {
'use strict';
/**
*
* Alerts Utility
* makes alerts interactive
*
* Attribute: uw-alerts
*
* Types of alerts:
* [default]
* Regular Info Alert
* Disappears automatically after 30 seconds
* Disappears after x seconds if explicitly specified via data-decay='x'
* Can be told not to disappear with data-decay='0'
*
* [success]
* Currently no special visual appearance
* Disappears automatically after 30 seconds
*
* [warning]
* Will be coloured warning-orange regardless of user's selected theme
* Does not disappear
*
* [error]
* Will be coloured error-red regardless of user's selected theme
* Does not disappear
*
* Example usage:
* <div .alerts uw-alerts>
* <div .alerts__toggler>
* <div .alert.alert-info>
* <div .alert__closer>
* <div .alert__icon>
* <div .alert__content>
* This is some information
*
*/
var ALERTS_UTIL_NAME = 'alerts';
var ALERTS_UTIL_SELECTOR = '[uw-alerts]';
var ALERTS_INITIALIZED_CLASS = 'alerts--initialized';
var ALERTS_ELEVATED_CLASS = 'alerts--elevated';
var ALERTS_TOGGLER_CLASS = 'alerts__toggler';
var ALERTS_TOGGLER_VISIBLE_CLASS = 'alerts__toggler--visible';
var ALERTS_TOGGLER_APPEAR_DELAY = 120;
var ALERT_CLASS = 'alert';
var ALERT_INITIALIZED_CLASS = 'alert--initialized';
var ALERT_CLOSER_CLASS = 'alert__closer';
var ALERT_ICON_CLASS = 'alert__icon';
var ALERT_CONTENT_CLASS = 'alert__content';
var ALERT_INVISIBLE_CLASS = 'alert--invisible';
var ALERT_AUTO_HIDE_DELAY = 10;
var ALERT_AUTOCLOSING_MATCHER = '.alert-info, .alert-success';
var alertsUtil = function(element) {
var togglerCheckRequested = false;
var togglerElement;
var alertElements;
function init() {
if (!element) {
throw new Error('Alerts util has to be called with an element!');
}
if (element.classList.contains(ALERTS_INITIALIZED_CLASS)) {
return false;
}
togglerElement = element.querySelector('.' + ALERTS_TOGGLER_CLASS);
alertElements = gatherAlertElements();
initToggler();
initAlerts();
// register http client interceptor to filter out Alerts Header
setupHttpInterceptor();
// mark initialized
element.classList.add(ALERTS_INITIALIZED_CLASS);
return {
name: ALERTS_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function gatherAlertElements() {
return Array.from(element.querySelectorAll('.' + ALERT_CLASS)).filter(function(alert) {
return !alert.classList.contains(ALERT_INITIALIZED_CLASS);
});
}
function initToggler() {
togglerElement.addEventListener('click', function() {
alertElements.forEach(function(alertEl) {
toggleAlert(alertEl, true);
});
togglerElement.classList.remove(ALERTS_TOGGLER_VISIBLE_CLASS);
});
}
function initAlerts() {
alertElements.forEach(initAlert);
}
function initAlert(alertElement) {
var autoHideDelay = ALERT_AUTO_HIDE_DELAY;
if (alertElement.dataset.decay) {
autoHideDelay = parseInt(alertElement.dataset.decay, 10);
}
var closeEl = alertElement.querySelector('.' + ALERT_CLOSER_CLASS);
closeEl.addEventListener('click', function() {
toggleAlert(alertElement);
});
if (autoHideDelay > 0 && alertElement.matches(ALERT_AUTOCLOSING_MATCHER)) {
window.setTimeout(function() {
toggleAlert(alertElement);
}, autoHideDelay * 1000);
}
}
function toggleAlert(alertEl, visible) {
alertEl.classList.toggle(ALERT_INVISIBLE_CLASS, !visible);
checkToggler();
}
function checkToggler() {
if (togglerCheckRequested) {
return;
}
var alertsHidden = alertElements.reduce(function(acc, alert) {
return acc && alert.classList.contains(ALERT_INVISIBLE_CLASS);
}, true);
window.setTimeout(function() {
togglerElement.classList.toggle(ALERTS_TOGGLER_VISIBLE_CLASS, alertsHidden);
togglerCheckRequested = false;
}, ALERTS_TOGGLER_APPEAR_DELAY);
}
function setupHttpInterceptor() {
if (HttpClient) {
HttpClient.addResponseInterceptor(responseInterceptor.bind(this));
}
}
function elevateAlerts() {
element.classList.add(ALERTS_ELEVATED_CLASS);
}
function responseInterceptor(response) {
var alerts;
for (var header of response.headers) {
if (header[0] === 'alerts') {
var decodedHeader = decodeURIComponent(header[1]);
alerts = JSON.parse(decodedHeader);
break;
}
}
if (alerts) {
alerts.forEach(function(alert) {
var alertElement = createAlertElement(alert.status, alert.content);
element.appendChild(alertElement);
alertElements.push(alertElement);
initAlert(alertElement);
});
elevateAlerts();
}
}
function createAlertElement(type, content) {
var alertElement = document.createElement('div');
alertElement.classList.add(ALERT_CLASS, 'alert-' + type);
var alertCloser = document.createElement('div');
alertCloser.classList.add(ALERT_CLOSER_CLASS);
var alertIcon = document.createElement('div');
alertIcon.classList.add(ALERT_ICON_CLASS);
var alertContent = document.createElement('div');
alertContent.classList.add(ALERT_CONTENT_CLASS);
alertContent.innerHTML = content;
alertElement.appendChild(alertCloser);
alertElement.appendChild(alertIcon);
alertElement.appendChild(alertContent);
return alertElement;
}
return init();
};
if (UtilRegistry) {
UtilRegistry.register({
name: ALERTS_UTIL_NAME,
selector: ALERTS_UTIL_SELECTOR,
setup: alertsUtil,
});
}
})();

View File

@ -1,109 +0,0 @@
(function() {
'use strict';
/**
*
* Asidenav Utility
* Correctly positions hovered asidenav submenus and handles the favorites button on mobile
*
* Attribute: uw-asidenav
*
* Example usage:
* <div uw-asidenav>
* <div .asidenav>
* <div .asidenav__box>
* <ul .asidenav__list.list--iconless>
* <li .asidenav__list-item>
* <a .asidenav__link-wrapper href="#">
* <div .asidenav__link-shorthand>EIP
* <div .asidenav__link-label>Einführung in die Programmierung
* <div .asidenav__nested-list-wrapper>
* <ul .asidenav__nested-list.list--iconless>
* Übungsblätter
* ...
*
*/
var ASIDENAV_UTIL_NAME = 'asidenav';
var ASIDENAV_UTIL_SELECTOR = '[uw-asidenav]';
var FAVORITES_BTN_CLASS = 'navbar__list-item--favorite';
var FAVORITES_BTN_ACTIVE_CLASS = 'navbar__list-item--active';
var ASIDENAV_INITIALIZED_CLASS = 'asidenav--initialized';
var ASIDENAV_EXPANDED_CLASS = 'main__aside--expanded';
var ASIDENAV_LIST_ITEM_CLASS = 'asidenav__list-item';
var ASIDENAV_SUBMENU_CLASS = 'asidenav__nested-list-wrapper';
var asidenavUtil = function(element) {
function init() {
if (!element) {
throw new Error('Asidenav utility cannot be setup without an element!');
}
if (element.classList.contains(ASIDENAV_INITIALIZED_CLASS)) {
return false;
}
initFavoritesButton();
initAsidenavSubmenus();
// mark initialized
element.classList.add(ASIDENAV_INITIALIZED_CLASS);
return {
name: ASIDENAV_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function initFavoritesButton() {
var favoritesBtn = document.querySelector('.' + FAVORITES_BTN_CLASS);
favoritesBtn.addEventListener('click', function(event) {
favoritesBtn.classList.toggle(FAVORITES_BTN_ACTIVE_CLASS);
element.classList.toggle(ASIDENAV_EXPANDED_CLASS);
event.preventDefault();
}, true);
}
function initAsidenavSubmenus() {
var asidenavLinksWithSubmenus = Array.from(element.querySelectorAll('.' + ASIDENAV_LIST_ITEM_CLASS))
.map(function(listItem) {
var submenu = listItem.querySelector('.' + ASIDENAV_SUBMENU_CLASS);
return { listItem, submenu };
}).filter(function(union) {
return union.submenu !== null;
});
asidenavLinksWithSubmenus.forEach(function(union) {
union.listItem.addEventListener('mouseover', createMouseoverHandler(union));
});
}
function createMouseoverHandler(union) {
return function mouseoverHanlder(event) {
var rectListItem = union.listItem.getBoundingClientRect();
var rectSubMenu = union.submenu.getBoundingClientRect();
union.submenu.style.left = (rectListItem.left + rectListItem.width) + 'px';
if (window.innerHeight - rectListItem.top < rectSubMenu.height) {
union.submenu.style.top = (rectListItem.top + rectListItem.height - rectSubMenu.height) + 'px';
} else {
union.submenu.style.top = rectListItem.top + 'px';
}
};
}
return init();
};
if (UtilRegistry) {
UtilRegistry.register({
name: ASIDENAV_UTIL_NAME,
selector: ASIDENAV_UTIL_SELECTOR,
setup: asidenavUtil,
});
}
})();

View File

@ -1,126 +0,0 @@
(function collonadeClosure() {
'use strict';
/**
*
* Async Form Utility
* prevents form submissions from reloading the page but instead firing an AJAX request
*
* Attribute: uw-async-form
* (works only on <form> elements)
*
* Example usage:
* <form uw-async-form method='POST' action='...'>
* ...
*
* Internationalization:
* This utility expects the following translations to be available:
* asyncFormFailure: text that gets shown if an async form request fails
* example: "Oops. Something went wrong."
*/
var ASYNC_FORM_UTIL_NAME = 'asyncForm';
var ASYNC_FORM_UTIL_SELECTOR = 'form[uw-async-form]';
var ASYNC_FORM_INITIALIZED_CLASS = 'check-all--initialized';
var ASYNC_FORM_RESPONSE_CLASS = 'async-form__response';
var ASYNC_FORM_LOADING_CLASS = 'async-form--loading';
var ASYNC_FORM_MIN_DELAY = 600;
var MODAL_SELECTOR = '.modal';
var MODAL_HEADER_KEY = 'Is-Modal';
var MODAL_HEADER_VALUE = 'True';
var asyncFormUtil = function(element) {
var lastRequestTimestamp = 0;
function init() {
if (!element) {
throw new Error('Async Form Utility cannot be setup without an element!');
}
if (element.classList.contains(ASYNC_FORM_INITIALIZED_CLASS)) {
return false;
}
element.addEventListener('submit', submitHandler);
element.classList.add(ASYNC_FORM_INITIALIZED_CLASS);
return {
name: ASYNC_FORM_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function processResponse(response) {
var responseElement = makeResponseElement(response.content, response.status);
var parentElement = element.parentElement;
// make sure there is a delay between click and response
var delay = Math.max(0, ASYNC_FORM_MIN_DELAY + lastRequestTimestamp - Date.now());
setTimeout(function() {
parentElement.insertBefore(responseElement, element);
element.remove();
}, delay);
}
function makeResponseElement(content, status) {
var responseElement = document.createElement('div');
status = status || 'info';
responseElement.classList.add(ASYNC_FORM_RESPONSE_CLASS);
responseElement.classList.add(ASYNC_FORM_RESPONSE_CLASS + '--' + status);
responseElement.innerHTML = content;
return responseElement;
}
function submitHandler(event) {
event.preventDefault();
if (!HttpClient) {
throw new Error('HttpClient not found! Can\'t fetch submit form asynchronously!');
}
element.classList.add(ASYNC_FORM_LOADING_CLASS)
lastRequestTimestamp = Date.now();
var url = element.getAttribute('action');
var headers = { };
var body = new FormData(element);
var isModal = element.closest(MODAL_SELECTOR);
if (!!isModal) {
headers[MODAL_HEADER_KEY] = MODAL_HEADER_VALUE;
}
HttpClient.post({
url: url,
headers: headers,
body: body,
accept: HttpClient.ACCEPT.JSON,
}).then(function(response) {
return response.json();
}).then(function(response) {
processResponse(response[0]);
}).catch(function(error) {
var failureMessage = I18n.get('asyncFormFailure');
processResponse({ content: failureMessage });
element.classList.remove(ASYNC_FORM_LOADING_CLASS);
});
}
return init();
};
if (UtilRegistry) {
UtilRegistry.register({
name: ASYNC_FORM_UTIL_NAME,
selector: ASYNC_FORM_UTIL_SELECTOR,
setup: asyncFormUtil
});
}
})();

View File

@ -1,150 +0,0 @@
(function() {
'use strict';
/**
*
* Check All Checkbox Utility
* adds a Check All Checkbox above columns with only checkboxes
*
* Attribute: [none]
* (will be set up automatically on tables)
*
* Example usage:
* (table with one column thats only checkboxes)
*/
var CHECK_ALL_UTIL_NAME = 'checkAll';
var CHECK_ALL_UTIL_SELECTOR = 'table';
var CHECKBOX_SELECTOR = '[type="checkbox"]';
var CHECK_ALL_INITIALIZED_CLASS = 'check-all--initialized';
var checkAllUtil = function(element) {
var columns = [];
var checkboxColumn = [];
var checkAllCheckbox = null;
function init() {
if (!element) {
throw new Error('Check All utility cannot be setup without an element!');
}
if (element.classList.contains(CHECK_ALL_INITIALIZED_CLASS)) {
return false;
}
gatherColumns();
setupCheckAllCheckbox();
// mark initialized
element.classList.add(CHECK_ALL_INITIALIZED_CLASS);
return {
name: CHECK_ALL_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function getCheckboxId() {
return 'check-all-checkbox-' + Math.floor(Math.random() * 100000);
}
function gatherColumns(tble) {
var rows = Array.from(element.querySelectorAll('tr'));
var cols = [];
rows.forEach(function(tr) {
var cells = Array.from(tr.querySelectorAll('td'));
cells.forEach(function(cell, cellIndex) {
if (!cols[cellIndex]) {
cols[cellIndex] = [];
}
cols[cellIndex].push(cell);
});
});
columns = cols;
}
function findCheckboxColumn(columns) {
var checkboxColumnId = null;
columns.forEach(function(col, i) {
if (isCheckboxColumn(col)) {
checkboxColumnId = i;
}
});
return checkboxColumnId;
}
function isCheckboxColumn(col) {
var onlyCheckboxes = true;
col.forEach(function(cell) {
if (onlyCheckboxes && !cell.querySelector(CHECKBOX_SELECTOR)) {
onlyCheckboxes = false;
}
});
return onlyCheckboxes;
}
function setupCheckAllCheckbox() {
var checkboxColumnId = findCheckboxColumn(columns);
if (checkboxColumnId === null) {
return;
}
checkboxColumn = columns[checkboxColumnId];
var firstRow = element.querySelector('tr');
var th = Array.from(firstRow.querySelectorAll('th, td'))[checkboxColumnId];
checkAllCheckbox = document.createElement('input');
checkAllCheckbox.setAttribute('type', 'checkbox');
checkAllCheckbox.setAttribute('id', getCheckboxId());
th.insertBefore(checkAllCheckbox, th.firstChild);
// manually set up new checkbox
if (UtilRegistry) {
UtilRegistry.setup(UtilRegistry.find('checkbox'), th);
}
checkAllCheckbox.addEventListener('input', onCheckAllCheckboxInput);
setupCheckboxListeners();
}
function onCheckAllCheckboxInput() {
toggleAll(checkAllCheckbox.checked);
}
function setupCheckboxListeners() {
checkboxColumn
.map(function(cell) {
return cell.querySelector(CHECKBOX_SELECTOR);
})
.forEach(function(checkbox) {
checkbox.addEventListener('input', updateCheckAllCheckboxState);
});
}
function updateCheckAllCheckboxState() {
var allChecked = checkboxColumn.every(function(cell) {
return cell.querySelector(CHECKBOX_SELECTOR).checked;
})
checkAllCheckbox.checked = allChecked;
}
function toggleAll(checked) {
checkboxColumn.forEach(function(cell) {
cell.querySelector(CHECKBOX_SELECTOR).checked = checked;
});
}
return init();
};
// register check all checkbox util
if (UtilRegistry) {
UtilRegistry.register({
name: CHECK_ALL_UTIL_NAME,
selector: CHECK_ALL_UTIL_SELECTOR,
setup: checkAllUtil
});
}
})();

View File

@ -1,610 +0,0 @@
(function() {
'use strict';
var formUtilities = [];
/**
*
* Reactive Submit Button Utility
* disables a forms LAST sumit button as long as the required inputs are invalid
* (only checks if the value of the inputs are not empty)
*
* Attribute: [none]
* (automatically setup on all form tags)
*
* Params:
* data-formnorequired: string
* If present the submit button will never get disabled
*
* Example usage:
* <form uw-reactive-submit-button>
* <input type="text" required>
* <button type="submit">
* </form>
*/
var REACTIVE_SUBMIT_BUTTON_UTIL_NAME = 'reactiveSubmitButton';
var REACTIVE_SUBMIT_BUTTON_UTIL_SELECTOR = 'form';
var REACTIVE_SUBMIT_BUTTON_INITIALIZED_CLASS = 'reactive-submit-button--initialized';
var reactiveSubmitButtonUtil = function(element) {
var requiredInputs;
var submitButton;
function init() {
if (!element) {
throw new Error('Reactive Submit Button utility cannot be setup without an element!');
}
if (element.classList.contains(REACTIVE_SUBMIT_BUTTON_INITIALIZED_CLASS)) {
return false;
}
// abort if form has param data-formnorequired
if (element.dataset.formnorequired !== undefined) {
throw new Error('Form has formnorequired data attribute. Will skip setup of reactive submit button.');
}
requiredInputs = Array.from(element.querySelectorAll('[required]'));
if (!requiredInputs) {
// abort if form has no required inputs
throw new Error('Submit button has formnorequired data attribute. Will skip setup of reactive submit button.');
}
var submitButtons = Array.from(element.querySelectorAll('[type="submit"]'));
if (!submitButtons || !submitButtons.length) {
throw new Error('Reactive Submit Button utility couldn\'t find any submit buttons!');
}
submitButton = submitButtons.reverse()[0];
// abort if form has param data-formnorequired
if (submitButton.dataset.formnorequired !== undefined) {
return false;
}
setupInputs();
updateButtonState();
element.classList.add(REACTIVE_SUBMIT_BUTTON_INITIALIZED_CLASS);
return {
name: REACTIVE_SUBMIT_BUTTON_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function setupInputs() {
requiredInputs.forEach(function(el) {
var checkbox = el.getAttribute('type') === 'checkbox';
var eventType = checkbox ? 'change' : 'input';
el.addEventListener(eventType, function() {
updateButtonState();
});
});
}
function updateButtonState() {
if (inputsValid()) {
submitButton.removeAttribute('disabled');
} else {
submitButton.setAttribute('disabled', 'true');
}
}
function inputsValid() {
var done = true;
requiredInputs.forEach(function(inp) {
var len = inp.value.trim().length;
if (done && len === 0) {
done = false;
}
});
return done;
}
return init();
};
// skipping reactiveButtonUtil (for now)
// the button did not properly re-enable after filling out a form for some safari users.
// if maybe in the future there is going to be a proper way of (asynchronously) and
// meaningfully validating forms this can be re-activated by commenting in the next few lines
// formUtilities.push({
// name: REACTIVE_SUBMIT_BUTTON_UTIL_NAME,
// selector: REACTIVE_SUBMIT_BUTTON_UTIL_SELECTOR,
// setup: reactiveSubmitButtonUtil,
// });
/**
*
* Interactive Fieldset Utility
* shows/hides inputs based on value of particular input
*
* Attribute: uw-interactive-fieldset
*
* Params:
* data-conditional-input: string
* Selector for the input that this fieldset watches for changes
* data-conditional-value: string
* The value the conditional input needs to be set to for this fieldset to be shown
* Can be omitted if conditionalInput is a checkbox
*
* Example usage:
* ## example with text input
* <input id="input-0" type="text">
* <fieldset uw-interactive-fieldset data-conditional-input="#input-0" data-conditional-value="yes">...</fieldset>
* <fieldset uw-interactive-fieldset data-conditional-input="#input-0" data-conditional-value="no">...</fieldset>
* ## example with <select>
* <select id="select-0">
* <option value="0">Zero
* <option value="1">One
* <fieldset uw-interactive-fieldset data-conditional-input="#select-0" data-conditional-value="0">...</fieldset>
* <fieldset uw-interactive-fieldset data-conditional-input="#select-0" data-conditional-value="1">...</fieldset>
* ## example with checkbox
* <input id="checkbox-0" type="checkbox">
* <input id="checkbox-1" type="checkbox">
* <fieldset uw-interactive-fieldset data-conditional-input="#checkbox-0">...</fieldset>
* <fieldset uw-interactive-fieldset data-conditional-input="#checkbox-1">...</fieldset>
*/
var INTERACTIVE_FIELDSET_UTIL_NAME = 'interactiveFieldset';
var INTERACTIVE_FIELDSET_UTIL_SELECTOR = '[uw-interactive-fieldset]';
var INTERACTIVE_FIELDSET_UTIL_TARGET_SELECTOR = '.interactive-fieldset__target';
var INTERACTIVE_FIELDSET_INITIALIZED_CLASS = 'interactive-fieldset--initialized';
var INTERACTIVE_FIELDSET_CHILD_SELECTOR = 'input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled])';
var interactiveFieldsetUtil = function(element) {
var conditionalInput;
var conditionalValue;
var target;
var childInputs;
function init() {
if (!element) {
throw new Error('Interactive Fieldset utility cannot be setup without an element!');
}
if (element.classList.contains(INTERACTIVE_FIELDSET_INITIALIZED_CLASS)) {
return false;
}
// param conditionalInput
if (!element.dataset.conditionalInput) {
throw new Error('Interactive Fieldset needs a selector for a conditional input!');
}
conditionalInput = document.querySelector('#' + element.dataset.conditionalInput);
if (!conditionalInput) {
// abort if form has no required inputs
throw new Error('Couldn\'t find the conditional input. Aborting setup for interactive fieldset.');
}
// param conditionalValue
if (!element.dataset.conditionalValue && !isCheckbox()) {
throw new Error('Interactive Fieldset needs a conditional value!');
}
conditionalValue = element.dataset.conditionalValue;
target = element.closest(INTERACTIVE_FIELDSET_UTIL_TARGET_SELECTOR);
if (!target || element.matches(INTERACTIVE_FIELDSET_UTIL_TARGET_SELECTOR)) {
target = element;
}
childInputs = Array.from(element.querySelectorAll(INTERACTIVE_FIELDSET_CHILD_SELECTOR));
// add event listener
var observer = new MutationObserver(function(mutationsList, observer) {
updateVisibility();
});
observer.observe(conditionalInput, { attributes: true, attributeFilter: ['disabled'] });
conditionalInput.addEventListener('input', updateVisibility);
// initial visibility update
updateVisibility();
// mark as initialized
element.classList.add(INTERACTIVE_FIELDSET_INITIALIZED_CLASS);
return {
name: INTERACTIVE_FIELDSET_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function updateVisibility() {
var active = matchesConditionalValue() && !conditionalInput.disabled;
target.classList.toggle('hidden', !active);
childInputs.forEach(function(el) {
el.disabled = !active;
if (el._flatpickr) {
el._flatpickr.altInput.disabled = !active;
}
});
}
function matchesConditionalValue() {
if (isCheckbox()) {
return conditionalInput.checked === true;
}
return conditionalInput.value === conditionalValue;
}
function isCheckbox() {
return conditionalInput.getAttribute('type') === 'checkbox';
}
return init();
};
formUtilities.push({
name: INTERACTIVE_FIELDSET_UTIL_NAME,
selector: INTERACTIVE_FIELDSET_UTIL_SELECTOR,
setup: interactiveFieldsetUtil,
});
/**
*
* Navigate Away Prompt Utility
* This utility asks the user if (s)he really wants to navigate away
* from a page containing a form if (s)he already touched an input.
* Form-Submits will not trigger the prompt.
* Utility will ignore forms that contain auto submit elements (buttons, inputs).
*
* Attribute: [none]
* (automatically setup on all form tags that dont automatically submit, see AutoSubmitButtonUtil)
*
* Example usage:
* (any page with a form)
*/
var NAVIGATE_AWAY_PROMPT_UTIL_NAME = 'navigateAwayPrompt';
var NAVIGATE_AWAY_PROMPT_UTIL_SELECTOR = 'form';
var NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS = 'navigate-away-prompt--initialized';
var navigateAwayPromptUtil = function(element) {
var touched = false;
var unloadDueToSubmit = false;
function init() {
if (!element) {
throw new Error('Navigate Away Prompt utility needs to be passed an element!');
}
if (element.classList.contains(NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS)) {
return false;
}
// ignore forms that get submitted automatically
if (element.querySelector(AUTO_SUBMIT_BUTTON_UTIL_SELECTOR) || element.querySelector(AUTO_SUBMIT_INPUT_UTIL_SELECTOR)) {
return false;
}
window.addEventListener('beforeunload', beforeUnloadHandler);
element.addEventListener('submit', function() {
unloadDueToSubmit = true;
});
element.addEventListener('change', function() {
touched = true;
unloadDueToSubmit = false;
});
// mark initialized
element.classList.add(NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS);
return {
name: NAVIGATE_AWAY_PROMPT_UTIL_NAME,
element: element,
destroy: function() {
window.removeEventListener('beforeunload', beforeUnloadHandler);
},
};
}
function beforeUnloadHandler(event) {
// allow the event to happen if the form was not touched by the
// user or the unload event was initiated by a form submit
if (!touched || unloadDueToSubmit) {
return false;
}
// cancel the unload event. This is the standard to force the prompt to appear.
event.preventDefault();
// for all non standard compliant browsers we return a truthy value to activate the prompt.
return true;
}
return init();
};
formUtilities.push({
name: NAVIGATE_AWAY_PROMPT_UTIL_NAME,
selector: NAVIGATE_AWAY_PROMPT_UTIL_SELECTOR,
setup: navigateAwayPromptUtil,
});
/**
*
* Auto Submit Button Utility
* Hides submit buttons in forms that are submitted programmatically
* We hide the button using JavaScript so no-js users will still be able to submit the form
*
* Attribute: uw-auto-submit-button
*
* Example usage:
* <button type="submit" uw-auto-submit-button>Submit
*/
var AUTO_SUBMIT_BUTTON_UTIL_NAME = 'autoSubmitButton';
var AUTO_SUBMIT_BUTTON_UTIL_SELECTOR = '[uw-auto-submit-button]';
var AUTO_SUBMIT_BUTTON_INITIALIZED_CLASS = 'auto-submit-button--initialized';
var AUTO_SUBMIT_BUTTON_HIDDEN_CLASS = 'hidden';
var autoSubmitButtonUtil = function(element) {
if (!element) {
throw new Error('Auto Submit Button utility needs to be passed an element!');
}
if (element.classList.contains(AUTO_SUBMIT_BUTTON_INITIALIZED_CLASS)) {
return false;
}
// hide and mark initialized
element.classList.add(AUTO_SUBMIT_BUTTON_HIDDEN_CLASS, AUTO_SUBMIT_BUTTON_INITIALIZED_CLASS);
return {
name: AUTO_SUBMIT_BUTTON_UTIL_NAME,
element: element,
destroy: function() {},
};
};
formUtilities.push({
name: AUTO_SUBMIT_BUTTON_UTIL_NAME,
selector: AUTO_SUBMIT_BUTTON_UTIL_SELECTOR,
setup: autoSubmitButtonUtil,
});
/**
*
* Auto Submit Input Utility
* Programmatically submits forms when a certain input changes value
*
* Attribute: uw-auto-submit-input
*
* Example usage:
* <input type="text" uw-auto-submit-input />
*/
var AUTO_SUBMIT_INPUT_UTIL_NAME = 'autoSubmitInput';
var AUTO_SUBMIT_INPUT_UTIL_SELECTOR = '[uw-auto-submit-input]';
var AUTO_SUBMIT_INPUT_INITIALIZED_CLASS = 'auto-submit-input--initialized';
var autoSubmitInputUtil = function(element) {
var form;
var debouncedHandler;
function autoSubmit() {
form.submit();
}
function init() {
if (!element) {
throw new Error('Auto Submit Input utility needs to be passed an element!');
}
if (element.classList.contains(AUTO_SUBMIT_INPUT_INITIALIZED_CLASS)) {
return false;
}
form = element.form;
if (!form) {
throw new Error('Could not determine associated form for auto submit input');
}
debouncedHandler = debounce(autoSubmit, 500);
element.addEventListener('input', debouncedHandler);
element.classList.add(AUTO_SUBMIT_INPUT_INITIALIZED_CLASS);
return {
name: AUTO_SUBMIT_INPUT_UTIL_NAME,
element: element,
destroy: function() {
element.removeEventListener('input', debouncedHandler);
},
};
}
return init();
};
formUtilities.push({
name: AUTO_SUBMIT_INPUT_UTIL_NAME,
selector: AUTO_SUBMIT_INPUT_UTIL_SELECTOR,
setup: autoSubmitInputUtil,
});
/**
*
* Form Error Remover Utility
* Removes errors from inputs when they are focused
*
* Attribute: [none]
* (automatically setup on all form tags)
*
* Example usage:
* (any regular form that can show input errors)
*/
var FORM_ERROR_REMOVER_UTIL_NAME = 'formErrorRemover';
var FORM_ERROR_REMOVER_UTIL_SELECTOR = 'form';
var FORM_ERROR_REMOVER_INITIALIZED_CLASS = 'form-error-remover--initialized';
var FORM_ERROR_REMOVER_INPUTS_SELECTOR = 'input:not([type="hidden"]), textarea, select';
var FORM_GROUP_SELECTOR = '.form-group';
var FORM_GROUP_WITH_ERRORS_CLASS = 'form-group--has-error';
var formErrorRemoverUtil = function(element) {
var formGroups;
function init() {
if (!element) {
throw new Error('Form Error Remover utility needs to be passed an element!');
}
if (element.classList.contains(FORM_ERROR_REMOVER_INITIALIZED_CLASS)) {
return false;
}
// find form groups
formGroups = Array.from(element.querySelectorAll(FORM_GROUP_SELECTOR));
formGroups.forEach(function(formGroup) {
if (!formGroup.classList.contains(FORM_GROUP_WITH_ERRORS_CLASS)) {
return;
}
var inputElements = Array.from(formGroup.querySelectorAll(FORM_ERROR_REMOVER_INPUTS_SELECTOR));
if (!inputElements) {
return false;
}
inputElements.forEach(function(inputElement) {
inputElement.addEventListener('input', function() {
formGroup.classList.remove(FORM_GROUP_WITH_ERRORS_CLASS);
});
});
});
// mark initialized
element.classList.add(FORM_ERROR_REMOVER_INITIALIZED_CLASS);
return {
name: FORM_ERROR_REMOVER_UTIL_NAME,
element: element,
destroy: function() {},
};
}
return init();
};
formUtilities.push({
name: FORM_ERROR_REMOVER_UTIL_NAME,
selector: FORM_ERROR_REMOVER_UTIL_SELECTOR,
setup: formErrorRemoverUtil,
});
/**
*
* Datepicker Utility
* Provides UI for entering dates and times
*
* Attribute: [none]
* (automatically setup on all relevant input tags)
*
* Example usage:
* (any form that uses inputs of type date, time, or datetime-local)
*/
var DATEPICKER_UTIL_NAME = 'datepicker';
var DATEPICKER_UTIL_SELECTOR = 'input[type="date"], input[type="time"], input[type="datetime-local"]';
var DATEPICKER_INITIALIZED_CLASS = 'datepicker--initialized';
var DATEPICKER_CONFIG = {
"datetime-local": {
enableTime: true,
altInput: true,
altFormat: "j. F Y, H:i", // maybe interpolate these formats for locale
dateFormat: "Y-m-dTH:i",
time_24hr: true
},
"date": {
altFormat: "j. F Y",
dateFormat: "Y-m-d",
altInput: true
},
"time": {
enableTime: true,
noCalendar: true,
altFormat: "H:i",
dateFormat: "H:i",
altInput: true,
time_24hr: true
}
};
var datepickerUtil = function(element) {
var flatpickrInstance;
function init() {
if (!element) {
throw new Error('Datepicker utility needs to be passed an element!');
}
if (element.classList.contains(DATEPICKER_INITIALIZED_CLASS)) {
return false;
}
var flatpickrConfig = DATEPICKER_CONFIG[element.getAttribute("type")];
if (!flatpickrConfig) {
throw new Error('Datepicker utility called on unsupported element!');
}
flatpickrInstance = flatpickr(element, flatpickrConfig);
// mark initialized
element.classList.add(DATEPICKER_INITIALIZED_CLASS);
return {
name: DATEPICKER_UTIL_NAME,
element: element,
destroy: function() { flatpickrInstance.destroy(); },
};
}
return init();
};
formUtilities.push({
name: DATEPICKER_UTIL_NAME,
selector: DATEPICKER_UTIL_SELECTOR,
setup: datepickerUtil,
});
// debounce function, taken from Underscore.js
function debounce(func, wait, immediate) {
var timeout;
return function() {
var context = this, args = arguments;
var later = function() {
timeout = null;
if (!immediate) func.apply(context, args);
};
var callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
}
// register the collected form utilities
if (UtilRegistry) {
formUtilities.forEach(UtilRegistry.register);
}
})();

View File

@ -1,200 +0,0 @@
(function() {
'use strict';
var inputUtilities = [];
/**
*
* FileInput Utility
* wraps native file input
*
* Attribute: uw-file-input
* (element must be an input of type='file')
*
* Example usage:
* <input type='file' uw-file-input>
*
* Internationalization:
* This utility expects the following translations to be available:
* »filesSelected«: label of multi-input button after selection
* example: "Dateien ausgewählt" (will be prepended by number of selected files)
* »selectFile«: label of single-input button before selection
* example: "Datei auswählen"
* »selectFiles«: label of multi-input button before selection
* example: "Datei(en) auswählen"
*
*/
var FILE_INPUT_UTIL_NAME = 'fileInput';
var FILE_INPUT_UTIL_SELECTOR = 'input[type="file"][uw-file-input]';
var FILE_INPUT_CLASS = 'file-input';
var FILE_INPUT_INITIALIZED_CLASS = 'file-input--initialized';
var FILE_INPUT_LIST_CLASS = 'file-input__list';
var FILE_INPUT_UNPACK_CHECKBOX_CLASS = 'file-input__unpack';
var FILE_INPUT_LABEL_CLASS = 'file-input__label';
var fileInputUtil = function(element) {
var isMultiFileInput = false;
var fileList;
var label;
function init() {
if (!element) {
throw new Error('FileInput utility cannot be setup without an element!');
}
if (element.classList.contains(FILE_INPUT_INITIALIZED_CLASS)) {
throw new Error('FileInput utility already initialized!');
}
// check if is multi-file input
isMultiFileInput = element.hasAttribute('multiple');
if (isMultiFileInput) {
fileList = createFileList();
}
label = createFileLabel();
updateLabel();
// add change listener
element.addEventListener('change', function() {
updateLabel();
renderFileList();
});
// add util class for styling and mark as initialized
element.classList.add(FILE_INPUT_CLASS, FILE_INPUT_INITIALIZED_CLASS);
return {
name: FILE_INPUT_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function renderFileList() {
if (!fileList) {
return;
}
var files = element.files;
fileList.innerHTML = '';
Array.from(files).forEach(function(file) {
var fileDisplayEl = document.createElement('li');
fileDisplayEl.innerHTML = file.name;
fileList.appendChild(fileDisplayEl);
});
}
function createFileList() {
var list = document.createElement('ol');
list.classList.add(FILE_INPUT_LIST_CLASS);
var unpackEl = element.parentElement.querySelector('.' + FILE_INPUT_UNPACK_CHECKBOX_CLASS);
if (unpackEl) {
element.parentElement.insertBefore(list, unpackEl);
} else {
element.parentElement.appendChild(list);
}
return list;
}
function createFileLabel() {
var label = document.createElement('label');
label.classList.add(FILE_INPUT_LABEL_CLASS);
label.setAttribute('for', element.id);
element.parentElement.insertBefore(label, element);
return label;
}
function updateLabel() {
var files = element.files;
if (files && files.length) {
label.innerText = isMultiFileInput ? files.length + ' ' + I18n.get('filesSelected') : files[0].name;
} else {
label.innerText = isMultiFileInput ? I18n.get('selectFiles') : I18n.get('selectFile');
}
}
return init();
}
inputUtilities.push({
name: FILE_INPUT_UTIL_NAME,
selector: FILE_INPUT_UTIL_SELECTOR,
setup: fileInputUtil,
})
/**
*
* Checkbox Utility
* wraps native checkbox
*
* Attribute: (none)
* (element must be an input of type="checkbox")
*
* Example usage:
* <input type="checkbox">
*
*/
var CHECKBOX_UTIL_NAME = 'checkbox';
var CHECKBOX_UTIL_SELECTOR = 'input[type="checkbox"]';
var CHECKBOX_CLASS = 'checkbox';
var CHECKBOX_INITIALIZED_CLASS = 'checkbox--initialized';
var checkboxUtil = function(element) {
function init() {
if (!element) {
throw new Error('Checkbox utility cannot be setup without an element!');
}
if (element.classList.contains(CHECKBOX_INITIALIZED_CLASS)) {
// throw new Error('Checkbox utility already initialized!');
return false;
}
if (element.parentElement.classList.contains(CHECKBOX_CLASS)) {
// throw new Error('Checkbox element\'s wrapper already has class "' + CHECKBOX_CLASS + '"!');
return false;
}
var siblingEl = element.nextSibling;
var parentEl = element.parentElement;
var wrapperEl = document.createElement('div');
wrapperEl.classList.add(CHECKBOX_CLASS);
var labelEl = document.createElement('label');
labelEl.setAttribute('for', element.id);
wrapperEl.appendChild(element);
wrapperEl.appendChild(labelEl);
parentEl.insertBefore(wrapperEl, siblingEl);
element.classList.add(CHECKBOX_INITIALIZED_CLASS);
return {
name: CHECKBOX_UTIL_NAME,
element: element,
destroy: function() {},
};
}
return init();
}
inputUtilities.push({
name: CHECKBOX_UTIL_NAME,
selector: CHECKBOX_UTIL_SELECTOR,
setup: checkboxUtil,
});
// register the collected input utilities
if (UtilRegistry) {
inputUtilities.forEach(UtilRegistry.register);
}
})();

View File

@ -1,232 +0,0 @@
(function() {
'use strict';
/**
*
* Mass Input Utility
* allows form shapes to be manipulated asynchronously:
* will asynchronously submit the containing form and replace the contents
* of the mass input element with the one from the BE response
* The utility will only trigger an AJAX request if the mass input element has
* an active/focused element whilst the form is being submitted.
*
* Attribute: uw-mass-input
*
* Example usage:
* <form method="POST" action="...">
* <input type="text">
* <div uw-mass-input>
* <input type="text">
* <button type="submit">
*/
var MASS_INPUT_UTIL_NAME = 'massInput';
var MASS_INPUT_UTIL_SELECTOR = '[uw-mass-input]';
var MASS_INPUT_CELL_SELECTOR = '.massinput__cell';
var MASS_INPUT_ADD_CELL_SELECTOR = '.massinput__cell--add';
var MASS_INPUT_SUBMIT_BUTTON_CLASS = 'massinput__submit-button';
var MASS_INPUT_INITIALIZED_CLASS = 'mass-input--initialized';
var massInputUtil = function(element) {
var massInputId;
var massInputFormSubmitHandler;
var massInputForm;
function init() {
if (!element) {
throw new Error('Mass Input utility cannot be setup without an element!');
}
if (element.classList.contains(MASS_INPUT_INITIALIZED_CLASS)) {
return false;
}
massInputId = element.dataset.massInputIdent || '_';
massInputForm = element.closest('form');
if (!massInputForm) {
throw new Error('Mass Input utility cannot be setup without being wrapped in a <form>!');
}
massInputFormSubmitHandler = makeSubmitHandler();
// setup submit buttons inside this massinput so browser
// uses correct submit button for form submission.
var buttons = getMassInputSubmitButtons();
buttons.forEach(function(button) {
setupSubmitButton(button);
});
massInputForm.addEventListener('submit', massInputFormSubmitHandler);
massInputForm.addEventListener('keypress', keypressHandler);
// mark initialized
element.classList.add(MASS_INPUT_INITIALIZED_CLASS);
return {
name: MASS_INPUT_UTIL_NAME,
element: element,
destroy: function() {
reset();
},
};
}
function makeSubmitHandler() {
if (!HttpClient) {
throw new Error('HttpClient not found!');
}
var method = massInputForm.getAttribute('method') || 'POST';
var url = massInputForm.getAttribute('action') || window.location.href;
var enctype = massInputForm.getAttribute('enctype') || 'application/json';
var requestFn;
if (HttpClient[method.toLowerCase()]) {
requestFn = HttpClient[method.toLowerCase()];
}
return function(event) {
var activeElement;
// check if event occured from either a mass input add/delete button or
// from inside one of massinput's inputs (i.e. a child is focused/active)
activeElement = element.querySelector(':focus, :active');
if (!activeElement) {
return false;
}
// find the according massinput cell thats hosts the element that triggered the submit
var massInputCell = activeElement.closest(MASS_INPUT_CELL_SELECTOR);
if (!massInputCell) {
return false;
}
var submitButton = massInputCell.querySelector('.' + MASS_INPUT_SUBMIT_BUTTON_CLASS);
if (!submitButton) {
return false;
}
var isAddCell = massInputCell.matches(MASS_INPUT_ADD_CELL_SELECTOR);
var submitButtonIsActive = submitButton.matches(':focus, :active');
// if the cell is not an add cell the active element must at least be the cells submit button
if (!isAddCell && !submitButtonIsActive) {
return false;
}
event.preventDefault();
var requestBody = serializeForm(submitButton, enctype);
if (requestFn && requestBody) {
var headers = {'Mass-Input-Shortcircuit': massInputId};
if (enctype !== 'multipart/form-data')
headers['Content-Type'] = enctype;
requestFn({
url: url,
headers: headers,
body: requestBody,
accept: HttpClient.ACCEPT.TEXT_HTML,
}).then(function(response) {
return HtmlHelpers.parseResponse(response);
}).then(function(response) {
processResponse(response.element);
if (isAddCell) {
reFocusAddCell();
}
});
}
};
}
function keypressHandler(event) {
if (event.keyCode !== 13) {
return false;
}
if (massInputFormSubmitHandler) {
return massInputFormSubmitHandler(event);
}
}
function getMassInputSubmitButtons() {
return Array.from(element.querySelectorAll('button[type="submit"][name][value], .' + MASS_INPUT_SUBMIT_BUTTON_CLASS));
}
function setupSubmitButton(button) {
button.setAttribute('type', 'button');
button.classList.add(MASS_INPUT_SUBMIT_BUTTON_CLASS);
button.addEventListener('click', massInputFormSubmitHandler);
}
function resetSubmitButton(button) {
button.setAttribute('type', 'submit');
button.classList.remove(MASS_INPUT_SUBMIT_BUTTON_CLASS);
button.removeEventListener('click', massInputFormSubmitHandler);
}
function processResponse(responseElement) {
element.innerHTML = "";
element.appendChild(responseElement);
reset();
if (UtilRegistry) {
UtilRegistry.setupAll(element);
}
}
function serializeForm(submitButton, enctype) {
var formData = new FormData(massInputForm);
// manually add name and value of submit button to formData
formData.append(submitButton.name, submitButton.value);
if (enctype === 'application/x-www-form-urlencoded') {
return new URLSearchParams(formData);
} else if (enctype === 'multipart/form-data') {
return formData;
} else {
throw new Error('Unsupported form enctype: ' + enctype);
}
}
function reFocusAddCell() {
var addCell = element.querySelector(MASS_INPUT_ADD_CELL_SELECTOR);
if (!addCell) {
return false;
}
var addCellInput = addCell.querySelector('input:not([type="hidden"])');
if (addCellInput) {
// Clearing of add-inputs is done in the backend
addCellInput.focus();
}
}
function reset() {
element.classList.remove(MASS_INPUT_INITIALIZED_CLASS);
massInputForm.removeEventListener('submit', massInputFormSubmitHandler)
massInputForm.removeEventListener('keypress', keypressHandler);
var buttons = getMassInputSubmitButtons();
buttons.forEach(function(button) {
resetSubmitButton(button);
});
}
return init();
};
// register mass input util
if (UtilRegistry) {
UtilRegistry.register({
name: MASS_INPUT_UTIL_NAME,
selector: MASS_INPUT_UTIL_SELECTOR,
setup: massInputUtil
});
}
})();

View File

@ -1,212 +0,0 @@
(function() {
'use strict';
/**
*
* Modal Utility
*
* Attribute: uw-modal
*
* Params:
* data-modal-trigger: string
* Selector for the element that toggles the modal.
* If trigger element has "href" attribute the modal will be dynamically loaded from the referenced page
* data-modal-closeable: boolean property
* If the param is present the modal will have a close-icon and can also be closed by clicking anywhere on the overlay
*
* Example usage:
* <div uw-modal data-modal-trigger='#trigger' data-modal-closeable>This is the modal content
* <div id='trigger'>Click me to open the modal
*/
var MODAL_UTIL_NAME = 'modal';
var MODAL_UTIL_SELECTOR = '[uw-modal]';
var MODAL_HEADERS = {
'Is-Modal': 'True',
};
var MODAL_INITIALIZED_CLASS = 'modal--initialized';
var MODAL_CLASS = 'modal';
var MODAL_OPEN_CLASS = 'modal--open';
var MODAL_TRIGGER_CLASS = 'modal__trigger';
var MODAL_CONTENT_CLASS = 'modal__content';
var MODAL_OVERLAY_CLASS = 'modal__overlay';
var MODAL_OVERLAY_OPEN_CLASS = 'modal__overlay--open';
var MODAL_CLOSER_CLASS = 'modal__closer';
var MAIN_CONTENT_CLASS = 'main__content-body'
// one singleton wrapper to keep all the modals to avoid CSS bug
// with blurry text due to `transform: translate(-50%, -50%)`
// will be created (and reused) for the first modal that gets initialized
var MODALS_WRAPPER_CLASS = 'modals-wrapper';
var MODALS_WRAPPER_SELECTOR = '.' + MODALS_WRAPPER_CLASS;
var MODALS_WRAPPER_OPEN_CLASS = 'modals-wrapper--open';
var modalUtil = function(element) {
var modalsWrapper;
var modalOverlay;
var modalUrl;
function _init() {
if (!element) {
throw new Error('Modal utility cannot be setup without an element!');
}
if (element.classList.contains(MODAL_INITIALIZED_CLASS)) {
return false;
}
ensureModalWrapper();
// param modalTrigger
if (!element.dataset.modalTrigger) {
throw new Error('Modal utility cannot be setup without a trigger element!');
} else {
setupTrigger();
}
// param modalCloseable
if (element.dataset.modalCloseable !== undefined) {
setupCloser();
}
// mark as initialized and add modal class for styling
element.classList.add(MODAL_INITIALIZED_CLASS, MODAL_CLASS);
return {
name: MODAL_UTIL_NAME,
element: element,
destroy: function() {}
};
}
function ensureModalWrapper() {
modalsWrapper = document.querySelector(MODALS_WRAPPER_SELECTOR);
if (!modalsWrapper) {
// create modal wrapper
modalsWrapper = document.createElement('div');
modalsWrapper.classList.add(MODALS_WRAPPER_CLASS);
document.body.appendChild(modalsWrapper);
}
modalOverlay = modalsWrapper.querySelector('.' + MODAL_OVERLAY_CLASS);
if (!modalOverlay) {
// create modal overlay
modalOverlay = document.createElement('div');
modalOverlay.classList.add(MODAL_OVERLAY_CLASS);
modalsWrapper.appendChild(modalOverlay);
}
}
function setupTrigger() {
var triggerSelector = element.dataset.modalTrigger;
if (!triggerSelector.startsWith('#')) {
triggerSelector = '#' + triggerSelector;
}
var triggerElement = document.querySelector(triggerSelector);
if (!triggerElement) {
throw new Error('Trigger element for Modal not found: "' + triggerSelector + '"');
}
triggerElement.classList.add(MODAL_TRIGGER_CLASS);
triggerElement.addEventListener('click', onTriggerClicked, false);
modalUrl = triggerElement.getAttribute('href');
}
function setupCloser() {
var closerElement = document.createElement('div');
element.insertBefore(closerElement, null);
closerElement.classList.add(MODAL_CLOSER_CLASS);
closerElement.addEventListener('click', onCloseClicked, false);
modalOverlay.addEventListener('click', onCloseClicked, false);
}
function onTriggerClicked(event) {
event.preventDefault();
open();
}
function onCloseClicked(event) {
event.preventDefault();
close();
}
function onKeyUp(event) {
if (event.key === 'Escape') {
close();
}
}
function open() {
element.classList.add(MODAL_OPEN_CLASS);
modalOverlay.classList.add(MODAL_OVERLAY_OPEN_CLASS);
modalsWrapper.classList.add(MODALS_WRAPPER_OPEN_CLASS);
modalsWrapper.appendChild(element);
if (modalUrl) {
fillModal(modalUrl);
}
document.addEventListener('keyup', onKeyUp);
}
function close() {
modalOverlay.classList.remove(MODAL_OVERLAY_OPEN_CLASS);
element.classList.remove(MODAL_OPEN_CLASS);
modalsWrapper.classList.remove(MODALS_WRAPPER_OPEN_CLASS);
document.removeEventListener('keyup', onKeyUp);
};
function fillModal(url) {
if (!HttpClient) {
throw new Error('HttpClient not found! Can\'t fetch modal content from ' + url);
}
HttpClient.get({
url: url,
headers: MODAL_HEADERS,
accept: HttpClient.ACCEPT.TEXT_HTML,
}).then(function(response) {
return HtmlHelpers.parseResponse(response);
}).then(function(response) {
processResponse(response.element);
});
}
function processResponse(responseElement) {
var modalContent = document.createElement('div');
modalContent.classList.add(MODAL_CONTENT_CLASS);
var contentBody = responseElement.querySelector('.' + MAIN_CONTENT_CLASS);
if (contentBody) {
modalContent.innerHTML = contentBody.innerHTML;
}
var previousModalContent = element.querySelector('.' + MODAL_CONTENT_CLASS);
if (previousModalContent) {
previousModalContent.remove();
}
element.insertBefore(modalContent, null);
// setup any newly arrived utils
UtilRegistry.setupAll(element);
}
return _init();
};
if (UtilRegistry) {
UtilRegistry.register({
name: MODAL_UTIL_NAME,
selector: MODAL_UTIL_SELECTOR,
setup: modalUtil
});
}
})();

View File

@ -1,114 +0,0 @@
(function() {
'use strict';
var SHOW_HIDE_UTIL_NAME = 'showHide';
var SHOW_HIDE_UTIL_SELECTOR = '[uw-show-hide]';
var SHOW_HIDE_LOCAL_STORAGE_KEY = 'SHOW_HIDE';
var SHOW_HIDE_INITIALIZED_CLASS = 'show-hide--initialized';
var SHOW_HIDE_COLLAPSED_CLASS = 'show-hide--collapsed';
var SHOW_HIDE_TOGGLE_CLASS = 'show-hide__toggle';
var SHOW_HIDE_TOGGLE_RIGHT_CLASS = 'show-hide__toggle--right';
/**
*
* ShowHide Utility
*
* Attribute: uw-show-hide
*
* Params: (all optional)
* data-show-hide-id: string
* If this param is given the state of the utility will be persisted in the clients local storage.
* data-show-hide-collapsed: boolean property
* If this param is present the ShowHide utility will be collapsed. This value will be overruled by any value stored in the LocalStorage.
* data-show-hide-align: 'right'
* Where to put the arrow that marks the element as a ShowHide toggle. Left of toggle by default.
*
* Example usage:
* <div>
* <div uw-show-hide>Click me
* <div>This will be toggled
* <div>This will be toggled as well
*/
var showHideUtil = function(element) {
var showHideId;
function init() {
if (!element) {
throw new Error('ShowHide utility cannot be setup without an element!');
}
if (element.classList.contains(SHOW_HIDE_INITIALIZED_CLASS)) {
return false;
}
// register click listener
addClickListener();
// param showHideId
if (element.dataset.showHideId) {
showHideId = element.dataset.showHideId;
}
// param showHideCollapsed
var collapsed = false;
if (element.dataset.showHideCollapsed !== undefined) {
collapsed = true;
}
if (showHideId) {
var localStorageCollapsed = getLocalStorage()[showHideId];
if (typeof localStorageCollapsed !== 'undefined') {
collapsed = localStorageCollapsed;
}
}
element.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS, collapsed);
// param showHideAlign
var alignment = element.dataset.showHideAlign;
if (alignment === 'right') {
element.classList.add(SHOW_HIDE_TOGGLE_RIGHT_CLASS);
}
// mark as initialized
element.classList.add(SHOW_HIDE_INITIALIZED_CLASS, SHOW_HIDE_TOGGLE_CLASS);
return {
name: SHOW_HIDE_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function addClickListener() {
element.addEventListener('click', function clickListener() {
var newState = element.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS);
if (showHideId) {
setLocalStorage(showHideId, newState);
}
});
}
function setLocalStorage(id, state) {
var lsData = getLocalStorage();
lsData[id] = state;
window.localStorage.setItem(SHOW_HIDE_LOCAL_STORAGE_KEY, JSON.stringify(lsData));
}
function getLocalStorage() {
return JSON.parse(window.localStorage.getItem(SHOW_HIDE_LOCAL_STORAGE_KEY)) || {};
}
return init();
};
if (UtilRegistry) {
UtilRegistry.register({
name: SHOW_HIDE_UTIL_NAME,
selector: SHOW_HIDE_UTIL_SELECTOR,
setup: showHideUtil
});
}
})();

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
if (I18n) {
I18n.addMany(#{frontendI18n});
if (window.App) {
window.App.i18n.addMany(#{frontendI18n});
} else {
throw new Error('I18n JavaScript service is missing!');
}

108
webpack.config.js Normal file
View File

@ -0,0 +1,108 @@
/* eslint-disable */
const webpack = require('webpack');
const path = require('path');
const ExtractTextWebpackPlugin = require('extract-text-webpack-plugin');
/*
* SplitChunksPlugin is enabled by default and replaced
* deprecated CommonsChunkPlugin. It automatically identifies modules which
* should be splitted of chunk by heuristics using module duplication count and
* module category (i. e. node_modules). And splits the chunks
*
* It is safe to remove "splitChunks" from the generated configuration
* and was added as an educational example.
*
* https://webpack.js.org/plugins/split-chunks-plugin/
*
*/
/*
* We've enabled UglifyJSPlugin for you! This minifies your app
* in order to load faster and run less javascript.
*
* https://github.com/webpack-contrib/uglifyjs-webpack-plugin
*
*/
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
module.exports = {
module: {
rules: [
{
loader: 'babel-loader',
options: {
plugins: ['syntax-dynamic-import'],
presets: [
[
'@babel/preset-env',
{
modules: false
}
]
]
},
test: /\.js$/
},
{
test: /\.css$/,
use: ExtractTextWebpackPlugin.extract({
fallback: "style-loader",
use: "css-loader"
})
},
{
test: /\.scss$/,
use: ExtractTextWebpackPlugin.extract({
fallback: 'style-loader',
use: ['css-loader', 'sass-loader', 'postcss-loader']
}),
exclude: /node_modules/
}
]
},
entry: {
main: [
path.resolve(__dirname, 'frontend/src', 'main.js'),
],
vendor: [
path.resolve(__dirname, 'frontend/vendor', 'main.js'),
],
polyfills: [
path.resolve(__dirname, 'frontend/polyfills', 'main.js'),
],
},
plugins: [
new ExtractTextWebpackPlugin({
filename: 'css/[name].css'
})
],
output: {
chunkFilename: 'js/[name].[chunkname].js',
filename: 'js/[name].js',
path: path.resolve(__dirname, 'static', 'bundles')
},
mode: 'development',
optimization: {
splitChunks: {
cacheGroups: {
vendors: {
priority: -10,
test: /[\\/]node_modules[\\/]/
}
},
chunks: 'async',
minChunks: 1,
minSize: 30000,
name: true
}
}
};