diff --git a/.changeset/28462-related-list-filter-or-group.md b/.changeset/28462-related-list-filter-or-group.md new file mode 100644 index 00000000000..d9b105fc95b --- /dev/null +++ b/.changeset/28462-related-list-filter-or-group.md @@ -0,0 +1,14 @@ +--- +'@object-ui/types': patch +'@object-ui/plugin-detail': patch +'@object-ui/react': patch +--- + +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 + +Clause-②: yes (widening) +Metadata-protocol: none + +`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. + +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. diff --git a/ui/packages/plugin-detail/src/RelatedList.tsx b/ui/packages/plugin-detail/src/RelatedList.tsx index 1c0ebbcc86c..a60e4b47e8a 100644 --- a/ui/packages/plugin-detail/src/RelatedList.tsx +++ b/ui/packages/plugin-detail/src/RelatedList.tsx @@ -49,7 +49,7 @@ import { } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import type { DataSource, FieldMetadata } from '@object-ui/types'; -import type { ViewFilterRule } from '@objectstack/spec/ui'; +import type { ViewFilterElement } from '@objectstack/spec/ui'; import { getCellRenderer, resolveCellRendererType, RecordPickerDialog, deriveLookupColumns, MaskedCellRenderer, RecordHoverCard } from '@object-ui/fields'; import { buildExpandFields, @@ -142,16 +142,19 @@ export interface RelatedListProps { * insert (e.g. the AI-seat cap) surface as an inline error. * * `picker.filter` restricts which records the dialog offers, and is typed as - * the spec's own `ViewFilterRule[]` rather than `any` (#3831): it goes to + * the spec's own `ViewFilterElement[]` rather than `any` (#3831): it goes to * `RecordPickerDialog`'s `baseFilter` VERBATIM, so the authored vocabulary is * the one enforced — a looser type here is where a wrong shape would hide. + * The element is a flat rule or an any-of group `{ or: [rule, ...] }` + * (objectstack#28020, #28462); the dialog lowers both through + * `toFilterNodeSafely`. */ add?: { picker: { object: string; valueField?: string; labelField?: string; - filter?: ViewFilterRule[]; + filter?: ViewFilterElement[]; }; linkField?: string; label?: string; @@ -255,13 +258,14 @@ export interface RelatedListProps { * for the list's own scope). * * Two shapes arrive, both produced by our own layers: the spec vocabulary - * (`ViewFilterRule[]`) as authored, and an ObjectQL AST node as composed by + * (`ViewFilterElement[]`: flat rules and any-of `{ or }` groups, + * objectstack#28462) as authored, and an ObjectQL AST node as composed by * `ElementDataSourceGate` (which ANDs component/view/binding filters through * `mergeFilterNodes` before this component ever sees them). Both are lowered * here through that same single sink — the repo's one filter→wire exit — so no * second conversion dialect appears. */ - filter?: ViewFilterRule[] | FilterNode; + filter?: ViewFilterElement[] | FilterNode; /** * Enable text filtering. The box keeps a row when a column the list shows * contains the term; a masked column (a `password` / `secret` field, or any diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedList.filterOrGroup-28462.test.ts b/ui/packages/plugin-detail/src/__tests__/RelatedList.filterOrGroup-28462.test.ts new file mode 100644 index 00000000000..b371974ace0 --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/RelatedList.filterOrGroup-28462.test.ts @@ -0,0 +1,78 @@ +/** + * 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. + */ + +/** + * objectstack#28462 — the props `record:related_list` hands its `filter` and + * `add.picker.filter` to take the contract's filter-list element (a flat rule + * or an any-of group `{ or: [rule, ...] }`), as the `@object-ui/types` face + * does, so the renderer passes the authored value through without a cast. + * + * Three declarations carry the value on its way to the wire and must move + * together: `RecordRelatedListComponentProps` (the authoring face), + * `RelatedListProps` here, and `@object-ui/react`'s + * `ResolveRelatedRecordActionsInput.filter` (the resolved list filter the + * renderer hands the host for "View all"). Widening the face alone is a TS2322 + * in `renderers/record-related-list.tsx` (measured on `main` `4bc963ca`). + * + * Type-level only: compiled by `tsc -p tsconfig.test.json` (this package's + * `type-check`), erased by vitest; the `it` below only keeps vitest from + * reporting an empty file. Lowering the group to an `or` node is + * `@object-ui/core`'s `toFilterNode` (objectstack#28370), not pinned here. + */ + +import { describe, it, expect } from 'vitest'; +import type { ViewFilterElement } from '@objectstack/spec/ui'; +import type { RecordRelatedListComponentProps } from '@object-ui/types'; +import type { FilterNode } from '@object-ui/core'; +import type { ResolveRelatedRecordActionsInput } from '@object-ui/react'; +import type { RelatedListProps } from '../RelatedList'; + +/** Invariant type equality. `A extends B` is NOT this: `never` and `any` pass that. */ +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Expect = T; + +// @ts-expect-error objectstack#28462 — `Expect` must refuse `false`, or every leg below is vacuous. +type _ExpectRefusesFalse = Expect; + +/** The list's own scope: the element array as authored, or the AST node the data-source gate composes. */ +export type _ListFilter = Expect>; + +/** The picker's filter: the element array, passed to `RecordPickerDialog`'s `baseFilter` verbatim. */ +export type _PickerFilter = Expect< + Equal['picker']['filter'], ViewFilterElement[] | undefined> +>; + +/** The resolved list filter the renderer hands the host carries the same elements. */ +export type _ActionsInputFilter = Expect< + Equal +>; + +/** The authoring face flows into all three with no cast — what the renderer does. */ +export function _flowsWithoutCast(authored: RecordRelatedListComponentProps): unknown[] { + const toList: RelatedListProps['filter'] = authored.filter; + const toPicker: NonNullable['picker']['filter'] = authored.add?.picker.filter; + const toHost: ResolveRelatedRecordActionsInput['filter'] = authored.filter; + return [toList, toPicker, toHost]; +} + +/** The card's example compiles on the component's own prop. */ +export const _group: RelatedListProps['filter'] = [ + { + or: [ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'completed_at', operator: 'greater_than', value: '{7_days_ago}' }, + ], + }, +]; + +describe('objectstack#28462 — RelatedList filter props take the { or } group', () => { + it('is a type-level pin (see the header); the module loaded', () => { + expect(_group).toHaveLength(1); + }); +}); diff --git a/ui/packages/react/src/context/RelatedRecordActionsContext.tsx b/ui/packages/react/src/context/RelatedRecordActionsContext.tsx index 066dd2f6c15..22610987703 100644 --- a/ui/packages/react/src/context/RelatedRecordActionsContext.tsx +++ b/ui/packages/react/src/context/RelatedRecordActionsContext.tsx @@ -39,7 +39,7 @@ import React, { createContext, useContext } from 'react'; import type { FilterNode } from '@object-ui/core'; -import type { ViewFilterRule } from '@objectstack/spec/ui'; +import type { ViewFilterElement } from '@objectstack/spec/ui'; /** A child-object action surfaced in a related list's row menu. */ export interface RelatedRowActionDef { @@ -171,8 +171,11 @@ export interface ResolveRelatedRecordActionsInput { * ({@link RelatedRecordHandlers.onViewAll}): it scopes that destination by * this filter too, or offers none. Absent means the list has no filter of * its own; it does not mean "unknown". + * + * Its elements are the list's: flat rules or any-of `{ or }` groups + * (objectstack#28462). */ - filter?: ViewFilterRule[] | FilterNode; + filter?: ViewFilterElement[] | FilterNode; } export interface RelatedRecordActionsValue { diff --git a/ui/packages/types/src/__tests__/related-list-add-picker-filter-mirror-9964.test.ts b/ui/packages/types/src/__tests__/related-list-add-picker-filter-mirror-9964.test.ts index e8b7b5bbdda..634d78ea71b 100644 --- a/ui/packages/types/src/__tests__/related-list-add-picker-filter-mirror-9964.test.ts +++ b/ui/packages/types/src/__tests__/related-list-add-picker-filter-mirror-9964.test.ts @@ -64,7 +64,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import type { z } from 'zod'; import { RecordRelatedListProps } from '@objectstack/spec/ui'; -import type { ViewFilterOrGroup, ViewFilterRule } from '@objectstack/spec/ui'; +import type { ViewFilterElement, ViewFilterOrGroup, ViewFilterRule } from '@objectstack/spec/ui'; import type { RecordRelatedListComponentProps } from '../record-components'; /* ── Type-level legs (compiled by `tsc -p tsconfig.test.json`, erased by vitest) ── */ @@ -95,26 +95,21 @@ type SpecPickerFilter = NonNullable['add' * expectation comes from the protocol rather than from this file. */ /** - * objectstack#28020: the contract's element is now a flat rule OR an any-of - * group (`{ or: [rule, ...] }`), and the group is `[PLANNED — not yet - * rendered]` there: the related list's own reader (`@object-ui/plugin-detail`) - * types and lowers flat rules only. So this face carries the contract MINUS - * the group until the console reads it (objectstack#28370), and the two legs - * below hold exactly that gap — widen this face and the second leg's - * companion together, never one alone. + * objectstack#28020 put an any-of group (`{ or: [rule, ...] }`) beside the flat + * rule in the contract's element, and objectstack#28462 widened this face to + * carry it, so the face is the contract again — no gap is withheld. */ -type FlatRulesOf = Exclude[number], ViewFilterOrGroup>[] | undefined; -export type _MirrorAgreesWithTheContract = Expect>>; -/** …and the contract does carry the group the face withholds — so the gap is the group and nothing else. */ -export type _TheContractAlsoTakesTheGroup = Expect[number], ViewFilterRule | ViewFilterOrGroup>>; +export type _MirrorAgreesWithTheContract = Expect>; +/** …and the contract's element is exactly the rule or the group, so the pair cannot agree on something looser. */ +export type _TheContractTakesRuleOrGroup = Expect[number], ViewFilterRule | ViewFilterOrGroup>>; /** - * …and the shape they agree on is the rule ARRAY. Stated separately from the + * …and the shape they agree on is the element ARRAY (rule or `{ or }` group). Stated separately from the * derivation above so a spec that loosened its own key could not make the pair * agree on nothing — that would satisfy the leg above while putting `unknown` * back on the authoring face. */ -export type _AndThatShapeIsTheRuleArray = Expect>; +export type _AndThatShapeIsTheRuleArray = Expect>; // @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`. type _UnknownIsNotTheContract = Expect>; @@ -139,9 +134,11 @@ const CASES: readonly Case[] = [ // The consumer's own prop type, satisfied straight from the authoring face. // This is the TS2322 the four `(schema as any).add` casts were hiding. { - what: 'the declared filter is assignable to the consumer’s `ViewFilterRule[]`', + // objectstack#28462 moved the consumer's prop to the contract's element + // (`ViewFilterElement[]`, rule or `{ or }` group) together with this face. + what: 'the declared filter is assignable to the consumer’s `ViewFilterElement[]`', rejected: false, - code: `const rules: ViewFilterRule[] | undefined = block.add?.picker.filter; void rules;`, + code: `const rules: ViewFilterElement[] | undefined = block.add?.picker.filter; void rules;`, }, // ── What the tightened face must now REFUSE ─────────────────────────────── { @@ -223,7 +220,7 @@ function erroringLines(preamble: string): Set { const IMPORTS = `import type { RecordRelatedListComponentProps } from '${MIRROR_IMPORT}';\n` + - `import type { ViewFilterRule } from '@objectstack/spec/ui';\n`; + `import type { ViewFilterElement, ViewFilterRule } from '@objectstack/spec/ui';\n`; /** The real mirror, under the name the cases annotate against. */ const REAL_PREAMBLE = @@ -270,7 +267,7 @@ describe('objectui#9964 — the mirror declares the contract’s rule array', () * and prove nothing about this change on their own. * * 0 — the assignability the four `as any` casts were hiding: TS2322 with - * `unknown`, clean with the rule array. + * `unknown`, clean with the element array. * 1,2,3 — the three wrong shapes: `unknown` accepts all of them. * 4 — the canonical form: accepted on both legs (the narrowing is not too * narrow, which is the half a rejection-only pin cannot state). diff --git a/ui/packages/types/src/__tests__/related-list-filter-or-group-28462.test.ts b/ui/packages/types/src/__tests__/related-list-filter-or-group-28462.test.ts new file mode 100644 index 00000000000..78b0701fea5 --- /dev/null +++ b/ui/packages/types/src/__tests__/related-list-filter-or-group-28462.test.ts @@ -0,0 +1,193 @@ +/** + * 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. + */ + +/** + * objectstack#28462 — `record:related_list`'s `filter` and `add.picker.filter` + * take the contract's filter-list ELEMENT, a flat rule or an any-of group + * `{ or: [rule, ...] }` (objectstack#28020), on the authoring face. + * + * Before: both keys were typed `ViewFilterRule[]`, so a typed author could not + * write the group the protocol accepts for these two carriers. The sibling + * pins (`related-list-filter-rule-array-10199`, + * `related-list-add-picker-filter-mirror-9964`) hold the face EQUAL to the + * contract; this file holds what that equality means for the group, on + * authored values, and compares every verdict with the protocol's own parse. + * + * Same mechanism as those siblings: the diagnostic legs compile authored + * snippets against the real mirror SOURCE through the TypeScript API, and + * compile them a SECOND time against the pre-widening declaration (the same + * mirror with ONE key put back to `ViewFilterRule[]`). `EXPECTED_FLIPS` names + * the cases that revert flips, per key. + */ + +import { describe, it, expect } from 'vitest'; +import ts from 'typescript'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { RecordRelatedListProps } from '@objectstack/spec/ui'; +import type { ViewFilterElement } from '@objectstack/spec/ui'; +import type { RecordRelatedListComponentProps } from '../record-components'; + +/* ── Type-level legs (compiled by `tsc -p tsconfig.test.json`, erased by vitest) ── */ + +const OPEN = { field: 'status', operator: 'equals', value: 'open' } as const; +const RECENT = { field: 'completed_at', operator: 'greater_than', value: '{7_days_ago}' } as const; + +/** The card's example: open, or completed in the last 7 days — beside a plain rule. */ +export const _listFilterGroup: RecordRelatedListComponentProps = { + objectName: 'task', + relationshipField: 'account_id', + filter: [{ or: [OPEN, RECENT] }, { field: 'archived', operator: 'equals', value: false }], +}; + +export const _pickerFilterGroup: RecordRelatedListComponentProps = { + objectName: 'account_member', + relationshipField: 'account_id', + add: { picker: { object: 'user', filter: [{ or: [OPEN, RECENT] }] }, linkField: 'user' }, +}; + +export const _nestedGroupRefused: RecordRelatedListComponentProps = { + objectName: 'task', + relationshipField: 'account_id', + // @ts-expect-error objectstack#28462 — one level only: a group's members are flat rules, never a group. + filter: [{ or: [{ or: [OPEN] }] }], +}; + +/** A reader of the key sees the element union, so it must tell the two apart before reading `field`. */ +export function _readerMustNarrow(el: NonNullable[number]): string { + // @ts-expect-error objectstack#28462 — `field` is not on the group arm; a reader that assumed flat rules is told so. + const f: string = el.field; + void f; + return 'or' in el ? 'group' : el.field; +} + +/** The element the face carries is the contract's own name for it. */ +export const _elementIsTheContracts: ViewFilterElement[] = _listFilterGroup.filter ?? []; + +/* ── Diagnostic legs: the same property, read under vitest ─────────────────── */ + +const HERE = dirname(fileURLToPath(import.meta.url)); +const MIRROR_IMPORT = join(HERE, '..', 'record-components').replace(/\\/g, '/'); + +type Key = 'filter' | 'picker'; + +interface Case { + readonly what: string; + readonly rejected: boolean; + readonly value: unknown; +} + +const R1 = { field: 'status', operator: 'equals', value: 'open' }; +const R2 = { field: 'priority', operator: 'greater_than', value: 2 }; + +const CASES: readonly Case[] = [ + { what: 'an any-of group alone type-checks', rejected: false, value: [{ or: [R1, R2] }] }, + { what: 'a group beside a plain rule type-checks', rejected: false, value: [{ or: [R1] }, R2] }, + { what: 'a group nested in a group is refused (one level only)', rejected: true, value: [{ or: [{ or: [R1] }] }] }, + { what: 'a group whose members are tuples is refused', rejected: true, value: [{ or: [['status', '=', 'open']] }] }, + { what: 'a group under any other key (`any`) is refused', rejected: true, value: [{ any: [R1] }] }, + { what: 'CONTROL — plain rules type-check on both legs', rejected: false, value: [R1, R2] }, + { what: 'CONTROL — a bare group object (not in the array) is refused on both legs', rejected: true, value: { or: [R1] } }, +]; + +/** Cases the pre-widening `ViewFilterRule[]` declaration flips: the two accepted groups. */ +const EXPECTED_FLIPS = [0, 1]; + +function snippet(key: Key, c: Case, i: number): string { + const v = JSON.stringify(c.value); + const body = + key === 'filter' + ? `{ objectName: 'task', relationshipField: 'account_id', filter: ${v} }` + : `{ objectName: 'm', relationshipField: 'account_id', add: { picker: { object: 'user', filter: ${v} } } }`; + return `const c${i}: Block = ${body}; void c${i};`; +} + +function erroringLines(key: Key, preamble: string): Set { + const header = `${preamble}\n`; + const source = `${header}${CASES.map((c, i) => snippet(key, c, i)).join('\n')}\nexport {};\n`; + const headerLines = header.split('\n').length - 1; + const VIRTUAL = join(HERE, `__relatedListOrGroup28462.${key}.virtual.ts`).replace(/\\/g, '/'); + const options: ts.CompilerOptions = { + strict: true, + skipLibCheck: true, + noEmit: true, + moduleResolution: ts.ModuleResolutionKind.Bundler, + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ESNext, + }; + const host = ts.createCompilerHost(options); + const getSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (fileName, languageVersion, ...rest) => + fileName === VIRTUAL + ? ts.createSourceFile(fileName, source, languageVersion, true) + : getSourceFile(fileName, languageVersion, ...rest); + const fileExists = host.fileExists.bind(host); + host.fileExists = (fileName) => (fileName === VIRTUAL ? true : fileExists(fileName)); + const readFile = host.readFile.bind(host); + host.readFile = (fileName) => (fileName === VIRTUAL ? source : readFile(fileName)); + const program = ts.createProgram([VIRTUAL], options, host); + const sf = program.getSourceFile(VIRTUAL); + if (!sf) throw new Error('virtual source file was not added to the program'); + const lines = new Set(); + for (const d of program.getSemanticDiagnostics(sf)) { + if (d.start == null) continue; + lines.add(sf.getLineAndCharacterOfPosition(d.start).line - headerLines); + } + return lines; +} + +const IMPORT = + `import type { RecordRelatedListComponentProps } from '${MIRROR_IMPORT}';\n` + + `import type { ViewFilterRule } from '@objectstack/spec/ui';\n`; + +const REAL = IMPORT + `type Block = RecordRelatedListComponentProps;`; + +/** ONE key put back to the pre-widening `ViewFilterRule[]`; everything else is the real mirror. */ +const REVERTED: Record = { + filter: IMPORT + `type Block = Omit & { filter?: ViewFilterRule[] };`, + picker: + IMPORT + + `type Add = NonNullable;\n` + + `type Block = Omit & { add?: Omit & ` + + `{ picker: Omit & { filter?: ViewFilterRule[] } } };`, +}; + +// Module scope on purpose: the compiler cost lands in the import phase. +const KEYS: readonly Key[] = ['filter', 'picker']; +const real = Object.fromEntries(KEYS.map((k) => [k, erroringLines(k, REAL)])) as Record>; +const reverted = Object.fromEntries(KEYS.map((k) => [k, erroringLines(k, REVERTED[k])])) as Record>; + +/** The protocol's own parsers for the two keys, off the installed artifact. */ +const SPEC: Record = { + filter: RecordRelatedListProps.shape.filter, + picker: RecordRelatedListProps.shape.add.unwrap().shape.picker.shape.filter, +}; + +for (const key of KEYS) { + describe(`objectstack#28462 — related-list ${key === 'filter' ? '`filter`' : '`add.picker.filter`'} takes the { or } group`, () => { + for (const [i, c] of CASES.entries()) { + it(c.what, () => { + expect({ case: c.what, rejected: real[key].has(i) }).toEqual({ case: c.what, rejected: c.rejected }); + }); + } + + it('every verdict the compiler gives, the protocol’s parse gives too', () => { + for (const [i, c] of CASES.entries()) { + expect({ case: c.what, specRefuses: !SPEC[key].safeParse(c.value).success }).toEqual({ + case: c.what, + specRefuses: real[key].has(i), + }); + } + }); + + it('putting this key back to `ViewFilterRule[]` flips exactly the accepted groups', () => { + const flipped = CASES.map((_c, i) => i).filter((i) => reverted[key].has(i) !== real[key].has(i)); + expect(flipped).toEqual(EXPECTED_FLIPS); + }); + }); +} diff --git a/ui/packages/types/src/__tests__/related-list-filter-rule-array-10199.test.ts b/ui/packages/types/src/__tests__/related-list-filter-rule-array-10199.test.ts index fe8c74cc7f1..a2d28b5828a 100644 --- a/ui/packages/types/src/__tests__/related-list-filter-rule-array-10199.test.ts +++ b/ui/packages/types/src/__tests__/related-list-filter-rule-array-10199.test.ts @@ -67,7 +67,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import type { z } from 'zod'; import { RecordRelatedListProps } from '@objectstack/spec/ui'; -import type { ViewFilterOrGroup, ViewFilterRule } from '@objectstack/spec/ui'; +import type { ViewFilterElement, ViewFilterOrGroup, ViewFilterRule } from '@objectstack/spec/ui'; import type { RecordRelatedListComponentProps } from '../record-components'; /* ── Type-level legs (compiled by `tsc -p tsconfig.test.json`, erased by vitest) ── */ @@ -95,24 +95,19 @@ type SpecFilter = z.input['filter']; /** ⭐ The ruling, in type form: the authoring face carries the protocol's declaration. */ /** - * objectstack#28020: the contract's element is now a flat rule OR an any-of - * group (`{ or: [rule, ...] }`), and the group is `[PLANNED — not yet - * rendered]` there: the related list's own reader (`@object-ui/plugin-detail`) - * types and lowers flat rules only. So this face carries the contract MINUS - * the group until the console reads it (objectstack#28370), and the two legs - * below hold exactly that gap — widen this face and the second leg's - * companion together, never one alone. + * objectstack#28020 put an any-of group (`{ or: [rule, ...] }`) beside the flat + * rule in the contract's element, and objectstack#28462 widened this face to + * carry it, so the face is the contract again — no gap is withheld. */ -type FlatRulesOf = Exclude[number], ViewFilterOrGroup>[] | undefined; -export type _MirrorAgreesWithTheContract = Expect>>; -/** …and the contract does carry the group the face withholds — so the gap is the group and nothing else. */ -export type _TheContractAlsoTakesTheGroup = Expect[number], ViewFilterRule | ViewFilterOrGroup>>; +export type _MirrorAgreesWithTheContract = Expect>; +/** …and the contract's element is exactly the rule or the group, so the pair cannot agree on something looser. */ +export type _TheContractTakesRuleOrGroup = Expect[number], ViewFilterRule | ViewFilterOrGroup>>; /** - * …and that declaration is the rule ARRAY. Stated separately so a spec that + * …and that declaration is the element ARRAY (rule or `{ or }` group). Stated separately so a spec that * loosened its own key could not make the pair agree on nothing. */ -export type _AndThatShapeIsTheRuleArray = Expect>; +export type _AndThatShapeIsTheRuleArray = Expect>; // @ts-expect-error objectui#10199 — the PRE-FIX declaration, asserted to be WRONG. This directive going unused (TS2578) means the face went back to `any`. type _AnyIsNotTheContract = Expect>; diff --git a/ui/packages/types/src/record-components.ts b/ui/packages/types/src/record-components.ts index 993323435a1..d57883f6432 100644 --- a/ui/packages/types/src/record-components.ts +++ b/ui/packages/types/src/record-components.ts @@ -17,7 +17,7 @@ * @packageDocumentation */ -import type { ElementDataSource, EmptyState, I18nLabel, ViewFilterRule } from '@objectstack/spec/ui'; +import type { ElementDataSource, EmptyState, I18nLabel, ViewFilterElement } from '@objectstack/spec/ui'; /** * ARIA props shared across all record components. @@ -524,7 +524,7 @@ export interface RecordRelatedListComponentProps { * ## Why this face carries the array and the consumer carries a union * * `@object-ui/plugin-detail`'s `RelatedList` types the same key - * `ViewFilterRule[] | FilterNode`, and it keeps that union on purpose + * `ViewFilterElement[] | FilterNode`, and it keeps that union on purpose * (objectui#10199, ruled protocol first). The `FilterNode` half is a RUNTIME * value: `ElementDataSourceGate` writes the composed component-AND-view-AND- * binding filter onto this key after it resolves a `dataSource` binding, and @@ -539,8 +539,14 @@ export interface RecordRelatedListComponentProps { * constrains. objectui#9964 moved `add.picker.filter` below for the same * reason; there the consumer already agreed with the protocol, here only the * authoring half does. + * + * The element is the contract's `ViewFilterElement` — a flat rule or an + * any-of group `{ or: [rule, ...] }` — since objectstack#28020 added the + * group to every carrier of a view filter list (objectstack#28462 widened + * this face, the `RelatedList` props and the related-actions input together; + * the consumers lower the group through `toFilterNode`, objectstack#28370). */ - filter?: ViewFilterRule[]; + filter?: ViewFilterElement[]; /** * Section title — the spec's `I18nLabel` (`RecordRelatedListProps.title`), a * plain string or an inline per-locale map. `RecordRelatedListRenderer` @@ -584,8 +590,12 @@ export interface RecordRelatedListComponentProps { * at parse today. This is a narrowing being UNDONE, and it is the * contract-first direction AGENTS.md #0.1 requires — the producer moves to * the contract, never the renderer to a lenient dialect. + * + * The element is the contract's `ViewFilterElement` (a flat rule or an + * any-of group `{ or: [rule, ...] }`, objectstack#28020), the same element + * as the list's own `filter` above (objectstack#28462). */ - picker: { object: string; valueField?: string; labelField?: string; filter?: ViewFilterRule[] }; + picker: { object: string; valueField?: string; labelField?: string; filter?: ViewFilterElement[] }; linkField?: string; /** * The Add button's text — the spec's `I18nLabel`