Skip to content

Commit b65aa5e

Browse files
fix(app-shell): both Create View doors build the persisted view through one builder, so "Save as view" saves a kanban the spec accepts (objectui#11581) (#11589)
Fixes #11581 Clause-②: no Why no: the new symbols (`buildNewViewSpec` in the new `packages/app-shell/src/views/newViewSpec.ts`, `buildAddViewSpec` in `ObjectView.tsx`, `offeredViewTypes` in `CreateViewDialog.tsx`) are module exports for the pins only. Nothing is added to `packages/app-shell/src/index.ts` (the package exports only `.`), and `views/index.ts` re-exports `CreateViewDialog` and `ObjectView` by name, not these. ## What changed The Create View dialog persists through two doors: "Save as view" on the object data page, and the add-view door on the object page (`handleViewCreate`: the tab bar's add button and the view-config panel's create mode). They assembled the spec separately from the same payload, and only the add-view door mirrored the columns into `kanban.columns` / `gallery.visibleFields`. Measured on the cut (`f1c9379`) with the installed `@objectstack/spec` 17.6.0: "Save as view" with Kanban is refused, `invalid_type` at `kanban.columns`; every other offered type parses at both doors, but a gallery from "Save as view" carries no `visibleFields` while the same payload through the tab bar does. - **One builder:** `buildNewViewSpec(config, { fallbackColumns, filter })`. It resolves `columns` (the payload's, else the door's fallback), places `filter` (no rules, no key), and owns every type-specific rule through `COLUMN_MIRRORS`, a TOTAL record over `ListViewVisualization`: `kanban` (required `columns`), `gallery` (`visibleFields` when the payload has none), `null` for the other seven, so a new visualization fails the build until its block is decided. - **Door 1** `buildSaveAsViewSpec(config, columns, urlFilters)` keeps its signature and pins; it now resolves only its inputs (page columns, URL triples folded to spec rules) and calls the builder. - **Door 2** `handleViewCreate`'s inline body moves to `buildAddViewSpec(config, objectDef)`, which supplies `defaultListColumnsFromObject(objectDef, 5)` and calls the builder. This door's body is byte-identical to before. - `CreateViewDialog.offeredViewTypes()`: a pure read of the picker's rows (`buildViewTypeMeta` with an identity translator), so the pin enumerates at collection time. - Behaviour change, "Save as view" only: a kanban now persists `kanban.columns` (was a 422 at the view write door); a gallery now persists `gallery.visibleFields` = the view's columns when it declares none, as the tab-bar door always did, so its cards show those fields. - objectui#11578's refusal toasts and the dialog's await, and objectui#11576's chart dataset binding, are unchanged. The other write sites objectui#11583 lists are not touched. - One `@object-ui/app-shell` patch changeset: `.changeset/11581-one-view-builder.md`. ## Pins - `CreateViewDialog.viewTypeParse-11581.test.tsx` - enumeration: `PARSE_ROWS` names exactly `offeredViewTypes()`, and `offeredViewTypes()` is the type grid the dialog renders; - one row per offered type (`it.each(offeredViewTypes())`): the real dialog is rendered and submitted with that row's picks, the payload goes through `buildSaveAsViewSpec` and `buildAddViewSpec`, each wrapped by `viewEnvelope`; `ListViewSchema` must accept the envelope's `config` and `ViewItemSchema` the envelope, at both doors (soft, so a row names every refusing door), and the two doors must build the same envelope from the same columns; - the builder's mirror pins (`kanban.columns`, `gallery.visibleFields` kept when declared, nothing for other types or a prototype-chain name); - "Save as view" with Kanban through `ObjectDataPage`, against the spec's view gate replayed at the network boundary: 200, `kanban.columns` equal to the persisted `columns`, lands on the draft. - `ObjectView.createKanbanView-11581.test.tsx`: the add-view door through the real `ObjectView` and dialog; the saved body parses and carries the mirror. ## Evidence - **Red before** (`1a8dc9a`, the pin plus door 2 moved unchanged into `buildAddViewSpec`, no builder): 3 failed, 10 passed. Kanban row: DOOR 1 refused, `invalid_type` at `kanban.columns`. Gallery row: "the two doors built different bodies from one payload". Page case: `expected 422 to be 200`. - **Green after** at `168f819`: the new file 17 passed; the door-2 page pin 1 passed. - **Ablation 1**, the kanban mirror set to `null` in the builder (anchor 1 to 0, injection 0 to 1, on disk): 5 failed, 13 passed. The kanban row fails at BOTH doors, both schemas (`kanban.columns` / `config.kanban.columns`, "expected array, received undefined"); both builder kanban pins; both page cases. Restored with `git checkout HEAD`; blob `accf26f1db91` equals the HEAD blob, `git diff HEAD` empty. - **Ablation 2**, a fake type appended to `buildViewTypeMeta`'s record (injection 0 to 1 on disk): 2 failed, 16 passed: the `PARSE_ROWS` enumeration and the row `fake_ablation_type` ("no parse row for the offered view type"). The rendered-grid check stays green, since the grid and `offeredViewTypes()` both carry it. Restored; blob `aa96b493d04b` equals the HEAD blob, `git diff HEAD` empty. ## Gates (head `168f819`) - `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build` (29 packages): exit 0. - `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`, which includes `src/**/*.test.tsx`): exit 0. - vitest, narrowed: the 75 test files that import a changed module by name (`git grep` for `ObjectView`, `ObjectDataPage`, `CreateViewDialog`, `runtime-metadata-persistence`, `newViewSpec` across `packages/**` and `apps/**` tests, minus the 46 hits in `packages/plugin-view`, which import that package's own `ObjectView`): 75 files, 857 tests passed. It includes every `CreateViewDialog.*`, `ObjectDataPage.*` and `ObjectView.create*` suite. - eslint on the 7 changed `.ts`/`.tsx` files, `--no-inline-config --format json`: 7 files, 0 errors. The narrowing is a measurement: the population is the config's `**/*.{ts,tsx}` rule objects; the rule set is `tseslint.configs.recommended` with no type-aware parser options, and no `eslint-rules/*` rule reads another file, so the diff cannot move a verdict on an untouched file. - `check:control-bytes`, `check:new-line-citations` (0 new), `check:changeset-claims` (2 pending bodies name `ObjectView.tsx` / `ObjectDataPage.tsx`; both re-read and still true), `check:pending-changeset-literals`, `check:metadata-write-doors`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:test-path-roots`, `check:unreferenced-sources`, `check:self-import`, `check:esm-specifiers`, `check:phantom-deps`, `check:unused-deps`, `check-changeset-presence`, `check-changeset-no-major`, `check-governed-queue-guard --test` (NOT GOVERNED): all exit 0. - NOT MEASURED: the whole `packages/app-shell` suite (988 test files). One run died with its process, without a verdict, about 20 minutes in, at `--maxWorkers=2`; CI's `pnpm test` runs it. `check:eager-closure`: PREREQUISITE NOT MET (it reads a built console bundle); CI owns it. ## Acceptance notes - The `grid` type card's test id, `create-view-type-grid`, is also the test id of the grid that holds the cards. The new pin reads cards by tag. No user effect; noted, not filed (carrier: none). - The dialog's key pattern accepts a one-character key, while the spec `ListViewSchema` `name` needs two. Not reachable as a refusal: `viewEnvelope` strips `name` from `config`, and the envelope's `name` is the qualified object-dot-key, which `ViewItemSchema` accepts (measured with a one-character key). Observation only. - `origin/main` moved to `0c012a7` after the cut (objectui#10872's README, objectui#11511's skills guide). Neither touches these files, so the branch is not merged with it. --- _Generated by [Claude Code](https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5a2ca6b commit b65aa5e

8 files changed

Lines changed: 694 additions & 44 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
"Save as view" now saves a Kanban view the platform accepts, and both Create View doors save the same view from the same dialog choices (objectui#11581).
6+
7+
The Create View dialog persists a new view through two doors: "Save as view" on the object data
8+
page, and Create View on the object page (the view tab bar's add button, and the view-config
9+
panel's create mode). The two assembled the saved view separately, and only the object page's
10+
door copied the view's columns into the type blocks that carry a field list of their own. So:
11+
12+
- a Kanban saved through "Save as view" had no `kanban.columns`, which `@objectstack/spec`
13+
requires ("Fields to show on cards"), and the platform's view write door refused it;
14+
- a Gallery saved through "Save as view" had no `gallery.visibleFields`, so its cards showed
15+
nothing under the title, while the same choices on the object page showed the view's columns.
16+
17+
Both doors now build the view through one builder. Each door still supplies its own columns (the
18+
data page's current columns, or the object's default business columns), and "Save as view" still
19+
folds the page's URL conditions into the view's `filter`. The builder writes those columns into
20+
`kanban.columns`, and into `gallery.visibleFields` when the gallery declares none. A view created
21+
on the object page is saved exactly as before.

‎packages/app-shell/src/views/CreateViewDialog.tsx‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,21 @@ function buildViewTypeMeta(t: (k: string) => string): ViewTypeMeta[] {
150150
.map(([type, row]) => ({ type, ...row }));
151151
}
152152

153+
/**
154+
* The view types this dialog offers, in grid order: the same rows the picker
155+
* renders, read from {@link buildViewTypeMeta} with an identity translator.
156+
*
157+
* Pure, so a test can enumerate it at collection time without rendering.
158+
* `CreateViewDialog.viewTypeParse-11581.test.tsx` drives its per-type parse
159+
* pin from this list, so a type added to the picker without a parse row there
160+
* turns that pin red (objectui#11581).
161+
*
162+
* Exported for that pin, not from the package index. @internal
163+
*/
164+
export function offeredViewTypes(): ListViewVisualization[] {
165+
return buildViewTypeMeta((k) => k).map((m) => m.type);
166+
}
167+
153168
/** Suggest a non-colliding default name like "Grid 1", "Grid 2", … */
154169
function suggestName(typeLabel: string, existing: Set<string>): string {
155170
for (let i = 1; i < 1000; i++) {
@@ -168,11 +183,13 @@ function suggestName(typeLabel: string, existing: Set<string>): string {
168183
// endDateField, gallery.coverField, map.latitudeField + longitudeField,
169184
// chart.chartType + dataset + values (+ dimensions), tree.parentField). The
170185
// blocks are meant to be the spec ListView blocks of those names. What
171-
// re-derives that is a parse against `ListViewSchema`, and only `chart` has
172-
// one: `CreateViewDialog.chartBinding-11576.test.tsx` (the "Save as view"
173-
// door) and `ObjectView.createChartView-11576.test.tsx` (the add-view door).
174-
// No test drives the other types' payloads through this dialog against the
175-
// spec (stated per AGENTS.md #9, not implied).
186+
// re-derives that is a parse against `ListViewSchema`:
187+
// `CreateViewDialog.viewTypeParse-11581.test.tsx` submits this dialog once per
188+
// type `offeredViewTypes()` lists and parses the body BOTH persisting doors
189+
// build from that payload ("Save as view" and the add-view door, which share
190+
// one builder, `buildNewViewSpec`). `chart` is also pinned on its own, through
191+
// each door's page: `CreateViewDialog.chartBinding-11576.test.tsx` and
192+
// `ObjectView.createChartView-11576.test.tsx`.
176193
//
177194
// `chart` was the one block that had drifted: it wrote `xAxisField` /
178195
// `yAxisFields`, the pre-ADR-0021 inline axes the spec's strict

‎packages/app-shell/src/views/CreateViewDialog.viewTypeParse-11581.test.tsx‎

Lines changed: 349 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/app-shell/src/views/ObjectDataPage.tsx‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import { PageHeader } from '../layout/PageHeader.js';
6565
import { getIcon } from '../utils/getIcon.js';
6666
import { useMetadataClient } from './metadata-admin/useMetadata.js';
6767
import { createRuntimeMetadata, viewEnvelope } from './runtime-metadata-persistence.js';
68+
import { buildNewViewSpec } from './newViewSpec.js';
6869
import { CreateViewDialog } from './CreateViewDialog.js';
6970
import {
7071
usePreviewDrafts,
@@ -168,22 +169,24 @@ function foldUrlFilterTriplesToSpecRules(triples: FilterTriple[]): ViewFilterRul
168169
* subConfig }`); `fallbackColumns` is this page's auto-derived, field-security
169170
* trimmed column list, used only when the dialog carried none.
170171
*
171-
* Exported for `ObjectDataPage.saveAsViewFilterFold.test.ts`. @internal
172+
* This door resolves only its own inputs (those columns, and the URL
173+
* conditions folded to spec rules); the spec itself, with every type-specific
174+
* rule, is built by `buildNewViewSpec`, the one builder this door shares with
175+
* the add-view door (objectui#11581). An all-dropped fold writes no `filter`
176+
* key at all, byte-identical to a save with no drill conditions active.
177+
*
178+
* Exported for `ObjectDataPage.saveAsViewFilterFold.test.ts` and
179+
* `CreateViewDialog.viewTypeParse-11581.test.tsx`. @internal
172180
*/
173181
export function buildSaveAsViewSpec(
174182
config: Record<string, any>,
175183
fallbackColumns: string[],
176184
urlFilters: FilterTriple[],
177185
): Record<string, any> {
178-
const filterRules = foldUrlFilterTriplesToSpecRules(urlFilters);
179-
return {
180-
...config,
181-
columns:
182-
Array.isArray(config.columns) && config.columns.length > 0 ? config.columns : fallbackColumns,
183-
// An all-dropped fold writes no `filter` key at all, byte-identical to a
184-
// save with no drill conditions active.
185-
...(filterRules.length ? { filter: filterRules } : {}),
186-
};
186+
return buildNewViewSpec(config, {
187+
fallbackColumns,
188+
filter: foldUrlFilterTriplesToSpecRules(urlFilters),
189+
});
187190
}
188191

189192
export function ObjectDataPage({ dataSource, objects }: any) {
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#11581, the add-view door through its page: `ObjectView`'s
5+
* `handleViewCreate`, reached from the view tab bar's add button, persists the
6+
* spec the one builder (`buildNewViewSpec`, via `buildAddViewSpec`) builds,
7+
* with the columns mirrored into the kanban block the spec requires them in.
8+
*
9+
* The per-type enumeration over both doors' builders, and "Save as view"
10+
* through its page, are in `CreateViewDialog.viewTypeParse-11581.test.tsx`.
11+
* This file proves the add-view door's callback goes through that builder: the
12+
* real `ObjectView` and the real dialog run, and only the metadata client is a
13+
* stub, so the body asserted is the one `createRuntimeMetadata` hands
14+
* `metadataClient.save`.
15+
*
16+
* Direction, written before the run: GREEN on the tree before the builder too
17+
* (this door always mirrored `kanban.columns`); RED when the kanban mirror is
18+
* removed from `buildNewViewSpec`.
19+
*/
20+
21+
import * as React from 'react';
22+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
23+
import { render, cleanup, act, screen, fireEvent, waitFor } from '@testing-library/react';
24+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
25+
import { ListViewSchema, ViewItemSchema } from '@objectstack/spec/ui';
26+
27+
vi.mock('@object-ui/permissions', async (importOriginal) => {
28+
const actual = await importOriginal<typeof import('@object-ui/permissions')>();
29+
// Stable identities: `ListView` names `perms` in its fetch dependencies.
30+
const perms = {
31+
check: () => ({ allowed: true }),
32+
checkField: () => true,
33+
getFieldPermissions: () => [],
34+
getRowFilter: () => undefined,
35+
getObjectApiOperations: () => undefined,
36+
roles: [],
37+
isLoaded: false,
38+
hasCapabilities: () => true,
39+
can: () => true,
40+
cannot: () => false,
41+
};
42+
const fieldPerms = { canRead: () => true, canWrite: () => true, permissions: [] };
43+
return { ...actual, usePermissions: () => perms, useFieldPermissions: () => fieldPerms };
44+
});
45+
46+
vi.mock('@object-ui/auth', async (importOriginal) => ({
47+
...(await importOriginal<Record<string, unknown>>()),
48+
useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }),
49+
// The add-view button and the dialog are admin-only.
50+
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
51+
createAuthenticatedFetch: () => vi.fn(),
52+
}));
53+
54+
vi.mock('@object-ui/collaboration', async (importOriginal) => ({
55+
...(await importOriginal<Record<string, unknown>>()),
56+
useRealtimeSubscription: () => ({ lastMessage: null }),
57+
useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }),
58+
}));
59+
60+
vi.mock('sonner', () => ({
61+
toast: Object.assign(vi.fn(), {
62+
success: vi.fn(), error: vi.fn(), info: vi.fn(),
63+
warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(),
64+
}),
65+
}));
66+
67+
vi.mock('./MetadataInspector', () => ({
68+
MetadataPanel: () => null,
69+
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
70+
}));
71+
vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null }));
72+
73+
// The metadata seam's client: `createRuntimeMetadata` writes through `save`;
74+
// the open dialog lists an empty dataset catalog through `list` / `get`.
75+
const metadataClient = {
76+
save: vi.fn(async () => ({})),
77+
get: vi.fn(async () => null),
78+
list: vi.fn(async () => []),
79+
};
80+
vi.mock('./metadata-admin/useMetadata', async (importOriginal) => ({
81+
...(await importOriginal<Record<string, unknown>>()),
82+
useMetadataClient: () => metadataClient,
83+
}));
84+
85+
import { ObjectView, defaultListColumnsFromObject } from './ObjectView';
86+
import { ExpressionProvider } from '../providers/ExpressionProvider';
87+
88+
const OBJECT_NAME = 'crm_deal';
89+
const DEAL = {
90+
name: OBJECT_NAME,
91+
label: 'Deal',
92+
fields: {
93+
name: { type: 'text', label: 'Name' },
94+
stage: { type: 'select', label: 'Stage', options: [{ value: 'open', label: 'Open' }] },
95+
amount: { type: 'number', label: 'Amount' },
96+
},
97+
listViews: { [`${OBJECT_NAME}.all`]: { name: `${OBJECT_NAME}.all`, label: 'All', type: 'grid', columns: ['name'] } },
98+
};
99+
100+
function makeDataSource() {
101+
return {
102+
find: vi.fn(async () => ({ data: [], total: 0 })),
103+
findOne: vi.fn(async () => null),
104+
create: vi.fn(async () => ({})),
105+
update: vi.fn(async () => ({})),
106+
delete: vi.fn(async () => ({})),
107+
getObjectSchema: vi.fn(async () => ({ name: OBJECT_NAME, fields: {} })),
108+
} as any;
109+
}
110+
111+
const settle = () => act(() => new Promise<void>((resolve) => setTimeout(resolve, 300)));
112+
const select = (key: string) => screen.getByTestId(`create-view-required-${key}`) as HTMLSelectElement;
113+
114+
beforeEach(() => {
115+
cleanup();
116+
vi.stubGlobal(
117+
'fetch',
118+
vi.fn(async () =>
119+
new Response(JSON.stringify({ data: [] }), {
120+
status: 200,
121+
headers: { 'content-type': 'application/json' },
122+
}),
123+
),
124+
);
125+
});
126+
127+
afterEach(() => {
128+
vi.unstubAllGlobals();
129+
vi.clearAllMocks();
130+
});
131+
132+
describe('ObjectView.handleViewCreate persists a kanban through the one builder (objectui#11581)', () => {
133+
it('the saved config passes the spec ListViewSchema, the envelope the ViewItem gate, with the columns mirrored into kanban.columns', async () => {
134+
render(
135+
<ExpressionProvider user={{ id: 'u1', name: 'Ada', profile: 'admin' }}>
136+
<MemoryRouter initialEntries={[`/apps/demo/${OBJECT_NAME}`]}>
137+
<Routes>
138+
<Route
139+
path="/apps/:appName/:objectName/*"
140+
element={<ObjectView dataSource={makeDataSource()} objects={[DEAL]} onEdit={() => {}} />}
141+
/>
142+
</Routes>
143+
</MemoryRouter>
144+
</ExpressionProvider>,
145+
);
146+
await settle();
147+
fireEvent.click(screen.getByTestId('view-tab-add'));
148+
const card = (await screen.findByTestId('create-view-type-kanban')) as HTMLButtonElement;
149+
await waitFor(() => expect(card.disabled).toBe(false));
150+
fireEvent.click(card);
151+
// `stage` is the object's one select field, so the dialog picks it.
152+
await waitFor(() => expect(select('groupByField').value).toBe('stage'));
153+
await act(async () => {
154+
fireEvent.click(screen.getByTestId('create-view-submit'));
155+
});
156+
await waitFor(() => expect(metadataClient.save).toHaveBeenCalledTimes(1));
157+
const [type, , body, opts] = metadataClient.save.mock.calls[0] as unknown as [string, string, any, any];
158+
expect(type).toBe('view');
159+
expect(opts).toEqual({ mode: 'draft' });
160+
// The spec's verdict first: it is the claim this pin exists for.
161+
const listView = ListViewSchema.safeParse(body.config);
162+
expect(listView.success, JSON.stringify(listView.error?.issues)).toBe(true);
163+
const item = ViewItemSchema.safeParse(body);
164+
expect(item.success, JSON.stringify(item.error?.issues)).toBe(true);
165+
const columns = defaultListColumnsFromObject(DEAL, 5);
166+
expect(columns.length).toBeGreaterThan(0);
167+
expect(body.config.columns).toEqual(columns);
168+
expect(body.config.kanban).toEqual({ groupByField: 'stage', columns });
169+
});
170+
});

‎packages/app-shell/src/views/ObjectView.tsx‎

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js';
4949
import { ViewConfigPanel } from './ViewConfigPanel.js';
5050
import { useMetadataClient } from './metadata-admin/useMetadata.js';
5151
import { persistRuntimeMetadata, createRuntimeMetadata, viewEnvelope, type ViewEnvelope } from './runtime-metadata-persistence.js';
52+
import { buildNewViewSpec } from './newViewSpec.js';
5253
import { ListViewSchema as SpecListViewSchema, normalizeFilterOperator, VIEW_CONSOLE_ROUND_TRIP_KEYS } from '@objectstack/spec/ui';
5354
import { CreateViewDialog } from './CreateViewDialog.js';
5455
import {
@@ -648,6 +649,25 @@ export function defaultListColumnsFromObject(
648649
return [];
649650
}
650651

652+
/**
653+
* Assemble the list-view `spec` the add-view door (`handleViewCreate`: the
654+
* Create View dialog from the view tab bar, and the view-config panel's create
655+
* mode) hands to `viewEnvelope`.
656+
*
657+
* This door resolves only its own fallback columns: the object's default
658+
* business columns (`defaultListColumnsFromObject` keeps the framework-injected
659+
* `owner_id` / audit columns out of the lead, #2702 / #2777, so a new view
660+
* never opens on a raw id). The spec itself, with every type-specific rule
661+
* (the columns mirrored into `kanban.columns` and `gallery.visibleFields`), is
662+
* built by `buildNewViewSpec`, the one builder this door shares with "Save as
663+
* view" (objectui#11581).
664+
*
665+
* Exported for `CreateViewDialog.viewTypeParse-11581.test.tsx`. @internal
666+
*/
667+
export function buildAddViewSpec(config: Record<string, any>, objectDef: any): Record<string, any> {
668+
return buildNewViewSpec(config, { fallbackColumns: defaultListColumnsFromObject(objectDef, 5) });
669+
}
670+
651671
/**
652672
* Read the persisted per-view overrides (density, column widths, sort, hidden
653673
* columns, inlineEdit …) for `ids`, preferring the adapter's one-request batch
@@ -1523,39 +1543,19 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
15231543
try {
15241544
let createdId: string | undefined;
15251545
if (metadataClient) {
1526-
// Prefill sensible defaults so the saved view renders rows
1527-
// immediately even if the user didn't pick columns yet.
1528-
const objectDef = objects?.find?.((o: any) => o.name === objectName);
1529-
// Prefill business columns only — the shared helper keeps the
1530-
// framework-injected `owner_id` / audit columns out of the lead
1531-
// (#2702, #2777), so a newly created view never opens on a raw id.
1532-
const defaultColumns = defaultListColumnsFromObject(objectDef, 5);
1533-
const incomingColumns = Array.isArray(config.columns) && config.columns.length > 0
1534-
? config.columns
1535-
: defaultColumns;
15361546
// ADR-0005 overlay path — write the full spec under a unique
15371547
// `name` via the metadata customization API instead of into
15381548
// the physical `sys_view` table (whose columns no longer
15391549
// accommodate the spec shape: arrays, nested objects, etc.).
1540-
const spec: Record<string, any> = { ...config, columns: incomingColumns };
1541-
// Per @objectstack/spec, certain view types nest their card/field
1542-
// list inside their type-specific subconfig (e.g. kanban.columns,
1543-
// gallery.visibleFields). The CreateViewDialog only collects
1544-
// required *picker* fields; we mirror the resolved column list
1545-
// into the subconfig here so the spec validator accepts the row.
1546-
if (config.type === 'kanban') {
1547-
spec.kanban = { ...(spec.kanban || {}), columns: incomingColumns };
1548-
} else if (config.type === 'gallery') {
1549-
const existing = spec.gallery || {};
1550-
if (!Array.isArray(existing.visibleFields) || existing.visibleFields.length === 0) {
1551-
spec.gallery = { ...existing, visibleFields: incomingColumns };
1552-
}
1553-
}
1550+
// The spec comes from the builder "Save as view" also uses
1551+
// (`buildAddViewSpec` → `buildNewViewSpec`, objectui#11581):
1552+
// this door supplies only its default business columns.
1553+
const objectDef = objects?.find?.((o: any) => o.name === objectName);
1554+
const spec = buildAddViewSpec(config, objectDef);
15541555
// ADR-0034: a new view is created as an invisible per-item
15551556
// draft via the metadata seam; an explicit Publish promotes it.
1556-
// UI-layer concerns (default columns, kanban/gallery massaging
1557-
// above, and the auto-activation below) stay here; the canonical
1558-
// ViewItem envelope + qualified-name identity live in the seam.
1557+
// The auto-activation below stays here; the canonical ViewItem
1558+
// envelope + qualified-name identity live in the seam.
15591559
//
15601560
// #2767 P1: the qualified name `<object>.<key>` is used as BOTH
15611561
// the URL segment and `body.name`, so the sys_metadata row key,

0 commit comments

Comments
 (0)