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
11 changes: 11 additions & 0 deletions .changeset/12282-types-autonumber-format.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 18 additions & 13 deletions content/docs/fields/auto-number.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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).

Expand All @@ -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
Expand Down Expand Up @@ -120,7 +125,7 @@ const orderNumber: AutoNumberFieldMetadata = {
type: 'autonumber',
name: 'order_number',
label: 'Order Number',
format: 'ORD-{YYYY}-{0000}',
autonumberFormat: 'ORD-{YYYY}-{0000}',
};
```

Expand Down
Original file line number Diff line number Diff line change
@@ -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 extends true> = T;
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

// ── The type: the spec's member, by reference; the shorthand kept ───────────

export type assertionAutonumberFormatByReference = [
Expect<Equal<AutoNumberFieldMetadata['autonumberFormat'], SpecField['autonumberFormat']>>,
Expect<Equal<AutoNumberFieldMetadata['format'], string | undefined>>,
];

/** 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<typeof FieldSchema.safeParse>, 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);
});
});
26 changes: 24 additions & 2 deletions packages/types/src/field-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading