From c72d3f1694026c81e89e37e81ec96aa7618ea26f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 12:57:27 +0000 Subject: [PATCH 1/2] feat(app-shell): give the drill URL dialect an is-null operator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drill escape hatch (OpenInListButton, drillDown.target: navigate) reaches the host's openRecordList, which serializes the drill filter into filter[...] search params for the ADR-0055 bare data surface. That dialect had equality plus four range bounds and nothing else, so an empty-bucket drill's condition simply vanished: { stage: 'won', owner: { $null: true } } and the bare null it replaced both produced filter[stage]=won, byte for byte. The surviving condition was the NON-empty one, so escalating an empty bucket to the list page returned a superset, silently, with chips that showed only the conditions that survived. drillUrlFilters now carries the operator on both halves of its one module, as NULL_FILTER: the write side emits filter[][null]=true for { $null: true }, the read side parses it back to [field, 'is_null', true] — the same triple the other two drill consumers already produce — and the chip grouper renders "is null" where it previously fell to the "= " default and showed a bare true. The value is a flag, not data. Only "true" spells the condition: this dialect cannot write "is not null", so [null]=false is dropped like an unknown suffix rather than answered at an operator with no producer, and { $null: false } is dropped on the write side for the same reason. The range maps are untouched on purpose — is_null is already a canonical ViewFilterRule word, and ObjectDataPage inverts URL_FILTER_OPS to bridge triples to the spec's ALIAS spelling, so an entry there would have made "Save as view" drop the condition. The objectui#9085 boundary pin, which recorded this gap as open and was designed to go red the day someone closed it, is updated to the new byte-identity rather than deleted. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa --- .changeset/9159-drill-url-isnull-operator.md | 45 +++++ ...bjectDataPage.saveAsViewFilterFold.test.ts | 33 ++- .../drillEmptyBucketEscapeHatch-9159.test.tsx | 190 ++++++++++++++++++ .../drillEmptyBucketNavHost-9085.test.ts | 81 +++++--- .../src/views/drillUrlFilters.test.ts | 67 ++++++ .../app-shell/src/views/drillUrlFilters.ts | 115 +++++++++-- 6 files changed, 485 insertions(+), 46 deletions(-) create mode 100644 .changeset/9159-drill-url-isnull-operator.md create mode 100644 packages/app-shell/src/views/drillEmptyBucketEscapeHatch-9159.test.tsx diff --git a/.changeset/9159-drill-url-isnull-operator.md b/.changeset/9159-drill-url-isnull-operator.md new file mode 100644 index 0000000000..19d74d71ef --- /dev/null +++ b/.changeset/9159-drill-url-isnull-operator.md @@ -0,0 +1,45 @@ +--- +'@object-ui/app-shell': minor +--- + +The drill "escape hatch" can spell an empty bucket: the `filter[...]` URL dialect grows an +is-null operator on both sides plus a chip for it (objectui#9159). + +`buildDatasetDrillFilter`'s output has three consumers. Two lower it through +`convertFiltersToAST`, where objectui#9085's `{ field: { $null: true } }` becomes +`[field, 'is_null', true]` and the empty bucket selects its own rows. The third is this +one — `OpenInListButton` and `drillDown.target: navigate`, which reach the host's +`openRecordList` and serialize the drill filter into `filter[...]` search params for the +ADR-0055 bare data surface. That dialect had equality plus four range bounds and nothing +else, so the condition simply vanished: `{ stage: 'won', owner: { $null: true } }` and +`{ stage: 'won', owner: null }` produced the byte-identical `filter[stage]=won`, and an +empty-bucket-only drill produced an empty query string. The surviving condition was the +NON-empty one, so drilling into the empty bucket and escalating to the full list page +returned a **superset** — silently, with filter chips showing only the conditions that +survived, so the page looked correctly scoped. + +`drillUrlFilters` now carries the operator on both halves of its one module, as +`NULL_FILTER`: + +- **write** — `{ $null: true }` becomes `filter[][null]=true`, emitted beside any + range bound on the same object because `convertFiltersToAST` emits both conditions for + that input; +- **read** — that param becomes `[field, 'is_null', true]`, the same triple the other two + consumers already produce; +- **chip** — the grouper renders `is null` for it, where it previously fell to the + `= ` default and displayed a bare `true`. + +The value is a FLAG, not a comparand. `filter[][null]=false` is **not** a second +operator: this dialect cannot write "is not null", so the read side drops that param the +way it drops an unknown suffix rather than inventing an operator with no producer — and +equality-to-empty-string remains no condition at all, since a param whose value is empty +was already skipped. `{ $null: false }` is dropped on the write side for the same reason, +degrading to a superset exactly as any other unspellable operator does. + +The range maps are deliberately untouched: `is_null` is already a canonical +`ViewFilterRule` word, and `ObjectDataPage` inverts `URL_FILTER_OPS` to bridge a triple's +operator to the spec's *alias* spelling. An entry there would have sent it through that +bridge as the alias `null`, which the rule schema refuses — a "Save as view" that silently +loses the condition. `ObjectDataPage.saveAsViewFilterFold.test.ts` pins the fold for the +new operator, and `drillEmptyBucketNavHost-9085.test.ts` — which recorded this boundary as +open — is updated to record it closed. diff --git a/packages/app-shell/src/views/ObjectDataPage.saveAsViewFilterFold.test.ts b/packages/app-shell/src/views/ObjectDataPage.saveAsViewFilterFold.test.ts index 8dc7c089c4..091e5ce694 100644 --- a/packages/app-shell/src/views/ObjectDataPage.saveAsViewFilterFold.test.ts +++ b/packages/app-shell/src/views/ObjectDataPage.saveAsViewFilterFold.test.ts @@ -25,7 +25,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { ViewItemSchema } from '@objectstack/spec/ui'; -import { URL_FILTER_OPS, type FilterTriple } from './drillUrlFilters'; +import { URL_FILTER_OPS, NULL_FILTER, type FilterTriple } from './drillUrlFilters'; import { viewEnvelope } from './runtime-metadata-persistence'; import { buildSaveAsViewSpec } from './ObjectDataPage'; @@ -93,11 +93,13 @@ describe('Save as view folds URL drill triples to spec rules (objectui#3419)', ( }); it('folds EVERY operator the URL contract can emit to a canonical spelling', () => { - // Derived from `URL_FILTER_OPS` (plus `=`, which has no `[op]` suffix form) - // so a range operator added to the URL contract fails HERE rather than at - // publish time. `parseUrlFilterTriples` emits nothing outside this set. - const emittable = ['=', ...Object.values(URL_FILTER_OPS)]; - expect(emittable).toEqual(['=', '>=', '<=', '>', '<']); + // Derived from `URL_FILTER_OPS` (plus `=`, which has no `[op]` suffix form, + // and `NULL_FILTER.op`, whose param carries a FLAG rather than a comparand + // and so is not in that range map) so an operator added to the URL contract + // fails HERE rather than at publish time. `parseUrlFilterTriples` emits + // nothing outside this set. + const emittable = ['=', ...Object.values(URL_FILTER_OPS), NULL_FILTER.op]; + expect(emittable).toEqual(['=', '>=', '<=', '>', '<', 'is_null']); const { spec, gate } = saveAsView( emittable.map((op, i) => ['f' + i, op, String(i)] as FilterTriple), @@ -108,10 +110,29 @@ describe('Save as view folds URL drill triples to spec rules (objectui#3419)', ( 'less_than_or_equal', 'greater_than', 'less_than', + // objectui#9159. `is_null` is ALREADY a canonical ViewFilterRule word, so + // it must reach `normalizeFilterOperator` unbridged; the symbol-to-alias + // table is keyed on the range symbols, which this operator is not one of. + // Were the flag added to `URL_FILTER_OPS` instead, the bridge would hand + // over the alias `null`, the rule schema would refuse it, and saving the + // view would silently drop the condition. + 'is_null', ]); expect(gate.success).toBe(true); }); + it('keeps the is-null flag intact through the fold, value and all', () => { + // The escape hatch's empty-bucket drill (objectui#9159) is savable as a + // view: `[field,'is_null',true]` is a canonical rule, and the value rides + // along exactly as `viewFilterFold` carries a value-less operator's value. + const { spec, gate } = saveAsView([['owner', NULL_FILTER.op, true]]); + expect(spec.filter).toEqual([{ field: 'owner', operator: 'is_null', value: true }]); + expect( + gate.success, + `ViewItem rejected by spec: ${JSON.stringify(gate.error?.issues)}`, + ).toBe(true); + }); + it('carries field and value through untouched', () => { // Placeholder resolution has already run upstream; the fold must not // re-interpret what it produced (`''` included — the spec accepts it). diff --git a/packages/app-shell/src/views/drillEmptyBucketEscapeHatch-9159.test.tsx b/packages/app-shell/src/views/drillEmptyBucketEscapeHatch-9159.test.tsx new file mode 100644 index 0000000000..f72e5bdc0d --- /dev/null +++ b/packages/app-shell/src/views/drillEmptyBucketEscapeHatch-9159.test.tsx @@ -0,0 +1,190 @@ +/** + * 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#9159 — the drill escape hatch can finally SPELL "this dimension is + * empty", and these assert it end to end. + * + * `buildDatasetDrillFilter`'s output has three consumers. Two lower it through + * `convertFiltersToAST`, where objectui#9085's `{ field: { $null: true } }` + * becomes `[field, 'is_null', true]` and the empty bucket selects its own rows. + * The third is this one — the host's `openRecordList`, which serializes the same + * object into `filter[...]` params for the ADR-0055 bare data surface. That + * dialect had equality plus four range bounds and nothing else, so the condition + * simply vanished: the empty-bucket drill and the non-empty one produced + * BYTE-IDENTICAL query strings, and escalating an empty bucket to the list page + * landed on a SUPERSET — silently, with chips that showed only the conditions + * that survived, so the page looked correctly scoped + * (`drillEmptyBucketNavHost-9085.test.ts` recorded that boundary; it now records + * the repair). + * + * ## Why the observation point is the DESTINATION SCOPE, not the serializer + * + * The defect's whole signature was that every layer looked healthy — the widget + * rendered, the navigation happened, the list loaded, and only the row set was + * wrong. So the escape hatch is driven for real (`useOpenRecordList` inside a + * router, the same harness `useOpenRecordList.placeholderScope-9022.test.tsx` + * uses) and the assertion is on the triples the bare data surface reads back out + * of the URL via this repo's own `parseUrlFilterTriples` — a round trip, never + * the two operator maps in isolation. A map-only assertion passes on a dialect + * that still cannot express the condition end to end. + * + * The filter under test is built by the real `buildDatasetDrillFilter`, so these + * pin the shape a producer actually hands over rather than a hand-written + * literal no caller emits. + * + * ## The flag is not data, and `false` is not a second operator + * + * `filter[][null]` carries a FLAG: only the exact `true` spelling is the + * is-null condition. `...[null]=false` is NOT "is not null" — the write side + * cannot spell that operator, so the read side refuses to invent it and drops + * the param the same way it drops an unknown suffix, leaving the drill degraded + * to a superset exactly as it was before this card. Pinned below so the read + * side has no unspecified input. + */ + +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; +import type { ReactNode } from 'react'; +import { buildDatasetDrillFilter } from '@object-ui/core'; +import { FilterScopeProvider } from '@object-ui/react'; +import { + parseUrlFilterTriples, + parseUrlEqualityFilterTriples, + groupFilterChips, + NULL_FILTER, +} from './drillUrlFilters'; +import { useOpenRecordList } from './useOpenRecordList'; + +/** The dimension-to-field map a dataset widget passes the drill builder. */ +const DIMENSION_FIELDS = { stage: 'stage', owner: 'owner' }; + +function Wrapper({ children }: { children: ReactNode }) { + return ( + + + + {children}} /> + + + + ); +} + +/** The handler under test, plus wherever it took the browser. */ +function useHarness() { + const openRecordList = useOpenRecordList(); + const { pathname, search } = useLocation(); + return { openRecordList, pathname, search }; +} + +/** Drive the real escape hatch and hand back the URL it navigated to. */ +function drillTo(objectName: string, filter?: Record) { + const { result } = renderHook(useHarness, { wrapper: Wrapper }); + act(() => result.current.openRecordList(objectName, filter)); + return result.current; +} + +/** The scope the destination list will actually run under. */ +const destinationScope = (search: string) => parseUrlFilterTriples(new URLSearchParams(search)); + +describe('the drill escape hatch and the empty bucket (objectui#9159)', () => { + it('an empty bucket clicked alongside a non-empty dimension reaches the list as BOTH conditions', () => { + // What the widget hands over for "stage = won, owner is empty". + const filter = buildDatasetDrillFilter( + { stage: 'won', owner: '' }, + ['stage', 'owner'], + DIMENSION_FIELDS, + ); + expect(filter).toEqual({ stage: 'won', owner: { $null: true } }); + + const { pathname, search } = drillTo('opportunity', filter); + expect(pathname).toBe('/apps/crm/opportunity/data'); + // The defect: this used to be `filter[stage]=won` and nothing else, so the + // list landed scoped by everything EXCEPT the thing the user clicked. + expect(destinationScope(search)).toEqual([ + ['stage', '=', 'won'], + ['owner', 'is_null', true], + ]); + }); + + it('an empty-bucket-ONLY drill is no longer an empty query string', () => { + const filter = buildDatasetDrillFilter({ owner: '' }, ['owner'], DIMENSION_FIELDS); + const { search } = drillTo('opportunity', filter); + + // It used to serialize to nothing at all, which is the widest possible + // answer: every row in the object. + expect(search).not.toBe(''); + expect(destinationScope(search)).toEqual([['owner', 'is_null', true]]); + }); + + it('the flag is spelled `filter[][null]=true` in the URL the user can share', () => { + const filter = buildDatasetDrillFilter({ owner: '' }, ['owner'], DIMENSION_FIELDS); + const { search } = drillTo('opportunity', filter); + + // Decoded rather than compared against the percent-encoded form, so this + // pins the CONTRACT (the param key and its value) and not URLSearchParams' + // encoding of square brackets. + expect(decodeURIComponent(search)).toBe('?filter[owner][null]=true'); + expect(NULL_FILTER).toEqual({ param: 'null', flag: 'true', op: 'is_null', key: '$null' }); + }); + + it('the chip the list renders names the condition instead of showing a bare `true`', () => { + const filter = buildDatasetDrillFilter( + { stage: 'won', owner: '' }, + ['stage', 'owner'], + DIMENSION_FIELDS, + ); + const { search } = drillTo('opportunity', filter); + const chips = groupFilterChips(destinationScope(search)); + + expect(chips).toEqual([ + { field: 'stage', text: '= won' }, + { field: 'owner', text: 'is null' }, + ]); + // The chip grouper renders anything that is not a range as `= `, so + // without its own arm this read `= true` — a condition the user never wrote + // against a value the object does not hold. + expect(chips[1].text).not.toContain('true'); + }); + + it('`[null]=false` is NOT a second operator — it produces no condition at all', () => { + // The write side cannot spell "is not null", so the read side refuses to + // invent it: the param is dropped exactly like an unknown suffix, never + // downgraded to `is_null false` and never to an equality against "false". + expect(parseUrlFilterTriples(new URLSearchParams('filter[owner][null]=false'))).toEqual([]); + // An empty value was already dropped before this card (a param whose value + // is `''` never reaches the suffix arm), so equality-to-empty-string stays + // what it always was: no condition. + expect(parseUrlFilterTriples(new URLSearchParams('filter[owner][null]='))).toEqual([]); + expect(parseUrlFilterTriples(new URLSearchParams('filter[owner]='))).toEqual([]); + }); + + it('CONTROL: a NON-empty drill still reaches the URL unchanged', () => { + const filter = buildDatasetDrillFilter({ owner: 'alice' }, ['owner'], DIMENSION_FIELDS); + const { search } = drillTo('opportunity', filter); + expect(destinationScope(search)).toEqual([['owner', '=', 'alice']]); + }); + + it('CONTROL: the object route still executes NO operator suffix, this flag included (objectui#9196)', () => { + // `/apps/:app/:object` implements equality and nothing else on purpose: + // teaching it operators would widen an addressable public surface, which is + // a behaviour addition and not this card. It drops the flag, as it drops + // every other suffixed form — it does not answer it at the wrong operator. + expect(parseUrlEqualityFilterTriples(new URLSearchParams('filter[owner][null]=true'))).toEqual( + [], + ); + expect(parseUrlEqualityFilterTriples(new URLSearchParams('filter[owner][gte]=1'))).toEqual([]); + // LIT CONTROL: the unsuffixed form still works there, so the empty results + // above are the route's boundary and not a broken parser. + expect(parseUrlEqualityFilterTriples(new URLSearchParams('filter[owner]=alice'))).toEqual([ + ['owner', '=', 'alice'], + ]); + }); +}); diff --git a/packages/app-shell/src/views/drillEmptyBucketNavHost-9085.test.ts b/packages/app-shell/src/views/drillEmptyBucketNavHost-9085.test.ts index 65dc3a76eb..6cebc01e7c 100644 --- a/packages/app-shell/src/views/drillEmptyBucketNavHost-9085.test.ts +++ b/packages/app-shell/src/views/drillEmptyBucketNavHost-9085.test.ts @@ -8,7 +8,8 @@ /** * The drill "escape hatch" and the empty bucket — the THIRD consumer of - * `buildDatasetDrillFilter`, measured for objectui#9085. + * `buildDatasetDrillFilter`, measured for objectui#9085 and REPAIRED by + * objectui#9159. * * `buildDatasetDrillFilter`'s output has three consumers. Two of them * (`ObjectDataTable`, and the report drill's `SchemaRenderer` path, which wraps @@ -18,22 +19,33 @@ * `openRecordList`, which does NOT go through that converter — it serializes * the same object into `filter[...]` URL params for the bare data surface. * - * ## The measured boundary, recorded rather than closed + * ## What objectui#9085 measured here, and why it left it open * - * ⚠️ This dialect has NO spelling for is-null. Its whole operator vocabulary is - * equality plus four range bounds (`URL_FILTER_OPS` / `RANGE_OP_PARAM` name - * them), so there is no param shape that carries "this dimension is empty" and - * no suffix `parseUrlFilterTriples` would read back as one. An empty-bucket - * drill escalated to the list page has therefore ALWAYS landed on a superset, - * and it still does. + * ⚠️ At that time this dialect had NO spelling for is-null. Its whole operator + * vocabulary was equality plus four range bounds, so there was no param shape + * carrying "this dimension is empty" and no suffix `parseUrlFilterTriples` would + * read back as one. `{ $null: true }` and the bare `null` it replaced therefore + * serialized BYTE-IDENTICALLY, an empty-bucket drill escalated to the list page + * had always landed on a superset, and objectui#9085 neither improved nor + * regressed that — which is exactly what this file was written to hold. * - * ⇒ objectui#9085 changes NOTHING here, and that is the claim these assertions - * exist to hold: the new spelling produces the byte-identical query string the - * bare `null` produced, so the escape hatch neither improves nor regresses. - * Closing it needs a URL-dialect operator on BOTH the write and the read side - * (plus a chip rendering for it), which is a separate card — ⛔ not folded in - * here, where it would be an unpinned new URL contract riding along with a - * one-expression producer fix. + * ## What objectui#9159 changed, and why these assertions moved rather than went + * + * That card added the missing operator on BOTH sides of the dialect + * (`NULL_FILTER` — `filter[][null]=true`), so the claim above has stopped + * being true, at the assertions the card said it would: the two spellings are no + * longer byte-identical, and an empty-bucket-only drill no longer serializes to + * nothing. The claim is UPDATED to the new byte-identity rather than deleted, + * because what this file is for is recording what the escape hatch does with an + * empty bucket — first that it dropped it, now that it carries it. + * + * ⇒ the surviving record: the bare `null` spelling is STILL not a condition (a + * producer writes it when it has nothing to say about the field), the RANGE + * vocabulary is still exactly the four bounds it always was, and the divergence + * between the two spellings is the repair. The end-to-end obligations of the new + * operator — the real `openRecordList`, the destination scope, the chip, and the + * answer for `[null]=false` — live in + * `drillEmptyBucketEscapeHatch-9159.test.tsx`. */ import { describe, it, expect } from 'vitest'; @@ -43,6 +55,7 @@ import { parseUrlFilterTriples, URL_FILTER_OPS, RANGE_OP_PARAM, + NULL_FILTER, } from './drillUrlFilters'; const DIMENSION_FIELDS = { stage: 'stage', owner: 'owner' }; @@ -50,30 +63,46 @@ const DIMENSION_FIELDS = { stage: 'stage', owner: 'owner' }; /** What the producer wrote BEFORE objectui#9085 — the shape being replaced. */ const PREVIOUS_EMPTY_SPELLING = null; -describe('drill escape hatch vs the empty bucket (objectui#9085)', () => { - it('the URL dialect has no is-null operator at all', () => { - // Stated as the two exported maps rather than as prose, so the day someone - // adds one, this assertion is where the claim above stops being true. +describe('drill escape hatch vs the empty bucket (objectui#9085, repaired by objectui#9159)', () => { + it('the is-null operator is its own flag and did NOT widen the range vocabulary', () => { + // Stated as the exported constants rather than as prose, so the day any of + // the three moves, this assertion is where the claim above stops being true. + // The two range maps are unchanged by objectui#9159 on purpose: `is_null` is + // already a canonical `ViewFilterRule` word, and `ObjectDataPage` inverts + // `URL_FILTER_OPS` to bridge triples to the spec's ALIAS spelling — an entry + // here would send it through that bridge and lose the condition from a saved + // view. expect(Object.keys(URL_FILTER_OPS)).toEqual(['gte', 'lte', 'gt', 'lt']); expect(Object.keys(RANGE_OP_PARAM)).toEqual(['$gte', '$lte', '$gt', '$lt']); + expect(NULL_FILTER).toEqual({ param: 'null', flag: 'true', op: 'is_null', key: '$null' }); }); - it('the new spelling serializes byte-identically to the bare null it replaced', () => { + it('the new spelling NO LONGER serializes identically to the bare null it replaced', () => { const now = buildDatasetDrillFilter({ stage: 'won', owner: '' }, ['stage', 'owner'], DIMENSION_FIELDS); const before = { stage: 'won', owner: PREVIOUS_EMPTY_SPELLING }; expect(now).toEqual({ stage: 'won', owner: { $null: true } }); + // objectui#9159: this pair used to be byte-identical, which is what made the + // escalated list a superset. The divergence IS the repair, so it is asserted + // as the two exact strings rather than as an inequality — the latter would + // also pass on a serializer that had merely started emitting garbage. expect(serializeDrillFilterParams(now).toString()) - .toBe(serializeDrillFilterParams(before).toString()); - // And the surviving condition is the NON-empty one, which is what makes - // this a superset rather than an empty list. + .toBe('filter%5Bstage%5D=won&filter%5Bowner%5D%5Bnull%5D=true'); + expect(serializeDrillFilterParams(before).toString()).toBe('filter%5Bstage%5D=won'); + // And BOTH conditions now reach the destination — the empty-bucket one being + // the one the user actually clicked. expect(parseUrlFilterTriples(new URLSearchParams(serializeDrillFilterParams(now).toString()))) - .toEqual([['stage', '=', 'won']]); + .toEqual([ + ['stage', '=', 'won'], + ['owner', 'is_null', true], + ]); }); - it('an empty-bucket-only drill serializes to nothing, before and after', () => { + it('an empty-bucket-only drill now serializes to the flag, while a bare null still serializes to nothing', () => { const now = buildDatasetDrillFilter({ owner: '' }, ['owner'], DIMENSION_FIELDS); - expect(serializeDrillFilterParams(now).toString()).toBe(''); + expect(serializeDrillFilterParams(now).toString()).toBe('filter%5Bowner%5D%5Bnull%5D=true'); + // Unchanged by objectui#9159, and deliberately: a JS `null` value means the + // producer has nothing to say about the field, not that the field is empty. expect(serializeDrillFilterParams({ owner: PREVIOUS_EMPTY_SPELLING }).toString()).toBe(''); }); diff --git a/packages/app-shell/src/views/drillUrlFilters.test.ts b/packages/app-shell/src/views/drillUrlFilters.test.ts index 62ef6a953c..327036be6e 100644 --- a/packages/app-shell/src/views/drillUrlFilters.test.ts +++ b/packages/app-shell/src/views/drillUrlFilters.test.ts @@ -155,3 +155,70 @@ describe('groupFilterChips', () => { ]); }); }); + +/** + * The is-null operator (objectui#9159) — the one member of this vocabulary + * whose URL value is a FLAG rather than a comparand. + * + * The escape-hatch defect it closes is pinned end to end, through the real + * `openRecordList`, in `drillEmptyBucketEscapeHatch-9159.test.tsx`; these are + * the module's own write/read/chip/delete obligations for it. + */ +describe('the is-null operator: `filter[][null]=true`', () => { + it('writes the flag for the `{ $null: true }` an empty-bucket drill produces', () => { + const qs = serializeDrillFilterParams({ owner: { $null: true } }); + expect(qs.get('filter[owner][null]')).toBe('true'); + }); + + it('reads the flag back as an is-null triple', () => { + expect(parse('filter[owner][null]=true')).toEqual([['owner', 'is_null', true]]); + }); + + it('survives the round-trip beside an equality condition', () => { + const filter = { stage: 'won', owner: { $null: true } }; + expect(parseUrlFilterTriples(serializeDrillFilterParams(filter))).toEqual([ + ['stage', '=', 'won'], + ['owner', 'is_null', true], + ]); + }); + + it('carries the flag alongside range bounds on the same field, as the AST converter does', () => { + // `convertFiltersToAST` emits BOTH conditions for this object, so the URL + // dialect emits both params rather than picking a winner. + const qs = serializeDrillFilterParams({ score: { $null: true, $gte: '5' } }); + expect(parseUrlFilterTriples(qs)).toEqual([ + ['score', 'is_null', true], + ['score', '>=', '5'], + ]); + }); + + it('drops `{ $null: false }` — this dialect has no "is not null" to write it to', () => { + // Degrading to a superset is the posture this module already takes for an + // operator it cannot spell; inventing the inverse operator on the read side + // would be a second, unpinned contract. + expect(serializeDrillFilterParams({ owner: { $null: false } }).toString()).toBe(''); + }); + + it('drops `[null]=false` on the read side instead of inventing an operator', () => { + expect(parse('filter[owner][null]=false')).toEqual([]); + // Not an equality against the string "false" either — that is the "wrong + // answer wearing a right answer's shape" this module refuses elsewhere. + expect(parse('filter[owner][null]=false').length).toBe(0); + }); + + it('drops an empty flag value, exactly as it drops any empty value', () => { + expect(parse('filter[owner][null]=')).toEqual([]); + }); + + it('renders a chip that names the condition instead of `= true`', () => { + expect(groupFilterChips([['owner', 'is_null', true]])).toEqual([ + { field: 'owner', text: 'is null' }, + ]); + }); + + it('removing the chip clears the flag param', () => { + const params = new URLSearchParams('filter[owner][null]=true&filter[stage]=won'); + deleteFieldFilterParams(params, 'owner'); + expect(params.toString()).toBe('filter%5Bstage%5D=won'); + }); +}); diff --git a/packages/app-shell/src/views/drillUrlFilters.ts b/packages/app-shell/src/views/drillUrlFilters.ts index 4810e6e0bd..ca8ea3552d 100644 --- a/packages/app-shell/src/views/drillUrlFilters.ts +++ b/packages/app-shell/src/views/drillUrlFilters.ts @@ -16,7 +16,9 @@ * Contract: * - equality `filter[field]=value` → `[field, '=', value]` * - range / cmp `filter[field][gte|lte|gt|lt]=v` → `[field, '>=' | … , v]` - * A date-bucket drill emits `gte` + `lt` to scope a list to a time bucket. + * - is-null FLAG `filter[field][null]=true` → `[field, 'is_null', true]` + * A date-bucket drill emits `gte` + `lt` to scope a list to a time bucket; an + * EMPTY-bucket drill emits the is-null flag (objectui#9159). */ /** Filter triple shape shared with view metadata: [field, operator, value]. */ @@ -29,6 +31,52 @@ export const URL_FILTER_OPS: Record = { gte: '>=', lte: '<=', gt * relevant `URL_FILTER_OPS` entries. */ export const RANGE_OP_PARAM: Record = { $gte: 'gte', $lte: 'lte', $gt: 'gt', $lt: 'lt' }; +/** + * The is-null operator (objectui#9159), in the ONE place both sides read it + * from, so the write and read halves cannot drift apart on its spelling. + * + * ## Its URL value is a FLAG, not a comparand — and that is the whole design + * + * Every other member of this vocabulary carries a value the user is filtering + * BY. This one carries no value at all: the condition is "this dimension is + * empty". So the param exists to be present, and `true` is the only spelling + * that means it. Two consequences, both deliberate and both pinned: + * + * - `filter[field][null]=false` is NOT a second operator. This dialect cannot + * WRITE "is not null" (nothing here emits it, and inventing a read-side-only + * operator would be a second contract with no producer), so the read side + * drops that param exactly as it drops an unknown suffix — never downgraded + * to `is_null false`, never to an equality against the string `"false"`. + * - equality-to-empty-string is not a substitute: `parseUrlFilterTriples` + * skips a param whose value is `''`, so `filter[owner]=` round-trips to no + * condition at all. The flag's value is a non-empty literal for that reason. + * + * ⚠️ `param` is deliberately NOT an entry in {@link URL_FILTER_OPS}. That map is + * the RANGE vocabulary, and `ObjectDataPage` inverts it to bridge a triple's + * operator to the spec's own alias spelling. `op` here is already a canonical + * `ViewFilterRule` operator word, so bridging it would map it to the alias + * `'null'`, which `normalizeFilterOperator` passes through verbatim and the rule + * schema then rejects — a saved view that silently loses this condition. Keeping + * the flag out of the range map is what keeps "Save as view" correct. + * + * ⚠️ Known unspelled synonyms, recorded rather than closed: `convertFiltersToAST` + * also lowers `{ $exists: false }` to is-null and `{ $null: false }` / + * `{ $exists: true }` to `is_not_null`. This dialect spells none of those, so a + * drill carrying one still degrades to a superset here — the same boundary this + * card closed for `{ $null: true }`, for producers nothing on this path emits + * today. + */ +export const NULL_FILTER = { + /** URL param suffix: `filter[][null]`. */ + param: 'null', + /** The ONLY param value that spells the condition. */ + flag: 'true', + /** ObjectQL operator it reads back as — what `convertFiltersToAST` emits for `{ $null: true }`. */ + op: 'is_null', + /** ObjectQL operator-object key the WRITE side recognizes. */ + key: '$null', +} as const; + /** * The ONE grammar for a key in this family, so the two arms below cannot drift * apart on what a field name is: `filter[]`, with an OPTIONAL @@ -40,9 +88,10 @@ export const RANGE_OP_PARAM: Record = { $gte: 'gte', $lte: 'lte' const FILTER_KEY = /^filter\[([^[\]]+)\](?:\[([^[\]]+)\])?$/; /** - * Parse `filter[]=` (equality) and `filter[][]=` - * (range/comparison) search params into ObjectQL triples. An unknown operator - * suffix is ignored (never silently downgraded to equality). + * Parse `filter[]=` (equality), `filter[][]=` + * (range/comparison) and `filter[][null]=true` ({@link NULL_FILTER}, the + * is-null flag) search params into ObjectQL triples. An unknown operator suffix + * is ignored (never silently downgraded to equality). */ export function parseUrlFilterTriples(searchParams: URLSearchParams): FilterTriple[] { const out: FilterTriple[] = []; @@ -55,6 +104,13 @@ export function parseUrlFilterTriples(searchParams: URLSearchParams): FilterTrip out.push([field, '=', value]); return; } + if (suffix === NULL_FILTER.param) { + // A flag, so only its one spelling is the condition; anything else here + // (`false` included) is dropped like an unknown suffix rather than + // answered at an operator this dialect cannot write. + if (value === NULL_FILTER.flag) out.push([field, NULL_FILTER.op, true]); + return; + } const op = URL_FILTER_OPS[suffix]; if (op) out.push([field, op, value]); }); @@ -73,6 +129,9 @@ export function parseUrlFilterTriples(searchParams: URLSearchParams): FilterTrip * correct — answering the narrower `amount = 100` when the URL asked for * `amount >= 100` is a wrong answer wearing a right answer's shape. * + * The is-null flag ({@link NULL_FILTER}) is a suffixed form, so this arm drops + * it too — the boundary below applies to it unchanged (objectui#9159). + * * ⚠️ This arm deliberately does NOT execute the operator suffix (objectui#9196). * Teaching this route range operators would widen the accepted set of an * addressable public surface — a behaviour addition, not a repair, and one that @@ -94,10 +153,16 @@ export function parseUrlEqualityFilterTriples(searchParams: URLSearchParams): Fi /** * Serialize a drill filter object into `filter[...]` search params. An ObjectQL - * range operator object (`{ $gte, $lt }`) becomes `filter[field][gte|lt]`; a - * plain value becomes `filter[field]`. `null`/`undefined` values and objects - * with no recognized operator are skipped (drill degrades to a superset) rather - * than stringified to `"[object Object]"`. + * range operator object (`{ $gte, $lt }`) becomes `filter[field][gte|lt]`; + * `{ $null: true }` — what an EMPTY-bucket drill carries — becomes the + * `filter[field][null]` flag ({@link NULL_FILTER}); a plain value becomes + * `filter[field]`. `null`/`undefined` values and objects with no recognized + * operator are skipped (drill degrades to a superset) rather than stringified to + * `"[object Object]"`. + * + * ⚠️ A JS `null` VALUE stays "no condition", and is not the is-null spelling: it + * is what a producer writes when it has nothing to say about the field. The + * empty bucket says something, and says it as `{ $null: true }` (objectui#9085). * * ## `$and` is flattened, because this dialect's conjunction is implicit * @@ -146,11 +211,22 @@ function collectFilterParams(filter: Record, params: URLSearchP continue; } if (typeof value === 'object' && !Array.isArray(value)) { + const ops = value as Record; + // The is-null FLAG (objectui#9159). Emitted BESIDE any range bound on the + // same object rather than instead of it, because `convertFiltersToAST` + // emits both conditions for that input and the two drill sinks agreeing is + // the point. Only `true` writes it: `{ $null: false }` is "is not null", + // an operator this dialect cannot spell, so it falls through and the drill + // degrades to a superset exactly as it does for any other operator absent + // from the maps above. + if (ops[NULL_FILTER.key] === true) { + params.set(`filter[${field}][${NULL_FILTER.param}]`, NULL_FILTER.flag); + } for (const [op, suffix] of Object.entries(RANGE_OP_PARAM)) { - const bound = (value as Record)[op]; + const bound = ops[op]; if (bound != null) params.set(`filter[${field}][${suffix}]`, String(bound)); } - continue; // handled (range ops) or skipped — never String(object) + continue; // handled (flag / range ops) or skipped — never String(object) } // Arrays reach here as `$in`-style comparands this dialect cannot spell; // skipping keeps the promise above (never `String(array)`). @@ -160,8 +236,11 @@ function collectFilterParams(filter: Record, params: URLSearchP } /** - * Delete the equality param AND every operator param (both range bounds) for a - * field, so removing a date-range chip drops the whole range together (#1752). + * Delete the equality param AND every operator param (both range bounds, and the + * is-null flag) for a field, so removing a date-range chip drops the whole range + * together (#1752) and removing an empty-bucket chip drops its flag + * (objectui#9159). Prefix-based, so it covers a suffix by construction rather + * than by listing one — a new operator is removable the day it is writable. * Mutates and returns `params`. */ export function deleteFieldFilterParams(params: URLSearchParams, field: string): URLSearchParams { @@ -176,6 +255,12 @@ export function deleteFieldFilterParams(params: URLSearchParams, field: string): * Group filter triples into ONE display chip per field, preserving first-seen * order. A date-bucket drill contributes two triples for the same field * (`>= start`, `< end`); they collapse into a single `start → end` range chip. + * + * The is-null flag gets its own text (objectui#9159). Without that arm it fell + * to the `= ` default and the chip read `= true` — a condition the user + * never wrote, against a value the object does not hold, on the one drill whose + * whole point is that the field is EMPTY. It is checked first so a field + * carrying the flag can never render as that bare `true`. */ export function groupFilterChips(triples: FilterTriple[]): Array<{ field: string; text: string }> { const order: string[] = []; @@ -189,10 +274,12 @@ export function groupFilterChips(triples: FilterTriple[]): Array<{ field: string } return order.map((field) => { const list = byField.get(field)!; + const isNull = list.some(([, op]) => op === NULL_FILTER.op); const gte = list.find(([, op]) => op === '>=' || op === '>'); const lt = list.find(([, op]) => op === '<' || op === '<='); - const text = - gte || lt + const text = isNull + ? 'is null' + : gte || lt ? `${gte ? String(gte[2]) : '…'} → ${lt ? String(lt[2]) : '…'}` : `= ${String(list[0][2])}`; return { field, text }; From 2dacd73f7379d9e11c7c0e92bba629f6ca8ad8d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 13:18:47 +0000 Subject: [PATCH 2/2] fix(app-shell): draw the is-null filter chip from the locale packs Round 2 of the drill URL is-null operator. The chip arm landed with its text finished inside groupFilterChips as the literal "is null". A user-facing literal in a renderer is unlocalized for every reader on every locale, and "make it English" answers only half of that -- the defect survives verbatim, in whatever language it was written in. The grouper now hands out an i18n KEY for that arm instead of finished text (FilterChip.textKey) and ObjectDataPage resolves it at the same half-chip seam that already draws the field name through fieldLabel. The key is the filter builder's existing operator label, already defined and translated in all ten packs and already policed by that family's locale-parity pin, so nothing new is authored and one operator label keeps one spelling across the product. The range and equality arms are untouched and still finish their own text: a comparand is the user's own data, which no catalogue can translate and which reads the same in every locale. That split is the type's documented contract, not a convenience. Passed bare, with no inline defaultValue: a default here would be an unwatched second English spelling that hides a pack miss, which this console deleted everywhere else for that reason. Pinned by a real render of the page under a non-English locale -- English cannot tell a hardcoded literal from a resolved pack value, since both render the same pixels -- with two controls: the packs are asserted to differ live, and the text is asserted not to contain the key's own prefix, which is what i18next renders when a key fails to resolve. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa --- .changeset/9159-drill-url-isnull-operator.md | 10 +- ...bjectDataPage.filterChipI18n-9159.test.tsx | 222 ++++++++++++++++++ .../app-shell/src/views/ObjectDataPage.tsx | 14 +- .../drillEmptyBucketEscapeHatch-9159.test.tsx | 22 +- .../drillEmptyBucketNavHost-9085.test.ts | 8 +- .../src/views/drillUrlFilters.test.ts | 24 +- .../app-shell/src/views/drillUrlFilters.ts | 47 +++- 7 files changed, 328 insertions(+), 19 deletions(-) create mode 100644 packages/app-shell/src/views/ObjectDataPage.filterChipI18n-9159.test.tsx diff --git a/.changeset/9159-drill-url-isnull-operator.md b/.changeset/9159-drill-url-isnull-operator.md index 19d74d71ef..515fcd4826 100644 --- a/.changeset/9159-drill-url-isnull-operator.md +++ b/.changeset/9159-drill-url-isnull-operator.md @@ -26,8 +26,14 @@ survived, so the page looked correctly scoped. that input; - **read** — that param becomes `[field, 'is_null', true]`, the same triple the other two consumers already produce; -- **chip** — the grouper renders `is null` for it, where it previously fell to the - `= ` default and displayed a bare `true`. +- **chip** — the grouper gives it its own arm, where it previously fell to the `= ` + default and displayed a bare `true`. That arm hands out an i18n KEY rather than finished + text (`FilterChip.textKey`), and `ObjectDataPage` resolves it at the same half-chip seam + that already draws the field name through `fieldLabel`. The key is the filter builder's + existing operator label, already defined and translated in all ten locale packs, so + nothing new is authored and no second spelling of one operator label is put at rest. The + range and equality arms are untouched and still finish their own text: a comparand is the + user's own data, which no catalogue can translate. The value is a FLAG, not a comparand. `filter[][null]=false` is **not** a second operator: this dialect cannot write "is not null", so the read side drops that param the diff --git a/packages/app-shell/src/views/ObjectDataPage.filterChipI18n-9159.test.tsx b/packages/app-shell/src/views/ObjectDataPage.filterChipI18n-9159.test.tsx new file mode 100644 index 0000000000..cfbed29051 --- /dev/null +++ b/packages/app-shell/src/views/ObjectDataPage.filterChipI18n-9159.test.tsx @@ -0,0 +1,222 @@ +/** + * 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#9159 round 2 — the is-null filter chip is drawn from the locale + * packs, like every other user-visible string on this page. + * + * The operator landed with its chip text finished inside `groupFilterChips` as + * the literal `'is null'`. That breaks two rules at once and "make it English" + * answers only the first: a user-facing literal in a renderer is unlocalized for + * every reader on every locale, and the defect survives verbatim in whatever + * language it was written in. So the grouper now hands out the filter builder's + * existing operator key and this page resolves it at the same half-chip seam + * that already draws the field name through `fieldLabel`. + * + * ## Why this file renders the page instead of asserting the key + * + * A test on the grouper's return value can only pin that a key is handed OUT + * (`drillUrlFilters.test.ts` does that). It stays green if the render site never + * calls `t` — which is the whole defect, one layer down. So the observation + * point is the DOM of the real page, under a real `I18nProvider`, and the + * assertion is on the chip's own text node reached through the remove button + * beside it rather than on the chip row's whole `textContent`, which also holds + * the "Filtered by" lead-in and the translated field name. + * + * ## Why a NON-English locale, and what each control rules out + * + * English cannot tell the two worlds apart: a hardcoded `'is null'` and a + * correctly resolved `en` pack value render the same pixels. Under `zh` they + * differ, so the assertion has the power to fail for the reason it exists. The + * controls below rule out the two ways this could pass vacuously — the packs + * agreeing (they are asserted to differ, live) and the key failing to resolve + * (i18next would render the key itself, so the text is asserted not to contain + * the key's own prefix). ⇒ a pack that loses this key is VISIBLE here rather + * than silently falling back to English. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + usePermissions: () => ({ + check: () => ({ allowed: true }), + checkField: () => true, + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + isLoaded: false, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }), + useFieldPermissions: () => ({ canRead: () => true, canWrite: () => true, permissions: [] }), + }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), + createAuthenticatedFetch: () => vi.fn(), +})); + +// Heavy children, all orthogonal to the chip row under test and each dragging in +// a plugin bundle — the same posture as the sibling create-affordance test on +// this page. +vi.mock('@object-ui/plugin-list', async (importOriginal) => ({ + ...(await importOriginal()), + ListView: () => null, +})); +vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); +vi.mock('./CreateViewDialog', () => ({ CreateViewDialog: () => null })); +vi.mock('./metadata-admin/useMetadata', () => ({ useMetadataClient: () => ({}) })); + +import { I18nProvider } from '@object-ui/i18n'; +import { builtInLocales } from '@object-ui/i18n/locales'; +import { ObjectDataPage } from './ObjectDataPage'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; +import { NULL_FILTER } from './drillUrlFilters'; + +const h = React.createElement; +const OBJECT_NAME = 'showcase_invoice'; +const FIELD = 'owner'; + +const OBJECTS = [ + { + name: OBJECT_NAME, + label: 'Invoice', + managedBy: 'platform', + fields: { + id: { type: 'text', label: 'Id' }, + owner: { type: 'text', label: 'Owner' }, + }, + }, +]; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [], total: 0 })), + findOne: vi.fn(async () => null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + } as any; +} + +/** What the pack itself says this operator is called, in one language. */ +function packLabel(language: string): unknown { + const pack = (builtInLocales as Record)[language]; + return NULL_FILTER.labelKey.split('.').reduce((node, part) => node?.[part], pack); +} + +/** + * Render the page at one `filter[...]` query string, in one language. + * + * `children` rides in the PROPS object rather than in `createElement`'s third + * argument: both providers declare it required, and the third-argument overload + * does not satisfy that (objectui#4040, the same fix the sibling i18n render + * tests carry). + */ +function renderAt(language: string, search: string) { + const page = h( + MemoryRouter, + { initialEntries: [`/apps/demo/${OBJECT_NAME}/data?${search}`] }, + h( + Routes, + null, + h(Route, { + path: '/apps/:appName/:objectName/data', + element: h(ObjectDataPage, { dataSource: makeDataSource(), objects: OBJECTS }), + }), + ), + ); + return render( + h(I18nProvider, { + config: { defaultLanguage: language, detectBrowserLanguage: false }, + children: h(ExpressionProvider, { + user: { id: 'u1', name: 'Ada', profile: 'admin' }, + children: page, + }), + }), + ); +} + +/** The empty-bucket drill's own query string, spelled from the contract. */ +const EMPTY_BUCKET_SEARCH = `filter[${FIELD}][${NULL_FILTER.param}]=${NULL_FILTER.flag}`; + +/** + * The chip's operator half. Reached through the remove button that sits beside + * it, so this cannot drift onto the field-name half or the row's lead-in. + */ +function chipOperatorText(): string { + const remove = document.querySelector(`[data-testid="object-data-remove-filter-${FIELD}"]`); + expect(remove, 'the empty-bucket chip did not render at all').toBeTruthy(); + return remove!.previousElementSibling?.textContent ?? ''; +} + +beforeEach(() => { + cleanup(); + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('the is-null filter chip is translated (objectui#9159)', () => { + it('renders the zh pack copy, not an English literal', () => { + renderAt('zh', EMPTY_BUCKET_SEARCH); + const rendered = chipOperatorText(); + + // The rendered copy IS the pack copy. Read from the pack rather than + // written out here: this card owns whether the chip is TRANSLATED, while + // the wording of the operator label belongs to the catalogue and to the + // filter builder that shares this key. + expect(rendered).toBe(packLabel('zh')); + // A key that failed to resolve renders as itself — the exact user-visible + // symptom the filter-builder operator family's locale-parity pin names. + expect(rendered).not.toContain('filterBuilder.operators'); + // And the two defects this arm replaced, neither of which can come back + // through a pack: the bare comparand, and the deleted English literal. + expect(rendered).not.toContain('true'); + expect(rendered).not.toBe('is null'); + }); + + it('CONTROL: the English pack renders too, and the two packs really differ', () => { + // Without this pair the zh assertion could pass on a page that never called + // `t` at all, if the packs happened to agree. + renderAt('en', EMPTY_BUCKET_SEARCH); + expect(chipOperatorText()).toBe(packLabel('en')); + expect(packLabel('zh')).not.toBe(packLabel('en')); + }); + + it('CONTROL: an equality chip is NOT translated, because its text is the user\'s own comparand', () => { + // The other half of the split: a comparand has no catalogue entry and must + // reach the DOM verbatim in every locale. A change that routed every chip + // through `t` would turn this red. + renderAt('zh', `filter[${FIELD}]=alice`); + expect(chipOperatorText()).toBe('= alice'); + }); +}); diff --git a/packages/app-shell/src/views/ObjectDataPage.tsx b/packages/app-shell/src/views/ObjectDataPage.tsx index 0dc960f459..e71642806a 100644 --- a/packages/app-shell/src/views/ObjectDataPage.tsx +++ b/packages/app-shell/src/views/ObjectDataPage.tsx @@ -570,13 +570,23 @@ export function ObjectDataPage({ dataSource, objects }: any) { {t('console.objectData.filteredBy', { defaultValue: 'Filtered by' })} - {filterChips.map(({ field, text }) => ( + {filterChips.map(({ field, text, textKey }) => ( {fieldLabel(objectDef.name, field, field)} - {text} + {/* + A chip carrying `textKey` is one whose text is PROSE rather than + the user's own comparand, so it is translated HERE — the same + half-chip seam that already draws the field name through + `fieldLabel` (objectui#9159). Passed bare, with no inline + `defaultValue`: all ten packs define this operator family and its + locale-parity pin holds them to it, so a default would only be an + unwatched second English spelling that hides a pack miss + (objectui#3469 deleted exactly that pattern from this console). + */} + {textKey ? t(textKey) : text}