diff --git a/src/Foundation.hs b/src/Foundation.hs index 98bdd1c5a..e4de524d2 100644 --- a/src/Foundation.hs +++ b/src/Foundation.hs @@ -1019,23 +1019,27 @@ siteLayout' headingOverride widget = do -- JavaScript utils addScript $ StaticR js_utils_alerts_js addScript $ StaticR js_utils_asidenav_js + addScript $ StaticR js_utils_asyncForm_js addScript $ StaticR js_utils_asyncTable_js addScript $ StaticR js_utils_checkAll_js + addScript $ StaticR js_utils_httpClient_js addScript $ StaticR js_utils_form_js addScript $ StaticR js_utils_inputs_js + addScript $ StaticR js_utils_modal_js addScript $ StaticR js_utils_setup_js addScript $ StaticR js_utils_showHide_js addScript $ StaticR js_utils_tabber_js addStylesheet $ StaticR css_utils_alerts_scss addStylesheet $ StaticR css_utils_asidenav_scss + addStylesheet $ StaticR css_utils_asyncForm_scss addStylesheet $ StaticR css_utils_form_scss addStylesheet $ StaticR css_utils_inputs_scss + addStylesheet $ StaticR css_utils_modal_scss addStylesheet $ StaticR css_utils_showHide_scss addStylesheet $ StaticR css_utils_tabber_scss addStylesheet $ StaticR css_utils_tooltip_scss -- widgets $(widgetFile "default-layout") - $(widgetFile "standalone/modal") withUrlRenderer $(hamletFile "templates/default-layout-wrapper.hamlet") diff --git a/src/Handler/Admin.hs b/src/Handler/Admin.hs index 620470def..501cc97b9 100644 --- a/src/Handler/Admin.hs +++ b/src/Handler/Admin.hs @@ -95,7 +95,7 @@ postAdminTestR = do (FormFailure errs) -> forM_ errs $ addMessage Error . toHtml let emailWidget' = [whamlet| -
+ ^{emailWidget} |] diff --git a/static/css/utils/asyncForm.scss b/static/css/utils/asyncForm.scss new file mode 100644 index 000000000..cd4fe3159 --- /dev/null +++ b/static/css/utils/asyncForm.scss @@ -0,0 +1,9 @@ +.async-form-response { + margin: 20px 0; +} + +.async-form-loading { + opacity: 0.1; + transition: opacity 800ms ease-in-out; + pointer-events: none; +} diff --git a/templates/standalone/modal.lucius b/static/css/utils/modal.scss similarity index 98% rename from templates/standalone/modal.lucius rename to static/css/utils/modal.scss index 0718a4367..5cac989a3 100644 --- a/templates/standalone/modal.lucius +++ b/static/css/utils/modal.scss @@ -13,6 +13,7 @@ color: var(--color-font); padding: 0 65px 0 20px; overflow: auto; + overscroll-behavior: contain; transition: opacity .2s .1s ease-in-out, transform .3s ease-in-out; diff --git a/static/js/utils/asyncForm.js b/static/js/utils/asyncForm.js new file mode 100644 index 000000000..7917012a9 --- /dev/null +++ b/static/js/utils/asyncForm.js @@ -0,0 +1,60 @@ +(function collonadeClosure() { + 'use strict'; + + window.utils = window.utils || {}; + + var ASYNC_FORM_RESPONSE_CLASS = 'async-form-response'; + var ASYNC_FORM_LOADING_CLASS = 'async-form-loading'; + var ASYNC_FORM_MIN_DELAY = 600; + + window.utils.asyncForm = function(formElement, options) { + + var lastRequestTimestamp = 0; + + function setup() { + formElement.addEventListener('submit', submitHandler); + } + + function processResponse(response) { + var responseElement = document.createElement('div'); + responseElement.classList.add(ASYNC_FORM_RESPONSE_CLASS); + responseElement.innerHTML = response.content; + var parentElement = formElement.parentElement; + + // make sure there is a delay between click and response + var delay = Math.max(0, ASYNC_FORM_MIN_DELAY + lastRequestTimestamp - Date.now()); + setTimeout(function() { + parentElement.insertBefore(responseElement, formElement); + formElement.remove(); + }, delay); + } + + function submitHandler(event) { + event.preventDefault(); + + formElement.classList.add(ASYNC_FORM_LOADING_CLASS) + lastRequestTimestamp = Date.now(); + + var url = formElement.getAttribute('action'); + var headers = { }; + var body = new FormData(formElement); + + if (options && options.headers) { + Object.keys(options.headers).forEach(function(headerKey) { + headers[headerKey] = options.headers[headerKey]; + }); + } + + window.utils.httpClient.post(url, headers, body) + .then(function(response) { + return response.json(); + }).then(function(response) { + processResponse(response[0]) + }).catch(function(error) { + console.error('could not fetch or process response from ' + url, { error }); + }); + } + + setup(); + }; +})(); diff --git a/static/js/utils/asyncTable.js b/static/js/utils/asyncTable.js index d11f6298e..a7f46a134 100644 --- a/static/js/utils/asyncTable.js +++ b/static/js/utils/asyncTable.js @@ -139,14 +139,16 @@ // fetches new sorted table from url with params and replaces contents of current table function updateTableFrom(url, tableOptions) { + if (!window.utils.httpClient) { + throw new Error('httpClient not found!'); + } + tableOptions = tableOptions || {}; - fetch(url, { - credentials: 'same-origin', - headers: { - 'Accept': 'text/html', - [shortCircuitHeader]: tableIdent - } - }).then(function(response) { + var headers = { + 'Accept': 'text/html', + [shortCircuitHeader]: tableIdent + }; + window.utils.httpClient.get(url, headers).then(function(response) { if (!response.ok) { throw new Error('Looks like there was a problem fetching ' + url.href + '. Status Code: ' + response.status); } @@ -195,6 +197,9 @@ Array.from(wrapper.querySelectorAll('form')).forEach(function(form) { window.utils.setup('form', form); }); + Array.from(wrapper.querySelectorAll('.modal')).forEach(function(modal) { + window.utils.setup('modal', modal); + }); } init(); diff --git a/static/js/utils/form.js b/static/js/utils/form.js index 30ba76c96..1e0db4c20 100644 --- a/static/js/utils/form.js +++ b/static/js/utils/form.js @@ -3,9 +3,10 @@ window.utils = window.utils || {}; - var JS_INITIALIZED = 'js-initialized'; + var JS_INITIALIZED = 'js-form-initialized'; var SUBMIT_BUTTON_SELECTOR = '[type="submit"]:not([formnovalidate])'; var AUTOSUBMIT_BUTTON_SELECTOR = '[type="submit"][data-autosubmit]'; + var AJAX_SUBMIT_FLAG = 'ajaxSubmit'; function formValidator(inputs) { var done = true; @@ -23,7 +24,6 @@ if (form.classList.contains(JS_INITIALIZED)) { return false; } - form.classList.add(JS_INITIALIZED); // reactive buttons var submitBtn = form.querySelector(SUBMIT_BUTTON_SELECTOR); @@ -37,6 +37,13 @@ // hide autoSubmit submit button window.utils.setup('autoSubmit', form, options); + + // async form + if (AJAX_SUBMIT_FLAG in form.dataset) { + window.utils.setup('asyncForm', form, options); + } + + form.classList.add(JS_INITIALIZED); }; // registers input-listener for each element in (array) and diff --git a/static/js/utils/httpClient.js b/static/js/utils/httpClient.js new file mode 100644 index 000000000..18d82fbb2 --- /dev/null +++ b/static/js/utils/httpClient.js @@ -0,0 +1,32 @@ +(function collonadeClosure() { + 'use strict'; + + window.utils = window.utils || {}; + + window.utils.httpClient = (function() { + + function _fetch(url, method, additionalHeaders, body) { + var requestOptions = { + credentials: 'same-origin', + headers: { }, + method: method, + body: body, + }; + + Object.keys(additionalHeaders).forEach(function(headerKey) { + requestOptions.headers[headerKey] = additionalHeaders[headerKey]; + }); + + return fetch(url, requestOptions); + } + + return { + get: function(url, headers) { + return _fetch(url, 'GET', headers); + }, + post: function(url, headers, body) { + return _fetch(url, 'POST', headers, body); + }, + } + })(); +})(); diff --git a/static/js/utils/modal.js b/static/js/utils/modal.js new file mode 100644 index 000000000..63d7471d3 --- /dev/null +++ b/static/js/utils/modal.js @@ -0,0 +1,127 @@ +(function() { + 'use strict'; + + window.utils = window.utils || {}; + + var JS_INITIALIZED_CLASS = 'js-modal-initialized'; + var MODAL_OPEN_CLASS = 'modal--open'; + var MODAL_TRIGGER_CLASS = 'modal__trigger'; + var MODAL_CONTENT_CLASS = 'modal__content'; + var MAIN_CONTENT_CLASS = 'main__content-body' + var MODAL_CLOSABLE_FLAG = 'closeable'; + var MODAL_DYNAMIC_FLAG = 'dynamic'; + var MODAL_HEADERS = { + 'Is-Modal': 'True', + }; + var OVERLAY_CLASS = 'modal__overlay'; + var OVERLAY_OPEN_CLASS = 'modal__overlay--open'; + var CLOSER_CLASS = 'modal__closer'; + + window.utils.modal = function(modalElement, options) { + + if (!modalElement || modalElement.classList.contains(JS_INITIALIZED_CLASS)) { + return; + } + + var overlayElement = document.createElement('div'); + var closerElement = document.createElement('div'); + var triggerElement = document.querySelector('#' + modalElement.dataset.trigger); + + function setup() { + document.body.insertBefore(modalElement, null); + + setupForm(); + setupCloser(); + + triggerElement.classList.add(MODAL_TRIGGER_CLASS); + triggerElement.addEventListener('click', openHandler, false); + + modalElement.classList.add(JS_INITIALIZED_CLASS); + } + + function openHandler(event) { + event.preventDefault(); + open(); + } + + function open() { + modalElement.classList.add(MODAL_OPEN_CLASS); + overlayElement.classList.add(OVERLAY_CLASS); + document.body.insertBefore(overlayElement, modalElement); + overlayElement.classList.add(OVERLAY_OPEN_CLASS); + + var modalUrl = triggerElement.getAttribute('href'); + if (modalUrl && MODAL_DYNAMIC_FLAG in modalElement.dataset) { + fillModal(modalUrl); + } + + document.addEventListener('keyup', keyupHandler); + } + + function closeHandler(event) { + event.preventDefault(); + close(); + } + + function close() { + overlayElement.classList.remove(OVERLAY_OPEN_CLASS); + modalElement.classList.remove(MODAL_OPEN_CLASS); + + document.removeEventListener('keyup', keyupHandler); + }; + + function setupForm() { + var form = modalElement.querySelector('form'); + if (form) { + window.utils.setup('form', form, { headers: MODAL_HEADERS }); + } + } + + function setupCloser() { + if (MODAL_CLOSABLE_FLAG in modalElement.dataset) { + modalElement.insertBefore(closerElement, null); + closerElement.classList.add(CLOSER_CLASS); + closerElement.addEventListener('click', closeHandler, false); + overlayElement.addEventListener('click', closeHandler, false); + } + } + + function fillModal(url) { + if (!window.utils.httpClient) { + throw new Error('httpClient not found! Can\' fetch modal content from ' + url); + } + + window.utils.httpClient.get(url, MODAL_HEADERS) + .then(function(response) { + response.text().then(processResponse); + }); + } + + function processResponse(reponseBody) { + var modalContent = document.createElement('div'); + modalContent.classList.add(MODAL_CONTENT_CLASS); + modalContent.innerHTML = reponseBody; + + var contentBody = modalContent.querySelector('.' + MAIN_CONTENT_CLASS); + + if (contentBody) { + modalContent.innerHTML = contentBody.innerHTML; + } + + var previousModalContent = modalElement.querySelector('.' + MODAL_CONTENT_CLASS); + if (previousModalContent) { + previousModalContent.remove(); + } + modalElement.insertBefore(modalContent, null); + setupForm(); + } + + function keyupHandler(event) { + if (event.key === 'Escape') { + close(); + } + } + + setup(); + }; +})(); diff --git a/static/js/utils/setup.js b/static/js/utils/setup.js index b71e226a5..36e61a9b7 100644 --- a/static/js/utils/setup.js +++ b/static/js/utils/setup.js @@ -5,9 +5,16 @@ var registeredSetupListeners = {}; - window.utils.setup = function(utilType, scope, options) { +/** + * setup function to initiate a util (utilName) on a scope (sope) with options (options). + * + * Utils need to be defined as property of `window.utils` and need to accept a scope and (optionally) options. + * Example: `window.utils.autoSubmit = function(scope, options) { ... };` + */ - if (!utilType || !scope) { + window.utils.setup = function(utilName, scope, options) { + + if (!utilName || !scope) { return; } @@ -15,44 +22,44 @@ var listener = function(event) { - if (event.detail.targetUtil !== utilType) { + if (event.detail.targetUtil !== utilName) { return false; } if (options.setupFunction) { options.setupFunction(scope, options); } else { - var util = window.utils[utilType]; + var util = window.utils[utilName]; if (!util) { - throw new Error('"' + utilType + '" is not a known js util'); + throw new Error('"' + utilName + '" is not a known js util'); } util(scope, options); } }; - if (registeredSetupListeners[utilType] && !options.singleton) { - registeredSetupListeners[utilType].push(listener); + if (registeredSetupListeners[utilName] && !options.singleton) { + registeredSetupListeners[utilName].push(listener); } else { - window.utils.teardown(utilType); - registeredSetupListeners[utilType] = [ listener ]; + window.utils.teardown(utilName); + registeredSetupListeners[utilName] = [ listener ]; } document.addEventListener('setup', listener); document.dispatchEvent(new CustomEvent('setup', { - detail: { targetUtil: utilType, module: 'none' }, + detail: { targetUtil: utilName, module: 'none' }, bubbles: true, cancelable: true, })); }; - window.utils.teardown = function(utilType) { - if (registeredSetupListeners[utilType]) { - registeredSetupListeners[utilType].forEach(function(listener) { + window.utils.teardown = function(utilName) { + if (registeredSetupListeners[utilName]) { + registeredSetupListeners[utilName].forEach(function(listener) { document.removeEventListener('setup', listener); }); - delete registeredSetupListeners[utilType]; + delete registeredSetupListeners[utilName]; } } })(); diff --git a/templates/default-layout.hamlet b/templates/default-layout.hamlet index 87870650b..f43282fb0 100644 --- a/templates/default-layout.hamlet +++ b/templates/default-layout.hamlet @@ -29,8 +29,8 @@ $if not isModal ^{widget} - - ^{alerts} + + ^{alerts} $if not isModal diff --git a/templates/help.hamlet b/templates/help.hamlet index 3fe70ebe6..5cc4c96b8 100644 --- a/templates/help.hamlet +++ b/templates/help.hamlet @@ -1,4 +1,3 @@ -_{MsgHelpIntroduction} - +
_{MsgHelpIntroduction} ^{formWidget} diff --git a/templates/standalone/modal.hamlet b/templates/standalone/modal.hamlet deleted file mode 100644 index 6e29713b0..000000000 --- a/templates/standalone/modal.hamlet +++ /dev/null @@ -1 +0,0 @@ - diff --git a/templates/standalone/modal.julius b/templates/standalone/modal.julius deleted file mode 100644 index f624d1b46..000000000 --- a/templates/standalone/modal.julius +++ /dev/null @@ -1,300 +0,0 @@ -(function() { - 'use strict'; - - window.utils = window.utils || {}; - - window.utils.modal = function(modal) { - var overlay = document.createElement('div'); - var closer = document.createElement('div'); - var trigger = document.querySelector('#' + modal.dataset.trigger); - // var origParent = modal.parentNode; - - function open(event) { - if (!modal.classList.contains('js-modal-initialized')) { - return; - } - - // disable modals for narrow screens - if (event) { - event.preventDefault(); - } - modal.classList.add('modal--open'); - overlay.classList.add('modal__overlay'); - // document.body.insertBefore(modal, null); - document.body.insertBefore(overlay, modal); - overlay.classList.add('modal__overlay--open'); - - if ('closeable' in modal.dataset) { - closer.classList.add('modal__closer'); - modal.insertBefore(closer, null); - closer.addEventListener('click', close, false); - overlay.addEventListener('click', close, false); - } - } - - // you can open this modal via event - // example: document.dispatchEvent(new CustomEvent('modal-open', { details: { for: 'modal-[id]' }})) - function openOnEvent(event) { - if (event.detail.for === modal.getAttribute('id')) { - open(); - } - } - - function close(event) { - overlay.remove(); - // origParent.insertBefore(modal, null); - modal.classList.remove('modal--open'); - closer.removeEventListener('click', close, false); - }; - - function setup() { - document.body.insertBefore(modal, null); - - // every modal can be openend via document-wide event, see openOnEvent - document.addEventListener('modal-open', openOnEvent, false); - - if ('dynamic' in modal.dataset) { - function fetchModal(url, init) { - function responseHtml(body) { - var modalContent = document.createElement('div'); - modalContent.innerHTML = body; - - var contentBody = modalContent.querySelector('.main__content-body'); - var scriptTags = []; - if (contentBody) { - modalContent.querySelectorAll('script').forEach(function(scriptTag) { - var existsAlready = Array.from(document.body.querySelectorAll('script')).some(function(haystack) { - if (haystack.text === scriptTag.text && haystack.getAttribute('src') === scriptTag.getAttribute('src')) { - scriptTags.push(haystack); - return true; - } else { - return false; - } - }); - if (existsAlready) - return; - - var scriptClone = document.createElement('script'); - if (scriptTag.text) - scriptClone.text = scriptTag.text; - if (scriptTag.hasAttributes()) { - var attrs = scriptTag.attributes; - for (var i = attrs.length - 1; i >= 0; i--) { - scriptClone.setAttribute(attrs[i].name, attrs[i].value); - } - } - - document.body.insertBefore(scriptClone, null); - scriptTags.push(scriptClone); - }); - - modalContent.querySelectorAll('style').forEach(function(styleTag) { - if (Array.from(document.head.querySelectorAll('style')).some(function(haystack) { - return haystack.innerText === styleTag.innerText; - })) { return } - - document.head.insertBefore(styleTag, null); - }); - - modalContent.querySelectorAll('link').forEach(function(linkTag) { - if (linkTag.getAttribute('rel') !== 'stylesheet') - return; - - if (Array.from(document.head.querySelectorAll('link')).some(function(haystack) { - return haystack.getAttribute('href') === linkTag.getAttribute('href'); - })) { return } - - - document.head.insertBefore(linkTag, null); - }); - - var modalAlertsEl = modalContent.querySelector('#alerts'); - var alertsEl = document.body.querySelector('#alerts'); - if (alertsEl && modalAlertsEl) { - var modalAlerts = Array.from(modalAlertsEl.childNodes); - - modalAlerts.forEach(function(alertEl) { - alertsEl.insertBefore(alertEl, alertsEl.querySelector('.alerts__toggler')); - }); - - if (modalAlerts.length !== 0) - document.dispatchEvent(new CustomEvent('setup', { detail: { scope: alertsEl } })); - - contentBody.removeChild(modalAlertsEl); - } - - modalContent = contentBody; - } - modalContent.classList.add('modal__content'); - - var nudgeAttr = function(attr, x) { - var oldVal = x.getAttribute(attr); - var newVal = modal.getAttribute('id') + '__' + oldVal; - - // console.log(oldVal, newVal); - x.setAttribute(attr, newVal); - }; - - var idAttrs = ['id', 'for', 'data-conditional-id']; - idAttrs.map(function(attr) { - modalContent.querySelectorAll('[' + attr + ']').forEach(function(x) { nudgeAttr(attr, x); }); - }); - - modal.querySelectorAll('.modal__content').forEach(function(prev) { modal.removeChild(prev); }); - modal.insertBefore(modalContent, null); - - var triggerContentLoad = function() { - console.log('contentReady', modal); - - document.dispatchEvent(new CustomEvent('setup', { - detail: { scope: modal }, - bubbles: true, - cancelable: true - })); - } - - scriptTags.forEach(function(t) { t.addEventListener('load', triggerContentLoad); }); - triggerContentLoad(); - - return 'html'; - } - - function responseJson(data) { - var alertsEl = document.querySelector('#alerts'); - if (!alertsEl) - return null; - - for (var i = 0; i < data.length; i++) { - var alert = document.createElement('div'); - alert.classList.add('alert', 'alert-' + data[i].class); - var alertContent = document.createElement('div'); - alertContent.classList.add('alert__content'); - alertContent.innerHTML = data[i].content; - alert.appendChild(alertContent); - - alertsEl.insertBefore(alert, alertsEl.querySelector('.alerts__toggler')); - } - - document.dispatchEvent(new CustomEvent('setup', { detail: { scope: alertsEl }, bubbles: true, cancelable: true })); - - return 'json'; - } - - - return fetch(url, init).then(function(response) { - var contentType = response.headers.get('Content-Type') - if (contentType && contentType.includes('text/html')) { - return response.text().then(responseHtml); - } else if (contentType && contentType.includes('application/json')) { - return response.json().then(responseJson); - } else { - console.log(response); - return null; - } - }); - }; - - modal.addEventListener('modal-fetch', function(event) { - var dynamicContentURL = (event.detail && event.detail.url) || trigger.getAttribute('href'); - - var fetchInit = (event.detail && event.detail.init) || { - credentials: 'same-origin', - headers: { - #{String (toPathPiece HeaderIsModal)}: 'True' - } - }; - - if (dynamicContentURL.length > 0) { - fetchModal(dynamicContentURL, fetchInit).then((event.detail && event.detail.then) || (function(){})); - } - }); - modal.dispatchEvent(new CustomEvent('modal-fetch', { - detail: { - then: (function() { - if (!trigger) - return; - - trigger.classList.add('modal__trigger'); - trigger.addEventListener('click', open, false); - }) - } - })); - } else if (trigger) { // if modal has trigger assigned to it open modal on click - trigger.classList.add('modal__trigger'); - trigger.addEventListener('click', open, false); - } - - - // tell further modals, that this one already got initialized - modal.classList.add('js-modal-initialized'); - - modal.addEventListener('modal-close', close); - } - - setup(); - }; - - window.utils.ajaxSubmit = function(modal, form) { - function doSubmit(event) { - event.preventDefault(); - - var modalContent = modal.querySelector('.modal__content'); - modalContent.style.pointerEvents = 'none'; - modalContent.style.opacity = 0.5; - - modal.dispatchEvent(new CustomEvent('modal-fetch', { - detail: { - url: form.target, - init: { - credentials: 'same-origin', - headers: { - #{String (toPathPiece HeaderIsModal)}: 'True' - }, - method: form.method, - body: new FormData(form) - }, - then: (function(typ) { - modal.dispatchEvent(new CustomEvent('modal-close')); - - modalContent.style.pointerEvents = 'auto'; - modalContent.style.opacity = 1; - - if (typ === 'json') { - modal.dispatchEvent(new CustomEvent('modal-fetch')); - } - }) - }, - bubbles: true, - cancelable: true - })); - }; - - form.addEventListener('submit', doSubmit); - form.classList.add('js-ajax-initialized'); - }; -})(); - -document.addEventListener('setup', function(e) { - if (e.detail.module && e.detail.module !== 'modal') - return; - - Array.from(e.detail.scope.querySelectorAll('.js-modal:not(.js-modal-initialized)')).forEach(function(modal) { - window.utils.modal(modal); - }); - - if (e.detail.scope.classList.contains('js-modal')) { - Array.from(e.detail.scope.querySelectorAll('form[data-ajax-submit]:not(.js-ajax-initialized)')).forEach(function(form) { - window.utils.ajaxSubmit(e.detail.scope, form); - }); - } else { - Array.from(e.detail.scope.querySelectorAll('.js-modal')).map(function(modal) { - Array.from(modal.querySelectorAll('form[data-ajax-submit]:not(.js-ajax-initialized)')).forEach(function(form) { - window.utils.ajaxSubmit(modal, form); - }); - }); - }; -}, false); - -document.addEventListener('DOMContentLoaded', function() { - document.dispatchEvent(new CustomEvent('setup', { detail: { scope: document.body, module: 'modal' }, bubbles: true, cancelable: true })) -}); diff --git a/templates/widgets/modal/modal.julius b/templates/widgets/modal/modal.julius new file mode 100644 index 000000000..14a53bde7 --- /dev/null +++ b/templates/widgets/modal/modal.julius @@ -0,0 +1,7 @@ +document.addEventListener('DOMContentLoaded', function() { + // TODO: replace for loop with one precise query for this specific modal instance + var modalElements = Array.from(document.querySelectorAll('.modal')); + modalElements.forEach(function(modal) { + window.utils.setup('modal', modal); + }); +});