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
38 changes: 38 additions & 0 deletions .changeset/10611-filter-ui-operator-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@object-ui/types': minor
---

feat(types)!: `FilterUISchema.filters[].operator` is retired on the `filter-ui` node and refused by name

⚠️ Breaking, marked `minor` under this repo's version-alignment rule (a `major`
in the fixed group would move all of it off the `@objectstack` major). A
`filter-ui` node whose `filters[]` entry authors `operator` now FAILS to
validate, and a TypeScript literal typed as `FilterUISchema` that sets it no
longer compiles.

`filter-ui` never read a per-filter operator. Its renderer picks each control
from `type` and reports a change as a field → value record with no operator in
it: a host `onChange` function receives that bare record, and only the authored
window event wraps it, as `detail: { values }`. It does no matching of its own.
Yet both published faces declared the key (the mirror as a seven-member enum,
the TypeScript face as the same union) and the docs page taught it. So
`operator: 'gt'`, a member of that enum, type-checked and parsed green through
`objectui validate`; a nonsense id was refused by the enum and did not
type-check; and both rendered and emitted exactly what a filter without it
does.

The member is now a `?: never` tombstone on the TypeScript face and a
`retirementTombstone()` on the zod mirror (ADR-0049). The `filters[]` entry is
a plain object schema that strips an undeclared key, so deleting the
declaration would have dropped an authored value in silence rather than refused
it. The refusal is an `invalid_type` issue at the entry's own path
(`filters.N.operator`). No replacement key is named, because nothing in this
component implements operators: remove the key. How each value is matched is up
to the host that consumes the change.

The `filter-ui` docs page no longer lists `operator` in its Schema block and
states the retirement. A one-time census, recorded on the card's pull request
and not re-derived here, found no producer that writes the key and no reader
that honours it.

Pinned in `packages/types/src/__tests__/filter-ui-operator-retired-10611.test.ts`.
9 changes: 8 additions & 1 deletion content/docs/components/complex/filter-ui.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ interface FilterUISchema {
field: string;
label?: string;
type: 'text' | 'number' | 'select' | 'date' | 'date-range' | 'boolean';
operator?: 'equals' | 'contains' | 'startsWith' | 'gt' | 'lt' | 'between' | 'in';
options?: Array<{ label: string; value: any }>;
placeholder?: string;
}>;
Expand All @@ -48,3 +47,11 @@ interface FilterUISchema {
layout?: 'inline' | 'popover' | 'drawer';
}
```

### Retired: `filters[].operator`

A filter entry no longer takes an `operator` (objectui#10611). The component never
read one: it picks each control from `type` and reports changes as `{ values }`, a
field-to-value record with no operator in it, and it does no matching of its own. How
each value is matched is up to the host that consumes the change. An authored
`operator` is now refused by name when the schema is validated.
156 changes: 156 additions & 0 deletions packages/types/src/__tests__/filter-ui-operator-retired-10611.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* 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.
*/

/**
* Retirement pin — `FilterUISchema.filters[].operator` is REFUSED on the
* `filter-ui` node (objectui#10611, ADR-0049 enforce-or-remove).
*
* ## The failure this pin exists to prevent
*
* Both published faces declared a per-filter `operator` (a seven-member enum on
* the mirror, the same union on the TS face) and the docs page taught it, but
* `filter-ui`'s renderer never read it: it picks each control from `type` and
* reports a change as a field → value record with no operator in it (a host
* `onChange` function receives that bare record; only the authored window
* event's `detail` is `{ values }`), and it does no matching of its own. So
* `operator: 'gt'`, a member of that enum, type-checked and parsed green
* through `objectui validate`; a nonsense id was refused by the enum and did
* not type-check; and both rendered and emitted exactly what a filter without
* it does.
* The render reading and the producer census are recorded on the card's pull
* request as a one-time measurement; this file is the instrument that keeps the
* contract.
*
* ## Why a tombstone and not a deletion
*
* `operator` sits on the `filters[]` ELEMENT, a plain `z.object`, which STRIPS
* an undeclared key (it is not the node's `.passthrough()` — that one KEEPS
* it). Either way a deleted member would have been a silent accept; block (c)
* pins the element's strip on a misspelling, so the reason is a reading and not
* prose.
*
* ## How refusals are asserted
*
* By the issue ENVELOPE — `code` and `path`, and that it is the ONLY issue, so a
* refusal cannot ride on some other failure of the same document — at the public
* door `safeValidateSchema` (what `objectui validate` / `objectui check` run) and
* on the mirror. The wording is not pinned; that the message and the published
* `.describe()` metadata are one string is.
*
* The `@ts-expect-error` directive in block (d) is REAL enforcement: this
* package type-checks its tests through `tsconfig.test.json`, so re-widening the
* member fails the build on the unused directive (TS2578). A green `vitest` run
* is NOT evidence about it — type assertions are erased before it runs.
*/

import { describe, it, expect } from 'vitest';
import type { FilterUISchema } from '../views';
import { FilterUISchema as FilterUIMirror } from '../zod/views.zod';
import { safeValidateSchema } from '../zod/index.zod';

type FilterEntry = FilterUISchema['filters'][number];

const QTY = { field: 'qty', label: 'Qty', type: 'number' } as const;
const NAME = { field: 'name', label: 'Name', type: 'text' } as const;
const node = (filters: Array<Record<string, unknown>>) => ({ type: 'filter-ui', layout: 'inline', filters });

/**
* Every member of the retired enum (so a partial re-widening shows up), plus a
* spelling the enum never admitted and a nonsense id. The one-time render
* reading on the pull request sampled a subset of these (it lists which) and
* gave each sampled one the same output as no operator at all.
*/
const AUTHORED = ['equals', 'contains', 'startsWith', 'gt', 'lt', 'between', 'in', 'greater_than', 'zz_nonsense_op'];

type Issue = { code: string; path: PropertyKey[]; message: string };
const issuesOf = (r: { success: boolean; error?: { issues: Issue[] } }): Issue[] => r.error?.issues ?? [];

const elementShape = (
(FilterUIMirror.shape.filters as unknown as { element: { shape: Record<string, { description?: string }> } }).element
).shape;

/* ── (a) refused by name, at the entry's own path ─────────────────────────── */

describe('objectui#10611 (a) — `filters[].operator` is RETIRED on `filter-ui`', () => {
it.each(AUTHORED.map((op) => [op]))(
'refuses `operator: %s` at the public door and on the mirror: ONE `invalid_type` issue at the entry',
(op) => {
const doc = node([QTY, { ...NAME, operator: op }]);
for (const r of [safeValidateSchema(doc), FilterUIMirror.safeParse(doc)]) {
expect(r.success, `an authored \`operator: ${op}\` was ACCEPTED`).toBe(false);
expect(issuesOf(r).map(({ code, path }) => ({ code, path }))).toEqual([
{ code: 'invalid_type', path: ['filters', 1, 'operator'] },
]);
}
},
);

it('the refusal message and the published `.describe()` metadata are ONE string', () => {
const r = FilterUIMirror.safeParse(node([{ ...QTY, operator: 'gt' }]));
expect(issuesOf(r)[0]!.message).toBe(elementShape.operator!.description);
});

it('stays DECLARED on the entry shape — a tombstone, not a deletion (see block c)', () => {
expect(Object.keys(elementShape)).toContain('operator');
});
});

/* ── (b) LIT CONTROL: the same entries without `operator` parse ───────────── */

describe('objectui#10611 (b) — LIT CONTROL: a `filters[]` entry without `operator` parses, at both doors', () => {
it('the same two entries, no operator', () => {
const doc = node([QTY, NAME]);
expect(issuesOf(safeValidateSchema(doc))).toEqual([]);
expect(issuesOf(FilterUIMirror.safeParse(doc))).toEqual([]);
});

it('every surviving entry member, on one entry', () => {
const doc = node([
{ field: 'status', label: 'Status', type: 'select', placeholder: 'Any', options: [{ label: 'Open', value: 'open' }] },
]);
expect(issuesOf(safeValidateSchema(doc))).toEqual([]);
});
});

/* ── (c) why a tombstone: an undeclared entry key is STRIPPED in silence ──── */

describe('objectui#10611 (c) — CONTROL: an undeclared `filters[]` key is STRIPPED, not refused', () => {
it('a misspelled `operatr` parses green and is gone after the parse — what a deletion would have left', () => {
const r = FilterUIMirror.safeParse(node([{ ...QTY, operatr: 'gt' }]));
expect(r.success).toBe(true);
expect(Object.keys((r.data as { filters: Array<Record<string, unknown>> }).filters[0]!)).not.toContain('operatr');
});
});

/* ── (d) the TS twin carries the same contract ───────────────────────────── */

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;

export type assertionOperatorRetired = Expect<Equal<FilterEntry['operator'], undefined>>;
/** The helper can FAIL — control on a surviving optional member of the same entry. */
export type assertionEqualCanFail = Expect<Equal<Equal<FilterEntry['label'], undefined>, false>>;

describe('objectui#10611 (d) — the TS twin refuses what the mirror refuses', () => {
it('an authored `operator` is a compile error — checked by `tsc -p tsconfig.test.json`', () => {
// Not `as const`: a readonly literal would fail for a reason of its own, and
// the `@ts-expect-error` would pass without testing.
const refused: FilterUISchema = {
type: 'filter-ui',
// @ts-expect-error — retired: `filter-ui` does not read a per-filter operator
filters: [{ field: 'qty', type: 'number', operator: 'gt' }],
};
expect(refused.filters).toHaveLength(1);
});

it('CONTROL — the same entry without `operator` compiles', () => {
const node: FilterUISchema = { type: 'filter-ui', filters: [{ field: 'qty', type: 'number' }] };
expect(node.filters[0]!.type).toBe('number');
});
});
18 changes: 16 additions & 2 deletions packages/types/src/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1134,9 +1134,23 @@ export interface FilterUISchema extends BaseSchema {
*/
type: 'text' | 'number' | 'select' | 'multi-select' | 'date' | 'date-range' | 'boolean';
/**
* Filter operator
* RETIRED (objectui#10611, ADR-0049) — `filter-ui` never reads a per-filter
* operator. Its renderer (`FilterUI` in `@object-ui/plugin-view`) picks the
* control from `type` and emits `{ values }` only: a field → value record
* with no operator in it. It does no matching of its own, so how each value
* is matched is up to the host that consumes the change.
*
* Until this retirement the key was declared here, mirrored in zod with a
* seven-member enum, and taught by the docs page — yet an authored
* `operator: 'gt'`, or a nonsense id, rendered and emitted exactly what a
* filter without it does. Nothing in this component implements operators,
* so no replacement key is named: remove it. `?: never` here and a
* `retirementTombstone()` on the mirror refuse it by name; deleting the
* member would have let the mirror strip an authored value in silence.
*
* @deprecated Retired — `filter-ui` does not read it.
*/
operator?: 'equals' | 'contains' | 'startsWith' | 'gt' | 'lt' | 'between' | 'in';
operator?: never;
/**
* Options for select filter
*/
Expand Down
8 changes: 7 additions & 1 deletion packages/types/src/zod/views.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,13 @@ export const FilterUISchema = BaseSchema.extend({
field: z.string().describe('Filter field'),
label: z.string().optional().describe('Filter label'),
type: z.enum(['text', 'number', 'select', 'multi-select', 'date', 'date-range', 'boolean']).describe('Filter type'),
operator: z.enum(['equals', 'contains', 'startsWith', 'gt', 'lt', 'between', 'in']).optional().describe('Filter operator'),
operator: retirementTombstone(
'RETIRED (objectui#10611, ADR-0049) — `filter-ui` never reads a per-filter `operator`: the renderer '
+ 'picks the control from `type` and emits `{ values }` only, a field → value record with no operator '
+ 'in it, and does no matching of its own. An authored value changed nothing the component rendered '
+ 'or emitted. Nothing in this component implements operators, so remove the key; how each value is '
+ 'matched is up to the host that consumes the change.',
),
options: z.array(z.object({ label: z.string(), value: z.any() })).optional().describe('Options for select filter'),
placeholder: z.string().optional().describe('Placeholder'),
})).describe('Available filters'),
Expand Down
Loading