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);
+}