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
54 changes: 54 additions & 0 deletions .changeset/8253-export-tree-view-config.md
Original file line number Diff line number Diff line change
@@ -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`.
36 changes: 34 additions & 2 deletions packages/plugin-tree/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
158 changes: 158 additions & 0 deletions packages/plugin-tree/src/ObjectTree.hostConfigExported-8253.test.ts
Original file line number Diff line number Diff line change
@@ -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 extends true> = T;
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type IsAny<T> = 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<Equal<IsAny<TreeViewConfig>, 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<Equal<keyof TreeViewConfig, DeclaredKey>>;

// Every key is optional: a host writes the subset it means. `Partial<T>`
// is structurally identical to `T` only when nothing is required.
type _AllOptional = Assert<Equal<TreeViewConfig, Partial<TreeViewConfig>>>;

// Per-key types, read against the sibling node schema's spelling.
type _ParentField = Assert<Equal<TreeViewConfig['parentField'], string | undefined>>;
type _LabelField = Assert<Equal<TreeViewConfig['labelField'], string | undefined>>;
type _TitleField = Assert<Equal<TreeViewConfig['titleField'], string | undefined>>;
type _Fields = Assert<Equal<TreeViewConfig['fields'], string[] | undefined>>;
type _Depth = Assert<Equal<TreeViewConfig['defaultExpandedDepth'], number | undefined>>;

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.
*/
32 changes: 23 additions & 9 deletions packages/plugin-tree/src/ObjectTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<TreeViewConfig, 'labelField' | 'fields'>>
& Pick<TreeViewConfig, 'parentField' | 'defaultExpandedDepth'>;

interface TreeNode {
id: string;
Expand All @@ -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<TreeConfig>;
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)
Expand Down
27 changes: 27 additions & 0 deletions packages/plugin-view/src/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}>;

Expand Down
Loading
Loading