diff --git a/packages/firecms_core/src/components/EntityCollectionTable/EntityCollectionTable.tsx b/packages/firecms_core/src/components/EntityCollectionTable/EntityCollectionTable.tsx index 19fb21380..0517a9088 100644 --- a/packages/firecms_core/src/components/EntityCollectionTable/EntityCollectionTable.tsx +++ b/packages/firecms_core/src/components/EntityCollectionTable/EntityCollectionTable.tsx @@ -354,6 +354,7 @@ export const EntityCollectionTable = function EntityCollectionTable void; onTextSearch?: (searchString?: string) => void; textSearchLoading?: boolean; + /** + * Search term the search bar should start with, typically the one restored from the + * URL query params by the table controller. + */ + initialSearchString?: string; } export function CollectionTableToolbar({ @@ -29,6 +34,7 @@ export function CollectionTableToolbar({ onTextSearch, onTextSearchClick, textSearchLoading, + initialSearchString, title, viewModeToggle }: CollectionTableToolbarProps) { @@ -79,6 +85,7 @@ export function CollectionTableToolbar({ disabled={Boolean(onTextSearchClick)} onClick={onTextSearchClick} onTextSearch={onTextSearchClick ? undefined : onTextSearch} + initialValue={initialSearchString} placeholder={t("search")} expandable={true} />} diff --git a/packages/firecms_core/src/components/EntityCollectionView/EntityCollectionView.tsx b/packages/firecms_core/src/components/EntityCollectionView/EntityCollectionView.tsx index 39ef11c43..559a5ffab 100644 --- a/packages/firecms_core/src/components/EntityCollectionView/EntityCollectionView.tsx +++ b/packages/firecms_core/src/components/EntityCollectionView/EntityCollectionView.tsx @@ -831,7 +831,11 @@ export const EntityCollectionView = React.memo( } = useTableSearchHelper({ collection, fullPath: resolvedFullPath, - parentCollectionIds + parentCollectionIds, + // When the search term comes from the URL instead of the search bar, text search + // has never been initialised for this collection. Initialise it so the search bar + // is usable (editable and clearable) right away. + initialSearchString: tableController.searchString }); // Popover open state managed at parent level to prevent closing when view changes @@ -881,6 +885,7 @@ export const EntityCollectionView = React.memo( loading={tableController.dataLoading} onTextSearch={textSearchEnabled && textSearchInitialised ? tableController.setSearchString : undefined} onTextSearchClick={textSearchEnabled && !textSearchInitialised ? onTextSearchClick : undefined} + initialSearchString={tableController.searchString} textSearchLoading={textSearchLoading} viewModeToggle={viewModeToggleElement} actionsStart={, + sortBy?: [string, "asc" | "desc"] | undefined, + searchString?: string) { + const entries: Record = {}; + if (sortBy) { + entries["__sort"] = encodeURIComponent(sortBy[0]); + entries["__sort_order"] = encodeURIComponent(sortBy[1]); + } + if (filterValues) { + Object.entries(filterValues).forEach(([key, value]) => { + if (value) { + const [op, val] = value; + let encodedValue: any = val; + try { + if (typeof val === "object") { + if (val instanceof Date) { + encodedValue = val.toISOString(); + } else if (Array.isArray(val)) { + encodedValue = JSON.stringify(val, (key, value) => { + if (value instanceof EntityReference) { + return encodeRef(value); + } + return value; + }); + } else if (val instanceof EntityReference) { + encodedValue = encodeRef(val); + } + } else if (typeof val === "string") { + // JSON.stringify wraps the string in quotes (e.g. "4" → '"4"') + // so that decodeString's JSON.parse restores the string type, + // not a number. Without this, "4" round-trips as the number 4. + encodedValue = JSON.stringify(val); + } + } catch (e) { + encodedValue = val; + } + if (encodedValue !== undefined) { + entries[encodeURIComponent(`${key}_op`)] = encodeURIComponent(op); + // Note: check for null/undefined explicitly instead of truthiness, + // otherwise falsy-but-valid values (boolean `false`, number `0`) + // would be serialized as "null" and round-trip back as `null`, + // breaking filters like `archived == false`. + entries[encodeURIComponent(`${key}_value`)] = encodedValue !== null && encodedValue !== undefined + ? encodeURIComponent(encodedValue.toString()) + : "null"; + } + } + }); + } + // The text search term is encoded once here; `parseFilterAndSort` reads it back with + // `URLSearchParams`, which decodes exactly once. Do NOT decode it a second time or + // terms containing a literal `%` (e.g. "50%") would throw a URIError. + if (searchString) { + entries["search"] = encodeURIComponent(searchString); + } + if (!Object.keys(entries).length) { + return ""; + } + return Object.entries(entries).map(([key, value]) => `${key}=${value}`).join("&"); +} + +/** + * Restore the state of a collection table from a URL query string. + * The inverse of {@link encodeFilterAndSort}. + */ +export function parseFilterAndSort(search: string): { + filterValues: FilterValues | undefined, + sortBy?: [Extract, "asc" | "desc"], + searchString?: string +} { + const entries = new URLSearchParams(search); + const filterValues: FilterValues = {}; + let sortBy: [string, "asc" | "desc"] | undefined = undefined; + entries.forEach((value, key) => { + if (key === "__sort") { + sortBy = [decodeURIComponent(value), entries.get("__sort_order") as "asc" | "desc"]; + } else if (key.endsWith("_op")) { + const field = key.replace("_op", ""); + const filterOp = decodeURIComponent(value) as WhereFilterOp; + const filterValStr = entries.get(`${field}_value`); + if (filterValStr !== null) { + filterValues[field] = [filterOp, decodeString(filterValStr)]; + } + } + }); + + // `URLSearchParams` has already percent-decoded the value, so it is used as is. + const searchString = entries.get("search") ?? undefined; + + return { + filterValues: Object.keys(filterValues).length ? filterValues : undefined, + sortBy, + searchString: searchString ? searchString : undefined + } +} + +function isDate(dateString: string): boolean { + // Define a regex pattern that matches the exact date format: 2025-01-07T23:00:00.000Z + const regexPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + + // Test the dateString against the regex pattern + if (!regexPattern.test(dateString)) { + return false; + } + + // If the regex matches, further validate if it is a valid UTC date + const date = new Date(dateString); + return date.toISOString() === dateString; +} + +function encodeRef(val: EntityReference) { + return `ref::${val.path}/${val.id}`; +} + +function decodeString(val: string): EntityReference | Date | string { + let parsedFilterVal: any = val; + if (isDate(val)) { + try { + parsedFilterVal = new Date(val); + } catch (e) { + // ignore + } + } + if (typeof parsedFilterVal === "string") { + try { + parsedFilterVal = JSON.parse(parsedFilterVal, (key, value) => { + if (typeof value === "string" && value.startsWith("ref::")) { + const [path, id] = value.substring(5).split("/"); + return new EntityReference(id, path); + } + return value; + }); + } catch (e) { + // ignore + } + } + + if (typeof parsedFilterVal === "string" && parsedFilterVal.startsWith("ref::")) { + const [path, id] = parsedFilterVal.substring(5).split("/"); + return new EntityReference(id, path); + } + return parsedFilterVal; +} diff --git a/packages/firecms_core/src/components/common/useDataSourceTableController.tsx b/packages/firecms_core/src/components/common/useDataSourceTableController.tsx index fecf39ea4..67b29e5fd 100644 --- a/packages/firecms_core/src/components/common/useDataSourceTableController.tsx +++ b/packages/firecms_core/src/components/common/useDataSourceTableController.tsx @@ -7,17 +7,16 @@ import { DataType, Entity, EntityCollection, - EntityReference, EntityTableController, FilterValues, FireCMSContext, SelectedCellProps, - User, - WhereFilterOp + User } from "../../types"; import { useDebouncedData } from "./useDebouncedData"; import { ScrollRestorationController } from "./useScrollRestoration"; import { isDataTypeFilterable } from "../../util"; +import { encodeFilterAndSort, parseFilterAndSort } from "./table_url_params"; const DEFAULT_PAGE_SIZE = 50; @@ -94,8 +93,6 @@ export function useDataSourceTableController = any const paginationEnabled = collection.pagination === undefined || Boolean(collection.pagination); const pageSize = typeof collection.pagination === "number" ? collection.pagination : DEFAULT_PAGE_SIZE; - const [searchString, setSearchString] = React.useState(); - const checkFilterCombination = useCallback((filterValues: FilterValues, sortBy?: [string, "asc" | "desc"]) => { if (!dataSource.isFilterCombinationValid) @@ -136,8 +133,9 @@ export function useDataSourceTableController = any const { filterValues: initialFilterUrl, sortBy: initialSortUrl, + searchString: initialSearchUrl } = parseFilterAndSort(location.search); - + const availableFilterKeys = collection.allowedFilters ?? Object.keys(collection.properties); const forcedFilterKeys = collection.forceFilter ? Object.keys(collection.forceFilter) : []; @@ -173,6 +171,11 @@ export function useDataSourceTableController = any const [filterValues, setFilterValues] = React.useState> | undefined>(removeUnallowedFilters(initFilters)); const [sortBy, setSortBy] = React.useState<[Extract, "asc" | "desc"] | undefined>((updateUrl ? initialSortUrl : undefined) ?? initialSortInternal); + // Like the filters and the sort, the text search term is only restored from the URL when + // this controller owns the URL. Controllers rendered inside a dialog (e.g. a reference + // selection dialog) get `updateUrl: false` and must not inherit the state of the + // collection behind them. See https://github.com/firecmsco/firecms/issues/702 + const [searchString, setSearchString] = React.useState(updateUrl ? initialSearchUrl : undefined); useUpdateUrl(filterValues, sortBy, searchString, updateUrl); @@ -252,32 +255,43 @@ export function useDataSourceTableController = any setDataLoadingError(error); }; - if (dataSource.listenCollection) { - return dataSource.listenCollection({ - path: resolvedPath, - collection, - onUpdate: onEntitiesUpdate, - onError, - searchString, - filter: filterValues, - limit: itemCount, - startAfter: undefined, - orderBy: sortByProperty, - order: currentSort - }); - } else { - dataSource.fetchCollection({ - path: resolvedPath, - collection, - searchString, - filter: filterValues, - limit: itemCount, - startAfter: undefined, - orderBy: sortByProperty, - order: currentSort - }) - .then(onEntitiesUpdate) - .catch(onError); + // Data sources may throw synchronously, e.g. when a text search is requested before + // the text search backend of the collection has been initialised. That can happen + // when the search term is restored from the URL instead of typed in the search bar, + // so it is reported as a data loading error rather than being left to blow up the + // whole view from inside this effect. + try { + if (dataSource.listenCollection) { + return dataSource.listenCollection({ + path: resolvedPath, + collection, + onUpdate: onEntitiesUpdate, + onError, + searchString, + filter: filterValues, + limit: itemCount, + startAfter: undefined, + orderBy: sortByProperty, + order: currentSort + }); + } else { + dataSource.fetchCollection({ + path: resolvedPath, + collection, + searchString, + filter: filterValues, + limit: itemCount, + startAfter: undefined, + orderBy: sortByProperty, + order: currentSort + }) + .then(onEntitiesUpdate) + .catch(onError); + return () => { + }; + } + } catch (e: any) { + onError(e instanceof Error ? e : new Error(String(e))); return () => { }; } @@ -331,9 +345,7 @@ function useUpdateUrl = any>( useEffect(() => { if (updateUrl) { - const newUrl = encodeFilterAndSort(filterValues, sortBy); - const search = searchString ? `&search=${encodeURIComponent(searchString)}` : ""; - const state = `${newUrl}${search}`; + const state = encodeFilterAndSort(filterValues, sortBy, searchString); const hash = window.location.hash; if (state === "") window.history.replaceState({}, "", `${window.location.pathname}${hash}`); @@ -342,130 +354,3 @@ function useUpdateUrl = any>( } }, [filterValues, sortBy, searchString, updateUrl]); } - -function encodeFilterAndSort(filterValues?: FilterValues, sortBy?: [string, "asc" | "desc"] | undefined) { - const entries: Record = {}; - if (sortBy) { - entries["__sort"] = encodeURIComponent(sortBy[0]); - entries["__sort_order"] = encodeURIComponent(sortBy[1]); - } - if (filterValues) { - Object.entries(filterValues).forEach(([key, value]) => { - if (value) { - const [op, val] = value; - let encodedValue: any = val; - try { - if (typeof val === "object") { - if (val instanceof Date) { - encodedValue = val.toISOString(); - } else if (Array.isArray(val)) { - encodedValue = JSON.stringify(val, (key, value) => { - if (value instanceof EntityReference) { - return encodeRef(value); - } - return value; - }); - } else if (val instanceof EntityReference) { - encodedValue = encodeRef(val); - } - } else if (typeof val === "string") { - // JSON.stringify wraps the string in quotes (e.g. "4" → '"4"') - // so that decodeString's JSON.parse restores the string type, - // not a number. Without this, "4" round-trips as the number 4. - encodedValue = JSON.stringify(val); - } - } catch (e) { - encodedValue = val; - } - if (encodedValue !== undefined) { - entries[encodeURIComponent(`${key}_op`)] = encodeURIComponent(op); - // Note: check for null/undefined explicitly instead of truthiness, - // otherwise falsy-but-valid values (boolean `false`, number `0`) - // would be serialized as "null" and round-trip back as `null`, - // breaking filters like `archived == false`. - entries[encodeURIComponent(`${key}_value`)] = encodedValue !== null && encodedValue !== undefined - ? encodeURIComponent(encodedValue.toString()) - : "null"; - } - } - }); - } - if (!Object.keys(entries).length) { - return ""; - } - return Object.entries(entries).map(([key, value]) => `${key}=${value}`).join("&"); -} - -function parseFilterAndSort(search: string): { - filterValues: FilterValues | undefined, - sortBy?: [Extract, "asc" | "desc"] -} { - const entries = new URLSearchParams(search); - const filterValues: FilterValues = {}; - let sortBy: [string, "asc" | "desc"] | undefined = undefined; - entries.forEach((value, key) => { - if (key === "__sort") { - sortBy = [decodeURIComponent(value), entries.get("__sort_order") as "asc" | "desc"]; - } else if (key.endsWith("_op")) { - const field = key.replace("_op", ""); - const filterOp = decodeURIComponent(value) as WhereFilterOp; - const filterValStr = entries.get(`${field}_value`); - if (filterValStr !== null) { - filterValues[field] = [filterOp, decodeString(filterValStr)]; - } - } - }); - - return { - filterValues: Object.keys(filterValues).length ? filterValues : undefined, - sortBy - } -} - -function isDate(dateString: string): boolean { - // Define a regex pattern that matches the exact date format: 2025-01-07T23:00:00.000Z - const regexPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; - - // Test the dateString against the regex pattern - if (!regexPattern.test(dateString)) { - return false; - } - - // If the regex matches, further validate if it is a valid UTC date - const date = new Date(dateString); - return date.toISOString() === dateString; -} - -function encodeRef(val: EntityReference) { - return `ref::${val.path}/${val.id}`; -} - -function decodeString(val: string): EntityReference | Date | string { - let parsedFilterVal: any = val; - if (isDate(val)) { - try { - parsedFilterVal = new Date(val); - } catch (e) { - // ignore - } - } - if (typeof parsedFilterVal === "string") { - try { - parsedFilterVal = JSON.parse(parsedFilterVal, (key, value) => { - if (typeof value === "string" && value.startsWith("ref::")) { - const [path, id] = value.substring(5).split("/"); - return new EntityReference(id, path); - } - return value; - }); - } catch (e) { - // ignore - } - } - - if (typeof parsedFilterVal === "string" && parsedFilterVal.startsWith("ref::")) { - const [path, id] = parsedFilterVal.substring(5).split("/"); - return new EntityReference(id, path); - } - return parsedFilterVal; -} diff --git a/packages/firecms_core/src/components/common/useTableSearchHelper.ts b/packages/firecms_core/src/components/common/useTableSearchHelper.ts index 8265e3ffb..612fb3ef8 100644 --- a/packages/firecms_core/src/components/common/useTableSearchHelper.ts +++ b/packages/firecms_core/src/components/common/useTableSearchHelper.ts @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { EntityCollection } from "../../types"; import { useCustomizationController, useDataSource, useFireCMSContext } from "../../hooks"; @@ -7,12 +7,19 @@ export interface UseTableSearchHelperParams> { collection: EntityCollection; fullPath: string; parentCollectionIds?: string[]; + /** + * Search term that is already being applied without the user having gone through the + * search bar, e.g. one restored from the URL query params. When set, text search is + * initialised automatically instead of waiting for a click on the search bar. + */ + initialSearchString?: string; } export function useTableSearchHelper>({ collection, fullPath, - parentCollectionIds + parentCollectionIds, + initialSearchString }: UseTableSearchHelperParams) { const context = useFireCMSContext(); @@ -21,6 +28,7 @@ export function useTableSearchHelper>({ const [textSearchLoading, setTextSearchLoading] = useState(false); const [textSearchInitialised, setTextSearchInitialised] = useState(false); + const autoInitialisedRef = useRef(false); let onTextSearchClick: (() => void) | undefined; let textSearchEnabled = Boolean(collection.textSearchEnabled); @@ -79,6 +87,17 @@ export function useTableSearchHelper>({ } }) } + + // Only ever runs once, and only when a search term was restored from outside the search + // bar: otherwise the search bar would show a term the user can neither edit nor clear, + // because it stays read only until text search is initialised. + const shouldAutoInitialise = Boolean(initialSearchString) && textSearchEnabled && !textSearchInitialised && Boolean(onTextSearchClick); + useEffect(() => { + if (autoInitialisedRef.current || !shouldAutoInitialise) return; + autoInitialisedRef.current = true; + onTextSearchClick?.(); + }, [shouldAutoInitialise]); + return { textSearchLoading, textSearchInitialised, diff --git a/packages/firecms_core/test/table_url_params.test.ts b/packages/firecms_core/test/table_url_params.test.ts new file mode 100644 index 000000000..f2876684e --- /dev/null +++ b/packages/firecms_core/test/table_url_params.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "@jest/globals"; + +import { encodeFilterAndSort, parseFilterAndSort } from "../src/components/common/table_url_params"; + +/** + * `encodeFilterAndSort` and `parseFilterAndSort` must stay exact inverses of each other. + * The `search` param used to be written by `useUpdateUrl` but never read back, which made it + * disappear on reload. See https://github.com/firecmsco/firecms/issues/724 + */ +describe("table url params: search term", () => { + + it("encodes the search term", () => { + expect(encodeFilterAndSort(undefined, undefined, "inimitable")).toEqual("search=inimitable"); + }); + + it("omits the search term when there is none", () => { + expect(encodeFilterAndSort(undefined, undefined, undefined)).toEqual(""); + expect(encodeFilterAndSort(undefined, undefined, "")).toEqual(""); + }); + + it("parses the search term", () => { + expect(parseFilterAndSort("?search=inimitable").searchString).toEqual("inimitable"); + }); + + it("parses the search term written by previous versions with a stray ampersand", () => { + // `useUpdateUrl` used to concatenate "&search=..." to a possibly empty query string + expect(parseFilterAndSort("?&search=inimitable").searchString).toEqual("inimitable"); + }); + + it("treats a missing or empty search term as undefined", () => { + expect(parseFilterAndSort("").searchString).toBeUndefined(); + expect(parseFilterAndSort("?__sort=name&__sort_order=asc").searchString).toBeUndefined(); + expect(parseFilterAndSort("?search=").searchString).toBeUndefined(); + }); + + it("round-trips search terms with characters that need escaping", () => { + const terms = [ + "inimitable", + "the great gatsby", + "100% pure", + "a&b", + "a+b", + "a=b", + "café", + "#hash", + "a/b?c", + "\"quoted\"" + ]; + terms.forEach((term) => { + const encoded = encodeFilterAndSort(undefined, undefined, term); + expect(parseFilterAndSort(`?${encoded}`).searchString).toEqual(term); + }); + }); + + it("round-trips the search term together with sort and filters", () => { + const filterValues = { name: ["==", "Dune"] } as const; + const sortBy: [string, "asc" | "desc"] = ["name", "desc"]; + + const encoded = encodeFilterAndSort(filterValues as any, sortBy, "inimitable"); + const parsed = parseFilterAndSort(`?${encoded}`); + + expect(parsed.searchString).toEqual("inimitable"); + expect(parsed.sortBy).toEqual(["name", "desc"]); + expect(parsed.filterValues).toEqual({ name: ["==", "Dune"] }); + }); + + it("does not confuse the search term with a property called `search`", () => { + const encoded = encodeFilterAndSort({ search: ["==", "engine"] } as any, undefined, "inimitable"); + const parsed = parseFilterAndSort(`?${encoded}`); + + expect(parsed.searchString).toEqual("inimitable"); + expect(parsed.filterValues).toEqual({ search: ["==", "engine"] }); + }); + + it("keeps sort and filters untouched when there is no search term", () => { + const encoded = encodeFilterAndSort({ archived: ["==", false] } as any, ["name", "asc"], undefined); + const parsed = parseFilterAndSort(`?${encoded}`); + + expect(encoded).not.toContain("search"); + expect(parsed.sortBy).toEqual(["name", "asc"]); + expect(parsed.filterValues).toEqual({ archived: ["==", false] }); + expect(parsed.searchString).toBeUndefined(); + }); + +}); diff --git a/packages/ui/src/components/SearchBar.tsx b/packages/ui/src/components/SearchBar.tsx index d1c4d03e1..4dc686c6a 100644 --- a/packages/ui/src/components/SearchBar.tsx +++ b/packages/ui/src/components/SearchBar.tsx @@ -11,6 +11,13 @@ interface SearchBarProps { onClick?: () => void; onTextSearch?: (searchString?: string) => void; placeholder?: string; + /** + * Text the input is initialised with. Use it when the search term is restored from + * somewhere else, e.g. a URL query parameter, so that the input reflects the search + * that is actually being applied. Only read when the component mounts; the search bar + * owns the text from then on. + */ + initialValue?: string; expandable?: boolean; /** * Size of the search bar. @@ -35,6 +42,7 @@ export function SearchBar({ onClick, onTextSearch, placeholder = "Search", + initialValue, expandable = false, size = "medium", large, @@ -46,7 +54,7 @@ export function SearchBar({ inputRef }: SearchBarProps) { - const [searchText, setSearchText] = useState(""); + const [searchText, setSearchText] = useState(initialValue ?? ""); const [active, setActive] = useState(false); const deferredValues = useDebounceValue(searchText, 200);