diff --git a/.babelrc b/.babelrc new file mode 100644 index 000000000..352d48a80 --- /dev/null +++ b/.babelrc @@ -0,0 +1,11 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "useBuiltIns": "usage" + } + ] + ], + "plugins": ["@babel/plugin-proposal-class-properties"] +} diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 000000000..8c0382cfa --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,24 @@ +{ + "env": { + "browser": true, + "es6": true, + "jasmine": true + }, + "extends": "eslint:recommended", + "globals": { + "Atomics": "readonly", + "SharedArrayBuffer": "readonly", + "flatpickr": "readonly", + "$": "readonly" + }, + "parser": "babel-eslint", + "parserOptions": { + "ecmaVersion": 2018 + }, + "rules": { + "no-console": "off", + "no-extra-semi": "off", + "semi": ["error", "always"], + "comma-dangle": ["error", "always-multiline"] + } +} diff --git a/.gitignore b/.gitignore index b85a1c848..0e2a4677f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ dist* +static/bundles/ static/tmp/ static/combined/ +node_modules/ *.hi *.o *.sqlite3 @@ -31,4 +33,4 @@ src/Handler/Course.SnapCustom.hs .stack-work-* .directory tags -test.log \ No newline at end of file +test.log diff --git a/static/js/polyfills/fetchPolyfill.js b/frontend/polyfills/fetch.js similarity index 100% rename from static/js/polyfills/fetchPolyfill.js rename to frontend/polyfills/fetch.js diff --git a/frontend/polyfills/main.js b/frontend/polyfills/main.js new file mode 100644 index 000000000..7e4c554ea --- /dev/null +++ b/frontend/polyfills/main.js @@ -0,0 +1,2 @@ +import './fetch'; +import './url-search-params'; diff --git a/static/js/polyfills/urlPolyfill.js b/frontend/polyfills/url-search-params.js similarity index 100% rename from static/js/polyfills/urlPolyfill.js rename to frontend/polyfills/url-search-params.js diff --git a/frontend/src/app.js b/frontend/src/app.js new file mode 100644 index 000000000..6c7fa215f --- /dev/null +++ b/frontend/src/app.js @@ -0,0 +1,25 @@ +import { HttpClient } from './services/http-client/http-client'; +import { HtmlHelpers } from './services/html-helpers/html-helpers'; +import { I18n } from './services/i18n/i18n'; +import { UtilRegistry } from './services/util-registry/util-registry'; + +export class App { + httpClient = new HttpClient(); + htmlHelpers = new HtmlHelpers(); + i18n = new I18n(); + utilRegistry = new UtilRegistry(); + + constructor() { + this.utilRegistry.setApp(this); + } + + registerUtilities(utils) { + if (!Array.isArray(utils)) { + throw new Error('Utils are expected to be passed as array!'); + } + + utils.forEach((util) => { + this.utilRegistry.register(util); + }); + } +} diff --git a/frontend/src/app.spec.js b/frontend/src/app.spec.js new file mode 100644 index 000000000..8ce962b2d --- /dev/null +++ b/frontend/src/app.spec.js @@ -0,0 +1,52 @@ +import { App } from "./app"; + +const TEST_UTILS = [ + { name: 'util1' }, + { name: 'util2' }, +]; + +describe('App', () => { + let app; + + beforeEach(() => { + app = new App(); + }); + + it('should create', () => { + expect(app).toBeTruthy(); + }); + + describe('provides services', () => { + it('HttpClient as httpClient', () => { + expect(app.httpClient).toBeTruthy(); + }); + + it('HtmlHelpers as htmlHelpers', () => { + expect(app.htmlHelpers).toBeTruthy(); + }); + + it('I18n as i18n', () => { + expect(app.i18n).toBeTruthy(); + }); + + it('UtilRegistry as utilRegistry', () => { + expect(app.utilRegistry).toBeTruthy(); + }); + }); + + describe('registerUtilities()', () => { + it('should register the given utilities', () => { + spyOn(app.utilRegistry, 'register'); + app.registerUtilities(TEST_UTILS); + expect(app.utilRegistry.register.calls.count()).toBe(TEST_UTILS.length); + expect(app.utilRegistry.register.calls.argsFor(0)).toEqual([TEST_UTILS[0]]); + expect(app.utilRegistry.register.calls.argsFor(1)).toEqual([TEST_UTILS[1]]); + }); + + it('should throw an error if not passed an array of utilities', () => { + expect(() => { + app.registerUtilities({}); + }).toThrow(); + }); + }); +}); diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 000000000..eb76d102e --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,24 @@ +import { App } from './app'; +import { Utils } from './utils/utils'; + +export const app = new App(); +app.registerUtilities(Utils); + +// attach the app to window to be able to get a hold of the +// app instance from the shakespearean templates +window.App = app; + +// dont know where to put this currently... +// interceptor to throw an error if an http response does not match the expected content-type +// function contentTypeInterceptor(response, options) { +// if (!options || !options.headers.get('Accept')) { +// return; +// } + +// const contentType = response.headers.get("content-type"); +// if (!contentType.match(options.accept)) { +// throw new Error('Server returned with "' + contentType + '" when "' + options.accept + '" was expected'); +// } +// } + +// window.HttpClient.addResponseInterceptor(contentTypeInterceptor); diff --git a/frontend/src/services/html-helpers/html-helpers.js b/frontend/src/services/html-helpers/html-helpers.js new file mode 100644 index 000000000..92d3168b5 --- /dev/null +++ b/frontend/src/services/html-helpers/html-helpers.js @@ -0,0 +1,41 @@ +export class HtmlHelpers { + + // `parseResponse` takes a raw HttpClient response and an options object. + // Returns an object with `element` being an contextual fragment of the + // HTML in the response and `ifPrefix` being the prefix that was used to + // "unique-ify" the ids of the received HTML. + // Original Response IDs can optionally be kept by adding `keepIds: true` + // to the `options` object. + parseResponse(response, options = {}) { + return response.text() + .then( + (responseText) => { + const docFrag = document.createRange().createContextualFragment(responseText); + let idPrefix = ''; + if (!options.keepIds) { + idPrefix = this._getIdPrefix(); + this._prefixIds(docFrag, idPrefix); + } + return Promise.resolve({ idPrefix, element: docFrag }); + }, + Promise.reject, + ).catch(console.error); + } + + _prefixIds(element, idPrefix) { + const idAttrs = ['id', 'for', 'data-conditional-input', 'data-modal-trigger']; + + idAttrs.forEach((attr) => { + Array.from(element.querySelectorAll('[' + attr + ']')).forEach((input) => { + const value = idPrefix + input.getAttribute(attr); + input.setAttribute(attr, value); + }); + }); + } + + _getIdPrefix() { + // leading 'r'(andom) to overcome the fact that IDs + // starting with a numeric value are not valid in CSS + return 'r' + Math.floor(Math.random() * 100000) + '__'; + } +} diff --git a/frontend/src/services/html-helpers/html-helpers.spec.js b/frontend/src/services/html-helpers/html-helpers.spec.js new file mode 100644 index 000000000..c77015b26 --- /dev/null +++ b/frontend/src/services/html-helpers/html-helpers.spec.js @@ -0,0 +1,56 @@ +import { HtmlHelpers } from "./html-helpers"; + +describe('HtmlHelpers', () => { + let htmlHelpers; + + beforeEach(() => { + htmlHelpers = new HtmlHelpers(); + }); + + it('should create', () => { + expect(htmlHelpers).toBeTruthy(); + }); + + describe('parseResponse()', () => { + let fakeHttpResponse; + + beforeEach(() => { + fakeHttpResponse = { + text: () => Promise.resolve('
Test
'), + }; + }); + + it('should return a promise with idPrefix and element', (done) => { + htmlHelpers.parseResponse(fakeHttpResponse).then(result => { + expect(result.idPrefix).toBeDefined(); + expect(result.element).toBeDefined(); + expect(result.element.textContent).toMatch('Test'); + done(); + }); + }); + + it('should nudge IDs', (done) => { + htmlHelpers.parseResponse(fakeHttpResponse).then(result => { + expect(result.idPrefix).toBeDefined(); + expect(result.element).toBeDefined(); + const elementWithOrigId = result.element.querySelector('#test-div'); + expect(elementWithOrigId).toBeFalsy(); + const elementWithNudgedId = result.element.querySelector('#' + result.idPrefix + 'test-div'); + expect(elementWithNudgedId).toBeTruthy(); + done(); + }); + }); + + it('should not nudge IDs with option "keepIds"', (done) => { + const options = { keepIds: true }; + + htmlHelpers.parseResponse(fakeHttpResponse, options).then(result => { + expect(result.idPrefix).toBe(''); + expect(result.element).toBeDefined(); + const elementWithOrigId = result.element.querySelector('#test-div'); + expect(elementWithOrigId).toBeTruthy(); + done(); + }); + }); + }); +}); diff --git a/frontend/src/services/http-client/http-client.js b/frontend/src/services/http-client/http-client.js new file mode 100644 index 000000000..6ba2341f0 --- /dev/null +++ b/frontend/src/services/http-client/http-client.js @@ -0,0 +1,41 @@ +export class HttpClient { + + static ACCEPT = { + TEXT_HTML: 'text/html', + JSON: 'application/json', + }; + + _responseInterceptors = []; + + addResponseInterceptor(interceptor) { + if (typeof interceptor === 'function') { + this._responseInterceptors.push(interceptor); + } + } + + get(args) { + args.method = 'GET'; + return this._fetch(args); + } + + post(args) { + args.method = 'POST'; + return this._fetch(args); + } + + _fetch(options) { + const requestOptions = { + credentials: 'same-origin', + ...options, + }; + + return fetch(options.url, requestOptions) + .then( + (response) => { + this._responseInterceptors.forEach((interceptor) => interceptor(response, options)); + return Promise.resolve(response); + }, + Promise.reject, + ).catch(console.error); + } +} diff --git a/frontend/src/services/http-client/http-client.spec.js b/frontend/src/services/http-client/http-client.spec.js new file mode 100644 index 000000000..e61e248f0 --- /dev/null +++ b/frontend/src/services/http-client/http-client.spec.js @@ -0,0 +1,116 @@ +import { HttpClient } from "./http-client"; + +const TEST_URL = 'http://example.com'; +const FAKE_RESPONSE = { + data: 'data', +}; + +describe('HttpClient', () => { + let httpClient; + + beforeEach(() => { + httpClient = new HttpClient(); + + // setup and spy on fake fetch API + spyOn(window, 'fetch').and.returnValue(Promise.resolve(FAKE_RESPONSE)); + }); + + it('should create', () => { + expect(httpClient).toBeTruthy(); + }); + + describe('get()', () => { + let params; + + beforeEach(() => { + params = { + url: TEST_URL, + }; + }); + + it('should GET the given url', () => { + httpClient.get(params); + expect(window.fetch).toHaveBeenCalledWith(params.url, jasmine.objectContaining({ method: 'GET' })); + }); + + it('should return a promise', (done) => { + const result = httpClient.get(params); + result.then((response) => { + expect(response).toEqual(FAKE_RESPONSE); + done(); + }); + }); + }); + + describe('post()', () => { + let params; + + beforeEach(() => { + params = { + url: TEST_URL, + }; + }); + + it('should POST the given url', () => { + httpClient.post(params); + expect(window.fetch).toHaveBeenCalledWith(params.url, jasmine.objectContaining({ method: 'POST' })); + }); + + it('should return a promise', (done) => { + const result = httpClient.post(params); + result.then((response) => { + expect(response).toEqual(FAKE_RESPONSE); + done(); + }); + }); + }); + + describe('Response Interceptors', () => { + it('can be added', () => { + const interceptor = () => {}; + expect(httpClient._responseInterceptors.length).toBe(0); + httpClient.addResponseInterceptor(interceptor); + expect(httpClient._responseInterceptors.length).toBe(1); + httpClient.addResponseInterceptor(interceptor); + expect(httpClient._responseInterceptors.length).toBe(2); + }); + + describe('get called', () => { + let intercepted1; + let intercepted2; + const interceptors = { + interceptor1: () => intercepted1 = true, + interceptor2: () => intercepted2 = true, + }; + + beforeEach(() => { + intercepted1 = false; + intercepted2 = false; + spyOn(interceptors, 'interceptor1').and.callThrough(); + spyOn(interceptors, 'interceptor2').and.callThrough(); + httpClient.addResponseInterceptor(interceptors.interceptor1); + httpClient.addResponseInterceptor(interceptors.interceptor2); + }); + + it('for GET requests', (done) => { + httpClient.get({ url: TEST_URL }).then(() => { + expect(intercepted1).toBeTruthy(); + expect(intercepted2).toBeTruthy(); + expect(interceptors.interceptor1).toHaveBeenCalledWith(FAKE_RESPONSE, jasmine.any(Object)); + expect(interceptors.interceptor2).toHaveBeenCalledWith(FAKE_RESPONSE, jasmine.any(Object)); + done(); + }); + }); + + it('for POST requests', (done) => { + httpClient.post({ url: TEST_URL }).then(() => { + expect(intercepted1).toBeTruthy(); + expect(intercepted2).toBeTruthy(); + expect(interceptors.interceptor1).toHaveBeenCalledWith(FAKE_RESPONSE, jasmine.any(Object)); + expect(interceptors.interceptor2).toHaveBeenCalledWith(FAKE_RESPONSE, jasmine.any(Object)); + done(); + }); + }); + }); + }); +}); diff --git a/frontend/src/services/i18n/i18n.js b/frontend/src/services/i18n/i18n.js new file mode 100644 index 000000000..7061f6ba6 --- /dev/null +++ b/frontend/src/services/i18n/i18n.js @@ -0,0 +1,32 @@ + /** + * I18n + * + * This module stores and serves translated strings, according to the users language settings. + * + * Translations are stored in /messages/frontend/*.msg. + * + * To make additions to any of these files accessible to JavaScrip Utilities + * you need to add them to the respective *.msg file and to the list of FrontendMessages + * in /src/Utils/Frontend/I18n.hs. + * + */ + +export class I18n { + + translations = {}; + + add(id, translation) { + this.translations[id] = translation; + } + + addMany(manyTranslations) { + Object.keys(manyTranslations).forEach((key) => this.add(key, manyTranslations[key])); + } + + get(id) { + if (!this.translations[id]) { + throw new Error('I18N Error: Translation missing for »' + id + '«!'); + } + return this.translations[id]; + } +} diff --git a/frontend/src/services/i18n/i18n.spec.js b/frontend/src/services/i18n/i18n.spec.js new file mode 100644 index 000000000..76e5348c0 --- /dev/null +++ b/frontend/src/services/i18n/i18n.spec.js @@ -0,0 +1,51 @@ +import { I18n } from "./i18n"; + +describe('I18n', () => { + let i18n; + + beforeEach(() => { + i18n = new I18n(); + }); + + // helper function + function expectTranslation(id, value) { + expect(i18n.translations[id]).toMatch(value); + } + + it('should create', () => { + expect(i18n).toBeTruthy(); + }); + + describe('add()', () => { + it('should add the translation', () => { + i18n.add('id1', 'translated-id1'); + expectTranslation('id1', 'translated-id1'); + }); + }); + + describe('addMany()', () => { + it('should add many translations', () => { + i18n.addMany({ + id1: 'translated-id1', + id2: 'translated-id2', + id3: 'translated-id3', + }); + expectTranslation('id1', 'translated-id1'); + expectTranslation('id2', 'translated-id2'); + expectTranslation('id3', 'translated-id3'); + }); + }); + + describe('get()', () => { + it('should return stored translations', () => { + i18n.translations.id1 = 'something'; + expect(i18n.get('id1')).toMatch('something'); + }); + + it('should throw error if translation is missing', () => { + expect(() => { + i18n.get('id1'); + }).toThrow(); + }); + }); +}); diff --git a/frontend/src/services/util-registry/util-registry.js b/frontend/src/services/util-registry/util-registry.js new file mode 100644 index 000000000..18b656b8c --- /dev/null +++ b/frontend/src/services/util-registry/util-registry.js @@ -0,0 +1,122 @@ +const DEBUG_MODE = /localhost/.test(window.location.href) && 0; + +export class UtilRegistry { + + _registeredUtils = []; + _activeUtilInstances = []; + _appInstance; + + constructor() { + document.addEventListener('DOMContentLoaded', () => this.setupAll()); + } + + /** + * function registerUtil + * + * utils need to have at least these properties: + * name: string | utils name, e.g. 'example' + * selector: string | utils selector, e.g. '[uw-example]' + * setup: Function | utils setup function, see below + * + * setup function must return instance object with at least these properties: + * name: string | utils name + * element: HTMLElement | element the util is applied to + * destroy: Function | function to destroy the util and remove any listeners + * + * @param util Object Utility that should be added to the registry + */ + register(util) { + if (DEBUG_MODE > 2) { + console.log('registering util "' + util.name + '"'); + console.log({ util }); + } + this._registeredUtils.push(util); + } + + deregister(name, destroy) { + const utilIndex = this._findUtilIndex(name); + + if (utilIndex >= 0) { + if (destroy === true) { + this._destroyUtilInstances(name); + } + + this._registeredUtils.splice(utilIndex, 1); + } + } + + setApp(appInstance) { + this._appInstance = appInstance; + } + + setupAll(scope) { + if (DEBUG_MODE > 1) { + console.info('registered js utilities:'); + console.table(this._registeredUtils); + } + + this._registeredUtils.forEach((util) => this.setup(util, scope)); + } + + setup(util, scope = document.body) { + if (DEBUG_MODE > 2) { + console.log('setting up util', { util }); + } + + if (util && typeof util.setup === 'function') { + const elements = this._findUtilElements(util, scope); + + elements.forEach((element) => { + let utilInstance = null; + + try { + utilInstance = util.setup(element, this._appInstance); + } catch(err) { + if (DEBUG_MODE > 0) { + console.warn('Error while trying to initialize a utility!', { util , element, err }); + } + } + + if (utilInstance) { + if (DEBUG_MODE > 2) { + console.info('Got utility instance for utility "' + util.name + '"', { utilInstance }); + } + + this._activeUtilInstances.push(utilInstance); + } + }); + } + } + + find(name) { + return this._registeredUtils.find((util) => util.name === name); + } + + _findUtilElements(util, scope) { + if (scope && scope.matches(util.selector)) { + return [scope]; + } + return Array.from(scope.querySelectorAll(util.selector)); + } + + _findUtilIndex(name) { + return this._registeredUtils.findIndex((util) => util.name === name); + } + + _destroyUtilInstances(name) { + this._activeUtilInstances + .map((util, index) => ({ + util: util, + index: index, + })) + .filter((activeUtil) => activeUtil.util.name === name) + .forEach((activeUtil) => { + // destroy util instance + activeUtil.util.destroy(); + delete this._activeUtilInstances[activeUtil.index]; + }); + + // get rid of now empty array slots + this._activeUtilInstances = this._activeUtilInstances.filter((util) => !!util); + } +} diff --git a/frontend/src/services/util-registry/util-registry.spec.js b/frontend/src/services/util-registry/util-registry.spec.js new file mode 100644 index 000000000..e3f69f63c --- /dev/null +++ b/frontend/src/services/util-registry/util-registry.spec.js @@ -0,0 +1,132 @@ +import { UtilRegistry } from "./util-registry"; + +const TEST_UTILS = [{ + name: 'util1', + selector: '#some-id', + setup: () => {}, +}, { + name: 'util2', + selector: '[uw-util]', + setup: () => {}, +}]; + +describe('UtilRegistry', () => { + let utilRegistry; + + beforeEach(() => { + utilRegistry = new UtilRegistry(); + }); + + it('should create', () => { + expect(utilRegistry).toBeTruthy(); + }); + + it('should setup all utlites when page is done loading', () => { + spyOn(utilRegistry, 'setupAll'); + document.dispatchEvent(new Event('DOMContentLoaded')); + expect(utilRegistry.setupAll).toHaveBeenCalled(); + }); + + describe('register()', () => { + it('should allow to add utilities', () => { + utilRegistry.register(TEST_UTILS[0]); + + const foundUtil = utilRegistry.find(TEST_UTILS[0].name); + expect(foundUtil).toEqual(TEST_UTILS[0]); + }); + }); + + describe('deregister()', () => { + it('should remove util', () => { + // register util + utilRegistry.register(TEST_UTILS[0]); + let foundUtil = utilRegistry.find('util1'); + expect(foundUtil).toBeTruthy(); + + // deregister util + utilRegistry.deregister(TEST_UTILS[0].name); + foundUtil = utilRegistry.find('util1'); + expect(foundUtil).toBeFalsy(); + }); + + it('should destroy util instances if requested', () => { + pending('TBD'); + }); + }); + + describe('setup()', () => { + it('should catch errors thrown by the utility', () => { + spyOn(TEST_UTILS[0], 'setup').and.throwError('some error'); + expect(() => { + utilRegistry.setup(TEST_UTILS[0]); + }).not.toThrow(); + }); + + it('should pass the app instance', () => { + const scope = document.createElement('div'); + const utilElement = document.createElement('div'); + utilElement.id = 'some-id'; + scope.appendChild(utilElement); + const fakeApp = { fn: () => {} }; + utilRegistry.setApp(fakeApp); + spyOn(TEST_UTILS[0], 'setup'); + utilRegistry.setup(TEST_UTILS[0], scope); + expect(TEST_UTILS[0].setup).toHaveBeenCalledWith(utilElement, fakeApp); + }); + + describe('given no scope', () => { + it('should use fallback scope', () => { + spyOn(TEST_UTILS[0], 'setup'); + utilRegistry.setup(TEST_UTILS[0]); + expect(TEST_UTILS[0].setup).not.toHaveBeenCalled(); + }); + }); + + describe('given a scope', () => { + let scope; + let utilElement1; + let utilElement2; + + beforeEach(() => { + scope = document.createElement('div'); + utilElement1 = document.createElement('div'); + utilElement2 = document.createElement('div'); + utilElement1.setAttribute('uw-util', ''); + utilElement2.setAttribute('uw-util', ''); + scope.appendChild(utilElement1); + scope.appendChild(utilElement2); + }); + + it('should call the utilities\' setup function for each matching element', () => { + spyOn(TEST_UTILS[1], 'setup'); + utilRegistry.setup(TEST_UTILS[1], scope); + // 2 matching elements in scope + expect(TEST_UTILS[1].setup.calls.count()).toBe(2); + expect(TEST_UTILS[1].setup.calls.argsFor(0)).toEqual([utilElement1, undefined]); + expect(TEST_UTILS[1].setup.calls.argsFor(1)).toEqual([utilElement2, undefined]); + }); + }); + }); + + describe('setupAll()', () => { + it('should setup all the utilities', () => { + spyOn(utilRegistry, 'setup'); + utilRegistry.register(TEST_UTILS[0]); + utilRegistry.register(TEST_UTILS[1]); + utilRegistry.setupAll(); + + expect(utilRegistry.setup.calls.count()).toBe(2); + expect(utilRegistry.setup.calls.argsFor(0)).toEqual([TEST_UTILS[0], undefined]); + expect(utilRegistry.setup.calls.argsFor(1)).toEqual([TEST_UTILS[1], undefined]); + }); + + it('should pass the given scope', () => { + spyOn(utilRegistry, 'setup'); + utilRegistry.register(TEST_UTILS[0]); + const scope = document.createElement('div'); + utilRegistry.setupAll(scope); + + expect(utilRegistry.setup).toHaveBeenCalledWith(TEST_UTILS[0], scope); + }); + }); +}); diff --git a/frontend/src/utils/alerts/alerts.js b/frontend/src/utils/alerts/alerts.js new file mode 100644 index 000000000..3e49080bc --- /dev/null +++ b/frontend/src/utils/alerts/alerts.js @@ -0,0 +1,206 @@ +import './alerts.scss'; + +/** + * + * Alerts Utility + * makes alerts interactive + * + * Attribute: uw-alerts + * + * Types of alerts: + * [default] + * Regular Info Alert + * Disappears automatically after 30 seconds + * Disappears after x seconds if explicitly specified via data-decay='x' + * Can be told not to disappear with data-decay='0' + * + * [success] + * Currently no special visual appearance + * Disappears automatically after 30 seconds + * + * [warning] + * Will be coloured warning-orange regardless of user's selected theme + * Does not disappear + * + * [error] + * Will be coloured error-red regardless of user's selected theme + * Does not disappear + * + * Example usage: + *
+ *
+ *
+ *
+ *
+ *
+ * This is some information + * + */ + +var ALERTS_UTIL_NAME = 'alerts'; +var ALERTS_UTIL_SELECTOR = '[uw-alerts]'; + +var ALERTS_INITIALIZED_CLASS = 'alerts--initialized'; +var ALERTS_ELEVATED_CLASS = 'alerts--elevated'; +var ALERTS_TOGGLER_CLASS = 'alerts__toggler'; +var ALERTS_TOGGLER_VISIBLE_CLASS = 'alerts__toggler--visible'; +var ALERTS_TOGGLER_APPEAR_DELAY = 120; + +var ALERT_CLASS = 'alert'; +var ALERT_INITIALIZED_CLASS = 'alert--initialized'; +var ALERT_CLOSER_CLASS = 'alert__closer'; +var ALERT_ICON_CLASS = 'alert__icon'; +var ALERT_CONTENT_CLASS = 'alert__content'; +var ALERT_INVISIBLE_CLASS = 'alert--invisible'; +var ALERT_AUTO_HIDE_DELAY = 10; +var ALERT_AUTOCLOSING_MATCHER = '.alert-info, .alert-success'; + +var alertsUtil = function(element, app) { + var togglerCheckRequested = false; + var togglerElement; + var alertElements; + + function init() { + if (!element) { + throw new Error('Alerts util has to be called with an element!'); + } + + if (element.classList.contains(ALERTS_INITIALIZED_CLASS)) { + return false; + } + + togglerElement = element.querySelector('.' + ALERTS_TOGGLER_CLASS); + alertElements = gatherAlertElements(); + + initToggler(); + initAlerts(); + + // register http client interceptor to filter out Alerts Header + setupHttpInterceptor(); + + // mark initialized + element.classList.add(ALERTS_INITIALIZED_CLASS); + + return { + name: ALERTS_UTIL_NAME, + element: element, + destroy: function() {}, + }; + } + + function gatherAlertElements() { + return Array.from(element.querySelectorAll('.' + ALERT_CLASS)).filter(function(alert) { + return !alert.classList.contains(ALERT_INITIALIZED_CLASS); + }); + } + + function initToggler() { + togglerElement.addEventListener('click', function() { + alertElements.forEach(function(alertEl) { + toggleAlert(alertEl, true); + }); + togglerElement.classList.remove(ALERTS_TOGGLER_VISIBLE_CLASS); + }); + } + + function initAlerts() { + alertElements.forEach(initAlert); + } + + function initAlert(alertElement) { + var autoHideDelay = ALERT_AUTO_HIDE_DELAY; + if (alertElement.dataset.decay) { + autoHideDelay = parseInt(alertElement.dataset.decay, 10); + } + + var closeEl = alertElement.querySelector('.' + ALERT_CLOSER_CLASS); + closeEl.addEventListener('click', function() { + toggleAlert(alertElement); + }); + + if (autoHideDelay > 0 && alertElement.matches(ALERT_AUTOCLOSING_MATCHER)) { + window.setTimeout(function() { + toggleAlert(alertElement); + }, autoHideDelay * 1000); + } + } + + function toggleAlert(alertEl, visible) { + alertEl.classList.toggle(ALERT_INVISIBLE_CLASS, !visible); + checkToggler(); + } + + function checkToggler() { + if (togglerCheckRequested) { + return; + } + + var alertsHidden = alertElements.reduce(function(acc, alert) { + return acc && alert.classList.contains(ALERT_INVISIBLE_CLASS); + }, true); + + window.setTimeout(function() { + togglerElement.classList.toggle(ALERTS_TOGGLER_VISIBLE_CLASS, alertsHidden); + togglerCheckRequested = false; + }, ALERTS_TOGGLER_APPEAR_DELAY); + } + + function setupHttpInterceptor() { + app.httpClient.addResponseInterceptor(responseInterceptor.bind(this)); + } + + function elevateAlerts() { + element.classList.add(ALERTS_ELEVATED_CLASS); + } + + function responseInterceptor(response) { + var alerts; + for (var header of response.headers) { + if (header[0] === 'alerts') { + var decodedHeader = decodeURIComponent(header[1]); + alerts = JSON.parse(decodedHeader); + break; + } + } + + if (alerts) { + alerts.forEach(function(alert) { + var alertElement = createAlertElement(alert.status, alert.content); + element.appendChild(alertElement); + alertElements.push(alertElement); + initAlert(alertElement); + }); + + elevateAlerts(); + } + } + + function createAlertElement(type, content) { + var alertElement = document.createElement('div'); + alertElement.classList.add(ALERT_CLASS, 'alert-' + type); + + var alertCloser = document.createElement('div'); + alertCloser.classList.add(ALERT_CLOSER_CLASS); + + var alertIcon = document.createElement('div'); + alertIcon.classList.add(ALERT_ICON_CLASS); + + var alertContent = document.createElement('div'); + alertContent.classList.add(ALERT_CONTENT_CLASS); + alertContent.innerHTML = content; + + alertElement.appendChild(alertCloser); + alertElement.appendChild(alertIcon); + alertElement.appendChild(alertContent); + + return alertElement; + } + + return init(); +}; + +export default { + name: ALERTS_UTIL_NAME, + selector: ALERTS_UTIL_SELECTOR, + setup: alertsUtil, +}; diff --git a/static/css/utils/alerts.scss b/frontend/src/utils/alerts/alerts.scss similarity index 100% rename from static/css/utils/alerts.scss rename to frontend/src/utils/alerts/alerts.scss diff --git a/frontend/src/utils/alerts/alerts.spec.js b/frontend/src/utils/alerts/alerts.spec.js new file mode 100644 index 000000000..65a4c4a72 --- /dev/null +++ b/frontend/src/utils/alerts/alerts.spec.js @@ -0,0 +1,8 @@ +import alerts from "./alerts"; + +describe('Alerts', () => { + + it('should be called alerts', () => { + expect(alerts.name).toMatch('alerts'); + }); +}); diff --git a/frontend/src/utils/asidenav/asidenav.js b/frontend/src/utils/asidenav/asidenav.js new file mode 100644 index 000000000..c5ba552c9 --- /dev/null +++ b/frontend/src/utils/asidenav/asidenav.js @@ -0,0 +1,105 @@ +import './asidenav.scss'; + +/** + * + * Asidenav Utility + * Correctly positions hovered asidenav submenus and handles the favorites button on mobile + * + * Attribute: uw-asidenav + * + * Example usage: + *
+ *
+ *
+ *