Merge branch 'master' of gitlab.cip.ifi.lmu.de:jost/UniWorX
This commit is contained in:
commit
a71ac7139d
12
.babelrc
12
.babelrc
@ -1,11 +1,9 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
["@babel/preset-env", { "useBuiltIns": "usage" }]
|
||||
],
|
||||
"plugins": ["@babel/plugin-proposal-class-properties"]
|
||||
"plugins": [
|
||||
["@babel/plugin-proposal-decorators", { "legacy": true }],
|
||||
["@babel/plugin-proposal-class-properties", { "loose": true }]
|
||||
]
|
||||
}
|
||||
|
||||
@ -13,12 +13,16 @@
|
||||
},
|
||||
"parser": "babel-eslint",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2018
|
||||
"ecmaVersion": 2018,
|
||||
"ecmaFeatures": {
|
||||
"legacyDecorators": true
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"no-console": "off",
|
||||
"no-extra-semi": "off",
|
||||
"semi": ["error", "always"],
|
||||
"comma-dangle": ["error", "always-multiline"]
|
||||
"comma-dangle": ["error", "always-multiline"],
|
||||
"quotes": ["error", "single"]
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ 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';
|
||||
import { isValidUtility } from './core/utility';
|
||||
|
||||
export class App {
|
||||
httpClient = new HttpClient();
|
||||
@ -11,6 +12,8 @@ export class App {
|
||||
|
||||
constructor() {
|
||||
this.utilRegistry.setApp(this);
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => this.utilRegistry.setupAll());
|
||||
}
|
||||
|
||||
registerUtilities(utils) {
|
||||
@ -18,7 +21,7 @@ export class App {
|
||||
throw new Error('Utils are expected to be passed as array!');
|
||||
}
|
||||
|
||||
utils.forEach((util) => {
|
||||
utils.filter(isValidUtility).forEach((util) => {
|
||||
this.utilRegistry.register(util);
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,8 +1,15 @@
|
||||
import { App } from "./app";
|
||||
import { App } from './app';
|
||||
import { Utility } from './core/utility';
|
||||
|
||||
@Utility({ selector: 'util1' })
|
||||
class TestUtil1 { }
|
||||
|
||||
@Utility({ selector: 'util2' })
|
||||
class TestUtil2 { }
|
||||
|
||||
const TEST_UTILS = [
|
||||
{ name: 'util1' },
|
||||
{ name: 'util2' },
|
||||
TestUtil1,
|
||||
TestUtil2,
|
||||
];
|
||||
|
||||
describe('App', () => {
|
||||
@ -16,6 +23,12 @@ describe('App', () => {
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should setup all utlites when page is done loading', () => {
|
||||
spyOn(app.utilRegistry, 'setupAll');
|
||||
document.dispatchEvent(new Event('DOMContentLoaded'));
|
||||
expect(app.utilRegistry.setupAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('provides services', () => {
|
||||
it('HttpClient as httpClient', () => {
|
||||
expect(app.httpClient).toBeTruthy();
|
||||
|
||||
22
frontend/src/core/utility.js
Normal file
22
frontend/src/core/utility.js
Normal file
@ -0,0 +1,22 @@
|
||||
export function isValidUtility(utility) {
|
||||
if (!utility) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!utility.selector) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export function Utility(metadata) {
|
||||
if (!metadata.selector) {
|
||||
throw new Error('Utility needs to have a selector!');
|
||||
}
|
||||
|
||||
return function (target) {
|
||||
target.selector = metadata.selector;
|
||||
target.isUtility = true;
|
||||
};
|
||||
};
|
||||
@ -15,9 +15,9 @@ window.App = app;
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const contentType = response.headers.get("content-type");
|
||||
// const contentType = response.headers.get('content-type');
|
||||
// if (!contentType.match(options.accept)) {
|
||||
// throw new Error('Server returned with "' + contentType + '" when "' + options.accept + '" was expected');
|
||||
// throw new Error('Server returned with '' + contentType + '' when '' + options.accept + '' was expected');
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
@ -3,20 +3,21 @@ 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.
|
||||
// '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);
|
||||
const element = document.createElement('div');
|
||||
element.innerHTML = responseText;
|
||||
let idPrefix = '';
|
||||
if (!options.keepIds) {
|
||||
idPrefix = this._getIdPrefix();
|
||||
this._prefixIds(docFrag, idPrefix);
|
||||
this._prefixIds(element, idPrefix);
|
||||
}
|
||||
return Promise.resolve({ idPrefix, element: docFrag });
|
||||
return Promise.resolve({ idPrefix, element });
|
||||
},
|
||||
Promise.reject,
|
||||
).catch(console.error);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { HtmlHelpers } from "./html-helpers";
|
||||
import { HtmlHelpers } from './html-helpers';
|
||||
|
||||
describe('HtmlHelpers', () => {
|
||||
let htmlHelpers;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { HttpClient } from "./http-client";
|
||||
import { HttpClient } from './http-client';
|
||||
|
||||
const TEST_URL = 'http://example.com';
|
||||
const FAKE_RESPONSE = {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { I18n } from "./i18n";
|
||||
import { I18n } from './i18n';
|
||||
|
||||
describe('I18n', () => {
|
||||
let i18n;
|
||||
|
||||
@ -6,10 +6,6 @@ export class UtilRegistry {
|
||||
_activeUtilInstances = [];
|
||||
_appInstance;
|
||||
|
||||
constructor() {
|
||||
document.addEventListener('DOMContentLoaded', () => this.setupAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* function registerUtil
|
||||
*
|
||||
@ -63,14 +59,16 @@ export class UtilRegistry {
|
||||
console.log('setting up util', { util });
|
||||
}
|
||||
|
||||
if (util && typeof util.setup === 'function') {
|
||||
let instances = [];
|
||||
|
||||
if (util) {
|
||||
const elements = this._findUtilElements(util, scope);
|
||||
|
||||
elements.forEach((element) => {
|
||||
let utilInstance = null;
|
||||
|
||||
try {
|
||||
utilInstance = util.setup(element, this._appInstance);
|
||||
utilInstance = new util(element, this._appInstance);
|
||||
} catch(err) {
|
||||
if (DEBUG_MODE > 0) {
|
||||
console.warn('Error while trying to initialize a utility!', { util , element, err });
|
||||
@ -82,10 +80,13 @@ export class UtilRegistry {
|
||||
console.info('Got utility instance for utility "' + util.name + '"', { utilInstance });
|
||||
}
|
||||
|
||||
this._activeUtilInstances.push(utilInstance);
|
||||
instances.push(utilInstance);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this._activeUtilInstances.push(...instances);
|
||||
return instances;
|
||||
}
|
||||
|
||||
find(name) {
|
||||
|
||||
@ -1,14 +1,5 @@
|
||||
import { UtilRegistry } from "./util-registry";
|
||||
|
||||
const TEST_UTILS = [{
|
||||
name: 'util1',
|
||||
selector: '#some-id',
|
||||
setup: () => {},
|
||||
}, {
|
||||
name: 'util2',
|
||||
selector: '[uw-util]',
|
||||
setup: () => {},
|
||||
}];
|
||||
import { UtilRegistry } from './util-registry';
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
describe('UtilRegistry', () => {
|
||||
let utilRegistry;
|
||||
@ -21,31 +12,28 @@ describe('UtilRegistry', () => {
|
||||
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]);
|
||||
let foundUtil = utilRegistry.find(TestUtil1.name);
|
||||
expect(foundUtil).toBeFalsy();
|
||||
|
||||
const foundUtil = utilRegistry.find(TEST_UTILS[0].name);
|
||||
expect(foundUtil).toEqual(TEST_UTILS[0]);
|
||||
utilRegistry.register(TestUtil1);
|
||||
|
||||
foundUtil = utilRegistry.find(TestUtil1.name);
|
||||
expect(foundUtil).toEqual(TestUtil1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deregister()', () => {
|
||||
it('should remove util', () => {
|
||||
// register util
|
||||
utilRegistry.register(TEST_UTILS[0]);
|
||||
let foundUtil = utilRegistry.find('util1');
|
||||
utilRegistry.register(TestUtil1);
|
||||
let foundUtil = utilRegistry.find(TestUtil1.name);
|
||||
expect(foundUtil).toBeTruthy();
|
||||
|
||||
// deregister util
|
||||
utilRegistry.deregister(TEST_UTILS[0].name);
|
||||
foundUtil = utilRegistry.find('util1');
|
||||
utilRegistry.deregister(TestUtil1.name);
|
||||
foundUtil = utilRegistry.find(TestUtil1.name);
|
||||
expect(foundUtil).toBeFalsy();
|
||||
});
|
||||
|
||||
@ -55,55 +43,62 @@ describe('UtilRegistry', () => {
|
||||
});
|
||||
|
||||
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]);
|
||||
utilRegistry.setup(ThrowingUtil);
|
||||
}).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('scope has no matching elements', () => {
|
||||
it('should not construct an instance', () => {
|
||||
const scope = document.createElement('div');
|
||||
const instances = utilRegistry.setup(TestUtil1, scope);
|
||||
expect(instances.length).toBe(0);
|
||||
});
|
||||
|
||||
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();
|
||||
const instances = utilRegistry.setup(TestUtil1);
|
||||
expect(instances.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given a scope', () => {
|
||||
let scope;
|
||||
let utilElement1;
|
||||
let utilElement2;
|
||||
describe('scope has matching elements', () => {
|
||||
let testScope;
|
||||
let testElement1;
|
||||
let testElement2;
|
||||
|
||||
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);
|
||||
testScope = document.createElement('div');
|
||||
testElement1 = document.createElement('div');
|
||||
testElement2 = document.createElement('div');
|
||||
testElement1.classList.add('util1');
|
||||
testElement2.classList.add('util1');
|
||||
testScope.appendChild(testElement1);
|
||||
testScope.appendChild(testElement2);
|
||||
});
|
||||
|
||||
it('should 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]);
|
||||
it('should construct a utility instance', () => {
|
||||
const setupUtilities = utilRegistry.setup(TestUtil1, testScope);
|
||||
expect(setupUtilities).toBeTruthy();
|
||||
expect(setupUtilities[0]).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should construct an instance for each matching element', () => {
|
||||
const setupUtilities = utilRegistry.setup(TestUtil1, testScope);
|
||||
expect(setupUtilities).toBeTruthy();
|
||||
expect(setupUtilities[0].element).toBe(testElement1);
|
||||
expect(setupUtilities[1].element).toBe(testElement2);
|
||||
});
|
||||
|
||||
it('should pass the app instance', () => {
|
||||
const fakeApp = { };
|
||||
utilRegistry.setApp(fakeApp);
|
||||
|
||||
const setupUtilities = utilRegistry.setup(TestUtil1, testScope);
|
||||
expect(setupUtilities).toBeTruthy();
|
||||
expect(setupUtilities[0].app).toBe(fakeApp);
|
||||
expect(setupUtilities[1].app).toBe(fakeApp);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -111,22 +106,41 @@ describe('UtilRegistry', () => {
|
||||
describe('setupAll()', () => {
|
||||
it('should setup all the utilities', () => {
|
||||
spyOn(utilRegistry, 'setup');
|
||||
utilRegistry.register(TEST_UTILS[0]);
|
||||
utilRegistry.register(TEST_UTILS[1]);
|
||||
utilRegistry.register(TestUtil1);
|
||||
utilRegistry.register(TestUtil2);
|
||||
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]);
|
||||
expect(utilRegistry.setup.calls.argsFor(0)).toEqual([TestUtil1, undefined]);
|
||||
expect(utilRegistry.setup.calls.argsFor(1)).toEqual([TestUtil2, undefined]);
|
||||
});
|
||||
|
||||
it('should pass the given scope', () => {
|
||||
spyOn(utilRegistry, 'setup');
|
||||
utilRegistry.register(TEST_UTILS[0]);
|
||||
utilRegistry.register(TestUtil1);
|
||||
const scope = document.createElement('div');
|
||||
utilRegistry.setupAll(scope);
|
||||
|
||||
expect(utilRegistry.setup).toHaveBeenCalledWith(TEST_UTILS[0], scope);
|
||||
expect(utilRegistry.setup).toHaveBeenCalledWith(TestUtil1, scope);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// test utilities
|
||||
@Utility({ selector: '.util1' })
|
||||
class TestUtil1 {
|
||||
constructor(element, app) {
|
||||
this.element = element;
|
||||
this.app = app;
|
||||
}
|
||||
}
|
||||
|
||||
@Utility({ selector: '#util2' })
|
||||
class TestUtil2 { }
|
||||
|
||||
@Utility({ selector: '#throws' })
|
||||
class ThrowingUtil {
|
||||
constructor() {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,191 +1,158 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
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
|
||||
*
|
||||
*/
|
||||
const ALERTS_INITIALIZED_CLASS = 'alerts--initialized';
|
||||
const ALERTS_ELEVATED_CLASS = 'alerts--elevated';
|
||||
const ALERTS_TOGGLER_CLASS = 'alerts__toggler';
|
||||
const ALERTS_TOGGLER_VISIBLE_CLASS = 'alerts__toggler--visible';
|
||||
const ALERTS_TOGGLER_APPEAR_DELAY = 120;
|
||||
|
||||
var ALERTS_UTIL_NAME = 'alerts';
|
||||
var ALERTS_UTIL_SELECTOR = '[uw-alerts]';
|
||||
const ALERT_CLASS = 'alert';
|
||||
const ALERT_INITIALIZED_CLASS = 'alert--initialized';
|
||||
const ALERT_CLOSER_CLASS = 'alert__closer';
|
||||
const ALERT_ICON_CLASS = 'alert__icon';
|
||||
const ALERT_CONTENT_CLASS = 'alert__content';
|
||||
const ALERT_INVISIBLE_CLASS = 'alert--invisible';
|
||||
const ALERT_AUTO_HIDE_DELAY = 10;
|
||||
const ALERT_AUTOCLOSING_MATCHER = '.alert-info, .alert-success';
|
||||
|
||||
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;
|
||||
@Utility({
|
||||
selector: '[uw-alerts]',
|
||||
})
|
||||
export class Alerts {
|
||||
_togglerCheckRequested = false;
|
||||
_togglerElement;
|
||||
_alertElements;
|
||||
|
||||
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';
|
||||
_element;
|
||||
_app;
|
||||
|
||||
var alertsUtil = function(element, app) {
|
||||
var togglerCheckRequested = false;
|
||||
var togglerElement;
|
||||
var alertElements;
|
||||
|
||||
function init() {
|
||||
constructor(element, app) {
|
||||
if (!element) {
|
||||
throw new Error('Alerts util has to be called with an element!');
|
||||
}
|
||||
|
||||
if (element.classList.contains(ALERTS_INITIALIZED_CLASS)) {
|
||||
this._element = element;
|
||||
this._app = app;
|
||||
|
||||
if (this._element.classList.contains(ALERTS_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
togglerElement = element.querySelector('.' + ALERTS_TOGGLER_CLASS);
|
||||
alertElements = gatherAlertElements();
|
||||
this._togglerElement = this._element.querySelector('.' + ALERTS_TOGGLER_CLASS);
|
||||
this._alertElements = this._gatherAlertElements();
|
||||
|
||||
initToggler();
|
||||
initAlerts();
|
||||
if (this._togglerElement) {
|
||||
this._initToggler();
|
||||
}
|
||||
|
||||
this._initAlerts();
|
||||
|
||||
// register http client interceptor to filter out Alerts Header
|
||||
setupHttpInterceptor();
|
||||
this._setupHttpInterceptor();
|
||||
|
||||
// mark initialized
|
||||
element.classList.add(ALERTS_INITIALIZED_CLASS);
|
||||
|
||||
return {
|
||||
name: ALERTS_UTIL_NAME,
|
||||
element: element,
|
||||
destroy: function() {},
|
||||
};
|
||||
this._element.classList.add(ALERTS_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
function gatherAlertElements() {
|
||||
return Array.from(element.querySelectorAll('.' + ALERT_CLASS)).filter(function(alert) {
|
||||
destroy() {
|
||||
console.log('TBD: Destroy Alert');
|
||||
}
|
||||
|
||||
_gatherAlertElements() {
|
||||
return Array.from(this._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);
|
||||
_initToggler() {
|
||||
this._togglerElement.addEventListener('click', () => {
|
||||
this._alertElements.forEach((alertEl) => this._toggleAlert(alertEl, true));
|
||||
this._togglerElement.classList.remove(ALERTS_TOGGLER_VISIBLE_CLASS);
|
||||
});
|
||||
}
|
||||
|
||||
function initAlerts() {
|
||||
alertElements.forEach(initAlert);
|
||||
_initAlerts() {
|
||||
this._alertElements.forEach((alert) => this._initAlert(alert));
|
||||
}
|
||||
|
||||
function initAlert(alertElement) {
|
||||
var autoHideDelay = ALERT_AUTO_HIDE_DELAY;
|
||||
_initAlert(alertElement) {
|
||||
let 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);
|
||||
const closeEl = alertElement.querySelector('.' + ALERT_CLOSER_CLASS);
|
||||
closeEl.addEventListener('click', () => {
|
||||
this._toggleAlert(alertElement);
|
||||
});
|
||||
|
||||
if (autoHideDelay > 0 && alertElement.matches(ALERT_AUTOCLOSING_MATCHER)) {
|
||||
window.setTimeout(function() {
|
||||
toggleAlert(alertElement);
|
||||
}, autoHideDelay * 1000);
|
||||
window.setTimeout(() => this._toggleAlert(alertElement), autoHideDelay * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAlert(alertEl, visible) {
|
||||
_toggleAlert(alertEl, visible) {
|
||||
alertEl.classList.toggle(ALERT_INVISIBLE_CLASS, !visible);
|
||||
checkToggler();
|
||||
this._checkToggler();
|
||||
}
|
||||
|
||||
function checkToggler() {
|
||||
if (togglerCheckRequested) {
|
||||
_checkToggler() {
|
||||
if (this._togglerCheckRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
var alertsHidden = alertElements.reduce(function(acc, alert) {
|
||||
const alertsHidden = this._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;
|
||||
window.setTimeout(() => {
|
||||
this._togglerElement.classList.toggle(ALERTS_TOGGLER_VISIBLE_CLASS, alertsHidden);
|
||||
this._togglerCheckRequested = false;
|
||||
}, ALERTS_TOGGLER_APPEAR_DELAY);
|
||||
}
|
||||
|
||||
function setupHttpInterceptor() {
|
||||
app.httpClient.addResponseInterceptor(responseInterceptor.bind(this));
|
||||
_setupHttpInterceptor() {
|
||||
this._app.httpClient.addResponseInterceptor(this._responseInterceptor.bind(this));
|
||||
}
|
||||
|
||||
function elevateAlerts() {
|
||||
element.classList.add(ALERTS_ELEVATED_CLASS);
|
||||
_elevateAlerts() {
|
||||
this._element.classList.add(ALERTS_ELEVATED_CLASS);
|
||||
}
|
||||
|
||||
function responseInterceptor(response) {
|
||||
var alerts;
|
||||
for (var header of response.headers) {
|
||||
_responseInterceptor = (response) => {
|
||||
let alerts;
|
||||
for (const header of response.headers) {
|
||||
if (header[0] === 'alerts') {
|
||||
var decodedHeader = decodeURIComponent(header[1]);
|
||||
const 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);
|
||||
alerts.forEach((alert) => {
|
||||
const alertElement = this._createAlertElement(alert.status, alert.content);
|
||||
this._element.appendChild(alertElement);
|
||||
this._alertElements.push(alertElement);
|
||||
this._initAlert(alertElement);
|
||||
});
|
||||
|
||||
elevateAlerts();
|
||||
this._elevateAlerts();
|
||||
}
|
||||
}
|
||||
|
||||
function createAlertElement(type, content) {
|
||||
var alertElement = document.createElement('div');
|
||||
_createAlertElement(type, content) {
|
||||
const alertElement = document.createElement('div');
|
||||
alertElement.classList.add(ALERT_CLASS, 'alert-' + type);
|
||||
|
||||
var alertCloser = document.createElement('div');
|
||||
const alertCloser = document.createElement('div');
|
||||
alertCloser.classList.add(ALERT_CLOSER_CLASS);
|
||||
|
||||
var alertIcon = document.createElement('div');
|
||||
const alertIcon = document.createElement('div');
|
||||
alertIcon.classList.add(ALERT_ICON_CLASS);
|
||||
|
||||
var alertContent = document.createElement('div');
|
||||
const alertContent = document.createElement('div');
|
||||
alertContent.classList.add(ALERT_CONTENT_CLASS);
|
||||
alertContent.innerHTML = content;
|
||||
|
||||
@ -195,12 +162,4 @@ var alertsUtil = function(element, app) {
|
||||
|
||||
return alertElement;
|
||||
}
|
||||
|
||||
return init();
|
||||
};
|
||||
|
||||
export default {
|
||||
name: ALERTS_UTIL_NAME,
|
||||
selector: ALERTS_UTIL_SELECTOR,
|
||||
setup: alertsUtil,
|
||||
};
|
||||
}
|
||||
|
||||
35
frontend/src/utils/alerts/alerts.md
Normal file
35
frontend/src/utils/alerts/alerts.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Alerts
|
||||
|
||||
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:
|
||||
```html
|
||||
<div .alerts uw-alerts>
|
||||
<div .alerts__toggler>
|
||||
<div .alert.alert-info>
|
||||
<div .alert__closer>
|
||||
<div .alert__icon>
|
||||
<div .alert__content>
|
||||
This is some information
|
||||
```
|
||||
@ -20,7 +20,7 @@
|
||||
&::before {
|
||||
content: '\f077';
|
||||
position: absolute;
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
left: 50%;
|
||||
top: 0;
|
||||
height: 30px;
|
||||
@ -126,7 +126,7 @@
|
||||
&::before {
|
||||
content: '\f05a';
|
||||
position: absolute;
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-size: 24px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
@ -164,7 +164,7 @@
|
||||
&::before {
|
||||
content: '\f00d';
|
||||
position: absolute;
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
|
||||
@ -1,8 +1,27 @@
|
||||
import alerts from "./alerts";
|
||||
import { Alerts } from './alerts';
|
||||
|
||||
const MOCK_APP = {
|
||||
httpClient: {
|
||||
addResponseInterceptor: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
describe('Alerts', () => {
|
||||
|
||||
it('should be called alerts', () => {
|
||||
expect(alerts.name).toMatch('alerts');
|
||||
let alerts;
|
||||
|
||||
beforeEach(() => {
|
||||
const element = document.createElement('div');
|
||||
alerts = new Alerts(element, MOCK_APP);
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(alerts).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should throw if called without an element', () => {
|
||||
expect(() => {
|
||||
new Alerts();
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,89 +1,75 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
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
|
||||
* ...
|
||||
*
|
||||
*/
|
||||
const FAVORITES_BTN_CLASS = 'navbar__list-item--favorite';
|
||||
const FAVORITES_BTN_ACTIVE_CLASS = 'navbar__list-item--active';
|
||||
const ASIDENAV_INITIALIZED_CLASS = 'asidenav--initialized';
|
||||
const ASIDENAV_EXPANDED_CLASS = 'main__aside--expanded';
|
||||
const ASIDENAV_LIST_ITEM_CLASS = 'asidenav__list-item';
|
||||
const ASIDENAV_SUBMENU_CLASS = 'asidenav__nested-list-wrapper';
|
||||
|
||||
var ASIDENAV_UTIL_NAME = 'asidenav';
|
||||
var ASIDENAV_UTIL_SELECTOR = '[uw-asidenav]';
|
||||
@Utility({
|
||||
selector: '[uw-asidenav]',
|
||||
})
|
||||
export class 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';
|
||||
_element;
|
||||
_asidenavSubmenus;
|
||||
|
||||
var asidenavUtil = function(element) {
|
||||
|
||||
function init() {
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
throw new Error('Asidenav utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
if (element.classList.contains(ASIDENAV_INITIALIZED_CLASS)) {
|
||||
this._element = element;
|
||||
|
||||
if (this._element.classList.contains(ASIDENAV_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
initFavoritesButton();
|
||||
initAsidenavSubmenus();
|
||||
this._initFavoritesButton();
|
||||
this._initAsidenavSubmenus();
|
||||
|
||||
// mark initialized
|
||||
element.classList.add(ASIDENAV_INITIALIZED_CLASS);
|
||||
|
||||
return {
|
||||
name: ASIDENAV_UTIL_NAME,
|
||||
element: element,
|
||||
destroy: function() {},
|
||||
};
|
||||
this._element.classList.add(ASIDENAV_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
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);
|
||||
destroy() {
|
||||
this._asidenavSubmenus.forEach((union) => {
|
||||
union.listItem.removeEventListener(union.hoverHandler);
|
||||
});
|
||||
}
|
||||
|
||||
function initAsidenavSubmenus() {
|
||||
var asidenavLinksWithSubmenus = Array.from(element.querySelectorAll('.' + ASIDENAV_LIST_ITEM_CLASS))
|
||||
_initFavoritesButton() {
|
||||
const favoritesBtn = document.querySelector('.' + FAVORITES_BTN_CLASS);
|
||||
if (favoritesBtn) {
|
||||
favoritesBtn.addEventListener('click', (event) => {
|
||||
favoritesBtn.classList.toggle(FAVORITES_BTN_ACTIVE_CLASS);
|
||||
this._element.classList.toggle(ASIDENAV_EXPANDED_CLASS);
|
||||
event.preventDefault();
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
|
||||
_initAsidenavSubmenus() {
|
||||
this._asidenavSubmenus = Array.from(this._element.querySelectorAll('.' + ASIDENAV_LIST_ITEM_CLASS))
|
||||
.map(function(listItem) {
|
||||
var submenu = listItem.querySelector('.' + ASIDENAV_SUBMENU_CLASS);
|
||||
const 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));
|
||||
this._asidenavSubmenus.forEach((union) => {
|
||||
union.hoverHandler = this._createMouseoverHandler(union);
|
||||
union.listItem.addEventListener('mouseover', union.hoverHandler);
|
||||
});
|
||||
}
|
||||
|
||||
function createMouseoverHandler(union) {
|
||||
return function mouseoverHanlder() {
|
||||
var rectListItem = union.listItem.getBoundingClientRect();
|
||||
var rectSubMenu = union.submenu.getBoundingClientRect();
|
||||
_createMouseoverHandler(union) {
|
||||
return () => {
|
||||
const rectListItem = union.listItem.getBoundingClientRect();
|
||||
const rectSubMenu = union.submenu.getBoundingClientRect();
|
||||
|
||||
union.submenu.style.left = (rectListItem.left + rectListItem.width) + 'px';
|
||||
if (window.innerHeight - rectListItem.top < rectSubMenu.height) {
|
||||
@ -91,15 +77,6 @@ var asidenavUtil = function(element) {
|
||||
} else {
|
||||
union.submenu.style.top = rectListItem.top + 'px';
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
return init();
|
||||
};
|
||||
|
||||
export default {
|
||||
name: ASIDENAV_UTIL_NAME,
|
||||
selector: ASIDENAV_UTIL_SELECTOR,
|
||||
setup: asidenavUtil,
|
||||
};
|
||||
}
|
||||
|
||||
21
frontend/src/utils/asidenav/asidenav.md
Normal file
21
frontend/src/utils/asidenav/asidenav.md
Normal file
@ -0,0 +1,21 @@
|
||||
# Asidenav
|
||||
|
||||
Correctly positions hovered asidenav submenus and handles the favorites button on mobile
|
||||
|
||||
## Attribute: `uw-asidenav`
|
||||
|
||||
## Example usage:
|
||||
```html
|
||||
<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
|
||||
...
|
||||
```
|
||||
21
frontend/src/utils/asidenav/asidenav.spec.js
Normal file
21
frontend/src/utils/asidenav/asidenav.spec.js
Normal file
@ -0,0 +1,21 @@
|
||||
import { Asidenav } from './asidenav';
|
||||
|
||||
describe('Asidenav', () => {
|
||||
|
||||
let asidenav;
|
||||
|
||||
beforeEach(() => {
|
||||
const element = document.createElement('div');
|
||||
asidenav = new Asidenav(element);
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(asidenav).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should throw if called without an element', () => {
|
||||
expect(() => {
|
||||
new Asidenav();
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
@ -1,74 +1,60 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
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."
|
||||
*/
|
||||
const ASYNC_FORM_INITIALIZED_CLASS = 'check-all--initialized';
|
||||
const ASYNC_FORM_RESPONSE_CLASS = 'async-form__response';
|
||||
const ASYNC_FORM_LOADING_CLASS = 'async-form--loading';
|
||||
const ASYNC_FORM_MIN_DELAY = 600;
|
||||
|
||||
var ASYNC_FORM_UTIL_NAME = 'asyncForm';
|
||||
var ASYNC_FORM_UTIL_SELECTOR = 'form[uw-async-form]';
|
||||
const MODAL_SELECTOR = '.modal';
|
||||
const MODAL_HEADER_KEY = 'Is-Modal';
|
||||
const MODAL_HEADER_VALUE = 'True';
|
||||
|
||||
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;
|
||||
@Utility({
|
||||
selector: 'form[uw-async-form]',
|
||||
})
|
||||
export class AsyncForm {
|
||||
|
||||
var MODAL_SELECTOR = '.modal';
|
||||
var MODAL_HEADER_KEY = 'Is-Modal';
|
||||
var MODAL_HEADER_VALUE = 'True';
|
||||
_lastRequestTimestamp = 0;
|
||||
_element;
|
||||
_app;
|
||||
|
||||
var asyncFormUtil = function(element, app) {
|
||||
|
||||
var lastRequestTimestamp = 0;
|
||||
|
||||
function init() {
|
||||
constructor(element, app) {
|
||||
if (!element) {
|
||||
throw new Error('Async Form Utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
if (element.classList.contains(ASYNC_FORM_INITIALIZED_CLASS)) {
|
||||
this._element = element;
|
||||
this._app = app;
|
||||
|
||||
if (this._element.classList.contains(ASYNC_FORM_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
element.addEventListener('submit', submitHandler);
|
||||
this._element.addEventListener('submit', this._submitHandler);
|
||||
|
||||
element.classList.add(ASYNC_FORM_INITIALIZED_CLASS);
|
||||
|
||||
return {
|
||||
name: ASYNC_FORM_UTIL_NAME,
|
||||
element: element,
|
||||
destroy: function() {},
|
||||
};
|
||||
this._element.classList.add(ASYNC_FORM_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
function processResponse(response) {
|
||||
var responseElement = makeResponseElement(response.content, response.status);
|
||||
var parentElement = element.parentElement;
|
||||
destroy() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
_processResponse(response) {
|
||||
const responseElement = this._makeResponseElement(response.content, response.status);
|
||||
const parentElement = this._element.parentElement;
|
||||
|
||||
// make sure there is a delay between click and response
|
||||
var delay = Math.max(0, ASYNC_FORM_MIN_DELAY + lastRequestTimestamp - Date.now());
|
||||
const delay = Math.max(0, ASYNC_FORM_MIN_DELAY + this._lastRequestTimestamp - Date.now());
|
||||
|
||||
setTimeout(function() {
|
||||
parentElement.insertBefore(responseElement, element);
|
||||
element.remove();
|
||||
setTimeout(() => {
|
||||
parentElement.insertBefore(responseElement, this._element);
|
||||
this._element.remove();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function makeResponseElement(content, status) {
|
||||
var responseElement = document.createElement('div');
|
||||
_makeResponseElement(content, status) {
|
||||
const responseElement = document.createElement('div');
|
||||
status = status || 'info';
|
||||
responseElement.classList.add(ASYNC_FORM_RESPONSE_CLASS);
|
||||
responseElement.classList.add(ASYNC_FORM_RESPONSE_CLASS + '--' + status);
|
||||
@ -76,42 +62,34 @@ var asyncFormUtil = function(element, app) {
|
||||
return responseElement;
|
||||
}
|
||||
|
||||
function submitHandler(event) {
|
||||
_submitHandler = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
element.classList.add(ASYNC_FORM_LOADING_CLASS);
|
||||
lastRequestTimestamp = Date.now();
|
||||
this._element.classList.add(ASYNC_FORM_LOADING_CLASS);
|
||||
this._lastRequestTimestamp = Date.now();
|
||||
|
||||
var url = element.getAttribute('action');
|
||||
var headers = { };
|
||||
var body = new FormData(element);
|
||||
const url = this._element.getAttribute('action');
|
||||
const headers = { };
|
||||
const body = new FormData(this._element);
|
||||
|
||||
var isModal = element.closest(MODAL_SELECTOR);
|
||||
const isModal = this._element.closest(MODAL_SELECTOR);
|
||||
if (isModal) {
|
||||
headers[MODAL_HEADER_KEY] = MODAL_HEADER_VALUE;
|
||||
}
|
||||
|
||||
app.httpClient.post({
|
||||
this._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 });
|
||||
}).then(
|
||||
(response) => response.json()
|
||||
).then(
|
||||
(response) => this._processResponse(response[0])
|
||||
).catch(() => {
|
||||
const failureMessage = this._app.i18n.get('asyncFormFailure');
|
||||
this._processResponse({ content: failureMessage });
|
||||
|
||||
element.classList.remove(ASYNC_FORM_LOADING_CLASS);
|
||||
this._element.classList.remove(ASYNC_FORM_LOADING_CLASS);
|
||||
});
|
||||
}
|
||||
|
||||
return init();
|
||||
};
|
||||
|
||||
export default {
|
||||
name: ASYNC_FORM_UTIL_NAME,
|
||||
selector: ASYNC_FORM_UTIL_SELECTOR,
|
||||
setup: asyncFormUtil,
|
||||
};
|
||||
}
|
||||
|
||||
17
frontend/src/utils/async-form/async-form.md
Normal file
17
frontend/src/utils/async-form/async-form.md
Normal file
@ -0,0 +1,17 @@
|
||||
# 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:
|
||||
```html
|
||||
<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 (e.g. 'Oops. Something went wrong.').
|
||||
21
frontend/src/utils/async-form/async-form.spec.js
Normal file
21
frontend/src/utils/async-form/async-form.spec.js
Normal file
@ -0,0 +1,21 @@
|
||||
import { AsyncForm } from './async-form';
|
||||
|
||||
describe('AsyncForm', () => {
|
||||
|
||||
let asyncForm;
|
||||
|
||||
beforeEach(() => {
|
||||
const element = document.createElement('div');
|
||||
asyncForm = new AsyncForm(element);
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(asyncForm).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should throw if called without an element', () => {
|
||||
expect(() => {
|
||||
new AsyncForm();
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
@ -1,235 +1,236 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import { HttpClient } from '../../services/http-client/http-client';
|
||||
import './async-table.scss';
|
||||
import * as debounce from 'lodash.debounce';
|
||||
import './async-table-filter.scss';
|
||||
import './async-table.scss';
|
||||
|
||||
/**
|
||||
*
|
||||
* Async Table Utility
|
||||
* makes table filters, sorting and pagination behave asynchronously via AJAX calls
|
||||
*
|
||||
* Attribute: uw-async-table
|
||||
*
|
||||
* Example usage:
|
||||
* (regular table)
|
||||
*/
|
||||
const INPUT_DEBOUNCE = 600;
|
||||
const HEADER_HEIGHT = 80;
|
||||
|
||||
var INPUT_DEBOUNCE = 600;
|
||||
var HEADER_HEIGHT = 80;
|
||||
const ASYNC_TABLE_LOCAL_STORAGE_KEY = 'ASYNC_TABLE';
|
||||
const ASYNC_TABLE_SCROLLTABLE_SELECTOR = '.scrolltable';
|
||||
const ASYNC_TABLE_INITIALIZED_CLASS = 'async-table--initialized';
|
||||
const ASYNC_TABLE_LOADING_CLASS = 'async-table--loading';
|
||||
|
||||
var ASYNC_TABLE_UTIL_NAME = 'asyncTable';
|
||||
var ASYNC_TABLE_UTIL_SELECTOR = '[uw-async-table]';
|
||||
const ASYNC_TABLE_FILTER_FORM_SELECTOR = '.table-filter-form';
|
||||
const ASYNC_TABLE_FILTER_FORM_ID_SELECTOR = '[name="form-identifier"]';
|
||||
|
||||
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';
|
||||
@Utility({
|
||||
selector: '[uw-async-table]',
|
||||
})
|
||||
export class AsyncTable {
|
||||
|
||||
var ASYNC_TABLE_FILTER_FORM_SELECTOR = '.table-filter-form';
|
||||
var ASYNC_TABLE_FILTER_FORM_ID_SELECTOR = '[name="form-identifier"]';
|
||||
_element;
|
||||
_app;
|
||||
|
||||
_asyncTableHeader;
|
||||
_asyncTableId;
|
||||
|
||||
var asyncTableUtil = function(element, app) {
|
||||
var asyncTableHeader;
|
||||
var asyncTableId;
|
||||
_ths = [];
|
||||
_pageLinks = [];
|
||||
_pagesizeForm;
|
||||
_scrollTable;
|
||||
_cssIdPrefix = '';
|
||||
|
||||
var ths = [];
|
||||
var pageLinks = [];
|
||||
var pagesizeForm;
|
||||
var scrollTable;
|
||||
var cssIdPrefix = '';
|
||||
|
||||
var tableFilterInputs = {
|
||||
_tableFilterInputs = {
|
||||
search: [],
|
||||
input: [],
|
||||
change: [],
|
||||
select: [],
|
||||
};
|
||||
|
||||
function init() {
|
||||
constructor(element, app) {
|
||||
if (!element) {
|
||||
throw new Error('Async Table utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
if (element.classList.contains(ASYNC_TABLE_INITIALIZED_CLASS)) {
|
||||
if (!app) {
|
||||
throw new Error('Async Table utility cannot be setup without an app!');
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
this._app = app;
|
||||
|
||||
if (this._element.classList.contains(ASYNC_TABLE_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// param asyncTableDbHeader
|
||||
if (element.dataset.asyncTableDbHeader !== undefined) {
|
||||
asyncTableHeader = element.dataset.asyncTableDbHeader;
|
||||
if (this._element.dataset.asyncTableDbHeader !== undefined) {
|
||||
this._asyncTableHeader = this._element.dataset.asyncTableDbHeader;
|
||||
}
|
||||
|
||||
var rawTableId = element.querySelector('table').id;
|
||||
cssIdPrefix = findCssIdPrefix(rawTableId);
|
||||
asyncTableId = rawTableId.replace(cssIdPrefix, '');
|
||||
const table = this._element.querySelector('table');
|
||||
if (!table) {
|
||||
throw new Error('Async Table utility needs a <table> in its element!');
|
||||
}
|
||||
|
||||
const rawTableId = table.id;
|
||||
this._cssIdPrefix = findCssIdPrefix(rawTableId);
|
||||
this._asyncTableId = rawTableId.replace(this._cssIdPrefix, '');
|
||||
|
||||
// find scrolltable wrapper
|
||||
scrollTable = element.querySelector(ASYNC_TABLE_SCROLLTABLE_SELECTOR);
|
||||
if (!scrollTable) {
|
||||
this._scrollTable = this._element.querySelector(ASYNC_TABLE_SCROLLTABLE_SELECTOR);
|
||||
if (!this._scrollTable) {
|
||||
throw new Error('Async Table cannot be set up without a scrolltable element!');
|
||||
}
|
||||
|
||||
setupSortableHeaders();
|
||||
setupPagination();
|
||||
setupPageSizeSelect();
|
||||
setupTableFilter();
|
||||
this._setupSortableHeaders();
|
||||
this._setupPagination();
|
||||
this._setupPageSizeSelect();
|
||||
this._setupTableFilter();
|
||||
|
||||
processLocalStorage();
|
||||
this._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() {},
|
||||
};
|
||||
this._element.classList.add(ASYNC_TABLE_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
function setupSortableHeaders() {
|
||||
ths = Array.from(scrollTable.querySelectorAll('th.sortable')).map(function(th) {
|
||||
return { element: th };
|
||||
});
|
||||
destroy() {
|
||||
console.log('TBD: Destroy AsyncTable');
|
||||
}
|
||||
|
||||
ths.forEach(function(th) {
|
||||
th.clickHandler = function(event) {
|
||||
setLocalStorageParameter('horizPos', (scrollTable || {}).scrollLeft);
|
||||
linkClickHandler(event);
|
||||
_setupSortableHeaders() {
|
||||
this._ths = Array.from(this._scrollTable.querySelectorAll('th.sortable'))
|
||||
.map((th) => ({ element: th }));
|
||||
|
||||
this._ths.forEach((th) => {
|
||||
th.clickHandler = (event) => {
|
||||
setLocalStorageParameter('horizPos', (this._scrollTable || {}).scrollLeft);
|
||||
this._linkClickHandler(event);
|
||||
};
|
||||
th.element.addEventListener('click', th.clickHandler);
|
||||
});
|
||||
}
|
||||
|
||||
function setupPagination() {
|
||||
var pagination = element.querySelector('#' + cssIdPrefix + asyncTableId + '-pagination');
|
||||
_setupPagination() {
|
||||
const pagination = this._element.querySelector('#' + this._cssIdPrefix + this._asyncTableId + '-pagination');
|
||||
if (pagination) {
|
||||
pageLinks = Array.from(pagination.querySelectorAll('.page-link')).map(function(link) {
|
||||
return { element: link };
|
||||
});
|
||||
this._pageLinks = Array.from(pagination.querySelectorAll('.page-link'))
|
||||
.map((link) => ({ element: link }));
|
||||
|
||||
pageLinks.forEach(function(link) {
|
||||
link.clickHandler = function(event) {
|
||||
var tableBoundingRect = scrollTable.getBoundingClientRect();
|
||||
this._pageLinks.forEach((link) => {
|
||||
link.clickHandler = (event) => {
|
||||
const tableBoundingRect = this._scrollTable.getBoundingClientRect();
|
||||
if (tableBoundingRect.top < HEADER_HEIGHT) {
|
||||
var scrollTo = {
|
||||
top: (scrollTable.offsetTop || 0) - HEADER_HEIGHT,
|
||||
left: scrollTable.offsetLeft || 0,
|
||||
const scrollTo = {
|
||||
top: (this._scrollTable.offsetTop || 0) - HEADER_HEIGHT,
|
||||
left: this._scrollTable.offsetLeft || 0,
|
||||
behavior: 'smooth',
|
||||
};
|
||||
setLocalStorageParameter('scrollTo', scrollTo);
|
||||
}
|
||||
linkClickHandler(event);
|
||||
this._linkClickHandler(event);
|
||||
};
|
||||
link.element.addEventListener('click', link.clickHandler);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setupPageSizeSelect() {
|
||||
_setupPageSizeSelect() {
|
||||
// pagesize form
|
||||
pagesizeForm = element.querySelector('#' + cssIdPrefix + asyncTableId + '-pagesize-form');
|
||||
this._pagesizeForm = this._element.querySelector('#' + this._cssIdPrefix + this._asyncTableId + '-pagesize-form');
|
||||
|
||||
if (pagesizeForm) {
|
||||
var pagesizeSelect = pagesizeForm.querySelector('[name=' + asyncTableId + '-pagesize]');
|
||||
pagesizeSelect.addEventListener('change', changePagesizeHandler);
|
||||
if (this._pagesizeForm) {
|
||||
const pagesizeSelect = this._pagesizeForm.querySelector('[name=' + this._asyncTableId + '-pagesize]');
|
||||
pagesizeSelect.addEventListener('change', this._changePagesizeHandler);
|
||||
}
|
||||
}
|
||||
|
||||
function setupTableFilter() {
|
||||
var tableFilterForm = element.querySelector(ASYNC_TABLE_FILTER_FORM_SELECTOR);
|
||||
|
||||
_setupTableFilter() {
|
||||
const tableFilterForm = this._element.querySelector(ASYNC_TABLE_FILTER_FORM_SELECTOR);
|
||||
if (tableFilterForm) {
|
||||
gatherTableFilterInputs(tableFilterForm);
|
||||
addTableFilterEventListeners(tableFilterForm);
|
||||
this._gatherTableFilterInputs(tableFilterForm);
|
||||
this._addTableFilterEventListeners(tableFilterForm);
|
||||
}
|
||||
}
|
||||
|
||||
function gatherTableFilterInputs(tableFilterForm) {
|
||||
Array.from(tableFilterForm.querySelectorAll('input[type="search"]')).forEach(function(input) {
|
||||
tableFilterInputs.search.push(input);
|
||||
_gatherTableFilterInputs(tableFilterForm) {
|
||||
Array.from(tableFilterForm.querySelectorAll('input[type="search"]')).forEach((input) => {
|
||||
this._tableFilterInputs.search.push(input);
|
||||
});
|
||||
|
||||
Array.from(tableFilterForm.querySelectorAll('input[type="text"]')).forEach(function(input) {
|
||||
tableFilterInputs.input.push(input);
|
||||
Array.from(tableFilterForm.querySelectorAll('input[type="text"]')).forEach((input) => {
|
||||
this._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('input:not([type="text"]):not([type="search"])')).forEach((input) => {
|
||||
this._tableFilterInputs.change.push(input);
|
||||
});
|
||||
|
||||
Array.from(tableFilterForm.querySelectorAll('select')).forEach(function(input) {
|
||||
tableFilterInputs.select.push(input);
|
||||
Array.from(tableFilterForm.querySelectorAll('select')).forEach((input) => {
|
||||
this._tableFilterInputs.select.push(input);
|
||||
});
|
||||
}
|
||||
|
||||
function addTableFilterEventListeners(tableFilterForm) {
|
||||
tableFilterInputs.search.forEach(function(input) {
|
||||
var debouncedInput = debounce(function() {
|
||||
_addTableFilterEventListeners(tableFilterForm) {
|
||||
this._tableFilterInputs.search.forEach((input) => {
|
||||
const debouncedInput = debounce(() => {
|
||||
if (input.value.length === 0 || input.value.length > 2) {
|
||||
updateFromTableFilter(tableFilterForm);
|
||||
this._updateFromTableFilter(tableFilterForm);
|
||||
}
|
||||
}, INPUT_DEBOUNCE);
|
||||
input.addEventListener('input', debouncedInput);
|
||||
});
|
||||
|
||||
tableFilterInputs.input.forEach(function(input) {
|
||||
var debouncedInput = debounce(function() {
|
||||
this._tableFilterInputs.input.forEach((input) => {
|
||||
const debouncedInput = debounce(() => {
|
||||
if (input.value.length === 0 || input.value.length > 2) {
|
||||
updateFromTableFilter(tableFilterForm);
|
||||
this._updateFromTableFilter(tableFilterForm);
|
||||
}
|
||||
}, INPUT_DEBOUNCE);
|
||||
input.addEventListener('input', debouncedInput);
|
||||
});
|
||||
|
||||
tableFilterInputs.change.forEach(function(input) {
|
||||
input.addEventListener('change', function() {
|
||||
updateFromTableFilter(tableFilterForm);
|
||||
this._tableFilterInputs.change.forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
this._updateFromTableFilter(tableFilterForm);
|
||||
});
|
||||
});
|
||||
|
||||
tableFilterInputs.select.forEach(function(input) {
|
||||
input.addEventListener('change', function() {
|
||||
updateFromTableFilter(tableFilterForm);
|
||||
this._tableFilterInputs.select.forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
this._updateFromTableFilter(tableFilterForm);
|
||||
});
|
||||
});
|
||||
|
||||
tableFilterForm.addEventListener('submit', function(event) {
|
||||
tableFilterForm.addEventListener('submit', (event) =>{
|
||||
event.preventDefault();
|
||||
updateFromTableFilter(tableFilterForm);
|
||||
this._updateFromTableFilter(tableFilterForm);
|
||||
});
|
||||
}
|
||||
|
||||
function updateFromTableFilter(tableFilterForm) {
|
||||
var url = serializeTableFilterToURL();
|
||||
var callback = null;
|
||||
_updateFromTableFilter(tableFilterForm) {
|
||||
const url = this._serializeTableFilterToURL();
|
||||
let callback = null;
|
||||
|
||||
var focusedInput = tableFilterForm.querySelector(':focus, :active');
|
||||
const focusedInput = tableFilterForm.querySelector(':focus, :active');
|
||||
// focus previously focused input
|
||||
if (focusedInput && focusedInput.selectionStart !== null) {
|
||||
var selectionStart = focusedInput.selectionStart;
|
||||
const 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, '');
|
||||
const prefix = findCssIdPrefix(focusedInput.id);
|
||||
const focusId = focusedInput.id.replace(prefix, '');
|
||||
callback = function(wrapper) {
|
||||
var idPrefix = getLocalStorageParameter('cssIdPrefix');
|
||||
var toBeFocused = wrapper.querySelector('#' + idPrefix + focusId);
|
||||
const idPrefix = getLocalStorageParameter('cssIdPrefix');
|
||||
const toBeFocused = wrapper.querySelector('#' + idPrefix + focusId);
|
||||
if (toBeFocused) {
|
||||
toBeFocused.focus();
|
||||
toBeFocused.selectionStart = selectionStart;
|
||||
}
|
||||
};
|
||||
}
|
||||
updateTableFrom(url, callback);
|
||||
this._updateTableFrom(url, callback);
|
||||
}
|
||||
|
||||
function serializeTableFilterToURL() {
|
||||
var url = new URL(getLocalStorageParameter('currentTableUrl') || window.location.href);
|
||||
_serializeTableFilterToURL() {
|
||||
const url = new URL(getLocalStorageParameter('currentTableUrl') || window.location.href);
|
||||
|
||||
var formIdElement = element.querySelector(ASYNC_TABLE_FILTER_FORM_ID_SELECTOR);
|
||||
const formIdElement = this._element.querySelector(ASYNC_TABLE_FILTER_FORM_ID_SELECTOR);
|
||||
if (!formIdElement) {
|
||||
// cannot serialize the filter form without an identifier
|
||||
return;
|
||||
@ -237,25 +238,25 @@ var asyncTableUtil = function(element, app) {
|
||||
|
||||
url.searchParams.set('form-identifier', formIdElement.value);
|
||||
url.searchParams.set('_hasdata', 'true');
|
||||
url.searchParams.set(asyncTableId + '-page', '0');
|
||||
url.searchParams.set(this._asyncTableId + '-page', '0');
|
||||
|
||||
tableFilterInputs.search.forEach(function(input) {
|
||||
this._tableFilterInputs.search.forEach(function(input) {
|
||||
url.searchParams.set(input.name, input.value);
|
||||
});
|
||||
|
||||
tableFilterInputs.input.forEach(function(input) {
|
||||
this._tableFilterInputs.input.forEach(function(input) {
|
||||
url.searchParams.set(input.name, input.value);
|
||||
});
|
||||
|
||||
tableFilterInputs.change.forEach(function(input) {
|
||||
this._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; });
|
||||
this._tableFilterInputs.select.forEach(function(select) {
|
||||
const options = Array.from(select.querySelectorAll('option'));
|
||||
const selected = options.find((option) => option.selected);
|
||||
if (selected) {
|
||||
url.searchParams.set(select.name, selected.value);
|
||||
}
|
||||
@ -264,119 +265,107 @@ var asyncTableUtil = function(element, app) {
|
||||
return url;
|
||||
}
|
||||
|
||||
function processLocalStorage() {
|
||||
var scrollTo = getLocalStorageParameter('scrollTo');
|
||||
if (scrollTo && scrollTable) {
|
||||
_processLocalStorage() {
|
||||
const scrollTo = getLocalStorageParameter('scrollTo');
|
||||
if (scrollTo && this._scrollTable) {
|
||||
window.scrollTo(scrollTo);
|
||||
}
|
||||
setLocalStorageParameter('scrollTo', null);
|
||||
|
||||
var horizPos = getLocalStorageParameter('horizPos');
|
||||
if (horizPos && scrollTable) {
|
||||
scrollTable.scrollLeft = horizPos;
|
||||
const horizPos = getLocalStorageParameter('horizPos');
|
||||
if (horizPos && this._scrollTable) {
|
||||
this._scrollTable.scrollLeft = horizPos;
|
||||
}
|
||||
setLocalStorageParameter('horizPos', null);
|
||||
}
|
||||
|
||||
function removeListeners() {
|
||||
ths.forEach(function(th) {
|
||||
_removeListeners() {
|
||||
this._ths.forEach(function(th) {
|
||||
th.element.removeEventListener('click', th.clickHandler);
|
||||
});
|
||||
|
||||
pageLinks.forEach(function(link) {
|
||||
this._pageLinks.forEach(function(link) {
|
||||
link.element.removeEventListener('click', link.clickHandler);
|
||||
});
|
||||
|
||||
if (pagesizeForm) {
|
||||
var pagesizeSelect = pagesizeForm.querySelector('[name=' + asyncTableId + '-pagesize]');
|
||||
pagesizeSelect.removeEventListener('change', changePagesizeHandler);
|
||||
if (this._pagesizeForm) {
|
||||
const pagesizeSelect = this._pagesizeForm.querySelector('[name=' + this._asyncTableId + '-pagesize]');
|
||||
pagesizeSelect.removeEventListener('change', this._changePagesizeHandler);
|
||||
}
|
||||
}
|
||||
|
||||
function linkClickHandler(event) {
|
||||
_linkClickHandler = (event) => {
|
||||
event.preventDefault();
|
||||
var url = getClickDestination(event.target);
|
||||
let url = this._getClickDestination(event.target);
|
||||
if (!url.match(/^http/)) {
|
||||
url = window.location.origin + window.location.pathname + url;
|
||||
}
|
||||
updateTableFrom(url);
|
||||
this._updateTableFrom(url);
|
||||
}
|
||||
|
||||
function getClickDestination(el) {
|
||||
_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';
|
||||
_changePagesizeHandler = (event) => {
|
||||
const paginationParamKey = this._asyncTableId + '-pagination';
|
||||
const pagesizeParamKey = this._asyncTableId + '-pagesize';
|
||||
const pageParamKey = this._asyncTableId + '-page';
|
||||
|
||||
var paginationParamEl = pagesizeForm.querySelector('[name="' + paginationParamKey + '"]');
|
||||
var url = new URL(getLocalStorageParameter('currentTableUrl') || window.location.href);
|
||||
const paginationParamEl = this._pagesizeForm.querySelector('[name="' + paginationParamKey + '"]');
|
||||
const 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);
|
||||
const encodedValue = encodeURIComponent(paginationParamEl.value);
|
||||
url.searchParams.set(paginationParamKey, encodedValue);
|
||||
}
|
||||
updateTableFrom(url.href);
|
||||
this._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);
|
||||
_updateTableFrom(url, callback) {
|
||||
this._element.classList.add(ASYNC_TABLE_LOADING_CLASS);
|
||||
|
||||
var headers = {
|
||||
const headers = {
|
||||
'Accept': HttpClient.ACCEPT.TEXT_HTML,
|
||||
[asyncTableHeader]: asyncTableId,
|
||||
[this._asyncTableHeader]: this._asyncTableId,
|
||||
};
|
||||
|
||||
app.httpClient.get({
|
||||
this._app.httpClient.get({
|
||||
url: url,
|
||||
headers: headers,
|
||||
}).then(function(response) {
|
||||
return app.htmlHelpers.parseResponse(response);
|
||||
}).then(function(response) {
|
||||
}).then(
|
||||
(response) => this._app.htmlHelpers.parseResponse(response)
|
||||
).then((response) => {
|
||||
setLocalStorageParameter('currentTableUrl', url.href);
|
||||
// reset table
|
||||
removeListeners();
|
||||
element.classList.remove(ASYNC_TABLE_INITIALIZED_CLASS);
|
||||
this._removeListeners();
|
||||
this._element.classList.remove(ASYNC_TABLE_INITIALIZED_CLASS);
|
||||
// update table with new
|
||||
updateWrapperContents(response);
|
||||
this._element.innerHTML = response.element.innerHTML;
|
||||
|
||||
app.utilRegistry.setupAll(element);
|
||||
this._app.utilRegistry.setupAll(this._element);
|
||||
|
||||
if (callback && typeof callback === 'function') {
|
||||
setLocalStorageParameter('cssIdPrefix', response.idPrefix);
|
||||
callback(element);
|
||||
callback(this._element);
|
||||
setLocalStorageParameter('cssIdPrefix', '');
|
||||
}
|
||||
}).catch(function(err) {
|
||||
console.error(err);
|
||||
}).finally(function() {
|
||||
element.classList.remove(ASYNC_TABLE_LOADING_CLASS);
|
||||
});
|
||||
}).catch((err) => console.error(err)
|
||||
).finally(() => this._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);
|
||||
const matcher = /r\d*?__/;
|
||||
const maybePrefix = id.match(matcher);
|
||||
if (maybePrefix && maybePrefix[0]) {
|
||||
return maybePrefix[0];
|
||||
}
|
||||
@ -384,7 +373,7 @@ function findCssIdPrefix(id) {
|
||||
}
|
||||
|
||||
function setLocalStorageParameter(key, value) {
|
||||
var currentLSState = JSON.parse(window.localStorage.getItem(ASYNC_TABLE_LOCAL_STORAGE_KEY)) || {};
|
||||
const currentLSState = JSON.parse(window.localStorage.getItem(ASYNC_TABLE_LOCAL_STORAGE_KEY)) || {};
|
||||
if (value !== null) {
|
||||
currentLSState[key] = value;
|
||||
} else {
|
||||
@ -392,30 +381,7 @@ function setLocalStorageParameter(key, value) {
|
||||
}
|
||||
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)) || {};
|
||||
const 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,
|
||||
};
|
||||
|
||||
7
frontend/src/utils/async-table/async-table.md
Normal file
7
frontend/src/utils/async-table/async-table.md
Normal file
@ -0,0 +1,7 @@
|
||||
# Async Table Utility
|
||||
Makes table filters, sorting and pagination behave asynchronously via AJAX calls.
|
||||
|
||||
## Attribute: `uw-async-table`
|
||||
|
||||
## Example usage:
|
||||
(any regular table)
|
||||
52
frontend/src/utils/async-table/async-table.spec.js
Normal file
52
frontend/src/utils/async-table/async-table.spec.js
Normal file
@ -0,0 +1,52 @@
|
||||
import { AsyncTable } from './async-table';
|
||||
|
||||
const AppTestMock = {
|
||||
httpClient: {
|
||||
get: () => {},
|
||||
},
|
||||
htmlHelpers: {
|
||||
parseResponse: () => {},
|
||||
},
|
||||
utilRegistry: {
|
||||
setupAll: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
describe('AsyncTable', () => {
|
||||
|
||||
let asyncTable;
|
||||
|
||||
beforeEach(() => {
|
||||
const element = document.createElement('div');
|
||||
const scrollTable = document.createElement('div');
|
||||
const table = document.createElement('table');
|
||||
scrollTable.classList.add('scrolltable');
|
||||
scrollTable.appendChild(table);
|
||||
element.appendChild(scrollTable);
|
||||
asyncTable = new AsyncTable(element, AppTestMock);
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(asyncTable).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should throw if element does not contain a .scrolltable', () => {
|
||||
const element = document.createElement('div');
|
||||
expect(() => {
|
||||
new AsyncTable(element, AppTestMock);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should throw if element does not contain a table', () => {
|
||||
const element = document.createElement('div');
|
||||
expect(() => {
|
||||
new AsyncTable(element, AppTestMock);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should throw if called without an element', () => {
|
||||
expect(() => {
|
||||
new AsyncTable();
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
@ -1,81 +1,76 @@
|
||||
/**
|
||||
*
|
||||
* 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)
|
||||
*/
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
var CHECK_ALL_UTIL_NAME = 'checkAll';
|
||||
var CHECK_ALL_UTIL_SELECTOR = 'table';
|
||||
const CHECKBOX_SELECTOR = '[type="checkbox"]';
|
||||
|
||||
var CHECKBOX_SELECTOR = '[type="checkbox"]';
|
||||
const CHECK_ALL_INITIALIZED_CLASS = 'check-all--initialized';
|
||||
|
||||
var CHECK_ALL_INITIALIZED_CLASS = 'check-all--initialized';
|
||||
@Utility({
|
||||
selector: 'table',
|
||||
})
|
||||
export class CheckAll {
|
||||
|
||||
var checkAllUtil = function(element, app) {
|
||||
var columns = [];
|
||||
var checkboxColumn = [];
|
||||
var checkAllCheckbox = null;
|
||||
_element;
|
||||
_app;
|
||||
|
||||
function init() {
|
||||
_columns = [];
|
||||
_checkboxColumn = [];
|
||||
_checkAllCheckbox = null;
|
||||
|
||||
constructor(element, app) {
|
||||
if (!element) {
|
||||
throw new Error('Check All utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
if (element.classList.contains(CHECK_ALL_INITIALIZED_CLASS)) {
|
||||
this._element = element;
|
||||
this._app = app;
|
||||
|
||||
if (this._element.classList.contains(CHECK_ALL_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
gatherColumns();
|
||||
setupCheckAllCheckbox();
|
||||
this._gatherColumns();
|
||||
this._setupCheckAllCheckbox();
|
||||
|
||||
// mark initialized
|
||||
element.classList.add(CHECK_ALL_INITIALIZED_CLASS);
|
||||
|
||||
return {
|
||||
name: CHECK_ALL_UTIL_NAME,
|
||||
element: element,
|
||||
destroy: function() {},
|
||||
};
|
||||
this._element.classList.add(CHECK_ALL_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
function getCheckboxId() {
|
||||
destroy() {
|
||||
console.log('TBD: Destroy CheckAll');
|
||||
}
|
||||
|
||||
_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) {
|
||||
_gatherColumns() {
|
||||
const rows = Array.from(this._element.querySelectorAll('tr'));
|
||||
const cols = [];
|
||||
rows.forEach((tr) => {
|
||||
const cells = Array.from(tr.querySelectorAll('td'));
|
||||
cells.forEach((cell, cellIndex) => {
|
||||
if (!cols[cellIndex]) {
|
||||
cols[cellIndex] = [];
|
||||
}
|
||||
cols[cellIndex].push(cell);
|
||||
});
|
||||
});
|
||||
columns = cols;
|
||||
this._columns = cols;
|
||||
}
|
||||
|
||||
function findCheckboxColumn(columns) {
|
||||
var checkboxColumnId = null;
|
||||
columns.forEach(function(col, i) {
|
||||
if (isCheckboxColumn(col)) {
|
||||
_findCheckboxColumn(columns) {
|
||||
let checkboxColumnId = null;
|
||||
columns.forEach((col, i) => {
|
||||
if (this._isCheckboxColumn(col)) {
|
||||
checkboxColumnId = i;
|
||||
}
|
||||
});
|
||||
return checkboxColumnId;
|
||||
}
|
||||
|
||||
function isCheckboxColumn(col) {
|
||||
var onlyCheckboxes = true;
|
||||
col.forEach(function(cell) {
|
||||
_isCheckboxColumn(col) {
|
||||
let onlyCheckboxes = true;
|
||||
col.forEach((cell) => {
|
||||
if (onlyCheckboxes && !cell.querySelector(CHECKBOX_SELECTOR)) {
|
||||
onlyCheckboxes = false;
|
||||
}
|
||||
@ -83,59 +78,50 @@ var checkAllUtil = function(element, app) {
|
||||
return onlyCheckboxes;
|
||||
}
|
||||
|
||||
function setupCheckAllCheckbox() {
|
||||
var checkboxColumnId = findCheckboxColumn(columns);
|
||||
_setupCheckAllCheckbox() {
|
||||
const checkboxColumnId = this._findCheckboxColumn(this._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);
|
||||
this._checkboxColumn = this._columns[checkboxColumnId];
|
||||
const firstRow = this._element.querySelector('tr');
|
||||
const th = Array.from(firstRow.querySelectorAll('th, td'))[checkboxColumnId];
|
||||
this._checkAllCheckbox = document.createElement('input');
|
||||
this._checkAllCheckbox.setAttribute('type', 'checkbox');
|
||||
this._checkAllCheckbox.setAttribute('id', this._getCheckboxId());
|
||||
th.insertBefore(this._checkAllCheckbox, th.firstChild);
|
||||
|
||||
// manually set up new checkbox
|
||||
app.utilRegistry.setup(app.utilRegistry.find('checkbox'), th);
|
||||
// set up new checkbox
|
||||
this._app.utilRegistry.setupAll(th);
|
||||
|
||||
checkAllCheckbox.addEventListener('input', onCheckAllCheckboxInput);
|
||||
setupCheckboxListeners();
|
||||
this._checkAllCheckbox.addEventListener('input', this._onCheckAllCheckboxInput);
|
||||
this._setupCheckboxListeners();
|
||||
}
|
||||
|
||||
function onCheckAllCheckboxInput() {
|
||||
toggleAll(checkAllCheckbox.checked);
|
||||
_onCheckAllCheckboxInput() {
|
||||
this._toggleAll(this._checkAllCheckbox.checked);
|
||||
}
|
||||
|
||||
function setupCheckboxListeners() {
|
||||
checkboxColumn
|
||||
.map(function(cell) {
|
||||
_setupCheckboxListeners() {
|
||||
this._checkboxColumn.map((cell) => {
|
||||
return cell.querySelector(CHECKBOX_SELECTOR);
|
||||
})
|
||||
.forEach(function(checkbox) {
|
||||
checkbox.addEventListener('input', updateCheckAllCheckboxState);
|
||||
.forEach((checkbox) => {
|
||||
checkbox.addEventListener('input', this.updateCheckAllCheckboxState);
|
||||
});
|
||||
}
|
||||
|
||||
function updateCheckAllCheckboxState() {
|
||||
var allChecked = checkboxColumn.every(function(cell) {
|
||||
_updateCheckAllCheckboxState() {
|
||||
const allChecked = this._checkboxColumn.every((cell) => {
|
||||
return cell.querySelector(CHECKBOX_SELECTOR).checked;
|
||||
});
|
||||
checkAllCheckbox.checked = allChecked;
|
||||
this._checkAllCheckbox.checked = allChecked;
|
||||
}
|
||||
|
||||
function toggleAll(checked) {
|
||||
checkboxColumn.forEach(function(cell) {
|
||||
_toggleAll(checked) {
|
||||
this._checkboxColumn.forEach((cell) => {
|
||||
cell.querySelector(CHECKBOX_SELECTOR).checked = checked;
|
||||
});
|
||||
}
|
||||
|
||||
return init();
|
||||
};
|
||||
|
||||
export default {
|
||||
name: CHECK_ALL_UTIL_NAME,
|
||||
selector: CHECK_ALL_UTIL_SELECTOR,
|
||||
setup: checkAllUtil,
|
||||
};
|
||||
}
|
||||
|
||||
9
frontend/src/utils/check-all/check-all.md
Normal file
9
frontend/src/utils/check-all/check-all.md
Normal file
@ -0,0 +1,9 @@
|
||||
# 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 exactly one column thats only checkboxes)
|
||||
|
||||
27
frontend/src/utils/check-all/check-all.spec.js
Normal file
27
frontend/src/utils/check-all/check-all.spec.js
Normal file
@ -0,0 +1,27 @@
|
||||
import { CheckAll } from './check-all';
|
||||
|
||||
const MOCK_APP = {
|
||||
utilRegistry: {
|
||||
setupAll: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
describe('CheckAll', () => {
|
||||
|
||||
let checkAll;
|
||||
|
||||
beforeEach(() => {
|
||||
const element = document.createElement('div');
|
||||
checkAll = new CheckAll(element, MOCK_APP);
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(checkAll).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should throw if called without an element', () => {
|
||||
expect(() => {
|
||||
new CheckAll();
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
29
frontend/src/utils/form/auto-submit-button.js
Normal file
29
frontend/src/utils/form/auto-submit-button.js
Normal file
@ -0,0 +1,29 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
export const AUTO_SUBMIT_BUTTON_UTIL_SELECTOR = '[uw-auto-submit-button]';
|
||||
|
||||
const AUTO_SUBMIT_BUTTON_INITIALIZED_CLASS = 'auto-submit-button--initialized';
|
||||
const AUTO_SUBMIT_BUTTON_HIDDEN_CLASS = 'hidden';
|
||||
|
||||
@Utility({
|
||||
selector: AUTO_SUBMIT_BUTTON_UTIL_SELECTOR,
|
||||
})
|
||||
export class AutoSubmitButton {
|
||||
|
||||
constructor(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);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
11
frontend/src/utils/form/auto-submit-button.md
Normal file
11
frontend/src/utils/form/auto-submit-button.md
Normal file
@ -0,0 +1,11 @@
|
||||
# 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:
|
||||
```html
|
||||
<button type='submit' uw-auto-submit-button>Submit
|
||||
```
|
||||
|
||||
47
frontend/src/utils/form/auto-submit-input.js
Normal file
47
frontend/src/utils/form/auto-submit-input.js
Normal file
@ -0,0 +1,47 @@
|
||||
import * as debounce from 'lodash.debounce';
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
export const AUTO_SUBMIT_INPUT_UTIL_SELECTOR = '[uw-auto-submit-input]';
|
||||
|
||||
const AUTO_SUBMIT_INPUT_INITIALIZED_CLASS = 'auto-submit-input--initialized';
|
||||
|
||||
@Utility({
|
||||
selector: AUTO_SUBMIT_INPUT_UTIL_SELECTOR,
|
||||
})
|
||||
export class AutoSubmitInput {
|
||||
|
||||
_element;
|
||||
|
||||
_form;
|
||||
_debouncedHandler;
|
||||
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
throw new Error('Auto Submit Input utility needs to be passed an element!');
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
|
||||
if (this._element.classList.contains(AUTO_SUBMIT_INPUT_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._form = this._element.form;
|
||||
if (!this._form) {
|
||||
throw new Error('Could not determine associated form for auto submit input');
|
||||
}
|
||||
|
||||
this._debouncedHandler = debounce(this.autoSubmit, 500);
|
||||
|
||||
this._element.addEventListener('input', this._debouncedHandler);
|
||||
this._element.classList.add(AUTO_SUBMIT_INPUT_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._element.removeEventListener('input', this._debouncedHandler);
|
||||
}
|
||||
|
||||
autoSubmit = () => {
|
||||
this._form.submit();
|
||||
}
|
||||
}
|
||||
9
frontend/src/utils/form/auto-submit-input.md
Normal file
9
frontend/src/utils/form/auto-submit-input.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Auto Submit Input Utility
|
||||
Programmatically submits forms when a certain input changes value
|
||||
|
||||
## Attribute: `uw-auto-submit-input`
|
||||
|
||||
## Example usage:
|
||||
```html
|
||||
<input type='text' uw-auto-submit-input />
|
||||
```
|
||||
62
frontend/src/utils/form/datepicker.js
Normal file
62
frontend/src/utils/form/datepicker.js
Normal file
@ -0,0 +1,62 @@
|
||||
import flatpickr from 'flatpickr';
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
const DATEPICKER_UTIL_SELECTOR = 'input[type="date"], input[type="time"], input[type="datetime-local"]';
|
||||
|
||||
const DATEPICKER_INITIALIZED_CLASS = 'datepicker--initialized';
|
||||
|
||||
const 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,
|
||||
},
|
||||
};
|
||||
|
||||
@Utility({
|
||||
selector: DATEPICKER_UTIL_SELECTOR,
|
||||
})
|
||||
export class Datepicker {
|
||||
|
||||
flatpickrInstance;
|
||||
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
throw new Error('Datepicker utility needs to be passed an element!');
|
||||
}
|
||||
|
||||
if (element.classList.contains(DATEPICKER_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const flatpickrConfig = DATEPICKER_CONFIG[element.getAttribute('type')];
|
||||
|
||||
if (!flatpickrConfig) {
|
||||
throw new Error('Datepicker utility called on unsupported element!');
|
||||
}
|
||||
|
||||
this.flatpickrInstance = flatpickr(element, flatpickrConfig);
|
||||
|
||||
// mark initialized
|
||||
element.classList.add(DATEPICKER_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.flatpickrInstance.destroy();
|
||||
}
|
||||
}
|
||||
8
frontend/src/utils/form/datepicker.md
Normal file
8
frontend/src/utils/form/datepicker.md
Normal file
@ -0,0 +1,8 @@
|
||||
# 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)
|
||||
46
frontend/src/utils/form/form-error-remover.js
Normal file
46
frontend/src/utils/form/form-error-remover.js
Normal file
@ -0,0 +1,46 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
const FORM_ERROR_REMOVER_INITIALIZED_CLASS = 'form-error-remover--initialized';
|
||||
const FORM_ERROR_REMOVER_INPUTS_SELECTOR = 'input:not([type="hidden"]), textarea, select';
|
||||
|
||||
const FORM_GROUP_SELECTOR = '.form-group';
|
||||
const FORM_GROUP_WITH_ERRORS_CLASS = 'form-group--has-error';
|
||||
|
||||
@Utility({
|
||||
selector: 'form',
|
||||
})
|
||||
export class FormErrorRemover {
|
||||
|
||||
constructor(element) {
|
||||
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
|
||||
const formGroups = Array.from(element.querySelectorAll(FORM_GROUP_SELECTOR));
|
||||
|
||||
formGroups.forEach((formGroup) => {
|
||||
if (!formGroup.classList.contains(FORM_GROUP_WITH_ERRORS_CLASS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inputElements = Array.from(formGroup.querySelectorAll(FORM_ERROR_REMOVER_INPUTS_SELECTOR));
|
||||
if (!inputElements) {
|
||||
return false;
|
||||
}
|
||||
|
||||
inputElements.forEach((inputElement) => {
|
||||
inputElement.addEventListener('input', () => {
|
||||
formGroup.classList.remove(FORM_GROUP_WITH_ERRORS_CLASS);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// mark initialized
|
||||
element.classList.add(FORM_ERROR_REMOVER_INITIALIZED_CLASS);
|
||||
}
|
||||
}
|
||||
8
frontend/src/utils/form/form-error-remover.md
Normal file
8
frontend/src/utils/form/form-error-remover.md
Normal file
@ -0,0 +1,8 @@
|
||||
# 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)
|
||||
@ -1,606 +1,17 @@
|
||||
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);
|
||||
};
|
||||
}
|
||||
import { AutoSubmitButton } from './auto-submit-button';
|
||||
import { AutoSubmitInput } from './auto-submit-input';
|
||||
import { Datepicker } from './datepicker';
|
||||
import { FormErrorRemover } from './form-error-remover';
|
||||
import { InteractiveFieldset } from './interactive-fieldset';
|
||||
import { NavigateAwayPrompt } from './navigate-away-prompt';
|
||||
|
||||
export const FormUtils = [
|
||||
AutoSubmitButton,
|
||||
AutoSubmitInput,
|
||||
Datepicker,
|
||||
FormErrorRemover,
|
||||
InteractiveFieldset,
|
||||
NavigateAwayPrompt,
|
||||
// ReactiveSubmitButton // not used currently
|
||||
];
|
||||
|
||||
@ -14,7 +14,7 @@ fieldset {
|
||||
}
|
||||
}
|
||||
|
||||
[uw-auto-submit-button][type="submit"] {
|
||||
[uw-auto-submit-button][type='submit'] {
|
||||
animation: fade-in 500ms ease-in-out backwards;
|
||||
animation-delay: 500ms;
|
||||
}
|
||||
|
||||
97
frontend/src/utils/form/interactive-fieldset.js
Normal file
97
frontend/src/utils/form/interactive-fieldset.js
Normal file
@ -0,0 +1,97 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
const INTERACTIVE_FIELDSET_UTIL_TARGET_SELECTOR = '.interactive-fieldset__target';
|
||||
|
||||
const INTERACTIVE_FIELDSET_INITIALIZED_CLASS = 'interactive-fieldset--initialized';
|
||||
const INTERACTIVE_FIELDSET_CHILD_SELECTOR = 'input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled])';
|
||||
|
||||
@Utility({
|
||||
selector: '[uw-interactive-fieldset]',
|
||||
})
|
||||
export class InteractiveFieldset {
|
||||
|
||||
_element;
|
||||
|
||||
conditionalInput;
|
||||
conditionalValue;
|
||||
target;
|
||||
childInputs;
|
||||
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
throw new Error('Interactive Fieldset utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
|
||||
if (this._element.classList.contains(INTERACTIVE_FIELDSET_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// param conditionalInput
|
||||
if (!this._element.dataset.conditionalInput) {
|
||||
throw new Error('Interactive Fieldset needs a selector for a conditional input!');
|
||||
}
|
||||
this.conditionalInput = document.querySelector('#' + this._element.dataset.conditionalInput);
|
||||
if (!this.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 (!this._element.dataset.conditionalValue && !this._isCheckbox()) {
|
||||
throw new Error('Interactive Fieldset needs a conditional value!');
|
||||
}
|
||||
this.conditionalValue = this._element.dataset.conditionalValue;
|
||||
|
||||
this.target = this._element.closest(INTERACTIVE_FIELDSET_UTIL_TARGET_SELECTOR);
|
||||
if (!this.target || this._element.matches(INTERACTIVE_FIELDSET_UTIL_TARGET_SELECTOR)) {
|
||||
this.target = this._element;
|
||||
}
|
||||
|
||||
this.childInputs = Array.from(this._element.querySelectorAll(INTERACTIVE_FIELDSET_CHILD_SELECTOR));
|
||||
|
||||
// add event listener
|
||||
const observer = new MutationObserver(() => this._updateVisibility());
|
||||
observer.observe(this.conditionalInput, { attributes: true, attributeFilter: ['disabled'] });
|
||||
this.conditionalInput.addEventListener('input', () => this._updateVisibility());
|
||||
|
||||
// initial visibility update
|
||||
this._updateVisibility();
|
||||
|
||||
// mark as initialized
|
||||
this._element.classList.add(INTERACTIVE_FIELDSET_INITIALIZED_CLASS);
|
||||
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
_updateVisibility() {
|
||||
const active = this._matchesConditionalValue() && !this.conditionalInput.disabled;
|
||||
|
||||
this.target.classList.toggle('hidden', !active);
|
||||
|
||||
this.childInputs.forEach((el) => {
|
||||
el.disabled = !active;
|
||||
|
||||
// disable input for flatpickrs added input as well if exists
|
||||
if (el._flatpickr) {
|
||||
el._flatpickr.altInput.disabled = !active;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_matchesConditionalValue() {
|
||||
if (this._isCheckbox()) {
|
||||
return this.conditionalInput.checked === true;
|
||||
}
|
||||
|
||||
return this.conditionalInput.value === this.conditionalValue;
|
||||
}
|
||||
|
||||
_isCheckbox() {
|
||||
return this.conditionalInput.getAttribute('type') === 'checkbox';
|
||||
}
|
||||
}
|
||||
35
frontend/src/utils/form/interactive-fieldset.md
Normal file
35
frontend/src/utils/form/interactive-fieldset.md
Normal file
@ -0,0 +1,35 @@
|
||||
# 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
|
||||
```html
|
||||
<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>`
|
||||
```html
|
||||
<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
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
71
frontend/src/utils/form/navigate-away-prompt.js
Normal file
71
frontend/src/utils/form/navigate-away-prompt.js
Normal file
@ -0,0 +1,71 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import { AUTO_SUBMIT_BUTTON_UTIL_SELECTOR } from './auto-submit-button';
|
||||
import { AUTO_SUBMIT_INPUT_UTIL_SELECTOR } from './auto-submit-input';
|
||||
|
||||
const NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS = 'navigate-away-prompt--initialized';
|
||||
const NAVIGATE_AWAY_PROMPT_UTIL_OPTOUT = '[uw-no-navigate-away-prompt]';
|
||||
|
||||
@Utility({
|
||||
selector: 'form',
|
||||
})
|
||||
export class NavigateAwayPrompt {
|
||||
|
||||
_element;
|
||||
|
||||
_touched = false;
|
||||
_unloadDueToSubmit = false;
|
||||
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
throw new Error('Navigate Away Prompt utility needs to be passed an element!');
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
|
||||
if (this._element.classList.contains(NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ignore forms that get submitted automatically
|
||||
if (this._element.querySelector(AUTO_SUBMIT_BUTTON_UTIL_SELECTOR) || this._element.querySelector(AUTO_SUBMIT_INPUT_UTIL_SELECTOR)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._element.matches(NAVIGATE_AWAY_PROMPT_UTIL_OPTOUT)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', this._beforeUnloadHandler);
|
||||
|
||||
this._element.addEventListener('submit', () => {
|
||||
this._unloadDueToSubmit = true;
|
||||
});
|
||||
this._element.addEventListener('change', () => {
|
||||
this._touched = true;
|
||||
this._unloadDueToSubmit = false;
|
||||
});
|
||||
|
||||
// mark initialized
|
||||
this._element.classList.add(NAVIGATE_AWAY_PROMPT_INITIALIZED_CLASS);
|
||||
|
||||
}
|
||||
|
||||
destroy() {
|
||||
window.removeEventListener('beforeunload', this._beforeUnloadHandler);
|
||||
}
|
||||
|
||||
_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 (!this._touched || this._unloadDueToSubmit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// cancel the unload event. This is the standard to force the prompt to appear.
|
||||
event.preventDefault();
|
||||
// chrome
|
||||
event.returnValue = true;
|
||||
// for all non standard compliant browsers we return a truthy value to activate the prompt.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
12
frontend/src/utils/form/navigate-away-prompt.md
Normal file
12
frontend/src/utils/form/navigate-away-prompt.md
Normal file
@ -0,0 +1,12 @@
|
||||
# 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)
|
||||
85
frontend/src/utils/form/reactive-submit-button.js
Normal file
85
frontend/src/utils/form/reactive-submit-button.js
Normal file
@ -0,0 +1,85 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
var REACTIVE_SUBMIT_BUTTON_INITIALIZED_CLASS = 'reactive-submit-button--initialized';
|
||||
|
||||
@Utility({
|
||||
selector: 'form',
|
||||
})
|
||||
export class ReactiveSubmitButton {
|
||||
|
||||
_element;
|
||||
|
||||
_requiredInputs;
|
||||
_submitButton;
|
||||
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
throw new Error('Reactive Submit Button utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
|
||||
if (this._element.classList.contains(REACTIVE_SUBMIT_BUTTON_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// abort if form has param data-formnorequired
|
||||
if (this._element.dataset.formnorequired !== undefined) {
|
||||
throw new Error('Form has formnorequired data attribute. Will skip setup of reactive submit button.');
|
||||
}
|
||||
|
||||
this._requiredInputs = Array.from(this._element.querySelectorAll('[required]'));
|
||||
if (!this._requiredInputs) {
|
||||
// abort if form has no required inputs
|
||||
throw new Error('Submit button has formnorequired data attribute. Will skip setup of reactive submit button.');
|
||||
}
|
||||
|
||||
const submitButtons = Array.from(this._element.querySelectorAll('[type="submit"]'));
|
||||
if (!submitButtons || !submitButtons.length) {
|
||||
throw new Error('Reactive Submit Button utility couldn\'t find any submit buttons!');
|
||||
}
|
||||
this._submitButton = submitButtons.reverse()[0];
|
||||
// abort if form has param data-formnorequired
|
||||
if (this._submitButton.dataset.formnorequired !== undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.setupInputs();
|
||||
this.updateButtonState();
|
||||
|
||||
this._element.classList.add(REACTIVE_SUBMIT_BUTTON_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
setupInputs() {
|
||||
this._requiredInputs.forEach((el) => {
|
||||
var checkbox = el.getAttribute('type') === 'checkbox';
|
||||
var eventType = checkbox ? 'change' : 'input';
|
||||
el.addEventListener(eventType, () => {
|
||||
this.updateButtonState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateButtonState() {
|
||||
if (this.inputsValid()) {
|
||||
this._submitButton.removeAttribute('disabled');
|
||||
} else {
|
||||
this._submitButton.setAttribute('disabled', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
inputsValid() {
|
||||
var done = true;
|
||||
this._requiredInputs.forEach((inp) => {
|
||||
var len = inp.value.trim().length;
|
||||
if (done && len === 0) {
|
||||
done = false;
|
||||
}
|
||||
});
|
||||
return done;
|
||||
}
|
||||
}
|
||||
17
frontend/src/utils/form/reactive-submit-button.md
Normal file
17
frontend/src/utils/form/reactive-submit-button.md
Normal file
@ -0,0 +1,17 @@
|
||||
# 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:
|
||||
```html
|
||||
<form uw-reactive-submit-button>
|
||||
<input type='text' required>
|
||||
<button type='submit'>
|
||||
```
|
||||
43
frontend/src/utils/inputs/checkbox.js
Normal file
43
frontend/src/utils/inputs/checkbox.js
Normal file
@ -0,0 +1,43 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import './checkbox.scss';
|
||||
|
||||
var CHECKBOX_CLASS = 'checkbox';
|
||||
var CHECKBOX_INITIALIZED_CLASS = 'checkbox--initialized';
|
||||
|
||||
@Utility({
|
||||
selector: 'input[type="checkbox"]',
|
||||
})
|
||||
export class Checkbox {
|
||||
|
||||
constructor(element) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
10
frontend/src/utils/inputs/checkbox.md
Normal file
10
frontend/src/utils/inputs/checkbox.md
Normal file
@ -0,0 +1,10 @@
|
||||
# Checkbox Utility
|
||||
Wraps native checkbox
|
||||
|
||||
## Attribute: (none)
|
||||
(element must be an input of type='checkbox')
|
||||
|
||||
## Example usage:
|
||||
```html
|
||||
<input type='checkbox'>
|
||||
```
|
||||
@ -5,7 +5,7 @@
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
[type="checkbox"] {
|
||||
[type='checkbox'] {
|
||||
position: fixed;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
@ -41,7 +41,7 @@
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
[type="checkbox"]:focus + label {
|
||||
[type='checkbox']:focus + label {
|
||||
border-color: #3273dc;
|
||||
box-shadow: 0 0 0 0.125em rgba(50,115,220,.25);
|
||||
outline: 0;
|
||||
|
||||
99
frontend/src/utils/inputs/file-input.js
Normal file
99
frontend/src/utils/inputs/file-input.js
Normal file
@ -0,0 +1,99 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
const FILE_INPUT_CLASS = 'file-input';
|
||||
const FILE_INPUT_INITIALIZED_CLASS = 'file-input--initialized';
|
||||
const FILE_INPUT_LIST_CLASS = 'file-input__list';
|
||||
const FILE_INPUT_UNPACK_CHECKBOX_CLASS = 'file-input__unpack';
|
||||
const FILE_INPUT_LABEL_CLASS = 'file-input__label';
|
||||
|
||||
@Utility({
|
||||
selector: 'input[type="file"][uw-file-input]',
|
||||
})
|
||||
export class FileInput {
|
||||
|
||||
_element;
|
||||
_app;
|
||||
|
||||
_isMultiFileInput = false;
|
||||
_fileList;
|
||||
_label;
|
||||
|
||||
constructor(element, app) {
|
||||
if (!element) {
|
||||
throw new Error('FileInput utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
this._element = element;
|
||||
this._app = app;
|
||||
|
||||
if (this._element.classList.contains(FILE_INPUT_INITIALIZED_CLASS)) {
|
||||
throw new Error('FileInput utility already initialized!');
|
||||
}
|
||||
|
||||
// check if is multi-file input
|
||||
this._isMultiFileInput = this._element.hasAttribute('multiple');
|
||||
if (this._isMultiFileInput) {
|
||||
this._fileList = this._createFileList();
|
||||
}
|
||||
|
||||
this._label = this._createFileLabel();
|
||||
this._updateLabel();
|
||||
|
||||
// add change listener
|
||||
this._element.addEventListener('change', () => {
|
||||
this._updateLabel();
|
||||
this._renderFileList();
|
||||
});
|
||||
|
||||
// add util class for styling and mark as initialized
|
||||
this._element.classList.add(FILE_INPUT_CLASS);
|
||||
this._element.classList.add(FILE_INPUT_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
_renderFileList() {
|
||||
if (!this._fileList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = this._element.files;
|
||||
this._fileList.innerHTML = '';
|
||||
Array.from(files).forEach((file) => {
|
||||
const fileDisplayEl = document.createElement('li');
|
||||
fileDisplayEl.innerHTML = file.name;
|
||||
this._fileList.appendChild(fileDisplayEl);
|
||||
});
|
||||
}
|
||||
|
||||
_createFileList() {
|
||||
const list = document.createElement('ol');
|
||||
list.classList.add(FILE_INPUT_LIST_CLASS);
|
||||
const unpackEl = this._element.parentElement.querySelector('.' + FILE_INPUT_UNPACK_CHECKBOX_CLASS);
|
||||
if (unpackEl) {
|
||||
this._element.parentElement.insertBefore(list, unpackEl);
|
||||
} else {
|
||||
this._element.parentElement.appendChild(list);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
_createFileLabel() {
|
||||
const label = document.createElement('label');
|
||||
label.classList.add(FILE_INPUT_LABEL_CLASS);
|
||||
label.setAttribute('for', this._element.id);
|
||||
this._element.parentElement.insertBefore(label, this._element);
|
||||
return label;
|
||||
}
|
||||
|
||||
_updateLabel() {
|
||||
const files = this._element.files;
|
||||
if (files && files.length) {
|
||||
this._label.innerText = this._isMultiFileInput ? files.length + ' ' + this._app.i18n.get('filesSelected') : files[0].name;
|
||||
} else {
|
||||
this._label.innerText = this._isMultiFileInput ? this._app.i18n.get('selectFiles') : this._app.i18n.get('selectFile');
|
||||
}
|
||||
}
|
||||
}
|
||||
23
frontend/src/utils/inputs/file-input.md
Normal file
23
frontend/src/utils/inputs/file-input.md
Normal file
@ -0,0 +1,23 @@
|
||||
# FileInput Utility
|
||||
Wraps native file input
|
||||
|
||||
## Attribute: `uw-file-input`
|
||||
(element must be an input of type='file')
|
||||
|
||||
## Example usage:
|
||||
```html
|
||||
<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'
|
||||
|
||||
@ -1,194 +1,10 @@
|
||||
import './checkbox.scss';
|
||||
import './radio.scss';
|
||||
import { Checkbox } from './checkbox';
|
||||
import { FileInput } from './file-input';
|
||||
|
||||
import './inputs.scss';
|
||||
import './radio.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,
|
||||
};
|
||||
export const InputUtils = [
|
||||
Checkbox,
|
||||
FileInput,
|
||||
];
|
||||
|
||||
@ -85,14 +85,14 @@
|
||||
}
|
||||
|
||||
/* TEXT INPUTS */
|
||||
input[type="text"],
|
||||
input[type="search"],
|
||||
input[type="password"],
|
||||
input[type="url"],
|
||||
input[type="number"],
|
||||
input[type="email"],
|
||||
input[type*="date"],
|
||||
input[type*="time"],
|
||||
input[type='text'],
|
||||
input[type='search'],
|
||||
input[type='password'],
|
||||
input[type='url'],
|
||||
input[type='number'],
|
||||
input[type='email'],
|
||||
input[type*='date'],
|
||||
input[type*='time'],
|
||||
select {
|
||||
/* from bulma.css */
|
||||
color: #363636;
|
||||
@ -111,13 +111,13 @@ select {
|
||||
padding: 4px 13px;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
input[type='number'] {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
input[type*="date"],
|
||||
input[type*="time"],
|
||||
.flatpickr-input[type="text"] {
|
||||
input[type*='date'],
|
||||
input[type*='time'],
|
||||
.flatpickr-input[type='text'] {
|
||||
width: 50%;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
[type="radio"] {
|
||||
[type='radio'] {
|
||||
position: fixed;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
|
||||
@ -1,176 +1,163 @@
|
||||
/**
|
||||
*
|
||||
* 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">
|
||||
*/
|
||||
import { Utility } from '../../core/utility';
|
||||
|
||||
var MASS_INPUT_UTIL_NAME = 'massInput';
|
||||
var MASS_INPUT_UTIL_SELECTOR = '[uw-mass-input]';
|
||||
const MASS_INPUT_CELL_SELECTOR = '.massinput__cell';
|
||||
const MASS_INPUT_ADD_CELL_SELECTOR = '.massinput__cell--add';
|
||||
const MASS_INPUT_SUBMIT_BUTTON_CLASS = 'massinput__submit-button';
|
||||
const MASS_INPUT_INITIALIZED_CLASS = 'mass-input--initialized';
|
||||
|
||||
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';
|
||||
@Utility({
|
||||
selector: '[uw-mass-input]',
|
||||
})
|
||||
export class MassInput {
|
||||
|
||||
var massInputUtil = function(element, app) {
|
||||
var massInputId;
|
||||
var massInputFormSubmitHandler;
|
||||
var massInputForm;
|
||||
_element;
|
||||
_app;
|
||||
|
||||
function init() {
|
||||
_massInputId;
|
||||
_massInputFormSubmitHandler;
|
||||
_massInputForm;
|
||||
|
||||
constructor(element, app) {
|
||||
if (!element) {
|
||||
throw new Error('Mass Input utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
if (element.classList.contains(MASS_INPUT_INITIALIZED_CLASS)) {
|
||||
this._element = element;
|
||||
this._app = app;
|
||||
|
||||
if (this._element.classList.contains(MASS_INPUT_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
massInputId = element.dataset.massInputIdent || '_';
|
||||
massInputForm = element.closest('form');
|
||||
this._massInputId = this._element.dataset.massInputIdent || '_';
|
||||
this._massInputForm = this._element.closest('form');
|
||||
|
||||
if (!massInputForm) {
|
||||
if (!this._massInputForm) {
|
||||
throw new Error('Mass Input utility cannot be setup without being wrapped in a <form>!');
|
||||
}
|
||||
|
||||
massInputFormSubmitHandler = makeSubmitHandler();
|
||||
this._massInputFormSubmitHandler = this._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);
|
||||
const buttons = this._getMassInputSubmitButtons();
|
||||
buttons.forEach((button) => {
|
||||
this._setupSubmitButton(button);
|
||||
});
|
||||
|
||||
massInputForm.addEventListener('submit', massInputFormSubmitHandler);
|
||||
massInputForm.addEventListener('keypress', keypressHandler);
|
||||
this._massInputForm.addEventListener('submit', this._massInputFormSubmitHandler);
|
||||
this._massInputForm.addEventListener('keypress', this._keypressHandler);
|
||||
|
||||
// mark initialized
|
||||
element.classList.add(MASS_INPUT_INITIALIZED_CLASS);
|
||||
|
||||
return {
|
||||
name: MASS_INPUT_UTIL_NAME,
|
||||
element: element,
|
||||
destroy: function() {
|
||||
reset();
|
||||
},
|
||||
};
|
||||
this._element.classList.add(MASS_INPUT_INITIALIZED_CLASS);
|
||||
}
|
||||
|
||||
function makeSubmitHandler() {
|
||||
var method = massInputForm.getAttribute('method') || 'POST';
|
||||
var url = massInputForm.getAttribute('action') || window.location.href;
|
||||
var enctype = massInputForm.getAttribute('enctype') || 'application/json';
|
||||
destroy() {
|
||||
this._reset();
|
||||
}
|
||||
|
||||
var requestFn;
|
||||
if (app.httpClient[method.toLowerCase()]) {
|
||||
requestFn = app.httpClient[method.toLowerCase()].bind(app.httpClient);
|
||||
_makeSubmitHandler() {
|
||||
const method = this._massInputForm.getAttribute('method') || 'POST';
|
||||
const url = this._massInputForm.getAttribute('action') || window.location.href;
|
||||
const enctype = this._massInputForm.getAttribute('enctype') || 'application/json';
|
||||
|
||||
let requestFn;
|
||||
if (this._app.httpClient[method.toLowerCase()]) {
|
||||
requestFn = this._app.httpClient[method.toLowerCase()].bind(this._app.httpClient);
|
||||
}
|
||||
|
||||
return function(event) {
|
||||
var activeElement;
|
||||
return (event) => {
|
||||
let 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');
|
||||
activeElement = this._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);
|
||||
const massInputCell = activeElement.closest(MASS_INPUT_CELL_SELECTOR);
|
||||
if (!massInputCell) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var submitButton = massInputCell.querySelector('.' + MASS_INPUT_SUBMIT_BUTTON_CLASS);
|
||||
const 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');
|
||||
const isAddCell = massInputCell.matches(MASS_INPUT_ADD_CELL_SELECTOR);
|
||||
const 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);
|
||||
const requestBody = this._serializeForm(submitButton, enctype);
|
||||
|
||||
if (requestFn && requestBody) {
|
||||
var headers = {'Mass-Input-Shortcircuit': massInputId};
|
||||
const headers = {'Mass-Input-Shortcircuit': this._massInputId};
|
||||
|
||||
if (enctype !== 'multipart/form-data')
|
||||
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);
|
||||
}).then((response) => {
|
||||
return this._app.htmlHelpers.parseResponse(response);
|
||||
}).then((response) => {
|
||||
this._processResponse(response.element);
|
||||
if (isAddCell) {
|
||||
reFocusAddCell();
|
||||
this._reFocusAddCell();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function keypressHandler(event) {
|
||||
_keypressHandler = (event) => {
|
||||
if (event.keyCode !== 13) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (massInputFormSubmitHandler) {
|
||||
return massInputFormSubmitHandler(event);
|
||||
if (this._massInputFormSubmitHandler) {
|
||||
return this._massInputFormSubmitHandler(event);
|
||||
}
|
||||
}
|
||||
|
||||
function getMassInputSubmitButtons() {
|
||||
return Array.from(element.querySelectorAll('button[type="submit"][name][value], .' + MASS_INPUT_SUBMIT_BUTTON_CLASS));
|
||||
_getMassInputSubmitButtons() {
|
||||
return Array.from(this._element.querySelectorAll('button[type="submit"][name][value], .' + MASS_INPUT_SUBMIT_BUTTON_CLASS));
|
||||
}
|
||||
|
||||
function setupSubmitButton(button) {
|
||||
_setupSubmitButton(button) {
|
||||
button.setAttribute('type', 'button');
|
||||
button.classList.add(MASS_INPUT_SUBMIT_BUTTON_CLASS);
|
||||
button.addEventListener('click', massInputFormSubmitHandler);
|
||||
button.addEventListener('click', this._massInputFormSubmitHandler);
|
||||
}
|
||||
|
||||
function resetSubmitButton(button) {
|
||||
_resetSubmitButton(button) {
|
||||
button.setAttribute('type', 'submit');
|
||||
button.classList.remove(MASS_INPUT_SUBMIT_BUTTON_CLASS);
|
||||
button.removeEventListener('click', massInputFormSubmitHandler);
|
||||
button.removeEventListener('click', this._massInputFormSubmitHandler);
|
||||
}
|
||||
|
||||
function processResponse(responseElement) {
|
||||
element.innerHTML = "";
|
||||
element.appendChild(responseElement);
|
||||
_processResponse(responseElement) {
|
||||
this._element.innerHTML = '';
|
||||
this._element.appendChild(responseElement);
|
||||
|
||||
reset();
|
||||
this._reset();
|
||||
|
||||
app.utilRegistry.setupAll(element);
|
||||
this._app.utilRegistry.setupAll(this._element);
|
||||
}
|
||||
|
||||
function serializeForm(submitButton, enctype) {
|
||||
var formData = new FormData(massInputForm);
|
||||
_serializeForm(submitButton, enctype) {
|
||||
const formData = new FormData(this._massInputForm);
|
||||
|
||||
// manually add name and value of submit button to formData
|
||||
formData.append(submitButton.name, submitButton.value);
|
||||
@ -184,35 +171,27 @@ var massInputUtil = function(element, app) {
|
||||
}
|
||||
}
|
||||
|
||||
function reFocusAddCell() {
|
||||
var addCell = element.querySelector(MASS_INPUT_ADD_CELL_SELECTOR);
|
||||
_reFocusAddCell() {
|
||||
const addCell = this._element.querySelector(MASS_INPUT_ADD_CELL_SELECTOR);
|
||||
if (!addCell) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var addCellInput = addCell.querySelector('input:not([type="hidden"])');
|
||||
const 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);
|
||||
_reset() {
|
||||
this._element.classList.remove(MASS_INPUT_INITIALIZED_CLASS);
|
||||
this._massInputForm.removeEventListener('submit', this._massInputFormSubmitHandler);
|
||||
this._massInputForm.removeEventListener('keypress', this._keypressHandler);
|
||||
|
||||
var buttons = getMassInputSubmitButtons();
|
||||
buttons.forEach(function(button) {
|
||||
resetSubmitButton(button);
|
||||
const buttons = this._getMassInputSubmitButtons();
|
||||
buttons.forEach((button) => {
|
||||
this._resetSubmitButton(button);
|
||||
});
|
||||
}
|
||||
|
||||
return init();
|
||||
};
|
||||
|
||||
export default {
|
||||
name: MASS_INPUT_UTIL_NAME,
|
||||
selector: MASS_INPUT_UTIL_SELECTOR,
|
||||
setup: massInputUtil,
|
||||
};
|
||||
}
|
||||
|
||||
17
frontend/src/utils/mass-input/mass-input.md
Normal file
17
frontend/src/utils/mass-input/mass-input.md
Normal file
@ -0,0 +1,17 @@
|
||||
# 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:
|
||||
```html
|
||||
<form method='POST' action='...'>
|
||||
<input type='text'>
|
||||
<div uw-mass-input>
|
||||
<input type='text'>
|
||||
<button type='submit'>
|
||||
```
|
||||
@ -1,203 +1,182 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
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 = {
|
||||
const 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';
|
||||
const MODAL_INITIALIZED_CLASS = 'modal--initialized';
|
||||
const MODAL_CLASS = 'modal';
|
||||
const MODAL_OPEN_CLASS = 'modal--open';
|
||||
const MODAL_TRIGGER_CLASS = 'modal__trigger';
|
||||
const MODAL_CONTENT_CLASS = 'modal__content';
|
||||
const MODAL_OVERLAY_CLASS = 'modal__overlay';
|
||||
const MODAL_OVERLAY_OPEN_CLASS = 'modal__overlay--open';
|
||||
const MODAL_CLOSER_CLASS = 'modal__closer';
|
||||
|
||||
var MAIN_CONTENT_CLASS = 'main__content-body';
|
||||
const 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';
|
||||
const MODALS_WRAPPER_CLASS = 'modals-wrapper';
|
||||
const MODALS_WRAPPER_SELECTOR = '.' + MODALS_WRAPPER_CLASS;
|
||||
const MODALS_WRAPPER_OPEN_CLASS = 'modals-wrapper--open';
|
||||
|
||||
var modalUtil = function(element, app) {
|
||||
@Utility({
|
||||
selector: '[uw-modal]',
|
||||
})
|
||||
export class Modal {
|
||||
|
||||
var modalsWrapper;
|
||||
var modalOverlay;
|
||||
var modalUrl;
|
||||
_element;
|
||||
_app;
|
||||
|
||||
function _init() {
|
||||
_modalsWrapper;
|
||||
_modalOverlay;
|
||||
_modalUrl;
|
||||
|
||||
constructor(element, app) {
|
||||
if (!element) {
|
||||
throw new Error('Modal utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
if (element.classList.contains(MODAL_INITIALIZED_CLASS)) {
|
||||
this._element = element;
|
||||
this._app = app;
|
||||
|
||||
if (this._element.classList.contains(MODAL_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ensureModalWrapper();
|
||||
this._ensureModalWrapper();
|
||||
|
||||
// param modalTrigger
|
||||
if (!element.dataset.modalTrigger) {
|
||||
if (!this._element.dataset.modalTrigger) {
|
||||
throw new Error('Modal utility cannot be setup without a trigger element!');
|
||||
} else {
|
||||
setupTrigger();
|
||||
this._setupTrigger();
|
||||
}
|
||||
|
||||
// param modalCloseable
|
||||
if (element.dataset.modalCloseable !== undefined) {
|
||||
setupCloser();
|
||||
if (this._element.dataset.modalCloseable !== undefined) {
|
||||
this._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() {},
|
||||
};
|
||||
this._element.classList.add(MODAL_INITIALIZED_CLASS, MODAL_CLASS);
|
||||
}
|
||||
|
||||
function ensureModalWrapper() {
|
||||
modalsWrapper = document.querySelector(MODALS_WRAPPER_SELECTOR);
|
||||
if (!modalsWrapper) {
|
||||
destroy() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
_ensureModalWrapper() {
|
||||
this._modalsWrapper = document.querySelector(MODALS_WRAPPER_SELECTOR);
|
||||
if (!this._modalsWrapper) {
|
||||
// create modal wrapper
|
||||
modalsWrapper = document.createElement('div');
|
||||
modalsWrapper.classList.add(MODALS_WRAPPER_CLASS);
|
||||
document.body.appendChild(modalsWrapper);
|
||||
this._modalsWrapper = document.createElement('div');
|
||||
this._modalsWrapper.classList.add(MODALS_WRAPPER_CLASS);
|
||||
document.body.appendChild(this._modalsWrapper);
|
||||
}
|
||||
|
||||
modalOverlay = modalsWrapper.querySelector('.' + MODAL_OVERLAY_CLASS);
|
||||
if (!modalOverlay) {
|
||||
this._modalOverlay = this._modalsWrapper.querySelector('.' + MODAL_OVERLAY_CLASS);
|
||||
if (!this._modalOverlay) {
|
||||
// create modal overlay
|
||||
modalOverlay = document.createElement('div');
|
||||
modalOverlay.classList.add(MODAL_OVERLAY_CLASS);
|
||||
modalsWrapper.appendChild(modalOverlay);
|
||||
this._modalOverlay = document.createElement('div');
|
||||
this._modalOverlay.classList.add(MODAL_OVERLAY_CLASS);
|
||||
this._modalsWrapper.appendChild(this._modalOverlay);
|
||||
}
|
||||
}
|
||||
|
||||
function setupTrigger() {
|
||||
var triggerSelector = element.dataset.modalTrigger;
|
||||
_setupTrigger() {
|
||||
let triggerSelector = this._element.dataset.modalTrigger;
|
||||
if (!triggerSelector.startsWith('#')) {
|
||||
triggerSelector = '#' + triggerSelector;
|
||||
}
|
||||
var triggerElement = document.querySelector(triggerSelector);
|
||||
const 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');
|
||||
triggerElement.addEventListener('click', this._onTriggerClicked, false);
|
||||
this._modalUrl = triggerElement.getAttribute('href');
|
||||
}
|
||||
|
||||
function setupCloser() {
|
||||
var closerElement = document.createElement('div');
|
||||
element.insertBefore(closerElement, null);
|
||||
_setupCloser() {
|
||||
const closerElement = document.createElement('div');
|
||||
this._element.insertBefore(closerElement, null);
|
||||
closerElement.classList.add(MODAL_CLOSER_CLASS);
|
||||
closerElement.addEventListener('click', onCloseClicked, false);
|
||||
modalOverlay.addEventListener('click', onCloseClicked, false);
|
||||
closerElement.addEventListener('click', this._onCloseClicked, false);
|
||||
this._modalOverlay.addEventListener('click', this._onCloseClicked, false);
|
||||
}
|
||||
|
||||
function onTriggerClicked(event) {
|
||||
_onTriggerClicked = (event) => {
|
||||
event.preventDefault();
|
||||
open();
|
||||
this._open();
|
||||
}
|
||||
|
||||
function onCloseClicked(event) {
|
||||
_onCloseClicked = (event) => {
|
||||
event.preventDefault();
|
||||
close();
|
||||
this._close();
|
||||
}
|
||||
|
||||
function onKeyUp(event) {
|
||||
_onKeyUp = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
close();
|
||||
this._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);
|
||||
_open() {
|
||||
this._element.classList.add(MODAL_OPEN_CLASS);
|
||||
this._modalOverlay.classList.add(MODAL_OVERLAY_OPEN_CLASS);
|
||||
this._modalsWrapper.classList.add(MODALS_WRAPPER_OPEN_CLASS);
|
||||
this._modalsWrapper.appendChild(this._element);
|
||||
|
||||
if (modalUrl) {
|
||||
fillModal(modalUrl);
|
||||
if (this._modalUrl) {
|
||||
this._fillModal(this._modalUrl);
|
||||
}
|
||||
|
||||
document.addEventListener('keyup', onKeyUp);
|
||||
document.addEventListener('keyup', this._onKeyUp);
|
||||
}
|
||||
|
||||
function close() {
|
||||
modalOverlay.classList.remove(MODAL_OVERLAY_OPEN_CLASS);
|
||||
element.classList.remove(MODAL_OPEN_CLASS);
|
||||
modalsWrapper.classList.remove(MODALS_WRAPPER_OPEN_CLASS);
|
||||
_close() {
|
||||
this._modalOverlay.classList.remove(MODAL_OVERLAY_OPEN_CLASS);
|
||||
this._element.classList.remove(MODAL_OPEN_CLASS);
|
||||
this._modalsWrapper.classList.remove(MODALS_WRAPPER_OPEN_CLASS);
|
||||
|
||||
document.removeEventListener('keyup', onKeyUp);
|
||||
document.removeEventListener('keyup', this._onKeyUp);
|
||||
};
|
||||
|
||||
function fillModal(url) {
|
||||
app.httpClient.get({
|
||||
_fillModal(url) {
|
||||
this._app.httpClient.get({
|
||||
url: url,
|
||||
headers: MODAL_HEADERS,
|
||||
}).then(function(response) {
|
||||
return app.htmlHelpers.parseResponse(response);
|
||||
}).then(function(response) {
|
||||
processResponse(response.element);
|
||||
});
|
||||
}).then(
|
||||
(response) => this._app.htmlHelpers.parseResponse(response)
|
||||
).then(
|
||||
(response) => this._processResponse(response.element)
|
||||
);
|
||||
}
|
||||
|
||||
function processResponse(responseElement) {
|
||||
var modalContent = document.createElement('div');
|
||||
_processResponse(responseElement) {
|
||||
const modalContent = document.createElement('div');
|
||||
modalContent.classList.add(MODAL_CONTENT_CLASS);
|
||||
|
||||
var contentBody = responseElement.querySelector('.' + MAIN_CONTENT_CLASS);
|
||||
const contentBody = responseElement.querySelector('.' + MAIN_CONTENT_CLASS);
|
||||
|
||||
if (contentBody) {
|
||||
modalContent.innerHTML = contentBody.innerHTML;
|
||||
}
|
||||
|
||||
var previousModalContent = element.querySelector('.' + MODAL_CONTENT_CLASS);
|
||||
const previousModalContent = this._element.querySelector('.' + MODAL_CONTENT_CLASS);
|
||||
if (previousModalContent) {
|
||||
previousModalContent.remove();
|
||||
}
|
||||
|
||||
element.insertBefore(modalContent, null);
|
||||
this._element.insertBefore(modalContent, null);
|
||||
|
||||
// setup any newly arrived utils
|
||||
app.utilRegistry.setupAll(element);
|
||||
this._app.utilRegistry.setupAll(this._element);
|
||||
}
|
||||
|
||||
return _init();
|
||||
};
|
||||
|
||||
export default {
|
||||
name: MODAL_UTIL_NAME,
|
||||
selector: MODAL_UTIL_SELECTOR,
|
||||
setup: modalUtil,
|
||||
};
|
||||
}
|
||||
|
||||
16
frontend/src/utils/modal/modal.md
Normal file
16
frontend/src/utils/modal/modal.md
Normal file
@ -0,0 +1,16 @@
|
||||
# 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`\
|
||||
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:
|
||||
```html
|
||||
<div uw-modal data-modal-trigger='#trigger' data-modal-closeable>This is the modal content
|
||||
<div id='trigger'>Click me to open the modal
|
||||
```
|
||||
@ -1,109 +1,87 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import './show-hide.scss';
|
||||
|
||||
var SHOW_HIDE_UTIL_NAME = 'showHide';
|
||||
var SHOW_HIDE_UTIL_SELECTOR = '[uw-show-hide]';
|
||||
const SHOW_HIDE_LOCAL_STORAGE_KEY = 'SHOW_HIDE';
|
||||
const SHOW_HIDE_INITIALIZED_CLASS = 'show-hide--initialized';
|
||||
const SHOW_HIDE_COLLAPSED_CLASS = 'show-hide--collapsed';
|
||||
const SHOW_HIDE_TOGGLE_CLASS = 'show-hide__toggle';
|
||||
const SHOW_HIDE_TOGGLE_RIGHT_CLASS = 'show-hide__toggle--right';
|
||||
|
||||
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';
|
||||
@Utility({
|
||||
selector: '[uw-show-hide]',
|
||||
})
|
||||
export class ShowHide {
|
||||
|
||||
/**
|
||||
*
|
||||
* 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) {
|
||||
_showHideId;
|
||||
_element;
|
||||
|
||||
var showHideId;
|
||||
|
||||
function init() {
|
||||
constructor(element) {
|
||||
if (!element) {
|
||||
throw new Error('ShowHide utility cannot be setup without an element!');
|
||||
}
|
||||
|
||||
if (element.classList.contains(SHOW_HIDE_INITIALIZED_CLASS)) {
|
||||
this._element = element;
|
||||
|
||||
if (this._element.classList.contains(SHOW_HIDE_INITIALIZED_CLASS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// register click listener
|
||||
addClickListener();
|
||||
this._addClickListener();
|
||||
|
||||
// param showHideId
|
||||
if (element.dataset.showHideId) {
|
||||
showHideId = element.dataset.showHideId;
|
||||
if (this._element.dataset.showHideId) {
|
||||
this._showHideId = this._element.dataset.showHideId;
|
||||
}
|
||||
|
||||
// param showHideCollapsed
|
||||
var collapsed = false;
|
||||
if (element.dataset.showHideCollapsed !== undefined) {
|
||||
let collapsed = false;
|
||||
if (this._element.dataset.showHideCollapsed !== undefined) {
|
||||
collapsed = true;
|
||||
}
|
||||
if (showHideId) {
|
||||
var localStorageCollapsed = getLocalStorage()[showHideId];
|
||||
|
||||
if (this._showHideId) {
|
||||
let localStorageCollapsed = this._getLocalStorage()[this._showHideId];
|
||||
if (typeof localStorageCollapsed !== 'undefined') {
|
||||
collapsed = localStorageCollapsed;
|
||||
}
|
||||
}
|
||||
element.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS, collapsed);
|
||||
this._element.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS, collapsed);
|
||||
|
||||
// param showHideAlign
|
||||
var alignment = element.dataset.showHideAlign;
|
||||
const alignment = this._element.dataset.showHideAlign;
|
||||
if (alignment === 'right') {
|
||||
element.classList.add(SHOW_HIDE_TOGGLE_RIGHT_CLASS);
|
||||
this._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() {},
|
||||
};
|
||||
this._element.classList.add(SHOW_HIDE_INITIALIZED_CLASS, SHOW_HIDE_TOGGLE_CLASS);
|
||||
}
|
||||
|
||||
function addClickListener() {
|
||||
element.addEventListener('click', function clickListener() {
|
||||
var newState = element.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS);
|
||||
|
||||
if (showHideId) {
|
||||
setLocalStorage(showHideId, newState);
|
||||
}
|
||||
});
|
||||
destroy() {
|
||||
this._element.removeEventListener('click', this._clickHandler);
|
||||
}
|
||||
|
||||
function setLocalStorage(id, state) {
|
||||
var lsData = getLocalStorage();
|
||||
_addClickListener() {
|
||||
this._element.addEventListener('click', this._clickHandler);
|
||||
}
|
||||
|
||||
_clickHandler = () => {
|
||||
const newState = this._element.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS);
|
||||
|
||||
if (this._showHideId) {
|
||||
this._setLocalStorage(this._showHideId, newState);
|
||||
}
|
||||
}
|
||||
|
||||
// maybe move these to a LocalStorageHelper?
|
||||
_setLocalStorage(id, state) {
|
||||
const lsData = this._getLocalStorage();
|
||||
lsData[id] = state;
|
||||
window.localStorage.setItem(SHOW_HIDE_LOCAL_STORAGE_KEY, JSON.stringify(lsData));
|
||||
}
|
||||
|
||||
function getLocalStorage() {
|
||||
_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,
|
||||
};
|
||||
}
|
||||
|
||||
21
frontend/src/utils/show-hide/show-hide.md
Normal file
21
frontend/src/utils/show-hide/show-hide.md
Normal file
@ -0,0 +1,21 @@
|
||||
# ShowHide
|
||||
|
||||
Allows to toggle the visibilty of an element by clicking another element.
|
||||
|
||||
## Attribute: `uw-show-hide`
|
||||
|
||||
## Params:
|
||||
- `data-show-hide-id: string` (optional)\
|
||||
If this param is given the state of the utility will be persisted in the clients local storage.
|
||||
- `data-show-hide-collapsed: boolean` (optional)\
|
||||
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'` (optional)\
|
||||
Where to put the arrow that marks the element as a ShowHide toggle. Left of toggle by default.
|
||||
|
||||
## Example usage:
|
||||
```html
|
||||
<div>
|
||||
<div uw-show-hide>Click me
|
||||
<div>This will be toggled
|
||||
<div>This will be toggled as well
|
||||
```
|
||||
@ -1,7 +1,10 @@
|
||||
import { Utility } from '../../core/utility';
|
||||
import './tooltips.scss';
|
||||
|
||||
export default {
|
||||
name: 'tooltips',
|
||||
// empty 'shell' to be able to load styles
|
||||
@Utility({
|
||||
selector: '[not-something-that-would-be-found]',
|
||||
setup: () => {},
|
||||
})
|
||||
export class Tooltip {
|
||||
destroy() {}
|
||||
};
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
@ -1,38 +1,26 @@
|
||||
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';
|
||||
import { Alerts } from './alerts/alerts';
|
||||
import { Asidenav } from './asidenav/asidenav';
|
||||
import { AsyncForm } from './async-form/async-form';
|
||||
import { ShowHide } from './show-hide/show-hide';
|
||||
import { AsyncTable } from './async-table/async-table';
|
||||
import { CheckAll } from './check-all/check-all';
|
||||
import { FormUtils } from './form/form';
|
||||
import { InputUtils } from './inputs/inputs';
|
||||
import { MassInput } from './mass-input/mass-input';
|
||||
import { Modal } from './modal/modal';
|
||||
import { Tooltip } from './tooltips/tooltips';
|
||||
|
||||
export const Utils = [
|
||||
alerts,
|
||||
asidenav,
|
||||
asyncForm,
|
||||
asyncTable,
|
||||
checkAll,
|
||||
massInput,
|
||||
fileInput,
|
||||
checkbox,
|
||||
modal,
|
||||
showHide,
|
||||
interactiveFieldset,
|
||||
navigateAwayPrompt,
|
||||
autoSubmitButton,
|
||||
autoSubmitInput,
|
||||
formErrorRemover,
|
||||
datepicker,
|
||||
tooltips,
|
||||
Alerts,
|
||||
Asidenav,
|
||||
AsyncForm,
|
||||
AsyncTable,
|
||||
CheckAll,
|
||||
ShowHide,
|
||||
...FormUtils,
|
||||
...InputUtils,
|
||||
MassInput,
|
||||
Modal,
|
||||
ShowHide,
|
||||
Tooltip,
|
||||
];
|
||||
|
||||
4
frontend/vendor/flatpickr.css
vendored
4
frontend/vendor/flatpickr.css
vendored
@ -254,7 +254,7 @@
|
||||
}
|
||||
.numInputWrapper span:after {
|
||||
display: block;
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
}
|
||||
.numInputWrapper span.arrowUp {
|
||||
@ -628,7 +628,7 @@ span.flatpickr-weekday {
|
||||
display: flex;
|
||||
}
|
||||
.flatpickr-time:after {
|
||||
content: "";
|
||||
content: '';
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
2
frontend/vendor/fontawesome.css
vendored
2
frontend/vendor/fontawesome.css
vendored
File diff suppressed because one or more lines are too long
43
package-lock.json
generated
43
package-lock.json
generated
@ -338,6 +338,17 @@
|
||||
"@babel/helper-plugin-utils": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-proposal-decorators": {
|
||||
"version": "7.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.4.4.tgz",
|
||||
"integrity": "sha512-z7MpQz3XC/iQJWXH9y+MaWcLPNSMY9RQSthrLzak8R8hCj0fuyNk+Dzi9kfNe/JxxlWQ2g7wkABbgWjW36MTcw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-create-class-features-plugin": "^7.4.4",
|
||||
"@babel/helper-plugin-utils": "^7.0.0",
|
||||
"@babel/plugin-syntax-decorators": "^7.2.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-proposal-json-strings": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.2.0.tgz",
|
||||
@ -388,6 +399,15 @@
|
||||
"@babel/helper-plugin-utils": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-syntax-decorators": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.2.0.tgz",
|
||||
"integrity": "sha512-38QdqVoXdHUQfTpZo3rQwqQdWtCn5tMv4uV6r2RMfTqNBuv4ZBhz79SfaQWKTVmxHjeFv/DnXVC/+agHCklYWA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-plugin-utils": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-syntax-json-strings": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.2.0.tgz",
|
||||
@ -1655,12 +1675,29 @@
|
||||
"babel-runtime": "^6.22.0"
|
||||
}
|
||||
},
|
||||
"babel-plugin-syntax-decorators": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-decorators/-/babel-plugin-syntax-decorators-6.13.0.tgz",
|
||||
"integrity": "sha1-MSVjtNvePMgGzuPkFszurd0RrAs=",
|
||||
"dev": true
|
||||
},
|
||||
"babel-plugin-syntax-dynamic-import": {
|
||||
"version": "6.18.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-dynamic-import/-/babel-plugin-syntax-dynamic-import-6.18.0.tgz",
|
||||
"integrity": "sha1-jWomIpyDdFqZgqRBBRVyyqF5sdo=",
|
||||
"dev": true
|
||||
},
|
||||
"babel-plugin-transform-decorators-legacy": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-transform-decorators-legacy/-/babel-plugin-transform-decorators-legacy-1.3.5.tgz",
|
||||
"integrity": "sha512-jYHwjzRXRelYQ1uGm353zNzf3QmtdCfvJbuYTZ4gKveK7M9H1fs3a5AKdY1JUDl0z97E30ukORW1dzhWvsabtA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"babel-plugin-syntax-decorators": "^6.1.18",
|
||||
"babel-runtime": "^6.2.0",
|
||||
"babel-template": "^6.3.0"
|
||||
}
|
||||
},
|
||||
"babel-plugin-transform-es2015-arrow-functions": {
|
||||
"version": "6.22.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.22.0.tgz",
|
||||
@ -6295,6 +6332,12 @@
|
||||
"integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==",
|
||||
"dev": true
|
||||
},
|
||||
"lodash.debounce": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||
"integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=",
|
||||
"dev": true
|
||||
},
|
||||
"lodash.tail": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.tail/-/lodash.tail-4.1.1.tgz",
|
||||
|
||||
@ -37,12 +37,14 @@
|
||||
"@babel/cli": "^7.4.4",
|
||||
"@babel/core": "^7.4.5",
|
||||
"@babel/plugin-proposal-class-properties": "^7.4.4",
|
||||
"@babel/plugin-proposal-decorators": "^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-plugin-transform-decorators-legacy": "^1.3.5",
|
||||
"babel-preset-es2015": "^6.24.1",
|
||||
"css-loader": "^2.1.1",
|
||||
"eslint": "^5.16.0",
|
||||
@ -57,6 +59,7 @@
|
||||
"karma-mocha-reporter": "^2.2.5",
|
||||
"karma-webpack": "^3.0.5",
|
||||
"lint-staged": "^8.1.7",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"node-sass": "^4.12.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"null-loader": "^2.0.0",
|
||||
|
||||
@ -36,6 +36,8 @@ import System.Log.FastLogger ( defaultBufSize, newStderrLoggerSet
|
||||
, toLogStr, rmLoggerSet
|
||||
)
|
||||
|
||||
import Handler.Utils (runAppLoggingT)
|
||||
|
||||
import qualified Data.Map.Strict as Map
|
||||
|
||||
import Foreign.Store
|
||||
@ -222,13 +224,6 @@ makeFoundation appSettings'@AppSettings{..} = do
|
||||
$logDebugS "setup" "Done"
|
||||
return foundation
|
||||
|
||||
runAppLoggingT :: UniWorX -> LoggingT m a -> m a
|
||||
runAppLoggingT app@(appLogger -> (_, loggerTVar)) = flip runLoggingT logFunc
|
||||
where
|
||||
logFunc loc src lvl str = do
|
||||
f <- messageLoggerSource app <$> readTVarIO loggerTVar
|
||||
f loc src lvl str
|
||||
|
||||
clusterSetting :: forall key m p.
|
||||
( MonadIO m
|
||||
, ClusterSetting key
|
||||
|
||||
@ -26,6 +26,9 @@ import qualified Database.Esqueleto as E
|
||||
|
||||
import Web.HttpApiData
|
||||
|
||||
import Data.Binary (Binary)
|
||||
import qualified Data.Binary as Binary
|
||||
|
||||
|
||||
instance PersistField (CI Text) where
|
||||
toPersistValue ciText = PersistDbSpecific . Text.encodeUtf8 $ CI.original ciText
|
||||
@ -92,5 +95,9 @@ instance FromHttpApiData (CI Text) where
|
||||
|
||||
instance (CI.FoldCase s, PathMultiPiece s) => PathMultiPiece (CI s) where
|
||||
fromPathMultiPiece = fmap CI.mk . fromPathMultiPiece
|
||||
toPathMultiPiece = toPathMultiPiece . CI.foldedCase
|
||||
toPathMultiPiece = toPathMultiPiece . CI.original
|
||||
|
||||
instance (CI.FoldCase s, Binary s) => Binary (CI s) where
|
||||
get = CI.mk <$> Binary.get
|
||||
put = Binary.put . CI.original
|
||||
putList = Binary.putList . map CI.original
|
||||
|
||||
@ -46,7 +46,7 @@ import Data.Map (Map, (!?))
|
||||
import qualified Data.Map as Map
|
||||
import qualified Data.HashSet as HashSet
|
||||
|
||||
import Data.List (nubBy, (!!))
|
||||
import Data.List (nubBy, (!!), findIndex)
|
||||
|
||||
import Data.Monoid (Any(..))
|
||||
|
||||
@ -493,7 +493,7 @@ askTokenUnsafe = $cachedHere $ do
|
||||
throwError =<< unauthorizedI MsgUnauthorizedTokenInvalid
|
||||
|
||||
validateToken :: Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> BearerToken UniWorX -> DB AuthResult
|
||||
validateToken mAuthId' route' isWrite' token' = runCachedMemoT $ for4 memo validateToken' mAuthId' route' isWrite' token'
|
||||
validateToken mAuthId' route' isWrite' token' = $runCachedMemoT $ for4 memo validateToken' mAuthId' route' isWrite' token'
|
||||
where
|
||||
validateToken' :: _ -> _ -> _ -> _ -> CachedMemoT (Maybe (AuthId UniWorX), Route UniWorX, Bool, BearerToken UniWorX) AuthResult DB AuthResult
|
||||
validateToken' mAuthId route isWrite BearerToken{..} = lift . exceptT return return $ do
|
||||
@ -524,7 +524,7 @@ tagAccessPredicate :: AuthTag -> AccessPredicate
|
||||
tagAccessPredicate AuthFree = trueAP
|
||||
tagAccessPredicate AuthAdmin = APDB $ \mAuthId route _ -> case route of
|
||||
-- Courses: access only to school admins
|
||||
CourseR tid ssh csh _ -> exceptT return return $ do
|
||||
CourseR tid ssh csh _ -> $cachedHereBinary (mAuthId, tid, ssh, csh) . exceptT return return $ do
|
||||
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
|
||||
[E.Value c] <- lift . E.select . E.from $ \(course `E.InnerJoin` userAdmin) -> do
|
||||
E.on $ course E.^. CourseSchool E.==. userAdmin E.^. UserAdminSchool
|
||||
@ -536,7 +536,7 @@ tagAccessPredicate AuthAdmin = APDB $ \mAuthId route _ -> case route of
|
||||
guardMExceptT (c > 0) (unauthorizedI MsgUnauthorizedSchoolAdmin)
|
||||
return Authorized
|
||||
-- other routes: access to any admin is granted here
|
||||
_other -> exceptT return return $ do
|
||||
_other -> $cachedHereBinary mAuthId . exceptT return return $ do
|
||||
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
|
||||
adrights <- lift $ selectFirst [UserAdminUser ==. authId] []
|
||||
guardMExceptT (isJust adrights) (unauthorizedI MsgUnauthorizedSiteAdmin)
|
||||
@ -566,7 +566,7 @@ tagAccessPredicate AuthDevelopment = APHandler $ \_ r _ -> do
|
||||
return $ Unauthorized "Route under development"
|
||||
#endif
|
||||
tagAccessPredicate AuthLecturer = APDB $ \mAuthId route _ -> case route of
|
||||
CourseR tid ssh csh _ -> exceptT return return $ do
|
||||
CourseR tid ssh csh _ -> $cachedHereBinary (mAuthId, tid, ssh, csh) . exceptT return return $ do
|
||||
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
|
||||
[E.Value c] <- lift . E.select . E.from $ \(course `E.InnerJoin` lecturer) -> do
|
||||
E.on $ course E.^. CourseId E.==. lecturer E.^. LecturerCourse
|
||||
@ -578,13 +578,13 @@ tagAccessPredicate AuthLecturer = APDB $ \mAuthId route _ -> case route of
|
||||
guardMExceptT (c>0) (unauthorizedI MsgUnauthorizedLecturer)
|
||||
return Authorized
|
||||
-- lecturer for any school will do
|
||||
_ -> exceptT return return $ do
|
||||
_ -> $cachedHereBinary mAuthId . exceptT return return $ do
|
||||
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
|
||||
void . maybeMExceptT (unauthorizedI MsgUnauthorizedSchoolLecturer) $ selectFirst [UserLecturerUser ==. authId] []
|
||||
return Authorized
|
||||
tagAccessPredicate AuthCorrector = APDB $ \mAuthId route _ -> exceptT return return $ do
|
||||
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
|
||||
resList <- lift . E.select . E.from $ \(course `E.InnerJoin` sheet `E.InnerJoin` sheetCorrector) -> do
|
||||
resList <- $cachedHereBinary (mAuthId) . lift . E.select . E.from $ \(course `E.InnerJoin` sheet `E.InnerJoin` sheetCorrector) -> do
|
||||
E.on $ sheetCorrector E.^. SheetCorrectorSheet E.==. sheet E.^. SheetId
|
||||
E.on $ sheet E.^. SheetCourse E.==. course E.^. CourseId
|
||||
E.where_ $ sheetCorrector E.^. SheetCorrectorUser E.==. E.val authId
|
||||
@ -593,17 +593,17 @@ tagAccessPredicate AuthCorrector = APDB $ \mAuthId route _ -> exceptT return ret
|
||||
resMap :: Map CourseId (Set SheetId)
|
||||
resMap = Map.fromListWith Set.union [ (cid, Set.singleton sid) | (E.Value cid, E.Value sid) <- resList ]
|
||||
case route of
|
||||
CSubmissionR _ _ _ _ cID _ -> maybeT (unauthorizedI MsgUnauthorizedSubmissionCorrector) $ do
|
||||
CSubmissionR _ _ _ _ cID _ -> $cachedHereBinary (mAuthId, cID) . maybeT (unauthorizedI MsgUnauthorizedSubmissionCorrector) $ do
|
||||
sid <- catchIfMaybeT (const True :: CryptoIDError -> Bool) $ decrypt cID
|
||||
Submission{..} <- MaybeT . lift $ get sid
|
||||
guard $ maybe False (== authId) submissionRatingBy
|
||||
return Authorized
|
||||
CSheetR tid ssh csh shn _ -> maybeT (unauthorizedI MsgUnauthorizedSheetCorrector) $ do
|
||||
CSheetR tid ssh csh shn _ -> $cachedHereBinary (mAuthId, tid, ssh, csh, shn) . maybeT (unauthorizedI MsgUnauthorizedSheetCorrector) $ do
|
||||
Entity cid _ <- MaybeT . lift . getBy $ TermSchoolCourseShort tid ssh csh
|
||||
Entity sid _ <- MaybeT . lift . getBy $ CourseSheet cid shn
|
||||
guard $ sid `Set.member` fromMaybe Set.empty (resMap !? cid)
|
||||
return Authorized
|
||||
CourseR tid ssh csh _ -> maybeT (unauthorizedI MsgUnauthorizedCorrector) $ do
|
||||
CourseR tid ssh csh _ -> $cachedHereBinary (mAuthId, tid, ssh, csh) . maybeT (unauthorizedI MsgUnauthorizedCorrector) $ do
|
||||
Entity cid _ <- MaybeT . lift . getBy $ TermSchoolCourseShort tid ssh csh
|
||||
guard $ cid `Set.member` Map.keysSet resMap
|
||||
return Authorized
|
||||
@ -612,7 +612,7 @@ tagAccessPredicate AuthCorrector = APDB $ \mAuthId route _ -> exceptT return ret
|
||||
return Authorized
|
||||
tagAccessPredicate AuthTutor = APDB $ \mAuthId route _ -> exceptT return return $ do
|
||||
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
|
||||
resList <- lift . E.select . E.from $ \(course `E.InnerJoin` tutorial `E.InnerJoin` tutor) -> do
|
||||
resList <- $cachedHereBinary authId . lift . E.select . E.from $ \(course `E.InnerJoin` tutorial `E.InnerJoin` tutor) -> do
|
||||
E.on $ tutor E.^. TutorTutorial E.==. tutorial E.^. TutorialId
|
||||
E.on $ tutorial E.^. TutorialCourse E.==. course E.^. CourseId
|
||||
E.where_ $ tutor E.^. TutorUser E.==. E.val authId
|
||||
@ -622,12 +622,12 @@ tagAccessPredicate AuthTutor = APDB $ \mAuthId route _ -> exceptT return return
|
||||
resMap = Map.fromListWith Set.union [ (cid, Set.singleton tutid) | (E.Value cid, E.Value tutid) <- resList ]
|
||||
case route of
|
||||
CTutorialR tid ssh csh tutn _ -> maybeT (unauthorizedI MsgUnauthorizedTutorialTutor) $ do
|
||||
Entity cid _ <- MaybeT . lift . getBy $ TermSchoolCourseShort tid ssh csh
|
||||
Entity tutid _ <- MaybeT . lift . getBy $ UniqueTutorial cid tutn
|
||||
Entity cid _ <- $cachedHereBinary (tid, ssh, csh) . MaybeT . lift . getBy $ TermSchoolCourseShort tid ssh csh
|
||||
Entity tutid _ <- $cachedHereBinary (cid, tutn) . MaybeT . lift . getBy $ UniqueTutorial cid tutn
|
||||
guard $ tutid `Set.member` fromMaybe Set.empty (resMap !? cid)
|
||||
return Authorized
|
||||
CourseR tid ssh csh _ -> maybeT (unauthorizedI MsgUnauthorizedCourseTutor) $ do
|
||||
Entity cid _ <- MaybeT . lift . getBy $ TermSchoolCourseShort tid ssh csh
|
||||
Entity cid _ <- $cachedHereBinary (tid, ssh, csh) . MaybeT . lift . getBy $ TermSchoolCourseShort tid ssh csh
|
||||
guard $ cid `Set.member` Map.keysSet resMap
|
||||
return Authorized
|
||||
_ -> do
|
||||
@ -636,10 +636,10 @@ tagAccessPredicate AuthTutor = APDB $ \mAuthId route _ -> exceptT return return
|
||||
tagAccessPredicate AuthTime = APDB $ \mAuthId route _ -> case route of
|
||||
CTutorialR tid ssh csh tutn TRegisterR -> maybeT (unauthorizedI MsgUnauthorizedTutorialTime) $ do
|
||||
now <- liftIO getCurrentTime
|
||||
course <- MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh
|
||||
Entity tutId Tutorial{..} <- MaybeT . getBy $ UniqueTutorial course tutn
|
||||
course <- $cachedHereBinary (tid, ssh, csh) . MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh
|
||||
Entity tutId Tutorial{..} <- $cachedHereBinary (course, tutn) . MaybeT . getBy $ UniqueTutorial course tutn
|
||||
registered <- case mAuthId of
|
||||
Just uid -> lift . existsBy $ UniqueTutorialParticipant tutId uid
|
||||
Just uid -> $cachedHereBinary (tutId, uid) . lift . existsBy $ UniqueTutorialParticipant tutId uid
|
||||
Nothing -> return False
|
||||
|
||||
if
|
||||
@ -654,8 +654,8 @@ tagAccessPredicate AuthTime = APDB $ \mAuthId route _ -> case route of
|
||||
-> mzero
|
||||
|
||||
CSheetR tid ssh csh shn subRoute -> maybeT (unauthorizedI MsgUnauthorizedSheetTime) $ do
|
||||
cid <- MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh
|
||||
Entity _sid Sheet{..} <- MaybeT . getBy $ CourseSheet cid shn
|
||||
cid <- $cachedHereBinary (tid, ssh, csh) . MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh
|
||||
Entity _sid Sheet{..} <- $cachedHereBinary (cid, shn) . MaybeT . getBy $ CourseSheet cid shn
|
||||
cTime <- liftIO getCurrentTime
|
||||
let
|
||||
visible = NTop sheetVisibleFrom <= NTop (Just cTime)
|
||||
@ -685,8 +685,8 @@ tagAccessPredicate AuthTime = APDB $ \mAuthId route _ -> case route of
|
||||
return Authorized
|
||||
|
||||
CourseR tid ssh csh (MaterialR mnm _) -> maybeT (unauthorizedI MsgUnauthorizedMaterialTime) $ do
|
||||
cid <- MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh
|
||||
Entity _mid Material{materialVisibleFrom} <- MaybeT . getBy $ UniqueMaterial cid mnm
|
||||
cid <- $cachedHereBinary (tid, ssh, csh) . MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh
|
||||
Entity _mid Material{materialVisibleFrom} <- $cachedHereBinary (cid, mnm) . MaybeT . getBy $ UniqueMaterial cid mnm
|
||||
cTime <- liftIO getCurrentTime
|
||||
let visible = NTop materialVisibleFrom <= NTop (Just cTime)
|
||||
guard visible
|
||||
@ -694,9 +694,9 @@ tagAccessPredicate AuthTime = APDB $ \mAuthId route _ -> case route of
|
||||
|
||||
CourseR tid ssh csh CRegisterR -> do
|
||||
now <- liftIO getCurrentTime
|
||||
mbc <- getBy $ TermSchoolCourseShort tid ssh csh
|
||||
mbc <- $cachedHereBinary (tid, ssh, csh) . getBy $ TermSchoolCourseShort tid ssh csh
|
||||
registered <- case (mbc,mAuthId) of
|
||||
(Just (Entity cid _), Just uid) -> isJust <$> (getBy $ UniqueParticipant uid cid)
|
||||
(Just (Entity cid _), Just uid) -> $cachedHereBinary (uid, cid) $ isJust <$> (getBy $ UniqueParticipant uid cid)
|
||||
_ -> return False
|
||||
case mbc of
|
||||
(Just (Entity _ Course{courseRegisterFrom, courseRegisterTo}))
|
||||
@ -710,7 +710,7 @@ tagAccessPredicate AuthTime = APDB $ \mAuthId route _ -> case route of
|
||||
|
||||
MessageR cID -> maybeT (unauthorizedI MsgUnauthorizedSystemMessageTime) $ do
|
||||
smId <- decrypt cID
|
||||
SystemMessage{systemMessageFrom, systemMessageTo} <- MaybeT $ get smId
|
||||
SystemMessage{systemMessageFrom, systemMessageTo} <- $cachedHereBinary smId . MaybeT $ get smId
|
||||
cTime <- (NTop . Just) <$> liftIO getCurrentTime
|
||||
guard $ NTop systemMessageFrom <= cTime
|
||||
&& NTop systemMessageTo >= cTime
|
||||
@ -720,7 +720,7 @@ tagAccessPredicate AuthTime = APDB $ \mAuthId route _ -> case route of
|
||||
tagAccessPredicate AuthCourseRegistered = APDB $ \mAuthId route _ -> case route of
|
||||
CourseR tid ssh csh _ -> exceptT return return $ do
|
||||
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
|
||||
[E.Value c] <- lift . E.select . E.from $ \(course `E.InnerJoin` courseParticipant) -> do
|
||||
[E.Value c] <- $cachedHereBinary (authId, tid, ssh, csh) . lift . E.select . E.from $ \(course `E.InnerJoin` courseParticipant) -> do
|
||||
E.on $ course E.^. CourseId E.==. courseParticipant E.^. CourseParticipantCourse
|
||||
E.where_ $ courseParticipant E.^. CourseParticipantUser E.==. E.val authId
|
||||
E.&&. course E.^. CourseTerm E.==. E.val tid
|
||||
@ -733,7 +733,7 @@ tagAccessPredicate AuthCourseRegistered = APDB $ \mAuthId route _ -> case route
|
||||
tagAccessPredicate AuthTutorialRegistered = APDB $ \mAuthId route _ -> case route of
|
||||
CTutorialR tid ssh csh tutn _ -> exceptT return return $ do
|
||||
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
|
||||
[E.Value c] <- lift . E.select . E.from $ \(course `E.InnerJoin` tutorial `E.InnerJoin` tutorialParticipant) -> do
|
||||
[E.Value c] <- $cachedHereBinary (authId, tid, ssh, csh, tutn) . lift . E.select . E.from $ \(course `E.InnerJoin` tutorial `E.InnerJoin` tutorialParticipant) -> do
|
||||
E.on $ tutorial E.^. TutorialId E.==. tutorialParticipant E.^. TutorialParticipantTutorial
|
||||
E.on $ course E.^. CourseId E.==. tutorial E.^. TutorialCourse
|
||||
E.where_ $ tutorialParticipant E.^. TutorialParticipantUser E.==. E.val authId
|
||||
@ -746,7 +746,7 @@ tagAccessPredicate AuthTutorialRegistered = APDB $ \mAuthId route _ -> case rout
|
||||
return Authorized
|
||||
CourseR tid ssh csh _ -> exceptT return return $ do
|
||||
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
|
||||
[E.Value c] <- lift . E.select . E.from $ \(course `E.InnerJoin` tutorial `E.InnerJoin` tutorialParticipant) -> do
|
||||
[E.Value c] <- $cachedHereBinary (authId, tid, ssh, csh) . lift . E.select . E.from $ \(course `E.InnerJoin` tutorial `E.InnerJoin` tutorialParticipant) -> do
|
||||
E.on $ tutorial E.^. TutorialId E.==. tutorialParticipant E.^. TutorialParticipantTutorial
|
||||
E.on $ course E.^. CourseId E.==. tutorial E.^. TutorialCourse
|
||||
E.where_ $ tutorialParticipant E.^. TutorialParticipantUser E.==. E.val authId
|
||||
@ -764,14 +764,14 @@ tagAccessPredicate AuthParticipant = APDB $ \_ route _ -> case route of
|
||||
whenExceptT ok Authorized
|
||||
participant <- decrypt cID
|
||||
-- participant is currently registered
|
||||
authorizedIfExists $ \(course `E.InnerJoin` courseParticipant) -> do
|
||||
$cachedHereBinary (participant, tid, ssh, csh) . authorizedIfExists $ \(course `E.InnerJoin` courseParticipant) -> do
|
||||
E.on $ course E.^. CourseId E.==. courseParticipant E.^. CourseParticipantCourse
|
||||
E.where_ $ courseParticipant E.^. CourseParticipantUser E.==. E.val participant
|
||||
E.&&. course E.^. CourseTerm E.==. E.val tid
|
||||
E.&&. course E.^. CourseSchool E.==. E.val ssh
|
||||
E.&&. course E.^. CourseShorthand E.==. E.val csh
|
||||
-- participant has at least one submission
|
||||
authorizedIfExists $ \(course `E.InnerJoin` sheet `E.InnerJoin` submission `E.InnerJoin` submissionUser) -> do
|
||||
$cachedHereBinary (participant, tid, ssh, csh) . authorizedIfExists $ \(course `E.InnerJoin` sheet `E.InnerJoin` submission `E.InnerJoin` submissionUser) -> do
|
||||
E.on $ submission E.^. SubmissionId E.==. submissionUser E.^. SubmissionUserSubmission
|
||||
E.on $ sheet E.^. SheetId E.==. submission E.^. SubmissionSheet
|
||||
E.on $ course E.^. CourseId E.==. sheet E.^. SheetCourse
|
||||
@ -780,7 +780,7 @@ tagAccessPredicate AuthParticipant = APDB $ \_ route _ -> case route of
|
||||
E.&&. course E.^. CourseSchool E.==. E.val ssh
|
||||
E.&&. course E.^. CourseShorthand E.==. E.val csh
|
||||
-- participant is member of a submissionGroup
|
||||
authorizedIfExists $ \(course `E.InnerJoin` submissionGroup `E.InnerJoin` submissionGroupUser) -> do
|
||||
$cachedHereBinary (participant, tid, ssh, csh) . authorizedIfExists $ \(course `E.InnerJoin` submissionGroup `E.InnerJoin` submissionGroupUser) -> do
|
||||
E.on $ submissionGroup E.^. SubmissionGroupId E.==. submissionGroupUser E.^. SubmissionGroupUserSubmissionGroup
|
||||
E.on $ course E.^. CourseId E.==. submissionGroup E.^. SubmissionGroupCourse
|
||||
E.where_ $ submissionGroupUser E.^. SubmissionGroupUserUser E.==. E.val participant
|
||||
@ -788,7 +788,7 @@ tagAccessPredicate AuthParticipant = APDB $ \_ route _ -> case route of
|
||||
E.&&. course E.^. CourseSchool E.==. E.val ssh
|
||||
E.&&. course E.^. CourseShorthand E.==. E.val csh
|
||||
-- participant is a sheet corrector
|
||||
authorizedIfExists $ \(course `E.InnerJoin` sheet `E.InnerJoin` sheetCorrector) -> do
|
||||
$cachedHereBinary (participant, tid, ssh, csh) . authorizedIfExists $ \(course `E.InnerJoin` sheet `E.InnerJoin` sheetCorrector) -> do
|
||||
E.on $ sheet E.^. SheetId E.==. sheetCorrector E.^. SheetCorrectorSheet
|
||||
E.on $ course E.^. CourseId E.==. sheet E.^. SheetCourse
|
||||
E.where_ $ sheetCorrector E.^. SheetCorrectorUser E.==. E.val participant
|
||||
@ -796,7 +796,7 @@ tagAccessPredicate AuthParticipant = APDB $ \_ route _ -> case route of
|
||||
E.&&. course E.^. CourseSchool E.==. E.val ssh
|
||||
E.&&. course E.^. CourseShorthand E.==. E.val csh
|
||||
-- participant is a tutorial user
|
||||
authorizedIfExists $ \(course `E.InnerJoin` tutorial `E.InnerJoin` tutorialUser) -> do
|
||||
$cachedHereBinary (participant, tid, ssh, csh) . authorizedIfExists $ \(course `E.InnerJoin` tutorial `E.InnerJoin` tutorialUser) -> do
|
||||
E.on $ tutorial E.^. TutorialId E.==. tutorialUser E.^. TutorialParticipantTutorial
|
||||
E.on $ course E.^. CourseId E.==. tutorial E.^. TutorialCourse
|
||||
E.where_ $ tutorialUser E.^. TutorialParticipantUser E.==. E.val participant
|
||||
@ -804,7 +804,7 @@ tagAccessPredicate AuthParticipant = APDB $ \_ route _ -> case route of
|
||||
E.&&. course E.^. CourseSchool E.==. E.val ssh
|
||||
E.&&. course E.^. CourseShorthand E.==. E.val csh
|
||||
-- participant is tutor for this course
|
||||
authorizedIfExists $ \(course `E.InnerJoin` tutorial `E.InnerJoin` tutor) -> do
|
||||
$cachedHereBinary (participant, tid, ssh, csh) . authorizedIfExists $ \(course `E.InnerJoin` tutorial `E.InnerJoin` tutor) -> do
|
||||
E.on $ tutorial E.^. TutorialId E.==. tutor E.^. TutorTutorial
|
||||
E.on $ course E.^. CourseId E.==. tutorial E.^. TutorialCourse
|
||||
E.where_ $ tutor E.^. TutorUser E.==. E.val participant
|
||||
@ -812,7 +812,7 @@ tagAccessPredicate AuthParticipant = APDB $ \_ route _ -> case route of
|
||||
E.&&. course E.^. CourseSchool E.==. E.val ssh
|
||||
E.&&. course E.^. CourseShorthand E.==. E.val csh
|
||||
-- participant is lecturer for this course
|
||||
authorizedIfExists $ \(course `E.InnerJoin` lecturer) -> do
|
||||
$cachedHereBinary (participant, tid, ssh, csh) . authorizedIfExists $ \(course `E.InnerJoin` lecturer) -> do
|
||||
E.on $ course E.^. CourseId E.==. lecturer E.^. LecturerCourse
|
||||
E.where_ $ lecturer E.^. LecturerUser E.==. E.val participant
|
||||
E.&&. course E.^. CourseTerm E.==. E.val tid
|
||||
@ -822,26 +822,26 @@ tagAccessPredicate AuthParticipant = APDB $ \_ route _ -> case route of
|
||||
r -> $unsupportedAuthPredicate AuthParticipant r
|
||||
tagAccessPredicate AuthCapacity = APDB $ \_ route _ -> case route of
|
||||
CTutorialR tid ssh csh tutn _ -> maybeT (unauthorizedI MsgTutorialNoCapacity) $ do
|
||||
cid <- MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh
|
||||
Entity tutId Tutorial{..} <- MaybeT . getBy $ UniqueTutorial cid tutn
|
||||
registered <- lift $ fromIntegral <$> count [ TutorialParticipantTutorial ==. tutId ]
|
||||
cid <- $cachedHereBinary (tid, ssh, csh) . MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh
|
||||
Entity tutId Tutorial{..} <- $cachedHereBinary (cid, tutn) . MaybeT . getBy $ UniqueTutorial cid tutn
|
||||
registered <- $cachedHereBinary tutId . lift $ fromIntegral <$> count [ TutorialParticipantTutorial ==. tutId ]
|
||||
guard $ NTop tutorialCapacity > NTop (Just registered)
|
||||
return Authorized
|
||||
CourseR tid ssh csh _ -> maybeT (unauthorizedI MsgCourseNoCapacity) $ do
|
||||
Entity cid Course{..} <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh
|
||||
registered <- lift $ fromIntegral <$> count [ CourseParticipantCourse ==. cid ]
|
||||
Entity cid Course{..} <- $cachedHereBinary (tid, ssh, csh) . MaybeT . getBy $ TermSchoolCourseShort tid ssh csh
|
||||
registered <- $cachedHereBinary cid . lift $ fromIntegral <$> count [ CourseParticipantCourse ==. cid ]
|
||||
guard $ NTop courseCapacity > NTop (Just registered)
|
||||
return Authorized
|
||||
r -> $unsupportedAuthPredicate AuthCapacity r
|
||||
tagAccessPredicate AuthRegisterGroup = APDB $ \mAuthId route _ -> case route of
|
||||
CTutorialR tid ssh csh tutn _ -> maybeT (unauthorizedI MsgUnauthorizedTutorialRegisterGroup) $ do
|
||||
cid <- MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh
|
||||
Entity _ Tutorial{..} <- MaybeT . getBy $ UniqueTutorial cid tutn
|
||||
cid <- $cachedHereBinary (tid, ssh, csh) . MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh
|
||||
Entity _ Tutorial{..} <- $cachedHereBinary (cid, tutn) . MaybeT . getBy $ UniqueTutorial cid tutn
|
||||
case (tutorialRegGroup, mAuthId) of
|
||||
(Nothing, _) -> return Authorized
|
||||
(_, Nothing) -> return AuthenticationRequired
|
||||
(Just rGroup, Just uid) -> do
|
||||
[E.Value hasOther] <- lift . E.select . return . E.exists . E.from $ \(tutorial `E.InnerJoin` participant) -> do
|
||||
[E.Value hasOther] <- $cachedHereBinary (uid, rGroup) . lift . E.select . return . E.exists . E.from $ \(tutorial `E.InnerJoin` participant) -> do
|
||||
E.on $ tutorial E.^. TutorialId E.==. participant E.^. TutorialParticipantTutorial
|
||||
E.where_ $ participant E.^. TutorialParticipantUser E.==. E.val uid
|
||||
E.&&. tutorial E.^. TutorialRegGroup E.==. E.just (E.val rGroup)
|
||||
@ -851,9 +851,9 @@ tagAccessPredicate AuthRegisterGroup = APDB $ \mAuthId route _ -> case route of
|
||||
tagAccessPredicate AuthEmpty = APDB $ \_ route _ -> case route of
|
||||
CourseR tid ssh csh _ -> maybeT (unauthorizedI MsgCourseNotEmpty) $ do
|
||||
-- Entity cid Course{..} <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh
|
||||
cid <- MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh
|
||||
assertM_ (<= 0) . lift $ count [ CourseParticipantCourse ==. cid ]
|
||||
assertM_ ((<= 0) :: Int -> Bool) . lift . fmap (E.unValue . unsafeHead) $ E.select . E.from $ \(sheet `E.InnerJoin` submission) -> do
|
||||
cid <- $cachedHereBinary (tid, ssh, csh) . MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh
|
||||
assertM_ (<= 0) . $cachedHereBinary cid . lift $ count [ CourseParticipantCourse ==. cid ]
|
||||
assertM_ ((<= 0) :: Int -> Bool) . $cachedHereBinary cid . lift . fmap (E.unValue . unsafeHead) $ E.select . E.from $ \(sheet `E.InnerJoin` submission) -> do
|
||||
E.on $ sheet E.^. SheetId E.==. submission E.^. SubmissionSheet
|
||||
E.where_ $ sheet E.^. SheetCourse E.==. E.val cid
|
||||
return E.countRows
|
||||
@ -861,26 +861,26 @@ tagAccessPredicate AuthEmpty = APDB $ \_ route _ -> case route of
|
||||
r -> $unsupportedAuthPredicate AuthEmpty r
|
||||
tagAccessPredicate AuthMaterials = APDB $ \_ route _ -> case route of
|
||||
CourseR tid ssh csh _ -> maybeT (unauthorizedI MsgUnfreeMaterials) $ do
|
||||
Entity _ Course{..} <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh
|
||||
Entity _ Course{..} <- $cachedHereBinary (tid, ssh, csh) . MaybeT . getBy $ TermSchoolCourseShort tid ssh csh
|
||||
guard courseMaterialFree
|
||||
return Authorized
|
||||
r -> $unsupportedAuthPredicate AuthMaterials r
|
||||
tagAccessPredicate AuthOwner = APDB $ \mAuthId route _ -> case route of
|
||||
CSubmissionR _ _ _ _ cID _ -> exceptT return return $ do
|
||||
CSubmissionR _ _ _ _ cID _ -> $cachedHereBinary (mAuthId, cID) . exceptT return return $ do
|
||||
sid <- catchIfMExceptT (const $ unauthorizedI MsgUnauthorizedSubmissionOwner) (const True :: CryptoIDError -> Bool) $ decrypt cID
|
||||
authId <- maybeExceptT AuthenticationRequired $ return mAuthId
|
||||
void . maybeMExceptT (unauthorizedI MsgUnauthorizedSubmissionOwner) . getBy $ UniqueSubmissionUser authId sid
|
||||
return Authorized
|
||||
r -> $unsupportedAuthPredicate AuthOwner r
|
||||
tagAccessPredicate AuthRated = APDB $ \_ route _ -> case route of
|
||||
CSubmissionR _ _ _ _ cID _ -> maybeT (unauthorizedI MsgUnauthorizedSubmissionRated) $ do
|
||||
CSubmissionR _ _ _ _ cID _ -> $cachedHereBinary cID . maybeT (unauthorizedI MsgUnauthorizedSubmissionRated) $ do
|
||||
sid <- catchIfMaybeT (const True :: CryptoIDError -> Bool) $ decrypt cID
|
||||
sub <- MaybeT $ get sid
|
||||
guard $ submissionRatingDone sub
|
||||
return Authorized
|
||||
r -> $unsupportedAuthPredicate AuthRated r
|
||||
tagAccessPredicate AuthUserSubmissions = APDB $ \_ route _ -> case route of
|
||||
CSheetR tid ssh csh shn _ -> maybeT (unauthorizedI MsgUnauthorizedUserSubmission) $ do
|
||||
CSheetR tid ssh csh shn _ -> $cachedHereBinary (tid, ssh, csh, shn) . maybeT (unauthorizedI MsgUnauthorizedUserSubmission) $ do
|
||||
Entity cid _ <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh
|
||||
Entity _ Sheet{ sheetSubmissionMode = SubmissionMode{..} } <- MaybeT . getBy $ CourseSheet cid shn
|
||||
guard $ is _Just submissionModeUser
|
||||
@ -888,8 +888,8 @@ tagAccessPredicate AuthUserSubmissions = APDB $ \_ route _ -> case route of
|
||||
r -> $unsupportedAuthPredicate AuthUserSubmissions r
|
||||
tagAccessPredicate AuthCorrectorSubmissions = APDB $ \_ route _ -> case route of
|
||||
CSheetR tid ssh csh shn _ -> maybeT (unauthorizedI MsgUnauthorizedCorrectorSubmission) $ do
|
||||
Entity cid _ <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh
|
||||
Entity _ Sheet{ sheetSubmissionMode = SubmissionMode{..} } <- MaybeT . getBy $ CourseSheet cid shn
|
||||
Entity cid _ <- $cachedHereBinary (tid, ssh, csh) . MaybeT . getBy $ TermSchoolCourseShort tid ssh csh
|
||||
Entity _ Sheet{ sheetSubmissionMode = SubmissionMode{..} } <- $cachedHereBinary (cid, shn) . MaybeT . getBy $ CourseSheet cid shn
|
||||
guard submissionModeCorrector
|
||||
return Authorized
|
||||
r -> $unsupportedAuthPredicate AuthCorrectorSubmissions r
|
||||
@ -910,7 +910,7 @@ tagAccessPredicate AuthSelf = APHandler $ \mAuthId route _ -> exceptT return ret
|
||||
tagAccessPredicate AuthAuthentication = APDB $ \mAuthId route _ -> case route of
|
||||
MessageR cID -> maybeT (unauthorizedI MsgUnauthorizedSystemMessageAuth) $ do
|
||||
smId <- decrypt cID
|
||||
SystemMessage{..} <- MaybeT $ get smId
|
||||
SystemMessage{..} <- $cachedHereBinary smId . MaybeT $ get smId
|
||||
let isAuthenticated = isJust mAuthId
|
||||
guard $ not systemMessageAuthenticatedOnly || isAuthenticated
|
||||
return Authorized
|
||||
@ -919,6 +919,21 @@ tagAccessPredicate AuthRead = APHandler . const . const $ bool (return Authorize
|
||||
tagAccessPredicate AuthWrite = APHandler . const . const $ bool (unauthorizedI MsgUnauthorized) (return Authorized)
|
||||
|
||||
|
||||
authTagSpecificity :: AuthTag -> AuthTag -> Ordering
|
||||
-- ^ Heuristic for which `AuthTag`s to evaluate first
|
||||
authTagSpecificity = comparing $ NTop . flip findIndex eqClasses . elem
|
||||
where
|
||||
eqClasses :: [[AuthTag]]
|
||||
-- ^ Constructors of `AuthTag` ordered (increasing) by execution order
|
||||
eqClasses =
|
||||
[ [ AuthFree, AuthDeprecated, AuthDevelopment ] -- Route wide
|
||||
, [ AuthRead, AuthWrite, AuthToken ] -- Request wide
|
||||
, [ AuthAdmin ] -- Site wide
|
||||
, [ AuthLecturer, AuthCourseRegistered, AuthParticipant, AuthTime, AuthMaterials, AuthUserSubmissions, AuthCorrectorSubmissions, AuthCapacity, AuthEmpty ] ++ [ AuthSelf, AuthNoEscalation ] ++ [ AuthAuthentication ] -- Course/User/SystemMessage wide
|
||||
, [ AuthCorrector ] ++ [ AuthTutor ] ++ [ AuthTutorialRegistered, AuthRegisterGroup ] -- Tutorial/Material/Sheet wide
|
||||
, [ AuthOwner, AuthRated ] -- Submission wide
|
||||
]
|
||||
|
||||
defaultAuthDNF :: AuthDNF
|
||||
defaultAuthDNF = PredDNF $ Set.fromList
|
||||
[ impureNonNull . Set.singleton $ PLVariable AuthAdmin
|
||||
@ -946,16 +961,19 @@ routeAuthTags = fmap (PredDNF . Set.mapMonotonic impureNonNull) . ofoldM partiti
|
||||
|
||||
evalAuthTags :: forall m. (MonadAP m, MonadLogger m) => AuthTagActive -> AuthDNF -> Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> WriterT (Set AuthTag) m AuthResult
|
||||
-- ^ `tell`s disabled predicates, identified as pivots
|
||||
evalAuthTags AuthTagActive{..} (map (Set.toList . toNullable) . Set.toList . dnfTerms -> authDNF) mAuthId route isWrite
|
||||
evalAuthTags AuthTagActive{..} (map (Set.toList . toNullable) . Set.toList . dnfTerms -> authDNF') mAuthId route isWrite
|
||||
= do
|
||||
mr <- getMsgRenderer
|
||||
let
|
||||
authVarSpecificity = authTagSpecificity `on` plVar
|
||||
authDNF = sortBy (authVarSpecificity `on` maximumBy authVarSpecificity . impureNonNull) $ map (sortBy authVarSpecificity) authDNF'
|
||||
|
||||
authTagIsInactive = not . authTagIsActive
|
||||
|
||||
evalAuthTag :: AuthTag -> WriterT (Set AuthTag) m AuthResult
|
||||
evalAuthTag authTag = lift . (runCachedMemoT :: CachedMemoT (AuthTag, Maybe UserId, Route UniWorX, Bool) AuthResult m _ -> m _) $ for4 memo evalAccessPred' authTag mAuthId route isWrite
|
||||
evalAuthTag authTag = lift . ($runCachedMemoT :: CachedMemoT (AuthTag, Maybe UserId, Route UniWorX, Bool) AuthResult m _ -> m _) $ for4 memo evalAccessPred' authTag mAuthId route isWrite
|
||||
where
|
||||
evalAccessPred' authTag' mAuthId' route' isWrite' = CachedMemoT $ do
|
||||
evalAccessPred' authTag' mAuthId' route' isWrite' = lift $ do
|
||||
$logDebugS "evalAccessPred" $ tshow (authTag', mAuthId', route', isWrite')
|
||||
evalAccessPred (tagAccessPredicate authTag') mAuthId' route' isWrite'
|
||||
|
||||
|
||||
@ -37,6 +37,8 @@ import System.FilePath.Posix (takeBaseName, takeFileName)
|
||||
import qualified Data.List as List
|
||||
import qualified Data.List.NonEmpty as NonEmpty
|
||||
|
||||
import Control.Monad.Logger
|
||||
|
||||
|
||||
-- | Check whether the user's preference for files is inline-viewing or downloading
|
||||
downloadFiles :: (MonadHandler m, HandlerSite m ~ UniWorX) => m Bool
|
||||
@ -247,3 +249,12 @@ guardAuthorizedFor :: ( HandlerSite h ~ UniWorX, MonadHandler h, MonadLogger h
|
||||
=> Route UniWorX -> a -> m (ReaderT SqlBackend h) a
|
||||
guardAuthorizedFor link val =
|
||||
val <$ guardM (lift $ (== Authorized) <$> evalAccessDB link False)
|
||||
|
||||
|
||||
runAppLoggingT :: UniWorX -> LoggingT m a -> m a
|
||||
runAppLoggingT app@(appLogger -> (_, loggerTVar)) = flip runLoggingT logFunc
|
||||
where
|
||||
logFunc loc src lvl str = do
|
||||
f <- messageLoggerSource app <$> readTVarIO loggerTVar
|
||||
f loc src lvl str
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ import Yesod.Auth as Import
|
||||
import Yesod.Core.Types as Import (loggerSet)
|
||||
import Yesod.Default.Config2 as Import
|
||||
import Yesod.Core.Json as Import (provideJson)
|
||||
import Yesod.Core.Types.Instances as Import (CachedMemoT(..))
|
||||
import Yesod.Core.Types.Instances as Import
|
||||
|
||||
import Utils as Import
|
||||
import Utils.Frontend.I18n as Import
|
||||
|
||||
131
src/Jobs.hs
131
src/Jobs.hs
@ -7,6 +7,7 @@ module Jobs
|
||||
|
||||
import Import
|
||||
import Utils.Lens
|
||||
import Handler.Utils
|
||||
|
||||
import Jobs.Types as Types hiding (JobCtl(JobCtlQueue))
|
||||
import Jobs.Types (JobCtl(JobCtlQueue))
|
||||
@ -93,7 +94,7 @@ handleJobs foundation@UniWorX{..} = do
|
||||
logStart = $logDebugS ("Jobs #" <> tshow n) "Starting"
|
||||
logStop = $logDebugS ("Jobs #" <> tshow n) "Stopping"
|
||||
removeChan = atomically . modifyTVar' appJobCtl . Map.delete =<< myThreadId
|
||||
doFork = flip forkFinally (\_ -> removeChan) . unsafeHandler foundation . bracket_ logStart logStop . flip runReaderT JobContext{..} . runConduit $ sourceTMChan chan .| handleJobs' n
|
||||
doFork = flip forkFinally (\_ -> removeChan) . runAppLoggingT foundation . bracket_ logStart logStop . flip runReaderT JobContext{..} . runConduit $ sourceTMChan chan .| handleJobs' foundation n
|
||||
(_, tId) <- allocate (liftIO doFork) (\_ -> liftIO . atomically $ closeTMChan chan)
|
||||
atomically . modifyTVar' appJobCtl $ Map.insert tId bChan
|
||||
|
||||
@ -101,7 +102,7 @@ handleJobs foundation@UniWorX{..} = do
|
||||
when (num > 0) $ do
|
||||
registeredCron <- liftIO newEmptyTMVarIO
|
||||
let execCrontab' = whenM (atomically $ readTMVar registeredCron) $
|
||||
unsafeHandler foundation $ runReaderT execCrontab JobContext{..}
|
||||
runReaderT (execCrontab foundation) JobContext{..}
|
||||
unregister = atomically . whenM (fromMaybe False <$> tryReadTMVar registeredCron) . void $ tryTakeTMVar appCronThread
|
||||
cData <- allocate (liftIO . forkFinally execCrontab' $ \_ -> unregister) (\_ -> liftIO . atomically . void $ tryTakeTMVar jobCrontab)
|
||||
registeredCron' <- atomically $ do
|
||||
@ -126,73 +127,75 @@ stopJobCtl UniWorX{appJobCtl, appCronThread} = do
|
||||
guard . none (`Map.member` wMap') $ Map.keysSet wMap
|
||||
|
||||
|
||||
execCrontab :: ReaderT JobContext (HandlerT UniWorX IO) ()
|
||||
execCrontab :: MonadIO m => UniWorX -> ReaderT JobContext m ()
|
||||
-- ^ Keeping a `HashMap` of the latest execution times of `JobCtl`s we have
|
||||
-- seen, wait for the time of the next job and fire it
|
||||
execCrontab = evalStateT go HashMap.empty
|
||||
execCrontab foundation = evalStateT go HashMap.empty
|
||||
where
|
||||
go = do
|
||||
mapStateT (liftHandlerT . runDB . setSerializable) $ do
|
||||
let
|
||||
merge (Entity leId CronLastExec{..})
|
||||
| Just job <- Aeson.parseMaybe parseJSON cronLastExecJob
|
||||
= State.modify $ HashMap.insertWith (<>) (JobCtlQueue job) (Max cronLastExecTime)
|
||||
| otherwise = lift $ delete leId
|
||||
runConduit $ transPipe lift (selectSource [] []) .| C.mapM_ merge
|
||||
cont <- mapStateT (mapReaderT $ liftIO . unsafeHandler foundation) $ do
|
||||
mapStateT (liftHandlerT . runDB . setSerializable) $ do
|
||||
let
|
||||
merge (Entity leId CronLastExec{..})
|
||||
| Just job <- Aeson.parseMaybe parseJSON cronLastExecJob
|
||||
= State.modify $ HashMap.insertWith (<>) (JobCtlQueue job) (Max cronLastExecTime)
|
||||
| otherwise = lift $ delete leId
|
||||
runConduit $ transPipe lift (selectSource [] []) .| C.mapM_ merge
|
||||
|
||||
refT <- liftIO getCurrentTime
|
||||
settings <- getsYesod appSettings'
|
||||
currentState <- mapStateT (mapReaderT $ liftIO . atomically) $ do
|
||||
crontab' <- liftBase . tryReadTMVar =<< asks jobCrontab
|
||||
case crontab' of
|
||||
Nothing -> return Nothing
|
||||
Just crontab -> Just <$> do
|
||||
State.modify . HashMap.filterWithKey $ \k _ -> HashMap.member k crontab
|
||||
prevExec <- State.get
|
||||
case earliestJob settings prevExec crontab refT of
|
||||
Nothing -> liftBase retry
|
||||
Just (_, MatchNone) -> liftBase retry
|
||||
Just x -> return (crontab, x)
|
||||
refT <- liftIO getCurrentTime
|
||||
settings <- getsYesod appSettings'
|
||||
currentState <- mapStateT (mapReaderT $ liftIO . atomically) $ do
|
||||
crontab' <- liftBase . tryReadTMVar =<< asks jobCrontab
|
||||
case crontab' of
|
||||
Nothing -> return Nothing
|
||||
Just crontab -> Just <$> do
|
||||
State.modify . HashMap.filterWithKey $ \k _ -> HashMap.member k crontab
|
||||
prevExec <- State.get
|
||||
case earliestJob settings prevExec crontab refT of
|
||||
Nothing -> liftBase retry
|
||||
Just (_, MatchNone) -> liftBase retry
|
||||
Just x -> return (crontab, x)
|
||||
|
||||
case currentState of
|
||||
Nothing -> return ()
|
||||
Just (currentCrontab, (jobCtl, nextMatch)) -> do
|
||||
let doJob = mapStateT (mapReaderT $ liftHandlerT . runDBJobs . setSerializable) $ do
|
||||
newCrontab <- lift . lift . hoist lift $ determineCrontab'
|
||||
if
|
||||
| ((==) `on` HashMap.lookup jobCtl) newCrontab currentCrontab
|
||||
-> do
|
||||
now <- liftIO $ getCurrentTime
|
||||
instanceID' <- getsYesod appInstanceID
|
||||
State.modify $ HashMap.alter (Just . ($ Max now) . maybe id (<>)) jobCtl
|
||||
case jobCtl of
|
||||
JobCtlQueue job -> do
|
||||
void . lift . lift $ upsertBy
|
||||
(UniqueCronLastExec $ toJSON job)
|
||||
CronLastExec
|
||||
{ cronLastExecJob = toJSON job
|
||||
, cronLastExecTime = now
|
||||
, cronLastExecInstance = instanceID'
|
||||
}
|
||||
[ CronLastExecTime =. now ]
|
||||
lift . lift $ queueDBJob job
|
||||
other -> writeJobCtl other
|
||||
| otherwise
|
||||
-> lift . mapReaderT (liftIO . atomically) $
|
||||
lift . void . flip swapTMVar newCrontab =<< asks jobCrontab
|
||||
case currentState of
|
||||
Nothing -> return False
|
||||
Just (currentCrontab, (jobCtl, nextMatch)) -> do
|
||||
let doJob = mapStateT (mapReaderT $ liftHandlerT . runDBJobs . setSerializable) $ do
|
||||
newCrontab <- lift . lift . hoist lift $ determineCrontab'
|
||||
if
|
||||
| ((==) `on` HashMap.lookup jobCtl) newCrontab currentCrontab
|
||||
-> do
|
||||
now <- liftIO $ getCurrentTime
|
||||
instanceID' <- getsYesod appInstanceID
|
||||
State.modify $ HashMap.alter (Just . ($ Max now) . maybe id (<>)) jobCtl
|
||||
case jobCtl of
|
||||
JobCtlQueue job -> do
|
||||
void . lift . lift $ upsertBy
|
||||
(UniqueCronLastExec $ toJSON job)
|
||||
CronLastExec
|
||||
{ cronLastExecJob = toJSON job
|
||||
, cronLastExecTime = now
|
||||
, cronLastExecInstance = instanceID'
|
||||
}
|
||||
[ CronLastExecTime =. now ]
|
||||
lift . lift $ queueDBJob job
|
||||
other -> writeJobCtl other
|
||||
| otherwise
|
||||
-> lift . mapReaderT (liftIO . atomically) $
|
||||
lift . void . flip swapTMVar newCrontab =<< asks jobCrontab
|
||||
|
||||
case nextMatch of
|
||||
MatchAsap -> doJob
|
||||
MatchNone -> return ()
|
||||
MatchAt nextTime -> do
|
||||
JobContext{jobCrontab} <- ask
|
||||
nextTime' <- applyJitter jobCtl nextTime
|
||||
$logDebugS "Cron" [st|Waiting until #{tshow (utcToLocalTimeTZ appTZ nextTime')} to execute #{tshow jobCtl}|]
|
||||
logFunc <- askLoggerIO
|
||||
whenM (liftIO . flip runLoggingT logFunc $ waitUntil jobCrontab currentCrontab nextTime')
|
||||
doJob
|
||||
case nextMatch of
|
||||
MatchAsap -> doJob
|
||||
MatchNone -> return ()
|
||||
MatchAt nextTime -> do
|
||||
JobContext{jobCrontab} <- ask
|
||||
nextTime' <- applyJitter jobCtl nextTime
|
||||
$logDebugS "Cron" [st|Waiting until #{tshow (utcToLocalTimeTZ appTZ nextTime')} to execute #{tshow jobCtl}|]
|
||||
logFunc <- askLoggerIO
|
||||
whenM (liftIO . flip runLoggingT logFunc $ waitUntil jobCrontab currentCrontab nextTime')
|
||||
doJob
|
||||
|
||||
go
|
||||
return True
|
||||
when cont go
|
||||
where
|
||||
acc :: NominalDiffTime
|
||||
acc = 1e-3
|
||||
@ -244,12 +247,12 @@ execCrontab = evalStateT go HashMap.empty
|
||||
bool (waitUntil crontabTV crontab nextTime) (return False) crontabChanged
|
||||
|
||||
|
||||
handleJobs' :: Natural -> Sink JobCtl (ReaderT JobContext Handler) ()
|
||||
handleJobs' wNum = C.mapM_ $ \jctl -> do
|
||||
handleJobs' :: (MonadIO m, MonadLogger m, MonadCatch m) => UniWorX -> Natural -> Sink JobCtl (ReaderT JobContext m) ()
|
||||
handleJobs' foundation wNum = C.mapM_ $ \jctl -> do
|
||||
$logDebugS logIdent $ tshow jctl
|
||||
resVars <- mapReaderT (liftIO . atomically) $
|
||||
HashMap.lookup jctl <$> (lift . readTVar =<< asks jobConfirm)
|
||||
res <- fmap (either Just $ const Nothing) . try $ handleCmd jctl
|
||||
res <- fmap (either Just $ const Nothing) . try . (mapReaderT $ liftIO . unsafeHandler foundation) $ handleCmd jctl
|
||||
sentRes <- liftIO . atomically $ foldrM (\resVar -> bool (tryPutTMVar resVar res) $ return True) False (maybe [] NonEmpty.toList resVars)
|
||||
case res of
|
||||
Just err
|
||||
|
||||
11
src/Utils.hs
11
src/Utils.hs
@ -69,6 +69,7 @@ import qualified Crypto.Data.PKCS7 as PKCS7
|
||||
import Data.Fixed
|
||||
import Data.Ratio ((%))
|
||||
|
||||
import Data.Binary (Binary)
|
||||
import qualified Data.Binary as Binary
|
||||
|
||||
import Network.Wai (requestMethod)
|
||||
@ -921,10 +922,18 @@ encodedSecretBoxOpen ciphertext = do
|
||||
-- Caching --
|
||||
-------------
|
||||
|
||||
cachedByBinary :: (Binary a, Typeable b, MonadHandler m) => a -> m b -> m b
|
||||
cachedByBinary k = cachedBy (toStrict $ Binary.encode k)
|
||||
|
||||
cachedHere :: Q Exp
|
||||
cachedHere = do
|
||||
loc <- location
|
||||
[e| cachedBy (toStrict $ Binary.encode loc) |]
|
||||
[e| cachedByBinary loc |]
|
||||
|
||||
cachedHereBinary :: Q Exp
|
||||
cachedHereBinary = do
|
||||
loc <- location
|
||||
[e| \k -> cachedByBinary (loc, k) |]
|
||||
|
||||
hashToText :: Hashable a => a -> Text
|
||||
hashToText = decodeUtf8 . Base64.encode . toStrict . Binary.encode . hash
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
{-# LANGUAGE GeneralizedNewtypeDeriving, UndecidableInstances #-}
|
||||
|
||||
module Yesod.Core.Types.Instances
|
||||
( CachedMemoT(..)
|
||||
( CachedMemoT
|
||||
, runCachedMemoT
|
||||
) where
|
||||
|
||||
import ClassyPrelude.Yesod
|
||||
@ -13,9 +14,15 @@ import Control.Monad.Fix
|
||||
import Control.Monad.Memo
|
||||
|
||||
import Data.Binary (Binary)
|
||||
import qualified Data.Binary as Binary
|
||||
|
||||
import Control.Monad.Logger (MonadLoggerIO)
|
||||
|
||||
import Utils
|
||||
|
||||
import Language.Haskell.TH
|
||||
|
||||
import Control.Monad.Reader (MonadReader(..))
|
||||
import Control.Monad.Trans.Reader (ReaderT, mapReaderT, runReaderT)
|
||||
|
||||
|
||||
instance MonadFix m => MonadFix (HandlerT site m) where
|
||||
@ -26,23 +33,31 @@ instance MonadFix m => MonadFix (WidgetT site m) where
|
||||
|
||||
|
||||
-- | Type-level tags for compatability of Yesod `cached`-System with `MonadMemo`
|
||||
newtype CachedMemoT k v m a = CachedMemoT { runCachedMemoT :: m a }
|
||||
newtype CachedMemoT k v m a = CachedMemoT { runCachedMemoT' :: ReaderT Loc m a }
|
||||
deriving newtype ( Functor, Applicative, Alternative, Monad, MonadPlus, MonadFix
|
||||
, MonadIO
|
||||
, MonadThrow, MonadCatch, MonadMask, MonadLogger, MonadLoggerIO
|
||||
, MonadResource, MonadHandler, MonadWidget
|
||||
, IsString, Semigroup, Monoid
|
||||
)
|
||||
|
||||
deriving newtype instance MonadBase b m => MonadBase b (CachedMemoT k v m)
|
||||
deriving newtype instance MonadBaseControl b m => MonadBaseControl b (CachedMemoT k v m)
|
||||
|
||||
deriving newtype instance MonadReader r m => MonadReader r (CachedMemoT k v m)
|
||||
instance MonadReader r m => MonadReader r (CachedMemoT k v m) where
|
||||
reader = CachedMemoT . lift . reader
|
||||
local f (CachedMemoT act) = CachedMemoT $ mapReaderT (local f) act
|
||||
|
||||
instance MonadTrans (CachedMemoT k v) where
|
||||
lift = CachedMemoT
|
||||
lift = CachedMemoT . lift
|
||||
|
||||
|
||||
-- | Uses `cachedBy` with a `Binary`-encoded @k@
|
||||
instance (Typeable v, Binary k, MonadHandler m) => MonadMemo k v (CachedMemoT k v m) where
|
||||
memo act key = cachedBy (toStrict $ Binary.encode key) $ act key
|
||||
memo act key = do
|
||||
loc <- CachedMemoT ask
|
||||
cachedByBinary (loc, key) $ act key
|
||||
|
||||
runCachedMemoT :: Q Exp
|
||||
runCachedMemoT = do
|
||||
loc <- location
|
||||
[e| flip runReaderT loc . runCachedMemoT' |]
|
||||
|
||||
@ -1,430 +0,0 @@
|
||||
(function collonadeClosure() {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
*
|
||||
* 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) {
|
||||
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) {
|
||||
if (!HttpClient) {
|
||||
throw new Error('HttpClient not found!');
|
||||
}
|
||||
|
||||
element.classList.add(ASYNC_TABLE_LOADING_CLASS);
|
||||
|
||||
var headers = {
|
||||
'Accept': 'text/html',
|
||||
[asyncTableHeader]: asyncTableId
|
||||
};
|
||||
|
||||
HttpClient.get({
|
||||
url: url,
|
||||
headers: headers,
|
||||
accept: HttpClient.ACCEPT.TEXT_HTML,
|
||||
}).then(function(response) {
|
||||
return 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);
|
||||
|
||||
if (UtilRegistry) {
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
// register async table utility
|
||||
if (UtilRegistry) {
|
||||
UtilRegistry.register({
|
||||
name: ASYNC_TABLE_UTIL_NAME,
|
||||
selector: ASYNC_TABLE_UTIL_SELECTOR,
|
||||
setup: asyncTableUtil,
|
||||
});
|
||||
}
|
||||
})();
|
||||
@ -1,6 +1,6 @@
|
||||
$newline never
|
||||
<div .scrolltable>
|
||||
<table *{dbsAttrs'} data-async-table-db-header=#{toPathPiece HeaderDBTableShortcircuit}>
|
||||
<table *{dbsAttrs'}>
|
||||
$maybe wHeaders' <- wHeaders
|
||||
<thead>
|
||||
<tr .table__row.table__row--head>
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
$newline never
|
||||
<div ##{wIdent "table-wrapper"} uw-async-table>
|
||||
<div ##{wIdent "table-wrapper"} uw-async-table data-async-table-db-header=#{toPathPiece HeaderDBTableShortcircuit}>
|
||||
^{table}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user