From 1c6fc8ccd2054ce0bd58d31265076b959a7ce38a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 16:25:16 +0000 Subject: [PATCH 1/4] feat(types,fields): the formula and summary widgets read the spec's returnType and summaryOperations (objectui#11070) The form-field face declares `returnType` and `summaryOperations` by reference to the spec's `FieldSchema` members, on both faces, with the form-field-zod-coverage row. `FormulaField` reads `returnType` and `SummaryField` reads `summaryOperations.function`; the snake_case reads are gone. `FormulaFieldMetadata.return_type` and `SummaryFieldMetadata`'s `summary_object` / `summary_field` / `summary_type` / `summary_filter` retire for the spec members, and `PasswordFieldMetadata.min_length` / `max_length` for `minLength` / `maxLength`. Refs objectui#11070 (round 3). Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh Co-authored-by: Claude --- packages/fields/src/widgets/FormulaField.tsx | 11 ++- packages/fields/src/widgets/SummaryField.tsx | 8 ++- .../__tests__/form-field-zod-coverage.test.ts | 4 ++ .../__tests__/imported-defaults-8317.test.ts | 7 +- .../strict-face-read-keys-11070.test.ts | 72 +++++++++++++++---- packages/types/src/field-types.ts | 58 ++++++++++----- packages/types/src/form.ts | 42 +++++++---- packages/types/src/zod/form.zod.ts | 5 ++ 8 files changed, 154 insertions(+), 53 deletions(-) diff --git a/packages/fields/src/widgets/FormulaField.tsx b/packages/fields/src/widgets/FormulaField.tsx index 5a760fb20c..8a21b86f94 100644 --- a/packages/fields/src/widgets/FormulaField.tsx +++ b/packages/fields/src/widgets/FormulaField.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { EmptyValue } from '@object-ui/components'; import { useDisplayLocale } from '@object-ui/i18n'; import { formatDate, toDisplayDate } from '@object-ui/core'; +import type { FormulaFieldMetadata } from '@object-ui/types'; import { FieldWidgetComponentProps } from './types.js'; /** @@ -13,15 +14,19 @@ export function FormulaField({ value, field, ...props }: FieldWidgetComponentPro // a value flips between null and set. A `date` return type used to format // through the MACHINE's locale (objectui#4468). const locale = useDisplayLocale(); - const formulaField = field as any; - const returnType = formulaField?.return_type || 'text'; + // The spec's `returnType` (`number` / `text` / `boolean` / `date`), the + // spelling object metadata carries — authoring stamps it from the inferred + // CEL type. It is the ONLY spelling read: the snake_case `return_type` this + // widget used to read is retired, so an object-bound formula field is + // formatted by what its definition declares (objectui#11070). + const returnType = (field as FormulaFieldMetadata | undefined)?.returnType ?? 'text'; if (value == null) { return ; } let displayValue: string; - if (returnType === 'number' || returnType === 'currency') { + if (returnType === 'number') { displayValue = typeof value === 'number' ? value.toFixed(2) : String(value); } else if (returnType === 'boolean') { displayValue = value ? 'Yes' : 'No'; diff --git a/packages/fields/src/widgets/SummaryField.tsx b/packages/fields/src/widgets/SummaryField.tsx index 61df1df8ff..5d2f7f18de 100644 --- a/packages/fields/src/widgets/SummaryField.tsx +++ b/packages/fields/src/widgets/SummaryField.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { EmptyValue } from '@object-ui/components'; +import type { SummaryFieldMetadata } from '@object-ui/types'; import { FieldWidgetComponentProps } from './types.js'; /** @@ -7,8 +8,11 @@ import { FieldWidgetComponentProps } from './types.js'; * Values are aggregated from related records and cannot be edited */ export function SummaryField({ value, field, ...props }: FieldWidgetComponentProps) { - const summaryField = field as any; - const summaryType = summaryField?.summary_type || 'count'; + // The aggregation `function` of the spec's `summaryOperations` roll-up + // (`{ object, field, function }`), the shape object metadata carries. It is + // the ONLY spelling read: the snake_case `summary_type` this widget used to + // read is retired (objectui#11070). + const summaryType = (field as SummaryFieldMetadata | undefined)?.summaryOperations?.function ?? 'count'; if (value == null) { return ; diff --git a/packages/types/src/__tests__/form-field-zod-coverage.test.ts b/packages/types/src/__tests__/form-field-zod-coverage.test.ts index e387f3d6bf..909279e19b 100644 --- a/packages/types/src/__tests__/form-field-zod-coverage.test.ts +++ b/packages/types/src/__tests__/form-field-zod-coverage.test.ts @@ -90,6 +90,10 @@ const DECLARED_KEYS = [ 'minLength', 'maxLength', 'pattern', + // objectui#11070 round 3 — the spec spellings the `formula` and `summary` + // widgets read (their snake_case forms are retired), by reference too. + 'returnType', + 'summaryOperations', ]; describe('FormFieldSchema covers the FormField contract', () => { diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index d1f26daa99..cba3976e02 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -287,9 +287,10 @@ const IMPORTED: Array = [ // like every other one. ['ElementNumberPropsSchema', SpecElementNumberPropsSchema], ['ElementDataSourceSchema', SpecElementDataSourceSchema], - // objectui#11070: `FormFieldSchema` reads nine of the spec's `FieldSchema` - // members by reference (the field metadata a hand-authored form writes on - // the entry itself), and `multiple` carries the spec's `.default(false)` — + // objectui#11070: `FormFieldSchema` reads spec `FieldSchema` members by + // reference (the field metadata a hand-authored form writes on the entry + // itself; `FormFieldSchema.shape` lists them), and `multiple` carries the + // spec's `.default(false)` — // exactly what this boundary exists to keep out of a parse output. ['FieldSchema', SpecFieldSchema], ] as const; diff --git a/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts b/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts index 43491e256d..b8830263f1 100644 --- a/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts +++ b/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts @@ -22,8 +22,9 @@ * - `form.showSubmit` — the `form` renderer's submit-button switch; * - `form.fields[]` — field metadata a hand-authored form writes on the entry * itself (`multiple`, `rows`, `accept`, `dimensions`, `reference`, `min`, - * `max`, `minLength`, `maxLength`, `pattern`), which the renderer hands - * each field widget as its metadata carrier; + * `max`, `minLength`, `maxLength`, `pattern`, and since round 3 + * `returnType` and `summaryOperations`), which the renderer hands each + * field widget as its metadata carrier; * - `dataSource` on `object-grid`, `object-form`, `object-kanban`, * `list-view`, `object-gantt`, `object-map` and `object-calendar` — the * spec's per-element binding, which each block's @@ -39,8 +40,11 @@ * shows the face did not open up; * 2. the declared key is judged by its declared type on BOTH faces; * 3. the read keys this card deliberately did NOT declare are still refused - * on the strict face — each waits on a ruling the card records; - * 4. the TypeScript faces type the binding, not `any` (type-level, read by + * on the strict face — each waits on a ruling the card records — and the + * snake_case spellings round 3 retired are refused by name; + * 4. the TypeScript faces type the binding, not `any`, and the field + * metadata types carry the spec members by reference with the retired + * snake_case members gone (type-level, read by * `tsc -p tsconfig.test.json` only — vitest does not typecheck). */ @@ -57,7 +61,9 @@ import type { ObjectMapSchema, } from '../objectql.js'; import type { FormField, FormSchema } from '../form.js'; +import type { FormulaFieldMetadata, PasswordFieldMetadata, SummaryFieldMetadata } from '../field-types.js'; import type { ElementDataSource as SpecElementDataSource } from '@objectstack/spec/ui'; +import type { Field as SpecField } from '@objectstack/spec/data'; import { AnyComponentSchema, StrictAnyComponentSchema } from '../zod/index.zod.js'; type Issue = { code: string; path: PropertyKey[]; keys?: string[]; errors?: Issue[][] }; @@ -104,6 +110,8 @@ describe('objectui#11070 — the declared read keys parse on the strict face', ( ['minLength', { type: 'input', minLength: 3 }], ['maxLength', { type: 'input', maxLength: 20 }], ['pattern', { type: 'input', pattern: '^[^@]+@[^@]+$' }], + ['returnType', { type: 'formula', returnType: 'number' }], + ['summaryOperations', { type: 'summary', summaryOperations: { object: 'orders', field: 'amount', function: 'sum' } }], ]; it.each(FIELD_CASES)('`fields[].%s` parses; a misspelled sibling is refused at the field', (key, field) => { @@ -142,6 +150,10 @@ describe('objectui#11070 — a declared key is judged by its declared type on bo ['a bare-string `accept` (the spec types it as an array)', form({ type: 'file', accept: 'application/pdf' })], ['a zero `rows` (the spec requires a positive integer)', form({ type: 'textarea', rows: 0 })], ['a numeric `pattern`', form({ type: 'input', pattern: 5 })], + ['a `returnType` the spec does not list (`datetime`)', form({ type: 'formula', returnType: 'datetime' })], + ['a `summaryOperations` with no `function`', form({ type: 'summary', summaryOperations: { object: 'orders', field: 'amount' } })], + ['a `summaryOperations.function` the spec does not list (`first`)', form({ type: 'summary', summaryOperations: { object: 'orders', field: 'amount', function: 'first' } })], + ['an unknown member inside `summaryOperations` (the spec closes it)', form({ type: 'summary', summaryOperations: { object: 'orders', field: 'amount', function: 'sum', functon: 'avg' } })], ['a binding that names no `object`', { type: 'object-kanban', dataSource: { filter: { a: 1 } } }], ['an adapter-shaped `dataSource`', { type: 'object-grid', objectName: 'task', dataSource: 'objectstack' }], ]; @@ -159,16 +171,11 @@ describe('objectui#11070 — the read keys left undeclared pending a ruling stay // second spelling of a spec key this card DID declare (`reference_to` / // `reference`, `min_length` / `minLength`): the seat's answer on // objectui#11070 keeps the legacy spelling refused, so an author writes the - // spec's. Three wait on objectui#11070's later rounds. `return_type` and - // `summary_type` are ruled (the seat's answer A): the widgets move to the - // spec's `returnType` and `summaryOperations`. That move waits on this face - // declaring those two members, since today the strict face refuses them by - // name as well, so moving the reads first would only rename the refused key. - // The grid field's `columns` has an element shape not yet decided. ⛔ - // Declaring one is a contract ruling, not a fix to this list. + // spec's. `reference_to` is still WRITTEN by in-repo producers that feed the + // lookup readers, so its reads stay until that is ruled. The grid field's + // `columns` has an element shape not yet decided. ⛔ Declaring one is a + // contract ruling, not a fix to this list. const PENDING: ReadonlyArray]> = [ - ['return_type', { type: 'formula', return_type: 'number' }], - ['summary_type', { type: 'summary', summary_type: 'sum' }], ['reference_to', { type: 'lookup', reference_to: 'users' }], ['min_length', { type: 'password', min_length: 8 }], ['columns', { type: 'grid', columns: [{ name: 'qty', type: 'number' }] }], @@ -179,6 +186,22 @@ describe('objectui#11070 — the read keys left undeclared pending a ruling stay expect(issuesOf(AnyComponentSchema, form(field))).toBeNull(); }); + // Round 3 (the seat's answer A): the `formula` and `summary` widgets read the + // spec's `returnType` and `summaryOperations` only, so these snake_case + // spellings are read by nothing and retired at once — no alias, no dual + // read. The strict face refuses each by name, as before; what changed is + // that the spec spelling beside it is now declared (block 1). + const RETIRED: ReadonlyArray]> = [ + ['return_type', { type: 'formula', return_type: 'number' }], + ['summary_type', { type: 'summary', summary_type: 'sum' }], + ['summary_object', { type: 'summary', summary_object: 'orders' }], + ['summary_field', { type: 'summary', summary_field: 'amount' }], + ]; + + it.each(RETIRED)('the retired `fields[].%s` is refused by name', (key, field) => { + expect(undeclared(issuesOf(StrictAnyComponentSchema, form(field)))).toEqual([`fields.0.${key}`]); + }); + it('`object-chart.dataSource` is refused by name until it is declared (the react-page wrapper no longer puns the adapter into that key; the declaration and the objectui#10770 node pin move together)', () => { const doc = { type: 'object-chart', objectName: 'task', chartType: 'bar', dataSource: { object: 'task' } }; expect(undeclared(issuesOf(StrictAnyComponentSchema, doc))).toEqual(['dataSource']); @@ -195,9 +218,30 @@ type IsAny = 0 extends 1 & T ? true : false; /** Each declared member is a named, typed member — not the index signature's `any`. */ export type assertionFormFieldMembersAreTyped = Expect, + | FormField['reference'] | FormField['min'] | FormField['max'] | FormField['minLength'] | FormField['maxLength'] | FormField['pattern'] + | FormField['returnType'] | FormField['summaryOperations']>, false >>; +/** + * Round 3: the spec members are carried BY REFERENCE, on the form-field face + * and on the field metadata types — an exact match, so a hand-restated copy + * (or a drift after a spec release) fails here. + */ +export type assertionSpecMembersByReference = [ + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, +]; +/** Round 3: the retired snake_case members are gone from the field metadata types — no second spelling. */ +type Retired = 'return_type' | 'summary_type' | 'summary_object' | 'summary_field' | 'summary_filter' | 'min_length' | 'max_length'; +export type assertionRetiredMembersAreGone = [ + Expect, never>>, + Expect, never>>, + Expect, never>>, +]; export type assertionShowSubmitIsBoolean = Expect>; /** * Each binding member IS the spec's `ElementDataSource` — an exact match, not a diff --git a/packages/types/src/field-types.ts b/packages/types/src/field-types.ts index 0e11d65bea..54d6e8bd1a 100644 --- a/packages/types/src/field-types.ts +++ b/packages/types/src/field-types.ts @@ -607,8 +607,20 @@ export interface UrlFieldMetadata extends BaseFieldMetadata { */ export interface PasswordFieldMetadata extends BaseFieldMetadata { type: 'password'; - min_length?: number; - max_length?: number; + /** + * Minimum character length — `@objectstack/spec`'s `FieldSchema.minLength`, + * typed BY REFERENCE (objectui#11070). The form's validation rules + * (`buildValidationRules` in `@object-ui/fields`) read it for every field + * type and enforce it at submit. It replaces the retired snake_case + * `min_length`, which the spec refuses by name. + */ + minLength?: SpecField['minLength']; + /** + * Maximum character length — `@objectstack/spec`'s `FieldSchema.maxLength`, + * typed BY REFERENCE and read the same way as {@link minLength}. It replaces + * the retired snake_case `max_length`. + */ + maxLength?: SpecField['maxLength']; } /** @@ -792,9 +804,19 @@ export interface FormulaFieldMetadata extends BaseFieldMetadata { */ formula?: string; /** - * Return type of the formula + * The value type the formula computes — `@objectstack/spec`'s + * `FieldSchema.returnType`, typed BY REFERENCE so the two cannot drift + * (objectui#11070). `FormulaField` formats the computed value by it: a + * number to two decimals, a boolean as Yes/No, a date through the shared + * date face, anything else as text (the default when it is absent). + * + * It replaces the retired snake_case `return_type`, which the spec refuses + * by name and which no reader reads any more: the spec spelling is the one + * object metadata carries (authoring stamps it from the inferred CEL type), + * so an object-bound formula field is formatted by what its definition + * declares. */ - return_type?: 'text' | 'number' | 'boolean' | 'date' | 'datetime'; + returnType?: SpecField['returnType']; /** * Whether to recompute on dependency changes */ @@ -808,21 +830,21 @@ export interface FormulaFieldMetadata extends BaseFieldMetadata { export interface SummaryFieldMetadata extends BaseFieldMetadata { type: 'summary'; /** - * Related object to summarize from - */ - summary_object?: string; - /** - * Field to aggregate in the related object - */ - summary_field?: string; - /** - * Aggregation type - */ - summary_type?: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'first' | 'last'; - /** - * Filter condition for summarized records + * The roll-up definition — `@objectstack/spec`'s + * `FieldSchema.summaryOperations`, typed BY REFERENCE so the two cannot + * drift (objectui#11070): the child `object`, the child `field` to + * aggregate, the aggregation `function` (`count` / `sum` / `min` / `max` / + * `avg`), and optionally the child's `relationshipField` and a `filter` + * restricting which child rows are aggregated. `SummaryField` formats the + * value by `function`: `count` as it arrives, the other four to two + * decimals. + * + * It replaces four retired snake_case members, one per part: + * `summary_object` (now `object`), `summary_field` (now `field`), + * `summary_type` (now `function`) and `summary_filter` (now `filter`). The + * spec refuses them, and no reader reads them any more. */ - summary_filter?: Record; + summaryOperations?: SpecField['summaryOperations']; /** * Whether to auto-update on related record changes */ diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index d8499485d0..614748481f 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -1932,19 +1932,18 @@ export interface FormField { // typed BY REFERENCE to that member, so the two cannot drift; `pattern` is // the one the spec does not declare. // - // ⛔ Not every key a widget reads off the carrier is declared here. Four are - // read in a snake_case spelling beside a spec key of the same meaning — - // `return_type` (spec `returnType`), `summary_type` (spec - // `summaryOperations`), `reference_to` (spec `reference`) and `min_length` - // (spec `minLength`) — and none of the four is declared: a second spelling - // is not added to this contract. Where the widgets already read the SPEC - // spelling too, that spelling is the one declared (`reference`, below; - // `minLength`, above), and `reference_to` / `min_length` stay refused by the - // strict face (the seat's answer on objectui#11070). `return_type` and - // `summary_type` have no read of the spec spelling to declare. The grid - // field's `columns` is read too, but its element shape is undecided: the - // declared `GridColumnDefinition` (`./field-types.ts`) is not the shape - // `GridField` reads. Those three remain open on objectui#11070. + // ⛔ Not every key a widget reads off the carrier is declared here. Two are + // still read in a snake_case spelling beside a spec key of the same + // meaning — `reference_to` (spec `reference`) and `min_length` (spec + // `minLength`) — and neither is declared: a second spelling is not added + // to this contract. The SPEC spelling is the one declared (`reference`, + // below; `minLength`, above), and `reference_to` / `min_length` stay + // refused by the strict face (the seat's answer on objectui#11070). The + // formula and summary widgets read ONLY the spec spellings, `returnType` + // and `summaryOperations` (below); their snake_case forms are retired and + // refused. The grid field's `columns` is read too, but its element shape + // is undecided: the declared `GridColumnDefinition` (`./field-types.ts`) is + // not the shape `GridField` reads. It remains open on objectui#11070. /** * Hold several values instead of one. Read by the `file`, `image`, @@ -2014,6 +2013,23 @@ export interface FormField { * cannot carry. */ pattern?: string; + /** + * The value type a `formula` field computes (`number` / `text` / + * `boolean` / `date`). The `formula` widget formats the value by it: a + * number to two decimals, a boolean as Yes/No, a date through the shared + * date face, anything else as text (the default when it is absent). The + * retired snake_case `return_type` is read by nothing and refused. + */ + returnType?: SpecField['returnType']; + /** + * The roll-up definition of a `summary` field: the child `object`, the + * child `field` and the aggregation `function`, plus an optional + * `relationshipField` and `filter`. The `summary` widget formats the value + * by `function`: `count` as it arrives, the other four to two decimals. The + * retired snake_case `summary_object` / `summary_field` / `summary_type` + * are read by nothing and refused. + */ + summaryOperations?: SpecField['summaryOperations']; } /** diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index 3d05515a73..9a62dbac71 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -978,6 +978,11 @@ export const FormFieldSchema = z.object({ maxLength: stripImportedDefaults(SpecFieldSchema).shape.maxLength, pattern: z.string().optional() .describe('Regular expression the value must match, as a string (JSON has no RegExp) — the built-in input branch puts it on the native control as `pattern`; the field-level spelling the `validation.pattern` refusal directs JSON authors to'), + // The spec spellings the `formula` and `summary` widgets read; their + // snake_case forms (`return_type`, `summary_type`, `summary_object`, + // `summary_field`) are retired and stay undeclared (objectui#11070). + returnType: stripImportedDefaults(SpecFieldSchema).shape.returnType, + summaryOperations: stripImportedDefaults(SpecFieldSchema).shape.summaryOperations, }).superRefine((field, ctx) => { // objectui#5449 — the namespace rule `@object-ui/core` has enforced since // objectui#5375, stated here so `objectui validate` (which reaches this From 52a2d42050a20fd5aede9c7ec3bff5a8aed4da05 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 16:29:47 +0000 Subject: [PATCH 2/4] feat(fields,docs): fixtures, tests and docs move to returnType / summaryOperations / minLength (objectui#11070) The fields-formula fixtures write the spec's `expression` and `returnType`; the fields-summary fixtures fold `summary_object` / `summary_field` / `summary_type` into `summaryOperations { object, field, function }`. The five packages/fields tests that wrote `return_type: 'date'` write `returnType`, now as annotated literals. A new pin covers both widgets' spec reads, each against a control, and the retirement of the snake reads. formula.mdx, summary.mdx and password.mdx teach the spec members. One changeset (types + fields minor, breaking stated), and a dated note on the three pending changesets whose `return_type` wording this makes false. Refs objectui#11070 (round 3). Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh Co-authored-by: Claude --- .../11070-field-metadata-spec-spellings.md | 16 ++++ .../6138-fields-schema-block-parity-pr2.md | 8 ++ .../8194-fields-date-widget-convention.md | 8 ++ .changeset/8809-date-carrier-unparsable.md | 8 ++ content/docs/fields/formula.mdx | 16 ++-- content/docs/fields/password.mdx | 7 +- content/docs/fields/summary.mdx | 72 +++++++++------ .../fields-formula/date-calculation.json | 4 +- .../fields-formula/numeric-formula.json | 4 +- .../fields-formula/text-concatenation.json | 4 +- .../average-of-field-values.json | 8 +- .../count-of-related-records.json | 8 +- .../fields-summary/sum-of-field-values.json | 8 +- .../date-carrier-unparsable-8809.test.tsx | 4 +- ...date-carriers.impossibleDay-10026.test.tsx | 4 +- .../__tests__/date-locale-channel.test.tsx | 2 +- ...time-carriers.impossibleDay-10301.test.tsx | 4 +- ...ields-date-widget-convention-8194.test.tsx | 8 +- .../formula-summary-spec-reads-11070.test.tsx | 87 +++++++++++++++++++ 19 files changed, 215 insertions(+), 65 deletions(-) create mode 100644 .changeset/11070-field-metadata-spec-spellings.md create mode 100644 packages/fields/src/widgets/__tests__/formula-summary-spec-reads-11070.test.tsx diff --git a/.changeset/11070-field-metadata-spec-spellings.md b/.changeset/11070-field-metadata-spec-spellings.md new file mode 100644 index 0000000000..40f902887d --- /dev/null +++ b/.changeset/11070-field-metadata-spec-spellings.md @@ -0,0 +1,16 @@ +--- +'@object-ui/types': minor +'@object-ui/fields': minor +--- + +The formula and summary field widgets read `@objectstack/spec`'s own spellings, `returnType` and `summaryOperations`, and the snake_case spellings they used to read are retired at once (objectui#11070). + +**Visible change.** Object metadata carries the spec spellings: the platform's example apps write `returnType` on a formula field and `summaryOperations: { object, field, function }` on a roll-up, and the metadata admin stamps `returnType` from the inferred formula type. The widgets read only `return_type` and `summary_type`, so on an object-bound form a formula or summary field rendered without its type formatting: a number formula showed `3` instead of `3.00`, a boolean formula `true` instead of `Yes`, a date formula the raw `2026-07-04` instead of the shared date face, and a `sum` roll-up `15750.5` instead of `15750.50`. They now format by what the definition declares. + +- **`@object-ui/fields`:** `FormulaField` reads `returnType` (`number` to two decimals, `boolean` as Yes/No, `date` through the shared date face, anything else as text). `SummaryField` reads `summaryOperations.function` (`count` as it arrives, `sum` / `avg` / `min` / `max` to two decimals). Neither reads a snake_case spelling any more. `FormulaField` also no longer formats a `currency` return type, which the spec does not list. +- **`@object-ui/types`, the form-field face:** `FormField` and its zod mirror `FormFieldSchema` declare `returnType` and `summaryOperations`, each the spec's `FieldSchema` member by reference, with the spec's own value rules. The strict authoring face (`StrictAnyComponentSchema`) accepts them on a hand-authored form's field, and judges them: a `returnType` of `datetime`, a `summaryOperations` with no `function`, a `function` of `first`, or an unknown member inside `summaryOperations` is refused on both faces. +- **`@object-ui/types`, the field metadata types:** `FormulaFieldMetadata.returnType` replaces `return_type`; `SummaryFieldMetadata.summaryOperations` replaces `summary_object`, `summary_field`, `summary_type` and `summary_filter`; `PasswordFieldMetadata.minLength` / `maxLength` replace `min_length` / `max_length`. Each is the spec member by reference. + +**Breaking, priced as minor under the fixed group's version policy.** A literal annotated as `FormulaFieldMetadata`, `SummaryFieldMetadata` or `PasswordFieldMetadata` that still writes a retired member no longer compiles (an excess-property error naming the key), and at runtime a formula or summary field that carries only `return_type` or `summary_type` renders its value unformatted. `@objectstack/spec`'s `FieldSchema` refuses every retired spelling by name, so no spec-compliant producer writes them. The fix is the spec spelling: `returnType`, and `summaryOperations: { object, field, function }` (with `filter` for the former `summary_filter`). + +**Not in this change.** `LookupFieldMetadata.reference_to` and the lookup readers' `reference_to` read stay: in-repo producers still write `reference_to` onto the field definitions those readers are handed, so retiring the spelling is a separate decision on objectui#11070. The strict face keeps refusing `return_type`, `summary_type`, `summary_object` and `summary_field` on a hand-authored form's field, as before. diff --git a/.changeset/6138-fields-schema-block-parity-pr2.md b/.changeset/6138-fields-schema-block-parity-pr2.md index 2bcd93ad29..c13855e8e8 100644 --- a/.changeset/6138-fields-schema-block-parity-pr2.md +++ b/.changeset/6138-fields-schema-block-parity-pr2.md @@ -36,6 +36,14 @@ no shipped type declares: - `user.mdx` documented the value as a user object; the field stores the user's id and the picker resolves the rest from `sys_user`. +⚠️ **Dated note, 2026-09-30 — the `return_type` union above has since been retired — +objectui#11070.** "The shipped union is `'text' | 'number' | 'boolean' | 'date' | +'datetime'`" above held when this change landed. Later in this same release objectui#11070 +(round 3) retired `FormulaFieldMetadata.return_type` for `@objectstack/spec`'s +`returnType`, typed by reference (`'number' | 'text' | 'boolean' | 'date'`), and +`formula.mdx` teaches that key. `.changeset/11070-field-metadata-spec-spellings.md` states +what ships; the text above is kept as the reading of this change. + Two undeclared-but-consumed keys were found by checking each divergence against its renderer, and are filed rather than deleted or documented as metadata: `dependsOn` on select and `description` on a lookup's static options diff --git a/.changeset/8194-fields-date-widget-convention.md b/.changeset/8194-fields-date-widget-convention.md index 196a40957b..283dce4aa4 100644 --- a/.changeset/8194-fields-date-widget-convention.md +++ b/.changeset/8194-fields-date-widget-convention.md @@ -19,6 +19,14 @@ default — so they never implemented the year-dropping decision the shared which sits in the same function as the descriptor path that already rendered through `formatDate`. +⚠️ **Dated note, 2026-09-30 — the formula key above has since been renamed — +objectui#11070.** "A `FormulaField` declaring `return_type: 'date'`" above held when this +change landed. Later in this same release objectui#11070 (round 3) moved `FormulaField` to +`@objectstack/spec`'s `returnType` and retired the `return_type` read, so the face described +here is reached by a formula declaring `returnType: 'date'`. +`.changeset/11070-field-metadata-spec-spellings.md` states what ships; the text above is +kept as the reading of this change. + **Visible change**: every one of those faces changes shape in every locale, in every year — not only the year token. In `en-US` a date renders `Jul 4` this year and `Jul 4, 2024` for a past year, where it used to render `7/4/2026` and diff --git a/.changeset/8809-date-carrier-unparsable.md b/.changeset/8809-date-carrier-unparsable.md index 94a22d3a04..088b5d50bc 100644 --- a/.changeset/8809-date-carrier-unparsable.md +++ b/.changeset/8809-date-carrier-unparsable.md @@ -6,6 +6,14 @@ The readonly `date` widget faces draw `formatDate`'s em-dash through the shared `EmptyValue` affordance instead of a plain span (objectui#8809). Two sites: `DateField`'s readonly branch and `FormulaField`'s `return_type: 'date'` path. +⚠️ **Dated note, 2026-09-30 — the formula key above has since been renamed — +objectui#11070.** "`FormulaField`'s `return_type: 'date'` path" above held when this change +landed. Later in this same release objectui#11070 (round 3) moved `FormulaField` to +`@objectstack/spec`'s `returnType` and retired the `return_type` read, so that path is the +one a formula declaring `returnType: 'date'` takes. +`.changeset/11070-field-metadata-spec-spellings.md` states what ships; the text above is +kept as the reading of this change. + A truthy value `new Date(...)` cannot read — `not-a-date`, `2024-13-45` — used to reach `formatDate`, which answers it with its own em-dash, and that dash was painted in a span with no `data-slot` of `empty-value` and no accessible name. diff --git a/content/docs/fields/formula.mdx b/content/docs/fields/formula.mdx index 2fc8d558fb..f3b970f86d 100644 --- a/content/docs/fields/formula.mdx +++ b/content/docs/fields/formula.mdx @@ -21,8 +21,9 @@ The Formula Field component displays computed values calculated from other field A formula field is authored as `FormulaFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the -expression, its declared return type and the recompute switch. `return_type` is a -closed union — `'text' | 'number' | 'boolean' | 'date' | 'datetime'`. +expression, its declared return type and the recompute switch. `returnType` is +`@objectstack/spec`'s own `FieldSchema.returnType`, typed by reference: a closed +union of `'number' | 'text' | 'boolean' | 'date'`. ```ts import type { FormulaFieldMetadata } from '@object-ui/types'; @@ -33,7 +34,7 @@ const totalPrice: FormulaFieldMetadata = { label: 'Total Price', readonly: true, formula: 'quantity * unit_price', - return_type: 'number', + returnType: 'number', auto_compute: true, }; ``` @@ -43,13 +44,12 @@ they are runtime widget props. See [Field Widget Props](/docs/fields/widget-prop ## Return Types -The formula field formats values based on return type: +The formula field formats values by `returnType`: -- **number**: Displays with decimal precision -- **currency**: Displays with currency symbol +- **number**: Displays with two decimal places - **boolean**: Displays as Yes/No -- **date**: Displays formatted date -- **text**: Displays as string +- **date**: Displays the formatted date +- **text**: Displays as a string (also the default when `returnType` is absent) ## Formula Examples diff --git a/content/docs/fields/password.mdx b/content/docs/fields/password.mdx index 61c73c3a61..249f8c0156 100644 --- a/content/docs/fields/password.mdx +++ b/content/docs/fields/password.mdx @@ -21,7 +21,8 @@ The Password Field component provides a secure text input for passwords with a t A password field is authored as `PasswordFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the two length -bounds. The reveal toggle and the masked read-only rendering are the widget's. +bounds, `minLength` and `maxLength` — `@objectstack/spec`'s own `FieldSchema` members, +typed by reference. The reveal toggle and the masked read-only rendering are the widget's. ```ts import type { PasswordFieldMetadata } from '@object-ui/types'; @@ -32,8 +33,8 @@ const password: PasswordFieldMetadata = { label: 'Password', placeholder: 'Enter a password', required: true, - min_length: 12, - max_length: 128, + minLength: 12, + maxLength: 128, }; ``` diff --git a/content/docs/fields/summary.mdx b/content/docs/fields/summary.mdx index b3dd0f9430..415ccb434c 100644 --- a/content/docs/fields/summary.mdx +++ b/content/docs/fields/summary.mdx @@ -20,9 +20,12 @@ The Summary Field component displays aggregated values from related records. Thi ## Field Schema A summary field is authored as `SummaryFieldMetadata` (`@object-ui/types`), which is -the source of truth for the key set: it extends `BaseFieldMetadata` with the related -object, the aggregated field, the aggregation and its filter. `summary_type` is a -closed union — `'count' | 'sum' | 'avg' | 'min' | 'max' | 'first' | 'last'`. +the source of truth for the key set: it extends `BaseFieldMetadata` with the roll-up +definition `summaryOperations` and the auto-update switch. `summaryOperations` is +`@objectstack/spec`'s own `FieldSchema.summaryOperations`, typed by reference: the +child `object`, the child `field` to aggregate, the aggregation `function` — a closed +union of `'count' | 'sum' | 'min' | 'max' | 'avg'` — and optionally the child's +`relationshipField` and a `filter` restricting which child rows are aggregated. ```ts import type { SummaryFieldMetadata } from '@object-ui/types'; @@ -32,10 +35,12 @@ const totalRevenue: SummaryFieldMetadata = { name: 'total_revenue', label: 'Total Revenue', readonly: true, - summary_object: 'opportunities', - summary_field: 'amount', - summary_type: 'sum', - summary_filter: { stage: 'closed_won' }, + summaryOperations: { + object: 'opportunities', + field: 'amount', + function: 'sum', + filter: { stage: 'closed_won' }, + }, auto_update: true, }; ``` @@ -43,7 +48,10 @@ const totalRevenue: SummaryFieldMetadata = { The computed value, and the `className` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). -## Summary Types +## Aggregation Functions + +The summary field formats values by `summaryOperations.function`: `count` as it +arrives, the other four to two decimal places. - **count**: Count of related records - **sum**: Sum of numeric field values @@ -60,9 +68,11 @@ they are runtime widget props. See [Field Widget Props](/docs/fields/widget-prop type: 'summary', name: 'task_count', label: 'Open Tasks', - summary_object: 'tasks', - summary_field: 'id', - summary_type: 'count' + summaryOperations: { + object: 'tasks', + field: 'id', + function: 'count' + } } ``` @@ -73,9 +83,11 @@ they are runtime widget props. See [Field Widget Props](/docs/fields/widget-prop type: 'summary', name: 'total_sales', label: 'Total Sales', - summary_object: 'invoices', - summary_field: 'amount', - summary_type: 'sum' + summaryOperations: { + object: 'invoices', + field: 'amount', + function: 'sum' + } } ``` @@ -86,9 +98,11 @@ they are runtime widget props. See [Field Widget Props](/docs/fields/widget-prop type: 'summary', name: 'avg_response_time', label: 'Avg Response Time', - summary_object: 'support_tickets', - summary_field: 'response_time_hours', - summary_type: 'avg' + summaryOperations: { + object: 'support_tickets', + field: 'response_time_hours', + function: 'avg' + } } ``` @@ -99,9 +113,11 @@ they are runtime widget props. See [Field Widget Props](/docs/fields/widget-prop type: 'summary', name: 'highest_bid', label: 'Highest Bid', - summary_object: 'bids', - summary_field: 'amount', - summary_type: 'max' + summaryOperations: { + object: 'bids', + field: 'amount', + function: 'max' + } } ``` @@ -124,24 +140,24 @@ Summary fields are calculated through database aggregations: ```plaintext // Example backend aggregation -const calculateSummary = async (config: SummaryConfig, parentId: string) => { - const { summary_object, summary_field, summary_type } = config; +const calculateSummary = async (summaryOperations: SummaryOperations, parentId: string) => { + const { object, field, function: fn } = summaryOperations; - switch (summary_type) { + switch (fn) { case 'count': - return db.count(summary_object, { parent_id: parentId }); + return db.count(object, { parent_id: parentId }); case 'sum': - return db.sum(summary_object, summary_field, { parent_id: parentId }); + return db.sum(object, field, { parent_id: parentId }); case 'avg': - return db.avg(summary_object, summary_field, { parent_id: parentId }); + return db.avg(object, field, { parent_id: parentId }); case 'min': - return db.min(summary_object, summary_field, { parent_id: parentId }); + return db.min(object, field, { parent_id: parentId }); case 'max': - return db.max(summary_object, summary_field, { parent_id: parentId }); + return db.max(object, field, { parent_id: parentId }); } }; ``` diff --git a/examples/schema-catalog/src/schemas/fields-formula/date-calculation.json b/examples/schema-catalog/src/schemas/fields-formula/date-calculation.json index 7028e0b349..2a118222eb 100644 --- a/examples/schema-catalog/src/schemas/fields-formula/date-calculation.json +++ b/examples/schema-catalog/src/schemas/fields-formula/date-calculation.json @@ -7,8 +7,8 @@ "name": "due_date", "label": "Due Date", "type": "formula", - "formula": "created_at + 30 days", - "return_type": "date", + "expression": "created_at + 30 days", + "returnType": "date", "readonly": true } ] diff --git a/examples/schema-catalog/src/schemas/fields-formula/numeric-formula.json b/examples/schema-catalog/src/schemas/fields-formula/numeric-formula.json index 0472ead113..bea6cf0199 100644 --- a/examples/schema-catalog/src/schemas/fields-formula/numeric-formula.json +++ b/examples/schema-catalog/src/schemas/fields-formula/numeric-formula.json @@ -7,8 +7,8 @@ "name": "total", "label": "Total Amount", "type": "formula", - "formula": "quantity * price", - "return_type": "number", + "expression": "quantity * price", + "returnType": "number", "readonly": true } ] diff --git a/examples/schema-catalog/src/schemas/fields-formula/text-concatenation.json b/examples/schema-catalog/src/schemas/fields-formula/text-concatenation.json index f69edac459..80f9ed4743 100644 --- a/examples/schema-catalog/src/schemas/fields-formula/text-concatenation.json +++ b/examples/schema-catalog/src/schemas/fields-formula/text-concatenation.json @@ -10,8 +10,8 @@ "name": "full_name", "label": "Full Name", "type": "formula", - "formula": "first_name + \" \" + last_name", - "return_type": "text", + "expression": "first_name + \" \" + last_name", + "returnType": "text", "readonly": true } ] diff --git a/examples/schema-catalog/src/schemas/fields-summary/average-of-field-values.json b/examples/schema-catalog/src/schemas/fields-summary/average-of-field-values.json index 65dd2011cd..74f0bd7820 100644 --- a/examples/schema-catalog/src/schemas/fields-summary/average-of-field-values.json +++ b/examples/schema-catalog/src/schemas/fields-summary/average-of-field-values.json @@ -10,9 +10,11 @@ "name": "avg_rating", "label": "Average Rating", "type": "summary", - "summary_object": "reviews", - "summary_field": "rating", - "summary_type": "avg", + "summaryOperations": { + "object": "reviews", + "field": "rating", + "function": "avg" + }, "readonly": true } ] diff --git a/examples/schema-catalog/src/schemas/fields-summary/count-of-related-records.json b/examples/schema-catalog/src/schemas/fields-summary/count-of-related-records.json index 8c2ad967b7..9d3cbf111f 100644 --- a/examples/schema-catalog/src/schemas/fields-summary/count-of-related-records.json +++ b/examples/schema-catalog/src/schemas/fields-summary/count-of-related-records.json @@ -10,9 +10,11 @@ "name": "order_count", "label": "Total Orders", "type": "summary", - "summary_object": "orders", - "summary_field": "id", - "summary_type": "count", + "summaryOperations": { + "object": "orders", + "field": "id", + "function": "count" + }, "readonly": true } ] diff --git a/examples/schema-catalog/src/schemas/fields-summary/sum-of-field-values.json b/examples/schema-catalog/src/schemas/fields-summary/sum-of-field-values.json index 027d03631f..06b3fb80fa 100644 --- a/examples/schema-catalog/src/schemas/fields-summary/sum-of-field-values.json +++ b/examples/schema-catalog/src/schemas/fields-summary/sum-of-field-values.json @@ -10,9 +10,11 @@ "name": "total_revenue", "label": "Total Revenue", "type": "summary", - "summary_object": "orders", - "summary_field": "amount", - "summary_type": "sum", + "summaryOperations": { + "object": "orders", + "field": "amount", + "function": "sum" + }, "readonly": true } ] diff --git a/packages/fields/src/__tests__/date-carrier-unparsable-8809.test.tsx b/packages/fields/src/__tests__/date-carrier-unparsable-8809.test.tsx index 5f1dde6f16..aee34f90ff 100644 --- a/packages/fields/src/__tests__/date-carrier-unparsable-8809.test.tsx +++ b/packages/fields/src/__tests__/date-carrier-unparsable-8809.test.tsx @@ -108,11 +108,11 @@ const SITES: ReadonlyArray ).container, ], [ - 'FormulaField (`return_type: date`)', + 'FormulaField (`returnType: date`)', (locale, value) => session( locale, - {}} field={{ type: 'formula', name: 'c', return_type: 'date' } as any} />, + {}} field={{ type: 'formula', name: 'c', returnType: 'date' }} />, ).container, ], ]; diff --git a/packages/fields/src/__tests__/date-carriers.impossibleDay-10026.test.tsx b/packages/fields/src/__tests__/date-carriers.impossibleDay-10026.test.tsx index 0ebd24d391..70f8c5a4da 100644 --- a/packages/fields/src/__tests__/date-carriers.impossibleDay-10026.test.tsx +++ b/packages/fields/src/__tests__/date-carriers.impossibleDay-10026.test.tsx @@ -86,10 +86,10 @@ const FACES: ReadonlyArray HTMLElement]> = ).container, ], [ - 'FormulaField (`return_type: date`)', + 'FormulaField (`returnType: date`)', (value) => session( - {}} field={{ type: 'formula', name: 'c', return_type: 'date' } as any} />, + {}} field={{ type: 'formula', name: 'c', returnType: 'date' }} />, ).container, ], ]; diff --git a/packages/fields/src/__tests__/date-locale-channel.test.tsx b/packages/fields/src/__tests__/date-locale-channel.test.tsx index cac151e65a..3a950ff911 100644 --- a/packages/fields/src/__tests__/date-locale-channel.test.tsx +++ b/packages/fields/src/__tests__/date-locale-channel.test.tsx @@ -287,7 +287,7 @@ describe('zh session — every date branch renders Chinese (objectui#4468)', () {}} - field={{ type: 'formula', name: 'computed_on', return_type: 'date' } as any} + field={{ type: 'formula', name: 'computed_on', returnType: 'date' }} />, ); expect(container.textContent).toContain(defaultDateFace(FIXED_INSTANT, 'zh')); diff --git a/packages/fields/src/__tests__/datetime-carriers.impossibleDay-10301.test.tsx b/packages/fields/src/__tests__/datetime-carriers.impossibleDay-10301.test.tsx index 3704276151..be82e1a6ad 100644 --- a/packages/fields/src/__tests__/datetime-carriers.impossibleDay-10301.test.tsx +++ b/packages/fields/src/__tests__/datetime-carriers.impossibleDay-10301.test.tsx @@ -84,10 +84,10 @@ const FACES: ReadonlyArray HTMLElement]> = ).container, ], [ - 'FormulaField (`return_type: date`, a date-time result)', + 'FormulaField (`returnType: date`, a date-time result)', (value) => session( - {}} field={{ type: 'formula', name: 'c', return_type: 'date' } as any} />, + {}} field={{ type: 'formula', name: 'c', returnType: 'date' }} />, ).container, ], ]; diff --git a/packages/fields/src/__tests__/fields-date-widget-convention-8194.test.tsx b/packages/fields/src/__tests__/fields-date-widget-convention-8194.test.tsx index efc33a0ed3..315975f19f 100644 --- a/packages/fields/src/__tests__/fields-date-widget-convention-8194.test.tsx +++ b/packages/fields/src/__tests__/fields-date-widget-convention-8194.test.tsx @@ -24,7 +24,7 @@ * * 1. `widgets/DateField.tsx` readonly `date` widget ← fixed * 2. `widgets/GridField.tsx` sub-grid `date` column ← fixed - * 3. `widgets/FormulaField.tsx` `return_type: 'date'` ← fixed + * 3. `widgets/FormulaField.tsx` `returnType: 'date'` ← fixed * 4. `widgets/lookupColumnDisplay.tsx` the `$date` fallback ← fixed * 5. `widgets/DateField`'s sibling `DateTimeField.tsx` readonly ← NOT * 6. `widgets/GridField.tsx`'s `datetime`/`time` branch ← NOT @@ -165,11 +165,11 @@ function gridCellText(): string { return cells[cells.length - 1].textContent ?? ''; } -/** Site 3 — a formula field declaring `return_type: 'date'`. */ +/** Site 3 — a formula field declaring `returnType: 'date'`. */ const renderFormula = (locale: string, value: unknown) => session( locale, - {}} field={{ type: 'formula', name: 'c', return_type: 'date' } as any} />, + {}} field={{ type: 'formula', name: 'c', returnType: 'date' }} />, ).container.textContent ?? ''; /** @@ -183,7 +183,7 @@ const renderLookupDollarDate = (locale: string, value: unknown) => const SITES: Array<[string, (locale: string, iso: string) => string, (iso: string, locale: string) => string]> = [ ['DateField (readonly)', renderDateField, FORMER_FACE], ['GridField (sub-grid date cell)', renderGridCell, FORMER_GRID_FACE], - ['FormulaField (return_type: date)', renderFormula, FORMER_FACE], + ['FormulaField (returnType: date)', renderFormula, FORMER_FACE], ['lookupColumnDisplay ($date fallback)', (l, iso) => renderLookupDollarDate(l, asDollarDate(iso)), FORMER_FACE], ]; diff --git a/packages/fields/src/widgets/__tests__/formula-summary-spec-reads-11070.test.tsx b/packages/fields/src/widgets/__tests__/formula-summary-spec-reads-11070.test.tsx new file mode 100644 index 0000000000..beffb12e67 --- /dev/null +++ b/packages/fields/src/widgets/__tests__/formula-summary-spec-reads-11070.test.tsx @@ -0,0 +1,87 @@ +/** + * 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. + */ + +/** + * The `formula` and `summary` widgets format a value by the SPEC spellings, + * and only by those (objectui#11070, round 3; the seat's answer A). + * + * `@objectstack/spec`'s `FieldSchema` carries a formula's result type as + * `returnType` and a roll-up as `summaryOperations: { object, field, function }` + * — the spellings object metadata carries (the example apps write them, and + * the metadata admin stamps `returnType` from the inferred CEL type). The two + * widgets used to read only the snake_case `return_type` / `summary_type`, so + * on an object-bound form a formula or summary field rendered without its + * type formatting. The snake_case reads are retired at once, with no dual + * read. + * + * Each case below has a control that changes only the key under test, so a + * green reads "the widget consumed the spec key", never "the default happened + * to match". The last block pins the retirement: a snake_case spelling alone + * no longer formats anything. + * + * The annotated literals are judged by TypeScript's excess-property check + * (`tsc -p tsconfig.test.json`), which refuses the retired members by name. + */ + +import { describe, expect, it } from 'vitest'; +import { render } from '@testing-library/react'; +import React from 'react'; +import type { FormulaFieldMetadata, SummaryFieldMetadata } from '@object-ui/types'; + +import { FormulaField } from '../FormulaField'; +import { SummaryField } from '../SummaryField'; + +const noop = () => {}; + +const formulaText = (field: FormulaFieldMetadata, value: unknown) => + render().container.textContent; + +const summaryText = (field: SummaryFieldMetadata, value: unknown) => + render().container.textContent; + +describe('objectui#11070 — `FormulaField` formats by the spec `returnType`', () => { + it('`returnType: number` formats to two decimals; with no `returnType` the value prints as text', () => { + expect(formulaText({ type: 'formula', name: 'total', returnType: 'number' }, 3)).toBe('3.00'); + expect(formulaText({ type: 'formula', name: 'total' }, 3)).toBe('3'); + }); + + it('`returnType: boolean` prints Yes / No; `returnType: text` prints the raw value', () => { + expect(formulaText({ type: 'formula', name: 'overdue', returnType: 'boolean' }, true)).toBe('Yes'); + expect(formulaText({ type: 'formula', name: 'overdue', returnType: 'boolean' }, false)).toBe('No'); + expect(formulaText({ type: 'formula', name: 'overdue', returnType: 'text' }, true)).toBe('true'); + }); +}); + +describe('objectui#11070 — `SummaryField` formats by `summaryOperations.function`', () => { + const rollUp = (fn: 'count' | 'sum' | 'avg' | 'min' | 'max'): SummaryFieldMetadata => ({ + type: 'summary', + name: 'line_total', + summaryOperations: { object: 'order_line', field: 'amount', function: fn }, + }); + + it.each(['sum', 'avg', 'min', 'max'] as const)('`function: %s` formats to two decimals', (fn) => { + expect(summaryText(rollUp(fn), 15750.5)).toBe('15750.50'); + }); + + it('`function: count` prints the value as it arrives, as does a field with no roll-up', () => { + expect(summaryText(rollUp('count'), 42)).toBe('42'); + expect(summaryText({ type: 'summary', name: 'line_total' }, 15750.5)).toBe('15750.5'); + }); +}); + +describe('objectui#11070 — the snake_case spellings are retired: alone, they format nothing', () => { + it('`return_type` is not read', () => { + const legacy = { type: 'formula', name: 'total', return_type: 'number' } as unknown as FormulaFieldMetadata; + expect(formulaText(legacy, 3)).toBe('3'); + }); + + it('`summary_type` is not read', () => { + const legacy = { type: 'summary', name: 'line_total', summary_type: 'sum' } as unknown as SummaryFieldMetadata; + expect(summaryText(legacy, 15750.5)).toBe('15750.5'); + }); +}); From 2a83d3586f169bb617cc1fdf9252c998824877d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 16:39:08 +0000 Subject: [PATCH 3/4] test(types): record FormFieldSchema.summaryOperations as an unnamed lazy slot (objectui#11070) `summaryOperations` is the spec's `FieldSchema` member by reference, and its optional `filter` is the spec's recursive filter condition, a `z.lazy` inside an imported schema that no local const names. The blind-region ledger of the wider direction records it, as the leg asks when a new lazy source joins. Refs objectui#11070 (round 3). Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh Co-authored-by: Claude --- packages/types/src/__tests__/zod-mirror-parity.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 1c52f794ba..65312cba11 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -5618,6 +5618,11 @@ const RECURSION_BREAKING_MIRRORS: readonly (readonly [string, unknown])[] = [ */ const UNNAMED_LAZY_SLOTS: readonly string[] = [ 'complex.zod.ts#DashboardComponentSchema.widgets', + // objectui#11070 round 3: `FormFieldSchema.summaryOperations` is the spec's + // `FieldSchema` member by reference, and its optional `filter` is the spec's + // recursive filter condition. That `z.lazy` sits inside an IMPORTED schema + // (rebuilt by `stripImportedDefaults`), so no local const names it either. + 'form.zod.ts#FormFieldSchema.summaryOperations', 'objectql.zod.ts#ObjectViewSchema.form', 'objectql.zod.ts#ObjectViewSchema.table', ]; From 998fb633fe5fdb223cc1cd5633d388c09fa08949 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 16:57:13 +0000 Subject: [PATCH 4/4] docs(changeset): state the tolerant-face narrowing the form-field declarations bring (objectui#11070) Measured on the built face at base and head: a `returnType` of `datetime` and a malformed `summaryOperations` were stripped unjudged by the tolerant face before, and are refused now. Refs objectui#11070 (round 3). Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh Co-authored-by: Claude --- .changeset/11070-field-metadata-spec-spellings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/11070-field-metadata-spec-spellings.md b/.changeset/11070-field-metadata-spec-spellings.md index 40f902887d..f32f57eb85 100644 --- a/.changeset/11070-field-metadata-spec-spellings.md +++ b/.changeset/11070-field-metadata-spec-spellings.md @@ -8,7 +8,7 @@ The formula and summary field widgets read `@objectstack/spec`'s own spellings, **Visible change.** Object metadata carries the spec spellings: the platform's example apps write `returnType` on a formula field and `summaryOperations: { object, field, function }` on a roll-up, and the metadata admin stamps `returnType` from the inferred formula type. The widgets read only `return_type` and `summary_type`, so on an object-bound form a formula or summary field rendered without its type formatting: a number formula showed `3` instead of `3.00`, a boolean formula `true` instead of `Yes`, a date formula the raw `2026-07-04` instead of the shared date face, and a `sum` roll-up `15750.5` instead of `15750.50`. They now format by what the definition declares. - **`@object-ui/fields`:** `FormulaField` reads `returnType` (`number` to two decimals, `boolean` as Yes/No, `date` through the shared date face, anything else as text). `SummaryField` reads `summaryOperations.function` (`count` as it arrives, `sum` / `avg` / `min` / `max` to two decimals). Neither reads a snake_case spelling any more. `FormulaField` also no longer formats a `currency` return type, which the spec does not list. -- **`@object-ui/types`, the form-field face:** `FormField` and its zod mirror `FormFieldSchema` declare `returnType` and `summaryOperations`, each the spec's `FieldSchema` member by reference, with the spec's own value rules. The strict authoring face (`StrictAnyComponentSchema`) accepts them on a hand-authored form's field, and judges them: a `returnType` of `datetime`, a `summaryOperations` with no `function`, a `function` of `first`, or an unknown member inside `summaryOperations` is refused on both faces. +- **`@object-ui/types`, the form-field face:** `FormField` and its zod mirror `FormFieldSchema` declare `returnType` and `summaryOperations`, each the spec's `FieldSchema` member by reference, with the spec's own value rules. The strict authoring face (`StrictAnyComponentSchema`) accepts them on a hand-authored form's field, and judges them: a `returnType` of `datetime`, a `summaryOperations` with no `function`, a `function` of `first`, or an unknown member inside `summaryOperations` is refused on both faces. The tolerant face (`AnyComponentSchema`) used to strip both keys from a form field unjudged, so those values were accepted silently before this change: that half is a narrowing. - **`@object-ui/types`, the field metadata types:** `FormulaFieldMetadata.returnType` replaces `return_type`; `SummaryFieldMetadata.summaryOperations` replaces `summary_object`, `summary_field`, `summary_type` and `summary_filter`; `PasswordFieldMetadata.minLength` / `maxLength` replace `min_length` / `max_length`. Each is the spec member by reference. **Breaking, priced as minor under the fixed group's version policy.** A literal annotated as `FormulaFieldMetadata`, `SummaryFieldMetadata` or `PasswordFieldMetadata` that still writes a retired member no longer compiles (an excess-property error naming the key), and at runtime a formula or summary field that carries only `return_type` or `summary_type` renders its value unformatted. `@objectstack/spec`'s `FieldSchema` refuses every retired spelling by name, so no spec-compliant producer writes them. The fix is the spec spelling: `returnType`, and `summaryOperations: { object, field, function }` (with `filter` for the former `summary_filter`).