Skip to content
8 changes: 8 additions & 0 deletions .changeset/12725-draft-marks-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@object-ui/app-shell': patch
---

Studio's copilot draft marks, the remainder (objectui#12725):

- **The Access pillar's permission edits are recorded as `studio`.** The permission matrix's draft save sends `surface: 'studio'` when Studio's Access pillar hosts it. The same editor under metadata-admin (environment scope) states no surface.
- **The copilot's element marks sit inside the widget rows.** In an `object` leaf's list-view properties, a card field (`kanban.columns`) or a column (`columns`) the AI copilot added carries the Draft chip in its own row of the card-field picker or the column list, instead of in a list under the widget. A copilot change to `columns` that added no column keeps the chip beside the setting.
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,9 @@ describe('PermissionMatrixEditPage — the package door\'s SAVE reads a /layers
await waitFor(() => expect(server.puts).toHaveLength(1), AUTOSAVE);

expect(layersStatuses()).toEqual([404]);
expect(server.puts[0].search).toBe(`?mode=draft&package=${PKG}`);
// objectui#12725 — mounted as Studio hosts it (`packageId` + `embedded`),
// so the draft save also names its authoring surface.
expect(server.puts[0].search).toBe(`?mode=draft&package=${PKG}&surface=studio`);
expect(server.puts[0].body.objects).toEqual({ a_account: { allowRead: true, allowCreate: true } });
expect(screen.queryByText(REFUSAL)).toBeNull();
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#12725 (the remainder, item 1) — the permission matrix names its
* authoring surface only under the Studio host.
*
* `PermissionMatrixEditPage` serves two hosts: Studio's Access pillar mounts it
* with `packageId` and `embedded`, and its draft save is a person's edit in
* Studio, so it sends `?surface=studio` (recorded as `by: 'studio'` in the
* draft's `draftChanges`, objectstack#24395). metadata-admin mounts it through
* the resource registry with only `type` and `name`: its save is the
* environment door's live write and states no surface.
*
* Both hosts are the REAL ones (`AccessPillar`, `MetadataResourceEditPage`
* through the built-in registration), and the save goes through a REAL
* `MetadataClient` whose transport records each `PUT`, so the pins read the
* query string the server would read.
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MetadataClient } from '@object-ui/data-objectstack';

const PKG = 'com.acme.crm';

const SET = {
name: 'sales_perms',
label: 'Sales',
objects: { a_account: { allowRead: true, allowCreate: true } },
fields: {},
};

const puts: URLSearchParams[] = [];

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

/** The save's transport: records each PUT's query and answers as the door does. */
async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const url = new URL(String(input), 'http://console.test');
if ((init?.method ?? 'GET').toUpperCase() === 'PUT') {
puts.push(url.searchParams);
return json(200, { success: true, version: 'v2', state: url.searchParams.get('mode') ?? 'active' });
}
return json(404, { error: { code: 'NOT_FOUND', message: 'absent' } });
}

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

/** The reads both hosts make, answered in memory; the save is the real client's. */
const client = {
list: async (type: string) => {
if (type === 'permission') return [{ name: SET.name, label: SET.label }];
if (type === 'object') return [{ name: 'a_account', label: 'Account' }];
return [];
},
listDrafts: async () => [],
layered: async () => ({ effective: SET, code: null, overlay: null, overlayScope: null }),
getDraft: async () => null,
get: async () => null,
save: (...args: Parameters<MetadataClient['save']>) => wire.save(...args),
};

vi.mock('./useMetadata', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
useMetadataClient: () => client,
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }],
}),
};
});
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));
vi.mock('./AccessExplainPanel', () => ({ AccessExplainPanel: () => null }));
vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null }));
vi.mock('../studio-design/StudioAiCopilot', () => ({ StudioChatDock: () => null }));

import '../../services/builtinComponents.js';
import { MetadataResourceEditPage } from './ResourceEditPage';
import { AccessPillar } from '../studio-design/StudioDesignSurface';

window.matchMedia = ((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;

/** The shared autosave fires 1.5s after the last edit. */
const AUTOSAVE = { timeout: 4000 };

const readBox = () => screen.getByRole('checkbox', { name: 'a_account Read' });

beforeEach(() => {
puts.length = 0;
window.confirm = vi.fn() as unknown as typeof window.confirm;
});
afterEach(cleanup);

describe('the permission matrix states `surface` only under the Studio host (objectui#12725)', () => {
it('Studio’s Access pillar: the draft save sends `?surface=studio`, once', async () => {
render(
<MemoryRouter>
<AccessPillar packageId={PKG} />
</MemoryRouter>,
);
await waitFor(() => expect(readBox()).toBeInTheDocument(), AUTOSAVE);

fireEvent.click(readBox());
await waitFor(() => expect(puts).toHaveLength(1), AUTOSAVE);
expect(puts[0]!.get('mode')).toBe('draft');
expect(puts[0]!.get('package')).toBe(PKG);
expect(puts[0]!.getAll('surface')).toEqual(['studio']);
});

it('metadata-admin: the environment door’s save sends no `surface`', async () => {
render(
<MemoryRouter>
<MetadataResourceEditPage type="permission" name={SET.name} />
</MemoryRouter>,
);
await waitFor(() => expect(readBox()).toBeInTheDocument(), AUTOSAVE);

fireEvent.click(readBox());
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
// CONTROL: the edit reached the wire, through the same client and transport.
await waitFor(() => expect(puts).toHaveLength(1));
expect(puts[0]!.get('mode')).toBeNull();
expect(puts[0]!.getAll('surface')).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -1024,9 +1024,14 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
// Environment door (no packageId) stays live (config).
// objectui#11773 — the guard pins the package door's DRAFT save; the
// environment door's live write passes through it unpinned, as before.
// objectui#12725 — the draft names its authoring surface only under the
// Studio host (`embedded`, the Access pillar), so a person's edit there
// is recorded `studio`, not `api`. metadata-admin mounts this page with
// neither `packageId` nor `embedded`, and states no surface.
const outcome = await saveVersioned(type, payload.name, toSave, {
force,
...(packageId ? { mode: 'draft' as const, packageId } : {}),
...(packageId && embedded ? { surface: 'studio' as const } : {}),
});
// The author chose the saved version; the load replaces the matrix.
if (outcome === 'reloaded') return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ export interface FieldsListEditorProps {
onSelectionChange?: (
sel: { kind: string; id: string; label?: string } | null,
) => void;
/**
* objectui#12725 — the field names of the columns the AI copilot added in the
* pending draft: each such row carries the copilot's draft chip. Absent, no
* row is marked.
*/
copilotAdded?: ReadonlySet<string>;
}

export function FieldsListEditor({
Expand All @@ -78,6 +84,7 @@ export function FieldsListEditor({
readOnly,
onPatch,
onSelectionChange,
copilotAdded,
}: FieldsListEditorProps) {
const canEdit = !readOnly;
// The designer locale, read the way this editor's add-field popover
Expand Down Expand Up @@ -204,6 +211,7 @@ export function FieldsListEditor({
index={i}
label={rowLabelOf(c, i)}
fieldName={colFieldName(c)}
copilotAdded={copilotAdded?.has(colFieldName(c) ?? '')}
fieldType={fieldTypeByName.get(colFieldName(c) ?? '') ?? 'text'}
selected={selectedIndex === i}
canEdit={canEdit}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import { resolveFieldTypeMeta, resolveCategoryTone } from './field-types.js';
import type { ObjectFieldInfo } from './useObjectFields.js';
import { useMetadataLocale, t, tFormat } from '../i18n.js';
import { DraftChip } from '../../studio-design/StudioDraftMark.js';

const DND_MIME = 'text/x-objectui-viewcol';

Expand Down Expand Up @@ -167,6 +168,7 @@ export function FieldListRow({
onDragEnd,
onDragOverRow,
onDropRow,
copilotAdded,
}: {
index: number;
label: string;
Expand All @@ -182,6 +184,11 @@ export function FieldListRow({
onDragEnd: () => void;
onDragOverRow: () => void;
onDropRow: () => void;
/**
* objectui#12725 — the AI copilot added this column in the pending draft:
* the row carries the copilot's draft chip. Absent, the row is unchanged.
*/
copilotAdded?: boolean;
}) {
// The designer locale, read as this file's `AddFieldPopover` reads it
// (objectui#10862): both hosts (`FieldsListEditor`, `ReportDefaultInspector`)
Expand Down Expand Up @@ -243,6 +250,9 @@ export function FieldListRow({
>
{label}
</span>
{copilotAdded && (
<DraftChip locale={locale} testId="studio-draft-element-chip" of={fieldName} />
)}
{fieldName && fieldName !== label && (
<code className="max-w-[7rem] shrink-0 truncate text-[10px] text-muted-foreground">
{fieldName}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#12725 (the remainder, item 3) — the AI copilot's mark on an array
* element it added is drawn INSIDE that element's row, by the two widgets
* Studio's list-view properties draw arrays with:
*
* - `WIDGETS['field-multi']` (a kanban card's fields, `kanban.columns`);
* - `FieldsListEditor` (a view's `columns`), whose rows are `FieldListRow`.
*
* Each takes the marked set through the optional `copilotAdded` prop. Pinned:
* - a marked element's row holds the draft chip; a published (unmarked) row
* holds none;
* - with no `copilotAdded`, the widget draws exactly what it draws when the
* set marks nothing it holds: the prop's absence changes nothing.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, within } from '@testing-library/react';

// `useObjectFields` short-circuits the fetch when handed a catalog, but still
// constructs the shared metadata client: stubbed so no test reaches the network
// (the mechanism `FieldsListEditor.fieldLabels-12693.test.tsx` uses).
const state = vi.hoisted(() => ({
metadataClient: { get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]) },
}));
vi.mock('../useMetadata', () => ({
useMetadataClient: () => state.metadataClient,
}));

import { FieldsListEditor } from './FieldsListEditor';
import { WIDGETS } from '../widgets';
import { loaded } from '../loadState';

afterEach(cleanup);

const CATALOG = [
{ name: 'book', label: 'Book', type: 'text', hidden: false },
{ name: 'reader', label: 'Reader', type: 'text', hidden: false },
{ name: 'due_date', label: 'Due date', type: 'date', hidden: false },
];

const FieldMulti = WIDGETS['field-multi'];

function mountFieldMulti(copilotAdded?: ReadonlySet<string>): HTMLElement {
const { container } = render(
<FieldMulti
schema={{}}
value={['book', 'reader', 'due_date']}
onChange={() => {}}
context={{ conditionScope: 'flattened', objectFields: loaded(CATALOG) }}
copilotAdded={copilotAdded}
/>,
);
return container;
}

function mountColumns(copilotAdded?: ReadonlySet<string>): HTMLElement {
const columns = ['book', 'reader', 'due_date'];
const { container } = render(
<FieldsListEditor
variantKey="config"
schema={{ type: 'grid', columns }}
columns={columns}
allStrings
objectName="acme_loan"
objectFieldsOverride={CATALOG}
selectedIndex={null}
onPatch={() => {}}
copilotAdded={copilotAdded}
/>,
);
return container;
}

/** The row drawing the element stored as `name`: the nearest box around its name. */
const rowOf = (container: HTMLElement, name: string): HTMLElement =>
within(container).getByText(name, { selector: 'code' }).closest('div')!;

const markIn = (row: HTMLElement) => within(row).queryByTestId('studio-draft-element-chip');

describe.each([
['field-multi', mountFieldMulti],
['FieldsListEditor', mountColumns],
] as const)('%s — the copilot’s element mark sits in the element’s row (objectui#12725)', (_name, mount) => {
it('marks the rows of the elements the copilot added, and no published row', () => {
const container = mount(new Set(['reader', 'due_date']));
for (const name of ['reader', 'due_date']) {
const mark = markIn(rowOf(container, name));
expect(mark).not.toBeNull();
expect(mark).toHaveAttribute('data-draft-of', name);
}
expect(markIn(rowOf(container, 'book'))).toBeNull();
expect(within(container).getAllByTestId('studio-draft-element-chip')).toHaveLength(2);
});

it('CONTROL: with no `copilotAdded` the widget draws no mark, and the same markup as a set marking nothing it holds', () => {
const absent = mount(undefined).innerHTML;
cleanup();
const unmarked = mount(new Set(['not_in_value'])).innerHTML;
expect(absent).not.toContain('studio-draft-element-chip');
expect(absent).toBe(unmarked);
});
});
13 changes: 12 additions & 1 deletion packages/app-shell/src/views/metadata-admin/widgets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { humanizeKey } from './inspectors/json-schema-to-fields.js';
import { datasetSelectOptions } from './inspectors/dataset-picker-options.js';
import type { DatasetCatalogEntry } from './previews/useDatasetCatalog.js';
import { useMonacoFallback } from './useMonacoFallback.js';
import { DraftChip } from '../studio-design/StudioDraftMark.js';
import {
type LoadState,
isLoading,
Expand Down Expand Up @@ -359,6 +360,13 @@ export interface WidgetProps {
fieldSpec?: FormFieldSpec;
/** All form data (for reading dependency values) */
formData?: Record<string, unknown>;
/**
* objectui#12725 — the elements of this value the AI copilot added in the
* pending draft, by stored value. Read by `field-multi` only, which draws the
* copilot's draft chip inside each such element's row. Absent (every host but
* Studio's list-view properties), nothing is marked.
*/
copilotAdded?: ReadonlySet<string>;
}

export type WidgetRenderer = (props: WidgetProps) => React.ReactElement;
Expand Down Expand Up @@ -2005,7 +2013,7 @@ function ViewRefWidget({ id, ariaLabelledBy, required, value, onChange, readOnly
* `yAxisFields`, `searchableFields`, …). Preserves order; supports reorder
* and removal; values outside the catalog are retained.
*/
function FieldRefMultiWidget({ value, onChange, readOnly, context, ariaLabelledBy }: WidgetProps) {
function FieldRefMultiWidget({ value, onChange, readOnly, context, ariaLabelledBy, copilotAdded }: WidgetProps) {
const locale = useMetadataLocale();
const fieldsState = context?.objectFields ?? NOT_ASKED;
// objectui#5228 — the failure is decided here, before the catalog is read:
Expand Down Expand Up @@ -2058,6 +2066,9 @@ function FieldRefMultiWidget({ value, onChange, readOnly, context, ariaLabelledB
{labelFor(name)}
<code className="ml-2 text-xs text-muted-foreground">{name}</code>
</span>
{copilotAdded?.has(name) && (
<DraftChip locale={locale} testId="studio-draft-element-chip" of={name} />
)}
{!readOnly && (
<>
<button
Expand Down
Loading
Loading