feat(board): harden Advanced Add App feature and add comprehensive test suites
This commit is contained in:
10 files changed
+2302
-392
No files matched your search
@@ -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<IntegrationKind>(["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<IntegrationKind>(),
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
"widget.common.error.noIntegration": "No integration selected for this widget",
|
||||
"common.error": "An unexpected error occurred",
|
||||
"common.action.retry": "Retry",
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
},
|
||||
useScopedI18n: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
|
||||
// Suppress console.error during error boundary tests to avoid noisy output
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const Throw = ({ when, error }: { when: boolean; error: Error }) => {
|
||||
if (when) throw error;
|
||||
return null;
|
||||
};
|
||||
|
||||
// Simulates the Live Preview Error Boundary container from item-select-modal.tsx
|
||||
const LivePreviewContainer = ({
|
||||
options,
|
||||
previewIntegrationIds,
|
||||
hasIntegrationSupport,
|
||||
integrationsRequired,
|
||||
onResetCallback,
|
||||
widgetComponent,
|
||||
}: {
|
||||
options: Record<string, unknown>;
|
||||
previewIntegrationIds: string[];
|
||||
hasIntegrationSupport: boolean;
|
||||
integrationsRequired: boolean;
|
||||
onResetCallback?: () => void;
|
||||
widgetComponent: (props: { options: Record<string, unknown>; integrationIds: string[] }) => JSX.Element;
|
||||
}) => {
|
||||
return (
|
||||
<div data-testid="outer-modal-pane">
|
||||
<div data-testid="outer-header">Add Widget Header</div>
|
||||
<div data-testid="live-preview-frame">
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={() => {
|
||||
reset();
|
||||
onResetCallback?.();
|
||||
}}
|
||||
resetKeys={[options, previewIntegrationIds]}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<Center h="100%" p="md" data-testid="preview-error-fallback">
|
||||
<WidgetError error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</Center>
|
||||
)}
|
||||
>
|
||||
<Throw
|
||||
error={new NoIntegrationSelectedError()}
|
||||
when={hasIntegrationSupport && previewIntegrationIds.length === 0 && integrationsRequired}
|
||||
/>
|
||||
{widgetComponent({ options, integrationIds: previewIntegrationIds })}
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
</div>
|
||||
<div data-testid="outer-footer">Add Widget Footer</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe("Advanced Add App - Live Preview Error Boundary Isolation", () => {
|
||||
it("isolates runtime errors thrown inside the preview widget without crashing outer modal components", async () => {
|
||||
const BuggyWidget = () => {
|
||||
throw new Error("Widget rendering failure in preview");
|
||||
};
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<LivePreviewContainer
|
||||
options={{ title: "Test" }}
|
||||
previewIntegrationIds={["integration-1"]}
|
||||
hasIntegrationSupport={true}
|
||||
integrationsRequired={true}
|
||||
widgetComponent={() => <BuggyWidget />}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// Outer components are preserved and not destroyed by the error
|
||||
expect(host.querySelector("[data-testid='outer-modal-pane']")).not.toBeNull();
|
||||
expect(host.querySelector("[data-testid='outer-header']")).not.toBeNull();
|
||||
expect(host.querySelector("[data-testid='outer-footer']")).not.toBeNull();
|
||||
|
||||
// The error fallback is rendered inside the preview frame
|
||||
expect(host.querySelector("[data-testid='preview-error-fallback']")).not.toBeNull();
|
||||
expect(host.textContent).toContain("Widget rendering failure in preview");
|
||||
});
|
||||
|
||||
it("throws and catches NoIntegrationSelectedError when integration is required and previewIntegrationIds is empty", async () => {
|
||||
const WorkingWidget = () => <div data-testid="widget-content">Healthy Widget</div>;
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<LivePreviewContainer
|
||||
options={{}}
|
||||
previewIntegrationIds={[]}
|
||||
hasIntegrationSupport={true}
|
||||
integrationsRequired={true}
|
||||
widgetComponent={() => <WorkingWidget />}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// Error fallback is active
|
||||
expect(host.querySelector("[data-testid='preview-error-fallback']")).not.toBeNull();
|
||||
expect(host.textContent).toContain("No integration selected for this widget");
|
||||
expect(host.querySelector("[data-testid='widget-content']")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders healthy widget without throwing when integrationsRequired is false even if previewIntegrationIds is empty", async () => {
|
||||
const OptionalIntegrationWidget = ({ integrationIds }: { integrationIds: string[] }) => (
|
||||
<div data-testid="widget-content">Widget rendered with {integrationIds.length} integrations</div>
|
||||
);
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<LivePreviewContainer
|
||||
options={{}}
|
||||
previewIntegrationIds={[]}
|
||||
hasIntegrationSupport={true}
|
||||
integrationsRequired={false}
|
||||
widgetComponent={({ integrationIds }) => <OptionalIntegrationWidget integrationIds={integrationIds} />}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("[data-testid='preview-error-fallback']")).toBeNull();
|
||||
expect(host.querySelector("[data-testid='widget-content']")).not.toBeNull();
|
||||
expect(host.textContent).toContain("Widget rendered with 0 integrations");
|
||||
});
|
||||
|
||||
it("renders healthy widget without throwing when widget has no integration support", async () => {
|
||||
const StandaloneWidget = () => <div data-testid="standalone-widget">Clock Widget</div>;
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<LivePreviewContainer
|
||||
options={{}}
|
||||
previewIntegrationIds={[]}
|
||||
hasIntegrationSupport={false}
|
||||
integrationsRequired={false}
|
||||
widgetComponent={() => <StandaloneWidget />}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("[data-testid='preview-error-fallback']")).toBeNull();
|
||||
expect(host.querySelector("[data-testid='standalone-widget']")).not.toBeNull();
|
||||
expect(host.textContent).toContain("Clock Widget");
|
||||
});
|
||||
|
||||
it("automatically resets and recovers when previewIntegrationIds or options change via resetKeys", async () => {
|
||||
const DynamicWidget = ({ integrationIds }: { integrationIds: string[] }) => {
|
||||
return <div data-testid="healthy-widget">Active with: {integrationIds.join(", ")}</div>;
|
||||
};
|
||||
|
||||
const TestHarness = () => {
|
||||
const [integrationIds, setIntegrationIds] = useState<string[]>([]);
|
||||
const [options, setOptions] = useState<Record<string, unknown>>({ query: "default" });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button data-testid="connect-service-btn" onClick={() => setIntegrationIds(["new-service-id"])}>
|
||||
Connect
|
||||
</button>
|
||||
<button data-testid="change-options-btn" onClick={() => setOptions({ query: "updated" })}>
|
||||
Change Option
|
||||
</button>
|
||||
<LivePreviewContainer
|
||||
options={options}
|
||||
previewIntegrationIds={integrationIds}
|
||||
hasIntegrationSupport={true}
|
||||
integrationsRequired={true}
|
||||
widgetComponent={({ integrationIds: ids }) => <DynamicWidget integrationIds={ids} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<TestHarness />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// Initial state: no integration -> error boundary is showing
|
||||
expect(host.querySelector("[data-testid='preview-error-fallback']")).not.toBeNull();
|
||||
expect(host.querySelector("[data-testid='healthy-widget']")).toBeNull();
|
||||
|
||||
// Click connect button -> previewIntegrationIds updates -> error boundary automatically resets
|
||||
const connectBtn = host.querySelector("[data-testid='connect-service-btn']");
|
||||
await act(async () => {
|
||||
(connectBtn as HTMLButtonElement)?.click();
|
||||
});
|
||||
|
||||
expect(host.querySelector("[data-testid='preview-error-fallback']")).toBeNull();
|
||||
expect(host.querySelector("[data-testid='healthy-widget']")).not.toBeNull();
|
||||
expect(host.textContent).toContain("Active with: new-service-id");
|
||||
});
|
||||
|
||||
it("recovers from error state when user clicks retry button in WidgetError", async () => {
|
||||
let shouldFail = true;
|
||||
const onResetCallback = vi.fn();
|
||||
|
||||
const FlakyWidget = () => {
|
||||
if (shouldFail) {
|
||||
throw new Error("Temporary network glitch");
|
||||
}
|
||||
return <div data-testid="recovered-widget">Recovered Content</div>;
|
||||
};
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<LivePreviewContainer
|
||||
options={{}}
|
||||
previewIntegrationIds={["integration-1"]}
|
||||
hasIntegrationSupport={true}
|
||||
integrationsRequired={true}
|
||||
onResetCallback={onResetCallback}
|
||||
widgetComponent={() => <FlakyWidget />}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("[data-testid='preview-error-fallback']")).not.toBeNull();
|
||||
expect(host.textContent).toContain("Temporary network glitch");
|
||||
|
||||
// Heal the condition
|
||||
shouldFail = false;
|
||||
|
||||
// Click the retry button rendered by BaseWidgetError
|
||||
const retryButton = Array.from(host.querySelectorAll("button")).find((btn) =>
|
||||
btn.textContent?.toLowerCase().includes("retry"),
|
||||
);
|
||||
expect(retryButton).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
retryButton?.click();
|
||||
});
|
||||
|
||||
expect(onResetCallback).toHaveBeenCalledOnce();
|
||||
expect(host.querySelector("[data-testid='preview-error-fallback']")).toBeNull();
|
||||
expect(host.querySelector("[data-testid='recovered-widget']")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
"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<Record<string, unknown>>("@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(
|
||||
<MantineProvider>
|
||||
<ConnectionStatusBadge status="ready" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<ConnectionStatusBadge status="needsSetup" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<ConnectionStatusBadge status="noConnectionRequired" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SupportedIntegrations supportedIntegrations={integrations} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// 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(
|
||||
<MantineProvider>
|
||||
<SupportedIntegrations supportedIntegrations={integrations} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// 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(
|
||||
<MantineProvider>
|
||||
<SupportedIntegrations supportedIntegrations={[]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent?.trim()).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WidgetItem", () => {
|
||||
const FakeIcon = () => <span data-testid="widget-icon">Icon</span>;
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<WidgetItem item={item} isSelected={false} connectionStatus="ready" onSelect={onSelect} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<WidgetItem item={item} isSelected={true} connectionStatus="noConnectionRequired" onSelect={vi.fn()} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.getAttribute("data-selected")).toBe("true");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -452,6 +452,7 @@ const ItemSelectModalContent = ({
|
||||
>
|
||||
{/* Left Column: Widget Cards Grid */}
|
||||
<Box
|
||||
display={{ base: isPreviewOpen ? "none" : "block", lg: "block" }}
|
||||
style={{
|
||||
flex: isPreviewOpen ? "0 0 440px" : "1 1 100%",
|
||||
maxWidth: isPreviewOpen ? "460px" : "100%",
|
||||
@@ -615,15 +616,18 @@ const WidgetLivePreviewAndConfigPane = ({
|
||||
onAddWidget,
|
||||
isAdding,
|
||||
}: WidgetLivePreviewAndConfigPaneProps) => {
|
||||
const t = useI18n();
|
||||
const [resources, setResources] = useState<{
|
||||
definition: WidgetDefinition;
|
||||
Component: ComponentType<WidgetComponentProps<WidgetKind>>;
|
||||
} | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<Error | null>(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 (
|
||||
<Card withBorder radius="md" padding="md" h="100%" shadow="xs">
|
||||
<Center h={320}>
|
||||
<Stack align="center" gap="xs">
|
||||
<ThemeIcon size={40} radius="xl" color="red" variant="light">
|
||||
<IconApi size={24} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600} c="red">
|
||||
{t("common.error")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" maw={300}>
|
||||
{loadError.message}
|
||||
</Text>
|
||||
<Group gap="xs" mt="xs">
|
||||
<Button size="xs" variant="light" onClick={loadResources}>
|
||||
{t("common.action.retry")}
|
||||
</Button>
|
||||
<Button size="xs" variant="subtle" onClick={onClose}>
|
||||
{t("common.action.close")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !resources) {
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md" h="100%" shadow="xs">
|
||||
@@ -737,11 +774,13 @@ const WidgetLivePreviewAndConfigContent = ({
|
||||
return (definition.supportedIntegrations ?? []).filter((k) => k !== "mock");
|
||||
}, [definition]);
|
||||
|
||||
const [optimisticIntegrations, setOptimisticIntegrations] = useState<IntegrationSelectOption[]>([]);
|
||||
|
||||
// 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<boolean>(compatibleIntegrations.length === 0);
|
||||
const [inlineConnectKind, setInlineConnectKind] = useState<IntegrationKind | null>(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<string, unknown> }) => {
|
||||
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 (
|
||||
<Card withBorder radius="md" padding="md" h="100%" shadow="xs" style={{ display: "flex", flexDirection: "column" }}>
|
||||
{/* Header Banner */}
|
||||
@@ -898,7 +971,7 @@ const WidgetLivePreviewAndConfigContent = ({
|
||||
size="sm"
|
||||
leftSection={<IconPlus size={16} />}
|
||||
loading={isAdding}
|
||||
onClick={() => onAddWidget(form.values.options, validIntegrationIds)}
|
||||
onClick={handleAddCurrentWidget}
|
||||
>
|
||||
{t("common.action.add")}
|
||||
</Button>
|
||||
@@ -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 (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
|
||||
@@ -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<void>) | undefined;
|
||||
let context: BrowserContext;
|
||||
let page: Page;
|
||||
let actions: ItemSelectActions;
|
||||
|
||||
const boardId = createId();
|
||||
const layoutId = createId();
|
||||
const mainSectionId = createId();
|
||||
const containerSectionId = createId();
|
||||
const nextcloudAppId = createId();
|
||||
const plexAppId = createId();
|
||||
const homeAssistantAppId = createId();
|
||||
|
||||
beforeAll(async () => {
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const { userId } = await seedAdminUserAsync(db, credentials);
|
||||
|
||||
// Seed test board with main canvas section & container section
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: boardName,
|
||||
creatorId: userId,
|
||||
isPublic: false,
|
||||
});
|
||||
|
||||
await db.insert(layouts).values({
|
||||
id: layoutId,
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
boardId,
|
||||
role: "base",
|
||||
});
|
||||
|
||||
await db.insert(sections).values([
|
||||
{
|
||||
id: mainSectionId,
|
||||
boardId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
},
|
||||
{
|
||||
id: containerSectionId,
|
||||
boardId,
|
||||
kind: "container",
|
||||
xOffset: 0,
|
||||
yOffset: 4,
|
||||
options: stringify({
|
||||
title: "Media Hub Container",
|
||||
customCssClasses: [],
|
||||
borderColor: "",
|
||||
showLabel: true,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(sectionLayouts).values({
|
||||
sectionId: containerSectionId,
|
||||
layoutId,
|
||||
parentSectionId: mainSectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 4,
|
||||
width: 12,
|
||||
height: 4,
|
||||
});
|
||||
|
||||
// Seed selectable test apps
|
||||
await db.insert(apps).values([
|
||||
{
|
||||
id: nextcloudAppId,
|
||||
name: "Nextcloud",
|
||||
description: "Self-hosted productivity platform",
|
||||
iconUrl: "/favicon.ico",
|
||||
href: "https://nextcloud.example.internal",
|
||||
},
|
||||
{
|
||||
id: plexAppId,
|
||||
name: "Plex Media Server",
|
||||
description: "Stream movies, TV shows, and music",
|
||||
iconUrl: "/favicon.ico",
|
||||
href: "https://plex.example.internal",
|
||||
},
|
||||
{
|
||||
id: homeAssistantAppId,
|
||||
name: "Home Assistant",
|
||||
description: "Open source home automation",
|
||||
iconUrl: "/favicon.ico",
|
||||
href: "https://homeassistant.example.internal",
|
||||
},
|
||||
]);
|
||||
|
||||
// Set user defaults & mark tours completed
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
homeBoardId: boardId,
|
||||
completedBoardTour: true,
|
||||
completedManageTour: true,
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
const container = await createHomarrContainer({
|
||||
environment: { AUTH_PROVIDERS: "credentials" },
|
||||
mounts: { "/appdata": localMountPath },
|
||||
}).start();
|
||||
|
||||
stopContainerAsync = async () => {
|
||||
await container.stop();
|
||||
};
|
||||
|
||||
baseUrl = `http://${container.getHost()}:${container.getMappedPort(7575)}`;
|
||||
browser = await chromium.launch();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await stopContainerAsync?.();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
page = await context.newPage();
|
||||
actions = new ItemSelectActions(page);
|
||||
|
||||
await loginAsync({
|
||||
page,
|
||||
baseUrl,
|
||||
credentials,
|
||||
destination: `/boards/${boardName}`,
|
||||
});
|
||||
|
||||
// Dismiss tour overlay if present
|
||||
const tourOverlay = page.locator('[data-onboarding-tour-overlay="true"]');
|
||||
const skipTour = page.getByRole("button", { name: "Skip tour", exact: true });
|
||||
try {
|
||||
if (await skipTour.isVisible({ timeout: 2_000 })) {
|
||||
await skipTour.click();
|
||||
await expect(tourOverlay).toHaveCount(0);
|
||||
}
|
||||
} catch {
|
||||
// Tour not present, proceed
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 1. Opening the Add App / Widget modal from edit mode
|
||||
// =========================================================================
|
||||
test("opens the ItemSelectModal from edit mode with full catalog and controls", async () => {
|
||||
await actions.enterEditModeAsync();
|
||||
|
||||
// Verify edit mode toggle is active
|
||||
const editToggle = page.getByTestId("board-edit-mode-toggle");
|
||||
await expect(editToggle).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
// Open modal via header action
|
||||
const headerAddButton = page.locator('button[aria-label="Add board content"]');
|
||||
await expect(headerAddButton).toBeVisible();
|
||||
await headerAddButton.click();
|
||||
|
||||
// Verify Universal Create dialog lists creation actions
|
||||
const createDialog = page.getByRole("dialog");
|
||||
await expect(createDialog).toBeVisible();
|
||||
|
||||
const widgetOption = createDialog
|
||||
.locator("button")
|
||||
.filter({ hasText: /Widget/i })
|
||||
.first();
|
||||
await expect(widgetOption).toBeVisible();
|
||||
await widgetOption.click();
|
||||
|
||||
// Verify Choose item to add modal is open
|
||||
const itemModal = page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first();
|
||||
await expect(itemModal).toBeVisible();
|
||||
|
||||
// Verify search input is rendered and autofocused
|
||||
const searchInput = itemModal
|
||||
.locator('input[placeholder*="Filter items"], input[aria-label*="Filter items"]')
|
||||
.first();
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
// Verify destination selector is present because multiple sections exist
|
||||
const destinationSelect = itemModal.locator('input[aria-label="Add to"]').first();
|
||||
await expect(destinationSelect).toBeVisible();
|
||||
|
||||
// Verify multiple widget cards are rendered in grid
|
||||
const widgetCards = itemModal.locator("button[aria-label]");
|
||||
expect(await widgetCards.count()).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 2. Search & filtering widgets by name and supported integration names
|
||||
// =========================================================================
|
||||
test("filters widgets by name and supported integration names with empty state fallback", async () => {
|
||||
await actions.openItemSelectModalAsync();
|
||||
const modal = page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first();
|
||||
|
||||
// Filter by widget name: "Clock"
|
||||
await actions.searchWidgetsAsync("Clock");
|
||||
const clockCard = modal.locator('button[aria-label="Clock"]').first();
|
||||
await expect(clockCard).toBeVisible();
|
||||
|
||||
// Non-matching widget should not be visible
|
||||
const weatherCard = modal.locator('button[aria-label="Weather"]').first();
|
||||
await expect(weatherCard).not.toBeVisible();
|
||||
|
||||
// Filter by supported integration name: "Sonarr"
|
||||
await actions.searchWidgetsAsync("Sonarr");
|
||||
// Widgets that support Sonarr (like Calendar or Releases) should appear
|
||||
const sonarrMatchingCards = modal.locator("button[aria-label]");
|
||||
expect(await sonarrMatchingCards.count()).toBeGreaterThan(0);
|
||||
|
||||
// Filter by non-existent query
|
||||
await actions.searchWidgetsAsync("nonexistent-search-term-xyz");
|
||||
await expect(modal.getByText("No results")).toBeVisible();
|
||||
|
||||
// Clear search restores all widget cards
|
||||
await actions.clearSearchAsync();
|
||||
expect(await modal.locator("button[aria-label]").count()).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 3. Selecting a widget to open master-detail 5×3 live preview
|
||||
// =========================================================================
|
||||
test("opens 5×3 master-detail live preview on card click and returns to grid on close", async () => {
|
||||
await actions.openItemSelectModalAsync();
|
||||
const modal = page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first();
|
||||
|
||||
// Select Clock widget to trigger master-detail layout
|
||||
await actions.selectWidgetAsync("Clock");
|
||||
|
||||
// Verify preview header banner
|
||||
await expect(modal.getByText("Live Preview & Options")).toBeVisible();
|
||||
await expect(modal.getByText("Clock").first()).toBeVisible();
|
||||
|
||||
// Verify 5×3 preview frame container
|
||||
await expect(modal.getByText("Preview (5×3)")).toBeVisible();
|
||||
|
||||
// Verify Add button in preview header
|
||||
const addBtn = modal.locator('button[type="button"]').filter({ hasText: /^Add$/i }).first();
|
||||
await expect(addBtn).toBeVisible();
|
||||
|
||||
// Verify Widget Settings section
|
||||
await expect(modal.getByText("Widget Settings")).toBeVisible();
|
||||
|
||||
// Close preview pane
|
||||
await actions.closePreviewAsync();
|
||||
|
||||
// Verify preview pane is closed and full catalog returns
|
||||
await expect(modal.getByText("Live Preview & Options")).not.toBeVisible();
|
||||
await expect(modal.locator('button[aria-label="Clock"]').first()).toBeVisible();
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 4. Interactive widget option configuration inside the settings panel
|
||||
// =========================================================================
|
||||
test("interactively configures widget options inside the settings panel", async () => {
|
||||
await actions.openItemSelectModalAsync();
|
||||
const modal = page.getByRole("dialog").filter({ hasText: "Choose item to add" }).first();
|
||||
|
||||
// Select Clock widget
|
||||
await actions.selectWidgetAsync("Clock");
|
||||
|
||||
// Toggle "Show custom title" switch on
|
||||
const customTitleSwitch = modal.getByLabel(/custom title/i).first();
|
||||
await expect(customTitleSwitch).toBeVisible();
|
||||
await customTitleSwitch.click();
|
||||
|
||||
// Custom title text input becomes conditionally visible
|
||||
const customTitleInput = modal.getByRole("textbox", { name: /custom title/i }).first();
|
||||
await expect(customTitleInput).toBeVisible({ timeout: 5_000 });
|
||||
await customTitleInput.fill("E2E Interactive Clock");
|
||||
|
||||
// Toggle "Use custom timezone" switch on
|
||||
const timezoneSwitch = modal.getByLabel(/custom timezone/i).first();
|
||||
if (await timezoneSwitch.isVisible()) {
|
||||
await timezoneSwitch.click();
|
||||
const timezoneSelect = modal.locator('input[placeholder*="timezone" i], input[aria-label*="timezone" i]').first();
|
||||
await expect(timezoneSelect).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
// Toggle "Show weather" switch on
|
||||
const weatherSwitch = modal.getByLabel(/weather/i).first();
|
||||
if (await weatherSwitch.isVisible()) {
|
||||
await weatherSwitch.click();
|
||||
// Location input should appear
|
||||
await expect(modal.getByText("Brisbane").or(modal.locator('input[value*="Brisbane"]')).first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 5. In-place / inline integration connection 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 });
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, unknown>>("@homarr/modals");
|
||||
return {
|
||||
...actual,
|
||||
useModalAction: () => ({
|
||||
openModal: mockOpenQuickAddAppModal,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"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(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: true }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: true }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -85,35 +85,6 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Embedded Dynamic Style for Card Hover & Focus */}
|
||||
<style>{`
|
||||
[data-app-card] {
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 175px;
|
||||
height: 100%;
|
||||
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
[data-app-card]:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
}
|
||||
[data-app-card][data-selected="true"] {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
border-width: 2px;
|
||||
}
|
||||
[data-app-card]:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Top Search Input */}
|
||||
<Input
|
||||
value={search}
|
||||
|
||||
@@ -35,274 +35,335 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
aria-label="Sonarr Card"
|
||||
icon={<span data-testid="card-icon">Icon</span>}
|
||||
title="Sonarr"
|
||||
topRight={<Badge data-testid="card-topright">Media</Badge>}
|
||||
description="Manage TV series downloads"
|
||||
footerLeft={<span data-testid="card-footer-left">Left Meta</span>}
|
||||
footerRight={<span data-testid="card-footer-right">Right Meta</span>}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
aria-label="Sonarr Card"
|
||||
icon={<span data-testid="card-icon">Icon</span>}
|
||||
title="Sonarr"
|
||||
topRight={<Badge data-testid="card-topright">Media</Badge>}
|
||||
description="Manage TV series downloads"
|
||||
footerLeft={<span data-testid="card-footer-left">Left Meta</span>}
|
||||
footerRight={<span data-testid="card-footer-right">Right Meta</span>}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
title="Plex"
|
||||
description="Media Server"
|
||||
footerLeft={<span>Footer</span>}
|
||||
classNames={{
|
||||
root: "custom-root",
|
||||
header: "custom-header",
|
||||
body: "custom-body",
|
||||
footer: "custom-footer",
|
||||
}}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Very Long Title Name That Should Not Wrap" topRight={<Badge>Category Badge</Badge>} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
title={<span data-testid="custom-title-node">Custom Title Element</span>}
|
||||
topRight={<span data-testid="custom-top-right">Top Right</span>}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Test" description="This should not appear">
|
||||
<div data-testid="custom-body-children">Custom Child Content</div>
|
||||
</SelectableCard>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="No Desc Item" description="" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("No description provided");
|
||||
});
|
||||
|
||||
it("omits header, body, or footer when corresponding props are omitted", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Selected Item" selected />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.getAttribute("data-selected")).toBe("true");
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Unselected Item" selected={false} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Disabled Item" disabled onClick={onClick} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Loading Item" loading onClick={onClick} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Interactive Card" onClick={onClick} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
icon={<span data-testid="only-icon">I</span>}
|
||||
title="Only Header"
|
||||
topRight={<span data-testid="only-topright">TR</span>}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Test" footerLeft={<span data-testid="left-only">Left</span>} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector(`.${classes.footer}`)).not.toBeNull();
|
||||
expect(host.querySelector("[data-testid='left-only']")).not.toBeNull();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Test" footerRight={<span data-testid="right-only">Right</span>} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
title="Plex"
|
||||
description="Media Server"
|
||||
footerLeft={<span>Footer</span>}
|
||||
classNames={{
|
||||
root: "custom-root",
|
||||
header: "custom-header",
|
||||
body: "custom-body",
|
||||
footer: "custom-footer",
|
||||
}}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
title="Very Long Title Name That Should Not Wrap"
|
||||
topRight={<Badge>Category Badge</Badge>}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
title={<span data-testid="custom-title-node">Custom Title Element</span>}
|
||||
topRight={<span data-testid="custom-top-right">Top Right</span>}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Test" description="This should not appear">
|
||||
<div data-testid="custom-body-children">Custom Child Content</div>
|
||||
</SelectableCard>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="No Desc Item" description="" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("No description provided");
|
||||
});
|
||||
|
||||
it("omits header, body, or footer when corresponding props are omitted", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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<HTMLButtonElement>();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard ref={ref} title="Ref Card" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
describe("states", () => {
|
||||
it("handles selected state via data-selected attribute", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Selected Item" selected />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Unselected Item" selected={false} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Disabled Item" disabled onClick={onClick} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Loading Item" loading onClick={onClick} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Radius Card" radius="lg" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
describe("interactions and accessibility", () => {
|
||||
it("supports keyboard activation via Enter and Space", async () => {
|
||||
const onClick = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Interactive Card" onClick={onClick} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
title="Custom Props Card"
|
||||
data-testid="my-selectable-card"
|
||||
data-custom="custom-value"
|
||||
tabIndex={0}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
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<HTMLButtonElement>();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard ref={ref} title="Ref Card" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
|
||||
expect(ref.current?.tagName).toBe("BUTTON");
|
||||
});
|
||||
|
||||
it("resolves radius CSS variables via varsResolver", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Radius Card" radius="lg" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.style.getPropertyValue("--selectable-card-radius")).toBe("var(--mantine-radius-lg)");
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Radius Card Sm" radius="sm" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
title="Custom Props Card"
|
||||
data-testid="my-selectable-card"
|
||||
data-custom="custom-value"
|
||||
tabIndex={0}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,7 @@ export const SelectableCard = factory<SelectableCardFactory>((_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<SelectableCardFactory>((_props, ref) => {
|
||||
gap="xs"
|
||||
style={{ width: "100%", overflow: "hidden" }}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0, minWidth: 0 }}>
|
||||
<Group gap="xs" wrap="nowrap" style={{ flex: "1 1 auto", minWidth: 0, overflow: "hidden" }}>
|
||||
{icon}
|
||||
{typeof title === "string" ? (
|
||||
<Text fw={700} size="md" style={{ whiteSpace: "nowrap" }}>
|
||||
<Text
|
||||
fw={700}
|
||||
size="md"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
) : (
|
||||
@@ -128,10 +137,7 @@ export const SelectableCard = factory<SelectableCardFactory>((_props, ref) => {
|
||||
<Box
|
||||
style={{
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{topRight}
|
||||
|
||||
Reference in new issue
Block a user