diff --git a/.changeset/7562-filter-field-type-fourteen-members.md b/.changeset/7562-filter-field-type-fourteen-members.md new file mode 100644 index 0000000000..f4825fdd25 --- /dev/null +++ b/.changeset/7562-filter-field-type-fourteen-members.md @@ -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). diff --git a/packages/types/src/__tests__/filter-builder-mirror-6939.test.ts b/packages/types/src/__tests__/filter-builder-mirror-6939.test.ts index 831832391c..0be5ad6aa5 100644 --- a/packages/types/src/__tests__/filter-builder-mirror-6939.test.ts +++ b/packages/types/src/__tests__/filter-builder-mirror-6939.test.ts @@ -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 @@ -130,7 +147,16 @@ function expectType(_: T = true as T): void { /* compile-time on // drifts back, which is the half a runtime assertion cannot cover. expectType>(); expectType>(); + | '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>(); expectType>(); // `id` is OPTIONAL, not merely typed `string | undefined`: an object that omits @@ -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 = [ + ['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', () => { @@ -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 @@ -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); }); }); @@ -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'); }); }); diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 9b7cc8c591..0b86a76a1b 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -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: - * `` `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 */ diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 5d981df7b0..489fe69f45 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -409,46 +409,66 @@ export const FilterGroupSchema: z.ZodType = z.lazy(() => * `…Clear allRemove condition…`, and the three value inputs degrade from * `text`/`number`/`number` to three `text` boxes. * - * ## The type vocabulary + * ## The type vocabulary — the FOURTEEN the published doc declares * - * `text` / `number` / `boolean` / `date` / `datetime` / `time` are the ruled - * six, and all six are live and MUTUALLY DISTINGUISHABLE at the value control - * (measured, one condition row each): `` `text`, `number`, `date`, - * `datetime-local`, `time`, and for `boolean` no input at all but an extra - * option Select. They are `FilterValueFamily` - * (`custom/filter-builder.tsx:406`), which `valueFamilyForFieldType` folds a - * column's `type` into and `FILTER_INPUT_TYPE_BY_FAMILY` draws from. + * Ruled on objectui#7562 (director seat, decision batch #88, 2026-09-08): of + * the three declarations of this one authoring surface — the published doc + * (`content/docs/components/complex/filter-builder.mdx`), the component, and + * this mirror — the DOC is the authority. The component already follows it, + * and a contract does not retract what it published to authors. So the enum + * below is the doc's fourteen in the doc's order, and `type` is OPTIONAL + * because the doc publishes `type?:` and the renderer reads `fieldType || + * "text"` (`custom/filter-builder.tsx:408`, and again at 964 for operators). * - * `string` LEAVES the vocabulary: it renders identically to a nonsense - * spelling, because both reach the text control by the unrecognised-word - * fallthrough rather than by being read. It is a phantom, and `text` is the - * spelling the registration's own `defaultProps` and all five catalog entries - * use. + * ⛔ The ruling carried a PRECONDITION, measured before this enum moved: + * every one of the fourteen has a renderer branch, because a key declared that + * nothing draws would have had to come OUT of the doc instead. Measured on + * `e3fb3b6` by driving one condition row per member through the real + * `FilterBuilder` and reading both the value control and the operator bucket. + * All fourteen passed and nothing was removed from the doc. The branches, in + * `custom/filter-builder.tsx`: * - * ⚠ `select` is RETAINED, which departs from a literal reading of the ruling's - * six. The ruling inherits the finding card's description of `select` as - * "extra"; measured, it is not. `selectLikeTypes = ["select", "status"]` - * (`custom/filter-builder.tsx:935`) is consumed by `operatorsForFieldType` - * (line 989, the `equals`/`in`/`notIn` bucket) and by - * `isOptionDrivenValueControl` (line 739), and a `select` column draws the - * option-driven Select rather than a text box — 39 elements and no ``, - * against 36 and one. Dropping it would REFUSE a spelling this mirror accepts - * today and the renderer draws distinctly, which is a fresh instance of the - * class this card closes. Flagged for contract review rather than decided here. + * - `numberLikeTypes:931` — `number`, `currency`, `percent`, `rating` + * ⇒ `` and the numeric bucket (`greaterThan` / + * `lessThan` / `greaterOrEqual` / `lessOrEqual`). + * - `dateLikeTypes:933`, plus the three equality tests at 411-413 — `date`, + * `datetime`, `time` ⇒ `` `date` / `datetime-local` / `time` + * and `before` / `after` / `between`. + * - `boolean` is its own family (line 410) ⇒ no `` at all but a + * two-item Select, and a two-operator bucket nothing else has. + * - `selectLikeTypes:935` — `select`, `status`; `lookupLikeTypes:947` — + * `lookup`, `master_detail`, `user` ⇒ the option-driven Select (a THIRD + * combobox on the row, no ``) and the relational `in` / `notIn` + * bucket. With no static option domain but a `referenceTo` — or + * `type: 'user'`, which defaults its own — all three lookup-like members + * draw the remote search picker instead (line 1277). * - * ⚠ NOT declared, and NOT a regression this change introduces: `status`, - * `currency`, `percent`, `rating`, `lookup`, `master_detail` and `user` are - * live spellings with their own buckets and controls (`number` inputs for the - * first four by way of `numberLikeTypes`, the option Select for the last three - * by way of `lookupLikeTypes`) and every one of them is refused by this mirror - * BEFORE this change as well as after. Reported on objectui#6939 as a - * pre-existing gap; widening to them is an accept-set change the ruling does - * not cover. + * ⚠ `text` is the fourteenth and its branch is BY NAME, not by a distinct + * control: it IS the unrecognised-word fallthrough target, so a `text` column + * measures identical to a nonsense spelling AND to an absent `type` — same 35 + * elements, same ``, same operator bucket, all three. What + * makes it READ rather than phantom is that the renderer names it: line 408 is + * where an absent `type` acquires the family called `text`, and `text` is a + * `FilterValueFamily` member (line 405) and a `FILTER_INPUT_TYPE_BY_FAMILY` + * key (line 431). The ruling says the same from the other side — "`text` when + * absent, as the renderer already reads it" — so its optional half cannot land + * while `text` is deleted. + * + * `string` stays OUT, and that is the contrast the paragraph above turns on: + * it is named NOWHERE in the renderer, so it reaches the text control only by + * the fallthrough. A phantom, removed by objectui#6939 and not restored here; + * the published doc does not offer it either, so the two faces agree. */ export const FilterFieldSchema = z.object({ value: z.string().describe('Field key — the identity every read site matches on'), label: z.string().describe('Field label'), - type: z.enum(['text', 'number', 'boolean', 'date', 'datetime', 'time', 'select']).describe('Field type'), + type: z.enum([ + 'text', 'number', 'currency', 'percent', 'rating', + 'date', 'datetime', 'time', + 'boolean', + 'select', 'status', + 'lookup', 'master_detail', 'user', + ]).optional().describe('Field type — the published doc\'s fourteen; `text` when absent'), operators: z.array(FilterOperatorSchema).optional().describe('Available operators'), options: z.array(z.object({ label: z.string(),