From c348b7cb035b2c48a8d85e1fe394116bba45f36e Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Tue, 5 May 2020 15:09:33 +0200 Subject: [PATCH] feat(async-table): history api Fixes #426 --- .../lib/storage-manager/storage-manager.js | 144 ++++++++++++++---- .../src/services/html-helpers/html-helpers.js | 2 +- frontend/src/utils/async-table/async-table.js | 107 +++++++++---- src/Handler/Utils/Table/Pagination.hs | 17 ++- src/Utils.hs | 2 +- templates/table/cell/header.hamlet | 4 +- 6 files changed, 215 insertions(+), 61 deletions(-) diff --git a/frontend/src/lib/storage-manager/storage-manager.js b/frontend/src/lib/storage-manager/storage-manager.js index d2461518c..418d92224 100644 --- a/frontend/src/lib/storage-manager/storage-manager.js +++ b/frontend/src/lib/storage-manager/storage-manager.js @@ -9,9 +9,10 @@ export const LOCATION = { LOCAL: 'local', SESSION: 'session', WINDOW: 'window', + HISTORY: 'history', }; -const LOCATION_SHADOWING = [ LOCATION.WINDOW, LOCATION.SESSION, LOCATION.LOCAL ]; +const LOCATION_SHADOWING = [ LOCATION.HISTORY, LOCATION.WINDOW, LOCATION.SESSION, LOCATION.LOCAL ]; export class StorageManager { @@ -26,7 +27,16 @@ export class StorageManager { constructor(namespace, version, options) { this._debugLog('constructor', namespace, version, options); - this.namespace = namespace; + if (typeof namespace === 'object') { + let sep = '_'; + const namespace_arr = Array.from(namespace); + while (namespace_arr.some(str => str.includes(sep))) + sep = sep + '_'; + + this.namespace = Array.from(namespace).join(sep); + } else { + this.namespace = namespace; + } this.version = semver.valid(version); if (!namespace) { @@ -48,7 +58,7 @@ export class StorageManager { throw new Error('Cannot setup StorageManager without window or global'); if (this._options.encryption) { - [LOCATION.LOCAL, LOCATION.SESSION].forEach((location) => { + [LOCATION.LOCAL, LOCATION.SESSION, LOCATION.HISTORY].forEach((location) => { const encryption = this._options.encryption.all || this._options.encryption[location]; if (encryption) this._requestStorageKey({ location: location, encryption: encryption }); }); @@ -70,17 +80,21 @@ export class StorageManager { switch (location) { case LOCATION.LOCAL: { - this._saveToLocalStorage(this._updateStorage(this._getFromLocalStorage(options), { [key]: value }, LOCATION.LOCAL, options)); + this._saveToLocalStorage({ ...this._getFromLocalStorage(options), [key]: value}, options); break; } case LOCATION.SESSION: { - this._saveToSessionStorage(this._updateStorage(this._getFromSessionStorage(options), { [key]: value }, LOCATION.SESSION, options)); + this._saveToSessionStorage({ ...this._getFromSessionStorage(options), [key]: value}, options); break; } case LOCATION.WINDOW: { this._saveToWindow({ ...this._getFromWindow(), [key]: value }); break; } + case LOCATION.HISTORY: { + this._saveToHistory({ ...this._getFromHistory(), [key]: value }, options); + break; + } default: console.error('StorageManager.save cannot save item with unsupported location'); } @@ -112,6 +126,10 @@ export class StorageManager { val = this._getFromWindow()[key]; break; } + case LOCATION.HISTORY: { + val = this._getFromHistory(options)[key]; + break; + } default: console.error('StorageManager.load cannot load item with unsupported location'); } @@ -138,14 +156,14 @@ export class StorageManager { delete val[key]; - return this._saveToLocalStorage(val); + return this._saveToLocalStorage(val, options); } case LOCATION.SESSION: { let val = this._getFromSessionStorage(options); delete val[key]; - return this._saveToSessionStorage(val); + return this._saveToSessionStorage(val, options); } case LOCATION.WINDOW: { let val = this._getFromWindow(); @@ -154,6 +172,14 @@ export class StorageManager { return this._saveToWindow(val); } + case LOCATION.HISTORY: { + let val = this._getFromHistory(options); + + delete val[key]; + + return this._saveToHistory(val, options); + } + default: console.error('StorageManager.load cannot load item with unsupported location'); } @@ -177,6 +203,8 @@ export class StorageManager { return this._clearSessionStorage(); case LOCATION.WINDOW: return this._clearWindow(); + case LOCATION.HISTORY: + return this._clearHistory(options && options.history); default: console.error('StorageManager.clear cannot clear with unsupported location'); } @@ -185,7 +213,7 @@ export class StorageManager { } - _getFromLocalStorage(options=this._options) { + _getFromLocalStorage(options) { this._debugLog('_getFromLocalStorage', options); let state; @@ -210,7 +238,7 @@ export class StorageManager { } } - _saveToLocalStorage(state) { + _saveToLocalStorage(state, options) { this._debugLog('_saveToLocalStorage', state); if (!state) @@ -223,8 +251,8 @@ export class StorageManager { } else { versionedState = { version: this.version, ...state }; } - - window.localStorage.setItem(this.namespace, JSON.stringify(versionedState)); + + window.localStorage.setItem(this.namespace, JSON.stringify(this._updateStorage({}, versionedState, LOCATION.LOCAL, options))); } _clearLocalStorage() { @@ -240,10 +268,10 @@ export class StorageManager { if (!this._global || !this._global.App) return {}; - if (!this._global.App.Storage) - this._global.App.Storage = {}; + if (!this._global.App.Storage || !this._global.App.Storage[this.namespace]) + return {}; - return this._global.App.Storage; + return this._global.App.Storage[this.namespace]; } _saveToWindow(value) { @@ -274,8 +302,71 @@ export class StorageManager { } } + _getFromHistory(options) { + this._debugLog('_getFromHistory'); - _getFromSessionStorage(options=this._options) { + if (!this._global || !this._global.history) + return {}; + + if (!this._global.history.state || !this._global.history.state[this.namespace]) + return {}; + + return this._getFromStorage(this._global.history.state[this.namespace], LOCATION.HISTORY, options); + } + + _saveToHistory(value, options) { + this._debugLog('_saveToHistory', options); + + + if (!this._global || !this._global.history) { + throw new Error('StorageManager._saveToHistory called when window.history is not available'); + } + + const push = (options.history && typeof options.history.push !== 'undefined') ? !!options.history.push : true; + const title = (options.history && options.history.title) || (this._global.document && this._global.document.title) || ''; + const url = (options.history && options.history.url) || (this._global.document && this._global.document.location); + + const state = this._global.history.state || {}; + state[this.namespace] = this._updateStorage({}, value, LOCATION.HISTORY, options); + + this._debugLog('_saveToHistory', { state: state, push: push, title: title, url: url}); + + if (push) + this._global.history.pushState(state, title, url); + else + this._global.history.replaceState(state, title, url); + } + + _clearHistory(options) { + this._debugLog('_clearHistory', options); + + if (!this._global || !this._global.history) { + throw new Error('StorageManager._clearHistory called when window.history is not available'); + } + + const push = (options.history && typeof options.history.push !== 'undefined' ? !!options.history.push : true) || true; + const title = (options.history && options.history.title) || (this._global.document && this._global.document.title) || ''; + const url = (options.history && options.history.url) || (this._global.document && this._global.document.location); + + const state = this._global.history.state || {}; + delete state[this.namespace]; + + if (push) + this._global.history.pushState(state, title, url); + else + this._global.history.replaceState(state, title, url); + } + + addHistoryListener(listener, options=this._options, ...args) { + const modified_listener = (function(event, ...listener_args) { // eslint-disable-line no-unused-vars + this._global.setTimeout(() => listener(this._getFromHistory(options), ...listener_args)); + }).bind(this); + + this._global.addEventListener('popstate', modified_listener, args); + } + + + _getFromSessionStorage(options) { this._debugLog('_getFromSessionStorage', options); let state; @@ -300,7 +391,7 @@ export class StorageManager { } } - _saveToSessionStorage(state) { + _saveToSessionStorage(state, options) { this._debugLog('_saveToSessionStorage', state); if (!state) @@ -314,7 +405,7 @@ export class StorageManager { versionedState = { version: this.version, ...state }; } - window.sessionStorage.setItem(this.namespace, JSON.stringify(versionedState)); + window.sessionStorage.setItem(this.namespace, JSON.stringify(this._updateStorage({}, versionedState, LOCATION.SESSION, options))); } _clearSessionStorage() { @@ -324,10 +415,10 @@ export class StorageManager { } - _getFromStorage(storage, location, options=this._options) { + _getFromStorage(storage, location, options) { this._debugLog('_getFromStorage', storage, location, options); - const encryption = options.encryption && (options.encryption.all || options.encryption[location]); + const encryption = options && options.encryption && (options.encryption.all || options.encryption[location]); if (encryption && storage.encryption) { return { ...storage, ...JSON.parse(decrypt(storage.encryption.ciphertext, this._encryptionKey[location]) || null) }; } else { @@ -335,10 +426,10 @@ export class StorageManager { } } - _updateStorage(storage, update, location, options=this._options) { + _updateStorage(storage, update, location, options) { this._debugLog('_updateStorage', storage, update, location, options); - const encryption = options.encryption && (options.encryption.all || options.encryption[location]); + const encryption = options && options.encryption && (options.encryption.all || options.encryption[location]); if (encryption && storage.encryption) { const updatedDecryptedStorage = { ...JSON.parse(decrypt(storage.encryption.ciphertext, this._encryptionKey[location]) || null), ...update }; console.log('updatedDecryptedStorage', updatedDecryptedStorage); @@ -357,13 +448,13 @@ export class StorageManager { const enc = this.load('encryption', { ...options, encryption: false }); const requestBody = { type : options.encryption, - length : 42, + length : sodium.crypto_secretbox_KEYBYTES, salt : enc.salt, timestamp : enc.timestamp, }; this._global.App.httpClient.post({ - url: '../../../../../../user/storage-key', // TODO use APPROOT instead + url: '/user/storage-key', headers: { 'Content-Type' : HttpClient.ACCEPT.JSON, 'Accept' : HttpClient.ACCEPT.JSON, @@ -381,11 +472,10 @@ export class StorageManager { }).catch(console.error); } - _debugLog() { + _debugLog() {} // _debugLog(fName, ...args) { - // console.log(`[DEBUGLOG] StorageManager.${fName}`, { args: args, instance: this }); - } - + // console.log(`[DEBUGLOG] StorageManager.${fName}`, { args: args, instance: this }); + // } } diff --git a/frontend/src/services/html-helpers/html-helpers.js b/frontend/src/services/html-helpers/html-helpers.js index 5799fee24..48f5cc082 100644 --- a/frontend/src/services/html-helpers/html-helpers.js +++ b/frontend/src/services/html-helpers/html-helpers.js @@ -17,7 +17,7 @@ export class HtmlHelpers { idPrefix = this._getIdPrefix(); this._prefixIds(element, idPrefix); } - return Promise.resolve({ idPrefix, element }); + return Promise.resolve({ idPrefix, element, headers: response.headers }); }, Promise.reject, ).catch(console.error); diff --git a/frontend/src/utils/async-table/async-table.js b/frontend/src/utils/async-table/async-table.js index e40ecc86d..811e2b3db 100644 --- a/frontend/src/utils/async-table/async-table.js +++ b/frontend/src/utils/async-table/async-table.js @@ -13,7 +13,9 @@ const INPUT_DEBOUNCE = 600; const FILTER_DEBOUNCE = 100; const HEADER_HEIGHT = 80; -const ASYNC_TABLE_LOCAL_STORAGE_KEY = 'ASYNC_TABLE'; +const ASYNC_TABLE_STORAGE_KEY = 'ASYNC_TABLE'; +const ASYNC_TABLE_STORAGE_VERSION = '2.0.0'; + const ASYNC_TABLE_SCROLLTABLE_SELECTOR = '.scrolltable'; const ASYNC_TABLE_INITIALIZED_CLASS = 'async-table--initialized'; const ASYNC_TABLE_LOADING_CLASS = 'async-table--loading'; @@ -31,6 +33,8 @@ export class AsyncTable { _asyncTableHeader; _asyncTableId; + _asyncTableIdent; + _ths = []; _pageLinks = []; _pagesizeForm; @@ -47,7 +51,10 @@ export class AsyncTable { }; _ignoreRequest = false; - _storageManager = new StorageManager(ASYNC_TABLE_LOCAL_STORAGE_KEY, '1.0.0', { location: LOCATION.WINDOW }); + _windowStorage; + _historyStorage; + + _active = true; constructor(element, app) { if (!element) { @@ -79,21 +86,58 @@ export class AsyncTable { this._cssIdPrefix = findCssIdPrefix(rawTableId); this._asyncTableId = rawTableId.replace(this._cssIdPrefix, ''); + this._asyncTableIdent = this._asyncTableId.replace(/-table-wrapper$/, ''); + + if (!this._asyncTableIdent) { + throw new Error('Async Table cannot be set up without an ident!'); + } + + this._windowStorage = new StorageManager([ASYNC_TABLE_STORAGE_KEY, this._asyncTableIdent], ASYNC_TABLE_STORAGE_VERSION, { location: LOCATION.WINDOW }); + this._historyStorage = new StorageManager([ASYNC_TABLE_STORAGE_KEY, this._asyncTableIdent], ASYNC_TABLE_STORAGE_VERSION, { location: LOCATION.HISTORY }); + // find scrolltable wrapper 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!'); } - this._setupTableFilter(); - this._processStorage(); - // clear currentTableUrl from previous requests - this._storageManager.remove('currentTableUrl'); + this._setupTableFilter(); + + this._windowStorage.remove('currentTableUrl'); + + if (!('currentTableUrl' in this._element.dataset)) { + this._element.dataset['currentTableUrl'] = document.location.href; + this._historyStorage.save('currentTableUrl', document.location.href, { location: LOCATION.HISTORY, history: { push: false } }); + } + + this._historyListener(); + + this._historyStorage.addHistoryListener(this._historyListener.bind(this)); // mark initialized - this._element.classList.add(ASYNC_TABLE_INITIALIZED_CLASS); + if (this._active) + this._element.classList.add(ASYNC_TABLE_INITIALIZED_CLASS); + } + + _historyListener(historyState) { + if (!this._active) + return; + + const windowUrl = this._element.dataset['currentTableUrl']; + const historyUrl = historyState ? historyState['currentTableUrl'] : this._historyStorage.load('currentTableUrl'); + this._debugLog('_historyListener', historyState, windowUrl, historyUrl); + + if (this._isEquivalentUrl(windowUrl, historyUrl)) + return; + + this._debugLog('_historyListener', historyUrl); + this._updateTableFrom(historyUrl || document.location.href, undefined, true); + } + + _isEquivalentUrl(a, b) { + return a === b; } start() { @@ -113,7 +157,7 @@ export class AsyncTable { this._ths.forEach((th) => { th.clickHandler = (event) => { - this._storageManager.save('horizPos', (this._scrollTable || {}).scrollLeft); + this._windowStorage.save('horizPos', (this._scrollTable || {}).scrollLeft); this._linkClickHandler(event); }; th.element.addEventListener('click', th.clickHandler); @@ -135,7 +179,7 @@ export class AsyncTable { left: this._scrollTable.offsetLeft || 0, behavior: 'smooth', }; - this._storageManager.save('scrollTo', scrollTo); + this._windowStorage.save('scrollTo', scrollTo); } this._linkClickHandler(event); }; @@ -256,7 +300,7 @@ export class AsyncTable { const prefix = findCssIdPrefix(focusedInput.id); const focusId = focusedInput.id.replace(prefix, ''); callback = function(wrapper) { - const idPrefix = this._storageManager.load('cssIdPrefix'); + const idPrefix = this._windowStorage.load('cssIdPrefix'); const toBeFocused = wrapper.querySelector('#' + idPrefix + focusId); if (toBeFocused) { toBeFocused.focus(); @@ -268,34 +312,33 @@ export class AsyncTable { } _serializeTableFilterToURL(tableFilterForm) { - const url = new URL(this._storageManager.load('currentTableUrl') || window.location.href); + const url = new URL(this._windowStorage.load('currentTableUrl') || window.location.href); // create new FormData and format any date values const formData = Datepicker.unformatAll(tableFilterForm, new FormData(tableFilterForm)); - for (var k of url.searchParams.keys()) { - url.searchParams.delete(k); - } + this._debugLog('_serializeTableFilterToURL', Array.from(formData.entries()), url.href); - for (var kv of formData.entries()) { - url.searchParams.append(kv[0], kv[1]); - } + const searchParams = new URLSearchParams(Array.from(formData.entries())); + url.search = searchParams.toString(); + + this._debugLog('_serializeTableFilterToURL', url.href); return url; } _processStorage() { - const scrollTo = this._storageManager.load('scrollTo'); + const scrollTo = this._windowStorage.load('scrollTo'); if (scrollTo && this._scrollTable) { window.scrollTo(scrollTo); } - this._storageManager.remove('scrollTo'); + this._windowStorage.remove('scrollTo'); - const horizPos = this._storageManager.load('horizPos'); + const horizPos = this._windowStorage.load('horizPos'); if (horizPos && this._scrollTable) { this._scrollTable.scrollLeft = horizPos; } - this._storageManager.remove('horizPos'); + this._windowStorage.remove('horizPos'); } _removeListeners() { @@ -330,7 +373,7 @@ export class AsyncTable { } _changePagesizeHandler = () => { - const url = new URL(this._storageManager.load('currentTableUrl') || window.location.href); + const url = new URL(this._windowStorage.load('currentTableUrl') || window.location.href); // create new FormData and format any date values const formData = Datepicker.unformatAll(this._pagesizeForm, new FormData(this._pagesizeForm)); @@ -347,7 +390,9 @@ export class AsyncTable { } // fetches new sorted element from url with params and replaces contents of current element - _updateTableFrom(url, callback) { + _updateTableFrom(url, callback, isPopState) { + url = new URL(url); + const cancelPendingUpdates = (() => { this._cancelPendingUpdates.forEach(f => f()); }).bind(this); @@ -372,23 +417,33 @@ export class AsyncTable { return false; } - this._storageManager.save('currentTableUrl', url.href); + if (!isPopState) + this._historyStorage.save('currentTableUrl', url.href, { location: LOCATION.HISTORY, history: { push: true, url: response.headers.get('DB-Table-Canonical-URL') || url.href } }); + + this._windowStorage.save('currentTableUrl', url.href); // reset table this._removeListeners(); + this._active = false; this._element.classList.remove(ASYNC_TABLE_INITIALIZED_CLASS); + this._element.dataset['currentTableUrl'] = url.href; // update table with new this._element.innerHTML = response.element.innerHTML; this._app.utilRegistry.initAll(this._element); if (callback && typeof callback === 'function') { - this._storageManager.save('cssIdPrefix', response.idPrefix); + this._windowStorage.save('cssIdPrefix', response.idPrefix); callback(this._element); - this._storageManager.remove('cssIdPrefix'); + this._windowStorage.remove('cssIdPrefix'); } }).catch((err) => console.error(err) ).finally(() => this._element.classList.remove(ASYNC_TABLE_LOADING_CLASS)); } + + _debugLog() {} + // _debugLog(fName, ...args) { + // console.log(`[DEBUGLOG] AsyncTable.${fName}`, { args: args, instance: this }); + // } } diff --git a/src/Handler/Utils/Table/Pagination.hs b/src/Handler/Utils/Table/Pagination.hs index 8ea5b6aaa..ccd00621d 100644 --- a/src/Handler/Utils/Table/Pagination.hs +++ b/src/Handler/Utils/Table/Pagination.hs @@ -552,7 +552,7 @@ defaultDBSFilterLayout filterWdgt filterEnctype filterAction scrolltable { formMethod = GET , formAction = Just filterAction , formEncoding = filterEnctype - , formAttrs = [("class", "table-filter-form")] + , formAttrs = [("class", "table-filter-form"), ("autocomplete", "off")] , formSubmit = FormAutoSubmit , formAnchor = Nothing :: Maybe Text } @@ -933,7 +933,7 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db getParams <- liftHandler $ queryToQueryText . Wai.queryString . reqWaiRequest <$> getRequest let tblLink :: (QueryText -> QueryText) -> SomeRoute UniWorX - tblLink f = SomeRoute . (currentRoute, ) . over (mapped . _2) (fromMaybe Text.empty) $ (f . substPi . setParam "_hasdata" Nothing) getParams + tblLink f = SomeRoute . (currentRoute, ) . over (mapped . _2) (fromMaybe Text.empty) $ (f . substPi . setParam "_hasdata" Nothing . setParam (toPathPiece PostFormIdentifier) Nothing) getParams substPi = foldr (.) id [ setParams (wIdent "sorting") . map toPathPiece $ fromMaybe [] piSorting , foldr (.) id . map (\k -> setParams (dbFilterKey dbtIdent' k) . fromMaybe [] . join $ traverse (Map.lookup k) piFilter) $ Map.keys dbtFilter @@ -1306,7 +1306,7 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db { formMethod = GET , formAction = Just . SomeRoute $ rawAction :#: wIdent "table-wrapper" , formEncoding = pagesizeEnc - , formAttrs = [("class", "pagesize")] + , formAttrs = [("class", "pagesize"), ("autocomplete", "off")] , formSubmit = FormAutoSubmit , formAnchor = Just $ wIdent "pagesize-form" } @@ -1315,6 +1315,7 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db csvWdgt = $(widgetFile "table/csv-transcode") + uiLayout :: Widget -> Widget uiLayout table = dbsFilterLayout filterWdgt filterEnc (SomeRoute $ rawAction :#: wIdent "table-wrapper") $(widgetFile "table/layout") dbInvalidateResult' = foldr (<=<) return . catMaybes $ @@ -1344,7 +1345,14 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db redirect $ tblLink id (act, _) -> act - dbInvalidateResult' <=< bool (dbHandler (Proxy @m) (Proxy @x) $ (\table -> $(widgetFile "table/layout-wrapper")) . uiLayout) (sendResponse <=< tblLayout . uiLayout <=< dbWidget (Proxy @m) (Proxy @x)) psShortcircuit <=< runDBTable dbtable paginationInput currentKeys . fmap swap $ runWriterT table' + let + wrapLayout :: DBResult m x -> DB (DBResult m x) + wrapLayout = dbHandler (Proxy @m) (Proxy @x) $ (\table -> $(widgetFile "table/layout-wrapper")) . uiLayout + shortcircuit :: forall void. DBResult m x -> DB void + shortcircuit res = do + addCustomHeader HeaderDBTableCanonicalURL =<< toTextUrl (tblLink substPi) + sendResponse =<< tblLayout . uiLayout =<< dbWidget (Proxy @m) (Proxy @x) res + dbInvalidateResult' <=< bool wrapLayout shortcircuit psShortcircuit <=< runDBTable dbtable paginationInput currentKeys . fmap swap $ runWriterT table' where tblLayout :: forall m'. (MonadHandler m', HandlerSite m' ~ UniWorX) => Widget -> m' Html tblLayout tbl' = do @@ -1356,6 +1364,7 @@ dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> db setParam :: Text -> Maybe Text -> QueryText -> QueryText setParam key = setParams key . maybeToList + dbTableWidget :: Monoid x => PSValidator (HandlerFor UniWorX) x diff --git a/src/Utils.hs b/src/Utils.hs index a9d9d9f87..513f38e68 100644 --- a/src/Utils.hs +++ b/src/Utils.hs @@ -841,7 +841,7 @@ choice = foldr (<|>) empty -- Custom HTTP Headers -- --------------------------------- -data CustomHeader = HeaderIsModal | HeaderDBTableShortcircuit | HeaderMassInputShortcircuit | HeaderAlerts +data CustomHeader = HeaderIsModal | HeaderDBTableShortcircuit | HeaderMassInputShortcircuit | HeaderAlerts | HeaderDBTableCanonicalURL deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic) instance Universe CustomHeader diff --git a/templates/table/cell/header.hamlet b/templates/table/cell/header.hamlet index fca1c703c..45c103d49 100644 --- a/templates/table/cell/header.hamlet +++ b/templates/table/cell/header.hamlet @@ -3,10 +3,10 @@ $newline never $maybe flag <- sortableKey $case directions $of [SortAsc] - + ^{widget} $of _ - + ^{widget} $nothing ^{widget}