diff --git a/.changeset/12725-draft-marks-rest.md b/.changeset/12725-draft-marks-rest.md new file mode 100644 index 0000000000..6a43bbb74a --- /dev/null +++ b/.changeset/12725-draft-marks-rest.md @@ -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. diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.layersUnsaved-11799.test.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.layersUnsaved-11799.test.tsx index 14c2e373fc..7684f74052 100644 --- a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.layersUnsaved-11799.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.layersUnsaved-11799.test.tsx @@ -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(); }); diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.saveSurface-12725.test.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.saveSurface-12725.test.tsx new file mode 100644 index 0000000000..3aa86c0f46 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.saveSurface-12725.test.tsx @@ -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 { + 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) => wire.save(...args), +}; + +vi.mock('./useMetadata', async (importOriginal) => { + const actual = await importOriginal>(); + 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( + + + , + ); + 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( + + + , + ); + 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([]); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx index da507aee0f..3694a08e82 100644 --- a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx @@ -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; diff --git a/packages/app-shell/src/views/metadata-admin/previews/FieldsListEditor.tsx b/packages/app-shell/src/views/metadata-admin/previews/FieldsListEditor.tsx index 49eddac300..aad3c01734 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/FieldsListEditor.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/FieldsListEditor.tsx @@ -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; } export function FieldsListEditor({ @@ -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 @@ -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} diff --git a/packages/app-shell/src/views/metadata-admin/previews/ViewColumnPanes.tsx b/packages/app-shell/src/views/metadata-admin/previews/ViewColumnPanes.tsx index 13b55ca59e..0f892bab3c 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/ViewColumnPanes.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/ViewColumnPanes.tsx @@ -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'; @@ -167,6 +168,7 @@ export function FieldListRow({ onDragEnd, onDragOverRow, onDropRow, + copilotAdded, }: { index: number; label: string; @@ -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`) @@ -243,6 +250,9 @@ export function FieldListRow({ > {label} + {copilotAdded && ( + + )} {fieldName && fieldName !== label && ( {fieldName} diff --git a/packages/app-shell/src/views/metadata-admin/previews/copilotAddedRowMark-12725.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/copilotAddedRowMark-12725.test.tsx new file mode 100644 index 0000000000..dd66704297 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/copilotAddedRowMark-12725.test.tsx @@ -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): HTMLElement { + const { container } = render( + {}} + context={{ conditionScope: 'flattened', objectFields: loaded(CATALOG) }} + copilotAdded={copilotAdded} + />, + ); + return container; +} + +function mountColumns(copilotAdded?: ReadonlySet): HTMLElement { + const columns = ['book', 'reader', 'due_date']; + const { container } = render( + {}} + 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); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/widgets.tsx b/packages/app-shell/src/views/metadata-admin/widgets.tsx index 43bdc39648..65bf9276b1 100644 --- a/packages/app-shell/src/views/metadata-admin/widgets.tsx +++ b/packages/app-shell/src/views/metadata-admin/widgets.tsx @@ -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, @@ -359,6 +360,13 @@ export interface WidgetProps { fieldSpec?: FormFieldSpec; /** All form data (for reading dependency values) */ formData?: Record; + /** + * 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; } export type WidgetRenderer = (props: WidgetProps) => React.ReactElement; @@ -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: @@ -2058,6 +2066,9 @@ function FieldRefMultiWidget({ value, onChange, readOnly, context, ariaLabelledB {labelFor(name)} {name} + {copilotAdded?.has(name) && ( + + )} {!readOnly && ( <>