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
19 changes: 13 additions & 6 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "26"
- name: Install disposable PostgreSQL engine
run: npm install --prefix backend/data/citation-validation --no-audit --no-fund --ignore-scripts @electric-sql/pglite@0.5.14
- name: Verify citation migration and transactions
Expand Down Expand Up @@ -47,7 +47,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "26"
cache: npm
cache-dependency-path: "**/package-lock.json"
- name: Install frontend deps
Expand All @@ -58,7 +58,12 @@ jobs:
run: npm run build

e2e:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 15
container:
# Keep this version aligned with @playwright/test in frontend/package-lock.json.
image: mcr.microsoft.com/playwright:v1.58.2-noble
options: --ipc=host
needs: [backend, frontend]
defaults:
run:
Expand All @@ -67,12 +72,14 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "26"
cache: npm
cache-dependency-path: "**/package-lock.json"
- name: Install frontend deps
timeout-minutes: 5
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Verify Playwright image version
run: node -e "if (require('@playwright/test/package.json').version !== '1.58.2') throw new Error('Update the CI Playwright image to match package-lock.json')"
- name: Run E2E smoke tests
timeout-minutes: 8
run: npm run test:e2e
12 changes: 4 additions & 8 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"dompurify": "^3.4.17",
"html-to-image": "^1.11.13",
"jszip": "^3.10.1",
"katex": "^0.19.1",
"katex": "^0.18.10",
"lowlight": "^3.3.0",
"papaparse": "^5.5.3",
"react": "^18.3.1",
Expand Down
1 change: 1 addition & 0 deletions frontend/playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export default defineConfig({
testDir: './e2e',
fullyParallel: false,
workers: 1,
globalTimeout: process.env.CI ? 6 * 60 * 1000 : undefined,
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'on-first-retry',
Expand Down
33 changes: 24 additions & 9 deletions frontend/src/components/ItemContextMenu.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'

const PANEL_CLASS = 'fixed z-[100] w-60 max-w-[calc(100vw-16px)] max-h-[calc(100vh-16px)] overflow-y-auto rounded-xl border border-slate-200 bg-white py-1 shadow-xl'
Expand All @@ -16,7 +16,7 @@ function ActionButton({ action, onClose, onMouseEnter }) {
</button>
}

function Submenu({ branch, onClose, onBack }) {
function Submenu({ branch, onClose, onBack, onMouseEnter }) {
const ref = useRef(null)
const [position, setPosition] = useState({ left: 0, top: 0 })
useLayoutEffect(() => {
Expand All @@ -32,6 +32,7 @@ function Submenu({ branch, onClose, onBack }) {
}, [branch])

return <div ref={ref} role="menu" aria-label={branch.group.label} style={position} className={PANEL_CLASS}
onMouseEnter={onMouseEnter}
onKeyDown={event => {
if (event.key === 'ArrowLeft' || event.key === 'Escape') {
event.preventDefault()
Expand All @@ -48,6 +49,20 @@ export default function ItemContextMenu({ x, y, title, groups, onClose }) {
const menuRef = useRef(null)
const [branch, setBranch] = useState(null)
const [position, setPosition] = useState({ left: x, top: y })
const branchTimer = useRef(null)
const cancelBranchChange = () => clearTimeout(branchTimer.current)
const openBranch = next => {
cancelBranchChange()
setBranch(next)
}
const hoverBranch = next => {
cancelBranchChange()
if (branch?.group.label === next?.group.label) return
// Allow diagonal pointer travel across other rows into the open submenu.
if (branch) branchTimer.current = setTimeout(() => setBranch(next), 200)
else setBranch(next)
}
useEffect(() => () => clearTimeout(branchTimer.current), [])

useLayoutEffect(() => {
const menu = menuRef.current
Expand Down Expand Up @@ -87,7 +102,7 @@ export default function ItemContextMenu({ x, y, title, groups, onClose }) {
: event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : null
if (next !== null) {
event.preventDefault()
if (event.target.closest('[role="menu"]') === rootRef.current) setBranch(null)
if (event.target.closest('[role="menu"]') === rootRef.current) openBranch(null)
buttons[next]?.focus()
}
}
Expand All @@ -98,18 +113,18 @@ export default function ItemContextMenu({ x, y, title, groups, onClose }) {
<div className="px-3 py-2 text-xs font-semibold text-slate-500 truncate" title={title}>{title}</div>
{groups.filter(group => group.actions.length).map(group => group.actions.length === 1 ? (
<div key={group.label} className="border-t border-slate-100 mt-1 pt-1">
<ActionButton action={group.actions[0]} onClose={onClose} onMouseEnter={() => setBranch(null)} />
<ActionButton action={group.actions[0]} onClose={onClose} onMouseEnter={() => hoverBranch(null)} />
</div>
) : (
<button key={group.label} role="menuitem" aria-haspopup="menu" aria-expanded={branch?.group.label === group.label}
className={`${BUTTON_CLASS} text-slate-700 ${branch?.group.label === group.label ? 'bg-blue-50' : ''}`}
onMouseEnter={event => setBranch({ group, trigger: event.currentTarget, focus: false })}
onClick={event => setBranch({ group, trigger: event.currentTarget, focus: true })}
onMouseEnter={event => hoverBranch({ group, trigger: event.currentTarget, focus: false })}
onClick={event => openBranch({ group, trigger: event.currentTarget, focus: true })}
onKeyDown={event => {
if (event.key === 'ArrowRight') {
event.preventDefault()
event.stopPropagation()
setBranch({ group, trigger: event.currentTarget, focus: true })
openBranch({ group, trigger: event.currentTarget, focus: true })
}
}}>
<Icon name={group.actions[0].icon} />
Expand All @@ -118,9 +133,9 @@ export default function ItemContextMenu({ x, y, title, groups, onClose }) {
</button>
))}
</div>
{branch && <Submenu branch={branch} onClose={onClose} onBack={() => {
{branch && <Submenu branch={branch} onClose={onClose} onMouseEnter={cancelBranchChange} onBack={() => {
branch.trigger.focus()
setBranch(null)
openBranch(null)
}} />}
</div>, document.body,
)
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/components/ItemContextMenu.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import ItemContextMenu from './ItemContextMenu'

afterEach(() => vi.useRealTimers())

it('keeps a submenu open when the pointer crosses another row to reach it', () => {
vi.useFakeTimers()
const copy = vi.fn()
const close = vi.fn()
render(<ItemContextMenu x={20} y={20} title="Paper" onClose={close} groups={[
{ label: 'Copy', actions: [
{ label: 'Copy title', icon: 'copy', run: copy },
{ label: 'Copy citation', icon: 'copy', run: vi.fn() },
] },
{ label: 'Open', actions: [{ label: 'Open paper', icon: 'open', run: vi.fn() }] },
]} />)
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'Copy', exact: true }))
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'Open paper' }))
const submenu = screen.getByRole('menu', { name: 'Copy' })
fireEvent.mouseEnter(submenu)
act(() => vi.advanceTimersByTime(300))
expect(submenu).toBeInTheDocument()
fireEvent.click(screen.getByRole('menuitem', { name: 'Copy title' }))
expect(copy).toHaveBeenCalledOnce()
expect(close).toHaveBeenCalledOnce()
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'Open paper' }))
act(() => vi.advanceTimersByTime(300))
expect(screen.queryByRole('menu', { name: 'Copy' })).not.toBeInTheDocument()
})
3 changes: 2 additions & 1 deletion frontend/src/pages/Library.smoke.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -329,11 +329,12 @@ describe('Library page smoke', () => {
expect(screen.getByRole('menuitem', { name })).toBeInTheDocument()
}
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'Process' }))
await screen.findByRole('menuitem', { name: /Generate notes/ })
for (const name of [/Generate notes/, /Auto-tag/, /Fetch PDFs/, /Generate embeddings/]) {
expect(screen.getByRole('menuitem', { name })).toBeInTheDocument()
}
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'Copy & export' }))
expect(screen.getByRole('menuitem', { name: /Export BibTeX/ })).toBeInTheDocument()
expect(await screen.findByRole('menuitem', { name: /Export BibTeX/ })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: /Delete item/ })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Clear selection' }))
expect(checkbox).not.toBeChecked()
Expand Down
Loading