diff --git a/src/components/AggregateGrid/AggregateGridPage.tsx b/src/components/AggregateGrid/AggregateGridPage.tsx index 3dfea1f8..7e4a35cb 100644 --- a/src/components/AggregateGrid/AggregateGridPage.tsx +++ b/src/components/AggregateGrid/AggregateGridPage.tsx @@ -28,6 +28,7 @@ import { useGetDataForAggregateGrid } from "../../connection/LibraryQueryHooks"; import { IMinimalBookInfo, ILangTagData } from "./AggregateGridInterfaces"; import { observer } from "mobx-react-lite"; import { useGetLoggedInUser, User } from "../../connection/LoggedInUser"; +import { isLocalhost } from "../../connection/DataSource"; import { Plugin, Template, @@ -75,7 +76,9 @@ export const AggregateGridPage: React.FunctionComponent<{ } : loadingResult; const user = useGetLoggedInUser(); - if (!user) { + // On a local dev machine (loopback hostnames) we don't require login, so the grids can be + // worked on without signing in. Everywhere else remains login-only. See isLocalhost. + if (!user && !isLocalhost()) { return
You must log in to see this page.
; } return ( diff --git a/src/components/CountryGrid/CountryGridColumns.tsx b/src/components/CountryGrid/CountryGridColumns.tsx index aba2480c..f97c0b61 100644 --- a/src/components/CountryGrid/CountryGridColumns.tsx +++ b/src/components/CountryGrid/CountryGridColumns.tsx @@ -1,5 +1,6 @@ import React from "react"; import { IGridColumn } from "../Grid/GridColumns"; +import { applyUrlKeys } from "../Grid/gridUrlConfig"; import { TableFilterRow } from "@devexpress/dx-react-grid-material-ui"; import { Filter, Sorting } from "@devexpress/dx-react-grid"; import { @@ -17,6 +18,17 @@ export interface ICountryGridRowData { bookCount: number; } +// Short, stable URL keys for every country-grid column (filters + sort/cols/widths). +// Must be unique within this grid and not equal a reserved param (sort/cols/widths). +const countryGridUrlKeys: { [name: string]: string } = { + name: "nm", + code: "cd", + knownLanguageCount: "klc", + blorgLanguageCount: "blc", + blorgLanguageTags: "blt", + bookCount: "bc", +}; + // Define the function getCountryGridColumnsDefinitions export function getCountryGridColumnsDefinitions(): IGridColumn[] { const definitions: IGridColumn[] = [ @@ -131,7 +143,7 @@ export function getCountryGridColumnsDefinitions(): IGridColumn[] { }, }, ]; - return definitions; + return applyUrlKeys(definitions, countryGridUrlKeys); } export function compareCountryGridRows( diff --git a/src/components/CountryGrid/CountryGridControlInternal.tsx b/src/components/CountryGrid/CountryGridControlInternal.tsx index 15f75f02..506a9d58 100644 --- a/src/components/CountryGrid/CountryGridControlInternal.tsx +++ b/src/components/CountryGrid/CountryGridControlInternal.tsx @@ -25,8 +25,6 @@ import { SortingState, PagingState, CustomPaging, - Filter as GridFilter, - Sorting, } from "@devexpress/dx-react-grid"; import { TableCell, useTheme } from "@material-ui/core"; import { @@ -36,8 +34,8 @@ import { getCountryGridColumnsDefinitions, adjustListDisplaysForFiltering, } from "./CountryGridColumns"; -import { IGridColumn } from "../Grid/GridColumns"; -import { useStorageState } from "react-storage-hooks"; +import { IGridColumn, getColumnsVisibleToUser } from "../Grid/GridColumns"; +import { useGridConfigInUrl } from "../Grid/useGridConfigInUrl"; import { useGetLoggedInUser } from "../../connection/LoggedInUser"; import { observer } from "mobx-react-lite"; import { ICountryGridControlProps } from "./CountryGridControl"; @@ -67,10 +65,36 @@ const CountryGridControlInternal: React.FunctionComponent([]); const [countryGridColumnDefinitions] = useState( getCountryGridColumnsDefinitions() ); + // The columns this user may see (some are moderator-/login-gated). Drives both the + // rendered `columns` set and which URL sort/filter config the hook is allowed to honor. + const visibleColumnDefinitions = useMemo( + () => getColumnsVisibleToUser(countryGridColumnDefinitions, user), + [countryGridColumnDefinitions, user] + ); + const availableColumnNames = useMemo( + () => visibleColumnDefinitions.map((c) => c.name), + [visibleColumnDefinitions] + ); + // Grid configuration (sort, column filters, column order/visibility, widths) lives + // in the URL so a view can be bookmarked/shared; column order & visibility also fall + // back to the user's personal localStorage preference. See useGridConfigInUrl. + const { + sortings, + setSortings, + gridFilters, + setGridFilters, + columnNamesInDisplayOrder, + setColumnNamesInDisplayOrder, + hiddenColumnNames, + setHiddenColumnNames, + columnWidths, + setColumnWidths, + } = useGridConfigInUrl(countryGridColumnDefinitions, "country-grid", { + availableColumnNames, + }); const { minimalBookInfo: bookData } = useContext(CachedBookDataContext); const [totalRowCount, setTotalRowCount] = useState(0); @@ -250,47 +274,8 @@ const CountryGridControlInternal: React.FunctionComponent([]); const [gridPage, setGridPage] = useState(0); - const [columns, setColumns] = useState>([]); - const [sortings, setSortings] = useState>([]); - const [ - columnNamesInDisplayOrder, - setColumnNamesInDisplayOrder, - ] = useStorageState( - localStorage, - "country-grid-column-order", - countryGridColumnDefinitions.map((c) => c.name) - ); - - // when a new version adds a new column, the list of columns in order will not match - // the full list of columns. Instead of coping with this, the devexpress grid just locks down the new - // column as the first one. So here we detect added and removed columns, while preserving order. - useEffect(() => { - const newCompleteSetInDefaultOrder = countryGridColumnDefinitions.map( - (c) => c.name - ); - const columnsThatNeedToBeAdded = newCompleteSetInDefaultOrder.filter( - (x) => !columnNamesInDisplayOrder.includes(x) - ); - const columnsThatNeedToBeRemoved = columnNamesInDisplayOrder.filter( - (x) => !newCompleteSetInDefaultOrder.includes(x) - ); - if ( - columnsThatNeedToBeAdded.length || - columnsThatNeedToBeRemoved.length - ) { - const oldOrderWithNewOnesAtEnd = columnNamesInDisplayOrder.concat( - columnsThatNeedToBeAdded - ); - const columnsWithAnyOldOnesRemoved = oldOrderWithNewOnesAtEnd.filter( - (n) => !columnsThatNeedToBeRemoved.includes(n) - ); - setColumnNamesInDisplayOrder(columnsWithAnyOldOnesRemoved); - } - }, [ - columnNamesInDisplayOrder, - setColumnNamesInDisplayOrder, - countryGridColumnDefinitions, - ]); + // The columns this user may see; drives the grid's rendered column set. + const columns = visibleColumnDefinitions; // Apply filtering and sorting to the rows, then set the page of rows to display. // Also set the total row count and the export data. @@ -324,25 +309,6 @@ const CountryGridControlInternal: React.FunctionComponent( - localStorage, - "country-grid-column-hidden", - countryGridColumnDefinitions - .filter((c) => !c.defaultVisible) - .map((c) => c.name) - ); - - const defaultColumnWidths = useMemo( - () => - countryGridColumnDefinitions.map((c) => ({ - columnName: c.name, - width: "auto", - })), - [countryGridColumnDefinitions] - ); - if (props.setExportColumnInfo) { props.setExportColumnInfo( columnNamesInDisplayOrder.filter((cn) => @@ -355,21 +321,6 @@ const CountryGridControlInternal: React.FunctionComponent { - setColumns( - countryGridColumnDefinitions.filter( - // some columns we include only if we are logged in, or - // logged in with the right permissions - (col) => - thisIsAModerator || - (!col.moderatorOnly && !col.loggedInOnly) || - (!col.moderatorOnly && col.loggedInOnly && user) - ) - ); - // todo? useEffect used to depend on router, though doesn't obviously use it. - }, [user, thisIsAModerator, countryGridColumnDefinitions]); - // note: this is an embedded function as a way to get at countryGridColumnDefinitions. It's important // that we don't reconstruct it on every render, or else we'll lose cursor focus on each key press. // Alternatives to this useMemo would include a ContextProvider, a higher-order function, or just @@ -398,14 +349,14 @@ const CountryGridControlInternal: React.FunctionComponent { setGridFilters(x); }} /> { setSortings(sorting); }} @@ -425,12 +376,13 @@ const CountryGridControlInternal: React.FunctionComponent setHiddenColumnNames(names) } diff --git a/src/components/Grid/GridColumns.tsx b/src/components/Grid/GridColumns.tsx index 1dc77ce4..defc0ff2 100644 --- a/src/components/Grid/GridColumns.tsx +++ b/src/components/Grid/GridColumns.tsx @@ -13,11 +13,16 @@ import titleCase from "title-case"; import { IFilter, BooleanOptions } from "../../IFilter"; import { CachedTables } from "../../model/CacheProvider"; import { BlorgLink } from "../BlorgLink"; +import { User } from "../../connection/LoggedInUser"; export interface IGridColumn extends DevExpressColumn { moderatorOnly?: boolean; loggedInOnly?: boolean; defaultVisible?: boolean; + // Short, collision-free key used for this column's filter in the URL query string. + // Defaults to `name`. Set it when the name is long or would collide with another query + // param (e.g. the "title" column vs. this app's existing "title" search param). + urlKey?: string; // A column definition specifies this if it needs a custom filter control getCustomFilterComponent?: FunctionComponent; // Given a BloomLibrary filter, modify it to include the value the user has set while using this column's filter control. @@ -32,6 +37,22 @@ export interface IGridColumn extends DevExpressColumn { getStringValue?: (b: Book) => string; } +// The columns a given user is allowed to see: some are gated behind being logged in +// (loggedInOnly) or being a moderator (moderatorOnly). Every grid uses this both to build the +// rendered `columns` set and (via useGridConfigInUrl) to ignore URL sort/filter config that +// names a column this user cannot see. Keep the two in agreement by going through this one helper. +export function getColumnsVisibleToUser( + columns: ReadonlyArray, + user: User | undefined +): IGridColumn[] { + return columns.filter( + (col) => + !!user?.moderator || + (!col.moderatorOnly && !col.loggedInOnly) || + (!col.moderatorOnly && !!col.loggedInOnly && !!user) + ); +} + // For some tags, we want to give them their own column. So we don't want to show them in the tags column. const kTagsToFilterOutOfTagsList = [ "topic:", @@ -40,6 +61,47 @@ const kTagsToFilterOutOfTagsList = [ "computedLevel", // added this one only because it gets in the way ]; +// Short, stable URL keys for every book-grid column (used for filters and inside +// sort/cols/widths). Must be unique and must not equal a reserved param +// (sort/cols/widths). "ti" avoids colliding with the app's existing "title" search param. +const bookGridUrlKeys: { [name: string]: string } = { + title: "ti", + languages: "lg", + languagecodes: "lc", + tags: "tg", + features: "ft", + country: "co", + incoming: "in", + level: "lv", + leveledReaderLevel: "lrl", + topic: "tp", + harvestState: "hs", + harvestLog: "hl", + harvestStartedAt: "hsa", + summary: "sm", + notes: "nt", + inCirculation: "ic", + draft: "dr", + "Is Rebrand": "rb", + license: "li", + copyright: "cr", + brandingProjectName: "bp", + pageCount: "pc", + phashOfFirstContentImage: "ph", + bookHashFromImages: "bh", + createdAt: "ca", + updatedAt: "ua", + credits: "cd", + publisher: "pb", + originalPublisher: "op", + uploader: "up", + keywords: "kw", + bookInstanceId: "bi", + analytics_startedCount: "asc", + analytics_finishedCount: "afc", + analytics_shellDownloads: "asd", +}; + export function getBookGridColumnsDefinitions(): IGridColumn[] { const definitions: IGridColumn[] = [ { @@ -406,6 +468,7 @@ export function getBookGridColumnsDefinitions(): IGridColumn[] { if (c.title === undefined) { x.title = titleCase(c.name); } + x.urlKey = bookGridUrlKeys[c.name] ?? c.urlKey; return x; }) .sort((a, b) => { @@ -476,7 +539,9 @@ const ChoicesFilterCell: React.FunctionComponent< choices: string[]; } > = (props) => { - const [value, setValue] = useState(props.filter?.value || ""); + // Controlled by the current filter (which the URL can change via back/forward), so the shown + // selection always matches the grid's active filter — no private copy that can go stale. + const value = props.filter?.value || ""; return ( { - setValue(e.target.value as string); props.onFilter({ columnName: props.column.name, operation: "contains", @@ -531,9 +595,9 @@ const ChoicesFilterCell: React.FunctionComponent< const TagExistsFilterCell: React.FunctionComponent = ( props ) => { - const [checked, setChecked] = useState( - props.filter?.value === "true" || false - ); + // Controlled by the current filter (URL/back-forward can change it), so the checkbox never + // shows a stale state that disagrees with the grid's active filter. + const checked = props.filter?.value === "true"; return ( = ( // we're switching to the opposite of what `checked` was value: !checked ? "true" : "false", }); - setChecked(!checked); }} /> diff --git a/src/components/Grid/GridControlInternal.tsx b/src/components/Grid/GridControlInternal.tsx index 860a6775..56f39ab6 100644 --- a/src/components/Grid/GridControlInternal.tsx +++ b/src/components/Grid/GridControlInternal.tsx @@ -1,12 +1,6 @@ import { css } from "@emotion/react"; -import React, { - useState, - useEffect, - useMemo, - ReactText, - useContext, -} from "react"; +import React, { useState, useMemo, ReactText, useContext } from "react"; import { Plugin, @@ -41,13 +35,16 @@ import { CustomPaging, Filter as GridFilter, RowDetailState, - Sorting, } from "@devexpress/dx-react-grid"; import { TableCell, useTheme } from "@material-ui/core"; import { IFilter, BooleanOptions } from "../../IFilter"; -import { getBookGridColumnsDefinitions, IGridColumn } from "./GridColumns"; +import { + getBookGridColumnsDefinitions, + IGridColumn, + getColumnsVisibleToUser, +} from "./GridColumns"; -import { useStorageState } from "react-storage-hooks"; +import { useGridConfigInUrl } from "./useGridConfigInUrl"; import { Book } from "../../model/Book"; import StaffPanel from "../Admin/StaffPanel"; import { useGetLoggedInUser, User } from "../../connection/LoggedInUser"; @@ -67,76 +64,48 @@ const GridControlInternal: React.FunctionComponent = observer ); const user = useGetLoggedInUser(); const kBooksPerGridPage = 20; - const [gridFilters, setGridFilters] = useState( - props.initialGridFilters || [] - ); const [gridPage, setGridPage] = useState(0); - const [columns, setColumns] = useState>([]); - const [sortings, setSortings] = useState>([]); const [bookGridColumnDefinitions] = useState( getBookGridColumnsDefinitions() ); const [expandedRowIds, setExpandedRowIds] = useState([]); - const [ - columnNamesInDisplayOrder, - setColumnNamesInDisplayOrder, - ] = useStorageState( - localStorage, - "book-grid-column-order", - bookGridColumnDefinitions.map((c) => c.name) + + // The columns this user may see (some are moderator-/login-gated). Drives both the + // rendered `columns` set and which URL sort/filter config the hook is allowed to honor. + const visibleColumnDefinitions = useMemo( + () => getColumnsVisibleToUser(bookGridColumnDefinitions, user), + [bookGridColumnDefinitions, user] ); - // when a new version adds a new column, the list of columns in order will not match - // the full list of columns. Instead of coping with this, the devexpress grid just locks down the new - // column as the first one. So here we detect added and removed columns, while preserving order. - useEffect(() => { - const newCompleteSetInDefaultOrder = bookGridColumnDefinitions.map( - (c) => c.name - ); - const columnsThatNeedToBeAdded = newCompleteSetInDefaultOrder.filter( - (x) => !columnNamesInDisplayOrder.includes(x) - ); - const columnsThatNeedToBeRemoved = columnNamesInDisplayOrder.filter( - (x) => !newCompleteSetInDefaultOrder.includes(x) - ); - if ( - columnsThatNeedToBeAdded.length || - columnsThatNeedToBeRemoved.length - ) { - const oldOrderWithNewOnesAtEnd = columnNamesInDisplayOrder.concat( - columnsThatNeedToBeAdded - ); - const columnsWithAnyOldOnesRemoved = oldOrderWithNewOnesAtEnd.filter( - (n) => !columnsThatNeedToBeRemoved.includes(n) - ); - setColumnNamesInDisplayOrder(columnsWithAnyOldOnesRemoved); - } - }, [ + const availableColumnNames = useMemo( + () => visibleColumnDefinitions.map((c) => c.name), + [visibleColumnDefinitions] + ); + // The columns this user may see; drives the grid's rendered column set. + const columns = visibleColumnDefinitions; + + // Grid configuration (sort, column filters, column order/visibility, widths) lives + // in the URL so a view can be bookmarked/shared; column order & visibility also fall + // back to the user's personal localStorage preference when the URL says nothing. + // The hook also reconciles columns added/removed across releases. + const { + sortings, + setSortings, + gridFilters, + setGridFilters, columnNamesInDisplayOrder, setColumnNamesInDisplayOrder, - bookGridColumnDefinitions, - ]); - - const [hiddenColumnNames, setHiddenColumnNames] = useStorageState< - string[] - >( - localStorage, - "book-grid-column-hidden", - bookGridColumnDefinitions - .filter((c) => !c.defaultVisible) - .map((c) => c.name) - ); + hiddenColumnNames, + setHiddenColumnNames, + columnWidths, + setColumnWidths, + } = useGridConfigInUrl(bookGridColumnDefinitions, "book-grid", { + initialFilters: props.initialGridFilters, + availableColumnNames, + }); // enhance: make the date nice (remove Hour/Minute/Seconds, show as YYYY-MM-DD) // enhance: add "in circulation" column - const defaultColumnWidths = useMemo( - () => - bookGridColumnDefinitions.map((c) => ({ - columnName: c.name, - width: "auto", - })), - [bookGridColumnDefinitions] - ); const filterMadeFromPageSearchPlusColumnFilters = CombineGridAndSearchBoxFilter( bookGridColumnDefinitions, gridFilters, @@ -183,21 +152,6 @@ const GridControlInternal: React.FunctionComponent = observer descending: s.direction === "desc", })) ); - const thisIsAModerator = user?.moderator; - useEffect(() => { - setColumns( - bookGridColumnDefinitions.filter( - // some columns we include only if we are logged in, or - // logged in with the right permissions - (col) => - user?.moderator || - (!col.moderatorOnly && !col.loggedInOnly) || - (!col.moderatorOnly && col.loggedInOnly && user) - ) - ); - //setColumnNamesInDisplayOrder(bookGridColumns.map(c => c.name)); - // todo? useEffect used to depend on router, though doesn't obviously use it. - }, [user, thisIsAModerator, bookGridColumnDefinitions]); // note: this is an embedded function as a way to get at bookGridColumnDefinitions. It's important // that we don't reconstruct it on every render, or else we'll lose cursor focus on each key press. @@ -267,7 +221,7 @@ const GridControlInternal: React.FunctionComponent = observer /> { // if (props.setCurrentFilter) { // props.setCurrentFilter( @@ -285,7 +239,7 @@ const GridControlInternal: React.FunctionComponent = observer /> { setSortings(sorting); }} @@ -310,7 +264,8 @@ const GridControlInternal: React.FunctionComponent = observer /> @@ -331,7 +286,7 @@ const GridControlInternal: React.FunctionComponent = observer /> )} setHiddenColumnNames(names) } diff --git a/src/components/Grid/GridPage.tsx b/src/components/Grid/GridPage.tsx index c92cc3d2..80ac722f 100644 --- a/src/components/Grid/GridPage.tsx +++ b/src/components/Grid/GridPage.tsx @@ -16,6 +16,7 @@ import { } from "./GridExport"; import { observer } from "mobx-react-lite"; import { useGetLoggedInUser } from "../../connection/LoggedInUser"; +import { isLocalhost } from "../../connection/DataSource"; export function isValidFilterForGrid(filter: string): boolean { return !filter || filter.startsWith(":search:"); @@ -37,7 +38,9 @@ export const GridPage: React.FunctionComponent<{ filters: string }> = observer( const l10n = useIntl(); const user = useGetLoggedInUser(); - if (!user) { + // On a local dev machine (loopback hostnames) we don't require login, so the grids can + // be worked on without signing in. Everywhere else remains login-only. See isLocalhost. + if (!user && !isLocalhost()) { return
You must log in to see this page.
; } return ( diff --git a/src/components/Grid/gridUrlConfig.test.ts b/src/components/Grid/gridUrlConfig.test.ts new file mode 100644 index 00000000..eeb02077 --- /dev/null +++ b/src/components/Grid/gridUrlConfig.test.ts @@ -0,0 +1,282 @@ +import { Sorting } from "@devexpress/dx-react-grid"; +import { IGridColumn } from "./GridColumns"; +import { + encodeSortings, + decodeSortings, + encodeStringArray, + decodeStringArray, + encodeWidths, + decodeWidths, + urlKeyForColumn, + nameToUrlKeyMap, + urlKeyToNameMap, + findUrlKeyProblems, + parseGridConfigFromSearch, + reconcileColumnOrder, + dropUnknownColumns, + mergeColumnWidths, + encodeVisibleOrder, + decodeVisibleOrder, + IColumnWidth, +} from "./gridUrlConfig"; + +// "title" gets a urlKey to avoid colliding with the app's existing "title" search param. +// title/incoming are visible by default; level is hidden by default. +const columns: IGridColumn[] = [ + { + name: "title", + title: "Title", + urlKey: "ti", + defaultVisible: true, + sortingEnabled: true, + }, + { name: "incoming", title: "Incoming", urlKey: "in", defaultVisible: true }, + { name: "level", title: "Level", urlKey: "lv", sortingEnabled: true }, +]; + +describe("sortings encode/decode (operate on whatever token is given)", () => { + it("round-trips a multi-column sort", () => { + const sortings: Sorting[] = [ + { columnName: "ti", direction: "asc" }, + { columnName: "lv", direction: "desc" }, + ]; + expect(encodeSortings(sortings)).toBe("ti:asc,lv:desc"); + expect(decodeSortings("ti:asc,lv:desc")).toEqual(sortings); + }); + it("empty/absent => undefined; '' => []", () => { + expect(encodeSortings([])).toBeUndefined(); + expect(decodeSortings(undefined)).toBeUndefined(); + expect(decodeSortings("")).toEqual([]); + }); + it("defaults a missing/garbled direction to asc", () => { + expect(decodeSortings("ti")).toEqual([ + { columnName: "ti", direction: "asc" }, + ]); + expect(decodeSortings("ti:sideways")).toEqual([ + { columnName: "ti", direction: "asc" }, + ]); + }); + it("de-dupes repeated columns (keep last)", () => { + expect(decodeSortings("ti:asc,ti:desc")).toEqual([ + { columnName: "ti", direction: "desc" }, + ]); + }); +}); + +describe("string array (cols) encode/decode", () => { + it("round-trips a list; '' => [], absent => undefined", () => { + expect(encodeStringArray(["ti", "lv"])).toBe("ti,lv"); + expect(decodeStringArray("ti,lv")).toEqual(["ti", "lv"]); + expect(encodeStringArray([])).toBe(""); + expect(decodeStringArray("")).toEqual([]); + expect(decodeStringArray(undefined)).toBeUndefined(); + }); +}); + +describe("widths encode/decode (reject non-positive / non-integer)", () => { + it("round-trips only resized integer widths", () => { + const widths: IColumnWidth[] = [ + { columnName: "ti", width: 200 }, + { columnName: "in", width: "auto" }, + { columnName: "lv", width: 120 }, + ]; + expect(encodeWidths(widths)).toBe("ti:200,lv:120"); + expect(decodeWidths("ti:200,lv:120")).toEqual([ + { columnName: "ti", width: 200 }, + { columnName: "lv", width: 120 }, + ]); + }); + it("rejects empty, zero, negative, hex, and non-numeric widths", () => { + expect(decodeWidths("ti:")).toEqual([]); + expect(decodeWidths("ti:0")).toEqual([]); + expect(decodeWidths("ti:-50")).toEqual([]); + expect(decodeWidths("ti:0x10")).toEqual([]); + expect(decodeWidths("ti:abc,lv:150")).toEqual([ + { columnName: "lv", width: 150 }, + ]); + }); + it("de-dupes repeated columns (keep last)", () => { + expect(decodeWidths("ti:10,ti:20")).toEqual([ + { columnName: "ti", width: 20 }, + ]); + }); + it("encodes all-auto as undefined", () => { + expect( + encodeWidths([{ columnName: "ti", width: "auto" }]) + ).toBeUndefined(); + }); + + it("rounds sub-pixel widths to whole pixels (and so they round-trip)", () => { + const encoded = encodeWidths([ + { columnName: "lc", width: 252.85833740234375 }, + { columnName: "lg", width: 188.4 }, + ]); + expect(encoded).toBe("lc:253,lg:188"); + expect(decodeWidths(encoded)).toEqual([ + { columnName: "lc", width: 253 }, + { columnName: "lg", width: 188 }, + ]); + }); +}); + +describe("urlKey maps and validation", () => { + it("urlKeyForColumn falls back to name", () => { + expect(urlKeyForColumn(columns[0])).toBe("ti"); + expect(urlKeyForColumn({ name: "plain" })).toBe("plain"); + }); + it("builds name<->key maps both ways", () => { + expect(nameToUrlKeyMap(columns).get("title")).toBe("ti"); + expect(urlKeyToNameMap(columns).get("lv")).toBe("level"); + }); + it("findUrlKeyProblems flags duplicates and reserved keys", () => { + expect(findUrlKeyProblems(columns)).toEqual([]); + const dup: IGridColumn[] = [ + { name: "a", urlKey: "x" }, + { name: "b", urlKey: "x" }, + { name: "c", urlKey: "sort" }, + ]; + const problems = findUrlKeyProblems(dup); + expect(problems.some((p) => p.includes("duplicated"))).toBe(true); + expect(problems.some((p) => p.includes("reserved"))).toBe(true); + }); +}); + +describe("parseGridConfigFromSearch (URL keys -> internal names)", () => { + it("reads readable per-column filters keyed by urlKey", () => { + const cfg = parseGridConfigFromSearch("?in=true&lv=4&ti=math", columns); + expect(cfg.filters).toEqual([ + { columnName: "title", operation: "contains", value: "math" }, + { columnName: "incoming", operation: "contains", value: "true" }, + { columnName: "level", operation: "contains", value: "4" }, + ]); + }); + it("does NOT treat the reserved 'title' param as the title filter", () => { + expect( + parseGridConfigFromSearch("?title=search", columns).filters + ).toBeUndefined(); + }); + it("maps sort/cols/widths keys back to names, dropping unknowns", () => { + const cfg = parseGridConfigFromSearch( + "?sort=lv:desc&cols=lv,xx,ti,in&widths=lv:90", + columns + ); + expect(cfg.sortings).toEqual([ + { columnName: "level", direction: "desc" }, + ]); + // cols lists the visible columns in order (xx dropped); all three become visible. + expect(cfg.order).toEqual(["level", "title", "incoming"]); + expect(cfg.hidden).toEqual([]); + expect(cfg.widths).toEqual([{ columnName: "level", width: 90 }]); + }); + it("treats a bare/empty filter key as no filter (undefined, not []), so a caller's initialFilters aren't clobbered", () => { + // A hand-edited/stale link like ?ti= must NOT flip filters to [] (which would suppress + // a fallback); it should read as "no filter present at all". + expect( + parseGridConfigFromSearch("?ti=", columns).filters + ).toBeUndefined(); + // A real value elsewhere still yields only that filter; the empty key is ignored. + expect( + parseGridConfigFromSearch("?ti=&lv=4", columns).filters + ).toEqual([{ columnName: "level", operation: "contains", value: "4" }]); + }); + it("returns filters undefined when no filter params are present", () => { + expect( + parseGridConfigFromSearch("?sort=ti:asc", columns).filters + ).toBeUndefined(); + }); +}); + +describe("visible-order encode/decode (cols carries visibility + order)", () => { + // factory: visible = [title, incoming] (in order); hidden = [level] + const DEFAULT_ORDER = ["title", "incoming", "level"]; + + it("encodes the visible columns in order; omits when it matches the factory default", () => { + // default view -> undefined (bare URL) + expect( + encodeVisibleOrder(DEFAULT_ORDER, ["level"], columns) + ).toBeUndefined(); + // reveal level -> ti,in,lv + expect(encodeVisibleOrder(DEFAULT_ORDER, [], columns)).toBe("ti,in,lv"); + // hide title -> in + expect( + encodeVisibleOrder(DEFAULT_ORDER, ["title", "level"], columns) + ).toBe("in"); + // reorder visible columns -> in,ti + expect( + encodeVisibleOrder( + ["incoming", "title", "level"], + ["level"], + columns + ) + ).toBe("in,ti"); + }); + + it("decodes to a full order + hidden set; unlisted columns are hidden", () => { + expect(decodeVisibleOrder("ti,in,lv", columns)).toEqual({ + order: ["title", "incoming", "level"], + hidden: [], + }); + // only incoming visible; title/level hidden, kept at factory positions + expect(decodeVisibleOrder("in", columns)).toEqual({ + order: ["title", "incoming", "level"], + hidden: ["title", "level"], + }); + // reordered + unknown key dropped + dedupe + expect(decodeVisibleOrder("lv,xx,ti,ti", columns)).toEqual({ + order: ["level", "incoming", "title"], + hidden: ["incoming"], + }); + // "" => everything hidden; absent => undefined (fall back) + expect(decodeVisibleOrder("", columns)).toEqual({ + order: ["title", "incoming", "level"], + hidden: ["title", "incoming", "level"], + }); + expect(decodeVisibleOrder(undefined, columns)).toBeUndefined(); + }); + + it("round-trips (encode -> decode preserves the visible view)", () => { + const encoded = encodeVisibleOrder( + ["incoming", "title", "level"], + ["level"], + columns + ); + const decoded = decodeVisibleOrder(encoded, columns)!; + const visible = decoded.order.filter( + (n) => !decoded.hidden.includes(n) + ); + expect(visible).toEqual(["incoming", "title"]); + }); +}); + +describe("reconcile / dropUnknown / mergeColumnWidths", () => { + const all = ["a", "b", "c", "d"]; + it("appends columns missing from a stale order; drops removed ones", () => { + expect(reconcileColumnOrder(["a", "b", "c"], all)).toEqual([ + "a", + "b", + "c", + "d", + ]); + expect(reconcileColumnOrder(["b", "x", "a"], all)).toEqual([ + "b", + "a", + "c", + "d", + ]); + }); + it("dropUnknownColumns removes unknown names", () => { + expect(dropUnknownColumns(["a", "gone"], all)).toEqual(["a"]); + }); + it("mergeColumnWidths gives every column a width, defaulting to auto", () => { + expect( + mergeColumnWidths( + ["a", "b", "c"], + [{ columnName: "b", width: 150 }] + ) + ).toEqual([ + { columnName: "a", width: "auto" }, + { columnName: "b", width: 150 }, + { columnName: "c", width: "auto" }, + ]); + }); +}); diff --git a/src/components/Grid/gridUrlConfig.ts b/src/components/Grid/gridUrlConfig.ts new file mode 100644 index 00000000..ab0b2dec --- /dev/null +++ b/src/components/Grid/gridUrlConfig.ts @@ -0,0 +1,372 @@ +// Pure (no-React) serialization of grid configuration to/from the URL query string. +// +// Each grid screen (book, language, country, uploader) lets the user sort, filter, +// show/hide columns, reorder columns, and resize columns. We keep that configuration in +// the URL so a view can be bookmarked or shared. useGridConfigInUrl.ts wires this to React +// state and the address bar. +// +// URL scheme (each grid is on its own route, so these don't collide between grids, nor with +// the book grid's path :search segment or the start/end date params): +// sort sort model one param, comma list "name:asc|desc" +// cols the VISIBLE columns, in display order — a single param that carries BOTH which +// columns are shown and their order: listed = shown, unlisted = hidden. e.g. +// ?cols=ti,lg,lc . Omitted when the view matches the factory default. +// widths resized column widths one param, comma list "name:px" (only resized columns) +// a per-column filter one param PER filtered column, keyed by the column's +// urlKey (falls back to its name), e.g. ?incoming=true&level=4 +// +// Column names appear only as *values* inside sort/cols/widths, so they can't collide with +// other query params. A filter, by contrast, uses the column's key as the param *name*, which +// can collide (e.g. a "title" column vs. this app's existing "title" search param), so a column +// may define a short, collision-free `urlKey`. +// +// Visibility + order are encoded together (the single `cols` list) rather than as a separate +// "which columns are hidden" delta. That keeps each column out of the URL twice, avoids the +// long hidden-list "goop" in grids that hide most columns by default, lists only the handful of +// VISIBLE columns, and is stable if the factory defaults later change (an old link reproduces +// exactly the columns it names; columns added later simply aren't in it). + +import { Filter as GridFilter, Sorting } from "@devexpress/dx-react-grid"; +import { IGridColumn } from "./GridColumns"; + +// DevExpress represents a (possibly "auto") column width as this shape. +export interface IColumnWidth { + columnName: string; + width: number | string; +} + +// The single-value params the grid owns. Per-column filter params are added on top. +export const RESERVED_GRID_PARAM_KEYS = ["sort", "cols", "widths"]; + +type Maybe = string | null | undefined; + +// --------------------------------------------------------------------------- +// sort (one param: "name:asc,other:desc") +// --------------------------------------------------------------------------- + +export function encodeSortings( + sortings: ReadonlyArray | null | undefined +): string | undefined { + if (!sortings || sortings.length === 0) return undefined; + return sortings + .map( + (s) => `${s.columnName}:${s.direction === "desc" ? "desc" : "asc"}` + ) + .join(","); +} + +export function decodeSortings(value: Maybe): Sorting[] | undefined { + if (value === null || value === undefined) return undefined; + if (value === "") return []; + const parsed = value + .split(",") + .map((token) => token.trim()) + .filter((token) => token.length > 0) + .map((token) => { + const idx = token.lastIndexOf(":"); + const columnName = idx === -1 ? token : token.slice(0, idx); + const dir = idx === -1 ? "" : token.slice(idx + 1); + return { + columnName, + direction: dir === "desc" ? "desc" : "asc", + } as Sorting; + }) + .filter((s) => s.columnName.length > 0); + return dedupeByColumn(parsed); +} + +// --------------------------------------------------------------------------- +// string lists: cols (one param: "a,b,c") +// --------------------------------------------------------------------------- + +export function encodeStringArray( + arr: ReadonlyArray | null | undefined +): string | undefined { + if (arr === null || arr === undefined) return undefined; + // An empty array is meaningful (e.g. "nothing hidden"); encode it as "". + return arr.join(","); +} + +export function decodeStringArray(value: Maybe): string[] | undefined { + if (value === null || value === undefined) return undefined; + if (value === "") return []; + return value.split(",").filter((x) => x.length > 0); +} + +// --------------------------------------------------------------------------- +// column widths (one param: "title:200,date:120") +// --------------------------------------------------------------------------- + +export function encodeWidths( + widths: ReadonlyArray | null | undefined +): string | undefined { + if (!widths) return undefined; + const resized = widths.filter( + (w) => typeof w.width === "number" && isFinite(w.width as number) + ); + if (resized.length === 0) return undefined; + // DevExpress reports sub-pixel widths (e.g. 252.858...). Round to whole pixels: it keeps + // the URL sane and matches decodeWidths, which only accepts integers (so an un-rounded + // value would be silently dropped on reload). + return resized + .map((w) => `${w.columnName}:${Math.round(w.width as number)}`) + .join(","); +} + +export function decodeWidths(value: Maybe): IColumnWidth[] | undefined { + if (value === null || value === undefined) return undefined; + if (value === "") return []; + const result: IColumnWidth[] = []; + for (const token of value.split(",")) { + const idx = token.lastIndexOf(":"); + if (idx === -1) continue; + const columnName = token.slice(0, idx); + const raw = token.slice(idx + 1); + // Only accept a plain positive integer; this rejects "", "0", negatives, + // hex ("0x10"), and NaN, any of which would collapse/break a column from a + // stale or hand-edited URL. + if (!columnName || !/^\d+$/.test(raw)) continue; + const width = Number(raw); + if (width <= 0) continue; + result.push({ columnName, width }); + } + return dedupeByColumn(result); +} + +// Keep the last entry per columnName (a hand-edited URL could repeat a column). +function dedupeByColumn(items: T[]): T[] { + const byColumn = new Map(); + for (const item of items) byColumn.set(item.columnName, item); + return [...byColumn.values()]; +} + +// --------------------------------------------------------------------------- +// per-column filter param keys +// --------------------------------------------------------------------------- + +// The query-param identifier for a column: its short `urlKey` if set, otherwise its name. +// Used both as a filter's param NAME and as the column's token inside sort/cols/widths, +// so the whole URL reads in the same compact terms (e.g. ?in=true&sort=ti:desc&cols=ti,lg). +export function urlKeyForColumn(column: IGridColumn): string { + return column.urlKey ?? column.name; +} + +// Stamp each column definition with its short URL key from a per-grid `{name: urlKey}` map, +// leaving any key the map doesn't mention at whatever `urlKey` the definition already had. +// The country/language/uploader grids end getXGridColumnsDefinitions() with this; the book grid +// applies the same `map[name] ?? urlKey` merge inline (it also titleCases/sorts in one pass). +export function applyUrlKeys( + definitions: IGridColumn[], + urlKeysByName: { [name: string]: string } +): IGridColumn[] { + return definitions.map((c) => ({ + ...c, + urlKey: urlKeysByName[c.name] ?? c.urlKey, + })); +} + +export function nameToUrlKeyMap( + columnDefinitions: ReadonlyArray +): Map { + return new Map(columnDefinitions.map((c) => [c.name, urlKeyForColumn(c)])); +} + +export function urlKeyToNameMap( + columnDefinitions: ReadonlyArray +): Map { + return new Map(columnDefinitions.map((c) => [urlKeyForColumn(c), c.name])); +} + +// Dev-time guard: every column's urlKey must be unique within a grid and must not shadow a +// reserved param. Returns the offending keys (empty if all good) so a test/assert can report. +export function findUrlKeyProblems( + columnDefinitions: ReadonlyArray +): string[] { + const reserved = new Set(RESERVED_GRID_PARAM_KEYS); + const seen = new Set(); + const problems: string[] = []; + for (const column of columnDefinitions) { + const key = urlKeyForColumn(column); + if (reserved.has(key)) + problems.push(`${column.name}: urlKey "${key}" is reserved`); + if (seen.has(key)) + problems.push(`${column.name}: urlKey "${key}" is duplicated`); + seen.add(key); + } + return problems; +} + +// --------------------------------------------------------------------------- +// reconciliation / validation helpers +// --------------------------------------------------------------------------- + +// Turn a candidate column order (from the URL or localStorage, possibly stale) into a +// complete, valid order: keep known names in their given order, drop names that are no +// longer real columns, and append any columns missing from the candidate (e.g. a column +// added in a newer release) at the end in their default order. +export function reconcileColumnOrder( + candidate: ReadonlyArray | null | undefined, + allColumnNamesInDefaultOrder: ReadonlyArray +): string[] { + const valid = new Set(allColumnNamesInDefaultOrder); + const kept = (candidate || []).filter((name) => valid.has(name)); + const present = new Set(kept); + const appended = allColumnNamesInDefaultOrder.filter( + (name) => !present.has(name) + ); + return [...kept, ...appended]; +} + +// Drop any names that aren't real columns (e.g. a column removed since the URL was made). +export function dropUnknownColumns( + names: ReadonlyArray | null | undefined, + allColumnNames: ReadonlyArray +): string[] { + const valid = new Set(allColumnNames); + return (names || []).filter((name) => valid.has(name)); +} + +function arraysEqual( + a: ReadonlyArray, + b: ReadonlyArray +): boolean { + return a.length === b.length && a.every((x, i) => x === b[i]); +} + +// Encode the VISIBLE columns, in display order, as the `cols` value (urlKeys). This single +// value carries both visibility (listed = shown) and order. Returns undefined when the visible +// set+order already equals the factory default, so a default view keeps the URL bare. Hidden +// columns are simply omitted; decodeVisibleOrder slots them back at their factory positions. +export function encodeVisibleOrder( + columnNamesInDisplayOrder: ReadonlyArray, + hiddenColumnNames: ReadonlyArray, + columnDefinitions: ReadonlyArray +): string | undefined { + const hidden = new Set(hiddenColumnNames); + const visibleInOrder = columnNamesInDisplayOrder.filter( + (n) => !hidden.has(n) + ); + const factoryVisibleInOrder = columnDefinitions + .filter((c) => c.defaultVisible) + .map((c) => c.name); + if (arraysEqual(visibleInOrder, factoryVisibleInOrder)) return undefined; + const toKey = nameToUrlKeyMap(columnDefinitions); + return encodeStringArray(visibleInOrder.map((n) => toKey.get(n) ?? n)); +} + +// Inverse of encodeVisibleOrder. From the `cols` value, produce the full column order (the +// visible columns in the given order, slotted into their factory positions; hidden columns +// keep their factory slots) and the hidden set (every column not listed). Returns undefined +// when `cols` is absent (caller falls back to localStorage/defaults). +export function decodeVisibleOrder( + value: Maybe, + columnDefinitions: ReadonlyArray +): { order: string[]; hidden: string[] } | undefined { + const decoded = decodeStringArray(value); + if (decoded === undefined) return undefined; + const keyToName = urlKeyToNameMap(columnDefinitions); + const factoryOrder = columnDefinitions.map((c) => c.name); + // urlKeys -> names, drop unknown, de-dupe (a stale/hand-edited url could repeat) + const visibleSet = new Set(); + const visible: string[] = []; + for (const key of decoded) { + const name = keyToName.get(key); + if (name !== undefined && !visibleSet.has(name)) { + visibleSet.add(name); + visible.push(name); + } + } + // Place the visible columns (in their given order) into the factory slots that are visible; + // keep hidden columns at their factory positions so a later reveal lands somewhere sensible. + const queue = [...visible]; + const order = factoryOrder.map((name) => + visibleSet.has(name) ? queue.shift()! : name + ); + const hidden = factoryOrder.filter((name) => !visibleSet.has(name)); + return { order, hidden }; +} + +// Produce the full controlled width list the grid expects: every column gets a width, +// defaulting to "auto" unless a resized numeric width was supplied for it. +export function mergeColumnWidths( + allColumnNamesInDefaultOrder: ReadonlyArray, + resized: ReadonlyArray | null | undefined +): IColumnWidth[] { + const byName = new Map(); + (resized || []).forEach((w) => byName.set(w.columnName, w.width)); + return allColumnNamesInDefaultOrder.map((name) => ({ + columnName: name, + width: byName.has(name) ? byName.get(name)! : "auto", + })); +} + +// --------------------------------------------------------------------------- +// whole-config parse / build against a search string +// --------------------------------------------------------------------------- + +export interface IGridConfigFromUrl { + // All columnNames below are the grid's internal names (mapped back from URL keys). + sortings?: Sorting[]; + filters?: GridFilter[]; // undefined => no filter params present at all + // order + hidden are derived together from the single `cols` param (visible-in-order). + // Both undefined when `cols` is absent (caller falls back to localStorage/defaults). + order?: string[]; // full column order (all columns) + hidden?: string[]; + widths?: IColumnWidth[]; +} + +// Parse the grid's config out of a search string. The URL speaks in short urlKeys; everything +// returned here is mapped back to the grid's internal column NAMES (unknown keys dropped). The +// hook owns WRITING the URL (incrementally, per dimension) -- see useGridConfigInUrl. +export function parseGridConfigFromSearch( + search: string, + columnDefinitions: ReadonlyArray +): IGridConfigFromUrl { + const params = new URLSearchParams(search); + const reserved = new Set(RESERVED_GRID_PARAM_KEYS); + const keyToName = urlKeyToNameMap(columnDefinitions); + const toName = (key: string) => keyToName.get(key); + + let sawFilterKey = false; + const filters: GridFilter[] = []; + for (const column of columnDefinitions) { + const key = urlKeyForColumn(column); + if (reserved.has(key)) continue; // never let a column shadow sort/cols/widths + if (!params.has(key)) continue; + const value = params.get(key); + // Only an actual (non-empty) value counts as "a filter is present". A bare `?ti=` + // (hand-edited/stale link) contributes no filter and must NOT flip filters from + // `undefined` to `[]`, or it would suppress the caller's initialFilters fallback. + if (value !== null && value !== "") { + sawFilterKey = true; + filters.push({ + columnName: column.name, + operation: "contains", + value, + }); + } + } + + // map sort/widths tokens (urlKeys) back to internal names, dropping unknowns. toName may + // return undefined for an unknown key, so narrow with a type-guard rather than `!`. + const sortings = decodeSortings(params.get("sort")) + ?.map((s) => ({ ...s, columnName: toName(s.columnName) })) + .filter((s): s is Sorting => s.columnName !== undefined); + const widths = decodeWidths(params.get("widths")) + ?.map((w) => ({ ...w, columnName: toName(w.columnName) })) + .filter((w): w is IColumnWidth => w.columnName !== undefined); + + // `cols` (visible columns in order) yields both the full order and the hidden set. + const visibility = decodeVisibleOrder( + params.get("cols"), + columnDefinitions + ); + + return { + sortings, + filters: sawFilterKey ? filters : undefined, + order: visibility?.order, + hidden: visibility?.hidden, + widths, + }; +} diff --git a/src/components/Grid/useGridConfigInUrl.test.tsx b/src/components/Grid/useGridConfigInUrl.test.tsx new file mode 100644 index 00000000..2f1dc90c --- /dev/null +++ b/src/components/Grid/useGridConfigInUrl.test.tsx @@ -0,0 +1,547 @@ +// Integration test for useGridConfigInUrl. The hook reads window.location on mount, mirrors +// changes to the address bar with history.replaceState, and re-reads on popstate -- no router +// or provider involved -- so we drive it here with the real jsdom window history. This stands +// in for the live-browser check, since the grid screens themselves are behind a login gate. + +import React from "react"; +import ReactDOM from "react-dom"; +import { act } from "react-dom/test-utils"; +import { useGridConfigInUrl, IGridConfigInUrl } from "./useGridConfigInUrl"; +import { IGridColumn } from "./GridColumns"; +import { Filter as GridFilter } from "@devexpress/dx-react-grid"; + +// Short urlKeys mirror the real scheme. "Is Rebrand" (a real book column whose NAME has a +// space) is included to prove the space lives only in the name, never in the URL (key "rb"). +const columns: IGridColumn[] = [ + { + name: "title", + title: "Title", + urlKey: "ti", + defaultVisible: true, + sortingEnabled: true, + }, + { name: "incoming", title: "Incoming", urlKey: "in", defaultVisible: true }, + { + name: "level", + title: "Level", + urlKey: "lv", + defaultVisible: false, + sortingEnabled: true, + }, + { + name: "Is Rebrand", + title: "Is Rebrand", + urlKey: "rb", + defaultVisible: false, + }, +]; +const DEFAULT_ORDER = ["title", "incoming", "level", "Is Rebrand"]; +const DEFAULT_HIDDEN = ["level", "Is Rebrand"]; + +let api: IGridConfigInUrl; +let harnessOptions: + | { initialFilters?: GridFilter[]; availableColumnNames?: string[] } + | undefined; +function Harness() { + api = useGridConfigInUrl(columns, "test-grid", harnessOptions); + return null; +} + +let container: HTMLDivElement; +function mount() { + container = document.createElement("div"); + document.body.appendChild(container); + act(() => { + ReactDOM.render(, container); + }); +} +function unmount() { + act(() => { + ReactDOM.unmountComponentAtNode(container); + }); + container.remove(); +} +function search() { + return decodeURIComponent(window.location.search); +} +function param(key: string) { + return new URLSearchParams(window.location.search).get(key); +} + +beforeEach(() => { + localStorage.clear(); + harnessOptions = undefined; + window.history.replaceState(null, "", "/grid/books"); +}); +afterEach(() => { + if (container && container.parentNode) unmount(); +}); + +describe("writing config to the URL (readable, abbreviated keys)", () => { + it("starts with defaults and a clean URL", () => { + mount(); + expect(window.location.search).toBe(""); + expect(api.sortings).toEqual([]); + expect(api.gridFilters).toEqual([]); + expect(api.columnNamesInDisplayOrder).toEqual(DEFAULT_ORDER); + expect(api.hiddenColumnNames).toEqual(DEFAULT_HIDDEN); + }); + + it("writes per-column filters keyed by the column's urlKey", () => { + mount(); + act(() => { + api.setGridFilters([ + { + columnName: "incoming", + operation: "contains", + value: "true", + }, + { columnName: "level", operation: "contains", value: "4" }, + ]); + }); + expect(search()).toContain("in=true"); + expect(search()).toContain("lv=4"); + expect(search()).not.toContain("["); + }); + + it("uses 'ti' for the title column (never a bare 'title' param)", () => { + mount(); + act(() => + api.setGridFilters([ + { columnName: "title", operation: "contains", value: "math" }, + ]) + ); + expect(param("ti")).toBe("math"); + expect(param("title")).toBeNull(); + }); + + it("maps a space-containing column name to a clean key ('Is Rebrand' -> rb)", () => { + mount(); + act(() => + api.setGridFilters([ + { + columnName: "Is Rebrand", + operation: "contains", + value: "yes", + }, + ]) + ); + expect(param("rb")).toBe("yes"); + expect(window.location.search).not.toContain("Rebrand"); + }); + + it("keeps special characters in a filter value exact", () => { + mount(); + act(() => + api.setGridFilters([ + { + columnName: "title", + operation: "contains", + value: 'a&b=c#d %100 +z "q"', + }, + ]) + ); + expect(param("ti")).toBe('a&b=c#d %100 +z "q"'); + }); + + it("writes sort, widths, and cols (visible columns in order); omits cols at default", () => { + mount(); + act(() => + api.setSortings([{ columnName: "level", direction: "desc" }]) + ); + // reveal everything -> all four columns visible, in order + act(() => api.setHiddenColumnNames([])); + act(() => + api.setColumnWidths([ + { columnName: "title", width: 250 }, + { columnName: "incoming", width: "auto" }, + { columnName: "level", width: "auto" }, + { columnName: "Is Rebrand", width: "auto" }, + ]) + ); + expect(param("sort")).toBe("lv:desc"); + expect(param("cols")).toBe("ti,in,lv,rb"); // visible-in-order (no separate show/hide) + expect(param("widths")).toBe("ti:250"); + + // hide down to a single visible column -> cols=in + act(() => api.setHiddenColumnNames(["title", "level", "Is Rebrand"])); + expect(param("cols")).toBe("in"); + + // reorder the visible columns -> cols reflects the new visible order + act(() => api.setHiddenColumnNames(DEFAULT_HIDDEN)); // visible back to [title, incoming] + act(() => + api.setColumnNamesInDisplayOrder([ + "incoming", + "title", + "level", + "Is Rebrand", + ]) + ); + expect(param("cols")).toBe("in,ti"); + + // back to the factory default view -> cols removed (no stale shadow) + act(() => api.setColumnNamesInDisplayOrder(DEFAULT_ORDER)); + expect(param("cols")).toBeNull(); + }); + + it("writes commas and colons literally (no %2C / %3A) for readability", () => { + mount(); + act(() => + api.setSortings([{ columnName: "level", direction: "desc" }]) + ); + act(() => api.setHiddenColumnNames([])); // reveal all -> cols=ti,in,lv,rb + // raw (un-decoded) search string should be human-readable + expect(window.location.search).toContain("sort=lv:desc"); + expect(window.location.search).toContain("cols=ti,in,lv,rb"); + expect(window.location.search).not.toContain("%2C"); + expect(window.location.search).not.toContain("%3A"); + }); + + it("ignores a sort on a non-sortable column from a crafted URL", () => { + window.history.replaceState(null, "", "/grid/books?sort=in:asc"); // incoming not sortable + mount(); + expect(api.sortings).toEqual([]); + }); +}); + +describe("typing stays in step (regression for dropped characters)", () => { + it("updates local state synchronously per keystroke and mirrors to the URL", () => { + mount(); + for (const v of ["m", "ma", "mat", "math"]) { + act(() => + api.setGridFilters([ + { columnName: "title", operation: "contains", value: v }, + ]) + ); + expect(api.gridFilters[0].value).toBe(v); + } + expect(param("ti")).toBe("math"); + }); +}); + +describe("restoring config from the URL (a shared/bookmarked link)", () => { + it("hydrates every dimension from an abbreviated query string", () => { + window.history.replaceState( + null, + "", + "/grid/books?sort=lv:asc&cols=ti,in,lv&widths=in:180&in=true" + ); + mount(); + expect(api.sortings).toEqual([ + { columnName: "level", direction: "asc" }, + ]); + // cols=ti,in,lv => those three visible; Is Rebrand (not listed) hidden + expect(api.hiddenColumnNames).toEqual(["Is Rebrand"]); + expect(api.columnNamesInDisplayOrder).toEqual(DEFAULT_ORDER); + expect(api.gridFilters).toEqual([ + { columnName: "incoming", operation: "contains", value: "true" }, + ]); + expect(api.columnWidths).toEqual([ + { columnName: "title", width: "auto" }, + { columnName: "incoming", width: 180 }, + { columnName: "level", width: "auto" }, + { columnName: "Is Rebrand", width: "auto" }, + ]); + }); + + it("treats columns not listed in cols as hidden (kept at factory position)", () => { + window.history.replaceState(null, "", "/grid/books?cols=ti,in"); + mount(); + // title+incoming visible (factory default); level & Is Rebrand hidden, at factory slots + expect(api.columnNamesInDisplayOrder).toEqual(DEFAULT_ORDER); + expect(api.hiddenColumnNames).toEqual(DEFAULT_HIDDEN); + }); + + it("round-trips resized widths across a remount", () => { + mount(); + act(() => + api.setColumnWidths([ + { columnName: "title", width: 300 }, + { columnName: "incoming", width: "auto" }, + { columnName: "level", width: "auto" }, + { columnName: "Is Rebrand", width: "auto" }, + ]) + ); + expect(param("widths")).toBe("ti:300"); + unmount(); + mount(); // fresh component reads the URL again + expect( + api.columnWidths.find((w) => w.columnName === "title")!.width + ).toBe(300); + }); +}); + +describe("back/forward navigation (popstate) re-reads the URL", () => { + it("restores sort and column order on Back", () => { + mount(); + act(() => api.setSortings([{ columnName: "title", direction: "asc" }])); + act(() => { + window.history.replaceState( + null, + "", + "/grid/books?sort=lv:desc&cols=lv,ti,in,rb" + ); + window.dispatchEvent(new PopStateEvent("popstate")); + }); + expect(api.sortings).toEqual([ + { columnName: "level", direction: "desc" }, + ]); + expect(api.columnNamesInDisplayOrder).toEqual([ + "level", + "title", + "incoming", + "Is Rebrand", + ]); + }); + + it("restores filters on Back", () => { + mount(); + act(() => + api.setGridFilters([ + { columnName: "level", operation: "contains", value: "4" }, + ]) + ); + act(() => { + window.history.replaceState(null, "", "/grid/books?in=true"); + window.dispatchEvent(new PopStateEvent("popstate")); + }); + expect(api.gridFilters).toEqual([ + { columnName: "incoming", operation: "contains", value: "true" }, + ]); + }); +}); + +describe("initialFilters (e.g. bulk-edit) seeding & precedence", () => { + it("seeds filters from initialFilters when the URL is silent", () => { + harnessOptions = { + initialFilters: [ + { + columnName: "incoming", + operation: "contains", + value: "true", + }, + ], + }; + mount(); + expect(api.gridFilters).toEqual([ + { columnName: "incoming", operation: "contains", value: "true" }, + ]); + }); + + it("lets URL filters override initialFilters", () => { + harnessOptions = { + initialFilters: [ + { + columnName: "incoming", + operation: "contains", + value: "true", + }, + ], + }; + window.history.replaceState(null, "", "/grid/books?lv=4"); + mount(); + expect(api.gridFilters).toEqual([ + { columnName: "level", operation: "contains", value: "4" }, + ]); + }); + + it("falls back to initialFilters (not empty) on Back to a filterless URL", () => { + harnessOptions = { + initialFilters: [ + { + columnName: "incoming", + operation: "contains", + value: "true", + }, + ], + }; + mount(); + act(() => + api.setGridFilters([ + { columnName: "level", operation: "contains", value: "4" }, + ]) + ); + act(() => { + window.history.replaceState(null, "", "/grid/books"); + window.dispatchEvent(new PopStateEvent("popstate")); + }); + expect(api.gridFilters).toEqual([ + { columnName: "incoming", operation: "contains", value: "true" }, + ]); + }); + + it("mirrors seeded initialFilters into a bare URL on mount (so the shown view is shareable)", () => { + harnessOptions = { + initialFilters: [ + { + columnName: "incoming", + operation: "contains", + value: "true", + }, + ], + }; + mount(); + // The grid is filtered AND the address bar reflects it, so copying the URL reproduces it. + expect(api.gridFilters).toEqual([ + { columnName: "incoming", operation: "contains", value: "true" }, + ]); + expect(param("in")).toBe("true"); + }); + + it("does not let a bare/empty filter param clobber initialFilters", () => { + harnessOptions = { + initialFilters: [ + { + columnName: "incoming", + operation: "contains", + value: "true", + }, + ], + }; + // ?ti= carries no real filter; the seeded initialFilters must survive. + window.history.replaceState(null, "", "/grid/books?ti="); + mount(); + expect(api.gridFilters).toEqual([ + { columnName: "incoming", operation: "contains", value: "true" }, + ]); + }); +}); + +describe("bare-URL backfill (make a localStorage layout shareable)", () => { + it("writes the localStorage-derived view into a bare URL as compact deltas", () => { + localStorage.setItem( + "test-grid-column-order", + JSON.stringify(["incoming", "title", "level", "Is Rebrand"]) + ); + localStorage.setItem( + "test-grid-column-hidden", + JSON.stringify(["Is Rebrand"]) // level revealed vs default + ); + mount(); + // single cols param = the visible columns in order (incoming,title,level; Is Rebrand hidden) + expect(param("cols")).toBe("in,ti,lv"); + }); + + it("leaves a bare URL bare when the layout equals the factory default", () => { + mount(); + expect(window.location.search).toBe(""); + }); + + it("does NOT inject local layout into a URL that already has grid params (shared link)", () => { + localStorage.setItem( + "test-grid-column-order", + JSON.stringify(["incoming", "title", "level", "Is Rebrand"]) + ); + window.history.replaceState(null, "", "/grid/books?ti=math"); + mount(); + // respected as-is: the shared filter stays, our local reorder is NOT added + expect(param("ti")).toBe("math"); + expect(param("cols")).toBeNull(); + }); +}); + +describe("localStorage precedence", () => { + it("uses personal localStorage column prefs when the URL is silent", () => { + localStorage.setItem( + "test-grid-column-hidden", + JSON.stringify(["incoming"]) + ); + localStorage.setItem( + "test-grid-column-order", + JSON.stringify(["incoming", "title", "level", "Is Rebrand"]) + ); + mount(); + expect(api.hiddenColumnNames).toEqual(["incoming"]); + expect(api.columnNamesInDisplayOrder).toEqual([ + "incoming", + "title", + "level", + "Is Rebrand", + ]); + }); + + it("lets the URL override the localStorage prefs", () => { + localStorage.setItem( + "test-grid-column-hidden", + JSON.stringify(["incoming"]) + ); + // cols=ti => only title visible; URL wins over the localStorage hidden set + window.history.replaceState(null, "", "/grid/books?cols=ti"); + mount(); + expect(api.hiddenColumnNames).toEqual([ + "incoming", + "level", + "Is Rebrand", + ]); + }); +}); + +describe("availableColumnNames (role-restricted columns from a shared link)", () => { + it("drops sort/filter on a column this user cannot see", () => { + // A shared link sorts and filters by `level`, but this user's available set excludes it + // (as if `level` were moderator-only and the viewer is not a moderator). + harnessOptions = { + availableColumnNames: ["title", "incoming", "Is Rebrand"], + }; + window.history.replaceState(null, "", "/grid/books?sort=lv:asc&lv=4"); + mount(); + expect(api.sortings).toEqual([]); + expect(api.gridFilters).toEqual([]); + // Column order still spans every definition (unchanged behavior). + expect(api.columnNamesInDisplayOrder).toContain("level"); + }); + + it("honors sort/filter on a column the user CAN see", () => { + harnessOptions = { availableColumnNames: DEFAULT_ORDER }; + window.history.replaceState(null, "", "/grid/books?sort=lv:asc&lv=4"); + mount(); + expect(api.sortings).toEqual([ + { columnName: "level", direction: "asc" }, + ]); + expect(api.gridFilters).toEqual([ + { columnName: "level", operation: "contains", value: "4" }, + ]); + }); + + it("does not lose a filter on an unavailable column when the user edits a visible one", () => { + // 'level' is not available to this user; a shared link filters it plus 'title'. + harnessOptions = { + availableColumnNames: ["title", "incoming", "Is Rebrand"], + }; + window.history.replaceState(null, "", "/grid/books?lv=4&ti=old"); + mount(); + expect(api.gridFilters).toEqual([ + { columnName: "title", operation: "contains", value: "old" }, + ]); + // The user edits the visible title filter; DevExpress only hands back visible columns. + act(() => + api.setGridFilters([ + { columnName: "title", operation: "contains", value: "new" }, + ]) + ); + // Still only title is visible to this user... + expect(api.gridFilters).toEqual([ + { columnName: "title", operation: "contains", value: "new" }, + ]); + // ...but the unavailable 'level' filter is preserved in state + URL (not clobbered). + expect(param("lv")).toBe("4"); + expect(param("ti")).toBe("new"); + }); + + it("does not lose a sort on an unavailable column when the user changes a visible one", () => { + harnessOptions = { + availableColumnNames: ["title", "incoming", "Is Rebrand"], + }; + window.history.replaceState(null, "", "/grid/books?sort=lv:asc"); + mount(); + expect(api.sortings).toEqual([]); + act(() => + api.setSortings([{ columnName: "title", direction: "desc" }]) + ); + expect(api.sortings).toEqual([ + { columnName: "title", direction: "desc" }, + ]); + // Visible title sort plus the preserved (unavailable) level sort. + expect(param("sort")).toBe("ti:desc,lv:asc"); + }); +}); diff --git a/src/components/Grid/useGridConfigInUrl.ts b/src/components/Grid/useGridConfigInUrl.ts new file mode 100644 index 00000000..6ddddd54 --- /dev/null +++ b/src/components/Grid/useGridConfigInUrl.ts @@ -0,0 +1,392 @@ +// Reusable hook that keeps a grid's configuration (sort, per-column filters, which columns +// are shown, their order, and any resized widths) in the URL so a view can be bookmarked or +// shared, while still honoring the user's personal column preferences stored in localStorage. +// +// Why we touch the URL with native history.replaceState instead of react-router / +// use-query-params: the filter row is a free-typing text input. Writing the URL through the +// router re-renders the routed tree on every keystroke, which blurs the input and drops +// characters. replaceState updates the address bar WITHOUT a navigation/re-render, so typing +// stays smooth. Grid state therefore lives in local React state (the source of truth for the +// controlled grid); the URL is a live mirror we (a) seed from on mount, (b) write on change, +// and (c) re-read on back/forward (popstate). +// +// Caveat (intentional/benign): because we use native replaceState, react-router's internal +// `history.location` does not learn about these query-string changes. That is fine here: the +// grid lives on a path-segment route (/grid/:filter*), nothing on the page reads the grid +// params from the router, and all navigation off these pages uses absolute hrefs (BlorgLink), +// so the stale router search is never used to compose a destination. Real Back/Forward fires a +// genuine popstate, which both react-router and the listener below handle. +// +// Precedence on load: a dimension present in the URL wins; otherwise we fall back to +// localStorage (for column order/visibility) or the column-definition defaults. +// +// The URL speaks in each column's short `urlKey` (see gridUrlConfig); internally the grid uses +// the column `name`. This hook maps name->key when writing and parseGridConfigFromSearch maps +// key->name when reading. +// +// Assumes one grid instance per route (the params sort/cols/widths + per-column filter keys are +// global to the query string; storageKeyPrefix namespaces only localStorage). + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useStorageState } from "react-storage-hooks"; +import { Filter as GridFilter, Sorting } from "@devexpress/dx-react-grid"; +import { IGridColumn } from "./GridColumns"; +import { + parseGridConfigFromSearch, + reconcileColumnOrder, + dropUnknownColumns, + mergeColumnWidths, + encodeVisibleOrder, + encodeSortings, + encodeWidths, + urlKeyForColumn, + nameToUrlKeyMap, + findUrlKeyProblems, + RESERVED_GRID_PARAM_KEYS, + IColumnWidth, + IGridConfigFromUrl, +} from "./gridUrlConfig"; + +export interface IGridConfigInUrl { + sortings: Sorting[]; + setSortings: (sortings: Sorting[]) => void; + gridFilters: GridFilter[]; + setGridFilters: (filters: GridFilter[]) => void; + columnNamesInDisplayOrder: string[]; + setColumnNamesInDisplayOrder: (order: string[]) => void; + hiddenColumnNames: string[]; + setHiddenColumnNames: (hidden: string[]) => void; + columnWidths: IColumnWidth[]; + setColumnWidths: (widths: IColumnWidth[]) => void; +} + +// storageKeyPrefix is e.g. "book-grid" / "language-grid"; it must match the keys the grids +// used before so existing users keep their saved column preferences. initialFilters seeds the +// per-column filters when the URL has none (e.g. the bulk-edit page opens the grid pre-filtered). +// availableColumnNames (optional) is the set of columns THIS user may see (see +// getColumnsVisibleToUser). A shared/bookmarked URL can name a column the viewer lacks (e.g. a +// moderator-only column); such sort/filter entries are ignored in what we hand back to the grid, +// so nobody ends up sorting/filtering by a column they can neither see nor clear. Defaults to all +// columns when the caller doesn't supply it. Pass a value that's stable across renders (memoize). +export function useGridConfigInUrl( + columnDefinitions: ReadonlyArray, + storageKeyPrefix: string, + options?: { + initialFilters?: GridFilter[]; + availableColumnNames?: ReadonlyArray; + } +): IGridConfigInUrl { + const allColumnNames = useMemo(() => columnDefinitions.map((c) => c.name), [ + columnDefinitions, + ]); + const defaultHidden = useMemo( + () => + columnDefinitions + .filter((c) => !c.defaultVisible) + .map((c) => c.name), + [columnDefinitions] + ); + const sortableNames = useMemo( + () => + new Set( + columnDefinitions + .filter((c) => c.sortingEnabled) + .map((c) => c.name) + ), + [columnDefinitions] + ); + const nameToKey = useMemo(() => nameToUrlKeyMap(columnDefinitions), [ + columnDefinitions, + ]); + const toKey = (name: string) => nameToKey.get(name) ?? name; + + // Dev-time guard: a duplicated or reserved urlKey would silently lose a column's + // filter/sort/etc. in the URL. Surface it loudly during development. + useEffect(() => { + if (process.env.NODE_ENV !== "production") { + const problems = findUrlKeyProblems(columnDefinitions); + if (problems.length) + // eslint-disable-next-line no-console + console.error( + `[${storageKeyPrefix}] grid urlKey problems:\n` + + problems.join("\n") + ); + } + }, [columnDefinitions, storageKeyPrefix]); + + // Personal defaults: same localStorage keys the grids used before this feature. + const [storedOrder, setStoredOrder] = useStorageState( + localStorage, + `${storageKeyPrefix}-column-order`, + allColumnNames + ); + const [storedHidden, setStoredHidden] = useStorageState( + localStorage, + `${storageKeyPrefix}-column-hidden`, + defaultHidden + ); + + // The columns THIS user may actually see. URL sort/filter config naming a column outside this + // set is dropped from what we expose (see the visible* memos near the return). Defaults to all. + const availableSet = useMemo( + () => new Set(options?.availableColumnNames ?? allColumnNames), + [options?.availableColumnNames, allColumnNames] + ); + + // A sort is only meaningful for a column that is both real and sortable. + const onlyValidSortings = (sortings: Sorting[] | undefined) => + (sortings ?? []).filter((s) => sortableNames.has(s.columnName)); + const onlyKnownFilters = (filters: GridFilter[] | undefined) => { + const known = new Set(allColumnNames); + return (filters ?? []).filter((f) => known.has(f.columnName)); + }; + + // The single precedence pipeline: a dimension present in the URL wins; otherwise fall back to + // the seeded initialFilters / personal localStorage layout / column defaults. Both the mount + // initializers and the popstate handler go through here so the two can't drift apart. + const buildStateFromConfig = ( + cfg: IGridConfigFromUrl, + fallbacks: { + storedOrder: string[]; + storedHidden: string[]; + initialFilters: GridFilter[] | undefined; + } + ) => ({ + sortings: onlyValidSortings(cfg.sortings), + filters: onlyKnownFilters(cfg.filters ?? fallbacks.initialFilters), + order: reconcileColumnOrder( + cfg.order ?? fallbacks.storedOrder, + allColumnNames + ), + hidden: dropUnknownColumns( + cfg.hidden ?? fallbacks.storedHidden, + allColumnNames + ), + widths: mergeColumnWidths(allColumnNames, cfg.widths), + }); + + // Parse the URL exactly once (mount). Reads of window.location here are intentional: the + // URL, not props, is the initial source of truth. (Memo deps are stable -> effectively + // mount-only; the result is consumed solely by the useState initializers below.) + const initial = useMemo( + () => + parseGridConfigFromSearch( + window.location.search, + columnDefinitions + ), + [columnDefinitions] + ); + const initialState = useMemo( + () => + buildStateFromConfig(initial, { + storedOrder, + storedHidden, + initialFilters: options?.initialFilters, + }), + // Mount snapshot only (mirrors `initial`); later localStorage/prop changes flow through + // the setters and popstate, not this one-time seed. + // eslint-disable-next-line react-hooks/exhaustive-deps + [initial] + ); + + const [sortings, setSortingsState] = useState( + initialState.sortings + ); + const [gridFilters, setFiltersState] = useState( + initialState.filters + ); + // order + hidden come together from the URL's `cols` (visible-in-order); if absent, fall + // back to the personal localStorage layout (order/hidden are stored separately there). + const [columnNamesInDisplayOrder, setOrderState] = useState( + initialState.order + ); + const [hiddenColumnNames, setHiddenState] = useState( + initialState.hidden + ); + const [columnWidths, setWidthsState] = useState( + initialState.widths + ); + + // --- write a single dimension to the address bar without causing a re-render --- + + const commitSearch = (params: URLSearchParams) => { + // URLSearchParams.toString() percent-encodes "," and ":" even though both are legal + // and readable in a query string. Un-encode just those two separators so the URL reads + // `show=ca,ph` / `sort=ti:desc` instead of `show=ca%2Cph`. Structural characters in + // filter values (&, =, #, space) stay encoded, and parsing handles either form. + const q = params.toString().replace(/%2C/g, ",").replace(/%3A/g, ":"); + const url = + window.location.pathname + + (q ? "?" + q : "") + + window.location.hash; + // Preserve window.history.state so react-router/history v4's navigation key survives. + window.history.replaceState(window.history.state, "", url); + }; + const writeParam = (key: string, value: string | undefined) => { + const params = new URLSearchParams(window.location.search); + if (value === undefined) params.delete(key); + else params.set(key, value); + commitSearch(params); + }; + const writeFilters = (filters: GridFilter[]) => { + const params = new URLSearchParams(window.location.search); + const reserved = new Set(RESERVED_GRID_PARAM_KEYS); + // Clear every per-column filter key, then set the active ones. Leaves + // sort/cols/widths and any unrelated params alone. + for (const c of columnDefinitions) { + const k = urlKeyForColumn(c); + if (!reserved.has(k)) params.delete(k); + } + for (const f of filters) { + if (f.value === undefined || f.value === null || f.value === "") + continue; + const key = toKey(f.columnName); + if (!reserved.has(key)) params.set(key, String(f.value)); + } + commitSearch(params); + }; + // `cols` carries visibility + order together (see gridUrlConfig). Both setColumns and + // setHidden funnel through here. encodeVisibleOrder returns undefined when the view matches + // the factory default, so writeParam then removes `cols` and the URL stays clean. + const writeVisibleOrder = (order: string[], hidden: string[]) => { + writeParam( + "cols", + encodeVisibleOrder(order, hidden, columnDefinitions) + ); + }; + + // --- back/forward (and manual URL edits): re-read the URL into local state --- + + // Keep the latest fallbacks in a ref so the popstate listener doesn't need to re-bind. + const fallbackRef = useRef({ + storedOrder, + storedHidden, + initialFilters: options?.initialFilters, + }); + fallbackRef.current = { + storedOrder, + storedHidden, + initialFilters: options?.initialFilters, + }; + useEffect(() => { + const onPop = () => { + const cfg = parseGridConfigFromSearch( + window.location.search, + columnDefinitions + ); + // Same precedence as the mount seed (URL wins; else seeded/localStorage/defaults). + const next = buildStateFromConfig(cfg, fallbackRef.current); + setSortingsState(next.sortings); + setFiltersState(next.filters); + setOrderState(next.order); + setHiddenState(next.hidden); + setWidthsState(next.widths); + }; + window.addEventListener("popstate", onPop); + return () => window.removeEventListener("popstate", onPop); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [columnDefinitions, allColumnNames, sortableNames]); + + // On a BARE url (no grid params at all), backfill the address bar from the user's current + // (localStorage-derived) view so the URL is shareable without manually toggling everything. + // encodeVisibleOrder writes nothing when the layout matches the factory default, so a bare + // default view stays bare. A URL that already carries any grid param is treated as + // explicit/shared and left untouched -- we never inject one viewer's saved layout into + // someone else's link. (Only column order/visibility live in localStorage; sort, filters and + // widths are URL-only, so on a bare url they're empty and nothing is written for them.) + const didBackfillRef = useRef(false); + useEffect(() => { + if (didBackfillRef.current) return; + didBackfillRef.current = true; + const urlHadGridConfig = !!( + (initial.sortings && initial.sortings.length) || + (initial.filters && initial.filters.length) || + initial.order || + (initial.widths && initial.widths.length) + ); + if (urlHadGridConfig) return; + writeVisibleOrder(columnNamesInDisplayOrder, hiddenColumnNames); + // Also mirror any seeded filters (e.g. bulk-edit's initialFilters) into the bare URL, so + // the view the user sees is actually the view a copied/bookmarked link reproduces. When + // there are none this is a no-op (writeFilters just clears the — absent — filter keys). + writeFilters(gridFilters); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // What we hand to the grid/query excludes any sort/filter on a column this user can't see + // (e.g. a moderator-only column named by a shared link). The raw state keeps the full config, + // so if the user's available set later widens (auth resolves) the entry re-appears. Column + // order/hidden/widths already span every definition and stay unfiltered (as before). + const visibleSortings = useMemo( + () => sortings.filter((s) => availableSet.has(s.columnName)), + [sortings, availableSet] + ); + const visibleGridFilters = useMemo( + () => gridFilters.filter((f) => availableSet.has(f.columnName)), + [gridFilters, availableSet] + ); + + return { + sortings: visibleSortings, + setSortings: (next: Sorting[]) => { + // `next` only ever covers columns this user can see (the grid never renders the + // others). Merge back any sort on a not-currently-available column so raw state and + // the URL keep the full config -- otherwise a moderator who edits a sort during the + // brief auth-loading window (or on a shared link) would silently lose a sort on a + // moderator-only column. Available and unavailable columns are disjoint, so no dupes. + const preserved = sortings.filter( + (s) => !availableSet.has(s.columnName) + ); + const merged = [...next, ...preserved]; + setSortingsState(merged); + writeParam( + "sort", + encodeSortings( + merged.map((s) => ({ + ...s, + columnName: toKey(s.columnName), + })) + ) + ); + }, + gridFilters: visibleGridFilters, + setGridFilters: (next: GridFilter[]) => { + // Same merge as setSortings: keep filters on columns outside the user's available set + // (e.g. during auth loading, or a shared link's moderator-only filter) so they aren't + // dropped from state/URL when the user edits a visible filter. (Filter order doesn't + // matter to CombineGridAndSearchBoxFilter.) + const preserved = gridFilters.filter( + (f) => !availableSet.has(f.columnName) + ); + const merged = [...next, ...preserved]; + // Local state first (snappy, focused typing); replaceState mirror second (no + // re-render, so the focused input is never disturbed). + setFiltersState(merged); + writeFilters(merged); + }, + columnNamesInDisplayOrder, + setColumnNamesInDisplayOrder: (next: string[]) => { + setOrderState(next); + setStoredOrder(next); // update personal default too + // `cols` encodes visible-columns-in-order, so it depends on the current hidden set. + writeVisibleOrder(next, hiddenColumnNames); + }, + hiddenColumnNames, + setHiddenColumnNames: (next: string[]) => { + setHiddenState(next); + setStoredHidden(next); // update personal default too + writeVisibleOrder(columnNamesInDisplayOrder, next); + }, + columnWidths, + setColumnWidths: (next: IColumnWidth[]) => { + setWidthsState(next); + // encodeWidths keeps only resized columns; all-auto => param removed. + writeParam( + "widths", + encodeWidths( + next.map((w) => ({ ...w, columnName: toKey(w.columnName) })) + ) + ); + }, + }; +} diff --git a/src/components/LanguageGrid/LanguageGridColumns.tsx b/src/components/LanguageGrid/LanguageGridColumns.tsx index 7ad3ee90..e874bdee 100644 --- a/src/components/LanguageGrid/LanguageGridColumns.tsx +++ b/src/components/LanguageGrid/LanguageGridColumns.tsx @@ -1,5 +1,6 @@ import React from "react"; import { IGridColumn } from "../Grid/GridColumns"; +import { applyUrlKeys } from "../Grid/gridUrlConfig"; import { TableFilterRow } from "@devexpress/dx-react-grid-material-ui"; import { Filter, Sorting } from "@devexpress/dx-react-grid"; import { @@ -26,6 +27,24 @@ export interface ILanguageGridRowData { } // Define the function getLanguageGridColumnsDefinitions +// Short, stable URL keys for every language-grid column (filters + sort/cols/widths). +// Must be unique within this grid and not equal a reserved param (sort/cols/widths). +const languageGridUrlKeys: { [name: string]: string } = { + exonym: "ex", + endonym: "en", + otherNames: "on", + langTag: "lt", + firstSeen: "fs", + bookCount: "bc", + level1Count: "l1", + level2Count: "l2", + level3Count: "l3", + level4Count: "l4", + uploaderCount: "uc", + uploaderEmails: "ue", + countryName: "cn", +}; + export function getLanguageGridColumnsDefinitions(): IGridColumn[] { const definitions: IGridColumn[] = [ { @@ -297,7 +316,7 @@ export function getLanguageGridColumnsDefinitions(): IGridColumn[] { }, }, ]; - return definitions; + return applyUrlKeys(definitions, languageGridUrlKeys); } export function filterBooksBeforeCreatingLanguageGridRows( diff --git a/src/components/LanguageGrid/LanguageGridControlInternal.tsx b/src/components/LanguageGrid/LanguageGridControlInternal.tsx index 69769e4d..14fcb03b 100644 --- a/src/components/LanguageGrid/LanguageGridControlInternal.tsx +++ b/src/components/LanguageGrid/LanguageGridControlInternal.tsx @@ -25,8 +25,6 @@ import { SortingState, PagingState, CustomPaging, - Filter as GridFilter, - Sorting, } from "@devexpress/dx-react-grid"; import { TableCell, useTheme } from "@material-ui/core"; import { @@ -37,8 +35,8 @@ import { filterLanguageGridRow, adjustListDisplaysForFiltering, } from "./LanguageGridColumns"; -import { IGridColumn } from "../Grid/GridColumns"; -import { useStorageState } from "react-storage-hooks"; +import { IGridColumn, getColumnsVisibleToUser } from "../Grid/GridColumns"; +import { useGridConfigInUrl } from "../Grid/useGridConfigInUrl"; import { useGetLoggedInUser } from "../../connection/LoggedInUser"; import { observer } from "mobx-react-lite"; import { ILanguageGridControlProps } from "./LanguageGridControl"; @@ -62,10 +60,36 @@ const LanguageGridControlInternal: React.FunctionComponent([]); const [languageGridColumnDefinitions] = useState( getLanguageGridColumnsDefinitions() ); + // The columns this user may see (some are moderator-/login-gated). Drives both the + // rendered `columns` set and which URL sort/filter config the hook is allowed to honor. + const visibleColumnDefinitions = useMemo( + () => getColumnsVisibleToUser(languageGridColumnDefinitions, user), + [languageGridColumnDefinitions, user] + ); + const availableColumnNames = useMemo( + () => visibleColumnDefinitions.map((c) => c.name), + [visibleColumnDefinitions] + ); + // Grid configuration (sort, column filters, column order/visibility, widths) lives + // in the URL so a view can be bookmarked/shared; column order & visibility also fall + // back to the user's personal localStorage preference. See useGridConfigInUrl. + const { + sortings, + setSortings, + gridFilters, + setGridFilters, + columnNamesInDisplayOrder, + setColumnNamesInDisplayOrder, + hiddenColumnNames, + setHiddenColumnNames, + columnWidths, + setColumnWidths, + } = useGridConfigInUrl(languageGridColumnDefinitions, "language-grid", { + availableColumnNames, + }); const { minimalBookInfo: bookData } = useContext(CachedBookDataContext); const [totalRowCount, setTotalRowCount] = useState(0); @@ -279,47 +303,8 @@ const LanguageGridControlInternal: React.FunctionComponent([]); const [gridPage, setGridPage] = useState(0); - const [columns, setColumns] = useState>([]); - const [sortings, setSortings] = useState>([]); - const [ - columnNamesInDisplayOrder, - setColumnNamesInDisplayOrder, - ] = useStorageState( - localStorage, - "language-grid-column-order", - languageGridColumnDefinitions.map((c) => c.name) - ); - - // when a new version adds a new column, the list of columns in order will not match - // the full list of columns. Instead of coping with this, the devexpress grid just locks down the new - // column as the first one. So here we detect added and removed columns, while preserving order. - useEffect(() => { - const newCompleteSetInDefaultOrder = languageGridColumnDefinitions.map( - (c) => c.name - ); - const columnsThatNeedToBeAdded = newCompleteSetInDefaultOrder.filter( - (x) => !columnNamesInDisplayOrder.includes(x) - ); - const columnsThatNeedToBeRemoved = columnNamesInDisplayOrder.filter( - (x) => !newCompleteSetInDefaultOrder.includes(x) - ); - if ( - columnsThatNeedToBeAdded.length || - columnsThatNeedToBeRemoved.length - ) { - const oldOrderWithNewOnesAtEnd = columnNamesInDisplayOrder.concat( - columnsThatNeedToBeAdded - ); - const columnsWithAnyOldOnesRemoved = oldOrderWithNewOnesAtEnd.filter( - (n) => !columnsThatNeedToBeRemoved.includes(n) - ); - setColumnNamesInDisplayOrder(columnsWithAnyOldOnesRemoved); - } - }, [ - columnNamesInDisplayOrder, - setColumnNamesInDisplayOrder, - languageGridColumnDefinitions, - ]); + // The columns this user may see; drives the grid's rendered column set. + const columns = visibleColumnDefinitions; // Apply filtering and sorting to the rows, then set the page of rows to display. // Also set the total row count and the export data. @@ -355,25 +340,6 @@ const LanguageGridControlInternal: React.FunctionComponent( - localStorage, - "language-grid-column-hidden", - languageGridColumnDefinitions - .filter((c) => !c.defaultVisible) - .map((c) => c.name) - ); - - const defaultColumnWidths = useMemo( - () => - languageGridColumnDefinitions.map((c) => ({ - columnName: c.name, - width: "auto", - })), - [languageGridColumnDefinitions] - ); - if (props.setExportColumnInfo) { props.setExportColumnInfo( columnNamesInDisplayOrder.filter((cn) => @@ -386,21 +352,6 @@ const LanguageGridControlInternal: React.FunctionComponent { - setColumns( - languageGridColumnDefinitions.filter( - // some columns we include only if we are logged in, or - // logged in with the right permissions - (col) => - thisIsAModerator || - (!col.moderatorOnly && !col.loggedInOnly) || - (!col.moderatorOnly && col.loggedInOnly && user) - ) - ); - // todo? useEffect used to depend on router, though doesn't obviously use it. - }, [user, thisIsAModerator, languageGridColumnDefinitions]); - // note: this is an embedded function as a way to get at languageGridColumnDefinitions. It's important // that we don't reconstruct it on every render, or else we'll lose cursor focus on each key press. // Alternatives to this useMemo would include a ContextProvider, a higher-order function, or just @@ -430,14 +381,14 @@ const LanguageGridControlInternal: React.FunctionComponent { setGridFilters(x); }} /> { setSortings(sorting); }} @@ -457,12 +408,13 @@ const LanguageGridControlInternal: React.FunctionComponent setHiddenColumnNames(names) } diff --git a/src/components/UploaderGrid/UploaderGridColumns.tsx b/src/components/UploaderGrid/UploaderGridColumns.tsx index 8e48313d..f2517c11 100644 --- a/src/components/UploaderGrid/UploaderGridColumns.tsx +++ b/src/components/UploaderGrid/UploaderGridColumns.tsx @@ -1,5 +1,6 @@ import React from "react"; import { IGridColumn } from "../Grid/GridColumns"; +import { applyUrlKeys } from "../Grid/gridUrlConfig"; import { TableFilterRow } from "@devexpress/dx-react-grid-material-ui"; import { Filter, Sorting } from "@devexpress/dx-react-grid"; import { @@ -20,6 +21,19 @@ export interface IUploaderGridData { latestUploadDate?: string; // (not yet implemented) } +// Short, stable URL keys for every uploader-grid column (filters + sort/cols/widths). +// Must be unique within this grid and not equal a reserved param (sort/cols/widths). +const uploaderGridUrlKeys: { [name: string]: string } = { + email: "em", + bookCount: "bc", + languages: "lg", + countryNames: "cn", + creationDate: "cd", + organization: "og", + firstUploadDate: "fud", + latestUploadDate: "lud", +}; + // Define the function getUploaderGridColumnsDefinitions export function getUploaderGridColumnsDefinitions(): IGridColumn[] { const definitions: IGridColumn[] = [ @@ -187,7 +201,7 @@ export function getUploaderGridColumnsDefinitions(): IGridColumn[] { }, }, ]; - return definitions; + return applyUrlKeys(definitions, uploaderGridUrlKeys); } export function filterBooksBeforeCreatingUploaderGridRows( diff --git a/src/components/UploaderGrid/UploaderGridControlInternal.tsx b/src/components/UploaderGrid/UploaderGridControlInternal.tsx index e0429a8d..a3039e86 100644 --- a/src/components/UploaderGrid/UploaderGridControlInternal.tsx +++ b/src/components/UploaderGrid/UploaderGridControlInternal.tsx @@ -25,8 +25,6 @@ import { SortingState, PagingState, CustomPaging, - Filter as GridFilter, - Sorting, } from "@devexpress/dx-react-grid"; import { TableCell, useTheme } from "@material-ui/core"; import { @@ -37,8 +35,8 @@ import { filterUploaderGridRow, adjustListDisplaysForFiltering, } from "./UploaderGridColumns"; -import { IGridColumn } from "../Grid/GridColumns"; -import { useStorageState } from "react-storage-hooks"; +import { IGridColumn, getColumnsVisibleToUser } from "../Grid/GridColumns"; +import { useGridConfigInUrl } from "../Grid/useGridConfigInUrl"; import { useGetLoggedInUser } from "../../connection/LoggedInUser"; import { observer } from "mobx-react-lite"; import { IUploaderGridControlProps } from "./UploaderGridControl"; @@ -66,10 +64,36 @@ const UploaderGridControlInternal: React.FunctionComponent([]); const [uploaderGridColumnDefinitions] = useState( getUploaderGridColumnsDefinitions() ); + // The columns this user may see (some are moderator-/login-gated). Drives both the + // rendered `columns` set and which URL sort/filter config the hook is allowed to honor. + const visibleColumnDefinitions = useMemo( + () => getColumnsVisibleToUser(uploaderGridColumnDefinitions, user), + [uploaderGridColumnDefinitions, user] + ); + const availableColumnNames = useMemo( + () => visibleColumnDefinitions.map((c) => c.name), + [visibleColumnDefinitions] + ); + // Grid configuration (sort, column filters, column order/visibility, widths) lives + // in the URL so a view can be bookmarked/shared; column order & visibility also fall + // back to the user's personal localStorage preference. See useGridConfigInUrl. + const { + sortings, + setSortings, + gridFilters, + setGridFilters, + columnNamesInDisplayOrder, + setColumnNamesInDisplayOrder, + hiddenColumnNames, + setHiddenColumnNames, + columnWidths, + setColumnWidths, + } = useGridConfigInUrl(uploaderGridColumnDefinitions, "uploader-grid", { + availableColumnNames, + }); const { minimalBookInfo: bookData } = useContext(CachedBookDataContext); @@ -206,46 +230,8 @@ const UploaderGridControlInternal: React.FunctionComponent([]); const [gridPage, setGridPage] = useState(0); - const [columns, setColumns] = useState>([]); - const [sortings, setSortings] = useState>([]); - const [ - columnNamesInDisplayOrder, - setColumnNamesInDisplayOrder, - ] = useStorageState( - localStorage, - "uploader-grid-column-order", - uploaderGridColumnDefinitions.map((c) => c.name) - ); - // when a new version adds a new column, the list of columns in order will not match - // the full list of columns. Instead of coping with this, the devexpress grid just locks down the new - // column as the first one. So here we detect added and removed columns, while preserving order. - useEffect(() => { - const newCompleteSetInDefaultOrder = uploaderGridColumnDefinitions.map( - (c) => c.name - ); - const columnsThatNeedToBeAdded = newCompleteSetInDefaultOrder.filter( - (x) => !columnNamesInDisplayOrder.includes(x) - ); - const columnsThatNeedToBeRemoved = columnNamesInDisplayOrder.filter( - (x) => !newCompleteSetInDefaultOrder.includes(x) - ); - if ( - columnsThatNeedToBeAdded.length || - columnsThatNeedToBeRemoved.length - ) { - const oldOrderWithNewOnesAtEnd = columnNamesInDisplayOrder.concat( - columnsThatNeedToBeAdded - ); - const columnsWithAnyOldOnesRemoved = oldOrderWithNewOnesAtEnd.filter( - (n) => !columnsThatNeedToBeRemoved.includes(n) - ); - setColumnNamesInDisplayOrder(columnsWithAnyOldOnesRemoved); - } - }, [ - columnNamesInDisplayOrder, - setColumnNamesInDisplayOrder, - uploaderGridColumnDefinitions, - ]); + // The columns this user may see; drives the grid's rendered column set. + const columns = visibleColumnDefinitions; // Apply filtering and sorting to the rows, then set the page of rows to display. // Also set the total row count and the export data. @@ -279,25 +265,6 @@ const UploaderGridControlInternal: React.FunctionComponent( - localStorage, - "uploader-grid-column-hidden", - uploaderGridColumnDefinitions - .filter((c) => !c.defaultVisible) - .map((c) => c.name) - ); - - const defaultColumnWidths = useMemo( - () => - uploaderGridColumnDefinitions.map((c) => ({ - columnName: c.name, - width: "auto", - })), - [uploaderGridColumnDefinitions] - ); - if (props.setExportColumnInfo) { props.setExportColumnInfo( columnNamesInDisplayOrder.filter((cn) => @@ -310,21 +277,6 @@ const UploaderGridControlInternal: React.FunctionComponent { - setColumns( - uploaderGridColumnDefinitions.filter( - // some columns we include only if we are logged in, or - // logged in with the right permissions - (col) => - thisIsAModerator || - (!col.moderatorOnly && !col.loggedInOnly) || - (!col.moderatorOnly && col.loggedInOnly && user) - ) - ); - // todo? useEffect used to depend on router, though doesn't obviously use it. - }, [user, thisIsAModerator, uploaderGridColumnDefinitions]); - // note: this is an embedded function as a way to get at languageGridColumnDefinitions. It's important // that we don't reconstruct it on every render, or else we'll lose cursor focus on each key press. // Alternatives to this useMemo would include a ContextProvider, a higher-order function, or just @@ -354,14 +306,14 @@ const UploaderGridControlInternal: React.FunctionComponent { setGridFilters(x); }} /> { setSortings(sorting); }} @@ -381,12 +333,13 @@ const UploaderGridControlInternal: React.FunctionComponent setHiddenColumnNames(names) } diff --git a/src/connection/DataSource.ts b/src/connection/DataSource.ts index 57bbec0e..0919f8c1 100644 --- a/src/connection/DataSource.ts +++ b/src/connection/DataSource.ts @@ -19,3 +19,13 @@ export function getDataSourceForHostname(hostname: string): DataSource { export function getDataSource(): DataSource { return getDataSourceForHostname(window.location.hostname); } + +// True on a local dev machine (loopback hostnames only). Note this is deliberately NOT true for +// a LAN address, where the dev server is network-exposed. Callers use it to relax dev-only gates +// (e.g. the grids skip the login requirement) without opening those gates on any real deployment. +export function isLocalhost( + hostname: string = window.location.hostname +): boolean { + // window.location.hostname returns the IPv6 loopback bracketed ("[::1]"); accept both forms. + return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(hostname); +}