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
14 changes: 14 additions & 0 deletions .changeset/28462-related-list-filter-or-group.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 9 additions & 5 deletions ui/packages/plugin-detail/src/RelatedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<A, B> =
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
type Expect<T extends true> = T;

// @ts-expect-error objectstack#28462 — `Expect` must refuse `false`, or every leg below is vacuous.
type _ExpectRefusesFalse = Expect<false>;

/** The list's own scope: the element array as authored, or the AST node the data-source gate composes. */
export type _ListFilter = Expect<Equal<RelatedListProps['filter'], ViewFilterElement[] | FilterNode | undefined>>;

/** The picker's filter: the element array, passed to `RecordPickerDialog`'s `baseFilter` verbatim. */
export type _PickerFilter = Expect<
Equal<NonNullable<RelatedListProps['add']>['picker']['filter'], ViewFilterElement[] | undefined>
>;

/** The resolved list filter the renderer hands the host carries the same elements. */
export type _ActionsInputFilter = Expect<
Equal<ResolveRelatedRecordActionsInput['filter'], ViewFilterElement[] | FilterNode | undefined>
>;

/** 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<RelatedListProps['add']>['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);
});
});
7 changes: 5 additions & 2 deletions ui/packages/react/src/context/RelatedRecordActionsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) ── */
Expand Down Expand Up @@ -95,26 +95,21 @@ type SpecPickerFilter = NonNullable<z.input<typeof RecordRelatedListProps>['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<F extends readonly unknown[] | undefined> = Exclude<NonNullable<F>[number], ViewFilterOrGroup>[] | undefined;
export type _MirrorAgreesWithTheContract = Expect<Equal<MirrorPickerFilter, FlatRulesOf<SpecPickerFilter>>>;
/** …and the contract does carry the group the face withholds — so the gap is the group and nothing else. */
export type _TheContractAlsoTakesTheGroup = Expect<Equal<NonNullable<SpecPickerFilter>[number], ViewFilterRule | ViewFilterOrGroup>>;
export type _MirrorAgreesWithTheContract = Expect<Equal<MirrorPickerFilter, SpecPickerFilter>>;
/** …and the contract's element is exactly the rule or the group, so the pair cannot agree on something looser. */
export type _TheContractTakesRuleOrGroup = Expect<Equal<NonNullable<SpecPickerFilter>[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<Equal<MirrorPickerFilter, ViewFilterRule[] | undefined>>;
export type _AndThatShapeIsTheRuleArray = Expect<Equal<MirrorPickerFilter, ViewFilterElement[] | undefined>>;

// @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<Equal<MirrorPickerFilter, unknown>>;
Expand All @@ -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 ───────────────────────────────
{
Expand Down Expand Up @@ -223,7 +220,7 @@ function erroringLines(preamble: string): Set<number> {

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 =
Expand Down Expand Up @@ -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).
Expand Down
Loading
Loading