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
16 changes: 16 additions & 0 deletions .changeset/26846-studio-access-polish.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string, (...args: never[]) => 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<string, unknown>) => payload,
};
}

async function renderExpanded() {
clientImpl = makeClient();
render(
<MemoryRouter>
<PermissionMatrixEditPage type="permission" name="project_editor" packageId="app.a" />
</MemoryRouter>,
);
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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -3164,6 +3171,10 @@ function FieldsSubTable({
<div className="space-y-2">
{/* 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). */}
<div className="flex flex-wrap items-center gap-2">
{fields.length > 6 && (
<Input
Expand All @@ -3174,6 +3185,12 @@ function FieldsSubTable({
className="h-7 w-44 text-xs"
/>
)}
<span
data-testid={`perm-field-count-${objectName}`}
className="shrink-0 whitespace-nowrap text-[11px] tabular-nums text-muted-foreground"
>
{visible.length} / {fields.length}
</span>
{writable && (
<div className="flex items-center gap-1">
<Button
Expand Down Expand Up @@ -3205,9 +3222,6 @@ function FieldsSubTable({
</Button>
</div>
)}
<span className="ml-auto text-[11px] text-muted-foreground">
{visible.length} / {fields.length}
</span>
</div>
{/* objectstack#26597 — what a grey box means, said once per sub-table. */}
{visible.some((f) => fieldsState[`${objectName}.${f.name}`] === undefined) && (
Expand All @@ -3221,7 +3235,9 @@ function FieldsSubTable({
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground">
<th className="text-left py-1 font-medium">{t('perm.field.col.name')}</th>
{/* objectstack#26846 — the name column stays in view while the
matrix scrolls sideways (FIELD_NAME_CELL). */}
<th className={cn('text-left py-1 font-medium', FIELD_NAME_CELL)}>{t('perm.field.col.name')}</th>
<th className="px-2 py-1 font-medium w-16 text-center" title={t('perm.field.read')}>{t('perm.field.read')}</th>
<th className="px-2 py-1 font-medium w-16 text-center" title={t('perm.field.edit')}>{t('perm.field.edit')}</th>
<th className="px-2 py-1 font-medium text-left" title={t('perm.field.when.tip')}>{t('perm.field.when')}</th>
Expand All @@ -3237,7 +3253,7 @@ function FieldsSubTable({
return (
<React.Fragment key={f.name}>
<tr className="border-t border-muted">
<td className="py-1">
<td className={cn('py-1', FIELD_NAME_CELL)} data-testid={`perm-field-name-${objectName}-${f.name}`}>
{f.label ?? f.name}
{f.label && (
<span className="ml-1 text-muted-foreground">({f.name})</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof PackageOwdOverviewPanel>['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(<PackageOwdOverviewPanel client={makeClient()} packageId="com.example.crm" locale="en-US" />);
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading