From 96f4fe7b834b24d32a61ae239c67f9b81b0f2b4d Mon Sep 17 00:00:00 2001 From: Matteo Alessani Date: Thu, 6 Aug 2026 16:37:20 +0200 Subject: [PATCH 1/3] feat: add new bar for search and filters --- packages/app-elements/src/locales/en.ts | 1 + packages/app-elements/src/locales/it.ts | 1 + .../src/ui/atoms/ButtonFilter.tsx | 55 +++ .../useResourceFilters/FiltersBar.tsx | 263 ++++++++++ .../useResourceFilters/FiltersDrawer.tsx | 93 ++++ .../useResourceFilters/FiltersNav.tsx | 133 +---- .../useResourceFilters/activeFilters.test.ts | 115 +++++ .../useResourceFilters/activeFilters.ts | 455 ++++++++++++++++++ .../useResourceFilters/useResourceFilters.tsx | 101 +++- 9 files changed, 1089 insertions(+), 128 deletions(-) create mode 100644 packages/app-elements/src/ui/resources/useResourceFilters/FiltersBar.tsx create mode 100644 packages/app-elements/src/ui/resources/useResourceFilters/FiltersDrawer.tsx create mode 100644 packages/app-elements/src/ui/resources/useResourceFilters/activeFilters.test.ts create mode 100644 packages/app-elements/src/ui/resources/useResourceFilters/activeFilters.ts diff --git a/packages/app-elements/src/locales/en.ts b/packages/app-elements/src/locales/en.ts index 2410736ac..9d8dffff3 100644 --- a/packages/app-elements/src/locales/en.ts +++ b/packages/app-elements/src/locales/en.ts @@ -241,6 +241,7 @@ const en = { back: "Back", go_back: "Go back", cancel: "Cancel", + clear_all: "Clear all", clear_text: "Clear text", close: "Close", continue: "Continue", diff --git a/packages/app-elements/src/locales/it.ts b/packages/app-elements/src/locales/it.ts index 21f3b1e0c..ac0c3ad37 100644 --- a/packages/app-elements/src/locales/it.ts +++ b/packages/app-elements/src/locales/it.ts @@ -17,6 +17,7 @@ const it: typeof en = { go_back: "Torna indietro", cancel: "Annulla", close: "Chiudi", + clear_all: "Cancella tutto", clear_text: "Svuota testo", continue: "Continua", could_not_retrieve_data: "Impossibile recuperare i dati", diff --git a/packages/app-elements/src/ui/atoms/ButtonFilter.tsx b/packages/app-elements/src/ui/atoms/ButtonFilter.tsx index 296bca141..3993f6e07 100644 --- a/packages/app-elements/src/ui/atoms/ButtonFilter.tsx +++ b/packages/app-elements/src/ui/atoms/ButtonFilter.tsx @@ -1,5 +1,6 @@ import cn from "classnames" import type { JSX } from "react" +import { Icon } from "./Icon" import { StatusIcon, type StatusIconProps } from "./StatusIcon" export interface ButtonFilterProps @@ -8,6 +9,22 @@ export interface ButtonFilterProps onRemoveRequest?: () => void icon?: StatusIconProps["name"] label: string + /** + * Visual style. + * - `button` (default): compact grey button where the whole label is clickable + * to re-open the filter. + * - `pill`: rounded chip rendering `label: value` with the value in bold, where + * only the remove (`x`) button is interactive. Matches the style used by the + * dashboard metrics pages. + */ + variant?: "button" | "pill" + /** + * Value(s) rendered in bold next to the label. Long values are truncated and + * shown in full through the native tooltip. + * + * Only used by the `pill` variant. + */ + value?: string } function ButtonFilter({ @@ -16,8 +33,46 @@ function ButtonFilter({ label, icon, className, + variant = "button", + value, ...rest }: ButtonFilterProps): JSX.Element { + if (variant === "pill") { + return ( +
+ + {value == null ? ( + label + ) : ( + <> + {label}: {value} + + )} + + {onRemoveRequest != null ? ( + + ) : null} +
+ ) + } + return (
void + /** + * Filters that are already implied by the current view, typically the ones + * defining the active tab. + * + * They are not rendered as pills, removing a pill reverts that single filter + * back to the value defined here, and "clear all" reverts to this set rather + * than to no filters at all. + */ + defaultValues?: FormFullValues + /** + * Overrides the filters button behavior. When set, the built-in drawer is not + * opened and the app is responsible for rendering the filters form — for + * example by navigating to a dedicated filters page. + */ + onFilterClick?: () => void + /** + * Placeholder text for the search bar + * @default 'Search...' + */ + searchBarPlaceholder?: string + /** + * Milliseconds to wait before triggering the search bar callback + * @default 500 + */ + searchBarDebounceMs?: number + /** + * Hide the search bar, keeping the filters button and the pills. + * @default false + */ + hideSearchBar?: boolean + /** + * Rendered to the right of the filters button, for page level actions such as + * an export button. + */ + actions?: ReactNode +} + +interface InternalProps { + instructions: FiltersInstructions + predicateWhitelist: string[] + /** Opens the drawer rendered by `FiltersDrawer`. */ + openDrawer: () => void +} + +/** + * Search bar with the filters button on the right and the applied filters + * rendered as removable pills below. + */ +export function FiltersBar({ + queryString, + onUpdate, + defaultValues, + onFilterClick, + searchBarPlaceholder, + searchBarDebounceMs, + hideSearchBar = false, + actions, + instructions, + predicateWhitelist, + openDrawer, +}: FiltersBarProps & InternalProps): JSX.Element { + const { user } = useTokenProvider() + const { adaptUrlQueryToFormValues, adaptFormValuesToUrlQuery } = + makeFilterAdapters({ instructions, predicateWhitelist }) + + const pills = getPillFilters({ + instructions, + queryString, + predicateWhitelist, + defaultValues, + timezone: user?.timezone, + locale: user?.locale, + }) + + const emit = (formValues: FormFullValues): void => { + onUpdate(adaptFormValuesToUrlQuery({ formValues })) + } + + const removePill = (pill: PillFilter): void => { + const formValues = adaptUrlQueryToFormValues({ queryString }) + + if (pill.kind === "timeRange") { + emit({ + ...formValues, + timePreset: defaultValues?.timePreset, + timeFrom: defaultValues?.timeFrom, + timeTo: defaultValues?.timeTo, + } as FormFullValues) + return + } + + emit({ + ...formValues, + // reverting to the view's own value, or emptying when it defines none + [pill.id]: defaultValues?.[pill.id] ?? [], + } as FormFullValues) + } + + const clearAll = (): void => { + emit( + getClearedFormValues({ + instructions, + queryString, + predicateWhitelist, + defaultValues, + }), + ) + } + + return ( + <> +
+ {hideSearchBar ? null : ( + // no width class on purpose: `SearchBar` is `w-full`, so this + // shrink-to-fit wrapper leaves it at its intrinsic width +
+ +
+ )} + +
+ + + + } + /> + {actions} +
+
+ + {pills.length > 0 && ( +
+ {pills.map((pill) => + pill.fetch != null ? ( + { + removePill(pill) + }} + /> + ) : ( + { + removePill(pill) + }} + /> + ), + )} + +
+ )} + + ) +} + +FiltersBar.displayName = "FiltersBar" + +/** + * Pill for a single selected resource, whose label has to be retrieved since it + * lives on the resource itself. + */ +function ResourcePill({ + label, + resource, + id, + fieldForLabel, + onRemoveRequest, +}: { + label: string + resource: ListableResourceType + id: string + fieldForLabel: string + onRemoveRequest: () => void +}): JSX.Element { + const { data, isLoading } = useCoreApi(resource, "retrieve", [ + id, + { + fields: { [resource]: [fieldForLabel] }, + }, + ]) + + const value = + data != null + ? fieldForLabel in data && data[fieldForLabel as keyof typeof data] + : undefined + + return ( + + + + ) +} diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/FiltersDrawer.tsx b/packages/app-elements/src/ui/resources/useResourceFilters/FiltersDrawer.tsx new file mode 100644 index 000000000..a7791b87a --- /dev/null +++ b/packages/app-elements/src/ui/resources/useResourceFilters/FiltersDrawer.tsx @@ -0,0 +1,93 @@ +import type { JSX } from "react" +import { t } from "#providers/I18NProvider" +import { Icon } from "#ui/atoms/Icon" +import { Text } from "#ui/atoms/Text" +import type { OverlayProps } from "#ui/internals/Overlay" +import { FiltersForm } from "./FiltersForm" +import type { FiltersInstructions } from "./types" + +export interface FiltersDrawerProps { + /** + * Callback triggered when the user applies the filters. + * The implementation should update the url query string. + */ + onUpdate: (newQueryString: string) => void + /** + * Title of the drawer + * @default 'Filters' + */ + title?: string +} + +interface InternalProps { + instructions: FiltersInstructions + predicateWhitelist: string[] + /** Overlay component from the hook, shared with the bar's filters button. */ + Overlay: React.FC + close: () => void + /** Current url query string, from the hook scope. */ + queryString: string +} + +/** + * Side drawer containing the filters form, opened by the `FiltersBar` filters + * button. + * + * Render it once per page, as a sibling of `FiltersBar`. + */ +export function FiltersDrawer({ + onUpdate, + title, + instructions, + predicateWhitelist, + Overlay, + close, + queryString, +}: FiltersDrawerProps & InternalProps): JSX.Element { + return ( + +
+
+ + {title ?? t("common.filters")} + + +
+ { + onUpdate(preserveViewTitle({ newQueryString, queryString })) + close() + }} + /> +
+
+ ) +} + +FiltersDrawer.displayName = "FiltersDrawer" + +/** + * The filters form has no notion of `viewTitle`, so submitting it would drop the + * active view (e.g. the selected tab) from the url. Carry it over. + */ +function preserveViewTitle({ + newQueryString, + queryString, +}: { + newQueryString: string + queryString: string +}): string { + const viewTitle = new URLSearchParams(queryString).get("viewTitle") + + if (viewTitle == null) { + return newQueryString + } + + const params = new URLSearchParams(newQueryString) + params.set("viewTitle", viewTitle) + return params.toString() +} diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/FiltersNav.tsx b/packages/app-elements/src/ui/resources/useResourceFilters/FiltersNav.tsx index 8a918a573..14c52d0d8 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/FiltersNav.tsx +++ b/packages/app-elements/src/ui/resources/useResourceFilters/FiltersNav.tsx @@ -10,9 +10,12 @@ import { useTokenProvider } from "#providers/TokenProvider" import { ButtonFilter } from "#ui/atoms/ButtonFilter" import { SkeletonTemplate } from "#ui/atoms/SkeletonTemplate" import { - formatCentsToCurrency, - type InputCurrencyProps, -} from "#ui/forms/InputCurrency" + extractCurrencyRangeFilterValues, + getButtonFilterLabel, + getInstructionItemByFilterPredicate, + makeCurrencyRangeFilterButtonLabel, + predicateBelongsToCurrencyRange, +} from "./activeFilters" import { makeFilterAdapters } from "./adapters" import { getDefaultBrowserTimezone, @@ -20,8 +23,6 @@ import { isTimeRangeFilterUiName, } from "./timeUtils" import { - type CurrencyRangeFieldValue, - type FiltersInstructionItem, type FiltersInstructions, type FormFullValues, getInstructionKey, @@ -357,21 +358,6 @@ export function FiltersNav({ ) } -function getInstructionItemByFilterPredicate({ - instructions, - filterPredicate, -}: { - instructions: FiltersInstructions - filterPredicate: string -}): FiltersInstructionItem | undefined { - if (isTimeRangeFilterUiName(filterPredicate)) { - return instructions.find(({ type }) => type === "timeRange") - } - return instructions.find( - (item) => getInstructionKey(item) === filterPredicate, - ) -} - /** * Render the button for InputResourceGroup when there's one value. * It fetches the resource to get the label. @@ -411,110 +397,3 @@ function ButtonFilterFetchResource({ ) } - -/** - * Get label for user defined ButtonFilter component by reading the `instructionItem` object. - * If the filter has options and only one value is selected, the label will be the option label. - * Otherwise, the label will be the filter group label plus the number of selected values. - */ -function getButtonFilterLabel({ - values, - instructionItem, -}: { - values: string | string[] - instructionItem: FiltersInstructionItem -}): string { - const isSingleElementArray = Array.isArray(values) && values.length === 1 - const isString = typeof values === "string" - const optionValue = Array.isArray(values) ? values[0] : values - - if ( - instructionItem.type === "options" && - "options" in instructionItem.render.props && - instructionItem.render.props.options != null && - instructionItem.render.props.options.length > 0 && - (isSingleElementArray || isString) - ) { - return ( - instructionItem.render.props.options.find( - ({ value }) => value === optionValue, - )?.label ?? instructionItem.label - ) - } - - if ( - instructionItem.type === "groupedPredicates" && - (isSingleElementArray || isString) - ) { - return ( - instructionItem.render.props.options.find( - ({ value }) => value === optionValue, - )?.label ?? instructionItem.label - ) - } - - if (instructionItem.type === "textSearch") { - return `${instructionItem.label} · ${optionValue}` - } - - return `${instructionItem.label} · ${values.length}` -} - -function extractCurrencyRangeFilterValues({ - activeFilters, - instructions, -}: { - activeFilters: Array<[string, UiFilterValue]> - instructions: FiltersInstructions -}): Array<[string, CurrencyRangeFieldValue]> { - const rangeFilters = activeFilters.filter(([filterPredicate]) => { - return predicateBelongsToCurrencyRange({ - filterPredicate, - instructions, - }) - }) as Array<[string, CurrencyRangeFieldValue]> - - return rangeFilters.filter( - ([, value]) => value.from != null || value.to != null, - ) -} - -/** - * Checks if a filter predicate belongs to a currency range filter - * by checking the instructions - */ -function predicateBelongsToCurrencyRange({ - filterPredicate, - instructions, -}: { - filterPredicate: string - instructions: FiltersInstructions -}): boolean { - const instructionItem = instructions.find( - (item) => getInstructionKey(item) === filterPredicate, - ) - - return instructionItem?.type === "currencyRange" -} - -function makeCurrencyRangeFilterButtonLabel( - value: CurrencyRangeFieldValue, -): string { - const currencyCode = value.currencyCode as InputCurrencyProps["currencyCode"] - if (value.from == null && value.to == null) { - return "" - } - - const formattedFrom = formatCentsToCurrency( - value.from ?? 0, - currencyCode, - true, - ) - - const formattedTo = - value.to != null - ? formatCentsToCurrency(value.to, currencyCode, true) - : "Max" - - return `${formattedFrom} - ${formattedTo}` -} diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/activeFilters.test.ts b/packages/app-elements/src/ui/resources/useResourceFilters/activeFilters.test.ts new file mode 100644 index 000000000..2fe9136d0 --- /dev/null +++ b/packages/app-elements/src/ui/resources/useResourceFilters/activeFilters.test.ts @@ -0,0 +1,115 @@ +import { + getClearedFormValues, + getPillFilters, + isSameFilterValue, +} from "./activeFilters" +import { instructions } from "./mockedInstructions" + +describe("isSameFilterValue", () => { + test("ignores array wrapping and ordering", () => { + expect(isSameFilterValue("placed", ["placed"])).toBe(true) + expect(isSameFilterValue(["a", "b"], ["b", "a"])).toBe(true) + expect(isSameFilterValue(["a"], ["b"])).toBe(false) + }) + + test("treats nullish and empty as equivalent", () => { + expect(isSameFilterValue(undefined, [])).toBe(true) + }) + + test("compares ranges by value", () => { + const range = { from: 100, to: 200, currencyCode: "EUR" } + expect(isSameFilterValue(range, { ...range })).toBe(true) + expect(isSameFilterValue(range, { ...range, to: 300 })).toBe(false) + }) +}) + +describe("getPillFilters", () => { + const baseArgs = { instructions, predicateWhitelist: [] } + + test("resolves option labels, spelling out every selected value", () => { + const pills = getPillFilters({ + ...baseArgs, + queryString: "status_in=placed&status_in=approved", + }) + + expect(pills).toEqual([ + { + id: "status_in", + label: "Status", + value: "Placed, Approved", + kind: "group", + }, + ]) + }) + + test("omits the free text filter, already visible in the search bar", () => { + const pills = getPillFilters({ + ...baseArgs, + queryString: "number_or_email_cont=foo&status_in=placed", + }) + + expect(pills.map((pill) => pill.id)).toEqual(["status_in"]) + }) + + test("omits filters matching the defaults of the current view", () => { + const pills = getPillFilters({ + ...baseArgs, + queryString: "status_in=placed&payment_status_eq=paid", + // as if the active tab were already filtering by status + defaultValues: { status_in: ["placed"] }, + }) + + expect(pills.map((pill) => pill.id)).toEqual(["payment_status_eq"]) + }) + + test("defers the label of a single selected resource to a fetch", () => { + const [pill] = getPillFilters({ + ...baseArgs, + queryString: "market_id_in=dLbQmsNqrX", + }) + + expect(pill?.value).toBeUndefined() + expect(pill?.fetch).toEqual({ + resource: "markets", + id: "dLbQmsNqrX", + fieldForLabel: "name", + }) + }) + + test("renders a time range preset as a single pill", () => { + const pills = getPillFilters({ + ...baseArgs, + queryString: "timePreset=today", + }) + + expect(pills).toHaveLength(1) + expect(pills[0]?.kind).toBe("timeRange") + }) +}) + +describe("getClearedFormValues", () => { + test("reverts to the defaults of the current view", () => { + const cleared = getClearedFormValues({ + instructions, + predicateWhitelist: [], + queryString: "status_in=cancelled&payment_status_eq=paid", + defaultValues: { status_in: ["placed"] }, + }) + + expect(cleared.status_in).toEqual(["placed"]) + expect(cleared.payment_status_eq).toBeUndefined() + }) + + test("keeps the free text search and the view title", () => { + const cleared = getClearedFormValues({ + instructions, + predicateWhitelist: [], + queryString: + "number_or_email_cont=foo&viewTitle=Open&payment_status_eq=paid", + }) + + expect(cleared.number_or_email_cont).toBe("foo") + expect(cleared.viewTitle).toBe("Open") + expect(cleared.payment_status_eq).toBeUndefined() + }) +}) diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/activeFilters.ts b/packages/app-elements/src/ui/resources/useResourceFilters/activeFilters.ts new file mode 100644 index 000000000..33aec4144 --- /dev/null +++ b/packages/app-elements/src/ui/resources/useResourceFilters/activeFilters.ts @@ -0,0 +1,455 @@ +import type { ListableResourceType } from "@commercelayer/sdk" +import castArray from "lodash-es/castArray" +import isDate from "lodash-es/isDate" +import isEmpty from "lodash-es/isEmpty" +import isEqual from "lodash-es/isEqual" +import { formatDateRange } from "#helpers/date" +import { t } from "#providers/I18NProvider" +import { + formatCentsToCurrency, + type InputCurrencyProps, +} from "#ui/forms/InputCurrency" +import { makeFilterAdapters } from "./adapters" +import { + getDefaultBrowserTimezone, + getTimeRangePresetName, + isTimeRangeFilterUiName, +} from "./timeUtils" +import { + type CurrencyRangeFieldValue, + type FiltersInstructionItem, + type FiltersInstructions, + type FormFullValues, + getInstructionKey, + isTextSearch, + type UiFilterValue, +} from "./types" + +/** + * Shared helpers to read the active filters out of a url query string and turn + * them into human readable labels. + * + * Used by both `FiltersNav` (legacy `ButtonFilter` look) and `FiltersBar` + * (metrics-style pills), so label resolution only ever has one implementation. + */ + +export function getInstructionItemByFilterPredicate({ + instructions, + filterPredicate, +}: { + instructions: FiltersInstructions + filterPredicate: string +}): FiltersInstructionItem | undefined { + if (isTimeRangeFilterUiName(filterPredicate)) { + return instructions.find(({ type }) => type === "timeRange") + } + return instructions.find( + (item) => getInstructionKey(item) === filterPredicate, + ) +} + +/** + * Get label for user defined ButtonFilter component by reading the `instructionItem` object. + * If the filter has options and only one value is selected, the label will be the option label. + * Otherwise, the label will be the filter group label plus the number of selected values. + */ +export function getButtonFilterLabel({ + values, + instructionItem, +}: { + values: string | string[] + instructionItem: FiltersInstructionItem +}): string { + const isSingleElementArray = Array.isArray(values) && values.length === 1 + const isString = typeof values === "string" + const optionValue = Array.isArray(values) ? values[0] : values + + if ( + instructionItem.type === "options" && + "options" in instructionItem.render.props && + instructionItem.render.props.options != null && + instructionItem.render.props.options.length > 0 && + (isSingleElementArray || isString) + ) { + return ( + instructionItem.render.props.options.find( + ({ value }) => value === optionValue, + )?.label ?? instructionItem.label + ) + } + + if ( + instructionItem.type === "groupedPredicates" && + (isSingleElementArray || isString) + ) { + return ( + instructionItem.render.props.options.find( + ({ value }) => value === optionValue, + )?.label ?? instructionItem.label + ) + } + + if (instructionItem.type === "textSearch") { + return `${instructionItem.label} · ${optionValue}` + } + + return `${instructionItem.label} · ${values.length}` +} + +export function extractCurrencyRangeFilterValues({ + activeFilters, + instructions, +}: { + activeFilters: Array<[string, UiFilterValue]> + instructions: FiltersInstructions +}): Array<[string, CurrencyRangeFieldValue]> { + const rangeFilters = activeFilters.filter(([filterPredicate]) => { + return predicateBelongsToCurrencyRange({ + filterPredicate, + instructions, + }) + }) as Array<[string, CurrencyRangeFieldValue]> + + return rangeFilters.filter( + ([, value]) => value.from != null || value.to != null, + ) +} + +/** + * Checks if a filter predicate belongs to a currency range filter + * by checking the instructions + */ +export function predicateBelongsToCurrencyRange({ + filterPredicate, + instructions, +}: { + filterPredicate: string + instructions: FiltersInstructions +}): boolean { + const instructionItem = instructions.find( + (item) => getInstructionKey(item) === filterPredicate, + ) + + return instructionItem?.type === "currencyRange" +} + +export function makeCurrencyRangeFilterButtonLabel( + value: CurrencyRangeFieldValue, +): string { + const currencyCode = value.currencyCode as InputCurrencyProps["currencyCode"] + if (value.from == null && value.to == null) { + return "" + } + + const formattedFrom = formatCentsToCurrency( + value.from ?? 0, + currencyCode, + true, + ) + + const formattedTo = + value.to != null + ? formatCentsToCurrency(value.to, currencyCode, true) + : "Max" + + return `${formattedFrom} - ${formattedTo}` +} + +/** + * Resolves every selected value to its option label and joins them. + * + * Unlike {@link getButtonFilterLabel}, which collapses multiple values into a + * counter (`Markets · 2`), this spells them all out (`Europe, Italy`) because a + * pill already shows the filter name separately. + */ +export function formatPillFilterValue({ + values, + instructionItem, +}: { + values: string | string[] + instructionItem: FiltersInstructionItem +}): string { + const options = + (instructionItem.type === "options" || + instructionItem.type === "groupedPredicates") && + "options" in instructionItem.render.props && + instructionItem.render.props.options != null + ? instructionItem.render.props.options + : undefined + + return castArray(values) + .map((value) => { + const asString = String(value) + return ( + options?.find((option) => option.value === asString)?.label ?? asString + ) + }) + .join(", ") +} + +/** + * Compares two filter values ignoring array wrapping and ordering, so that + * `"placed"`, `["placed"]` and `["placed"]` in a different order all match. + * Range values (objects) are compared as-is. + */ +export function isSameFilterValue(a: unknown, b: unknown): boolean { + if (isEqual(a, b)) { + return true + } + + const isRange = (value: unknown): boolean => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + !isDate(value) + + if (isRange(a) || isRange(b)) { + return false + } + + const normalize = (value: unknown): string[] => + castArray(value ?? []) + .map((item) => String(item)) + .sort() + + return isEqual(normalize(a), normalize(b)) +} + +export interface PillFilter { + /** + * Predicate of the filter, or `timePreset` for the time range. + * Used as react key and to know what to reset when removing the pill. + */ + id: string + /** Filter group label, e.g. `Payment status`. */ + label: string + /** + * Formatted value(s), e.g. `Paid, Authorized`. + * `undefined` when it has to be resolved by fetching the resource, see `fetch`. + */ + value?: string + /** + * Set for `inputResourceGroup` filters with a single selected value, whose + * label lives on the resource itself and has to be retrieved. + */ + fetch?: { + resource: ListableResourceType + id: string + fieldForLabel: string + } + /** Which reset strategy the remove button has to apply. */ + kind: "group" | "timeRange" +} + +/** + * Reads the url query string and returns one descriptor per active filter, ready + * to be rendered as a pill. + * + * Filters matching `defaultValues` are omitted: on a page where the current view + * (e.g. a tab) already implies a set of filters, only the user's additions are + * worth showing as removable pills. + * + * The free text filter is always omitted, since it is already visible in the + * search bar, and so are hidden filters and `viewTitle`. + */ +export function getPillFilters({ + instructions, + queryString, + predicateWhitelist, + defaultValues = {}, + timezone, + locale, +}: { + instructions: FiltersInstructions + queryString: string + predicateWhitelist: string[] + defaultValues?: FormFullValues + timezone?: string + locale?: Parameters[0]["locale"] +}): PillFilter[] { + const { adaptUrlQueryToFormValues } = makeFilterAdapters({ + instructions, + predicateWhitelist, + }) + + const filters = adaptUrlQueryToFormValues({ queryString }) + + if (filters == null) { + return [] + } + + const hiddenFilters = instructions + .filter((item) => item.hidden === true) + .map((item) => getInstructionKey(item)) + + const textPredicate = instructions.find(isTextSearch)?.sdk.predicate + + const activeFilters: Array<[string, UiFilterValue]> = Object.entries(filters) + .filter(([, value]) => isDate(value) || !isEmpty(value)) + .filter(([filterName]) => !hiddenFilters.includes(filterName)) + .filter(([filterName]) => filterName !== "viewTitle") + // the free text filter is already rendered by the search bar + .filter(([filterName]) => filterName !== textPredicate) + + const pills: PillFilter[] = [] + + const userDefinedFilters = activeFilters.filter( + ([filterPredicate]) => + !isTimeRangeFilterUiName(filterPredicate) && + !predicateBelongsToCurrencyRange({ filterPredicate, instructions }), + ) as Array<[string, string | string[]]> + + for (const [filterPredicate, value] of userDefinedFilters) { + if (isSameFilterValue(value, defaultValues[filterPredicate])) { + continue + } + + const instructionItem = getInstructionItemByFilterPredicate({ + instructions, + filterPredicate, + }) + + if (instructionItem == null) { + continue + } + + const arrValue = castArray(value) + + // the label of a single selected resource has to be retrieved + if ( + instructionItem.render.component === "inputResourceGroup" && + arrValue[0] !== undefined && + arrValue.length === 1 + ) { + pills.push({ + id: filterPredicate, + label: instructionItem.label, + kind: "group", + fetch: { + resource: instructionItem.render.props.resource, + id: arrValue[0], + fieldForLabel: instructionItem.render.props.fieldForLabel, + }, + }) + continue + } + + pills.push({ + id: filterPredicate, + label: instructionItem.label, + value: formatPillFilterValue({ values: value, instructionItem }), + kind: "group", + }) + } + + for (const [filterPredicate, rangeValue] of extractCurrencyRangeFilterValues({ + activeFilters, + instructions, + })) { + if (isSameFilterValue(rangeValue, defaultValues[filterPredicate])) { + continue + } + + const instructionItem = getInstructionItemByFilterPredicate({ + instructions, + filterPredicate, + }) + + if (instructionItem == null) { + continue + } + + pills.push({ + id: filterPredicate, + label: instructionItem.label, + value: makeCurrencyRangeFilterButtonLabel(rangeValue), + kind: "group", + }) + } + + const selectedTimePreset = filters.timePreset + const selectedTimeFrom = filters.timeFrom + const selectedTimeTo = filters.timeTo + + if ( + selectedTimePreset != null && + !isSameFilterValue(selectedTimePreset, defaultValues.timePreset) + ) { + const instructionItem = instructions.find( + ({ type }) => type === "timeRange", + ) + + if (instructionItem != null) { + if (selectedTimePreset === "custom") { + if (selectedTimeFrom != null && selectedTimeTo != null) { + pills.push({ + id: "timePreset", + label: instructionItem.label, + kind: "timeRange", + value: formatDateRange({ + rangeFrom: selectedTimeFrom.toString(), + rangeTo: selectedTimeTo.toString(), + timezone: timezone ?? getDefaultBrowserTimezone(), + locale, + }), + }) + } + } else { + pills.push({ + id: "timePreset", + label: instructionItem.label, + kind: "timeRange", + value: getTimeRangePresetName(selectedTimePreset, t), + }) + } + } + } + + return pills +} + +/** + * Form values to apply when clearing all the filters at once. + * + * Hidden filters, `viewTitle` and the free text search are preserved — they are + * not represented as pills, so wiping them would be an invisible side effect. + * Everything else goes back to `defaultValues` (empty when not provided). + */ +export function getClearedFormValues({ + instructions, + queryString, + predicateWhitelist, + defaultValues = {}, +}: { + instructions: FiltersInstructions + queryString: string + predicateWhitelist: string[] + defaultValues?: FormFullValues +}): FormFullValues { + const { adaptUrlQueryToFormValues } = makeFilterAdapters({ + instructions, + predicateWhitelist, + }) + + const emptyFilters = adaptUrlQueryToFormValues({ queryString: "" }) + const currentFilters = adaptUrlQueryToFormValues({ queryString }) + + const hiddenFilters = instructions + .filter((item) => item.hidden === true) + .map((item) => getInstructionKey(item)) + const textPredicate = instructions.find(isTextSearch)?.sdk.predicate + + const filtersToKeep = Object.entries(currentFilters).reduce( + (toKeep, [filterName, value]) => { + const isToKeep = + hiddenFilters.includes(filterName) || + filterName === "viewTitle" || + filterName === textPredicate + + return isToKeep ? { ...toKeep, [filterName]: value } : toKeep + }, + {}, + ) + + return { ...emptyFilters, ...defaultValues, ...filtersToKeep } +} diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/useResourceFilters.tsx b/packages/app-elements/src/ui/resources/useResourceFilters/useResourceFilters.tsx index 6a496b21c..0e871f405 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/useResourceFilters.tsx +++ b/packages/app-elements/src/ui/resources/useResourceFilters/useResourceFilters.tsx @@ -1,6 +1,14 @@ import type { ListableResourceType, QueryFilter } from "@commercelayer/sdk" -import { type JSX, useCallback, useEffect, useMemo, useState } from "react" +import { + type JSX, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react" import { useTranslation } from "react-i18next" +import { useOverlay } from "#hooks/useOverlay" import { useTokenProvider } from "#providers/TokenProvider" import { Spacer } from "#ui/atoms/Spacer" import type { SearchBarProps } from "#ui/composite/SearchBar" @@ -18,6 +26,8 @@ import type { UseResourceTableConfig, } from "#ui/resources/useResourceTable/types" import { makeFilterAdapters } from "./adapters" +import { FiltersBar, type FiltersBarProps } from "./FiltersBar" +import { FiltersDrawer, type FiltersDrawerProps } from "./FiltersDrawer" import { FiltersForm as FiltersFormComponent, type FiltersFormProps, @@ -52,8 +62,34 @@ interface UseResourceFiltersHook { * Helper methods to transform filters from/to url query string, sdk and form values */ adapters: ReturnType + /** + * Search bar with the filters button on the right and the applied filters + * rendered as removable pills below. + * + * Clicking the filters button opens the drawer rendered by `FiltersDrawer`, + * unless an `onFilterClick` prop is provided. + * + * @example + * ```jsx + * const { FiltersBar, FiltersDrawer, FilteredTable } = useResourceFilters({ instructions }) + * + * + * + * + * ``` + */ + FiltersBar: (props: FiltersBarProps) => React.ReactNode + /** + * Side drawer with the filters form, opened by the `FiltersBar` filters button. + * Render it once per page as a sibling of `FiltersBar`. + */ + FiltersDrawer: (props: FiltersDrawerProps) => React.ReactNode /** * Search bar component with filters navigation buttons + * + * @deprecated Use `FiltersBar` together with `FiltersDrawer` instead, they + * render the search bar and the filters as pills in the style used by the + * dashboard. This component will be removed in a future major release. */ SearchWithNav: ( props: Pick & { @@ -193,6 +229,52 @@ export function useResourceFilters({ }) }, [JSON.stringify(validInstructions)]) + const { + Overlay: FiltersOverlay, + open: openFiltersDrawer, + close: closeFiltersDrawer, + } = useOverlay() + + const FiltersBarComponent = useMemo( + () => + makeFiltersBar({ + validInstructions, + predicateWhitelist, + openDrawer: openFiltersDrawer, + }), + [JSON.stringify(validInstructions), openFiltersDrawer], + ) + + // The overlay component identity flips when the drawer opens and `queryString` + // changes on every navigation. Reading them from a ref keeps the returned + // component identity stable, so the drawer content is never remounted while in + // use, which would discard what the user is filling in. + const drawerPropsRef = useRef({ + Overlay: FiltersOverlay, + close: closeFiltersDrawer, + queryString, + }) + drawerPropsRef.current = { + Overlay: FiltersOverlay, + close: closeFiltersDrawer, + queryString, + } + + const FiltersDrawerComponent: UseResourceFiltersHook["FiltersDrawer"] = + useCallback( + (props): JSX.Element => ( + + ), + [JSON.stringify(validInstructions)], + ) + const FiltersForm: UseResourceFiltersHook["FiltersForm"] = useCallback( ({ onSubmit }): JSX.Element => { return ( @@ -222,6 +304,8 @@ export function useResourceFilters({ adapters, sdkFilters, hasActiveFilter, + FiltersBar: FiltersBarComponent, + FiltersDrawer: FiltersDrawerComponent, SearchWithNav, FiltersForm, FilteredList, @@ -230,6 +314,21 @@ export function useResourceFilters({ } } +const makeFiltersBar: (options: { + validInstructions: FiltersInstructions + predicateWhitelist: string[] + openDrawer: () => void +}) => UseResourceFiltersHook["FiltersBar"] = + ({ validInstructions, predicateWhitelist, openDrawer }) => + (props) => ( + + ) + // internal implementation of the ResourceList component exposed from the useResourceList hook function ResourceListComponent({ metricsQuery, From 7e58e6ee285381f29b1756b058e8860cb9aa2f27 Mon Sep 17 00:00:00 2001 From: Matteo Alessani Date: Fri, 7 Aug 2026 18:27:14 +0200 Subject: [PATCH 2/3] chore: enhance filter drawer with multi select --- .../useResourceFilters/FieldOptions.tsx | 8 + .../useResourceFilters/FieldOptionsSelect.tsx | 144 ++++++++++++++++++ .../useResourceFilters/FiltersBar.tsx | 47 ++++-- .../useResourceFilters/activeFilters.test.ts | 15 +- .../useResourceFilters/activeFilters.ts | 19 ++- .../ui/resources/useResourceFilters/types.ts | 36 +++++ packages/docs/src/mocks/data/markets.js | 78 ++++++++++ .../resources/useResourceFilters.stories.tsx | 75 ++++++++- 8 files changed, 395 insertions(+), 27 deletions(-) create mode 100644 packages/app-elements/src/ui/resources/useResourceFilters/FieldOptionsSelect.tsx diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/FieldOptions.tsx b/packages/app-elements/src/ui/resources/useResourceFilters/FieldOptions.tsx index d067b7e50..d053cde6a 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/FieldOptions.tsx +++ b/packages/app-elements/src/ui/resources/useResourceFilters/FieldOptions.tsx @@ -2,6 +2,7 @@ import type { JSX } from "react" import { useFormContext } from "react-hook-form" import { HookedInputResourceGroup } from "#ui/forms/InputResourceGroup" import { HookedInputToggleButton } from "#ui/forms/InputToggleButton" +import { FieldOptionsSelect } from "./FieldOptionsSelect" import type { FilterItemOptions } from "./types" import { computeFilterLabel } from "./utils" @@ -43,5 +44,12 @@ export function FieldOptions({ item }: FieldProps): JSX.Element | null { {...item.render.props} /> ) + + case "inputSelect": + return ( + + ) } } diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/FieldOptionsSelect.tsx b/packages/app-elements/src/ui/resources/useResourceFilters/FieldOptionsSelect.tsx new file mode 100644 index 000000000..426c8196b --- /dev/null +++ b/packages/app-elements/src/ui/resources/useResourceFilters/FieldOptionsSelect.tsx @@ -0,0 +1,144 @@ +import type { QueryParamsList } from "@commercelayer/sdk" +import castArray from "lodash-es/castArray" +import uniqBy from "lodash-es/uniqBy" +import type { JSX } from "react" +import { useFormContext } from "react-hook-form" +import { useCoreApi, useCoreSdkProvider } from "#providers/CoreSdkProvider" +import { HookedInputSelect, type InputSelectValue } from "#ui/forms/InputSelect" +import type { FilterItemOptions } from "./types" + +type SelectRender = Extract< + FilterItemOptions["render"], + { component: "inputSelect" } +> + +/** Core caps `pageSize` at 25, so this is also the most we can load in one go. */ +const defaultLimit = 25 + +/** + * Renders an `options` filter as a (multi) select dropdown, the style used by the + * dashboard metrics filters. + * + * Options come from the Core API. Anything already selected is fetched + * separately, so its label resolves even when it is not in the first page, and + * when `searchBy` is set typing searches server-side instead of filtering only + * what has been loaded. + */ +export function FieldOptionsSelect({ + item, +}: { + item: FilterItemOptions & { render: SelectRender } +}): JSX.Element | null { + const { watch } = useFormContext() + const { sdkClient } = useCoreSdkProvider() + + const { + resource, + fieldForLabel, + fieldForValue, + searchBy, + sortBy, + filters = {}, + limit = defaultLimit, + placeholder, + isClearable, + isMulti = true, + hideWhenSingleItem, + } = item.render.props + + const selectedValues = castArray(watch(item.sdk.predicate) ?? []).map( + (value) => String(value), + ) + + const listQuery: QueryParamsList = { + fields: { + [resource]: [fieldForValue, fieldForLabel], + }, + pageSize: limit as QueryParamsList["pageSize"], + ...(sortBy != null + ? { sort: { [sortBy.attribute]: sortBy.direction } } + : {}), + filters, + } + + const toOption = (item: Record): InputSelectValue => ({ + value: String(item[fieldForValue]), + label: String(item[fieldForLabel] ?? item[fieldForValue]), + }) + + const { data: firstPage, isLoading } = useCoreApi( + resource, + "list", + [listQuery], + { revalidateOnFocus: false }, + ) + + // Selected options may live on a later page, so they are fetched on their own + // to keep their labels resolvable. With nothing selected this is the very same + // query as above, which swr dedupes. + const { data: selectedResources } = useCoreApi( + resource, + "list", + [ + selectedValues.length === 0 + ? listQuery + : { + ...listQuery, + filters: { + ...filters, + [`${fieldForValue}_in`]: selectedValues.join(","), + }, + }, + ], + { revalidateOnFocus: false }, + ) + + const initialValues = uniqBy( + [...(selectedResources ?? []), ...(firstPage ?? [])].map((resource) => + toOption(resource as unknown as Record), + ), + "value", + ) + + // parity with `inputResourceGroup`: a filter over a single possible value is + // not worth showing, unless the user already picked something + if ( + hideWhenSingleItem === true && + firstPage?.meta?.recordCount === 1 && + selectedValues.length === 0 + ) { + return null + } + + return ( + { + // the sdk resource is only known at runtime, so the `list` shape + // cannot be inferred from the union of all listable resources + const results = await ( + sdkClient[resource] as unknown as { + list: ( + params: QueryParamsList, + ) => Promise>> + } + ).list({ + ...listQuery, + filters: { ...filters, [searchBy]: hint }, + }) + + return results.map(toOption) + } + } + /> + ) +} diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/FiltersBar.tsx b/packages/app-elements/src/ui/resources/useResourceFilters/FiltersBar.tsx index 23335a58c..9af7d75c9 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/FiltersBar.tsx +++ b/packages/app-elements/src/ui/resources/useResourceFilters/FiltersBar.tsx @@ -185,8 +185,9 @@ export function FiltersBar({ key={pill.id} label={pill.label} resource={pill.fetch.resource} - id={pill.fetch.id} + ids={pill.fetch.ids} fieldForLabel={pill.fetch.fieldForLabel} + fieldForValue={pill.fetch.fieldForValue} onRemoveRequest={() => { removePill(pill) }} @@ -222,40 +223,54 @@ export function FiltersBar({ FiltersBar.displayName = "FiltersBar" /** - * Pill for a single selected resource, whose label has to be retrieved since it - * lives on the resource itself. + * Pill for a filter backed by a resource: the labels of the selected values live + * on the resources themselves, so they are retrieved in a single request and + * joined. Falls back to the raw ids while loading or when a value no longer + * resolves (e.g. a deleted record). */ function ResourcePill({ label, resource, - id, + ids, fieldForLabel, + fieldForValue, onRemoveRequest, }: { label: string resource: ListableResourceType - id: string + ids: string[] fieldForLabel: string + fieldForValue: string onRemoveRequest: () => void }): JSX.Element { - const { data, isLoading } = useCoreApi(resource, "retrieve", [ - id, - { - fields: { [resource]: [fieldForLabel] }, - }, - ]) + const { data, isLoading } = useCoreApi( + resource, + "list", + [ + { + fields: { [resource]: [fieldForValue, fieldForLabel] }, + pageSize: 25, + filters: { [`${fieldForValue}_in`]: ids.join(",") }, + }, + ], + { revalidateOnFocus: false }, + ) + + const labelsById = new Map( + (data ?? []).map((item) => { + const record = item as unknown as Record + return [String(record[fieldForValue]), String(record[fieldForLabel])] + }), + ) - const value = - data != null - ? fieldForLabel in data && data[fieldForLabel as keyof typeof data] - : undefined + const value = ids.map((id) => labelsById.get(id) ?? id).join(", ") return ( diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/activeFilters.test.ts b/packages/app-elements/src/ui/resources/useResourceFilters/activeFilters.test.ts index 2fe9136d0..fac011398 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/activeFilters.test.ts +++ b/packages/app-elements/src/ui/resources/useResourceFilters/activeFilters.test.ts @@ -62,7 +62,7 @@ describe("getPillFilters", () => { expect(pills.map((pill) => pill.id)).toEqual(["payment_status_eq"]) }) - test("defers the label of a single selected resource to a fetch", () => { + test("defers the labels of selected resources to a fetch", () => { const [pill] = getPillFilters({ ...baseArgs, queryString: "market_id_in=dLbQmsNqrX", @@ -71,11 +71,22 @@ describe("getPillFilters", () => { expect(pill?.value).toBeUndefined() expect(pill?.fetch).toEqual({ resource: "markets", - id: "dLbQmsNqrX", + ids: ["dLbQmsNqrX"], fieldForLabel: "name", + fieldForValue: "id", }) }) + test("defers every selected resource, not just a single one", () => { + // showing raw ids would be the alternative, as it did before + const [pill] = getPillFilters({ + ...baseArgs, + queryString: "market_id_in=dLbQmsNqrX&market_id_in=NgojhKoyYN", + }) + + expect(pill?.fetch?.ids).toEqual(["dLbQmsNqrX", "NgojhKoyYN"]) + }) + test("renders a time range preset as a single pill", () => { const pills = getPillFilters({ ...baseArgs, diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/activeFilters.ts b/packages/app-elements/src/ui/resources/useResourceFilters/activeFilters.ts index 33aec4144..fae1762d5 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/activeFilters.ts +++ b/packages/app-elements/src/ui/resources/useResourceFilters/activeFilters.ts @@ -229,13 +229,14 @@ export interface PillFilter { */ value?: string /** - * Set for `inputResourceGroup` filters with a single selected value, whose - * label lives on the resource itself and has to be retrieved. + * Set for filters backed by a resource (`inputResourceGroup`, `inputSelect`), + * whose labels live on the resources themselves and have to be retrieved. */ fetch?: { resource: ListableResourceType - id: string + ids: string[] fieldForLabel: string + fieldForValue: string } /** Which reset strategy the remove button has to apply. */ kind: "group" | "timeRange" @@ -315,11 +316,12 @@ export function getPillFilters({ const arrValue = castArray(value) - // the label of a single selected resource has to be retrieved + // These are backed by a resource, so the options carry no labels: they have + // to be retrieved, otherwise the pill would show raw ids. if ( - instructionItem.render.component === "inputResourceGroup" && - arrValue[0] !== undefined && - arrValue.length === 1 + (instructionItem.render.component === "inputResourceGroup" || + instructionItem.render.component === "inputSelect") && + arrValue.length > 0 ) { pills.push({ id: filterPredicate, @@ -327,8 +329,9 @@ export function getPillFilters({ kind: "group", fetch: { resource: instructionItem.render.props.resource, - id: arrValue[0], + ids: arrValue, fieldForLabel: instructionItem.render.props.fieldForLabel, + fieldForValue: instructionItem.render.props.fieldForValue, }, }) continue diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/types.ts b/packages/app-elements/src/ui/resources/useResourceFilters/types.ts index f68b47212..006c68de9 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/types.ts +++ b/packages/app-elements/src/ui/resources/useResourceFilters/types.ts @@ -163,6 +163,42 @@ export type FilterItemOptions = BaseFilterItem & { "onChange" | "defaultValues" | "title" > } + | { + /** + * UI component to render: a select dropdown, matching the style of the + * dashboard metrics filters. Prefer it over `inputResourceGroup` when the + * options are many, since it searches server-side instead of showing a + * checkbox list with a "see all" overlay. + */ + component: "inputSelect" + /** + * props required for the UI component + */ + props: Pick< + InputResourceGroupProps, + | "resource" + | "fieldForLabel" + | "fieldForValue" + | "searchBy" + | "sortBy" + | "filters" + | "hideWhenSingleItem" + > & { + /** + * How many options to load upfront. Capped at 25 by the Core API, which + * is why `searchBy` should be set when more options exist. + * @default 25 + */ + limit?: number + placeholder?: string + isClearable?: boolean + /** + * Filter predicates are usually `_in`, so multiple values are expected. + * @default true + */ + isMulti?: boolean + } + } } export interface FilterItemTextSearch extends Omit { diff --git a/packages/docs/src/mocks/data/markets.js b/packages/docs/src/mocks/data/markets.js index 039cccd3a..8643be639 100644 --- a/packages/docs/src/mocks/data/markets.js +++ b/packages/docs/src/mocks/data/markets.js @@ -459,4 +459,82 @@ const allMarkets = http.get( }, ) +/** + * A longer, dynamic list of markets that honours `filter[q][name_cont]`, + * `filter[q][id_in]` and `page[size]`. + * + * Deliberately **not** part of the default handlers: msw matches on the path and + * uses the first matching handler, so adding it there would shadow the two + * handlers above and change the markets shown in every other story (two of them + * reference specific market ids). Stories that need it opt in with + * `worker.use(marketsWithSearch)`. + */ +const marketNames = [ + "Adyen", + "Austria", + "Belgium", + "Croatia", + "Denmark", + "Estonia", + "Europe", + "Finland", + "France", + "Germany", + "Greece", + "Hungary", + "Iceland", + "Ireland", + "Italia 4", + "Italy", + "Latvia", + "Lithuania", + "Luxembourg", + "Malta", + "Milan", + "Netherlands", + "Norway", + "Poland", + "Portugal", + "Romania", + "Slovakia", + "Slovenia", + "Spain", + "Sweden", + "Switzerland", + "United States", +] + +export const marketsWithSearch = http.get( + "https://mock.localhost/api/markets", + async ({ request }) => { + await delay(300) + + const url = new URL(request.url) + const search = url.searchParams.get("filter[q][name_cont]") + const idsIn = url.searchParams.get("filter[q][id_in]") + const pageSize = Number(url.searchParams.get("page[size]") ?? 25) + + const matching = marketNames + .map((name, index) => ({ id: `market-${index}`, name })) + .filter(({ name, id }) => { + if (idsIn != null) { + return idsIn.split(",").includes(id) + } + return ( + search == null || name.toLowerCase().includes(search.toLowerCase()) + ) + }) + + return HttpResponse.json({ + data: matching.slice(0, pageSize).map(({ id, name }) => ({ + id, + type: "markets", + attributes: { name }, + meta: { mode: "test", organization_id: "WXlEOFrjnr" }, + })), + meta: { record_count: matching.length, page_count: 1 }, + }) + }, +) + export default [allMarkets, someMarkets] diff --git a/packages/docs/src/stories/resources/useResourceFilters.stories.tsx b/packages/docs/src/stories/resources/useResourceFilters.stories.tsx index 89d35114b..576033263 100644 --- a/packages/docs/src/stories/resources/useResourceFilters.stories.tsx +++ b/packages/docs/src/stories/resources/useResourceFilters.stories.tsx @@ -6,7 +6,7 @@ import { Title, } from "@storybook/addon-docs/blocks" import type { Meta, StoryFn } from "@storybook/react-vite" -import { useState } from "react" +import { useEffect, useState } from "react" import { useOverlay } from "#hooks/useOverlay" import { CoreSdkProvider } from "#providers/CoreSdkProvider" import { MockTokenProvider as TokenProvider } from "#providers/TokenProvider/MockTokenProvider" @@ -17,6 +17,8 @@ import { presetResourceListItem } from "#ui/resources/ResourceListItem/ResourceL import { useResourceFilters } from "#ui/resources/useResourceFilters" import { instructions } from "#ui/resources/useResourceFilters/mockedInstructions" import type { FiltersInstructions } from "#ui/resources/useResourceFilters/types" +import { worker } from "../../mocks/browser" +import { marketsWithSearch } from "../../mocks/data/markets" const mockedOrder = presetResourceListItem.orderAwaitingApproval const navigate = (qs: string): void => { @@ -227,6 +229,77 @@ export const SearchWithNav: StoryFn = () => { ) } +/** + * Same instructions as the other stories, with `Markets` rendered as a dropdown + * (`component: 'inputSelect'`) rather than as a checkbox list. + */ +const instructionsWithMarketsSelect: FiltersInstructions = instructions.map( + (item) => + item.type === "options" && item.sdk.predicate === "market_id_in" + ? { + ...item, + render: { + component: "inputSelect", + props: { + resource: "markets", + fieldForLabel: "name", + fieldForValue: "id", + // enables server-side search: Core caps `page[size]` at 25, so + // without this the options past the first page are unreachable + searchBy: "name_cont", + sortBy: { attribute: "name", direction: "asc" }, + }, + }, + } + : item, +) + +/** + * `FiltersBar` renders the search bar with the filters button on the right and the + * active filters as removable pills below, the style used by the dashboard. + * `FiltersDrawer` holds the filters form and is opened by that button, so the two + * are meant to be rendered together. + * + * The `Markets` field in the drawer uses `component: 'inputSelect'`: a multi + * select dropdown. Type into it to see that the search hits the API, so options + * beyond the first page of 25 can still be picked. + * + * + * Filters live in the url query string. This story keeps it in React state + * instead, and prints it below, so the whole thing is interactive without a + * router — in an app you would pass the query string from your router and write + * back to it in `onUpdate`. + * + **/ +export const FiltersBarWithDrawer: StoryFn = () => { + const [queryString, setQueryString] = useState("") + const { FiltersBar, FiltersDrawer } = useResourceFilters({ + instructions: instructionsWithMarketsSelect, + }) + + // Only this story needs a searchable list of markets: the default handler + // returns a fixed set, which would make the dropdown look like it ignores what + // you type. Scoped with `use`/`resetHandlers` so no other story is affected. + useEffect(() => { + worker.use(marketsWithSearch) + return () => { + worker.resetHandlers() + } + }, []) + + return ( + + + + +
+          {queryString === "" ? "(no filter applied)" : `?${queryString}`}
+        
+
+
+ ) +} + /** * While all the components above — returned from `useResourceFilters` hook — are already connected together, * it's still possible to use some helper methods to build your own logic. From 599eb07cf04859600a606ae468f42440334c9d51 Mon Sep 17 00:00:00 2001 From: Matteo Alessani Date: Mon, 10 Aug 2026 18:09:53 +0200 Subject: [PATCH 3/3] fix: hide filter button if no filters to display --- .../useResourceFilters/FiltersBar.tsx | 47 ++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/FiltersBar.tsx b/packages/app-elements/src/ui/resources/useResourceFilters/FiltersBar.tsx index 9af7d75c9..93844b934 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/FiltersBar.tsx +++ b/packages/app-elements/src/ui/resources/useResourceFilters/FiltersBar.tsx @@ -93,6 +93,19 @@ export function FiltersBar({ const { adaptUrlQueryToFormValues, adaptFormValuesToUrlQuery } = makeFilterAdapters({ instructions, predicateWhitelist }) + /** + * Whether the drawer would have anything to show. A `searchBar` text filter is + * rendered by this bar and skipped by the form (`FieldTextSearch` returns + * `null` for it), and hidden instructions render nothing — so an instruction + * set made only of those would open an empty drawer. The button is dropped + * instead, which is what an app with search but no filters wants. + */ + const hasFilterFields = instructions.some( + (item) => + item.hidden !== true && + !(item.type === "textSearch" && item.render.component === "searchBar"), + ) + const pills = getPillFilters({ instructions, queryString, @@ -157,22 +170,24 @@ export function FiltersBar({ )}
- - - - } - /> + {hasFilterFields && ( + + + + } + /> + )} {actions}