diff --git a/.changeset/12282-types-autonumber-format.md b/.changeset/12282-types-autonumber-format.md new file mode 100644 index 0000000000..dd631c6eba --- /dev/null +++ b/.changeset/12282-types-autonumber-format.md @@ -0,0 +1,11 @@ +--- +'@object-ui/types': minor +--- + +`AutoNumberFieldMetadata` declares `autonumberFormat`, typed by reference to `@objectstack/spec`'s `FieldSchema.autonumberFormat`, and keeps `format` as its shorthand (objectui#12282). + +- FROM `format?: string` alone → TO `autonumberFormat`, typed as `@objectstack/spec/data`'s `Field['autonumberFormat']` (a string), beside `format?: string`. The spec prefers `autonumberFormat` on a new field, and its own field form, which Studio's field designer renders, offers it for an `autonumber` field. A typed literal carrying it used to fail with an excess-property error. + +`format` is not retired: the spec still accepts it as the shorthand that predates `autonumberFormat`. When a field sets both, the backend uses `autonumberFormat` (`resolveAutonumberFormat`, `@objectstack/spec/data`). The `auto-number` field docs page teaches `autonumberFormat` for a new field and `format` as the shorthand. `autonumber-format-declared-12282.test.ts` pins both keys beside the installed spec's own answer. + +**Clause-②: yes**: the authoring face gains `autonumberFormat` on `AutoNumberFieldMetadata`. Nothing is removed, so every literal that compiled before still compiles. diff --git a/content/docs/fields/auto-number.mdx b/content/docs/fields/auto-number.mdx index b572cb1478..72fab47e16 100644 --- a/content/docs/fields/auto-number.mdx +++ b/content/docs/fields/auto-number.mdx @@ -21,8 +21,8 @@ The AutoNumber Field component displays auto-generated sequence numbers. This is An auto-number field is authored as `AutoNumberFieldMetadata` (`@object-ui/types`), which is the source of truth for the key set: it extends `BaseFieldMetadata` with the -record-number format. The type value is `autonumber`, the spelling `@objectstack/spec`'s -`FieldType` declares (`auto_number` is refused at `type`). +record-number pattern, `autonumberFormat`. The type value is `autonumber`, the spelling +`@objectstack/spec`'s `FieldType` declares (`auto_number` is refused at `type`). ```ts import type { AutoNumberFieldMetadata } from '@object-ui/types'; @@ -33,10 +33,15 @@ const invoiceNumber: AutoNumberFieldMetadata = { label: 'Invoice Number', inlineHelpText: 'Assigned by the platform when the record is created.', readonly: true, - format: 'INV-{0000}', + autonumberFormat: 'INV-{0000}', }; ``` +`autonumberFormat` is the spelling `@objectstack/spec` prefers on a new field. +`format` is the older shorthand for the same pattern, and the spec still accepts +it: `format: 'INV-{0000}'` mints the same numbers. When a field sets both, +`autonumberFormat` wins and `format` is ignored. + The generated value, and the `className` a host supplies, are **not** metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props). @@ -47,30 +52,30 @@ Common format patterns: ### Simple Sequential ```plaintext -format: '{0000}' // 0001, 0002, 0003... -format: '{00000}' // 00001, 00002, 00003... +autonumberFormat: '{0000}' // 0001, 0002, 0003... +autonumberFormat: '{00000}' // 00001, 00002, 00003... ``` ### With Prefix ```plaintext -format: 'ORD-{0000}' // ORD-0001, ORD-0002... -format: 'INV-{00000}' // INV-00001, INV-00002... -format: 'CUST-{000}' // CUST-001, CUST-002... +autonumberFormat: 'ORD-{0000}' // ORD-0001, ORD-0002... +autonumberFormat: 'INV-{00000}' // INV-00001, INV-00002... +autonumberFormat: 'CUST-{000}' // CUST-001, CUST-002... ``` ### Date-Based ```plaintext -format: '{YYYY}-{0000}' // 2024-0001, 2024-0002... -format: '{YY}{MM}-{000}' // 2403-001, 2403-002... -format: 'ORD-{YYYYMMDD}-{00}' // ORD-20240315-01... +autonumberFormat: '{YYYY}-{0000}' // 2024-0001, 2024-0002... +autonumberFormat: '{YY}{MM}-{000}' // 2403-001, 2403-002... +autonumberFormat: 'ORD-{YYYYMMDD}-{00}' // ORD-20240315-01... ``` ### Mixed Format ```plaintext -format: 'PO-{YYYY}-{MM}-{0000}' // PO-2024-03-0001 +autonumberFormat: 'PO-{YYYY}-{MM}-{0000}' // PO-2024-03-0001 ``` ## Format Placeholders @@ -120,7 +125,7 @@ const orderNumber: AutoNumberFieldMetadata = { type: 'autonumber', name: 'order_number', label: 'Order Number', - format: 'ORD-{YYYY}-{0000}', + autonumberFormat: 'ORD-{YYYY}-{0000}', }; ``` diff --git a/packages/types/src/__tests__/autonumber-format-declared-12282.test.ts b/packages/types/src/__tests__/autonumber-format-declared-12282.test.ts new file mode 100644 index 0000000000..64e52f3716 --- /dev/null +++ b/packages/types/src/__tests__/autonumber-format-declared-12282.test.ts @@ -0,0 +1,91 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12282 — `AutoNumberFieldMetadata` declares `@objectstack/spec`'s + * `FieldSchema.autonumberFormat` BY REFERENCE, and keeps `format`. + * + * The spec declares both keys on an `autonumber` field: `autonumberFormat` is + * the one it prefers on a new field, and `format` is the older shorthand it + * still accepts. Until this card a typed literal could carry only the + * shorthand, so the auto-number docs page could not teach the preferred key. + * + * - The type half is checked by this package's `type-check` through + * `tsconfig.test.json`: FRESH annotated literals carrying + * `autonumberFormat`, `format`, and both compile, and the member is the + * spec's own type rather than a restatement of it. + * - The runtime half reads the installed spec, so "the spec accepts each + * literal" and "`autonumberFormat` wins when a field carries both" are + * re-derived on every run rather than asserted in prose. + */ +import { describe, it, expect } from 'vitest'; +import { FieldSchema, resolveAutonumberFormat, type Field as SpecField } from '@objectstack/spec/data'; +import type { AutoNumberFieldMetadata } from '../field-types.js'; + +type Expect = T; +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; + +// ── The type: the spec's member, by reference; the shorthand kept ─────────── + +export type assertionAutonumberFormatByReference = [ + Expect>, + Expect>, +]; + +/** The key the spec prefers on a new field. */ +export const _autonumberFormat: AutoNumberFieldMetadata = { + type: 'autonumber', + name: 'invoice_number', + autonumberFormat: 'INV-{YYYY}-{0000}', +}; + +/** The control: the `format` shorthand still compiles. */ +export const _format: AutoNumberFieldMetadata = { + type: 'autonumber', + name: 'invoice_number', + format: 'INV-{0000}', +}; + +/** Both keys on one field, with different patterns so the winner is visible. */ +export const _both: AutoNumberFieldMetadata = { + type: 'autonumber', + name: 'invoice_number', + autonumberFormat: 'INV-{YYYY}-{0000}', + format: 'INV-{0000}', +}; + +// ── The installed spec: what the type follows ──────────────────────────────── + +/** The `unrecognized_keys` issue naming `key`, if the parse refused it by name. */ +function refusedByName(res: ReturnType, key: string): boolean { + if (res.success) return false; + return res.error.issues.some( + (i) => i.code === 'unrecognized_keys' && ((i as { keys?: string[] }).keys ?? []).includes(key), + ); +} + +describe('installed @objectstack/spec — the auto-number format keys the type declares (objectui#12282)', () => { + it('CONTROL — the spec refuses a key it does not declare on the same field, by name', () => { + const res = FieldSchema.safeParse({ ..._autonumberFormat, zzz_not_a_real_key: 'INV-{0000}' }); + expect(refusedByName(res, 'zzz_not_a_real_key')).toBe(true); + }); + + it.each([ + ['autonumberFormat', _autonumberFormat], + ['format', _format], + ['both keys', _both], + ] as const)('the spec accepts the typed literal carrying %s', (_label, field) => { + expect(FieldSchema.safeParse(field).success).toBe(true); + }); + + it('`autonumberFormat` wins when a field carries both, and `format` is read only without it', () => { + expect(resolveAutonumberFormat(_both)).toBe(_both.autonumberFormat); + expect(resolveAutonumberFormat(_format)).toBe(_format.format); + expect(resolveAutonumberFormat(_autonumberFormat)).toBe(_autonumberFormat.autonumberFormat); + }); +}); diff --git a/packages/types/src/field-types.ts b/packages/types/src/field-types.ts index 6e45440f78..e23321c27b 100644 --- a/packages/types/src/field-types.ts +++ b/packages/types/src/field-types.ts @@ -1052,12 +1052,34 @@ export interface SummaryFieldMetadata extends BaseFieldMetadata { */ export interface AutoNumberFieldMetadata extends BaseFieldMetadata { type: 'autonumber'; + /** + * The record-number pattern — `@objectstack/spec`'s + * `FieldSchema.autonumberFormat`, typed BY REFERENCE so the two cannot drift + * (objectui#12282): literal text plus a `{0000}` counter, the date tokens and + * `{field_name}` interpolation that the spec's `parseAutonumberFormat` reads, + * e.g. `'INV-{YYYY}-{0000}'`. The spec prefers it on a new field, and it is + * the key the spec's own field form, which Studio's field designer renders, + * offers for an `autonumber` field. + * + * It wins over `format` when a field carries both: the backend takes a + * non-empty `autonumberFormat`, then a non-empty `format`, then the + * contract default `{0000}` (`resolveAutonumberFormat`, + * `@objectstack/spec/data`). + */ + autonumberFormat?: SpecField['autonumberFormat']; + /** + * The shorthand for `autonumberFormat` — `@objectstack/spec`'s + * `FieldSchema.format`, which on an `autonumber` field is the same + * record-number pattern and predates `autonumberFormat`. The spec still + * accepts it and the backend still honours it, but only when the field + * carries no `autonumberFormat`. Prefer `autonumberFormat` on a new field. + */ format?: string; /** * REFUSED BY NAME (objectui#12242): `FieldSchema` refuses `starting_number` * with `unrecognized_keys` and declares no starting value (the record number - * is minted by the backend from `format`), and nothing reads it. Retired with - * no replacement key. + * is minted by the backend from `autonumberFormat`, or its shorthand + * `format`), and nothing reads it. Retired with no replacement key. * @deprecated Nothing reads this key. Remove it. */ starting_number?: never;