diff --git a/.changeset/11547-drill-url-is-empty-operator.md b/.changeset/11547-drill-url-is-empty-operator.md new file mode 100644 index 0000000000..96b76a9003 --- /dev/null +++ b/.changeset/11547-drill-url-is-empty-operator.md @@ -0,0 +1,15 @@ +--- +'@object-ui/app-shell': minor +--- + +The drill `filter[...]` URL dialect can spell "is empty" (objectui#11547). + +From `@objectstack/spec` 17.6.0, `parseFilterAST` lowers the view operators `is_empty` / `is_not_empty` to `{ $empty: true | false }` rather than `$null`. A drill composed by `composeDrillFilter` goes through that lowering, so a widget filter saying "is empty" reached the escape hatch's serializer as `$empty`, which had no URL spelling. The condition was dropped from the drill URL, and the list page opened on a superset of what the widget counted. On 17.5.0 the same widget drilled as `filter[][null]=true`. + +`drillUrlFilters` now carries the pair on both sides of its one module, as `EMPTY_FILTER`, following the objectui#9159 precedent: + +- **write**: `{ $empty: true | false }` becomes `filter[][empty]=true|false`. It is written beside any `[null]` param and any range bound on the same object, never instead of them. A non-boolean `$empty` writes nothing. +- **read**: on the ADR-0055 `/data` surface that param becomes `[field, 'is_empty', true]` or `[field, 'is_not_empty', true]`, the triples `convertFiltersToAST` emits for the same objects. The data sink lowers them back to `$empty`. Any other value is dropped, as for `[null]`. +- **chip**: each direction hands out the filter builder's existing `is_empty` / `is_not_empty` operator key, which every locale pack already translates. No new string is authored. + +The range maps are unchanged. `is_empty` and `is_not_empty` are canonical `ViewFilterRule` words, so "Save as view" keeps the condition. Removing the chip clears the param through the existing prefix delete. Every spelling that worked before is written byte-identically. diff --git a/packages/app-shell/src/views/ObjectDataPage.filterChipI18n-9159.test.tsx b/packages/app-shell/src/views/ObjectDataPage.filterChipI18n-9159.test.tsx index cfbed29051..fbcf108b07 100644 --- a/packages/app-shell/src/views/ObjectDataPage.filterChipI18n-9159.test.tsx +++ b/packages/app-shell/src/views/ObjectDataPage.filterChipI18n-9159.test.tsx @@ -87,7 +87,7 @@ import { I18nProvider } from '@object-ui/i18n'; import { builtInLocales } from '@object-ui/i18n/locales'; import { ObjectDataPage } from './ObjectDataPage'; import { ExpressionProvider } from '../providers/ExpressionProvider'; -import { NULL_FILTER } from './drillUrlFilters'; +import { NULL_FILTER, EMPTY_FILTER } from './drillUrlFilters'; const h = React.createElement; const OBJECT_NAME = 'showcase_invoice'; @@ -220,3 +220,39 @@ describe('the is-null filter chip is translated (objectui#9159)', () => { expect(chipOperatorText()).toBe('= alice'); }); }); + +/** + * objectui#11547 — the "is empty" pair's chips, through the same real page and + * the same real packs. The arm hands out the filter builder's existing + * `is_empty` / `is_not_empty` keys; a key that did not resolve would render as + * itself, and a shared key would draw both directions with one label. + */ +describe('the is-empty filter chips are translated (objectui#11547)', () => { + /** What the pack itself calls one operator, in one language. */ + const packLabelOf = (language: string, key: string): unknown => + key.split('.').reduce( + (node, part) => (node as Record | undefined)?.[part], + (builtInLocales as Record)[language], + ); + + it.each([ + [EMPTY_FILTER.flag, EMPTY_FILTER.labelKey], + [EMPTY_FILTER.notFlag, EMPTY_FILTER.notLabelKey], + ])('`[empty]=%s` renders the zh pack copy of `%s`', (flag, key) => { + renderAt('zh', `filter[${FIELD}][${EMPTY_FILTER.param}]=${flag}`); + const rendered = chipOperatorText(); + expect(rendered).toBe(packLabelOf('zh', key)); + expect(rendered).not.toContain('filterBuilder.operators'); + expect(rendered).not.toContain('true'); + expect(rendered).not.toContain('false'); + }); + + it('CONTROL: the en pack renders too, the packs differ, and the two directions differ', () => { + renderAt('en', `filter[${FIELD}][${EMPTY_FILTER.param}]=${EMPTY_FILTER.flag}`); + expect(chipOperatorText()).toBe(packLabelOf('en', EMPTY_FILTER.labelKey)); + expect(packLabelOf('zh', EMPTY_FILTER.labelKey)).not.toBe(packLabelOf('en', EMPTY_FILTER.labelKey)); + expect(packLabelOf('zh', EMPTY_FILTER.notLabelKey)).not.toBe(packLabelOf('zh', EMPTY_FILTER.labelKey)); + // And neither is the is-null label: "is empty" is a different question. + expect(packLabelOf('zh', EMPTY_FILTER.labelKey)).not.toBe(packLabelOf('zh', NULL_FILTER.labelKey)); + }); +}); diff --git a/packages/app-shell/src/views/ObjectDataPage.saveAsViewFilterFold.test.ts b/packages/app-shell/src/views/ObjectDataPage.saveAsViewFilterFold.test.ts index e9c32ee7cf..c5f27fb1a1 100644 --- a/packages/app-shell/src/views/ObjectDataPage.saveAsViewFilterFold.test.ts +++ b/packages/app-shell/src/views/ObjectDataPage.saveAsViewFilterFold.test.ts @@ -25,7 +25,13 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { ViewItemSchema } from '@objectstack/spec/ui'; -import { URL_FILTER_OPS, NULL_FILTER, type FilterTriple } from './drillUrlFilters'; +import { + URL_FILTER_OPS, + NULL_FILTER, + EMPTY_FILTER, + parseUrlFilterTriples, + type FilterTriple, +} from './drillUrlFilters'; import { viewEnvelope } from './runtime-metadata-persistence'; import { buildSaveAsViewSpec } from './ObjectDataPage'; @@ -103,8 +109,20 @@ describe('Save as view folds URL drill triples to spec rules (objectui#3419)', ( // `op` would have left this pin GREEN while the contract grew an operator // it does not cover — the set is computed from the constants, so a missing // member is invisible here rather than red. - const emittable = ['=', ...Object.values(URL_FILTER_OPS), NULL_FILTER.op, NULL_FILTER.notOp]; - expect(emittable).toEqual(['=', '>=', '<=', '>', '<', 'is_null', 'is_not_null']); + // + // ⚠️ objectui#11547 added the "is empty" pair (`EMPTY_FILTER`) for the same + // reason and with the same obligation: both of its operators are listed. + const emittable = [ + '=', + ...Object.values(URL_FILTER_OPS), + NULL_FILTER.op, + NULL_FILTER.notOp, + EMPTY_FILTER.op, + EMPTY_FILTER.notOp, + ]; + expect(emittable).toEqual([ + '=', '>=', '<=', '>', '<', 'is_null', 'is_not_null', 'is_empty', 'is_not_empty', + ]); const { spec, gate } = saveAsView( emittable.map((op, i) => ['f' + i, op, String(i)] as FilterTriple), @@ -124,6 +142,10 @@ describe('Save as view folds URL drill triples to spec rules (objectui#3419)', ( 'is_null', // objectui#9508 — the inverse direction, canonical for the same reason. 'is_not_null', + // objectui#11547 — the "is empty" pair, canonical words too, so it also + // reaches `normalizeFilterOperator` unbridged. + 'is_empty', + 'is_not_empty', ]); expect(gate.success).toBe(true); }); @@ -139,6 +161,27 @@ describe('Save as view folds URL drill triples to spec rules (objectui#3419)', ( ).toBe(true); }); + it.each([ + [EMPTY_FILTER.flag, EMPTY_FILTER.op], + [EMPTY_FILTER.notFlag, EMPTY_FILTER.notOp], + ])( + 'keeps `filter[owner][empty]=%s` through the fold as `%s` (objectui#11547)', + (flag, op) => { + // A drill that the URL can now carry must also be savable, or the user + // reads a chip the saved view then silently does not carry. Read from the + // URL itself, so the reader and the fold are judged together. + const triples = parseUrlFilterTriples( + new URLSearchParams(`filter[owner][${EMPTY_FILTER.param}]=${flag}`), + ); + const { spec, gate } = saveAsView(triples); + expect(spec.filter).toEqual([{ field: 'owner', operator: op, value: true }]); + expect( + gate.success, + `ViewItem rejected by spec: ${JSON.stringify(gate.error?.issues)}`, + ).toBe(true); + }, + ); + it('keeps the is-null flag intact through the fold, value and all', () => { // The escape hatch's empty-bucket drill (objectui#9159) is savable as a // view: `[field,'is_null',true]` is a canonical rule, and the value rides diff --git a/packages/app-shell/src/views/drillEmptyDialect-11547.test.tsx b/packages/app-shell/src/views/drillEmptyDialect-11547.test.tsx new file mode 100644 index 0000000000..10f0cf7ef4 --- /dev/null +++ b/packages/app-shell/src/views/drillEmptyDialect-11547.test.tsx @@ -0,0 +1,166 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11547 — a composed drill whose widget filter says "is empty" keeps + * that condition on the way to the list page. + * + * `composeDrillFilter` merges the widget filter with the clicked bucket and + * lowers the result through the spec's `parseFilterAST`. From + * `@objectstack/spec` 17.6.0 that lowering spells the view operators + * `is_empty` / `is_not_empty` as `{ $empty: true | false }`; before it, all + * four empty spellings lowered to `$null`, which this dialect already carried + * as `filter[][null]`. `$empty` had no spelling at all, so the condition + * VANISHED from the drill URL and the list opened on a superset of what the + * widget counted — silently, the objectui#9159 / objectui#9508 shape. + * + * ## Why the observation point is the DESTINATION SCOPE + * + * Same reason objectui#9159 gave: the defect's signature is that every layer + * looks healthy and only the row set is wrong. So the escape hatch is driven for + * real (`useOpenRecordList` inside a router) with a filter built by the real + * `composeDrillFilter`, and the assertions are on what the bare data surface + * reads back out of the URL — and, one step further, on what the data sink + * lowers that to. + */ + +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; +import type { ReactNode } from 'react'; +import { composeDrillFilter, convertFiltersToAST, mergeFilterNodes } from '@object-ui/core'; +import { FilterScopeProvider } from '@object-ui/react'; +import { isFilterAST, parseFilterAST } from '@objectstack/spec/data'; +import { parseUrlFilterTriples, groupFilterChips, EMPTY_FILTER } from './drillUrlFilters'; +import { useOpenRecordList } from './useOpenRecordList'; + +function Wrapper({ children }: { children: ReactNode }) { + return ( + + + + {children}} /> + + + + ); +} + +function useHarness() { + const openRecordList = useOpenRecordList(); + const { pathname, search } = useLocation(); + return { openRecordList, pathname, search }; +} + +/** Drive the real escape hatch and hand back the URL it navigated to. */ +function drillTo(objectName: string, filter?: Record) { + const { result } = renderHook(useHarness, { wrapper: Wrapper }); + act(() => result.current.openRecordList(objectName, filter)); + return result.current; +} + +/** The scope the destination list will actually run under. */ +const destinationScope = (search: string) => parseUrlFilterTriples(new URLSearchParams(search)); + +/** Both directions, as the widget author writes them and as the URL spells them. */ +const DIRECTIONS = [ + ['is_empty', true, 'true'], + ['is_not_empty', false, 'false'], +] as const; + +describe('a composed "is empty" widget filter reaches the drill URL (objectui#11547)', () => { + it.each(DIRECTIONS)( + 'a widget filter `%s` composed with a click context survives to the destination', + (op, flag, param) => { + const composed = composeDrillFilter([['owner', op, true]], { stage: 'won' }); + // The spec's 17.6.0 lowering, recorded where it will redden if it moves: + // this is the spelling the serializer has to carry. + expect(composed).toEqual({ $and: [{ owner: { $empty: flag } }, { stage: 'won' }] }); + + const { pathname, search } = drillTo('opportunity', composed); + expect(pathname).toBe('/apps/crm/opportunity/data'); + // The defect: this used to be `?filter[stage]=won` and nothing else. + expect(decodeURIComponent(search)).toBe(`?filter[owner][empty]=${param}&filter[stage]=won`); + expect(destinationScope(search)).toEqual([ + ['owner', op, true], + ['stage', '=', 'won'], + ]); + }, + ); + + it.each(DIRECTIONS)( + 'the same holds for a widget filter authored as a ViewFilterRule `%s`', + (op, _flag, param) => { + // The other shape a widget's `filter` arrives in: a stored rule list. + const composed = composeDrillFilter([{ field: 'owner', operator: op }], { stage: 'won' }); + const { search } = drillTo('opportunity', composed); + expect(decodeURIComponent(search)).toContain(`filter[owner][empty]=${param}`); + expect(destinationScope(search)).toContainEqual(['owner', op, true]); + }, + ); + + it('an "is empty"-only drill is no longer an empty query string', () => { + // It used to serialize to nothing at all, the widest possible answer: every + // row in the object. + const { search } = drillTo('opportunity', composeDrillFilter([['owner', 'is_empty', true]], undefined)); + expect(decodeURIComponent(search)).toBe('?filter[owner][empty]=true'); + }); + + it('CONTROL: a 17.5.0-shaped `$null` composition still writes `[null]`, unchanged', () => { + // What the same widget produced before the bump. Lights the harness: the + // `[empty]` answers above are the new arm, not a serializer that now writes + // something for every operator object. + const { search } = drillTo('opportunity', { $and: [{ owner: { $null: true } }, { stage: 'won' }] }); + expect(decodeURIComponent(search)).toBe('?filter[owner][null]=true&filter[stage]=won'); + expect(destinationScope(search)).toEqual([ + ['owner', 'is_null', true], + ['stage', '=', 'won'], + ]); + }); + + it('CONTROL: an `is_null` widget filter still composes to `$null` and writes `[null]`', () => { + // 17.6.0 split the lowering: the null pair kept `$null`, only the empty pair + // moved. Both arms are therefore live at once. + const composed = composeDrillFilter([['owner', 'is_null', true]], { stage: 'won' }); + expect(composed).toEqual({ $and: [{ owner: { $null: true } }, { stage: 'won' }] }); + expect(decodeURIComponent(drillTo('opportunity', composed).search)) + .toBe('?filter[owner][null]=true&filter[stage]=won'); + }); +}); + +describe('the drilled list filters the way the widget counted (objectui#11547)', () => { + it.each(DIRECTIONS)('agrees with `convertFiltersToAST` for %s (`$empty: %s`)', (_op, flag) => { + // Agreement with the converter the other two drill sinks use, read from the + // converter itself rather than transcribed from it. + const { search } = drillTo('opportunity', { owner: { $empty: flag } }); + expect(destinationScope(search)).toEqual([convertFiltersToAST({ owner: { $empty: flag } })]); + }); + + it.each(DIRECTIONS)( + 'the `/data` list hands the data sink a filter it lowers back to `$empty` (%s)', + (_op, flag) => { + // The list composes its base filter through `mergeFilterNodes` (ListView's + // `buildEffectiveFilter`) and the sink lowers that with `parseFilterAST`. + // So the URL round trip ends where the widget's count began: `$empty`. + const { search } = drillTo('opportunity', { owner: { $empty: flag } }); + const node = mergeFilterNodes(destinationScope(search)); + expect(isFilterAST(node)).toBe(true); + expect(parseFilterAST(node)).toEqual({ owner: { $empty: flag } }); + }, + ); + + it('draws a chip carrying the operator KEY for each direction, never `= true`', () => { + const { search } = drillTo('opportunity', { owner: { $empty: true }, region: { $empty: false } }); + const chips = groupFilterChips(destinationScope(search)); + expect(chips).toEqual([ + { field: 'owner', textKey: EMPTY_FILTER.labelKey }, + { field: 'region', textKey: EMPTY_FILTER.notLabelKey }, + ]); + expect(chips.map((c) => c.text)).toEqual([undefined, undefined]); + }); +}); diff --git a/packages/app-shell/src/views/drillUrlFilters.test.ts b/packages/app-shell/src/views/drillUrlFilters.test.ts index 3e7dcb5072..bb0ebd24a8 100644 --- a/packages/app-shell/src/views/drillUrlFilters.test.ts +++ b/packages/app-shell/src/views/drillUrlFilters.test.ts @@ -459,3 +459,142 @@ describe('the is-not-null operator and its synonyms (objectui#9508)', () => { ).toBe('filter[stage]=won&filter[owner][null]=true&filter[close_date][gte]=2026-04-01&filter[close_date][lt]=2026-07-01'); }); }); + +/** + * objectui#11547 — the "is empty" pair, `filter[][empty]=true|false`. + * + * From `@objectstack/spec` 17.6.0, `parseFilterAST` lowers the view operators + * `is_empty` / `is_not_empty` to `{ $empty: true | false }` instead of `$null`, + * so a composed drill of a widget filter saying "is empty" reached this + * serializer as an operator object with no arm and wrote NO param. The + * end-to-end obligation (real `composeDrillFilter`, real escape hatch, the + * destination scope and the sink's lowering) is pinned in + * `drillEmptyDialect-11547.test.tsx`; these are the module's own write, read, + * chip and delete obligations for the pair. + */ +describe('the is-empty operator pair: `filter[][empty]=true|false` (objectui#11547)', () => { + it.each([ + ['{ $empty: true }', 'true', 'is_empty', { $empty: true }], + ['{ $empty: false }', 'false', 'is_not_empty', { $empty: false }], + ] as const)('writes %s as `[empty]=%s` and reads it back as `%s`', (_label, flag, op, ops) => { + const qs = serializeDrillFilterParams({ owner: ops }); + expect(qs.get('filter[owner][empty]')).toBe(flag); + // The round trip, not the write alone. + expect(parseUrlFilterTriples(qs)).toEqual([['owner', op, true]]); + }); + + it('agrees with `convertFiltersToAST` on both directions, read from the converter itself', () => { + for (const ops of [{ $empty: true }, { $empty: false }]) { + expect(parseUrlFilterTriples(serializeDrillFilterParams({ owner: ops }))) + .toEqual([convertFiltersToAST({ owner: ops })]); + } + }); + + it('writes nothing for a NON-boolean `$empty`, as the is-null keys never did', () => { + for (const v of ['yes', 1, 0, 'true', null, {}]) { + expect(serializeDrillFilterParams({ owner: { $empty: v } }).toString()).toBe(''); + } + // LIT CONTROL: the boolean forms of the same key do write. + expect(serializeDrillFilterParams({ owner: { $empty: true } }).toString()).not.toBe(''); + expect(serializeDrillFilterParams({ owner: { $empty: false } }).toString()).not.toBe(''); + }); + + it('reads only the two exact spellings, comparand `true` in both directions', () => { + expect(parse('filter[owner][empty]=true')).toEqual([['owner', 'is_empty', true]]); + expect(parse('filter[owner][empty]=false')).toEqual([['owner', 'is_not_empty', true]]); + // Never an equality against the string "false", never `is_not_empty false`. + expect(parse('filter[owner][empty]=false')[0][2]).toBe(true); + for (const v of ['1', '0', 'yes', 'TRUE', 'False', 'null', 'empty']) { + expect(parse(`filter[owner][empty]=${v}`)).toEqual([]); + } + expect(parse('filter[owner][empty]=')).toEqual([]); + }); + + it('carries the flag alongside range bounds on the same field, as the AST converter does', () => { + const qs = serializeDrillFilterParams({ score: { $empty: false, $gte: '5' } }); + expect(parseUrlFilterTriples(qs)).toEqual([ + ['score', 'is_not_empty', true], + ['score', '>=', '5'], + ]); + }); + + it('writes `$empty` BESIDE a `$null` on the same field — two params, so nothing has to win', () => { + // Unlike `$null` vs `$exists` (one param, one direction), these are two + // different params, so the conjunction is expressible as written and the + // URL carries exactly what `convertFiltersToAST` emits for the object. + for (const ops of [ + { $null: true, $empty: false }, + { $null: false, $empty: true }, + { $exists: true, $empty: true }, + ]) { + const triples = parseUrlFilterTriples(serializeDrillFilterParams({ owner: ops })); + // The converter ANDs two conditions on one field under one `and` head; + // the URL's conjunction is implicit, so the triples are that head's body. + expect(triples).toHaveLength(2); + expect(['and', ...triples]).toEqual(convertFiltersToAST({ owner: ops })); + } + expect(decodeURIComponent(serializeDrillFilterParams({ owner: { $null: true, $empty: false } }).toString())) + .toBe('filter[owner][null]=true&filter[owner][empty]=false'); + }); + + it('survives a COMPOSED `$and` round-trip beside an equality condition', () => { + const composed = { $and: [{ owner: { $empty: true } }, { stage: 'won' }] }; + expect(parseUrlFilterTriples(serializeDrillFilterParams(composed))).toEqual([ + ['owner', 'is_empty', true], + ['stage', '=', 'won'], + ]); + }); + + it('renders a chip carrying each direction\'s operator KEY, not `= true`', () => { + expect(groupFilterChips([['owner', 'is_empty', true]])).toEqual([ + { field: 'owner', textKey: 'filterBuilder.operators.is_empty' }, + ]); + expect(groupFilterChips([['owner', 'is_not_empty', true]])).toEqual([ + { field: 'owner', textKey: 'filterBuilder.operators.is_not_empty' }, + ]); + }); + + it('keeps the is-null label first on a field that carries both pairs', () => { + // One chip per field. The is-null arms answer first, so every list they + // answered before this pair existed keeps its answer; the list itself still + // applies both conditions, as it does for a range bound beside a flag. + expect(groupFilterChips([ + ['owner', 'is_empty', true], + ['owner', 'is_not_null', true], + ])).toEqual([{ field: 'owner', textKey: 'filterBuilder.operators.is_not_null' }]); + }); + + it('removing the chip clears the `[empty]` param with the same prefix delete', () => { + const params = new URLSearchParams( + 'filter[owner][empty]=false&filter[owner][null]=true&filter[stage]=won', + ); + deleteFieldFilterParams(params, 'owner'); + expect(params.toString()).toBe('filter%5Bstage%5D=won'); + }); + + it('CONTROL: the equality-only route still executes NO operator suffix', () => { + // objectui#9196's boundary, untouched: `[empty]` is a suffixed form. + expect(parseUrlEqualityFilterTriples(new URLSearchParams('filter[owner][empty]=true'))) + .toEqual([]); + }); + + it('CONTROL: the `$null` and `$exists` pairs write exactly what they wrote before', () => { + expect( + decodeURIComponent( + serializeDrillFilterParams({ + a: { $null: true }, + b: { $null: false }, + c: { $exists: false }, + d: { $exists: true }, + }).toString(), + ), + ).toBe('filter[a][null]=true&filter[b][null]=false&filter[c][null]=true&filter[d][null]=false'); + }); + + it('CONTROL: the range vocabulary did not grow — `empty` is not a range suffix', () => { + // `ObjectDataPage` inverts this map to bridge a triple to the spec's alias + // spelling; an `empty` entry would hand "Save as view" an alias the rule + // schema refuses. + expect({ ...URL_FILTER_OPS }).toEqual({ gte: '>=', lte: '<=', gt: '>', lt: '<' }); + }); +}); diff --git a/packages/app-shell/src/views/drillUrlFilters.ts b/packages/app-shell/src/views/drillUrlFilters.ts index 28520ac5d3..9062b6d994 100644 --- a/packages/app-shell/src/views/drillUrlFilters.ts +++ b/packages/app-shell/src/views/drillUrlFilters.ts @@ -16,11 +16,15 @@ * Contract: * - equality `filter[field]=value` → `[field, '=', value]` * - range / cmp `filter[field][gte|lte|gt|lt]=v` → `[field, '>=' | … , v]` - * - emptiness `filter[field][null]=true|false` → `[field, 'is_null', true]` + * - is null `filter[field][null]=true|false` → `[field, 'is_null', true]` * / `[field, 'is_not_null', true]` + * - is empty `filter[field][empty]=true|false` → `[field, 'is_empty', true]` + * / `[field, 'is_not_empty', true]` * A date-bucket drill emits `gte` + `lt` to scope a list to a time bucket; an - * EMPTY-bucket drill emits `[null]=true` (objectui#9159), and a widget filter - * asking for "this field is set" emits `[null]=false` (objectui#9508). + * EMPTY-bucket drill emits `[null]=true` (objectui#9159), a widget filter + * asking for "this field is set" emits `[null]=false` (objectui#9508), and a + * widget filter saying "is empty" / "is not empty" emits `[empty]=true|false` + * (objectui#11547). */ /** Filter triple shape shared with view metadata: [field, operator, value]. */ @@ -101,10 +105,17 @@ export const RANGE_OP_PARAM: Record = { $gte: 'gte', $lte: 'lte' * inverse. Both are read here, because the two drill routes deliver different * ones — measured, not assumed: * - * - a COMPOSED drill filter only ever arrives spelled `$null`. + * - a COMPOSED drill filter's is-null condition only ever arrives spelled + * `$null`. * `composeDrillFilter` lowers through the spec's `parseFilterAST`, which * canonicalises `is_not_null` back to `{ $null: false }`, so an authored * `$exists` is already gone by the time it reaches this function. + * ⚠️ Its is-EMPTY condition does not arrive as `$null` any more: from + * `@objectstack/spec` 17.6.0 that lowering spells `is_empty` / + * `is_not_empty` as `$empty`, a different question on a text or + * multi-value field, carried by its own pair, {@link EMPTY_FILTER} + * (objectui#11547). Before 17.6.0 all four empty spellings lowered to + * `$null` and reached this pair. * - an UNCOMPOSED one arrives spelled however the author wrote it. A widget * that hands its own resolved filter straight to the escape hatch * (`ObjectMetricWidget`, whose drawer renders `OpenInListButton`) passes @@ -167,6 +178,58 @@ export const NULL_FILTER = { notLabelKey: 'filterBuilder.operators.is_not_null', } as const; +/** + * The "is empty" operator pair (objectui#11547): {@link NULL_FILTER}'s shape, + * for the spec's `$empty` predicate rather than `$null`. + * + * ## Why it is a pair of its own and not another spelling of is-null + * + * `$empty` judges a field by its DECLARED type — on a text field it also finds + * `''`, on a multi-value field also `[]` — so it is not `$null` under another + * name, and folding it onto `[null]` would answer a narrower question than the + * widget asked. From `@objectstack/spec` 17.6.0, `parseFilterAST` lowers the + * view operators `is_empty` / `is_not_empty` to it, so a COMPOSED drill of a + * widget filter saying "is empty" arrives here as `{ $empty: true | false }`. + * Without this pair that object hit no arm below and wrote NO param: the list + * opened unscoped by the condition, a superset of what the widget counted. + * + * Everything {@link NULL_FILTER} documents about the VALUE holds here + * unchanged: one param whose value is the direction, only its two exact + * spellings are conditions, the comparand is the literal `true` in both + * triples, and a non-boolean under the producer key writes nothing. + * + * ⚠️ When `$empty` and `$null` sit on ONE field they are two params, not one + * param asked for two directions, so nothing has to win: both are written, both + * read back, and the list ANDs them exactly as `convertFiltersToAST` does for + * the same object. That conjunction is the narrowest answer, not a superset. + * + * ⚠️ Kept OUT of {@link URL_FILTER_OPS} for the reason {@link NULL_FILTER} + * gives: `is_empty` / `is_not_empty` are already canonical `ViewFilterRule` + * words, and bridging them through that range map is what would lose the + * condition from "Save as view". + */ +export const EMPTY_FILTER = { + /** URL param suffix, shared by both directions: `filter[][empty]`. */ + param: 'empty', + /** The ONLY param value that spells IS EMPTY. */ + flag: 'true', + /** The ONLY param value that spells IS NOT EMPTY. */ + notFlag: 'false', + /** ObjectQL operator `flag` reads back as — what `convertFiltersToAST` emits for `{ $empty: true }`. */ + op: 'is_empty', + /** ObjectQL operator `notFlag` reads back as — what it emits for `{ $empty: false }`. */ + notOp: 'is_not_empty', + /** ObjectQL operator-object key whose BOOLEAN is the direction verbatim. */ + key: '$empty', + /** + * The filter builder's existing operator labels, reused rather than forked, + * exactly as {@link NULL_FILTER} reuses its pair: present and translated in + * all ten packs and already inside that family's locale-parity pin. + */ + labelKey: 'filterBuilder.operators.is_empty', + notLabelKey: 'filterBuilder.operators.is_not_empty', +} as const; + /** * The ONE grammar for a key in this family, so the two arms below cannot drift * apart on what a field name is: `filter[]`, with an OPTIONAL @@ -179,10 +242,11 @@ const FILTER_KEY = /^filter\[([^[\]]+)\](?:\[([^[\]]+)\])?$/; /** * Parse `filter[]=` (equality), `filter[][]=` - * (range/comparison) and `filter[][null]=true|false` ({@link - * NULL_FILTER}, the emptiness pair) search params into ObjectQL triples. An + * (range/comparison), `filter[][null]=true|false` ({@link NULL_FILTER}, + * the is-null pair) and `filter[][empty]=true|false` ({@link + * EMPTY_FILTER}, the is-empty pair) search params into ObjectQL triples. An * unknown operator suffix is ignored (never silently downgraded to equality), - * and so is an unknown value in the emptiness slot. + * and so is an unknown value in either flag slot. */ export function parseUrlFilterTriples(searchParams: URLSearchParams): FilterTriple[] { const out: FilterTriple[] = []; @@ -205,6 +269,12 @@ export function parseUrlFilterTriples(searchParams: URLSearchParams): FilterTrip else if (value === NULL_FILTER.notFlag) out.push([field, NULL_FILTER.notOp, true]); return; } + if (suffix === EMPTY_FILTER.param) { + // Same two-spelling rule as the is-null slot above (objectui#11547). + if (value === EMPTY_FILTER.flag) out.push([field, EMPTY_FILTER.op, true]); + else if (value === EMPTY_FILTER.notFlag) out.push([field, EMPTY_FILTER.notOp, true]); + return; + } const op = URL_FILTER_OPS[suffix]; if (op) out.push([field, op, value]); }); @@ -251,7 +321,9 @@ export function parseUrlEqualityFilterTriples(searchParams: URLSearchParams): Fi * emptiness pair ({@link NULL_FILTER}) — `{ $null: true }`, what an EMPTY-bucket * drill carries, and its three synonyms `{ $exists: false }`, `{ $null: false }` * and `{ $exists: true }` (objectui#9508) — becomes `filter[field][null]` with - * the direction as its value; a plain value becomes `filter[field]`. + * the direction as its value; `{ $empty: true | false }` ({@link EMPTY_FILTER}, + * objectui#11547) becomes `filter[field][empty]` the same way; a plain value + * becomes `filter[field]`. * `null`/`undefined` values and objects with no recognized operator are skipped * (drill degrades to a superset) rather than stringified to * `"[object Object]"`. @@ -337,6 +409,18 @@ function collectFilterParams(filter: Record, params: URLSearchP direction ? NULL_FILTER.flag : NULL_FILTER.notFlag, ); } + // The "is empty" pair (objectui#11547) — what a composed drill carries + // for an `is_empty` / `is_not_empty` widget filter from spec 17.6.0. Its + // own param, so it is written beside the is-null param and any range + // bound, never instead of them; `typeof` gates it as `nullDirection` gates + // its keys, so a non-boolean writes nothing. + const empty = ops[EMPTY_FILTER.key]; + if (typeof empty === 'boolean') { + params.set( + `filter[${field}][${EMPTY_FILTER.param}]`, + empty ? EMPTY_FILTER.flag : EMPTY_FILTER.notFlag, + ); + } for (const [op, suffix] of Object.entries(RANGE_OP_PARAM)) { const bound = ops[op]; if (bound != null) params.set(`filter[${field}][${suffix}]`, String(bound)); @@ -402,6 +486,9 @@ export interface FilterChip { * the answer objectui#9159 shipped; the URL cannot produce that pair (one param * key, one direction), so this only fixes the order for a hand-built list. * + * The "is empty" pair ({@link EMPTY_FILTER}, objectui#11547) gets the same two + * arms, checked after these. + * * These arms are the only ones that yield `textKey` rather than `text`, for the * reason on {@link FilterChip}. The keys are the filter builder's existing * operator keys, already present and already translated in all ten packs, and @@ -427,6 +514,16 @@ export function groupFilterChips(triples: FilterTriple[]): FilterChip[] { if (list.some(([, op]) => op === NULL_FILTER.notOp)) { return { field, textKey: NULL_FILTER.notLabelKey }; } + // objectui#11547 — the "is empty" pair, same posture and same carrier. + // After the is-null arms, so every list those arms already answered keeps + // its answer; a field carrying both params shows the is-null label while + // the list still applies both (one chip per field, as for a range bound). + if (list.some(([, op]) => op === EMPTY_FILTER.op)) { + return { field, textKey: EMPTY_FILTER.labelKey }; + } + if (list.some(([, op]) => op === EMPTY_FILTER.notOp)) { + return { field, textKey: EMPTY_FILTER.notLabelKey }; + } const gte = list.find(([, op]) => op === '>=' || op === '>'); const lt = list.find(([, op]) => op === '<' || op === '<='); const text =