diff --git a/.changeset/26738-filter-builder-held-row.md b/.changeset/26738-filter-builder-held-row.md new file mode 100644 index 00000000000..59fccf92f73 --- /dev/null +++ b/.changeset/26738-filter-builder-held-row.md @@ -0,0 +1,10 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): "Add filter" in the Studio list-view inspector adds a row that can be edited + +Clause-②: no +Metadata-protocol: none + +The `filter-builder` widget (the Filter row of the Studio list-view inspector and of each tab preset) now holds its condition rows until they are complete. It used to rebuild the rows from the stored rules on every render, and the save-side fold drops a row that is not complete yet, so the blank row "Add filter" inserts vanished on the next render and no list-view filter could be authored in Studio. An incomplete row now stays on screen and writes nothing; completing it writes exactly the complete rule. What is saved is unchanged: an incomplete rule still never reaches the view, and a stored filter shows as its rows and round-trips as stored. diff --git a/ui/packages/app-shell/src/views/metadata-admin/widgets.filterBuilderHeldRow-26738.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/widgets.filterBuilderHeldRow-26738.test.tsx new file mode 100644 index 00000000000..96347cbf6c2 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/widgets.filterBuilderHeldRow-26738.test.tsx @@ -0,0 +1,149 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26738 — the `filter-builder` widget HOLDS a condition row until + * it is complete, so "Add filter" adds a row that can be edited. + * + * The widget used to re-derive the builder's rows from the stored rules on + * every render. The fold (`foldFilterGroupToSpecRules`) drops a row that is + * not complete yet — the blank row "Add filter" inserts — and must keep + * dropping it, so the next render of the host erased the new row: the Studio + * list-view inspector could not author a filter at all. + * + * Pinned here, through a host that writes the way the list-view inspector + * does (a NEW body object on every `onChange`, so every write re-renders): + * + * - "Add filter" adds a row that stays on screen, and writes nothing while + * the row is incomplete; + * - completing the row writes exactly the complete rule, and the row stays; + * - a stored filter shows as its rows and round-trips unchanged: opening it + * writes nothing, and its rule reaches the host as stored; + * - CONTROL: a stored filter that changes from outside re-seeds the rows. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { WIDGETS } from './widgets'; +import { loaded } from './loadState'; + +// The builder lives inside a Radix popover, which does not open in jsdom. +// Rendering the popover parts inline is this directory's existing way of +// reaching it (`WidgetContext.catalogUnion.test.tsx`); the rest of the barrel +// stays real. No JSX in the factory: `vi.mock` is hoisted above the imports. +vi.mock('@object-ui/components', async () => { + const actual = await vi.importActual>('@object-ui/components'); + const Pass = ({ children }: { children?: unknown }) => children as never; + return { ...actual, Popover: Pass, PopoverTrigger: Pass, PopoverContent: Pass }; +}); + +afterEach(cleanup); + +const FIELDS = loaded([ + { name: 'title', label: 'Title', type: 'text' }, + { name: 'status', label: 'Status', type: 'text' }, +]); + +type Rule = { field: string; operator: string; value?: unknown }; +type Body = { type: string; filter?: Rule[] }; + +const FilterWidget = WIDGETS['filter-builder']; + +/** + * Writes like `ObjectListViewInspector`'s Filter row: drop `filter` when the + * rules are empty, and store a NEW body either way. + */ +function Host({ initial, writes }: { initial: Body; writes: Array }) { + const [body, setBody] = React.useState(initial); + return ( + <> + { + writes.push(rules as Rule[] | undefined); + const { filter: _dropped, ...rest } = body; + setBody(Array.isArray(rules) && rules.length > 0 ? { ...rest, filter: rules as Rule[] } : { ...rest }); + }} + /> +
{JSON.stringify(body)}
+