diff --git a/.changeset/8253-export-tree-view-config.md b/.changeset/8253-export-tree-view-config.md new file mode 100644 index 0000000000..e0c126f32a --- /dev/null +++ b/.changeset/8253-export-tree-view-config.md @@ -0,0 +1,54 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-view': minor +'@object-ui/plugin-tree': patch +--- + +Export the host `tree` view config as `TreeViewConfig` (objectui#8253, director +decision batch #78, 2026-09-07, maintainer 「同意」 on option (a)). + +**What was wrong.** `tree` is a host-composition-only view type — ruled deliberate on +objectui#5321, it is a member of neither `ObjectViewSchema.defaultViewType` nor +`NamedListView.type`, so the branch runs only when a host passes a `views` prop. On +that path a per-view `tree` block is read at four sites, and its only description +anywhere was a module-local, non-exported `interface TreeConfig` inside +`plugin-tree/src/ObjectTree.tsx`. The live host is the console: it stores view records +and passes them as `views`, and its create-view dialog offers `tree`. So a real +consumer wrote this block with no type to write it against, and a misspelled +`parentFeild` was admitted by the views entry's `[key: string]: any`, stored, read by +nobody and reported by nothing. Declared ≠ enforced on a surface a non-author +re-writes. + +**The grades, and why.** + +- `@object-ui/types` — **minor**: one new name, `TreeViewConfig`, becomes reachable + from the package entry. Nothing existing is renamed, retyped or removed, and no + value's validation changes anywhere in this package. +- `@object-ui/plugin-view` — **minor**: `ObjectViewProps.views[n].tree` is now + declared `TreeViewConfig` where it previously resolved to `any` through the entry's + index signature. On a face that already admitted every spelling a declaration + **cannot widen — it can only narrow**, and this one does: a host composing the entry + as an object literal now gets `parentFeild` reported as an excess property instead + of silently dropped. The index signature itself is untouched, so every other + undeclared key a host puts on the entry still type-checks exactly as before. +- `@object-ui/plugin-tree` — **patch**: the module-local interface becomes an import + of the exported one and the resolver's own type is `Pick`ed off it. No runtime + behaviour changes, and no key is added to or removed from what the renderer reads. + +**`titleField` is declared, not deleted — and that was a measurement.** The ruling put +this key to a test: declare it if the console writes it, else remove the read. The +console's create-view dialog does **not** offer it (its `tree` slot collects +`parentField` alone), but the console's own host composition reads it by name, so do +the `ListView` and `ObjectView` tree branches, and the rung is pinned as live behaviour +by objectui#6557 ("the tree's second view-declared rung … still answers"). Deleting the +read would have reversed a recorded ruling and reddened its pin. Declaring it makes +declared = enforced at all four read sites at once. `labelField` remains canonical and +wins wherever both are present. + +**Migration.** None required. Hosts that already compose a `tree` block keep working; +hosts that annotate one against `TreeViewConfig` start getting a compile error for a +misspelled key instead of a view that silently ignores it. + +⛔ This does not make `tree` an authorable view type. objectui#5321 is untouched: the +block is host config, written by a host and never by a document author, and the +authored node remains the flat `ObjectTreeSchema`. diff --git a/packages/plugin-tree/README.md b/packages/plugin-tree/README.md index 880e15c5e6..e6549489aa 100644 --- a/packages/plugin-tree/README.md +++ b/packages/plugin-tree/README.md @@ -66,14 +66,46 @@ declares no `views` member at all. The `tree` branch runs only when a **host** composes `ObjectView` with a `views` prop, whose entries carry `id` and `label` and are typed `ViewType`. That was ruled deliberate on objectui#5321 (2026-08-20): `tree` and `chart` are recorded as host-composition-only surfaces -rather than added to the authored unions. The per-view `tree` config block that -path reads is host config, so it is not documented here as authoring surface. +rather than added to the authored unions. The live consumer is the console: it passes stored view records to `ObjectView` as `views`, and its create-view dialog offers `tree` among the types a console user can create. To render a tree from authored metadata, write the `object-tree` node above. +#### The host config has a type — `TreeViewConfig` + +Host config is **not** untyped config. A block a host stores and re-writes is a +contract, so the per-view `tree` block is exported from `@object-ui/types` and +is the single declaration of that shape — the renderer imports it rather than +keeping a private copy (objectui#8253, ruled 2026-09-07): + +```ts +import type { TreeViewConfig } from '@object-ui/types'; + +// The block a host writes as `tree` on a `views` entry, or as `options.tree` +// on a stored view record. Every key is optional; a host writes the subset it +// means. +const treeConfig: TreeViewConfig = { + parentField: 'parent', // single-parent pointer (auto-detected if omitted) + labelField: 'name', // indented first column + fields: ['name', 'manager'], // additional flat columns + defaultExpandedDepth: 1, // 0 = roots only; omit = expand all +}; +``` + +Annotating the block is what turns a typo into a diagnostic: `parentFeild` used +to be stored, read by nobody and reported by nothing, because the `views` entry +admits any key. Against this type it is a compile error. + +`titleField` is also declared — a legacy second rung for `labelField`, kept +because the console's own composition still reads it. Prefer `labelField`, +which wins wherever both are present. + +⛔ This does not make `tree` an authorable view type. objectui#5321 is +unchanged: the block is written by a **host**, never by a document author, and +the authored node is the flat `object-tree` schema at the top of this file. + ## License MIT — see [LICENSE](./LICENSE). diff --git a/packages/plugin-tree/src/ObjectTree.hostConfigExported-8253.test.ts b/packages/plugin-tree/src/ObjectTree.hostConfigExported-8253.test.ts new file mode 100644 index 0000000000..3b23d98598 --- /dev/null +++ b/packages/plugin-tree/src/ObjectTree.hostConfigExported-8253.test.ts @@ -0,0 +1,158 @@ +/** + * 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. + */ + +/** + * objectui#8253 — the host `tree` view config is EXPORTED, and this file is + * what stops the next refactor un-exporting it in silence. + * + * ## What was wrong + * + * `tree` is a host-composition-only view type (objectui#5321, ruling B): it is + * on neither authored union, so the branch runs only when a host passes a + * `views` prop. On that path a per-view `tree` block is read — and its only + * description anywhere was the module-local, non-exported `interface TreeConfig` + * in `ObjectTree.tsx`. The live host is the console: it stores view records and + * passes them as `views`, and its create-view dialog offers `tree`. So a real + * consumer wrote this block with no type to write it against, and a misspelled + * `parentFeild` was admitted by the views entry's `[key: string]: any`, read by + * nobody, and reported by nothing. Declared ≠ enforced, class (c). + * + * Ruled on objectui#8253 (director seat, decision batch #78, 2026-09-07, + * maintainer 「同意」), option (a): `packages/types` exports the config, the + * module-local copy becomes an import of it, and ⛔ there is no second copy. + * + * ## Why the import below says `@object-ui/types` and not `../../types/src` + * + * This is the load-bearing part of the pin, not a style choice. This package's + * `tsconfig.test.json` sets `"paths": {}` precisely so `@object-ui/*` resolves + * through the WORKSPACE DEPENDENCY rather than through the root tsconfig's + * source-tree mapping — so the specifier below is resolved the way a published + * consumer resolves it: `packages/types/package.json` `exports["."].types` → + * `dist/index.d.ts`. A pin that imported `'../../types/src/views'` would never + * touch `dist` or the `exports` map and would stay green through a barrel that + * had stopped re-exporting the name, or an `exports` map that had stopped + * publishing the entry. This one goes red for both. + * + * ⚠️ Consequence, and it is deliberate: `@object-ui/types` must be BUILT before + * `pnpm --filter @object-ui/plugin-tree type-check` means anything. A stale + * `dist/*.d.ts` lies in both directions. + * + * ## The assertions are compile-time; vitest alone would not catch a broken pin + * + * Same mechanism as `types/src/__tests__/drill-down-config-declared-keys.test.ts`: + * `tsconfig.json` excludes tests (they must not emit into `dist`) and + * `tsconfig.test.json` picks them back up, chained off the package's + * `type-check` script. Vitest strips types without checking them. The runtime + * `expect` at the foot of each type-level case is a marker, ⛔ never the pin. + */ + +import { describe, it, expect } from 'vitest'; + +// Through the PACKAGE ENTRY — see the header. ⛔ Do not relax this to a +// relative path into `packages/types/src`. +import type { TreeViewConfig } from '@object-ui/types'; + +/* -------------------------------------------------------------------------- */ +/* Compile-time pins — compiled by tsconfig.test.json, chained off type-check. */ +/* -------------------------------------------------------------------------- */ + +type Assert = T; +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type IsAny = 0 extends 1 & T ? true : false; + +/** + * The keys `getTreeConfig` in `ObjectTree.tsx` reads off this block, and the + * ones the ruling says the type carries EXACTLY. Pinned as a `keyof` equality + * rather than a bag of `HasKey` checks, because equality is the only spelling + * that fails in BOTH directions — a key added here without a reader is as much + * a defect as a key removed from under one. + */ +type DeclaredKey = + | 'parentField' + | 'labelField' + | 'titleField' + | 'fields' + | 'defaultExpandedDepth'; + +describe('objectui#8253 — TreeViewConfig is reachable through @object-ui/types', () => { + it('is pinned at compile time', () => { + // Non-vacuity. `keyof any` is `string | number | symbol`, and every + // `Equal<…>` below would report whatever an `any` made convenient. If the + // import ever resolves to `any` — a broken export map degrades exactly + // this way — this line fails FIRST and names the reason. + type _ConfigIsReal = Assert, false>>; + + // The census, total in both directions. This ALSO refuses an index + // signature: `[key: string]: any` would put `string` into `keyof` and this + // equality would fail. That matters more than it looks — an index + // signature here would re-open the exact hole the card was filed for, by + // making every misspelling assignable again. + type _Census = Assert>; + + // Every key is optional: a host writes the subset it means. `Partial` + // is structurally identical to `T` only when nothing is required. + type _AllOptional = Assert>>; + + // Per-key types, read against the sibling node schema's spelling. + type _ParentField = Assert>; + type _LabelField = Assert>; + type _TitleField = Assert>; + type _Fields = Assert>; + type _Depth = Assert>; + + expect(true).toBe(true); + }); + + it('refuses the misspelling the card was filed for, at compile time', () => { + // ⭐ A FRESH object literal is the right instrument HERE, and it is the + // wrong one in `types/src/__tests__/menu-item-union.test.ts` — worth + // saying why, because the two files disagree on purpose. That file pins a + // `?: never` TOMBSTONE, where a fresh literal's excess-property rejection + // would be evidence-identical to the key simply not being declared. This + // file pins the opposite property: that an UNDECLARED key is refused at + // all. Excess-property checking on a fresh literal IS that property, and + // it is the diagnostic a host composing a view entry inline actually + // receives. + // + // `@ts-expect-error` is self-firing: if the line below ever STOPS being an + // error — an index signature added, the type widened to `any`, the export + // replaced by a looser one — this file fails with "unused + // '@ts-expect-error' directive" rather than passing quietly. + // + // `parentFeild` is the triage comment's own example of the silently-dropped + // misspelling. Refusing it here is the entire point of exporting this type. + // ⚠️ The directive must be the LAST comment line before the offending + // property — TypeScript matches it to the line that follows it, so prose + // underneath it would disarm the pin silently. + const misspelled: TreeViewConfig = { + // @ts-expect-error objectui#8253 — an undeclared key on the host tree config is refused. + parentFeild: 'parent', + }; + + // The correctly-spelled twin, so the refusal above is a statement about + // the SPELLING and not about the type refusing objects in general. If this + // line ever fails, the pin above stops being evidence of anything. + const spelled: TreeViewConfig = { parentField: 'parent' }; + + expect(spelled.parentField).toBe('parent'); + expect(misspelled).toBeTruthy(); + }); +}); + +/* + * ⚠️ The off-disk reader census that belongs beside these pins does NOT live + * here, and the reason is mechanical rather than aesthetic: this package's + * `tsconfig.test.json` carries no `"types": ["node"]`, so `node:fs` is not a + * name this project can resolve, and adding it would put a Node API within + * reach of a package that ships to browsers. The census lives in + * `packages/types/src/__tests__/tree-view-config-readers-8253.test.ts`, beside + * the declaration it is total over, in a project that already declares `node` + * for exactly this purpose. What stays HERE is the half only this package can + * assert: that the name resolves through the published `exports` map. + */ diff --git a/packages/plugin-tree/src/ObjectTree.tsx b/packages/plugin-tree/src/ObjectTree.tsx index 7867221cd8..ae26d1a9ab 100644 --- a/packages/plugin-tree/src/ObjectTree.tsx +++ b/packages/plugin-tree/src/ObjectTree.tsx @@ -20,7 +20,7 @@ */ import React, { useEffect, useMemo, useState } from 'react'; -import type { DataSource } from '@object-ui/types'; +import type { DataSource, TreeViewConfig } from '@object-ui/types'; import { useNavigationOverlay, useSafeFieldLabel, @@ -78,12 +78,26 @@ export interface ObjectTreeProps { loading?: boolean; } -interface TreeConfig { - parentField?: string; - labelField: string; - fields: string[]; - defaultExpandedDepth?: number; -} +/** + * The RESOLVED form of the host's `tree` block — what `getTreeConfig` hands the + * renderer after flooring, ⛔ not a second declaration of the config's shape. + * + * The shape itself is `TreeViewConfig` in `@object-ui/types`, exported for this + * card (objectui#8253, ruling batch #78, option (a)): the module-local + * `interface TreeConfig` that used to stand here was the ONLY description of a + * block a real host stores and re-writes. Every key name and every key TYPE + * below is `Pick`ed off that one declaration, so a key added, renamed or + * retyped there arrives here without an edit — which is the property a + * hand-copied interface cannot have, and the reason #7646's private copy is the + * shape to avoid. + * + * The only thing this adds is REQUIREDNESS, and only for the two keys the + * resolver floors: `labelField` falls back to `'name'` and `fields` to `[]`, so + * the renderer below indexes both without a guard. + */ +type ResolvedTreeConfig = + Required> + & Pick; interface TreeNode { id: string; @@ -105,8 +119,8 @@ function fieldKey(f: any): string | undefined { return columnIdentity(f) || (f && typeof f === 'object' ? f.key : undefined) || undefined; } -function getTreeConfig(schema: any): TreeConfig { - const nested = (schema.tree || schema.filter?.tree || {}) as Partial; +function getTreeConfig(schema: any): ResolvedTreeConfig { + const nested = (schema.tree || schema.filter?.tree || {}) as TreeViewConfig; const rawFields = Array.isArray(schema.fields) ? schema.fields : Array.isArray(nested.fields) diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 1d530c7076..02a23d9f95 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -33,6 +33,7 @@ import type { NamedListView, ViewNavigationConfig, ObjectMapConfig, + TreeViewConfig, } from '@object-ui/types'; import { ObjectGrid } from '@object-ui/plugin-grid'; import { ObjectForm } from '@object-ui/plugin-form'; @@ -307,6 +308,32 @@ export interface ObjectViewProps { columns?: string[]; sort?: Array<{ field: string; order: 'asc' | 'desc' }>; filter?: any[]; + /** + * Per-view `tree` config — the block the `'tree'` branch of + * `generateViewSchema` below reads (objectui#8253, ruling batch #78, + * option (a), maintainer 「同意」). + * + * ⭐ This declaration can only NARROW, and that is the whole of its value. + * The `[key: string]: any` on the line under it already ADMITTED a `tree` + * key of any shape, so nothing that used to be refused becomes accepted; + * what changes is that a host writing this entry as an object LITERAL now + * gets `parentFeild` reported as an excess property instead of stored, + * dropped and never mentioned. That is the class-(c) defect this card was + * filed for. + * + * ⛔ Declared, ⛔ not re-declared: the shape is `TreeViewConfig` in + * `@object-ui/types`, which `plugin-tree`'s resolver imports as well. One + * declaration, three readers. + * + * ⚠️ Reach, measured on this tree and NOT claimed wider than it is: the + * console's own call site passes `mergedViews`, built by + * `app-shell/src/views/ObjectView.tsx` as `views.map((v: any) => …)` over + * STORED view records, so it arrives here as `any[]` and this declaration + * reports nothing about it. It bites a host that composes the entry inline + * against this prop's type. Typing the stored-record path is a separate + * change on app-shell, recorded rather than smuggled in here. + */ + tree?: TreeViewConfig; [key: string]: any; }>; diff --git a/packages/plugin-view/src/__tests__/ObjectView.treeViewConfigDeclared-8253.test.ts b/packages/plugin-view/src/__tests__/ObjectView.treeViewConfigDeclared-8253.test.ts new file mode 100644 index 0000000000..adbdeb9b1c --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.treeViewConfigDeclared-8253.test.ts @@ -0,0 +1,121 @@ +/** + * 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. + */ + +/** + * objectui#8253 — `ObjectViewProps.views[n].tree` is DECLARED, so a host that + * composes the entry gets a compile error instead of a silently dropped key. + * + * ## Which direction this declaration moves, measured rather than asserted + * + * The views entry closes with `[key: string]: any`. On a face like that a + * declaration CANNOT widen — every spelling was already admitted, so no value + * that used to be refused becomes accepted. It can only NARROW, by putting + * validation where there was none. That is what happens here, and the two pins + * below measure both halves of it: + * + * - the LEG THAT NARROWS — a misspelled key inside `tree` is now an + * excess-property error on a host's object literal; + * - the LEG THAT DOES NOT MOVE — every OTHER undeclared key on the entry is + * still admitted through the index signature, exactly as before. Without + * this second leg "it narrows" would be indistinguishable from "the index + * signature was removed", which would be a breaking change to every host. + * + * ⚠️ Reach, stated because a pin that overclaims is worse than none. The + * console's own call site does NOT receive this diagnostic today: + * `app-shell/src/views/ObjectView.tsx` builds `mergedViews` as + * `views.map((v: any) => …)` over STORED view records, so it arrives typed + * `any[]` and nothing here reports on it. The diagnostic reaches a host that + * writes the entry inline against this prop's declared type. Typing the + * stored-record path is an app-shell change and was recorded, ⛔ not smuggled + * into this card. + * + * ## `@object-ui/types`, not a relative path + * + * This package's `tsconfig.test.json` sets `"paths": {}`, so the specifier + * resolves through the workspace dependency's `exports` map to + * `packages/types/dist/index.d.ts` — the surface a published consumer sees. + * ⚠️ `@object-ui/types` must therefore be BUILT for this file to mean anything. + */ + +import { describe, it, expect } from 'vitest'; +import type { TreeViewConfig } from '@object-ui/types'; +import type { ObjectViewProps } from '../ObjectView'; + +type Assert = T; +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type IsAny = 0 extends 1 & T ? true : false; + +/** One element of the `views` prop — the shape a host composes. */ +type ViewEntry = NonNullable[number]; + +describe('objectui#8253 — the views entry declares its `tree` block', () => { + it('is pinned at compile time', () => { + // Non-vacuity: before this card `tree` resolved to `any` through the index + // signature, and `any` satisfies every assignability pin below. This line + // is what tells the two apart, and it is the line that would have failed + // on the parent commit. + type _TreeIsNotAny = Assert, false>>; + + // ⛔ One declaration, not two: the entry's `tree` IS the exported type. + // A structurally-equal local twin would pass an `extends` check, so this + // is pinned as invariant equality. + type _SameDeclaration = Assert>; + + expect(true).toBe(true); + }); + + it('NARROWS: a misspelled key inside `tree` is now a compile error', () => { + // The class-(c) defect in its original spelling — the console user writes + // `parentFeild`, the block is stored, nothing reads it, nothing says so. + // ⚠️ The directive must be the LAST comment line before the property. + const composed: ViewEntry = { + id: 'tree', + label: 'Hierarchy', + type: 'tree', + tree: { + // @ts-expect-error objectui#8253 — an undeclared key on the tree block is refused. + parentFeild: 'parent', + }, + }; + + expect(composed.type).toBe('tree'); + }); + + it('DOES NOT WIDEN OR BREAK: an undeclared key elsewhere on the entry is still admitted', () => { + // The control leg. The index signature is untouched, so a host's own + // bookkeeping key on the ENTRY still type-checks. If this line ever starts + // failing, the change stopped being a narrowing of one key and became a + // breaking change to every host that composes `views`. + const withHostKey: ViewEntry = { + id: 'tree', + label: 'Hierarchy', + type: 'tree', + someHostBookkeepingKey: { anything: true }, + }; + + expect(withHostKey.someHostBookkeepingKey).toEqual({ anything: true }); + }); + + it('accepts every declared key, so the refusal above is about spelling', () => { + const full: ViewEntry = { + id: 'tree', + label: 'Hierarchy', + type: 'tree', + tree: { + parentField: 'parent', + labelField: 'name', + titleField: 'subject', + fields: ['name', 'manager'], + defaultExpandedDepth: 1, + }, + }; + + expect(full.tree).toMatchObject({ parentField: 'parent', defaultExpandedDepth: 1 }); + }); +}); diff --git a/packages/types/src/__tests__/tree-view-config-readers-8253.test.ts b/packages/types/src/__tests__/tree-view-config-readers-8253.test.ts new file mode 100644 index 0000000000..b6ab8fdf25 --- /dev/null +++ b/packages/types/src/__tests__/tree-view-config-readers-8253.test.ts @@ -0,0 +1,225 @@ +/** + * 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. + */ + +/** + * objectui#8253 — `TreeViewConfig`'s key census is total over its readers, and + * the module-local copy it replaced is GONE rather than shadowed. + * + * ## The card + * + * `tree` is a host-composition-only view type (objectui#5321 ruling B): on + * neither authored union, reached only when a host passes a `views` prop. The + * per-view `tree` block that path reads had NO exported type — its only + * description was the module-local `interface TreeConfig` in + * `plugin-tree/src/ObjectTree.tsx`. The live host is the console, which stores + * view records and passes them as `views`, so a real consumer wrote this block + * against nothing and a misspelled key was stored, dropped and never reported. + * Ruled option (a) on objectui#8253 (decision batch #78, 2026-09-07, maintainer + * 「同意」): export the config, import it at the reader, ⛔ no second copy. + * + * ## What this file owns, and what it deliberately does not + * + * It owns the DERIVED half — the part a type-level pin cannot see: + * + * - the private interface is gone from the reader; + * - the reader imports the exported name instead; + * - the resolver's local `ResolvedTreeConfig` is `Pick`ed off the one + * declaration rather than hand-written beside it; + * - all four reader sites are where this census says they are. + * + * It does NOT own reachability through the published `exports` map. That is + * unassertable from inside this package — `tsconfig.test.json` here sets + * `"paths": {}` AND there is no self-link in `packages/types/node_modules`, so + * `@object-ui/types` is not a specifier this project can resolve at all. It is + * pinned from a CONSUMER instead, in + * `plugin-tree/src/ObjectTree.hostConfigExported-8253.test.ts`, whose own + * `"paths": {}` sends the specifier through the workspace dependency to + * `packages/types/dist/index.d.ts`. Two files, two halves, neither redundant. + * + * ## Every zero below has a firing control + * + * A `not.toMatch` is worth nothing until the same instrument is shown matching + * something that IS there. Each negative assertion is paired with a positive + * one on the same regex family and the same file. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { TreeViewConfig } from '../index'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); +const read = (rel: string) => readFileSync(join(REPO_ROOT, rel), 'utf8'); + +const RESOLVER = 'packages/plugin-tree/src/ObjectTree.tsx'; +const VIEW_BRANCH = 'packages/plugin-view/src/ObjectView.tsx'; +const LIST_BRANCH = 'packages/plugin-list/src/ListView.tsx'; +const CONSOLE_COMPOSITION = 'packages/app-shell/src/views/ObjectView.tsx'; + +/** The declared keys, as a value — so the runtime census and the type agree. */ +const DECLARED = [ + 'parentField', + 'labelField', + 'titleField', + 'fields', + 'defaultExpandedDepth', +] as const; + +/* -------------------------------------------------------------------------- */ +/* Compile-time: the value list above IS the type's key set, not a copy of it. */ +/* -------------------------------------------------------------------------- */ + +type Assert = T; +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; + +// Without this, `DECLARED` is a second hand-maintained key list — the exact +// artefact this card removed from `ObjectTree.tsx`. It is checked in BOTH +// directions, so a key added to the interface without being added here fails +// as loudly as the reverse. +type _CensusMatchesType = Assert>; + +describe('the module-local copy is gone, not shadowed (objectui#8253)', () => { + const src = read(RESOLVER); + + // ⚠️ Anchored at the start of a line, and that is load-bearing rather than + // tidy. The first spelling of this pin was an unanchored + // `/interface\s+TreeConfig\b/`, and it went RED on a green tree: the + // replacement docblock in `ObjectTree.tsx` says the words "interface + // TreeConfig" while EXPLAINING that the interface is gone. An unanchored + // regex over a source file cannot tell a declaration from prose about a + // declaration. A declaration starts its line; a docblock line starts with + // ` * `. + const DECLARATION = (name: string) => + new RegExp(String.raw`^\s*(export\s+)?interface\s+${name}\b`, 'm'); + + it('declares no private `TreeConfig` interface any more', () => { + expect(src).not.toMatch(DECLARATION('TreeConfig')); + }); + + it('CONTROL: the same instrument still finds an interface that IS there', () => { + // `TreeNode` is declared in the same file, two statements from where + // `TreeConfig` stood. If the regex family has stopped matching interface + // declarations at all, this fails and the negative above stops counting. + expect(src).toMatch(DECLARATION('TreeNode')); + }); + + it('CONTROL: and the anchor is what does the work', () => { + // Pins the trap itself, so the next person to "simplify" this regex is + // told why it is shaped this way: the prose IS in the file, and only the + // anchor keeps it from reading as a declaration. + expect(src).toMatch(/interface TreeConfig/); + expect(src).not.toMatch(DECLARATION('TreeConfig')); + }); + + it('imports the exported type from the package entry', () => { + expect(src).toContain("TreeViewConfig } from '@object-ui/types'"); + }); + + it('derives its resolved form from the one declaration instead of restating it', () => { + // `ResolvedTreeConfig` may add REQUIREDNESS (the resolver floors + // `labelField` and `fields`) but must not respell a key or a key's type — + // a structurally-equal hand-written twin type-checks fine and would defeat + // the export entirely. + expect(src).toMatch(/Required>/); + expect(src).toMatch(/Pick/); + }); + + it('CONTROL: and the resolver still returns that type', () => { + // Proves the derived type is WIRED IN, not merely declared and orphaned. + expect(src).toMatch(/function getTreeConfig\(schema: any\): ResolvedTreeConfig/); + }); +}); + +describe('every declared key has a reader (objectui#8253)', () => { + const resolver = read(RESOLVER); + /** `getTreeConfig`'s body — the block the ruling scoped the census to. */ + const fn = (() => { + const from = resolver.slice(resolver.indexOf('function getTreeConfig')); + return from.slice(0, from.indexOf('\n}\n') + 3); + })(); + + it('the resolver body was actually located', () => { + // The slice above is derived from source text; if the function is renamed + // the slice silently becomes the whole file and every assertion under it + // passes for the wrong reason. Bound it explicitly. + expect(fn.length).toBeGreaterThan(80); + expect(fn.length).toBeLessThan(resolver.length / 2); + expect(fn).toContain('function getTreeConfig'); + }); + + it.each(DECLARED)('reads `%s`', (key) => { + expect(fn).toContain(key); + }); + + it('CONTROL: a key that is neither declared nor read is absent', () => { + // If this ever appears in the resolver, the census has stopped being total + // and the type owes a decision — declare the key, or delete the read. That + // is the card's own rule, applied to itself. + expect(fn).not.toContain('sortField'); + }); +}); + +describe('`titleField` stays DECLARED — the measurement the ruling asked for', () => { + // The ruling put this key to a measurement: declare it if the console writes + // it, else delete the read. The answer came back split, and the READ side + // decides. + // + // - the console's create-view dialog does NOT offer it: its `tree` slot + // collects `parentField` alone (asserted below); + // - but the console's own host composition reads it BY NAME, as do the + // `ListView` and `ObjectView` tree branches; + // - and objectui#6557 pinned the rung as live behaviour in + // `app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx` + // ("the tree's second view-declared rung … still answers"), a regression + // pin landed precisely so a later edit could not collapse the tree's two + // view-declared rungs into one. + // + // ⇒ Deleting the read would reverse a recorded ruling and redden its pin — + // a maintainer's call, not a rider on this card. Declaring the key is what + // makes declared = enforced at all four sites at once. + + it('the console composition reads it', () => { + expect(read(CONSOLE_COMPOSITION)).toContain('tree?.titleField'); + }); + + it('the plugin-view tree branch reads it', () => { + expect(read(VIEW_BRANCH)).toContain('viewOptions.tree?.titleField'); + }); + + it('the plugin-list tree branch reads it', () => { + expect(read(LIST_BRANCH)).toContain('treeCfg.titleField'); + }); + + it("objectui#6557's pin on the rung is still in the tree", () => { + // Named explicitly: if this file is ever deleted, whoever deletes it is + // told here that this declaration's justification went with it. + expect(read('packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx')) + .toContain('tree.titleField'); + }); + + it("CONTROL: the console's create-view dialog still does NOT offer it for `tree`", () => { + // The other half of the measurement, and the half that would have argued + // for deleting the read. `titleField` IS collected — for calendar, + // timeline and gantt — so a zero for the `tree` slot is a reading about + // the tree slot and not about a broken instrument. + const dialog = read('packages/app-shell/src/views/CreateViewDialog.tsx'); + const treeSlot = (() => { + const from = dialog.slice(dialog.indexOf('\n tree: [')); + return from.slice(0, from.indexOf('\n ],') + 5); + })(); + + expect(treeSlot).toContain("key: 'parentField'"); + expect(treeSlot).not.toContain("key: 'titleField'"); + // FIRING CONTROL: the same instrument, on a slot that does collect it. + expect(dialog).toContain("key: 'titleField'"); + }); +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 12bb59e321..2319b5936e 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -879,6 +879,10 @@ export type { export type { // View System Enhancements ViewType, + // Host-composition config for the `tree` view type (objectui#8253). The + // ONE declaration of the block `ObjectViewProps.views[n].tree` carries; + // `plugin-tree` imports it instead of re-deriving a module-local copy. + TreeViewConfig, DetailViewSchema, DetailViewField, DetailViewSection, diff --git a/packages/types/src/views.ts b/packages/types/src/views.ts index 7d6fff3e60..f674e671a8 100644 --- a/packages/types/src/views.ts +++ b/packages/types/src/views.ts @@ -52,6 +52,100 @@ import type { ListView as SpecListView } from '@objectstack/spec/ui'; */ export type ViewType = NonNullable | 'list' | 'detail'; +/** + * Per-view `tree` configuration — the HOST-composition contract (objectui#8253). + * + * This is the block a host writes as `tree` on a `views` entry + * (`ObjectViewProps.views[n].tree`, or `options.tree` on a stored view record), + * and it is the ONE declaration of that shape. ⛔ Do not re-derive a private + * copy of it: the module-local `TreeConfig` in `plugin-tree/src/ObjectTree.tsx` + * that used to be the only description of these keys is now an import of this + * type, and objectui#7646 is the shape a second copy takes. + * + * ## Why it is declared here, and what it is NOT + * + * `tree` is a HOST-COMPOSITION-ONLY view type, ruled deliberate on objectui#5321 + * (maintainer ruling B, 2026-08-20): it is a member of neither + * `ObjectViewSchema.defaultViewType` nor `NamedListView.type`, so no AUTHORED + * document selects a tree view and the branch runs only when a host passes a + * `views` prop. ⛔ That ruling is untouched by this declaration — typing the + * host path does not put `tree` on an authored union, and this type is NOT the + * `object-tree` NODE schema. The node an author writes is `ObjectTreeSchema` + * (`./objectql.ts`), whose keys sit FLAT on the node; this block sits nested + * under a view entry and is written by a host, never by a document author. + * + * ## Why it is a contract at all (objectui#8253, ruling batch #78, 2026-09-07) + * + * Maintainer 「同意」 on option (a): a configuration a real host STORES and + * RE-WRITES is a contract and has a type. The live host is the console — it + * passes stored view records to `ObjectView` as `views`, and its create-view + * dialog offers `tree`. Until this declaration existed a console user's + * misspelled `parentFeild` was admitted by the `[key: string]: any` on the + * views entry, read by nobody, and reported by nothing: declared ≠ enforced on + * a surface a non-author re-writes. + * + * ## The reader census these keys are exactly total over + * + * Every key below is read, and every read of this block is of a key below: + * + * - `plugin-tree/src/ObjectTree.tsx` `getTreeConfig` — the resolver + * - `plugin-view/src/ObjectView.tsx` the `'tree'` branch of `generateViewSchema` + * - `plugin-list/src/ListView.tsx` the `'tree'` branch + * - `app-shell/src/views/ObjectView.tsx` `options.tree`, the console's own composition + * + * ⚠️ `fields` is `string[]`, matching `ObjectTreeSchema.fields`, even + * though `ObjectTree`'s `fieldKey` also normalises a column OBJECT + * (`{ name | fieldName | field | key }`). That tolerance exists because hosts + * like `ListView` forward their own already-resolved column entries into the + * same slot; it is a reader's resilience, ⛔ not an invitation to write column + * objects here, and declaring the wider form would widen the accept set past + * what the sibling node schema admits. + */ +export interface TreeViewConfig { + /** + * Field holding the parent record reference (single-parent pointer). + * When omitted, the renderer auto-detects the object's `tree` / + * self-reference field. + */ + parentField?: string; + /** Field rendered (indented) in the tree's first column. Floored at `name`. */ + labelField?: string; + /** + * Legacy second rung for {@link TreeViewConfig.labelField}, kept DECLARED + * rather than removed — measured, objectui#8253. + * + * The ruling put this key to a measurement: declare it if the console writes + * it, else delete the read. The measurement came back split, and the half + * that decides is the READ side. The console's create-view dialog does NOT + * offer it — `CreateViewDialog.tsx`'s `tree` slot collects `parentField` and + * nothing else, while `titleField` is what its calendar / timeline / gantt + * slots collect. But the console's own host composition reads it BY NAME + * (`app-shell/src/views/ObjectView.tsx`, `viewDef.tree?.titleField`), so do + * `ListView` and `ObjectView`'s tree branches, and the rung is PINNED as live + * behaviour by `app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx` + * — "the tree's second view-declared rung (`tree.titleField`) still answers", + * a regression pin objectui#6557 landed precisely so a later edit could not + * collapse the tree's two view-declared rungs into one. + * + * ⇒ Deleting the read would reverse a recorded ruling and redden its pin, + * which is a maintainer's call and not a rider on this card. Declaring it is + * what makes declared = enforced across all four read sites at once. + * + * ⛔ Not a tolerant dual-read to be extended: `labelField` is the canonical + * spelling and wins wherever both are present. + */ + titleField?: string; + /** + * Additional fields rendered as flat columns alongside the label column. + * When omitted the host's own column list is used. + */ + fields?: string[]; + /** + * Initial expansion depth. `0` = roots only; omitted = expand everything. + */ + defaultExpandedDepth?: number; +} + /** * Detail View Field Configuration */ diff --git a/scripts/check-doc-example-types.mjs b/scripts/check-doc-example-types.mjs index de0cb2891d..411faf30e4 100644 --- a/scripts/check-doc-example-types.mjs +++ b/scripts/check-doc-example-types.mjs @@ -815,19 +815,19 @@ export const UNGATED_EXAMPLES = { reason: 'usage fragment: references `myAdapter`, `report`, which the example never declares', }, - 'packages/plugin-view/src/ObjectView.tsx:591 ObjectView': { + 'packages/plugin-view/src/ObjectView.tsx:618 ObjectView': { card: null, codes: [2304], reason: 'usage fragment: references `dataSource`, which the example never declares', }, - 'packages/plugin-view/src/ObjectView.tsx:605 ObjectView': { + 'packages/plugin-view/src/ObjectView.tsx:632 ObjectView': { card: null, codes: [2304], reason: 'usage fragment: references `dataSource`, which the example never declares', }, - 'packages/plugin-view/src/ObjectView.tsx:622 ObjectView': { + 'packages/plugin-view/src/ObjectView.tsx:649 ObjectView': { card: null, codes: [2304], reason: diff --git a/scripts/check-spec-symbol-derivation.mjs b/scripts/check-spec-symbol-derivation.mjs index 2b9b99c88f..ac490bd90c 100644 --- a/scripts/check-spec-symbol-derivation.mjs +++ b/scripts/check-spec-symbol-derivation.mjs @@ -882,9 +882,6 @@ const DEBT = { "@object-ui/plugin-detail": [ "RecordAlertProps", ], - "@object-ui/plugin-tree": [ - "TreeConfig", - ], }; // Files under these paths are not objectui's own authored surface.