Skip to content

Commit 82b29fe

Browse files
feat(types): a related list's filter and add-picker filter take a view filter's any-of group (#28466)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuRvUhSZyCbMsUouh25Dmw3S)_ Fixes #28462 Clause-②: yes (widening) Metadata-protocol: none Skills-docs: none (a TypeScript face widens to what the spec carriers already declare; no authorable key, value or skill-taught shape moves) **Landing order: lands after the PR for #28370** (the console reader that teaches `@object-ui/core`'s `toFilterNode` the `{ or }` group). Until then a typed author could write a group the related list does not yet lower. Recorded on the card as claim amendment 6548122377. ## 维护者速读 - 改了什么:记录页相关列表的 `filter` 和"添加"选择器的 `filter`,在 `@object-ui/types`、`@object-ui/plugin-detail` 的 `RelatedList` 属性、`@object-ui/react` 的相关操作输入三处,类型从"只收普通规则"放宽为"普通规则或 `{ or: [...] }` 任一组"。 - 为什么改:spec 自 #28020 起这两个键就收任一组,界面这一侧的类型还只收普通规则,TS 作者写不出 spec 认可的组。 - 风险与回滚:只改类型,不改运行时代码;回滚即 revert 本 PR。唯一风险是落地顺序:要排在 #28370 的 PR 之后。 - 先看:`ui/packages/types/src/record-components.ts`(两个字段)、`ui/packages/plugin-detail/src/RelatedList.tsx`(两条 prop)、`ui/packages/types/src/__tests__/related-list-filter-or-group-28462.test.ts`。 - 两分钟验证:`cd ui && pnpm exec vitest run packages/types/src/__tests__/related-list-filter-or-group-28462.test.ts`,预期 18 个用例全过。 - 需要你做的:无,席位落地。 ## What changes - `RecordRelatedListComponentProps.filter` and `.add.picker.filter` (`@object-ui/types`): `ViewFilterRule[]` → `ViewFilterElement[]` from `@objectstack/spec/ui`, the element `RecordRelatedListProps` declares for both keys. - `RelatedListProps.filter` (`ViewFilterElement[] | FilterNode`) and `RelatedListProps.add.picker.filter` (`ViewFilterElement[]`) in `@object-ui/plugin-detail`. - `ResolveRelatedRecordActionsInput.filter` in `@object-ui/react` (`ViewFilterElement[] | FilterNode`): `record-related-list.tsx` hands it the resolved list filter. Added to the file surface by claim amendment 6548186999. Its one implementer, app-shell's `RelatedRecordActionsBridge`, passes it to `exactRecordListFilter(sources: readonly unknown[])`, so nothing there changes. - The sibling pins `related-list-filter-rule-array-10199` and `related-list-add-picker-filter-mirror-9964` held the face at "the contract minus the group" until the console read it. They now hold the face **equal** to the contract. - No runtime code changes. No zod mirror in `@object-ui/types` carries these keys (the `record:related_list` node is spec-owned by reference in `zod-mirror-parity.test.ts`), so no zod twin or parity row moves. ## Tests - New `ui/packages/types/src/__tests__/related-list-filter-or-group-28462.test.ts`. For each key (`filter`, `add.picker.filter`) it compiles authored values against the real mirror source: a group alone and a group beside a rule are accepted; a nested group, a group of tuples and `{ any: [...] }` are refused; plus two controls. Every compiler verdict is compared with the protocol's own parse. A second leg compiles against the mirror with that one key put back to `ViewFilterRule[]` and must flip exactly the two accepted groups. Type-level legs include `@ts-expect-error` for a nested group, and a reader that reads `.field` without narrowing. - New `ui/packages/plugin-detail/src/__tests__/RelatedList.filterOrGroup-28462.test.ts`: type-level `Equal` legs for the two `RelatedList` props and the react input, and the authoring face flowing into all three without a cast. ## Acceptance notes - The card's "lowers to an `or` node beside the parent scope" assertion is not in this PR. The lowering is #28370's `toFilterNode` change, and on `main` `4bc963ca` `toFilterNode` passes a group element through unlowered. Agreed with the seat: types only here, and this PR lands after #28370's PR. - Branch is the harness-assigned `claude/project-thread-l8kwol` (not `claude/issue-28462-*`), as named in the claim. ## Verification <details><summary>Premise on main 4bc963c: widening only the mirror reds Type Check UI</summary> `pnpm --filter @object-ui/plugin-detail type-check` → exit 2, three TS2322 in `src/renderers/record-related-list.tsx`: L308 (`filter: resolvedListFilter` → `ResolveRelatedRecordActionsInput.filter`), L693 (`filter={schema.filter}`), L695 (`add={…}` → `picker.filter`). </details> <details><summary>Type-checks and tests (head fa7ade4)</summary> - `pnpm --filter @object-ui/types type-check`, `@object-ui/react type-check`, `@object-ui/plugin-detail type-check`: all exit 0 (each runs `tsc -p tsconfig.test.json`, so the new type legs are compiled). - Downstream: `pnpm exec turbo run build --filter='@object-ui/app-shell^...'` then `pnpm --filter @object-ui/app-shell type-check`: VERDICT command-exit 0. - `cd ui && pnpm exec vitest run packages/types/ …RelatedList.filterOrGroup-28462… RelatedList.listFilter`: 388 files, 10461 tests passed. - `cd ui && pnpm exec vitest run packages/plugin-detail/ packages/react/src/context/`: 371 files passed (1 skipped), 3325 tests passed (18 skipped). - eslint on the 7 changed source and test files: 0 errors (102 warnings, all pre-existing lines; none in the new files). </details> <details><summary>Ablation (fix committed first; restore leg blob == HEAD)</summary> 1. `RelatedList.tsx` picker prop back to `ViewFilterRule[]` (via `scripts/ablation-replace.mjs`, anchor 1 → 0): `pnpm --filter @object-ui/plugin-detail type-check` exit 2, TS2322 at `record-related-list.tsx(695,9)`. Restored: blob `a60e4b47` == HEAD blob, `git diff HEAD` empty. 2. `record-components.ts` `add.picker.filter` back to `ViewFilterRule[]`: the new types test → 4 failed / 14 passed (both accepted-group cases, the parse-agreement case and the flip case on the picker key). Restored: blob `d57883f6` == HEAD blob. </details> <details><summary>Derived gates (dispatch-gates --commands at fa7ade4)</summary> 34 commands run, every one exit 0 (incl. `check-sdui-manifest.mjs` and its `--self-test`, `check:nul-bytes`, `check:published-files`, `check:type-check-coverage`, `check:type-check-debt`, `check:undeclared-dep-imports`). `--ran` reconcile: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. Type Check UI and the other type-check lanes are CI's. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgX3FadPoTM1W7TqHHf)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgX3FadPoTM1W7TqHHf Co-authored-by: Claude <noreply@anthropic.com>
1 parent 768ec4b commit 82b29fe

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)