Skip to content

Commit f8195bd

Browse files
fix(drill): the list URL carries a boolean, a set, $ne and the text operators, so Open in list and View all regain an exact destination (#30777)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu1MP9pZ1S7S7wkrGF5w4Znn)_ Fixes #30743 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:对象列表页 `/apps/APP/OBJECT/data` 的 URL 新增六种写法:布尔 `filter[F][bool]=true|false`、`$ne` 与五个文本运算符 `filter[F][ne|contains|notcontains|startswith|endswith|icontains]=V`、集合 `filter[F][in]=A&filter[F][in]=B`(及 `[nin]`)。下钻「在列表中打开」、`drillDown.target: 'navigate'` 和相关列表「查看全部」遇到这些条件时恢复去处。 - 为什么改:#30654 之后,凡是 URL 说不全的下钻都不给出口;按是/否字段分组的图表、`status in (...)` 这类度量范围最常见,全部没了去处。实测(见下):布尔原样写出会读回成字符串 `'true'`,其余写法根本写不出参数。 - 风险与回滚:只放宽「能精确写出」的集合,每个新写法读回后必须与抽屉查询的 AST 节点完全一致(含类型)才放行;`$or`、`$between`、空集合、布尔的 `$ne` 仍然不给出口。旧链接 `filter[F]=true` 含义不变(文本)。回滚即 revert。 - 先看:`ui/packages/app-shell/src/views/drillUrlFilters.ts`(新常量与读写两侧)、`ui/packages/app-shell/src/views/useOpenRecordList.ts`(`exactRecordListFilter` 的接受集与 `isSameComparison`)、`ui/packages/app-shell/src/views/ObjectDataPage.tsx`(筛选标签渲染与「另存为视图」桥接)。 - 两分钟验证:打开 `/apps/APP/OBJECT/data?filter[status][in]=paid&filter[status][in]=sent`,预期列表只含这两种状态,顶部标签显示「In paid, sent」(中文包为对应译文),点标签上的 ✕ 两个参数一起移除。 - 需要你做的:无,席位落地。 ## What changes Measured first, on `origin/main` `b3098661`, through the real `toFilterNode` → `parseFilterAST` → `serializeDrillFilterParams` → `parseUrlFilterTriples` chain, against `convertFiltersToAST` (what the drill drawer queries): | condition | drawer node | URL written before | read back before | |---|---|---|---| | `{ is_active: true }` | `[is_active, '=', true]` | `filter[is_active]=true` | `[is_active, '=', 'true']` (a string) | | `$in` / `$nin` | `[status, 'in', [...]]` | nothing | nothing | | `$ne` | `[status, '!=', 'void']` | nothing | nothing | | `$contains`, `$notContains`, `$startsWith`, `$endsWith`, `$icontains` | `[name, 'contains', 'acme']` … | nothing | nothing | All 18 probed cases answered `exactRecordListFilter` → `null`. After this PR every one of them round-trips to the drawer's own node, except the three that must not (an empty `$in`, a boolean `$ne`), which still answer `null`. `ui/packages/app-shell/src/views/drillUrlFilters.ts` - `BOOLEAN_FILTER` (`[bool]`): the only two spellings are `true` / `false`, read back as the boolean; anything else in that slot is dropped, never read as text. A bare boolean in a drill filter is written there instead of `filter[F]=true`. - `URL_COMPARE_OPS` / `COMPARE_OP_PARAM`: `ne → '!='` and the five text operators, suffix → the AST word `convertFiltersToAST` emits. Prototype-free, like `URL_FILTER_OPS`, which keeps its four pinned entries. - `URL_LIST_OPS` / `LIST_OP_PARAM`: `in` / `nin` as a repeated param, one member per occurrence (no delimiter, so a member may contain a comma). The read side gathers every occurrence on one field into one triple at the first occurrence's position. The write side writes the whole list or nothing (an empty list, an empty member or a boolean member writes no param), and a later source replaces an earlier list on the same field, the `set` posture of every other param. - `URL_TRIPLE_OP_WORD`: AST word → canonical `ViewFilterRule` word, read by the chips (label key `filterBuilder.operators.WORD`, existing in all ten packs) and by "Save as view". - `groupFilterChips`: a negation, text or membership chip carries the operator label as `textKey` and the comparand as `text` (a set joined with a comma). `FilterChip.textKey`'s doc now says it may sit beside `text`. Every existing chip is unchanged. `ui/packages/app-shell/src/views/useOpenRecordList.ts` - `exactRecordListFilter` accepts a boolean under equality (bare or `$eq`), `$ne` with a string or finite number, the text operators with a non-empty string, and `$in` / `$nin` with a non-empty list of strings or finite numbers. - Each condition's proof is stronger: written alone it must read back as one triple (now: one param NAME, repeated for a set), AND that triple must equal `convertFiltersToAST(condition)`, field, operator and comparand, with the one difference the route has always carried (a finite number reads back as its text). A boolean reading back as `'true'` fails this check. `ui/packages/app-shell/src/views/ObjectDataPage.tsx` - The chip renders `t(textKey) + ' ' + text` when it carries both. - `TRIPLE_OP_TO_SPEC_ALIAS` spreads `URL_TRIPLE_OP_WORD`, so "Save as view" folds `!=` to `not_equals`, `nin` to `not_in`, and so on; the record gate passes. One changeset: `@object-ui/app-shell` patch. ## Tests - `drillUrlFilters.test.ts`: a new describe for the six spellings: write/read for each against `convertFiltersToAST`, only two `[bool]` spellings, `filter[name]=true` still text, set order and grouping, whole-list-or-nothing, later set replaces, boolean `$ne` writes nothing, prototype sweep over the new lookups, chips, prefix delete of a set, equality-only route still executes none. - `exactRecordListFilter-25888.test.ts`: the new accepted spellings round-trip; each equals the drawer node; numbers in a set; a set beside an equality; a repeated set written once; new refusals (empty set, empty member, boolean member, two different sets, boolean `$ne`, text operator with a number, `$between`). - `useOpenRecordList.placeholderScope-9022.test.tsx`: through the real host, an `$in` scope and a boolean bucket open the exact URL; a boolean `$ne` still opens nothing. - `ObjectDataPage.saveAsViewFilterFold.test.ts`: every new operator folds to a canonical rule and the `ViewItemSchema` gate passes; the operator sets are computed from the exported maps. - `ObjectDataPage.filterChipI18n-9159.test.tsx`: on the real page with the real packs, `[in]` / `[nin]` / `[ne]` chips draw the zh label beside the comparand, and the en control differs. Moved pins (intended, each the behaviour this card asks to change): - `exactRecordListFilter-25888`: four refusals (set operator, negation, text operator, boolean comparand) became accepted cases. - `useOpenRecordList.placeholderScope-9022`: "an `$in` scope offers no destination" now opens the exact list. - `ObjectView.urlFilterSuffix-9196`: row 1 used `[contains]` as its unknown suffix; it now uses `[like]`, and a new row pins `[contains]` executing on `/data` only while the object route still drops it. Its non-vacuity battery gains the `/data` contains triple. ## Acceptance notes - A boolean chip reads `= true` / `= false`: the raw comparand, the posture every equality chip already has (an option label is not drawn either). - A set of numbers reads back as the members' text (`['1', '2']`), the posture equality and the range bounds have always had; the exactness check allows exactly that difference and nothing else. - Still refused, by design: `$or` and every other combinator, `$between`, an empty set, a set member that is empty or boolean, `$ne` on a boolean, a text operator with a non-string, two different comparands or sets in one param. - The plain object route (`ObjectView`, equality-only, objectui#9196's boundary) learns none of the new suffixes. - `ui/packages/i18n/src/locales/*.ts` untouched: the chip labels are the filter builder's existing `filterBuilder.operators.*` keys. - Serial: PR #30595 also adds lines to `ObjectDataPage.filterChipI18n-9159.test.tsx`; this PR appends one describe at the end of that file. Whichever lands second merges the other. - NOT MEASURED: a live browser run of the Console; the chip and navigation behaviour is covered through the real `ObjectDataPage` render and the real `useRecordListDrillNavigation` host in the tests above. ## Verification All readings on head `ed322918`. <details><summary>Targeted suites: 21 files, 383 tests, all green</summary> `pnpm exec vitest run --maxWorkers=2` from `ui/` over `drillUrlFilters.test.ts`, `exactRecordListFilter-25888`, both `RelatedRecordActionsBridge` View-all suites (12741, 25888), `useOpenRecordList.placeholderScope-9022`, `ObjectView.urlFilterSuffix-9196`, `ObjectView.filterParamsKeyEncoding-9287`, every `ObjectDataPage.*` suite, every `drill*` suite in app-shell views, `studio-canvas-preview`, and plugin-dashboard's `DatasetWidget` and `ObjectPivotTable.drillFilterComposition-9024`: `Test Files 21 passed (21)`, `Tests 383 passed (383)`. Before the pin moves the same run read `7 failed | 339 passed`, the seven moved pins listed above and nothing else. </details> <details><summary>Type-check: clean</summary> `pnpm --filter @object-ui/app-shell type-check` (both `tsc --noEmit` and `tsc -p tsconfig.test.json`, after building the `@object-ui/app-shell^...` closure): 0 `error TS`, `VERDICT command-exit 0`. </details> <details><summary>Ablation: one mutation, three reds, restored</summary> `node scripts/ablation-replace.mjs` on `drillUrlFilters.ts`, anchor `out.push([field, '=', true])` in the `[bool]` read arm replaced with the string `'true'` (anchor 1 → 0, blob `35873c7f` → `0120ef72`): `drillUrlFilters.test.ts` "reads the `[bool]` slot back as the BOOLEAN the drawer compares with" red, and `exactRecordListFilter-25888` "writes the spellings objectstack#30743 added" plus "reads each of those back as the node the drill drawer queries" red (the gate's comparand-type check refusing the string). `Tests 3 failed | 116 passed`. Restored: blob == HEAD, `git diff HEAD` empty. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands` names 35 families for this change set (10 paths, 642 changed lines, under the 3000 threshold). Running locally at open time; the readings and the `--ran` reconciliation are in the os-dev-report comment on #30743. Every family CI runs is read from this PR's check-runs. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` untouched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is CI's run. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the maintainer brief is the repo's Chinese 速读 block). --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgW9yrPaVhdtD84RWcR)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgW9yrPaVhdtD84RWcR Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9bdeed2 commit f8195bd

10 files changed

Lines changed: 609 additions & 33 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(drill): "Open in list" and a related list's "View all" open the exact list again for a yes/no bucket, an `$in` / `$nin` set, `$ne` and the text operators
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
The object list route (`/apps/APP/OBJECT/data?filter[...]`) gains six spellings, each read back as the very comparison the drill drawer queries, comparand type included:
11+
12+
- `filter[FIELD][bool]=true|false` is equality to the boolean. `filter[FIELD]=true` still means the text "true".
13+
- `filter[FIELD][ne|contains|notcontains|startswith|endswith|icontains]=VALUE` is `$ne` and the five text operators.
14+
- `filter[FIELD][in]=A&filter[FIELD][in]=B` (and `[nin]`) is a set, one repeated param per member.
15+
16+
A drill or "View all" whose filter uses only these and the earlier spellings now offers its list destination; anything the route still cannot say exactly (`$or`, `$between`, an empty set, a boolean under `$ne`) still offers none rather than a wider list. The list page shows each new condition as a chip with the filter builder's own operator label, removes it with the chip, and keeps it through "Save as view".

‎ui/packages/app-shell/src/views/ObjectDataPage.filterChipI18n-9159.test.tsx‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,3 +256,37 @@ describe('the is-empty filter chips are translated (objectui#11547)', () => {
256256
expect(packLabelOf('zh', EMPTY_FILTER.labelKey)).not.toBe(packLabelOf('zh', NULL_FILTER.labelKey));
257257
});
258258
});
259+
260+
/**
261+
* objectstack#30743 — a membership or negation chip draws its operator through
262+
* the filter builder's label and its comparand verbatim, on the real page and
263+
* the real packs: the label half translated, the comparand half not.
264+
*/
265+
describe('the membership and negation chips (objectstack#30743)', () => {
266+
const packLabelOf = (language: string, key: string): unknown =>
267+
key.split('.').reduce<unknown>(
268+
(node, part) => (node as Record<string, unknown> | undefined)?.[part],
269+
(builtInLocales as Record<string, unknown>)[language],
270+
);
271+
272+
it.each([
273+
['in', 'filterBuilder.operators.in'],
274+
['nin', 'filterBuilder.operators.not_in'],
275+
])('`[%s]` renders the zh label of `%s` beside the members, joined', (suffix, key) => {
276+
renderAt('zh', `filter[${FIELD}][${suffix}]=alice&filter[${FIELD}][${suffix}]=bob`);
277+
expect(chipOperatorText()).toBe(`${packLabelOf('zh', key)} alice, bob`);
278+
});
279+
280+
it('`[ne]` renders the zh "does not equal" label beside the comparand', () => {
281+
renderAt('zh', `filter[${FIELD}][ne]=alice`);
282+
const rendered = chipOperatorText();
283+
expect(rendered).toBe(`${packLabelOf('zh', 'filterBuilder.operators.not_equals')} alice`);
284+
expect(rendered).not.toContain('filterBuilder.operators');
285+
});
286+
287+
it('CONTROL: the en pack differs, so the label half really went through `t`', () => {
288+
renderAt('en', `filter[${FIELD}][in]=alice`);
289+
expect(chipOperatorText()).toBe(`${packLabelOf('en', 'filterBuilder.operators.in')} alice`);
290+
expect(packLabelOf('zh', 'filterBuilder.operators.in')).not.toBe(packLabelOf('en', 'filterBuilder.operators.in'));
291+
});
292+
});

‎ui/packages/app-shell/src/views/ObjectDataPage.saveAsViewFilterFold.test.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import {
2929
URL_FILTER_OPS,
3030
NULL_FILTER,
3131
EMPTY_FILTER,
32+
URL_COMPARE_OPS,
33+
URL_LIST_OPS,
3234
parseUrlFilterTriples,
3335
type FilterTriple,
3436
} from './drillUrlFilters';
@@ -103,7 +105,7 @@ describe('Save as view folds URL drill triples to spec rules (objectui#3419)', (
103105
// and the emptiness PAIR, whose one param carries a direction rather than a
104106
// comparand and so is not in that range map) so an operator added to the URL
105107
// contract fails HERE rather than at publish time. `parseUrlFilterTriples`
106-
// emits nothing outside this set.
108+
// emits nothing outside this set and the next test's (objectstack#30743).
107109
//
108110
// ⚠️ `NULL_FILTER.notOp` joined the set in objectui#9508. Listing only
109111
// `op` would have left this pin GREEN while the contract grew an operator
@@ -150,6 +152,33 @@ describe('Save as view folds URL drill triples to spec rules (objectui#3419)', (
150152
expect(gate.success).toBe(true);
151153
});
152154

155+
it('folds the negation, text, membership and boolean triples too (objectstack#30743)', () => {
156+
// Computed from the two maps for the reason the test above gives: an
157+
// operator added to either map fails here, not at publish time.
158+
expect(Object.values(URL_COMPARE_OPS)).toEqual([
159+
'!=', 'contains', 'notcontains', 'startswith', 'endswith', 'icontains',
160+
]);
161+
expect(Object.values(URL_LIST_OPS)).toEqual(['in', 'nin']);
162+
const triples: FilterTriple[] = [
163+
...Object.values(URL_COMPARE_OPS).map((op, i) => ['c' + i, op, 'v' + i] as FilterTriple),
164+
...Object.values(URL_LIST_OPS).map((op, i) => ['l' + i, op, ['a', 'b']] as FilterTriple),
165+
['done', '=', true],
166+
];
167+
const { spec, gate } = saveAsView(triples);
168+
expect(spec.filter).toEqual([
169+
{ field: 'c0', operator: 'not_equals', value: 'v0' },
170+
{ field: 'c1', operator: 'contains', value: 'v1' },
171+
{ field: 'c2', operator: 'not_contains', value: 'v2' },
172+
{ field: 'c3', operator: 'starts_with', value: 'v3' },
173+
{ field: 'c4', operator: 'ends_with', value: 'v4' },
174+
{ field: 'c5', operator: 'icontains', value: 'v5' },
175+
{ field: 'l0', operator: 'in', value: ['a', 'b'] },
176+
{ field: 'l1', operator: 'not_in', value: ['a', 'b'] },
177+
{ field: 'done', operator: 'equals', value: true },
178+
]);
179+
expect(gate.success).toBe(true);
180+
});
181+
153182
it('keeps the is-NOT-null direction intact through the fold too (objectui#9508)', () => {
154183
// A drill that can be WRITTEN into the URL must also be savable, or the
155184
// user reads a chip the saved view then silently does not carry.

‎ui/packages/app-shell/src/views/ObjectDataPage.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
groupFilterChips,
5858
deleteFieldFilterParams,
5959
URL_FILTER_OPS,
60+
URL_TRIPLE_OP_WORD,
6061
type FilterTriple,
6162
} from './drillUrlFilters.js';
6263
import {
@@ -147,6 +148,9 @@ const TRIPLE_OP_TO_SPEC_ALIAS: Record<string, string> = {
147148
...Object.fromEntries(
148149
Object.entries(URL_FILTER_OPS).map(([suffix, symbol]) => [symbol, suffix]),
149150
),
151+
// objectstack#30743 — negation, text and membership triples carry their
152+
// canonical word already, which `normalizeFilterOperator` returns unchanged.
153+
...URL_TRIPLE_OP_WORD,
150154
};
151155

152156
/**
@@ -171,7 +175,7 @@ const TRIPLE_OP_TO_SPEC_ALIAS: Record<string, string> = {
171175
* from the persisted view with a debug-level note, never written off-spec:
172176
* declared = enforced, and a view body that fails the record gate is rejected
173177
* whole at publish time, which would lose the user's other conditions too.
174-
* (`parseUrlFilterTriples` only ever emits the five operators bridged above, so
178+
* (`parseUrlFilterTriples` only ever emits operators bridged above, so
175179
* the drop path is defence in depth against the URL contract growing an
176180
* operator the spec has no word for.)
177181
*/
@@ -675,7 +679,9 @@ export function ObjectDataPage({ dataSource, objects }: any) {
675679
unwatched second English spelling that hides a pack miss
676680
(objectui#3469 deleted exactly that pattern from this console).
677681
*/}
678-
<span className="text-muted-foreground">{textKey ? t(textKey) : text}</span>
682+
<span className="text-muted-foreground">
683+
{textKey && text !== undefined ? `${t(textKey)} ${text}` : textKey ? t(textKey) : text}
684+
</span>
679685
<button
680686
type="button"
681687
onClick={() => removeUrlFilter(field)}

‎ui/packages/app-shell/src/views/ObjectView.urlFilterSuffix-9196.test.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,15 @@ const objectRoute = (qs: string) => parseUrlEqualityFilterTriples(new URLSearchP
7878

7979
describe("objectui#9196 — the card's three rows, both readers, one run", () => {
8080
it('row 1 — an unknown operator suffix is dropped by BOTH readers', () => {
81-
expect(dataSurface('filter[owners][contains]=u1')).toEqual([]);
81+
// `[contains]` was this row's unknown suffix until objectstack#30743 gave
82+
// the `/data` surface a text-operator spelling; `[like]` is still unknown
83+
// to both.
84+
expect(dataSurface('filter[owners][like]=u1')).toEqual([]);
85+
expect(objectRoute('filter[owners][like]=u1')).toEqual([]);
86+
});
87+
88+
it('row 1, after objectstack#30743 — `[contains]` executes on /data ONLY, and is dropped on the object route', () => {
89+
expect(dataSurface('filter[owners][contains]=u1')).toEqual([['owners', 'contains', 'u1']]);
8290
expect(objectRoute('filter[owners][contains]=u1')).toEqual([]);
8391
});
8492

@@ -160,6 +168,7 @@ describe('objectui#9196 — no emitted field name ever carries a bracket', () =>
160168
]);
161169
expect(inputs.flatMap((qs) => dataSurface(qs))).toEqual([
162170
['amount', '>=', '100'],
171+
['owners', 'contains', 'u1'],
163172
['owners', '=', 'u1'],
164173
['account.region', '=', 'NA'],
165174
]);

‎ui/packages/app-shell/src/views/__tests__/exactRecordListFilter-25888.test.ts‎

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import { describe, it, expect } from 'vitest';
1414
import { exactRecordListFilter, recordListPath } from '../useOpenRecordList';
1515
import { parseUrlFilterTriples } from '../drillUrlFilters';
16+
import { convertFiltersToAST } from '@object-ui/core';
1617

1718
const readAll = () => true;
1819
const PARENT = { record_id: 'C-1' };
@@ -69,17 +70,92 @@ describe('exactRecordListFilter — the AND of the sources, written exactly or n
6970
]);
7071
});
7172

73+
it('writes the spellings objectstack#30743 added: a boolean, a negation, the text operators, both sets', () => {
74+
const filter = exactRecordListFilter(
75+
[
76+
PARENT,
77+
[
78+
['done', '=', true],
79+
['archived', '=', false],
80+
['status', 'in', ['paid', 'sent']],
81+
['stage', 'nin', ['lost']],
82+
['owner', '!=', 'u1'],
83+
['name', 'contains', 'acme'],
84+
['name', 'notcontains', 'test'],
85+
['code', 'startswith', 'INV'],
86+
['code', 'endswith', '-X'],
87+
['email', 'icontains', 'ACME'],
88+
],
89+
],
90+
readAll,
91+
);
92+
expect(roundTrip(filter)).toEqual([
93+
['record_id', '=', 'C-1'],
94+
['done', '=', true],
95+
['archived', '=', false],
96+
['status', 'in', ['paid', 'sent']],
97+
['stage', 'nin', ['lost']],
98+
['owner', '!=', 'u1'],
99+
['name', 'contains', 'acme'],
100+
['name', 'notcontains', 'test'],
101+
['code', 'startswith', 'INV'],
102+
['code', 'endswith', '-X'],
103+
['email', 'icontains', 'ACME'],
104+
]);
105+
});
106+
107+
it('reads each of those back as the node the drill drawer queries, comparand type included', () => {
108+
// The drawer lowers the drill's object filter through `convertFiltersToAST`;
109+
// the list lowers nothing, it reads triples. The two must be one question.
110+
const cases: Record<string, unknown>[] = [
111+
{ done: true },
112+
{ done: false },
113+
{ status: { $in: ['paid', 'sent'] } },
114+
{ stage: { $nin: ['lost'] } },
115+
{ owner: { $ne: 'u1' } },
116+
{ name: { $contains: 'acme' } },
117+
{ name: { $notContains: 'test' } },
118+
{ code: { $startsWith: 'INV' } },
119+
{ code: { $endsWith: '-X' } },
120+
{ email: { $icontains: 'ACME' } },
121+
];
122+
for (const condition of cases) {
123+
expect(roundTrip(exactRecordListFilter([condition], readAll))).toEqual([convertFiltersToAST(condition)]);
124+
}
125+
});
126+
127+
it('a set of numbers reads back as its members\' text, the posture equality and the bounds already have', () => {
128+
expect(roundTrip(exactRecordListFilter([{ priority: { $in: [1, 2] } }], readAll))).toEqual([
129+
['priority', 'in', ['1', '2']],
130+
]);
131+
});
132+
133+
it('a set and an equality on one field are two params, both kept', () => {
134+
expect(roundTrip(exactRecordListFilter([{ status: { $in: ['a', 'b'] } }, { status: 'a' }], readAll))).toEqual([
135+
['status', 'in', ['a', 'b']],
136+
['status', '=', 'a'],
137+
]);
138+
});
139+
140+
it('a repeated set that agrees is written once', () => {
141+
const set = { status: { $in: ['a', 'b'] } };
142+
expect(roundTrip(exactRecordListFilter([set, set], readAll))).toEqual([['status', 'in', ['a', 'b']]]);
143+
});
144+
72145
it('a repeated condition that agrees is written once', () => {
73146
const filter = exactRecordListFilter([PARENT, [['record_id', '=', 'C-1']]], readAll);
74147
expect(roundTrip(filter)).toEqual([['record_id', '=', 'C-1']]);
75148
});
76149

77150
it.each([
78151
['an `or`', ['or', ['status', '=', 'a'], ['status', '=', 'b']]],
79-
['a set operator', [['status', 'in', ['a', 'b']]]],
80-
['a negation', [['status', '!=', 'a']]],
81-
['a text operator', [['name', 'contains', 'x']]],
82-
['a boolean comparand', [['done', '=', true]]],
152+
['an empty set', { status: { $in: [] } }],
153+
['a set holding an empty string', { status: { $in: ['a', ''] } }],
154+
['a set holding a boolean', { status: { $in: ['a', true] } }],
155+
['two different sets on one field', { $and: [{ status: { $in: ['a', 'b'] } }, { status: { $in: ['a'] } }] }],
156+
['a negation of a boolean', [['done', '!=', true]]],
157+
['a text operator with a number', { name: { $contains: 5 } }],
158+
['a `$between`', { amount: { $between: [1, 9] } }],
83159
['an empty-string comparand', [['status', '=', '']]],
84160
['a field-reference comparand', { amount: { $eq: { $field: 'budget' } } }],
85161
['a second, different comparand for the relationship', [['record_id', '=', 'C-2']]],

0 commit comments

Comments
 (0)