Skip to content

Commit 0d4941f

Browse files
committed
feat(types): a related list's filter and add-picker filter take a view filter's any-of group
The @object-ui/types mirror of record:related_list typed `filter` and `add.picker.filter` as flat ViewFilterRule[] while the spec carriers take ViewFilterElement (a rule or an `{ or: [...] }` group). Widen the mirror, the RelatedList props it flows into, and the related-actions input's resolved filter together; widening the mirror alone is a TS2322 in record-related-list.tsx. Types only, runtime unchanged. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6bhgX3FadPoTM1W7TqHHf
1 parent 4bc963c commit 0d4941f

8 files changed

Lines changed: 337 additions & 43 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/types': patch
3+
'@object-ui/plugin-detail': patch
4+
'@object-ui/react': patch
5+
---
6+
7+
feat(types): a related list's `filter` and its add-picker `filter` take a view filter's any-of group `{ or: [...] }`, as the spec carriers do
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
`RecordRelatedListComponentProps.filter` and `.add.picker.filter` in `@object-ui/types` are now `ViewFilterElement[]` (a flat rule or an any-of group `{ or: [rule, ...] }`, from `@objectstack/spec/ui`), the element the protocol's `RecordRelatedListProps` already declares for both keys. The props the related-list renderer hands those values to move with them: `RelatedListProps.filter` and `RelatedListProps.add.picker.filter` in `@object-ui/plugin-detail`, and `ResolveRelatedRecordActionsInput.filter` in `@object-ui/react`. Every value these types accepted before is still accepted; a nested group or a group of tuples is still refused by `tsc`, as the protocol's parse refuses it.
13+
14+
Types only: no runtime code changes. The related list and its picker lower the group through `@object-ui/core`'s `toFilterNode`, which the console reader change for the any-of group teaches to read it.

‎ui/packages/plugin-detail/src/RelatedList.tsx‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import {
4949
} from 'lucide-react';
5050
import type { LucideIcon } from 'lucide-react';
5151
import type { DataSource, FieldMetadata } from '@object-ui/types';
52-
import type { ViewFilterRule } from '@objectstack/spec/ui';
52+
import type { ViewFilterElement } from '@objectstack/spec/ui';
5353
import { getCellRenderer, resolveCellRendererType, RecordPickerDialog, deriveLookupColumns, MaskedCellRenderer, RecordHoverCard } from '@object-ui/fields';
5454
import {
5555
buildExpandFields,
@@ -142,16 +142,19 @@ export interface RelatedListProps {
142142
* insert (e.g. the AI-seat cap) surface as an inline error.
143143
*
144144
* `picker.filter` restricts which records the dialog offers, and is typed as
145-
* the spec's own `ViewFilterRule[]` rather than `any` (#3831): it goes to
145+
* the spec's own `ViewFilterElement[]` rather than `any` (#3831): it goes to
146146
* `RecordPickerDialog`'s `baseFilter` VERBATIM, so the authored vocabulary is
147147
* the one enforced — a looser type here is where a wrong shape would hide.
148+
* The element is a flat rule or an any-of group `{ or: [rule, ...] }`
149+
* (objectstack#28020, #28462); the dialog lowers both through
150+
* `toFilterNodeSafely`.
148151
*/
149152
add?: {
150153
picker: {
151154
object: string;
152155
valueField?: string;
153156
labelField?: string;
154-
filter?: ViewFilterRule[];
157+
filter?: ViewFilterElement[];
155158
};
156159
linkField?: string;
157160
label?: string;
@@ -255,13 +258,14 @@ export interface RelatedListProps {
255258
* for the list's own scope).
256259
*
257260
* Two shapes arrive, both produced by our own layers: the spec vocabulary
258-
* (`ViewFilterRule[]`) as authored, and an ObjectQL AST node as composed by
261+
* (`ViewFilterElement[]`: flat rules and any-of `{ or }` groups,
262+
* objectstack#28462) as authored, and an ObjectQL AST node as composed by
259263
* `ElementDataSourceGate` (which ANDs component/view/binding filters through
260264
* `mergeFilterNodes` before this component ever sees them). Both are lowered
261265
* here through that same single sink — the repo's one filter→wire exit — so no
262266
* second conversion dialect appears.
263267
*/
264-
filter?: ViewFilterRule[] | FilterNode;
268+
filter?: ViewFilterElement[] | FilterNode;
265269
/**
266270
* Enable text filtering. The box keeps a row when a column the list shows
267271
* contains the term; a masked column (a `password` / `secret` field, or any
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#28462 — the props `record:related_list` hands its `filter` and
11+
* `add.picker.filter` to take the contract's filter-list element (a flat rule
12+
* or an any-of group `{ or: [rule, ...] }`), as the `@object-ui/types` face
13+
* does, so the renderer passes the authored value through without a cast.
14+
*
15+
* Three declarations carry the value on its way to the wire and must move
16+
* together: `RecordRelatedListComponentProps` (the authoring face),
17+
* `RelatedListProps` here, and `@object-ui/react`'s
18+
* `ResolveRelatedRecordActionsInput.filter` (the resolved list filter the
19+
* renderer hands the host for "View all"). Widening the face alone is a TS2322
20+
* in `renderers/record-related-list.tsx` (measured on `main` `4bc963ca`).
21+
*
22+
* Type-level only: compiled by `tsc -p tsconfig.test.json` (this package's
23+
* `type-check`), erased by vitest; the `it` below only keeps vitest from
24+
* reporting an empty file. Lowering the group to an `or` node is
25+
* `@object-ui/core`'s `toFilterNode` (objectstack#28370), not pinned here.
26+
*/
27+
28+
import { describe, it, expect } from 'vitest';
29+
import type { ViewFilterElement } from '@objectstack/spec/ui';
30+
import type { RecordRelatedListComponentProps } from '@object-ui/types';
31+
import type { FilterNode } from '@object-ui/core';
32+
import type { ResolveRelatedRecordActionsInput } from '@object-ui/react';
33+
import type { RelatedListProps } from '../RelatedList';
34+
35+
/** Invariant type equality. `A extends B` is NOT this: `never` and `any` pass that. */
36+
type Equal<A, B> =
37+
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
38+
type Expect<T extends true> = T;
39+
40+
// @ts-expect-error objectstack#28462 — `Expect` must refuse `false`, or every leg below is vacuous.
41+
type _ExpectRefusesFalse = Expect<false>;
42+
43+
/** The list's own scope: the element array as authored, or the AST node the data-source gate composes. */
44+
export type _ListFilter = Expect<Equal<RelatedListProps['filter'], ViewFilterElement[] | FilterNode | undefined>>;
45+
46+
/** The picker's filter: the element array, passed to `RecordPickerDialog`'s `baseFilter` verbatim. */
47+
export type _PickerFilter = Expect<
48+
Equal<NonNullable<RelatedListProps['add']>['picker']['filter'], ViewFilterElement[] | undefined>
49+
>;
50+
51+
/** The resolved list filter the renderer hands the host carries the same elements. */
52+
export type _ActionsInputFilter = Expect<
53+
Equal<ResolveRelatedRecordActionsInput['filter'], ViewFilterElement[] | FilterNode | undefined>
54+
>;
55+
56+
/** The authoring face flows into all three with no cast — what the renderer does. */
57+
export function _flowsWithoutCast(authored: RecordRelatedListComponentProps): unknown[] {
58+
const toList: RelatedListProps['filter'] = authored.filter;
59+
const toPicker: NonNullable<RelatedListProps['add']>['picker']['filter'] = authored.add?.picker.filter;
60+
const toHost: ResolveRelatedRecordActionsInput['filter'] = authored.filter;
61+
return [toList, toPicker, toHost];
62+
}
63+
64+
/** The card's example compiles on the component's own prop. */
65+
export const _group: RelatedListProps['filter'] = [
66+
{
67+
or: [
68+
{ field: 'status', operator: 'equals', value: 'open' },
69+
{ field: 'completed_at', operator: 'greater_than', value: '{7_days_ago}' },
70+
],
71+
},
72+
];
73+
74+
describe('objectstack#28462 — RelatedList filter props take the { or } group', () => {
75+
it('is a type-level pin (see the header); the module loaded', () => {
76+
expect(_group).toHaveLength(1);
77+
});
78+
});

‎ui/packages/react/src/context/RelatedRecordActionsContext.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939

4040
import React, { createContext, useContext } from 'react';
4141
import type { FilterNode } from '@object-ui/core';
42-
import type { ViewFilterRule } from '@objectstack/spec/ui';
42+
import type { ViewFilterElement } from '@objectstack/spec/ui';
4343

4444
/** A child-object action surfaced in a related list's row menu. */
4545
export interface RelatedRowActionDef {
@@ -171,8 +171,11 @@ export interface ResolveRelatedRecordActionsInput {
171171
* ({@link RelatedRecordHandlers.onViewAll}): it scopes that destination by
172172
* this filter too, or offers none. Absent means the list has no filter of
173173
* its own; it does not mean "unknown".
174+
*
175+
* Its elements are the list's: flat rules or any-of `{ or }` groups
176+
* (objectstack#28462).
174177
*/
175-
filter?: ViewFilterRule[] | FilterNode;
178+
filter?: ViewFilterElement[] | FilterNode;
176179
}
177180

178181
export interface RelatedRecordActionsValue {

‎ui/packages/types/src/__tests__/related-list-add-picker-filter-mirror-9964.test.ts‎

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ import { fileURLToPath } from 'node:url';
6464
import { dirname, join } from 'node:path';
6565
import type { z } from 'zod';
6666
import { RecordRelatedListProps } from '@objectstack/spec/ui';
67-
import type { ViewFilterOrGroup, ViewFilterRule } from '@objectstack/spec/ui';
67+
import type { ViewFilterElement, ViewFilterOrGroup, ViewFilterRule } from '@objectstack/spec/ui';
6868
import type { RecordRelatedListComponentProps } from '../record-components';
6969

7070
/* ── Type-level legs (compiled by `tsc -p tsconfig.test.json`, erased by vitest) ── */
@@ -95,26 +95,21 @@ type SpecPickerFilter = NonNullable<z.input<typeof RecordRelatedListProps>['add'
9595
* expectation comes from the protocol rather than from this file.
9696
*/
9797
/**
98-
* objectstack#28020: the contract's element is now a flat rule OR an any-of
99-
* group (`{ or: [rule, ...] }`), and the group is `[PLANNED — not yet
100-
* rendered]` there: the related list's own reader (`@object-ui/plugin-detail`)
101-
* types and lowers flat rules only. So this face carries the contract MINUS
102-
* the group until the console reads it (objectstack#28370), and the two legs
103-
* below hold exactly that gap — widen this face and the second leg's
104-
* companion together, never one alone.
98+
* objectstack#28020 put an any-of group (`{ or: [rule, ...] }`) beside the flat
99+
* rule in the contract's element, and objectstack#28462 widened this face to
100+
* carry it, so the face is the contract again — no gap is withheld.
105101
*/
106-
type FlatRulesOf<F extends readonly unknown[] | undefined> = Exclude<NonNullable<F>[number], ViewFilterOrGroup>[] | undefined;
107-
export type _MirrorAgreesWithTheContract = Expect<Equal<MirrorPickerFilter, FlatRulesOf<SpecPickerFilter>>>;
108-
/** …and the contract does carry the group the face withholds — so the gap is the group and nothing else. */
109-
export type _TheContractAlsoTakesTheGroup = Expect<Equal<NonNullable<SpecPickerFilter>[number], ViewFilterRule | ViewFilterOrGroup>>;
102+
export type _MirrorAgreesWithTheContract = Expect<Equal<MirrorPickerFilter, SpecPickerFilter>>;
103+
/** …and the contract's element is exactly the rule or the group, so the pair cannot agree on something looser. */
104+
export type _TheContractTakesRuleOrGroup = Expect<Equal<NonNullable<SpecPickerFilter>[number], ViewFilterRule | ViewFilterOrGroup>>;
110105

111106
/**
112-
* …and the shape they agree on is the rule ARRAY. Stated separately from the
107+
* …and the shape they agree on is the element ARRAY (rule or `{ or }` group). Stated separately from the
113108
* derivation above so a spec that loosened its own key could not make the pair
114109
* agree on nothing — that would satisfy the leg above while putting `unknown`
115110
* back on the authoring face.
116111
*/
117-
export type _AndThatShapeIsTheRuleArray = Expect<Equal<MirrorPickerFilter, ViewFilterRule[] | undefined>>;
112+
export type _AndThatShapeIsTheRuleArray = Expect<Equal<MirrorPickerFilter, ViewFilterElement[] | undefined>>;
118113

119114
// @ts-expect-error objectui#9964 — the PRE-FIX declaration, asserted to be WRONG: `unknown` must not satisfy this pin. This directive going unused (TS2578) means the mirror went back to `unknown`.
120115
type _UnknownIsNotTheContract = Expect<Equal<MirrorPickerFilter, unknown>>;
@@ -139,9 +134,11 @@ const CASES: readonly Case[] = [
139134
// The consumer's own prop type, satisfied straight from the authoring face.
140135
// This is the TS2322 the four `(schema as any).add` casts were hiding.
141136
{
142-
what: 'the declared filter is assignable to the consumer’s `ViewFilterRule[]`',
137+
// objectstack#28462 moved the consumer's prop to the contract's element
138+
// (`ViewFilterElement[]`, rule or `{ or }` group) together with this face.
139+
what: 'the declared filter is assignable to the consumer’s `ViewFilterElement[]`',
143140
rejected: false,
144-
code: `const rules: ViewFilterRule[] | undefined = block.add?.picker.filter; void rules;`,
141+
code: `const rules: ViewFilterElement[] | undefined = block.add?.picker.filter; void rules;`,
145142
},
146143
// ── What the tightened face must now REFUSE ───────────────────────────────
147144
{
@@ -223,7 +220,7 @@ function erroringLines(preamble: string): Set<number> {
223220

224221
const IMPORTS =
225222
`import type { RecordRelatedListComponentProps } from '${MIRROR_IMPORT}';\n` +
226-
`import type { ViewFilterRule } from '@objectstack/spec/ui';\n`;
223+
`import type { ViewFilterElement, ViewFilterRule } from '@objectstack/spec/ui';\n`;
227224

228225
/** The real mirror, under the name the cases annotate against. */
229226
const REAL_PREAMBLE =
@@ -270,7 +267,7 @@ describe('objectui#9964 — the mirror declares the contract’s rule array', ()
270267
* and prove nothing about this change on their own.
271268
*
272269
* 0 — the assignability the four `as any` casts were hiding: TS2322 with
273-
* `unknown`, clean with the rule array.
270+
* `unknown`, clean with the element array.
274271
* 1,2,3 — the three wrong shapes: `unknown` accepts all of them.
275272
* 4 — the canonical form: accepted on both legs (the narrowing is not too
276273
* narrow, which is the half a rejection-only pin cannot state).

0 commit comments

Comments
 (0)