Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ceed91214
|
||
|
|
4e12a5d524
|
No files matched your search
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user