feat(async-table): history api

Fixes #426
This commit is contained in:
Gregor Kleen 2020-05-05 15:09:33 +02:00
parent 9e47a7cd28
commit c348b7cb03
6 changed files with 215 additions and 61 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,10 +3,10 @@ $newline never
$maybe flag <- sortableKey
$case directions
$of [SortAsc]
<a .table__th-link rel=nofollow href=^{tblLink' $ setParams (wIdent "sorting") (map toPathPiece (SortingSetting flag SortDesc : piSorting'))}>
<a .table__th-link rel=nofollow href=^{tblLink' $ setParams (wIdent "sorting") (map toPathPiece (SortingSetting flag SortDesc : piSorting')) . substPi}>
^{widget}
$of _
<a .table__th-link rel=nofollow href=^{tblLink' $ setParams (wIdent "sorting") (map toPathPiece (SortingSetting flag SortAsc : piSorting'))}>
<a .table__th-link rel=nofollow href=^{tblLink' $ setParams (wIdent "sorting") (map toPathPiece (SortingSetting flag SortAsc : piSorting')) . substPi}>
^{widget}
$nothing
^{widget}