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
17 changes: 17 additions & 0 deletions .changeset/10946-expression-wire-slots-by-reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/types': minor
---

A spec-shape conditional-formatting rule's `condition` and a bulk action's `visible` now declare the named view's own expression slots, read by reference from `@objectstack/spec` (objectui#10946).

- `SpecConditionalFormattingRule.condition` was `string`. It is now `string` or the protocol's `ObjectListViewSchema.conditionalFormatting[].condition` slot, which adds the `{ dialect, source }` envelope that `objectstack build` emits. The widening reaches every type built on the rule: `ConditionalFormattingRule`, `KanbanConditionalFormattingRule`, and the `conditionalFormatting` members of `ObjectGridSchema`, `ListViewSchema`, `ObjectKanbanSchema` and `NamedListView`.
- `BulkActionDef.visible` keeps its arm `ExpressionWire` (a string or `{ dialect?, source }`), which it now names instead of spelling inline. It gains the protocol's `bulkActionDefs[].visible` slot as a second arm. On the installed spec that envelope makes `source` optional and may carry `ast` and `meta`.
- The zod mirror moves with the type. The list view's and the kanban board's rule unions share one `condition` schema. `z.string()` is its first arm, so a string condition parses exactly as before: it is not canonicalized into an envelope, and `''` is still accepted. The spec's own slot schema is the second arm, taken by reference, so `safeValidateSchema` (what `os validate` and `os check` run) now accepts an envelope condition on `list-view` and `object-kanban` nodes and refuses one the protocol refuses.

Why: both members were narrower than the protocol declares and than the grid's evaluator reads. `resolveConditionalFormatting` and the bulk-eligibility fold both accept the envelope. So every relay of a named view into a grid needed a type assertion. `@object-ui/plugin-view`'s route-2 `ObjectView` loses its two. Nothing changes at runtime: no evaluator, renderer or relay changed behaviour. The compiled output of the removed assertions is unchanged.

The two spec lines differ, and these members follow the installed one. The published slot is `ExpressionInputSchema` (`source` or `ast`). Spec `main` narrows it to `EvaluatedExpressionInputSchema`, which requires a non-blank `source`. No engine evaluates `ast` today. So an `ast`-only envelope, which the installed slot admits, is evaluated as a fault: the formatting rule does not match, the bulk def qualifies no record, and the fault is warned.

**Breaking for TypeScript readers of these members, released as `minor`** under this repo's version-alignment rule (the fixed group's major follows `@objectstack`). Code that reads `rule.condition` as a `string`, or reads `def.visible.source` as a `string`, stops compiling. Narrow on `typeof` first, as the shared evaluator does. Code that writes these members compiles unchanged.

Pins: `spec-expression-wire-slots-10946.test.ts` in `@object-ui/types` covers the relays with no assertion, the zod twin's input equalling the TS member, envelope acceptance with string and empty-string controls, and the reference identity of the spec arm. `specExpressionWire-10946.test.tsx` in `@object-ui/plugin-grid` covers the real grid painting an envelope-matched row, the bulk fold reading `source` beside `ast`, and the `ast`-only fault on both.
134 changes: 134 additions & 0 deletions packages/plugin-grid/src/__tests__/specExpressionWire-10946.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* 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.
*/

/**
* objectui#10946 — the widened declarations are the values `object-grid`
* already evaluates.
*
* `@object-ui/types` now declares a spec-shape rule's `condition` and a bulk
* def's `visible` as the named view's own expression slots (a string, or the
* `{ dialect, source }` envelope). That card changed no runtime code; what it
* owes is proof that the grid honours what the type now admits. Each fixture
* below is typed through the grid's DECLARED member (no `any`), so the same
* file pins both halves: `tsc` accepts the value, and the real renderer reads
* it.
*
* The `ast`-only envelope is typed loosely on purpose. The installed spec's
* slot admits it and spec `main` does not, so a typed fixture would compile on
* one line and fail the other. Its case pins the runtime answer the
* declarations' docblocks describe: evaluated as a fault, so the rule does not
* match and the bulk def qualifies no record, and the fault is warned.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import type { BulkActionDef, ObjectGridSchema } from '@object-ui/types';

import { ObjectGrid } from '../ObjectGrid';
import { partitionBulkRows } from '../bulkEligibility';
import { registerAllFields } from '@object-ui/fields';
import { ActionProvider } from '@object-ui/react';

registerAllFields();

type GridRules = NonNullable<ObjectGridSchema['conditionalFormatting']>;

function renderGrid(rows: Record<string, unknown>[], conditionalFormatting: GridRules) {
const schema: ObjectGridSchema = {
type: 'object-grid',
objectName: 'test_object',
columns: [{ field: 'name', label: 'Name' }],
data: { provider: 'value', items: rows },
conditionalFormatting,
};
return render(
<ActionProvider>
<ObjectGrid schema={schema} />
</ActionProvider>,
);
}

/** The `<tr>` the browser would paint for the row whose `name` is `label`. */
function rowOf(label: string): HTMLTableRowElement {
const tr = screen.getByText(label).closest('tr');
if (!tr) throw new Error(`no <tr> for row ${label}`);
return tr as HTMLTableRowElement;
}

const ROWS = [
{ id: '1', name: 'Won deal', stage: 'won' },
{ id: '2', name: 'Open deal', stage: 'open' },
];

afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

describe('objectui#10946 — `object-grid` evaluates a `{ dialect, source }` rule condition', () => {
it('THE WIDENED FORM: an envelope condition paints the row it matches, and only that row', () => {
renderGrid(ROWS, [
{ condition: { dialect: 'cel', source: "record.stage == 'won'" }, style: { backgroundColor: 'rgb(1, 2, 3)' } },
]);

expect(rowOf('Won deal').style.backgroundColor).toBe('rgb(1, 2, 3)');
expect(rowOf('Open deal').style.backgroundColor).toBe('');
});

it('CONTROL: the string form, unchanged, reaches the same verdict', () => {
renderGrid(ROWS, [
{ condition: "record.stage == 'won'", style: { backgroundColor: 'rgb(1, 2, 3)' } },
]);

expect(rowOf('Won deal').style.backgroundColor).toBe('rgb(1, 2, 3)');
expect(rowOf('Open deal').style.backgroundColor).toBe('');
});

it('an `ast`-only envelope is a fault, not a match: no row is painted, and the fault is warned', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const astOnly = [
{ condition: { dialect: 'cel', ast: { op: 'eq' } }, style: { backgroundColor: 'rgb(4, 5, 6)' } },
] as unknown as GridRules;

renderGrid(ROWS, astOnly);

expect(rowOf('Won deal').style.backgroundColor).toBe('');
expect(rowOf('Open deal').style.backgroundColor).toBe('');
expect(warn).toHaveBeenCalled();
});
});

describe('objectui#10946 — a bulk def\'s `visible` envelope that carries `ast`', () => {
const BULK_ROWS = [
{ id: 'r1', done: false },
{ id: 'r2', done: true },
];

it('THE WIDENED FORM: `ast` beside `source` is declared, and `source` decides', () => {
const def: BulkActionDef = {
name: 'mark_done',
operation: 'custom',
visible: { dialect: 'cel', source: '!record.done', ast: { op: 'not' } },
};

expect(partitionBulkRows(def, BULK_ROWS).eligible.map((r) => r.id)).toEqual(['r1']);
});

it('an `ast`-only envelope qualifies no record (fail closed), and the fault is warned', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const def = { name: 'mark_done', operation: 'custom', visible: { dialect: 'cel', ast: { op: 'not' } } } as unknown as BulkActionDef;

const { eligible, skipped } = partitionBulkRows(def, BULK_ROWS);

expect(eligible).toEqual([]);
expect(skipped).toBe(2);
expect(warn).toHaveBeenCalled();
});
});
19 changes: 10 additions & 9 deletions packages/plugin-view/src/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2323,17 +2323,18 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
searchableFields: currentNamedViewConfig?.searchableFields,
// The protocol's rule `condition` is a string or the `{ dialect, source }`
// expression wire; `ObjectGrid` hands every rule to the shared evaluator
// (`resolveConditionalFormatting`), which reads both. The grid's declared
// rule type spells the string form only, hence the assertion — the same
// one `viewSort` above makes for the named view's `sort`.
conditionalFormatting: currentNamedViewConfig?.conditionalFormatting as ObjectGridSchema['conditionalFormatting'],
// (`resolveConditionalFormatting`), which reads both. No assertion: the
// grid's declared rule type reads the named view's `condition` slot by
// reference (objectui#10946), so the relay type-checks as written.
conditionalFormatting: currentNamedViewConfig?.conditionalFormatting,
rowActions: currentNamedViewConfig?.rowActions,
bulkActions: currentNamedViewConfig?.bulkActions,
// Same reason as `conditionalFormatting`: the protocol's `visible`
// expression wire may carry `ast` without `source`, which the grid's
// declared `BulkActionDef` does not spell. It is the value the host
// delegation already hands `ObjectGrid` through `ListView`.
bulkActionDefs: currentNamedViewConfig?.bulkActionDefs as ObjectGridSchema['bulkActionDefs'],
// Same as `conditionalFormatting`: the protocol's `visible` expression
// wire may carry `ast` alongside (or, on the installed spec, instead of)
// `source`, and `BulkActionDef.visible` reads that slot by reference
// (objectui#10946). It is the value the host delegation already hands
// `ObjectGrid` through `ListView`.
bulkActionDefs: currentNamedViewConfig?.bulkActionDefs,
exportOptions: gridExportOptions(currentNamedViewConfig?.exportOptions),
// ⭐ objectui#8980 — the AUTHOR-REACHABLE read point for two of the
// seventeen. `ObjectGrid` already reads both (`schema.grouping` in its
Expand Down
187 changes: 187 additions & 0 deletions packages/types/src/__tests__/spec-expression-wire-slots-10946.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/**
* 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.
*/

/**
* objectui#10946 — `SpecConditionalFormattingRule.condition` and
* `BulkActionDef.visible` read the named view's expression slots BY REFERENCE.
*
* Before this card `condition` was `string` alone and `visible` had no arm for
* the protocol's envelope (on the installed spec its `source` is optional and
* it may carry `ast` / `meta`). Both were narrower than `ObjectListViewSchema`
* declares and than `ObjectGrid`'s evaluator reads, so every relay of a named
* view into a grid needed a type assertion.
*
* ## Where each half bites
*
* - The TYPE pins are checked by `tsc -p tsconfig.test.json` (this package's
* `type-check` script), not by vitest. Every relay below is an assignment
* written WITHOUT an assertion, so a narrower declaration fails the build.
* The `it` blocks that hold them only keep the values referenced.
* - The ZOD pins run here. The mirror keeps `z.string()` as its FIRST arm, so a
* string condition parses exactly as before, and reads the spec's own slot
* schema for the envelope. Both properties are asserted, each with a control.
*/
import { describe, it, expect, expectTypeOf } from 'vitest';
import type { z } from 'zod';
import {
ListViewSchema as SpecListViewSchema,
type ObjectListViewSchema as SpecObjectListViewSchema,
} from '@objectstack/spec/ui';
import type {
BulkActionDef,
ConditionalFormattingRule,
KanbanConditionalFormattingRule,
SpecConditionalFormattingRule,
} from '../objectql';
import { ListViewSchema, ObjectKanbanSchema } from '../zod/index.zod';
import { KanbanConditionalFormattingRuleSchema } from '../zod/objectql.zod';

/* ── the protocol's own values, as a relay receives them ─────────────────── */

type NamedView = z.input<typeof SpecObjectListViewSchema>;
type NamedViewRule = NonNullable<NamedView['conditionalFormatting']>[number];
type NamedViewBulkDef = NonNullable<NamedView['bulkActionDefs']>[number];

// The relays `ObjectView` (route 2) performs, as plain assignments.
const relayRule = (rule: NamedViewRule): SpecConditionalFormattingRule => rule;
const relayGridRules = (rules: NamedView['conditionalFormatting']): ConditionalFormattingRule[] | undefined => rules;
const relayKanbanRule = (rule: NamedViewRule): KanbanConditionalFormattingRule => rule;
const relayBulkDefs = (defs: NamedView['bulkActionDefs']): BulkActionDef[] | undefined => defs;
const relayVisible = (visible: NamedViewBulkDef['visible']): BulkActionDef['visible'] => visible;

/* ── authored literals ───────────────────────────────────────────────────── */

const envelopeRule: SpecConditionalFormattingRule = {
condition: { dialect: 'cel', source: "record.stage == 'won'" },
style: { backgroundColor: '#dcfce7' },
};

// CONTROL — the string form the member declared before is still declared.
const stringRule: SpecConditionalFormattingRule = {
condition: "record.stage == 'won'",
style: { backgroundColor: '#dcfce7' },
};

const invalidRule: SpecConditionalFormattingRule = {
// @ts-expect-error — a number is no predicate: the widening is to the spec's slot, not to `unknown`.
condition: 42,
style: {},
};

// The member the card names: an envelope that carries `ast` beside `source`.
const visibleWithAst: BulkActionDef = {
name: 'close_all',
operation: 'custom',
visible: { dialect: 'cel', source: '!record.done', ast: { op: 'not' } },
};

// CONTROL — the arm `visible` declared before (`ExpressionWire`, dialect optional) is kept.
const visibleWire: BulkActionDef = {
name: 'close_all',
operation: 'custom',
visible: { source: '!record.done' },
};

describe('objectui#10946 — the TS faces admit the named view\'s expression slots (tsc is the pin)', () => {
it('a named view\'s rules and bulk defs relay into the grid-facing declarations without an assertion', () => {
expect([relayRule, relayGridRules, relayKanbanRule, relayBulkDefs, relayVisible].every((f) => typeof f === 'function')).toBe(true);
});

it('an envelope condition and an envelope `visible` carrying `ast` are declared; the string and wire forms still are', () => {
expect(envelopeRule.condition).toEqual({ dialect: 'cel', source: "record.stage == 'won'" });
expect(stringRule.condition).toBe("record.stage == 'won'");
expect(visibleWithAst.visible).toHaveProperty('ast');
expect(visibleWire.visible).toEqual({ source: '!record.done' });
expect(invalidRule.style).toEqual({});
});

it('the zod mirror\'s `condition` input is the TS member, by construction', () => {
type MirrorRule = Extract<NonNullable<z.input<typeof ListViewSchema>['conditionalFormatting']>[number], { style: unknown }>;
type KanbanMirrorRule = Extract<z.input<typeof KanbanConditionalFormattingRuleSchema>, { style: unknown }>;
expectTypeOf<MirrorRule['condition']>().toEqualTypeOf<SpecConditionalFormattingRule['condition']>();
expectTypeOf<KanbanMirrorRule['condition']>().toEqualTypeOf<SpecConditionalFormattingRule['condition']>();
});
});

/* ── the zod mirror ──────────────────────────────────────────────────────── */

const ENVELOPE = { dialect: 'cel', source: "record.stage == 'won'" };

const listView = (condition: unknown) => ({
type: 'list-view',
objectName: 'task',
conditionalFormatting: [{ condition, style: { backgroundColor: '#dcfce7' } }],
});

const kanban = (condition: unknown) => ({
type: 'object-kanban',
objectName: 'task',
groupBy: 'status',
conditionalFormatting: [{ condition, style: { backgroundColor: '#dcfce7' } }],
});

/** The spec's own `condition` slot schema — what the mirror must read, not copy. */
const specConditionSlot = SpecListViewSchema.shape.conditionalFormatting.unwrap().element.shape.condition;

describe.each([
['list-view (`ListViewSchema`)', ListViewSchema, listView],
['object-kanban (`KanbanConditionalFormattingRuleSchema`)', ObjectKanbanSchema, kanban],
] as const)('objectui#10946 — the zod mirror of `condition` on %s', (_label, schema, doc) => {
it('accepts the `{ dialect, source }` envelope and returns it unchanged', () => {
const parsed = schema.safeParse(doc(ENVELOPE));
expect(parsed.success).toBe(true);
expect((parsed.data as { conditionalFormatting: Array<{ condition: unknown }> }).conditionalFormatting[0].condition).toEqual(ENVELOPE);
});

it('CONTROL — a string condition parses exactly as before: not canonicalized into an envelope', () => {
const parsed = schema.safeParse(doc("record.stage == 'won'"));
expect(parsed.success).toBe(true);
expect((parsed.data as { conditionalFormatting: Array<{ condition: unknown }> }).conditionalFormatting[0].condition).toBe("record.stage == 'won'");
// The spec slot ALONE would have rewritten it — which is why `z.string()` is the first arm.
expect(specConditionSlot.parse("record.stage == 'won'")).toEqual({ dialect: 'cel', source: "record.stage == 'won'" });
});

it('CONTROL — the empty string the mirror accepted before is still accepted (no narrowing)', () => {
expect(schema.safeParse(doc('')).success).toBe(true);
// The spec slot alone refuses it (`min(1)`), so reading it alone would have narrowed this validator.
expect(specConditionSlot.safeParse('').success).toBe(false);
});

it('refuses an envelope the protocol refuses, at the rule, while the valid envelope in the same document parses', () => {
for (const bad of [{ dialect: 'cel' }, { source: "record.stage == 'won'" }, 42]) {
const parsed = schema.safeParse(doc(bad));
expect(parsed.success).toBe(false);
expect(parsed.error?.issues[0]?.path.slice(0, 2)).toEqual(['conditionalFormatting', 0]);
}
expect(schema.safeParse(doc(ENVELOPE)).success).toBe(true);
});
});

describe('objectui#10946 — the envelope arm IS the spec\'s slot schema (reference, not a copy)', () => {
// A faithful copy passes every value comparison above; reference identity is
// the only check that tells a derivation from a fork (the `ExpressionWireSchema`
// pin in `base-schema-predicate-envelope-7530.test.ts` makes the same move).
type UnionOf = { options: readonly unknown[] };
type RuleArm = { shape: { condition: UnionOf } };

it('on the list view\'s rule union', () => {
const rule = ListViewSchema.shape.conditionalFormatting.unwrap().element as unknown as UnionOf;
const condition = (rule.options[1] as RuleArm).shape.condition;
expect(condition.options[1]).toBe(specConditionSlot);
});

it('on the kanban rule union', () => {
const condition = ((KanbanConditionalFormattingRuleSchema as unknown as UnionOf).options[1] as RuleArm).shape.condition;
expect(condition.options[1]).toBe(specConditionSlot);
});

it('CONTROL — the reference check can fail: the string arm is NOT the spec slot', () => {
const condition = ((KanbanConditionalFormattingRuleSchema as unknown as UnionOf).options[1] as RuleArm).shape.condition;
expect(condition.options[0]).not.toBe(specConditionSlot);
});
});
Loading
Loading