Skip to content

Commit 2b17339

Browse files
authored
fix(list): keep injected owner_id out of leading auto-derived list columns (#2702)
Classify default list columns via the shared isSystemManagedField helper (branches on the spec `system` flag) so the framework-injected owner_id is pushed to the end (ObjectGrid) / excluded from business columns (InterfaceListPage) instead of leading. Adds the `system` flag to @object-ui/types field metadata, unit tests, and a changeset.
1 parent 5a89ee5 commit 2b17339

8 files changed

Lines changed: 212 additions & 21 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@object-ui/types": patch
3+
"@object-ui/plugin-grid": patch
4+
"@object-ui/app-shell": patch
5+
---
6+
7+
fix(list): keep the injected `owner_id` out of the leading auto-derived columns
8+
9+
A view-less object's default list columns are derived from the object's field
10+
order. The framework's `applySystemFields` spreads its injected
11+
system/audit/ownership fields to the FRONT of that order and stamps them
12+
`system: true`; `owner_id` is deliberately non-hidden and non-readonly
13+
(ownership is reassignable), so the old name-based exclusion lists in
14+
`ObjectGrid` and `InterfaceListPage` — which never listed `owner_id` — let it
15+
through as column #1 on many showcase list pages (e.g. `showcase_field_zoo`).
16+
17+
Default-column derivation now classifies system fields via the shared
18+
`isSystemManagedField` helper, which branches on the spec `system` flag (the
19+
single source of truth stamped by the registry) with a name-set fallback that
20+
includes the ownership/tenancy FKs. `owner_id` is pushed to the end
21+
(`ObjectGrid`) / excluded from the business columns (`InterfaceListPage`), so
22+
auto-derived lists lead with business fields again and pick up future injected
23+
fields without editing a name list. Also declares the `system` flag on the
24+
`@object-ui/types` field metadata.

packages/app-shell/src/views/InterfaceListPage.defaults.test.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { describe, it, expect } from 'vitest';
44
import {
5+
defaultColumnsFromObject,
56
defaultKanbanFromObject,
67
defaultCalendarFromObject,
78
defaultGalleryFromObject,
@@ -52,3 +53,55 @@ describe('InterfaceListPage default viz bindings', () => {
5253
expect(defaultKanbanFromObject(obj)).toEqual({ groupField: 'real_sel', groupByField: 'real_sel' });
5354
});
5455
});
56+
57+
describe('defaultColumnsFromObject', () => {
58+
// Mirrors how the framework's `applySystemFields` presents an object to the
59+
// console: injected system fields (owner_id, audit columns) are spread to the
60+
// FRONT of the field map and carry `system: true`; owner_id is deliberately
61+
// non-hidden / non-readonly because ownership is reassignable.
62+
const fieldZooLike = {
63+
fields: {
64+
owner_id: { type: 'lookup', label: 'Owner', system: true },
65+
created_at: { type: 'datetime', system: true, readonly: true },
66+
created_by: { type: 'lookup', system: true, readonly: true },
67+
organization_id: { type: 'lookup', system: true, hidden: true },
68+
name: { type: 'text' },
69+
f_email: { type: 'email' },
70+
f_number: { type: 'number' },
71+
},
72+
};
73+
74+
it('does NOT lead with the injected owner_id — business fields come first', () => {
75+
const cols = defaultColumnsFromObject(fieldZooLike);
76+
expect(cols[0]).toBe('name');
77+
expect(cols).not.toContain('owner_id');
78+
expect(cols).not.toContain('created_at');
79+
expect(cols).not.toContain('organization_id');
80+
expect(cols).toEqual(['name', 'f_email', 'f_number']);
81+
});
82+
83+
it('excludes owner_id even when it arrives without the system flag (name fallback)', () => {
84+
const cols = defaultColumnsFromObject({
85+
fields: { owner_id: { type: 'lookup' }, title: { type: 'text' } },
86+
});
87+
expect(cols).toEqual(['title']);
88+
});
89+
90+
it('honors highlightFields as the curated override', () => {
91+
const cols = defaultColumnsFromObject({
92+
highlightFields: ['name', 'owner_id'],
93+
fields: fieldZooLike.fields,
94+
});
95+
// Curated list wins verbatim (only dropping names with no field def).
96+
expect(cols).toEqual(['name', 'owner_id']);
97+
});
98+
99+
it('caps the auto-derived business columns at six', () => {
100+
const fields: Record<string, any> = { owner_id: { type: 'lookup', system: true } };
101+
for (let i = 0; i < 10; i++) fields[`b_${i}`] = { type: 'text' };
102+
const cols = defaultColumnsFromObject({ fields });
103+
expect(cols).toHaveLength(6);
104+
expect(cols).not.toContain('owner_id');
105+
expect(cols[0]).toBe('b_0');
106+
});
107+
});

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

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { useAdapter, SchemaRenderer, useNavigationOverlay } from '@object-ui/rea
2222
import { Empty, EmptyTitle, EmptyDescription, NavigationOverlay } from '@object-ui/components';
2323
import { Database } from 'lucide-react';
2424
import { useObjectTranslation } from '@object-ui/i18n';
25+
import { isSystemManagedField } from '@object-ui/types';
2526
import { useMetadata } from '../providers/MetadataProvider';
2627
import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState';
2728
import { RecordDetailView } from './RecordDetailView';
@@ -70,14 +71,10 @@ function resolveSourceView(objectDef: any, sourceView?: string): any | undefined
7071
* Default column set when the resolved view carries none — mirrors
7172
* ObjectView's data-mode fallback so an interface page never renders a
7273
* column-less grid. Priority: the `highlightFields` semantic role
73-
* (ADR-0085), else the first business fields (system/audit columns
74-
* excluded).
74+
* (ADR-0085), else the first business fields — framework-managed
75+
* system/audit/ownership columns (including the injected, editable `owner_id`)
76+
* are excluded via the shared `isSystemManagedField` classifier.
7577
*/
76-
const SYSTEM_FIELDS = new Set([
77-
'id', 'created_at', 'createdAt', 'updated_at', 'updatedAt',
78-
'deleted_at', 'deletedAt', 'created_by', 'createdBy',
79-
'updated_by', 'updatedBy', '_version', '_rev',
80-
]);
8178
export function defaultColumnsFromObject(objectDef: any): string[] {
8279
const curated = objectDef?.highlightFields;
8380
if (Array.isArray(curated) && curated.length > 0) {
@@ -86,7 +83,7 @@ export function defaultColumnsFromObject(objectDef: any): string[] {
8683
const fields = objectDef?.fields;
8784
if (fields && typeof fields === 'object') {
8885
return Object.entries(fields)
89-
.filter(([name, f]: [string, any]) => f && !f.hidden && !SYSTEM_FIELDS.has(name))
86+
.filter(([name, f]: [string, any]) => f && !f.hidden && !isSystemManagedField(name, f))
9087
.map(([name]) => name)
9188
.slice(0, 6);
9289
}
@@ -111,7 +108,7 @@ function firstFieldMatching(
111108
const fields = objectDef?.fields;
112109
if (!fields || typeof fields !== 'object') return undefined;
113110
const hit = Object.entries(fields).find(
114-
([name, f]: [string, any]) => f && !f.hidden && !SYSTEM_FIELDS.has(name) && pred(name, f),
111+
([name, f]: [string, any]) => f && !f.hidden && !isSystemManagedField(name, f) && pred(name, f),
115112
);
116113
return hit?.[0];
117114
}

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
import React, { useEffect, useState, useCallback, useMemo } from 'react';
2525
import type { ObjectGridSchema, DataSource, ListColumn, ViewData } from '@object-ui/types';
26+
import { isSystemManagedField } from '@object-ui/types';
2627
import type { I18nLabel } from '@objectstack/spec/ui';
2728
import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useObjectTranslation, useSafeFieldLabel, usePredicateScope } from '@object-ui/react';
2829
import { getCellRenderer, resolveCellRendererType, formatCurrency, formatCompactCurrency, formatDate, formatPercent, humanizeLabel, getBadgeColorClasses, FieldEditWidget, hasFieldEditWidget, DISCRETE_EDIT_TYPES, coerceToSafeValue } from '@object-ui/fields';
@@ -1185,11 +1186,12 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
11851186
// marked `hidden: true`/`readonly: true` so default list views show only
11861187
// the business fields users actually care about. Callers can still opt-in
11871188
// to system columns by passing an explicit `fields` / `columns` prop.
1188-
const SYSTEM_FIELDS = new Set([
1189-
'id', 'created_at', 'createdAt', 'updated_at', 'updatedAt',
1190-
'deleted_at', 'deletedAt', 'created_by', 'createdBy',
1191-
'updated_by', 'updatedBy', '_version', '_rev',
1192-
]);
1189+
//
1190+
// "System-managed" is decided by `isSystemManagedField`, which branches on
1191+
// the framework's `field.system` flag (single source of truth stamped by
1192+
// `applySystemFields`) — this is what keeps the injected, non-readonly
1193+
// `owner_id` from leading the auto-derived columns, and covers any future
1194+
// injected field without editing a name list here.
11931195
const highlightFields: string[] | undefined = (objectSchema as any)?.highlightFields;
11941196
const allFieldNames = Object.keys(objectSchema.fields || {});
11951197
let fieldsToShow: string[];
@@ -1198,19 +1200,20 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
11981200
} else if (highlightFields?.length) {
11991201
fieldsToShow = highlightFields.filter((n) => objectSchema.fields?.[n]);
12001202
} else {
1201-
// Drop hidden + readonly system-managed fields, then push remaining
1202-
// system identifier/audit fields to the end as a fallback.
1203+
// Drop hidden + readonly system-managed fields, then push the remaining
1204+
// system/audit/ownership columns (e.g. the injected, editable `owner_id`)
1205+
// to the end as a fallback so business fields lead.
12031206
const visibleFields = allFieldNames.filter((n) => {
12041207
const f = objectSchema.fields?.[n];
12051208
if (!f) return false;
12061209
if (f.hidden) return false;
1207-
// Drop readonly fields when their name matches a system identifier/audit column.
1208-
if (f.readonly && SYSTEM_FIELDS.has(n)) return false;
1210+
// Drop readonly bookkeeping columns (created_at/by, updated_at/by, …).
1211+
if (f.readonly && isSystemManagedField(n, f)) return false;
12091212
return true;
12101213
});
12111214
fieldsToShow = [
1212-
...visibleFields.filter((n) => !SYSTEM_FIELDS.has(n)),
1213-
...visibleFields.filter((n) => SYSTEM_FIELDS.has(n)),
1215+
...visibleFields.filter((n) => !isSystemManagedField(n, objectSchema.fields?.[n])),
1216+
...visibleFields.filter((n) => isSystemManagedField(n, objectSchema.fields?.[n])),
12141217
];
12151218
}
12161219

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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+
import { describe, it, expect } from 'vitest';
10+
import { isSystemManagedField, SYSTEM_MANAGED_FIELD_NAMES } from '@object-ui/types';
11+
12+
describe('isSystemManagedField', () => {
13+
it('treats fields flagged `system: true` as system-managed (the single source of truth)', () => {
14+
expect(isSystemManagedField('anything_custom', { system: true })).toBe(true);
15+
// Flag wins even for a name that is not in the canonical set — covers
16+
// future framework-injected fields without editing the name list.
17+
expect(isSystemManagedField('brand_new_injected_field', { system: true })).toBe(true);
18+
});
19+
20+
it('classifies the injected owner_id as system-managed by flag AND by name', () => {
21+
expect(isSystemManagedField('owner_id', { system: true })).toBe(true); // stamped by registry
22+
expect(isSystemManagedField('owner_id', {})).toBe(true); // name fallback (no flag)
23+
expect(isSystemManagedField('owner_id')).toBe(true); // name fallback (no field)
24+
});
25+
26+
it('classifies audit / identity / tenancy columns by name when no flag is present', () => {
27+
for (const name of ['id', 'created_at', 'created_by', 'updated_at', 'updated_by', 'organization_id']) {
28+
expect(isSystemManagedField(name, {})).toBe(true);
29+
}
30+
});
31+
32+
it('does NOT classify author-declared business fields as system-managed', () => {
33+
expect(isSystemManagedField('name', {})).toBe(false);
34+
expect(isSystemManagedField('f_email', { system: false })).toBe(false);
35+
expect(isSystemManagedField('amount')).toBe(false);
36+
});
37+
38+
it('exposes owner_id and the tenancy FKs in the canonical name set', () => {
39+
expect(SYSTEM_MANAGED_FIELD_NAMES.has('owner_id')).toBe(true);
40+
expect(SYSTEM_MANAGED_FIELD_NAMES.has('organization_id')).toBe(true);
41+
expect(SYSTEM_MANAGED_FIELD_NAMES.has('created_at')).toBe(true);
42+
expect(SYSTEM_MANAGED_FIELD_NAMES.has('name')).toBe(false);
43+
});
44+
});

packages/types/src/field-types.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,16 @@ export interface BaseFieldMetadata {
5555
* Whether field is read-only
5656
*/
5757
readonly?: boolean;
58-
58+
59+
/**
60+
* Auto-injected system/audit/ownership field (e.g. `created_at`,
61+
* `updated_by`, `organization_id`, `owner_id`), stamped by the framework's
62+
* `applySystemFields`. Surfaces that separate framework-managed bookkeeping
63+
* from author-declared business fields (e.g. default list-column derivation)
64+
* branch on this flag. Mirrors the spec `Field.system` property.
65+
*/
66+
system?: boolean;
67+
5968
/**
6069
* Placeholder text
6170
*/

packages/types/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,11 @@ export type {
430430
ObjectRelationship,
431431
} from './field-types';
432432

433+
// System / audit / ownership field classification — runtime helper + name set,
434+
// used by default list-column derivation to keep framework-injected fields
435+
// (notably `owner_id`) out of the leading business columns.
436+
export { SYSTEM_MANAGED_FIELD_NAMES, isSystemManagedField } from './system-fields';
437+
433438
// ============================================================================
434439
// Phase 3: Data Protocol Advanced Types
435440
// ============================================================================
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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+
* Canonical names of the framework-managed system / audit / ownership columns
11+
* that `applySystemFields` auto-provisions onto business objects (record
12+
* identity, audit `*_at` / `*_by`, soft-delete bookkeeping, and the
13+
* reassignable ownership / tenant FKs).
14+
*
15+
* This is the DISPLAY-oriented set: it deliberately includes the *editable*
16+
* ownership/tenant lookups (`owner_id`, `organization_id`, `tenant_id`,
17+
* `company_id`, `space`) so that surfaces which separate bookkeeping from
18+
* business data — auto-derived list columns, record pickers, related lists —
19+
* don't lead with them. It is intentionally BROADER than the inline-edit
20+
* "never editable" set (`owner_id` IS reassignable), which lives separately in
21+
* `@object-ui/plugin-detail`.
22+
*
23+
* Prefer {@link isSystemManagedField}, which also honours the spec `system`
24+
* flag (the single source of truth stamped by the registry) so future injected
25+
* fields are covered without touching this list.
26+
*/
27+
export const SYSTEM_MANAGED_FIELD_NAMES: ReadonlySet<string> = new Set<string>([
28+
// Record identity
29+
'id', '_id', '__v', '_version', '_rev',
30+
// Audit
31+
'created', 'created_at', 'createdAt', 'created_by', 'createdBy',
32+
'modified', 'modified_by',
33+
'updated_at', 'updatedAt', 'updated_by', 'updatedBy',
34+
// Soft-delete / lifecycle bookkeeping
35+
'deleted', 'deleted_at', 'deletedAt', 'is_deleted', 'instance_state', 'locked',
36+
// Ownership / tenancy (framework-injected, editable)
37+
'owner_id', 'organization_id', 'tenant_id', 'company_id', 'space',
38+
]);
39+
40+
/**
41+
* Whether a field is a framework-managed system / audit / ownership column
42+
* rather than an author-declared business field.
43+
*
44+
* Branches on the spec `system` flag first — the single source of truth the
45+
* registry (`applySystemFields`) stamps on every field it injects — and falls
46+
* back to {@link SYSTEM_MANAGED_FIELD_NAMES} for metadata that predates the
47+
* flag or arrives without it. Default list-column derivation uses this so
48+
* injected fields (notably `owner_id`, which is non-hidden and non-readonly
49+
* because ownership is reassignable) never lead the auto-derived columns.
50+
*/
51+
export function isSystemManagedField(
52+
name: string,
53+
field?: { system?: boolean } | null,
54+
): boolean {
55+
return field?.system === true || SYSTEM_MANAGED_FIELD_NAMES.has(name);
56+
}

0 commit comments

Comments
 (0)