diff --git a/apps/nextjs/src/components/board/items/item-select-data.spec.ts b/apps/nextjs/src/components/board/items/item-select-data.spec.ts index a9527a3ba..cc269c8b6 100644 --- a/apps/nextjs/src/components/board/items/item-select-data.spec.ts +++ b/apps/nextjs/src/components/board/items/item-select-data.spec.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +import type { IntegrationKind } from "@homarr/definitions"; + import { getWidgetConnectionStatus, resolveMatchingIntegrationsAsync, @@ -8,95 +10,254 @@ import { } from "./item-select-data"; describe("getWidgetConnectionStatus", () => { - const availableKinds = new Set(["sonarr" as const]); + const availableKinds = new Set(["sonarr", "plex"]); - it("reports widgets with a configured compatible integration as ready", () => { - expect( - getWidgetConnectionStatus({ - supportedIntegrations: ["sonarr", "radarr"], - availableKinds, - connectionOptional: false, - }), - ).toBe("ready"); + describe("ready status", () => { + it("returns 'ready' when at least one supported integration is in availableKinds", () => { + expect( + getWidgetConnectionStatus({ + supportedIntegrations: ["sonarr", "radarr"], + availableKinds, + connectionOptional: false, + }), + ).toBe("ready"); + }); + + it("returns 'ready' when all supported integrations are in availableKinds", () => { + expect( + getWidgetConnectionStatus({ + supportedIntegrations: ["sonarr", "plex"], + availableKinds, + connectionOptional: false, + }), + ).toBe("ready"); + }); + + it("returns 'ready' even if connectionOptional is true as long as a matching integration exists", () => { + expect( + getWidgetConnectionStatus({ + supportedIntegrations: ["sonarr"], + availableKinds, + connectionOptional: true, + }), + ).toBe("ready"); + }); }); - it("reports required integrations that are not configured as needing setup", () => { - expect( - getWidgetConnectionStatus({ - supportedIntegrations: ["radarr"], - availableKinds, - connectionOptional: false, - }), - ).toBe("needsSetup"); + describe("needsSetup status", () => { + it("returns 'needsSetup' when supported integrations are non-empty, none are available, and connectionOptional is false", () => { + expect( + getWidgetConnectionStatus({ + supportedIntegrations: ["radarr", "sabNzbd"], + availableKinds, + connectionOptional: false, + }), + ).toBe("needsSetup"); + }); + + it("returns 'needsSetup' when availableKinds is empty and connection is required", () => { + expect( + getWidgetConnectionStatus({ + supportedIntegrations: ["sonarr"], + availableKinds: new Set(), + connectionOptional: false, + }), + ).toBe("needsSetup"); + }); }); - it.each([ - { supportedIntegrations: [], connectionOptional: false }, - { supportedIntegrations: ["radarr" as const], connectionOptional: true }, - ])("reports a widget without a required connection", ({ supportedIntegrations, connectionOptional }) => { - expect(getWidgetConnectionStatus({ supportedIntegrations, availableKinds, connectionOptional })).toBe( - "noConnectionRequired", - ); + describe("noConnectionRequired status", () => { + it("returns 'noConnectionRequired' when supportedIntegrations is empty regardless of connectionOptional", () => { + expect( + getWidgetConnectionStatus({ + supportedIntegrations: [], + availableKinds, + connectionOptional: false, + }), + ).toBe("noConnectionRequired"); + + expect( + getWidgetConnectionStatus({ + supportedIntegrations: [], + availableKinds: new Set(), + connectionOptional: true, + }), + ).toBe("noConnectionRequired"); + }); + + it("returns 'noConnectionRequired' when connectionOptional is true and no supported integrations are available", () => { + expect( + getWidgetConnectionStatus({ + supportedIntegrations: ["radarr", "jellyfin"], + availableKinds, + connectionOptional: true, + }), + ).toBe("noConnectionRequired"); + }); + + it("returns 'noConnectionRequired' when connectionOptional is true and availableKinds is empty", () => { + expect( + getWidgetConnectionStatus({ + supportedIntegrations: ["sonarr"], + availableKinds: new Set(), + connectionOptional: true, + }), + ).toBe("noConnectionRequired"); + }); }); }); -describe("widget selection lock", () => { - it("rejects repeated selection synchronously until the active selection finishes", () => { +describe("selection concurrency locks", () => { + it("acquires lock on unlocked ref and sets current to true", () => { + const lock = { current: false }; + + const acquired = tryLockSelection(lock); + expect(acquired).toBe(true); + expect(lock.current).toBe(true); + }); + + it("rejects lock acquisition on an already locked ref", () => { + const lock = { current: true }; + + const acquired = tryLockSelection(lock); + expect(acquired).toBe(false); + expect(lock.current).toBe(true); + }); + + it("unlocks lock and allows subsequent acquisition", () => { const lock = { current: false }; expect(tryLockSelection(lock)).toBe(true); expect(tryLockSelection(lock)).toBe(false); unlockSelection(lock); + expect(lock.current).toBe(false); + expect(tryLockSelection(lock)).toBe(true); }); + + it("handles repeated unlock safely without throwing", () => { + const lock = { current: false }; + expect(() => { + unlockSelection(lock); + unlockSelection(lock); + }).not.toThrow(); + expect(lock.current).toBe(false); + }); + + it("prevents multiple concurrent callers from proceeding simultaneously", () => { + const lock = { current: false }; + const results: boolean[] = []; + + // Simulate 5 simultaneous selection attempts + for (let i = 0; i < 5; i++) { + results.push(tryLockSelection(lock)); + } + + expect(results).toEqual([true, false, false, false, false]); + }); }); describe("resolveMatchingIntegrationsAsync", () => { - it("waits for the shared integration query before a cold integration-backed selection", async () => { - const ensureDataAsync = vi.fn(async () => [ - { id: "matching", kind: "sonarr" as const }, - { id: "other", kind: "radarr" as const }, - ]); + it("returns empty array immediately when widget does not support integrations without calling ensureDataAsync", async () => { + const ensureDataAsync = vi.fn(async () => [{ id: "1", kind: "sonarr" as IntegrationKind }]); - await expect( - resolveMatchingIntegrationsAsync({ - hasIntegrationSupport: true, - supportedIntegrations: ["sonarr"], - currentData: undefined, - ensureDataAsync, - }), - ).resolves.toEqual([{ id: "matching", kind: "sonarr" }]); - expect(ensureDataAsync).toHaveBeenCalledOnce(); - }); + const result = await resolveMatchingIntegrationsAsync({ + hasIntegrationSupport: false, + supportedIntegrations: ["sonarr"], + currentData: undefined, + ensureDataAsync, + }); - it("does not wait for integrations when the widget has no integration support", async () => { - const ensureDataAsync = vi.fn(async () => [{ id: "unused", kind: "sonarr" as const }]); - - await expect( - resolveMatchingIntegrationsAsync({ - hasIntegrationSupport: false, - supportedIntegrations: [], - currentData: undefined, - ensureDataAsync, - }), - ).resolves.toEqual([]); + expect(result).toEqual([]); expect(ensureDataAsync).not.toHaveBeenCalled(); }); - it("excludes matching integrations the user cannot use", async () => { + it("uses currentData synchronously when available without calling ensureDataAsync", async () => { const currentData = [ - { id: "allowed", kind: "sonarr" as const, permissions: { hasUseAccess: true } }, - { id: "blocked", kind: "sonarr" as const, permissions: { hasUseAccess: false } }, + { id: "1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: true } }, + { id: "2", kind: "radarr" as IntegrationKind, permissions: { hasUseAccess: true } }, + { id: "3", kind: "plex" as IntegrationKind, permissions: { hasUseAccess: true } }, ]; + const ensureDataAsync = vi.fn(); + + const result = await resolveMatchingIntegrationsAsync({ + hasIntegrationSupport: true, + supportedIntegrations: ["sonarr", "radarr"], + currentData, + ensureDataAsync, + }); + + expect(result).toEqual([currentData[0], currentData[1]]); + expect(ensureDataAsync).not.toHaveBeenCalled(); + }); + + it("calls ensureDataAsync when currentData is undefined and filters by supported kinds", async () => { + const fetchedData = [ + { id: "1", kind: "sonarr" as IntegrationKind }, + { id: "2", kind: "radarr" as IntegrationKind }, + { id: "3", kind: "sabNzbd" as IntegrationKind }, + ]; + const ensureDataAsync = vi.fn(async () => fetchedData); + + const result = await resolveMatchingIntegrationsAsync({ + hasIntegrationSupport: true, + supportedIntegrations: ["radarr", "sabNzbd"], + currentData: undefined, + ensureDataAsync, + }); + + expect(result).toEqual([fetchedData[1], fetchedData[2]]); + expect(ensureDataAsync).toHaveBeenCalledOnce(); + }); + + it("excludes integrations where permissions.hasUseAccess is explicitly false", async () => { + const currentData = [ + { id: "allowed-1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: true } }, + { id: "blocked-1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: false } }, + { id: "allowed-no-perms", kind: "sonarr" as IntegrationKind }, + ]; + + const result = await resolveMatchingIntegrationsAsync({ + hasIntegrationSupport: true, + supportedIntegrations: ["sonarr"], + currentData, + ensureDataAsync: vi.fn(), + }); + + expect(result).toEqual([currentData[0], currentData[2]]); + }); + + it("returns empty array when none of the fetched integrations match supported kinds", async () => { + const fetchedData = [ + { id: "1", kind: "plex" as IntegrationKind }, + { id: "2", kind: "jellyfin" as IntegrationKind }, + ]; + const ensureDataAsync = vi.fn(async () => fetchedData); + + const result = await resolveMatchingIntegrationsAsync({ + hasIntegrationSupport: true, + supportedIntegrations: ["sonarr", "radarr"], + currentData: undefined, + ensureDataAsync, + }); + + expect(result).toEqual([]); + expect(ensureDataAsync).toHaveBeenCalledOnce(); + }); + + it("propagates error when ensureDataAsync rejects", async () => { + const ensureDataAsync = vi.fn(async () => { + throw new Error("Network timeout"); + }); await expect( resolveMatchingIntegrationsAsync({ hasIntegrationSupport: true, supportedIntegrations: ["sonarr"], - currentData, - ensureDataAsync: vi.fn(), + currentData: undefined, + ensureDataAsync, }), - ).resolves.toEqual([currentData[0]]); + ).rejects.toThrow("Network timeout"); }); }); 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 new file mode 100644 index 000000000..ed80c5abb --- /dev/null +++ b/apps/nextjs/src/components/board/items/item-select-error-boundary.spec.tsx @@ -0,0 +1,304 @@ +// @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 new file mode 100644 index 000000000..1326bcf95 --- /dev/null +++ b/apps/nextjs/src/components/board/items/item-select-integration-sanitization.spec.ts @@ -0,0 +1,254 @@ +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.spec.tsx b/apps/nextjs/src/components/board/items/item-select-modal.spec.tsx new file mode 100644 index 000000000..df5c421d7 --- /dev/null +++ b/apps/nextjs/src/components/board/items/item-select-modal.spec.tsx @@ -0,0 +1,204 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; +import { MantineProvider } from "@mantine/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; + +import { ConnectionStatusBadge, SupportedIntegrations, WidgetItem } from "./item-select-modal"; + +vi.mock("@homarr/translation/client", () => ({ + useI18n: () => (key: string) => { + const translations: Record = { + "item.select.connection.ready": "Ready", + "item.select.connection.needsSetup": "Needs setup", + "item.select.connection.noConnectionRequired": "No connection required", + "widget.mediaServer.name": "Media Server", + "widget.mediaServer.description": "Stream your media", + "widget.clock.name": "Clock", + "widget.clock.description": "Displays current time", + }; + return translations[key] ?? key; + }, + useScopedI18n: () => (key: string) => key, +})); + +vi.mock("@homarr/definitions", async () => { + const actual = await vi.importActual>("@homarr/definitions"); + return { + ...actual, + getIntegrationName: (kind: string) => kind.charAt(0).toUpperCase() + kind.slice(1), + }; +}); + +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); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); +}); + +describe("Advanced Add App - Modal Subcomponents", () => { + describe("ConnectionStatusBadge", () => { + it("renders 'Ready' badge with teal color when status is 'ready'", async () => { + await act(async () => + root.render( + + + , + ), + ); + + expect(host.textContent).toContain("Ready"); + const badge = host.querySelector("span, div"); + expect(badge).not.toBeNull(); + }); + + it("renders 'Needs setup' badge with orange color when status is 'needsSetup'", async () => { + await act(async () => + root.render( + + + , + ), + ); + + expect(host.textContent).toContain("Needs setup"); + }); + + it("renders 'No connection required' badge with gray color when status is 'noConnectionRequired'", async () => { + await act(async () => + root.render( + + + , + ), + ); + + expect(host.textContent).toContain("No connection required"); + }); + }); + + describe("SupportedIntegrations", () => { + it("renders up to 3 avatars for supported integrations", async () => { + const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex"]; + + await act(async () => + root.render( + + + , + ), + ); + + // Avatars should be rendered for the 3 items + expect(host.querySelectorAll("img, svg, div").length).toBeGreaterThan(0); + expect(host.textContent).not.toContain("+"); + }); + + it("renders overflow indicator when more than 3 integrations are supported", async () => { + const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex", "jellyfin", "emby"]; + + await act(async () => + root.render( + + + , + ), + ); + + // 5 integrations total: 3 shown, +2 in badge + expect(host.textContent).toContain("+2"); + }); + + it("renders empty when supportedIntegrations is empty", async () => { + await act(async () => + root.render( + + + , + ), + ); + + expect(host.textContent?.trim()).toBe(""); + }); + }); + + describe("WidgetItem", () => { + const FakeIcon = () => Icon; + + it("renders widget card with icon, title, description, and connection status", async () => { + const onSelect = vi.fn(); + const item = { + kind: "mediaServer" as WidgetKind, + name: "Media Server", + description: "Stream your media collection effortlessly", + icon: FakeIcon as never, + supportedIntegrations: ["plex" as IntegrationKind, "jellyfin" as IntegrationKind], + }; + + await act(async () => + root.render( + + + , + ), + ); + + const button = host.querySelector("button"); + expect(button).not.toBeNull(); + expect(host.textContent).toContain("Media Server"); + expect(host.textContent).toContain("Stream your media collection effortlessly"); + expect(host.textContent).toContain("Ready"); + expect(host.querySelector("[data-testid='widget-icon']")).not.toBeNull(); + + await act(async () => { + button?.click(); + }); + + expect(onSelect).toHaveBeenCalledOnce(); + }); + + it("marks card as selected when isSelected is true", async () => { + const item = { + kind: "clock" as WidgetKind, + name: "Clock", + description: "Displays current time", + icon: FakeIcon as never, + supportedIntegrations: [] as IntegrationKind[], + }; + + await act(async () => + root.render( + + + , + ), + ); + + const button = host.querySelector("button"); + expect(button?.getAttribute("data-selected")).toBe("true"); + }); + }); +}); 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 1fc5ffd42..59bfced2e 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -452,6 +452,7 @@ const ItemSelectModalContent = ({ > {/* Left Column: Widget Cards Grid */} { + const t = useI18n(); const [resources, setResources] = useState<{ definition: WidgetDefinition; Component: ComponentType>; } | null>(null); const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(null); - useEffect(() => { + const loadResources = useCallback(() => { let active = true; setIsLoading(true); + setLoadError(null); loadWidgetResources(item.kind) .then((res) => { if (active) { @@ -631,8 +635,9 @@ const WidgetLivePreviewAndConfigPane = ({ setIsLoading(false); } }) - .catch((_err) => { + .catch((err) => { if (active) { + setLoadError(err instanceof Error ? err : new Error(String(err))); setIsLoading(false); } }); @@ -641,6 +646,38 @@ const WidgetLivePreviewAndConfigPane = ({ }; }, [item.kind]); + useEffect(() => { + return loadResources(); + }, [loadResources]); + + if (loadError) { + return ( + +
+ + + + + + {t("common.error")} + + + {loadError.message} + + + + + + +
+
+ ); + } + if (isLoading || !resources) { return ( @@ -737,11 +774,13 @@ const WidgetLivePreviewAndConfigContent = ({ 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 ?? []; - return (integrationData ?? []) + const existing = (integrationData ?? []) .filter((i) => supported.includes(i.kind)) .map((i) => ({ id: i.id, @@ -749,7 +788,11 @@ const WidgetLivePreviewAndConfigContent = ({ kind: i.kind, url: i.url, })); - }, [definition, integrationData]); + 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); @@ -787,6 +830,13 @@ const WidgetLivePreviewAndConfigContent = ({ }, }); + // 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)); @@ -802,10 +852,10 @@ const WidgetLivePreviewAndConfigContent = ({ async onSuccess(data) { await utils.integration.all.invalidate(); if (data && "integration" in data && data.integration) { - form.setFieldValue("integrationIds", [ - ...(maxIntegrations > 1 ? form.values.integrationIds : []), - data.integration.id, - ]); + const nextIds = [...(maxIntegrations > 1 ? form.values.integrationIds : []), data.integration.id].slice( + -maxIntegrations, + ); + form.setFieldValue("integrationIds", nextIds); setShowInlineConnect(false); showSuccessNotification({ title: "Demo Service Connected", @@ -835,10 +885,17 @@ const WidgetLivePreviewAndConfigContent = ({ const handleInlineCreateSuccess = (result?: CreatedIntegrationResult) => { void utils.integration.all.invalidate(); if (result?.integration?.id) { - form.setFieldValue("integrationIds", [ - ...(maxIntegrations > 1 ? form.values.integrationIds : []), - 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({ @@ -860,14 +917,30 @@ const WidgetLivePreviewAndConfigContent = ({ const handleUpdatePartialOptions = useCallback( ({ newOptions }: { newOptions: Record }) => { - form.setFieldValue("options", { - ...form.values.options, - ...newOptions, - }); + 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 */} @@ -898,7 +971,7 @@ const WidgetLivePreviewAndConfigContent = ({ size="sm" leftSection={} loading={isAdding} - onClick={() => onAddWidget(form.values.options, validIntegrationIds)} + onClick={handleAddCurrentWidget} > {t("common.action.add")} @@ -957,7 +1030,7 @@ const WidgetLivePreviewAndConfigContent = ({ integrationIds={previewIntegrationIds} width={previewWidth || 500} height={previewHeight || 300} - isEditMode={false} + isEditMode={true} displayMode="compact" boardId={boardId} itemId="preview" @@ -1247,29 +1320,6 @@ const ItemSelectModalFrame = ({ { enabled: isAdmin }, ); - const { mutate: autoCreateMock } = clientApi.integration.create.useMutation({ - onSuccess: () => { - void utils.integration.all.invalidate(); - }, - }); - - const attemptedMockRef = useRef(false); - useEffect(() => { - if (integrationData && canCreateIntegration && !attemptedMockRef.current) { - const hasMock = integrationData.some((i) => i.kind === "mock"); - if (!hasMock) { - attemptedMockRef.current = true; - autoCreateMock({ - name: "Demo Service", - url: "https://demo.homarr.dev", - kind: "mock", - secrets: [], - attemptSearchEngineCreation: false, - }); - } - } - }, [integrationData, canCreateIntegration, autoCreateMock]); - return ( ( diff --git a/e2e/specs/board/advanced-add-app.spec.ts b/e2e/specs/board/advanced-add-app.spec.ts new file mode 100644 index 000000000..3ae61415d --- /dev/null +++ b/e2e/specs/board/advanced-add-app.spec.ts @@ -0,0 +1,475 @@ +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 and mock demo service fallback + // ========================================================================= + test("supports in-place demo service fallback and mock integration connection", async () => { + await actions.openItemSelectModalAsync(); + const modal = page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first(); + + // Search and select Media releases widget (supports mock integration) + 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 }); + + // Verify "Use Demo Service" button is available when no integration is connected + const demoBtn = modal.getByRole("button", { name: "Use Demo Service", exact: false }).first(); + if (await demoBtn.isVisible()) { + await demoBtn.click(); + + // Verify demo / mock data badge appears in preview frame header + await expect(modal.getByText("Showing Demo / Mock Data")).toBeVisible({ timeout: 15_000 }); + } + } + }); + + // ========================================================================= + // 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 }); + }); +}); diff --git a/packages/modals-collection/src/apps/app-select-modal.spec.tsx b/packages/modals-collection/src/apps/app-select-modal.spec.tsx new file mode 100644 index 000000000..f5cf0bd12 --- /dev/null +++ b/packages/modals-collection/src/apps/app-select-modal.spec.tsx @@ -0,0 +1,424 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; +import { MantineProvider } from "@mantine/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AppSelectModal } from "./app-select-modal"; + +const AppSelectModalComponent = AppSelectModal.component; + +const sampleApps = [ + { + id: "app-1", + name: "Sonarr", + description: "Manage TV series downloads", + iconUrl: "https://example.com/sonarr.png", + href: "http://sonarr.local", + }, + { + id: "app-2", + name: "Radarr", + description: "Manage movie collections", + iconUrl: "https://example.com/radarr.png", + href: "http://radarr.local", + }, + { + id: "app-3", + name: "Plex", + description: "Stream your media collection", + iconUrl: "https://example.com/plex.png", + href: "http://plex.local", + }, +]; + +const mockUseQuery = vi.fn(() => ({ + data: sampleApps, + isPending: false, +})); + +const mockOpenQuickAddAppModal = vi.fn(); + +vi.mock("@homarr/api/client", () => ({ + clientApi: { + app: { + selectable: { + useQuery: () => mockUseQuery(), + }, + }, + }, +})); + +vi.mock("@homarr/modals", async () => { + const actual = await vi.importActual>("@homarr/modals"); + return { + ...actual, + useModalAction: () => ({ + openModal: mockOpenQuickAddAppModal, + }), + }; +}); + +vi.mock("@homarr/translation/client", () => ({ + useI18n: () => (key: string) => { + const translations: Record = { + "app.action.select.search": "Search apps", + "app.action.select.title": "Select application", + "app.action.select.selected": "Selected", + "app.action.select.toggle": "Click to select", + "app.action.select.noResults": "No apps found", + "app.action.create.title": "Add custom app", + "app.action.create.description": "Configure custom URL and icon", + "common.action.discard": "Discard", + "common.action.add": "Add", + }; + return translations[key] ?? 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); + vi.clearAllMocks(); + mockUseQuery.mockReturnValue({ + data: sampleApps, + isPending: false, + }); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); +}); + +describe("AppSelectModal", () => { + describe("single selection mode", () => { + it("renders app cards with name, description, and icon", async () => { + const closeModal = vi.fn(); + const onSelect = vi.fn(); + + await act(async () => + root.render( + + + , + ), + ); + + expect(host.textContent).toContain("Sonarr"); + expect(host.textContent).toContain("Radarr"); + expect(host.textContent).toContain("Plex"); + expect(host.textContent).toContain("Manage TV series downloads"); + }); + + it("selects an app and closes modal when card is clicked", async () => { + const closeModal = vi.fn(); + const onSelect = vi.fn(); + + await act(async () => + root.render( + + + , + ), + ); + + const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr")); + expect(sonarrCard).toBeDefined(); + + await act(async () => { + sonarrCard?.click(); + }); + + expect(onSelect).toHaveBeenCalledWith(sampleApps[0]); + expect(closeModal).toHaveBeenCalledOnce(); + }); + }); + + describe("multi selection mode", () => { + it("toggles multiple app selections and submits batch on button click", async () => { + const closeModal = vi.fn(); + const onSelectMany = vi.fn(); + + await act(async () => + root.render( + + + , + ), + ); + + const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr")); + const radarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Radarr")); + + // Select Sonarr + await act(async () => { + sonarrCard?.click(); + }); + + // Modal stays open, 1 app selected + expect(closeModal).not.toHaveBeenCalled(); + expect(host.textContent).toContain("1 app selected"); + + // Select Radarr + await act(async () => { + radarrCard?.click(); + }); + + expect(host.textContent).toContain("2 apps selected"); + + // Submit multi select + const submitBtn = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Add (2)")); + expect(submitBtn).toBeDefined(); + + await act(async () => { + submitBtn?.click(); + }); + + expect(onSelectMany).toHaveBeenCalledWith([sampleApps[0], sampleApps[1]]); + expect(closeModal).toHaveBeenCalledOnce(); + }); + + it("unselects an app when clicked a second time", async () => { + const closeModal = vi.fn(); + const onSelectMany = vi.fn(); + + await act(async () => + root.render( + + + , + ), + ); + + const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr")); + + // Select Sonarr + await act(async () => { + sonarrCard?.click(); + }); + expect(host.textContent).toContain("1 app selected"); + + // Unselect Sonarr + await act(async () => { + sonarrCard?.click(); + }); + expect(host.textContent).not.toContain("1 app selected"); + }); + + it("clears selection when Discard button is clicked", async () => { + const closeModal = vi.fn(); + const onSelectMany = vi.fn(); + + await act(async () => + root.render( + + + , + ), + ); + + const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr")); + await act(async () => { + sonarrCard?.click(); + }); + expect(host.textContent).toContain("1 app selected"); + + const discardBtn = Array.from(host.querySelectorAll("button")).find((btn) => + btn.textContent?.includes("Discard"), + ); + await act(async () => { + discardBtn?.click(); + }); + + expect(host.textContent).not.toContain("app selected"); + }); + }); + + describe("search and filtering", () => { + it("filters apps in real-time by search query (case-insensitive)", async () => { + const closeModal = vi.fn(); + const onSelect = vi.fn(); + + await act(async () => + root.render( + + + , + ), + ); + + const searchInput = host.querySelector("input"); + expect(searchInput).not.toBeNull(); + + // Type "plex" + await act(async () => { + if (searchInput) { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + nativeInputValueSetter?.call(searchInput, "plex"); + searchInput.dispatchEvent(new Event("input", { bubbles: true })); + searchInput.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + + expect(host.textContent).toContain("Plex"); + expect(host.textContent).not.toContain("Sonarr"); + expect(host.textContent).not.toContain("Radarr"); + }); + + it("selects the single matching app when Enter key is pressed in search input", async () => { + const closeModal = vi.fn(); + const onSelect = vi.fn(); + + await act(async () => + root.render( + + + , + ), + ); + + const searchInput = host.querySelector("input"); + await act(async () => { + if (searchInput) { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + nativeInputValueSetter?.call(searchInput, "sonarr"); + searchInput.dispatchEvent(new Event("input", { bubbles: true })); + searchInput.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + + await act(async () => { + searchInput?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + + expect(onSelect).toHaveBeenCalledWith(sampleApps[0]); + expect(closeModal).toHaveBeenCalledOnce(); + }); + + it("displays empty state message when search yields no results", async () => { + const closeModal = vi.fn(); + const onSelect = vi.fn(); + + await act(async () => + root.render( + + + , + ), + ); + + const searchInput = host.querySelector("input"); + await act(async () => { + if (searchInput) { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + nativeInputValueSetter?.call(searchInput, "non-matching-query"); + searchInput.dispatchEvent(new Event("input", { bubbles: true })); + searchInput.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + + expect(host.textContent).toContain("No apps found"); + }); + }); + + describe("custom app creation card", () => { + it("renders custom app creation card when withCreate is true", async () => { + const closeModal = vi.fn(); + const onSelect = vi.fn(); + + await act(async () => + root.render( + + + , + ), + ); + + expect(host.textContent).toContain("Add custom app"); + expect(host.textContent).toContain("Configure custom URL and icon"); + }); + + it("omits custom app creation card when withCreate is false", async () => { + const closeModal = vi.fn(); + const onSelect = vi.fn(); + + await act(async () => + root.render( + + + , + ), + ); + + expect(host.textContent).not.toContain("Add custom app"); + }); + + it("opens QuickAddAppModal when custom app creation card is clicked in single select mode", async () => { + const closeModal = vi.fn(); + const onSelect = vi.fn(); + + await act(async () => + root.render( + + + , + ), + ); + + const createCard = Array.from(host.querySelectorAll("button")).find((btn) => + btn.textContent?.includes("Add custom app"), + ); + expect(createCard).toBeDefined(); + + await act(async () => { + createCard?.click(); + }); + + expect(mockOpenQuickAddAppModal).toHaveBeenCalledOnce(); + + // Trigger the onClose callback passed to QuickAddAppModal + const options = mockOpenQuickAddAppModal.mock.calls[0]?.[0] as { onClose: (app: (typeof sampleApps)[0]) => void }; + const newCustomApp = { + id: "custom-app-1", + name: "Custom Home Assistant", + description: "Local instance", + iconUrl: "https://example.com/ha.png", + href: "http://ha.local", + }; + + await act(async () => { + options.onClose(newCustomApp); + }); + + expect(onSelect).toHaveBeenCalledWith(newCustomApp); + expect(closeModal).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/packages/modals-collection/src/apps/app-select-modal.tsx b/packages/modals-collection/src/apps/app-select-modal.tsx index 9e114f9e8..9c59d7817 100644 --- a/packages/modals-collection/src/apps/app-select-modal.tsx +++ b/packages/modals-collection/src/apps/app-select-modal.tsx @@ -85,35 +85,6 @@ export const AppSelectModal = createModal(({ actions, inner return ( - {/* Embedded Dynamic Style for Card Hover & Focus */} - - {/* Top Search Input */} { }); describe("SelectableCard", () => { - it("renders as a native button with default button type and accessible slots", async () => { - const onClick = vi.fn(); - await act(async () => - root.render( - - Icon} - title="Sonarr" - topRight={Media} - description="Manage TV series downloads" - footerLeft={Left Meta} - footerRight={Right Meta} - onClick={onClick} - /> - , - ), - ); + describe("rendering slots", () => { + it("renders as a native button with default button type and accessible slots", async () => { + const onClick = vi.fn(); + await act(async () => + root.render( + + Icon} + title="Sonarr" + topRight={Media} + description="Manage TV series downloads" + footerLeft={Left Meta} + footerRight={Right Meta} + onClick={onClick} + /> + , + ), + ); - const button = host.querySelector("button"); - expect(button).not.toBeNull(); - expect(button?.type).toBe("button"); - expect(button?.getAttribute("aria-label")).toBe("Sonarr Card"); - expect(host.querySelector("[data-testid='card-icon']")).not.toBeNull(); - expect(host.textContent).toContain("Sonarr"); - expect(host.querySelector("[data-testid='card-topright']")).not.toBeNull(); - expect(host.textContent).toContain("Manage TV series downloads"); - expect(host.querySelector("[data-testid='card-footer-left']")).not.toBeNull(); - expect(host.querySelector("[data-testid='card-footer-right']")).not.toBeNull(); + const button = host.querySelector("button"); + expect(button).not.toBeNull(); + expect(button?.type).toBe("button"); + expect(button?.getAttribute("aria-label")).toBe("Sonarr Card"); + expect(host.querySelector("[data-testid='card-icon']")).not.toBeNull(); + expect(host.textContent).toContain("Sonarr"); + expect(host.querySelector("[data-testid='card-topright']")).not.toBeNull(); + expect(host.textContent).toContain("Manage TV series downloads"); + expect(host.querySelector("[data-testid='card-footer-left']")).not.toBeNull(); + expect(host.querySelector("[data-testid='card-footer-right']")).not.toBeNull(); - await act(async () => button?.click()); - expect(onClick).toHaveBeenCalledOnce(); - }); - - it("applies Styles API classes for root, header, body, and footer", async () => { - await act(async () => - root.render( - - Footer} - classNames={{ - root: "custom-root", - header: "custom-header", - body: "custom-body", - footer: "custom-footer", - }} - /> - , - ), - ); - - const button = host.querySelector("button"); - expect(button?.classList.contains(classes.root)).toBe(true); - expect(button?.classList.contains("custom-root")).toBe(true); - - const header = button?.querySelector(`.${classes.header}`); - expect(header).not.toBeNull(); - expect(header?.classList.contains("custom-header")).toBe(true); - - const body = button?.querySelector(`.${classes.body}`); - expect(body).not.toBeNull(); - expect(body?.classList.contains("custom-body")).toBe(true); - - const footer = button?.querySelector(`.${classes.footer}`); - expect(footer).not.toBeNull(); - expect(footer?.classList.contains("custom-footer")).toBe(true); - }); - - it("prioritizes title without wrapping and constrains topRight badge with ellipsis", async () => { - await act(async () => - root.render( - - Category Badge} /> - , - ), - ); - - const button = host.querySelector("button"); - const header = button?.querySelector(`.${classes.header}`); - expect(header).not.toBeNull(); - - // The title text is rendered with whiteSpace: nowrap and fw: 700 - const titleText = header?.querySelector("p, span, div"); - expect(titleText?.textContent).toContain("Very Long Title Name That Should Not Wrap"); - - // Check that custom ReactNode title is supported - await act(async () => - root.render( - - Custom Title Element} - topRight={Top Right} - /> - , - ), - ); - - expect(host.querySelector("[data-testid='custom-title-node']")).not.toBeNull(); - expect(host.querySelector("[data-testid='custom-top-right']")).not.toBeNull(); - }); - - it("renders custom children in the body slot over description", async () => { - await act(async () => - root.render( - - -
Custom Child Content
-
-
, - ), - ); - - expect(host.querySelector("[data-testid='custom-body-children']")).not.toBeNull(); - expect(host.textContent).toContain("Custom Child Content"); - expect(host.textContent).not.toContain("This should not appear"); - }); - - it("renders description with fallback when description is empty string", async () => { - await act(async () => - root.render( - - - , - ), - ); - - expect(host.textContent).toContain("No description provided"); - }); - - it("omits header, body, or footer when corresponding props are omitted", async () => { - await act(async () => - root.render( - - - , - ), - ); - - const button = host.querySelector("button"); - expect(button?.querySelector(`.${classes.header}`)).toBeNull(); - expect(button?.querySelector(`.${classes.body}`)).toBeNull(); - expect(button?.querySelector(`.${classes.footer}`)).toBeNull(); - }); - - it("handles selected state via data-selected attribute", async () => { - await act(async () => - root.render( - - - , - ), - ); - - const button = host.querySelector("button"); - expect(button?.getAttribute("data-selected")).toBe("true"); - - await act(async () => - root.render( - - - , - ), - ); - - expect(button?.hasAttribute("data-selected")).toBe(false); - }); - - it("handles disabled state and prevents click handlers", async () => { - const onClick = vi.fn(); - await act(async () => - root.render( - - - , - ), - ); - - const button = host.querySelector("button"); - expect(button?.disabled).toBe(true); - - await act(async () => button?.click()); - expect(onClick).not.toHaveBeenCalled(); - }); - - it("handles loading state by disabling the button and showing LoadingOverlay", async () => { - const onClick = vi.fn(); - await act(async () => - root.render( - - - , - ), - ); - - const button = host.querySelector("button"); - expect(button?.disabled).toBe(true); - - await act(async () => button?.click()); - expect(onClick).not.toHaveBeenCalled(); - }); - - it("supports keyboard activation via Enter and Space", async () => { - const onClick = vi.fn(); - await act(async () => - root.render( - - - , - ), - ); - - const button = host.querySelector("button"); - button?.focus(); - expect(document.activeElement).toBe(button); - - // Native buttons fire click event on Enter / Space keydown/keyup - await act(async () => { - button?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + await act(async () => button?.click()); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it("renders only header when only header slots (icon, title, topRight) are provided", async () => { + await act(async () => + root.render( + + I} + title="Only Header" + topRight={TR} + /> + , + ), + ); + + const button = host.querySelector("button"); + expect(button?.querySelector(`.${classes.header}`)).not.toBeNull(); + expect(button?.querySelector(`.${classes.body}`)).toBeNull(); + expect(button?.querySelector(`.${classes.footer}`)).toBeNull(); + expect(host.querySelector("[data-testid='only-icon']")).not.toBeNull(); + expect(host.querySelector("[data-testid='only-topright']")).not.toBeNull(); + }); + + it("renders footer when only footerLeft or only footerRight is provided", async () => { + await act(async () => + root.render( + + Left} /> + , + ), + ); + + expect(host.querySelector(`.${classes.footer}`)).not.toBeNull(); + expect(host.querySelector("[data-testid='left-only']")).not.toBeNull(); + + await act(async () => + root.render( + + Right} /> + , + ), + ); + + expect(host.querySelector(`.${classes.footer}`)).not.toBeNull(); + expect(host.querySelector("[data-testid='right-only']")).not.toBeNull(); + }); + + it("applies Styles API classes for root, header, body, and footer", async () => { + await act(async () => + root.render( + + Footer} + classNames={{ + root: "custom-root", + header: "custom-header", + body: "custom-body", + footer: "custom-footer", + }} + /> + , + ), + ); + + const button = host.querySelector("button"); + expect(button?.classList.contains(classes.root)).toBe(true); + expect(button?.classList.contains("custom-root")).toBe(true); + + const header = button?.querySelector(`.${classes.header}`); + expect(header).not.toBeNull(); + expect(header?.classList.contains("custom-header")).toBe(true); + + const body = button?.querySelector(`.${classes.body}`); + expect(body).not.toBeNull(); + expect(body?.classList.contains("custom-body")).toBe(true); + + const footer = button?.querySelector(`.${classes.footer}`); + expect(footer).not.toBeNull(); + expect(footer?.classList.contains("custom-footer")).toBe(true); + }); + + it("prioritizes title without wrapping and constrains topRight badge with ellipsis", async () => { + await act(async () => + root.render( + + Category Badge} + /> + , + ), + ); + + const button = host.querySelector("button"); + const header = button?.querySelector(`.${classes.header}`); + expect(header).not.toBeNull(); + + const titleText = header?.querySelector("p, span, div"); + expect(titleText?.textContent).toContain("Very Long Title Name That Should Not Wrap"); + + await act(async () => + root.render( + + Custom Title Element} + topRight={Top Right} + /> + , + ), + ); + + expect(host.querySelector("[data-testid='custom-title-node']")).not.toBeNull(); + expect(host.querySelector("[data-testid='custom-top-right']")).not.toBeNull(); + }); + + it("renders custom children in the body slot over description", async () => { + await act(async () => + root.render( + + +
Custom Child Content
+
+
, + ), + ); + + expect(host.querySelector("[data-testid='custom-body-children']")).not.toBeNull(); + expect(host.textContent).toContain("Custom Child Content"); + expect(host.textContent).not.toContain("This should not appear"); + }); + + it("renders description with fallback when description is empty string", async () => { + await act(async () => + root.render( + + + , + ), + ); + + expect(host.textContent).toContain("No description provided"); + }); + + it("omits header, body, or footer when corresponding props are omitted", async () => { + await act(async () => + root.render( + + + , + ), + ); + + const button = host.querySelector("button"); + expect(button?.querySelector(`.${classes.header}`)).toBeNull(); + expect(button?.querySelector(`.${classes.body}`)).toBeNull(); + expect(button?.querySelector(`.${classes.footer}`)).toBeNull(); }); - expect(onClick).toHaveBeenCalledOnce(); }); - it("forwards ref to the underlying HTMLButtonElement", async () => { - const ref = createRef(); - await act(async () => - root.render( - - - , - ), - ); + describe("states", () => { + it("handles selected state via data-selected attribute", async () => { + await act(async () => + root.render( + + + , + ), + ); - expect(ref.current).toBeInstanceOf(HTMLButtonElement); - expect(ref.current?.tagName).toBe("BUTTON"); + const button = host.querySelector("button"); + expect(button?.getAttribute("data-selected")).toBe("true"); + + await act(async () => + root.render( + + + , + ), + ); + + expect(button?.hasAttribute("data-selected")).toBe(false); + }); + + it("handles disabled state and prevents click handlers", async () => { + const onClick = vi.fn(); + await act(async () => + root.render( + + + , + ), + ); + + const button = host.querySelector("button"); + expect(button?.disabled).toBe(true); + + await act(async () => button?.click()); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("handles loading state by disabling the button and showing LoadingOverlay", async () => { + const onClick = vi.fn(); + await act(async () => + root.render( + + + , + ), + ); + + const button = host.querySelector("button"); + expect(button?.disabled).toBe(true); + + await act(async () => button?.click()); + expect(onClick).not.toHaveBeenCalled(); + }); }); - it("resolves radius CSS variables via varsResolver", async () => { - await act(async () => - root.render( - - - , - ), - ); + describe("interactions and accessibility", () => { + it("supports keyboard activation via Enter and Space", async () => { + const onClick = vi.fn(); + await act(async () => + root.render( + + + , + ), + ); - const button = host.querySelector("button"); - expect(button?.style.getPropertyValue("--selectable-card-radius")).toBe("var(--mantine-radius-lg)"); - }); + const button = host.querySelector("button"); + button?.focus(); + expect(document.activeElement).toBe(button); - it("passes arbitrary HTML attributes down to the button element", async () => { - await act(async () => - root.render( - - - , - ), - ); + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(onClick).toHaveBeenCalledOnce(); + }); - const button = host.querySelector("button"); - expect(button?.getAttribute("data-testid")).toBe("my-selectable-card"); - expect(button?.getAttribute("data-custom")).toBe("custom-value"); - expect(button?.tabIndex).toBe(0); + it("forwards ref to the underlying HTMLButtonElement", async () => { + const ref = createRef(); + await act(async () => + root.render( + + + , + ), + ); + + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + expect(ref.current?.tagName).toBe("BUTTON"); + }); + + it("resolves radius CSS variables via varsResolver", async () => { + await act(async () => + root.render( + + + , + ), + ); + + const button = host.querySelector("button"); + expect(button?.style.getPropertyValue("--selectable-card-radius")).toBe("var(--mantine-radius-lg)"); + + await act(async () => + root.render( + + + , + ), + ); + + expect(button?.style.getPropertyValue("--selectable-card-radius")).toBe("var(--mantine-radius-sm)"); + }); + + it("passes arbitrary HTML attributes down to the button element", async () => { + await act(async () => + root.render( + + + , + ), + ); + + const button = host.querySelector("button"); + expect(button?.getAttribute("data-testid")).toBe("my-selectable-card"); + expect(button?.getAttribute("data-custom")).toBe("custom-value"); + expect(button?.tabIndex).toBe(0); + }); }); }); diff --git a/packages/ui/src/components/selectable-card/selectable-card.tsx b/packages/ui/src/components/selectable-card/selectable-card.tsx index c5db014cf..7bcc96053 100644 --- a/packages/ui/src/components/selectable-card/selectable-card.tsx +++ b/packages/ui/src/components/selectable-card/selectable-card.tsx @@ -99,6 +99,7 @@ export const SelectableCard = factory((_props, ref) => { ref={ref} disabled={disabled || loading} data-selected={selected || undefined} + aria-pressed={selected !== undefined ? selected : undefined} {...getStyles("root")} {...others} > @@ -114,10 +115,18 @@ export const SelectableCard = factory((_props, ref) => { gap="xs" style={{ width: "100%", overflow: "hidden" }} > - + {icon} {typeof title === "string" ? ( - + {title} ) : ( @@ -128,10 +137,7 @@ export const SelectableCard = factory((_props, ref) => { {topRight}