diff --git a/.changeset/26846-studio-access-polish.md b/.changeset/26846-studio-access-polish.md new file mode 100644 index 00000000000..0000a94da65 --- /dev/null +++ b/.changeset/26846-studio-access-polish.md @@ -0,0 +1,16 @@ +--- +'@object-ui/i18n': patch +'@object-ui/app-shell': patch +--- + +fix(app-shell): four small fixes to the Studio Access pillar + +Clause-②: no +Metadata-protocol: none + +- **A create that loses the race to a draft says so in plain words.** When a draft permission set with the same identifier appears between Studio's check and its save, the server refuses the save. Studio used to show the server's sentence about the "first-write pin" and `If-None-Match`. It now reads the refusal by its code and says that a draft with that identifier already exists. New key `engine.studio.access.createDraftTaken` in `@object-ui/i18n` (en, zh). +- **The sharing-baseline (OWD) overview drops a refusal once its edits are undone.** After a refused save, putting every row back to its loaded value ("No changes") now removes the refusal. An edit that is still unsaved keeps it. +- **The Access list follows a label change at once.** A draft save of the open permission set re-reads the list, so a renamed set shows its new label without a publish or a reload. +- **The field table under an expanded object stays readable when the matrix scrolls sideways.** The "shown / total" count sits beside the field filter instead of at the far end of the row, and the field-name column stays pinned to the left edge. + +What to do: nothing. Nothing a permission set or object declares changes. diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.fieldTableLayout-26846.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.fieldTableLayout-26846.test.tsx new file mode 100644 index 00000000000..d6751ca30ac --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.fieldTableLayout-26846.test.tsx @@ -0,0 +1,95 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26846 — the field sub-table under an expanded object stays + * readable when the matrix scrolls sideways (its floor is wider than the + * Studio content column at 1440 px): + * + * - the "visible / total" count sits beside the filter it counts, not at the + * far end of the row, where it fell past the visible edge; + * - the field-name column, header and rows, is pinned to the left edge of the + * scroll container over an opaque background. + * + * jsdom lays nothing out, so this pins the structure and the classes the + * browser reads; it is no screenshot. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { t } from './i18n'; + +// `withPreviewDrafts` answers the client itself, not a promise (objectstack#26840). +type FakeClient = Record unknown>; + +let clientImpl: FakeClient; + +vi.mock('./useMetadata', () => ({ + useMetadataClient: () => clientImpl, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }], + }), +})); + +vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null })); + +import { PermissionMatrixEditPage } from './PermissionMatrixEditor'; + +afterEach(cleanup); + +// Eight fields, so the filter shows (threshold is > 6). +const FIELDS = ['amount', 'email', 'name', 'notes', 'phone', 'region', 'revenue', 'status']; + +function makeClient(): FakeClient { + return { + layered: async () => ({ + effective: { name: 'project_editor', label: 'Project editor', objects: { a_project: { allowRead: true } }, fields: {} }, + code: null, + overlay: null, + overlayScope: null, + }), + getDraft: async () => null, + withPreviewDrafts() { return this; }, + list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_project' } }] : []), + get: async (type: string) => (type === 'object' ? { fields: FIELDS.map((name) => ({ name })) } : null), + save: async (_type: string, _name: string, payload: Record) => payload, + }; +} + +async function renderExpanded() { + clientImpl = makeClient(); + render( + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: /a_project/ })); + await screen.findByLabelText('a_project.notes readable'); +} + +const PINNED = ['sticky', 'left-0', 'bg-background']; + +describe('the field sub-table when the matrix scrolls sideways (objectstack#26846)', () => { + it('the count sits right after the filter, not pushed to the far end of the row', async () => { + await renderExpanded(); + const count = screen.getByTestId('perm-field-count-a_project'); + expect(count).toHaveTextContent('8 / 8'); + expect(count.previousElementSibling).toBe(screen.getByLabelText(t('perm.field.filter', 'en'))); + expect(count).not.toHaveClass('ml-auto'); + expect(count).toHaveClass('shrink-0', 'whitespace-nowrap'); + }); + + it('the field-name column is pinned left, header and every row', async () => { + await renderExpanded(); + const header = screen.getByRole('columnheader', { name: t('perm.field.col.name', 'en') }); + expect(header).toHaveClass(...PINNED); + for (const f of FIELDS) { + expect(screen.getByTestId(`perm-field-name-a_project-${f}`)).toHaveClass(...PINNED); + } + // CONTROL: the boxes beside it scroll; only the name column is pinned. + expect(screen.getByLabelText('a_project.notes readable').closest('td')).not.toHaveClass('sticky'); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx index 8686e2a93e4..5785b14a165 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx @@ -3007,6 +3007,13 @@ function StudioObjectRow({ ); } +/** + * objectstack#26846 — the field sub-table's name cell, header and rows alike: + * pinned to the left edge of the matrix's scroll container, over an opaque + * background so the boxes scrolling under it do not show through. + */ +const FIELD_NAME_CELL = 'sticky left-0 z-[1] bg-background pr-2'; + /** * The inherited look, shared by object and field boxes: grey with a dashed * edge, so a box that draws a grant the set inherits never reads as one the @@ -3164,6 +3171,10 @@ function FieldsSubTable({
{/* Field toolbar (B4): filter + bulk over the visible field set. The filter only appears once one-by-one would actually hurt. */} + {/* objectstack#26846 — the count sits beside the filter it counts. At + the far end of the row it sat past the visible edge whenever the + matrix scrolled sideways (its floor is wider than the Studio + content column at 1440 px). */}
{fields.length > 6 && ( )} + + {visible.length} / {fields.length} + {writable && (
)} - - {visible.length} / {fields.length} -
{/* objectstack#26597 — what a grey box means, said once per sub-table. */} {visible.some((f) => fieldsState[`${objectName}.${f.name}`] === undefined) && ( @@ -3221,7 +3235,9 @@ function FieldsSubTable({ - + {/* objectstack#26846 — the name column stays in view while the + matrix scrolls sideways (FIELD_NAME_CELL). */} + @@ -3237,7 +3253,7 @@ function FieldsSubTable({ return ( -
{t('perm.field.col.name')}{t('perm.field.col.name')} {t('perm.field.read')} {t('perm.field.edit')} {t('perm.field.when')}
+ {f.label ?? f.name} {f.label && ( ({f.name}) diff --git a/ui/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.refusalClearsOnRevert-26846.test.tsx b/ui/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.refusalClearsOnRevert-26846.test.tsx new file mode 100644 index 00000000000..8adc007aada --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.refusalClearsOnRevert-26846.test.tsx @@ -0,0 +1,68 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26846 — a refused OWD save's banner answers the edits it was + * sent. Reverting every row to its loaded value ("No changes") takes it away; + * CONTROL: an edit that is still dirty keeps it. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { t } from '../metadata-admin/i18n'; +import { PackageOwdOverviewPanel } from './PackageOwdOverviewPanel'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const REFUSAL = 'object/crm_memo: a controlled_by_parent object needs exactly one required master_detail field'; +const CBP = t('engine.studio.settings.sharingControlledByParent', 'en-US'); +const PRIVATE = t('engine.studio.settings.sharingPrivate', 'en-US'); +const PUBLIC_READ = t('engine.studio.settings.sharingPublicRead', 'en-US'); + +function makeClient() { + const body = { name: 'crm_memo', label: 'Memo', sharingModel: 'private', fields: {} }; + return { + list: async (type: string) => (type === 'object' ? [{ name: 'crm_memo', label: 'Memo' }] : []), + listDrafts: async () => [], + layered: async () => ({ effective: body, code: null }), + getDraft: async () => null, + save: vi.fn(async () => { + throw Object.assign(new Error(REFUSAL), { status: 422, code: 'VALIDATION_ERROR' }); + }), + } as unknown as React.ComponentProps['client']; +} + +async function pick(label: string) { + fireEvent.keyDown(screen.getByTestId('owd-internal-crm_memo'), { key: 'ArrowDown' }); + fireEvent.click(within(await screen.findByRole('listbox')).getByRole('option', { name: label })); +} + +async function refuseOnce() { + render(); + await screen.findByTestId('owd-row-crm_memo'); + await pick(CBP); + fireEvent.click(screen.getByTestId('owd-save')); + await screen.findByText(new RegExp(REFUSAL)); +} + +beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn(); +}); +afterEach(cleanup); + +describe('PackageOwdOverviewPanel — the refusal banner and the revert (objectstack#26846)', () => { + it('reverting the refused edit to the loaded value clears the banner', async () => { + await refuseOnce(); + await pick(PRIVATE); + expect(screen.getByTestId('owd-save')).toHaveTextContent(t('engine.studio.owd.saveNone', 'en-US')); + await waitFor(() => expect(screen.queryByText(new RegExp(REFUSAL))).toBeNull()); + }); + + it('CONTROL: an edit that is still dirty keeps the banner', async () => { + await refuseOnce(); + await pick(PUBLIC_READ); + expect(screen.getByTestId('owd-dirty-crm_memo')).toBeInTheDocument(); + expect(screen.getByText(new RegExp(REFUSAL))).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.tsx b/ui/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.tsx index 8f2f7c70c4b..0a9176ff61a 100644 --- a/ui/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.tsx +++ b/ui/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.tsx @@ -334,6 +334,12 @@ export function PackageOwdOverviewPanel({ }, [], ); + // objectstack#26846 — a refusal answers the edits it was sent. Once every + // row is back at its loaded value ("No changes") there is nothing left for + // it to judge, so it goes; an edit that stays dirty keeps it. + React.useEffect(() => { + if (!isDirty) setSaveError(null); + }, [isDirty]); const doSave = React.useCallback(async () => { const changed = rowState.filter((s) => s.dirty); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessCreateTaken-26836.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessCreateTaken-26836.test.tsx index 8de96f7d7cc..4b5eac18929 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessCreateTaken-26836.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessCreateTaken-26836.test.tsx @@ -15,7 +15,8 @@ * - Create over a published id another package holds: refused, nothing * written, and no "Open". * - Create over a draft id: refused before the save, and the save still - * carries the first-write pin for a draft that lands after the check. + * carries the first-write pin for a draft that lands after the check; + * that pin's refusal reads in the author's words (objectstack#26846). * - A fresh id: created as before. * - Discard: a published set's draft goes and the matrix reads the live * set again; a never-published set leaves the rail. @@ -221,10 +222,29 @@ describe('objectstack#26836 — "+ New" over a taken identifier', () => { const dialog = await openCreate('Project Editor'); await waitFor(() => expect(clientImpl.save).toHaveBeenCalledTimes(1)); expect(clientImpl.save.mock.calls[0][3]).toMatchObject({ mode: 'draft', packageId: PKG, ifNoneMatch: '*' }); - expect(await within(dialog).findByText(/asserted that no draft row exists/)).toBeInTheDocument(); + // objectstack#26846 — read by its code, in the author's words: the door's + // sentence about the pin never reaches the dialog. + expect( + await within(dialog).findByText(tFormat('engine.studio.access.createDraftTaken', 'en', { name: 'project_editor' })), + ).toBeInTheDocument(); + expect(within(dialog).queryByText(/asserted that no draft row exists/)).toBeNull(); expect(clientImpl.drafts.project_editor.body.label).toBe('Somebody else'); }); + it('CONTROL (objectstack#26846): a refusal with another code keeps its own text', async () => { + clientImpl = makeClient(); + renderAccess(); + await screen.findByTestId('access-empty', undefined, { timeout: 8000 }); + clientImpl.save.mockRejectedValueOnce( + Object.assign(new Error('permission/project_editor: label is too long'), { status: 422, code: 'VALIDATION_ERROR' }), + ); + const dialog = await openCreate('Project Editor'); + expect(await within(dialog).findByText(/label is too long/)).toBeInTheDocument(); + expect( + within(dialog).queryByText(tFormat('engine.studio.access.createDraftTaken', 'en', { name: 'project_editor' })), + ).toBeNull(); + }); + it('a fresh identifier: created as a draft, as before, and opened', async () => { clientImpl = makeClient({ published: { project_editor: { pkg: PKG, body: EDITOR } } }); renderAccess(); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessRailLabelLive-26846.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessRailLabelLive-26846.test.tsx new file mode 100644 index 00000000000..d51c8636a4c --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessRailLabelLive-26846.test.tsx @@ -0,0 +1,97 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26846 — a label the open set's draft save changed shows on the + * Access rail at once: the pillar reads its sets again on the matrix's + * `onDraftSaved`, with no publish and no reload. The matrix page is a stand-in + * that reports one draft save; the pillar and its rail are the real ones. + * + * CONTROL: before that save the rail reads the label it loaded. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +let draftLabel = 'Dispatcher'; + +const mockClient = { + list: vi.fn(async () => []), + listDrafts: vi.fn(async () => [{ type: 'permission', name: 'dispatcher', packageId: 'app.a', label: draftLabel }]), + layered: vi.fn(async () => null), + getDraft: vi.fn(async () => null), + get: vi.fn(async () => null), + save: vi.fn(async () => ({})), +}; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + useMetadataClient: () => mockClient, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }], + }), + }; +}); +// The open set's editor: one button that stands for an autosave of its label. +vi.mock('../metadata-admin/PermissionMatrixEditor', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + PermissionMatrixEditPage: ({ onDraftSaved }: { onDraftSaved?: () => void }) => ( + + ), + }; +}); +vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null })); +vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null })); +vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null })); + +import { AccessPillar } from './StudioDesignSurface'; + +// jsdom has no matchMedia — useIsMobile (rail overlay) needs a stub. +window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; + +afterEach(cleanup); + +describe('objectstack#26846 — the Access rail follows a draft save of the open set', () => { + it('a label the save changed shows on the rail without a publish or a reload', async () => { + draftLabel = 'Dispatcher'; + const onDraftSaved = vi.fn(); + render( + + + , + ); + const rail = within(await screen.findByTestId('studio-rail-access')); + // CONTROL: the label the rail loaded. + expect(await rail.findByRole('button', { name: 'Dispatcher' })).toBeInTheDocument(); + + fireEvent.click(await screen.findByTestId('matrix-autosave')); + expect(onDraftSaved).toHaveBeenCalledTimes(1); + await waitFor(() => expect(rail.getByRole('button', { name: 'Dispatcher v2' })).toBeInTheDocument()); + expect(rail.queryByRole('button', { name: 'Dispatcher' })).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index be0fab1d7ac..74773eb4aa1 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -333,6 +333,7 @@ import { FiltersEditor } from '../metadata-admin/inspectors/NavFiltersEditor.js' import { CelPredicateField } from '../metadata-admin/CelPredicateField.js'; import type { CelLintIssue } from '../metadata-admin/celAuthoring.js'; import type { NavigationEntryItem, NavigationItemType } from '@object-ui/types'; +import { errorCodeIs } from '@object-ui/types'; import type { NavTargetLabelResolver } from '@object-ui/layout'; import { readFields, @@ -9653,7 +9654,15 @@ export function AccessPillar({ setOwdOpen(false); setCurrent(name); } catch (e) { - setCreateErr(formatMetadataError(e)); + // objectstack#26846 — the first-write pin's refusal, for a draft that + // landed after the check above, read by its code: this create sent + // the pin, so a conflict means the identifier holds a draft. The + // door's sentence speaks of the pin itself and is not shown. + setCreateErr( + errorCodeIs(e, 'METADATA_CONFLICT') + ? tFormat('engine.studio.access.createDraftTaken', locale, { name }) + : formatMetadataError(e), + ); } finally { setBusy(false); } @@ -9981,6 +9990,10 @@ export function AccessPillar({ // The set now holds a pending draft, so its discard shows. setDraftNames((names) => (names.has(current) ? names : new Set(names).add(current))); onDraftSaved?.(); + // objectstack#26846 — and the rail reads the sets again, so a + // label this save changed shows there without a publish or a + // reload. + void load(); }} readOnly={readOnly} onDirtyChange={setMatrixDirty} diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 6dcc35bcc29..8cd311cffe4 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -2514,6 +2514,7 @@ const STUDIO_EN: Record = { // Access pillar: a create over a taken identifier, and the draft discard (objectstack#26836). 'engine.studio.access.createTaken': 'This package already has a permission set with the identifier “{name}”: {label}. Open it, or choose another identifier.', 'engine.studio.access.createTakenElsewhere': 'A permission set with the identifier “{name}” already exists outside this package: {label}. Choose another identifier.', + 'engine.studio.access.createDraftTaken': 'A draft permission set with the identifier “{name}” already exists. Choose another identifier.', 'engine.studio.access.discardConfirm': 'Discard the pending draft of “{item}”? The published set stays as it is; only its unpublished changes are removed.', 'engine.studio.access.discardConfirmUnpublished': 'Discard “{item}”? It has never been published, so discarding its draft removes the set.', 'engine.studio.access.discardFailed': 'Could not discard the draft', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 9aaa1b909e9..b9297a3b64c 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -2264,6 +2264,7 @@ const STUDIO_ZH: Record = { // Access pillar: a create over a taken identifier, and the draft discard (objectstack#26836). 'engine.studio.access.createTaken': '此软件包已有标识为「{name}」的权限集:{label}。请打开它,或换一个标识。', 'engine.studio.access.createTakenElsewhere': '此软件包之外已有标识为「{name}」的权限集:{label}。请换一个标识。', + 'engine.studio.access.createDraftTaken': '已有标识为「{name}」的权限集草稿。请换一个标识。', 'engine.studio.access.discardConfirm': '放弃「{item}」的待发布草稿吗?已发布的权限集保持不变,只移除未发布的修改。', 'engine.studio.access.discardConfirmUnpublished': '放弃「{item}」吗?它从未发布过,放弃草稿会移除这个权限集。', 'engine.studio.access.discardFailed': '无法放弃草稿',