diff --git a/.changeset/system-field-classifier-consolidation.md b/.changeset/system-field-classifier-consolidation.md new file mode 100644 index 000000000..f579fef15 --- /dev/null +++ b/.changeset/system-field-classifier-consolidation.md @@ -0,0 +1,25 @@ +--- +"@object-ui/plugin-grid": patch +"@object-ui/fields": patch +"@object-ui/plugin-detail": patch +--- + +fix(list): route remaining system-field groupings through the shared classifier + +Follow-up to the `owner_id` default-column fix: consolidate the display-oriented +system-field exclusions onto the shared `isSystemManagedField` / +`SYSTEM_MANAGED_FIELD_NAMES` (from `@object-ui/types`) so the framework-injected +`owner_id` is treated consistently across the grid, record picker, and detail +drawer. + +- `ObjectGrid` record-detail drawer: the business-fields vs. muted meta-section + split now uses the shared classifier, so `owner_id` (and other injected system + fields) land in the meta section instead of the business body. +- `deriveLookupColumns` (record picker): drops its local name set for the shared + classifier — now flag-aware (`field.system`), not just name-based. +- `RecordDetailDrawer`: its default `systemFields` set is derived from the shared + `SYSTEM_MANAGED_FIELD_NAMES`; the `systemFields` prop override is preserved. + +`deriveRelatedLists`' narrow "audit FK on every object" set and plugin-detail's +inline-edit "never editable" set are intentionally left distinct — different +semantics (the latter deliberately keeps `owner_id` editable). diff --git a/packages/fields/src/widgets/deriveLookupColumns.test.ts b/packages/fields/src/widgets/deriveLookupColumns.test.ts index c53868f65..a5bff8301 100644 --- a/packages/fields/src/widgets/deriveLookupColumns.test.ts +++ b/packages/fields/src/widgets/deriveLookupColumns.test.ts @@ -50,6 +50,19 @@ describe('deriveLookupColumns', () => { expect(deriveLookupColumns(accountSchema, { displayField: 'name', max: 2 })).toHaveLength(2); }); + it('skips fields flagged `system: true` even when the name is not a known audit column', () => { + const schema = { + fields: { + name: { type: 'text', label: 'Name' }, + // Non-canonical name, but stamped system by the framework — must be skipped. + custom_ref: { type: 'lookup', label: 'Custom Ref', system: true } as any, + code: { type: 'text', label: 'Code' }, + }, + }; + const cols = deriveLookupColumns(schema, { displayField: 'name', max: 10 }); + expect(cols.map((c) => c.field)).toEqual(['name', 'code']); + }); + it('honours an object-level displayFields list, with the display field first', () => { const schema = { fields: { diff --git a/packages/fields/src/widgets/deriveLookupColumns.ts b/packages/fields/src/widgets/deriveLookupColumns.ts index bc6052ff6..a35d89db8 100644 --- a/packages/fields/src/widgets/deriveLookupColumns.ts +++ b/packages/fields/src/widgets/deriveLookupColumns.ts @@ -1,17 +1,5 @@ import type { LookupColumnDef } from '@object-ui/types'; - -/** - * System / audit fields that carry no disambiguating value in a record picker. - * Excluded from auto-derived columns so the picker shows business data, not - * bookkeeping. - */ -const SYSTEM_FIELDS = new Set([ - 'id', '_id', - 'created', 'modified', 'created_at', 'updated_at', - 'created_by', 'updated_by', 'modified_by', - 'owner_id', 'organization_id', 'space', 'company_id', - 'instance_state', 'locked', 'is_deleted', 'deleted', -]); +import { isSystemManagedField } from '@object-ui/types'; /** * Field types that don't render usefully as a compact picker column (large @@ -31,7 +19,7 @@ export interface DeriveColumnsOptions { } interface ObjectSchemaLike { - fields?: Record; + fields?: Record; /** * ADR-0085 semantic role: the object's most important fields. The single * source for "how to list this object" — shared with the detail-page related @@ -106,8 +94,10 @@ export function deriveLookupColumns( // 3. Derive from the field set in declaration order. const candidates = Object.keys(fields).filter((name) => { if (name === displayField) return false; - if (SYSTEM_FIELDS.has(name)) return false; const f = fieldDef(name); + // Skip framework-managed system/audit/ownership columns (incl. owner_id) — + // they carry no disambiguating value in a record picker. + if (isSystemManagedField(name, f)) return false; if (f.hidden) return false; if (f.type && NON_TABULAR_TYPES.has(f.type)) return false; return true; diff --git a/packages/plugin-detail/src/RecordDetailDrawer.tsx b/packages/plugin-detail/src/RecordDetailDrawer.tsx index 97385ba37..353b9df5e 100644 --- a/packages/plugin-detail/src/RecordDetailDrawer.tsx +++ b/packages/plugin-detail/src/RecordDetailDrawer.tsx @@ -28,6 +28,7 @@ import { SheetTitle, } from '@object-ui/components'; import type { DataSource } from '@object-ui/types'; +import { SYSTEM_MANAGED_FIELD_NAMES } from '@object-ui/types'; import { InlineEditProvider } from '@object-ui/react'; import { DetailView } from './DetailView'; import { InlineEditSaveBar } from './InlineEditSaveBar'; @@ -42,15 +43,12 @@ import { useDetailTranslation } from './useDetailTranslation'; * `` (single-line, muted) rather than a heavy panel, so * users can still see who/when created or last touched a record. * - * Keep this in sync with the audit fields auto-injected by the - * framework's `applySystemFields` (created_at/created_by/updated_at/ - * updated_by) plus the legacy/tenant identifiers. + * Derived from the shared `SYSTEM_MANAGED_FIELD_NAMES` — the same set the grid's + * default-column derivation uses — so it stays in lockstep with the fields + * `applySystemFields` injects (audit `*_at`/`*_by`, the ownership/tenant FKs, + * soft-delete bookkeeping). Callers can still override via the `systemFields` prop. */ -const DEFAULT_SYSTEM_FIELDS = new Set([ - 'id', '_id', '__v', 'created_at', 'updated_at', 'createdAt', 'updatedAt', - 'created_by', 'updated_by', 'organization_id', 'tenant_id', 'owner_id', - 'deleted_at', 'is_deleted', -]); +const DEFAULT_SYSTEM_FIELDS = new Set(SYSTEM_MANAGED_FIELD_NAMES); export interface RecordDetailDrawerProps { /** Whether the drawer is currently open. */ diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 6c6c3b315..21bca6141 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -1956,14 +1956,18 @@ export const ObjectGrid: React.FC = ({ // this same type-aware renderer instead of the card view falling back to // a raw `String(value)` dump (which showed "[object Object]" for lookups). const renderRecordDetail = (record: any) => { - const systemFields = ['_id', 'id', 'created_at', 'updated_at', 'created_by', 'updated_by']; const entries = Object.entries(record); // Honor `hidden: true` on the schema field def — internal/system fields // (e.g. database_url, environment_id, is_system) shouldn't leak into the // grid's record-detail drawer just because they're in the record payload. const isHidden = (key: string) => objectSchema?.fields?.[key]?.hidden === true; - const regularFields = entries.filter(([key]) => !systemFields.includes(key) && !isHidden(key)); - const metaFields = entries.filter(([key]) => systemFields.includes(key) && key !== '_id' && key !== 'id'); + // Split business fields from framework-managed system/audit/ownership + // columns via the shared classifier (branches on `field.system`), so the + // injected `owner_id` and friends land in the muted meta section rather than + // the business body — consistent with the grid's default-column derivation. + const isSystem = (key: string) => isSystemManagedField(key, objectSchema?.fields?.[key]); + const regularFields = entries.filter(([key]) => !isSystem(key) && !isHidden(key)); + const metaFields = entries.filter(([key]) => isSystem(key) && key !== '_id' && key !== 'id' && !isHidden(key)); const formatFieldLabel = (key: string): string => key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' ');