diff --git a/.changeset/27866-permission-grid-chrome.md b/.changeset/27866-permission-grid-chrome.md new file mode 100644 index 00000000000..601b71426c5 --- /dev/null +++ b/.changeset/27866-permission-grid-chrome.md @@ -0,0 +1,16 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/console': patch +--- + +fix(app-shell): the permission-set grid signals cut columns, keeps its Object column in view, labels expanded field rows by their own header, and tells apart two fields that share a label + +Clause-②: no +Metadata-protocol: none + +The permission-set grid is wider than its pane at 1440 px and on a phone, on the Setup page and on the Studio Access pillar's Objects tab alike: + +- **Cut columns are signalled.** While columns are cut on the right (the Bulk column at 1440 px), the pane's right edge fades; the fade goes once the last column is in view. +- **The Object column stays in view.** The Object column, header and rows, is pinned to the pane's left edge and draws its edge once rows slide under it. On a phone the pinned column shows the object's label alone, so the boxes keep the room to scroll. +- **Expanded field rows keep their own header.** An expanded object's field header (Field / Read / Edit / When) pins under the matrix's header row while the field list is in view, so field rows are no longer read against the object columns above them. +- **Two fields with one label are told apart.** When two of an object's fields share a label (an authored `owner` beside the platform's `owner_id`, both "Owner"), each row draws its API name as the distinguishing text instead of a muted aside. diff --git a/ui/packages/app-shell/src/views/metadata-admin/MatrixScrollPane.tsx b/ui/packages/app-shell/src/views/metadata-admin/MatrixScrollPane.tsx new file mode 100644 index 00000000000..15c33755315 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/MatrixScrollPane.tsx @@ -0,0 +1,73 @@ +/** + * The permission matrix's scroll pane (objectstack#27866): the grid's floor is + * wider than the content column at 1440 px, so the pane scrolls sideways. A + * fade on the right edge says columns are cut while any remain past it, and + * `data-scrolled-x` lets the pinned first column draw its edge once rows slide + * under it. + */ +import * as React from 'react'; +import { cn } from '@object-ui/components'; + +/** Where the pane's horizontal scroll sits: content cut on the right, scrolled off the left. */ +export interface PaneOverflow { + right: boolean; + left: boolean; +} + +/** Read off the pane's box; a sub-pixel remainder is not a cut column. */ +export function readPaneOverflow(el: Pick): PaneOverflow { + return { + right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1, + left: el.scrollLeft > 0, + }; +} + +export function MatrixScrollPane({ + className, + testId, + children, +}: { + /** Classes of the pane's outer box (its place in the page's flex column). */ + className?: string; + testId?: string; + children: React.ReactNode; +}) { + const ref = React.useRef(null); + const [overflow, setOverflow] = React.useState({ right: false, left: false }); + const measure = React.useCallback(() => { + const el = ref.current; + if (!el) return; + const next = readPaneOverflow(el); + setOverflow((prev) => (prev.right === next.right && prev.left === next.left ? prev : next)); + }, []); + React.useLayoutEffect(() => { + measure(); + const el = ref.current; + if (!el || typeof ResizeObserver === 'undefined') return; + // The pane and the grid both resize: the window, and a field list opening. + const ro = new ResizeObserver(measure); + ro.observe(el); + if (el.firstElementChild) ro.observe(el.firstElementChild); + return () => ro.disconnect(); + }, [measure]); + return ( +
+
+ {children} +
+ {overflow.right && ( + + ); +} diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.gridChrome-27866.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.gridChrome-27866.test.tsx new file mode 100644 index 00000000000..731d6b42ef4 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.gridChrome-27866.test.tsx @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27866 — the permission-set grid stays readable when it is wider + * than its pane (1440 px desktop, 390 px phone), on both renderings: + * + * - while columns are cut on the right, a fade on the pane's right edge says + * so (`MatrixScrollPane`), and it goes once the last column is in view; + * - the Object column, header and rows, is pinned to the pane's left edge; + * - an expanded object's field header pins under the matrix's header row, so + * field rows are labelled by their own columns; + * - two fields sharing a label draw their API names as the distinguishing text. + * + * jsdom lays nothing out: the pane's box is stubbed where the cue is read, and + * the rest pins the classes the browser reads; it is no screenshot. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { t } from './i18n'; +import { readPaneOverflow } from './MatrixScrollPane'; + +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); + +// An authored `owner` beside the platform's `owner_id`, both labelled "Owner". +const FIELDS = [ + { name: 'name', label: 'Project Name' }, + { name: 'owner', label: 'Owner' }, + { name: 'owner_id', label: 'Owner', system: true }, + { name: 'status', label: 'Status' }, +]; + +function makeClient(): FakeClient { + return { + layered: async () => ({ + effective: { + name: 'project_editor', + label: 'Project editor', + objects: { a_project: { allowRead: true }, b_task: { 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', label: 'Project' } }, { item: { name: 'b_task', label: 'Task' } }] : [], + get: async (type: string) => (type === 'object' ? { fields: FIELDS } : null), + save: async (_type: string, _name: string, payload: Record) => payload, + }; +} + +async function renderPage(studio = false) { + clientImpl = makeClient(); + render( + + {} } : undefined} + /> + , + ); + await screen.findByRole('button', { name: objectButton('a_project') }, { timeout: 5000 }); +} + +/** An object row's expand button: its label, then its API name. */ +const objectButton = (name: string) => new RegExp(`^${name === 'a_project' ? 'Project' : 'Task'}\\W*${name}\\W*$`); + +async function expand() { + fireEvent.click(screen.getByRole('button', { name: objectButton('a_project') })); + await screen.findByLabelText('a_project.owner readable'); +} + +/** Gives the pane a box, then lets it re-read it the way a scroll does. */ +function layOut(pane: HTMLElement, box: { scrollLeft: number; scrollWidth: number; clientWidth: number }) { + Object.defineProperty(pane, 'scrollWidth', { configurable: true, value: box.scrollWidth }); + Object.defineProperty(pane, 'clientWidth', { configurable: true, value: box.clientWidth }); + pane.scrollLeft = box.scrollLeft; + act(() => { + fireEvent.scroll(pane); + }); +} + +const PINNED_LEFT = ['sticky', 'left-0', 'bg-background']; + +describe('readPaneOverflow (objectstack#27866)', () => { + it('reads a cut right edge and a scrolled-off left edge', () => { + expect(readPaneOverflow({ scrollLeft: 0, scrollWidth: 1304, clientWidth: 1184 })).toEqual({ right: true, left: false }); + expect(readPaneOverflow({ scrollLeft: 120, scrollWidth: 1304, clientWidth: 1184 })).toEqual({ right: false, left: true }); + expect(readPaneOverflow({ scrollLeft: 60, scrollWidth: 1304, clientWidth: 1184 })).toEqual({ right: true, left: true }); + // A grid that fits, and a sub-pixel remainder, cut nothing. + expect(readPaneOverflow({ scrollLeft: 0, scrollWidth: 1184, clientWidth: 1184 })).toEqual({ right: false, left: false }); + expect(readPaneOverflow({ scrollLeft: 0, scrollWidth: 1184.5, clientWidth: 1184 })).toEqual({ right: false, left: false }); + }); +}); + +describe.each([ + ['the routed Setup page', false], + ['the Studio Objects tab', true], +])('the permission grid on %s (objectstack#27866)', (_where, studio) => { + it('fades the right edge while columns are cut, and not once the last one is in view', async () => { + await renderPage(studio); + const pane = screen.getByTestId('perm-matrix-pane'); + expect(pane).toContainElement(screen.getAllByRole('table')[0]); + + layOut(pane, { scrollLeft: 0, scrollWidth: 1304, clientWidth: 1184 }); + expect(screen.getByTestId('perm-matrix-overflow-cue')).toHaveAttribute('aria-hidden', 'true'); + expect(pane).not.toHaveAttribute('data-scrolled-x'); + + layOut(pane, { scrollLeft: 120, scrollWidth: 1304, clientWidth: 1184 }); + expect(screen.queryByTestId('perm-matrix-overflow-cue')).toBeNull(); + // Rows now slide under the pinned column, which draws its edge. + expect(pane).toHaveAttribute('data-scrolled-x', 'true'); + + // CONTROL: a grid that fits its pane shows no cue. + layOut(pane, { scrollLeft: 0, scrollWidth: 1600, clientWidth: 1600 }); + expect(screen.queryByTestId('perm-matrix-overflow-cue')).toBeNull(); + }); + + it('pins the Object column, header and every row, to the left edge', async () => { + await renderPage(studio); + const header = screen.getByRole('columnheader', { name: t('perm.col.object', 'en') }); + expect(header).toHaveClass(...(studio ? ['sticky', 'left-0', 'bg-sidebar'] : PINNED_LEFT)); + for (const name of ['a_project', 'b_task']) { + const cell = screen.getByRole('button', { name: objectButton(name) }).closest('td'); + expect(cell).toHaveClass(...PINNED_LEFT); + } + // CONTROL: the boxes scroll under it. + expect(screen.getByLabelText(/^a_project Create/).closest('td')).not.toHaveClass('sticky'); + }); + + it("pins the field header under the matrix's header row once an object is expanded", async () => { + await renderPage(studio); + await expand(); + // The matrix's header row is the h-9 the field header pins beneath. + const objectHeader = screen.getByRole('columnheader', { name: t('perm.col.object', 'en') }); + expect(objectHeader.closest('tr')).toHaveClass('h-9'); + const fieldHead = screen.getByTestId('perm-field-head-a_project'); + const cells = Array.from(fieldHead.querySelectorAll('th')); + expect(cells.map((c) => c.textContent)).toEqual([ + t('perm.field.col.name', 'en'), + t('perm.field.read', 'en'), + t('perm.field.edit', 'en'), + t('perm.field.when', 'en'), + ]); + for (const c of cells) expect(c).toHaveClass('sticky', 'top-9', 'bg-background'); + // The name header is pinned both ways and stacks over the pinned name cells. + expect(cells[0]).toHaveClass('left-0', 'z-[3]'); + // CONTROL: field rows are not pinned to the top. + expect(screen.getByLabelText('a_project.owner readable').closest('td')).not.toHaveClass('top-9'); + }); + + it('draws the API names of two fields that share a label as their distinguishing text', async () => { + await renderPage(studio); + await expand(); + for (const name of ['owner', 'owner_id']) { + const cell = screen.getByTestId(`perm-field-name-a_project-${name}`); + expect(cell).toHaveAttribute('data-shared-label', 'true'); + expect(cell).toHaveTextContent(`Owner(${name})`); + expect(cell.querySelector('span')).toHaveClass('text-foreground'); + expect(cell.querySelector('span')).not.toHaveClass('text-muted-foreground'); + } + // CONTROL: a field with a label of its own keeps the muted aside. + const status = screen.getByTestId('perm-field-name-a_project-status'); + expect(status).not.toHaveAttribute('data-shared-label'); + expect(status.querySelector('span')).toHaveClass('text-muted-foreground'); + }); +}); 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 91154a3be5e..7453b3b2917 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx @@ -114,6 +114,7 @@ import { translateValidationMessage, useMetadataLocale, } from './i18n.js'; +import { MatrixScrollPane } from './MatrixScrollPane.js'; import { PermissionAdvancedFacets } from './PermissionAdvancedFacets.js'; import { CelPredicateField } from './CelPredicateField.js'; import type { CelLintIssue } from './celAuthoring.js'; @@ -2225,7 +2226,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, t={t} /> {objectInheritedLegendEl} -
+ -
+ {writable && (

{t('engine.studio.access.bulkHint')} @@ -2418,7 +2419,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, {objectInheritedLegendEl} {/* Matrix */} -

+
)} -
+ {dialogsEl} @@ -2476,6 +2477,16 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, ); } +/** + * objectstack#27866 — the matrix's Object column, header and rows alike: pinned + * to the left edge of the scroll pane over an opaque background, so on a phone + * a row's boxes keep their object in view. Once the pane has scrolled sideways + * (`MatrixScrollPane`'s `data-scrolled-x`) the column draws its right edge. + * A row tint is laid on as a background image, over the opaque colour. + */ +const STICKY_OBJECT_CELL = + 'sticky left-0 bg-background after:pointer-events-none after:absolute after:inset-y-0 after:right-0 after:hidden after:w-px after:bg-border group-data-[scrolled-x=true]/matrix:after:block'; + /* ────────────────────────────────────────────────────────────────── */ /* Subcomponent: PermissionTable */ /* ────────────────────────────────────────────────────────────────── */ @@ -2535,8 +2546,8 @@ function PermissionTable({ // objectstack#26885 the Export column (one more w-14). - - + + {objectActions.map((a) => ( - +
{t('perm.col.object')}
{t('perm.col.object')} -
+
{o.accessDefault === 'private' && ( @@ -2831,7 +2848,7 @@ function StudioObjectMatrix({ - + {objectActions.map((a, i) => (
{t('perm.col.object')}{t('perm.col.object')} {t('engine.studio.access.colOwd')} @@ -2954,7 +2971,13 @@ function StudioObjectRow({ if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false); }} > - +