diff --git a/.changeset/spec-tree-view-type.md b/.changeset/spec-tree-view-type.md new file mode 100644 index 0000000000..e5fe5fe3db --- /dev/null +++ b/.changeset/spec-tree-view-type.md @@ -0,0 +1,11 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): add a `tree` view type to the ListView schema + +`'tree'` is now a valid `ListView.type` (and `VisualizationType`), backed by a +new `TreeConfigSchema` (`parentField` / `labelField` / `fields` / +`defaultExpandedDepth`, passthrough). This lets a self-referencing object be +served as a tree-grid; without it the runtime Zod-validates view metadata and +silently drops `type:'tree'`. Renderer ships in objectui `@object-ui/plugin-tree`. diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 2e2d8b4358..003a6362fa 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -16,7 +16,7 @@ import { ShowcaseExternalDatasource } from './src/datasources/showcase-external. import { ExternalCustomer, ExternalOrder } from './src/objects/external/index.js'; import { setupShowcaseExternalDatasource } from './src/datasources/external-fixture.js'; import { registerRecalcEndpoint } from './src/server/recalc-endpoint.js'; -import { TaskViews, ProjectViews, InquiryViews } from './src/views/index.js'; +import { TaskViews, ProjectViews, InquiryViews, BusinessUnitViews } from './src/views/index.js'; import { ShowcaseApp } from './src/apps/index.js'; import { ChartGalleryDashboard, OpsDashboard } from './src/dashboards/index.js'; import { ShowcaseTaskDataset, ShowcaseProjectDataset } from './src/datasets/index.js'; @@ -153,7 +153,7 @@ export default defineStack({ // UI apps: [ShowcaseApp], portals: allPortals, - views: [TaskViews, ProjectViews, InquiryViews], + views: [TaskViews, ProjectViews, InquiryViews, BusinessUnitViews], pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage], dashboards: [ChartGalleryDashboard, OpsDashboard], books: allBooks, diff --git a/examples/app-showcase/src/apps/index.ts b/examples/app-showcase/src/apps/index.ts index c486e6726f..44c472c81d 100644 --- a/examples/app-showcase/src/apps/index.ts +++ b/examples/app-showcase/src/apps/index.ts @@ -39,6 +39,7 @@ export const ShowcaseApp = App.create({ { id: 'nav_products', type: 'object', objectName: 'showcase_product', label: 'Products', icon: 'package' }, { id: 'nav_teams', type: 'object', objectName: 'showcase_team', label: 'Teams', icon: 'users' }, { id: 'nav_categories', type: 'object', objectName: 'showcase_category', label: 'Categories', icon: 'list-tree' }, + { id: 'nav_business_units', type: 'object', objectName: 'showcase_business_unit', label: 'Business Units', icon: 'network' }, { id: 'nav_field_zoo', type: 'object', objectName: 'showcase_field_zoo', label: 'Field Zoo', icon: 'shapes' }, ], }, diff --git a/examples/app-showcase/src/data/index.ts b/examples/app-showcase/src/data/index.ts index 8c02ccab38..7bb020d122 100644 --- a/examples/app-showcase/src/data/index.ts +++ b/examples/app-showcase/src/data/index.ts @@ -7,6 +7,7 @@ import { Preference } from '../objects/preference.object.js'; import { Project } from '../objects/project.object.js'; import { Task } from '../objects/task.object.js'; import { Category } from '../objects/category.object.js'; +import { BusinessUnit } from '../objects/business-unit.object.js'; import { Team, ProjectMembership } from '../objects/team.object.js'; import { Product, Invoice, InvoiceLine } from '../objects/invoice.object.js'; import { FieldZoo } from '../objects/field-zoo.object.js'; @@ -83,6 +84,24 @@ const categories = defineSeed(Category, { ], }); +// Org-chart hierarchy seeded by `name` external id; `parent` references another +// record's name, building a 3-level tree the `tree` view renders. +const businessUnits = defineSeed(BusinessUnit, { + mode: 'upsert', + externalId: 'name', + records: [ + { name: 'Acme Corporation', kind: 'company', manager: 'Dana Wong', headcount: 0 }, + { name: 'Product & Engineering', parent: 'Acme Corporation', kind: 'division', manager: 'Lena Ortiz', headcount: 0 }, + { name: 'Platform', parent: 'Product & Engineering', kind: 'department', manager: 'Sam Patel', headcount: 12 }, + { name: 'Frontend Guild', parent: 'Product & Engineering', kind: 'department', manager: 'Yuki Tan', headcount: 9 }, + { name: 'Design Systems', parent: 'Frontend Guild', kind: 'team', manager: 'Priya Rao', headcount: 4 }, + { name: 'Go-To-Market', parent: 'Acme Corporation', kind: 'division', manager: 'Marcus Bell', headcount: 0 }, + { name: 'Sales', parent: 'Go-To-Market', kind: 'department', manager: 'Erin Cole', headcount: 18 }, + { name: 'Enterprise Sales', parent: 'Sales', kind: 'team', manager: 'Tom Nyx', headcount: 7 }, + { name: 'Marketing', parent: 'Go-To-Market', kind: 'department', manager: 'Aria Kim', headcount: 6 }, + ], +}); + const teams = defineSeed(Team, { mode: 'upsert', externalId: 'name', @@ -195,4 +214,4 @@ const preferences = defineSeed(Preference, { ], }); -export const ShowcaseSeedData = [accounts, products, projects, tasks, categories, teams, memberships, fieldZoo, invoices, invoiceLines, preferences]; +export const ShowcaseSeedData = [accounts, products, projects, tasks, categories, businessUnits, teams, memberships, fieldZoo, invoices, invoiceLines, preferences]; diff --git a/examples/app-showcase/src/objects/business-unit.object.ts b/examples/app-showcase/src/objects/business-unit.object.ts new file mode 100644 index 0000000000..f5446600c0 --- /dev/null +++ b/examples/app-showcase/src/objects/business-unit.object.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * Business Unit — a self-referencing org-chart hierarchy. `parent` points back + * at the same object, so the tree has arbitrary depth (company → division → + * department → team). This is the canonical case for the `tree` view type: + * fixed-depth grouping can't express an unbounded self-referencing hierarchy. + */ +export const BusinessUnit = ObjectSchema.create({ + name: 'showcase_business_unit', + label: 'Business Unit', + pluralLabel: 'Business Units', + icon: 'network', + description: 'Org-chart hierarchy — demonstrates the tree / tree-grid view over a self-referencing object.', + + fields: { + name: Field.text({ label: 'Name', required: true, searchable: true, maxLength: 120 }), + parent: Field.lookup('showcase_business_unit', { label: 'Parent Unit', allowCreate: true }), + kind: Field.select({ + label: 'Type', + options: [ + { label: 'Company', value: 'company' }, + { label: 'Division', value: 'division' }, + { label: 'Department', value: 'department' }, + { label: 'Team', value: 'team' }, + ], + defaultValue: 'department', + }), + manager: Field.text({ label: 'Manager', maxLength: 120 }), + headcount: Field.number({ label: 'Headcount', min: 0, defaultValue: 0 }), + }, +}); diff --git a/examples/app-showcase/src/objects/index.ts b/examples/app-showcase/src/objects/index.ts index fd6c1428e6..9a3a7c2813 100644 --- a/examples/app-showcase/src/objects/index.ts +++ b/examples/app-showcase/src/objects/index.ts @@ -4,6 +4,7 @@ export { Account } from './account.object.js'; export { Project } from './project.object.js'; export { Task } from './task.object.js'; export { Category } from './category.object.js'; +export { BusinessUnit } from './business-unit.object.js'; export { Team, ProjectMembership } from './team.object.js'; export { Product, Invoice, InvoiceLine } from './invoice.object.js'; export { FieldZoo } from './field-zoo.object.js'; diff --git a/examples/app-showcase/src/views/business-unit.view.ts b/examples/app-showcase/src/views/business-unit.view.ts new file mode 100644 index 0000000000..9ee3f190ac --- /dev/null +++ b/examples/app-showcase/src/views/business-unit.view.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineView } from '@objectstack/spec'; + +const data = { provider: 'object' as const, object: 'showcase_business_unit' }; + +/** + * Business Unit views — a flat grid plus an Organization Chart that uses the + * `tree` view type to nest records by their self-referencing `parent` field. + */ +export const BusinessUnitViews = defineView({ + list: { + label: 'All Units', + type: 'grid', + data, + columns: [ + { field: 'name' }, + { field: 'kind' }, + { field: 'manager' }, + { field: 'headcount' }, + { field: 'parent' }, + ], + }, + listViews: { + org_chart: { + label: 'Organization Chart', + type: 'tree', + data, + columns: ['name', 'kind', 'manager', 'headcount'], + tree: { + parentField: 'parent', + labelField: 'name', + fields: ['kind', 'manager', 'headcount'], + // Roots + one level expanded by default; deeper teams expand on click. + defaultExpandedDepth: 1, + }, + }, + }, + formViews: { + default: { + type: 'simple', + data, + sections: [ + { label: 'Unit', columns: 2, fields: ['name', 'parent', 'kind', 'manager', 'headcount'] }, + ], + }, + }, +}); diff --git a/examples/app-showcase/src/views/index.ts b/examples/app-showcase/src/views/index.ts index f9e220a906..0d26324e15 100644 --- a/examples/app-showcase/src/views/index.ts +++ b/examples/app-showcase/src/views/index.ts @@ -3,3 +3,4 @@ export { TaskViews } from './task.view.js'; export { ProjectViews } from './project.view.js'; export { InquiryViews } from './inquiry.view.js'; +export { BusinessUnitViews } from './business-unit.view.js'; diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 91bec47dad..b6bffdf5c5 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3282,6 +3282,7 @@ "TransitionConfigSchema (const)", "TransitionPreset (type)", "TransitionPresetSchema (const)", + "TreeConfigSchema (const)", "Typography (type)", "TypographySchema (const)", "UrlNavItem (type)", diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 7c5e5261c5..3874db17ca 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -225,6 +225,7 @@ export const VisualizationTypeSchema = lazySchema(() => z.enum([ 'gantt', 'map', 'chart', + 'tree', ]).describe('Visualization type that users can switch to')); /** @@ -433,6 +434,23 @@ export const GanttConfigSchema = lazySchema(() => z.object({ // stripped here, so a renderer release no longer has to wait on a spec release. }).passthrough()); +/** + * Tree (tree-grid) Settings + * + * Renders a self-referencing object as an indented, expand/collapse tree-grid. + * Flat records are nested via a single-parent pointer field (`parentField`). + * Unlike a fixed-depth `grouping`, a tree handles arbitrary depth (org charts, + * category trees, BOMs, nested comments). When `parentField` is omitted the + * renderer auto-detects the object's `tree`/self-reference field. + */ +export const TreeConfigSchema = lazySchema(() => z.object({ + parentField: z.string().optional().describe('Single-parent pointer field (auto-detected from the object schema when omitted)'), + labelField: z.string().optional().describe('Field rendered indented in the first column (defaults to "name")'), + fields: z.array(z.string()).optional().describe('Additional fields rendered as flat columns alongside the label'), + defaultExpandedDepth: z.number().int().min(0).optional().describe('Initial expansion depth (0 = roots only; omit = expand all)'), +// Forward-compatible: let renderer-ahead config knobs reach plugin-tree. +}).passthrough()); + /** * Navigation Mode Enum * Defines how to navigate to the detail view from a list item. @@ -502,7 +520,8 @@ export const ListViewSchema = lazySchema(() => z.object({ 'timeline', // Chronological Stream (Feed) 'gantt', // Project Timeline 'map', // Geospatial - 'chart' // Aggregate visualisation + 'chart', // Aggregate visualisation + 'tree' // Self-referencing hierarchy (tree-grid) ]).default('grid'), /** Data Source Configuration */ @@ -552,6 +571,7 @@ export const ListViewSchema = lazySchema(() => z.object({ gallery: GalleryConfigSchema.optional(), timeline: TimelineConfigSchema.optional(), chart: ListChartConfigSchema.optional(), + tree: TreeConfigSchema.optional(), /** View Metadata (Airtable-style view management) */ description: I18nLabelSchema.optional().describe('View description for documentation/tooltips'),