Merge pull request #46 from QRun-IO/fix/45-record-actions-menu

fix(record-view): always show the Actions menu when it has entries (#45)
This commit was merged in pull request #46.
This commit is contained in:
james.maes authored and GitHub committed 2026-10-11 10:04:06 -05:00
commit b0b0d469fe
7 files changed
+134 -30

No files matched your search

+1
View File
@@ -4,6 +4,7 @@
## Product fixes
- Always show the record view's Actions menu when it has entries. Tables without processes or a custom `VIEW_SCREEN_ACTIONS` menu, and read-only tables, again reach Developer Mode and Audit (Material parity); Copy and Delete move into that menu instead of also appearing as separate buttons. [#45](https://github.com/QRun-IO/qqq-frontend-next/issues/45)
- Clear remembered query filters and saved-view selections on logout or a detected user identity change. Same-user resumes keep their query state; global appearance, density and legacy column preferences remain intact. This correction awaits its own branch acceptance. [#1006](https://github.com/QRun-IO/qqq/issues/1006)
- In searchable possible-value fields, invalidate old results immediately when the search term changes, so a debounced request cannot remove an option during its selection. [#970](https://github.com/QRun-IO/qqq/issues/970)
- Keep code suggestions open when a delayed textarea scroll event reports the position already used for the popup; dismiss them when the scroll position actually changes. [#954](https://github.com/QRun-IO/qqq/issues/954)
@@ -16,12 +16,13 @@
// Phone action sheet: focus management and routes (QRun-IO/qqq#694, #649)
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { QProcessMetaData, QTableMetaData } from '@/types'
import type { AuditSource } from '@/lib/api/audits'
import type { RecordViewActionsPlacement } from '@/lib/utils/record-layout-utils'
import { RecordViewHeader } from './RecordViewHeader'
@@ -144,20 +145,92 @@ describe('RecordViewHeader #/launchProcess= links', () => {
})
})
const readOnly = { ...table, insertPermission: false, editPermission: false, deletePermission: false,
capabilities: ['TABLE_QUERY', 'TABLE_GET'] } as unknown as QTableMetaData
function renderTable(tableMetaData: QTableMetaData, auditSource: AuditSource = null) {
return render(
<QueryClientProvider client={new QueryClient()}>
<RecordViewHeader tableMetaData={tableMetaData} record={{ tableName: 'person', values: { id: 5 }, recordLabel: 'Morgan Sample' }}
t1Fields={[]} viewMode="tabs" setViewMode={vi.fn()} hideActions={false} navigateFrom={{ path: '/app/person', label: 'Person' }}
auditSource={auditSource} />
</QueryClientProvider>
)
}
/** Labels of the desktop Actions menu's items, after opening it. */
async function openDesktopActions(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Record actions menu' }))
return screen.getAllByRole('menuitem').map((item) => item.textContent)
}
describe('RecordViewHeader for a read-only user', () => {
it('offers no phone Actions trigger when there is nothing to do', () => {
const readOnly = { ...table, insertPermission: false, editPermission: false, deletePermission: false,
capabilities: ['TABLE_QUERY', 'TABLE_GET'] } as unknown as QTableMetaData
render(
<QueryClientProvider client={new QueryClient()}>
<RecordViewHeader tableMetaData={readOnly} record={{ tableName: 'person', values: { id: 5 }, recordLabel: 'Morgan Sample' }}
t1Fields={[]} viewMode="tabs" setViewMode={vi.fn()} hideActions={false} navigateFrom={{ path: '/app/person', label: 'Person' }} />
</QueryClientProvider>
)
beforeEach(() => vi.clearAllMocks())
it('still offers Developer Mode from the desktop Actions menu, with no write actions (#45)', async () => {
const user = userEvent.setup()
renderTable(readOnly)
for (const action of ['Edit', 'Copy', 'Delete']) {
expect(screen.queryByRole('button', { name: `${action} Person record` })).not.toBeInTheDocument()
}
const items = await openDesktopActions(user)
expect(items).toEqual(['Developer Mode'])
await user.click(screen.getByRole('menuitem', { name: 'Developer Mode' }))
expect(push).toHaveBeenCalledWith('/app/person/5/dev')
})
it('offers Developer Mode and Audit from the phone sheet', async () => {
const user = userEvent.setup()
renderTable(readOnly, 'table')
await user.click(screen.getByRole('button', { name: 'Record actions' }))
const sheet = screen.getByRole('dialog', { name: 'Record actions' })
expect(within(sheet).getByRole('button', { name: 'Developer Mode' })).toBeInTheDocument()
expect(within(sheet).getByRole('button', { name: 'Audit' })).toBeInTheDocument()
expect(within(sheet).queryByRole('button', { name: 'Edit Person' })).not.toBeInTheDocument()
expect(within(sheet).queryByRole('button', { name: 'Delete Person' })).not.toBeInTheDocument()
})
it('offers no Actions controls when a custom menu resolves to nothing', () => {
const empty = { ...readOnly, menus: [{ slot: 'VIEW_SCREEN_ACTIONS', label: 'Actions', items: [
{ itemType: 'BUILT_IN', values: { option: 'EDIT' } },
] }] } as unknown as QTableMetaData
renderTable(empty)
expect(screen.queryByRole('button', { name: 'Record actions menu' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Record actions' })).not.toBeInTheDocument()
})
})
describe('RecordViewHeader Actions menu for a table without processes or a custom menu (#45)', () => {
beforeEach(() => vi.clearAllMocks())
it('renders the default menu with New, Copy, Delete and Developer Mode', async () => {
const user = userEvent.setup()
renderTable(table)
expect(await openDesktopActions(user)).toEqual(['New', 'Copy', 'Edit', 'Delete', 'Developer Mode'])
await user.click(screen.getByRole('menuitem', { name: 'Developer Mode' }))
expect(push).toHaveBeenCalledWith('/app/person/5/dev')
})
it('adds Audit when the user can read audits', async () => {
const user = userEvent.setup()
renderTable(table, 'table')
const items = await openDesktopActions(user)
expect(items).toContain('Developer Mode')
expect(items).toContain('Audit')
})
it('does not duplicate Copy or Delete as standalone buttons beside the menu', async () => {
const user = userEvent.setup()
renderTable(table)
expect(screen.getByRole('button', { name: 'Edit Person record' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Copy Person record' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Delete Person record' })).not.toBeInTheDocument()
const items = await openDesktopActions(user)
expect(items.filter((label) => label === 'Copy')).toHaveLength(1)
expect(items.filter((label) => label === 'Delete')).toHaveLength(1)
})
})
describe('RecordViewHeader layout', () => {
it('keeps secondary actions in the process menu without duplicate desktop buttons', async () => {
const user = userEvent.setup()
@@ -170,10 +243,10 @@ describe('RecordViewHeader layout', () => {
expect(push).toHaveBeenCalledWith('/app/person/5/copy')
})
it('keeps standalone secondary actions when there is no process menu', () => {
renderHeader()
expect(screen.getByRole('button', { name: 'Copy Person record' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete Person record' })).toBeInTheDocument()
it('keeps the table processes in the Actions menu alongside Developer Mode', async () => {
const user = userEvent.setup()
renderHeader([{ name: 'tagRecords', label: 'Tag Records', hasPermission: true } as QProcessMetaData])
expect(await openDesktopActions(user)).toEqual(['New', 'Copy', 'Edit', 'Delete', 'Tag Records', 'Developer Mode'])
})
it('lets the controls wrap onto their own row instead of squeezing the title', () => {
+9 -8
View File
@@ -242,9 +242,11 @@ export function RecordViewHeader({
const phoneActionEntries = hasCustomActionsMenu ? actionEntries : defaultPhoneMenuExtras(actionEntries)
const hasMetadataActions = (tableMetaData.menus ?? []).some((menu) =>
(menu.slot === 'VIEW_SCREEN_ACTIONS' || menu.slot === 'VIEW_SCREEN_ADDITIONAL') && hasMenuItems(resolveMenu(menu)))
// Keep the existing read-only security gate: a default Developer Mode entry alone does not
// expose an otherwise empty Actions control, while a table's explicit menu still can.
const hasMobileActions = canEdit || canInsert || canDelete || availableProcesses.length > 0 || hasMetadataActions
// Show the Actions controls whenever the resolved menu has something to offer. The default menu
// always resolves Developer Mode (and Audit when the user can read audits), so a table without
// processes or a custom menu, or a read-only table, still reaches them, as Material did (#45).
const hasRecordActions = hasMenuItems(actionEntries) || canEdit || canInsert || canDelete
|| availableProcesses.length > 0 || hasMetadataActions
/**
* Runs a resolved menu action from either the desktop dropdown or the phone sheet.
@@ -296,9 +298,8 @@ export function RecordViewHeader({
const desktopActions = !hideActions && (
<div className="hidden md:flex md:flex-wrap md:items-center md:gap-2" data-qqq-id="record-view-desktop-actions">
{tableMetaData.shareableTableMetaData && <ShareButton tableMetaData={tableMetaData} record={record} />}
{hasMobileActions && <RecordActions className="flex-wrap" tableMetaData={tableMetaData} record={record}
actionEntries={hasCustomActionsMenu || availableProcesses.length > 0 ? actionEntries : []}
resolveMenu={resolveMenu} onAction={onAction} />}
{hasRecordActions && <RecordActions className="flex-wrap" tableMetaData={tableMetaData} record={record}
actionEntries={actionEntries} resolveMenu={resolveMenu} onAction={onAction} />}
</div>
)
@@ -437,7 +438,7 @@ export function RecordViewHeader({
{/* Mobile: bottom-sheet trigger button — MED-17 */}
<div className="flex items-center gap-2 md:hidden">
{tableMetaData.shareableTableMetaData && <ShareButton tableMetaData={tableMetaData} record={record} />}
{hasMobileActions && <button
{hasRecordActions && <button
type="button"
ref={mobileActionsTrigger}
onClick={() => setMobileActionsOpen(true)}
@@ -461,7 +462,7 @@ export function RecordViewHeader({
</div>
{/* Mobile actions bottom-sheet — MED-17 */}
{mobileActionsOpen && hasMobileActions && (
{mobileActionsOpen && hasRecordActions && (
<div className="md:hidden" data-qqq-id="mobile-actions-sheet">
{/* Backdrop */}
<div
+2 -1
View File
@@ -912,6 +912,7 @@
"fixture": "persona viewer",
"scenarios": [
"no Edit, Copy or Delete",
"the Actions menu (or phone sheet) still offers Developer Mode, with no New, Edit, Copy or Delete (QRun-IO/qqq-frontend-next#45)",
"create and edit URLs show the permission message"
],
"negative": [
@@ -1195,7 +1196,7 @@
"The phone Actions sheet fits the screen, takes focus, keeps Tab and Shift+Tab inside, closes by Escape or a backdrop tap with focus back on its trigger, and runs Edit"
],
"negative": [
"A read-only user gets no phone Actions trigger (asserted in REC-046)"
"A read-only user's phone Actions sheet offers no write action (asserted in REC-046)"
],
"issues": [
"QRun-IO/qqq#708"
@@ -120,8 +120,14 @@ test.describe('Processes added to every screen, for a user whose metadata omits
await open(page, '/app/qryBin/2')
await expect(page.getByRole('heading', { level: 1, name: 'Nuts' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Delete Storage Bin record' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Record actions menu' })).toHaveCount(0)
// the default Actions menu still renders (Delete, Developer Mode, Audit), without the omitted process (#45)
await page.getByRole('button', { name: 'Record actions menu' }).click()
const recordItems = page.locator('[data-qqq-id="record-view-actions-menu"]').getByRole('menuitem')
await expect(recordItems.filter({ hasText: 'Delete' })).toHaveCount(1)
await expect(recordItems.filter({ hasText: 'Developer Mode' })).toHaveCount(1)
await expect(recordItems.filter({ hasText: 'Audit' })).toHaveCount(1)
await expect(recordItems.filter({ hasText: 'Tag Records' })).toHaveCount(0)
await page.keyboard.press('Escape')
await expect(page.getByRole('button', { name: 'Tag Records', exact: true })).toHaveCount(0)
await open(page, `/app/${TAG}?recordsParam=recordIds&recordIds=1&tableName=person`)
await expect(page.locator('[data-qqq-id="not-found-state"]', { hasText: TAG })).toBeVisible()
@@ -153,7 +159,12 @@ test.describe('Processes added to every screen, without process permission', ()
await open(page, '/app/qryBin/2')
await expect(page.getByRole('heading', { level: 1, name: 'Nuts' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Edit Storage Bin record' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Record actions menu' })).toHaveCount(0)
await page.getByRole('button', { name: 'Record actions menu' }).click()
const recordItems = page.locator('[data-qqq-id="record-view-actions-menu"]').getByRole('menuitem')
await expect(recordItems.filter({ hasText: 'Developer Mode' })).toHaveCount(1)
await expect(recordItems.filter({ hasText: 'Audit' })).toHaveCount(1)
await expect(recordItems.filter({ hasText: 'Tag Records' })).toHaveCount(0)
await page.keyboard.press('Escape')
await expect(page.getByRole('button', { name: 'Tag Records', exact: true })).toHaveCount(0)
// the v1 route the UI uses refuses it, as does the unversioned one
+20 -3
View File
@@ -56,9 +56,26 @@ test.describe('read-only persona', () => {
for (const action of ['Edit', 'Copy', 'Delete']) {
await expect(page.getByRole('button', { name: `${action} Person record` })).toHaveCount(0)
}
await expect(page.getByRole('button', { name: 'Record actions menu' })).toHaveCount(0)
// Phone: no Actions trigger opening an empty sheet either
await expect(page.getByRole('button', { name: 'Record actions', exact: true })).toHaveCount(0)
// The Actions control remains for Developer Mode (#45), but offers no write action
const desktopMenu = page.getByRole('button', { name: 'Record actions menu', exact: true })
const sheetTrigger = page.getByRole('button', { name: 'Record actions', exact: true })
await expect(desktopMenu.or(sheetTrigger).first()).toBeVisible()
if (await sheetTrigger.count()) {
await sheetTrigger.click()
const sheet = page.getByRole('dialog', { name: 'Record actions' })
await expect(sheet.getByRole('button', { name: 'Developer Mode', exact: true })).toBeVisible()
for (const action of ['New', 'Edit Person', 'Copy Person', 'Delete Person']) {
await expect(sheet.getByRole('button', { name: action, exact: true })).toHaveCount(0)
}
} else {
await desktopMenu.click()
const menu = page.locator('[data-qqq-id="record-view-actions-menu"]')
await expect(menu.getByRole('menuitem', { name: 'Developer Mode', exact: true })).toBeVisible()
for (const action of ['New', 'Edit', 'Copy', 'Delete']) {
await expect(menu.getByRole('menuitem', { name: action, exact: true })).toHaveCount(0)
}
}
await page.keyboard.press('Escape')
await page.goto('/app/person/1/edit', { waitUntil: 'domcontentloaded' })
await expect(page.getByRole('alert').filter({ hasText: 'permission' })).toHaveText('You do not have permission to edit Person records.')
+1 -1
View File
@@ -149,7 +149,7 @@ export async function openForm(page: Page, path: string, heading: string) {
/**
* Starts a record action (Edit, Copy or Delete) whether the view shows it as a
* button, inside the Actions menu (tables with record processes), or in the phone
* button, inside the Actions menu, or in the phone
* action sheet.
*/
export async function recordAction(page: Page, action: 'Edit' | 'Copy' | 'Delete', tableLabel: string) {