Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/autolist-owner-id-columns.md
Original file line number Diff line number Diff line change
@@ -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.
72 changes: 72 additions & 0 deletions packages/app-shell/src/views/ObjectView.defaultColumns.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, any> = { 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([]);
});
});
84 changes: 40 additions & 44 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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]) => {
Expand Down
Loading