diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/FieldGroupedPredicates.tsx b/packages/app-elements/src/ui/resources/useResourceFilters/FieldGroupedPredicates.tsx new file mode 100644 index 00000000..76d860c5 --- /dev/null +++ b/packages/app-elements/src/ui/resources/useResourceFilters/FieldGroupedPredicates.tsx @@ -0,0 +1,46 @@ +import type { JSX } from "react" +import { useFormContext } from "react-hook-form" +import { HookedInputToggleButton } from "#ui/forms/InputToggleButton" +import type { FilterItemGroupedPredicates } from "./types" +import { computeFilterLabel } from "./utils" + +interface FieldGroupedPredicatesProps { + item: FilterItemGroupedPredicates +} + +export function FieldGroupedPredicates({ + item, +}: FieldGroupedPredicatesProps): JSX.Element { + const { watch } = useFormContext() + + const visibleOptions = item.render.props.options.filter( + (opt) => opt.isHidden !== true, + ) + + const selectedValue = watch(item.urlParamKey) as string | string[] | undefined + + const selectedCount = Array.isArray(selectedValue) + ? selectedValue.length + : selectedValue != null + ? 1 + : 0 + + return ( + ({ label, value }))} + /> + ) +} + +FieldGroupedPredicates.displayName = "FieldGroupedPredicates" diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/FiltersForm.tsx b/packages/app-elements/src/ui/resources/useResourceFilters/FiltersForm.tsx index 8a2f2a16..6132e4e4 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/FiltersForm.tsx +++ b/packages/app-elements/src/ui/resources/useResourceFilters/FiltersForm.tsx @@ -7,6 +7,7 @@ import { Spacer } from "#ui/atoms/Spacer" import { HookedForm } from "#ui/forms/Form" import { makeFilterAdapters } from "./adapters" import { FieldCurrencyRange } from "./FieldCurrencyRange" +import { FieldGroupedPredicates } from "./FieldGroupedPredicates" import { FieldOptions } from "./FieldOptions" import { FieldTextSearch } from "./FieldTextSearch" import { FieldTimeRange } from "./FieldTimeRange" @@ -107,6 +108,14 @@ function FiltersForm({ ) } + if (item.type === "groupedPredicates") { + return ( + + + + ) + } + return null })}
diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/FiltersNav.tsx b/packages/app-elements/src/ui/resources/useResourceFilters/FiltersNav.tsx index 1e86e767..8a918a57 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/FiltersNav.tsx +++ b/packages/app-elements/src/ui/resources/useResourceFilters/FiltersNav.tsx @@ -24,6 +24,7 @@ import { type FiltersInstructionItem, type FiltersInstructions, type FormFullValues, + getInstructionKey, isTextSearch, type UiFilterName, type UiFilterValue, @@ -140,7 +141,7 @@ export function FiltersNav({ () => instructions .filter((item) => item.hidden === true) - .map((item) => item.sdk.predicate), + .map((item) => getInstructionKey(item)), [instructions], ) @@ -366,7 +367,9 @@ function getInstructionItemByFilterPredicate({ if (isTimeRangeFilterUiName(filterPredicate)) { return instructions.find(({ type }) => type === "timeRange") } - return instructions.find((item) => item.sdk.predicate === filterPredicate) + return instructions.find( + (item) => getInstructionKey(item) === filterPredicate, + ) } /** @@ -439,6 +442,17 @@ function getButtonFilterLabel({ ) } + 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}` } @@ -477,7 +491,7 @@ function predicateBelongsToCurrencyRange({ instructions: FiltersInstructions }): boolean { const instructionItem = instructions.find( - (item) => item.sdk.predicate === filterPredicate, + (item) => getInstructionKey(item) === filterPredicate, ) return instructionItem?.type === "currencyRange" diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/FiltersSearchBar.tsx b/packages/app-elements/src/ui/resources/useResourceFilters/FiltersSearchBar.tsx index 5083b160..12513c25 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/FiltersSearchBar.tsx +++ b/packages/app-elements/src/ui/resources/useResourceFilters/FiltersSearchBar.tsx @@ -4,7 +4,11 @@ import type { JSX } from "react" import { t } from "#providers/I18NProvider" import { SearchBar, type SearchBarProps } from "#ui/composite/SearchBar" import { makeFilterAdapters } from "./adapters" -import type { FiltersInstructions } from "./types" +import type { + FilterItemTextSearch, + FiltersInstructions, + FormFullValues, +} from "./types" export interface FilterSearchBarProps extends Pick { @@ -53,7 +57,7 @@ function FiltersSearchBar({ }) const textPredicate = instructions.find( - (item) => + (item): item is FilterItemTextSearch => item.type === "textSearch" && item.render.component === "searchBar", )?.sdk.predicate @@ -70,7 +74,7 @@ function FiltersSearchBar({ formValues: { ...currentFilters, [textPredicate]: isEmpty(hint?.trim()) ? undefined : hint, - }, + } as FormFullValues, }) onUpdate(newQueryString) diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/adaptFormValuesToSdk.test.ts b/packages/app-elements/src/ui/resources/useResourceFilters/adaptFormValuesToSdk.test.ts index 288144b6..094e857b 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/adaptFormValuesToSdk.test.ts +++ b/packages/app-elements/src/ui/resources/useResourceFilters/adaptFormValuesToSdk.test.ts @@ -197,6 +197,50 @@ describe("adaptFormValuesToSdk", () => { }) }) + test("should map a groupedPredicates option to its sdk predicate", () => { + expect( + adaptFormValuesToSdk({ + formValues: { + quantity_filter: "has_items", + }, + instructions, + }), + ).toStrictEqual({ + quantity_gteq: "1", + archived_at_null: true, + status_in: "placed,approved,cancelled", + }) + }) + + test("should map a different groupedPredicates option to its own sdk predicate", () => { + expect( + adaptFormValuesToSdk({ + formValues: { + quantity_filter: "empty", + }, + instructions, + }), + ).toStrictEqual({ + quantity_eq: "0", + archived_at_null: true, + status_in: "placed,approved,cancelled", + }) + }) + + test("should omit groupedPredicates sdk predicates when no option is selected", () => { + expect( + adaptFormValuesToSdk({ + formValues: { + quantity_filter: undefined, + }, + instructions, + }), + ).toStrictEqual({ + archived_at_null: true, + status_in: "placed,approved,cancelled", + }) + }) + test("should generate `lastname_eq` when whitelisted", () => { expect( adaptFormValuesToSdk({ @@ -224,6 +268,7 @@ describe("extractEnforcedValues", () => { test("should return empty object if no enforced values", () => { const instructionsWithoutDefaultOptions = instructions.filter( (item) => + item.type === "groupedPredicates" || !("defaultOptions" in item.sdk && item.sdk.defaultOptions != null), ) expect( diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/adaptFormValuesToSdk.ts b/packages/app-elements/src/ui/resources/useResourceFilters/adaptFormValuesToSdk.ts index 65617480..6369f31e 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/adaptFormValuesToSdk.ts +++ b/packages/app-elements/src/ui/resources/useResourceFilters/adaptFormValuesToSdk.ts @@ -8,10 +8,13 @@ import { makeSdkFilterTime } from "./timeUtils" import { type CurrencyRangeFieldValue, type FilterItemCurrencyRange, + type FilterItemGroupedPredicates, type FilterItemOptions, type FilterItemTextSearch, type FiltersInstructions, + getInstructionKey, isCurrencyRange, + isGroupedPredicates, isItemOptions, isTextSearch, type TimeRangePreset, @@ -49,17 +52,23 @@ export function adaptFormValuesToSdk< ): item is | FilterItemOptions | FilterItemTextSearch - | FilterItemCurrencyRange => - isItemOptions(item) || isTextSearch(item) || isCurrencyRange(item), + | FilterItemCurrencyRange + | FilterItemGroupedPredicates => + isItemOptions(item) || + isTextSearch(item) || + isCurrencyRange(item) || + isGroupedPredicates(item), ) .flatMap((item) => - ([] as string[]).concat(item.sdk.predicate).concat(predicateWhitelist), + ([] as string[]) + .concat(getInstructionKey(item)) + .concat(predicateWhitelist), ) const sdkFilters = formFieldNames.reduce>( (acc, key) => { const instructionItem = instructions.find( - (item) => item.sdk.predicate === key, + (item) => getInstructionKey(item) === key, ) if (instructionItem == null) { @@ -75,6 +84,7 @@ export function adaptFormValuesToSdk< // user custom defined parseFormValue function if ( + instructionItem.type !== "groupedPredicates" && "parseFormValue" in instructionItem.sdk && instructionItem.sdk.parseFormValue != null ) { @@ -110,6 +120,24 @@ export function adaptFormValuesToSdk< } } + if (instructionItem.type === "groupedPredicates") { + // Each selected option value maps to its own distinct SDK predicate + value + const selectedValues = castArray(formValues[key]).filter( + (value): value is string => + typeof value === "string" && value.length > 0, + ) + return selectedValues.reduce((innerAcc, selectedValue) => { + const option = instructionItem.render.props.options.find( + (o) => o.value === selectedValue, + ) + if (option == null) return innerAcc + return { + ...innerAcc, + [option.sdk.predicate]: option.sdk.value, + } + }, acc) + } + return acc }, { diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/adaptFormValuesToUrlQuery.test.ts b/packages/app-elements/src/ui/resources/useResourceFilters/adaptFormValuesToUrlQuery.test.ts index a9efd783..2e4e0141 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/adaptFormValuesToUrlQuery.test.ts +++ b/packages/app-elements/src/ui/resources/useResourceFilters/adaptFormValuesToUrlQuery.test.ts @@ -106,6 +106,30 @@ describe("adaptFormValuesToUrlQuery", () => { ).toBe("archived_at_null=show") }) + test("should serialize a groupedPredicates selection as its urlParamKey value", () => { + expect( + adaptFormValuesToUrlQuery({ + formValues: { + status_in: ["approved"], + quantity_filter: "has_items", + }, + instructions, + }), + ).toBe("quantity_filter=has_items&status_in=approved") + }) + + test("should omit groupedPredicates urlParamKey when no option is selected", () => { + expect( + adaptFormValuesToUrlQuery({ + formValues: { + status_in: ["approved"], + quantity_filter: undefined, + }, + instructions, + }), + ).toBe("status_in=approved") + }) + test("should accept empty values", () => { expect( adaptFormValuesToUrlQuery({ diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/adaptSdkToMetrics.test.ts b/packages/app-elements/src/ui/resources/useResourceFilters/adaptSdkToMetrics.test.ts index 68c21bfc..afbdb43d 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/adaptSdkToMetrics.test.ts +++ b/packages/app-elements/src/ui/resources/useResourceFilters/adaptSdkToMetrics.test.ts @@ -211,4 +211,39 @@ describe("adaptSdkToMetrics", () => { }, }) }) + + test("Should include groupedPredicates option SDK predicates as main resource metrics filters", () => { + expect( + adaptSdkToMetrics({ + sdkFilters: { quantity_eq: "0" }, + resourceType: "orders", + instructions, + }), + ).toStrictEqual({ + order: { + date_from: "2022-04-05T15:20:01Z", + date_to: "2023-04-05T15:20:00Z", + date_field: "updated_at", + quantity: { eq: "0" }, + }, + }) + }) + + test("Should include groupedPredicates option SDK predicates alongside standard filters", () => { + expect( + adaptSdkToMetrics({ + sdkFilters: { quantity_gteq: "1", status_in: "approved,cancelled" }, + resourceType: "orders", + instructions, + }), + ).toStrictEqual({ + order: { + date_from: "2022-04-05T15:20:01Z", + date_to: "2023-04-05T15:20:00Z", + date_field: "updated_at", + quantity: { gte: "1" }, + statuses: { in: ["approved", "cancelled"] }, + }, + }) + }) }) diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/adaptSdkToMetrics.ts b/packages/app-elements/src/ui/resources/useResourceFilters/adaptSdkToMetrics.ts index 6011af4f..c4acf804 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/adaptSdkToMetrics.ts +++ b/packages/app-elements/src/ui/resources/useResourceFilters/adaptSdkToMetrics.ts @@ -5,6 +5,10 @@ import { removeMillisecondsFromIsoDate, } from "#helpers/date" import type { FiltersInstructions } from "#ui/resources/useResourceFilters/types" +import { + getInstructionKey, + isGroupedPredicates, +} from "#ui/resources/useResourceFilters/types" export type CoreResourceEnabledInMetrics = "orders" | "returns" type MetricsResource = "order" | "return" @@ -76,6 +80,15 @@ export function adaptSdkToMetrics({ instructions.find((item) => item.type === "timeRange")?.sdk.predicate ?? "created_at" + // Pre-compute the set of all SDK predicates declared inside groupedPredicates options. + // These are not top-level instruction keys, but must be allowed through the drop guard + // so they can be forwarded to the Metrics API as regular field filters. + const groupedPredicatesOptionKeys = new Set( + instructions + .filter(isGroupedPredicates) + .flatMap((item) => item.render.props.options.map((o) => o.sdk.predicate)), + ) + // separate relationships from main resource filters const regroupedFilters = Object.entries(sdkFilters).reduce<{ /** main resource filters */ @@ -89,13 +102,14 @@ export function adaptSdkToMetrics({ }>( (acc, [key, value]) => { const instructionItem = instructions.find( - (item) => item.sdk.predicate === key, + (item) => getInstructionKey(item) === key, ) if ( instructionItem == null && !predicateWhitelist.includes(key) && - !key.startsWith(defaultDatePredicate) + !key.startsWith(defaultDatePredicate) && + !groupedPredicatesOptionKeys.has(key) ) { return acc } diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/adaptSdkToUrlQuery.test.ts b/packages/app-elements/src/ui/resources/useResourceFilters/adaptSdkToUrlQuery.test.ts index b3f7a263..e200baea 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/adaptSdkToUrlQuery.test.ts +++ b/packages/app-elements/src/ui/resources/useResourceFilters/adaptSdkToUrlQuery.test.ts @@ -53,4 +53,28 @@ describe("adaptSdkToUrlQuery", () => { }), ).toBe("lastname_eq=doe&status_in=approved&status_in=cancelled") }) + + test("should map a groupedPredicates option SDK predicate back to its virtual URL param", () => { + expect( + adaptSdkToUrlQuery({ + sdkFilters: { + quantity_eq: "0", + status_in: "approved", + }, + instructions, + }), + ).toBe("quantity_filter=empty&status_in=approved") + }) + + test("should map multiple groupedPredicates option SDK predicates back to their virtual URL params", () => { + expect( + adaptSdkToUrlQuery({ + sdkFilters: { + quantity_gteq: "1", + status_in: "approved", + }, + instructions, + }), + ).toBe("quantity_filter=has_items&status_in=approved") + }) }) diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/adaptSdkToUrlQuery.ts b/packages/app-elements/src/ui/resources/useResourceFilters/adaptSdkToUrlQuery.ts index 28628c4a..24ce2e29 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/adaptSdkToUrlQuery.ts +++ b/packages/app-elements/src/ui/resources/useResourceFilters/adaptSdkToUrlQuery.ts @@ -1,6 +1,9 @@ import type { QueryFilter } from "@commercelayer/sdk" import queryString from "query-string" -import type { FiltersInstructions } from "#ui/resources/useResourceFilters/types" +import { + type FiltersInstructions, + isGroupedPredicates, +} from "#ui/resources/useResourceFilters/types" import { adaptUrlQueryToUrlQuery } from "./adaptUrlQueryToUrlQuery" export interface AdaptSdkToUrlQueryParams { @@ -14,7 +17,11 @@ export function adaptSdkToUrlQuery({ predicateWhitelist, instructions, }: AdaptSdkToUrlQueryParams): string { - const sdkFiltersWithArrayValues = Object.entries(sdkFilters).reduce( + // Invert groupedPredicates option predicates back to their virtual URL param keys + // before entering the standard URL pipeline, otherwise they would be dropped as unknown. + const resolvedFilters = invertGroupedPredicates(sdkFilters, instructions) + + const sdkFiltersWithArrayValues = Object.entries(resolvedFilters).reduce( (acc, [key, value]) => { return { ...acc, @@ -35,3 +42,66 @@ export function adaptSdkToUrlQuery({ instructions, }) } + +/** + * Converts SDK predicates produced by `groupedPredicates` options back to the + * corresponding virtual URL param key + option value, so the result can be + * understood by `adaptUrlQueryToUrlQuery`. + * + * Example: an instruction with `urlParamKey: "availability"` has an option + * `{ value: "in_stock", sdk: { predicate: "quantity_gteq", value: "1" } }`. + * When `sdkFilters` contains `{ quantity_gteq: "1" }`, this returns + * `{ availability: "in_stock" }` โ€” removing `quantity_gteq` from the result. + */ +function invertGroupedPredicates( + sdkFilters: QueryFilter, + instructions: FiltersInstructions, +): QueryFilter { + const groupedInstructions = instructions.filter(isGroupedPredicates) + if (groupedInstructions.length === 0) return sdkFilters + + const consumedSdkKeys = new Set() + const virtualParams: Record = {} + + for (const instruction of groupedInstructions) { + const matchedOptionValues: string[] = [] + const matchedSdkPredicates: string[] = [] + + for (const option of instruction.render.props.options) { + const sdkValue = sdkFilters[option.sdk.predicate] + if (sdkValue != null && String(sdkValue) === option.sdk.value) { + matchedOptionValues.push(option.value) + matchedSdkPredicates.push(option.sdk.predicate) + } + } + + if (matchedOptionValues.length === 0) continue + + const isSingleMode = instruction.render.props.mode === "single" + + // In single mode, multiple matches cannot be represented without losing + // information. Leave the original SDK predicates untouched instead. + if (isSingleMode && matchedOptionValues.length > 1) continue + + const [firstValue] = matchedOptionValues + if (firstValue == null) continue + + for (const predicate of matchedSdkPredicates) { + consumedSdkKeys.add(predicate) + } + + // Single mode: only one option can be active at a time + // Multi mode: multiple options can be active simultaneously + virtualParams[instruction.urlParamKey] = isSingleMode + ? firstValue + : matchedOptionValues + } + + if (consumedSdkKeys.size === 0) return sdkFilters + + // Strip the consumed SDK keys and inject the virtual URL params in their place + return Object.entries(sdkFilters).reduce((acc, [key, value]) => { + if (consumedSdkKeys.has(key)) return acc + return { ...acc, [key]: value } + }, virtualParams) +} diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/adaptUrlQueryToFormValues.test.ts b/packages/app-elements/src/ui/resources/useResourceFilters/adaptUrlQueryToFormValues.test.ts index e4faf21f..6a401f34 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/adaptUrlQueryToFormValues.test.ts +++ b/packages/app-elements/src/ui/resources/useResourceFilters/adaptUrlQueryToFormValues.test.ts @@ -14,6 +14,7 @@ describe("adaptUrlQueryToFormValues", () => { status_in: ["cancelled"], payment_status_eq: undefined, fulfillment_status_in: [], + quantity_filter: undefined, archived_at_null: undefined, timePreset: undefined, timeFrom: undefined, @@ -40,6 +41,7 @@ describe("adaptUrlQueryToFormValues", () => { status_in: [], payment_status_eq: undefined, fulfillment_status_in: [], + quantity_filter: undefined, archived_at_null: undefined, timePreset: undefined, timeFrom: undefined, @@ -68,6 +70,7 @@ describe("adaptUrlQueryToFormValues", () => { status_in: [], payment_status_eq: undefined, fulfillment_status_in: [], + quantity_filter: undefined, archived_at_null: undefined, timePreset: undefined, timeFrom: undefined, @@ -94,6 +97,7 @@ describe("adaptUrlQueryToFormValues", () => { status_in: ["approved"], payment_status_eq: undefined, fulfillment_status_in: [], + quantity_filter: undefined, archived_at_null: undefined, timePreset: undefined, timeFrom: undefined, @@ -120,6 +124,7 @@ describe("adaptUrlQueryToFormValues", () => { status_in: [], payment_status_eq: undefined, fulfillment_status_in: [], + quantity_filter: undefined, archived_at_null: undefined, timePreset: undefined, timeFrom: undefined, @@ -147,6 +152,7 @@ describe("adaptUrlQueryToFormValues", () => { status_in: ["placed"], payment_status_eq: undefined, fulfillment_status_in: [], + quantity_filter: undefined, archived_at_null: undefined, timePreset: undefined, timeFrom: undefined, @@ -174,6 +180,7 @@ describe("adaptUrlQueryToFormValues", () => { status_in: ["placed"], payment_status_eq: undefined, fulfillment_status_in: [], + quantity_filter: undefined, archived_at_null: undefined, timePreset: undefined, timeFrom: undefined, @@ -189,6 +196,60 @@ describe("adaptUrlQueryToFormValues", () => { }) }) + test("should parse a groupedPredicates option value from the query string", () => { + expect( + adaptUrlQueryToFormValues({ + queryString: "quantity_filter=has_items", + instructions, + }), + ).toStrictEqual({ + market_id_in: [], + status_in: [], + payment_status_eq: undefined, + fulfillment_status_in: [], + quantity_filter: "has_items", + archived_at_null: undefined, + timePreset: undefined, + timeFrom: undefined, + timeTo: undefined, + name_eq: undefined, + number_or_email_cont: undefined, + viewTitle: undefined, + total_amount_cents: { + from: undefined, + to: undefined, + currencyCode: undefined, + }, + }) + }) + + test("should discard an invalid groupedPredicates option value from the query string", () => { + expect( + adaptUrlQueryToFormValues({ + queryString: "quantity_filter=not_a_valid_option", + instructions, + }), + ).toStrictEqual({ + market_id_in: [], + status_in: [], + payment_status_eq: undefined, + fulfillment_status_in: [], + quantity_filter: undefined, + archived_at_null: undefined, + timePreset: undefined, + timeFrom: undefined, + timeTo: undefined, + name_eq: undefined, + number_or_email_cont: undefined, + viewTitle: undefined, + total_amount_cents: { + from: undefined, + to: undefined, + currencyCode: undefined, + }, + }) + }) + test("should handle partial currency range values", () => { expect( adaptUrlQueryToFormValues({ @@ -201,6 +262,7 @@ describe("adaptUrlQueryToFormValues", () => { status_in: ["placed"], payment_status_eq: undefined, fulfillment_status_in: [], + quantity_filter: undefined, archived_at_null: undefined, timePreset: undefined, timeFrom: undefined, diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/adaptUrlQueryToFormValues.ts b/packages/app-elements/src/ui/resources/useResourceFilters/adaptUrlQueryToFormValues.ts index dbf8320d..1b9a6a71 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/adaptUrlQueryToFormValues.ts +++ b/packages/app-elements/src/ui/resources/useResourceFilters/adaptUrlQueryToFormValues.ts @@ -5,6 +5,7 @@ import { type FiltersInstructions, type FormFullValues, filterableTimeRangePreset, + getInstructionKey, isCurrencyRange, type UiFilterName, type UiFilterValue, @@ -34,7 +35,7 @@ export function adaptUrlQueryToFormValues< const allowedQueryParams = [ ...instructions .filter((item) => !isCurrencyRange(item)) - .map((item) => item.sdk.predicate), + .map((item) => getInstructionKey(item)), ...predicateWhitelist, // ...currencyRangeFieldKeys ] @@ -101,7 +102,7 @@ export function adaptUrlQueryToFormValues< const formValues = allowedQueryParams.reduce( (formValues, key) => { const instructionItem = instructions.find( - (item) => item.sdk.predicate === key, + (item) => getInstructionKey(item) === key, ) if (instructionItem == null) { @@ -163,6 +164,47 @@ export function adaptUrlQueryToFormValues< } } + // single grouped predicates option: store one option value in URL + if ( + instructionItem.type === "groupedPredicates" && + instructionItem.render.props.mode === "single" + ) { + const allowedValues = instructionItem.render.props.options.map( + (o) => o.value, + ) + return parsedQuery[key] != null + ? { + ...formValues, + [key]: parseQueryStringValueAsArray( + parsedQuery[key], + allowedValues, + )[0], + } + : { + ...formValues, + [key]: undefined, + } + } + + // multi grouped predicates options: store array of option values in URL + if (instructionItem.type === "groupedPredicates") { + const allowedValues = instructionItem.render.props.options.map( + (o) => o.value, + ) + return parsedQuery[key] != null + ? { + ...formValues, + [key]: parseQueryStringValueAsArray( + parsedQuery[key], + allowedValues, + ), + } + : { + ...formValues, + [key]: [], + } + } + return formValues }, { diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/adaptUrlQueryToSdk.test.ts b/packages/app-elements/src/ui/resources/useResourceFilters/adaptUrlQueryToSdk.test.ts index 08752cf4..377293c4 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/adaptUrlQueryToSdk.test.ts +++ b/packages/app-elements/src/ui/resources/useResourceFilters/adaptUrlQueryToSdk.test.ts @@ -66,6 +66,32 @@ describe("adaptUrlQueryToSdk", () => { }) }) + test("should resolve a groupedPredicates option to its sdk predicate", () => { + expect( + adaptUrlQueryToSdk({ + queryString: "quantity_filter=has_items", + instructions, + }), + ).toStrictEqual({ + quantity_gteq: "1", + archived_at_null: true, + status_in: "placed,approved,cancelled", + }) + }) + + test("should resolve a different groupedPredicates option to its own sdk predicate", () => { + expect( + adaptUrlQueryToSdk({ + queryString: "quantity_filter=empty", + instructions, + }), + ).toStrictEqual({ + quantity_eq: "0", + archived_at_null: true, + status_in: "placed,approved,cancelled", + }) + }) + test("should consider the query string whitelist", () => { expect( adaptUrlQueryToSdk({ diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/adaptUrlQueryToUrlQuery.test.ts b/packages/app-elements/src/ui/resources/useResourceFilters/adaptUrlQueryToUrlQuery.test.ts index 168da0af..9d750281 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/adaptUrlQueryToUrlQuery.test.ts +++ b/packages/app-elements/src/ui/resources/useResourceFilters/adaptUrlQueryToUrlQuery.test.ts @@ -58,6 +58,24 @@ describe("adaptUrlQueryToUrlQuery", () => { ).toBe("status_in=approved&status_in=cancelled") }) + test("should preserve a valid groupedPredicates urlParamKey through the round-trip", () => { + expect( + adaptUrlQueryToUrlQuery({ + queryString: "quantity_filter=has_items&status_in=approved", + instructions, + }), + ).toBe("quantity_filter=has_items&status_in=approved") + }) + + test("should strip a groupedPredicates urlParamKey with an invalid option value", () => { + expect( + adaptUrlQueryToUrlQuery({ + queryString: "quantity_filter=not_a_valid_option&status_in=approved", + instructions, + }), + ).toBe("status_in=approved") + }) + test("should ignore invalid values", () => { expect( adaptUrlQueryToUrlQuery({ diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/adapters.ts b/packages/app-elements/src/ui/resources/useResourceFilters/adapters.ts index 2eaffbb0..184403bd 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/adapters.ts +++ b/packages/app-elements/src/ui/resources/useResourceFilters/adapters.ts @@ -7,6 +7,7 @@ import { adaptUrlQueryToFormValues as adaptUrlQueryToFormValuesFn } from "./adap import { adaptUrlQueryToSdk as adaptUrlQueryToSdkFn } from "./adaptUrlQueryToSdk" import { adaptUrlQueryToUrlQuery as adaptUrlQueryToUrlQueryFn } from "./adaptUrlQueryToUrlQuery" import type { FiltersInstructions } from "./types" +import { getInstructionKey } from "./types" export const makeFilterAdapters: MakeFiltersAdapters = ({ instructions, @@ -87,9 +88,10 @@ function isValidInstructions(instructions: FiltersInstructions): boolean { const hasReservedTimePresetUiFilterName = instructions.filter( (item) => - (item.sdk.predicate === "timePreset" && item.type !== "timeRange") || - item.sdk.predicate === "timeFrom" || - item.sdk.predicate === "timeTo", + (getInstructionKey(item) === "timePreset" && + item.type !== "timeRange") || + getInstructionKey(item) === "timeFrom" || + getInstructionKey(item) === "timeTo", )?.length > 0 const isInvalid = diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/mockedInstructions.ts b/packages/app-elements/src/ui/resources/useResourceFilters/mockedInstructions.ts index 209749ff..415df2c8 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/mockedInstructions.ts +++ b/packages/app-elements/src/ui/resources/useResourceFilters/mockedInstructions.ts @@ -148,6 +148,29 @@ export const instructions: FiltersInstructions = [ }, }, }, + { + label: "Items quantity", + type: "groupedPredicates", + urlParamKey: "quantity_filter", + render: { + component: "inputToggleButton", + props: { + mode: "single", + options: [ + { + label: "Has items", + value: "has_items", + sdk: { predicate: "quantity_gteq", value: "1" }, + }, + { + label: "Empty", + value: "empty", + sdk: { predicate: "quantity_eq", value: "0" }, + }, + ], + }, + }, + }, { label: "Time range", type: "timeRange", diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/types.ts b/packages/app-elements/src/ui/resources/useResourceFilters/types.ts index 3d176678..f68b4721 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/types.ts +++ b/packages/app-elements/src/ui/resources/useResourceFilters/types.ts @@ -73,6 +73,63 @@ export interface BaseFilterItem { } } +export interface FilterItemGroupedPredicates { + /** + * Label of the filter field in form component + */ + label: string + /** + * Flag to hide the filter field in form component + */ + hidden?: boolean + type: "groupedPredicates" + /** + * The URL query string key used to identify this filter in the URL and form state. + * Unlike other filter types, this is NOT an SDK predicate โ€” it is a virtual key used + * only for URL serialization and react-hook-form state management. + * The actual SDK predicates are defined per-option inside `render.props.options`. + * @example + * ```ts + * urlParamKey: "availability" // โ†’ ?availability=in_stock + * ``` + */ + urlParamKey: string + render: { + /** + * UI component to render + */ + component: "inputToggleButton" + /** + * Props required for the UI component + */ + props: { + mode: "single" | "multi" + /** + * Each option maps to a specific SDK predicate + value pair when selected. + * An option can be hidden from the UI but still be used in the query. + * @example + * ```ts + * [ + * { label: 'Has items', value: 'has_items', sdk: { predicate: 'quantity_gteq', value: '1' } }, + * { label: 'Empty', value: 'empty', sdk: { predicate: 'quantity_eq', value: '0' } }, + * ] + * ``` + */ + options: Array<{ + label: string + /** The value stored in the URL query string and form state */ + value: string + isHidden?: boolean + /** The actual SDK predicate + value this option maps to when selected */ + sdk: { + predicate: string + value: string + } + }> + } + } +} + export type FilterItemOptions = BaseFilterItem & { type: "options" render: @@ -161,6 +218,7 @@ export type FiltersInstructionItem = | FilterItemTextSearch | FilterItemTime | FilterItemCurrencyRange + | FilterItemGroupedPredicates export type FiltersInstructions = FiltersInstructionItem[] @@ -181,3 +239,25 @@ export function isCurrencyRange( ): item is FilterItemCurrencyRange { return item.type === "currencyRange" } + +export function isGroupedPredicates( + item: FiltersInstructionItem, +): item is FilterItemGroupedPredicates { + return item.type === "groupedPredicates" +} + +/** + * Returns the URL query string key / form field name for any instruction item. + * + * - For all standard types (`options`, `textSearch`, `timeRange`, `currencyRange`) this is `item.sdk.predicate`. + * - For `groupedPredicates` it is `item.urlParamKey`, because that type has no single SDK predicate + * (each option maps to its own predicate at the option level). + * + * Use this helper everywhere you need to resolve the URL/form key from an unnarrowed `FiltersInstructionItem` + * instead of accessing `item.sdk.predicate` directly. + */ +export function getInstructionKey(item: FiltersInstructionItem): string { + return item.type === "groupedPredicates" + ? item.urlParamKey + : item.sdk.predicate +} diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/utils.ts b/packages/app-elements/src/ui/resources/useResourceFilters/utils.ts index 62545294..b5ab4244 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/utils.ts +++ b/packages/app-elements/src/ui/resources/useResourceFilters/utils.ts @@ -6,6 +6,7 @@ import type { UiFilterName, UiFilterValue, } from "./types" +import { getInstructionKey } from "./types" /** * Show the filter label with the counter for selected options @@ -55,7 +56,7 @@ export function getActiveFilterCountFromUrl({ }) return instructions.reduce((total, instructionItem) => { - const { predicate } = instructionItem.sdk + const predicate = getInstructionKey(instructionItem) if (instructionItem.hidden === true) { return total