diff --git a/packages/web/src/components/CreateGraphModal.tsx b/packages/web/src/components/CreateGraphModal.tsx index ce8c98b9..cfec251f 100644 --- a/packages/web/src/components/CreateGraphModal.tsx +++ b/packages/web/src/components/CreateGraphModal.tsx @@ -6,6 +6,7 @@ import { useGraph } from '../contexts/GraphContext'; import { useAuth } from '../contexts/AuthContext'; import { useNotifications } from '../contexts/NotificationContext'; import { CreateGraphInput } from '../types/graph'; +import { createPortal } from 'react-dom'; interface CreateGraphModalProps { isOpen: boolean; @@ -212,7 +213,9 @@ export function CreateGraphModal({ isOpen, onClose, parentGraphId }: CreateGraph if (!isOpen) return null; - return ( + // In document.body: inside the layout's content wrapper the phone's top bar + // and bottom nav (z-30, outside it) would draw over the dialog and take its taps. + return createPortal((
{/* Enhanced Backdrop with gradient */} @@ -773,5 +776,5 @@ export function CreateGraphModal({ isOpen, onClose, parentGraphId }: CreateGraph
- ); + ), document.body); } \ No newline at end of file diff --git a/packages/web/src/components/DeleteGraphModal.tsx b/packages/web/src/components/DeleteGraphModal.tsx index f87eb3f0..5f4158b0 100644 --- a/packages/web/src/components/DeleteGraphModal.tsx +++ b/packages/web/src/components/DeleteGraphModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useRef } from 'react'; import { useQuery, useMutation } from '@apollo/client'; import { X, Trash2, GitBranch, Shield } from 'lucide-react'; import { AlertTriangle, CheckCircle, WORK_ITEM_TYPES } from '../constants/workItemConstants'; @@ -8,6 +8,8 @@ import { GET_WORK_ITEMS, GET_EDGES, DELETE_WORK_ITEM, DELETE_EDGE } from '../lib import { GET_CHILD_GRAPHS } from '../graphql/graph'; import { projectDeleteBlock } from '../lib/workspaceRules'; import type { Graph } from '../types/graph'; +import { createPortal } from 'react-dom'; +import { useModalA11y } from '../hooks/useModalA11y'; interface DeleteGraphModalProps { isOpen: boolean; @@ -17,6 +19,8 @@ interface DeleteGraphModalProps { } export function DeleteGraphModal({ isOpen, onClose, graph }: DeleteGraphModalProps) { + const panelRef = useRef(null); + useModalA11y(panelRef, { isOpen, label: 'Delete graph' }); const { currentGraph, deleteGraph } = useGraph(); const targetGraph = graph ?? currentGraph; // Asked of the backend each time: another tab may have added a graph inside since this tab's list was read. @@ -239,7 +243,9 @@ export function DeleteGraphModal({ isOpen, onClose, graph }: DeleteGraphModalPro } }; - return ( + // In document.body: inside the layout's content wrapper the phone's top bar + // and bottom nav (z-30, outside it) would draw over the dialog and take its taps. + return createPortal((
{/* Enhanced Backdrop with gradient */} @@ -249,7 +255,7 @@ export function DeleteGraphModal({ isOpen, onClose, graph }: DeleteGraphModalPro /> {/* Enhanced Modal with better styling */} -
+
{/* Gradient accent line at top - red for danger */}
@@ -807,5 +813,5 @@ export function DeleteGraphModal({ isOpen, onClose, graph }: DeleteGraphModalPro
- ); + ), document.body); } diff --git a/packages/web/src/components/GraphSelector.test.tsx b/packages/web/src/components/GraphSelector.test.tsx index c1f56351..2bb276f0 100644 --- a/packages/web/src/components/GraphSelector.test.tsx +++ b/packages/web/src/components/GraphSelector.test.tsx @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; import { GraphSelector } from './GraphSelector'; +import { onGraphRequests, requestGraph } from '../lib/graphRequests'; const graph = (id: string, name: string, extra: Record = {}) => ({ id, name, type: 'SUBGRAPH', createdBy: 'me', parentGraphId: null, depth: 0, path: [id], nodeCount: 1, edgeCount: 0, isShared: false, ...extra, @@ -58,7 +59,8 @@ describe('the graph selector edit and delete buttons', () => { const { onDeleteGraph, actions } = open(); const own = actions('Welcome', 0); fireEvent.mouseEnter(own.trash); - expect(screen.queryByText(/System graph/)).toBeNull(); + expect(screen.queryByRole('tooltip')).toBeNull(); + expect(own.trash.getAttribute('aria-describedby')).toBeNull(); fireEvent.click(own.trash); expect(onDeleteGraph).toHaveBeenCalledWith(expect.objectContaining({ id: 'w', createdBy: 'me' })); }); @@ -67,8 +69,54 @@ describe('the graph selector edit and delete buttons', () => { const { onDeleteGraph, actions } = open(); const shared = actions('Welcome', 1); fireEvent.mouseEnter(shared.trash); - expect(screen.getByText("🔒 System graph — can't be deleted")).toBeTruthy(); + expect(screen.getByRole('tooltip').textContent).toBe("🔒 System graph — can't be deleted"); fireEvent.click(shared.trash); expect(onDeleteGraph).not.toHaveBeenCalled(); }); + + it('explain a locked button when it is tapped, on screen and to screen readers', () => { + const { actions } = open(); + const shared = actions('Welcome', 1); + fireEvent.click(shared.trash); + expect(screen.getByRole('tooltip').textContent).toBe("🔒 System graph — can't be deleted"); + expect(document.getElementById(shared.trash.getAttribute('aria-describedby')!)?.textContent).toBe("🔒 System graph — can't be deleted"); + expect(shared.trash.getAttribute('aria-disabled')).toBe('true'); + }); + + it('put the reason on the left of a button near the right edge, as on a phone', () => { + const { actions } = open(); + const shared = actions('Welcome', 1); + shared.trash.getBoundingClientRect = () => ({ left: window.innerWidth - 40, right: window.innerWidth - 10, top: 100, height: 30, width: 30, bottom: 130, x: 0, y: 0, toJSON: () => ({}) }); + fireEvent.click(shared.trash); + const tip = screen.getByRole('tooltip'); + expect(tip.className).toContain('-translate-x-full'); + expect(parseFloat(tip.style.left)).toBe(window.innerWidth - 48); + }); +}); + +describe('the phone top bar graph selector', () => { + it('asks the workspace to create, rename and delete a graph, by name', () => { + const answer = { create: vi.fn(), edit: vi.fn(), delete: vi.fn() }; + const stop = onGraphRequests(answer); + render( + requestGraph('create')} + onEditGraph={graph => requestGraph('edit', graph)} + onDeleteGraph={graph => requestGraph('delete', graph)} + /> + ); + const list = () => { + fireEvent.click(screen.getByTestId('graph-selector-mobile')); + return within(screen.getByTestId('graph-selector-dropdown')); + }; + fireEvent.click(list().getByRole('button', { name: 'Edit Ideas' })); + fireEvent.click(list().getByRole('button', { name: 'Delete Ideas' })); + fireEvent.click(list().getByTitle('Create New Graph')); + stop(); + + expect(answer.edit).toHaveBeenCalledWith(expect.objectContaining({ id: 'i', name: 'Ideas' })); + expect(answer.delete).toHaveBeenCalledWith(expect.objectContaining({ id: 'i', name: 'Ideas' })); + expect(answer.create).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/web/src/components/GraphSelector.tsx b/packages/web/src/components/GraphSelector.tsx index cb9be473..8b978f05 100644 --- a/packages/web/src/components/GraphSelector.tsx +++ b/packages/web/src/components/GraphSelector.tsx @@ -9,6 +9,9 @@ import { GET_WORK_ITEMS, GET_EDGES } from '../lib/queries'; import { offlineEnabled, localGuest } from '../lib/offline/engine'; import { exampleAction, starterHierarchy, systemLocked } from '../lib/workspaceRules'; +/** Room the locked-button reason needs beside its button (it is one line, about 200 px). */ +const LOCKED_TIP_WIDTH = 240; + interface GraphSelectorProps { onCreateGraph?: () => void; onEditGraph?: (graph: any) => void; @@ -55,7 +58,11 @@ export function GraphSelector({ onCreateGraph, onEditGraph, onDeleteGraph, testI }); }; const [buttonPosition, setButtonPosition] = useState<{ top: number; left: number; width: number } | null>(null); - const [hoveredTooltip, setHoveredTooltip] = useState<{ message: string; x: number; y: number } | null>(null); + const [hoveredTooltip, setHoveredTooltip] = useState<{ message: string; x: number; y: number; left?: boolean } | null>(null); + const tooltipTimer = useRef(null); + useEffect(() => () => { + if (tooltipTimer.current !== null) window.clearTimeout(tooltipTimer.current); + }, []); const dropdownRef = useRef(null); const buttonRef = useRef(null); @@ -155,31 +162,44 @@ export function GraphSelector({ onCreateGraph, onEditGraph, onDeleteGraph, testI const graphActions = (row: any) => { const graph = availableGraphs.find(g => g.id === row.id) ?? row; const isSystemGraph = systemLocked(graph, currentUser?.id); - const action = (run: (graph: any) => void, locked: string, hover: string, icon: ReactNode) => ( - - ); + // Why a locked button does nothing: beside it (on its left when the right + // edge is near, as on a phone), on hover or on a tap, and to screen readers. + const explain = (target: HTMLElement, message: string, fade: boolean) => { + const rect = target.getBoundingClientRect(); + const left = rect.right + 8 + LOCKED_TIP_WIDTH > window.innerWidth; + if (tooltipTimer.current !== null) window.clearTimeout(tooltipTimer.current); + tooltipTimer.current = fade ? window.setTimeout(() => setHoveredTooltip(null), 2500) : null; + setHoveredTooltip({ message, x: left ? rect.left - 8 : rect.right + 8, y: rect.top + rect.height / 2, left }); + }; + const action = (label: string, run: (graph: any) => void, locked: string, hover: string, icon: ReactNode) => { + const reasonId = `graph-locked-${label.toLowerCase()}-${graph.id}`; + return ( + + ); + }; return ( -
- {onEditGraph && action(onEditGraph, '🔒 System graph — can\'t be edited', 'hover:text-blue-400 hover:bg-blue-500/20', )} - {onDeleteGraph && action(onDeleteGraph, '🔒 System graph — can\'t be deleted', 'hover:text-red-400 hover:bg-red-500/20', )} +
+ {onEditGraph && action('Edit', onEditGraph, '🔒 System graph — can\'t be edited', 'hover:text-blue-400 hover:bg-blue-500/20', )} + {onDeleteGraph && action('Delete', onDeleteGraph, '🔒 System graph — can\'t be deleted', 'hover:text-red-400 hover:bg-red-500/20', )}
); }; @@ -519,7 +539,8 @@ export function GraphSelector({ onCreateGraph, onEditGraph, onDeleteGraph, testI {/* Tooltip Portal */} {hoveredTooltip && createPortal(
- + requestGraph('create')} + onEditGraph={graph => requestGraph('edit', graph)} + onDeleteGraph={graph => requestGraph('delete', graph)} + />
diff --git a/packages/web/src/components/UpdateGraphModal.tsx b/packages/web/src/components/UpdateGraphModal.tsx index 305a0a6b..8d5aa1c2 100644 --- a/packages/web/src/components/UpdateGraphModal.tsx +++ b/packages/web/src/components/UpdateGraphModal.tsx @@ -1,8 +1,10 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { X, Folder, FolderOpen, Plus, FileText, Save } from 'lucide-react'; import { useGraph } from '../contexts/GraphContext'; import { useAuth } from '../contexts/AuthContext'; import { useNotifications } from '../contexts/NotificationContext'; +import { createPortal } from 'react-dom'; +import { useModalA11y } from '../hooks/useModalA11y'; interface UpdateGraphModalProps { isOpen: boolean; @@ -11,6 +13,8 @@ interface UpdateGraphModalProps { } export function UpdateGraphModal({ isOpen, onClose, graphToEdit }: UpdateGraphModalProps) { + const panelRef = useRef(null); + useModalA11y(panelRef, { isOpen, label: 'Edit graph' }); const { updateGraph, availableGraphs } = useGraph(); const { currentTeam } = useAuth(); const { showSuccess, showError } = useNotifications(); @@ -189,7 +193,9 @@ export function UpdateGraphModal({ isOpen, onClose, graphToEdit }: UpdateGraphMo } }; - return ( + // In document.body: inside the layout's content wrapper the phone's top bar + // and bottom nav (z-30, outside it) would draw over the dialog and take its taps. + return createPortal((
{/* Enhanced Backdrop with gradient */} @@ -199,7 +205,7 @@ export function UpdateGraphModal({ isOpen, onClose, graphToEdit }: UpdateGraphMo /> {/* Enhanced Modal with better styling */} -
+
{/* Gradient accent line at top */}
@@ -485,5 +491,5 @@ export function UpdateGraphModal({ isOpen, onClose, graphToEdit }: UpdateGraphMo
- ); + ), document.body); } \ No newline at end of file diff --git a/packages/web/src/hooks/useModalA11y.ts b/packages/web/src/hooks/useModalA11y.ts index eb1b66e0..46816548 100644 --- a/packages/web/src/hooks/useModalA11y.ts +++ b/packages/web/src/hooks/useModalA11y.ts @@ -68,11 +68,13 @@ export function useModalA11y(ref: RefObject, opts: ModalA11yOptions const position = () => { const bottom = Math.max(0, ...headers.filter(header => header.getClientRects().length > 0) .map(header => header.getBoundingClientRect().bottom)); - const offset = !matchMedia(COMPACT_WORKSPACE_QUERY).matches && window.innerHeight - bottom >= 420 ? bottom : 0; + const compact = typeof window.matchMedia === 'function' && window.matchMedia(COMPACT_WORKSPACE_QUERY).matches; + const offset = !compact && window.innerHeight - bottom >= 420 ? bottom : 0; backdrop?.style.setProperty('--modal-header-offset', `${offset}px`); }; - const observer = new ResizeObserver(position); - headers.forEach(header => observer.observe(header)); + // Without ResizeObserver (older browsers, jsdom) the window resize below still positions it. + const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(position); + headers.forEach(header => observer?.observe(header)); window.addEventListener('resize', position); position(); @@ -124,7 +126,7 @@ export function useModalA11y(ref: RefObject, opts: ModalA11yOptions el.addEventListener('keydown', onKeyDown); return () => { - observer.disconnect(); + observer?.disconnect(); window.removeEventListener('resize', position); backdrop?.style.removeProperty('--modal-header-offset'); if (raf) cancelAnimationFrame(raf); diff --git a/packages/web/src/lib/graphRequests.test.ts b/packages/web/src/lib/graphRequests.test.ts new file mode 100644 index 00000000..389b08cc --- /dev/null +++ b/packages/web/src/lib/graphRequests.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; +import { GRAPH_REQUESTS, onGraphRequests, requestGraph } from './graphRequests'; +import type { Graph } from '../types/graph'; + +const graph = { id: 'g1', name: 'Plan a launch' } as Graph; +const handlers = () => ({ create: vi.fn(), edit: vi.fn(), delete: vi.fn() }); + +describe('graph requests from outside the workspace page', () => { + it('reach the workspace: create, and edit or delete with the graph that was picked', () => { + const answer = handlers(); + const stop = onGraphRequests(answer); + requestGraph('create'); + requestGraph('edit', graph); + requestGraph('delete', graph); + stop(); + + expect(answer.create).toHaveBeenCalledTimes(1); + expect(answer.edit).toHaveBeenCalledWith(graph); + expect(answer.delete).toHaveBeenCalledWith(graph); + }); + + it('stop reaching it once the workspace has gone', () => { + const answer = handlers(); + onGraphRequests(answer)(); + requestGraph('create'); + requestGraph('delete', graph); + + expect(answer.create).not.toHaveBeenCalled(); + expect(answer.delete).not.toHaveBeenCalled(); + }); + + it('ignore an edit or delete that names no graph', () => { + const answer = handlers(); + const stop = onGraphRequests(answer); + window.dispatchEvent(new CustomEvent(GRAPH_REQUESTS.edit)); + window.dispatchEvent(new CustomEvent(GRAPH_REQUESTS.delete, { detail: {} })); + stop(); + + expect(answer.edit).not.toHaveBeenCalled(); + expect(answer.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/lib/graphRequests.ts b/packages/web/src/lib/graphRequests.ts new file mode 100644 index 00000000..14efea87 --- /dev/null +++ b/packages/web/src/lib/graphRequests.ts @@ -0,0 +1,38 @@ +import type { Graph } from '../types/graph'; + +/** + * Creating, renaming and deleting a graph, asked for from outside the + * workspace page and answered by Workspace, which owns those dialogs. The + * phone's top bar (Layout) has its own graph selector; like its "New work + * item" button ('graphdone-create-task'), it sends a window event. + */ +export const GRAPH_REQUESTS = { + create: 'graphdone-create-graph', + edit: 'graphdone-edit-graph', + delete: 'graphdone-delete-graph', +} as const; + +/** Asks the workspace to create a graph, or to edit or delete `graph` (an edit or delete without one is ignored). */ +export function requestGraph(kind: keyof typeof GRAPH_REQUESTS, graph?: Graph): void { + window.dispatchEvent(new CustomEvent(GRAPH_REQUESTS[kind], { detail: { graph } })); +} + +export interface GraphRequestHandlers { + create(): void; + edit(graph: Graph): void; + delete(graph: Graph): void; +} + +/** Answers graph requests until the returned function is called. An edit or delete without a graph is ignored. */ +export function onGraphRequests(handlers: GraphRequestHandlers): () => void { + const stops = (Object.keys(GRAPH_REQUESTS) as Array).map(kind => { + const listener = (event: Event) => { + const graph = (event as CustomEvent<{ graph?: Graph }>).detail?.graph; + if (kind === 'create') handlers.create(); + else if (graph) handlers[kind](graph); + }; + window.addEventListener(GRAPH_REQUESTS[kind], listener); + return () => window.removeEventListener(GRAPH_REQUESTS[kind], listener); + }); + return () => stops.forEach(stop => stop()); +} diff --git a/packages/web/src/pages/Workspace.tsx b/packages/web/src/pages/Workspace.tsx index 2fac8bab..90117f95 100644 --- a/packages/web/src/pages/Workspace.tsx +++ b/packages/web/src/pages/Workspace.tsx @@ -2,6 +2,7 @@ import {COMPACT_WORKSPACE_QUERY} from '../hooks/useCompactWorkspace'; import { offlineEnabled } from '../lib/offline/engine'; import { skipBackgroundPoll, useHostedUsage } from '../lib/hostedUsage'; import { WorkspaceViewTabs } from '../components/WorkspaceViewTabs'; +import { onGraphRequests } from '../lib/graphRequests'; import { AgentConnection } from '../components/AgentConnection'; import type { Graph, WorkItem } from '../types/graph'; import { useState, useEffect, useCallback } from 'react'; @@ -40,6 +41,16 @@ export function Workspace() { const [showGraphSelectionModal, setShowGraphSelectionModal] = useState(false); const [graphToEdit, setGraphToEdit] = useState(null); const [graphToDelete, setGraphToDelete] = useState(null); + const openCreateGraph = () => setShowCreateGraphModal(true); + const openEditGraph = (graph: Graph) => { + setGraphToEdit(graph); + setShowUpdateGraphModal(true); + }; + const openDeleteGraph = (graph: Graph) => { + setGraphToDelete(graph); + setShowDeleteGraphModal(true); + }; + useEffect(() => onGraphRequests({ create: openCreateGraph, edit: openEditGraph, delete: openDeleteGraph }), []); const { viewMode, setViewMode } = useViewMode(); const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.matchMedia(COMPACT_WORKSPACE_QUERY).matches @@ -164,17 +175,7 @@ export function Workspace() { {/* Graph Selector - Full Featured */}
- setShowCreateGraphModal(true)} - onEditGraph={(graph) => { - setGraphToEdit(graph); - setShowUpdateGraphModal(true); - }} - onDeleteGraph={(graph) => { - setGraphToDelete(graph); - setShowDeleteGraphModal(true); - }} - /> +
diff --git a/tests/devices/screens.ts b/tests/devices/screens.ts index 853ef6f6..4a37dba8 100644 --- a/tests/devices/screens.ts +++ b/tests/devices/screens.ts @@ -47,6 +47,13 @@ async function zoomToFirstCard(page: Page) { await settle(page, 1200); } +/** The graph list from whichever graph selector this layout shows (the phone's sits in its top bar). */ +async function openGraphList(page: Page) { + await openView(page, 'cards'); + await page.locator('[data-testid="graph-selector"]:visible, [data-testid="graph-selector-mobile"]:visible').first().click(); + await expect(page.getByTestId('graph-selector-dropdown')).toBeVisible(); +} + /** Activate a graph card header control with the keyboard (they are buttons). */ async function useCardControl(page: Page, selector: string, graphId?: string) { await openView(page, 'graph', graphId); @@ -189,6 +196,40 @@ export const UI_FIT_SCREENS: UiFitScreen[] = [ await settle(page, 800); }, }, + { + id: 'graph-list', + async open(page) { + await openGraphList(page); + await settle(page, 800); + }, + }, + { + id: 'graph-create', + async open(page) { + await openGraphList(page); + await page.getByTestId('graph-selector-dropdown').getByRole('button', { name: 'Create New Graph', exact: true }).click(); + await expect(page.getByRole('dialog', { name: 'Create new graph' })).toBeVisible(); + await settle(page, 800); + }, + }, + { + id: 'graph-edit', + async open(page) { + await openGraphList(page); + await page.getByTestId('graph-selector-dropdown').locator('button[aria-label^="Edit "]:not([aria-disabled])').first().click(); + await expect(page.getByRole('dialog', { name: 'Edit graph' })).toBeVisible(); + await settle(page, 800); + }, + }, + { + id: 'graph-delete', + async open(page) { + await openGraphList(page); + await page.getByTestId('graph-selector-dropdown').locator('button[aria-label^="Delete "]:not([aria-disabled])').first().click(); + await expect(page.getByRole('dialog', { name: 'Delete graph' })).toBeVisible(); + await settle(page, 800); + }, + }, { id: 'create-item', async open(page) { diff --git a/tests/lib/journeys/workspace.ts b/tests/lib/journeys/workspace.ts index c6e9c0d9..09ddb64c 100644 --- a/tests/lib/journeys/workspace.ts +++ b/tests/lib/journeys/workspace.ts @@ -7,8 +7,8 @@ import { gql } from '../graphRequest'; * saved truth, whichever backend serves it. Journeys never seed through an API: * they start where a real person starts and use the same controls. * - * JOURNEY_BACKEND=selfhost sign in as the admin test user, new graph (default; - * phones, which cannot create a graph, get it from the API) + * JOURNEY_BACKEND=selfhost sign in as the admin test user and create a new graph + * with the visible graph selector (default; on every device) * JOURNEY_BACKEND=hosted-guest "Continue as Guest" on the hosted build; work is * saved in the browser's local workspace */ @@ -32,20 +32,7 @@ export async function enterWorkspace(page: Page): Promise<{ graphId?: string }> return {}; } await login(page, TEST_USERS.ADMIN); - if (!(await page.getByTestId('graph-selector').isVisible())) { - // Phones have no "Create New Graph" control (the phone header's project - // switcher only switches), so the trial's empty graph is made through the - // API, as that dialog would make it, and opened. - const me = (await gql(page, '{ me { id } }')).me.id; - const created = await gql(page, 'mutation($input:[GraphCreateInput!]!){createGraphs(input:$input){graphs{id}}}', - { input: [{ name: `Journey ${Date.now()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: me }] }); - const graphId: string = created.createGraphs.graphs[0].id; - await page.evaluate(id => localStorage.setItem('currentGraphId', id), graphId); - await page.goto('/'); - await openGraphView(page); - return { graphId }; - } - await page.getByTestId('graph-selector').click(); + await page.locator('[data-testid="graph-selector"]:visible, [data-testid="graph-selector-mobile"]:visible').first().click(); await page.getByRole('button', { name: 'Create New Graph', exact: true }).click(); const create = page.getByRole('dialog', { name: 'Create new graph' }); await create.getByRole('button', { name: 'Continue', exact: true }).click();