Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions .changeset/30743-list-url-boolean-in-ne-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/app-shell': patch
---

fix(drill): "Open in list" and a related list's "View all" open the exact list again for a yes/no bucket, an `$in` / `$nin` set, `$ne` and the text operators

Clause-②: yes (widening)
Metadata-protocol: none

The object list route (`/apps/APP/OBJECT/data?filter[...]`) gains six spellings, each read back as the very comparison the drill drawer queries, comparand type included:

- `filter[FIELD][bool]=true|false` is equality to the boolean. `filter[FIELD]=true` still means the text "true".
- `filter[FIELD][ne|contains|notcontains|startswith|endswith|icontains]=VALUE` is `$ne` and the five text operators.
- `filter[FIELD][in]=A&filter[FIELD][in]=B` (and `[nin]`) is a set, one repeated param per member.

A drill or "View all" whose filter uses only these and the earlier spellings now offers its list destination; anything the route still cannot say exactly (`$or`, `$between`, an empty set, a boolean under `$ne`) still offers none rather than a wider list. The list page shows each new condition as a chip with the filter builder's own operator label, removes it with the chip, and keeps it through "Save as view".
Original file line number Diff line number Diff line change
Expand Up @@ -256,3 +256,37 @@ describe('the is-empty filter chips are translated (objectui#11547)', () => {
expect(packLabelOf('zh', EMPTY_FILTER.labelKey)).not.toBe(packLabelOf('zh', NULL_FILTER.labelKey));
});
});

/**
* objectstack#30743 — a membership or negation chip draws its operator through
* the filter builder's label and its comparand verbatim, on the real page and
* the real packs: the label half translated, the comparand half not.
*/
describe('the membership and negation chips (objectstack#30743)', () => {
const packLabelOf = (language: string, key: string): unknown =>
key.split('.').reduce<unknown>(
(node, part) => (node as Record<string, unknown> | undefined)?.[part],
(builtInLocales as Record<string, unknown>)[language],
);

it.each([
['in', 'filterBuilder.operators.in'],
['nin', 'filterBuilder.operators.not_in'],
])('`[%s]` renders the zh label of `%s` beside the members, joined', (suffix, key) => {
renderAt('zh', `filter[${FIELD}][${suffix}]=alice&filter[${FIELD}][${suffix}]=bob`);
expect(chipOperatorText()).toBe(`${packLabelOf('zh', key)} alice, bob`);
});

it('`[ne]` renders the zh "does not equal" label beside the comparand', () => {
renderAt('zh', `filter[${FIELD}][ne]=alice`);
const rendered = chipOperatorText();
expect(rendered).toBe(`${packLabelOf('zh', 'filterBuilder.operators.not_equals')} alice`);
expect(rendered).not.toContain('filterBuilder.operators');
});

it('CONTROL: the en pack differs, so the label half really went through `t`', () => {
renderAt('en', `filter[${FIELD}][in]=alice`);
expect(chipOperatorText()).toBe(`${packLabelOf('en', 'filterBuilder.operators.in')} alice`);
expect(packLabelOf('zh', 'filterBuilder.operators.in')).not.toBe(packLabelOf('en', 'filterBuilder.operators.in'));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
URL_FILTER_OPS,
NULL_FILTER,
EMPTY_FILTER,
URL_COMPARE_OPS,
URL_LIST_OPS,
parseUrlFilterTriples,
type FilterTriple,
} from './drillUrlFilters';
Expand Down Expand Up @@ -103,7 +105,7 @@ describe('Save as view folds URL drill triples to spec rules (objectui#3419)', (
// and the emptiness PAIR, whose one param carries a direction rather than a
// comparand and so is not in that range map) so an operator added to the URL
// contract fails HERE rather than at publish time. `parseUrlFilterTriples`
// emits nothing outside this set.
// emits nothing outside this set and the next test's (objectstack#30743).
//
// ⚠️ `NULL_FILTER.notOp` joined the set in objectui#9508. Listing only
// `op` would have left this pin GREEN while the contract grew an operator
Expand Down Expand Up @@ -150,6 +152,33 @@ describe('Save as view folds URL drill triples to spec rules (objectui#3419)', (
expect(gate.success).toBe(true);
});

it('folds the negation, text, membership and boolean triples too (objectstack#30743)', () => {
// Computed from the two maps for the reason the test above gives: an
// operator added to either map fails here, not at publish time.
expect(Object.values(URL_COMPARE_OPS)).toEqual([
'!=', 'contains', 'notcontains', 'startswith', 'endswith', 'icontains',
]);
expect(Object.values(URL_LIST_OPS)).toEqual(['in', 'nin']);
const triples: FilterTriple[] = [
...Object.values(URL_COMPARE_OPS).map((op, i) => ['c' + i, op, 'v' + i] as FilterTriple),
...Object.values(URL_LIST_OPS).map((op, i) => ['l' + i, op, ['a', 'b']] as FilterTriple),
['done', '=', true],
];
const { spec, gate } = saveAsView(triples);
expect(spec.filter).toEqual([
{ field: 'c0', operator: 'not_equals', value: 'v0' },
{ field: 'c1', operator: 'contains', value: 'v1' },
{ field: 'c2', operator: 'not_contains', value: 'v2' },
{ field: 'c3', operator: 'starts_with', value: 'v3' },
{ field: 'c4', operator: 'ends_with', value: 'v4' },
{ field: 'c5', operator: 'icontains', value: 'v5' },
{ field: 'l0', operator: 'in', value: ['a', 'b'] },
{ field: 'l1', operator: 'not_in', value: ['a', 'b'] },
{ field: 'done', operator: 'equals', value: true },
]);
expect(gate.success).toBe(true);
});

it('keeps the is-NOT-null direction intact through the fold too (objectui#9508)', () => {
// A drill that can be WRITTEN into the URL must also be savable, or the
// user reads a chip the saved view then silently does not carry.
Expand Down
10 changes: 8 additions & 2 deletions ui/packages/app-shell/src/views/ObjectDataPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
groupFilterChips,
deleteFieldFilterParams,
URL_FILTER_OPS,
URL_TRIPLE_OP_WORD,
type FilterTriple,
} from './drillUrlFilters.js';
import {
Expand Down Expand Up @@ -147,6 +148,9 @@ const TRIPLE_OP_TO_SPEC_ALIAS: Record<string, string> = {
...Object.fromEntries(
Object.entries(URL_FILTER_OPS).map(([suffix, symbol]) => [symbol, suffix]),
),
// objectstack#30743 — negation, text and membership triples carry their
// canonical word already, which `normalizeFilterOperator` returns unchanged.
...URL_TRIPLE_OP_WORD,
};

/**
Expand All @@ -171,7 +175,7 @@ const TRIPLE_OP_TO_SPEC_ALIAS: Record<string, string> = {
* from the persisted view with a debug-level note, never written off-spec:
* declared = enforced, and a view body that fails the record gate is rejected
* whole at publish time, which would lose the user's other conditions too.
* (`parseUrlFilterTriples` only ever emits the five operators bridged above, so
* (`parseUrlFilterTriples` only ever emits operators bridged above, so
* the drop path is defence in depth against the URL contract growing an
* operator the spec has no word for.)
*/
Expand Down Expand Up @@ -675,7 +679,9 @@ export function ObjectDataPage({ dataSource, objects }: any) {
unwatched second English spelling that hides a pack miss
(objectui#3469 deleted exactly that pattern from this console).
*/}
<span className="text-muted-foreground">{textKey ? t(textKey) : text}</span>
<span className="text-muted-foreground">
{textKey && text !== undefined ? `${t(textKey)} ${text}` : textKey ? t(textKey) : text}
</span>
<button
type="button"
onClick={() => removeUrlFilter(field)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,15 @@ const objectRoute = (qs: string) => parseUrlEqualityFilterTriples(new URLSearchP

describe("objectui#9196 — the card's three rows, both readers, one run", () => {
it('row 1 — an unknown operator suffix is dropped by BOTH readers', () => {
expect(dataSurface('filter[owners][contains]=u1')).toEqual([]);
// `[contains]` was this row's unknown suffix until objectstack#30743 gave
// the `/data` surface a text-operator spelling; `[like]` is still unknown
// to both.
expect(dataSurface('filter[owners][like]=u1')).toEqual([]);
expect(objectRoute('filter[owners][like]=u1')).toEqual([]);
});

it('row 1, after objectstack#30743 — `[contains]` executes on /data ONLY, and is dropped on the object route', () => {
expect(dataSurface('filter[owners][contains]=u1')).toEqual([['owners', 'contains', 'u1']]);
expect(objectRoute('filter[owners][contains]=u1')).toEqual([]);
});

Expand Down Expand Up @@ -160,6 +168,7 @@ describe('objectui#9196 — no emitted field name ever carries a bracket', () =>
]);
expect(inputs.flatMap((qs) => dataSurface(qs))).toEqual([
['amount', '>=', '100'],
['owners', 'contains', 'u1'],
['owners', '=', 'u1'],
['account.region', '=', 'NA'],
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { describe, it, expect } from 'vitest';
import { exactRecordListFilter, recordListPath } from '../useOpenRecordList';
import { parseUrlFilterTriples } from '../drillUrlFilters';
import { convertFiltersToAST } from '@object-ui/core';

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

it('writes the spellings objectstack#30743 added: a boolean, a negation, the text operators, both sets', () => {
const filter = exactRecordListFilter(
[
PARENT,
[
['done', '=', true],
['archived', '=', false],
['status', 'in', ['paid', 'sent']],
['stage', 'nin', ['lost']],
['owner', '!=', 'u1'],
['name', 'contains', 'acme'],
['name', 'notcontains', 'test'],
['code', 'startswith', 'INV'],
['code', 'endswith', '-X'],
['email', 'icontains', 'ACME'],
],
],
readAll,
);
expect(roundTrip(filter)).toEqual([
['record_id', '=', 'C-1'],
['done', '=', true],
['archived', '=', false],
['status', 'in', ['paid', 'sent']],
['stage', 'nin', ['lost']],
['owner', '!=', 'u1'],
['name', 'contains', 'acme'],
['name', 'notcontains', 'test'],
['code', 'startswith', 'INV'],
['code', 'endswith', '-X'],
['email', 'icontains', 'ACME'],
]);
});

it('reads each of those back as the node the drill drawer queries, comparand type included', () => {
// The drawer lowers the drill's object filter through `convertFiltersToAST`;
// the list lowers nothing, it reads triples. The two must be one question.
const cases: Record<string, unknown>[] = [
{ done: true },
{ done: false },
{ status: { $in: ['paid', 'sent'] } },
{ stage: { $nin: ['lost'] } },
{ owner: { $ne: 'u1' } },
{ name: { $contains: 'acme' } },
{ name: { $notContains: 'test' } },
{ code: { $startsWith: 'INV' } },
{ code: { $endsWith: '-X' } },
{ email: { $icontains: 'ACME' } },
];
for (const condition of cases) {
expect(roundTrip(exactRecordListFilter([condition], readAll))).toEqual([convertFiltersToAST(condition)]);
}
});

it('a set of numbers reads back as its members\' text, the posture equality and the bounds already have', () => {
expect(roundTrip(exactRecordListFilter([{ priority: { $in: [1, 2] } }], readAll))).toEqual([
['priority', 'in', ['1', '2']],
]);
});

it('a set and an equality on one field are two params, both kept', () => {
expect(roundTrip(exactRecordListFilter([{ status: { $in: ['a', 'b'] } }, { status: 'a' }], readAll))).toEqual([
['status', 'in', ['a', 'b']],
['status', '=', 'a'],
]);
});

it('a repeated set that agrees is written once', () => {
const set = { status: { $in: ['a', 'b'] } };
expect(roundTrip(exactRecordListFilter([set, set], readAll))).toEqual([['status', 'in', ['a', 'b']]]);
});

it('a repeated condition that agrees is written once', () => {
const filter = exactRecordListFilter([PARENT, [['record_id', '=', 'C-1']]], readAll);
expect(roundTrip(filter)).toEqual([['record_id', '=', 'C-1']]);
});

it.each([
['an `or`', ['or', ['status', '=', 'a'], ['status', '=', 'b']]],
['a set operator', [['status', 'in', ['a', 'b']]]],
['a negation', [['status', '!=', 'a']]],
['a text operator', [['name', 'contains', 'x']]],
['a boolean comparand', [['done', '=', true]]],
['an empty set', { status: { $in: [] } }],
['a set holding an empty string', { status: { $in: ['a', ''] } }],
['a set holding a boolean', { status: { $in: ['a', true] } }],
['two different sets on one field', { $and: [{ status: { $in: ['a', 'b'] } }, { status: { $in: ['a'] } }] }],
['a negation of a boolean', [['done', '!=', true]]],
['a text operator with a number', { name: { $contains: 5 } }],
['a `$between`', { amount: { $between: [1, 9] } }],
['an empty-string comparand', [['status', '=', '']]],
['a field-reference comparand', { amount: { $eq: { $field: 'budget' } } }],
['a second, different comparand for the relationship', [['record_id', '=', 'C-2']]],
Expand Down
Loading
Loading