refactor(board): restore simple add item workflow with SelectableCard and clean modal flow

This commit is contained in:
Thomas Camlong committed 2026-08-21 00:12:32 +02:00
1 parent 3ba731da2c
commit 7690e5e542
4 files changed
+164 -1849

No files matched your search

@@ -1,304 +0,0 @@
// @vitest-environment jsdom
import { act, createElement, useState } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { Button, Center, MantineProvider } from "@mantine/core";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WidgetError } from "@homarr/widgets/errors";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string) => {
const translations: Record<string, string> = {
"widget.common.error.noIntegration": "No integration selected for this widget",
"common.error": "An unexpected error occurred",
"common.action.retry": "Retry",
};
return translations[key] ?? key;
},
useScopedI18n: () => (key: string) => key,
}));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
// Suppress console.error during error boundary tests to avoid noisy output
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
vi.restoreAllMocks();
});
const Throw = ({ when, error }: { when: boolean; error: Error }) => {
if (when) throw error;
return null;
};
// Simulates the Live Preview Error Boundary container from item-select-modal.tsx
const LivePreviewContainer = ({
options,
previewIntegrationIds,
hasIntegrationSupport,
integrationsRequired,
onResetCallback,
widgetComponent,
}: {
options: Record<string, unknown>;
previewIntegrationIds: string[];
hasIntegrationSupport: boolean;
integrationsRequired: boolean;
onResetCallback?: () => void;
widgetComponent: (props: { options: Record<string, unknown>; integrationIds: string[] }) => JSX.Element;
}) => {
return (
<div data-testid="outer-modal-pane">
<div data-testid="outer-header">Add Widget Header</div>
<div data-testid="live-preview-frame">
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={() => {
reset();
onResetCallback?.();
}}
resetKeys={[options, previewIntegrationIds]}
fallbackRender={({ resetErrorBoundary, error }) => (
<Center h="100%" p="md" data-testid="preview-error-fallback">
<WidgetError error={error} resetErrorBoundary={resetErrorBoundary} />
</Center>
)}
>
<Throw
error={new NoIntegrationSelectedError()}
when={hasIntegrationSupport && previewIntegrationIds.length === 0 && integrationsRequired}
/>
{widgetComponent({ options, integrationIds: previewIntegrationIds })}
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
</div>
<div data-testid="outer-footer">Add Widget Footer</div>
</div>
);
};
describe("Advanced Add App - Live Preview Error Boundary Isolation", () => {
it("isolates runtime errors thrown inside the preview widget without crashing outer modal components", async () => {
const BuggyWidget = () => {
throw new Error("Widget rendering failure in preview");
};
await act(async () =>
root.render(
<MantineProvider>
<LivePreviewContainer
options={{ title: "Test" }}
previewIntegrationIds={["integration-1"]}
hasIntegrationSupport={true}
integrationsRequired={true}
widgetComponent={() => <BuggyWidget />}
/>
</MantineProvider>,
),
);
// Outer components are preserved and not destroyed by the error
expect(host.querySelector("[data-testid='outer-modal-pane']")).not.toBeNull();
expect(host.querySelector("[data-testid='outer-header']")).not.toBeNull();
expect(host.querySelector("[data-testid='outer-footer']")).not.toBeNull();
// The error fallback is rendered inside the preview frame
expect(host.querySelector("[data-testid='preview-error-fallback']")).not.toBeNull();
expect(host.textContent).toContain("Widget rendering failure in preview");
});
it("throws and catches NoIntegrationSelectedError when integration is required and previewIntegrationIds is empty", async () => {
const WorkingWidget = () => <div data-testid="widget-content">Healthy Widget</div>;
await act(async () =>
root.render(
<MantineProvider>
<LivePreviewContainer
options={{}}
previewIntegrationIds={[]}
hasIntegrationSupport={true}
integrationsRequired={true}
widgetComponent={() => <WorkingWidget />}
/>
</MantineProvider>,
),
);
// Error fallback is active
expect(host.querySelector("[data-testid='preview-error-fallback']")).not.toBeNull();
expect(host.textContent).toContain("No integration selected for this widget");
expect(host.querySelector("[data-testid='widget-content']")).toBeNull();
});
it("renders healthy widget without throwing when integrationsRequired is false even if previewIntegrationIds is empty", async () => {
const OptionalIntegrationWidget = ({ integrationIds }: { integrationIds: string[] }) => (
<div data-testid="widget-content">Widget rendered with {integrationIds.length} integrations</div>
);
await act(async () =>
root.render(
<MantineProvider>
<LivePreviewContainer
options={{}}
previewIntegrationIds={[]}
hasIntegrationSupport={true}
integrationsRequired={false}
widgetComponent={({ integrationIds }) => <OptionalIntegrationWidget integrationIds={integrationIds} />}
/>
</MantineProvider>,
),
);
expect(host.querySelector("[data-testid='preview-error-fallback']")).toBeNull();
expect(host.querySelector("[data-testid='widget-content']")).not.toBeNull();
expect(host.textContent).toContain("Widget rendered with 0 integrations");
});
it("renders healthy widget without throwing when widget has no integration support", async () => {
const StandaloneWidget = () => <div data-testid="standalone-widget">Clock Widget</div>;
await act(async () =>
root.render(
<MantineProvider>
<LivePreviewContainer
options={{}}
previewIntegrationIds={[]}
hasIntegrationSupport={false}
integrationsRequired={false}
widgetComponent={() => <StandaloneWidget />}
/>
</MantineProvider>,
),
);
expect(host.querySelector("[data-testid='preview-error-fallback']")).toBeNull();
expect(host.querySelector("[data-testid='standalone-widget']")).not.toBeNull();
expect(host.textContent).toContain("Clock Widget");
});
it("automatically resets and recovers when previewIntegrationIds or options change via resetKeys", async () => {
const DynamicWidget = ({ integrationIds }: { integrationIds: string[] }) => {
return <div data-testid="healthy-widget">Active with: {integrationIds.join(", ")}</div>;
};
const TestHarness = () => {
const [integrationIds, setIntegrationIds] = useState<string[]>([]);
const [options, setOptions] = useState<Record<string, unknown>>({ query: "default" });
return (
<div>
<button data-testid="connect-service-btn" onClick={() => setIntegrationIds(["new-service-id"])}>
Connect
</button>
<button data-testid="change-options-btn" onClick={() => setOptions({ query: "updated" })}>
Change Option
</button>
<LivePreviewContainer
options={options}
previewIntegrationIds={integrationIds}
hasIntegrationSupport={true}
integrationsRequired={true}
widgetComponent={({ integrationIds: ids }) => <DynamicWidget integrationIds={ids} />}
/>
</div>
);
};
await act(async () =>
root.render(
<MantineProvider>
<TestHarness />
</MantineProvider>,
),
);
// Initial state: no integration -> error boundary is showing
expect(host.querySelector("[data-testid='preview-error-fallback']")).not.toBeNull();
expect(host.querySelector("[data-testid='healthy-widget']")).toBeNull();
// Click connect button -> previewIntegrationIds updates -> error boundary automatically resets
const connectBtn = host.querySelector("[data-testid='connect-service-btn']");
await act(async () => {
(connectBtn as HTMLButtonElement)?.click();
});
expect(host.querySelector("[data-testid='preview-error-fallback']")).toBeNull();
expect(host.querySelector("[data-testid='healthy-widget']")).not.toBeNull();
expect(host.textContent).toContain("Active with: new-service-id");
});
it("recovers from error state when user clicks retry button in WidgetError", async () => {
let shouldFail = true;
const onResetCallback = vi.fn();
const FlakyWidget = () => {
if (shouldFail) {
throw new Error("Temporary network glitch");
}
return <div data-testid="recovered-widget">Recovered Content</div>;
};
await act(async () =>
root.render(
<MantineProvider>
<LivePreviewContainer
options={{}}
previewIntegrationIds={["integration-1"]}
hasIntegrationSupport={true}
integrationsRequired={true}
onResetCallback={onResetCallback}
widgetComponent={() => <FlakyWidget />}
/>
</MantineProvider>,
),
);
expect(host.querySelector("[data-testid='preview-error-fallback']")).not.toBeNull();
expect(host.textContent).toContain("Temporary network glitch");
// Heal the condition
shouldFail = false;
// Click the retry button rendered by BaseWidgetError
const retryButton = Array.from(host.querySelectorAll("button")).find((btn) =>
btn.textContent?.toLowerCase().includes("retry"),
);
expect(retryButton).toBeDefined();
await act(async () => {
retryButton?.click();
});
expect(onResetCallback).toHaveBeenCalledOnce();
expect(host.querySelector("[data-testid='preview-error-fallback']")).toBeNull();
expect(host.querySelector("[data-testid='recovered-widget']")).not.toBeNull();
});
});
@@ -1,254 +0,0 @@
import { describe, expect, it } from "vitest";
import type { IntegrationKind } from "@homarr/definitions";
// Helper functions mirroring the exact business logic from item-select-modal.tsx
export const getCompatibleIntegrations = <
TIntegration extends { id: string; name: string; kind: IntegrationKind; url?: string },
>(
supportedIntegrations: readonly IntegrationKind[] | undefined,
integrationData: TIntegration[] | undefined,
) => {
if (!supportedIntegrations) return [];
return (integrationData ?? [])
.filter((i) => supportedIntegrations.includes(i.kind))
.map((i) => ({
id: i.id,
name: i.name,
kind: i.kind,
url: i.url,
}));
};
export const sanitizeIntegrationIds = (selectedIds: string[], compatibleIntegrations: { id: string }[]): string[] => {
const compatibleIds = new Set(compatibleIntegrations.map((i) => i.id));
return selectedIds.filter((id) => compatibleIds.has(id));
};
export const resolvePreviewIntegrationIds = ({
validIntegrationIds,
supportsMock,
mockIntegration,
}: {
validIntegrationIds: string[];
supportsMock: boolean;
mockIntegration: { id: string } | null | undefined;
}): string[] => {
if (validIntegrationIds.length > 0) return validIntegrationIds;
if (supportsMock && mockIntegration) return [mockIntegration.id];
return [];
};
export const computeInitialIntegrationIds = ({
hasIntegrationSupport,
maxIntegrations = Infinity,
compatibleIntegrations,
}: {
hasIntegrationSupport: boolean;
maxIntegrations?: number;
compatibleIntegrations: { id: string }[];
}): string[] => {
if (!hasIntegrationSupport) return [];
if (compatibleIntegrations.length === 1 && compatibleIntegrations[0]) {
return [compatibleIntegrations[0].id];
}
return compatibleIntegrations.slice(0, maxIntegrations).map((i) => i.id);
};
export const appendOrReplaceIntegrationId = ({
currentIds,
newId,
maxIntegrations = Infinity,
}: {
currentIds: string[];
newId: string;
maxIntegrations?: number;
}): string[] => {
return [...(maxIntegrations > 1 ? currentIds : []), newId];
};
describe("Advanced Add App - Integration ID Sanitization & Mock Fallback", () => {
describe("getCompatibleIntegrations", () => {
it("returns empty array when supportedIntegrations is undefined or empty", () => {
const integrations = [{ id: "1", name: "Sonarr", kind: "sonarr" as IntegrationKind }];
expect(getCompatibleIntegrations(undefined, integrations)).toEqual([]);
expect(getCompatibleIntegrations([], integrations)).toEqual([]);
});
it("filters integration data to only include kinds supported by the widget", () => {
const integrations = [
{ id: "sonarr-1", name: "Sonarr Instance", kind: "sonarr" as IntegrationKind, url: "http://sonarr.local" },
{ id: "radarr-1", name: "Radarr Instance", kind: "radarr" as IntegrationKind, url: "http://radarr.local" },
{ id: "plex-1", name: "Plex Server", kind: "plex" as IntegrationKind, url: "http://plex.local" },
];
const compatible = getCompatibleIntegrations(["sonarr", "radarr"], integrations);
expect(compatible).toEqual([
{ id: "sonarr-1", name: "Sonarr Instance", kind: "sonarr", url: "http://sonarr.local" },
{ id: "radarr-1", name: "Radarr Instance", kind: "radarr", url: "http://radarr.local" },
]);
});
it("returns empty array when integrationData is undefined", () => {
expect(getCompatibleIntegrations(["sonarr"], undefined)).toEqual([]);
});
});
describe("sanitizeIntegrationIds", () => {
it("filters out IDs that are not present in compatible integrations", () => {
const compatible = [{ id: "valid-1" }, { id: "valid-2" }];
const selected = ["valid-1", "stale-deleted-id", "valid-2", "incompatible-service-id"];
const sanitized = sanitizeIntegrationIds(selected, compatible);
expect(sanitized).toEqual(["valid-1", "valid-2"]);
});
it("returns empty array when no selected IDs match compatible integrations", () => {
const compatible = [{ id: "valid-1" }];
const selected = ["unknown-1", "unknown-2"];
expect(sanitizeIntegrationIds(selected, compatible)).toEqual([]);
});
it("returns empty array when compatible list is empty", () => {
expect(sanitizeIntegrationIds(["any-id"], [])).toEqual([]);
});
});
describe("resolvePreviewIntegrationIds (mock fallback logic)", () => {
const mockIntegration = { id: "mock-instance-id" };
it("uses validIntegrationIds when at least one valid integration is selected", () => {
const previewIds = resolvePreviewIntegrationIds({
validIntegrationIds: ["real-service-1"],
supportsMock: true,
mockIntegration,
});
expect(previewIds).toEqual(["real-service-1"]);
});
it("falls back to mock integration when validIntegrationIds is empty and widget supports mock", () => {
const previewIds = resolvePreviewIntegrationIds({
validIntegrationIds: [],
supportsMock: true,
mockIntegration,
});
expect(previewIds).toEqual(["mock-instance-id"]);
});
it("returns empty array when validIntegrationIds is empty and widget does NOT support mock", () => {
const previewIds = resolvePreviewIntegrationIds({
validIntegrationIds: [],
supportsMock: false,
mockIntegration,
});
expect(previewIds).toEqual([]);
});
it("returns empty array when validIntegrationIds is empty and mockIntegration is null/undefined", () => {
const previewIds = resolvePreviewIntegrationIds({
validIntegrationIds: [],
supportsMock: true,
mockIntegration: null,
});
expect(previewIds).toEqual([]);
});
it("prefers real integrations over mock integration even if mock is available", () => {
const previewIds = resolvePreviewIntegrationIds({
validIntegrationIds: ["plex-1", "jellyfin-1"],
supportsMock: true,
mockIntegration,
});
expect(previewIds).toEqual(["plex-1", "jellyfin-1"]);
expect(previewIds).not.toContain("mock-instance-id");
});
});
describe("computeInitialIntegrationIds", () => {
it("returns empty array when widget has no integration support", () => {
const initial = computeInitialIntegrationIds({
hasIntegrationSupport: false,
compatibleIntegrations: [{ id: "1" }],
});
expect(initial).toEqual([]);
});
it("auto-selects single choice when exactly 1 compatible integration exists", () => {
const initial = computeInitialIntegrationIds({
hasIntegrationSupport: true,
maxIntegrations: 5,
compatibleIntegrations: [{ id: "only-one" }],
});
expect(initial).toEqual(["only-one"]);
});
it("slices compatible integrations up to maxIntegrations when multiple exist", () => {
const compatible = [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }];
const singleSelect = computeInitialIntegrationIds({
hasIntegrationSupport: true,
maxIntegrations: 1,
compatibleIntegrations: compatible,
});
expect(singleSelect).toEqual(["1"]);
const multiSelect = computeInitialIntegrationIds({
hasIntegrationSupport: true,
maxIntegrations: 2,
compatibleIntegrations: compatible,
});
expect(multiSelect).toEqual(["1", "2"]);
const unlimited = computeInitialIntegrationIds({
hasIntegrationSupport: true,
compatibleIntegrations: compatible,
});
expect(unlimited).toEqual(["1", "2", "3", "4"]);
});
});
describe("appendOrReplaceIntegrationId (inline create & demo service connection)", () => {
it("replaces existing selection when maxIntegrations is 1 (single-select)", () => {
const result = appendOrReplaceIntegrationId({
currentIds: ["old-id"],
newId: "new-id",
maxIntegrations: 1,
});
expect(result).toEqual(["new-id"]);
});
it("appends to existing selection when maxIntegrations is greater than 1 (multi-select)", () => {
const result = appendOrReplaceIntegrationId({
currentIds: ["service-1", "service-2"],
newId: "service-3",
maxIntegrations: 3,
});
expect(result).toEqual(["service-1", "service-2", "service-3"]);
});
it("appends to empty selection correctly for both single and multi-select", () => {
expect(
appendOrReplaceIntegrationId({
currentIds: [],
newId: "created-id",
maxIntegrations: 1,
}),
).toEqual(["created-id"]);
expect(
appendOrReplaceIntegrationId({
currentIds: [],
newId: "created-id",
maxIntegrations: 5,
}),
).toEqual(["created-id"]);
});
});
});
File diff suppressed because it is too large. Load diff
-472
View File
@@ -1,472 +0,0 @@
import path from "node:path";
import type { Browser, BrowserContext, Page } from "@playwright/test";
import { chromium, expect } from "@playwright/test";
import { createId } from "@paralleldrive/cuid2";
import { eq } from "drizzle-orm";
import { stringify } from "superjson";
import { afterAll, beforeAll, beforeEach, describe, test } from "vitest";
import {
apps,
boards,
integrations,
itemLayouts,
items,
layouts,
sectionLayouts,
sections,
users,
} from "../../../packages/db/schema/sqlite";
import { ItemSelectActions } from "../../shared/actions/item-select-actions";
import { createHomarrContainer } from "../../shared/create-homarr-container";
import { createSqliteDbFileAsync } from "../../shared/e2e-db";
import { loginAsync } from "../../shared/login";
import { seedAdminUserAsync } from "../../shared/seed-admin-user";
const credentials = { username: "admin", password: "Comp(exP4sswOrd" };
const boardName = "advanced-add-app-e2e";
describe("Advanced Add App & Widget Modal E2E", () => {
let browser: Browser;
let baseUrl: string;
let stopContainerAsync: (() => Promise<void>) | undefined;
let context: BrowserContext;
let page: Page;
let actions: ItemSelectActions;
const boardId = createId();
const layoutId = createId();
const mainSectionId = createId();
const containerSectionId = createId();
const nextcloudAppId = createId();
const plexAppId = createId();
const homeAssistantAppId = createId();
beforeAll(async () => {
const { db, localMountPath } = await createSqliteDbFileAsync();
const { userId } = await seedAdminUserAsync(db, credentials);
// Seed test board with main canvas section & container section
await db.insert(boards).values({
id: boardId,
name: boardName,
creatorId: userId,
isPublic: false,
});
await db.insert(layouts).values({
id: layoutId,
name: "Base",
columnCount: 12,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
boardId,
role: "base",
});
await db.insert(sections).values([
{
id: mainSectionId,
boardId,
kind: "empty",
xOffset: 0,
yOffset: 0,
},
{
id: containerSectionId,
boardId,
kind: "container",
xOffset: 0,
yOffset: 4,
options: stringify({
title: "Media Hub Container",
customCssClasses: [],
borderColor: "",
showLabel: true,
collapsible: false,
showOpenAll: false,
}),
},
]);
await db.insert(sectionLayouts).values({
sectionId: containerSectionId,
layoutId,
parentSectionId: mainSectionId,
xOffset: 0,
yOffset: 4,
width: 12,
height: 4,
});
// Seed selectable test apps
await db.insert(apps).values([
{
id: nextcloudAppId,
name: "Nextcloud",
description: "Self-hosted productivity platform",
iconUrl: "/favicon.ico",
href: "https://nextcloud.example.internal",
},
{
id: plexAppId,
name: "Plex Media Server",
description: "Stream movies, TV shows, and music",
iconUrl: "/favicon.ico",
href: "https://plex.example.internal",
},
{
id: homeAssistantAppId,
name: "Home Assistant",
description: "Open source home automation",
iconUrl: "/favicon.ico",
href: "https://homeassistant.example.internal",
},
]);
// Set user defaults & mark tours completed
await db
.update(users)
.set({
homeBoardId: boardId,
completedBoardTour: true,
completedManageTour: true,
})
.where(eq(users.id, userId));
const container = await createHomarrContainer({
environment: { AUTH_PROVIDERS: "credentials" },
mounts: { "/appdata": localMountPath },
}).start();
stopContainerAsync = async () => {
await container.stop();
};
baseUrl = `http://${container.getHost()}:${container.getMappedPort(7575)}`;
browser = await chromium.launch();
}, 120_000);
afterAll(async () => {
await browser?.close();
await stopContainerAsync?.();
});
beforeEach(async () => {
context = await browser.newContext({
viewport: { width: 1440, height: 900 },
reducedMotion: "reduce",
});
page = await context.newPage();
actions = new ItemSelectActions(page);
await loginAsync({
page,
baseUrl,
credentials,
destination: `/boards/${boardName}`,
});
// Dismiss tour overlay if present
const tourOverlay = page.locator('[data-onboarding-tour-overlay="true"]');
const skipTour = page.getByRole("button", { name: "Skip tour", exact: true });
try {
if (await skipTour.isVisible({ timeout: 2_000 })) {
await skipTour.click();
await expect(tourOverlay).toHaveCount(0);
}
} catch {
// Tour not present, proceed
}
});
// =========================================================================
// 1. Opening the Add App / Widget modal from edit mode
// =========================================================================
test("opens the ItemSelectModal from edit mode with full catalog and controls", async () => {
await actions.enterEditModeAsync();
// Verify edit mode toggle is active
const editToggle = page.getByTestId("board-edit-mode-toggle");
await expect(editToggle).toHaveAttribute("aria-pressed", "true");
// Open modal via header action
const headerAddButton = page.locator('button[aria-label="Add board content"]');
await expect(headerAddButton).toBeVisible();
await headerAddButton.click();
// Verify Universal Create dialog lists creation actions
const createDialog = page.getByRole("dialog");
await expect(createDialog).toBeVisible();
const widgetOption = createDialog
.locator("button")
.filter({ hasText: /Widget/i })
.first();
await expect(widgetOption).toBeVisible();
await widgetOption.click();
// Verify Choose item to add modal is open
const itemModal = page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first();
await expect(itemModal).toBeVisible();
// Verify search input is rendered and autofocused
const searchInput = itemModal
.locator('input[placeholder*="Filter items"], input[aria-label*="Filter items"]')
.first();
await expect(searchInput).toBeVisible();
// Verify destination selector is present because multiple sections exist
const destinationSelect = itemModal.locator('input[aria-label="Add to"]').first();
await expect(destinationSelect).toBeVisible();
// Verify multiple widget cards are rendered in grid
const widgetCards = itemModal.locator("button[aria-label]");
expect(await widgetCards.count()).toBeGreaterThan(5);
});
// =========================================================================
// 2. Search & filtering widgets by name and supported integration names
// =========================================================================
test("filters widgets by name and supported integration names with empty state fallback", async () => {
await actions.openItemSelectModalAsync();
const modal = page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first();
// Filter by widget name: "Clock"
await actions.searchWidgetsAsync("Clock");
const clockCard = modal.locator('button[aria-label="Clock"]').first();
await expect(clockCard).toBeVisible();
// Non-matching widget should not be visible
const weatherCard = modal.locator('button[aria-label="Weather"]').first();
await expect(weatherCard).not.toBeVisible();
// Filter by supported integration name: "Sonarr"
await actions.searchWidgetsAsync("Sonarr");
// Widgets that support Sonarr (like Calendar or Releases) should appear
const sonarrMatchingCards = modal.locator("button[aria-label]");
expect(await sonarrMatchingCards.count()).toBeGreaterThan(0);
// Filter by non-existent query
await actions.searchWidgetsAsync("nonexistent-search-term-xyz");
await expect(modal.getByText("No results")).toBeVisible();
// Clear search restores all widget cards
await actions.clearSearchAsync();
expect(await modal.locator("button[aria-label]").count()).toBeGreaterThan(5);
});
// =========================================================================
// 3. Selecting a widget to open master-detail 5×3 live preview
// =========================================================================
test("opens 5×3 master-detail live preview on card click and returns to grid on close", async () => {
await actions.openItemSelectModalAsync();
const modal = page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first();
// Select Clock widget to trigger master-detail layout
await actions.selectWidgetAsync("Clock");
// Verify preview header banner
await expect(modal.getByText("Live Preview & Options")).toBeVisible();
await expect(modal.getByText("Clock").first()).toBeVisible();
// Verify 5×3 preview frame container
await expect(modal.getByText("Preview (5×3)")).toBeVisible();
// Verify Add button in preview header
const addBtn = modal.locator('button[type="button"]').filter({ hasText: /^Add$/i }).first();
await expect(addBtn).toBeVisible();
// Verify Widget Settings section
await expect(modal.getByText("Widget Settings")).toBeVisible();
// Close preview pane
await actions.closePreviewAsync();
// Verify preview pane is closed and full catalog returns
await expect(modal.getByText("Live Preview & Options")).not.toBeVisible();
await expect(modal.locator('button[aria-label="Clock"]').first()).toBeVisible();
});
// =========================================================================
// 4. Interactive widget option configuration inside the settings panel
// =========================================================================
test("interactively configures widget options inside the settings panel", async () => {
await actions.openItemSelectModalAsync();
const modal = page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first();
// Select Clock widget
await actions.selectWidgetAsync("Clock");
// Toggle "Show custom title" switch on
const customTitleSwitch = modal.getByLabel(/custom title/i).first();
await expect(customTitleSwitch).toBeVisible();
await customTitleSwitch.click();
// Custom title text input becomes conditionally visible
const customTitleInput = modal.getByRole("textbox", { name: /custom title/i }).first();
await expect(customTitleInput).toBeVisible({ timeout: 5_000 });
await customTitleInput.fill("E2E Interactive Clock");
// Toggle "Use custom timezone" switch on
const timezoneSwitch = modal.getByLabel(/custom timezone/i).first();
if (await timezoneSwitch.isVisible()) {
await timezoneSwitch.click();
const timezoneSelect = modal.locator('input[placeholder*="timezone" i], input[aria-label*="timezone" i]').first();
await expect(timezoneSelect).toBeVisible({ timeout: 5_000 });
}
// Toggle "Show weather" switch on
const weatherSwitch = modal.getByLabel(/weather/i).first();
if (await weatherSwitch.isVisible()) {
await weatherSwitch.click();
// Location input should appear
await expect(modal.getByText("Brisbane").or(modal.locator('input[value*="Brisbane"]')).first()).toBeVisible({
timeout: 5_000,
});
}
});
// =========================================================================
// 5. In-place / inline integration connection form
// =========================================================================
test("supports in-place integration connection form within widget settings", async () => {
await actions.openItemSelectModalAsync();
const modal = page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first();
// Search and select Sonarr widget
await actions.searchWidgetsAsync("Media releases");
const mediaReleasesCard = modal.locator('button[aria-label*="Media releases" i]').first();
if (await mediaReleasesCard.isVisible()) {
await mediaReleasesCard.click();
await modal.locator('text="Live Preview & Options"').waitFor({ state: "visible", timeout: 15_000 });
// Check if inline connect form is visible
const connectHeader = modal.locator('text="Connect"').first();
if (await connectHeader.isVisible()) {
await expect(connectHeader).toBeVisible();
}
}
});
// =========================================================================
// 6. Adding a widget directly to the board and verifying it appears in target section
// =========================================================================
test("adds configured widget to board and verifies placement and undo notification", async () => {
await actions.openItemSelectModalAsync();
const modal = page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first();
// Search and select Clock widget
await actions.searchWidgetsAsync("Clock");
await actions.selectWidgetAsync("Clock");
// Click Add button
await actions.submitAddWidgetAsync();
// Modal should close
await expect(modal).not.toBeVisible({ timeout: 10_000 });
// Success notification with Undo button should appear
await expect(page.getByText("Widget added")).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: "Undo", exact: true })).toBeVisible();
// Clock widget container should be present on the board canvas
const clockWidget = page.locator(".clock-widget-container, [data-kind='clock']").first();
await expect(clockWidget).toBeVisible({ timeout: 15_000 });
});
// =========================================================================
// 7. Destination lane / container section selection
// =========================================================================
test("places widget into selected destination container section", async () => {
await actions.openItemSelectModalAsync();
const modal = page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first();
// Select container section from destination dropdown
await actions.selectDestinationAsync("Media Hub Container");
// Select Weather or Clock widget
await actions.searchWidgetsAsync("Clock");
await actions.selectWidgetAsync("Clock");
// Click Add
await actions.submitAddWidgetAsync();
await expect(modal).not.toBeVisible({ timeout: 10_000 });
// Verify notification
await expect(page.getByText("Widget added")).toBeVisible({ timeout: 10_000 });
// Verify item is placed inside container section on the board
const containerSection = page
.locator(`[data-section-id="${containerSectionId}"], [data-id="${containerSectionId}"]`)
.first();
if (await containerSection.isVisible()) {
await expect(containerSection.locator(".clock-widget-container, [data-kind='clock']").first()).toBeVisible({
timeout: 10_000,
});
}
});
// =========================================================================
// 8. App selection flow with SelectableCard grid
// =========================================================================
test("opens AppSelectModal, filters with SelectableCard grid, and multi-selects apps to board", async () => {
await actions.enterEditModeAsync();
// Open Universal Create modal
const headerAddButton = page.locator('button[aria-label="Add board content"]');
await headerAddButton.click();
// Click "Add app"
const appOption = page
.getByRole("dialog")
.locator("button")
.filter({ hasText: /Add app/i })
.first();
await appOption.waitFor({ state: "visible", timeout: 10_000 });
await appOption.click();
// Verify AppSelectModal opens
const appModal = page.getByRole("dialog").filter({ hasText: "Select an app to add to this board" }).first();
await expect(appModal).toBeVisible();
// Verify "+ Create app" / "Custom Application" dashed card is present
await expect(appModal.getByText("Custom Application")).toBeVisible();
// Search filter for "Nextcloud"
const appSearchInput = appModal
.locator('input[placeholder*="Search for an app" i], input[aria-label*="Search for an app" i]')
.first();
await appSearchInput.fill("Nextcloud");
await page.waitForTimeout(200);
// Verify Nextcloud is shown, Plex is filtered out
await expect(appModal.locator('button[aria-label="Nextcloud"]').first()).toBeVisible();
await expect(appModal.locator('button[aria-label="Plex Media Server"]')).not.toBeVisible();
// Clear search
await appSearchInput.clear();
await page.waitForTimeout(200);
// Multi-select Nextcloud and Plex Media Server
const nextcloudCard = appModal.locator('button[aria-label="Nextcloud"]').first();
const plexCard = appModal.locator('button[aria-label="Plex Media Server"]').first();
await nextcloudCard.click();
await plexCard.click();
// Multi-select footer bar appears
await expect(appModal.getByText("2 apps selected")).toBeVisible();
const submitAppsBtn = appModal.getByRole("button", { name: /Add \(2\)/i });
await expect(submitAppsBtn).toBeVisible();
// Click Add (2)
await submitAppsBtn.click();
// Modal closes and apps appear on the board
await expect(appModal).not.toBeVisible({ timeout: 10_000 });
await expect(page.getByText("Nextcloud").first()).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("Plex Media Server").first()).toBeVisible({ timeout: 15_000 });
});
});