From cb1ed4c909feadacd0c443824caf8bbc980a01d9 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Fri, 15 Sep 2023 16:41:19 +0100 Subject: [PATCH] HPCC-30303 Persist columns widths per page Fix ability to store "any" state on a browser history item Signed-off-by: Gordon Smith --- esp/src/src-react/components/Files.tsx | 1 + esp/src/src-react/components/Title.tsx | 2 +- .../src-react/components/controls/Grid.tsx | 26 ++++++--- esp/src/src-react/components/forms/Login.tsx | 2 +- esp/src/src-react/hooks/store.ts | 53 ++++++++++++++++-- esp/src/src-react/util/history.ts | 55 +++++++++++-------- 6 files changed, 104 insertions(+), 35 deletions(-) diff --git a/esp/src/src-react/components/Files.tsx b/esp/src/src-react/components/Files.tsx index 907327039a6..03f60dff097 100644 --- a/esp/src/src-react/components/Files.tsx +++ b/esp/src/src-react/components/Files.tsx @@ -160,6 +160,7 @@ export const Files: React.FunctionComponent = ({ }, Name: { label: nlsHPCC.LogicalName, + width: 360, formatter: (name, row) => { const file = Get(row.NodeGroup, name, row); if (row.__hpcc_isDir) { diff --git a/esp/src/src-react/components/Title.tsx b/esp/src/src-react/components/Title.tsx index 510b8fb17ce..b482d08fadd 100644 --- a/esp/src/src-react/components/Title.tsx +++ b/esp/src/src-react/components/Title.tsx @@ -135,7 +135,7 @@ export const DevTitle: React.FunctionComponent = ({ method: "post" }).then(() => { setUserSession({ ...userSession, Status: "Locked" }); - replaceUrl("/login", null, true); + replaceUrl("/login", true); }); } }, diff --git a/esp/src/src-react/components/controls/Grid.tsx b/esp/src/src-react/components/controls/Grid.tsx index 41c6c499ee6..52cc86c8d27 100644 --- a/esp/src/src-react/components/controls/Grid.tsx +++ b/esp/src/src-react/components/controls/Grid.tsx @@ -7,7 +7,7 @@ import nlsHPCC from "src/nlsHPCC"; import { createCopyDownloadSelection } from "../Common"; import { updatePage, updateSort } from "../../util/history"; import { useDeepCallback, useDeepEffect, useDeepMemo } from "../../hooks/deepHooks"; -import { useUserStore } from "../../hooks/store"; +import { useUserStore, useNonReactiveEphemeralPageStore } from "../../hooks/store"; import { useUserTheme } from "../../hooks/theme"; /* --- Debugging dependency changes --- @@ -65,24 +65,30 @@ function updateColumnSorted(columns: IColumn[], attr: any, desc: boolean) { } } -function columnsAdapter(columns: FluentColumns): IColumn[] { +function columnsAdapter(columns: FluentColumns, columnWidths: Map): IColumn[] { const retVal: IColumn[] = []; for (const key in columns) { const column = columns[key]; + const width = columnWidths.get(key) ?? column.width; if (column?.selectorType === undefined && column?.hidden !== true) { retVal.push({ key, name: column.label ?? key, fieldName: column.field ?? key, - minWidth: column.width ?? 70, - maxWidth: column.width, + minWidth: width ?? 70, + maxWidth: width, isResizable: true, isSorted: false, isSortedDescending: false, iconName: column.headerIcon, isIconOnly: !!column.headerIcon, data: column, - onRender: tooltipItemRenderer + styles: { root: { width } }, + onRender: (item: any, index: number, col: IColumn) => { + col.minWidth = column.width ?? 70; + col.maxWidth = column.width; + return tooltipItemRenderer(item, index, col); + } } as IColumn); } } @@ -187,6 +193,7 @@ const FluentStoreGrid: React.FunctionComponent = ({ const memoizedColumns = useDeepMemo(() => columns, [], [columns]); const [sorted, setSorted] = React.useState(sort); const [items, setItems] = React.useState([]); + const [columnWidths] = useNonReactiveEphemeralPageStore("columnWidths"); const selectionHandler = useConst(new Selection({ onSelectionChanged: () => { @@ -220,8 +227,8 @@ const FluentStoreGrid: React.FunctionComponent = ({ }, [], [sort]); const fluentColumns: IColumn[] = React.useMemo(() => { - return columnsAdapter(memoizedColumns); - }, [memoizedColumns]); + return columnsAdapter(memoizedColumns, columnWidths); + }, [columnWidths, memoizedColumns]); React.useEffect(() => { updateColumnSorted(fluentColumns, sorted?.attribute as string, sorted?.descending); @@ -258,6 +265,10 @@ const FluentStoreGrid: React.FunctionComponent = ({ }); }, []); + const columnResize = React.useCallback((column: IColumn, newWidth: number, columnIndex?: number) => { + columnWidths.set(column.key, newWidth); + }, [columnWidths]); + return = ({ selectionPreservedOnEmptyClick={true} onColumnHeaderClick={onColumnClick} onRenderDetailsHeader={renderDetailsHeader} + onColumnResize={columnResize} styles={gridStyles(height)} />; }; diff --git a/esp/src/src-react/components/forms/Login.tsx b/esp/src/src-react/components/forms/Login.tsx index a5dc021cb51..ecf06925c62 100644 --- a/esp/src/src-react/components/forms/Login.tsx +++ b/esp/src/src-react/components/forms/Login.tsx @@ -109,7 +109,7 @@ export const Login: React.FunctionComponent = ({ } else { createUserSession(cookies).then(() => { setErrorMessage(""); - replaceUrl("/", null, true); + replaceUrl("/", true); }).catch(err => logger.error("Unable to create user session.")); } } diff --git a/esp/src/src-react/hooks/store.ts b/esp/src/src-react/hooks/store.ts index 663c6e963d1..e8f598ca566 100644 --- a/esp/src/src-react/hooks/store.ts +++ b/esp/src/src-react/hooks/store.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { useConst } from "@fluentui/react-hooks"; -import { globalKeyValStore, IKeyValStore, userKeyValStore } from "src/KeyValStore"; +import { globalKeyValStore, IKeyValStore, localKeyValStore, sessionKeyValStore, userKeyValStore } from "src/KeyValStore"; +import { parseHash } from "../util/history"; function toString(value: T, defaultValue: T): string { if (value === undefined) value = defaultValue; @@ -79,14 +80,58 @@ function useStore(store: IKeyValStore, key: string, defaultValue: T, monitor: return [value, extSetValue, reset]; } -export function useUserStore(key: string, defaultValue: T, monitor: boolean = false) { +export function useGlobalStore(key: string, defaultValue: T, monitor: boolean = false) { + const store = useConst(() => globalKeyValStore()); + return useStore(store, key, defaultValue, monitor); +} +export function useUserStore(key: string, defaultValue: T, monitor: boolean = false) { const store = useConst(() => userKeyValStore()); return useStore(store, key, defaultValue, monitor); } -export function useGlobalStore(key: string, defaultValue: T, monitor: boolean = false) { +export function useLocalStore(key: string, defaultValue: T, monitor: boolean = false) { + const store = useConst(() => localKeyValStore()); + return useStore(store, key, defaultValue, monitor); +} - const store = useConst(() => globalKeyValStore()); +export function useSessionStore(key: string, defaultValue: T, monitor: boolean = false) { + const store = useConst(() => sessionKeyValStore()); return useStore(store, key, defaultValue, monitor); } + +/* Ephemeral Store + This store is used to persist data that is only needed for the current page. + It is only persisted in a global variable. + It is also non reactive = i.e. changing its content will not trigger a re-render. +*/ + +const g_state: Map>> = new Map(); + +function useNonReactiveEphemeralPageGlobalStore(): [Map>, () => void] { + const pathname = useConst(() => parseHash(window.location.hash).pathname); + + const reset = React.useCallback(() => { + g_state.set(pathname, new Map()); + }, [pathname]); + + if (!g_state.has(pathname)) { + reset(); + } + + return [g_state.get(pathname), reset]; +} + +export function useNonReactiveEphemeralPageStore(id: string): [Map, () => void] { + const [pageStates] = useNonReactiveEphemeralPageGlobalStore(); + + const reset = React.useCallback(() => { + pageStates.set(id, new Map()); + }, [id, pageStates]); + + if (!pageStates.has(id)) { + reset(); + } + + return [pageStates.get(id), reset]; +} diff --git a/esp/src/src-react/util/history.ts b/esp/src/src-react/util/history.ts index 280ff2cb8c8..9521b9902f4 100644 --- a/esp/src/src-react/util/history.ts +++ b/esp/src/src-react/util/history.ts @@ -20,7 +20,7 @@ export function resolve(pathnameOrContext: string | ResolveContext) { return g_router.resolve(pathnameOrContext); } -function parseHash(hash: string): HistoryLocation { +export function parseHash(hash: string): HistoryLocation { if (hash[0] !== "#") { return { pathname: "/", @@ -82,6 +82,7 @@ interface HistoryLocation { pathname: string; search: string; id: string; + state?: { [key: string]: any } } export type ListenerCallback = (location: HistoryLocation, action: string) => void; @@ -140,19 +141,19 @@ class History { return hashUrl; } - push(to: { pathname?: string, search?: string }, state?: S) { + push(to: { pathname?: string, search?: string }) { const newHash = this.fixHash(`${this.trimRightSlash(to.pathname || this.location.pathname)}${to.search || ""}`); if (window.location.hash !== newHash) { - globalHistory.pushState(state, "", newHash); + globalHistory.pushState(undefined, "", newHash); this.location = parseHash(newHash); this.broadcast("PUSH"); } } - replace(to: { pathname?: string, search?: string }, state?: S) { + replace(to: { pathname?: string, search?: string }) { const newHash = this.fixHash(`${this.trimRightSlash(to.pathname || this.location.pathname)}${to.search || ""}`); if (window.location.hash !== newHash) { - globalHistory.replaceState(state, "", newHash); + globalHistory.replaceState(globalHistory.state, "", newHash); this.location = parseHash(newHash); this.broadcast("REPLACE"); } @@ -188,49 +189,49 @@ class History { this.updateRecent(); for (const key in this._listeners) { const listener = this._listeners[key]; - listener(this.location, action); + listener({ ...this.location, state: { ...globalHistory.state } }, action); } } } export const hashHistory = new History(); -export function pushSearch(_: object, state?: any) { +export function pushSearch(_: object) { const search = stringify(_ as any); hashHistory.push({ search: search ? "?" + search : "" - }, state); + }); } -export function updateSearch(_: object, state?: any) { +export function updateSearch(_: object) { const search = stringify(_ as any); hashHistory.replace({ search: search ? "?" + search : "" - }, state); + }); } -export function pushUrl(_: string, state?: any) { +export function pushUrl(_: string) { hashHistory.push({ pathname: _ - }, state); + }); } -export function replaceUrl(_: string, state?: any, refresh: boolean = false) { +export function replaceUrl(_: string, refresh: boolean = false) { hashHistory.replace({ pathname: _ - }, state); + }); if (refresh) window.location.reload(); } -export function pushParam(key: string, val?: string | string[] | number | boolean, state?: any) { - pushParams({ [key]: val }, state); +export function pushParam(key: string, val?: string | string[] | number | boolean) { + pushParams({ [key]: val }); } -export function pushParamExact(key: string, val?: string | string[] | number | boolean, state?: any) { - pushParams({ [key]: val }, state, true); +export function pushParamExact(key: string, val?: string | string[] | number | boolean) { + pushParams({ [key]: val }, true); } -export function pushParams(search: { [key: string]: string | string[] | number | boolean }, state?: any, keepEmpty: boolean = false) { +export function pushParams(search: { [key: string]: string | string[] | number | boolean }, keepEmpty: boolean = false) { const params = parseQuery(hashHistory.location.search); for (const key in search) { const val = search[key]; @@ -241,15 +242,25 @@ export function pushParams(search: { [key: string]: string | string[] | number | params[key] = val; } } - pushSearch(params, state); + pushSearch(params); } -export function updateParam(key: string, val?: string | string[] | number | boolean, state?: any) { +export function updateParam(key: string, val?: string | string[] | number | boolean) { const params = parseQuery(hashHistory.location.search); if (val === undefined) { delete params[key]; } else { params[key] = val; } - updateSearch(params, state); + updateSearch(params); +} + +export function updateState(key: string, val?: string | string[] | number | boolean) { + const state = { ...globalHistory.state }; + if (val === undefined) { + delete state[key]; + } else { + state[key] = val; + } + globalHistory.replaceState(state, ""); }