Skip to content

Commit 4e11db8

Browse files
os-zhuangclaude
andauthored
refactor(types,app-shell,detail,grid,designer): hand-copied checklists become derivations or gated inventories (#3017) (#3043)
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<DesignerFieldType>). 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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e4c2783 commit 4e11db8

20 files changed

Lines changed: 521 additions & 129 deletions

packages/app-shell/src/views/RecordDetailView.tsx

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ import { decisionOutputDefs, decisionOutputParams, foldDecisionOutputs } from '.
3737
import { interpretActionResponse } from '../utils/actionResponse';
3838
import { interpretFlowResponse } from '../utils/flowResponse';
3939
import { useRecordBreadcrumbTitle } from '../context/NavigationContext';
40+
// Audit provenance renders as the one-line <RecordMetaFooter>; the other
41+
// framework-injected bookkeeping columns are hidden from the body outright.
42+
// Both sets are derived, not restated — see record-detail-system-fields.ts.
43+
import { AUDIT_FIELD_NAMES, HIDDEN_SYSTEM_FIELD_NAMES } from './record-detail-system-fields';
4044
import type { FeedItem } from '@object-ui/types';
4145
import type { ActionDef, ActionParamDef } from '@object-ui/core';
4246
import { useRecordApprovals, recordLockedByApproval } from '../hooks/useRecordApprovals';
@@ -107,27 +111,6 @@ export function resolveActionUser(
107111
: identity;
108112
}
109113

110-
/**
111-
* Audit field names auto-injected by the framework's `applySystemFields`.
112-
* Filtered out of the auto-generated body sections — they are rendered
113-
* separately as a single subtle one-line `<RecordMetaFooter>` (see
114-
* `@object-ui/plugin-detail`) so provenance stays discoverable without a
115-
* heavy "System Information" panel. The inline-edit drawer also hides
116-
* them via `DEFAULT_SYSTEM_FIELDS` in
117-
* `@object-ui/plugin-detail/RecordDetailDrawer`.
118-
*/
119-
const AUDIT_FIELD_NAMES = new Set(['created_at', 'created_by', 'updated_at', 'updated_by']);
120-
121-
/**
122-
* System/tenant fields that the framework auto-injects on every record but
123-
* which carry no business value on a detail page. Hidden from the
124-
* auto-generated sections. Authors who really want to surface one can
125-
* assign it to a `fieldGroups` group explicitly (explicit listing wins).
126-
*/
127-
const HIDDEN_SYSTEM_FIELD_NAMES = new Set([
128-
'organization_id', 'tenant_id', 'is_deleted', 'deleted_at',
129-
]);
130-
131114
/**
132115
* Field-type signals that suggest a "secondary / system / metadata"
133116
* placement when auto-grouping fields. These move out of the main

packages/app-shell/src/views/metadata-admin/previews/object-fields-bridge.ts

Lines changed: 7 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,41 +18,18 @@
1818
* is destroyed.
1919
*/
2020

21+
import { DESIGNER_FIELD_TYPES } from '@object-ui/types';
2122
import type {
2223
DesignerFieldDefinition,
2324
DesignerFieldType,
2425
} from '@object-ui/types';
2526

26-
/** Set of types the designer can edit losslessly. Keep in sync with FieldDesigner. */
27-
const DESIGNER_TYPES = new Set<DesignerFieldType>([
28-
'text',
29-
'textarea',
30-
'number',
31-
'boolean',
32-
'date',
33-
'datetime',
34-
'time',
35-
'select',
36-
'email',
37-
'phone',
38-
'url',
39-
'password',
40-
'currency',
41-
'percent',
42-
'lookup',
43-
'formula',
44-
'autonumber',
45-
'file',
46-
'image',
47-
'markdown',
48-
'html',
49-
'color',
50-
'code',
51-
'location',
52-
'address',
53-
'rating',
54-
'slider',
55-
]);
27+
/**
28+
* Set of types the designer can edit losslessly — derived from the canonical
29+
* `DESIGNER_FIELD_TYPES` vocabulary rather than restated (objectui#3017), so
30+
* this bridge and `FieldDesigner`'s palette cannot drift by construction.
31+
*/
32+
const DESIGNER_TYPES: ReadonlySet<DesignerFieldType> = new Set(DESIGNER_FIELD_TYPES);
5633

5734
interface FrameworkFieldDef {
5835
type?: string;
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* **The detail page's system-field partition is derived, not restated**
11+
* (objectui#3017). `HIDDEN_SYSTEM_FIELD_NAMES` used to hard-code four
12+
* bookkeeping columns under a comment; it is now the spec's
13+
* `FIELD_GROUP_SYSTEM_FIELDS` minus the audit set, so a system column the
14+
* framework starts injecting tomorrow defaults to hidden instead of leaking
15+
* into the auto-generated body sections until someone edits a hand list.
16+
*
17+
* What is worth asserting is that the partition stays a partition, and that
18+
* the columns each side exists for are still on that side.
19+
*/
20+
21+
import { describe, it, expect } from 'vitest';
22+
import { FIELD_GROUP_SYSTEM_FIELDS } from '@objectstack/spec/data';
23+
import { AUDIT_FIELD_NAMES, HIDDEN_SYSTEM_FIELD_NAMES } from './record-detail-system-fields';
24+
25+
describe('record-detail system-field partition (objectui#3017)', () => {
26+
it('audit ∪ hidden covers the spec vocabulary exactly — nothing leaks into the body', () => {
27+
const union = [...AUDIT_FIELD_NAMES, ...HIDDEN_SYSTEM_FIELD_NAMES].sort();
28+
expect(union).toEqual([...FIELD_GROUP_SYSTEM_FIELDS].sort());
29+
});
30+
31+
it('audit and hidden are disjoint — a column gets one treatment, not two', () => {
32+
for (const name of AUDIT_FIELD_NAMES) {
33+
expect(HIDDEN_SYSTEM_FIELD_NAMES.has(name), `'${name}' is both footer-rendered and hidden`).toBe(false);
34+
}
35+
});
36+
37+
it('is not vacuous — the bookkeeping columns the hidden set exists for are still hidden', () => {
38+
// If the spec dropped or renamed one of these, the derivation would
39+
// silently stop hiding it and the column would surface as a raw field on
40+
// every detail page. Fail loudly and make that a deliberate decision.
41+
for (const name of ['organization_id', 'tenant_id', 'is_deleted', 'deleted_at']) {
42+
expect(HIDDEN_SYSTEM_FIELD_NAMES.has(name), `'${name}' no longer derived as hidden — spec vocabulary moved?`).toBe(true);
43+
}
44+
});
45+
46+
it('provenance stays in the footer, never silently swallowed by the hidden set', () => {
47+
for (const name of ['created_at', 'created_by', 'updated_at', 'updated_by']) {
48+
expect(AUDIT_FIELD_NAMES.has(name)).toBe(true);
49+
}
50+
});
51+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* How `RecordDetailView` partitions the framework-injected system columns
11+
* (spec `FIELD_GROUP_SYSTEM_FIELDS`) between its two treatments:
12+
*
13+
* - the four audit-provenance columns (`AUDIT_FIELD_NAMES`, single source in
14+
* `@object-ui/types`) are rendered as a single subtle one-line
15+
* `<RecordMetaFooter>` (see `@object-ui/plugin-detail`) so provenance stays
16+
* discoverable without a heavy "System Information" panel;
17+
* - every other system column is hidden from the auto-generated body
18+
* sections outright — tenancy / soft-delete bookkeeping carries no business
19+
* value on a detail page. Authors who really want to surface one can
20+
* assign it to a `fieldGroups` group explicitly (explicit listing wins).
21+
*
22+
* `HIDDEN_SYSTEM_FIELD_NAMES` is derived as spec-set minus audit-set rather
23+
* than restated (objectui#3017): a system column the framework starts
24+
* injecting tomorrow is bookkeeping until someone deliberately surfaces it,
25+
* so it should default to hidden without waiting for a hand-list edit here.
26+
* `record-detail-system-fields.test.ts` pins the partition's invariants.
27+
*/
28+
29+
import { AUDIT_FIELD_NAMES } from '@object-ui/types';
30+
import { FIELD_GROUP_SYSTEM_FIELDS } from '@objectstack/spec/data';
31+
32+
export { AUDIT_FIELD_NAMES };
33+
34+
/** Framework-injected bookkeeping columns hidden from detail body sections. */
35+
export const HIDDEN_SYSTEM_FIELD_NAMES: ReadonlySet<string> = new Set(
36+
[...FIELD_GROUP_SYSTEM_FIELDS].filter((name) => !AUDIT_FIELD_NAMES.has(name)),
37+
);

packages/plugin-designer/src/FieldDesigner.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,16 @@ const FIELD_TYPE_META: Record<DesignerFieldType, { label: string; Icon: React.FC
102102
slider: { label: 'Slider', Icon: SlidersHorizontal },
103103
};
104104

105-
const ALL_FIELD_TYPES = Object.keys(FIELD_TYPE_META) as DesignerFieldType[];
106-
107105
type FieldTypeCategory = 'text' | 'number' | 'date' | 'choice' | 'relation' | 'advanced';
108106

109-
const FIELD_TYPE_CATEGORIES: Record<FieldTypeCategory, DesignerFieldType[]> = {
107+
/**
108+
* Category partition of the designer vocabulary, driving the type filter and
109+
* the drawer's type `<select>`. Exported (with {@link CATEGORY_ORDER}) so
110+
* `fieldTypeCategories.test.ts` can assert it stays a complete partition of
111+
* `DESIGNER_FIELD_TYPES` — a type added to the vocabulary but left out here
112+
* would otherwise silently vanish from those pickers (objectui#3017).
113+
*/
114+
export const FIELD_TYPE_CATEGORIES: Record<FieldTypeCategory, DesignerFieldType[]> = {
110115
text: ['text', 'textarea', 'email', 'phone', 'url', 'password', 'markdown', 'html'],
111116
number: ['number', 'currency', 'percent', 'autonumber', 'rating', 'slider'],
112117
date: ['date', 'datetime', 'time'],
@@ -115,7 +120,7 @@ const FIELD_TYPE_CATEGORIES: Record<FieldTypeCategory, DesignerFieldType[]> = {
115120
advanced: ['formula', 'file', 'image', 'color', 'code', 'location', 'address'],
116121
};
117122

118-
const CATEGORY_ORDER: FieldTypeCategory[] = ['text', 'number', 'date', 'choice', 'relation', 'advanced'];
123+
export const CATEGORY_ORDER: FieldTypeCategory[] = ['text', 'number', 'date', 'choice', 'relation', 'advanced'];
119124

120125

121126
// ============================================================================

packages/plugin-designer/src/MetadataFieldsPage.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
*/
3232

3333
import React, { useCallback, useEffect, useMemo, useState } from 'react';
34+
import { DESIGNER_FIELD_TYPES } from '@object-ui/types';
3435
import type { DesignerFieldDefinition, DesignerFieldType } from '@object-ui/types';
3536
import { MetadataClient, type MetadataClientConfig } from '@object-ui/data-objectstack';
3637
import { FieldDesigner } from './FieldDesigner';
@@ -68,12 +69,8 @@ interface ServerObjectSchema {
6869
[key: string]: unknown;
6970
}
7071

71-
const KNOWN_FIELD_TYPES = new Set<DesignerFieldType>([
72-
'text', 'textarea', 'number', 'boolean', 'date', 'datetime', 'time', 'select',
73-
'email', 'phone', 'url', 'password', 'currency', 'percent', 'lookup', 'formula',
74-
'autonumber', 'file', 'image', 'markdown', 'html', 'color', 'code', 'location',
75-
'address', 'rating', 'slider',
76-
]);
72+
// Derived from the canonical vocabulary rather than restated (objectui#3017).
73+
const KNOWN_FIELD_TYPES: ReadonlySet<DesignerFieldType> = new Set(DESIGNER_FIELD_TYPES);
7774

7875
function toDesignerType(raw: string | undefined): DesignerFieldType {
7976
if (raw && KNOWN_FIELD_TYPES.has(raw as DesignerFieldType)) {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* `FIELD_TYPE_CATEGORIES` partitions the designer vocabulary for the type
11+
* filter and the drawer's type `<select>`. tsc guarantees every entry IS a
12+
* `DesignerFieldType` (and that `FIELD_TYPE_META` covers the whole union),
13+
* but nothing at compile time guarantees every type LANDS in a category — a
14+
* freshly added type would silently be missing from those pickers. This is
15+
* that gate (objectui#3017).
16+
*/
17+
18+
import { describe, it, expect } from 'vitest';
19+
import { DESIGNER_FIELD_TYPES } from '@object-ui/types';
20+
import { CATEGORY_ORDER, FIELD_TYPE_CATEGORIES } from '../FieldDesigner';
21+
22+
describe('FIELD_TYPE_CATEGORIES covers the designer vocabulary (objectui#3017)', () => {
23+
const flattened = Object.values(FIELD_TYPE_CATEGORIES).flat();
24+
25+
it('every DesignerFieldType appears in exactly one category', () => {
26+
expect(new Set(flattened).size, 'a type appears in two categories').toBe(flattened.length);
27+
expect([...flattened].sort()).toEqual([...DESIGNER_FIELD_TYPES].sort());
28+
});
29+
30+
it('CATEGORY_ORDER renders every category — a dropped entry hides its whole group', () => {
31+
expect([...CATEGORY_ORDER].sort()).toEqual(Object.keys(FIELD_TYPE_CATEGORIES).sort());
32+
expect(new Set(CATEGORY_ORDER).size).toBe(CATEGORY_ORDER.length);
33+
});
34+
});

packages/plugin-detail/src/RecordMetaFooter.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,16 @@
99
import * as React from 'react';
1010
import { cn, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@object-ui/components';
1111
import { getCellRenderer, resolveCellRendererType } from '@object-ui/fields';
12+
import { AUDIT_FIELD_BY_ROLE } from '@object-ui/types';
1213
import type { FieldMetadata } from '@object-ui/types';
1314
import { useDetailTranslation } from './useDetailTranslation';
1415

1516
/**
16-
* Audit field names auto-injected by the framework's `applySystemFields`.
17-
* Kept in sync with `AUDIT_FIELD_NAMES` in `RecordDetailView`.
17+
* Audit field names auto-injected by the framework's `applySystemFields` —
18+
* the shared vocabulary `RecordDetailView` also uses to keep these out of the
19+
* body sections (single source in `@object-ui/types`, objectui#3017).
1820
*/
19-
const AUDIT_FIELDS = {
20-
createdAt: 'created_at',
21-
createdBy: 'created_by',
22-
updatedAt: 'updated_at',
23-
updatedBy: 'updated_by',
24-
} as const;
21+
const AUDIT_FIELDS = AUDIT_FIELD_BY_ROLE;
2522

2623
export interface RecordMetaFooterProps {
2724
/** The current record data; expected to contain audit fields when available. */

packages/plugin-grid/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@object-ui/permissions": "workspace:*",
3030
"@object-ui/react": "workspace:*",
3131
"@object-ui/types": "workspace:*",
32+
"@objectstack/spec": "^17.0.0-rc.0",
3233
"@tanstack/react-virtual": "^3.14.8",
3334
"exceljs": "^4.4.0",
3435
"lucide-react": "^1.25.0"

packages/plugin-grid/src/ImportWizard.tsx

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { Upload, FileSpreadsheet, CheckCircle2, AlertCircle, X, ArrowRight, ArrowLeft, Save, Trash2, ClipboardPaste, Download, Undo2 } from 'lucide-react';
1414
import { useObjectTranslation } from '@object-ui/react';
1515
import { sanitizeFileNameBase } from '@object-ui/core';
16+
import { BOOLEAN_IMPORT_TOKENS, REFERENCE_IMPORT_TYPES } from './importCoercionContract';
1617
import type {
1718
DataSource,
1819
ImportRequestOptions,
@@ -361,21 +362,6 @@ const CONFIDENCE_CLASS: Record<MappingConfidence, string> = {
361362
low: 'text-muted-foreground',
362363
};
363364

364-
/** Boolean tokens the server's import coercion accepts (import-coerce.ts).
365-
* Kept in sync so the preview step doesn't flag a cell the server would take
366-
* (e.g. Chinese 是/否, on/off, ✓/×). Compared case-insensitively. */
367-
const BOOLEAN_IMPORT_TOKENS = new Set([
368-
'true', 't', 'yes', 'y', '1', 'on', '是', '对', '✓', '√',
369-
'false', 'f', 'no', 'n', '0', 'off', '否', '错', '✗', '×',
370-
]);
371-
372-
/** Field types the server resolves from display text to record IDs during
373-
* `/import` (kept in step with the server's import-coerce REFERENCE_TYPES).
374-
* The legacy per-row create fallback has no resolution step — raw cell text
375-
* would be stored verbatim into relation fields — so the fallback must refuse
376-
* to run when any mapped column targets one of these types. */
377-
const REFERENCE_IMPORT_TYPES = new Set(['lookup', 'master_detail', 'user', 'reference', 'tree']);
378-
379365
/** Mapped fields whose type the legacy fallback cannot import safely. */
380366
function mappedReferenceFields(
381367
mapping: Record<number, string>,

0 commit comments

Comments
 (0)