diff --git a/packages/app/src/components/DashboardContainer.tsx b/packages/app/src/components/DashboardContainer.tsx
index f52cc96bad..e93b970f5a 100644
--- a/packages/app/src/components/DashboardContainer.tsx
+++ b/packages/app/src/components/DashboardContainer.tsx
@@ -35,7 +35,7 @@ type DashboardContainerProps = {
onToggleCollapsible: () => void;
onToggleBordered: () => void;
onDelete: (action: 'ungroup' | 'delete') => void;
- /** Tile count inside this container — determines whether "Ungroup Tiles" is offered. */
+ /** Tile count inside this container — determines whether "Ungroup Tiles" is offered. */ // [prose-lint: allow]
tileCount: number;
onAddTile: () => void;
activeTabId: string | undefined;
@@ -162,7 +162,11 @@ export default function DashboardContainer({
- } onClick={onAddTab}>
+ }
+ onClick={onAddTab}
+ data-testid={`group-add-tab-${container.id}`}
+ >
Add Tab
@@ -237,6 +241,7 @@ export default function DashboardContainer({
return (
setHovered(true)}
onMouseLeave={() => setHovered(false)}
mt={8}
@@ -275,7 +280,7 @@ export default function DashboardContainer({
) : (
- /* Plain header (1 tab or collapsed) — shows title + chevron */
+ /* Plain header (1 tab or collapsed) — shows title + chevron */ /* [prose-lint: allow] */
{
+ // Tests that PATCH the saved dashboard (round-trip / drag-persists) get
+ // an additional `@full-stack` tag so the backend-bearing CI lane picks
+ // them up. UI-only tests stay on `@dashboard` only.
+ let dashboardPage: DashboardPage;
+
+ test.beforeEach(async ({ page }) => {
+ dashboardPage = new DashboardPage(page);
+ await dashboardPage.goto();
+ await dashboardPage.createNewDashboard();
+ });
+
+ /**
+ * Helper: add a group, return its container id.
+ *
+ * `useDashboardContainers.handleAddContainer` sets `collapsed: false` and
+ * leaves `collapsible` / `bordered` undefined, so both default to `true`
+ * via `container.collapsible !== false` / `container.bordered !== false`
+ * in DashboardContainer.tsx:83-84.
+ */
+ async function addGroupAndGetId(): Promise {
+ const before = await dashboardPage.getGroupOrder();
+ await dashboardPage.addGroup();
+ await expect(dashboardPage.getGroups()).toHaveCount(before.length + 1);
+ const after = await dashboardPage.getGroupOrder();
+ const newId = after.find(id => !before.includes(id));
+ if (!newId) throw new Error('Newly added group id not found');
+ return newId;
+ }
+
+ test('group renders with default collapsible chevron and bordered style', async () => {
+ const id = await addGroupAndGetId();
+ const chevron = dashboardPage.getGroupChevron(id);
+
+ await test.step('chevron is present and reports expanded by default', async () => {
+ await expect(chevron).toBeVisible();
+ await expect(chevron).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ await test.step('container reports bordered via data-bordered', async () => {
+ // The component sets `data-bordered="true|false"` on the container
+ // shell. Reading the attribute keeps the spec decoupled from the
+ // particular style strategy (inline `border`, CSS var, etc.).
+ expect(await dashboardPage.getGroupBorderedAttr(id)).toBe('true');
+ });
+
+ await test.step('clicking the chevron collapses the group', async () => {
+ await chevron.click();
+ await expect(chevron).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ await test.step('clicking the chevron again re-expands it', async () => {
+ await chevron.click();
+ await expect(chevron).toHaveAttribute('aria-expanded', 'true');
+ });
+ });
+
+ test('toggling bordered via the overflow menu flips data-bordered', async () => {
+ const id = await addGroupAndGetId();
+
+ await test.step('starts as bordered', async () => {
+ expect(await dashboardPage.getGroupBorderedAttr(id)).toBe('true');
+ });
+
+ await test.step('Hide Border flips data-bordered to false', async () => {
+ await dashboardPage.toggleGroupBordered(id);
+ await expect
+ .poll(() => dashboardPage.getGroupBorderedAttr(id), { timeout: 5000 })
+ .toBe('false');
+ });
+
+ await test.step('the menu now offers Show Border and restores it', async () => {
+ await dashboardPage.openGroupMenu(id);
+ const borderedToggle = dashboardPage.getGroupBorderedToggle(id);
+ await expect(borderedToggle).toHaveText('Show Border');
+ await borderedToggle.click();
+ await expect
+ .poll(() => dashboardPage.getGroupBorderedAttr(id), { timeout: 5000 })
+ .toBe('true');
+ });
+ });
+
+ test('adding a tab reveals the tab bar and switching tabs updates URL state', async () => {
+ const id = await addGroupAndGetId();
+ const tabsInGroup = dashboardPage.getGroupTabs(id);
+
+ await test.step('a one-tab group does not render the tab bar', async () => {
+ // DashboardContainer.tsx:82 sets `hasTabs = tabs.length >= 2`. The new
+ // group has 1 tab, so no Tabs.List renders.
+ await expect(tabsInGroup).toHaveCount(0);
+ });
+
+ await test.step('Add Tab brings the tab bar with two tabs', async () => {
+ await dashboardPage.addTabToGroup(id);
+ await expect(tabsInGroup).toHaveCount(2);
+ });
+
+ await test.step('switching tabs updates ?activeTabs and aria-selected', async () => {
+ // After Add Tab, handleAddTab + handleTabChange leave the new tab
+ // selected, so the URL param eventually records the second tab's id.
+ // nuqs flushes URL state asynchronously, so poll rather than read
+ // once. We use the URL as the source of truth for ids rather than
+ // parsing them out of Mantine's internal DOM.
+ await expect
+ .poll(() => dashboardPage.getActiveTabsParam()[id])
+ .toBeTruthy();
+ const secondTabId = dashboardPage.getActiveTabsParam()[id]!;
+
+ await tabsInGroup.first().click();
+ await expect
+ .poll(() => dashboardPage.getActiveTabsParam()[id])
+ .not.toBe(secondTabId);
+ await expect(tabsInGroup.first()).toHaveAttribute(
+ 'aria-selected',
+ 'true',
+ );
+ await expect(tabsInGroup.last()).toHaveAttribute(
+ 'aria-selected',
+ 'false',
+ );
+
+ await tabsInGroup.last().click();
+ await expect
+ .poll(() => dashboardPage.getActiveTabsParam()[id])
+ .toBe(secondTabId);
+ await expect(tabsInGroup.last()).toHaveAttribute('aria-selected', 'true');
+ });
+ });
+
+ test('?collapsed URL param survives reload and restores the collapsed state', async () => {
+ const id = await addGroupAndGetId();
+ const chevron = dashboardPage.getGroupChevron(id);
+
+ // The two `useQueryState` setters (?collapsed and ?expanded) flush
+ // independently after a chevron click. Reading both as a tuple inside
+ // `expect.poll` avoids a one-shot read of the slow side after the
+ // fast side already resolved.
+ const readCollapseTuple = () => ({
+ collapsed: dashboardPage.getCollapsedParam(),
+ expanded: dashboardPage.getExpandedParam(),
+ });
+
+ await test.step('collapsing a default-expanded group writes ?collapsed=', async () => {
+ await chevron.click();
+ await expect.poll(readCollapseTuple).toMatchObject({
+ collapsed: expect.arrayContaining([id]),
+ expanded: expect.not.arrayContaining([id]),
+ });
+ });
+
+ await test.step('the collapsed state persists across reload', async () => {
+ await dashboardPage.page.reload();
+ await dashboardPage.waitForLoaded();
+ const reloadedChevron = dashboardPage.getGroupChevron(id);
+ await expect(reloadedChevron).toHaveAttribute('aria-expanded', 'false');
+ expect(dashboardPage.getCollapsedParam()).toContain(id);
+ });
+
+ await test.step('expanding again moves the id to ?expanded= and survives reload', async () => {
+ await dashboardPage.getGroupChevron(id).click();
+ await expect.poll(readCollapseTuple).toMatchObject({
+ collapsed: expect.not.arrayContaining([id]),
+ expanded: expect.arrayContaining([id]),
+ });
+
+ await dashboardPage.page.reload();
+ await dashboardPage.waitForLoaded();
+ await expect(dashboardPage.getGroupChevron(id)).toHaveAttribute(
+ 'aria-expanded',
+ 'true',
+ );
+ });
+ });
+
+ test('?activeTabs URL param survives reload and restores the active tab', async () => {
+ const id = await addGroupAndGetId();
+ await dashboardPage.addTabToGroup(id);
+
+ const tabs = dashboardPage.getGroupTabs(id);
+ await expect(tabs).toHaveCount(2);
+
+ // The URL stores the active tab id; capture it as the source of truth.
+ // nuqs flushes URL state asynchronously, so poll rather than read once.
+ await expect
+ .poll(() => dashboardPage.getActiveTabsParam()[id])
+ .toBeTruthy();
+ const secondTabId = dashboardPage.getActiveTabsParam()[id]!;
+
+ await test.step('switch to the first tab so the URL reflects it', async () => {
+ await tabs.first().click();
+ await expect
+ .poll(() => dashboardPage.getActiveTabsParam()[id])
+ .not.toBe(secondTabId);
+ });
+
+ await test.step('switch back to the second tab and reload', async () => {
+ await tabs.last().click();
+ await expect
+ .poll(() => dashboardPage.getActiveTabsParam()[id])
+ .toBe(secondTabId);
+
+ await dashboardPage.page.reload();
+ await dashboardPage.waitForLoaded();
+ const reloadedTabs = dashboardPage.getGroupTabs(id);
+ await expect(reloadedTabs.last()).toHaveAttribute(
+ 'aria-selected',
+ 'true',
+ );
+ await expect
+ .poll(() => dashboardPage.getActiveTabsParam()[id])
+ .toBe(secondTabId);
+ });
+ });
+
+ test(
+ 'save-and-reload round-trip preserves containers and tabs',
+ { tag: '@full-stack' },
+ async () => {
+ // Two groups: group A is left default-bordered, group B gets a second
+ // tab. Bordered toggle is exercised in its own test and is
+ // intentionally kept out of this round-trip. setDashboard for a
+ // remote dashboard fires PATCH /api/dashboards/{id} without an
+ // optimistic update, so back-to-back mutations that derive from the
+ // same in-memory snapshot can clobber each other; this test sequences
+ // its mutating actions deliberately and waits for the backend to
+ // confirm before navigating away.
+ const idA = await addGroupAndGetId();
+ const idB = await addGroupAndGetId();
+
+ // Start listening for the addTabToGroup PATCH before issuing it,
+ // because a fast handler can return before the spec reaches a
+ // post-hoc `waitForResponse`.
+ const tabPatch = dashboardPage.waitForDashboardPatch();
+
+ await test.step('group B gets a second tab', async () => {
+ await dashboardPage.addTabToGroup(idB);
+ const groupBTabs = dashboardPage.getGroupTabs(idB);
+ await expect(groupBTabs).toHaveCount(2);
+ });
+
+ // Capture the dashboard id from the URL while we're still on the page.
+ const dashboardId = dashboardPage.getCurrentDashboardId();
+
+ await test.step('wait for the addTab PATCH to land before leaving', async () => {
+ // setDashboard mutations are fire-and-forget; if we navigate
+ // before the PATCH lands, the change is dropped.
+ await tabPatch;
+ });
+
+ await test.step('navigate away then back', async () => {
+ await dashboardPage.page.goto('/search');
+ await expect(dashboardPage.page).toHaveURL(/.*\/search/);
+
+ await dashboardPage.page.goto(`/dashboards/${dashboardId}`);
+ await dashboardPage.waitForLoaded();
+ });
+
+ await test.step('both containers are present in original order', async () => {
+ // Poll because the reloaded dashboard hydrates via a remote fetch.
+ await expect
+ .poll(() => dashboardPage.getGroupOrder())
+ .toEqual([idA, idB]);
+ });
+
+ await test.step('group B still has two tabs', async () => {
+ const tabs = dashboardPage.getGroupTabs(idB);
+ await expect(tabs).toHaveCount(2);
+ });
+ },
+ );
+
+ test(
+ 'drag-to-reorder rearranges groups and the new order persists',
+ { tag: '@full-stack' },
+ async () => {
+ const idA = await addGroupAndGetId();
+ const idB = await addGroupAndGetId();
+ const idC = await addGroupAndGetId();
+
+ await test.step('initial order is [A, B, C]', async () => {
+ expect(await dashboardPage.getGroupOrder()).toEqual([idA, idB, idC]);
+ });
+
+ await test.step('drag onto self is a no-op', async () => {
+ // DashboardDndContext.tsx:67-70 guards
+ // `activeData.containerId !== overData.containerId`.
+ await dashboardPage.dragGroupTo(idA, idA);
+ expect(await dashboardPage.getGroupOrder()).toEqual([idA, idB, idC]);
+ });
+
+ // Start listening for the reorder PATCH before issuing the drag.
+ const reorderPatch = dashboardPage.waitForDashboardPatch();
+
+ await test.step('dragging A onto C produces [B, C, A]', async () => {
+ // arrayMove(containers, indexOf(A)=0, indexOf(C)=2) yields the @dnd-kit
+ // documented "shift" semantics: [B, C, A]. See
+ // useDashboardContainers.handleReorderContainers and
+ // DashboardDndContext.handleDragEnd.
+ await dashboardPage.dragGroupTo(idA, idC);
+ await expect
+ .poll(() => dashboardPage.getGroupOrder())
+ .toEqual([idB, idC, idA]);
+ });
+
+ await test.step('the new order persists across navigation', async () => {
+ const dashboardId = dashboardPage.getCurrentDashboardId();
+ // Wait for the reorder PATCH before leaving the page; otherwise the
+ // mutation is dropped on the goto.
+ await reorderPatch;
+
+ await dashboardPage.page.goto('/search');
+ await expect(dashboardPage.page).toHaveURL(/.*\/search/);
+ await dashboardPage.page.goto(`/dashboards/${dashboardId}`);
+ await dashboardPage.waitForLoaded();
+
+ await expect
+ .poll(() => dashboardPage.getGroupOrder())
+ .toEqual([idB, idC, idA]);
+ });
+ },
+ );
+});
diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts
index 7f962520ac..19f1e24099 100644
--- a/packages/app/tests/e2e/page-objects/DashboardPage.ts
+++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts
@@ -64,7 +64,7 @@ export class DashboardPage {
private readonly createDashboardButton: Locator;
private readonly addDropdownButton: Locator;
private readonly addTileMenuItem: Locator;
- private readonly addSectionMenuItem: Locator;
+ private readonly addGroupMenuItem: Locator;
private readonly dashboardNameHeading: Locator;
private readonly searchSubmitButton: Locator;
private readonly liveButton: Locator;
@@ -101,9 +101,7 @@ export class DashboardPage {
this.addTileMenuItem = page.locator(
'[data-testid="add-new-tile-menu-item"]',
);
- this.addSectionMenuItem = page.locator(
- '[data-testid="add-new-section-menu-item"]',
- );
+ this.addGroupMenuItem = page.getByTestId('add-new-group-menu-item');
this.searchInput = page.locator('[data-testid="search-input"]');
this.searchSubmitButton = page.locator(
'[data-testid="search-submit-button"]',
@@ -244,11 +242,254 @@ export class DashboardPage {
}
/**
- * Add a new section to the dashboard
+ * Add a new group (container) to the dashboard via the "+ Add" dropdown.
*/
- async addSection() {
+ async addGroup() {
await this.addDropdownButton.click();
- await this.addSectionMenuItem.click();
+ await this.addGroupMenuItem.click();
+ }
+
+ /**
+ * Get the group container element by its container id.
+ */
+ getGroup(containerId: string): Locator {
+ return this.page.getByTestId(`group-container-${containerId}`);
+ }
+
+ /**
+ * Get all rendered group containers in DOM order.
+ */
+ getGroups(): Locator {
+ return this.page.locator('[data-testid^="group-container-"]');
+ }
+
+ /**
+ * Read the container ids of all groups in DOM order. Each id is parsed
+ * out of the `group-container-${id}` testid. Drops empty entries to
+ * surface DOM regressions (a group rendered without the testid prefix
+ * would otherwise show as `""` and silently pass equality checks).
+ */
+ async getGroupOrder(): Promise {
+ const groups = this.getGroups();
+ const ids = await groups.evaluateAll(elements =>
+ elements.map(el => {
+ const testId = el.getAttribute('data-testid') ?? '';
+ return testId.replace(/^group-container-/, '');
+ }),
+ );
+ return ids.filter(id => id.length > 0);
+ }
+
+ /**
+ * Wait for the dashboard page shell to be visible. Used after reload /
+ * cross-page navigation to a single point of synchronisation.
+ */
+ async waitForLoaded() {
+ await expect(this.page.getByTestId('dashboard-page')).toBeVisible();
+ }
+
+ /** Locator for a group's collapse/expand chevron. */
+ getGroupChevron(containerId: string): Locator {
+ return this.page.getByTestId(`group-chevron-${containerId}`);
+ }
+
+ /** Locator for the bordered toggle menu item inside a group's overflow menu. */
+ getGroupBorderedToggle(containerId: string): Locator {
+ return this.page.getByTestId(`group-toggle-bordered-${containerId}`);
+ }
+
+ /** Locator for the visible tabs inside a group's tab bar. */
+ getGroupTabs(containerId: string): Locator {
+ return this.getGroup(containerId).getByRole('tab');
+ }
+
+ /**
+ * Read the inline border state of a group via the `data-bordered`
+ * attribute. Reading the attribute (rather than inspecting inline
+ * `style.border`) keeps the spec decoupled from how borders happen to
+ * be applied.
+ */
+ getGroupBorderedAttr(containerId: string): Promise {
+ return this.getGroup(containerId).getAttribute('data-bordered');
+ }
+
+ /**
+ * Wait for a backend dashboard PATCH (the fire-and-forget mutation that
+ * `setDashboard` issues) to land before navigating away. Required between
+ * any state mutation that the round-trip test relies on and the next
+ * `goto`/reload, because `setDashboard` is fire-and-forget and a fast
+ * navigation drops the in-flight request.
+ */
+ async waitForDashboardPatch() {
+ await this.page.waitForResponse(
+ r =>
+ r.url().includes('/api/dashboards/') &&
+ r.request().method() === 'PATCH' &&
+ r.ok(),
+ { timeout: 15000 },
+ );
+ }
+
+ /**
+ * Hover the group then open its overflow ("...") menu so subsequent
+ * menu-item helpers can click into it.
+ *
+ * The menu trigger lives behind `pointer-events: none` until the React
+ * `hovered` state commits (DashboardContainer.tsx:106-109,
+ * `hoverControlStyle`). Playwright's auto-wait checks visibility but
+ * not `pointer-events`, so the click can land on a hidden-from-input
+ * element. Wait for `pointer-events` to clear before clicking.
+ */
+ async openGroupMenu(containerId: string) {
+ const group = this.getGroup(containerId);
+ await group.hover();
+ const trigger = this.page.getByTestId(`group-menu-${containerId}`);
+ await expect(trigger).not.toHaveCSS('pointer-events', 'none');
+ await trigger.click();
+ }
+
+ /**
+ * Toggle the bordered visual style via the overflow menu.
+ */
+ async toggleGroupBordered(containerId: string) {
+ await this.openGroupMenu(containerId);
+ await this.page.getByTestId(`group-toggle-bordered-${containerId}`).click();
+ }
+
+ /**
+ * Add a new tab to the group via the overflow menu.
+ */
+ async addTabToGroup(containerId: string) {
+ await this.openGroupMenu(containerId);
+ await this.page.getByTestId(`group-add-tab-${containerId}`).click();
+ }
+
+ /**
+ * Read the `?activeTabs` query param as a `{ containerId: tabId }` map.
+ * Returns an empty object when the param is missing or malformed.
+ *
+ * The serializer is `parseAsJsonEncoded` (see
+ * `packages/app/src/utils/queryParsers.ts`), which double-encodes its
+ * value to survive the Microsoft-Teams `+` -> `%2B` re-encoding. nuqs
+ * writes `encodeURIComponent(JSON.stringify(value))` AND then nuqs's
+ * URL machinery encodes the resulting `%XX` sequences a second time.
+ * `searchParams.get(...)` decodes one level, so we have to decode the
+ * second level ourselves before `JSON.parse` to recover the object.
+ * The fallback to plain `JSON.parse` keeps us compatible with the
+ * old single-encoded format, mirroring the parser.
+ */
+ getActiveTabsParam(): Record {
+ const url = new URL(this.page.url());
+ const raw = url.searchParams.get('activeTabs');
+ if (!raw) return {};
+ const tryParse = (value: string): unknown => {
+ try {
+ return JSON.parse(value);
+ } catch {
+ return undefined;
+ }
+ };
+ let parsed: unknown;
+ try {
+ parsed = tryParse(decodeURIComponent(raw));
+ } catch {
+ parsed = undefined;
+ }
+ if (parsed === undefined) parsed = tryParse(raw);
+ // `JSON.parse` returns `any`. Anything other than a non-array object
+ // (e.g. `"123"`, `null`, `[…]`) would lie to callers and silently
+ // fail downstream `expect.poll(() => …[id])` checks.
+ if (
+ typeof parsed !== 'object' ||
+ parsed === null ||
+ Array.isArray(parsed)
+ ) {
+ return {};
+ }
+ // Build the result explicitly so the `string` value type is enforced
+ // at runtime (and we don't trust JSON.parse's `any` return).
+ const result: Record = {};
+ for (const [key, value] of Object.entries(parsed)) {
+ if (typeof value === 'string') result[key] = value;
+ }
+ return result;
+ }
+
+ /**
+ * Read the `?collapsed` query param as an array of container ids. nuqs
+ * serializes arrays as comma-separated values (`?collapsed=id1,id2`).
+ * Returns an empty array when the param is missing.
+ */
+ getCollapsedParam(): string[] {
+ const raw = new URL(this.page.url()).searchParams.get('collapsed');
+ return raw ? raw.split(',') : [];
+ }
+
+ /**
+ * Read the `?expanded` query param as an array of container ids. Mirrors
+ * `getCollapsedParam` semantics.
+ */
+ getExpandedParam(): string[] {
+ const raw = new URL(this.page.url()).searchParams.get('expanded');
+ return raw ? raw.split(',') : [];
+ }
+
+ /**
+ * Drag a group's drag handle onto another group, reordering them.
+ *
+ * @dnd-kit's MouseSensor uses `activationConstraint: { distance: 8 }`
+ * (see DashboardDndContext.tsx). The handle has to traverse 8+ pixels
+ * before the drag activates, so a single-shot drop never registers.
+ * The multi-step `mouse.move` mirrors the existing histogram-brush
+ * pattern in SearchPage.ts.
+ *
+ * Stability notes:
+ * - Pointerdown is preceded by a `mouse.move(startX, startY)` so the
+ * pointerdown coalesces deterministically.
+ * - The activation nudge is 10px (just past the 8px threshold) and
+ * stays inside the drag handle's bounds so we never leave the hit
+ * area before activation registers.
+ * - The target's `boundingBox()` is recomputed immediately before the
+ * final move, because @dnd-kit applies a 250ms sortable-item
+ * transform that shifts neighbouring containers while the drag is
+ * in flight.
+ */
+ async dragGroupTo(fromContainerId: string, toContainerId: string) {
+ const handle = this.page.getByTestId(
+ `group-drag-handle-${fromContainerId}`,
+ );
+ const target = this.getGroup(toContainerId);
+ await handle.scrollIntoViewIfNeeded();
+ await target.scrollIntoViewIfNeeded();
+
+ const handleBox = await handle.boundingBox();
+ if (!handleBox) {
+ throw new Error('Drag source not visible');
+ }
+
+ const startX = handleBox.x + handleBox.width / 2;
+ const startY = handleBox.y + handleBox.height / 2;
+
+ await this.page.mouse.move(startX, startY);
+ await this.page.mouse.move(startX, startY); // stabilise pointerdown coalescing
+ await this.page.mouse.down();
+ // Nudge to cross the 8px activation threshold while staying inside
+ // the handle's hit area.
+ await this.page.mouse.move(startX + 10, startY, { steps: 5 });
+
+ // Recompute the target box after activation; @dnd-kit shifts
+ // sibling containers during the drag and a stale box can land the
+ // pointer on the wrong neighbour.
+ const targetBox = await target.boundingBox();
+ if (!targetBox) {
+ await this.page.mouse.up();
+ throw new Error('Drag target not visible after activation');
+ }
+ const endX = targetBox.x + targetBox.width / 2;
+ const endY = targetBox.y + targetBox.height / 2;
+
+ await this.page.mouse.move(endX, endY, { steps: 15 });
+ await this.page.mouse.up();
}
/**
@@ -653,7 +894,7 @@ export class DashboardPage {
/**
* Get the `title` attribute of a cell (by column index) in the first row of
- * a table tile. The title mirrors the cell's stringified value — useful
+ * a table tile. The | title mirrors the cell's stringified value, useful
* for extracting column values (e.g. a ServiceName) for later assertions.
*/
async getFirstTableRowValue(tileIndex = 0, columnIndex = 0): Promise {
@@ -690,7 +931,7 @@ export class DashboardPage {
/**
* Click the first row's first cell of a table tile. Each cell contains a
- * div[role="link"] that owns the onRowClick handler — click that directly
+ * div[role="link"] that owns the onRowClick handler. Click that directly
* to trigger the configured action.
*/
async clickFirstTableRow(tileIndex = 0) {
|