diff --git a/.changeset/7561-filter-builder-operator-trigger-label.md b/.changeset/7561-filter-builder-operator-trigger-label.md index f1058fd349..acd89a76f6 100644 --- a/.changeset/7561-filter-builder-operator-trigger-label.md +++ b/.changeset/7561-filter-builder-operator-trigger-label.md @@ -47,3 +47,16 @@ marker: Which of the two vocabularies should win remains open and is deliberately not answered here (objectui#7561). + +Superseded in this release by objectui#9306: the mounted `SelectItem` ids are +no longer camelCase. The dropdown now draws the protocol's own ids — the twenty +`VIEW_FILTER_OPERATORS` members (`greater_than`, `not_in`, …) plus the opt-in +`exists` / `notExists` — so picking **Less than** makes `onChange` hand back +`less_than`: still never `lt`, and no longer `lessThan`. The vocabulary question +this entry leaves open is answered by that change: the protocol's spelling is +the one the dropdown emits, and a retired camelCase id a stored filter still +carries is read as the deprecated alias it is and written back canonical on the +author's next edit. The fold this entry added is unchanged, and the builder now +also folds a row's spelling when the group arrives, so the alias table (`gt` / +`lt` / `eq`) and the retired camelCase ids both still draw their operator's +label. diff --git a/.changeset/8748-icontains-empty-comparand.md b/.changeset/8748-icontains-empty-comparand.md index adfcda0c95..fcf861d675 100644 --- a/.changeset/8748-icontains-empty-comparand.md +++ b/.changeset/8748-icontains-empty-comparand.md @@ -53,3 +53,14 @@ they did; only what the builder WRITES from now on changes. ⚠️ The builder ROW is unaffected by the drop: it is held as local state and stays on screen with its value box empty, so the five text operators stay reachable — the criteria is what the rows emit, not what they are. + +Superseded in this release by objectui#9306, for the `@object-ui/fields` half: +the builder's operator ids are now the protocol's canonical spellings, so the +text operators `condToMongo` drops on an empty comparand are `contains`, +`icontains`, `not_contains`, `starts_with` and `ends_with` (the case-insensitive +contains is `icontains` now, and no longer opt-in), and the value-less operators +it leaves untouched are `is_null` / `exists` / `is_empty` and their negations +`is_not_null` / `notExists` / `is_not_empty`. The drop itself, `equals ''`, and +every emitted `$`-token (`$contains`, `$icontains`, `$notContains`, +`$startsWith`, `$endsWith`) are unchanged — objectui#9306's census measured the +same stored predicate for all 22 former ids and the ids they became. diff --git a/.changeset/9302-filter-builder-valueless-canonical-fold.md b/.changeset/9302-filter-builder-valueless-canonical-fold.md index 235fb83ea1..c0bb7b5b57 100644 --- a/.changeset/9302-filter-builder-valueless-canonical-fold.md +++ b/.changeset/9302-filter-builder-valueless-canonical-fold.md @@ -49,3 +49,19 @@ and no row's stored `operator` is rewritten — rendering is not an edit. Which operator vocabulary should WIN is a separate, still-open question and is not decided here. The `contains` / `icontains` boundary is untouched and pinned: the fold this gate routes through maps neither onto the other. + +Superseded in this release by objectui#9306: the dropdown's ids are now the +protocol's canonical spellings, so `VALUELESS_FILTER_BUILDER_OPERATORS` holds +`is_empty`, `is_not_empty`, `is_null`, `is_not_null`, `exists` and `notExists`. +The exported set's membership therefore did change in this release — by that +change, not this one, and it is still one id per operator with no second +spelling added. The gate this entry repaired is unchanged in shape: it folds the +row's spelling before the lookup (now through `normalizeFilterBuilderOperator`, +the spec's `normalizeFilterOperator` plus one local row for the +`containsCaseInsensitive` spelling the spec's table lacks), so the spellings the +gate answers "no value" for without their being members are now the retired +camelCase ids a filter stored before objectui#9306 carries, and the spec's other +alias rows for these operators. The vocabulary question this entry calls +open is answered: the dropdown speaks the protocol's ids, and camelCase is read +as the deprecated alias form and rewritten canonical on the next edit. +`contains` and `icontains` remain two operators. diff --git a/.changeset/9306-filter-builder-protocol-operator-ids.md b/.changeset/9306-filter-builder-protocol-operator-ids.md new file mode 100644 index 0000000000..d19d5794b6 --- /dev/null +++ b/.changeset/9306-filter-builder-protocol-operator-ids.md @@ -0,0 +1,60 @@ +--- +'@object-ui/components': minor +'@object-ui/fields': minor +'@object-ui/app-shell': minor +'@object-ui/plugin-view': minor +'@object-ui/i18n': minor +--- + +The `FilterBuilder` dropdown speaks the protocol's operator ids (objectui#9306). + +`defaultOperators` now emits the twenty members of `@objectstack/spec`'s +`VIEW_FILTER_OPERATORS`, spelled as the spec spells them (`not_equals`, +`greater_than_or_equal`, `is_null`, `icontains`, …), plus the two opt-in +existence ids `exists` / `notExists`, which the protocol has no member for and +which stay unfolded (objectui#9559 ruling B). The camelCase ids the dropdown used +to emit (`notEquals`, `greaterOrEqual`, `isNull`, …) are the spec's deprecated +alias form (objectui#7993); `containsCaseInsensitive` is the one former id the +spec's alias table has no row for, and the builder reads it itself (below). + +**Stored filters keep loading.** The builder folds a stored spelling at its read +boundary, through the spec's `normalizeFilterOperator` plus one local row the +spec's alias table lacks (`containsCaseInsensitive` → `icontains`, +objectstack-ai/objectstack#20092), and the author's next edit writes the +canonical id back. Opening a stored filter writes nothing. No row changes the +predicate it stores: the sharing-rule criteria, the dataset filter, the saved-view +fold, the live grid and the override recovery pass were each measured over all +22 former ids against the ids they became, and store the same predicate. The only +cells that differ are `icontains` on the three consumers that never offered the +case-insensitive contains (dataset filter, saved-view fold, live grid), where +the old id produced no storable filter at all and the new one does. + +**Breaking, stated here because the group never takes a `major`:** + +- `@object-ui/components`: the published `FilterBuilderOperator` type NARROWS + from the camelCase union to the spec's `ViewFilterOperator` plus `'exists' | + 'notExists'` — a `'greaterOrEqual'` literal typed against it no longer + compiles. `FILTER_BUILDER_OPERATORS` and `VALUELESS_FILTER_BUILDER_OPERATORS` + hold the canonical ids, and a host's `onChange` receives them. New export: + `normalizeFilterBuilderOperator`, the builder's read-side fold. +- `@object-ui/components`: `icontains` ("Contains (ignore case)") is no longer + opt-in. Its old reason — only the Mongo criteria dialect could carry a + case-insensitive contains — stopped being true when `VIEW_FILTER_OPERATORS` + gained `icontains` (spec 17.1.0), and `OPT_IN_OPERATORS`' own docblock recorded + deleting the entry as the planned outcome. It is offered on the text bucket to + every consumer; `contains` and `icontains` stay two operators (objectui#7379). +- `@object-ui/i18n`: the `filterBuilder.operators.*` keys are re-keyed to the + canonical ids in all ten packs (`filterBuilder.operators.is_null`, …); every + translated value is unchanged. A host that overrides one of these keys must + re-key its override. +- `@object-ui/fields`: `FILTER_CONDITION_EXTRA_OPERATORS` is `['exists', + 'notExists']` — the case-insensitive contains needs no grant any more. + `FilterConditionField` still writes `$icontains` for it, and its builder rows + (`kvToCondition`) carry the canonical ids. +- `@object-ui/plugin-view`: `toFilterGroup` emits the canonical ids. + +Also in `@object-ui/app-shell`: the dataset inspector's bridge maps `icontains` +to `$icontains`; the drill-down "is null" chip reads the re-keyed label; and the +view-override recovery pass folds a row's operator before its value-less check, +so a stored `{ operator: 'isEmpty', value: '' }` row is kept rather than dropped +as unfinished. diff --git a/.changeset/9359-list-ast-valueless-canonical-fold.md b/.changeset/9359-list-ast-valueless-canonical-fold.md index 9119107a77..e768243a91 100644 --- a/.changeset/9359-list-ast-valueless-canonical-fold.md +++ b/.changeset/9359-list-ast-valueless-canonical-fold.md @@ -60,3 +60,18 @@ operator is rewritten: converting is not migrating. Which operator vocabulary should WIN is a separate, still-open question and is not decided here. The `contains` / `icontains` boundary is untouched and pinned: the fold this reader routes through maps neither onto the other. + +Superseded in this release by objectui#9306: the list toolbar's FilterBuilder +now emits the protocol's canonical ids, so the canonical spelling IS a measured +producer into this reader, and `VALUELESS_FILTER_BUILDER_OPERATORS` holds +`is_empty`, `is_not_empty`, `is_null`, `is_not_null`, `exists` and `notExists` — +its membership changed in this release by that change, still one id per +operator. A group restored per browser carries whatever ids it was written +with, so a group written before objectui#9306 can still reach this reader in +camelCase. This reader is unchanged and answers both spellings alike: over the +former dropdown ids and the ids they became (the list reader's leg of +objectui#9306's census), every pair emits the same node except +`containsCaseInsensitive`, which the toolbar never offered and which the builder +folds onto `icontains` before a row leaves it. The vocabulary +question this entry calls open is answered: the dropdown speaks the protocol's +ids, and camelCase is the deprecated alias form. diff --git a/examples/schema-catalog/test/filter-builder-operator-alias-trigger-7561.test.tsx b/examples/schema-catalog/test/filter-builder-operator-alias-trigger-7561.test.tsx index 3acf7168ea..582d2412b1 100644 --- a/examples/schema-catalog/test/filter-builder-operator-alias-trigger-7561.test.tsx +++ b/examples/schema-catalog/test/filter-builder-operator-alias-trigger-7561.test.tsx @@ -17,7 +17,8 @@ * Three catalog entries carry the rows this measures — `product-search`, * `with-conditions`, and the `filter-builder` nested inside `search-interface`. * No `SelectItem` in the operator dropdown carries a spelling from outside its - * own camelCase vocabulary, and the trigger used to match its value LITERALLY + * own vocabulary (camelCase then, the protocol's ids since objectui#9306), and + * the trigger used to match its value LITERALLY * against the mounted items, so any other spelling of the same operator drew a * blank operator cell over a row that filtered correctly. * @@ -41,13 +42,17 @@ * one canonical member and the trigger now resolves through it. Before that * repair they differ — which is what makes this measurement able to fail. * - * ⭐ The corrected column deliberately uses the dropdown's OWN camelCase ids - * (`lessThan`), not the spec's canonical `less_than`: those ids are the ones - * mounted, so the corrected column is the "what it would have looked like if - * authored in the renderer's dialect" arm the card described. Both arms - * rendering the same text is the claim; neither arm is a recommendation about - * which vocabulary an author SHOULD use — that is objectui#7561's separate - * ruling and is not decided here. + * ⭐ The corrected column deliberately uses the camelCase ids (`lessThan`), + * not the spec's canonical `less_than`. When objectui#7561 landed those were + * the dropdown's OWN ids, the ones mounted, so that column was the "what it + * would have looked like if authored in the renderer's dialect" arm the card + * described. Since objectui#9306 the dropdown mounts the canonical ids and the + * camelCase ones are the spec's DEPRECATED alias form, which the builder folds + * at its read boundary — so the same column now measures the other direction: + * a filter stored before that change still draws its label. Both arms + * rendering the same text is the claim either way; neither arm is a + * recommendation about which vocabulary an author SHOULD use — the canonical + * one, per objectui#9306's ruling, which this control does not re-decide. * * ⚠️ The two vocabularies OVERLAP on three members (`equals`, `contains`, * `in` are spelled identically in both), so "the arms are two dialects" cannot @@ -89,10 +94,11 @@ const EXPECTED: Record<(typeof AFFECTED)[number], string[]> = { }; /** - * The declared spellings these entries author → the dropdown id each folds - * onto. Covers the spec's alias table too, so an entry re-authored in EITHER - * off-dropdown dialect is still carried by the control arm rather than - * silently passed through as an identity. + * The declared spellings these entries author → the camelCase id each folds + * from: the dropdown's own id before objectui#9306, the deprecated alias form + * after it (see this file's header). Covers the spec's alias table too, so an + * entry re-authored in EITHER off-canonical dialect is still carried by the + * control arm rather than silently passed through as an identity. */ const CORRECTION: Record = { // what the catalog authors today — `@objectstack/spec`'s canonical members diff --git a/packages/app-shell/src/views/ObjectView.overlayPairValue.test.ts b/packages/app-shell/src/views/ObjectView.overlayPairValue.test.ts index abb7bb6534..1785e371fc 100644 --- a/packages/app-shell/src/views/ObjectView.overlayPairValue.test.ts +++ b/packages/app-shell/src/views/ObjectView.overlayPairValue.test.ts @@ -164,8 +164,9 @@ describe('the arities this change must not touch (#5025)', () => { }); it('still keeps a value-less operator whose value slot is empty', () => { - // Both dialects: the builder id and the canonical spec spelling this - // layer additionally sees. + // Both spellings a stored overlay can carry: the canonical one (the + // builder's own id since objectui#9306) and the deprecated camelCase + // one a row stored before that change still holds. const row = overlay([ { field: 'closed_at', operator: 'isEmpty', value: '' }, { field: 'owner', operator: 'is_null', value: '' }, @@ -174,6 +175,49 @@ describe('the arities this change must not touch (#5025)', () => { }); }); +/** + * objectui#9306 — the recovery pass FOLDS a row's operator before asking + * whether it takes a value. + * + * The value-less set it asks (`VALUELESS_FILTER_OPERATORS`) is written in the + * protocol's spellings, and since objectui#9306 so is the builder half of it: + * the deprecated camelCase ids are no longer members. A stored overlay row + * `{ operator: 'isEmpty', value: '' }` — written before that change — would + * then miss a RAW lookup, fall to the value test, have its `''` read as + * unfilled, and be DROPPED: the stored filter loses a condition on read, and + * the list widens to rows the author excluded. Every spelling below is one the + * spec's alias table folds onto a value-less member. + */ +describe('a stored deprecated-spelling value-less row survives the recovery pass (objectui#9306)', () => { + it.each([ + ['isEmpty', 'is_empty'], + ['isNotEmpty', 'is_not_empty'], + ['isNull', 'is_null'], + ['isNotNull', 'is_not_null'], + ['isnull', 'is_null'], + ])('`%s` (folds to `%s`) with an empty value slot is KEPT', (operator) => { + const row = overlay([{ field: 'closed_at', operator, value: '' }]); + expect(sanitizeViewOverride(row)).toBe(row); + }); + + it('…in the legacy runtime triple shape too', () => { + const row = overlay([['closed_at', 'isEmpty', '']]); + expect(sanitizeViewOverride(row)).toBe(row); + }); + + it('CONTROL: a value-TAKING deprecated spelling with no value is still dropped', () => { + // The fold must not make every camelCase row look finished: `notEquals` + // takes a value, so an empty one is an unfinished row exactly as its + // canonical twin's is. + expect( + sanitizeViewOverride(overlay([{ field: 'name', operator: 'notEquals', value: '' }])).filter, + ).toBeUndefined(); + expect( + sanitizeViewOverride(overlay([{ field: 'name', operator: 'not_equals', value: '' }])).filter, + ).toBeUndefined(); + }); +}); + describe('why the read path is the last guard (#5025)', () => { // NON-DISCRIMINATING by construction: this asserts a fact about // `@objectstack/spec`, not about `sanitizeViewOverride`, so it is green in diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index b13b2a26ca..aa05200cc4 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -48,7 +48,7 @@ import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; import { ViewConfigPanel } from './ViewConfigPanel.js'; import { useMetadataClient } from './metadata-admin/useMetadata.js'; import { persistRuntimeMetadata, createRuntimeMetadata, viewEnvelope, type ViewEnvelope } from './runtime-metadata-persistence.js'; -import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; +import { ListViewSchema as SpecListViewSchema, normalizeFilterOperator } from '@objectstack/spec/ui'; import { CreateViewDialog } from './CreateViewDialog.js'; import { usePreviewDrafts, @@ -696,8 +696,19 @@ export function defaultListColumnsFromObject( * * The split of duties is the helper's own: it answers the VALUE question only, * while which operators want no value at all stays with - * {@link VALUELESS_FILTER_OPERATORS} above — this layer additionally sees the - * canonical spec spellings (`is_null`) that never reach the dropdown. + * {@link VALUELESS_FILTER_OPERATORS} above. + * + * That membership is asked of the row's spelling FOLDED through the spec's own + * `normalizeFilterOperator` (objectui#9306), never of the raw spelling — the + * same both-sides fold objectui#9302 / #9359 put on the builder's gate and the + * live grid, and that {@link isFilterValueComplete} already applies to the + * arity question. The set's members are protocol ids (plus `exists` / + * `notExists`), on which the fold is the identity, so folding the row is + * folding both sides. It became load-bearing when the builder's ids became the + * protocol's: a row stored under the deprecated camelCase id — + * `{ operator: 'isEmpty', value: '' }` — no longer matches the set raw, and a + * raw lookup would hand it to the value test, which reads `''` as unfilled and + * DROPS it, so the stored filter would lose a condition on read. */ export function sanitizeViewOverride(override: any): any { if (!override || typeof override !== 'object') return override; @@ -715,17 +726,20 @@ export function sanitizeViewOverride(override: any): any { if (narrowed !== override) return narrowed; if (!Array.isArray(override.filter)) return override; + // Folded before the lookup — see the docblock (objectui#9306). + const isValueless = (operator: unknown): boolean => + VALUELESS_FILTER_OPERATORS.has(normalizeFilterOperator(String(operator))); const kept = override.filter.filter((entry: any) => { if (Array.isArray(entry)) { // Legacy runtime triple: [field, operator, value] if (entry.length < 2) return false; const [, operator, value] = entry; - if (VALUELESS_FILTER_OPERATORS.has(String(operator))) return true; + if (isValueless(operator)) return true; return isFilterValueComplete(String(operator), value); } if (!entry || typeof entry !== 'object') return false; if (typeof entry.field !== 'string' || entry.field === '') return false; - if (VALUELESS_FILTER_OPERATORS.has(String(entry.operator))) return true; + if (isValueless(entry.operator)) return true; const value = entry.value; return isFilterValueComplete(String(entry.operator), value); }); diff --git a/packages/app-shell/src/views/drillEmptyBucketEscapeHatch-9159.test.tsx b/packages/app-shell/src/views/drillEmptyBucketEscapeHatch-9159.test.tsx index 7bd0e7029b..a669b167cb 100644 --- a/packages/app-shell/src/views/drillEmptyBucketEscapeHatch-9159.test.tsx +++ b/packages/app-shell/src/views/drillEmptyBucketEscapeHatch-9159.test.tsx @@ -144,7 +144,7 @@ describe('the drill escape hatch and the empty bucket (objectui#9159)', () => { expect(NULL_FILTER.flag).toBe('true'); expect(NULL_FILTER.op).toBe('is_null'); expect(NULL_FILTER.key).toBe('$null'); - expect(NULL_FILTER.labelKey).toBe('filterBuilder.operators.isNull'); + expect(NULL_FILTER.labelKey).toBe('filterBuilder.operators.is_null'); }); it('the chip the list renders names the condition instead of showing a bare `true`', () => { diff --git a/packages/app-shell/src/views/drillEmptyBucketNavHost-9085.test.ts b/packages/app-shell/src/views/drillEmptyBucketNavHost-9085.test.ts index 35055e874f..478d35ae2d 100644 --- a/packages/app-shell/src/views/drillEmptyBucketNavHost-9085.test.ts +++ b/packages/app-shell/src/views/drillEmptyBucketNavHost-9085.test.ts @@ -88,7 +88,7 @@ describe('drill escape hatch vs the empty bucket (objectui#9085, repaired by obj expect(NULL_FILTER.flag).toBe('true'); expect(NULL_FILTER.op).toBe('is_null'); expect(NULL_FILTER.key).toBe('$null'); - expect(NULL_FILTER.labelKey).toBe('filterBuilder.operators.isNull'); + expect(NULL_FILTER.labelKey).toBe('filterBuilder.operators.is_null'); }); it('the new spelling NO LONGER serializes identically to the bare null it replaced', () => { diff --git a/packages/app-shell/src/views/drillUrlFilters.test.ts b/packages/app-shell/src/views/drillUrlFilters.test.ts index c4d320fc89..3e7dcb5072 100644 --- a/packages/app-shell/src/views/drillUrlFilters.test.ts +++ b/packages/app-shell/src/views/drillUrlFilters.test.ts @@ -307,7 +307,7 @@ describe('the is-null operator: `filter[][null]=true`', () => { // existing operator key and the render site resolves it — pinned against a // real non-English render in `ObjectDataPage.filterChipI18n-9159.test.tsx`. expect(groupFilterChips([['owner', 'is_null', true]])).toEqual([ - { field: 'owner', textKey: 'filterBuilder.operators.isNull' }, + { field: 'owner', textKey: 'filterBuilder.operators.is_null' }, ]); // And it finishes NO text of its own, so nothing can render that bare // `true` even if the render site forgot the key. @@ -418,7 +418,7 @@ describe('the is-not-null operator and its synonyms (objectui#9508)', () => { it('renders a chip carrying the is-not-null operator KEY, not `= true`', () => { expect(groupFilterChips([['owner', 'is_not_null', true]])).toEqual([ - { field: 'owner', textKey: 'filterBuilder.operators.isNotNull' }, + { field: 'owner', textKey: 'filterBuilder.operators.is_not_null' }, ]); expect(groupFilterChips([['owner', 'is_not_null', true]])[0].text).toBeUndefined(); // And it is a DIFFERENT key from the is-null chip's — a single shared key diff --git a/packages/app-shell/src/views/drillUrlFilters.ts b/packages/app-shell/src/views/drillUrlFilters.ts index 4ff2b0017c..9b0fd8273a 100644 --- a/packages/app-shell/src/views/drillUrlFilters.ts +++ b/packages/app-shell/src/views/drillUrlFilters.ts @@ -152,13 +152,19 @@ export const NULL_FILTER = { * and translate it, and that family already has a locale-parity pin. The chip * arm below hands this OUT; resolving it is the render site's job. */ - labelKey: 'filterBuilder.operators.isNull', + labelKey: 'filterBuilder.operators.is_null', /** * Same family, same pin, for the inverse direction (objectui#9508) — the - * builder offers `isNotNull` as its own row, so this key is already in that + * builder offers `is_not_null` as its own row, so this key is already in that * parity pin's denominator and no eleventh translation is introduced here. + * + * Both keys follow the builder's operator ids, which are the protocol's own + * spellings since objectui#9306 (the family was keyed `isNull` / + * `isNotNull` before). A key left on the old spelling resolves to NOTHING and + * the chip renders the key itself — `ObjectDataPage.filterChipI18n-9159` + * renders the chip through the real packs, which is what catches that. */ - notLabelKey: 'filterBuilder.operators.isNotNull', + notLabelKey: 'filterBuilder.operators.is_not_null', } as const; /** diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.dateRoundTrip-9382.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.dateRoundTrip-9382.test.tsx index 237659c149..9e7c66269f 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.dateRoundTrip-9382.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.dateRoundTrip-9382.test.tsx @@ -85,7 +85,7 @@ const offeredBy = (type: string): string[] => operatorsForFieldType(type).map((o */ function probeValue(operator: string): unknown { if (operator === 'between') return ['2026-01-01', '2026-03-31']; - if (operator === 'in' || operator === 'notIn') return ['a']; + if (operator === 'in' || operator === 'not_in') return ['a']; return '2026-01-01'; } @@ -214,14 +214,14 @@ describe('controls — these are lit in BOTH directions and are not pins', () => it('greaterThan IS a member of the number bucket', () => { // Without this, "the date bucket does not contain greaterThan" could be a // lookup that answers false for every word. - expect(offeredBy('number')).toContain('greaterThan'); - expect(offeredBy('date')).not.toContain('greaterThan'); + expect(offeredBy('number')).toContain('greater_than'); + expect(offeredBy('date')).not.toContain('greater_than'); expect(offeredBy('date')).toContain('after'); }); it('a number column still round-trips greaterThan / lessThan unchanged', () => { - expect(roundTrip('amount', 'greaterThan', FIELDS).readBack).toBe('greaterThan'); - expect(roundTrip('amount', 'lessThan', FIELDS).readBack).toBe('lessThan'); + expect(roundTrip('amount', 'greater_than', FIELDS).readBack).toBe('greater_than'); + expect(roundTrip('amount', 'less_than', FIELDS).readBack).toBe('less_than'); }); }); @@ -229,7 +229,7 @@ describe('boundaries the repair must not cross', () => { it('reads back through the fixed table when no fields are supplied', () => { // The pure spec-shape callers pass no field list; they must keep the // unchanged default rather than get an invented answer. - expect(conditionToGroup({ closed_at: { $gt: '2026-01-01' } }).group.conditions[0].operator).toBe('greaterThan'); + expect(conditionToGroup({ closed_at: { $gt: '2026-01-01' } }).group.conditions[0].operator).toBe('greater_than'); }); it('a field not in the list is judged against the text bucket the builder draws for it, not given an invented operator', () => { @@ -239,7 +239,7 @@ describe('boundaries the repair must not cross', () => { // bucket, which does not offer `greaterThan`, so the row would open under a // BLANK operator trigger. objectui#10257 sends it to the Source tab, as // objectui#10062 already did for `$between` on the same column. - expect(offeredBy('text')).not.toContain('greaterThan'); + expect(offeredBy('text')).not.toContain('greater_than'); expect(conditionToGroup({ mystery: { $gt: 1 } }, FIELDS).representable).toBe(false); // CONTROL: a token the text bucket offers still reads back unchanged there. expect(conditionToGroup({ mystery: { $eq: 1 } }, FIELDS).group.conditions[0].operator).toBe('equals'); @@ -247,11 +247,11 @@ describe('boundaries the repair must not cross', () => { it('leaves the unambiguous tokens alone on a date column', () => { expect(roundTrip('closed_at', 'equals', FIELDS).readBack).toBe('equals'); - expect(roundTrip('closed_at', 'notEquals', FIELDS).readBack).toBe('notEquals'); - expect(roundTrip('closed_at', 'isNull', FIELDS).readBack).toBe('isNull'); - expect(roundTrip('closed_at', 'isNotNull', FIELDS).readBack).toBe('isNotNull'); - expect(roundTrip('closed_at', 'isEmpty', FIELDS).readBack).toBe('isEmpty'); - expect(roundTrip('closed_at', 'isNotEmpty', FIELDS).readBack).toBe('isNotEmpty'); + expect(roundTrip('closed_at', 'not_equals', FIELDS).readBack).toBe('not_equals'); + expect(roundTrip('closed_at', 'is_null', FIELDS).readBack).toBe('is_null'); + expect(roundTrip('closed_at', 'is_not_null', FIELDS).readBack).toBe('is_not_null'); + expect(roundTrip('closed_at', 'is_empty', FIELDS).readBack).toBe('is_empty'); + expect(roundTrip('closed_at', 'is_not_empty', FIELDS).readBack).toBe('is_not_empty'); }); it('does not widen what the bridge accepts', () => { diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.nullOperators-9363.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.nullOperators-9363.test.ts index c5cdb2080f..2ad843413b 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.nullOperators-9363.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.nullOperators-9363.test.ts @@ -62,15 +62,15 @@ describe('groupToCondition — the null predicates this inspector offers (object expect(groupToCondition(row('equals', 'acme'))).toEqual({ closed_at: { $eq: 'acme' } }); }); - it('isNull serializes to the dialect\'s null predicate instead of vanishing', () => { + it('is_null serializes to the dialect\'s null predicate instead of vanishing', () => { expect( - groupToCondition(row('isNull')), + groupToCondition(row('is_null')), 'an `Is null` row serialized to nothing; committing that ERASES dataset.filter', ).toEqual({ closed_at: { $null: true } }); }); - it('isNotNull serializes to the same predicate negated', () => { - expect(groupToCondition(row('isNotNull'))).toEqual({ closed_at: { $null: false } }); + it('is_not_null serializes to the same predicate negated', () => { + expect(groupToCondition(row('is_not_null'))).toEqual({ closed_at: { $null: false } }); }); it('keeps a null row alongside a complete one instead of dropping either', () => { @@ -79,7 +79,7 @@ describe('groupToCondition — the null predicates this inspector offers (object logic: 'and', conditions: [ { id: 'c1', field: 'stage', operator: 'equals', value: 'won' }, - { id: 'c2', field: 'closed_at', operator: 'isNull', value: '' }, + { id: 'c2', field: 'closed_at', operator: 'is_null', value: '' }, ], })).toEqual({ $and: [{ stage: { $eq: 'won' } }, { closed_at: { $null: true } }] }); }); @@ -94,7 +94,7 @@ describe('groupToCondition — the null predicates this inspector offers (object expect(representable).toBe(true); const edited: BuilderGroup = { ...group, - conditions: [{ ...group.conditions[0], operator: 'isNull', value: '' }], + conditions: [{ ...group.conditions[0], operator: 'is_null', value: '' }], }; expect( groupToCondition(edited), @@ -103,8 +103,8 @@ describe('groupToCondition — the null predicates this inspector offers (object }); it('leaves the $exists pair exactly as it was', () => { - expect(groupToCondition(row('isEmpty'))).toEqual({ closed_at: { $exists: false } }); - expect(groupToCondition(row('isNotEmpty'))).toEqual({ closed_at: { $exists: true } }); + expect(groupToCondition(row('is_empty'))).toEqual({ closed_at: { $exists: false } }); + expect(groupToCondition(row('is_not_empty'))).toEqual({ closed_at: { $exists: true } }); }); it('still drops an operator it does not map, rather than emitting a wrong filter', () => { @@ -114,8 +114,10 @@ describe('groupToCondition — the null predicates this inspector offers (object // remaining drop inert; objectui#10062 took the fourth, `between`, behind // the both-bounds rule. None this inspector OFFERS is left (the partition // below), so the fixture is an operator the builder draws only when a - // caller grants it — `containsCaseInsensitive`, which this one does not. - expect(groupToCondition(row('containsCaseInsensitive', 'x'))).toBeUndefined(); + // caller grants it — `exists`, which this one does not. (It was + // `containsCaseInsensitive` until objectui#9306 made the case-insensitive + // contains an ordinary operator, which this bridge now maps.) + expect(groupToCondition(row('exists'))).toBeUndefined(); }); it('an empty group is still `undefined` — that is the author CLEARING the filter', () => { @@ -145,11 +147,11 @@ describe('the emitted token is the spec\'s, not a local invention (objectui#9363 describe('conditionToGroup — the read half round-trips the new shape (objectui#9363)', () => { it('reads a stored $null back as the operator the author picked', () => { expect(conditionToGroup({ closed_at: { $null: true } })).toEqual({ - group: { id: 'g', logic: 'and', conditions: [{ id: 'c0', field: 'closed_at', operator: 'isNull', value: '' }] }, + group: { id: 'g', logic: 'and', conditions: [{ id: 'c0', field: 'closed_at', operator: 'is_null', value: '' }] }, representable: true, }); expect(conditionToGroup({ closed_at: { $null: false } }).group.conditions[0].operator) - .toBe('isNotNull'); + .toBe('is_not_null'); }); it('round-trips condition → group → condition', () => { @@ -210,7 +212,7 @@ const DECLARED_UNEXPRESSIBLE: string[] = []; /** A value that keeps a row from being dropped as INCOMPLETE, per operator. */ function probeValue(operator: string): unknown { if (VALUELESS_FILTER_BUILDER_OPERATORS.has(operator)) return ''; - if (operator === 'in' || operator === 'notIn') return ['a', 'b']; + if (operator === 'in' || operator === 'not_in') return ['a', 'b']; if (operator === 'between') return [1, 5]; return 'x'; } @@ -235,7 +237,7 @@ describe('every operator this inspector OFFERS is either expressible or declared expect(expressible.sort()).toEqual(OFFERED.filter((o) => !DECLARED_UNEXPRESSIBLE.includes(o))); // And the null pair is on the expressible side — the card's defect, stated // as a fact about the offering rather than about two literals. - expect(expressible).toContain('isNull'); - expect(expressible).toContain('isNotNull'); + expect(expressible).toContain('is_null'); + expect(expressible).toContain('is_not_null'); }); }); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.readHalfHolds-10257.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.readHalfHolds-10257.test.tsx index 7c5e42ed5f..206e64a411 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.readHalfHolds-10257.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.readHalfHolds-10257.test.tsx @@ -206,12 +206,12 @@ describe('class 2 — a stored token is not opened as an operator the column\'s /** The addendum's measured rows (comment 5817965568), plus the value-less tokens on a boolean column. */ const OFF_BUCKET: ReadonlyArray<{ stored: FilterCondition; type: string; readAs: string }> = [ { stored: { closed_at: { $in: ['2026-01-01', '2026-02-01'] } }, type: 'date', readAs: 'in' }, - { stored: { name: { $gt: 'm' } }, type: 'text', readAs: 'greaterThan' }, + { stored: { name: { $gt: 'm' } }, type: 'text', readAs: 'greater_than' }, { stored: { amount: { $in: [1, 2] } }, type: 'number', readAs: 'in' }, // "Every token" includes the value-less arms: the boolean bucket offers // only `equals` / `notEquals`. - { stored: { flag: { $exists: true } }, type: 'boolean', readAs: 'isNotEmpty' }, - { stored: { flag: { $null: false } }, type: 'boolean', readAs: 'isNotNull' }, + { stored: { flag: { $exists: true } }, type: 'boolean', readAs: 'is_not_empty' }, + { stored: { flag: { $null: false } }, type: 'boolean', readAs: 'is_not_null' }, ]; it('the premise: each read-back operator is missing from its column\'s bucket, and the builder would reconcile it to `equals`', () => { @@ -341,7 +341,7 @@ describe('the invariant, swept: every row the read half opens, the panel draws a const finished = (operator: string): unknown => { if (VALUELESS_FILTER_BUILDER_OPERATORS.has(operator)) return ''; if (operator === 'between') return ['2026-01-01', '2026-03-31']; - if (operator === 'in' || operator === 'notIn') return ['a']; + if (operator === 'in' || operator === 'not_in') return ['a']; return '2026-01-01'; }; const lost: string[] = []; diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.test.ts index 9fd586901f..25e4715a6a 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.test.ts @@ -11,12 +11,12 @@ describe('datasetFilterCondition', () => { it('serializes multiple conditions as a flat $and', () => { expect(groupToCondition({ logic: 'and', conditions: [ { field: 'stage', operator: 'equals', value: 'won' }, - { field: 'amount', operator: 'greaterThan', value: 1000 }, + { field: 'amount', operator: 'greater_than', value: 1000 }, ] })).toEqual({ $and: [{ stage: { $eq: 'won' } }, { amount: { $gt: 1000 } }] }); }); it('maps isEmpty/isNotEmpty to $exists', () => { - expect(groupToCondition({ logic: 'and', conditions: [{ field: 'closed_at', operator: 'isNotEmpty' }] })) + expect(groupToCondition({ logic: 'and', conditions: [{ field: 'closed_at', operator: 'is_not_empty' }] })) .toEqual({ closed_at: { $exists: true } }); }); @@ -25,9 +25,11 @@ describe('datasetFilterCondition', () => { // being unmapped in objectui#9372 and `between` in objectui#10062 (both // asserted as EMITTED in `datasetFilterCondition.unmappedInert-9372`), so // keeping either here would pin a branch it no longer reaches — an - // assertion that passes because nothing is produced. `containsCaseInsensitive` - // is an opt-in operator this bridge does not map. - expect(groupToCondition({ logic: 'and', conditions: [{ field: 'x', operator: 'containsCaseInsensitive', value: 'ac' }] })) + // assertion that passes because nothing is produced. `exists` is an opt-in + // operator this bridge does not map (the fixture was + // `containsCaseInsensitive` until objectui#9306 made that one ordinary and + // mapped it). + expect(groupToCondition({ logic: 'and', conditions: [{ field: 'x', operator: 'exists', value: '' }] })) .toBeUndefined(); }); @@ -43,10 +45,10 @@ describe('datasetFilterCondition', () => { // a complete row alongside an incomplete one keeps only the complete one expect(groupToCondition({ logic: 'and', conditions: [ { field: 'stage', operator: 'equals', value: 'won' }, - { field: 'amount', operator: 'greaterThan', value: '' }, + { field: 'amount', operator: 'greater_than', value: '' }, ] })).toEqual({ stage: { $eq: 'won' } }); // value-less operators are still kept - expect(groupToCondition({ logic: 'and', conditions: [{ field: 'closed_at', operator: 'isNotEmpty', value: '' }] })) + expect(groupToCondition({ logic: 'and', conditions: [{ field: 'closed_at', operator: 'is_not_empty', value: '' }] })) .toEqual({ closed_at: { $exists: true } }); }); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.ts b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.ts index 5037f5c55a..bc50245253 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.ts @@ -2,7 +2,8 @@ /** * Bridge between the visual {@link FilterBuilder} (a flat `FilterGroup` of - * `{field, operator, value}` rows, camelCase operators) and the spec + * `{field, operator, value}` rows, whose operator ids are the protocol's own + * `VIEW_FILTER_OPERATORS` spellings since objectui#9306) and the spec * `FilterCondition` (Mongo-style `{ field: { $op: value } }`, conjoined with * `$and`) stored on `dataset.filter` / `measure.filter`. * @@ -28,26 +29,46 @@ * emitted only with both bounds present (objectui#10062). * * The write half is deliberately NOT injective — the spec carries one token for - * "strictly greater", which both `greaterThan` and `after` have to use — so the + * "strictly greater", which both `greater_than` and `after` have to use — so the * read half cannot be a plain inverse table. {@link readBackOperator} settles * the ambiguous tokens against the field's own operator bucket (objectui#9382). */ import { isFilterValueComplete, operatorsForFieldType } from '@object-ui/components'; -/** FilterBuilder camelCase operator → FilterCondition Mongo operator. */ +/** + * FilterBuilder operator → FilterCondition Mongo operator. + * + * Keyed on the builder's ids, which are the protocol's canonical operator ids + * since objectui#9306 (they were camelCase before). Every row reaching this + * bridge carries one: the builder folds a stored deprecated camelCase id onto + * its canonical twin at its own read boundary, and {@link conditionToGroup} + * reads a stored `$`-token straight onto the canonical id. A camelCase key here + * would therefore match nothing — and an unmatched operator is DROPPED (see the + * `continue` in {@link groupToCondition}), so this table is pinned id for id, + * with the stored predicate each one writes, by + * `filter-builder-protocol-ids-census-9306.test.ts`. + */ const OP_TO_MONGO: Record = { - equals: '$eq', notEquals: '$ne', - greaterThan: '$gt', greaterOrEqual: '$gte', lessThan: '$lt', lessOrEqual: '$lte', + equals: '$eq', not_equals: '$ne', + greater_than: '$gt', greater_than_or_equal: '$gte', less_than: '$lt', less_than_or_equal: '$lte', after: '$gt', before: '$lt', - contains: '$contains', in: '$in', notIn: '$nin', + contains: '$contains', in: '$in', not_in: '$nin', // objectui#9372. The builder offers these three only on its TEXT bucket, // which is the side the spec's declared-type door passes them on // (`TEXT_OPERATOR_DOOR_CASES`: `passes` over `text`, `door-refusal` over // `number` / `date` / `boolean`), and every filter backend answers them // against the same canonical table (`FILTER_TEXT_CASES`). So mapping them is // a bridge to a predicate the platform already agrees on, not a new claim. - notContains: '$notContains', startsWith: '$startsWith', endsWith: '$endsWith', + not_contains: '$notContains', starts_with: '$startsWith', ends_with: '$endsWith', + // objectui#9306. The case-insensitive contains stopped being opt-in when the + // dropdown took the protocol's id for it (see `OPT_IN_OPERATORS` in + // `@object-ui/components`), so this inspector offers it on the text bucket + // like any other text operator. It is bridged on exactly the ground the three + // above are: `$icontains` is a `FILTER_OPERATORS` member, and both + // `TEXT_OPERATOR_DOOR_CASES` and `FILTER_TEXT_CASES` carry it. It is the same + // token `FilterConditionField` has always written for this row. + icontains: '$icontains', // objectui#10062 (ruling batch #146 item 5, letter A). A PAIR operator, // offered on the builder's date bucket, stored as the spec's own // `{ $between: [lo, hi] }`. It was held back only because the builder can @@ -65,10 +86,11 @@ const OP_TO_MONGO: Record = { * see {@link MONGO_PREIMAGE} and {@link readBackOperator}. */ const MONGO_TO_OP: Record = { - $eq: 'equals', $ne: 'notEquals', - $gt: 'greaterThan', $gte: 'greaterOrEqual', $lt: 'lessThan', $lte: 'lessOrEqual', - $contains: 'contains', $in: 'in', $nin: 'notIn', - $notContains: 'notContains', $startsWith: 'startsWith', $endsWith: 'endsWith', + $eq: 'equals', $ne: 'not_equals', + $gt: 'greater_than', $gte: 'greater_than_or_equal', $lt: 'less_than', $lte: 'less_than_or_equal', + $contains: 'contains', $in: 'in', $nin: 'not_in', + $notContains: 'not_contains', $startsWith: 'starts_with', $endsWith: 'ends_with', + $icontains: 'icontains', $between: 'between', }; @@ -84,7 +106,7 @@ const MONGO_TO_OP: Record = { * The first two are disambiguated by their PAYLOAD, in the `$exists` / `$null` * arms of {@link conditionToGroup}, because the stored value is the boolean * that picks the operator. `$gt` / `$lt` carry the author's comparand instead, - * so no bit of the stored condition tells `after` from `greaterThan` — which + * so no bit of the stored condition tells `after` from `greater_than` — which * is why the field's declared type has to. */ const MONGO_PREIMAGE: Record = (() => { @@ -101,7 +123,7 @@ export interface BuilderFieldDef { value: string; label?: string; type?: string * * ## Why the type has to be consulted (objectui#9382) * - * `after` and `greaterThan` both write `$gt`, and the spec's filter vocabulary + * `after` and `greater_than` both write `$gt`, and the spec's filter vocabulary * has exactly one token for "strictly greater" — there is no `$after` for the * write half to have used. So the collapse is not a defect in what gets stored: * the stored filter is correct and filters correctly. What was lost is only the @@ -150,7 +172,7 @@ function readBackOperator(mop: string, fieldType: string | undefined): string | * no input for it — so they are matched ahead of the value-completeness check * in {@link groupToCondition}, not after it. * - * `isNull` / `isNotNull` are not a spelling of `isEmpty` / `isNotEmpty`. The + * `is_null` / `is_not_null` are not a spelling of `is_empty` / `is_not_empty`. The * dropdown offers both pairs as their own rows and the spec's filter vocabulary * carries both `$null` and `$exists`, so they stay distinct in both directions; * collapsing them would draw two labels for one wire predicate and rewrite the @@ -165,8 +187,8 @@ function readBackOperator(mop: string, fieldType: string | undefined): string | * with no error and the condition still on screen. */ const VALUELESS_TO_MONGO: Record> = { - isEmpty: { $exists: false }, isNotEmpty: { $exists: true }, - isNull: { $null: true }, isNotNull: { $null: false }, + is_empty: { $exists: false }, is_not_empty: { $exists: true }, + is_null: { $null: true }, is_not_null: { $null: false }, }; export interface BuilderCondition { id?: string; field: string; operator: string; value?: unknown } @@ -373,14 +395,14 @@ export function conditionToGroup( if (opKeys.length !== 1) return { group: empty, representable: false }; const mop = opKeys[0]; if (mop === '$exists') { - row = { id: `c${i}`, field, operator: v.$exists ? 'isNotEmpty' : 'isEmpty', value: '' }; + row = { id: `c${i}`, field, operator: v.$exists ? 'is_not_empty' : 'is_empty', value: '' }; } else if (mop === '$null') { // The inverse of the write half: `$null: false` is "is not null", so // the boolean picks the operator rather than becoming the row's value. // Without this arm a filter this bridge now WRITES would read back as // non-representable, sending the author to the Source tab for a row the // builder can draw. - row = { id: `c${i}`, field, operator: v.$null ? 'isNull' : 'isNotNull', value: '' }; + row = { id: `c${i}`, field, operator: v.$null ? 'is_null' : 'is_not_null', value: '' }; } else { const op = readBackOperator(mop, fields?.find((f) => f.value === field)?.type); if (!op) return { group: empty, representable: false }; diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.unmappedInert-9372.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.unmappedInert-9372.test.ts index f3c810180a..395e17da5e 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.unmappedInert-9372.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.unmappedInert-9372.test.ts @@ -158,11 +158,13 @@ describe('(ii) an operator this bridge cannot express is inert, not destructive it('an operator this bridge does not map at all is still inert, not destructive', () => { // The unmapped arm outlives objectui#10062 for operators this inspector - // does not offer — `containsCaseInsensitive` is an opt-in the builder draws - // only when a caller grants it, and this caller grants none. - expect(operatorsForFieldType('text', []).map((o) => o.value)).not.toContain('containsCaseInsensitive'); - expect(groupToCondition(row('containsCaseInsensitive', 'ac'))).toBeUndefined(); - expect(commitFor(row('containsCaseInsensitive', 'ac'))).toEqual({ hold: true }); + // does not offer — `exists` is an opt-in the builder draws only when a + // caller grants it, and this caller grants none. (The fixture was + // `containsCaseInsensitive` until objectui#9306 made the case-insensitive + // contains an ordinary, mapped operator.) + expect(operatorsForFieldType('text', []).map((o) => o.value)).not.toContain('exists'); + expect(groupToCondition(row('exists'))).toBeUndefined(); + expect(commitFor(row('exists'))).toEqual({ hold: true }); }); it('THE GESTURE, blank-value route: blanking the only row\'s value commits NOTHING — no operator needed', () => { @@ -204,11 +206,16 @@ describe('(ii) an operator this bridge cannot express is inert, not destructive }); }); -describe('(i) the three text operators this bridge now expresses (objectui#9372)', () => { +describe('(i) the text operators this bridge now expresses (objectui#9372, objectui#9306)', () => { + // `icontains` joined in objectui#9306, when it stopped being opt-in: every + // leg below — the token, the round trip, both conformance tables, both + // doors and the text-only offering — holds for it exactly as for the three + // objectui#9372 bridged. const MAPPED: ReadonlyArray = [ - ['notContains', '$notContains'], - ['startsWith', '$startsWith'], - ['endsWith', '$endsWith'], + ['icontains', '$icontains'], + ['not_contains', '$notContains'], + ['starts_with', '$startsWith'], + ['ends_with', '$endsWith'], ]; it('serializes each one to the spec\'s own token', () => { diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/filter-builder-protocol-ids-census-9306.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/filter-builder-protocol-ids-census-9306.test.ts new file mode 100644 index 0000000000..03440b856f --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/filter-builder-protocol-ids-census-9306.test.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The 22-id round-trip census across every consumer of the FilterBuilder's + * operator ids (objectui#9306). + * + * The dropdown stopped speaking camelCase and started speaking the protocol's + * own ids. Every table that reads a builder row was keyed on the camelCase + * spelling, and the hazard is specific: `FilterConditionField.condToMongo` + * has a `default` arm that stores an EQUALITY, and the dataset bridge DROPS an + * operator it does not map. A table left on camelCase would not fail — it + * would store a different predicate. So the ruling's bar is "⛔ no row may + * change which predicate it stores", and this file is where it is held. + * + * ## What each column is, and where its "before" came from + * + * The expected value in every column is the predicate the SAME row stored on + * the tree before objectui#9306, when it carried the camelCase id — measured + * once, by running each consumer over the 22 camelCase rows on the base tree + * and over the 22 canonical rows on the branch (recorded in the PR). Every + * cell matched, with one named exception: the dataset bridge column for + * `icontains`. That operator was opt-in as `containsCaseInsensitive`, and the + * dataset inspector never granted it, so no row there could carry it and the + * bridge had no mapping; objectui#9306 made it an ordinary operator and mapped + * it to `$icontains`, the token `FilterConditionField` has always written for + * it. A new row, not a changed one. + * + * - `mongo` / `readBack` — `@object-ui/fields`' sharing-rule criteria + * (`condToMongo`, then `kvToCondition` on what it wrote). + * - `dataset` / `datasetReadBack` — `app-shell`'s `dataset.filter` bridge + * (`groupToCondition`, then `conditionToGroup`). + * - the saved-view fold and the override recovery pass, which fold every + * spelling through the spec's `normalizeFilterOperator` and are asked to + * answer the canonical row and the camelCase row IDENTICALLY. + * + * The live grid (`plugin-list`'s `convertFilterGroupToAST`) and the view + * config reader (`plugin-view`'s `specToBuilderOperator`) are not exported to + * this package; their legs of the same census live beside them, in + * `convertFilterGroupToAST.canonicalSpelling.test.ts` and + * `view-operator-builder-parity.test.ts`. + */ +import { describe, it, expect } from 'vitest'; +import { FILTER_BUILDER_OPERATORS, normalizeFilterBuilderOperator } from '@object-ui/components'; +import { condToMongo, kvToCondition } from '@object-ui/fields'; +import { groupToCondition, conditionToGroup } from './datasetFilterCondition'; +import { foldFilterGroupToSpecRules } from '../../viewFilterFold'; +import { sanitizeViewOverride } from '../../ObjectView'; + +const DATE = '2026-01-01'; + +/** + * One row per former dropdown id: the camelCase id, the protocol id it became + * (the amendment's mapping, with ruling B keeping the existence pair + * unfolded), a value that makes the row complete, and the predicate each + * consumer stores for it. + */ +const CENSUS: ReadonlyArray<{ + legacy: string; + id: string; + value: unknown; + mongo: unknown; + readBack: string; + dataset: unknown; + datasetReadBack: string | null; +}> = [ + { legacy: 'equals', id: 'equals', value: 'x', mongo: { f: 'x' }, readBack: 'equals', dataset: { f: { $eq: 'x' } }, datasetReadBack: 'equals' }, + { legacy: 'notEquals', id: 'not_equals', value: 'x', mongo: { f: { $ne: 'x' } }, readBack: 'not_equals', dataset: { f: { $ne: 'x' } }, datasetReadBack: 'not_equals' }, + { legacy: 'contains', id: 'contains', value: 'x', mongo: { f: { $contains: 'x' } }, readBack: 'contains', dataset: { f: { $contains: 'x' } }, datasetReadBack: 'contains' }, + // The named exception in the `dataset` column — see the file header. + { legacy: 'containsCaseInsensitive', id: 'icontains', value: 'x', mongo: { f: { $icontains: 'x' } }, readBack: 'icontains', dataset: { f: { $icontains: 'x' } }, datasetReadBack: 'icontains' }, + { legacy: 'notContains', id: 'not_contains', value: 'x', mongo: { f: { $notContains: 'x' } }, readBack: 'not_contains', dataset: { f: { $notContains: 'x' } }, datasetReadBack: 'not_contains' }, + { legacy: 'isEmpty', id: 'is_empty', value: '', mongo: { f: { $in: [null, ''] } }, readBack: 'is_empty', dataset: { f: { $exists: false } }, datasetReadBack: 'is_empty' }, + { legacy: 'isNotEmpty', id: 'is_not_empty', value: '', mongo: { f: { $nin: [null, ''] } }, readBack: 'is_not_empty', dataset: { f: { $exists: true } }, datasetReadBack: 'is_not_empty' }, + { legacy: 'greaterThan', id: 'greater_than', value: 5, mongo: { f: { $gt: 5 } }, readBack: 'greater_than', dataset: { f: { $gt: 5 } }, datasetReadBack: 'greater_than' }, + { legacy: 'lessThan', id: 'less_than', value: 5, mongo: { f: { $lt: 5 } }, readBack: 'less_than', dataset: { f: { $lt: 5 } }, datasetReadBack: 'less_than' }, + { legacy: 'greaterOrEqual', id: 'greater_than_or_equal', value: 5, mongo: { f: { $gte: 5 } }, readBack: 'greater_than_or_equal', dataset: { f: { $gte: 5 } }, datasetReadBack: 'greater_than_or_equal' }, + { legacy: 'lessOrEqual', id: 'less_than_or_equal', value: 5, mongo: { f: { $lte: 5 } }, readBack: 'less_than_or_equal', dataset: { f: { $lte: 5 } }, datasetReadBack: 'less_than_or_equal' }, + // `before` / `after` share `$lt` / `$gt` with the comparison pair, so a + // field-less read-back lands on the comparison id — a collapse that predates + // this change and that objectui#9382 settles from the field's type. + { legacy: 'before', id: 'before', value: DATE, mongo: { f: { $lt: DATE } }, readBack: 'less_than', dataset: { f: { $lt: DATE } }, datasetReadBack: 'less_than' }, + { legacy: 'after', id: 'after', value: DATE, mongo: { f: { $gt: DATE } }, readBack: 'greater_than', dataset: { f: { $gt: DATE } }, datasetReadBack: 'greater_than' }, + { legacy: 'between', id: 'between', value: [1, 5], mongo: { f: { $gte: 1, $lte: 5 } }, readBack: 'between', dataset: { f: { $between: [1, 5] } }, datasetReadBack: 'between' }, + { legacy: 'in', id: 'in', value: ['a', 'b'], mongo: { f: { $in: ['a', 'b'] } }, readBack: 'in', dataset: { f: { $in: ['a', 'b'] } }, datasetReadBack: 'in' }, + { legacy: 'notIn', id: 'not_in', value: ['a', 'b'], mongo: { f: { $nin: ['a', 'b'] } }, readBack: 'not_in', dataset: { f: { $nin: ['a', 'b'] } }, datasetReadBack: 'not_in' }, + { legacy: 'startsWith', id: 'starts_with', value: 'x', mongo: { f: { $startsWith: 'x' } }, readBack: 'starts_with', dataset: { f: { $startsWith: 'x' } }, datasetReadBack: 'starts_with' }, + { legacy: 'endsWith', id: 'ends_with', value: 'x', mongo: { f: { $endsWith: 'x' } }, readBack: 'ends_with', dataset: { f: { $endsWith: 'x' } }, datasetReadBack: 'ends_with' }, + { legacy: 'isNull', id: 'is_null', value: '', mongo: { f: { $null: true } }, readBack: 'is_null', dataset: { f: { $null: true } }, datasetReadBack: 'is_null' }, + { legacy: 'isNotNull', id: 'is_not_null', value: '', mongo: { f: { $null: false } }, readBack: 'is_not_null', dataset: { f: { $null: false } }, datasetReadBack: 'is_not_null' }, + // Ruling B: stored as `$exists`, never folded onto `$null`. The dataset + // inspector does not grant the pair, so its bridge has no row for it. + { legacy: 'exists', id: 'exists', value: '', mongo: { f: { $exists: true } }, readBack: 'exists', dataset: undefined, datasetReadBack: null }, + { legacy: 'notExists', id: 'notExists', value: '', mongo: { f: { $exists: false } }, readBack: 'notExists', dataset: undefined, datasetReadBack: null }, +]; + +const noTypes = () => undefined; +const row = (operator: string, value: unknown) => ({ id: 'c1', field: 'f', operator, value }); +const group = (operator: string, value: unknown) => ({ id: 'root', logic: 'and' as const, conditions: [row(operator, value)] }); + +describe('objectui#9306 census — the table covers the whole vocabulary', () => { + it('has one row per id the dropdown draws, and each row\'s id is what the builder reads its legacy id as', () => { + expect(CENSUS).toHaveLength(22); + expect(CENSUS.map((r) => r.id).sort()).toEqual([...FILTER_BUILDER_OPERATORS].sort()); + for (const r of CENSUS) expect(normalizeFilterBuilderOperator(r.legacy), r.legacy).toBe(r.id); + }); +}); + +describe('objectui#9306 census — sharing-rule criteria (`condToMongo` / `kvToCondition`)', () => { + it.each(CENSUS)('`$id` stores the predicate `$legacy` stored', ({ id, value, mongo }) => { + // ⚠️ The column that matters most: `condToMongo`'s `default` arm stores an + // EQUALITY, so a protocol id with no arm would not throw — it would + // silently become `{ f: value }`, and a sharing rule would match a + // different set of records than the one on screen. + expect(condToMongo(row(id, value) as never, noTypes)).toEqual(mongo); + }); + + it.each(CENSUS)('`$id` reads back as `$readBack`', ({ id, value, readBack }) => { + const stored = condToMongo(row(id, value) as never, noTypes) as Record; + const [[field, v]] = Object.entries(stored); + expect(kvToCondition(field, v, 0)?.operator).toBe(readBack); + }); + + it('CONTROL: an id with no arm really does fall to the equality — the hazard is live', () => { + // Without this the column above could pass for a `condToMongo` that + // refused every unknown id, and the equality hazard would be unmeasured. + expect(condToMongo(row('no_such_operator', 5) as never, noTypes)).toEqual({ f: 5 }); + }); +}); + +describe('objectui#9306 census — dataset filters (`groupToCondition` / `conditionToGroup`)', () => { + it.each(CENSUS)('`$id` stores the dataset predicate', ({ id, value, dataset }) => { + expect(groupToCondition(group(id, value))).toEqual(dataset); + }); + + it.each(CENSUS.filter((r) => r.dataset !== undefined))( + '`$id` reads back as `$datasetReadBack`', + ({ dataset, datasetReadBack }) => { + const { group: g, representable } = conditionToGroup(dataset as never); + expect(representable).toBe(true); + expect(g.conditions.map((c) => c.operator)).toEqual([datasetReadBack]); + }, + ); +}); + +describe('objectui#9306 census — the folding readers answer both spellings identically', () => { + it.each(CENSUS.filter((r) => r.legacy !== 'containsCaseInsensitive'))( + 'the saved-view fold stores the same rule for `$legacy` and `$id`', + ({ legacy, id, value }) => { + const canonical = foldFilterGroupToSpecRules(group(id, value)); + expect(foldFilterGroupToSpecRules(group(legacy, value))).toEqual(canonical); + expect(canonical.ok).toBe(true); + }, + ); + + it('`containsCaseInsensitive` is the one spelling that fold cannot read — and the builder reads it first', () => { + // The spec's alias table has no row for it (objectstack-ai/objectstack#20092), + // so the fold passes it verbatim and the server's enum refuses it. It never + // reaches the fold from the builder: the builder folds it onto `icontains` + // at its own read boundary, and it was never offered to the view surfaces. + expect(foldFilterGroupToSpecRules(group('containsCaseInsensitive', 'x'))).toEqual({ + ok: true, + rules: [{ field: 'f', operator: 'containsCaseInsensitive', value: 'x' }], + }); + expect(normalizeFilterBuilderOperator('containsCaseInsensitive')).toBe('icontains'); + expect(foldFilterGroupToSpecRules(group('icontains', 'x'))).toEqual({ + ok: true, + rules: [{ field: 'f', operator: 'icontains', value: 'x' }], + }); + }); + + it.each(CENSUS)('the override recovery pass keeps or drops `$legacy` exactly as `$id`', ({ legacy, id, value }) => { + const keep = (operator: string) => { + const overlay = { name: 'v', object: 'o', filter: [{ field: 'f', operator, value }] }; + return sanitizeViewOverride(overlay) === overlay; + }; + expect(keep(legacy)).toBe(keep(id)); + // Every census row is complete, so both are kept — a drop here is a stored + // filter losing a condition on read. + expect(keep(id)).toBe(true); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/widgets.tsx b/packages/app-shell/src/views/metadata-admin/widgets.tsx index 64b9fefe33..c1b5b785cf 100644 --- a/packages/app-shell/src/views/metadata-admin/widgets.tsx +++ b/packages/app-shell/src/views/metadata-admin/widgets.tsx @@ -2384,21 +2384,15 @@ function ActionMultiWidget({ value, onChange, readOnly, context, ariaLabelledBy /* the shared fold normalizes through the spec's own */ /* `normalizeFilterOperator`, which covers the four builder operators this */ /* table had drifted behind (startsWith / endsWith / isNull / isNotNull). */ -/** Spec operator → FilterBuilder camelCase. Keys cover both the canonical - * vocabulary and legacy spellings (shorthand + snake/camel) so stored view - * metadata written before canonicalization still seeds the builder. */ -const SPEC_TO_FB: Record = { - equals: 'equals', eq: 'equals', - not_equals: 'notEquals', ne: 'notEquals', neq: 'notEquals', notEquals: 'notEquals', - contains: 'contains', not_contains: 'notContains', notContains: 'notContains', - is_empty: 'isEmpty', isEmpty: 'isEmpty', is_not_empty: 'isNotEmpty', isNotEmpty: 'isNotEmpty', - greater_than: 'greaterThan', gt: 'greaterThan', greaterThan: 'greaterThan', - less_than: 'lessThan', lt: 'lessThan', lessThan: 'lessThan', - greater_than_or_equal: 'greaterOrEqual', gte: 'greaterOrEqual', greaterOrEqual: 'greaterOrEqual', - less_than_or_equal: 'lessOrEqual', lte: 'lessOrEqual', lessOrEqual: 'lessOrEqual', - before: 'before', after: 'after', between: 'between', - in: 'in', not_in: 'notIn', nin: 'notIn', notIn: 'notIn', -}; +/* */ +/* The READ direction's local `SPEC_TO_FB` table (spec spelling → builder */ +/* camelCase) is gone too (objectui#9306). The builder's ids ARE the spec's */ +/* canonical spellings now, and the builder folds every other spelling a */ +/* stored rule may carry — the spec's legacy aliases (`gt`, `nin`, …) and the */ +/* deprecated camelCase ids (`greaterOrEqual`, …) — through the spec's own */ +/* `normalizeFilterOperator` at its read boundary. A stored rule is therefore */ +/* handed over as authored; a hand-kept copy of that fold here is exactly how */ +/* this file drifted before. */ interface FilterRuleLite { field: string; operator: string; value?: unknown } @@ -2439,9 +2433,10 @@ function FilterBuilderField({ value, onChange, fields, readOnly, id, ariaLabelle logic: 'and' as const, conditions: rules.map((r, i) => ({ id: `c${i}`, - // Keep the raw operator verbatim if the builder has no camelCase - // equivalent, so it round-trips on save instead of being rewritten. - operator: SPEC_TO_FB[r.operator] ?? r.operator ?? 'equals', + // Handed over as stored: the builder reads it through the spec's fold + // (objectui#9306), and a spelling nothing folds is kept verbatim so it + // round-trips on save instead of being rewritten. + operator: r.operator ?? 'equals', field: r.field, value: (r.value as any) ?? '', })), diff --git a/packages/app-shell/src/views/viewFilterFold.emptyValue.test.ts b/packages/app-shell/src/views/viewFilterFold.emptyValue.test.ts index 1957994b31..4639b7c1e7 100644 --- a/packages/app-shell/src/views/viewFilterFold.emptyValue.test.ts +++ b/packages/app-shell/src/views/viewFilterFold.emptyValue.test.ts @@ -35,6 +35,7 @@ import { describe, it, expect } from 'vitest'; import { VALUELESS_FILTER_BUILDER_OPERATORS } from '@object-ui/components'; +import { normalizeFilterOperator } from '@objectstack/spec/ui'; import { foldFilterGroupToSpecRules, VALUELESS_FILTER_OPERATORS } from './viewFilterFold'; const group = (conditions: unknown[], logic = 'and') => ({ id: 'root', logic, conditions }); @@ -151,17 +152,44 @@ describe('VALUELESS_FILTER_OPERATORS — parity with the builder’s own set', ( } }); - it('adds the canonical spec spellings the builder never sees', () => { + it('covers the canonical spec spellings a stored rule carries', () => { // The fold reads a stored `ViewFilterRule`, whose operator has been - // through `normalizeFilterOperator`; those spellings are this layer's - // own addition and belong to no dropdown. + // through `normalizeFilterOperator`. Since objectui#9306 those canonical + // spellings ARE the builder's own ids, so the builder's set carries them + // too — which is the inversion of what this pin used to assert (that + // they were this layer's addition and belonged to no dropdown). for (const operator of ['is_empty', 'is_not_empty', 'is_null', 'is_not_null']) { expect(VALUELESS_FILTER_OPERATORS.has(operator), operator).toBe(true); expect( VALUELESS_FILTER_BUILDER_OPERATORS.has(operator), - `"${operator}" is a canonical spec spelling, not a builder id — it must not ` - + 'have crept into the shared set', - ).toBe(false); + `"${operator}" is the builder's own id since objectui#9306`, + ).toBe(true); } }); + + it('does NOT list the deprecated camelCase spellings — readers fold instead (objectui#9306)', () => { + // A row stored before objectui#9306 may still carry `isEmpty`, and it + // must still read as value-less. That is carried by FOLDING the row's + // spelling (the fold below and `sanitizeViewOverride` in ObjectView.tsx + // both do), never by a second spelling in this set: listing the retired + // ids here is the second vocabulary that ruling refuses. + for (const operator of ['isEmpty', 'isNotEmpty', 'isNull', 'isNotNull']) { + expect(VALUELESS_FILTER_OPERATORS.has(operator), operator).toBe(false); + expect(VALUELESS_FILTER_OPERATORS.has(normalizeFilterOperator(operator)), operator).toBe(true); + } + }); + + it('a stored camelCase value-less row survives the fold (objectui#9306)', () => { + // The reading the set-level pin above leaves to the fold itself. + const result = foldFilterGroupToSpecRules( + group([{ id: 'r1', field: 'closed_at', operator: 'isEmpty', value: '' }]), + ); + expect(result).toEqual({ ok: true, rules: [{ field: 'closed_at', operator: 'is_empty', value: '' }] }); + // …and it is the rule the canonical spelling folds to, byte for byte. + expect(result).toEqual( + foldFilterGroupToSpecRules( + group([{ id: 'r1', field: 'closed_at', operator: 'is_empty', value: '' }]), + ), + ); + }); }); diff --git a/packages/app-shell/src/views/viewFilterFold.ts b/packages/app-shell/src/views/viewFilterFold.ts index be2cf5e8b9..ed9c0ee58b 100644 --- a/packages/app-shell/src/views/viewFilterFold.ts +++ b/packages/app-shell/src/views/viewFilterFold.ts @@ -10,7 +10,9 @@ * FilterBuilder → `@objectstack/spec` view filter, the WRITE direction. * * The FilterBuilder (`@object-ui/components`) speaks a grouped dialect — - * `{ id, logic, conditions }` with a per-row `id` and camelCase operators. + * `{ id, logic, conditions }` with a per-row `id`. Its operator ids are the + * spec's own canonical spellings since objectui#9306 (they were camelCase + * before, and a group read from older storage may still carry those). * `@objectstack/spec`'s `ListViewSchema.filter` / `ViewTab.filter` declare * `z.array(ViewFilterRuleSchema)`: a FLAT list of `{ field, operator, value }`. * Persisting the builder's group verbatim is a type error the server rejects @@ -52,8 +54,8 @@ interface FilterGroupLike { } /** - * Operators that are COMPLETE without a value — in both the FilterBuilder's - * dialect and the canonical spelling `normalizeFilterOperator` maps it to. + * Operators that are COMPLETE without a value, keyed by the spelling + * `normalizeFilterOperator` folds a row's operator to. * * The builder renders no value input for these (`needsValueInput` in * `@object-ui/components`'s `filter-builder.tsx`), so "no value" is the row's @@ -76,15 +78,27 @@ interface FilterGroupLike { * * The builder half is no longer restated here: it is imported from * `@object-ui/components`, the module whose `needsValueInput` decides it - * (objectui#4744). This layer only ADDS what the builder never sees — the - * canonical spec spellings a stored `ViewFilterRule` carries. - * `viewFilterFold.emptyValue.test.ts` pins the union against that import so a - * new value-less operator upstream cannot land here half-applied. + * (objectui#4744). Since objectui#9306 that set is itself written in the + * canonical spellings, so the four listed beside it are already members; they + * stay listed so this layer's answer for the spellings a stored + * `ViewFilterRule` carries does not depend on which vocabulary the builder's + * set happens to be written in. `viewFilterFold.emptyValue.test.ts` pins the + * union against that import so a new value-less operator upstream cannot land + * here half-applied. + * + * ⚠️ Membership is asked of the FOLDED spelling, never of the raw one. The set + * does NOT list the deprecated camelCase ids (`isEmpty`, `isNull`, …) that a + * row stored before objectui#9306 may carry, and it is not meant to: listing a + * second spelling here is how the builder's set and this one drifted before. + * The fold below asks the normalized operator, and `sanitizeViewOverride` + * (`ObjectView.tsx`) folds before its lookup too — a reader that asked this set + * the RAW spelling would treat a stored `{ operator: 'isEmpty', value: '' }` as + * a row still waiting for a value, and drop it. */ export const VALUELESS_FILTER_OPERATORS: ReadonlySet = new Set([ // FilterBuilder vocabulary — the one shared set, not a copy of it. ...VALUELESS_FILTER_BUILDER_OPERATORS, - // …and their canonical spec spellings, which only this layer sees. + // …and the canonical spellings a stored rule carries, independently of it. 'is_empty', 'is_not_empty', 'is_null', 'is_not_null', ]); @@ -127,9 +141,12 @@ function isGroupLike(value: unknown): value is FilterGroupLike { * Folding rules: * * - **Operators** are normalized through the spec's OWN - * {@link normalizeFilterOperator}, so the builder's camelCase ids - * (`notEquals`, `greaterOrEqual`, `startsWith`, `isNull`, …) land on the - * canonical vocabulary `ViewFilterRuleSchema` enumerates. Using the spec's + * {@link normalizeFilterOperator}, so any alias spelling a row carries — + * including the builder's deprecated camelCase ids (`notEquals`, + * `greaterOrEqual`, `startsWith`, `isNull`, …) that a group read from older + * storage may still hold; the builder itself has emitted the canonical ids + * since objectui#9306 — lands on the canonical vocabulary + * `ViewFilterRuleSchema` enumerates. Using the spec's * exported map rather than a hand-kept table is what keeps this from * becoming a second dialect — the previous local table in * `metadata-admin/widgets.tsx` had drifted four operators behind the @@ -162,8 +179,9 @@ function isGroupLike(value: unknown): value is FilterGroupLike { * storage — and on the next read it became the view's whole filter, replacing * the source-declared one and emptying the list for every user of that view. * The two sides now agree: what is not applied is not persisted. A value-less - * OPERATOR ({@link VALUELESS_FILTER_OPERATORS} — `isEmpty` / `isNull` and - * friends) is complete without a value and is kept; only a row that WANTS a + * OPERATOR ({@link VALUELESS_FILTER_OPERATORS} — `is_empty` / `is_null` and + * friends, in any spelling that folds onto them) is complete without a value + * and is kept; only a row that WANTS a * value and has none is dropped. * * Refusals (objectstack#5159, maintainer adjudication A1): a shape that cannot diff --git a/packages/components/src/__tests__/filter-builder-field-switch-operator.test.tsx b/packages/components/src/__tests__/filter-builder-field-switch-operator.test.tsx index d5769e81e6..cb982f479c 100644 --- a/packages/components/src/__tests__/filter-builder-field-switch-operator.test.tsx +++ b/packages/components/src/__tests__/filter-builder-field-switch-operator.test.tsx @@ -148,9 +148,9 @@ describe('switching the field settles the operator into the new bucket', () => { }); it('resets down to the two operators a boolean column offers', async () => { - // The narrowest bucket in the builder — `["equals", "notEquals"]` — so it + // The narrowest bucket in the builder — `["equals", "not_equals"]` — so it // is the one most switches land outside of. - const { onChange } = renderRow({ field: 'title', operator: 'startsWith', value: 'ac' }); + const { onChange } = renderRow({ field: 'title', operator: 'starts_with', value: 'ac' }); await pick(0, 'Won'); expect(lastRow(onChange).operator).toBe('equals'); @@ -248,24 +248,26 @@ describe('an operator the new bucket still offers is LEFT ALONE', () => { }); it('keeps a value-less operator both buckets carry', async () => { - const { onChange } = renderRow({ field: 'title', operator: 'isNull', value: '' }); + const { onChange } = renderRow({ field: 'title', operator: 'is_null', value: '' }); await pick(0, 'Amount'); - expect(lastRow(onChange)).toMatchObject({ field: 'amount', operator: 'isNull' }); + expect(lastRow(onChange)).toMatchObject({ field: 'amount', operator: 'is_null' }); }); }); describe('membership is decided through the spec’s canonical fold', () => { - it('recognises the canonical spelling of an operator the bucket lists as an alias', () => { - // A stored view read back without camelCasing carries `not_in`; the - // dropdown lists the alias `notIn`. They are ONE operator, so a + it('recognises a deprecated alias of an operator the bucket lists canonically', () => { + // The dropdown lists the protocol id `not_in` (objectui#9306); a caller of + // this exported helper may still hold the deprecated alias `notIn`, which + // was the dropdown's own id before that change. They are ONE operator, so a // select → lookup switch must not reset the row to `equals` merely - // because the two spellings differ — and the row keeps its own spelling, - // because a field switch is not a spelling migration. + // because the two spellings differ — and the helper keeps the row's own + // spelling, because a field switch is not a spelling migration (the + // builder's READ boundary is where a spelling is migrated). expect(normalizeFilterOperator('notIn')).toBe('not_in'); - expect(offeredFor('lookup')).toContain('notIn'); - expect(offeredFor('lookup')).not.toContain('not_in'); - expect(reconcileOperatorForField('not_in', operatorsForFieldType('lookup'))).toBe('not_in'); + expect(offeredFor('lookup')).toContain('not_in'); + expect(offeredFor('lookup')).not.toContain('notIn'); + expect(reconcileOperatorForField('notIn', operatorsForFieldType('lookup'))).toBe('notIn'); }); it('still resets a canonical spelling the new bucket cannot express', () => { diff --git a/packages/components/src/__tests__/filter-builder-field-switch-value.test.tsx b/packages/components/src/__tests__/filter-builder-field-switch-value.test.tsx index fbede158c5..e7a6364ae5 100644 --- a/packages/components/src/__tests__/filter-builder-field-switch-value.test.tsx +++ b/packages/components/src/__tests__/filter-builder-field-switch-value.test.tsx @@ -346,10 +346,10 @@ describe('a value the new column CAN already hold is left alone', () => { }); it('keeps a number across two numeric columns', async () => { - const { onChange } = renderRow({ field: 'amount', operator: 'greaterThan', value: 42 }); + const { onChange } = renderRow({ field: 'amount', operator: 'greater_than', value: 42 }); await pickField('Quota'); - expect(lastRow(onChange)).toMatchObject({ field: 'quota', operator: 'greaterThan', value: 42 }); + expect(lastRow(onChange)).toMatchObject({ field: 'quota', operator: 'greater_than', value: 42 }); }); it('keeps a date string on a text column — text holds everything', async () => { @@ -378,10 +378,10 @@ describe('a value the new column CAN already hold is left alone', () => { }); it('leaves a value-less row alone', async () => { - const { onChange } = renderRow({ field: 'title', operator: 'isNull', value: '' }); + const { onChange } = renderRow({ field: 'title', operator: 'is_null', value: '' }); await pickField('Amount'); - expect(lastRow(onChange)).toMatchObject({ operator: 'isNull', value: '' }); + expect(lastRow(onChange)).toMatchObject({ operator: 'is_null', value: '' }); }); }); @@ -503,14 +503,14 @@ describe('`retypeFilterValue` — the convertibility judgement, one family at a describe('`retypeFilterValue` — one shape at a time', () => { it('a list converts entry by entry, keeping the ones that carry', () => { // Pinned on the helper rather than through the dropdown, and honestly so: - // the only buckets offering `in`/`notIn` today are `select` and `lookup`, + // the only buckets offering `in`/`not_in` today are `select` and `lookup`, // both text-family, so no field switch can currently drive a list into a // number column. The helper answers for the family the operator lands in, // not for today's buckets, and this is where that answer is fixed. expect(retypeFilterValue(['42', 'acme', '7'], 'number', 'in')).toEqual([42, 7]); expect(retypeFilterValue(['won', 'lost'], 'number', 'in')).toEqual([]); expect(retypeFilterValue(['won', 'lost'], 'select', 'in')).toEqual(['won', 'lost']); - expect(retypeFilterValue([], 'number', 'notIn')).toEqual([]); + expect(retypeFilterValue([], 'number', 'not_in')).toEqual([]); // A scalar reaching a list operator is normalised, not wrapped blindly. expect(retypeFilterValue('42', 'number', 'in')).toEqual([42]); expect(retypeFilterValue('', 'number', 'in')).toEqual([]); 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 6ec2223611..4b2b7b6e97 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 @@ -50,6 +50,16 @@ * The four `describe`s below the table are green in BOTH directions by * construction: they are the boundaries the repair must not cross, so they * fail only for a repair that over-reaches. + * + * ## After objectui#9306 + * + * The dropdown's ids are now the spec's canonical spellings, so the + * `canonical` rows below match a mounted item literally and are no longer + * controls. The dialect they used to stand in for — a spelling the dropdown + * does not mount — is now the builder's own former camelCase ids (`deprecated` + * below), which a stored filter saved before that change still carries. Those + * rows, and the alias table, are the controls now; the trigger names them + * because the builder folds a row's spelling at its read boundary. */ import { describe, it, expect, vi } from 'vitest'; import React from 'react'; @@ -94,13 +104,18 @@ function operatorTriggerText() { * canonical members, so the dialect now reaches this builder from STORED * filters rather than from the corpus — which is the reason the row stays * here rather than following the corpus; + * - `deprecated` — the dropdown's own camelCase ids BEFORE objectui#9306, + * now the spec's deprecated alias form (plus `containsCaseInsensitive`, + * which the spec's table lacks and the builder folds itself); * - `overlap` — the three ids both vocabularies share. NOT controls. + * + * Since objectui#9306 the `canonical` rows are the dropdown's own ids too. */ const SPELLINGS: ReadonlyArray<{ operator: string; field: string; label: string; - dialect: 'canonical' | 'alias' | 'overlap'; + dialect: 'canonical' | 'deprecated' | 'alias' | 'overlap'; }> = [ // The three-member overlap — green before AND after. Over-reach guard only. { operator: 'equals', field: 'title', label: 'Equals', dialect: 'overlap' }, @@ -143,6 +158,33 @@ const SPELLINGS: ReadonlyArray<{ { operator: 'lte', field: 'amount', label: 'Less than or equal', dialect: 'alias' }, { operator: 'gte', field: 'amount', label: 'Greater than or equal', dialect: 'alias' }, { operator: 'nin', field: 'stage', label: 'Not in', dialect: 'alias' }, + + // The dropdown's former camelCase ids — what a filter stored before + // objectui#9306 carries (objectui#9306). + { operator: 'notEquals', field: 'title', label: 'Does not equal', dialect: 'deprecated' }, + { + operator: 'containsCaseInsensitive', + field: 'title', + label: 'Contains (ignore case)', + dialect: 'deprecated', + }, + { operator: 'notContains', field: 'title', label: 'Does not contain', dialect: 'deprecated' }, + { operator: 'startsWith', field: 'title', label: 'Starts with', dialect: 'deprecated' }, + { operator: 'endsWith', field: 'title', label: 'Ends with', dialect: 'deprecated' }, + { operator: 'isEmpty', field: 'title', label: 'Is empty', dialect: 'deprecated' }, + { operator: 'isNotEmpty', field: 'title', label: 'Is not empty', dialect: 'deprecated' }, + { operator: 'isNull', field: 'title', label: 'Is null', dialect: 'deprecated' }, + { operator: 'isNotNull', field: 'title', label: 'Is not null', dialect: 'deprecated' }, + { operator: 'greaterThan', field: 'amount', label: 'Greater than', dialect: 'deprecated' }, + { operator: 'lessThan', field: 'amount', label: 'Less than', dialect: 'deprecated' }, + { + operator: 'greaterOrEqual', + field: 'amount', + label: 'Greater than or equal', + dialect: 'deprecated', + }, + { operator: 'lessOrEqual', field: 'amount', label: 'Less than or equal', dialect: 'deprecated' }, + { operator: 'notIn', field: 'stage', label: 'Not in', dialect: 'deprecated' }, ]; describe('objectui#7561 — the trigger names the operator the row holds', () => { @@ -162,23 +204,31 @@ describe('objectui#7561 — the trigger names the operator the row holds', () => const dialects = SPELLINGS.map((s) => s.dialect); expect(dialects.filter((d) => d === 'canonical').length).toBeGreaterThanOrEqual(10); expect(dialects.filter((d) => d === 'alias').length).toBeGreaterThanOrEqual(5); - // …and every non-overlap spelling really is outside the dropdown's own + expect(dialects.filter((d) => d === 'deprecated').length).toBeGreaterThanOrEqual(10); + // …and every control spelling really is outside the dropdown's own // vocabulary, so none of them could have matched a mounted item literally. + // Since objectui#9306 the canonical rows ARE the dropdown's vocabulary, so + // they sit on the inside with the overlap. const dropdownIds = new Set(operatorsForFieldType('text').concat( operatorsForFieldType('number'), operatorsForFieldType('select'), ).map((op) => op.value)); for (const s of SPELLINGS) { - if (s.dialect === 'overlap') expect(dropdownIds.has(s.operator)).toBe(true); - else expect(dropdownIds.has(s.operator)).toBe(false); + if (s.dialect === 'overlap' || s.dialect === 'canonical') { + expect(dropdownIds.has(s.operator), s.operator).toBe(true); + } else { + expect(dropdownIds.has(s.operator), s.operator).toBe(false); + } } }); }); describe('objectui#7561 — ⛔ the repair does not rewrite what the row carries', () => { - it.each(['gt', 'greater_than'])('a row spelled `%s` is not migrated on render', (operator) => { + it.each(['gt', 'greater_than', 'greaterThan'])('a row spelled `%s` is not migrated on render', (operator) => { // The boundary the triage seat drew: fixing the BLANK must not rewrite the // id any stored filter carries. Rendering is not an edit, so the component - // must not call back at all. + // must not call back at all. objectui#9306 folds the spelling at the + // builder's read boundary, but only into its own state — the canonical id + // reaches the host with the author's next edit, never on render. const { onChange } = renderRow({ field: 'amount', operator, value: '5' }); expect(onChange).not.toHaveBeenCalled(); }); @@ -235,7 +285,8 @@ describe('objectui#7561 — ⛔ the dropdown still emits its own vocabulary', () fireEvent.click(option); const calls = onChange.mock.calls; expect(calls.length).toBeGreaterThan(0); - // `lessThan`, the builder's camelCase id — NOT `lt`, and NOT `less_than`. - expect(calls[calls.length - 1][0].conditions[0].operator).toBe('lessThan'); + // `less_than`, the builder's own id (the protocol's, since objectui#9306) + // — NOT `lt`, the row's old dialect, and NOT the retired `lessThan`. + expect(calls[calls.length - 1][0].conditions[0].operator).toBe('less_than'); }); }); diff --git a/packages/components/src/__tests__/filter-builder-operator-locale-parity.test.ts b/packages/components/src/__tests__/filter-builder-operator-locale-parity.test.ts index f59fc2c55f..2c37c2a405 100644 --- a/packages/components/src/__tests__/filter-builder-operator-locale-parity.test.ts +++ b/packages/components/src/__tests__/filter-builder-operator-locale-parity.test.ts @@ -36,10 +36,12 @@ * That export is the vocabulary the dropdown draws, derived from the operators * the component actually renders — so a new operator added to `defaultOperators` * lands here as a red test naming the packs it still needs, instead of shipping - * as a raw key. It includes the opt-in ids (`containsCaseInsensitive`, - * `exists`, `notExists`): `OPT_IN_OPERATORS` governs which CONSUMERS are offered - * an operator, not whether the builder can draw it, and `FilterConditionField` - * grants all three — a drawable operator needs a label. + * as a raw key. It includes the opt-in ids (`exists`, `notExists`): + * `OPT_IN_OPERATORS` governs which CONSUMERS are offered an operator, not + * whether the builder can draw it, and `FilterConditionField` grants both — a + * drawable operator needs a label. (The case-insensitive contains was a third + * opt-in id, `containsCaseInsensitive`, until objectui#9306 made it the + * ordinary protocol id `icontains`.) * * Placement is forced by the dependency graph: `@object-ui/components` depends * on `@object-ui/i18n`, so this is the side that can see both the vocabulary and diff --git a/packages/components/src/__tests__/filter-builder-opt-in-operators.test.ts b/packages/components/src/__tests__/filter-builder-opt-in-operators.test.ts index aae0cf6a18..a0e5d4e1bd 100644 --- a/packages/components/src/__tests__/filter-builder-opt-in-operators.test.ts +++ b/packages/components/src/__tests__/filter-builder-opt-in-operators.test.ts @@ -8,17 +8,15 @@ /** * The opt-in half of the FilterBuilder's operator vocabulary (objectui#4023, - * objectui#4736). + * objectui#4736, objectui#9306). * * This one dropdown feeds three at-rest dialects and they do not accept the - * same operators. Two families are carried only by the MongoDB-style + * same operators. One family is carried only by the MongoDB-style * `FieldOperatorsSchema` criteria: * - * - `containsCaseInsensitive` authors `$icontains`. `VIEW_FILTER_OPERATORS` - * (what a saved view stores) and `VALID_AST_OPERATORS` (what the live grid - * sends) have no case-insensitive contains at all. - * - `exists` / `notExists` author `$exists`. Neither of those two vocabularies - * has an existence operator either, under any spelling. + * - `exists` / `notExists` author `$exists`. Neither `VIEW_FILTER_OPERATORS` + * (what a saved view stores) nor `VALID_AST_OPERATORS` (what the live grid + * sends) has an existence operator, under any spelling. * * Offering such an operator unconditionally hands users a filter two of three * consumers cannot execute — the same hazard that held objectui#4023 blocked @@ -27,10 +25,18 @@ * shipped to every consumer in objectui#2942 and the list toolbar sent it to * the wire verbatim. * - * So the default answer is "not offered", and these tests pin BOTH directions: - * withheld unless asked for, and actually reachable once asked for. A gate that - * only checked the second would go green on a component that offers everything - * to everybody. + * The case-insensitive contains used to be the other opt-in family, as + * `containsCaseInsensitive`. It is not any more (objectui#9306): the dropdown + * now offers the protocol's own `icontains`, which BOTH other vocabularies + * declare, so the gate that withheld it had no dialect left to protect — its + * entry was deleted, exactly as the `OPT_IN_OPERATORS` docblock planned. The + * first block below pins it as an ordinary text operator, so the entry cannot + * come back without this file saying so. + * + * So the default answer for an opt-in id is "not offered", and these tests pin + * BOTH directions: withheld unless asked for, and actually reachable once asked + * for. A gate that only checked the second would go green on a component that + * offers everything to everybody. * * What these tests do NOT decide is which ids belong in `OPT_IN_OPERATORS` — * this package cannot see the dialects. That equality is forced per consumer: @@ -42,31 +48,37 @@ import { FILTER_BUILDER_OPERATORS, operatorsForFieldType } from '../custom/filte const idsFor = (type: string | undefined, extra?: readonly string[]) => operatorsForFieldType(type, extra).map((op) => op.value); -describe('FilterBuilder opt-in operators', () => { - it('withholds containsCaseInsensitive from a consumer that did not ask', () => { - expect(idsFor('text')).not.toContain('containsCaseInsensitive'); - expect(idsFor(undefined)).not.toContain('containsCaseInsensitive'); +describe('icontains is an ordinary operator (objectui#9306)', () => { + it('is offered on a text field to a consumer that asked for nothing', () => { + expect(idsFor('text')).toContain('icontains'); + expect(idsFor(undefined)).toContain('icontains'); }); - it('offers it to a text field once the consumer opts in', () => { - const ids = idsFor('text', ['containsCaseInsensitive']); - expect(ids).toContain('containsCaseInsensitive'); - // Beside its case-sensitive twin, not instead of it: `contains` keeps its - // own row and its own meaning. + it('sits beside its case-sensitive twin, not instead of it', () => { + // `contains` and `icontains` are two operators, never one with a flag + // (objectui#7379): each keeps its own row and its own meaning. + const ids = idsFor('text'); expect(ids).toContain('contains'); - expect(ids.indexOf('containsCaseInsensitive')).toBe(ids.indexOf('contains') + 1); + expect(ids.indexOf('icontains')).toBe(ids.indexOf('contains') + 1); }); - it('does not offer it on types whose operators are not string matching', () => { - // Opting in widens the TEXT bucket, not every bucket — a number or boolean - // field gains no substring operator it never had. + it('is not offered on types whose operators are not string matching', () => { + // It lives in the TEXT bucket only — a number or boolean field gains no + // substring operator it never had. for (const type of ['number', 'currency', 'boolean', 'date', 'select', 'lookup']) { - expect(idsFor(type, ['containsCaseInsensitive']), type).not.toContain( - 'containsCaseInsensitive', - ); + expect(idsFor(type), type).not.toContain('icontains'); } }); + it('the retired id is gone from the dropdown entirely', () => { + // A stored `containsCaseInsensitive` is READ as `icontains` + // (`normalizeFilterBuilderOperator`); it is no longer an id anything draws. + expect(FILTER_BUILDER_OPERATORS).not.toContain('containsCaseInsensitive'); + expect(idsFor('text', ['containsCaseInsensitive'])).toEqual(idsFor('text')); + }); +}); + +describe('FilterBuilder opt-in operators', () => { it('ignores an opt-in id that is not an operator at all', () => { // A consumer's typo must not smuggle a row into the dropdown; the operator // list stays the intersection with what this component actually defines. @@ -78,7 +90,6 @@ describe('FilterBuilder opt-in operators', () => { // and the spec→builder parity guards in plugin-view read it that way. An // opt-in operator is drawable, so leaving it out would understate the // vocabulary and let a future spec operator look unreachable when it is not. - expect(FILTER_BUILDER_OPERATORS).toContain('containsCaseInsensitive'); expect(FILTER_BUILDER_OPERATORS).toContain('exists'); expect(FILTER_BUILDER_OPERATORS).toContain('notExists'); expect(new Set(FILTER_BUILDER_OPERATORS).size).toBe(FILTER_BUILDER_OPERATORS.length); @@ -105,18 +116,19 @@ describe('FilterBuilder opt-in operators', () => { expect(ids, String(type)).toContain('exists'); expect(ids, String(type)).toContain('notExists'); // Beside the null predicates, never instead of them: `$exists` and - // `$null` are distinct spec operators and both keep their own rows. - expect(ids, String(type)).toContain('isNull'); - expect(ids, String(type)).toContain('isNotNull'); + // `$null` are distinct spec operators and both keep their own rows + // (objectui#9559 ruling B: no fold). + expect(ids, String(type)).toContain('is_null'); + expect(ids, String(type)).toContain('is_not_null'); } }); it('grants each opt-in id independently', () => { - // A consumer that can store `$exists` but not `$icontains` (or the reverse) - // must not get the other for free — that would make `extraOperators` a - // single "unlock everything" switch and put the whole opt-in gate back. - expect(idsFor('text', ['exists'])).not.toContain('containsCaseInsensitive'); - expect(idsFor('text', ['containsCaseInsensitive'])).not.toContain('exists'); + // A consumer that can store one opt-in id must not get another for free — + // that would make `extraOperators` a single "unlock everything" switch and + // put the whole opt-in gate back. + expect(idsFor('text', ['exists'])).toContain('exists'); expect(idsFor('text', ['exists'])).not.toContain('notExists'); + expect(idsFor('text', ['notExists'])).not.toContain('exists'); }); }); diff --git a/packages/components/src/__tests__/filter-builder-protocol-ids-9306.test.tsx b/packages/components/src/__tests__/filter-builder-protocol-ids-9306.test.tsx new file mode 100644 index 0000000000..30b211b5f6 --- /dev/null +++ b/packages/components/src/__tests__/filter-builder-protocol-ids-9306.test.tsx @@ -0,0 +1,278 @@ +/** + * 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. + */ + +/** + * The FilterBuilder dropdown speaks the protocol's operator ids (objectui#9306). + * + * The ruling, as amended on the card and with objectui#9559's ruling B + * governing the existence pair: + * + * 1. `defaultOperators` emits the twenty `VIEW_FILTER_OPERATORS` members and + * nothing else — plus `exists` / `notExists`, which stay opt-in builder + * ids with no protocol member and are NOT folded onto the null checks. + * 2. A stored filter carrying a camelCase id keeps loading: the spelling is + * folded on READ through the spec's `normalizeFilterOperator`, and the + * builder writes the canonical id back. A lossless read-side conversion, + * ⛔ not a second vocabulary. + * 3. The corrected mapping, 22 dropdown ids onto the protocol — the table + * below is that mapping verbatim, and `contains` / `icontains` stay two + * operators (objectui#7379). + * + * This file pins each of those at the component. The cross-consumer half — the + * stored predicate each id produces in every table that reads it — is + * `filter-builder-protocol-ids-census-9306.test.ts` in `app-shell`, which can + * see every consumer this package cannot. + */ +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { + VIEW_FILTER_OPERATORS, + normalizeFilterOperator, + type ViewFilterOperator, +} from '@objectstack/spec/ui'; +import { + FilterBuilder, + FILTER_BUILDER_OPERATORS, + normalizeFilterBuilderOperator, + type FilterBuilderOperator, +} from '../custom/filter-builder'; + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +function expectType(_: T = true as T): void { /* compile-time only */ } + +// The published type IS the protocol's union plus the two opt-in existence +// ids. Fails to compile (`tsc -p tsconfig.test.json`) if a protocol member is +// added upstream and the dropdown does not offer it, or if a camelCase id comes +// back. +expectType>(); + +/** + * The amendment's corrected mapping — the dropdown's 22 former ids onto the + * id the dropdown now draws. `exists` / `notExists` map to THEMSELVES: ruling B + * (objectui#9559) supersedes the amendment's fold onto `is_not_null` / + * `is_null`, because on the key-presence drivers that fold would change which + * records a stored sharing rule matches. + */ +const LEGACY_TO_PROTOCOL: ReadonlyArray = [ + ['equals', 'equals'], + ['notEquals', 'not_equals'], + ['contains', 'contains'], + ['containsCaseInsensitive', 'icontains'], + ['notContains', 'not_contains'], + ['isEmpty', 'is_empty'], + ['isNotEmpty', 'is_not_empty'], + ['greaterThan', 'greater_than'], + ['lessThan', 'less_than'], + ['greaterOrEqual', 'greater_than_or_equal'], + ['lessOrEqual', 'less_than_or_equal'], + ['before', 'before'], + ['after', 'after'], + ['between', 'between'], + ['in', 'in'], + ['notIn', 'not_in'], + ['startsWith', 'starts_with'], + ['endsWith', 'ends_with'], + ['isNull', 'is_null'], + ['isNotNull', 'is_not_null'], + ['exists', 'exists'], + ['notExists', 'notExists'], +]; + +describe('objectui#9306 — the vocabulary the dropdown draws', () => { + it('is the twenty protocol members plus the two opt-in existence ids, nothing else', () => { + const drawn = [...FILTER_BUILDER_OPERATORS].sort(); + expect(drawn).toEqual([...VIEW_FILTER_OPERATORS, 'exists', 'notExists'].sort()); + // Non-vacuity: the protocol set is read, not assumed. + expect(VIEW_FILTER_OPERATORS.length).toBeGreaterThanOrEqual(20); + }); + + it('draws exactly the right-hand column of the ruling\'s mapping', () => { + expect([...FILTER_BUILDER_OPERATORS].sort()).toEqual( + [...new Set(LEGACY_TO_PROTOCOL.map(([, id]) => id))].sort(), + ); + expect(LEGACY_TO_PROTOCOL).toHaveLength(22); + }); + + it('keeps `contains` and `icontains` two operators (objectui#7379)', () => { + expect(FILTER_BUILDER_OPERATORS).toContain('contains'); + expect(FILTER_BUILDER_OPERATORS).toContain('icontains'); + expect(normalizeFilterBuilderOperator('icontains')).not.toBe( + normalizeFilterBuilderOperator('contains'), + ); + }); + + it('keeps `exists` / `notExists` distinct from the null checks (ruling B)', () => { + expect(normalizeFilterBuilderOperator('exists')).toBe('exists'); + expect(normalizeFilterBuilderOperator('notExists')).toBe('notExists'); + expect(normalizeFilterBuilderOperator('exists')).not.toBe('is_not_null'); + expect(normalizeFilterBuilderOperator('notExists')).not.toBe('is_null'); + }); +}); + +describe('objectui#9306 — the read-side fold, id by id', () => { + it.each(LEGACY_TO_PROTOCOL)('a stored `%s` is read as `%s`', (legacy, id) => { + expect(normalizeFilterBuilderOperator(legacy)).toBe(id); + // …and the id the dropdown draws reads as itself: the fold is idempotent. + expect(normalizeFilterBuilderOperator(id)).toBe(id); + }); + + it('the ONE local row exists because the spec\'s table lacks it — measured', () => { + // The dispatch asked for this to be checked, not assumed: if the spec's + // own normalizer ever folds it, the local row is redundant and goes + // (objectstack-ai/objectstack#20092). + expect(normalizeFilterOperator('containsCaseInsensitive')).toBe('containsCaseInsensitive'); + // Control: the spec's normalizer is live — it folds its own alias rows. + expect(normalizeFilterOperator('greaterOrEqual')).toBe('greater_than_or_equal'); + // Every OTHER legacy id is the spec's fold, not a local one. + for (const [legacy, id] of LEGACY_TO_PROTOCOL) { + if (legacy === 'containsCaseInsensitive') continue; + expect(normalizeFilterOperator(legacy), legacy).toBe(id); + } + }); + + it('a spelling nothing folds comes back unchanged', () => { + expect(normalizeFilterBuilderOperator('totally_unknown')).toBe('totally_unknown'); + // An inherited `Object.prototype` name is not a table row — neither the + // builder's own map (a `Map`, so no prototype) nor the spec's, whose + // normalizer answers such a key with the prototype's member rather than a + // string. The builder keeps the spelling rather than store that. + for (const key of ['constructor', 'toString', 'hasOwnProperty', 'valueOf']) { + expect(normalizeFilterBuilderOperator(key), key).toBe(key); + } + }); +}); + +/** + * One stored row per legacy id, on a field whose bucket offers that operator, + * with a value the row can hold. The existence pair is granted, as + * `FilterConditionField` grants it — without the grant its row would draw no + * label, which is the opt-in gate and not this pin's subject. + */ +const FIELDS = [ + { value: 'title', label: 'Title', type: 'text' }, + { value: 'amount', label: 'Amount', type: 'number' }, + { value: 'closed', label: 'Closed', type: 'date' }, + { value: 'stage', label: 'Stage', type: 'select', options: [{ value: 'won', label: 'Won' }] }, +]; + +const FIELD_FOR: Record = { + equals: 'title', + notEquals: 'title', + contains: 'title', + containsCaseInsensitive: 'title', + notContains: 'title', + isEmpty: 'title', + isNotEmpty: 'title', + greaterThan: 'amount', + lessThan: 'amount', + greaterOrEqual: 'amount', + lessOrEqual: 'amount', + before: 'closed', + after: 'closed', + between: 'closed', + in: 'stage', + notIn: 'stage', + startsWith: 'title', + endsWith: 'title', + isNull: 'title', + isNotNull: 'title', + exists: 'title', + notExists: 'title', +}; + +const VALUE_FOR = (legacy: string): unknown => { + if (legacy === 'in' || legacy === 'notIn') return ['won']; + if (legacy === 'between') return ['2026-01-01', '2026-02-01']; + if (['isEmpty', 'isNotEmpty', 'isNull', 'isNotNull', 'exists', 'notExists'].includes(legacy)) return ''; + if (FIELD_FOR[legacy] === 'amount') return 5; + if (FIELD_FOR[legacy] === 'closed') return '2026-01-01'; + return 'acme'; +}; + +const STORED = { + id: 'root', + logic: 'and' as const, + conditions: LEGACY_TO_PROTOCOL.map(([legacy]) => ({ + id: `c-${legacy}`, + field: FIELD_FOR[legacy], + operator: legacy, + value: VALUE_FOR(legacy), + })), +}; + +function renderStored(value: typeof STORED) { + const onChange = vi.fn(); + const utils = render( + , + ); + return { ...utils, onChange }; +} + +/** Each row's operator trigger text, in row order. */ +function operatorLabels(container: HTMLElement): string[] { + return Array.from( + container.querySelectorAll('div.col-span-4:nth-child(2) [role="combobox"]'), + ).map((e) => e.textContent ?? ''); +} + +describe('objectui#9306 — a stored camelCase filter loads, and saves back canonical', () => { + it('loads: every row names its operator, and nothing is written on render', () => { + const { container, onChange } = renderStored(STORED); + const labels = operatorLabels(container); + expect(labels).toHaveLength(22); + // No blank trigger and no raw id: each stored spelling reads as the + // operator it is. + expect(labels.filter((l) => l.trim() === '')).toEqual([]); + for (const [legacy] of LEGACY_TO_PROTOCOL) expect(labels).not.toContain(legacy); + expect(labels[LEGACY_TO_PROTOCOL.findIndex(([l]) => l === 'containsCaseInsensitive')]) + .toBe('Contains (ignore case)'); + // Opening a stored filter must not dirty the form that holds it. + expect(onChange).not.toHaveBeenCalled(); + }); + + it('saves back: the author\'s next edit writes every row\'s CANONICAL id, and nothing else moves', () => { + const { onChange } = renderStored(STORED); + fireEvent.click(screen.getByRole('button', { name: /add filter/i })); + expect(onChange).toHaveBeenCalledTimes(1); + const written = onChange.mock.calls[0][0] as typeof STORED; + + // The 22 stored rows, then the one the edit added. + expect(written.conditions).toHaveLength(23); + LEGACY_TO_PROTOCOL.forEach(([legacy, id], i) => { + expect(written.conditions[i], legacy).toEqual({ + ...STORED.conditions[i], + operator: id, + }); + }); + // Anti-vacuity: the write really rewrote spellings — most rows moved. + const moved = written.conditions + .slice(0, 22) + .filter((c, i) => c.operator !== STORED.conditions[i].operator); + expect(moved.length).toBe(LEGACY_TO_PROTOCOL.filter(([l, id]) => l !== id).length); + expect(moved.length).toBeGreaterThanOrEqual(14); + }); + + it('a group already in canonical ids is written back byte-for-byte (no churn)', () => { + const canonical = { + ...STORED, + conditions: STORED.conditions.map((c, i) => ({ ...c, operator: LEGACY_TO_PROTOCOL[i][1] })), + }; + const { onChange } = renderStored(canonical); + fireEvent.click(screen.getByRole('button', { name: /add filter/i })); + const written = onChange.mock.calls[0][0] as typeof STORED; + expect(written.conditions.slice(0, 22)).toEqual(canonical.conditions); + }); +}); diff --git a/packages/components/src/__tests__/filter-builder-valueless-canonical-spelling-9302.test.tsx b/packages/components/src/__tests__/filter-builder-valueless-canonical-spelling-9302.test.tsx index 4db003dc75..8d577df9ad 100644 --- a/packages/components/src/__tests__/filter-builder-valueless-canonical-spelling-9302.test.tsx +++ b/packages/components/src/__tests__/filter-builder-valueless-canonical-spelling-9302.test.tsx @@ -72,6 +72,15 @@ * the spec's vocabulary, so the fold returns them verbatim. They pin that * routing the lookup through the fold did not drop the two members that * have nothing to fold to. + * + * ## After objectui#9306 + * + * The dropdown's ids — and so the exported set's members — are now the + * canonical spellings, and the former camelCase ids are the deprecated alias + * form a stored filter may still carry. The gate is unchanged (it folds the + * row's spelling before the lookup), so the table below reads the same; only + * which spelling plays "the dropdown's own id" and which plays "the other + * spelling" swapped. The rows are relabelled to say so. */ import { describe, it, expect, vi } from 'vitest'; import React from 'react'; @@ -130,29 +139,32 @@ const operatorTriggerText = () => screen.getAllByRole('combobox')[1].textContent * The card's acceptance table, measured through the real `FilterBuilder`. * * `dialect` is load-bearing for reading a failure, not decoration: - * - `dropdown` — one of this builder's own camelCase ids, i.e. a member of - * the exported set. 0 inputs today and after: the over-reach guard; - * - `canonical` — `@objectstack/spec`'s snake_case spelling of the SAME - * operator, which a stored view carries. 1 input today (the defect), 0 - * after: the firing cases; + * - `dropdown` — one of this builder's own ids, i.e. a member of the + * exported set: the spec's canonical spelling since objectui#9306. 0 + * inputs: the over-reach guard; + * - `deprecated` — the dropdown's former camelCase id for the SAME operator, + * which a filter stored before objectui#9306 carries. Not a member of the + * exported set, so it draws 0 only because the gate folds: the firing + * cases (before objectui#9302 it was the canonical spelling that played + * this part); * - `control` — really does take a value. 1 input in both directions. */ const ROWS: ReadonlyArray<{ operator: string; label: string; inputs: number; - dialect: 'dropdown' | 'canonical' | 'control'; + dialect: 'dropdown' | 'deprecated' | 'control'; /** `OPT_IN_OPERATORS` ids the consumer must grant before they are mounted. */ extraOperators?: readonly string[]; }> = [ - { operator: 'isNull', label: 'Is null', inputs: 0, dialect: 'dropdown' }, - { operator: 'is_null', label: 'Is null', inputs: 0, dialect: 'canonical' }, - { operator: 'isNotNull', label: 'Is not null', inputs: 0, dialect: 'dropdown' }, - { operator: 'is_not_null', label: 'Is not null', inputs: 0, dialect: 'canonical' }, - { operator: 'isEmpty', label: 'Is empty', inputs: 0, dialect: 'dropdown' }, - { operator: 'is_empty', label: 'Is empty', inputs: 0, dialect: 'canonical' }, - { operator: 'isNotEmpty', label: 'Is not empty', inputs: 0, dialect: 'dropdown' }, - { operator: 'is_not_empty', label: 'Is not empty', inputs: 0, dialect: 'canonical' }, + { operator: 'isNull', label: 'Is null', inputs: 0, dialect: 'deprecated' }, + { operator: 'is_null', label: 'Is null', inputs: 0, dialect: 'dropdown' }, + { operator: 'isNotNull', label: 'Is not null', inputs: 0, dialect: 'deprecated' }, + { operator: 'is_not_null', label: 'Is not null', inputs: 0, dialect: 'dropdown' }, + { operator: 'isEmpty', label: 'Is empty', inputs: 0, dialect: 'deprecated' }, + { operator: 'is_empty', label: 'Is empty', inputs: 0, dialect: 'dropdown' }, + { operator: 'isNotEmpty', label: 'Is not empty', inputs: 0, dialect: 'deprecated' }, + { operator: 'is_not_empty', label: 'Is not empty', inputs: 0, dialect: 'dropdown' }, // No canonical twin exists for these two — the spec's vocabulary has no // `exists` member and its alias table deliberately has no row for one. // @@ -194,9 +206,10 @@ const ROWS: ReadonlyArray<{ * THIS builder leaves value-less. Nothing here decides which those are; the * builder's own export does, and the fold carries it across. * - `VIEW_FILTER_OPERATOR_ALIASES` — every other spelling the spec accepts - * for one of those canonical members. The camelCase rows of this table are - * the builder's own dropdown ids; the rest (today: the all-lowercase rows) - * are spellings no literal table here ever named. + * for one of those canonical members. The camelCase rows of this table + * were the builder's own dropdown ids until objectui#9306 and are the + * deprecated spellings a stored filter may still carry; the rest (today: + * the all-lowercase rows) are spellings no literal table here ever named. * * ⛔ Do not replace this with the list it currently produces. The count is not * written down anywhere in this file on purpose — `--reporter=verbose` names @@ -352,17 +365,19 @@ describe('objectui#9302 — one operator, one row, whichever spelling it arrives describe('objectui#9302 — ⛔ the repair moves nothing but the gate', () => { it('the EXPORTED set keeps its dropdown-only membership', () => { - // Acceptance criterion 3, and the ruling's whole point: two other layers - // read this set, and one of them already compensates for the canonical - // spellings. Widening it would make that layer's deliberate half redundant - // by side effect. Green in both directions by construction — it fails only - // for a repair that widened the export instead of folding at the gate. + // Acceptance criterion 3: the set states what the DROPDOWN draws, one id + // per operator, and is not widened with other spellings — the gate folds + // instead. Since objectui#9306 the dropdown's ids are the canonical + // spellings, so the six members are those; the deprecated camelCase ids + // are NOT members (a second spelling in a published set is exactly the + // second vocabulary that ruling refuses). It fails for a repair that + // widened the export instead of folding at the gate. expect([...VALUELESS_FILTER_BUILDER_OPERATORS].sort()).toEqual([ 'exists', - 'isEmpty', - 'isNotEmpty', - 'isNotNull', - 'isNull', + 'is_empty', + 'is_not_empty', + 'is_not_null', + 'is_null', 'notExists', ]); }); diff --git a/packages/components/src/custom/filter-builder.tsx b/packages/components/src/custom/filter-builder.tsx index 7ddd16a177..a972001780 100644 --- a/packages/components/src/custom/filter-builder.tsx +++ b/packages/components/src/custom/filter-builder.tsx @@ -95,33 +95,45 @@ export interface FilterBuilderProps { // `label` is the English fallback; the actual rendered text comes from // `filterBuilder.operators.` so operators are translated like the rest // of the builder. Keep the two in sync when adding operators. +// +// The ids ARE the protocol's operator ids (objectui#9306): every one of the +// twenty members of `@objectstack/spec`'s `VIEW_FILTER_OPERATORS`, spelled as +// the spec spells them, plus the two opt-in existence ids the protocol has no +// member for (`exists` / `notExists`, see `OPT_IN_OPERATORS`). The camelCase +// spellings this list used to carry (`notEquals`, `greaterOrEqual`, …) are the +// spec's DEPRECATED alias form (objectui#7993): a stored filter carrying one is +// still read — {@link normalizeFilterBuilderOperator} folds it onto the id below +// — and the next write the author makes stores the canonical id instead. +// `filter-builder-protocol-ids-9306.test.tsx` pins the twenty against the spec +// itself, so this list cannot drift behind the protocol again. const defaultOperators = [ { value: "equals", label: "Equals" }, - { value: "notEquals", label: "Does not equal" }, + { value: "not_equals", label: "Does not equal" }, { value: "contains", label: "Contains" }, - // Case-insensitive `contains` — the spec's `$icontains` (objectui#4023). - // OPT-IN: see `OPT_IN_OPERATORS` below for why it is not offered everywhere. - { value: "containsCaseInsensitive", label: "Contains (ignore case)" }, - { value: "notContains", label: "Does not contain" }, - { value: "isEmpty", label: "Is empty" }, - { value: "isNotEmpty", label: "Is not empty" }, - { value: "greaterThan", label: "Greater than" }, - { value: "lessThan", label: "Less than" }, - { value: "greaterOrEqual", label: "Greater than or equal" }, - { value: "lessOrEqual", label: "Less than or equal" }, + // Case-insensitive `contains` — the spec's `$icontains` (objectui#4023), and + // its own member of the protocol set: `contains` and `icontains` are two + // operators, never one with a flag (objectui#7379). + { value: "icontains", label: "Contains (ignore case)" }, + { value: "not_contains", label: "Does not contain" }, + { value: "is_empty", label: "Is empty" }, + { value: "is_not_empty", label: "Is not empty" }, + { value: "greater_than", label: "Greater than" }, + { value: "less_than", label: "Less than" }, + { value: "greater_than_or_equal", label: "Greater than or equal" }, + { value: "less_than_or_equal", label: "Less than or equal" }, { value: "before", label: "Before" }, { value: "after", label: "After" }, { value: "between", label: "Between" }, { value: "in", label: "In" }, - { value: "notIn", label: "Not in" }, + { value: "not_in", label: "Not in" }, // String-specific spec operators ($startsWith / $endsWith) — they validated // at the data layer but were unreachable from this dropdown (#2942). - { value: "startsWith", label: "Starts with" }, - { value: "endsWith", label: "Ends with" }, + { value: "starts_with", label: "Starts with" }, + { value: "ends_with", label: "Ends with" }, // Null / existence spec operators ($null / $exists). Distinct from - // isEmpty/isNotEmpty, which also treat '' as empty. - { value: "isNull", label: "Is null" }, - { value: "isNotNull", label: "Is not null" }, + // is_empty/is_not_empty, which also treat '' as empty. + { value: "is_null", label: "Is null" }, + { value: "is_not_null", label: "Is not null" }, { value: "exists", label: "Is set" }, { value: "notExists", label: "Is not set" }, ] as const @@ -156,7 +168,7 @@ const defaultOperators = [ * that speaks that dialect. Mapping it onto a near-equivalent in the others is * NOT the alternative: that is a different question silently answered — the * same reason `view-operator-builder-parity.test.ts` refuses to map `is_null` - * onto `isEmpty`. + * onto `is_empty`. * * Which side of the line an id falls on is not left to this comment. * `plugin-list`'s `list-offered-operator-expressible-parity.test.ts` forces the @@ -164,13 +176,21 @@ const defaultOperators = [ * both directions: an unexpressible id that stays offered fails, and an id that * becomes expressible while still withheld fails too. * - * ### `containsCaseInsensitive` — the spec's `$icontains` (objectui#4023) - * - * Carried by the Mongo criteria dialect, where every driver and evaluation face - * the platform ships executes it (objectstack#5702 + objectstack#6520). The - * other two vocabularies have no case-insensitive contains at all, so offering - * it everywhere is the exact hazard objectui#4023 was held blocked over, - * relocated from the drivers to this repo's own bridges. + * ### `icontains` — opt-in no longer (objectui#4023, objectui#9306) + * + * It was opt-in under its old id `containsCaseInsensitive`, on the ground that + * only the Mongo criteria dialect could carry a case-insensitive contains. That + * ground is gone, and it went before this entry did: `VIEW_FILTER_OPERATORS` + * has declared `icontains` since `@objectstack/spec` 17.1.0 (objectui#5328), + * `VALID_AST_OPERATORS` carries it, and `ListView`'s `mapOperator` has its own + * arm for it. The parity test above could not see that while the dropdown's id + * was `containsCaseInsensitive` — a spelling the spec's alias table does not + * fold, so it read as unexpressible on both dialects. Once the dropdown spoke + * the protocol's id, the same test measured it expressible on both and withheld, + * which is exactly the "delete the entry" event the last paragraph of this + * docblock describes. So it is an ordinary operator now, offered on the text + * bucket to every consumer; `app-shell`'s dataset bridge maps it to the spec's + * `$icontains`, the token `FilterConditionField` has always written for it. * * ### `exists` / `notExists` — the spec's `$exists` (objectui#4736) * @@ -182,13 +202,14 @@ const defaultOperators = [ * members matching `/exist/`. So there was nothing to map onto and the id went * out verbatim. * - * Collapsing them onto `isNotNull` / `isNull` would be a semantic claim, not a - * bridge, and it is refused on three counts: + * Collapsing them onto `is_not_null` / `is_null` would be a semantic claim, not + * a bridge, and it is refused on three counts (and ruled so again for + * objectui#9306, by objectui#9559's ruling B: they stay opt-in, unfolded): * - * 1. The builder already offers `isNull` / `isNotNull` as their own rows, so - * the collapse would draw two labels for one wire predicate. + * 1. The builder already offers `is_null` / `is_not_null` as their own rows, + * so the collapse would draw two labels for one wire predicate. * 2. The round trip is lossy: a saved `exists` reads back through - * `specToBuilderOperator('is_not_null')` as `isNotNull`, silently + * `specToBuilderOperator('is_not_null')` as `is_not_null`, silently * rewriting the author's choice on reopen. * 3. The equivalence is not this repo's to declare. `@objectstack/spec`'s own * `data/index.d.ts` records `$exists` = has-value (`!= null`) as settled @@ -197,12 +218,11 @@ const defaultOperators = [ * KEY-PRESENCE, both frozen by objectstack#5499 — which is why upstream * cannot enrol a `$exists` conformance row yet either. * - * The lasting fix for both families is upstream — the view and AST vocabularies - * gaining the operator — at which point the entry below is deleted, the parity - * test flips it back on by itself, and the operator becomes ordinary. + * The lasting fix is upstream — the view and AST vocabularies gaining the + * operator — at which point the entry below is deleted, the parity test flips + * it back on by itself, and the operator becomes ordinary. */ const OPT_IN_OPERATORS = new Set([ - "containsCaseInsensitive", "exists", "notExists", ]) @@ -212,35 +232,118 @@ const OPT_IN_OPERATORS = new Set([ * hold, derived from the operators it actually renders. Includes the opt-in * ids: they are drawable, just not offered unconditionally. * - * Exported because several translation tables map an external vocabulary - * (the spec's `VIEW_FILTER_OPERATORS`, Mongo `$`-tokens) *onto* this one, and - * a value that is not in this list produces a condition row whose operator - * select has nothing to select. Those tables assert against this export - * rather than restating it, so adding an operator here is the only edit - * needed to widen them. + * Since objectui#9306 these ARE the protocol's ids — the twenty members of the + * spec's `VIEW_FILTER_OPERATORS`, plus the opt-in `exists` / `notExists` the + * protocol has no member for — so a table that maps the spec's vocabulary onto + * this one is an identity over the twenty. It is still exported because those + * tables, and the parity pins over them, assert against this list rather than + * restating it: a value that is not in it produces a condition row whose + * operator select has nothing to select. */ export const FILTER_BUILDER_OPERATORS = defaultOperators.map(o => o.value) -/** An operator id the FilterBuilder can render. */ +/** + * An operator id the FilterBuilder can render: the spec's `ViewFilterOperator` + * plus the two opt-in existence ids (objectui#9306). A type-level pin in + * `filter-builder-protocol-ids-9306.test.tsx` holds it EQUAL to that union, so + * a protocol member added upstream fails to compile there until the dropdown + * offers it. + */ export type FilterBuilderOperator = (typeof defaultOperators)[number]['value'] /** - * Operator ids — this dropdown's OWN camelCase ids — for which this builder - * renders no value input, so "no value" is the row's FINISHED state, not an - * unfinished one (objectui#4744). + * The one stored spelling the builder reads that the spec's alias table does + * NOT fold (objectui#9306). + * + * Every other camelCase id this dropdown used to write — `notEquals`, + * `greaterOrEqual`, `isNull`, … — is a row of `VIEW_FILTER_OPERATOR_ALIASES`, + * so `normalizeFilterOperator` already folds it onto the protocol id and this + * file does not restate it. `containsCaseInsensitive` is the exception, + * measured: `normalizeFilterOperator('containsCaseInsensitive')` returns its + * input unchanged, because the spec's table has no row for it. A filter the + * builder saved under that id (a sharing rule's `FilterConditionField`, the one + * consumer it was offered to) must still load as the operator it is, so this + * row is the builder's own — the ONE local alias, not a second vocabulary. The + * spec-side row is objectstack-ai/objectstack#20092; when the spec's table + * gains it, this map is deleted and the fold below is the spec's alone. + */ +const UNFOLDED_LEGACY_OPERATOR_IDS: ReadonlyMap = new Map([ + ["containsCaseInsensitive", "icontains"], +]) + +/** + * The spelling a stored operator is READ as — the protocol id for every + * spelling the spec or this builder ever wrote (objectui#9306). + * + * The ruling this executes: camelCase is the deprecated alias form + * (objectui#7993), accepted on read and rewritten on write. So a filter stored + * as `{ operator: 'greaterOrEqual' }` keeps loading — it is folded here, through + * the spec's own `normalizeFilterOperator`, onto `greater_than_or_equal` — and + * the builder holds and emits that canonical id from then on, so the next write + * the author makes stores it. A lossless conversion on the read side, ⛔ not a + * second vocabulary: nothing this builder EMITS is ever a camelCase id. + * + * An id nothing folds (`exists`, `notExists`, a spelling from no vocabulary) + * comes back unchanged, exactly as the spec's normalizer returns it: inventing + * a mapping for a word the protocol does not contain would be a claim, not a + * repair. + * + * ⚠️ One guard on the spec's answer, and only because the builder now STORES + * what this returns (before objectui#9306 the fold was used for comparison + * only). `normalizeFilterOperator` is declared to return a string, but it + * looks the spelling up on a plain object, so an `Object.prototype` key comes + * back as whatever the prototype holds — `'constructor'` returns the `Object` + * function, measured on `@objectstack/spec` 17.4.0. Held in a row and written + * back, that would drop the row's operator on serialisation. A non-string + * answer is therefore treated as "nothing folds" and the spelling is kept; + * the defect itself belongs to the spec and is reported there, not patched + * around here in any other way. + */ +export function normalizeFilterBuilderOperator(operator: string): string { + const local = UNFOLDED_LEGACY_OPERATOR_IDS.get(operator) + if (local !== undefined) return local + const folded: unknown = normalizeFilterOperator(operator) + return typeof folded === "string" ? folded : operator +} + +/** + * A group with every row's operator read through + * {@link normalizeFilterBuilderOperator} — the builder's read boundary. + * + * Returns the SAME object when no row's spelling moved, so a group that is + * already canonical (every group this builder emitted itself) is not re-created + * on each prop pass. + */ +function normalizeGroupOperators(group: FilterGroup): FilterGroup { + let moved = false + const conditions = group.conditions.map((c) => { + if (!c || typeof c.operator !== "string") return c + const operator = normalizeFilterBuilderOperator(c.operator) + if (operator === c.operator) return c + moved = true + return { ...c, operator } + }) + return moved ? { ...group, conditions } : group +} + +/** + * Operator ids — this dropdown's own ids, which are the protocol's canonical + * spellings since objectui#9306 — for which this builder renders no value + * input, so "no value" is the row's FINISHED state, not an unfinished one + * (objectui#4744). * * This is the source of truth for that distinction, and it lives here because * this component is the thing that decides it. But membership here is not the - * whole question: `needsValueInput` below is the complement of this set's - * FOLD-CLOSURE under the spec's `normalizeFilterOperator`, not of this set - * itself (objectui#9302), so the gate answers "no value" for spellings that - * are NOT members — the canonical form `foldFilterGroupToSpecRules` persists, - * and the alias rows the spec publishes for those same operators. A consumer - * holding a spelling that did not come from this dropdown must therefore ask - * that FOLD-CLOSURE, not this set: fold this set's own members through - * `normalizeFilterOperator` as well as the spelling, and ask the result — - * the same fold the gate applies to BOTH sides. How much wider the gate's - * preimage is is NOT restated here: the pin + * whole question: `needsValueInput` below asks it of the row's spelling AFTER + * {@link normalizeFilterBuilderOperator} (objectui#9302), so the gate answers + * "no value" for spellings that are NOT members — the deprecated camelCase ids + * a stored filter may still carry, and the alias rows the spec publishes for + * those same operators. A consumer holding a spelling that did not come from + * this dropdown must therefore fold it before asking, and fold this set's own + * members through the same normalizer too — the spec's `normalizeFilterOperator` + * is the identity on them, so that fold costs nothing and keeps the consumer + * correct whichever vocabulary this set is written in. How much wider the + * gate's preimage is is NOT restated here: the pin * `filter-builder-valueless-canonical-spelling-9302.test.tsx` walks the * spec's two published tables and names every row it measures, which is the * only form of that answer that moves when those tables do (AGENTS.md #9). @@ -249,9 +352,7 @@ export type FilterBuilderOperator = (typeof defaultOperators)[number]['value'] * one reads the membership FROM here rather than restating it: * * - `plugin-list`'s `convertFilterGroupToAST` — what the live grid QUERIES; - * - `app-shell`'s `foldFilterGroupToSpecRules` — what a saved view PERSISTS - * (its `VALUELESS_FILTER_OPERATORS` is this set plus the canonical spec - * spellings, which only that layer sees). + * - `app-shell`'s `foldFilterGroupToSpecRules` — what a saved view PERSISTS. * * Placement is forced by the dependency graph — `app-shell` depends on * `plugin-list` depends on this package, so this is the only module all three @@ -259,11 +360,12 @@ export type FilterBuilderOperator = (typeof defaultOperators)[number]['value'] * states is a fact about what this dropdown draws. * * Why it exists at all: each consumer used to keep its own copy, and the - * live-grid copy listed only `isEmpty`/`isNotEmpty`. A fresh row is seeded - * `{ operator: 'equals', value: '' }` and the operator dropdown preserves - * `value`, so picking **Is null** as the first action left `value: ''` — the - * grid read that as an unfinished row, dropped it, and applied NO filter at - * all while the panel showed one. Silent, and every record came back. + * live-grid copy listed only the two empty-string operators. A fresh row is + * seeded `{ operator: 'equals', value: '' }` and the operator dropdown + * preserves `value`, so picking **Is null** as the first action left + * `value: ''` — the grid read that as an unfinished row, dropped it, and + * applied NO filter at all while the panel showed one. Silent, and every + * record came back. * * `exists` / `notExists` stay listed even though `OPT_IN_OPERATORS` withholds * them from most consumers (objectui#4736): the builder still draws them @@ -272,42 +374,14 @@ export type FilterBuilderOperator = (typeof defaultOperators)[number]['value'] * question, answered by `OPT_IN_OPERATORS` and by each consumer's parity test. */ export const VALUELESS_FILTER_BUILDER_OPERATORS: ReadonlySet = new Set([ - "isEmpty", - "isNotEmpty", - "isNull", - "isNotNull", + "is_empty", + "is_not_empty", + "is_null", + "is_not_null", "exists", "notExists", ]) -/** - * The same six operators, keyed by the spelling `normalizeFilterOperator` - * folds each of them to — the lookup table the value-input gate below reads - * (objectui#9302). - * - * DERIVED, never a second literal: a hand-kept canonical copy beside the - * exported set is exactly how the two could come to disagree, and the - * disagreement would be invisible — a row that draws an input the label says - * it does not take. - * - * Why it exists separately instead of widening the export: the exported set - * states a fact about what THIS DROPDOWN draws, and its members are this - * builder's own camelCase ids. Two other layers read it — `plugin-list`'s - * `convertFilterGroupToAST` and `app-shell`'s `foldFilterGroupToSpecRules`, - * the latter already documented as this set PLUS the canonical spellings only - * that layer sees. Folding the canonical spellings INTO the export would make - * that layer's deliberate compensation redundant by side effect, in a file - * nobody is editing. The defect was never a set missing members; it was a - * reader that forgot to normalize its input. - * - * `exists` / `notExists` fold to themselves — the spec's vocabulary has no - * member for either and its alias table deliberately has no row for them — so - * this set is the same size as the one it derives from. - */ -const VALUELESS_FILTER_BUILDER_OPERATORS_CANONICAL: ReadonlySet = new Set( - [...VALUELESS_FILTER_BUILDER_OPERATORS].map(normalizeFilterOperator), -) - /** * The SHAPE an operator's `value` must have — the question * `ViewFilterRuleSchema` asks, asked here in the one place that decides what @@ -337,18 +411,19 @@ const LIST_VALUE_OPERATORS: ReadonlySet = new Set(VIEW_FILTER_LIST_VALUE const PAIR_VALUE_OPERATORS: ReadonlySet = new Set(VIEW_FILTER_PAIR_VALUE_OPERATORS) /** - * Which value shape `operator` takes, in EITHER dialect. + * Which value shape `operator` takes, whichever spelling it arrives in. * - * The argument is folded through the spec's own `normalizeFilterOperator` - * first, so the builder's camelCase dropdown ids (`notIn`) and the canonical - * spellings a stored rule carries (`not_in`) land on the same answer. That - * fold is the whole point: one vocabulary, declared upstream, consulted here — - * not a second local dialect that has to be kept in sync by hand. + * The argument is folded through {@link normalizeFilterBuilderOperator} — the + * spec's own `normalizeFilterOperator` plus the builder's one unfolded legacy + * id — first, so the canonical id this dropdown emits (`not_in`) and the + * deprecated alias a stored filter may still carry (`notIn`) land on the same + * answer. That fold is the whole point: one vocabulary, declared upstream, + * consulted here — not a second local dialect kept in sync by hand. * * @internal exported for tests */ export function filterValueArity(operator: string): FilterValueArity { - const canonical = normalizeFilterOperator(operator) + const canonical = normalizeFilterBuilderOperator(operator) if (LIST_VALUE_OPERATORS.has(canonical)) return "list" if (PAIR_VALUE_OPERATORS.has(canonical)) return "pair" return "scalar" @@ -395,7 +470,7 @@ export function reshapeFilterValue( * NEW field offers (objectui#4768). * * Each field type has its own operator bucket, and the buckets are not nested: - * a `select` column offers `in` / `notIn`, a `text` column does not. Changing + * a `select` column offers `in` / `not_in`, a `text` column does not. Changing * the field used to write `{ field }` alone, so the row's operator survived * into a bucket that no longer contains it — `in` on a text column. The Radix * trigger then had nothing to render (its `SelectValue` matches against the @@ -408,16 +483,16 @@ export function reshapeFilterValue( * entry (`equals` for every bucket this builder draws), and the caller then * re-shapes the value for that operator's family. * - * Membership is decided CANONICALLY, through the spec's own - * `normalizeFilterOperator` — the same fold `filterValueArity` uses. A stored - * rule can reach this builder spelled `not_in` while the dropdown lists the - * alias `notIn`; those are one operator, so a select→lookup switch must not - * silently rewrite the author's operator to `equals` just because the two - * spellings differ. The fold is safe to compare through because it is - * INJECTIVE over this builder's vocabulary — all 22 ids in `defaultOperators` - * normalize to 22 distinct canonical operators, pinned in - * `filter-builder-field-switch-operator.test.tsx` — so no two OFFERED - * operators can ever collapse onto one another. + * Membership is decided CANONICALLY, through + * {@link normalizeFilterBuilderOperator} — the same fold `filterValueArity` + * uses. A caller can hand this helper a row spelled with the deprecated alias + * `notIn` while the dropdown lists the protocol id `not_in`; those are one + * operator, so a select→lookup switch must not silently rewrite the author's + * operator to `equals` just because the two spellings differ. The fold is safe + * to compare through because it is INJECTIVE over this builder's vocabulary — + * all 22 ids in `defaultOperators` normalize to 22 distinct canonical + * operators, pinned in `filter-builder-field-switch-operator.test.tsx` — so no + * two OFFERED operators can ever collapse onto one another. * * @internal exported for tests */ @@ -425,9 +500,9 @@ export function reconcileOperatorForField( operator: string, offeredOperators: ReadonlyArray<{ value: string }>, ): string { - const canonical = normalizeFilterOperator(operator) + const canonical = normalizeFilterBuilderOperator(operator) const stillOffered = offeredOperators.some( - (op) => normalizeFilterOperator(op.value) === canonical, + (op) => normalizeFilterBuilderOperator(op.value) === canonical, ) // Kept in the row's OWN spelling: a field switch is not a spelling migration. if (stillOffered) return operator @@ -440,17 +515,20 @@ export function reconcileOperatorForField( * fold (objectui#7561). * * Radix matches `SelectValue` against the `SelectItem`s actually MOUNTED, and - * the mounted ids are this builder's own camelCase vocabulary. A row can hold - * the operator under another spelling of the SAME operator and still be - * perfectly valid: + * the mounted ids are this builder's own vocabulary — the protocol's canonical + * ids since objectui#9306 (they were camelCase when this helper was written). A + * row can hold the operator under another spelling of the SAME operator and + * still be perfectly valid: * - * - the spec's canonical `greater_than`, which is what `FilterOperatorSchema` - * accepts and what `foldFilterGroupToSpecRules` persists; + * - a deprecated camelCase alias such as `greaterThan`, which a filter saved + * before objectui#9306 still carries; * - the spec's alias table `gt` / `lt` / `eq`, which three schema-catalog * entries author today. * - * Neither matched `greaterThan` literally, so the trigger drew BLANK over a row - * that filtered correctly — the user's own operator, invisible and unreachable. + * When this helper was written the mounted id was `greaterThan` and the stored + * spelling `greater_than`; neither matched the other literally, so the trigger + * drew BLANK over a row that filtered correctly — the user's own operator, + * invisible and unreachable. * Everywhere the operator's MEANING matters this component already folds first * ({@link filterValueArity}, {@link reconcileOperatorForField}); this was the * one place it did not, and that omission — not the vocabulary divergence — is @@ -462,7 +540,12 @@ export function reconcileOperatorForField( * * 1. it does not rewrite `condition.operator` — the row keeps the spelling it * arrived with, exactly as {@link reconcileOperatorForField} keeps it, and - * nothing is written back on render; + * nothing is written back on render. (Since objectui#9306 the BUILDER + * folds a row's spelling once, at its read boundary — see + * {@link normalizeFilterBuilderOperator} — so the rows it renders already + * hold protocol ids. This helper still folds both sides, because it is + * exported and its caller's rows need not have come through that + * boundary.) * 2. it does not mount a new `SelectItem`, so the vocabulary the dropdown * EMITS is byte-identical — contrast the value select above, where * objectui#4874 ruling C mounts the outside-options value as its own @@ -485,9 +568,9 @@ export function mountedOperatorValue( operator: string, offeredOperators: ReadonlyArray<{ value: string }>, ): string { - const canonical = normalizeFilterOperator(operator) + const canonical = normalizeFilterBuilderOperator(operator) const mounted = offeredOperators.find( - (op) => normalizeFilterOperator(op.value) === canonical, + (op) => normalizeFilterBuilderOperator(op.value) === canonical, ) return mounted?.value ?? operator } @@ -695,7 +778,7 @@ function convertScalarToFamily( * - `scalar` — converted, or `""`. * - `list` — converted ENTRY BY ENTRY, keeping the ones that carry: * `["42", "acme"]` → `[42]`, and `[]` when none do. Reachable today only - * between the two buckets that offer `in`/`notIn` (`select` ↔ `lookup`), + * between the two buckets that offer `in`/`not_in` (`select` ↔ `lookup`), * which are both text-family, so the conversion is the identity there; it * is written for the family the operator lands in rather than for today's * buckets, and pinned directly on this helper. @@ -983,25 +1066,25 @@ const useSafeFilterTranslation = createSafeTranslation( 'filterBuilder.rangeStart': 'From', 'filterBuilder.rangeEnd': 'To', 'filterBuilder.operators.equals': 'Equals', - 'filterBuilder.operators.notEquals': 'Does not equal', + 'filterBuilder.operators.not_equals': 'Does not equal', 'filterBuilder.operators.contains': 'Contains', - 'filterBuilder.operators.containsCaseInsensitive': 'Contains (ignore case)', - 'filterBuilder.operators.notContains': 'Does not contain', - 'filterBuilder.operators.isEmpty': 'Is empty', - 'filterBuilder.operators.isNotEmpty': 'Is not empty', - 'filterBuilder.operators.greaterThan': 'Greater than', - 'filterBuilder.operators.lessThan': 'Less than', - 'filterBuilder.operators.greaterOrEqual': 'Greater than or equal', - 'filterBuilder.operators.lessOrEqual': 'Less than or equal', + 'filterBuilder.operators.icontains': 'Contains (ignore case)', + 'filterBuilder.operators.not_contains': 'Does not contain', + 'filterBuilder.operators.is_empty': 'Is empty', + 'filterBuilder.operators.is_not_empty': 'Is not empty', + 'filterBuilder.operators.greater_than': 'Greater than', + 'filterBuilder.operators.less_than': 'Less than', + 'filterBuilder.operators.greater_than_or_equal': 'Greater than or equal', + 'filterBuilder.operators.less_than_or_equal': 'Less than or equal', 'filterBuilder.operators.before': 'Before', 'filterBuilder.operators.after': 'After', 'filterBuilder.operators.between': 'Between', 'filterBuilder.operators.in': 'In', - 'filterBuilder.operators.notIn': 'Not in', - 'filterBuilder.operators.startsWith': 'Starts with', - 'filterBuilder.operators.endsWith': 'Ends with', - 'filterBuilder.operators.isNull': 'Is null', - 'filterBuilder.operators.isNotNull': 'Is not null', + 'filterBuilder.operators.not_in': 'Not in', + 'filterBuilder.operators.starts_with': 'Starts with', + 'filterBuilder.operators.ends_with': 'Ends with', + 'filterBuilder.operators.is_null': 'Is null', + 'filterBuilder.operators.is_not_null': 'Is not null', 'filterBuilder.operators.exists': 'Is set', 'filterBuilder.operators.notExists': 'Is not set', // The half-filled range's description, read from the SHARED `validation` @@ -1020,22 +1103,25 @@ const useSafeFilterTranslation = createSafeTranslation( 'filterBuilder.where', ) -const NULLNESS_OPERATORS = ["isNull", "isNotNull", "exists", "notExists"] -const textOperators = ["equals", "notEquals", "contains", "containsCaseInsensitive", "notContains", "startsWith", "endsWith", "isEmpty", "isNotEmpty", ...NULLNESS_OPERATORS] -const numberOperators = ["equals", "notEquals", "greaterThan", "lessThan", "greaterOrEqual", "lessOrEqual", "isEmpty", "isNotEmpty", ...NULLNESS_OPERATORS] -const booleanOperators = ["equals", "notEquals"] -const dateOperators = ["equals", "notEquals", "before", "after", "between", "isEmpty", "isNotEmpty", ...NULLNESS_OPERATORS] -const selectOperators = ["equals", "notEquals", "in", "notIn", "isEmpty", "isNotEmpty", ...NULLNESS_OPERATORS] -const lookupOperators = ["equals", "notEquals", "in", "notIn", "isEmpty", "isNotEmpty", ...NULLNESS_OPERATORS] +// Typed as the dropdown's own id union, so a bucket naming an id the dropdown +// does not draw — a stale camelCase spelling, say — fails to compile instead of +// silently offering nothing (objectui#9306). +const NULLNESS_OPERATORS: readonly FilterBuilderOperator[] = ["is_null", "is_not_null", "exists", "notExists"] +const textOperators: readonly FilterBuilderOperator[] = ["equals", "not_equals", "contains", "icontains", "not_contains", "starts_with", "ends_with", "is_empty", "is_not_empty", ...NULLNESS_OPERATORS] +const numberOperators: readonly FilterBuilderOperator[] = ["equals", "not_equals", "greater_than", "less_than", "greater_than_or_equal", "less_than_or_equal", "is_empty", "is_not_empty", ...NULLNESS_OPERATORS] +const booleanOperators: readonly FilterBuilderOperator[] = ["equals", "not_equals"] +const dateOperators: readonly FilterBuilderOperator[] = ["equals", "not_equals", "before", "after", "between", "is_empty", "is_not_empty", ...NULLNESS_OPERATORS] +const selectOperators: readonly FilterBuilderOperator[] = ["equals", "not_equals", "in", "not_in", "is_empty", "is_not_empty", ...NULLNESS_OPERATORS] +const lookupOperators: readonly FilterBuilderOperator[] = ["equals", "not_equals", "in", "not_in", "is_empty", "is_not_empty", ...NULLNESS_OPERATORS] /** Field types that share the same operator/input behavior as number (numeric comparison operators, number input) */ const numberLikeTypes = ["number", "currency", "percent", "rating"] /** Field types that share the same operator/input behavior as date (before/after operators, date/datetime/time input) */ const dateLikeTypes = ["date", "datetime", "time"] -/** Field types that use select operators (equals/in/notIn) and render dropdown or checkbox list when options provided */ +/** Field types that use select operators (equals/in/not_in) and render dropdown or checkbox list when options provided */ const selectLikeTypes = ["select", "status"] /** - * Relational/reference field types that use lookup operators (equals/in/notIn) + * Relational/reference field types that use lookup operators (equals/in/not_in) * and render dropdown or checkbox list when options provided. * * `owner` left this list with objectui#4914: it is a RETIRED spelling, and @@ -1178,14 +1264,23 @@ function FilterBuilder({ extraOperators, }: FilterBuilderProps) { const { t } = useSafeFilterTranslation() + // THE READ BOUNDARY (objectui#9306). A group arriving from the host is held + // with every row's operator folded onto its protocol id, so a filter stored + // under a deprecated camelCase id loads as the operator it is — and since + // every emit below spreads this state, the host's NEXT write carries the + // canonical id. Nothing is emitted here on read: opening a stored filter + // must not dirty the form that holds it, so the rewrite rides the author's + // own next edit, exactly as the ruling orders ("accepted on read and + // rewritten on write"). const [filterGroup, setFilterGroup] = React.useState( - isValidGroup(value) ? value : EMPTY_GROUP, + isValidGroup(value) ? normalizeGroupOperators(value) : EMPTY_GROUP, ) React.useEffect(() => { if (!isValidGroup(value)) return - if (JSON.stringify(value) !== JSON.stringify(filterGroup)) { - setFilterGroup(value) + const next = normalizeGroupOperators(value) + if (JSON.stringify(next) !== JSON.stringify(filterGroup)) { + setFilterGroup(next) } }, [value]) @@ -1319,23 +1414,22 @@ function FilterBuilder({ }) } - // The complement of the exported set's FOLD-CLOSURE, never a second literal - // beside it: + // The complement of the exported set, never a second literal beside it: // that set's whole job is to let other layers know which rows this builder // leaves value-less, and a hand-kept copy here is how they drifted apart. // - // BOTH sides are folded through the spec's `normalizeFilterOperator` — the - // same fold `filterValueArity` and `reconcileOperatorForField` already - // perform, so this is one more site joining a fold this file does rather - // than a new dialect. The gate used to do a raw `has()` on whatever spelling - // the row carried, and the set's members are the dropdown's camelCase ids: - // a stored rule spelled `is_null` — the spec's CANONICAL form, which is what - // `foldFilterGroupToSpecRules` persists and what any spec-side producer - // emits — missed the set and was treated as value-taking. The row then drew - // a box to type a value into, directly beside a trigger reading `Is null` - // (objectui#9302). One operator, two spellings, two different rows. + // The row's spelling is folded through `normalizeFilterBuilderOperator` + // before the lookup — the same fold `filterValueArity` and + // `reconcileOperatorForField` perform — and the set's members are already + // the folded (protocol) ids, so one spelling of an operator can never get a + // different answer from another. The gate used to do a raw `has()` on + // whatever spelling the row carried while the set held the dropdown's then + // camelCase ids: a stored rule spelled `is_null` missed the set and was + // treated as value-taking, drawing a box to type a value into beside a + // trigger reading `Is null` (objectui#9302). One operator, two spellings, + // two different rows — which the fold rules out in either direction. const needsValueInput = (operator: string) => { - return !VALUELESS_FILTER_BUILDER_OPERATORS_CANONICAL.has(normalizeFilterOperator(operator)) + return !VALUELESS_FILTER_BUILDER_OPERATORS.has(normalizeFilterBuilderOperator(operator)) } // Derived from the value FAMILY rather than from a second branch ladder over @@ -1351,8 +1445,8 @@ function FilterBuilder({ const renderValueInput = (condition: FilterBuilderCondition) => { const field = fields.find((f) => f.value === condition.field) // The spec's vocabulary, not a local literal — and folded through - // `normalizeFilterOperator`, so a stored `not_in` read back in canonical - // form gets the multi-value input its alias `notIn` already got. + // `normalizeFilterBuilderOperator`, so the deprecated alias `notIn` gets + // the multi-value input the protocol id `not_in` gets. const arity = filterValueArity(condition.operator) const isMultiOperator = arity === "list" // THE GATE (objectui#4914, ruling B), ahead of the control choice. @@ -1398,7 +1492,7 @@ function FilterBuilder({ ) } - // For select/lookup fields with options and multi-select operator (in/notIn) + // For select/lookup fields with options and multi-select operator (in/not_in) if (field?.options && isMultiOperator) { const selectedValues = normalizeToArray(condition.value) // The list face of the same invisible value (objectui#4874). A selected diff --git a/packages/core/src/utils/__tests__/filter-icontains-alignment-8976.test.ts b/packages/core/src/utils/__tests__/filter-icontains-alignment-8976.test.ts index 5c0e6ae456..6134576042 100644 --- a/packages/core/src/utils/__tests__/filter-icontains-alignment-8976.test.ts +++ b/packages/core/src/utils/__tests__/filter-icontains-alignment-8976.test.ts @@ -14,7 +14,8 @@ * * `$icontains` is a canonical member of `@objectstack/spec`'s * `FILTER_OPERATORS`; `ValueDataSource` executes it; `FilterConditionField` - * emits it for the `containsCaseInsensitive` builder row; and + * emits it for the `icontains` builder row (spelled `containsCaseInsensitive` + * until objectui#9306); and * `packages/core/src/adapters/README.md` PRESCRIBES it as the repair for * `$like` / `$ilike` / `$regex`. `convertOperatorToAST` had no row for it, so * `convertFiltersToAST` refused it with the generic unknown-operator paragraph diff --git a/packages/core/src/utils/filter-converter.ts b/packages/core/src/utils/filter-converter.ts index 63228ed3fc..bb500d149e 100644 --- a/packages/core/src/utils/filter-converter.ts +++ b/packages/core/src/utils/filter-converter.ts @@ -162,7 +162,8 @@ export function convertOperatorToAST(operator: string): string | null { '$endsWith': 'endswith', // Case-insensitive contains. A canonical `FILTER_OPERATORS` member that // `ValueDataSource` executes and `FilterConditionField` emits (for its - // `containsCaseInsensitive` builder row), while this map refused it with the + // `icontains` builder row — spelled `containsCaseInsensitive` until + // objectui#9306), while this map refused it with the // generic unknown-operator paragraph — so ONE authored filter selected rows // through the in-memory matcher and 400'd on the ObjectStack lowering path // (objectui#8976). The other direction of the same split objectui#8568 fixed: diff --git a/packages/fields/src/widgets/FilterConditionField.tsx b/packages/fields/src/widgets/FilterConditionField.tsx index cfc7552644..e93dd866a0 100644 --- a/packages/fields/src/widgets/FilterConditionField.tsx +++ b/packages/fields/src/widgets/FilterConditionField.tsx @@ -48,8 +48,7 @@ interface BuilderGroup { const EMPTY_GROUP: BuilderGroup = { id: 'root', logic: 'and', conditions: [] }; /** - * Opt-in FilterBuilder operators this widget offers (objectui#4023, - * objectui#4736). + * Opt-in FilterBuilder operators this widget offers (objectui#4736). * * The shared dropdown withholds these because two of its three consumers * persist into dialects that cannot carry them (see `OPT_IN_OPERATORS` in @@ -59,14 +58,19 @@ const EMPTY_GROUP: BuilderGroup = { id: 'root', logic: 'and', conditions: [] }; * folded into a `ViewFilterRule` — so the spec's `FILTER_OPERATORS` is the only * vocabulary it has to satisfy. * - * - `containsCaseInsensitive` authors `$icontains`, executable on every - * driver and evaluation face the platform ships (objectstack#5702 + - * objectstack#6520). * - `exists` / `notExists` author `$exists`, which `condToMongo` has emitted * and `kvToCondition` has read back since objectui#2942. Naming them here * is what KEEPS them reachable now that the shared dropdown no longer * offers them to the list and view surfaces, whose dialects have no - * existence operator at all (objectui#4736). + * existence operator at all (objectui#4736). They are NOT folded onto + * `is_not_null` / `is_null` (objectui#9559 ruling B, objectui#9306): on the + * key-presence drivers that would change which records a stored sharing + * rule matches. + * + * The case-insensitive contains used to be named here too, as + * `containsCaseInsensitive`. Since objectui#9306 it is the protocol's own + * `icontains` and an ordinary operator every consumer is offered, so there is + * nothing to opt into; it still authors `$icontains`, exactly as before. * * Module scope, not an inline literal: a fresh array each render would reset * `FilterBuilder`'s memo inputs on every keystroke. @@ -74,7 +78,6 @@ const EMPTY_GROUP: BuilderGroup = { id: 'root', logic: 'and', conditions: [] }; * @internal exported for tests */ export const FILTER_CONDITION_EXTRA_OPERATORS: readonly string[] = [ - 'containsCaseInsensitive', 'exists', 'notExists', ]; @@ -141,10 +144,16 @@ function coerceByType(value: any, type?: string): any { * and which therefore have an "operator chosen, box still empty" state * (objectui#8748). * - * `isEmpty` / `isNotEmpty` / `isNull` / `exists` and their kin are not on this - * list: they read no value at all, so an empty box is their normal resting + * `is_empty` / `is_not_empty` / `is_null` / `exists` and their kin are not on + * this list: they read no value at all, so an empty box is their normal resting * state rather than an unfinished row. * + * Keyed, like every table in this file, on the builder's ids — which are the + * protocol's canonical operator ids since objectui#9306. Rows reach this widget + * from {@link kvToCondition} and from the builder's `onChange`, and both carry + * those ids (the builder folds a deprecated camelCase id at its own read + * boundary), so a camelCase key here would match nothing. + * * ⚠️ This is a SECOND "is this row finished" rule, beside `isFilterValueComplete` * in `@object-ui/components`' `filter-builder.tsx`, and the divergence is * deliberate rather than a copy that drifted. That helper answers the VALUE @@ -164,10 +173,10 @@ function coerceByType(value: any, type?: string): any { */ const TEXT_COMPARAND_OPERATORS: ReadonlySet = new Set([ 'contains', - 'containsCaseInsensitive', - 'notContains', - 'startsWith', - 'endsWith', + 'icontains', + 'not_contains', + 'starts_with', + 'ends_with', ]); /** @@ -196,6 +205,14 @@ function toArray(value: any): any[] { * chokepoint where a builder token becomes a spec `FieldOperatorsSchema` key, * and a wrong spelling here is rejected downstream by `convertFiltersToAST` * rather than at authoring time. @internal + * + * ⚠️ The arms are keyed on the builder's PROTOCOL ids (objectui#9306), and the + * `default` arm stores an EQUALITY. So an operator id with no arm here is not + * refused — it silently becomes `{ [field]: value }`, which for a + * `greater_than_or_equal` row would share a different set of records than the + * one on screen. Every id the dropdown can draw has its own arm, and + * `filter-builder-protocol-ids-census-9306.test.ts` (app-shell) pins each one's + * stored predicate, so a missing arm is a red test rather than a quiet `$eq`. */ export function condToMongo(c: BuilderCondition, typeOf: (f: string) => string | undefined): Record | null { const { field, operator, value } = c || ({} as BuilderCondition); @@ -219,7 +236,7 @@ export function condToMongo(c: BuilderCondition, typeOf: (f: string) => string | const cv = coerceByType(value, t); switch (operator) { case 'equals': return { [field]: cv }; - case 'notEquals': return { [field]: { $ne: cv } }; + case 'not_equals': return { [field]: { $ne: cv } }; case 'contains': return { [field]: { $contains: value } }; // Case-insensitive contains (objectui#4023). `$contains` and its ASCII-case- // folding twin are two operators, not one with a flag: `contains` keeps @@ -227,30 +244,30 @@ export function condToMongo(c: BuilderCondition, typeOf: (f: string) => string | // The fold is ASCII-only by contract (objectstack#4706 Q1 = A) — `café` does // NOT match `CAFÉ` — which is why the label says "ignore case" rather than // promising an accent-blind search. - case 'containsCaseInsensitive': return { [field]: { $icontains: value } }; + case 'icontains': return { [field]: { $icontains: value } }; // `$notContains` is the spec spelling (FieldOperatorsSchema, data/filter.zod.ts). // This emitted `$ncontains` — a token that appears nowhere in @objectstack/spec and // that convertFiltersToAST throws on, so every "does not contain" rule authored here // was rejected downstream. See kvToCondition for reading the old spelling back. - case 'notContains': return { [field]: { $notContains: value } }; + case 'not_contains': return { [field]: { $notContains: value } }; // String-specific spec operators — previously unreachable from the // builder UI even though FieldOperatorsSchema accepts them (#2942). - case 'startsWith': return { [field]: { $startsWith: value } }; - case 'endsWith': return { [field]: { $endsWith: value } }; - case 'isEmpty': return { [field]: { $in: [null, ''] } }; - case 'isNotEmpty': return { [field]: { $nin: [null, ''] } }; - // Null / existence spec operators. Distinct from isEmpty/isNotEmpty, + case 'starts_with': return { [field]: { $startsWith: value } }; + case 'ends_with': return { [field]: { $endsWith: value } }; + case 'is_empty': return { [field]: { $in: [null, ''] } }; + case 'is_not_empty': return { [field]: { $nin: [null, ''] } }; + // Null / existence spec operators. Distinct from is_empty/is_not_empty, // which also treat '' as empty. - case 'isNull': return { [field]: { $null: true } }; - case 'isNotNull': return { [field]: { $null: false } }; + case 'is_null': return { [field]: { $null: true } }; + case 'is_not_null': return { [field]: { $null: false } }; case 'exists': return { [field]: { $exists: true } }; case 'notExists': return { [field]: { $exists: false } }; - case 'greaterThan': + case 'greater_than': case 'after': return { [field]: { $gt: cv } }; - case 'lessThan': + case 'less_than': case 'before': return { [field]: { $lt: cv } }; - case 'greaterOrEqual': return { [field]: { $gte: cv } }; - case 'lessOrEqual': return { [field]: { $lte: cv } }; + case 'greater_than_or_equal': return { [field]: { $gte: cv } }; + case 'less_than_or_equal': return { [field]: { $lte: cv } }; // objectui#9914 — a range reaches storage only once BOTH bounds are filled // in; a half-filled one is DROPPED, exactly as the unfinished text row // above is. @@ -290,7 +307,7 @@ export function condToMongo(c: BuilderCondition, typeOf: (f: string) => string | return { [field]: { $gte: coerceByType(a, t), $lte: coerceByType(b, t) } }; } case 'in': return { [field]: { $in: toArray(value).map((v) => coerceByType(v, t)) } }; - case 'notIn': return { [field]: { $nin: toArray(value).map((v) => coerceByType(v, t)) } }; + case 'not_in': return { [field]: { $nin: toArray(value).map((v) => coerceByType(v, t)) } }; default: return { [field]: cv }; } } @@ -345,6 +362,8 @@ function criteriaKey(mongo: any): string { * Criteria → builder condition (the reverse of {@link condToMongo}). Returning * `null` makes the builder refuse to load the rule ("criteria can't be * represented"), so this must keep accepting spellings previously written. + * The rows it produces carry the builder's protocol ids (objectui#9306); the + * stored criteria are `$`-tokens, which that change did not touch. * @internal */ export function kvToCondition(field: string, v: any, idx: number): BuilderCondition | null { @@ -357,33 +376,33 @@ export function kvToCondition(field: string, v: any, idx: number): BuilderCondit const op = opKeys[0]; const val = v[op]; switch (op) { - case '$ne': return { id, field, operator: 'notEquals', value: val }; + case '$ne': return { id, field, operator: 'not_equals', value: val }; case '$contains': return { id, field, operator: 'contains', value: val }; // Without this arm a criteria the builder itself just wrote would fail to // load on reopen ("criteria can't be represented") and drop the admin into // the raw-JSON editor — the degradation objectui#4023 deliverable 2 names. - case '$icontains': return { id, field, operator: 'containsCaseInsensitive', value: val }; + case '$icontains': return { id, field, operator: 'icontains', value: val }; // `$ncontains` is the pre-fix spelling this widget used to emit. Criteria saved // before the fix still carry it, so keep reading it — dropping it here would make // those rules fail to load ("criteria can't be represented") instead of migrating. case '$notContains': - case '$ncontains': return { id, field, operator: 'notContains', value: val }; - case '$gt': return { id, field, operator: 'greaterThan', value: val }; - case '$lt': return { id, field, operator: 'lessThan', value: val }; - case '$gte': return { id, field, operator: 'greaterOrEqual', value: val }; - case '$lte': return { id, field, operator: 'lessOrEqual', value: val }; - case '$startsWith': return { id, field, operator: 'startsWith', value: val }; - case '$endsWith': return { id, field, operator: 'endsWith', value: val }; - case '$null': return { id, field, operator: val === false ? 'isNotNull' : 'isNull', value: '' }; + case '$ncontains': return { id, field, operator: 'not_contains', value: val }; + case '$gt': return { id, field, operator: 'greater_than', value: val }; + case '$lt': return { id, field, operator: 'less_than', value: val }; + case '$gte': return { id, field, operator: 'greater_than_or_equal', value: val }; + case '$lte': return { id, field, operator: 'less_than_or_equal', value: val }; + case '$startsWith': return { id, field, operator: 'starts_with', value: val }; + case '$endsWith': return { id, field, operator: 'ends_with', value: val }; + case '$null': return { id, field, operator: val === false ? 'is_not_null' : 'is_null', value: '' }; case '$exists': return { id, field, operator: val === false ? 'notExists' : 'exists', value: '' }; case '$in': return arraysEqual(val, [null, '']) - ? { id, field, operator: 'isEmpty', value: '' } + ? { id, field, operator: 'is_empty', value: '' } : { id, field, operator: 'in', value: val }; case '$nin': return arraysEqual(val, [null, '']) - ? { id, field, operator: 'isNotEmpty', value: '' } - : { id, field, operator: 'notIn', value: val }; + ? { id, field, operator: 'is_not_empty', value: '' } + : { id, field, operator: 'not_in', value: val }; default: return null; } } @@ -546,8 +565,8 @@ export function FilterConditionField({ * emitted. That was survivable only while every row round-tripped. Since the * same change makes `condToMongo` DROP a text row whose value box is still * empty, a projected row deletes itself: switching a row's operator to any of - * `contains` / `containsCaseInsensitive` / `notContains` / `startsWith` / - * `endsWith` emits no fragment, the criteria goes back to empty, and + * `contains` / `icontains` / `not_contains` / `starts_with` / + * `ends_with` emits no fragment, the criteria goes back to empty, and * `FilterBuilder` — which re-seeds its internal rows whenever the incoming * `value` differs from them — drops the row before a comparand can be typed. * Measured: those five operators were unreachable through this UI, except by diff --git a/packages/fields/src/widgets/__tests__/FilterConditionField.betweenBlankBound-9914.test.tsx b/packages/fields/src/widgets/__tests__/FilterConditionField.betweenBlankBound-9914.test.tsx index e546d9b4fc..73b4144995 100644 --- a/packages/fields/src/widgets/__tests__/FilterConditionField.betweenBlankBound-9914.test.tsx +++ b/packages/fields/src/widgets/__tests__/FilterConditionField.betweenBlankBound-9914.test.tsx @@ -243,7 +243,7 @@ describe('condToMongo: the `between` arm, both directions', () => { it('leaves every other operator alone — `equals ""` is still a real predicate', () => { expect(condToMongo({ id: 'c', field: 'name', operator: 'equals', value: '' } as any, noTypes)) .toEqual({ name: '' }); - expect(condToMongo({ id: 'c', field: 'age', operator: 'greaterOrEqual', value: '' } as any, noTypes)) + expect(condToMongo({ id: 'c', field: 'age', operator: 'greater_than_or_equal', value: '' } as any, noTypes)) .toEqual({ age: { $gte: '' } }); }); diff --git a/packages/fields/src/widgets/__tests__/FilterConditionField.builderRows-8748.test.tsx b/packages/fields/src/widgets/__tests__/FilterConditionField.builderRows-8748.test.tsx index 07b4403217..03552e1d1a 100644 --- a/packages/fields/src/widgets/__tests__/FilterConditionField.builderRows-8748.test.tsx +++ b/packages/fields/src/widgets/__tests__/FilterConditionField.builderRows-8748.test.tsx @@ -17,10 +17,13 @@ * `FilterBuilder` re-seeds its internal rows whenever the incoming `value` * differs from them (`filter-builder.tsx`). Dropping at `condToMongo` therefore * DELETED THE ROW FROM THE SCREEN: switching a row to any of `contains` / - * `containsCaseInsensitive` / `notContains` / `startsWith` / `endsWith` emitted - * no fragment, the criteria went back to empty, and the row vanished before a - * comparand could be typed. Those five operators were unreachable through this - * UI except by typing the value under `equals` first. + * `containsCaseInsensitive` / `notContains` / `startsWith` / `endsWith` (the + * builder's ids when this was written; `contains` / `icontains` / + * `not_contains` / `starts_with` / `ends_with` since objectui#9306, and the + * case-insensitive one is no longer opt-in) emitted no fragment, the criteria + * went back to empty, and the row vanished before a comparand could be typed. + * Those five operators were unreachable through this UI except by typing the + * value under `equals` first. * * No unit test could see it: `condToMongo` is a pure function and answers * correctly in isolation. It takes a RENDER-level round-trip — a controlled diff --git a/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts b/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts index 38a9bc2a90..14b86a7067 100644 --- a/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts +++ b/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts @@ -56,15 +56,16 @@ const noTypes = () => undefined; * * `$icontains` was here from objectui#3560 until objectui#4023: the spec gained * it between @objectstack/spec 17.0.0-rc.2 and rc.5, and no builder operator - * could author it. `containsCaseInsensitive` now does, so the entry is gone and - * the parity assertion below is what holds that honest. + * could author it. `icontains` (spelled `containsCaseInsensitive` until + * objectui#9306) now does, so the entry is gone and the parity assertion below + * is what holds that honest. * * `$like` and `$ilike` arrived with the `@objectstack/spec` 17.0.0 GA pin * (objectui#4636), and their entry here is a DECISION, not an open question: * objectui#4911, maintainer-ruled B on 2026-08-17 — this visual builder * deliberately does not offer raw pattern-matching authoring. The constrained * intents are the authorable surface and already reach the dropdown (`contains` - * / `containsCaseInsensitive` / `startsWith` / `endsWith`), so what a `$like` + * / `icontains` / `starts_with` / `ends_with`), so what a `$like` * row would add on top of them is exactly the raw `%`/`_` wildcard form — the * one operator where a mis-authored value silently returns the wrong rows * instead of erroring, which is an error bed in an end-user filter UI, and for @@ -100,36 +101,36 @@ function operatorsOf(frag: Record | null): string[] { describe('condToMongo emits only spec operator spellings', () => { const builderOperators = [ - 'equals', 'notEquals', 'contains', 'containsCaseInsensitive', 'notContains', - 'isEmpty', 'isNotEmpty', - 'greaterThan', 'lessThan', 'greaterOrEqual', 'lessOrEqual', 'in', 'notIn', + 'equals', 'not_equals', 'contains', 'icontains', 'not_contains', + 'is_empty', 'is_not_empty', + 'greater_than', 'less_than', 'greater_than_or_equal', 'less_than_or_equal', 'in', 'not_in', 'before', 'after', - 'startsWith', 'endsWith', 'isNull', 'isNotNull', 'exists', 'notExists', + 'starts_with', 'ends_with', 'is_null', 'is_not_null', 'exists', 'notExists', ]; it.each(builderOperators)('%s emits a spec-defined operator', (operator) => { const frag = condToMongo( - { id: 'c1', field: 'name', operator, value: operator === 'in' || operator === 'notIn' ? ['a'] : 'a' } as any, + { id: 'c1', field: 'name', operator, value: operator === 'in' || operator === 'not_in' ? ['a'] : 'a' } as any, noTypes, ); const unknown = operatorsOf(frag).filter((op) => !SPEC_OPERATORS.has(op)); expect(unknown, `${operator} emits an operator @objectstack/spec does not define`).toEqual([]); }); - it('containsCaseInsensitive emits $icontains, and contains still emits $contains', () => { + it('icontains emits $icontains, and contains still emits $contains', () => { // The pair, in one assertion: objectui#4023's recorded default is a NEW // operator, so `contains` keeping its case-SENSITIVE spelling is half of // what shipped. Flipping it would silently change every stored filter view. expect( - condToMongo({ id: 'c1', field: 'name', operator: 'containsCaseInsensitive', value: 'acme' } as any, noTypes), + condToMongo({ id: 'c1', field: 'name', operator: 'icontains', value: 'acme' } as any, noTypes), ).toEqual({ name: { $icontains: 'acme' } }); expect( condToMongo({ id: 'c2', field: 'name', operator: 'contains', value: 'acme' } as any, noTypes), ).toEqual({ name: { $contains: 'acme' } }); }); - it('notContains emits $notContains, not the pre-fix $ncontains', () => { - const frag = condToMongo({ id: 'c1', field: 'name', operator: 'notContains', value: 'x' } as any, noTypes); + it('not_contains emits $notContains, not the pre-fix $ncontains', () => { + const frag = condToMongo({ id: 'c1', field: 'name', operator: 'not_contains', value: 'x' } as any, noTypes); expect(frag).toEqual({ name: { $notContains: 'x' } }); }); @@ -150,7 +151,7 @@ describe('every spec field operator is reachable from the builder (#2942)', () = // sweep and take a spec token's only route to the UI with it. const emitted = new Set(); for (const operator of FILTER_BUILDER_OPERATORS) { - const value = operator === 'in' || operator === 'notIn' ? ['a'] : operator === 'between' ? [1, 5] : 'a'; + const value = operator === 'in' || operator === 'not_in' ? ['a'] : operator === 'between' ? [1, 5] : 'a'; const frag = condToMongo({ id: 'c1', field: 'f', operator, value } as any, noTypes); for (const op of operatorsOf(frag)) emitted.add(op); } @@ -183,17 +184,20 @@ describe('every spec field operator is reachable from the builder (#2942)', () = * still be unauthorable, which is precisely the state objectui#4023 found. */ it('the $icontains operator is one the builder can draw, and this widget offers it', () => { - expect(FILTER_BUILDER_OPERATORS).toContain('containsCaseInsensitive'); - expect(FILTER_CONDITION_EXTRA_OPERATORS).toContain('containsCaseInsensitive'); + expect(FILTER_BUILDER_OPERATORS).toContain('icontains'); // Every opt-in this widget asks for must be an id the dropdown knows; // a typo here is a row that never renders and never fails anything else. for (const id of FILTER_CONDITION_EXTRA_OPERATORS) { expect(FILTER_BUILDER_OPERATORS, `${id} is not a FilterBuilder operator id`).toContain(id); } - // …and it reaches a text field's dropdown once this widget opts in. + // It reaches a text field's dropdown with this widget's grants — and since + // objectui#9306 without them too: `icontains` left `OPT_IN_OPERATORS`, so + // this widget no longer has to name it, and does not. const offered = operatorsForFieldType('text', FILTER_CONDITION_EXTRA_OPERATORS).map((o) => o.value); - expect(offered).toContain('containsCaseInsensitive'); + expect(offered).toContain('icontains'); expect(offered).toContain('contains'); + expect(FILTER_CONDITION_EXTRA_OPERATORS).not.toContain('icontains'); + expect(operatorsForFieldType('text').map((o) => o.value)).toContain('icontains'); }); /** @@ -221,30 +225,31 @@ describe('every spec field operator is reachable from the builder (#2942)', () = expect(offered, `${type} lost the existence pair`).toContain('exists'); expect(offered, `${type} lost the existence pair`).toContain('notExists'); // Distinct rows from the null predicates, which author `$null`. The two - // are different spec operators and `condToMongo` keeps them apart. - expect(offered, type).toContain('isNull'); - expect(offered, type).toContain('isNotNull'); + // are different spec operators and `condToMongo` keeps them apart (and + // objectui#9559 ruling B keeps them unfolded). + expect(offered, type).toContain('is_null'); + expect(offered, type).toContain('is_not_null'); } }); }); describe('kvToCondition round-trips what condToMongo writes', () => { const cases: Array<[string, unknown]> = [ - ['notEquals', 'a'], + ['not_equals', 'a'], ['contains', 'a'], // objectui#4023 deliverable 2: a saved filter view must not degrade on // reopen. Without the `$icontains` arm in kvToCondition, criteria this very // builder wrote comes back "not representable" and forces the JSON editor. - ['containsCaseInsensitive', 'a'], - ['notContains', 'a'], - ['greaterThan', 1], - ['lessThan', 1], - ['greaterOrEqual', 1], - ['lessOrEqual', 1], - ['startsWith', 'a'], - ['endsWith', 'a'], - ['isNull', ''], - ['isNotNull', ''], + ['icontains', 'a'], + ['not_contains', 'a'], + ['greater_than', 1], + ['less_than', 1], + ['greater_than_or_equal', 1], + ['less_than_or_equal', 1], + ['starts_with', 'a'], + ['ends_with', 'a'], + ['is_null', ''], + ['is_not_null', ''], ['exists', ''], ['notExists', ''], ]; @@ -260,7 +265,7 @@ describe('kvToCondition round-trips what condToMongo writes', () => { // ("criteria can't be represented") rather than migrate. expect(kvToCondition('name', { $ncontains: 'x' }, 0)).toMatchObject({ field: 'name', - operator: 'notContains', + operator: 'not_contains', value: 'x', }); }); @@ -292,7 +297,7 @@ describe('objectui#8748 — an unfinished text row is dropped, not emitted', () ['cleared back out', ''], ['null', null], ]; - const textOperators = ['contains', 'containsCaseInsensitive', 'notContains', 'startsWith', 'endsWith']; + const textOperators = ['contains', 'icontains', 'not_contains', 'starts_with', 'ends_with']; for (const operator of textOperators) { it.each(emptyValues)(`${operator} with an empty comparand (%s) emits nothing`, (_label, value) => { @@ -307,7 +312,7 @@ describe('objectui#8748 — an unfinished text row is dropped, not emitted', () // Green before this card and after it. Without it a `return null` for every // text row would satisfy every case above and silently delete the operators. expect( - condToMongo({ id: 'c1', field: 'name', operator: 'containsCaseInsensitive', value: 'acme' } as any, noTypes), + condToMongo({ id: 'c1', field: 'name', operator: 'icontains', value: 'acme' } as any, noTypes), ).toEqual({ name: { $icontains: 'acme' } }); expect( condToMongo({ id: 'c2', field: 'name', operator: 'contains', value: 'a' } as any, noTypes), @@ -315,18 +320,18 @@ describe('objectui#8748 — an unfinished text row is dropped, not emitted', () }); it('the value-less operators keep emitting on an empty box — they read no comparand', () => { - // `isNull` / `exists` / `isEmpty` and their negations resolve from the + // `is_null` / `exists` / `is_empty` and their negations resolve from the // operator NAME; an empty value box is their resting state, not an // unfinished row, so the drop must not reach them. - expect(condToMongo({ id: 'c1', field: 'name', operator: 'isNull', value: '' } as any, noTypes)) + expect(condToMongo({ id: 'c1', field: 'name', operator: 'is_null', value: '' } as any, noTypes)) .toEqual({ name: { $null: true } }); expect(condToMongo({ id: 'c2', field: 'name', operator: 'exists', value: '' } as any, noTypes)) .toEqual({ name: { $exists: true } }); expect(condToMongo({ id: 'c3', field: 'name', operator: 'notExists', value: '' } as any, noTypes)) .toEqual({ name: { $exists: false } }); - expect(condToMongo({ id: 'c4', field: 'name', operator: 'isEmpty', value: '' } as any, noTypes)) + expect(condToMongo({ id: 'c4', field: 'name', operator: 'is_empty', value: '' } as any, noTypes)) .toEqual({ name: { $in: [null, ''] } }); - expect(condToMongo({ id: 'c5', field: 'name', operator: 'isNotEmpty', value: '' } as any, noTypes)) + expect(condToMongo({ id: 'c5', field: 'name', operator: 'is_not_empty', value: '' } as any, noTypes)) .toEqual({ name: { $nin: [null, ''] } }); }); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index cbe06ce9e7..28aa2b6029 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -3584,25 +3584,25 @@ const ar = { rangeEnd: "إلى", operators: { equals: "يساوي", - notEquals: "لا يساوي", + not_equals: "لا يساوي", contains: "يحتوي", - containsCaseInsensitive: "يحتوي (مع تجاهل حالة الأحرف)", - notContains: "لا يحتوي", - isEmpty: "فارغ", - isNotEmpty: "غير فارغ", - greaterThan: "أكبر من", - lessThan: "أصغر من", - greaterOrEqual: "أكبر من أو يساوي", - lessOrEqual: "أصغر من أو يساوي", + icontains: "يحتوي (مع تجاهل حالة الأحرف)", + not_contains: "لا يحتوي", + is_empty: "فارغ", + is_not_empty: "غير فارغ", + greater_than: "أكبر من", + less_than: "أصغر من", + greater_than_or_equal: "أكبر من أو يساوي", + less_than_or_equal: "أصغر من أو يساوي", before: "قبل", after: "بعد", between: "بين", in: "ضمن", - notIn: "ليس ضمن", - startsWith: "يبدأ بـ", - endsWith: "ينتهي بـ", - isNull: "يساوي null", - isNotNull: "لا يساوي null", + not_in: "ليس ضمن", + starts_with: "يبدأ بـ", + ends_with: "ينتهي بـ", + is_null: "يساوي null", + is_not_null: "لا يساوي null", exists: "محدد", notExists: "غير محدد", }, diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index e875628905..384070eba5 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -3568,25 +3568,25 @@ const de = { rangeEnd: "Bis", operators: { equals: "Gleich", - notEquals: "Ungleich", + not_equals: "Ungleich", contains: "Enthält", - containsCaseInsensitive: "Enthält (Groß-/Kleinschreibung ignorieren)", - notContains: "Enthält nicht", - isEmpty: "Ist leer", - isNotEmpty: "Ist nicht leer", - greaterThan: "Größer als", - lessThan: "Kleiner als", - greaterOrEqual: "Größer oder gleich", - lessOrEqual: "Kleiner oder gleich", + icontains: "Enthält (Groß-/Kleinschreibung ignorieren)", + not_contains: "Enthält nicht", + is_empty: "Ist leer", + is_not_empty: "Ist nicht leer", + greater_than: "Größer als", + less_than: "Kleiner als", + greater_than_or_equal: "Größer oder gleich", + less_than_or_equal: "Kleiner oder gleich", before: "Vor", after: "Nach", between: "Zwischen", in: "In", - notIn: "Nicht in", - startsWith: "Beginnt mit", - endsWith: "Endet mit", - isNull: "Ist null", - isNotNull: "Ist nicht null", + not_in: "Nicht in", + starts_with: "Beginnt mit", + ends_with: "Endet mit", + is_null: "Ist null", + is_not_null: "Ist nicht null", exists: "Ist gesetzt", notExists: "Ist nicht gesetzt", }, diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index bf3ef25c91..9fae67e3a5 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -3968,25 +3968,25 @@ const en = { rangeEnd: 'To', operators: { equals: 'Equals', - notEquals: 'Does not equal', + not_equals: 'Does not equal', contains: 'Contains', - containsCaseInsensitive: 'Contains (ignore case)', - notContains: 'Does not contain', - isEmpty: 'Is empty', - isNotEmpty: 'Is not empty', - greaterThan: 'Greater than', - lessThan: 'Less than', - greaterOrEqual: 'Greater than or equal', - lessOrEqual: 'Less than or equal', + icontains: 'Contains (ignore case)', + not_contains: 'Does not contain', + is_empty: 'Is empty', + is_not_empty: 'Is not empty', + greater_than: 'Greater than', + less_than: 'Less than', + greater_than_or_equal: 'Greater than or equal', + less_than_or_equal: 'Less than or equal', before: 'Before', after: 'After', between: 'Between', in: 'In', - notIn: 'Not in', - startsWith: 'Starts with', - endsWith: 'Ends with', - isNull: 'Is null', - isNotNull: 'Is not null', + not_in: 'Not in', + starts_with: 'Starts with', + ends_with: 'Ends with', + is_null: 'Is null', + is_not_null: 'Is not null', exists: 'Is set', notExists: 'Is not set', }, diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index dc9c119da6..df159bbd5b 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -3572,25 +3572,25 @@ const es = { rangeEnd: "Hasta", operators: { equals: "Igual a", - notEquals: "Distinto de", + not_equals: "Distinto de", contains: "Contiene", - containsCaseInsensitive: "Contiene (ignorar mayúsculas)", - notContains: "No contiene", - isEmpty: "Está vacío", - isNotEmpty: "No está vacío", - greaterThan: "Mayor que", - lessThan: "Menor que", - greaterOrEqual: "Mayor o igual", - lessOrEqual: "Menor o igual", + icontains: "Contiene (ignorar mayúsculas)", + not_contains: "No contiene", + is_empty: "Está vacío", + is_not_empty: "No está vacío", + greater_than: "Mayor que", + less_than: "Menor que", + greater_than_or_equal: "Mayor o igual", + less_than_or_equal: "Menor o igual", before: "Antes de", after: "Después de", between: "Entre", in: "En", - notIn: "No en", - startsWith: "Comienza por", - endsWith: "Termina en", - isNull: "Es null", - isNotNull: "No es null", + not_in: "No en", + starts_with: "Comienza por", + ends_with: "Termina en", + is_null: "Es null", + is_not_null: "No es null", exists: "Está definido", notExists: "No está definido", }, diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index ad82010c1c..4d6426e265 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -3570,25 +3570,25 @@ const fr = { rangeEnd: "À", operators: { equals: "Égal à", - notEquals: "Différent de", + not_equals: "Différent de", contains: "Contient", - containsCaseInsensitive: "Contient (ignorer la casse)", - notContains: "Ne contient pas", - isEmpty: "Est vide", - isNotEmpty: "N'est pas vide", - greaterThan: "Supérieur à", - lessThan: "Inférieur à", - greaterOrEqual: "Supérieur ou égal", - lessOrEqual: "Inférieur ou égal", + icontains: "Contient (ignorer la casse)", + not_contains: "Ne contient pas", + is_empty: "Est vide", + is_not_empty: "N'est pas vide", + greater_than: "Supérieur à", + less_than: "Inférieur à", + greater_than_or_equal: "Supérieur ou égal", + less_than_or_equal: "Inférieur ou égal", before: "Avant", after: "Après", between: "Entre", in: "Dans", - notIn: "Pas dans", - startsWith: "Commence par", - endsWith: "Se termine par", - isNull: "Est null", - isNotNull: "N'est pas null", + not_in: "Pas dans", + starts_with: "Commence par", + ends_with: "Se termine par", + is_null: "Est null", + is_not_null: "N'est pas null", exists: "Est défini", notExists: "N'est pas défini", }, diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 49f9e978e7..d091c818e1 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -3570,25 +3570,25 @@ const ja = { rangeEnd: "終了", operators: { equals: "等しい", - notEquals: "等しくない", + not_equals: "等しくない", contains: "含む", - containsCaseInsensitive: "含む(大文字小文字を区別しない)", - notContains: "含まない", - isEmpty: "空である", - isNotEmpty: "空でない", - greaterThan: "より大きい", - lessThan: "より小さい", - greaterOrEqual: "以上", - lessOrEqual: "以下", + icontains: "含む(大文字小文字を区別しない)", + not_contains: "含まない", + is_empty: "空である", + is_not_empty: "空でない", + greater_than: "より大きい", + less_than: "より小さい", + greater_than_or_equal: "以上", + less_than_or_equal: "以下", before: "より前", after: "より後", between: "範囲内", in: "いずれか", - notIn: "いずれでもない", - startsWith: "前方一致", - endsWith: "後方一致", - isNull: "null である", - isNotNull: "null でない", + not_in: "いずれでもない", + starts_with: "前方一致", + ends_with: "後方一致", + is_null: "null である", + is_not_null: "null でない", exists: "設定済み", notExists: "未設定", }, diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 4d75a5fffa..b6e46c2c4b 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -3567,25 +3567,25 @@ const ko = { rangeEnd: "끝", operators: { equals: "같음", - notEquals: "같지 않음", + not_equals: "같지 않음", contains: "포함", - containsCaseInsensitive: "포함 (대소문자 무시)", - notContains: "포함하지 않음", - isEmpty: "비어 있음", - isNotEmpty: "비어 있지 않음", - greaterThan: "보다 큼", - lessThan: "보다 작음", - greaterOrEqual: "크거나 같음", - lessOrEqual: "작거나 같음", + icontains: "포함 (대소문자 무시)", + not_contains: "포함하지 않음", + is_empty: "비어 있음", + is_not_empty: "비어 있지 않음", + greater_than: "보다 큼", + less_than: "보다 작음", + greater_than_or_equal: "크거나 같음", + less_than_or_equal: "작거나 같음", before: "이전", after: "이후", between: "사이", in: "포함됨", - notIn: "포함되지 않음", - startsWith: "다음으로 시작", - endsWith: "다음으로 끝남", - isNull: "null임", - isNotNull: "null이 아님", + not_in: "포함되지 않음", + starts_with: "다음으로 시작", + ends_with: "다음으로 끝남", + is_null: "null임", + is_not_null: "null이 아님", exists: "설정됨", notExists: "설정되지 않음", }, diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index f4aadcbdd4..c07553d7d9 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -3567,25 +3567,25 @@ const pt = { rangeEnd: "Até", operators: { equals: "Igual a", - notEquals: "Diferente de", + not_equals: "Diferente de", contains: "Contém", - containsCaseInsensitive: "Contém (ignorar maiúsculas)", - notContains: "Não contém", - isEmpty: "Está vazio", - isNotEmpty: "Não está vazio", - greaterThan: "Maior que", - lessThan: "Menor que", - greaterOrEqual: "Maior ou igual", - lessOrEqual: "Menor ou igual", + icontains: "Contém (ignorar maiúsculas)", + not_contains: "Não contém", + is_empty: "Está vazio", + is_not_empty: "Não está vazio", + greater_than: "Maior que", + less_than: "Menor que", + greater_than_or_equal: "Maior ou igual", + less_than_or_equal: "Menor ou igual", before: "Antes de", after: "Depois de", between: "Entre", in: "Em", - notIn: "Não em", - startsWith: "Começa com", - endsWith: "Termina com", - isNull: "É null", - isNotNull: "Não é null", + not_in: "Não em", + starts_with: "Começa com", + ends_with: "Termina com", + is_null: "É null", + is_not_null: "Não é null", exists: "Está definido", notExists: "Não está definido", }, diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 2db07986c1..a6327c41ef 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -3592,25 +3592,25 @@ const ru = { rangeEnd: "До", operators: { equals: "Равно", - notEquals: "Не равно", + not_equals: "Не равно", contains: "Содержит", - containsCaseInsensitive: "Содержит (без учёта регистра)", - notContains: "Не содержит", - isEmpty: "Пусто", - isNotEmpty: "Не пусто", - greaterThan: "Больше", - lessThan: "Меньше", - greaterOrEqual: "Больше или равно", - lessOrEqual: "Меньше или равно", + icontains: "Содержит (без учёта регистра)", + not_contains: "Не содержит", + is_empty: "Пусто", + is_not_empty: "Не пусто", + greater_than: "Больше", + less_than: "Меньше", + greater_than_or_equal: "Больше или равно", + less_than_or_equal: "Меньше или равно", before: "До", after: "После", between: "Между", in: "В списке", - notIn: "Не в списке", - startsWith: "Начинается с", - endsWith: "Заканчивается на", - isNull: "Равно null", - isNotNull: "Не равно null", + not_in: "Не в списке", + starts_with: "Начинается с", + ends_with: "Заканчивается на", + is_null: "Равно null", + is_not_null: "Не равно null", exists: "Задано", notExists: "Не задано", }, diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 10e53b1ae2..f6c203e1a4 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -3638,25 +3638,25 @@ const zh = { rangeEnd: '结束值', operators: { equals: '等于', - notEquals: '不等于', + not_equals: '不等于', contains: '包含', - containsCaseInsensitive: '包含(忽略大小写)', - notContains: '不包含', - isEmpty: '为空', - isNotEmpty: '不为空', - greaterThan: '大于', - lessThan: '小于', - greaterOrEqual: '大于或等于', - lessOrEqual: '小于或等于', + icontains: '包含(忽略大小写)', + not_contains: '不包含', + is_empty: '为空', + is_not_empty: '不为空', + greater_than: '大于', + less_than: '小于', + greater_than_or_equal: '大于或等于', + less_than_or_equal: '小于或等于', before: '早于', after: '晚于', between: '介于', in: '属于', - notIn: '不属于', - startsWith: '以…开头', - endsWith: '以…结尾', - isNull: '为 null', - isNotNull: '不为 null', + not_in: '不属于', + starts_with: '以…开头', + ends_with: '以…结尾', + is_null: '为 null', + is_not_null: '不为 null', exists: '已设置', notExists: '未设置', }, diff --git a/packages/plugin-list/src/__tests__/convertFilterGroupToAST.canonicalSpelling.test.ts b/packages/plugin-list/src/__tests__/convertFilterGroupToAST.canonicalSpelling.test.ts index f6f2443fda..4a31548eda 100644 --- a/packages/plugin-list/src/__tests__/convertFilterGroupToAST.canonicalSpelling.test.ts +++ b/packages/plugin-list/src/__tests__/convertFilterGroupToAST.canonicalSpelling.test.ts @@ -75,6 +75,16 @@ * emission is unreachable in the product and pinned here so a future * decision to offer them lands as a red test, exactly as * `convertFilterGroupToAST.test.ts` already pins it.) + * + * ## After objectui#9306 + * + * The builder's ids — and so the exported set's members — are the canonical + * spellings now, and its former camelCase ids are the deprecated alias form a + * stored filter (or a per-user cached panel group) may still carry. This + * reader is unchanged: it folds before the lookup, so the table reads the same + * nodes. Only the roles swapped — the canonical rows are the dropdown's own + * ids, and the camelCase rows are the spellings that reach the set only + * through the fold, i.e. the firing cases. The rows are relabelled to say so. */ import { describe, it, expect } from 'vitest'; import { normalizeFilterOperator } from '@objectstack/spec/ui'; @@ -101,32 +111,34 @@ const emit = (operator: string, value: unknown = '') => * The card's measurement table, turned into a pin. * * `dialect` is load-bearing for reading a failure, not decoration: - * - `dropdown` — one of the builder's own camelCase ids, i.e. a literal - * member of the exported set. Emits its node today and after: over-reach + * - `dropdown` — one of the builder's own ids, i.e. a literal member of the + * exported set: `@objectstack/spec`'s canonical spelling, which is also + * what a saved view stores (objectui#9306). Emits its node: over-reach * guard; - * - `canonical` — `@objectstack/spec`'s spelling of the SAME operator, which - * is what a saved view stores. `[]` today (the defect), the node after: - * the firing cases; + * - `deprecated` — the builder's former camelCase id for the SAME operator, + * which a filter stored before objectui#9306 carries. Not a member, so it + * reaches the set only through the fold: the firing cases (the canonical + * spelling played this part when objectui#9359 was measured); * - `control` — really does take a value, and has one. */ const ROWS: ReadonlyArray<{ operator: string; value?: unknown; emitted: unknown[]; - dialect: 'dropdown' | 'canonical' | 'control'; + dialect: 'dropdown' | 'deprecated' | 'control'; }> = [ - { operator: 'isNull', emitted: ['title', 'isnull', null], dialect: 'dropdown' }, - { operator: 'is_null', emitted: ['title', 'isnull', null], dialect: 'canonical' }, - { operator: 'isNotNull', emitted: ['title', 'isnotnull', null], dialect: 'dropdown' }, - { operator: 'is_not_null', emitted: ['title', 'isnotnull', null], dialect: 'canonical' }, + { operator: 'isNull', emitted: ['title', 'isnull', null], dialect: 'deprecated' }, + { operator: 'is_null', emitted: ['title', 'isnull', null], dialect: 'dropdown' }, + { operator: 'isNotNull', emitted: ['title', 'isnotnull', null], dialect: 'deprecated' }, + { operator: 'is_not_null', emitted: ['title', 'isnotnull', null], dialect: 'dropdown' }, // `isEmpty` / `isNotEmpty` are resolved to a null comparison BEFORE // `mapOperator` is consulted, so their canonical twins must land on the same // arm — otherwise the repair would trade one spelling-dependent answer for // another, which is the defect this card is about. - { operator: 'isEmpty', emitted: ['title', '=', null], dialect: 'dropdown' }, - { operator: 'is_empty', emitted: ['title', '=', null], dialect: 'canonical' }, - { operator: 'isNotEmpty', emitted: ['title', '!=', null], dialect: 'dropdown' }, - { operator: 'is_not_empty', emitted: ['title', '!=', null], dialect: 'canonical' }, + { operator: 'isEmpty', emitted: ['title', '=', null], dialect: 'deprecated' }, + { operator: 'is_empty', emitted: ['title', '=', null], dialect: 'dropdown' }, + { operator: 'isNotEmpty', emitted: ['title', '!=', null], dialect: 'deprecated' }, + { operator: 'is_not_empty', emitted: ['title', '!=', null], dialect: 'dropdown' }, // No canonical twin exists for these two. { operator: 'exists', emitted: ['title', 'exists', null], dialect: 'dropdown' }, { operator: 'notExists', emitted: ['title', 'notExists', null], dialect: 'dropdown' }, @@ -143,8 +155,8 @@ describe('objectui#9359 — one operator, one emitted node, whichever spelling i ).toEqual(emitted); }); - it.each(ROWS.filter((r) => r.dialect === 'canonical'))( - 'canonical `$operator` emits the SAME node as its dropdown twin', + it.each(ROWS.filter((r) => r.dialect === 'deprecated'))( + 'deprecated `$operator` emits the SAME node as its dropdown twin', ({ operator, emitted }) => { // The acceptance criterion stated directly: two spellings of one operator // are one filter. Asserted against the twin's own live emission rather @@ -210,11 +222,12 @@ describe('objectui#9359 — one operator, one emitted node, whichever spelling i }); describe('objectui#9359 — the instrument can actually fire', () => { - it('every canonical case is outside the exported set and folds onto a member', () => { + it('every firing case is outside the exported set and folds onto a member', () => { // The instrument standard. A table built only on spellings the raw `has()` // already matched would be green before ANY repair and would measure - // nothing at all. - const canonical = ROWS.filter((r) => r.dialect === 'canonical'); + // nothing at all. Since objectui#9306 the firing cases are the deprecated + // camelCase rows. + const canonical = ROWS.filter((r) => r.dialect === 'deprecated'); expect(canonical.length).toBeGreaterThanOrEqual(4); const foldedMembers = new Set( [...VALUELESS_FILTER_BUILDER_OPERATORS].map((op) => String(normalizeFilterOperator(op))), @@ -243,17 +256,17 @@ describe('objectui#9359 — the instrument can actually fire', () => { describe('objectui#9359 — ⛔ the repair moves nothing but this reader', () => { it('the EXPORTED set keeps its dropdown-only membership', () => { - // Acceptance criterion 4, and the ruling's whole point: two other layers - // read this set and one of them already compensates for the canonical - // spellings. Widening it would make that layer's deliberate half redundant - // by side effect. Green in both directions by construction — it fails only - // for a repair that widened the export instead of folding at the reader. + // Acceptance criterion 4: the set states the dropdown's own ids, one per + // operator, and is not widened with other spellings — readers fold instead. + // Since objectui#9306 those ids are the canonical spellings; the deprecated + // camelCase ids are NOT members. It fails for a repair that widened the + // export instead of folding at the reader. expect([...VALUELESS_FILTER_BUILDER_OPERATORS].sort()).toEqual([ 'exists', - 'isEmpty', - 'isNotEmpty', - 'isNotNull', - 'isNull', + 'is_empty', + 'is_not_empty', + 'is_not_null', + 'is_null', 'notExists', ]); }); @@ -301,3 +314,41 @@ describe('objectui#9359 — ⛔ the fold does not cross the `contains` boundary' expect(emit('icontains', 'ac')).toEqual(['title', 'icontains', 'ac']); }); }); + +/** + * The live grid's leg of objectui#9306's 22-id census (the other consumers' + * legs are `filter-builder-protocol-ids-census-9306.test.ts` in app-shell). + * + * The builder's ids moved from camelCase to the protocol's spellings. This + * reader was already spelling-agnostic — `mapOperator` matches case- and + * underscore-insensitively and the value-less check folds — so the claim here + * is that the move changed NOTHING it queries: every former id and the id it + * became emit the same node. `containsCaseInsensitive` is the one exception, + * and it is not a regression: the list toolbar never offered it, and neither + * `mapOperator` nor the spec's alias table knows that spelling; the builder + * folds it onto `icontains` at its own read boundary before a row gets here. + */ +describe('objectui#9306 — the live grid emits the same node for every former id and its protocol id', () => { + const CENSUS: ReadonlyArray = [ + ['equals', 'equals', 'x'], ['notEquals', 'not_equals', 'x'], ['contains', 'contains', 'x'], + ['notContains', 'not_contains', 'x'], ['isEmpty', 'is_empty', ''], ['isNotEmpty', 'is_not_empty', ''], + ['greaterThan', 'greater_than', 5], ['lessThan', 'less_than', 5], + ['greaterOrEqual', 'greater_than_or_equal', 5], ['lessOrEqual', 'less_than_or_equal', 5], + ['before', 'before', '2026-01-01'], ['after', 'after', '2026-01-01'], ['between', 'between', [1, 5]], + ['in', 'in', ['a', 'b']], ['notIn', 'not_in', ['a', 'b']], + ['startsWith', 'starts_with', 'x'], ['endsWith', 'ends_with', 'x'], + ['isNull', 'is_null', ''], ['isNotNull', 'is_not_null', ''], + ['exists', 'exists', ''], ['notExists', 'notExists', ''], + ]; + + it.each(CENSUS)('`%s` and `%s` emit one node', (legacy, id, value) => { + const node = emit(id, value); + expect(node.length, `${id} emitted nothing`).toBeGreaterThan(0); + expect(emit(legacy, value)).toEqual(node); + }); + + it('`icontains` reaches the wire as its own AST operator', () => { + expect(emit('icontains', 'x')).toEqual(['title', 'icontains', 'x']); + expect(isFilterAST(emit('icontains', 'x'))).toBe(true); + }); +}); diff --git a/packages/plugin-list/src/__tests__/convertFilterGroupToAST.test.ts b/packages/plugin-list/src/__tests__/convertFilterGroupToAST.test.ts index 4ec5855a57..28fb3017e0 100644 --- a/packages/plugin-list/src/__tests__/convertFilterGroupToAST.test.ts +++ b/packages/plugin-list/src/__tests__/convertFilterGroupToAST.test.ts @@ -86,15 +86,18 @@ describe('convertFilterGroupToAST', () => { */ describe('convertFilterGroupToAST — every value-less operator emits a real node', () => { /** The node each value-less operator must emit for a row that has no value. */ + // + // Keyed by the builder's own ids, which are the protocol's canonical + // spellings since objectui#9306 (camelCase when this table was written). const EMITTED: Record = { // Resolved to a null comparison before `mapOperator` is consulted — these // two already worked, and are pinned so the fix cannot regress them. - isEmpty: ['f', '=', null], - isNotEmpty: ['f', '!=', null], + is_empty: ['f', '=', null], + is_not_empty: ['f', '!=', null], // The defect. `mapOperator` has had these rows all along and both spellings // are members of `VALID_AST_OPERATORS`; the row simply never reached it. - isNull: ['f', 'isnull', null], - isNotNull: ['f', 'isnotnull', null], + is_null: ['f', 'isnull', null], + is_not_null: ['f', 'isnotnull', null], // Kept for the same reason as the rest — the builder draws them value-less // — and NOT expressible on this dialect: `mapOperator` has no row, so the // id passes through verbatim and the AST gate refuses the whole filter. @@ -130,7 +133,7 @@ describe('convertFilterGroupToAST — every value-less operator emits a real nod // AST gate rejects is no better than `[]` — worse, since it takes the rest of // the filter down with it — so the four operators this toolbar OFFERS are // checked through the gate the server uses. - it.each(['isEmpty', 'isNotEmpty', 'isNull', 'isNotNull'])( + it.each(['is_empty', 'is_not_empty', 'is_null', 'is_not_null'])( '%s survives isFilterAST, the gate that decides if the filter is parsed at all', (operator) => { const node = emit(operator); diff --git a/packages/plugin-list/src/__tests__/list-offered-operator-expressible-parity.test.ts b/packages/plugin-list/src/__tests__/list-offered-operator-expressible-parity.test.ts index f4439627b4..0de6393b1a 100644 --- a/packages/plugin-list/src/__tests__/list-offered-operator-expressible-parity.test.ts +++ b/packages/plugin-list/src/__tests__/list-offered-operator-expressible-parity.test.ts @@ -17,8 +17,8 @@ * * - `filter-operator-ast-parity.test.ts` (this package and `data-objectstack`) * iterates `VIEW_FILTER_OPERATORS`; - * - `view-operator-builder-parity.test.ts` iterates `__CANONICAL_TO_BUILDER`, - * keyed by that same vocabulary; + * - `view-operator-builder-parity.test.ts` drives that same vocabulary + * through plugin-view's `specToBuilderOperator`; * - `FilterConditionField.operators.test.ts` asks whether every spec * `$`-token is reachable from the dropdown. * @@ -139,7 +139,7 @@ const DRAWABLE = offeredAcrossBuckets(FILTER_BUILDER_OPERATORS); /** A value that keeps a row from being dropped as incomplete, per operator. */ function probeValue(operator: string): unknown { - if (operator === 'in' || operator === 'notIn') return ['a', 'b']; + if (operator === 'in' || operator === 'not_in') return ['a', 'b']; if (operator === 'between') return [1, 5]; return 'x'; } @@ -287,19 +287,48 @@ describe('the list toolbar offers only operators its dialects can express', () = } }); - // The withdrawal is scoped to the existence pair: `isNull` / `isNotNull` are - // real members of both vocabularies and must keep their rows. Collapsing the - // one family onto the other is what this fix deliberately did NOT do — the - // same refusal `view-operator-builder-parity.test.ts` records for - // `is_null` -> `isEmpty`. + // The withdrawal is scoped to the existence pair: `is_null` / `is_not_null` + // are real members of both vocabularies and must keep their rows. Collapsing + // the one family onto the other is what this fix deliberately did NOT do — + // the same refusal `view-operator-builder-parity.test.ts` records for + // `is_null` -> `is_empty`. it('keeps the null predicates, which both dialects do express', () => { - for (const id of ['isNull', 'isNotNull', 'isEmpty', 'isNotEmpty']) { + for (const id of ['is_null', 'is_not_null', 'is_empty', 'is_not_empty']) { expect(OFFERED_BY_LIST, `${id} must still be offered`).toContain(id); expect(isExpressible(id), `${id} must still be expressible on both dialects`).toBe(true); } }); }); +/** + * The flip the `OPT_IN_OPERATORS` docblock predicted, pinned by name + * (objectui#9306). + * + * The case-insensitive contains was opt-in while the dropdown spelled it + * `containsCaseInsensitive`, a spelling neither of this toolbar's dialects + * folds. Once the dropdown spoke the protocol's `icontains`, the spine above + * measured it expressible on both and still withheld, and its entry was + * deleted. This names that outcome so a re-added entry lands here, with the + * reason, rather than only as a line in the spine's equality. + */ +describe('icontains is offered, because both dialects express it (objectui#9306)', () => { + it('reaches the toolbar and survives both dialects', () => { + expect(OFFERED_BY_LIST).toContain('icontains'); + expect(liveGridResult('icontains').ok, 'the live grid no longer expresses icontains').toBe(true); + expect(savedViewResult('icontains').ok, 'a saved view no longer stores icontains').toBe(true); + }); + + it('stays a different operator from contains', () => { + // objectui#7379: two operators, never one with a flag. + expect(OFFERED_BY_LIST).toContain('contains'); + expect(savedViewResult('icontains').canonical).not.toBe(savedViewResult('contains').canonical); + }); + + it('the retired spelling is no id this toolbar draws', () => { + expect(DRAWABLE).not.toContain('containsCaseInsensitive'); + }); +}); + /** * The other side of the ledger for {@link RETIRED_FIELD_TYPE} — objectui#4914. * diff --git a/packages/plugin-view/src/config/__tests__/view-operator-builder-parity.test.ts b/packages/plugin-view/src/config/__tests__/view-operator-builder-parity.test.ts index 9ef10a7154..b8ec138ad7 100644 --- a/packages/plugin-view/src/config/__tests__/view-operator-builder-parity.test.ts +++ b/packages/plugin-view/src/config/__tests__/view-operator-builder-parity.test.ts @@ -24,86 +24,98 @@ * legitimately carries them, and all nine reached the builder as a raw spelling * its dropdown cannot select. Five now map; four are recorded as deliberate * gaps, asserted below so the list cannot grow silently. + * + * ## After objectui#9306 + * + * The table this file used to sweep, `CANONICAL_TO_BUILDER`, is gone: the + * builder's ids ARE the canonical `VIEW_FILTER_OPERATORS` spellings now, so the + * table had become the identity over the twenty. What it guaranteed is asked + * of {@link specToBuilderOperator} directly instead — every canonical member, + * every alias the spec folds and every infix spelling must resolve to an id + * the builder can draw — and the NULL / empty-string distinction is pinned on + * that same function. */ import { describe, it, expect } from 'vitest'; import { VIEW_FILTER_OPERATORS, VIEW_FILTER_OPERATOR_ALIASES } from '@objectstack/spec/ui'; import { FILTER_BUILDER_OPERATORS } from '@object-ui/components'; -import { specToBuilderOperator, __CANONICAL_TO_BUILDER } from '../view-config-utils'; +import { specToBuilderOperator } from '../view-config-utils'; /** * Canonical view operators the FilterBuilder cannot express. * - * Empty — every one of the 19 now maps. It was `starts_with`, `ends_with`, - * `is_null`, `is_not_null` until #2942 gave the builder `startsWith`/`endsWith`/ - * `isNull`/`isNotNull`. + * Empty — every one of them maps. It was `starts_with`, `ends_with`, + * `is_null`, `is_not_null` until #2942 gave the builder those four operators. * * Shrink it by adding the operator to the FilterBuilder, never by mapping onto a - * near-equivalent: `is_null` → `isEmpty` would rewrite a NULL predicate into an + * near-equivalent: `is_null` → `is_empty` would rewrite a NULL predicate into an * empty-string one on the next save. */ const NO_BUILDER_EQUIVALENT: string[] = []; -/** Case- and separator-insensitive key, matching the module's own `fold`. */ -const fold = (op: string) => op.toLowerCase().replace(/[\s_-]+/g, ''); - -describe('CANONICAL_TO_BUILDER', () => { - it('covers every canonical view operator, and nothing else', () => { - expect(Object.keys(__CANONICAL_TO_BUILDER).sort()).toEqual([...VIEW_FILTER_OPERATORS].sort()); +describe('every canonical view operator is a builder id (objectui#9306)', () => { + it('resolves to ITSELF — the builder speaks the protocol\'s spelling', () => { + // The identity the removed table had become, asserted instead of stored. + const moved = VIEW_FILTER_OPERATORS + .filter(op => !NO_BUILDER_EQUIVALENT.includes(op)) + .filter(op => specToBuilderOperator(op) !== op); + expect(moved).toEqual([]); }); - it('maps onto operator ids the FilterBuilder actually renders', () => { - const builderIds = new Set(FILTER_BUILDER_OPERATORS); - const notRenderable = Object.entries(__CANONICAL_TO_BUILDER) - .filter(([, id]) => id !== null && !builderIds.has(id as never)) - .map(([op, id]) => `${op} -> ${id}`); - expect(notRenderable).toEqual([]); + it('and every one of them is an id the FilterBuilder actually renders', () => { + const builderIds = new Set(FILTER_BUILDER_OPERATORS); + const notRenderable = VIEW_FILTER_OPERATORS.filter(op => !builderIds.has(op)); + expect(notRenderable).toEqual([...NO_BUILDER_EQUIVALENT]); }); - /** - * The guard that catches drift in the direction the hand-kept gap list could - * not: the builder GAINING an operator this table still calls unmappable. - * `starts_with` and `startsWith` fold to the same key, so an unmapped operator - * whose folded name matches a folded builder id is an omission by definition — - * which is exactly how #2942's four new operators went unnoticed here. - */ - it('leaves nothing unmapped that the builder can already draw', () => { - const byFolded = new Map(FILTER_BUILDER_OPERATORS.map(id => [fold(id), id])); - const missed = Object.entries(__CANONICAL_TO_BUILDER) - .filter(([, id]) => id === null) - .filter(([op]) => byFolded.has(fold(op))) - .map(([op]) => `${op} -> ${byFolded.get(fold(op))} exists but is unmapped`); - expect(missed).toEqual([]); + it('keeps the NULL and empty-string predicates distinct', () => { + expect(specToBuilderOperator('is_null')).toBe('is_null'); + expect(specToBuilderOperator('is_not_null')).toBe('is_not_null'); + expect(specToBuilderOperator('is_empty')).toBe('is_empty'); + expect(specToBuilderOperator('is_not_empty')).toBe('is_not_empty'); + expect(specToBuilderOperator('is_null')).not.toBe(specToBuilderOperator('is_empty')); }); +}); - it('records exactly the documented gaps', () => { - const unmapped = Object.entries(__CANONICAL_TO_BUILDER) - .filter(([, id]) => id === null) - .map(([op]) => op); - expect(unmapped.sort()).toEqual([...NO_BUILDER_EQUIVALENT].sort()); - }); +/** + * The view-config reader's leg of objectui#9306's 22-id census: every former + * dropdown id reads onto the id the dropdown draws now. `containsCaseInsensitive` + * is the one this reader leaves verbatim (the spec's alias table has no row for + * it); the builder folds it onto `icontains` at its own read boundary. + */ +describe('every former dropdown id reads onto the id the dropdown draws now (objectui#9306)', () => { + const CENSUS: ReadonlyArray = [ + ['equals', 'equals'], ['notEquals', 'not_equals'], ['contains', 'contains'], + ['notContains', 'not_contains'], ['isEmpty', 'is_empty'], ['isNotEmpty', 'is_not_empty'], + ['greaterThan', 'greater_than'], ['lessThan', 'less_than'], + ['greaterOrEqual', 'greater_than_or_equal'], ['lessOrEqual', 'less_than_or_equal'], + ['before', 'before'], ['after', 'after'], ['between', 'between'], ['in', 'in'], ['notIn', 'not_in'], + ['startsWith', 'starts_with'], ['endsWith', 'ends_with'], ['isNull', 'is_null'], ['isNotNull', 'is_not_null'], + ['exists', 'exists'], ['notExists', 'notExists'], + ]; - it('keeps the NULL and empty-string predicates distinct', () => { - expect(__CANONICAL_TO_BUILDER.is_null).toBe('isNull'); - expect(__CANONICAL_TO_BUILDER.is_not_null).toBe('isNotNull'); - expect(__CANONICAL_TO_BUILDER.is_empty).toBe('isEmpty'); - expect(__CANONICAL_TO_BUILDER.is_not_empty).toBe('isNotEmpty'); + it.each(CENSUS)('`%s` reads as `%s`, an id the builder draws', (legacy, id) => { + expect(specToBuilderOperator(legacy)).toBe(id); + expect(FILTER_BUILDER_OPERATORS as readonly string[]).toContain(id); }); }); describe('specToBuilderOperator', () => { it('resolves every canonical view operator that has an equivalent', () => { - const builderIds = new Set(FILTER_BUILDER_OPERATORS); + const builderIds = new Set(FILTER_BUILDER_OPERATORS); const unresolved = VIEW_FILTER_OPERATORS .filter(op => !NO_BUILDER_EQUIVALENT.includes(op)) - .filter(op => !builderIds.has(specToBuilderOperator(op) as never)); + .filter(op => !builderIds.has(specToBuilderOperator(op))); expect(unresolved).toEqual([]); }); it('resolves every legacy alias the spec still folds', () => { - const builderIds = new Set(FILTER_BUILDER_OPERATORS); + // Including the builder's own former camelCase ids (`notEquals`, + // `greaterOrEqual`, …), which are rows of this table and which a view + // stored before objectui#9306 may carry. + const builderIds = new Set(FILTER_BUILDER_OPERATORS); const unresolved = Object.entries(VIEW_FILTER_OPERATOR_ALIASES) .filter(([, canonical]) => !NO_BUILDER_EQUIVALENT.includes(canonical)) - .filter(([alias]) => !builderIds.has(specToBuilderOperator(alias) as never)) + .filter(([alias]) => !builderIds.has(specToBuilderOperator(alias))) .map(([alias]) => alias); expect(unresolved).toEqual([]); }); @@ -111,40 +123,38 @@ describe('specToBuilderOperator', () => { it('resolves the infix spellings a stored filter array carries', () => { expect(specToBuilderOperator('=')).toBe('equals'); expect(specToBuilderOperator('==')).toBe('equals'); - expect(specToBuilderOperator('!=')).toBe('notEquals'); - expect(specToBuilderOperator('<>')).toBe('notEquals'); - expect(specToBuilderOperator('>')).toBe('greaterThan'); - expect(specToBuilderOperator('<')).toBe('lessThan'); - expect(specToBuilderOperator('>=')).toBe('greaterOrEqual'); - expect(specToBuilderOperator('<=')).toBe('lessOrEqual'); - expect(specToBuilderOperator('nin')).toBe('notIn'); + expect(specToBuilderOperator('!=')).toBe('not_equals'); + expect(specToBuilderOperator('<>')).toBe('not_equals'); + expect(specToBuilderOperator('>')).toBe('greater_than'); + expect(specToBuilderOperator('<')).toBe('less_than'); + expect(specToBuilderOperator('>=')).toBe('greater_than_or_equal'); + expect(specToBuilderOperator('<=')).toBe('less_than_or_equal'); + expect(specToBuilderOperator('nin')).toBe('not_in'); expect(specToBuilderOperator('like')).toBe('contains'); }); it('folds case and separators, so one spelling class is one entry', () => { for (const spelling of ['not_in', 'notIn', 'not in', 'NOT_IN', 'not-in', 'notin']) { - expect(specToBuilderOperator(spelling)).toBe('notIn'); + expect(specToBuilderOperator(spelling)).toBe('not_in'); } - for (const spelling of ['greater_than_or_equal', 'greaterThanOrEqual', 'greaterorequal']) { - expect(specToBuilderOperator(spelling)).toBe('greaterOrEqual'); + for (const spelling of ['greater_than_or_equal', 'greaterThanOrEqual', 'greaterorequal', 'greaterOrEqual']) { + expect(specToBuilderOperator(spelling)).toBe('greater_than_or_equal'); } }); + it('keeps contains and icontains two operators (objectui#7379)', () => { + expect(specToBuilderOperator('icontains')).toBe('icontains'); + expect(specToBuilderOperator('contains')).toBe('contains'); + }); + it('returns an unrecognised operator unchanged rather than coercing it', () => { // Visible in the UI as an incomplete condition row beats a silent rewrite // to `equals`, which would drop the author's predicate on the next save. + // (`containsCaseInsensitive` is one of these HERE — the spec's alias table + // has no row for it — and the builder's own read boundary folds it.) expect(specToBuilderOperator('totally_made_up')).toBe('totally_made_up'); expect(specToBuilderOperator('$regex')).toBe('$regex'); - }); - - it('resolves the four operators #2942 made expressible', () => { - expect(specToBuilderOperator('starts_with')).toBe('startsWith'); - expect(specToBuilderOperator('ends_with')).toBe('endsWith'); - expect(specToBuilderOperator('is_null')).toBe('isNull'); - expect(specToBuilderOperator('is_not_null')).toBe('isNotNull'); - // …without collapsing them onto the empty-string pair. - expect(specToBuilderOperator('is_empty')).toBe('isEmpty'); - expect(specToBuilderOperator('is_not_empty')).toBe('isNotEmpty'); + expect(specToBuilderOperator('containsCaseInsensitive')).toBe('containsCaseInsensitive'); }); it('defaults an absent operator to equals', () => { diff --git a/packages/plugin-view/src/config/view-config-utils.ts b/packages/plugin-view/src/config/view-config-utils.ts index 72621931d0..7913e93d10 100644 --- a/packages/plugin-view/src/config/view-config-utils.ts +++ b/packages/plugin-view/src/config/view-config-utils.ts @@ -26,55 +26,25 @@ import type { ViewFilterOperator } from '@objectstack/spec/ui'; // server used to drop silently (#2901, objectstack#3948). /** - * Every canonical `VIEW_FILTER_OPERATORS` member, mapped onto the operator id - * the FilterBuilder renders — `null` where the builder has no equivalent. + * There is no canonical → builder table any more (objectui#9306). * - * Total by construction: the type is keyed by `ViewFilterOperator`, so an - * operator added to the spec's view vocabulary fails to compile here instead - * of reaching the builder as a raw spelling its dropdown cannot select. + * This file used to carry `CANONICAL_TO_BUILDER`, a total map from every + * `VIEW_FILTER_OPERATORS` member onto the FilterBuilder's camelCase id for it + * (`not_equals` → `notEquals`, `icontains` → `containsCaseInsensitive`, …). + * The dropdown now speaks the protocol's own ids, so that map had become the + * identity over the twenty, and an identity table is a second copy of the + * spec's list waiting to drift from it. The fact it used to guarantee — every + * canonical operator reaches the builder as an id its dropdown can select — + * is now carried by the builder's `FilterBuilderOperator` type itself, which + * a type-level pin in `@object-ui/components` holds EQUAL to the spec's + * `ViewFilterOperator` plus the two opt-in existence ids, and by + * `view-operator-builder-parity.test.ts`, which drives every canonical member + * and every alias row through {@link specToBuilderOperator} below. * - * **Every canonical operator now maps.** The four that did not — - * `starts_with`/`ends_with`/`is_null`/`is_not_null` — were unmapped because the - * FilterBuilder had no such operator; #2942 added `startsWith`/`endsWith`/ - * `isNull`/`isNotNull` to it, and this table did not follow, so a stored view - * carrying them still reached the builder as a raw spelling it could by then - * have rendered. The parity guard catches that class now: a canonical operator - * whose name folds onto a builder id it is not mapped to fails the test. - * - * `is_null`/`is_not_null` map to `isNull`/`isNotNull` and NOT to - * `isEmpty`/`isNotEmpty` — the builder now draws both pairs, and folding the - * NULL predicate onto the empty-string one would silently rewrite the author's - * operator the next time the view was saved. + * What survives is the part the spec does not do for us: folding the infix + * spellings a stored filter ARRAY carries, and matching case- and + * separator-insensitively, onto the canonical member. */ -const CANONICAL_TO_BUILDER: Record = { - 'equals': 'equals', - 'not_equals': 'notEquals', - 'contains': 'contains', - // Case-insensitive contains, canonical in `VIEW_FILTER_OPERATORS` as of - // `@objectstack/spec` 17.1.0 (objectui#5328). The builder HAS an equivalent - // — `containsCaseInsensitive`, which authors the spec's `$icontains` - // (filter-builder.tsx:160, objectui#4023) — so this is a real row and not a - // `null`: mapping it to `contains` would quietly rewrite a case-insensitive - // filter into a case-sensitive one the next time the view was saved, the - // same folding the `is_null` note below refuses. - 'icontains': 'containsCaseInsensitive', - 'not_contains': 'notContains', - 'starts_with': 'startsWith', - 'ends_with': 'endsWith', - 'greater_than': 'greaterThan', - 'less_than': 'lessThan', - 'greater_than_or_equal': 'greaterOrEqual', - 'less_than_or_equal': 'lessOrEqual', - 'in': 'in', - 'not_in': 'notIn', - 'is_empty': 'isEmpty', - 'is_not_empty': 'isNotEmpty', - 'is_null': 'isNull', - 'is_not_null': 'isNotNull', - 'before': 'before', - 'after': 'after', - 'between': 'between', -}; /** * Infix and short spellings a stored *filter array* carries instead of a view @@ -112,7 +82,8 @@ const FOLDED_TO_CANONICAL: Map = new Map([ ]); /** - * Resolve any stored operator spelling to a FilterBuilder operator id. + * Resolve any stored operator spelling to a FilterBuilder operator id — which, + * since objectui#9306, is the canonical `VIEW_FILTER_OPERATORS` member itself. * * Returns the input unchanged when no mapping exists, so an unrecognised * operator is visible in the UI rather than silently coerced to `equals`. @@ -120,13 +91,9 @@ const FOLDED_TO_CANONICAL: Map = new Map([ export function specToBuilderOperator(op: string): string { const raw = String(op ?? '').trim(); if (!raw) return 'equals'; - const canonical = INFIX_TO_CANONICAL[raw.toLowerCase()] ?? FOLDED_TO_CANONICAL.get(fold(raw)); - return (canonical ? CANONICAL_TO_BUILDER[canonical] : null) ?? raw; + return INFIX_TO_CANONICAL[raw.toLowerCase()] ?? FOLDED_TO_CANONICAL.get(fold(raw)) ?? raw; } -/** Exported for the parity guard — the mapping's canonical half. */ -export const __CANONICAL_TO_BUILDER = CANONICAL_TO_BUILDER; - // --------------------------------------------------------------------------- // Field type normalization: ObjectUI → FilterBuilder // --------------------------------------------------------------------------- diff --git a/packages/types/src/__tests__/filter-operator-protocol-set-9559.test.ts b/packages/types/src/__tests__/filter-operator-protocol-set-9559.test.ts index 8b651154d6..037ec3c0db 100644 --- a/packages/types/src/__tests__/filter-operator-protocol-set-9559.test.ts +++ b/packages/types/src/__tests__/filter-operator-protocol-set-9559.test.ts @@ -49,7 +49,15 @@ expectType, ViewFilterOperator>>(); /** The spec rule's own operator member — the side this mirror must agree with. */ const PROTOCOL = ViewFilterRuleSchema.shape.operator; -/** The filter-builder dropdown's ids, as `FILTER_BUILDER_OPERATORS` spells them today. */ +/** + * The filter-builder dropdown's ids as `FILTER_BUILDER_OPERATORS` spelled them + * when objectui#9559 landed — the camelCase ids that objectui#9306 then retired + * in favour of the protocol's own spellings. They stay the corpus here because + * they are exactly what a filter stored before objectui#9306 still carries, so + * "which of them does the mirror accept" is still the question worth pinning. + * (The dropdown's CURRENT ids are the 20 canonical members plus `exists` / + * `notExists`; `@object-ui/components` pins those against the spec.) + */ const DROPDOWN_IDS = [ 'equals', 'notEquals', 'contains', 'containsCaseInsensitive', 'notContains', 'isEmpty', 'isNotEmpty', 'greaterThan', 'lessThan', 'greaterOrEqual', 'lessOrEqual', @@ -145,7 +153,7 @@ describe('objectui#9559 — the mirror is the protocol operator set (pin a)', () expect(normalised).toBeGreaterThan(0); }); - it('the dropdown\'s ids: 19 of 22 are accepted, and the 3 refusals are the protocol\'s gaps', () => { + it('the dropdown\'s pre-objectui#9306 ids: 19 of 22 are accepted, and the 3 refusals are the protocol\'s gaps', () => { // A reading, not a target: it moves only with the protocol's table. The // three are recorded upstream-blocked in `OPT_IN_OPERATORS` (exists / // notExists) or lack a spec alias row (containsCaseInsensitive -> icontains).