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
10 changes: 10 additions & 0 deletions .changeset/30317-draft-only-object-view-seed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/app-shell': patch
---

fix(studio): on a draft-only object, a new or first-edited list view starts from the object's first five fields

Clause-②: no
Metadata-protocol: none

In Studio Interfaces, a list view created on an object that so far exists only as a draft in the package started with no columns. The first edit of a default view that did not exist yet wrote only the column the author touched, and New view with no columns picked wrote none. The list-view panel seeded those columns from the shell's merged objects, which hold published objects only. When the merged definition is missing, the panel now reads the object's drafted definition from this package (`GET /meta/object/:name?preview=draft&package=`, the read the field picker already makes) and seeds from it, so both start from the same first five fields a published object gets. A published object reads nothing new. If the drafted read fails or finds nothing, the seed stays empty as before.
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#30317 — on a draft-only object, a list view created in Studio
* Interfaces starts from the object's first five fields, as one of a
* published object does.
*
* Measured on #30177's dev boot: the first edit of `acmetrial_deal.default`
* (a draft-only object, no view yet) wrote `columns: [amount]`, the one
* column the author touched, and New view fell back to the same empty seed.
* The shell's merged `metadata.objects` holds published objects only, so the
* hook found no definition to seed from. It now reads the object's drafted
* definition through the same client with the drafts overlaid when the merged
* one is missing.
*
* The REAL hook over a REAL `MetadataClient`, whose transport is an in-memory
* server answering as the framework does (`GET /meta/object/<name>?preview=draft`
* serves the pending draft in its envelope; 404 for a name with neither).
*
* Pinned:
* - a draft-only object: the seed is its drafted definition's first five
* fields, the first edit of the default view writes them plus the edit,
* and New view with no columns picked writes them too;
* - CONTROL: a published object seeds from the merged definition, and no
* object read is sent for it;
* - CONTROL: an object neither published nor drafted (404) seeds nothing and
* the panel does not fail on it.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, cleanup, render } from '@testing-library/react';
import { MetadataCtx, type MetadataContextValue } from '@object-ui/react';
import { MetadataClient } from '@object-ui/data-objectstack';
import { newListViewItem, useObjectListViewDraft, type DraftOnlyObjects, type ObjectListViewDraft } from './ObjectListViewInspector';
import type { Surface } from './navSurface';

const PKG = 'com.example.acme-trial';
const DEAL = 'acmetrial_deal';
const CASE = 'acmetrial_case';
const GHOST = 'acmetrial_ghost';

const dealDraft = {
name: DEAL,
label: 'Deal',
fields: {
internal_score: { name: 'internal_score', type: 'number', hidden: true },
name: { name: 'name', type: 'text' },
amount: { name: 'amount', type: 'currency' },
stage: { name: 'stage', type: 'select' },
close_date: { name: 'close_date', type: 'date' },
probability: { name: 'probability', type: 'percent' },
notes: { name: 'notes', type: 'textarea' },
},
};
const DEAL_SEED = ['name', 'amount', 'stage', 'close_date', 'probability'];

const caseDef = {
name: CASE,
label: 'Case',
fields: { subject: { name: 'subject', type: 'text' }, priority: { name: 'priority', type: 'select' } },
};

const server = {
objectDrafts: new Map<string, Record<string, unknown>>(),
requests: [] as Array<{ method: string; path: string; search: string; status: number }>,
writes: [] as Array<{ path: string; body: Record<string, unknown> }>,
};

const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });

async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const method = (init?.method ?? 'GET').toUpperCase();
const url = new URL(String(input), 'http://studio.test');
let status = 200;
let body: unknown = [];
const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/);
if (meta) {
const seg = meta[1].split('/').map(decodeURIComponent);
if (method === 'PUT' && seg.length === 2) {
server.writes.push({ path: `${seg[0]}/${seg[1]}`, body: JSON.parse(String(init?.body ?? '{}')) });
body = { success: true, version: 'v2', state: 'draft' };
} else if (seg.length === 1) {
// `_drafts`, the published lists and the draft-overlaid lists: nothing pending, nothing published.
body = [];
} else if (seg.length === 2 && seg[0] === 'object') {
const draft = url.searchParams.get('preview') === 'draft' ? server.objectDrafts.get(seg[1]) : undefined;
if (draft) body = { type: 'object', name: seg[1], item: { ...draft, _draft: true } };
else [status, body] = [404, { error: { code: 'NOT_FOUND', message: 'absent' } }];
} else if (seg.length === 2) {
[status, body] = [404, { error: { code: 'NO_DRAFT', message: 'absent' } }];
}
}
server.requests.push({ method, path: url.pathname, search: url.search, status });
return json(status, body);
}

const client = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch });

// The shell's metadata cache holds published objects only: the draft-only one is absent.
const METADATA: MetadataContextValue = {
apps: [],
objects: [caseDef],
dashboards: [],
reports: [],
pages: [],
loading: false,
error: null,
refresh: async () => {},
invalidate: () => {},
ensureType: async () => [],
getItem: async () => null,
getItemsByType: () => [],
};

const known: DraftOnlyObjects = { publishNonce: 0, names: new Set([DEAL, GHOST]) };

let latest: ObjectListViewDraft | null = null;
function HookProbe({ object }: { object: string }) {
const leaf: Surface = { type: 'object', name: object, label: object };
const d = useObjectListViewDraft({ client, packageId: PKG, leaf, publishNonce: 0, draftOnlyObjects: known });
React.useLayoutEffect(() => {
latest = d;
});
return null;
}

const settle = () =>
act(async () => {
for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0));
});

async function mount(object: string) {
render(
<MetadataCtx.Provider value={METADATA}>
<HookProbe object={object} />
</MetadataCtx.Provider>,
);
await settle();
return () => latest!;
}

/** The panel's first edit, as the panel makes it: one column added to the view it shows. */
async function firstEdit(d: () => ObjectListViewDraft, column: string) {
const { target, row, seedColumns } = d();
const working = row ?? newListViewItem(target!, seedColumns);
const config = working.config as Record<string, unknown>;
await act(async () => {
d().edit({ ...working, config: { ...config, columns: [...(config.columns as unknown[]), column] } });
});
await act(async () => {
await d().save({ unmoved: () => true });
});
await settle();
}

const objectReads = (name: string) =>
server.requests.filter((r) => r.method === 'GET' && r.path === `/api/v1/meta/object/${name}`);

beforeEach(() => {
server.objectDrafts.clear();
server.requests = [];
server.writes = [];
latest = null;
});
afterEach(cleanup);

describe('useObjectListViewDraft — a draft-only object seeds a new view from its drafted definition (objectstack#30317)', () => {
it('the first edit of the default view writes the drafted object\'s first five fields, then the edit', async () => {
server.objectDrafts.set(DEAL, dealDraft);
const d = await mount(DEAL);

expect(d().target).toEqual({ objectName: DEAL, viewId: `${DEAL}.default`, isDefault: true });
expect(d().seedColumns).toEqual(DEAL_SEED);
expect(d().canvas?.view).toMatchObject({ columns: DEAL_SEED });

await firstEdit(d, 'notes');
expect(server.writes.map((w) => w.path)).toEqual([`view/${DEAL}.default`]);
expect((server.writes[0]!.body.config as Record<string, unknown>).columns).toEqual([...DEAL_SEED, 'notes']);
});

it('New view with no columns picked writes the drafted object\'s first five fields', async () => {
server.objectDrafts.set(DEAL, dealDraft);
const d = await mount(DEAL);

let refusal: unknown = 'pending';
await act(async () => {
refusal = await d().createView({ type: 'grid', label: 'Open deals', name: `${DEAL}.open_deals` });
});
expect(refusal).toBeNull();
expect(server.writes.map((w) => w.path)).toEqual([`view/${DEAL}.open_deals`]);
expect((server.writes[0]!.body.config as Record<string, unknown>).columns).toEqual(DEAL_SEED);
});

it('CONTROL: a published object seeds from the merged definition, and no object read is sent for it', async () => {
const d = await mount(CASE);

expect(d().seedColumns).toEqual(['subject', 'priority']);
expect(objectReads(CASE)).toEqual([]);
});

it('CONTROL: an object with neither a draft nor a published row seeds nothing, and the panel does not fail', async () => {
const d = await mount(GHOST);

expect(objectReads(GHOST).map((r) => r.status)).toEqual([404]);
expect(d().seedColumns).toEqual([]);
expect(d().failure).toBeNull();
expect(d().loadedFor).toBe(`listView:${GHOST}.default`);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
* packageId })`) under its own version guard (objectui#11773), debounced by the
* pillar's autosave like every other Studio editor. A view that does not exist
* yet is created by the first edit, seeded with the columns the running app
* shows an object that has no view (`defaultListColumnsFromObject`).
* shows an object that has no view (`defaultListColumnsFromObject`); for a
* draft-only object, from this package's drafted definition (objectstack#30317).
*
* ## Switching views, and adding one
*
Expand Down Expand Up @@ -561,7 +562,34 @@ export function useObjectListViewDraft({
objectName && !holding ? listViewChoicesOf(objectName, publishedViews, draftViewRows, entryTarget, target) : [];
const viewId = target?.viewId ?? '';
const targetKey = viewId ? listViewBufferKey(viewId) : '';
const seedColumns = React.useMemo(() => defaultListColumnsFromObject(objectDef, 5), [objectDef]);
// objectstack#30317 — a draft-only object is absent from the merged
// metadata, so the columns a view created here starts with are read off
// this package's drafted definition, read with the drafts overlaid (the
// read `useObjectFields` makes for the panel's field picker). Only when the
// merged definition is missing; a failed or empty answer seeds nothing.
const [draftedDef, setDraftedDef] = React.useState<{ for: string; def: Row | null }>({ for: '', def: null });
const needsDraftedDef = !!objectName && !objectDef;
React.useEffect(() => {
if (!objectName || !needsDraftedDef) return;
let cancelled = false;
const forKey = JSON.stringify([packageId, objectName]);
client
.withPreviewDrafts(true)
.get<Row>('object', objectName, { packageId })
.then(
(def) => {
if (!cancelled) setDraftedDef({ for: forKey, def: isPlainObject(def) ? def : null });
},
() => {
if (!cancelled) setDraftedDef({ for: forKey, def: null });
},
);
return () => {
cancelled = true;
};
}, [client, packageId, objectName, needsDraftedDef, publishNonce]);
const seedDef = objectDef ?? (needsDraftedDef && draftedDef.for === viewDraftsKey ? draftedDef.def : undefined);
const seedColumns = React.useMemo(() => defaultListColumnsFromObject(seedDef, 5), [seedDef]);

const [buffer, setBuffer] = React.useState<{
for: string;
Expand Down
Loading