From d82678b52a21978d8e72ac40dba98c58c1fb79c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Fri, 26 Jun 2026 09:22:57 +0800 Subject: [PATCH] fix(tree): render with object-shaped columns + offer Tree in Create View dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ObjectTree crashed ('e.replace is not a function') when a host like ListView passed its columns as field *objects* ({name,label,...}) rather than bare strings — fieldLabel() called .replace() on the object. Normalize every field entry to its string key in getTreeConfig (parentField/labelField/fields). The Create View dialog had a hardcoded type list with no 'tree' entry, so users couldn't create a tree view from the UI. Add a Tree card (ListTree icon) with a required parentField picker (self-referencing tree/lookup/master_detail field) and zh/en i18n. Regression test covers the object-shaped-columns crash. --- .../app-shell/src/views/CreateViewDialog.tsx | 15 ++++++++++ packages/i18n/src/locales/en.ts | 4 +++ packages/i18n/src/locales/zh.ts | 4 +++ packages/plugin-tree/src/ObjectTree.test.tsx | 26 +++++++++++++++++ packages/plugin-tree/src/ObjectTree.tsx | 29 ++++++++++++++----- 5 files changed, 71 insertions(+), 7 deletions(-) diff --git a/packages/app-shell/src/views/CreateViewDialog.tsx b/packages/app-shell/src/views/CreateViewDialog.tsx index a12c05f436..3e9f1f22bd 100644 --- a/packages/app-shell/src/views/CreateViewDialog.tsx +++ b/packages/app-shell/src/views/CreateViewDialog.tsx @@ -46,6 +46,7 @@ import { Clock, Map as MapIcon, BarChart3, + ListTree, AlertCircle, } from 'lucide-react'; @@ -87,6 +88,7 @@ function buildViewTypeMeta(t: (k: string) => string): ViewTypeMeta[] { { type: 'gantt', icon: GanttChartSquare, label: t('console.objectView.viewTypeGantt'), description: t('console.objectView.viewTypeGanttDesc') }, { type: 'map', icon: MapIcon, label: t('console.objectView.viewTypeMap'), description: t('console.objectView.viewTypeMapDesc') }, { type: 'chart', icon: BarChart3, label: t('console.objectView.viewTypeChart'), description: t('console.objectView.viewTypeChartDesc') }, + { type: 'tree', icon: ListTree, label: t('console.objectView.viewTypeTree'), description: t('console.objectView.viewTypeTreeDesc') }, ]; } @@ -254,6 +256,19 @@ const REQUIRED_FIELDS_BY_TYPE: Record = { filter: (f) => f.type === 'number', }, ], + tree: [ + { + key: 'parentField', + i18nKey: 'console.objectView.parentField', + helpI18nKey: 'console.objectView.parentFieldHelp', + // Self-referencing pointer: a `tree` field, or a lookup/master_detail + // back to the same object. `rawType` carries the unnormalized field type. + filter: (f) => + f.rawType === 'tree' || + f.rawType === 'lookup' || + f.rawType === 'master_detail', + }, + ], // grid has no strictly required fields at create time }; diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 8581b98324..7c9daadb0e 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1310,6 +1310,10 @@ const en = { viewTypeMapDesc: 'Geographic markers from latitude / longitude fields.', viewTypeChart: 'Chart', viewTypeChartDesc: 'Aggregated bar / line / pie visualisations.', + viewTypeTree: 'Tree', + viewTypeTreeDesc: 'Nest self-referencing records into a hierarchy by a parent field.', + parentField: 'Parent field', + parentFieldHelp: 'The field pointing to the parent record (same object) that defines the hierarchy. Only self-referencing fields qualify.', newView: 'New View', typeOptions: 'Type Options', groupByField: 'Group by field', diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 7554a78d6f..672b304073 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1304,6 +1304,10 @@ const zh = { viewTypeMapDesc: '根据经纬度字段显示地理标记。', viewTypeChart: '图表', viewTypeChartDesc: '聚合的柱状/折线/饼状图。', + viewTypeTree: '树形', + viewTypeTreeDesc: '按父级字段把自引用记录嵌套成层级树。', + parentField: '父级字段', + parentFieldHelp: '指向同一对象的父级字段,决定树的层级(仅自引用字段可选)。', newView: '新视图', typeOptions: '类型选项', groupByField: '分组字段', diff --git a/packages/plugin-tree/src/ObjectTree.test.tsx b/packages/plugin-tree/src/ObjectTree.test.tsx index d19eaf1511..a17336b732 100644 --- a/packages/plugin-tree/src/ObjectTree.test.tsx +++ b/packages/plugin-tree/src/ObjectTree.test.tsx @@ -79,6 +79,32 @@ describe('ObjectTree', () => { expect(screen.queryByText('Engineering')).toBeNull(); }); + it('accepts field entries as objects (host columns), not just strings', async () => { + // ListView passes columns as field *objects*; feeding those straight into + // `.replace()` threw "e.replace is not a function" and failed to render. + render( + , + ); + await waitFor(() => expect(screen.getByTestId('object-tree')).toBeTruthy()); + expect(screen.getByText('Acme')).toBeTruthy(); + // The object-shaped `head` column still renders its values. + expect(screen.getByText('VP Eng')).toBeTruthy(); + expect(screen.getByText('Head')).toBeTruthy(); + }); + it('keeps orphan records (parent outside the result set) as roots', async () => { const orphans = [ { id: '10', name: 'Floating', parent_id: '999' }, diff --git a/packages/plugin-tree/src/ObjectTree.tsx b/packages/plugin-tree/src/ObjectTree.tsx index fbb29e4292..b828270728 100644 --- a/packages/plugin-tree/src/ObjectTree.tsx +++ b/packages/plugin-tree/src/ObjectTree.tsx @@ -57,16 +57,31 @@ function getDataConfig(schema: any): ViewData | null { return null; } +/** + * Normalize a field entry to its string key. Hosts like ListView pass columns + * as field *objects* (`{ name | fieldName | field, label, … }`), not bare + * strings — feeding those straight into `.replace()`/record indexing throws + * ("e.replace is not a function"). Accept both shapes here so the tree is + * resilient regardless of caller. + */ +function fieldKey(f: any): string | undefined { + if (typeof f === 'string') return f; + if (f && typeof f === 'object') return f.name || f.fieldName || f.field || f.key; + return undefined; +} + function getTreeConfig(schema: any): TreeConfig { const nested = (schema.tree || schema.filter?.tree || {}) as Partial; + const rawFields = Array.isArray(schema.fields) + ? schema.fields + : Array.isArray(nested.fields) + ? nested.fields + : []; return { - parentField: schema.parentField ?? nested.parentField, - labelField: schema.labelField ?? nested.labelField ?? schema.titleField ?? 'name', - fields: Array.isArray(schema.fields) - ? schema.fields - : Array.isArray(nested.fields) - ? nested.fields - : [], + parentField: fieldKey(schema.parentField ?? nested.parentField), + labelField: + fieldKey(schema.labelField ?? nested.labelField ?? schema.titleField) ?? 'name', + fields: rawFields.map(fieldKey).filter((f): f is string => !!f), defaultExpandedDepth: schema.defaultExpandedDepth ?? nested.defaultExpandedDepth, }; }