From ed3229182f4c7c56ce16e5fee007ef32c98201c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 02:13:24 +0000 Subject: [PATCH] fix(drill): the list URL says a boolean, $in/$nin, $ne and the text operators, so "Open in list" and "View all" open the exact list again The object list route gains `filter[FIELD][bool]=true|false`, the `[ne|contains|notcontains|startswith|endswith|icontains]` suffixes and the repeated `[in]` / `[nin]` membership params. Each reads back through parseUrlFilterTriples as the node convertFiltersToAST lowers the same condition to, comparand type included, and exactRecordListFilter now proves that per condition before it accepts one. A drill on a yes/no bucket or under an $in / $ne / text-operator scope, and a related list's "View all" with such a filter, get their list destination back; $or, $between, an empty set and a boolean under $ne stay refused. Claude-Session: https://claude.ai/code/session_01L6bhgW9yrPaVhdtD84RWcR Co-authored-by: Claude --- .../30743-list-url-boolean-in-ne-text.md | 16 ++ ...bjectDataPage.filterChipI18n-9159.test.tsx | 34 +++ ...bjectDataPage.saveAsViewFilterFold.test.ts | 31 ++- .../app-shell/src/views/ObjectDataPage.tsx | 10 +- .../ObjectView.urlFilterSuffix-9196.test.ts | 11 +- .../exactRecordListFilter-25888.test.ts | 84 ++++++- .../src/views/drillUrlFilters.test.ts | 131 +++++++++++ .../app-shell/src/views/drillUrlFilters.ts | 210 +++++++++++++++++- ...nRecordList.placeholderScope-9022.test.tsx | 27 ++- .../app-shell/src/views/useOpenRecordList.ts | 88 ++++++-- 10 files changed, 609 insertions(+), 33 deletions(-) create mode 100644 .changeset/30743-list-url-boolean-in-ne-text.md diff --git a/.changeset/30743-list-url-boolean-in-ne-text.md b/.changeset/30743-list-url-boolean-in-ne-text.md new file mode 100644 index 00000000000..6b601b775a2 --- /dev/null +++ b/.changeset/30743-list-url-boolean-in-ne-text.md @@ -0,0 +1,16 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(drill): "Open in list" and a related list's "View all" open the exact list again for a yes/no bucket, an `$in` / `$nin` set, `$ne` and the text operators + +Clause-②: yes (widening) +Metadata-protocol: none + +The object list route (`/apps/APP/OBJECT/data?filter[...]`) gains six spellings, each read back as the very comparison the drill drawer queries, comparand type included: + +- `filter[FIELD][bool]=true|false` is equality to the boolean. `filter[FIELD]=true` still means the text "true". +- `filter[FIELD][ne|contains|notcontains|startswith|endswith|icontains]=VALUE` is `$ne` and the five text operators. +- `filter[FIELD][in]=A&filter[FIELD][in]=B` (and `[nin]`) is a set, one repeated param per member. + +A drill or "View all" whose filter uses only these and the earlier spellings now offers its list destination; anything the route still cannot say exactly (`$or`, `$between`, an empty set, a boolean under `$ne`) still offers none rather than a wider list. The list page shows each new condition as a chip with the filter builder's own operator label, removes it with the chip, and keeps it through "Save as view". diff --git a/ui/packages/app-shell/src/views/ObjectDataPage.filterChipI18n-9159.test.tsx b/ui/packages/app-shell/src/views/ObjectDataPage.filterChipI18n-9159.test.tsx index fbcf108b079..a40520ba331 100644 --- a/ui/packages/app-shell/src/views/ObjectDataPage.filterChipI18n-9159.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectDataPage.filterChipI18n-9159.test.tsx @@ -256,3 +256,37 @@ describe('the is-empty filter chips are translated (objectui#11547)', () => { expect(packLabelOf('zh', EMPTY_FILTER.labelKey)).not.toBe(packLabelOf('zh', NULL_FILTER.labelKey)); }); }); + +/** + * objectstack#30743 — a membership or negation chip draws its operator through + * the filter builder's label and its comparand verbatim, on the real page and + * the real packs: the label half translated, the comparand half not. + */ +describe('the membership and negation chips (objectstack#30743)', () => { + const packLabelOf = (language: string, key: string): unknown => + key.split('.').reduce( + (node, part) => (node as Record | undefined)?.[part], + (builtInLocales as Record)[language], + ); + + it.each([ + ['in', 'filterBuilder.operators.in'], + ['nin', 'filterBuilder.operators.not_in'], + ])('`[%s]` renders the zh label of `%s` beside the members, joined', (suffix, key) => { + renderAt('zh', `filter[${FIELD}][${suffix}]=alice&filter[${FIELD}][${suffix}]=bob`); + expect(chipOperatorText()).toBe(`${packLabelOf('zh', key)} alice, bob`); + }); + + it('`[ne]` renders the zh "does not equal" label beside the comparand', () => { + renderAt('zh', `filter[${FIELD}][ne]=alice`); + const rendered = chipOperatorText(); + expect(rendered).toBe(`${packLabelOf('zh', 'filterBuilder.operators.not_equals')} alice`); + expect(rendered).not.toContain('filterBuilder.operators'); + }); + + it('CONTROL: the en pack differs, so the label half really went through `t`', () => { + renderAt('en', `filter[${FIELD}][in]=alice`); + expect(chipOperatorText()).toBe(`${packLabelOf('en', 'filterBuilder.operators.in')} alice`); + expect(packLabelOf('zh', 'filterBuilder.operators.in')).not.toBe(packLabelOf('en', 'filterBuilder.operators.in')); + }); +}); diff --git a/ui/packages/app-shell/src/views/ObjectDataPage.saveAsViewFilterFold.test.ts b/ui/packages/app-shell/src/views/ObjectDataPage.saveAsViewFilterFold.test.ts index c5f27fb1a14..be3c227d73b 100644 --- a/ui/packages/app-shell/src/views/ObjectDataPage.saveAsViewFilterFold.test.ts +++ b/ui/packages/app-shell/src/views/ObjectDataPage.saveAsViewFilterFold.test.ts @@ -29,6 +29,8 @@ import { URL_FILTER_OPS, NULL_FILTER, EMPTY_FILTER, + URL_COMPARE_OPS, + URL_LIST_OPS, parseUrlFilterTriples, type FilterTriple, } from './drillUrlFilters'; @@ -103,7 +105,7 @@ describe('Save as view folds URL drill triples to spec rules (objectui#3419)', ( // and the emptiness PAIR, whose one param carries a direction 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. + // emits nothing outside this set and the next test's (objectstack#30743). // // ⚠️ `NULL_FILTER.notOp` joined the set in objectui#9508. Listing only // `op` would have left this pin GREEN while the contract grew an operator @@ -150,6 +152,33 @@ describe('Save as view folds URL drill triples to spec rules (objectui#3419)', ( expect(gate.success).toBe(true); }); + it('folds the negation, text, membership and boolean triples too (objectstack#30743)', () => { + // Computed from the two maps for the reason the test above gives: an + // operator added to either map fails here, not at publish time. + expect(Object.values(URL_COMPARE_OPS)).toEqual([ + '!=', 'contains', 'notcontains', 'startswith', 'endswith', 'icontains', + ]); + expect(Object.values(URL_LIST_OPS)).toEqual(['in', 'nin']); + const triples: FilterTriple[] = [ + ...Object.values(URL_COMPARE_OPS).map((op, i) => ['c' + i, op, 'v' + i] as FilterTriple), + ...Object.values(URL_LIST_OPS).map((op, i) => ['l' + i, op, ['a', 'b']] as FilterTriple), + ['done', '=', true], + ]; + const { spec, gate } = saveAsView(triples); + expect(spec.filter).toEqual([ + { field: 'c0', operator: 'not_equals', value: 'v0' }, + { field: 'c1', operator: 'contains', value: 'v1' }, + { field: 'c2', operator: 'not_contains', value: 'v2' }, + { field: 'c3', operator: 'starts_with', value: 'v3' }, + { field: 'c4', operator: 'ends_with', value: 'v4' }, + { field: 'c5', operator: 'icontains', value: 'v5' }, + { field: 'l0', operator: 'in', value: ['a', 'b'] }, + { field: 'l1', operator: 'not_in', value: ['a', 'b'] }, + { field: 'done', operator: 'equals', value: true }, + ]); + expect(gate.success).toBe(true); + }); + it('keeps the is-NOT-null direction intact through the fold too (objectui#9508)', () => { // A drill that can be WRITTEN into the URL must also be savable, or the // user reads a chip the saved view then silently does not carry. diff --git a/ui/packages/app-shell/src/views/ObjectDataPage.tsx b/ui/packages/app-shell/src/views/ObjectDataPage.tsx index 0c59b6ddcf0..5e4bf913dd2 100644 --- a/ui/packages/app-shell/src/views/ObjectDataPage.tsx +++ b/ui/packages/app-shell/src/views/ObjectDataPage.tsx @@ -57,6 +57,7 @@ import { groupFilterChips, deleteFieldFilterParams, URL_FILTER_OPS, + URL_TRIPLE_OP_WORD, type FilterTriple, } from './drillUrlFilters.js'; import { @@ -147,6 +148,9 @@ const TRIPLE_OP_TO_SPEC_ALIAS: Record = { ...Object.fromEntries( Object.entries(URL_FILTER_OPS).map(([suffix, symbol]) => [symbol, suffix]), ), + // objectstack#30743 — negation, text and membership triples carry their + // canonical word already, which `normalizeFilterOperator` returns unchanged. + ...URL_TRIPLE_OP_WORD, }; /** @@ -171,7 +175,7 @@ const TRIPLE_OP_TO_SPEC_ALIAS: Record = { * from the persisted view with a debug-level note, never written off-spec: * declared = enforced, and a view body that fails the record gate is rejected * whole at publish time, which would lose the user's other conditions too. - * (`parseUrlFilterTriples` only ever emits the five operators bridged above, so + * (`parseUrlFilterTriples` only ever emits operators bridged above, so * the drop path is defence in depth against the URL contract growing an * operator the spec has no word for.) */ @@ -675,7 +679,9 @@ export function ObjectDataPage({ dataSource, objects }: any) { unwatched second English spelling that hides a pack miss (objectui#3469 deleted exactly that pattern from this console). */} - {textKey ? t(textKey) : text} + + {textKey && text !== undefined ? `${t(textKey)} ${text}` : textKey ? t(textKey) : text} +