From c71cd759d6ff465d44a82937ae2ef7635c22fed7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 21:45:43 +0000 Subject: [PATCH 1/5] fix(plugin-list,components): filter builder starts on the first visible column, drops hidden fields, offers one empty check per null-only type (objectui#11810) ListView hands its FilterBuilder the object definition's field map minus the fields marked hidden (kept only when filterableFields names one or a held condition filters on it), ordered: the view's columns as the grid shows them, other business fields, system fields (isSystemManagedField). "Add filter" seeds on that list's first entry, so a new condition starts on the first visible column instead of the hidden organization_id. FilterBuilder's operator dropdown no longer offers is_null / is_not_null on a column whose type the spec's expandEmptyOperator rates null_only: there is_empty matches exactly the same records in every dialect the builder writes. Text and list-valued columns keep both pairs. The row's own operator stays mounted, so a stored is_null still loads, and operatorsForFieldType (what a row can hold) is unchanged. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/11810-filter-builder-fields.md | 12 ++ ...ter-builder-empty-null-pair-11810.test.tsx | 147 ++++++++++++++ ...ilder-operator-alias-trigger-7561.test.tsx | 7 +- .../components/src/custom/filter-builder.tsx | 77 +++++++- packages/plugin-list/src/ListView.tsx | 61 +++++- .../ListView.filterFieldList-11810.test.tsx | 187 ++++++++++++++++++ 6 files changed, 475 insertions(+), 16 deletions(-) create mode 100644 .changeset/11810-filter-builder-fields.md create mode 100644 packages/components/src/__tests__/filter-builder-empty-null-pair-11810.test.tsx create mode 100644 packages/plugin-list/src/__tests__/ListView.filterFieldList-11810.test.tsx diff --git a/.changeset/11810-filter-builder-fields.md b/.changeset/11810-filter-builder-fields.md new file mode 100644 index 0000000000..ac0d1486c6 --- /dev/null +++ b/.changeset/11810-filter-builder-fields.md @@ -0,0 +1,12 @@ +--- +'@object-ui/plugin-list': patch +'@object-ui/components': patch +--- + +The list filter builder starts on the view's first column, hides hidden fields, and offers one empty check where "empty" and "null" mean the same records (objectui#11810). + +- **Field list.** The list view's Filter panel no longer offers a field the object definition marks `hidden: true` (Organization, Owning Business Unit, the search index). Its fields follow the view's columns in the order the grid shows them, then the other business fields, then the system fields (created / modified / owner). A hidden field stays listed only when the view names it in `filterableFields`, or when a condition the panel already holds filters on it, so a restored filter still shows its field. +- **"Add filter".** A new condition starts on the view's first visible column instead of the hidden Organization field. +- **Empty checks.** On a column whose type cannot hold an empty value other than null (select, lookup, number, date and the other "null only" types of `@objectstack/spec`'s `expandEmptyOperator`), the operator list offers "Is empty" / "Is not empty" and no longer "Is null" / "Is not null": there they match the same records. Text columns and list-valued columns keep both pairs: there "Is empty" also matches blank text, or an empty list. A stored "Is null" condition on such a column still loads and shows as "Is null". Every `FilterBuilder` consumer gets this offer; what a row can hold (`operatorsForFieldType`) is unchanged. + +No export, prop, type member or language-pack key is added. diff --git a/packages/components/src/__tests__/filter-builder-empty-null-pair-11810.test.tsx b/packages/components/src/__tests__/filter-builder-empty-null-pair-11810.test.tsx new file mode 100644 index 0000000000..ad4f22210d --- /dev/null +++ b/packages/components/src/__tests__/filter-builder-empty-null-pair-11810.test.tsx @@ -0,0 +1,147 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11810 — one empty-check pair per column, unless the column's type + * really tells "empty" from "null"; then both pairs, with a hint saying how. + * + * The per-type table is the spec's own (`expandEmptyOperator`, the function + * every server face expands `$empty` with): on a `null_only` type `is_empty` + * matches null and nothing else, which is exactly what `is_null` matches, so + * the dropdown used to ask an end user to choose between two labels for one + * set of records. `text` types count `''` as empty too and list types count + * `[]`, so there the two pairs are two predicates and both stay. + * + * The narrowing is the dropdown's OFFER only. `operatorsForFieldType` — what a + * row can HOLD, which `app-shell`'s dataset read-back and `plugin-list`'s + * parity pin ask — is unchanged, and a stored `is_null` on a `select` column + * still loads and shows as what it is. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { expandEmptyOperator } from '@objectstack/spec/data'; +import { FilterBuilder, operatorsForFieldType } from '../custom/filter-builder'; + +/** One field per type under test, so a row can be pointed at any of them. */ +const TYPES = [ + 'text', 'textarea', 'email', + 'select', 'status', 'lookup', 'user', 'master_detail', + 'number', 'currency', 'date', 'datetime', + 'multiselect', 'tags', +] as const; +const FIELDS = TYPES.map((type) => ({ value: `f_${type}`, label: `F ${type}`, type })); + +afterEach(cleanup); + +function renderRow(condition: Record) { + const onChange = vi.fn(); + // Hoisted out of the JSX: the builder re-seeds on a new `value` identity. + const value = { id: 'root', logic: 'and', conditions: [{ id: 'c1', value: '', ...condition }] }; + render(); + return { onChange }; +} + +/** Open the row's operator dropdown (combobox 1) and read what it mounts. */ +async function openOperatorList(): Promise<{ options: string[]; hint: string | null }> { + fireEvent.keyDown(screen.getAllByRole('combobox')[1], { key: 'ArrowDown' }); + const options = await waitFor(() => { + const found = screen.getAllByRole('option'); + expect(found.length).toBeGreaterThan(0); + return found.map((o) => o.textContent ?? ''); + }); + return { options, hint: screen.queryByTestId('filter-empty-check-hint')?.textContent ?? null }; +} + +const EMPTY_PAIR = ['Is empty', 'Is not empty']; +const NULL_PAIR = ['Is null', 'Is not null']; + +describe('objectui#11810 — the dropdown offers one empty-check pair unless the type distinguishes them', () => { + it('the per-type table this suite expects is the spec’s own (lit control on each arm)', () => { + // Read, not restated: if the spec moves a type between arms, this pin says + // so before the DOM pins below disagree with it for a reason nobody sees. + expect(expandEmptyOperator({ type: 'select' }).arm).toBe('null_only'); + expect(expandEmptyOperator({ type: 'number' }).arm).toBe('null_only'); + expect(expandEmptyOperator({ type: 'lookup' }).arm).toBe('null_only'); + expect(expandEmptyOperator({ type: 'text' }).arm).toBe('text'); + expect(expandEmptyOperator({ type: 'tags' }).arm).toBe('multi_value'); + }); + + for (const type of ['select', 'status', 'lookup', 'user', 'master_detail', 'number', 'currency', 'date', 'datetime'] as const) { + it(`a ${type} column offers "Is empty" / "Is not empty" and no null pair, with no hint`, async () => { + renderRow({ field: `f_${type}`, operator: 'equals' }); + const { options, hint } = await openOperatorList(); + expect(options).toEqual(expect.arrayContaining(EMPTY_PAIR)); + for (const label of NULL_PAIR) expect(options).not.toContain(label); + expect(hint).toBeNull(); + }); + } + + for (const type of ['text', 'textarea', 'email'] as const) { + it(`a ${type} column offers both pairs`, async () => { + renderRow({ field: `f_${type}`, operator: 'equals' }); + const { options } = await openOperatorList(); + expect(options).toEqual(expect.arrayContaining([...EMPTY_PAIR, ...NULL_PAIR])); + }); + } + + for (const type of ['multiselect', 'tags'] as const) { + it(`a ${type} column offers both pairs`, async () => { + renderRow({ field: `f_${type}`, operator: 'equals' }); + const { options } = await openOperatorList(); + expect(options).toEqual(expect.arrayContaining([...EMPTY_PAIR, ...NULL_PAIR])); + }); + } +}); + +describe('objectui#11810 — a stored null check still loads (controls)', () => { + it('a stored `is_null` on a select column shows "Is null" and keeps it mounted', async () => { + renderRow({ field: 'f_select', operator: 'is_null' }); + // The trigger matches against MOUNTED items: an unmounted operator draws + // blank (objectui#4768 / #7561), which is the regression this guards. + expect(screen.getAllByRole('combobox')[1].textContent).toBe('Is null'); + const { options, hint } = await openOperatorList(); + expect(options).toContain('Is null'); + // Only the row's own: its partner is still not offered to a fresh choice. + expect(options).not.toContain('Is not null'); + // Same records as "Is empty" on this column — nothing to explain. + expect(hint).toBeNull(); + }); + + it('a stored camelCase `isNotNull` on a number column loads as "Is not null"', () => { + // The read boundary folds the deprecated spelling (objectui#9306); the + // row-own rule must compare the FOLDED id, or this draws blank. + renderRow({ field: 'f_number', operator: 'isNotNull' }); + expect(screen.getAllByRole('combobox')[1].textContent).toBe('Is not null'); + }); + + it('switching a text row on "Is null" to a select column keeps the row’s operator', async () => { + const { onChange } = renderRow({ field: 'f_text', operator: 'is_null' }); + fireEvent.keyDown(screen.getAllByRole('combobox')[0], { key: 'ArrowDown' }); + const option = await waitFor(() => { + const found = screen.getAllByRole('option').find((o) => o.textContent === 'F select'); + expect(found).toBeTruthy(); + return found!; + }); + fireEvent.click(option); + const row = onChange.mock.calls[onChange.mock.calls.length - 1][0].conditions[0]; + expect(row).toMatchObject({ field: 'f_select', operator: 'is_null' }); + expect(screen.getAllByRole('combobox')[1].textContent).toBe('Is null'); + }); + + it('what a row can HOLD is unchanged: `operatorsForFieldType` still carries both pairs', () => { + // The author-facing callers read this, not the dropdown: the dataset + // inspector's read-back (`builderHolds`) keeps a stored `$null` on a + // select column editable in the visual builder only while it does. + for (const type of ['select', 'lookup', 'number', 'date', 'text']) { + const ids = operatorsForFieldType(type).map((op) => op.value); + expect(ids, type).toEqual(expect.arrayContaining(['is_empty', 'is_not_empty', 'is_null', 'is_not_null'])); + } + }); +}); diff --git a/packages/components/src/__tests__/filter-builder-operator-alias-trigger-7561.test.tsx b/packages/components/src/__tests__/filter-builder-operator-alias-trigger-7561.test.tsx index 4b2b7b6e97..a907da4888 100644 --- a/packages/components/src/__tests__/filter-builder-operator-alias-trigger-7561.test.tsx +++ b/packages/components/src/__tests__/filter-builder-operator-alias-trigger-7561.test.tsx @@ -254,7 +254,10 @@ describe('objectui#7561 — ⛔ the dropdown still emits its own vocabulary', () // // The order is `defaultOperators`' declaration order filtered by the // bucket — NOT the bucket array's own order (`operatorsForFieldType` - // filters the declaration list). Recorded from the base tree. + // filters the declaration list). Recorded from the base tree, less the + // `Is null` / `Is not null` pair objectui#11810 stopped offering on a + // number column: there they match exactly what `Is empty` / `Is not empty` + // match (`filter-builder-empty-null-pair-11810.test.tsx`). renderRow({ field: 'amount', operator: 'gt', value: '5' }); fireEvent.keyDown(screen.getAllByRole('combobox')[1], { key: 'ArrowDown' }); return waitFor(() => { @@ -268,8 +271,6 @@ describe('objectui#7561 — ⛔ the dropdown still emits its own vocabulary', () 'Less than', 'Greater than or equal', 'Less than or equal', - 'Is null', - 'Is not null', ]); }); }); diff --git a/packages/components/src/custom/filter-builder.tsx b/packages/components/src/custom/filter-builder.tsx index 50d3cb3c92..1aa728a39c 100644 --- a/packages/components/src/custom/filter-builder.tsx +++ b/packages/components/src/custom/filter-builder.tsx @@ -15,6 +15,7 @@ import { VIEW_FILTER_PAIR_VALUE_OPERATORS, normalizeFilterOperator, } from "@objectstack/spec/ui" +import { expandEmptyOperator } from "@objectstack/spec/data" import { SchemaRendererContext } from "@object-ui/react" import type { FilterBuilderCondition as AuthoredFilterBuilderCondition, @@ -1179,8 +1180,15 @@ const selectLikeTypes = ["select", "status"] const lookupLikeTypes = ["lookup", "master_detail", "user"] /** - * The operators the dropdown offers for a field of `fieldType`, given the - * opt-in ids this instance was granted. + * The operators a row on a field of `fieldType` can HOLD, given the opt-in ids + * this instance was granted — every operator the dropdown can draw for it. + * + * What the dropdown OFFERS a fresh choice is this set minus the nullness pair + * on a type that cannot tell "empty" from "null" (objectui#11810, see + * {@link offeredOperatorsForRow}). That narrowing is deliberately NOT applied + * here: callers outside this file (`app-shell`'s dataset read-back, + * `plugin-list`'s parity pin) ask "can the builder hold this row", and a + * stored `is_null` on a `select` column still can. * * A pure function rather than a closure so the selection rule — and above all * the {@link OPT_IN_OPERATORS} gate, whose whole job is to keep an operator OFF @@ -1230,6 +1238,61 @@ export function operatorsForFieldType( ) } +/** + * The nullness pair a column offers only when its type can hold an empty + * value that is not null (objectui#11810). + */ +const NULLNESS_PAIR: ReadonlySet = new Set(["is_null", "is_not_null"]) + +/** + * Which row of the spec's ruled 「is empty」 table a column of `fieldType` + * takes — read from `@objectstack/spec`'s `expandEmptyOperator`, the one + * function every server face expands `$empty` with, never from a local type + * list (objectui#11810): + * + * - `text` — text-like types: `is_empty` matches null OR `''`; + * - `multi_value` — list-valued types: `is_empty` matches null OR `[]`; + * - `null_only` — every other type: `is_empty` matches null only, which is + * exactly what `is_null` matches. + * + * Keyed on the TYPE alone because that is all a field descriptor here carries: + * a `lookup` / `user` / `select` declared `multiple: true` is `multi_value` on + * the server and is judged `null_only` here. The pair it is then offered, + * `is_empty` / `is_not_empty`, is the one whose server expansion counts `[]`. + */ +function emptyCheckArm(fieldType: string | undefined) { + return expandEmptyOperator({ type: fieldType || "text" }).arm +} + +/** + * The operators the dropdown MOUNTS for one row (objectui#11810). + * + * {@link operatorsForFieldType}, minus `is_null` / `is_not_null` on a column + * whose type cannot tell empty from null (`null_only`, see + * {@link emptyCheckArm}). There the two pairs are one predicate under two + * labels — every dialect this builder writes expands `is_empty` on such a + * column to "is null" — so an end user was asked to choose between two words + * for the same records. `is_empty` / `is_not_empty` is the pair that stays: it + * is offered on every bucket that has an empty check, so one label means one + * thing across columns. + * + * The row's OWN operator is always mounted. A stored filter that already + * reads `is_null` on a `select` column (a sharing rule's `$null`, a saved + * view's `is_null` rule) must still load as what it is: an unmounted operator + * draws a BLANK trigger (objectui#4768 / #7561), and nothing here rewrites a + * stored spelling (objectui#9306 folds spellings, never predicates). + */ +function offeredOperatorsForRow( + fieldType: string | undefined, + extraOperators: readonly string[] | undefined, + rowOperator: string, +): ReadonlyArray<{ value: string; label: string }> { + const drawable = operatorsForFieldType(fieldType, extraOperators) + if (emptyCheckArm(fieldType) !== "null_only") return drawable + const held = normalizeFilterBuilderOperator(rowOperator) + return drawable.filter((op) => !NULLNESS_PAIR.has(op.value) || op.value === held) +} + /** * Does this row have NO value — the ONE reading of "empty" this component makes * (objectui#4873). @@ -1403,6 +1466,12 @@ function FilterBuilder({ return operatorsForFieldType(field?.type, extraOperators) } + // What the row's operator dropdown mounts — see `offeredOperatorsForRow`. + const getOperatorsForRow = (condition: FilterBuilderCondition) => { + const field = fields.find((f) => f.value === condition.field) + return offeredOperatorsForRow(field?.type, extraOperators, condition.operator) + } + /** * Change a row's field AND, when the new field's bucket no longer offers the * row's operator, reset that operator — re-shaping the value for the family @@ -1920,7 +1989,7 @@ function FilterBuilder({ // keeps its own spelling; only this comparison is folded. value={mountedOperatorValue( condition.operator, - getOperatorsForField(condition.field), + getOperatorsForRow(condition), )} // Radix hands back the `value` of a mounted `SelectItem`, // and every one mounted below is a builder id; the guard is @@ -1933,7 +2002,7 @@ function FilterBuilder({ - {getOperatorsForField(condition.field).map((op) => ( + {getOperatorsForRow(condition).map((op) => ( {t(`filterBuilder.operators.${op.value}`)} diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 7ae9512282..c9e46bae7c 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -19,7 +19,7 @@ import { SchemaRenderer, useNavigationOverlay, classifyLoadError, usePredicateSc import type { LoadErrorKind } from '@object-ui/react'; import { useDensityMode, resolveInlineAriaProps } from '@object-ui/react'; import type { ListViewSchema, ObjectMapConfig } from '@object-ui/types'; -import { detectStatusField } from '@object-ui/types'; +import { detectStatusField, isSystemManagedField } from '@object-ui/types'; import { usePullToRefresh } from '@object-ui/mobile'; import { resolveConditionalFormatting, buildExpandFields, buildExportFileName, resolveEffectiveCrudAffordances, isObjectInlineEditable, partitionRowsByPredicate, normalizeListViewSchema, isListViewVisualization, rowHeightToDensityMode, mergeFilterNodes, FilterOperatorError, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, PLATFORM_RECORD_COLUMNS, EXPANDABLE_FIELD_TYPES, UNMATERIALIZED_FIELD_TYPES, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort } from '@object-ui/core'; import { useObjectLabel, useSafeFieldLabel, createSafeTranslation, useDisplayLocale, pickLocalized } from '@object-ui/i18n'; @@ -3964,16 +3964,59 @@ export const ListView = React.forwardRef(({ }, [objectDef, schema.columns, schema.objectName, tFieldLabel, translateOptions]); /** - * The FILTER builder's candidates: the view's `filterableFields` whitelist, - * applied to the full set. Behaviour is unchanged by objectui#4243 — the - * whitelist simply moved out of the shared memo into the one builder it was - * authored for, so widening the SORT picker cannot widen this. + * The FILTER builder's candidates, in the order its field list draws them. + * + * Membership: the view's `filterableFields` whitelist, applied to the full + * set (objectui#4243 moved it out of the shared memo into the one builder it + * was authored for, so widening the SORT picker cannot widen this). + * + * objectui#11810 — the object definition is served with the injected system + * columns FIRST, and this list used to be that map verbatim: the builder's + * field list led with Organization, Created By, Owner …, and "Add filter" + * (which seeds a row on the list's first entry) started every condition on + * the HIDDEN `organization_id`. Now: + * + * - a field the definition marks `hidden: true` (`organization_id`, + * `owning_business_unit_id`, the `__search` companion) is not offered — + * unless the author named it in `filterableFields`, or a condition the + * panel already holds filters on it, which would otherwise draw a BLANK + * field trigger (a filter restored from the per-user cache, saved back + * when "Add filter" still defaulted to Organization); + * - the order is this view's columns, in the order the grid shows them + * (`effectiveFields`), then every other business field, then the system + * fields (the shared `isSystemManagedField`, which reads the spec's + * `system` flag), then a hidden field kept by the rule above; within a + * tier the definition's own order holds. + * + * So "Add filter" starts on the view's first visible column — no default is + * chosen here or in the builder beyond "the first entry of this list". */ const filterFields = React.useMemo(() => { - if (!schema.filterableFields || schema.filterableFields.length === 0) return candidateFields; - const allowed = new Set(schema.filterableFields); - return candidateFields.filter(f => allowed.has(f.value)); - }, [candidateFields, schema.filterableFields]); + const whitelist = + schema.filterableFields && schema.filterableFields.length > 0 + ? new Set(schema.filterableFields) + : undefined; + const defs: Record | undefined = objectDef?.fields; + const isHidden = (name: string) => defs?.[name]?.hidden === true; + const held = new Set((currentFilters.conditions ?? []).map((c) => c.field)); + const columnRank = new Map(); + for (const column of effectiveFields) { + const name = columnIdentity(column); + if (name && !columnRank.has(name)) columnRank.set(name, columnRank.size); + } + // 0: a column of this view · 1: another business field · 2: a system + // field · 3: a hidden field kept only because it was named. + const tierOf = (name: string) => + isHidden(name) ? 3 : columnRank.has(name) ? 0 : isSystemManagedField(name, defs?.[name]) ? 2 : 1; + return candidateFields + .filter((f) => (!whitelist || whitelist.has(f.value)) && (!isHidden(f.value) || !!whitelist || held.has(f.value))) + .map((field, index) => ({ field, index, tier: tierOf(field.value) })) + .sort((a, b) => + a.tier - b.tier || + (a.tier === 0 ? columnRank.get(a.field.value)! - columnRank.get(b.field.value)! : a.index - b.index), + ) + .map(({ field }) => field); + }, [candidateFields, currentFilters.conditions, effectiveFields, objectDef, schema.filterableFields]); // Sort candidates: ALL fields the view can name, minus the ones the sort // cannot honestly reach (objectui#4243 — previously ⊂ filter candidates). diff --git a/packages/plugin-list/src/__tests__/ListView.filterFieldList-11810.test.tsx b/packages/plugin-list/src/__tests__/ListView.filterFieldList-11810.test.tsx new file mode 100644 index 0000000000..4b4003c4fe --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.filterFieldList-11810.test.tsx @@ -0,0 +1,187 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11810 — the field list `ListView` hands its filter builder. + * + * Reported on the showcase Tasks list: "Add filter" started every condition on + * Organization (`organization_id`, `hidden: true`), and the 25-entry field list + * led with the injected system columns — Organization, Created By, Last + * Modified At, Last Modified By, Owner, Owning Business Unit — before Title, + * hidden ones (`owning_business_unit_id`, the `__search` companion) included. + * The list was the object definition's field map verbatim, and the builder + * seeds a new row on that list's first entry. + * + * The fixture is the served Tasks definition rebuilt from its sources rather + * than typed by hand: the injected columns are the spec's own + * `injectedSystemColumnDefs` (the table `applySystemFields` spreads), in the + * order the report observed them ahead of the declared fields; the declared + * fields are `task.object.ts`'s, with the author's own `created_at`; the + * companion is the `hidden` + `system` text column the engine provisions. Its + * size is the report's 25 — pinned, so a drift in either source is loud. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { injectedSystemColumnDefs } from '@objectstack/spec/data'; +import type { DataSource, ListViewSchema } from '@object-ui/types'; +import { SchemaRendererProvider } from '@object-ui/react'; + +type Candidate = { value: string; label: string; type: string }; + +// Every field list `ListView` hands the builder, while the REAL builder still +// renders — the "Add filter" pin below drives it. +const captured = vi.hoisted(() => ({ fields: [] as Candidate[][] })); +vi.mock('@object-ui/components', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + FilterBuilder: (props: React.ComponentProps) => { + captured.fields.push((props.fields ?? []) as Candidate[]); + return ; + }, + }; +}); + +import { ListView } from '../ListView'; + +/** The declared fields of the showcase Tasks object, in declaration order. */ +const DECLARED: Record> = { + title: { type: 'text', label: 'Title' }, + project: { type: 'master_detail', label: 'Project', reference: 'showcase_project' }, + assignee: { type: 'text', label: 'Assignee' }, + status: { type: 'select', label: 'Status', options: [{ label: 'Backlog', value: 'backlog' }] }, + priority: { type: 'select', label: 'Priority', options: [{ label: 'Low', value: 'low' }] }, + estimate_hours: { type: 'number', label: 'Estimate (h)' }, + progress: { type: 'progress', label: 'Progress' }, + done: { type: 'boolean', label: 'Done' }, + due_date: { type: 'date', label: 'Due Date' }, + start_date: { type: 'date', label: 'Start Date' }, + end_date: { type: 'date', label: 'End Date' }, + created_at: { type: 'datetime', label: 'Created At' }, + location: { type: 'location', label: 'Work Location' }, + cover: { type: 'image', label: 'Cover Image' }, + labels: { type: 'tags', label: 'Labels' }, + notes: { type: 'textarea', label: 'Notes' }, + sync_status: { type: 'select', label: 'Sync Status', options: [{ label: 'Synced', value: 'synced' }] }, + sync_error: { type: 'textarea', label: 'Sync Error' }, +}; + +/** The served map: injected columns first (the author's `created_at` wins its own slot), then declared, then the companion. */ +function servedTaskFields(): Record> { + const injected = injectedSystemColumnDefs({ name: 'showcase_task', fields: DECLARED }); + const ahead = Object.fromEntries(Object.entries(injected).filter(([name]) => !(name in DECLARED))); + return { + ...ahead, + ...DECLARED, + __search: { type: 'text', label: 'Search Index', hidden: true, system: true, readonly: true }, + }; +} + +/** The showcase Tasks default list's columns (`task.view.ts`). */ +const TASK_COLUMNS = ['title', 'project', 'assignee', 'status', 'priority', 'due_date', 'progress']; + +const HIDDEN = ['organization_id', 'owning_business_unit_id', '__search']; +const BUSINESS_REST = ['estimate_hours', 'done', 'start_date', 'end_date', 'location', 'cover', 'labels', 'notes', 'sync_status', 'sync_error']; +const SYSTEM = ['created_by', 'updated_at', 'updated_by', 'owner_id', 'created_at']; + +function mount(schemaExtra: Record = {}, props: Record = {}) { + const dataSource = { + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'showcase_task', fields: servedTaskFields() }), + }; + const schema = { + type: 'list-view', + objectName: 'showcase_task', + viewType: 'grid', + columns: TASK_COLUMNS.map((field) => ({ field })), + ...schemaExtra, + } as unknown as ListViewSchema; + render( + + + , + ); +} + +/** Open the Filter popover and wait for the list built from the DEFINITION (not the columns-only fallback). */ +async function openFilterFieldList(): Promise { + fireEvent.click(screen.getByRole('button', { name: /^filter/i })); + await screen.findByText('Filter Records'); + await waitFor(() => { + const latest = captured.fields[captured.fields.length - 1] ?? []; + // `estimate_hours` is no column, so seeing it proves the definition loaded. + expect(latest.some((f) => f.value === 'estimate_hours')).toBe(true); + }); + return captured.fields[captured.fields.length - 1].map((f) => f.value); +} + +function filterPanel() { + return screen.getByText('Filter Records').closest('[role="dialog"]') as HTMLElement; +} + +afterEach(() => { + cleanup(); + captured.fields.length = 0; +}); + +describe('objectui#11810 — the filter field list: no hidden field, the view’s columns first, system fields last', () => { + it('the fixture is the reported shape: 25 fields, hidden system columns first (lit control)', () => { + const served = servedTaskFields(); + expect(Object.keys(served)).toHaveLength(25); + expect(Object.keys(served)[0]).toBe('organization_id'); + for (const name of HIDDEN) expect(served[name]?.hidden, name).toBe(true); + }); + + it('offers no hidden field, and orders columns → other business fields → system fields', async () => { + mount(); + const list = await openFilterFieldList(); + for (const name of HIDDEN) expect(list, name).not.toContain(name); + expect(list).toEqual([...TASK_COLUMNS, ...BUSINESS_REST, ...SYSTEM]); + }); + + it('"Add filter" starts the new condition on the view’s first visible column', async () => { + mount(); + await openFilterFieldList(); + fireEvent.click(within(filterPanel()).getByRole('button', { name: /add filter/i })); + await waitFor(() => { + expect(within(filterPanel()).getAllByRole('combobox')[0].textContent).toBe('Title'); + }); + }); + + it('follows the order the grid shows its columns in (`fieldOrder`), not the authored one', async () => { + mount({ fieldOrder: ['due_date', 'title'] }); + const list = await openFilterFieldList(); + expect(list.slice(0, TASK_COLUMNS.length)).toEqual(['due_date', 'title', 'project', 'assignee', 'status', 'priority', 'progress']); + }); + + it('keeps a hidden field a held condition filters on, last, so the restored row still names it', async () => { + mount({}, { + initialFilters: { id: 'root', logic: 'and', conditions: [{ id: 'c1', field: 'organization_id', operator: 'equals', value: '' }] }, + }); + const list = await openFilterFieldList(); + expect(list[list.length - 1]).toBe('organization_id'); + expect(list).not.toContain('owning_business_unit_id'); + expect(within(filterPanel()).getAllByRole('combobox')[0].textContent).toBe('Organization'); + }); + + it('keeps a hidden field the author named in `filterableFields`', async () => { + mount({ filterableFields: ['owning_business_unit_id', 'estimate_hours', 'title'] }); + fireEvent.click(screen.getByRole('button', { name: /^filter/i })); + await screen.findByText('Filter Records'); + await waitFor(() => { + const latest = (captured.fields[captured.fields.length - 1] ?? []).map((f) => f.value); + expect(latest).toEqual(['title', 'estimate_hours', 'owning_business_unit_id']); + }); + }); +}); From cedb17c32ffc5ae2c90eb0687128ab11f4cf0cf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 21:45:56 +0000 Subject: [PATCH 2/5] feat(components,i18n): say how "Is empty" and "Is null" differ where a column keeps both (objectui#11810) On a text-like or list-valued column the operator dropdown keeps both empty-check pairs, and now closes with one line saying how they differ: "Is empty" also matches blank text (or an empty list); "Is null" matches only a missing value. The arm is the spec's expandEmptyOperator; the operator labels are interpolated, not restated. New language-pack keys filterBuilder.emptyCheckHint.text / .list in all ten packs; the de quote-pairing census moves 71 -> 75 for their spans. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/11810-filter-builder-fields.md | 5 ++- ...ter-builder-empty-null-pair-11810.test.tsx | 13 +++++-- .../components/src/custom/filter-builder.tsx | 37 +++++++++++++++++++ .../__tests__/de-quote-pairing-3876.test.ts | 11 ++++-- packages/i18n/src/locales/ar.ts | 4 ++ packages/i18n/src/locales/de.ts | 4 ++ packages/i18n/src/locales/en.ts | 4 ++ packages/i18n/src/locales/es.ts | 4 ++ packages/i18n/src/locales/fr.ts | 4 ++ packages/i18n/src/locales/ja.ts | 4 ++ packages/i18n/src/locales/ko.ts | 4 ++ packages/i18n/src/locales/pt.ts | 4 ++ packages/i18n/src/locales/ru.ts | 4 ++ packages/i18n/src/locales/zh.ts | 4 ++ 14 files changed, 97 insertions(+), 9 deletions(-) diff --git a/.changeset/11810-filter-builder-fields.md b/.changeset/11810-filter-builder-fields.md index ac0d1486c6..8b934f56f0 100644 --- a/.changeset/11810-filter-builder-fields.md +++ b/.changeset/11810-filter-builder-fields.md @@ -1,12 +1,13 @@ --- '@object-ui/plugin-list': patch '@object-ui/components': patch +'@object-ui/i18n': patch --- The list filter builder starts on the view's first column, hides hidden fields, and offers one empty check where "empty" and "null" mean the same records (objectui#11810). - **Field list.** The list view's Filter panel no longer offers a field the object definition marks `hidden: true` (Organization, Owning Business Unit, the search index). Its fields follow the view's columns in the order the grid shows them, then the other business fields, then the system fields (created / modified / owner). A hidden field stays listed only when the view names it in `filterableFields`, or when a condition the panel already holds filters on it, so a restored filter still shows its field. - **"Add filter".** A new condition starts on the view's first visible column instead of the hidden Organization field. -- **Empty checks.** On a column whose type cannot hold an empty value other than null (select, lookup, number, date and the other "null only" types of `@objectstack/spec`'s `expandEmptyOperator`), the operator list offers "Is empty" / "Is not empty" and no longer "Is null" / "Is not null": there they match the same records. Text columns and list-valued columns keep both pairs: there "Is empty" also matches blank text, or an empty list. A stored "Is null" condition on such a column still loads and shows as "Is null". Every `FilterBuilder` consumer gets this offer; what a row can hold (`operatorsForFieldType`) is unchanged. +- **Empty checks.** On a column whose type cannot hold an empty value other than null (select, lookup, number, date and the other "null only" types of `@objectstack/spec`'s `expandEmptyOperator`), the operator list offers "Is empty" / "Is not empty" and no longer "Is null" / "Is not null": there they match the same records. Text columns and list-valued columns keep both pairs, and the operator list says how they differ ("Is empty" also matches blank text, or an empty list). A stored "Is null" condition on such a column still loads and shows as "Is null". Every `FilterBuilder` consumer gets this offer; what a row can hold (`operatorsForFieldType`) is unchanged. -No export, prop, type member or language-pack key is added. +New language-pack keys: `filterBuilder.emptyCheckHint.text` and `filterBuilder.emptyCheckHint.list`, in all ten packs. No export, prop or type member is added. diff --git a/packages/components/src/__tests__/filter-builder-empty-null-pair-11810.test.tsx b/packages/components/src/__tests__/filter-builder-empty-null-pair-11810.test.tsx index ad4f22210d..81bbe0e9a4 100644 --- a/packages/components/src/__tests__/filter-builder-empty-null-pair-11810.test.tsx +++ b/packages/components/src/__tests__/filter-builder-empty-null-pair-11810.test.tsx @@ -29,6 +29,9 @@ import '@testing-library/jest-dom'; import { expandEmptyOperator } from '@objectstack/spec/data'; import { FilterBuilder, operatorsForFieldType } from '../custom/filter-builder'; +const TEXT_HINT = '"Is empty" also matches blank text; "Is null" matches only a missing value.'; +const LIST_HINT = '"Is empty" also matches an empty list; "Is null" matches only a missing value.'; + /** One field per type under test, so a row can be pointed at any of them. */ const TYPES = [ 'text', 'textarea', 'email', @@ -84,18 +87,20 @@ describe('objectui#11810 — the dropdown offers one empty-check pair unless the } for (const type of ['text', 'textarea', 'email'] as const) { - it(`a ${type} column offers both pairs`, async () => { + it(`a ${type} column offers both pairs and the blank-text hint`, async () => { renderRow({ field: `f_${type}`, operator: 'equals' }); - const { options } = await openOperatorList(); + const { options, hint } = await openOperatorList(); expect(options).toEqual(expect.arrayContaining([...EMPTY_PAIR, ...NULL_PAIR])); + expect(hint).toBe(TEXT_HINT); }); } for (const type of ['multiselect', 'tags'] as const) { - it(`a ${type} column offers both pairs`, async () => { + it(`a ${type} column offers both pairs and the empty-list hint`, async () => { renderRow({ field: `f_${type}`, operator: 'equals' }); - const { options } = await openOperatorList(); + const { options, hint } = await openOperatorList(); expect(options).toEqual(expect.arrayContaining([...EMPTY_PAIR, ...NULL_PAIR])); + expect(hint).toBe(LIST_HINT); }); } }); diff --git a/packages/components/src/custom/filter-builder.tsx b/packages/components/src/custom/filter-builder.tsx index 1aa728a39c..5840b8da19 100644 --- a/packages/components/src/custom/filter-builder.tsx +++ b/packages/components/src/custom/filter-builder.tsx @@ -1133,6 +1133,11 @@ const useSafeFilterTranslation = createSafeTranslation( 'filterBuilder.operators.is_not_null': 'Is not null', 'filterBuilder.operators.exists': 'Is set', 'filterBuilder.operators.notExists': 'Is not set', + // How the two empty checks differ, on the columns that keep both pairs + // (objectui#11810). The operator labels are interpolated rather than + // restated, so the hint names them exactly as the dropdown above it does. + 'filterBuilder.emptyCheckHint.text': '"{{isEmpty}}" also matches blank text; "{{isNull}}" matches only a missing value.', + 'filterBuilder.emptyCheckHint.list': '"{{isEmpty}}" also matches an empty list; "{{isNull}}" matches only a missing value.', // The half-filled range's description, read from the SHARED `validation` // namespace rather than declared as a new `filterBuilder.*` key // (objectui#10061). `{{field}} is required` already exists in all ten packs @@ -1472,6 +1477,37 @@ function FilterBuilder({ return offeredOperatorsForRow(field?.type, extraOperators, condition.operator) } + /** + * The line under the operator list that says how "Is empty" and "Is null" + * differ, for a dropdown that offers both (objectui#11810). Only a column + * whose type can hold an empty value that is not null keeps both pairs, and + * the hint names which empty value the extra pair counts. Nothing at all + * when the dropdown offers at most one of them. + */ + const renderEmptyCheckHint = (condition: FilterBuilderCondition): React.ReactNode => { + const ids = new Set(getOperatorsForRow(condition).map((op) => op.value)) + if (!ids.has("is_empty") || !ids.has("is_null")) return null + const field = fields.find((f) => f.value === condition.field) + const labels = { + isEmpty: t("filterBuilder.operators.is_empty"), + isNull: t("filterBuilder.operators.is_null"), + } + const arm = emptyCheckArm(field?.type) + // A stored `is_null` held on a `null_only` column mounts both, and on that + // column they match the same records: there is no difference to explain. + if (arm === "null_only") return null + return ( +

+ {arm === "text" + ? t("filterBuilder.emptyCheckHint.text", labels) + : t("filterBuilder.emptyCheckHint.list", labels)} +

+ ) + } + /** * Change a row's field AND, when the new field's bucket no longer offers the * row's operator, reset that operator — re-shaping the value for the family @@ -2007,6 +2043,7 @@ function FilterBuilder({ {t(`filterBuilder.operators.${op.value}`)} ))} + {renderEmptyCheckHint(condition)}
diff --git a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index 061f0c24b4..b74c05049a 100644 --- a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -309,7 +309,11 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // 71 once objectui#11695 added `objectActions.deleteConfirmTitle`, the // delete confirmation's title, which quotes the record's name // („{{name}}“) — one interpolated span, runtime data. - expect(okSpans, 'correctly paired spans').toBe(71); + // 75 once objectui#11810 added `filterBuilder.emptyCheckHint.text` and + // `.list`, the filter builder's note on how its two empty checks differ, + // which each quote both operator labels („{{isEmpty}}“ / „{{isNull}}“) — + // four interpolated spans, the dropdown's own labels. + expect(okSpans, 'correctly paired spans').toBe(75); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -349,8 +353,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // objectui#11445 made `search.resultsCount` a count family: `_one` and // `_other` carry one matched pair each and `resultsCountPlural` left. // 71 / 71 / 0 after objectui#11695 added `objectActions.deleteConfirmTitle`, - // one more matched pair. - expect({ open, close, rdq }).toEqual({ open: 71, close: 71, rdq: 0 }); + // one more matched pair. 75 / 75 / 0 after objectui#11810 added the two + // `filterBuilder.emptyCheckHint` values, two matched pairs each. + expect({ open, close, rdq }).toEqual({ open: 75, close: 75, rdq: 0 }); // The durable shape: every „ closed by a “, every surplus “ an English // opener answered by a ”. Survived translating the two English values. expect(close).toBe(open + rdq); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 412fb78213..da8305579b 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -4209,6 +4209,10 @@ const ar = { exists: "محدد", notExists: "غير محدد", }, + emptyCheckHint: { + text: '"{{isEmpty}}" يطابق أيضًا النص الفارغ؛ "{{isNull}}" يطابق القيمة المفقودة فقط.', + list: '"{{isEmpty}}" يطابق أيضًا القائمة الفارغة؛ "{{isNull}}" يطابق القيمة المفقودة فقط.', + }, }, sortBuilder: { sortBy: "ترتيب حسب", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 1ba934913a..e0dc0e9063 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -3874,6 +3874,10 @@ const de = { exists: "Ist gesetzt", notExists: "Ist nicht gesetzt", }, + emptyCheckHint: { + text: '„{{isEmpty}}“ trifft auch auf leeren Text zu; „{{isNull}}“ nur auf einen fehlenden Wert.', + list: '„{{isEmpty}}“ trifft auch auf eine leere Liste zu; „{{isNull}}“ nur auf einen fehlenden Wert.', + }, }, sortBuilder: { sortBy: "Sortieren nach", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 31cb2707c2..9bdadd65ae 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -4411,6 +4411,10 @@ const en = { exists: 'Is set', notExists: 'Is not set', }, + emptyCheckHint: { + text: '"{{isEmpty}}" also matches blank text; "{{isNull}}" matches only a missing value.', + list: '"{{isEmpty}}" also matches an empty list; "{{isNull}}" matches only a missing value.', + }, }, sortBuilder: { sortBy: 'Sort by', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index a906b00fe0..7d56b03fa0 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -3956,6 +3956,10 @@ const es = { exists: "Está definido", notExists: "No está definido", }, + emptyCheckHint: { + text: '«{{isEmpty}}» también incluye texto en blanco; «{{isNull}}» solo un valor ausente.', + list: '«{{isEmpty}}» también incluye una lista vacía; «{{isNull}}» solo un valor ausente.', + }, }, sortBuilder: { sortBy: "Ordenar por", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index febbb84d33..77dbed49e8 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -3953,6 +3953,10 @@ const fr = { exists: "Est défini", notExists: "N'est pas défini", }, + emptyCheckHint: { + text: '« {{isEmpty}} » correspond aussi à un texte vide ; « {{isNull}} » uniquement à une valeur absente.', + list: '« {{isEmpty}} » correspond aussi à une liste vide ; « {{isNull}} » uniquement à une valeur absente.', + }, }, sortBuilder: { sortBy: "Trier par", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 7662fbd17d..b231256f80 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -3876,6 +3876,10 @@ const ja = { exists: "設定済み", notExists: "未設定", }, + emptyCheckHint: { + text: '「{{isEmpty}}」は空白のテキストにも一致します。「{{isNull}}」は値がない場合にのみ一致します。', + list: '「{{isEmpty}}」は空のリストにも一致します。「{{isNull}}」は値がない場合にのみ一致します。', + }, }, sortBuilder: { sortBy: "並べ替え", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 68215b223f..a39b2ba6be 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -3873,6 +3873,10 @@ const ko = { exists: "설정됨", notExists: "설정되지 않음", }, + emptyCheckHint: { + text: '"{{isEmpty}}"은(는) 빈 텍스트도 포함합니다. "{{isNull}}"은(는) 값이 없는 경우만 포함합니다.', + list: '"{{isEmpty}}"은(는) 빈 목록도 포함합니다. "{{isNull}}"은(는) 값이 없는 경우만 포함합니다.', + }, }, sortBuilder: { sortBy: "정렬 기준", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 23987f28c9..c505d031ec 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -3951,6 +3951,10 @@ const pt = { exists: "Está definido", notExists: "Não está definido", }, + emptyCheckHint: { + text: '"{{isEmpty}}" também corresponde a texto em branco; "{{isNull}}" apenas a um valor ausente.', + list: '"{{isEmpty}}" também corresponde a uma lista vazia; "{{isNull}}" apenas a um valor ausente.', + }, }, sortBuilder: { sortBy: "Ordenar por", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 58ef4ed57b..170ac65435 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -4054,6 +4054,10 @@ const ru = { exists: "Задано", notExists: "Не задано", }, + emptyCheckHint: { + text: '«{{isEmpty}}» также находит пустой текст; «{{isNull}}» — только отсутствующее значение.', + list: '«{{isEmpty}}» также находит пустой список; «{{isNull}}» — только отсутствующее значение.', + }, }, sortBuilder: { sortBy: "Сортировать по", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index be4f1d2bbd..57adc770ca 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -3945,6 +3945,10 @@ const zh = { exists: '已设置', notExists: '未设置', }, + emptyCheckHint: { + text: '「{{isEmpty}}」也匹配空白文本;「{{isNull}}」只匹配没有值的记录。', + list: '「{{isEmpty}}」也匹配空列表;「{{isNull}}」只匹配没有值的记录。', + }, }, sortBuilder: { sortBy: '排序依据', From 3c5fe54340802667438971f8e27a11cc805f1973 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 21:49:54 +0000 Subject: [PATCH 3/5] chore(plugin-list,components): type the new field-list read and test row instead of any (objectui#11810) Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../filter-builder-empty-null-pair-11810.test.tsx | 7 ++++--- packages/plugin-list/src/ListView.tsx | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/components/src/__tests__/filter-builder-empty-null-pair-11810.test.tsx b/packages/components/src/__tests__/filter-builder-empty-null-pair-11810.test.tsx index 81bbe0e9a4..2a91d7ffba 100644 --- a/packages/components/src/__tests__/filter-builder-empty-null-pair-11810.test.tsx +++ b/packages/components/src/__tests__/filter-builder-empty-null-pair-11810.test.tsx @@ -27,7 +27,7 @@ import React from 'react'; import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import { expandEmptyOperator } from '@objectstack/spec/data'; -import { FilterBuilder, operatorsForFieldType } from '../custom/filter-builder'; +import { FilterBuilder, operatorsForFieldType, type FilterGroup } from '../custom/filter-builder'; const TEXT_HINT = '"Is empty" also matches blank text; "Is null" matches only a missing value.'; const LIST_HINT = '"Is empty" also matches an empty list; "Is null" matches only a missing value.'; @@ -46,8 +46,9 @@ afterEach(cleanup); function renderRow(condition: Record) { const onChange = vi.fn(); // Hoisted out of the JSX: the builder re-seeds on a new `value` identity. - const value = { id: 'root', logic: 'and', conditions: [{ id: 'c1', value: '', ...condition }] }; - render(); + // Cast: a stored row may carry a spelling the row type does not admit. + const value = { id: 'root', logic: 'and', conditions: [{ id: 'c1', value: '', ...condition }] } as unknown as FilterGroup; + render(); return { onChange }; } diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index c9e46bae7c..f48dfb760e 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -3996,7 +3996,7 @@ export const ListView = React.forwardRef(({ schema.filterableFields && schema.filterableFields.length > 0 ? new Set(schema.filterableFields) : undefined; - const defs: Record | undefined = objectDef?.fields; + const defs: Record | undefined = objectDef?.fields; const isHidden = (name: string) => defs?.[name]?.hidden === true; const held = new Set((currentFilters.conditions ?? []).map((c) => c.field)); const columnRank = new Map(); From 78fb73bfd49a4537e79199e94840cee9e6eec7b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:31:30 +0000 Subject: [PATCH 4/5] =?UTF-8?q?chore(changeset):=20minor=20for=20i18n=20an?= =?UTF-8?q?d=20components=20under=20Clause-=E2=91=A1=20yes=20(objectui#118?= =?UTF-8?q?10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two new language-pack keys widen @object-ui/i18n's published packs, and @object-ui/components ships the hint that reads them, so both declare minor (the precedent objectui#11645 set for added keys). plugin-list's part is a fix and stays patch; the fixed group versions all three together at the group's highest level. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/11810-filter-builder-fields.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/11810-filter-builder-fields.md b/.changeset/11810-filter-builder-fields.md index 8b934f56f0..cab38b5f99 100644 --- a/.changeset/11810-filter-builder-fields.md +++ b/.changeset/11810-filter-builder-fields.md @@ -1,7 +1,7 @@ --- '@object-ui/plugin-list': patch -'@object-ui/components': patch -'@object-ui/i18n': patch +'@object-ui/components': minor +'@object-ui/i18n': minor --- The list filter builder starts on the view's first column, hides hidden fields, and offers one empty check where "empty" and "null" mean the same records (objectui#11810). @@ -10,4 +10,4 @@ The list filter builder starts on the view's first column, hides hidden fields, - **"Add filter".** A new condition starts on the view's first visible column instead of the hidden Organization field. - **Empty checks.** On a column whose type cannot hold an empty value other than null (select, lookup, number, date and the other "null only" types of `@objectstack/spec`'s `expandEmptyOperator`), the operator list offers "Is empty" / "Is not empty" and no longer "Is null" / "Is not null": there they match the same records. Text columns and list-valued columns keep both pairs, and the operator list says how they differ ("Is empty" also matches blank text, or an empty list). A stored "Is null" condition on such a column still loads and shows as "Is null". Every `FilterBuilder` consumer gets this offer; what a row can hold (`operatorsForFieldType`) is unchanged. -New language-pack keys: `filterBuilder.emptyCheckHint.text` and `filterBuilder.emptyCheckHint.list`, in all ten packs. No export, prop or type member is added. +`@object-ui/i18n` gains two language-pack keys in all ten packs, `filterBuilder.emptyCheckHint.text` and `filterBuilder.emptyCheckHint.list`, which carry that hint. No export, prop or type member is added. From 24a52a451bde48583a2ca26861cb906a9cc2526c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 23:18:32 +0000 Subject: [PATCH 5/5] fix(components): raise the @objectstack/spec floor to ^17.5.0 for expandEmptyOperator (objectui#11810) The published entry now imports expandEmptyOperator from @objectstack/spec/data, which the spec first exports in 17.5.0; ^17.0.0 admitted 17.0-17.4, where the import breaks. pnpm-lock.yaml regenerated with pnpm install --lockfile-only: the importer specifier line only. The changeset names the raise. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/11810-filter-builder-fields.md | 2 ++ packages/components/package.json | 2 +- pnpm-lock.yaml | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/11810-filter-builder-fields.md b/.changeset/11810-filter-builder-fields.md index cab38b5f99..909bd2a0e7 100644 --- a/.changeset/11810-filter-builder-fields.md +++ b/.changeset/11810-filter-builder-fields.md @@ -11,3 +11,5 @@ The list filter builder starts on the view's first column, hides hidden fields, - **Empty checks.** On a column whose type cannot hold an empty value other than null (select, lookup, number, date and the other "null only" types of `@objectstack/spec`'s `expandEmptyOperator`), the operator list offers "Is empty" / "Is not empty" and no longer "Is null" / "Is not null": there they match the same records. Text columns and list-valued columns keep both pairs, and the operator list says how they differ ("Is empty" also matches blank text, or an empty list). A stored "Is null" condition on such a column still loads and shows as "Is null". Every `FilterBuilder` consumer gets this offer; what a row can hold (`operatorsForFieldType`) is unchanged. `@object-ui/i18n` gains two language-pack keys in all ten packs, `filterBuilder.emptyCheckHint.text` and `filterBuilder.emptyCheckHint.list`, which carry that hint. No export, prop or type member is added. + +`@object-ui/components` raises its `@objectstack/spec` floor from `^17.0.0` to `^17.5.0`, because its published entry now imports `expandEmptyOperator`, which the spec first exports in 17.5.0. diff --git a/packages/components/package.json b/packages/components/package.json index dc74759a05..455a96ebd6 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -39,7 +39,7 @@ "@object-ui/react-runtime": "workspace:*", "@object-ui/sdui-parser": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0", + "@objectstack/spec": "^17.5.0", "@radix-ui/react-accordion": "^1.2.20", "@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-aspect-ratio": "^1.1.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab3e8fc47e..2c8b158ae6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1006,7 +1006,7 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0 + specifier: ^17.5.0 version: 17.7.0(ai@7.0.65(zod@4.6.5)) '@radix-ui/react-accordion': specifier: ^1.2.20