From 7690e5e542a0b386ea1d5ef43839a9b10b828939 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 21 Aug 2026 00:12:32 +0200 Subject: [PATCH] refactor(board): restore simple add item workflow with SelectableCard and clean modal flow --- .../items/item-select-error-boundary.spec.tsx | 304 ------ ...em-select-integration-sanitization.spec.ts | 254 ----- .../board/items/item-select-modal.tsx | 983 +++--------------- e2e/specs/board/advanced-add-app.spec.ts | 472 --------- 4 files changed, 164 insertions(+), 1849 deletions(-) delete mode 100644 apps/nextjs/src/components/board/items/item-select-error-boundary.spec.tsx delete mode 100644 apps/nextjs/src/components/board/items/item-select-integration-sanitization.spec.ts delete mode 100644 e2e/specs/board/advanced-add-app.spec.ts diff --git a/apps/nextjs/src/components/board/items/item-select-error-boundary.spec.tsx b/apps/nextjs/src/components/board/items/item-select-error-boundary.spec.tsx deleted file mode 100644 index ed80c5abb..000000000 --- a/apps/nextjs/src/components/board/items/item-select-error-boundary.spec.tsx +++ /dev/null @@ -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 = { - "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; - previewIntegrationIds: string[]; - hasIntegrationSupport: boolean; - integrationsRequired: boolean; - onResetCallback?: () => void; - widgetComponent: (props: { options: Record; integrationIds: string[] }) => JSX.Element; -}) => { - return ( -
-
Add Widget Header
-
- - {({ reset }) => ( - { - reset(); - onResetCallback?.(); - }} - resetKeys={[options, previewIntegrationIds]} - fallbackRender={({ resetErrorBoundary, error }) => ( -
- -
- )} - > - - {widgetComponent({ options, integrationIds: previewIntegrationIds })} -
- )} -
-
-
Add Widget Footer
-
- ); -}; - -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( - - } - /> - , - ), - ); - - // 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 = () =>
Healthy Widget
; - - await act(async () => - root.render( - - } - /> - , - ), - ); - - // 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[] }) => ( -
Widget rendered with {integrationIds.length} integrations
- ); - - await act(async () => - root.render( - - } - /> - , - ), - ); - - 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 = () =>
Clock Widget
; - - await act(async () => - root.render( - - } - /> - , - ), - ); - - 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
Active with: {integrationIds.join(", ")}
; - }; - - const TestHarness = () => { - const [integrationIds, setIntegrationIds] = useState([]); - const [options, setOptions] = useState>({ query: "default" }); - - return ( -
- - - } - /> -
- ); - }; - - await act(async () => - root.render( - - - , - ), - ); - - // 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
Recovered Content
; - }; - - await act(async () => - root.render( - - } - /> - , - ), - ); - - 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(); - }); -}); diff --git a/apps/nextjs/src/components/board/items/item-select-integration-sanitization.spec.ts b/apps/nextjs/src/components/board/items/item-select-integration-sanitization.spec.ts deleted file mode 100644 index 1326bcf95..000000000 --- a/apps/nextjs/src/components/board/items/item-select-integration-sanitization.spec.ts +++ /dev/null @@ -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"]); - }); - }); -}); diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index 95c801d75..8a08889ad 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -1,23 +1,14 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { ComponentType } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Avatar, Badge, Box, Button, - Card, Center, - CloseButton, Divider, - Flex, Group, Image, Input, - Loader, - LoadingOverlay, - Paper, - ScrollArea, - SegmentedControl, Select, SimpleGrid, Stack, @@ -26,10 +17,7 @@ import { Tooltip, } from "@mantine/core"; import { notifications } from "@mantine/notifications"; -import { useElementSize } from "@mantine/hooks"; -import { IconApi, IconBuildingStore, IconPlus, IconSearch } from "@tabler/icons-react"; -import { QueryErrorResetBoundary } from "@tanstack/react-query"; -import { ErrorBoundary } from "react-error-boundary"; +import { IconApi, IconBuildingStore, IconSearch } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; @@ -46,26 +34,15 @@ import { widgetKindsWithOptionalIntegrations, } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; -import { createModal, useModalAction } from "@homarr/modals"; +import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; -import { IntegrationAvatar, SelectableCard } from "@homarr/ui"; +import { SelectableCard } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui"; import { widgetCatalogIcons } from "@homarr/widgets/catalog"; -import { WidgetError } from "@homarr/widgets/errors"; -import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; -import { getInputForType } from "@homarr/widgets/inputs"; -import { FormProvider, useForm } from "@homarr/widgets/inputs/form"; -import { loadWidgetDefinition, loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; -import type { WidgetComponentProps, WidgetDefinition } from "@homarr/widgets/definition"; -import type { OptionsBuilderResult } from "@homarr/widgets/options"; -import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select"; -import { WidgetIntegrationSelect } from "@homarr/widgets/widget-integration-select"; +import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; -import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form"; -import type { CreatedIntegrationResult } from "~/app/[locale]/manage/integrations/new/_integration-new-form"; -import type { WidgetEditModalState } from "@homarr/widgets/modals"; import type { EmptySection } from "~/app/[locale]/boards/_types"; import { useSetupAnalytics } from "~/components/create/setup-analytics"; import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; @@ -87,7 +64,6 @@ interface ItemSelectModalContentProps { isAdmin: boolean; canCreateIntegration: boolean; initialSearch?: string; - initialWidgetKind?: WidgetKind; } interface WidgetItemData { @@ -98,6 +74,8 @@ interface WidgetItemData { description: string; } +const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 }; + const ItemSelectModalContent = ({ actions, integrationData, @@ -106,7 +84,6 @@ const ItemSelectModalContent = ({ isAdmin, canCreateIntegration, initialSearch = "", - initialWidgetKind, }: ItemSelectModalContentProps) => { const [search, setSearch] = useState(initialSearch); const t = useI18n(); @@ -201,9 +178,6 @@ const ItemSelectModalContent = ({ [customWidgetDefs, search], ); - // Selected item state for live preview pane - only shown when explicitly selected - const [selectedKind, setSelectedKind] = useState(initialWidgetKind ?? null); - const notifyDefinitionLoadError = (error: unknown) => { showErrorNotification({ title: t("common.error"), @@ -278,7 +252,7 @@ const ItemSelectModalContent = ({ options, integrationIds: [], }, - onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }: WidgetEditModalState) => { + onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => { const updatedBoard = createItem({ id: itemId, kind: "customApi", @@ -294,9 +268,7 @@ const ItemSelectModalContent = ({ settings, }, { - title(translate) { - return `${translate("item.edit.title")} - ${customWidgetDefinition.name}`; - }, + title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`, }, ); } catch (error) { @@ -307,15 +279,12 @@ const ItemSelectModalContent = ({ } }; - const handleDirectAdd = async ( - kind: WidgetKind, - configuredOptions?: Record, - integrationIds?: string[], - ) => { + const handleAdd = async (kind: WidgetKind) => { if (!tryLockSelection(selectionLock)) return; flowStartedAt.current = performance.now(); trackSetup("widget-started", { entryPoint: "board", intent: kind }); setLoadingSelection(kind); + preloadWidgetEditModal(); try { const definition = await loadWidgetDefinition(kind); const hasIntegrationSupport = "supportedIntegrations" in definition; @@ -330,10 +299,48 @@ const ItemSelectModalContent = ({ const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity; const matchingIntegrationCount = Math.min(matchingIntegrations.length, maxIntegrations); const itemId = createId(); - const defaultOptions = configuredOptions ?? reduceWidgetOptionsWithDefinition(definition, settings); - const selectedIntegrationIds = - integrationIds ?? matchingIntegrations.slice(0, maxIntegrations).map((integration) => integration.id); - + const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings); + const openEditor = ( + availableIntegrations: { id: string; name: string; url: string; kind: IntegrationKind }[], + ) => { + const selectedIntegrationIds = availableIntegrations + .slice(0, maxIntegrations) + .map((integration) => integration.id); + openEditModal( + { + kind, + definition, + value: { + advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, + options: defaultOptions, + integrationIds: selectedIntegrationIds, + }, + onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => { + const updatedBoard = createItem({ + id: itemId, + kind, + options, + integrationIds: newIntegrationIds, + targetSectionId: effectiveSectionId ?? undefined, + advancedOptions, + }); + if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return; + trackSetup("widget-completed", { + entryPoint: "board", + intent: kind, + outcome: "completed", + elapsedMs: flowStartedAt.current ? Math.round(performance.now() - flowStartedAt.current) : undefined, + }); + }, + integrationData: availableIntegrations, + integrationSupport: hasIntegrationSupport, + settings, + }, + { + title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`, + }, + ); + }; const integrationsRequired = hasIntegrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false); @@ -352,9 +359,10 @@ const ItemSelectModalContent = ({ return; } + actions.closeModal(); openIntegrationModal({ allowedKinds: (definition.supportedIntegrations ?? []).filter((integration) => integration !== "mock"), - onSuccess: (result: CreatedIntegrationResult | undefined) => { + onSuccess: (result) => { if (result) { trackSetup("dependency-resolved-inline", { entryPoint: "board", @@ -362,15 +370,7 @@ const ItemSelectModalContent = ({ outcome: "continued", canResolveInline: true, }); - const updatedBoard = createItem({ - id: itemId, - kind, - options: defaultOptions, - integrationIds: [result.integration.id], - targetSectionId: effectiveSectionId ?? undefined, - advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, - }); - notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`)); + openEditor([result.integration]); } }, }); @@ -378,21 +378,7 @@ const ItemSelectModalContent = ({ } actions.closeModal(); - const updatedBoard = createItem({ - id: itemId, - kind, - options: defaultOptions, - integrationIds: selectedIntegrationIds, - targetSectionId: effectiveSectionId ?? undefined, - advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, - }); - if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return; - trackSetup("widget-completed", { - entryPoint: "board", - intent: kind, - outcome: "completed", - elapsedMs: flowStartedAt.current ? Math.round(performance.now() - flowStartedAt.current) : undefined, - }); + openEditor(matchingIntegrations); } catch (error) { notifyDefinitionLoadError(error); } finally { @@ -401,12 +387,9 @@ const ItemSelectModalContent = ({ } }; - const selectedItemData = selectedKind ? items.find((item) => item.kind === selectedKind) : null; - const isPreviewOpen = Boolean(selectedItemData); - return ( - {/* Top Search and Placement Controls */} + {/* Top Search & Destination Selector */} @@ -443,741 +426,122 @@ const ItemSelectModalContent = ({ )} - {/* Animated Split Container */} - - {/* Left Column: Widget Cards Grid */} - - - - - {filteredItems.map((item) => ( - setSelectedKind((current) => (current === item.kind ? null : item.kind))} - disabled={loadingSelection !== null} - loading={loadingSelection === item.kind} - connectionStatus={getWidgetConnectionStatus({ - supportedIntegrations: item.supportedIntegrations, - availableKinds, - connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind), - })} - /> - ))} - - - {/* Custom Widgets Section for Admins */} - {isAdmin && ( - <> - - - - {t("customWidget.page.list.title")} - - - } - labelPosition="center" - my="md" - /> - - - {filteredCustomWidgets.map((def) => ( - void handleAddCustomWidget(def)} - aria-label={def.name} - icon={ - def.iconUrl ? ( - - ) : ( - - - - ) - } - title={def.name} - description={def.description ?? ""} - footerLeft={ - - Custom Widget - - } - /> - ))} - - - - - - - )} - - {filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && ( -
- {t("common.noResults")} -
- )} -
-
-
- - {/* Right Column: Live Preview & Interactive Settings Panel (Only shown when an item is selected) */} - {selectedItemData && ( - + {filteredItems.map((item) => ( + void handleAdd(item.kind)} + onFocus={() => { + void loadWidgetDefinition(item.kind).catch(() => undefined); + preloadWidgetEditModal(); }} - > - setSelectedKind(null)} - onOpenConnectIntegration={() => { - const supported = widgetIntegrationSupport[selectedItemData.kind] ?? []; - openIntegrationModal({ - allowedKinds: supported.filter((i) => i !== "mock"), - onSuccess: (result) => { - if (result) { - void ensureIntegrationDataAsync(); - } - }, - }); - }} - onAddWidget={(configuredOptions, integrationIds) => - void handleDirectAdd(selectedItemData.kind, configuredOptions, integrationIds) + onPointerEnter={() => { + void loadWidgetDefinition(item.kind).catch(() => undefined); + preloadWidgetEditModal(); + }} + disabled={loadingSelection !== null} + loading={loadingSelection === item.kind} + connectionStatus={getWidgetConnectionStatus({ + supportedIntegrations: item.supportedIntegrations, + availableKinds, + connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind), + })} + /> + ))} + + {isAdmin && ( + <> + + + + {t("customWidget.page.list.title")} + + } - isAdding={loadingSelection === selectedItemData.kind} + labelPosition="center" + my="sm" + style={{ gridColumn: "1 / -1" }} /> - + {filteredCustomWidgets.map((def) => ( + void handleAddCustomWidget(def)} + aria-label={def.name} + icon={ + def.iconUrl ? ( + + ) : ( + + + + ) + } + title={def.name} + description={def.description ?? ""} + footerLeft={ + + Custom Widget + + } + /> + ))} + + + + )} -
+ + + {filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && ( +
+ {t("common.noResults")} +
+ )}
); }; // ========================================================================= -// WidgetLivePreviewAndConfigPane: Asynchronously Loads Resources & Renders Content -// ========================================================================= -interface WidgetLivePreviewAndConfigPaneProps { - item: WidgetItemData; - boardId: string; - integrationData: RouterOutputs["integration"]["all"]; - canCreateIntegration: boolean; - onClose: () => void; - onOpenConnectIntegration: () => void; - onAddWidget: (options: Record, integrationIds: string[]) => void; - isAdding: boolean; -} - -const WidgetLivePreviewAndConfigPane = ({ - item, - boardId, - integrationData, - canCreateIntegration, - onClose, - onOpenConnectIntegration, - onAddWidget, - isAdding, -}: WidgetLivePreviewAndConfigPaneProps) => { - const t = useI18n(); - const [resources, setResources] = useState<{ - definition: WidgetDefinition; - Component: ComponentType>; - } | null>(null); - const [isLoading, setIsLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - - const loadResources = useCallback(() => { - let active = true; - setIsLoading(true); - setLoadError(null); - loadWidgetResources(item.kind) - .then((res) => { - if (active) { - setResources(res); - setIsLoading(false); - } - }) - .catch((err) => { - if (active) { - setLoadError(err instanceof Error ? err : new Error(String(err))); - setIsLoading(false); - } - }); - return () => { - active = false; - }; - }, [item.kind]); - - useEffect(() => { - return loadResources(); - }, [loadResources]); - - if (loadError) { - return ( - -
- - - - - - {t("common.error")} - - - {loadError.message} - - - - - - -
-
- ); - } - - if (isLoading || !resources) { - return ( - -
- -
-
- ); - } - - return ( - ( - -
- - - - - - Failed to load widget preview - - - {error instanceof Error ? error.message : String(error)} - - - - - - -
-
- )} - > - -
- ); -}; - -// ========================================================================= -// WidgetLivePreviewAndConfigContent: Form State & Real-time Live Preview -// ========================================================================= -interface WidgetLivePreviewAndConfigContentProps { - item: WidgetItemData; - definition: WidgetDefinition; - Component: ComponentType>; - boardId: string; - integrationData: RouterOutputs["integration"]["all"]; - canCreateIntegration: boolean; - onClose: () => void; - onOpenConnectIntegration: () => void; - onAddWidget: (options: Record, integrationIds: string[]) => void; - isAdding: boolean; -} - -const WidgetLivePreviewAndConfigContent = ({ - item, - definition, - Component, - boardId, - integrationData, - canCreateIntegration, - onClose, - onOpenConnectIntegration, - onAddWidget, - isAdding, -}: WidgetLivePreviewAndConfigContentProps) => { - const t = useI18n(); - const settings = useSettings(); - - const { data: selectableApps } = clientApi.app.selectable.useQuery(undefined, { - enabled: item.kind === "app", - }); - - const utils = clientApi.useUtils(); - const supportedKinds = useMemo(() => { - if (!("supportedIntegrations" in definition)) return []; - return (definition.supportedIntegrations ?? []).filter((k) => k !== "mock"); - }, [definition]); - - const [optimisticIntegrations, setOptimisticIntegrations] = useState([]); - - // Filter integrations compatible with this widget - const compatibleIntegrations: IntegrationSelectOption[] = useMemo(() => { - if (!("supportedIntegrations" in definition)) return []; - const supported = definition.supportedIntegrations ?? []; - const existing = (integrationData ?? []) - .filter((i) => supported.includes(i.kind)) - .map((i) => ({ - id: i.id, - name: i.name, - kind: i.kind, - url: i.url, - })); - const newOptimistic = optimisticIntegrations.filter( - (opt) => !existing.some((e) => e.id === opt.id) && supported.includes(opt.kind), - ); - return [...existing, ...newOptimistic]; - }, [definition, integrationData, optimisticIntegrations]); - - const [showInlineConnect, setShowInlineConnect] = useState(compatibleIntegrations.length === 0); - const [inlineConnectKind, setInlineConnectKind] = useState(supportedKinds[0] ?? null); - - const supportsMock = - "supportedIntegrations" in definition && (definition.supportedIntegrations ?? []).includes("mock"); - const mockIntegration = supportsMock ? (integrationData ?? []).find((i) => i.kind === "mock") : null; - - // Initial options - const initialOptions = useMemo(() => { - const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings); - if (item.kind === "app" && !defaultOptions.appId && selectableApps?.[0]) { - return { - ...defaultOptions, - appId: selectableApps[0].id, - }; - } - return defaultOptions; - }, [definition, item.kind, selectableApps, settings]); - - // Initial integration IDs: auto-select single choice if available - const initialIntegrationIds = useMemo(() => { - if (!("supportedIntegrations" in definition)) return []; - const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity; - if (compatibleIntegrations.length === 1 && compatibleIntegrations[0]) { - return [compatibleIntegrations[0].id]; - } - return compatibleIntegrations.slice(0, maxIntegrations).map((i) => i.id); - }, [compatibleIntegrations, definition]); - - const form = useForm({ - initialValues: { - options: initialOptions, - integrationIds: initialIntegrationIds, - }, - }); - - // Auto-sync selectable apps when loaded - useEffect(() => { - if (item.kind === "app" && !form.values.options.appId && selectableApps?.[0]?.id) { - form.setFieldValue("options.appId", selectableApps[0].id); - } - }, [item.kind, selectableApps, form]); - - const validIntegrationIds = useMemo(() => { - const compatibleIds = new Set(compatibleIntegrations.map((i) => i.id)); - return form.values.integrationIds.filter((id) => compatibleIds.has(id)); - }, [form.values.integrationIds, compatibleIntegrations]); - - const previewIntegrationIds = useMemo(() => { - if (validIntegrationIds.length > 0) return validIntegrationIds; - if (supportsMock && mockIntegration) return [mockIntegration.id]; - return []; - }, [validIntegrationIds, supportsMock, mockIntegration]); - - const handleInlineCreateSuccess = (result?: CreatedIntegrationResult) => { - void utils.integration.all.invalidate(); - if (result?.integration?.id) { - const newOption: IntegrationSelectOption = { - id: result.integration.id, - name: result.integration.name, - kind: result.integration.kind, - url: result.integration.url, - }; - setOptimisticIntegrations((prev) => [...prev, newOption]); - const nextIds = [...(maxIntegrations > 1 ? form.values.integrationIds : []), result.integration.id].slice( - -maxIntegrations, - ); - form.setFieldValue("integrationIds", nextIds); - } - setShowInlineConnect(false); - showSuccessNotification({ - message: t("integration.page.create.notification.success.message"), - }); - }; - - const optionsDefinition = useMemo(() => { - return definition.createOptions(settings) as Record; - }, [definition, settings]); - - const hasIntegrationSupport = "supportedIntegrations" in definition; - const integrationsRequired = - hasIntegrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false); - const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity; - - const { ref: previewContainerRef, width: previewWidth, height: previewHeight } = useElementSize(); - const board = useRequiredBoard(); - - const handleUpdatePartialOptions = useCallback( - ({ newOptions }: { newOptions: Record }) => { - form.setValues((prev) => ({ - ...prev, - options: { - ...prev.options, - ...newOptions, - }, - })); - }, - [form], - ); - - const handleAddCurrentWidget = () => { - const validation = form.validate(); - if (validation.hasErrors) return; - if (integrationsRequired && validIntegrationIds.length === 0) { - showErrorNotification({ - title: t("common.error"), - message: t("widget.common.error.noIntegration"), - }); - return; - } - onAddWidget(form.values.options, validIntegrationIds); - }; - - return ( - - {/* Header Banner */} - - - - - - - - {item.name} - - - Live Preview & Options - - - - - - - - - - - {/* Live Widget Preview Frame (Simulating a 5x3 board widget tile) */} - - - - Preview (5×3) - - - -
- - - - {({ reset }) => ( - ( -
- -
- )} - > - - -
- )} -
-
-
-
-
- - {/* Scrollable Configuration Form */} - - - Widget Settings - - - - - {/* Integration Configuration / In-Place Connection */} - {hasIntegrationSupport && ( - - - {/* Existing Integrations Dropdown (when not creating inline) */} - {compatibleIntegrations.length > 0 && !showInlineConnect && ( - - 1} - withAsterisk={integrationsRequired} - {...form.getInputProps("integrationIds")} - /> - {canCreateIntegration && supportedKinds.length > 0 && ( - - - - )} - - )} - - {/* Inline Connect Form */} - {showInlineConnect && canCreateIntegration && supportedKinds.length > 0 && ( - - - - - - Connect {getIntegrationName(inlineConnectKind ?? supportedKinds[0]!)} - - - {compatibleIntegrations.length > 0 && ( - - )} - - - {/* Multiple choice selector if widget supports >1 integration kind */} - {supportedKinds.length > 1 && ( - setInlineConnectKind(val as IntegrationKind)} - data={supportedKinds.map((kind) => ({ - value: kind, - label: getIntegrationName(kind), - }))} - /> - )} - - 0 ? () => setShowInlineConnect(false) : undefined} - /> - - )} - - {/* No Connected Services and cannot create */} - {compatibleIntegrations.length === 0 && !canCreateIntegration && ( - - - No Connected Services - - - This widget requires a connected service ( - {definition?.supportedIntegrations?.map(getIntegrationName).join(", ")}). - - - )} - - - )} - - {/* Dynamic Option Inputs */} - {Object.entries(optionsDefinition).map(([key, value]) => { - const InputComponent = getInputForType(value.type); - if ( - !InputComponent || - value.shouldHide?.( - form.values.options as never, - compatibleIntegrations - .filter(({ id }) => form.values.integrationIds.includes(id)) - .map(({ kind }) => kind), - ) - ) { - return null; - } - - return ( - - ); - })} - - {Object.keys(optionsDefinition).length === 0 && - (!hasIntegrationSupport || compatibleIntegrations.length === 0) && ( -
- - This widget does not require additional configuration. - -
- )} -
-
-
-
-
- ); -}; - -const Throw = ({ when, error }: { when: boolean; error: Error }) => { - if (when) throw error; - return null; -}; - -// ========================================================================= -// WidgetItem: Variant 3 (Dashboard Inset) with Variant 4 (Fuller Icon & MD text) +// WidgetItem: SelectableCard with Inset, Title, Status Badge, and Footer // ========================================================================= export const WidgetItem = ({ item, - isSelected, onSelect, + onFocus, + onPointerEnter, disabled, loading, connectionStatus, }: { item: WidgetItemData; - isSelected?: boolean; onSelect: () => void; + onFocus?: () => void; + onPointerEnter?: () => void; disabled: boolean; loading: boolean; connectionStatus: WidgetConnectionStatus; }) => { return ( @@ -1185,7 +549,7 @@ export const WidgetItem = ({ } title={item.name} - topRight={!isSelected ? : null} + topRight={} description={item.description} footerLeft={} /> @@ -1268,40 +632,21 @@ const ItemSelectModalFrame = ({ ); return ( - ( -
- - - {t("common.error")} - - - {error instanceof Error ? error.message : String(error)} - - - -
- )} - > - utils.integration.all.ensureData()} - isAdmin={isAdmin} - canCreateIntegration={canCreateIntegration} - initialWidgetKind={innerProps.initialWidgetKind} - initialSearch={ - innerProps.initialWidgetKind - ? t(`widget.${innerProps.initialWidgetKind}.name`) - : innerProps.initialIntegrationKind - ? getIntegrationName(innerProps.initialIntegrationKind) - : undefined - } - /> -
+ utils.integration.all.ensureData()} + isAdmin={isAdmin} + canCreateIntegration={canCreateIntegration} + initialSearch={ + innerProps.initialWidgetKind + ? t(`widget.${innerProps.initialWidgetKind}.name`) + : innerProps.initialIntegrationKind + ? getIntegrationName(innerProps.initialIntegrationKind) + : undefined + } + /> ); }; @@ -1311,5 +656,5 @@ export const ItemSelectModal = createModal<{ initialWidgetKind?: WidgetKind; }>((props) => ).withOptions({ defaultTitle: (t) => t("item.create.title"), - size: "xxl", + size: modalSizeSelect, }); diff --git a/e2e/specs/board/advanced-add-app.spec.ts b/e2e/specs/board/advanced-add-app.spec.ts deleted file mode 100644 index a8fa2bba8..000000000 --- a/e2e/specs/board/advanced-add-app.spec.ts +++ /dev/null @@ -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) | 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 }); - }); -});