From 670c7bcab1a7c1d8e10510f5654b0326c6e396b8 Mon Sep 17 00:00:00 2001 From: factmarc10010 Date: Wed, 26 Aug 2026 16:49:56 +0200 Subject: [PATCH 1/3] fix(OneFilterPicker): track nested filter keys when options load async A nested `in` filter only exposed its child filter keys when its options were a literal array, so a filter whose options arrive from a loader resolved to no nested keys at all. The parent then read as empty everywhere it is summarised outside the option list: no active dot in the filter list, no contribution to the "N selected" count or the select-all indeterminate state, nothing cleared by its clear/select-all toggle, and an orphaned child value left behind when its chip is removed. The keys have to be knowable without the options at hand, since the filter list and the chips render before the filter is ever opened. Adds an optional `nestedFilterKeys` to the in-filter options for that, and falls back to the options a previous load already resolved. --- .../OneFilterPicker/OneFilterPicker.tsx | 2 +- .../__test__/nestedAsyncOptions.test.tsx | 147 ++++++++++++++++++ .../OneFilterPicker/components/FilterList.tsx | 2 +- .../filterTypes/InFilter/InFilter.tsx | 12 +- .../components/__tests__/option-utils.test.ts | 103 ++++++++++++ .../InFilter/components/option-utils.ts | 40 ++++- .../filterTypes/InFilter/types.ts | 7 + .../filterTypes/InFilter/useLoadOptions.ts | 13 +- .../internal/getClearedFiltersValue.ts | 2 +- 9 files changed, 315 insertions(+), 13 deletions(-) create mode 100644 packages/react/src/patterns/OneFilterPicker/__test__/nestedAsyncOptions.test.tsx create mode 100644 packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/components/__tests__/option-utils.test.ts diff --git a/packages/react/src/patterns/OneFilterPicker/OneFilterPicker.tsx b/packages/react/src/patterns/OneFilterPicker/OneFilterPicker.tsx index 8176789388..5bffeb8ce7 100644 --- a/packages/react/src/patterns/OneFilterPicker/OneFilterPicker.tsx +++ b/packages/react/src/patterns/OneFilterPicker/OneFilterPicker.tsx @@ -162,7 +162,7 @@ const FiltersRoot = ({ // Also clear nested child filter keys to avoid orphaned values const filterDef = filters?.[key] if (filterDef?.type === "in" && filterDef.options) { - const nestedKeys = collectNestedFilterKeys(filterDef.options) + const nestedKeys = collectNestedFilterKeys(filterDef) nestedKeys.forEach((nestedKey) => { delete newFilters[nestedKey as keyof Definition] }) diff --git a/packages/react/src/patterns/OneFilterPicker/__test__/nestedAsyncOptions.test.tsx b/packages/react/src/patterns/OneFilterPicker/__test__/nestedAsyncOptions.test.tsx new file mode 100644 index 0000000000..106f15256e --- /dev/null +++ b/packages/react/src/patterns/OneFilterPicker/__test__/nestedAsyncOptions.test.tsx @@ -0,0 +1,147 @@ +import userEvent from "@testing-library/user-event" +import "@testing-library/jest-dom/vitest" +import { describe, expect, it, vi } from "vitest" + +import { zeroRender as render, screen, waitFor } from "@/testing/test-utils" + +import type { FiltersDefinition } from "../types" + +import { OneFilterPicker } from "../index" + +/** + * A nested ("in" filter with children) filter whose options arrive from an async + * loader can't have its nested child keys read off the definition. Without them + * the picker treats the parent as empty: no active dot, no count, nothing to + * clear or to drop along with the chip — even though the child key holds values. + * + * `nestedFilterKeys` declares those keys up front so every one of those branches + * works before (and without) the options ever being loaded. + */ +const officeOptions = [ + { + value: "101", + label: "Barcelona HQ", + children: { + filterKey: "space", + options: [ + { value: "1", label: "Floor 1" }, + { value: "2", label: "Floor 2" }, + ], + }, + }, +] + +const asyncDefinition = { + office: { + type: "in", + label: "Office", + options: { + nestedFilterKeys: ["space"], + options: async () => officeOptions, + }, + }, + space: { + type: "in", + label: "Space", + hideSelector: true, + options: { + options: [ + { value: "1", label: "Floor 1" }, + { value: "2", label: "Floor 2" }, + ], + }, + }, +} as const satisfies FiltersDefinition + +const openPicker = async (user: ReturnType) => { + await user.click(screen.getByRole("button", { name: /filters/i })) + await waitFor(() => + expect(screen.getByRole("button", { name: "Office" })).toBeInTheDocument() + ) +} + +describe("OneFilterPicker - nested filters with async options", () => { + it("marks the parent filter as active when only a nested child is selected", async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }) + + render( + + ) + + await openPicker(user) + + expect( + screen.getByRole("button", { + name: "Office", + description: "Active filters: Office", + }) + ).toBeInTheDocument() + }) + + it("counts nested child selections in the parent's selected label", async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }) + + render( + + ) + + await openPicker(user) + await user.click(screen.getByRole("button", { name: "Office" })) + + expect(await screen.findByText("2 selected")).toBeInTheDocument() + }) + + it("clears nested child selections from the parent's select-all toggle", async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }) + const onChange = vi.fn() + + render( + + ) + + await openPicker(user) + await user.click(screen.getByRole("button", { name: "Office" })) + + // With a nested selection the toggle is indeterminate, so it clears. + await user.click( + await screen.findByRole("checkbox", { name: /select all/i }) + ) + await user.click(screen.getByRole("button", { name: /apply filters/i })) + + // Empty values are dropped on apply: an orphaned `space: ["1"]` would survive. + expect(onChange).toHaveBeenCalledWith({}) + }) + + it("drops nested child selections together with the parent chip", async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }) + const onChange = vi.fn() + + render( + + ) + + const removeChip = await screen.findByRole("button", { + name: "Close", + description: /^Office:/, + }) + await user.click(removeChip) + + expect(onChange).toHaveBeenCalledWith({}) + }) +}) diff --git a/packages/react/src/patterns/OneFilterPicker/components/FilterList.tsx b/packages/react/src/patterns/OneFilterPicker/components/FilterList.tsx index 5df6d1e0b3..0829e685ca 100644 --- a/packages/react/src/patterns/OneFilterPicker/components/FilterList.tsx +++ b/packages/react/src/patterns/OneFilterPicker/components/FilterList.tsx @@ -69,7 +69,7 @@ export function FilterList({ for (const [key, filter] of Object.entries(definition)) { if (filter.type === "in" && "options" in filter) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- accessing nested options generically - const nested = collectNestedFilterKeys((filter as any).options) + const nested = collectNestedFilterKeys(filter as any) if (nested.length > 0) map.set(key, nested) } } diff --git a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/InFilter.tsx b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/InFilter.tsx index eaa465d472..a8551361d3 100644 --- a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/InFilter.tsx +++ b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/InFilter.tsx @@ -16,6 +16,7 @@ import { InFilterFlatOption } from "./components/InFilterFlatOption" import { InFilterOptionRow } from "./components/InFilterOptionRow" import { collectNestedFilterKeys, + collectNestedFilterKeysFromOptions, optionMatchesSearch, } from "./components/option-utils" import { InFilterOptionItem, InFilterOptions } from "./types" @@ -167,9 +168,16 @@ export function InFilter({ [hasSource, options, searchTermLower] ) + // The schema alone only knows the nested keys it declares or lists literally; + // the loaded options add the ones that were still unresolved at that point. const nestedFilterKeys = useMemo( - () => collectNestedFilterKeys(schema.options), - [schema.options] + () => [ + ...new Set([ + ...collectNestedFilterKeys(schema), + ...collectNestedFilterKeysFromOptions(options), + ]), + ], + [schema, options] ) const nestedSelectionsCount = useMemo( diff --git a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/components/__tests__/option-utils.test.ts b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/components/__tests__/option-utils.test.ts new file mode 100644 index 0000000000..2d7ac6fdf8 --- /dev/null +++ b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/components/__tests__/option-utils.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest" + +import type { FilterTypeSchema } from "../../../types" +import type { InFilterOptionItem, InFilterOptions } from "../../types" + +import { getCacheKey, loadOptions } from "../../useLoadOptions" +import { collectNestedFilterKeys } from "../option-utils" + +type Schema = FilterTypeSchema> + +const workplaceOptions: InFilterOptionItem[] = [ + { + value: "barcelona", + label: "Barcelona Office", + children: { + filterKey: "workArea", + options: [ + { + value: "floor-1", + label: "Floor 1", + children: { + filterKey: "desk", + options: [{ value: "desk-1", label: "Desk 1" }], + }, + }, + ], + }, + }, + { value: "remote", label: "Remote" }, +] + +describe("collectNestedFilterKeys", () => { + it("walks literal option arrays at every depth", () => { + const schema: Schema = { + label: "Workplace", + options: { options: workplaceOptions }, + } + + expect(collectNestedFilterKeys(schema)).toEqual(["workArea", "desk"]) + }) + + it("returns no keys for a flat filter", () => { + const schema: Schema = { + label: "Department", + options: { options: [{ value: "eng", label: "Engineering" }] }, + } + + expect(collectNestedFilterKeys(schema)).toEqual([]) + }) + + it("reads the keys declared in the schema when options are async", () => { + const schema: Schema = { + label: "Workplace", + options: { + nestedFilterKeys: ["workArea"], + options: async () => workplaceOptions, + }, + } + + expect(collectNestedFilterKeys(schema)).toEqual(["workArea"]) + }) + + it("reads the keys declared in the schema when options come from a source", () => { + const schema: Schema = { + label: "Workplace", + options: { + nestedFilterKeys: ["workArea"], + source: { dataAdapter: { fetchData: async () => ({ records: [] }) } }, + mapOptions: (item: InFilterOptionItem) => item, + }, + } + + expect(collectNestedFilterKeys(schema)).toEqual(["workArea"]) + }) + + it("falls back to the options resolved by a previous load", async () => { + const schema: Schema = { + label: "Workplace (cached)", + options: { cache: true, options: async () => workplaceOptions }, + } + + expect(collectNestedFilterKeys(schema)).toEqual([]) + + await loadOptions(getCacheKey(schema), () => workplaceOptions, true) + + expect(collectNestedFilterKeys(schema)).toEqual(["workArea", "desk"]) + }) + + it("merges the declared keys with the ones found in the options", async () => { + const schema: Schema = { + label: "Workplace (merged)", + options: { + cache: true, + nestedFilterKeys: ["workArea"], + options: async () => workplaceOptions, + }, + } + + await loadOptions(getCacheKey(schema), () => workplaceOptions, true) + + expect(collectNestedFilterKeys(schema)).toEqual(["workArea", "desk"]) + }) +}) diff --git a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/components/option-utils.ts b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/components/option-utils.ts index c82a332c56..9db5be69e0 100644 --- a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/components/option-utils.ts +++ b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/components/option-utils.ts @@ -1,4 +1,6 @@ +import { FilterTypeSchema } from "../../types" import { InFilterOptionItem, InFilterOptions } from "../types" +import { getCacheKey, getCachedOptions } from "../useLoadOptions" /** * Recursively checks whether an option or any of its nested children @@ -36,12 +38,10 @@ export function hasSelectedDescendant( } /** - * Collects all nested child filter keys from an InFilter's options. - * Used to determine if a parent filter should show an active indicator - * when any of its nested children have selections. + * Collects the nested child filter keys reachable from an option tree. */ -export function collectNestedFilterKeys( - filterOptions: InFilterOptions +export function collectNestedFilterKeysFromOptions( + options: InFilterOptionItem[] | undefined ): string[] { const keys = new Set() @@ -54,8 +54,34 @@ export function collectNestedFilterKeys( } } - if ("options" in filterOptions && Array.isArray(filterOptions.options)) { - collect(filterOptions.options) + collect(options ?? []) + + return [...keys] +} + +/** + * All nested child filter keys of an InFilter, used to decide whether a parent + * filter holds selections through its children (active indicator, selected + * count, clearing). + * + * The keys must be knowable without the options at hand, because the filter + * list and the chips render before the filter is ever opened. Three sources, + * in decreasing reliability: the `nestedFilterKeys` declared in the schema, + * literal option arrays, and the options resolved by a previous load. + */ +export function collectNestedFilterKeys( + schema: FilterTypeSchema> +): string[] { + const filterOptions = schema.options + const keys = new Set(filterOptions.nestedFilterKeys ?? []) + + const resolvedOptions = + "options" in filterOptions && Array.isArray(filterOptions.options) + ? filterOptions.options + : getCachedOptions(getCacheKey(schema)) + + for (const key of collectNestedFilterKeysFromOptions(resolvedOptions)) { + keys.add(key) } return [...keys] diff --git a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/types.ts b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/types.ts index 932872ed51..b0f5f71e5e 100644 --- a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/types.ts +++ b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/types.ts @@ -36,6 +36,13 @@ export type InFilterOptionItem = { */ export type InFilterOptions = { cache?: boolean + /** + * Filter keys where nested child selections are stored, declared up front. + * Only needed when `options` is async or comes from a `source`: the picker + * has to know the nested keys to track child selections (active dot, counts, + * clearing) before — or without ever — loading the options. + */ + nestedFilterKeys?: string[] /** * Optional function to resolve labels for specific values without fetching all options. * This is useful when you have a dynamic source and want to avoid fetching all options diff --git a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/useLoadOptions.ts b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/useLoadOptions.ts index 40d6438544..6a628f5c70 100644 --- a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/useLoadOptions.ts +++ b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/useLoadOptions.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react" import { RecordType, useData, useDataSource } from "@/hooks/datasource" -import { InFilterDefinition } from "." +import type { InFilterDefinition } from "." import { FilterTypeSchema } from "../types" import { InFilterOptionItem, InFilterOptions } from "./types" @@ -22,6 +22,17 @@ export function getCacheKey( return JSON.stringify(schema) } +/** + * Resolved options for a schema, if they have been loaded at least once. + * Lets callers outside the filter body (filter list, chip removal) inspect the + * option tree without triggering a load. + */ +export function getCachedOptions( + cacheKey: string +): InFilterOptionItem[] | undefined { + return optionsCache.get(cacheKey) as InFilterOptionItem[] | undefined +} + /** * Cache a label for a specific value in a schema */ diff --git a/packages/react/src/patterns/OneFilterPicker/internal/getClearedFiltersValue.ts b/packages/react/src/patterns/OneFilterPicker/internal/getClearedFiltersValue.ts index 07b58f6a3f..fad5e2ee2f 100644 --- a/packages/react/src/patterns/OneFilterPicker/internal/getClearedFiltersValue.ts +++ b/packages/react/src/patterns/OneFilterPicker/internal/getClearedFiltersValue.ts @@ -18,7 +18,7 @@ export function getClearedFiltersValue( continue } - for (const nestedKey of collectNestedFilterKeys(filter.options)) { + for (const nestedKey of collectNestedFilterKeys(filter)) { clearedFilters[nestedKey as keyof Filters] = [] as unknown as FiltersState[keyof Filters] } From a8f3cf2ca67cd4376c8d12a9bdca095fef4b194e Mon Sep 17 00:00:00 2001 From: factmarc10010 Date: Wed, 26 Aug 2026 17:08:33 +0200 Subject: [PATCH 2/3] chore(OneFilterPicker): trim the nested-filter-keys comments --- .../__test__/nestedAsyncOptions.test.tsx | 11 +++-------- .../filterTypes/InFilter/InFilter.tsx | 2 -- .../InFilter/components/option-utils.ts | 16 +++++----------- .../filterTypes/InFilter/types.ts | 6 ++---- .../filterTypes/InFilter/useLoadOptions.ts | 6 +----- 5 files changed, 11 insertions(+), 30 deletions(-) diff --git a/packages/react/src/patterns/OneFilterPicker/__test__/nestedAsyncOptions.test.tsx b/packages/react/src/patterns/OneFilterPicker/__test__/nestedAsyncOptions.test.tsx index 106f15256e..5c9617e3a2 100644 --- a/packages/react/src/patterns/OneFilterPicker/__test__/nestedAsyncOptions.test.tsx +++ b/packages/react/src/patterns/OneFilterPicker/__test__/nestedAsyncOptions.test.tsx @@ -9,13 +9,9 @@ import type { FiltersDefinition } from "../types" import { OneFilterPicker } from "../index" /** - * A nested ("in" filter with children) filter whose options arrive from an async - * loader can't have its nested child keys read off the definition. Without them - * the picker treats the parent as empty: no active dot, no count, nothing to - * clear or to drop along with the chip — even though the child key holds values. - * - * `nestedFilterKeys` declares those keys up front so every one of those branches - * works before (and without) the options ever being loaded. + * With async options the picker can't walk the tree to find the nested child + * keys, so it used to treat the parent as empty: no active dot, no count, + * nothing to clear or to drop with the chip. `nestedFilterKeys` declares them. */ const officeOptions = [ { @@ -114,7 +110,6 @@ describe("OneFilterPicker - nested filters with async options", () => { await openPicker(user) await user.click(screen.getByRole("button", { name: "Office" })) - // With a nested selection the toggle is indeterminate, so it clears. await user.click( await screen.findByRole("checkbox", { name: /select all/i }) ) diff --git a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/InFilter.tsx b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/InFilter.tsx index a8551361d3..6874db38d1 100644 --- a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/InFilter.tsx +++ b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/InFilter.tsx @@ -168,8 +168,6 @@ export function InFilter({ [hasSource, options, searchTermLower] ) - // The schema alone only knows the nested keys it declares or lists literally; - // the loaded options add the ones that were still unresolved at that point. const nestedFilterKeys = useMemo( () => [ ...new Set([ diff --git a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/components/option-utils.ts b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/components/option-utils.ts index 9db5be69e0..784d94cf39 100644 --- a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/components/option-utils.ts +++ b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/components/option-utils.ts @@ -37,9 +37,7 @@ export function hasSelectedDescendant( return false } -/** - * Collects the nested child filter keys reachable from an option tree. - */ +/** Nested child filter keys reachable from an option tree. */ export function collectNestedFilterKeysFromOptions( options: InFilterOptionItem[] | undefined ): string[] { @@ -60,14 +58,10 @@ export function collectNestedFilterKeysFromOptions( } /** - * All nested child filter keys of an InFilter, used to decide whether a parent - * filter holds selections through its children (active indicator, selected - * count, clearing). - * - * The keys must be knowable without the options at hand, because the filter - * list and the chips render before the filter is ever opened. Three sources, - * in decreasing reliability: the `nestedFilterKeys` declared in the schema, - * literal option arrays, and the options resolved by a previous load. + * Nested child filter keys of an InFilter. The filter list and the chips render + * before the filter is ever opened, so the keys have to be resolvable without + * the options: declared in the schema, listed literally, or left over in the + * cache from a previous load. */ export function collectNestedFilterKeys( schema: FilterTypeSchema> diff --git a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/types.ts b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/types.ts index b0f5f71e5e..9223f82e3d 100644 --- a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/types.ts +++ b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/types.ts @@ -37,10 +37,8 @@ export type InFilterOptionItem = { export type InFilterOptions = { cache?: boolean /** - * Filter keys where nested child selections are stored, declared up front. - * Only needed when `options` is async or comes from a `source`: the picker - * has to know the nested keys to track child selections (active dot, counts, - * clearing) before — or without ever — loading the options. + * Filter keys holding nested child selections. Only needed for async or + * `source` options, which the picker can't walk to discover them. */ nestedFilterKeys?: string[] /** diff --git a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/useLoadOptions.ts b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/useLoadOptions.ts index 6a628f5c70..4757e838b4 100644 --- a/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/useLoadOptions.ts +++ b/packages/react/src/patterns/OneFilterPicker/filterTypes/InFilter/useLoadOptions.ts @@ -22,11 +22,7 @@ export function getCacheKey( return JSON.stringify(schema) } -/** - * Resolved options for a schema, if they have been loaded at least once. - * Lets callers outside the filter body (filter list, chip removal) inspect the - * option tree without triggering a load. - */ +/** Options a previous load resolved for a schema, without triggering one. */ export function getCachedOptions( cacheKey: string ): InFilterOptionItem[] | undefined { From 5a088df424aad1fae80f1d858586194e6e9e10eb Mon Sep 17 00:00:00 2001 From: Albert Prieto Date: Thu, 27 Aug 2026 10:40:05 +0200 Subject: [PATCH 3/3] chore: retrigger stuck CI workflows