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
40 changes: 40 additions & 0 deletions .changeset/7562-filter-field-type-fourteen-members.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@object-ui/types': minor
---

`FilterFieldSchema.type` / `FilterField['type']`: widen to the fourteen field types the
published doc declares, and make the key OPTIONAL (objectui#7562, director seat,
decision batch #88, 2026-09-08).

**What was wrong.** One authoring surface had three declarations that disagreed. The
published doc (`content/docs/components/complex/filter-builder.mdx`) offers fourteen
`type` members and marks the key optional; the renderer follows the doc — it buckets all
fourteen by name and reads `fieldType || "text"` when the key is absent; this mirror
offered seven and REQUIRED the key. So a `fields` entry written against our own
documentation, which the component renders correctly, was refused by our own validator.
The ruling made the published doc the authority: a contract does not retract what it
published to authors.

**What changes.** `type` now accepts `text` · `number` · `currency` · `percent` ·
`rating` · `date` · `datetime` · `time` · `boolean` · `select` · `status` · `lookup` ·
`master_detail` · `user`, and may be omitted (absent means `text`). Widening only —
every document that validated before still validates. `{ value, label }` with no `type`
now validates, as does `{ value, label, type: 'currency' }`.

**Still refused, deliberately.** `string` stays out. It is named nowhere in the renderer
and reaches the text control only by the unrecognised-word fallthrough, so it is
indistinguishable from a nonsense spelling — the phantom objectui#6939 removed. `text`
shares that fallthrough but IS named (`valueFamilyForFieldType`'s `fieldType || "text"`),
which is the whole difference between the two. The vocabulary also stays CLOSED: an
unrecognised spelling is still refused, so this is not "`type` stopped being checked".

**Measured before it moved.** The ruling's precondition was that every one of the
fourteen has a renderer branch — a member that nothing draws would have come OUT of the
doc instead. One condition row per member was driven through the real `FilterBuilder`
and both the value control and the operator bucket were read. All fourteen have a
branch; nothing was withdrawn from the doc. The table is in `FilterFieldSchema`'s
docblock, bucket by bucket with the file:line that carries each one.

**Not in this change.** `FilterBuilderConditionSchema.id` is objectui#8415, the filter
OPERATOR vocabulary is objectui#7561, and the filter GROUP's `id` stays optional
(objectui#7560 measured zero read sites for it — the same answer does not transfer).
141 changes: 123 additions & 18 deletions packages/types/src/__tests__/filter-builder-mirror-6939.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@
* renders byte-identically (the render half measures that). Requiring it
* would invent a refusal the renderer does not make.
*
* ## objectui#7562 — the vocabulary widened to the published doc's FOURTEEN
*
* Ruled 2026-09-08 (director seat, decision batch #88): of the three
* declarations of this authoring surface the published doc is the AUTHORITY,
* so `type` widens to its fourteen members and becomes OPTIONAL (`text` when
* absent). The ruling's precondition — every one of the fourteen has a
* renderer branch, or it comes out of the doc instead — was measured first,
* one condition row per member through the real `FilterBuilder`, reading both
* the value control and the operator bucket. All fourteen passed; nothing was
* withdrawn from the doc. `FilterFieldSchema`'s docblock carries the table.
*
* The assertions below therefore MOVED, not merely widened: the seven
* `still refuses the live-but-unruled spelling …` pins became
* `accepts …, and the renderer draws it`, and the doc-vs-mirror assertion's
* closing line — which pinned that the mirror still required `type` — became
* its opposite. That is the whole delta objectui#7562 lands here.
*
* ## What this change does NOT reach, stated rather than left as an absence
*
* Two of the four census entries — `product-search` and `with-conditions`, plus
Expand Down Expand Up @@ -130,7 +147,16 @@ function expectType<T extends true>(_: T = true as T): void { /* compile-time on
// drifts back, which is the half a runtime assertion cannot cover.
expectType<Equal<TsFilterField['value'], string>>();
expectType<Equal<TsFilterField['type'],
'text' | 'number' | 'boolean' | 'date' | 'datetime' | 'time' | 'select'>>();
| 'text' | 'number' | 'currency' | 'percent' | 'rating'
| 'date' | 'datetime' | 'time'
| 'boolean'
| 'select' | 'status'
| 'lookup' | 'master_detail' | 'user'
| undefined>>();
// `type` is OPTIONAL, not merely `… | undefined`: an entry that OMITS the key
// must be assignable, which only this annotation proves — the same distinction
// the group `id` needs three lines down (objectui#7562, item 2).
const fieldWithoutType: TsFilterField = { value: 'a', label: 'A' };
expectType<Equal<TsFilterGroup['logic'], 'and' | 'or'>>();
expectType<Equal<TsFilterGroup['id'], string | undefined>>();
// `id` is OPTIONAL, not merely typed `string | undefined`: an object that omits
Expand Down Expand Up @@ -174,12 +200,37 @@ describe('objectui#6939 — the field key is `value`', () => {
const RULED = ['text', 'number', 'boolean', 'date', 'datetime', 'time'] as const;

/**
* Live field-type spellings this mirror refuses BEFORE this change and still
* refuses after — each with its own bucket in `custom/filter-builder.tsx`
* (`numberLikeTypes` for the first four, `selectLikeTypes`/`lookupLikeTypes`
* for the rest) and its own value control.
* The seven the published doc declares that this mirror refused until
* objectui#7562 — each with its own named bucket in
* `custom/filter-builder.tsx` and its own value control, which is exactly why
* the ruling let them in rather than cutting them out of the doc.
*
* Paired with the bucket that CARRIES each one, so "it draws" is asserted
* against the renderer source rather than restated as prose. The buckets are
* literal `const` arrays, so a rename or a deletion in the component turns
* this red instead of leaving the mirror declaring a key nothing draws.
*/
const UNRULED_LIVE_TYPES = ['status', 'currency', 'percent', 'rating', 'lookup', 'master_detail', 'user'] as const;
const DOC_ONLY_TYPES: ReadonlyArray<readonly [string, string]> = [
['currency', 'const numberLikeTypes = ["number", "currency", "percent", "rating"]'],
['percent', 'const numberLikeTypes = ["number", "currency", "percent", "rating"]'],
['rating', 'const numberLikeTypes = ["number", "currency", "percent", "rating"]'],
['status', 'const selectLikeTypes = ["select", "status"]'],
['lookup', 'const lookupLikeTypes = ["lookup", "master_detail", "user"]'],
['master_detail', 'const lookupLikeTypes = ["lookup", "master_detail", "user"]'],
['user', 'const lookupLikeTypes = ["lookup", "master_detail", "user"]'],
];

/** The seven spellings above, for the places that only need the names. */
const UNRULED_LIVE_TYPES = DOC_ONLY_TYPES.map(([type]) => type);

/** Every member the published doc offers — the accept set after objectui#7562. */
const DOCUMENTED_FOURTEEN = [
'text', 'number', 'currency', 'percent', 'rating',
'date', 'datetime', 'time',
'boolean',
'select', 'status',
'lookup', 'master_detail', 'user',
] as const;

describe('objectui#6939 — the type vocabulary', () => {

Expand All @@ -204,18 +255,70 @@ describe('objectui#6939 — the type vocabulary', () => {
expect(FilterFieldSchema.safeParse({ value: 'a', label: 'A', type: 'string' }).success).toBe(false);
});

it.each(UNRULED_LIVE_TYPES)(
'still refuses the live-but-unruled spelling `%s` — a PRE-EXISTING gap, not a regression here',
(type) => {
// ⚠️ Each of these has its own bucket in the component and draws its own
// control, and each was refused by this mirror BEFORE this change as well
// as after. Widening to them is an accept-set change the ruling does not
// cover; reported on objectui#6939 instead of taken here. This assertion
// exists so the gap is a recorded decision rather than an absence.
expect(FilterFieldSchema.safeParse({ value: 'a', label: 'A', type }).success).toBe(false);
it.each(DOC_ONLY_TYPES)(
'accepts `%s`, and the renderer draws it — the bucket that carries it is `%s`',
(type, bucket) => {
// objectui#7562, items 1+2. This assertion is the INVERSE of the one it
// replaced (`still refuses the live-but-unruled spelling …`): the ruling
// made the published doc the authority, so a member the doc offers and
// the renderer draws is a member this mirror accepts.
//
// The two halves are asserted together on purpose. Accepting a spelling
// is only correct while the renderer still has a branch for it, and the
// ruling's precondition is exactly that pairing — "⛔ never a key
// declared that nothing draws". So the accept and the branch that earns
// it redden as one.
expect(FilterFieldSchema.safeParse({ value: 'a', label: 'A', type }).success).toBe(true);
expect(readFileSync(join(REPO_ROOT, READER), 'utf8')).toContain(bucket);
},
);

it('`text` earns its place by NAME, not by a distinct control', () => {
// ⚠️ The one member whose branch a DOM measurement cannot show. `text` is
// the unrecognised-word fallthrough TARGET, so a `text` column draws what
// a nonsense spelling draws; measured, they are identical. What separates
// it from the `string` phantom below is that the renderer NAMES it — line
// 408 is where an absent `type` acquires the family called `text` — and
// that naming is also what makes `type` safe to leave optional, which is
// why these two assertions live in one test.
const src = readFileSync(join(REPO_ROOT, READER), 'utf8');
expect(src).toContain('const type = fieldType || "text"');
// Pinned in FULL, because the claim is about the whole list: these are the
// six family names, and `string` is not one of them.
expect(src).toContain(
'type FilterValueFamily = "text" | "number" | "boolean" | "date" | "datetime" | "time"',
);
expect(FilterFieldSchema.safeParse({ value: 'a', label: 'A', type: 'text' }).success).toBe(true);
// …and `string`, which the renderer names in no bucket (the four are
// pinned verbatim in the tests above) and in no field-type equality test,
// is still refused. The pair is the point: one fallthrough, two verdicts,
// decided by whether the renderer says the word.
expect(src).not.toContain('type === "string"');
expect(FilterFieldSchema.safeParse({ value: 'a', label: 'A', type: 'string' }).success).toBe(false);
});

it('`type` is OPTIONAL — the renderer reads `fieldType || "text"`', () => {
// objectui#7562 item 2. `{ value, label }` is what `FilterBuilderProps`
// declares (`type?: string`) and what a field list stripped of `type`
// renders as: three text columns, every row still drawn. The mirror
// refused it until this change, which is the divergence being closed.
expect(FilterFieldSchema.safeParse({ value: 'a', label: 'A' }).success).toBe(true);
// Absent is not the same as PRESENT-and-nonsense: the vocabulary is still
// closed, so this widening cannot be read as "type stopped being checked".
expect(FilterFieldSchema.safeParse({ value: 'a', label: 'A', type: 'zzz' }).success).toBe(false);
expect(FilterFieldSchema.safeParse({ value: 'a', label: 'A', type: undefined }).success).toBe(true);
});

it('the accept set is EXACTLY the published doc, member for member', () => {
// The set equality, not fourteen individual accepts: an enum that had
// gained a fifteenth member the doc never published would pass every
// per-member assertion above and fail only here.
const declared = (FilterFieldSchema as unknown as {
shape: { type: { unwrap(): { options: string[] } } };
}).shape.type.unwrap().options;
expect([...declared].sort()).toEqual([...DOCUMENTED_FOURTEEN].sort());
});

it('the gap is measured against the PUBLISHED doc, not against a private opinion', () => {
// `content/docs/components/complex/filter-builder.mdx` is a THIRD
// declaration of this component's authoring surface, independent of both
Expand All @@ -230,10 +333,11 @@ describe('objectui#6939 — the type vocabulary', () => {
for (const type of [...RULED, 'select', ...UNRULED_LIVE_TYPES]) {
expect(doc, `the published doc no longer offers \`${type}\``).toContain(`'${type}'`);
}
// …and the doc declares `type` OPTIONAL, which this mirror still does not.
// A fourth pre-existing gap, recorded for the same reason as the seven.
// …and the doc declares `type` OPTIONAL, which this mirror now does too.
// Read from the doc rather than restated, so narrowing the DOC to a
// required `type` reddens here as well — the wrong direction, both ways.
expect(doc).toContain('type?:');
expect(FilterFieldSchema.safeParse({ value: 'a', label: 'A' }).success).toBe(false);
expect(FilterFieldSchema.safeParse({ value: 'a', label: 'A' }).success).toBe(true);
});
});

Expand Down Expand Up @@ -356,5 +460,6 @@ describe('objectui#6939 — the keys are DECLARED, not passthrough holes', () =>
it('the unused type-level bindings above are referenced, so lint keeps them', () => {
expect(groupWithoutId.conditions).toEqual([]);
expect(legacyNamedField).toBeDefined();
expect(fieldWithoutType.value).toBe('a');
});
});
43 changes: 25 additions & 18 deletions packages/types/src/complex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,24 +727,31 @@ export interface FilterField {
*/
label: string;
/**
* Field type — the value FAMILY the column is edited in.
*
* The six ruled members are `FilterValueFamily`
* (`custom/filter-builder.tsx:406`) and each draws a distinct control:
* `<input type>` `text` / `number` / `date` / `datetime-local` / `time`, and
* for `boolean` a two-item Select and no input at all. `string` left this
* union as a phantom — it reached the text control by the unrecognised-word
* fallthrough, indistinguishable from a nonsense spelling, while `text` is
* what the registration's `defaultProps` and every catalog entry author.
*
* `select` is RETAINED against a literal reading of the ruling's six:
* `selectLikeTypes` gives it its own operator bucket and the option-driven
* Select, so dropping it would refuse a live spelling. `status`, `currency`,
* `percent`, `rating`, `lookup`, `master_detail` and `user` are live too and
* are still absent — a pre-existing gap reported on objectui#6939, not a
* regression introduced here.
*/
type: 'text' | 'number' | 'boolean' | 'date' | 'datetime' | 'time' | 'select';
* Field type — the value FAMILY the column is edited in. OPTIONAL: absent
* means `text`, which is what `valueFamilyForFieldType` and
* `operatorsForFieldType` both read (`fieldType || "text"`,
* `custom/filter-builder.tsx:408` and `:964`).
*
* The fourteen members are the published doc's
* (`content/docs/components/complex/filter-builder.mdx`), which objectui#7562
* ruled the authority for this authoring surface, in the doc's own order.
* Every one of them was measured to have a renderer branch before this union
* widened — the ruling's precondition — and none had to be withdrawn from the
* doc. The mirror's docblock (`zod/complex.zod.ts`, `FilterFieldSchema`)
* carries the branch-by-branch table with the file:line for each bucket.
*
* `string` is still absent and is the contrast that makes the rest read: it
* is named nowhere in the renderer and reaches the text control only by the
* unrecognised-word fallthrough, so it is a phantom (objectui#6939). `text`
* shares that fallthrough but IS named — line 408 is where an absent `type`
* acquires it — which is why one is declared and the other is not.
*/
type?:
| 'text' | 'number' | 'currency' | 'percent' | 'rating'
| 'date' | 'datetime' | 'time'
| 'boolean'
| 'select' | 'status'
| 'lookup' | 'master_detail' | 'user';
/**
* Available operators for this field
*/
Expand Down
Loading
Loading