Merge branch 'master' of gitlab.cip.ifi.lmu.de:jost/UniWorX

This commit is contained in:
Steffen Jost 2019-06-04 08:14:26 +02:00
commit a71ac7139d
76 changed files with 2107 additions and 2362 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { HtmlHelpers } from "./html-helpers";
import { HtmlHelpers } from './html-helpers';
describe('HtmlHelpers', () => {
let htmlHelpers;

View File

@ -1,4 +1,4 @@
import { HttpClient } from "./http-client";
import { HttpClient } from './http-client';
const TEST_URL = 'http://example.com';
const FAKE_RESPONSE = {

View File

@ -1,4 +1,4 @@
import { I18n } from "./i18n";
import { I18n } from './i18n';
describe('I18n', () => {
let i18n;

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View 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
...
```

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

View File

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

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

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

View File

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

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

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

View File

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

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

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

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

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

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

View 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 />
```

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

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

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

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

View File

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

View File

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

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

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

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

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

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

View 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'>
```

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

View 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'>
```

View File

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

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

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@
position: relative;
display: inline-block;
[type="radio"] {
[type='radio'] {
position: fixed;
top: -1px;
left: -1px;

View File

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

View 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'>
```

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

43
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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