Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
3796d59
chore(deps): resolve @objectstack/* 17.6.0 in pnpm-lock.yaml (objectu…
claude Oct 2, 2026
b6684fd
chore(scripts): spec-symbols allows @object-ui/components' EmptyState…
claude Oct 2, 2026
c2dc334
refactor(app-shell,data-objectstack): retire the `| 'computed'` widen…
claude Oct 2, 2026
3b3cc6b
test(plugin-dashboard): read the ADR-0087 registry from @objectstack/…
claude Oct 2, 2026
1eff255
fix(components): page:header stops publishing `breadcrumb`, which @ob…
claude Oct 2, 2026
6e76e90
test(console): strike the two `endpoint` bookings @objectstack/spec 1…
claude Oct 2, 2026
7ca5a36
fix(i18n): recognise a served translation document by its `picklists`…
claude Oct 2, 2026
5ef3050
test(plugin-designer): the `constructor` field-name refusal is read a…
claude Oct 2, 2026
74fd7eb
docs(types,core,app-shell,plugins): restamp the installed-pin claims …
claude Oct 2, 2026
d73f74e
chore(changeset): declare the @objectstack/* 17.6.0 bump (objectui#11…
claude Oct 2, 2026
8879fe5
test(scripts,app-shell,console): re-pin the counts and keys @objectst…
claude Oct 3, 2026
31c6905
test(types): object-grid's bag `grouping` is judged since @objectstac…
claude Oct 3, 2026
2ba091c
test(types): the card footer is a judged child position since @object…
claude Oct 3, 2026
060cec8
chore(scripts): lower the eager-closure ceilings by the 250,096 bytes…
claude Oct 3, 2026
40d9b5a
Merge remote-tracking branch 'origin/main' into claude/issue-11438-ob…
claude Oct 3, 2026
bb6d7d3
Merge origin/main (c73cdb5) into claude/issue-11438-objectstack-17-6-0
claude Oct 3, 2026
00c13e7
test(console): book record:line_items' 17.6.0 differences to objectui…
claude Oct 3, 2026
c9121f9
test(console): the group-5 booking's prose names no installed version…
claude Oct 3, 2026
acd62a3
fix(core): ValueDataSource executes is_empty / is_not_empty and $empt…
claude Oct 3, 2026
158204d
fix(core): convertFiltersToAST lowers $empty, the canonical operator …
claude Oct 3, 2026
9786e01
test: promote the staged-$empty tripwires to @objectstack/spec 17.6.0…
claude Oct 3, 2026
4ac6be7
feat(plugin-grid,plugin-list,types): follow object-grid's 17.6.0 desc…
claude Oct 3, 2026
518c855
chore(changeset): objectui#11227's changeset, and corrections to thre…
claude Oct 3, 2026
ed622b4
test: type the two casts the slice's new pins added instead of wideni…
claude Oct 3, 2026
8a9cf1b
test(core): the refusal-subject census names the $empty flag refusal,…
claude Oct 3, 2026
6a86dd1
test(types),docs: register EmptyStateSchema at the import boundary, r…
claude Oct 3, 2026
da4c629
Merge origin/main (6f5719e1c) into claude/issue-11438-objectstack-17-6-0
claude Oct 3, 2026
9187c5c
Merge trunk claude/issue-11438-objectstack-17-6-0 (da4c629f1) into ob…
claude Oct 3, 2026
0a518da
test(types): book the dimensionless measure-arity check on the 7715 c…
claude Oct 3, 2026
c4775ed
test(types,plugin-dashboard): book the dimensionless measure-arity ro…
claude Oct 3, 2026
6c5011d
test(console): book row 1 of the 17.6.0 bump, object-grid.keyboardNav…
claude Oct 3, 2026
bebd7ec
test(console): the row-1 booking's docblock cites ruling A″ by record…
claude Oct 3, 2026
13f07dd
test(core): book row 5 of the 17.6.0 bump, ActionSchema.outcomeMessag…
claude Oct 3, 2026
6b8a0ed
fix(types): raise the declared @objectstack/spec floor to ^17.6.0 (ob…
claude Oct 3, 2026
56fe66b
Merge trunk claude/issue-11438-objectstack-17-6-0 (13f07dd0f) into cl…
claude Oct 3, 2026
0507d67
docs(changeset): the 17.6.0 bump's range sentence names the one floor…
claude Oct 3, 2026
4f0ea47
fix(app-shell): the drill URL dialect spells "is empty", so a compose…
claude Oct 3, 2026
846ee2b
test(app-shell): the is-empty pins' it.each titles print the flag and…
claude Oct 3, 2026
feecb03
Merge trunk claude/issue-11438-objectstack-17-6-0 (0507d6716) into ob…
claude Oct 3, 2026
75780e9
test(app-shell): the is-empty chip pin's pack lookup carries no `any`…
claude Oct 3, 2026
1fac6ff
Merge main (6158e4c93, objectui#11531's landing) into objectui#11547'…
claude Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/11547-drill-url-is-empty-operator.md
Original file line number Diff line number Diff line change
@@ -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[<field>][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[<field>][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.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<unknown>(
(node, part) => (node as Record<string, unknown> | undefined)?.[part],
(builtInLocales as Record<string, unknown>)[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));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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),
Expand All @@ -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);
});
Expand All @@ -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
Expand Down
166 changes: 166 additions & 0 deletions packages/app-shell/src/views/drillEmptyDialect-11547.test.tsx
Original file line number Diff line number Diff line change
@@ -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[<field>][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 (
<MemoryRouter initialEntries={['/apps/crm/dashboard/pipeline']}>
<FilterScopeProvider currentUserId={null} currentOrgId={null}>
<Routes>
<Route path="/apps/:appName/*" element={<>{children}</>} />
</Routes>
</FilterScopeProvider>
</MemoryRouter>
);
}

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<string, unknown>) {
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]);
});
});
Loading
Loading