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 (
)
+
+ 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
new file mode 100644
index 000000000..93844b934
--- /dev/null
+++ b/packages/app-elements/src/ui/resources/useResourceFilters/FiltersBar.tsx
@@ -0,0 +1,293 @@
+import type { ListableResourceType } from "@commercelayer/sdk"
+import type { JSX, ReactNode } from "react"
+import { useCoreApi } from "#providers/CoreSdkProvider"
+import { t } from "#providers/I18NProvider"
+import { useTokenProvider } from "#providers/TokenProvider"
+import { Button } from "#ui/atoms/Button"
+import { ButtonFilter } from "#ui/atoms/ButtonFilter"
+import { Icon } from "#ui/atoms/Icon"
+import { SkeletonTemplate } from "#ui/atoms/SkeletonTemplate"
+import { Tooltip } from "#ui/atoms/Tooltip"
+import {
+ getClearedFormValues,
+ getPillFilters,
+ type PillFilter,
+} from "./activeFilters"
+import { makeFilterAdapters } from "./adapters"
+import { FiltersSearchBar } from "./FiltersSearchBar"
+import type { FiltersInstructions, FormFullValues } from "./types"
+
+export interface FiltersBarProps {
+ /**
+ * Url query string to be parsed.
+ * It must be "reactive", so most of the time it should come from the router.
+ */
+ queryString: string
+ /**
+ * Callback triggered when the user interacts with the search bar or removes a
+ * filter. The implementation should update the url query string.
+ */
+ onUpdate: (newQueryString: string) => 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 })
+
+ /**
+ * 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,
+ 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
+
+
+
+ )}
+
+
+ {hasFilterFields && (
+
+
+
+ }
+ />
+ )}
+ {actions}
+
+
+
+ {pills.length > 0 && (
+
+ {pills.map((pill) =>
+ pill.fetch != null ? (
+ {
+ removePill(pill)
+ }}
+ />
+ ) : (
+ {
+ removePill(pill)
+ }}
+ />
+ ),
+ )}
+
+
+ {t("common.clear_all")}
+
+
+ )}
+ >
+ )
+}
+
+FiltersBar.displayName = "FiltersBar"
+
+/**
+ * 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,
+ ids,
+ fieldForLabel,
+ fieldForValue,
+ onRemoveRequest,
+}: {
+ label: string
+ resource: ListableResourceType
+ ids: string[]
+ fieldForLabel: string
+ fieldForValue: string
+ onRemoveRequest: () => void
+}): JSX.Element {
+ 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 = ids.map((id) => labelsById.get(id) ?? id).join(", ")
+
+ 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..fac011398
--- /dev/null
+++ b/packages/app-elements/src/ui/resources/useResourceFilters/activeFilters.test.ts
@@ -0,0 +1,126 @@
+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 labels of selected resources to a fetch", () => {
+ const [pill] = getPillFilters({
+ ...baseArgs,
+ queryString: "market_id_in=dLbQmsNqrX",
+ })
+
+ expect(pill?.value).toBeUndefined()
+ expect(pill?.fetch).toEqual({
+ resource: "markets",
+ 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,
+ 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..fae1762d5
--- /dev/null
+++ b/packages/app-elements/src/ui/resources/useResourceFilters/activeFilters.ts
@@ -0,0 +1,458 @@
+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 filters backed by a resource (`inputResourceGroup`, `inputSelect`),
+ * whose labels live on the resources themselves and have to be retrieved.
+ */
+ fetch?: {
+ resource: ListableResourceType
+ ids: string[]
+ fieldForLabel: string
+ fieldForValue: 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)
+
+ // 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" ||
+ instructionItem.render.component === "inputSelect") &&
+ arrValue.length > 0
+ ) {
+ pills.push({
+ id: filterPredicate,
+ label: instructionItem.label,
+ kind: "group",
+ fetch: {
+ resource: instructionItem.render.props.resource,
+ ids: arrValue,
+ fieldForLabel: instructionItem.render.props.fieldForLabel,
+ fieldForValue: instructionItem.render.props.fieldForValue,
+ },
+ })
+ 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/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/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,
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.