diff --git a/client-app/src/desktop/AppModel.ts b/client-app/src/desktop/AppModel.ts index c6f3add49..c036c6b7a 100755 --- a/client-app/src/desktop/AppModel.ts +++ b/client-app/src/desktop/AppModel.ts @@ -21,6 +21,7 @@ import {panelsTab} from './tabs/panels/PanelsTab'; import {fmtDateTimeSec} from '@xh/hoist/format'; import {span} from '@xh/hoist/cmp/layout'; import {BaseAppModel} from '../BaseAppModel'; +import {isEmpty} from 'lodash'; export class AppModel extends BaseAppModel { /** Singleton instance reference - installed by XH upon init. */ @@ -47,6 +48,8 @@ export class AppModel extends BaseAppModel { override async initAsync() { await super.initAsync(); await XH.installServicesAsync(GitHubService, PortfolioService); + // Set the queryParamsMode to 'loose' to allow for more flexible URL query parameters. + XH.router.setOption('queryParamsMode', 'loose'); // Demo app-specific handling of EnvironmentService.serverVersion observable. this.addReaction({ @@ -184,6 +187,11 @@ export class AppModel extends BaseAppModel { name: 'simpleRouting', path: '/simpleRouting', children: [{name: 'recordId', path: '/:recordId'}] + }, + { + name: 'advancedRouting', + path: '/advancedRouting', + ...routeParamEncoders } ] }, @@ -210,3 +218,15 @@ export class AppModel extends BaseAppModel { ]; } } + +// Encoding of json route params as base64 +export const routeParamEncoders = { + encodeParams: params => { + if (isEmpty(params)) return {}; + return {q: window.btoa(JSON.stringify(params))}; + }, + decodeParams: params => { + if (!params.q) return {}; + return JSON.parse(window.atob(params.q)); + } +}; diff --git a/client-app/src/desktop/tabs/other/OtherTab.ts b/client-app/src/desktop/tabs/other/OtherTab.ts index 873c5a7c0..bf1cac3e2 100644 --- a/client-app/src/desktop/tabs/other/OtherTab.ts +++ b/client-app/src/desktop/tabs/other/OtherTab.ts @@ -18,6 +18,7 @@ import {placeholderPanel} from './PlaceholderPanel'; import {popupsPanel} from './PopupsPanel'; import {relativeTimestampPanel} from './relativetimestamp/RelativeTimestampPanel'; import {simpleRoutingPanel} from './routing/SimpleRoutingPanel'; +import {advancedRoutingPanel} from './routing/AdvancedRoutingPanel'; export const otherTab = hoistCmp.factory(() => tabContainer({ @@ -45,8 +46,9 @@ export const otherTab = hoistCmp.factory(() => {id: 'pinPad', title: 'PIN Pad', content: pinPadPanel}, {id: 'placeholder', title: 'Placeholder', content: placeholderPanel}, {id: 'popups', content: popupsPanel}, - {id: 'timestamp', content: relativeTimestampPanel}, - {id: 'simpleRouting', content: simpleRoutingPanel} + {id: 'simpleRouting', title: 'Routing (Simple)', content: simpleRoutingPanel}, + {id: 'advancedRouting', title: 'Routing (Advanced)', content: advancedRoutingPanel}, + {id: 'timestamp', content: relativeTimestampPanel} ] }, className: 'toolbox-tab' diff --git a/client-app/src/desktop/tabs/other/routing/AdvancedRoutingPanel.tsx b/client-app/src/desktop/tabs/other/routing/AdvancedRoutingPanel.tsx new file mode 100644 index 000000000..1afa6c186 --- /dev/null +++ b/client-app/src/desktop/tabs/other/routing/AdvancedRoutingPanel.tsx @@ -0,0 +1,203 @@ +import {grid, GridModel} from '@xh/hoist/cmp/grid'; +import {span} from '@xh/hoist/cmp/layout'; +import {creates, hoistCmp, HoistModel, XH} from '@xh/hoist/core'; +import {select, switchInput} from '@xh/hoist/desktop/cmp/input'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {action, makeObservable, observable} from '@xh/hoist/mobx'; +import React from 'react'; +import {State} from 'router5'; +import {wrapper} from '../../../common'; + +export const advancedRoutingPanel = hoistCmp.factory({ + displayName: 'AdvancedRoutingPanel', + model: creates(() => new AdvancedRoutingPanelModel()), + + render({model}) { + return wrapper({ + description: [ +

+ This example demonstrates how to use URL route parameters to store and restore + the state of a component. The state of the grid (grouping, sorting, and selected + record) is stored in the URL, and the state is restored when the URL is + revisited. +

, +

+ Hoist applications are able to navigate to a specific URL and specify whether or + not to push onto the route history. In this example, selecting individual + records in the grid will not save the URL to the route history, but changing the{' '} + groupBy or sortBy fields will. Hoist also provides the + ability to prevent route deactivation, allowing the developer to present the + user with a pop-up before navigating away from the current route. +

, +

+ The state is encoded in the URL as a base64 string, which is then + decoded and parsed to restore the state. +

, +

+ The current state encoding is:
+
+ groupBy: {XH.routerState.params.groupBy || 'None'} +
+ sortBy: {XH.routerState.params.sortBy || 'None'} +
+ selectedId: {XH.routerState.params.selectedId || 'None'} +
+

, +

+ ], + item: panel({ + ref: model.panelRef, + mask: 'onLoad', + item: grid(), + tbar: [ + span('Group by:'), + select({ + bind: 'groupBy', + options: [ + {value: 'city', label: 'City'}, + {value: 'trade_date', label: 'Trade Date'}, + {value: 'city,trade_date', label: 'City › Trade Date'}, + {value: null, label: 'None'} + ], + width: 160 + }), + span('Sort by:'), + select({ + bind: 'sortBy', + options: [ + {value: 'id|desc', label: 'Company ID (Desc)'}, + {value: 'id|asc', label: 'Company ID (Asc)'}, + {value: 'company|desc', label: 'Company Name (Desc)'}, + {value: 'company|asc', label: 'Company Name (Asc)'}, + {value: 'city|desc', label: 'City (Desc)'}, + {value: 'city|asc', label: 'City (Asc)'}, + {value: 'trade_date|desc', label: 'Trade Date (Desc)'}, + {value: 'trade_date|asc', label: 'Trade Date (Asc)'}, + {value: null, label: 'None'} + ] + }), + switchInput({ + bind: 'preventDeactivate', + label: 'Prevent Route Deactivation' + }) + ] + }) + }); + } +}); + +class AdvancedRoutingPanelModel extends HoistModel { + @observable groupBy = null; + @observable sortBy = null; + @observable preventDeactivate = false; + gridModel: GridModel = null; + + constructor() { + super(); + makeObservable(this); + + this.gridModel = new GridModel({ + columns: [ + {field: 'id'}, + {field: 'company', flex: 1}, + {field: 'city', flex: 1}, + {field: 'trade_date', flex: 1} + ] + }); + + this.addReaction( + { + track: () => XH.routerState, + run: (newState, oldState) => this.processRouterState(newState, oldState) + }, + { + track: () => [this.groupBy, this.sortBy, this.gridModel.selectedRecord?.id], + run: () => this.updateRoute() + } + ); + + window.addEventListener('beforeunload', e => { + if (!XH.routerState.name.startsWith('default.other.advancedRouting')) { + delete e.returnValue; + return; + } + if (this.preventDeactivate) e.preventDefault(); + }); + } + + @action + private setGroupBy(groupBy: string) { + this.groupBy = groupBy; + + const groupByArr = groupBy ? groupBy.split(',') : []; + this.gridModel.setGroupBy(groupByArr); + } + + @action + private setSortBy(sortBy: string) { + this.sortBy = sortBy; + + const sortByArr = sortBy ? sortBy.split(',') : []; + this.gridModel.setSortBy(sortByArr); + } + + @action + private async setSelected(recordId: string | number) { + await this.gridModel.selectAsync(Number(recordId)); + if (!this.gridModel.selectedId) { + XH.dangerToast(`Record ${recordId} not found`); + } + } + + @action + private setPreventDeactivate(preventDeactivate: boolean) { + this.preventDeactivate = preventDeactivate; + } + + @action + private async parseRouteParams() { + const {groupBy, sortBy, selectedId} = XH.routerState.params; + if (groupBy) this.setGroupBy(groupBy); + if (sortBy) this.setSortBy(sortBy); + if (selectedId) await this.setSelected(selectedId); + } + + @action + private async processRouterState(newState?: State, oldState?: State) { + if ( + !newState.name.startsWith('default.other.advancedRouting') && + oldState.name.startsWith('default.other.advancedRouting') + ) + return XH.navigate(newState.name, null, {replace: true}); + else if ( + newState.name.startsWith('default.other.advancedRouting') && + !oldState.name.startsWith('default.other.advancedRouting') + ) + this.updateRoute(); + else if (newState.name.startsWith('default.other.advancedRouting')) + await this.parseRouteParams(); + } + + @action + private updateRoute() { + if ( + XH.routerState.name.startsWith('default.other.advancedRouting') && + !this.gridModel.empty + ) { + const {groupBy, sortBy} = this; + const selectedId = this.gridModel.selectedRecord?.id; + XH.navigate( + 'default.other.advancedRouting', + {groupBy, sortBy, selectedId}, + // Only push URL to route history if groupBy or sortBy changes. + {replace: selectedId != XH.routerState.params.selectedId} + ); + } + } + + override async doLoadAsync(loadSpec) { + const {trades} = await XH.fetchJson({url: 'trade'}); + this.gridModel.loadData(trades); + await this.parseRouteParams(); + } +}