From 2456bf7d64e783d76884254f213c3796be280694 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 02:46:13 +0000 Subject: [PATCH 1/2] fix(console): pin the Approvals Inbox Approve / Reject column and truncate long record titles At 1024 px the inbox table was 1074 px wide in a 718 px box: the record titles are nowrap and an auto-layout table sizes a column by its content's minimum width, so the Record column took 473 px and the inline Approve / Reject column sat past the card's right edge. - The Record cells are max-w-0, so a long title truncates (full text in its title) instead of widening the table; the Record header keeps a 10rem floor so rows stay distinguishable. - The Approve / Reject column is sticky at the right edge in the header and every row, inside the overflow box's seam="pinned" mode, carrying the shared PINNED_RIGHT_SEAM_SHADOW_CLASSES. Its fill is the card's; the row's hover and focus tints and the focused row's ring are redrawn on it. Claude-Session: https://claude.ai/code/session_011vfKFYJe5mXFiDw6F7CHuD Co-authored-by: Claude --- .../src/pages/system/ApprovalsInboxPage.tsx | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.tsx index 21bd2a8d2b..8333f03bfc 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.tsx @@ -73,6 +73,7 @@ import { SelectValue, cn, HorizontalOverflowBox, + PINNED_RIGHT_SEAM_SHADOW_CLASSES, } from '@object-ui/components'; import { toast } from 'sonner'; import { useAuth } from '@object-ui/auth'; @@ -866,6 +867,18 @@ function RecordCell({ r, href, hiddenKeys, deadLabel, unresolvableLabel }: { ); } +/** + * objectui#12816 — the inline Approve / Reject column is pinned to the table's + * right edge, the way the console's lists pin their Actions column: inside the + * `seam="pinned"` overflow box the pinned cells draw the shared seam shadow on + * their own left edge while columns scroll under them. The cell's fill is the + * card's, so a table that does not scroll looks as before; the row's hover and + * keyboard-focus tints are layered back over that fill by an `::after` behind + * the cell's content (the sticky cell is its own stacking context, so `-z-10` + * stays inside it). + */ +const PINNED_ACTIONS_CLASSES = `sticky right-0 z-10 bg-card after:pointer-events-none after:absolute after:inset-0 after:-z-10 after:opacity-0 in-[tr:hover]:after:opacity-100 ${PINNED_RIGHT_SEAM_SHADOW_CLASSES}`; + function InlineActions({ r, actionable, @@ -2060,7 +2073,9 @@ export function ApprovalsInboxPage() { {/* objectui#12708 — wider than the panel, the table scrolls in the shared overflow box: a right-edge shadow while columns remain to the right, its scrollbar on hover. */} - + {/* objectui#12816 — `seam="pinned"`: the shadow moves from + the box's edge to the pinned Approve / Reject cells. */} + @@ -2081,11 +2096,15 @@ export function ApprovalsInboxPage() { spreading five auto columns evenly and leaving 审批事项 over-wide next to a cramped 状态. */} {tr('colRequest', 'Request')} - {tr('colRecord', 'Record')} + {/* objectui#12816 — the Record cells are `max-w-0` (below), + so this floor is the column's only minimum: enough to + tell one record from the next at 1024 px, however long + the titles are. */} + {tr('colRecord', 'Record')} {tr('colRequester', 'Requester')} {tr('colStatus', 'Status')} {tr('colWaiting', 'Submitted')} - + @@ -2109,7 +2128,11 @@ export function ApprovalsInboxPage() { )} - + {/* objectui#12816 — `max-w-0` takes the record title's + unbreakable width out of the auto-layout table's + column sizing, so a long title truncates (full text + in its `title`) instead of widening the table. */} + - + Date: Sat, 17 Oct 2026 02:48:11 +0000 Subject: [PATCH 2/2] test(console): pin the Approvals Inbox pinned actions column and record truncation ApprovalsInboxPage.pinnedActions-12816 pins, at a stubbed 718 px box with a 60-character record title: the Approve / Reject header and every row's cell pinned right with the shared seam shadow, the Record cell max-w-0 with the full title in title, a short-title CONTROL, and the focused row's ring on the pinned cell. The objectui#12708 pin now reads the box's overflow mark, since the shadow moved from the box's edge to the pinned cells' seam. Claude-Session: https://claude.ai/code/session_011vfKFYJe5mXFiDw6F7CHuD Co-authored-by: Claude --- .../12816-approvals-inbox-pinned-actions.md | 5 + ...valsInboxPage.pinnedActions-12816.test.tsx | 223 ++++++++++++++++++ ...valsInboxPage.tableOverflow-12708.test.tsx | 19 +- 3 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 .changeset/12816-approvals-inbox-pinned-actions.md create mode 100644 apps/console/src/pages/system/ApprovalsInboxPage.pinnedActions-12816.test.tsx diff --git a/.changeset/12816-approvals-inbox-pinned-actions.md b/.changeset/12816-approvals-inbox-pinned-actions.md new file mode 100644 index 0000000000..64f5d4a534 --- /dev/null +++ b/.changeset/12816-approvals-inbox-pinned-actions.md @@ -0,0 +1,5 @@ +--- +'@object-ui/console': patch +--- + +Approvals Inbox: on a narrow screen the inline Approve / Reject column stays pinned to the table's right edge, with a shadow on its left edge while the table scrolls under it, instead of sitting past the card's edge. A long record title now truncates (its full text on hover) instead of widening the table. diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.pinnedActions-12816.test.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.pinnedActions-12816.test.tsx new file mode 100644 index 0000000000..bc726d8483 --- /dev/null +++ b/apps/console/src/pages/system/ApprovalsInboxPage.pinnedActions-12816.test.tsx @@ -0,0 +1,223 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Approvals Inbox — the Approve / Reject column is pinned right and a long + * record title truncates (objectui#12816). + * + * Measured at 1024 px (HotCRM, three account approvals titled like + * "ACC-000011 - Contoso Pharmaceuticals and Life Sciences Group"): the table + * was 1074 px wide in a 718 px box. The titles are `nowrap`, and an + * auto-layout table sizes a column by its content's minimum width, so the + * Record column took 473 px and pushed the inline Approve / Reject column + * past the card's right edge. + * + * - The Record cells are `max-w-0`: the title no longer sizes its column, so + * it truncates, its full text in `title`. The header keeps a floor. + * - The Approve / Reject cells are pinned right in the header and every row, + * with the shared seam shadow (`PINNED_RIGHT_SEAM_SHADOW_CLASSES`), inside + * the overflow box's `seam="pinned"` mode. + * + * jsdom lays nothing out, so the 718 px box is stubbed geometry and these pins + * read the classes that do the layout; the real-browser readings are on the + * pull request. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor, act, within } from '@testing-library/react'; +import { PINNED_RIGHT_SEAM_SHADOW_CLASSES } from '@object-ui/components'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +const APP = 'com.objectstack.account'; + +const { adapterFind, approvalsApiStub, ADAPTER, AUTH, I18N } = vi.hoisted(() => { + // The card's own measured title: 60 characters. + const LONG_TITLE = 'ACC-000011 - Contoso Pharmaceuticals and Life Sciences Group'; + const rows = [ + { + id: 'req_long', + process_name: 'invoice_approval', + process_label: 'Invoice Approval', + object_name: 'showcase_invoice', + object_label: 'Invoice', + record_id: 'acc_long', + record_title: LONG_TITLE, + status: 'pending', + pending_approvers: ['u_1'], + submitter_id: 'u_2', + submitter_name: 'Sam Submitter', + submitted_at: '2026-08-19T00:00:00.000Z', + }, + { + id: 'req_short', + process_name: 'invoice_approval', + process_label: 'Invoice Approval', + object_name: 'showcase_invoice', + object_label: 'Invoice', + record_id: 'acc_short', + record_title: 'Short Co', + status: 'pending', + pending_approvers: ['u_1'], + submitter_id: 'u_2', + submitter_name: 'Sam Submitter', + submitted_at: '2026-08-19T00:00:00.000Z', + }, + ]; + + // Both records are readable, so both titles are links. + const adapterFind = vi.fn(async (_object: string, params?: Record) => { + const ids = (params?.$filter as { id?: { $in?: string[] } } | undefined)?.id?.$in ?? []; + return { data: ids.map((id) => ({ id })) }; + }); + + const approvalsApiStub = { + listRequests: vi.fn(async () => ({ data: rows })), + getRequest: vi.fn(async (id: string) => ({ data: rows.find((r) => r.id === id) })), + listActions: vi.fn(async () => ({ data: [] })), + approve: vi.fn(async () => ({ data: rows[0], finalized: true })), + reject: vi.fn(async () => ({ data: rows[0], finalized: true })), + }; + + // STABLE singletons. A mocked hook that returns a fresh object per render + // gives every render a new `user` / `adapter` identity, which re-runs the + // page's load effect (and the probe's) forever — the page never leaves its + // loading skeleton and every query below times out on an empty table. + const ADAPTER = { find: adapterFind }; + const AUTH = { user: { id: 'u_1', email: 'approver@example.com' } }; + const I18N = { + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + language: 'en', + }; + + return { adapterFind, approvalsApiStub, rows, ADAPTER, AUTH, I18N }; +}); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => I18N, +})); + +vi.mock('@object-ui/auth', async (importOriginal) => { + const authFetch = vi.fn(async () => new Response('{}', { status: 200 })); + return { + ...(await importOriginal>()), + useAuth: () => AUTH, + createAuthenticatedFetch: () => authFetch, + TokenStorage: { get: () => null }, + }; +}); + +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => ADAPTER, + DeclaredActionsBar: () => null, + isViaOverrideRow: () => false, +})); + +// `buildApproverIdentities` stays REAL — the "can this approver act" pin is only +// worth anything if the identity resolution under it is the shipped one. +vi.mock('../../services/approvalsApi', async (importOriginal) => ({ + ...(await importOriginal>()), + approvalsApi: approvalsApiStub, +})); + +// Imported after the mocks so the page picks them up. +import { ApprovalsInboxPage } from './ApprovalsInboxPage'; + +function renderInbox() { + return render( + + + } /> + + , + ); +} + +function setGeometry(box: HTMLElement, g: { scrollLeft: number; scrollWidth: number; clientWidth: number }) { + Object.defineProperty(box, 'scrollWidth', { configurable: true, value: g.scrollWidth }); + Object.defineProperty(box, 'clientWidth', { configurable: true, value: g.clientWidth }); + box.scrollLeft = g.scrollLeft; + act(() => { fireEvent.scroll(box); }); +} + +const LONG = 'ACC-000011 - Contoso Pharmaceuticals and Life Sciences Group'; + +beforeEach(() => { + for (const fn of Object.values(approvalsApiStub)) fn.mockClear(); + adapterFind.mockClear(); +}); +afterEach(cleanup); + +async function renderAt718() { + renderInbox(); + await waitFor(() => expect(screen.getByRole('link', { name: LONG })).toBeInTheDocument()); + const box = screen.getByTestId('approvals-inbox-table-scroller'); + setGeometry(box, { scrollLeft: 0, scrollWidth: 762, clientWidth: 718 }); + return box; +} + +function expectPinnedRight(cell: Element) { + expect(cell).toHaveClass('sticky', 'right-0', 'z-10', 'bg-card'); + expect(cell).toHaveClass(...PINNED_RIGHT_SEAM_SHADOW_CLASSES.split(' ')); +} + +describe('Approvals Inbox — pinned Approve / Reject, truncating Record (objectui#12816)', () => { + it('pins the Approve / Reject column right, in the header and in every row, with the seam shadow', async () => { + const box = await renderAt718(); + // `seam="pinned"`: the pinned cells draw the shadow, so the box's own + // edge overlay stays off while it marks that it can scroll further right. + expect(box).toHaveAttribute('data-overflow-right', 'true'); + expect(screen.queryByTestId('horizontal-overflow-shadow')).toBeNull(); + + const header = within(box).getByRole('columnheader', { name: 'Actions' }); + expectPinnedRight(header); + expect(header).toBe(header.parentElement!.lastElementChild); + + const rows = within(box).getAllByRole('row').slice(1); + expect(rows).toHaveLength(2); + for (const row of rows) { + const cell = row.lastElementChild!; + expectPinnedRight(cell); + expect(within(cell as HTMLElement).getByRole('button', { name: 'Approve' })).toBeInTheDocument(); + expect(within(cell as HTMLElement).getByRole('button', { name: 'Reject' })).toBeInTheDocument(); + } + }); + + it('truncates a 60-character record title instead of letting it size the column', async () => { + const box = await renderAt718(); + expect(LONG).toHaveLength(60); + const link = screen.getByRole('link', { name: LONG }); + // The link is still the whole title, and carries it in full. + expect(link).toHaveAttribute('title', LONG); + expect(link).toHaveTextContent(LONG); + expect(link.querySelector('span')).toHaveClass('truncate'); + // The cell takes the title's unbreakable width out of the table's column + // sizing, so the table does not widen for it; the header keeps a floor. + expect(link.closest('td')).toHaveClass('max-w-0'); + expect(within(box).getByRole('columnheader', { name: 'Record' })).toHaveClass('w-[30%]', 'min-w-40'); + }); + + it('CONTROL: a short title renders as before, and the other columns keep their widths', async () => { + const box = await renderAt718(); + const link = screen.getByRole('link', { name: 'Short Co' }); + expect(link).toHaveAttribute('title', 'Short Co'); + expect(link).toHaveAttribute('href', `/apps/${APP}/showcase_invoice/record/acc_short`); + expect(within(box).getByRole('columnheader', { name: 'Request' })).toHaveClass('w-[22%]'); + expect(within(box).getByRole('columnheader', { name: 'Requester' })).toHaveClass('w-[16%]'); + expect(within(box).getByRole('columnheader', { name: 'Status' })).toHaveClass('w-[110px]'); + expect(within(box).getByRole('columnheader', { name: 'Submitted' })).toHaveClass('w-[132px]'); + for (const name of ['Request', 'Requester', 'Status', 'Submitted']) { + expect(within(box).getByRole('columnheader', { name })).not.toHaveClass('sticky'); + } + }); + + it('the keyboard-focused row keeps its ring across the pinned cell', async () => { + const box = await renderAt718(); + const [first, second] = within(box).getAllByRole('row').slice(1); + act(() => { fireEvent.keyDown(window, { key: 'j' }); }); + await waitFor(() => expect(first).toHaveClass('ring-2')); + expect(first.lastElementChild!.className).toContain('shadow-[inset_0_2px_0_var(--color-ring)'); + expect(second.lastElementChild!.className).not.toContain('shadow-['); + }); +}); diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.tableOverflow-12708.test.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.tableOverflow-12708.test.tsx index b8436a90ef..1aeaf166cf 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.tableOverflow-12708.test.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.tableOverflow-12708.test.tsx @@ -8,9 +8,10 @@ * Submitted, the inline Approve / Reject cell) is wider than its card: a long * record title holds the Record column open and the columns after it run past * the card's right edge, with nothing to say the table continues. The table - * now sits in the shared overflow box (`HorizontalOverflowBox`): a right-edge - * shadow while columns remain to the right, none at the right end, and its - * scrollbar on hover. + * now sits in the shared overflow box (`HorizontalOverflowBox`): a shadow while + * columns remain to the right, none at the right end, and its scrollbar on + * hover. Since objectui#12816 that shadow is the pinned Approve / Reject + * cells' seam (`ApprovalsInboxPage.pinnedActions-12816.test.tsx`). * * jsdom lays nothing out, so the box's scroll geometry is stubbed, as the * box's own pins in `@object-ui/components` stub it. @@ -155,16 +156,20 @@ describe('Approvals Inbox — the wide table shows that it scrolls (objectui#127 expect(table!.parentElement).toHaveClass('overflow-visible'); }); - it('draws the right-edge shadow while the table is wider than the box, and none at the right end', async () => { + // objectui#12816 pins the Approve / Reject column right, so the cue moved + // from the box's edge overlay to the pinned cells' seam (`seam="pinned"`): + // the box says it can scroll further right, and no edge overlay is drawn. + it('marks the box while the table is wider than it, and not at the right end', async () => { renderInbox(); await waitFor(() => expect(screen.getAllByText('Readable Invoice').length).toBeGreaterThan(0)); const box = screen.getByTestId('approvals-inbox-table-scroller'); - expect(screen.queryByTestId('horizontal-overflow-shadow')).toBeNull(); + expect(box).not.toHaveAttribute('data-overflow-right'); setGeometry(box, { scrollLeft: 0, scrollWidth: 1071, clientWidth: 718 }); - expect(screen.getByTestId('horizontal-overflow-shadow')).toBeInTheDocument(); + expect(box).toHaveAttribute('data-overflow-right', 'true'); + expect(screen.queryByTestId('horizontal-overflow-shadow')).toBeNull(); setGeometry(box, { scrollLeft: 1071 - 718, scrollWidth: 1071, clientWidth: 718 }); - expect(screen.queryByTestId('horizontal-overflow-shadow')).toBeNull(); + expect(box).not.toHaveAttribute('data-overflow-right'); }); });