From 6db50bb9d8b6db8430af6cefa837fa71a2c33937 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:52:21 +0800 Subject: [PATCH] fix(list): keep injected owner_id out of auto-generated list columns (#2777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ObjectView renders an object's default "所有记录" list (and prefills the "Add View" dialog) from field order when the object declares no explicit list view. Both paths carried their own name-based SYSTEM_FIELDS set that — like the pre-#2702 lists in ObjectGrid / InterfaceListPage — never listed owner_id. Since applySystemFields spreads injected system/audit/ownership fields to the FRONT of the field map and owner_id is deliberately non-hidden / non-readonly (ownership is reassignable), it leaked through as the leading raw-id column on every view-less object (e.g. showcase_invoice), redundant with the business owner (Field.user) column. Derive both paths through a single shared defaultListColumnsFromObject helper that classifies system fields via isSystemManagedField from @object-ui/types (the classifier #2702 introduced) — branching on the spec `system` flag with a name-set fallback covering the ownership/tenancy FKs. Auto-derived lists lead with business fields again and pick up future injected fields without editing a name list. Adds a red/green unit test + changeset. Closes #2777. Co-Authored-By: Claude Opus 4.8 --- .changeset/autolist-owner-id-columns.md | 24 ++++++ .../views/ObjectView.defaultColumns.test.tsx | 72 ++++++++++++++++ packages/app-shell/src/views/ObjectView.tsx | 84 +++++++++---------- 3 files changed, 136 insertions(+), 44 deletions(-) create mode 100644 .changeset/autolist-owner-id-columns.md create mode 100644 packages/app-shell/src/views/ObjectView.defaultColumns.test.tsx diff --git a/.changeset/autolist-owner-id-columns.md b/.changeset/autolist-owner-id-columns.md new file mode 100644 index 000000000..a38650ab2 --- /dev/null +++ b/.changeset/autolist-owner-id-columns.md @@ -0,0 +1,24 @@ +--- +"@object-ui/app-shell": patch +--- + +fix(list): keep the injected `owner_id` out of the auto-generated list columns + +`ObjectView` renders an object's default "所有记录" tabular view (and prefills the +"Add View" dialog) from the object's field order when it declares no explicit +list view. Both paths carried their own name-based `SYSTEM_FIELDS` exclusion set +that — like the pre-#2702 lists in `ObjectGrid` / `InterfaceListPage` — never +listed `owner_id`. Because the framework's `applySystemFields` spreads its +injected system/audit/ownership fields to the FRONT of the field map and +`owner_id` is deliberately non-hidden and non-readonly (ownership is +reassignable), it leaked through as the leading, raw-id column on every object +without a declared list view (e.g. `showcase_invoice`), redundant with the +business `owner` (`Field.user`) column. + +Both paths now derive their columns through a single shared +`defaultListColumnsFromObject` helper that classifies system fields via the +`isSystemManagedField` helper from `@object-ui/types` (the same classifier +#2702 introduced) — branching on the spec `system` flag with a name-set +fallback that includes the ownership/tenancy FKs. Auto-derived lists lead with +business fields again and pick up future injected fields without editing a name +list. Closes #2777. diff --git a/packages/app-shell/src/views/ObjectView.defaultColumns.test.tsx b/packages/app-shell/src/views/ObjectView.defaultColumns.test.tsx new file mode 100644 index 000000000..62116c0ad --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.defaultColumns.test.tsx @@ -0,0 +1,72 @@ +/** + * 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. + */ + +import { describe, it, expect } from 'vitest'; +import { defaultListColumnsFromObject } from './ObjectView'; + +describe('defaultListColumnsFromObject', () => { + // Mirrors how the framework's `applySystemFields` presents an object with no + // declared list view to the console: the injected system fields (owner_id, + // audit columns, tenancy FK) are spread to the FRONT of the field map and + // carry `system: true`; `owner_id` is deliberately non-hidden / non-readonly + // because ownership is reassignable. This is the exact `showcase_invoice` + // shape from #2777. + const invoiceLike = { + fields: { + owner_id: { type: 'lookup', label: 'Owner id', system: true }, + created_at: { type: 'datetime', system: true, readonly: true }, + created_by: { type: 'lookup', system: true, readonly: true }, + organization_id: { type: 'lookup', system: true, hidden: true }, + invoice_no: { type: 'text', label: '发票号' }, + account: { type: 'lookup', label: '客户' }, + contact: { type: 'lookup', label: '联系人' }, + owner: { type: 'user', label: '负责人' }, + }, + }; + + it('#2777: does NOT lead the auto-list with the injected owner_id — business fields come first', () => { + const cols = defaultListColumnsFromObject(invoiceLike, 5); + expect(cols[0]).toBe('invoice_no'); + expect(cols).not.toContain('owner_id'); + expect(cols).not.toContain('created_at'); + expect(cols).not.toContain('created_by'); + expect(cols).not.toContain('organization_id'); + // The business `owner` (Field.user, display value) survives — only the + // injected `owner_id` id column is dropped. + expect(cols).toEqual(['invoice_no', 'account', 'contact', 'owner']); + }); + + it('excludes owner_id even when it arrives WITHOUT the system flag (name fallback)', () => { + const cols = defaultListColumnsFromObject({ + fields: { owner_id: { type: 'lookup' }, title: { type: 'text' } }, + }); + expect(cols).toEqual(['title']); + }); + + it('honors highlightFields as the curated override (owner_id kept if explicitly listed)', () => { + const cols = defaultListColumnsFromObject({ + highlightFields: ['invoice_no', 'owner_id'], + fields: invoiceLike.fields, + }); + expect(cols).toEqual(['invoice_no', 'owner_id']); + }); + + it('caps the auto-derived business columns at the requested limit', () => { + const fields: Record = { owner_id: { type: 'lookup', system: true } }; + for (let i = 0; i < 10; i++) fields[`b_${i}`] = { type: 'text' }; + const cols = defaultListColumnsFromObject({ fields }, 5); + expect(cols).toHaveLength(5); + expect(cols).not.toContain('owner_id'); + expect(cols[0]).toBe('b_0'); + }); + + it('returns an empty list when the object has no fields', () => { + expect(defaultListColumnsFromObject({})).toEqual([]); + expect(defaultListColumnsFromObject(undefined)).toEqual([]); + }); +}); diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 7390e43da..bec8e5a10 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -37,7 +37,7 @@ import { Plus, Upload, Star, StarOff, Table as TableIcon, KanbanSquare, Calendar import { useFavorites } from '../hooks/useFavorites'; import { getIcon } from '../utils/getIcon'; import type { ListViewSchema, ViewNavigationConfig, FeedItem } from '@object-ui/types'; -import { detectStatusField } from '@object-ui/types'; +import { detectStatusField, isSystemManagedField } from '@object-ui/types'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector'; import { ViewConfigPanel } from './ViewConfigPanel'; import { useMetadataClient } from './metadata-admin/useMetadata'; @@ -116,6 +116,34 @@ function substituteFilterTokens(filter: any, currentUserId: string | undefined): return filter.map(sub); } +/** + * Default list columns for an object that declares no explicit list view. + * + * Priority: the `highlightFields` semantic role (ADR-0085) wins verbatim + * (only dropping names with no field def); otherwise the first `limit` + * business fields in declared order. Framework-injected system / audit / + * ownership columns are excluded via the shared `isSystemManagedField` + * classifier — its single source of truth is the spec `system` flag stamped + * by `applySystemFields`, with a name-set fallback covering the injected, + * non-hidden / non-readonly `owner_id` and `organization_id`. Without this, + * `applySystemFields` (which spreads injected fields to the FRONT of the field + * map) would surface `owner_id` as a leading raw-id column (#2702, #2777). + */ +export function defaultListColumnsFromObject(objectDef: any, limit = 5): string[] { + const curated = objectDef?.highlightFields; + if (Array.isArray(curated) && curated.length > 0) { + return curated.filter((n: string) => objectDef?.fields?.[n]); + } + const fields = objectDef?.fields; + if (fields && typeof fields === 'object') { + return Object.entries(fields) + .filter(([name, f]: [string, any]) => f && !f.hidden && !isSystemManagedField(name, f)) + .map(([name]) => name) + .slice(0, limit); + } + return []; +} + export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey }: any) { const { objectName } = useParams(); const { t } = useObjectTranslation(); @@ -248,21 +276,10 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an // Prefill sensible defaults so the saved view renders rows // immediately even if the user didn't pick columns yet. const objectDef = objects?.find?.((o: any) => o.name === objectName); - const SYSTEM_FIELDS = new Set([ - 'id', 'created_at', 'createdAt', 'updated_at', 'updatedAt', - 'deleted_at', 'deletedAt', 'created_by', 'createdBy', - 'updated_by', 'updatedBy', '_version', '_rev', - ]); - let defaultColumns: string[] = []; - const curated = objectDef?.highlightFields; - if (Array.isArray(curated) && curated.length > 0) { - defaultColumns = curated.filter((n: string) => objectDef.fields?.[n]); - } else if (objectDef?.fields) { - defaultColumns = Object.entries(objectDef.fields) - .filter(([name, f]: [string, any]) => f && !f.hidden && !SYSTEM_FIELDS.has(name)) - .map(([name]) => name) - .slice(0, 5); - } + // Prefill business columns only — the shared helper keeps the + // framework-injected `owner_id` / audit columns out of the lead + // (#2702, #2777), so a newly created view never opens on a raw id. + const defaultColumns = defaultListColumnsFromObject(objectDef, 5); const incomingColumns = Array.isArray(config.columns) && config.columns.length > 0 ? config.columns : defaultColumns; @@ -543,34 +560,13 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an // Resolve Views from objectDef.listViews (camelCase per @objectstack/spec) const views = useMemo(() => { - // Default column resolution priority: - // 1. The `highlightFields` semantic role (ADR-0085). - // 2. Business fields only — exclude system-managed identifiers/audit - // columns (id, created_at, updated_at, …) and fields explicitly - // marked hidden/readonly on the schema. First 5 kept for compactness. - const SYSTEM_FIELDS = new Set([ - 'id', 'created_at', 'createdAt', 'updated_at', 'updatedAt', - 'deleted_at', 'deletedAt', 'created_by', 'createdBy', - 'updated_by', 'updatedBy', '_version', '_rev', - ]); - const resolveDefaultColumns = (): string[] => { - const curated = objectDef.highlightFields; - if (Array.isArray(curated) && curated.length > 0) { - return curated.filter((n: string) => objectDef.fields?.[n]); - } - if (objectDef.fields) { - return Object.entries(objectDef.fields) - .filter(([name, f]: [string, any]) => { - if (!f) return false; - if (f.hidden) return false; - if (SYSTEM_FIELDS.has(name)) return false; - return true; - }) - .map(([name]) => name) - .slice(0, 5); - } - return []; - }; + // Default columns for the auto-generated "所有记录" view (and any saved + // grid view with no explicit columns). `highlightFields` (ADR-0085) wins; + // otherwise the first business fields — framework-injected system / audit + // / ownership columns (notably the non-readonly `owner_id`) are excluded + // by the shared classifier. #2702 applied this to ObjectGrid / + // InterfaceListPage; this view resolves its columns here (#2777). + const resolveDefaultColumns = (): string[] => defaultListColumnsFromObject(objectDef, 5); const definedViews = objectDef.listViews || objectDef.list_views || {}; const viewList = Object.entries(definedViews).map(([key, value]: [string, any]) => {