From af5ad81224498bb1deb5237c8e18284f79469ebc Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:18:01 +0800 Subject: [PATCH] refactor(types,app-shell,detail,grid,designer): hand-copied checklists become derivations or gated inventories (#3017) Sites #3-#6 of the audit, plus three more of the same species found while gating - all previously "keep in sync" comments with zero mechanism: - DesignerFieldType is now derived from a runtime DESIGNER_FIELD_TYPES array in @object-ui/types; app-shell's object-fields-bridge and plugin-designer's MetadataFieldsPage derive their 27-type sets from it instead of restating the list (FieldDesigner's FIELD_TYPE_META was already compile-gated via Record). The dead ALL_FIELD_TYPES const is removed, and the FIELD_TYPE_CATEGORIES / CATEGORY_ORDER partition gets a coverage gate - a type added to the vocabulary but left uncategorized would silently vanish from the type pickers. - The audit-provenance columns get one source: AUDIT_FIELD_BY_ROLE / AUDIT_FIELD_NAMES in @object-ui/types, consumed by RecordMetaFooter and RecordDetailView. RecordDetailView's HIDDEN_SYSTEM_FIELD_NAMES is now derived as spec FIELD_GROUP_SYSTEM_FIELDS minus the audit set, so a newly injected system column defaults to hidden instead of leaking into detail bodies until a hand list is edited. - ImportWizard's REFERENCE_IMPORT_TYPES is derived from the spec's REFERENCE_VALUE_TYPES plus the generic 'reference' alias - the same derivation the server uses - in a new importCoercionContract module. - The boolean token table cannot be derived (no spec export yet, framework#3786): it is split into true/false sets with disjointness + normalization gates and a pinned inventory so edits are deliberate. - multiValueFields' two sets were member-for-member copies of the spec's MULTI_OPTION_TYPES / MULTI_CAPABLE_TYPES - now consumed directly, with non-vacuity anchors so a shrinking spec vocabulary fails loudly instead of resurrecting the #2204 column-shape corruption. Mutation-tested: seven injected drifts across five source files produced 13 test failures across all five gate files, plus compile errors in FieldDesigner (TS2353/TS2322), MetadataFieldsPage (TS2769) and RecordMetaFooter (TS2339). Zero behavior change - every derived set is value-identical to the list it replaces. Co-Authored-By: Claude Fable 5 --- .../app-shell/src/views/RecordDetailView.tsx | 25 +----- .../previews/object-fields-bridge.ts | 37 ++------ .../views/record-detail-system-fields.test.ts | 51 +++++++++++ .../src/views/record-detail-system-fields.ts | 37 ++++++++ .../plugin-designer/src/FieldDesigner.tsx | 13 ++- .../src/MetadataFieldsPage.tsx | 9 +- .../src/__tests__/fieldTypeCategories.test.ts | 34 +++++++ .../plugin-detail/src/RecordMetaFooter.tsx | 13 ++- packages/plugin-grid/package.json | 1 + packages/plugin-grid/src/ImportWizard.tsx | 16 +--- .../src/hooks/multiValueFields.test.ts | 47 ++++++++++ .../plugin-grid/src/hooks/multiValueFields.ts | 22 +++-- .../src/importCoercionContract.test.ts | 90 +++++++++++++++++++ .../plugin-grid/src/importCoercionContract.ts | 57 ++++++++++++ .../__tests__/designer-field-types.test.ts | 44 +++++++++ .../types/src/__tests__/system-fields.test.ts | 43 ++++++++- packages/types/src/designer.ts | 69 ++++++++------ packages/types/src/index.ts | 9 +- packages/types/src/system-fields.ts | 27 ++++++ pnpm-lock.yaml | 6 +- 20 files changed, 521 insertions(+), 129 deletions(-) create mode 100644 packages/app-shell/src/views/record-detail-system-fields.test.ts create mode 100644 packages/app-shell/src/views/record-detail-system-fields.ts create mode 100644 packages/plugin-designer/src/__tests__/fieldTypeCategories.test.ts create mode 100644 packages/plugin-grid/src/hooks/multiValueFields.test.ts create mode 100644 packages/plugin-grid/src/importCoercionContract.test.ts create mode 100644 packages/plugin-grid/src/importCoercionContract.ts create mode 100644 packages/types/src/__tests__/designer-field-types.test.ts diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 98b71ce950..37d0b49d50 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -37,6 +37,10 @@ import { decisionOutputDefs, decisionOutputParams, foldDecisionOutputs } from '. import { interpretActionResponse } from '../utils/actionResponse'; import { interpretFlowResponse } from '../utils/flowResponse'; import { useRecordBreadcrumbTitle } from '../context/NavigationContext'; +// Audit provenance renders as the one-line ; the other +// framework-injected bookkeeping columns are hidden from the body outright. +// Both sets are derived, not restated — see record-detail-system-fields.ts. +import { AUDIT_FIELD_NAMES, HIDDEN_SYSTEM_FIELD_NAMES } from './record-detail-system-fields'; import type { FeedItem } from '@object-ui/types'; import type { ActionDef, ActionParamDef } from '@object-ui/core'; import { useRecordApprovals, recordLockedByApproval } from '../hooks/useRecordApprovals'; @@ -107,27 +111,6 @@ export function resolveActionUser( : identity; } -/** - * Audit field names auto-injected by the framework's `applySystemFields`. - * Filtered out of the auto-generated body sections — they are rendered - * separately as a single subtle one-line `` (see - * `@object-ui/plugin-detail`) so provenance stays discoverable without a - * heavy "System Information" panel. The inline-edit drawer also hides - * them via `DEFAULT_SYSTEM_FIELDS` in - * `@object-ui/plugin-detail/RecordDetailDrawer`. - */ -const AUDIT_FIELD_NAMES = new Set(['created_at', 'created_by', 'updated_at', 'updated_by']); - -/** - * System/tenant fields that the framework auto-injects on every record but - * which carry no business value on a detail page. Hidden from the - * auto-generated sections. Authors who really want to surface one can - * assign it to a `fieldGroups` group explicitly (explicit listing wins). - */ -const HIDDEN_SYSTEM_FIELD_NAMES = new Set([ - 'organization_id', 'tenant_id', 'is_deleted', 'deleted_at', -]); - /** * Field-type signals that suggest a "secondary / system / metadata" * placement when auto-grouping fields. These move out of the main diff --git a/packages/app-shell/src/views/metadata-admin/previews/object-fields-bridge.ts b/packages/app-shell/src/views/metadata-admin/previews/object-fields-bridge.ts index d313e0b25d..8e90fd2fd3 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/object-fields-bridge.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/object-fields-bridge.ts @@ -18,41 +18,18 @@ * is destroyed. */ +import { DESIGNER_FIELD_TYPES } from '@object-ui/types'; import type { DesignerFieldDefinition, DesignerFieldType, } from '@object-ui/types'; -/** Set of types the designer can edit losslessly. Keep in sync with FieldDesigner. */ -const DESIGNER_TYPES = new Set([ - 'text', - 'textarea', - 'number', - 'boolean', - 'date', - 'datetime', - 'time', - 'select', - 'email', - 'phone', - 'url', - 'password', - 'currency', - 'percent', - 'lookup', - 'formula', - 'autonumber', - 'file', - 'image', - 'markdown', - 'html', - 'color', - 'code', - 'location', - 'address', - 'rating', - 'slider', -]); +/** + * Set of types the designer can edit losslessly — derived from the canonical + * `DESIGNER_FIELD_TYPES` vocabulary rather than restated (objectui#3017), so + * this bridge and `FieldDesigner`'s palette cannot drift by construction. + */ +const DESIGNER_TYPES: ReadonlySet = new Set(DESIGNER_FIELD_TYPES); interface FrameworkFieldDef { type?: string; diff --git a/packages/app-shell/src/views/record-detail-system-fields.test.ts b/packages/app-shell/src/views/record-detail-system-fields.test.ts new file mode 100644 index 0000000000..21851a431a --- /dev/null +++ b/packages/app-shell/src/views/record-detail-system-fields.test.ts @@ -0,0 +1,51 @@ +/** + * 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 detail page's system-field partition is derived, not restated** + * (objectui#3017). `HIDDEN_SYSTEM_FIELD_NAMES` used to hard-code four + * bookkeeping columns under a comment; it is now the spec's + * `FIELD_GROUP_SYSTEM_FIELDS` minus the audit set, so a system column the + * framework starts injecting tomorrow defaults to hidden instead of leaking + * into the auto-generated body sections until someone edits a hand list. + * + * What is worth asserting is that the partition stays a partition, and that + * the columns each side exists for are still on that side. + */ + +import { describe, it, expect } from 'vitest'; +import { FIELD_GROUP_SYSTEM_FIELDS } from '@objectstack/spec/data'; +import { AUDIT_FIELD_NAMES, HIDDEN_SYSTEM_FIELD_NAMES } from './record-detail-system-fields'; + +describe('record-detail system-field partition (objectui#3017)', () => { + it('audit ∪ hidden covers the spec vocabulary exactly — nothing leaks into the body', () => { + const union = [...AUDIT_FIELD_NAMES, ...HIDDEN_SYSTEM_FIELD_NAMES].sort(); + expect(union).toEqual([...FIELD_GROUP_SYSTEM_FIELDS].sort()); + }); + + it('audit and hidden are disjoint — a column gets one treatment, not two', () => { + for (const name of AUDIT_FIELD_NAMES) { + expect(HIDDEN_SYSTEM_FIELD_NAMES.has(name), `'${name}' is both footer-rendered and hidden`).toBe(false); + } + }); + + it('is not vacuous — the bookkeeping columns the hidden set exists for are still hidden', () => { + // If the spec dropped or renamed one of these, the derivation would + // silently stop hiding it and the column would surface as a raw field on + // every detail page. Fail loudly and make that a deliberate decision. + for (const name of ['organization_id', 'tenant_id', 'is_deleted', 'deleted_at']) { + expect(HIDDEN_SYSTEM_FIELD_NAMES.has(name), `'${name}' no longer derived as hidden — spec vocabulary moved?`).toBe(true); + } + }); + + it('provenance stays in the footer, never silently swallowed by the hidden set', () => { + for (const name of ['created_at', 'created_by', 'updated_at', 'updated_by']) { + expect(AUDIT_FIELD_NAMES.has(name)).toBe(true); + } + }); +}); diff --git a/packages/app-shell/src/views/record-detail-system-fields.ts b/packages/app-shell/src/views/record-detail-system-fields.ts new file mode 100644 index 0000000000..0cd9962c41 --- /dev/null +++ b/packages/app-shell/src/views/record-detail-system-fields.ts @@ -0,0 +1,37 @@ +/** + * 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. + */ + +/** + * How `RecordDetailView` partitions the framework-injected system columns + * (spec `FIELD_GROUP_SYSTEM_FIELDS`) between its two treatments: + * + * - the four audit-provenance columns (`AUDIT_FIELD_NAMES`, single source in + * `@object-ui/types`) are rendered as a single subtle one-line + * `` (see `@object-ui/plugin-detail`) so provenance stays + * discoverable without a heavy "System Information" panel; + * - every other system column is hidden from the auto-generated body + * sections outright — tenancy / soft-delete bookkeeping carries no business + * value on a detail page. Authors who really want to surface one can + * assign it to a `fieldGroups` group explicitly (explicit listing wins). + * + * `HIDDEN_SYSTEM_FIELD_NAMES` is derived as spec-set minus audit-set rather + * than restated (objectui#3017): a system column the framework starts + * injecting tomorrow is bookkeeping until someone deliberately surfaces it, + * so it should default to hidden without waiting for a hand-list edit here. + * `record-detail-system-fields.test.ts` pins the partition's invariants. + */ + +import { AUDIT_FIELD_NAMES } from '@object-ui/types'; +import { FIELD_GROUP_SYSTEM_FIELDS } from '@objectstack/spec/data'; + +export { AUDIT_FIELD_NAMES }; + +/** Framework-injected bookkeeping columns hidden from detail body sections. */ +export const HIDDEN_SYSTEM_FIELD_NAMES: ReadonlySet = new Set( + [...FIELD_GROUP_SYSTEM_FIELDS].filter((name) => !AUDIT_FIELD_NAMES.has(name)), +); diff --git a/packages/plugin-designer/src/FieldDesigner.tsx b/packages/plugin-designer/src/FieldDesigner.tsx index 03a6e76fbc..2ae59049f2 100644 --- a/packages/plugin-designer/src/FieldDesigner.tsx +++ b/packages/plugin-designer/src/FieldDesigner.tsx @@ -102,11 +102,16 @@ const FIELD_TYPE_META: Record = { +/** + * Category partition of the designer vocabulary, driving the type filter and + * the drawer's type ``. tsc guarantees every entry IS a + * `DesignerFieldType` (and that `FIELD_TYPE_META` covers the whole union), + * but nothing at compile time guarantees every type LANDS in a category — a + * freshly added type would silently be missing from those pickers. This is + * that gate (objectui#3017). + */ + +import { describe, it, expect } from 'vitest'; +import { DESIGNER_FIELD_TYPES } from '@object-ui/types'; +import { CATEGORY_ORDER, FIELD_TYPE_CATEGORIES } from '../FieldDesigner'; + +describe('FIELD_TYPE_CATEGORIES covers the designer vocabulary (objectui#3017)', () => { + const flattened = Object.values(FIELD_TYPE_CATEGORIES).flat(); + + it('every DesignerFieldType appears in exactly one category', () => { + expect(new Set(flattened).size, 'a type appears in two categories').toBe(flattened.length); + expect([...flattened].sort()).toEqual([...DESIGNER_FIELD_TYPES].sort()); + }); + + it('CATEGORY_ORDER renders every category — a dropped entry hides its whole group', () => { + expect([...CATEGORY_ORDER].sort()).toEqual(Object.keys(FIELD_TYPE_CATEGORIES).sort()); + expect(new Set(CATEGORY_ORDER).size).toBe(CATEGORY_ORDER.length); + }); +}); diff --git a/packages/plugin-detail/src/RecordMetaFooter.tsx b/packages/plugin-detail/src/RecordMetaFooter.tsx index 4af11fa1b4..252672a875 100644 --- a/packages/plugin-detail/src/RecordMetaFooter.tsx +++ b/packages/plugin-detail/src/RecordMetaFooter.tsx @@ -9,19 +9,16 @@ import * as React from 'react'; import { cn, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@object-ui/components'; import { getCellRenderer, resolveCellRendererType } from '@object-ui/fields'; +import { AUDIT_FIELD_BY_ROLE } from '@object-ui/types'; import type { FieldMetadata } from '@object-ui/types'; import { useDetailTranslation } from './useDetailTranslation'; /** - * Audit field names auto-injected by the framework's `applySystemFields`. - * Kept in sync with `AUDIT_FIELD_NAMES` in `RecordDetailView`. + * Audit field names auto-injected by the framework's `applySystemFields` — + * the shared vocabulary `RecordDetailView` also uses to keep these out of the + * body sections (single source in `@object-ui/types`, objectui#3017). */ -const AUDIT_FIELDS = { - createdAt: 'created_at', - createdBy: 'created_by', - updatedAt: 'updated_at', - updatedBy: 'updated_by', -} as const; +const AUDIT_FIELDS = AUDIT_FIELD_BY_ROLE; export interface RecordMetaFooterProps { /** The current record data; expected to contain audit fields when available. */ diff --git a/packages/plugin-grid/package.json b/packages/plugin-grid/package.json index 2aefda2d83..e824907280 100644 --- a/packages/plugin-grid/package.json +++ b/packages/plugin-grid/package.json @@ -29,6 +29,7 @@ "@object-ui/permissions": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", + "@objectstack/spec": "^17.0.0-rc.0", "@tanstack/react-virtual": "^3.14.8", "exceljs": "^4.4.0", "lucide-react": "^1.25.0" diff --git a/packages/plugin-grid/src/ImportWizard.tsx b/packages/plugin-grid/src/ImportWizard.tsx index 2cd8eefcfc..b42c2313f5 100644 --- a/packages/plugin-grid/src/ImportWizard.tsx +++ b/packages/plugin-grid/src/ImportWizard.tsx @@ -13,6 +13,7 @@ import { import { Upload, FileSpreadsheet, CheckCircle2, AlertCircle, X, ArrowRight, ArrowLeft, Save, Trash2, ClipboardPaste, Download, Undo2 } from 'lucide-react'; import { useObjectTranslation } from '@object-ui/react'; import { sanitizeFileNameBase } from '@object-ui/core'; +import { BOOLEAN_IMPORT_TOKENS, REFERENCE_IMPORT_TYPES } from './importCoercionContract'; import type { DataSource, ImportRequestOptions, @@ -361,21 +362,6 @@ const CONFIDENCE_CLASS: Record = { low: 'text-muted-foreground', }; -/** Boolean tokens the server's import coercion accepts (import-coerce.ts). - * Kept in sync so the preview step doesn't flag a cell the server would take - * (e.g. Chinese 是/否, on/off, ✓/×). Compared case-insensitively. */ -const BOOLEAN_IMPORT_TOKENS = new Set([ - 'true', 't', 'yes', 'y', '1', 'on', '是', '对', '✓', '√', - 'false', 'f', 'no', 'n', '0', 'off', '否', '错', '✗', '×', -]); - -/** Field types the server resolves from display text to record IDs during - * `/import` (kept in step with the server's import-coerce REFERENCE_TYPES). - * The legacy per-row create fallback has no resolution step — raw cell text - * would be stored verbatim into relation fields — so the fallback must refuse - * to run when any mapped column targets one of these types. */ -const REFERENCE_IMPORT_TYPES = new Set(['lookup', 'master_detail', 'user', 'reference', 'tree']); - /** Mapped fields whose type the legacy fallback cannot import safely. */ function mappedReferenceFields( mapping: Record, diff --git a/packages/plugin-grid/src/hooks/multiValueFields.test.ts b/packages/plugin-grid/src/hooks/multiValueFields.test.ts new file mode 100644 index 0000000000..ec5c83ca23 --- /dev/null +++ b/packages/plugin-grid/src/hooks/multiValueFields.test.ts @@ -0,0 +1,47 @@ +/** + * 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. + */ + +/** + * `isMultiValueField` consumes the spec's `MULTI_OPTION_TYPES` / + * `MULTI_CAPABLE_TYPES` instead of restating them (objectui#3017). The + * anchors below are the non-vacuity gate: if the spec vocabulary silently + * shrank, `normalizeMultiValuePatch` would stop wrapping lone scalars for + * that type and the column-shape corruption of #2204 would return — fail + * loudly here instead. + */ + +import { describe, it, expect } from 'vitest'; +import { isMultiValueField, normalizeMultiValuePatch } from './multiValueFields'; + +describe('isMultiValueField (spec-derived, objectui#3017)', () => { + it('always-multi option types stay covered', () => { + for (const type of ['multiselect', 'checkboxes', 'tags']) { + expect(isMultiValueField({ type }), `'${type}' no longer always-multi — spec vocabulary moved?`).toBe(true); + } + }); + + it('multi-capable types stay covered when flagged multiple', () => { + for (const type of ['select', 'radio', 'lookup', 'user', 'file', 'image']) { + expect(isMultiValueField({ type, multiple: true }), `'${type}' no longer multi-capable — spec vocabulary moved?`).toBe(true); + expect(isMultiValueField({ type }), `'${type}' must stay single-value without the flag`).toBe(false); + } + }); + + it('stays null-tolerant and conservative for everything else', () => { + expect(isMultiValueField(undefined)).toBe(false); + expect(isMultiValueField(null)).toBe(false); + expect(isMultiValueField({})).toBe(false); + expect(isMultiValueField({ type: 'text', multiple: true })).toBe(false); + }); + + it('normalizeMultiValuePatch still wraps a lone scalar for a spec-classified field', () => { + const fields = { assignees: { type: 'user', multiple: true } }; + expect(normalizeMultiValuePatch({ assignees: 'u1' }, fields)).toEqual({ assignees: ['u1'] }); + expect(normalizeMultiValuePatch({ assignees: ['u1'] }, fields)).toEqual({ assignees: ['u1'] }); + }); +}); diff --git a/packages/plugin-grid/src/hooks/multiValueFields.ts b/packages/plugin-grid/src/hooks/multiValueFields.ts index 811dc975c7..27628bed79 100644 --- a/packages/plugin-grid/src/hooks/multiValueFields.ts +++ b/packages/plugin-grid/src/hooks/multiValueFields.ts @@ -6,6 +6,8 @@ * LICENSE file in the root directory of this source tree. */ +import { MULTI_CAPABLE_TYPES, MULTI_OPTION_TYPES } from '@objectstack/spec/data'; + /** * Minimal object-schema field shape needed to decide single- vs multi-value * semantics. Matches the `fields` map served by the ObjectStack meta API @@ -16,23 +18,19 @@ export interface MultiValueFieldDef { multiple?: boolean; } -/** Types whose persisted value is ALWAYS an array of scalars. */ -const ALWAYS_MULTI_TYPES = new Set(['multiselect', 'checkboxes', 'tags']); - /** - * Types that become array-shaped when flagged `multiple: true`. Mirrors the - * server-side write pipeline (framework #2552): per the spec, `multiple` - * applies to select/lookup/file/image; `radio` shares the select semantics - * and `user` is stored identically to `lookup` (the runtime expands - * `Field.user` to `type: 'user'`, not `'lookup'`). + * Whether the given object-schema field stores an array of scalars. + * + * A null-tolerant wrapper over the spec's own classification (its + * `isMultiValueField` requires a def): `MULTI_OPTION_TYPES` are always + * array-shaped, `MULTI_CAPABLE_TYPES` become array-shaped when flagged + * `multiple: true` — the same sets the server-side write pipeline derives + * from (framework #2552), consumed instead of restated (objectui#3017). */ -const MULTI_CAPABLE_TYPES = new Set(['select', 'radio', 'lookup', 'user', 'file', 'image']); - -/** Whether the given object-schema field stores an array of scalars. */ export function isMultiValueField(def: MultiValueFieldDef | undefined | null): boolean { const t = def?.type; if (!t) return false; - if (ALWAYS_MULTI_TYPES.has(t)) return true; + if (MULTI_OPTION_TYPES.has(t)) return true; return MULTI_CAPABLE_TYPES.has(t) && def?.multiple === true; } diff --git a/packages/plugin-grid/src/importCoercionContract.test.ts b/packages/plugin-grid/src/importCoercionContract.test.ts new file mode 100644 index 0000000000..c6611a4b83 --- /dev/null +++ b/packages/plugin-grid/src/importCoercionContract.test.ts @@ -0,0 +1,90 @@ +/** + * 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. + */ + +/** + * Gates on the Import Wizard's mirror of the server's import-coercion + * contract (`import-coerce.ts` in the framework) — objectui#3017. + * + * `REFERENCE_IMPORT_TYPES` is derived from the same spec constant the server + * derives from, so the two ends share one source. The boolean token table has + * no spec export yet (framework#3786), so it cannot be derived — the pinned + * inventory below is the tripwire that makes edits deliberate instead of + * silent. When the spec starts publishing the table, replace the local sets + * with the import and delete the inventory. + */ + +import { describe, it, expect } from 'vitest'; +import { REFERENCE_VALUE_TYPES } from '@objectstack/spec/data'; +import { + BOOLEAN_FALSE_IMPORT_TOKENS, + BOOLEAN_IMPORT_TOKENS, + BOOLEAN_TRUE_IMPORT_TOKENS, + REFERENCE_IMPORT_TYPES, +} from './importCoercionContract'; + +describe('BOOLEAN_IMPORT_TOKENS (server BOOL_TRUE/BOOL_FALSE mirror)', () => { + it('pins the token inventory — edits must be checked against the server table', () => { + // Deliberate second statement: if this fails you edited the wizard's + // boolean vocabulary. Verify the server's BOOL_TRUE/BOOL_FALSE in + // import-coerce.ts accepts the same tokens before re-pinning, or the + // preview will flag cells the server takes (or wave through cells it + // rejects as `invalid_boolean`). + expect([...BOOLEAN_TRUE_IMPORT_TOKENS].sort()).toEqual( + ['true', 't', 'yes', 'y', '1', 'on', '是', '对', '✓', '√'].sort(), + ); + expect([...BOOLEAN_FALSE_IMPORT_TOKENS].sort()).toEqual( + ['false', 'f', 'no', 'n', '0', 'off', '否', '错', '✗', '×'].sort(), + ); + }); + + it('true and false tokens are disjoint — one token, one verdict', () => { + for (const token of BOOLEAN_TRUE_IMPORT_TOKENS) { + expect(BOOLEAN_FALSE_IMPORT_TOKENS.has(token), `'${token}' is both truthy and falsy`).toBe(false); + } + }); + + it('every token survives the lookup normalization (trim + lowercase)', () => { + // validateValue checks `value.trim().toLowerCase()` against the set — a + // token stored with case or whitespace could never match anything. + for (const token of BOOLEAN_IMPORT_TOKENS) { + expect(token, `'${token}' is not trim/lowercase-normalized`).toBe(token.trim().toLowerCase()); + expect(token.length).toBeGreaterThan(0); + } + }); + + it('the combined set is exactly the union of the two verdict sets', () => { + expect(BOOLEAN_IMPORT_TOKENS.size).toBe( + BOOLEAN_TRUE_IMPORT_TOKENS.size + BOOLEAN_FALSE_IMPORT_TOKENS.size, + ); + for (const token of [...BOOLEAN_TRUE_IMPORT_TOKENS, ...BOOLEAN_FALSE_IMPORT_TOKENS]) { + expect(BOOLEAN_IMPORT_TOKENS.has(token)).toBe(true); + } + }); +}); + +describe('REFERENCE_IMPORT_TYPES (derived from spec REFERENCE_VALUE_TYPES)', () => { + it('is spec plus the generic reference alias, nothing else', () => { + for (const type of REFERENCE_VALUE_TYPES) { + expect(REFERENCE_IMPORT_TYPES.has(type)).toBe(true); + } + expect(REFERENCE_IMPORT_TYPES.has('reference')).toBe(true); + // Also implies 'reference' ∉ REFERENCE_VALUE_TYPES today — if the spec + // starts including it, this fails so the local alias can be retired. + expect(REFERENCE_IMPORT_TYPES.size).toBe(REFERENCE_VALUE_TYPES.size + 1); + }); + + it('is not vacuous — the types the legacy-fallback refusal exists for are still covered', () => { + // The per-row create fallback stores raw cell text verbatim; if a member + // vanished from the spec set, the wizard would silently stop refusing to + // fall back for that type and corrupt relation fields. Fail loudly and + // make the vocabulary change a deliberate review. + for (const type of ['lookup', 'master_detail', 'user', 'tree', 'reference']) { + expect(REFERENCE_IMPORT_TYPES.has(type), `'${type}' no longer covered — spec vocabulary moved?`).toBe(true); + } + }); +}); diff --git a/packages/plugin-grid/src/importCoercionContract.ts b/packages/plugin-grid/src/importCoercionContract.ts new file mode 100644 index 0000000000..2595fc5609 --- /dev/null +++ b/packages/plugin-grid/src/importCoercionContract.ts @@ -0,0 +1,57 @@ +/** + * 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 slice of the server's import-coercion contract (`import-coerce.ts` in + * the framework) that the Import Wizard's preview step re-checks client-side, + * so a cell is flagged red here exactly when the server would reject it — + * and never flagged for a value the server would take (objectui#3017). + */ + +import { REFERENCE_VALUE_TYPES } from '@objectstack/spec/data'; + +/** + * Truthy tokens the server's boolean coercion accepts (`BOOL_TRUE`), compared + * after `trim().toLowerCase()` — so every entry must already be lower-case + * and trimmed (`importCoercionContract.test.ts` enforces both, plus + * disjointness from the falsy set). + * + * The spec does not publish this table yet, so it cannot be derived — the + * paired inventory in the test is the tripwire that keeps edits deliberate + * (framework#3786 tracks exporting it from the source of truth). + */ +export const BOOLEAN_TRUE_IMPORT_TOKENS: ReadonlySet = new Set([ + 'true', 't', 'yes', 'y', '1', 'on', '是', '对', '✓', '√', +]); + +/** Falsy tokens the server's boolean coercion accepts (`BOOL_FALSE`). */ +export const BOOLEAN_FALSE_IMPORT_TOKENS: ReadonlySet = new Set([ + 'false', 'f', 'no', 'n', '0', 'off', '否', '错', '✗', '×', +]); + +/** Every boolean token the server accepts (e.g. Chinese 是/否, on/off, ✓/×). */ +export const BOOLEAN_IMPORT_TOKENS: ReadonlySet = new Set([ + ...BOOLEAN_TRUE_IMPORT_TOKENS, + ...BOOLEAN_FALSE_IMPORT_TOKENS, +]); + +/** + * Field types the server resolves from display text to record IDs during + * `/import`. Derived exactly the way the server derives its + * `REFERENCE_TYPES` — the spec's reference-shaped value types plus the + * generic `'reference'` alias — so the two ends share one source instead of + * two hand lists. + * + * The legacy per-row create fallback has no resolution step — raw cell text + * would be stored verbatim into relation fields — so the fallback must refuse + * to run when any mapped column targets one of these types. + */ +export const REFERENCE_IMPORT_TYPES: ReadonlySet = new Set([ + ...REFERENCE_VALUE_TYPES, + 'reference', +]); diff --git a/packages/types/src/__tests__/designer-field-types.test.ts b/packages/types/src/__tests__/designer-field-types.test.ts new file mode 100644 index 0000000000..4f6f4f1791 --- /dev/null +++ b/packages/types/src/__tests__/designer-field-types.test.ts @@ -0,0 +1,44 @@ +/** + * 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. + */ + +/** + * **`DESIGNER_FIELD_TYPES` is the single vocabulary source** (objectui#3017). + * + * `DesignerFieldType` is derived from this array, `FieldDesigner`'s + * `FIELD_TYPE_META` must cover it (total `Record`, so tsc gates that side), + * and app-shell's `object-fields-bridge` builds its editable-subset check + * from it. The inventory below is a deliberate second statement of the list: + * growing or shrinking the vocabulary is a two-file edit by design, so it + * cannot happen as a drive-by — the failure message routes the editor to the + * consumers that must be reviewed alongside it. + */ + +import { describe, it, expect } from 'vitest'; +import { DESIGNER_FIELD_TYPES } from '../index'; + +const INVENTORY = [ + 'address', 'autonumber', 'boolean', 'code', 'color', 'currency', 'date', + 'datetime', 'email', 'file', 'formula', 'html', 'image', 'location', + 'lookup', 'markdown', 'number', 'password', 'percent', 'phone', 'rating', + 'select', 'slider', 'text', 'textarea', 'time', 'url', +] as const; + +describe('DESIGNER_FIELD_TYPES (canonical Field Designer vocabulary)', () => { + it('has no duplicate entries — a duplicate would silently shrink every derived Set', () => { + expect(new Set(DESIGNER_FIELD_TYPES).size).toBe(DESIGNER_FIELD_TYPES.length); + }); + + it('matches the checked-in inventory — vocabulary edits must be deliberate', () => { + // If this fails you changed the designer's type vocabulary. That is fine, + // but it is never only a types change: FieldDesigner needs presentation + // (FIELD_TYPE_META + FIELD_TYPE_CATEGORIES), the object-fields-bridge + // round-trip should be re-checked for the new/removed type, and this + // inventory re-pinned to acknowledge the review happened. + expect([...DESIGNER_FIELD_TYPES].sort()).toEqual([...INVENTORY]); + }); +}); diff --git a/packages/types/src/__tests__/system-fields.test.ts b/packages/types/src/__tests__/system-fields.test.ts index 52ea9d5d4e..72c5922409 100644 --- a/packages/types/src/__tests__/system-fields.test.ts +++ b/packages/types/src/__tests__/system-fields.test.ts @@ -7,7 +7,13 @@ */ import { describe, it, expect } from 'vitest'; -import { isSystemManagedField, SYSTEM_MANAGED_FIELD_NAMES } from '../index'; +import { FIELD_GROUP_SYSTEM_FIELDS } from '@objectstack/spec/data'; +import { + AUDIT_FIELD_BY_ROLE, + AUDIT_FIELD_NAMES, + isSystemManagedField, + SYSTEM_MANAGED_FIELD_NAMES, +} from '../index'; describe('isSystemManagedField', () => { it('treats fields flagged `system: true` as system-managed (the single source of truth)', () => { @@ -42,3 +48,38 @@ describe('isSystemManagedField', () => { expect(SYSTEM_MANAGED_FIELD_NAMES.has('name')).toBe(false); }); }); + +describe('AUDIT_FIELD_BY_ROLE / AUDIT_FIELD_NAMES (single audit vocabulary, objectui#3017)', () => { + it('pins the four provenance columns — edits must be deliberate', () => { + // A deliberate second statement: RecordMetaFooter renders exactly these, + // RecordDetailView excludes exactly these from body sections, and the + // framework injects them via `applySystemFields`. Changing the list is a + // cross-surface decision, not a drive-by — re-pin after reviewing both + // consumers (and the framework's registry, which owns the wire names). + expect(AUDIT_FIELD_BY_ROLE).toEqual({ + createdAt: 'created_at', + createdBy: 'created_by', + updatedAt: 'updated_at', + updatedBy: 'updated_by', + }); + expect([...AUDIT_FIELD_NAMES].sort()).toEqual( + Object.values(AUDIT_FIELD_BY_ROLE).sort(), + ); + }); + + it('stays within the display-oriented system-managed superset', () => { + for (const name of AUDIT_FIELD_NAMES) { + expect(SYSTEM_MANAGED_FIELD_NAMES.has(name), `${name} missing from SYSTEM_MANAGED_FIELD_NAMES`).toBe(true); + } + }); + + it('stays within the spec-published system-field vocabulary (cross-repo tripwire)', () => { + // FIELD_GROUP_SYSTEM_FIELDS is the spec's own list of the columns the + // framework injects. If the framework renames an audit column (say + // created_at → createdAt), the spec set moves and this fails — turning a + // silent cross-repo drift into a red test. + for (const name of AUDIT_FIELD_NAMES) { + expect(FIELD_GROUP_SYSTEM_FIELDS.has(name), `spec no longer lists '${name}' as a system field`).toBe(true); + } + }); +}); diff --git a/packages/types/src/designer.ts b/packages/types/src/designer.ts index ee7b365acc..c1a16eb464 100644 --- a/packages/types/src/designer.ts +++ b/packages/types/src/designer.ts @@ -688,35 +688,48 @@ export interface ObjectManagerSchema extends BaseSchema { // Field Designer (Field Configuration Wizard) // ============================================================================ +/** + * Canonical, ordered list of field types the Field Designer supports. + * + * Single runtime source for every surface that enumerates the designer's + * vocabulary: `FieldDesigner` renders its palette in exactly this order (its + * `FIELD_TYPE_META` is a `Record`, so adding a member + * here without presentation is a compile error), and app-shell's + * `object-fields-bridge` derives its editable-subset check from it instead of + * restating the list (objectui#3017). + */ +export const DESIGNER_FIELD_TYPES = [ + 'text', + 'textarea', + 'number', + 'boolean', + 'date', + 'datetime', + 'time', + 'select', + 'email', + 'phone', + 'url', + 'password', + 'currency', + 'percent', + 'lookup', + 'formula', + 'autonumber', + 'file', + 'image', + 'markdown', + 'html', + 'color', + 'code', + 'location', + 'address', + 'rating', + 'slider', +] as const; + /** Supported field types in the Field Designer */ -export type DesignerFieldType = - | 'text' - | 'textarea' - | 'number' - | 'boolean' - | 'date' - | 'datetime' - | 'time' - | 'select' - | 'email' - | 'phone' - | 'url' - | 'password' - | 'currency' - | 'percent' - | 'lookup' - | 'formula' - | 'autonumber' - | 'file' - | 'image' - | 'markdown' - | 'html' - | 'color' - | 'code' - | 'location' - | 'address' - | 'rating' - | 'slider'; +export type DesignerFieldType = (typeof DESIGNER_FIELD_TYPES)[number]; /** Select option for field designer */ export interface DesignerFieldOption { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 825f3eda32..9336278581 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -438,7 +438,13 @@ export type { // System / audit / ownership field classification — runtime helper + name set, // used by default list-column derivation to keep framework-injected fields // (notably `owner_id`) out of the leading business columns. -export { SYSTEM_MANAGED_FIELD_NAMES, isSystemManagedField } from './system-fields'; +export { + SYSTEM_MANAGED_FIELD_NAMES, + AUDIT_FIELD_BY_ROLE, + AUDIT_FIELD_NAMES, + isSystemManagedField, +} from './system-fields'; +export type { AuditFieldName } from './system-fields'; export { MANAGED_BY_BUCKETS } from './managed-by'; export type { ManagedByBucket } from './managed-by'; @@ -607,6 +613,7 @@ export type { export { DASHBOARD_COLOR_VARIANTS, DASHBOARD_WIDGET_TYPES, + DESIGNER_FIELD_TYPES, } from './designer'; // ============================================================================ diff --git a/packages/types/src/system-fields.ts b/packages/types/src/system-fields.ts index 57f9bc194a..7f0bbffd51 100644 --- a/packages/types/src/system-fields.ts +++ b/packages/types/src/system-fields.ts @@ -37,6 +37,33 @@ export const SYSTEM_MANAGED_FIELD_NAMES: ReadonlySet = new Set([ 'owner_id', 'organization_id', 'tenant_id', 'company_id', 'space', ]); +/** + * The four audit-provenance columns `applySystemFields` injects on every + * business object, keyed by display role. + * + * Single source for the surfaces that treat provenance specially + * (objectui#3017): `RecordMetaFooter` (`@object-ui/plugin-detail`) renders + * exactly these four as the one-line footer, and `RecordDetailView` + * (`@object-ui/app-shell`) excludes the same four from the auto-generated + * body sections so they only appear in that footer. The snake_case values are + * the wire names the framework stores — they are a strict subset of the + * spec's `FIELD_GROUP_SYSTEM_FIELDS` (asserted in `system-fields.test.ts`). + */ +export const AUDIT_FIELD_BY_ROLE = { + createdAt: 'created_at', + createdBy: 'created_by', + updatedAt: 'updated_at', + updatedBy: 'updated_by', +} as const; + +/** Wire name of one audit-provenance column. */ +export type AuditFieldName = (typeof AUDIT_FIELD_BY_ROLE)[keyof typeof AUDIT_FIELD_BY_ROLE]; + +/** {@link AUDIT_FIELD_BY_ROLE} as a set, for name-filtering call sites. */ +export const AUDIT_FIELD_NAMES: ReadonlySet = new Set( + Object.values(AUDIT_FIELD_BY_ROLE), +); + /** * Whether a field is a framework-managed system / audit / ownership column * rather than an author-declared business field. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 073234d9e7..7170f5ab91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1977,6 +1977,9 @@ importers: '@object-ui/types': specifier: workspace:* version: link:../types + '@objectstack/spec': + specifier: ^17.0.0-rc.0 + version: 17.0.0-rc.0(ai@7.0.37(zod@4.4.3)) '@tanstack/react-virtual': specifier: ^3.14.8 version: 3.14.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1996,9 +1999,6 @@ importers: '@object-ui/data-objectstack': specifier: workspace:* version: link:../data-objectstack - '@objectstack/spec': - specifier: ^17.0.0-rc.0 - version: 17.0.0-rc.0(ai@7.0.37(zod@4.4.3)) '@vitejs/plugin-react': specifier: ^6.0.4 version: 6.0.4(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))