refactor(board): modernize item and application select modals with SelectableCard (#6642)
Modernize board item, application, and integration selection flows with shared Mantine components, batch board actions, improved accessibility, and hardened persistence.
This commit is contained in:
1 parent
bd4f6a92c6
commit
f3014fe271
60 files changed
+3791
-1839
No files matched your search
@@ -11,6 +11,19 @@
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.settingsButton {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
:global([data-type="item"]:hover) .settingsButton,
|
||||
:global([data-type="item"]:focus-within) .settingsButton,
|
||||
.settingsButton[data-menu-open="true"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.editModeInertContent {
|
||||
display: contents;
|
||||
}
|
||||
@@ -39,3 +52,9 @@
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.settingsButton {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-ite
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { useOpenItemMoveModal } from "./item-move-modal";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
|
||||
interface BoardItemMenuProps {
|
||||
@@ -105,9 +106,11 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
size={24}
|
||||
radius="sm"
|
||||
pos="absolute"
|
||||
top={12}
|
||||
right={8}
|
||||
top={4}
|
||||
right={4}
|
||||
style={{ zIndex: 30 }}
|
||||
className={itemContentClasses.settingsButton}
|
||||
data-menu-open={isMenuOpen || undefined}
|
||||
data-board-widget-settings
|
||||
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
||||
>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
.action {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
.card:hover .action,
|
||||
.card:focus-within .action {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.action {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.action {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// @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.create.connectionStatus.ready": "Ready",
|
||||
"item.create.connectionStatus.needsSetup": "Needs setup",
|
||||
"item.create.connectionStatus.noConnectionRequired": "No connection required",
|
||||
"item.create.standalone": "Standalone",
|
||||
"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 null when status is 'noConnectionRequired'", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<ConnectionStatusBadge status="noConnectionRequired" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("[class*='Badge-root']")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SupportedIntegrations", () => {
|
||||
it("renders up to 5 avatars for supported integrations", async () => {
|
||||
const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex"];
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SupportedIntegrations integrations={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 6 or more integrations are supported", async () => {
|
||||
const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex", "jellyfin", "emby", "deluge"];
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SupportedIntegrations integrations={integrations} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// 6 integrations total: 4 shown, +2 in badge
|
||||
expect(host.textContent).toContain("+2");
|
||||
});
|
||||
|
||||
it("renders Standalone label when integrations list is empty", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SupportedIntegrations integrations={[]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Standalone");
|
||||
});
|
||||
});
|
||||
|
||||
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} disabled={false} loading={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("does not mark widget cards as selected", 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}
|
||||
disabled={false}
|
||||
loading={false}
|
||||
connectionStatus="noConnectionRequired"
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.hasAttribute("data-selected")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
@@ -6,16 +6,18 @@ import {
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Input,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
|
||||
import { IconApi, IconBuildingStore, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -23,8 +25,8 @@ import { useSession } from "@homarr/auth/client";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import {
|
||||
getIconUrl,
|
||||
getBoardLaneColumnCount,
|
||||
getIconUrl,
|
||||
getIntegrationName,
|
||||
getRootSectionLane,
|
||||
widgetIntegrationSupport,
|
||||
@@ -36,14 +38,14 @@ import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
import { SelectableCard } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import type { WidgetConnectionStatus } from "./item-select-data";
|
||||
import {
|
||||
@@ -64,6 +66,16 @@ interface ItemSelectModalContentProps {
|
||||
initialSearch?: string;
|
||||
}
|
||||
|
||||
export interface WidgetItemData {
|
||||
kind: WidgetKind;
|
||||
supportedIntegrations: IntegrationKind[];
|
||||
icon: TablerIcon;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 };
|
||||
|
||||
const ItemSelectModalContent = ({
|
||||
actions,
|
||||
integrationData,
|
||||
@@ -108,15 +120,14 @@ const ItemSelectModalContent = ({
|
||||
[board.sections, currentLayout, mainCanvasSection, t],
|
||||
);
|
||||
const [targetSectionId, setTargetSectionId] = useState<string | null>(placementOptions[0]?.value ?? null);
|
||||
const effectiveSectionId = placementOptions.some(({ value }) => value === targetSectionId)
|
||||
? targetSectionId
|
||||
: (placementOptions[0]?.value ?? null);
|
||||
|
||||
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
||||
const selectionLock = useRef(false);
|
||||
const { createItem, removeItem } = useItemActions();
|
||||
|
||||
useEffect(() => {
|
||||
if (placementOptions.some(({ value }) => value === targetSectionId)) return;
|
||||
setTargetSectionId(placementOptions[0]?.value ?? null);
|
||||
}, [placementOptions, targetSectionId]);
|
||||
|
||||
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||
const settings = useSettings();
|
||||
@@ -128,7 +139,7 @@ const ItemSelectModalContent = ({
|
||||
[integrationData],
|
||||
);
|
||||
|
||||
const items = useMemo(
|
||||
const items = useMemo<WidgetItemData[]>(
|
||||
() =>
|
||||
widgetKinds
|
||||
.filter((kind) => kind !== "customApi")
|
||||
@@ -188,7 +199,7 @@ const ItemSelectModalContent = ({
|
||||
createdItem.layouts.find(({ layoutId }) => layoutId === currentLayoutId)?.sectionId ??
|
||||
createdItem.layouts[0]?.sectionId;
|
||||
const actualDestination = placementOptions.find(({ value }) => value === actualSectionId)?.label;
|
||||
const wasRelocated = targetSectionId !== null && actualSectionId !== targetSectionId && actualDestination;
|
||||
const wasRelocated = effectiveSectionId !== null && actualSectionId !== effectiveSectionId && actualDestination;
|
||||
const notificationId = `board-item-created:${itemId}`;
|
||||
showSuccessNotification({
|
||||
id: notificationId,
|
||||
@@ -247,7 +258,7 @@ const ItemSelectModalContent = ({
|
||||
kind: "customApi",
|
||||
integrationIds: [],
|
||||
options: configuredOptions,
|
||||
targetSectionId: targetSectionId ?? undefined,
|
||||
targetSectionId: effectiveSectionId ?? undefined,
|
||||
advancedOptions,
|
||||
});
|
||||
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
|
||||
@@ -310,7 +321,7 @@ const ItemSelectModalContent = ({
|
||||
kind,
|
||||
options,
|
||||
integrationIds: newIntegrationIds,
|
||||
targetSectionId: targetSectionId ?? undefined,
|
||||
targetSectionId: effectiveSectionId ?? undefined,
|
||||
advancedOptions,
|
||||
});
|
||||
if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return;
|
||||
@@ -377,125 +388,233 @@ const ItemSelectModalContent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex gap="md" align="stretch">
|
||||
<Box style={{ flex: "1 1 0%", minWidth: 0 }}>
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
ariaLabel={t("item.create.search")}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
|
||||
const [item] = filteredItems;
|
||||
if (item) void handleAdd(item.kind);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{placementOptions.length > 1 && (
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
<Select
|
||||
label={t("item.create.destination.label")}
|
||||
description={t("item.create.destination.description")}
|
||||
data={placementOptions}
|
||||
value={targetSectionId}
|
||||
onChange={setTargetSectionId}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{filteredItems.map((item) => (
|
||||
<WidgetItem
|
||||
key={item.kind}
|
||||
item={item}
|
||||
onSelect={() => void handleAdd(item.kind)}
|
||||
onFocus={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
}}
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === item.kind}
|
||||
connectionStatus={getWidgetConnectionStatus({
|
||||
supportedIntegrations: item.supportedIntegrations,
|
||||
availableKinds,
|
||||
connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
|
||||
})}
|
||||
<Stack gap="md">
|
||||
{/* Top Search & Destination Selector */}
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="sm">
|
||||
<Box style={{ flex: "1 1 240px", minWidth: 200 }}>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
aria-label={t("item.create.search")}
|
||||
data-autofocus
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
loadingSelection === null &&
|
||||
filteredItems.length === 1 &&
|
||||
filteredItems[0]
|
||||
) {
|
||||
void handleAdd(filteredItems[0].kind);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{placementOptions.length > 1 && (
|
||||
<Box style={{ flex: "0 1 280px", minWidth: 200 }}>
|
||||
<Select
|
||||
label={t("item.create.destination.label")}
|
||||
data={placementOptions}
|
||||
value={effectiveSectionId}
|
||||
onChange={setTargetSectionId}
|
||||
allowDeselect={false}
|
||||
size="xs"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Divider
|
||||
label={t("customWidget.page.list.title")}
|
||||
labelPosition="center"
|
||||
my="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
{/* Grid of Widgets */}
|
||||
<SimpleGrid cols={selectGridCols} spacing="sm">
|
||||
{filteredItems.map((item) => (
|
||||
<WidgetItem
|
||||
key={item.kind}
|
||||
item={item}
|
||||
onSelect={() => void handleAdd(item.kind)}
|
||||
onFocus={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
}}
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === item.kind}
|
||||
connectionStatus={getWidgetConnectionStatus({
|
||||
supportedIntegrations: item.supportedIntegrations,
|
||||
availableKinds,
|
||||
connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Divider
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<IconApi size={14} />
|
||||
<Text size="xs" fw={600}>
|
||||
{t("customWidget.page.list.title")}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
labelPosition="center"
|
||||
my="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<SelectableCard
|
||||
key={def.id}
|
||||
onClick={() => void handleAddCustomWidget(def)}
|
||||
aria-label={def.name}
|
||||
icon={
|
||||
def.iconUrl ? (
|
||||
<Image src={def.iconUrl} w={24} h={24} fit="contain" style={{ flexShrink: 0 }} />
|
||||
) : (
|
||||
<ThemeIcon size={34} radius="md" variant="light" color="primaryColor">
|
||||
<IconApi size={20} />
|
||||
</ThemeIcon>
|
||||
)
|
||||
}
|
||||
title={def.name}
|
||||
description={def.description ?? ""}
|
||||
footerLeft={
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("item.create.customWidget")}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<CatalogItem
|
||||
key={def.id}
|
||||
height={selectGridCardHeight}
|
||||
label={def.name}
|
||||
status={t("item.create.connectionStatus.noConnectionRequired")}
|
||||
disabled={loadingSelection !== null}
|
||||
busy={loadingSelection === `custom:${def.id}`}
|
||||
onSelect={() => void handleAddCustomWidget(def)}
|
||||
onFocus={() => {
|
||||
void loadWidgetDefinition("customApi").catch(() => undefined);
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
void loadWidgetDefinition("customApi").catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
{def.iconUrl ? (
|
||||
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
) : (
|
||||
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
)}
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{def.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{def.description ?? ""}
|
||||
</Text>
|
||||
<ConnectionStatusBadge status="noConnectionRequired" />
|
||||
</Stack>
|
||||
</CatalogItem>
|
||||
))}
|
||||
))}
|
||||
<Box mt="xs" style={{ gridColumn: "1 / -1" }}>
|
||||
<Button
|
||||
component="a"
|
||||
href="/manage/custom-widgets/workshop"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
fullWidth
|
||||
leftSection={<IconBuildingStore size={16} />}
|
||||
>
|
||||
{t("workshop.browseWorkshop")}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
{/* Opens in a new tab so unsaved board changes survive the detour. */}
|
||||
<Button
|
||||
component="a"
|
||||
href="/manage/custom-widgets/workshop"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
fullWidth
|
||||
leftSection={<IconBuildingStore size={16} />}
|
||||
>
|
||||
{t("workshop.browseWorkshop")}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
</Box>
|
||||
</Flex>
|
||||
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// WidgetItem: SelectableCard with Inset, Title, Status Badge, and Footer
|
||||
// =========================================================================
|
||||
export const WidgetItem = ({
|
||||
item,
|
||||
onSelect,
|
||||
onFocus,
|
||||
onPointerEnter,
|
||||
disabled,
|
||||
loading,
|
||||
connectionStatus,
|
||||
}: {
|
||||
item: WidgetItemData;
|
||||
onSelect: () => void;
|
||||
onFocus?: () => void;
|
||||
onPointerEnter?: () => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
connectionStatus: WidgetConnectionStatus;
|
||||
}) => {
|
||||
return (
|
||||
<SelectableCard
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
onClick={onSelect}
|
||||
onFocus={onFocus}
|
||||
onPointerEnter={onPointerEnter}
|
||||
aria-label={item.name}
|
||||
icon={
|
||||
<ThemeIcon size={34} radius="md" variant="light" color="primaryColor">
|
||||
<item.icon size={20} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
title={item.name}
|
||||
topRight={<ConnectionStatusBadge status={connectionStatus} />}
|
||||
description={item.description}
|
||||
footerLeft={<SupportedIntegrations integrations={item.supportedIntegrations} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => {
|
||||
const t = useI18n();
|
||||
if (status === "noConnectionRequired") return null;
|
||||
const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray";
|
||||
|
||||
return (
|
||||
<Badge variant="dot" color={color} size="xs" tt="none" w="fit-content" maw="100%">
|
||||
{t(`item.create.connectionStatus.${status}`)}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => {
|
||||
const t = useI18n();
|
||||
|
||||
if (integrations.length === 0) {
|
||||
return (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
{t("item.create.standalone")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const countToShow = integrations.length >= 6 ? 4 : 5;
|
||||
const moreCount = integrations.length - countToShow;
|
||||
|
||||
return (
|
||||
<Group gap={2}>
|
||||
<Tooltip.Group closeDelay={100}>
|
||||
<Group gap={2}>
|
||||
{integrations.slice(0, countToShow).map((integration) => (
|
||||
<Tooltip key={integration} label={getIntegrationName(integration)} withArrow>
|
||||
<Avatar src={getIconUrl(integration)} size="xs" radius="xl" />
|
||||
</Tooltip>
|
||||
))}
|
||||
{moreCount > 0 && (
|
||||
<Tooltip
|
||||
withArrow
|
||||
label={
|
||||
<Stack gap={2}>
|
||||
{integrations.slice(countToShow).map((integration) => (
|
||||
<Text key={integration} size="xs">
|
||||
{getIntegrationName(integration)}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<Avatar radius="xl" size="xs">
|
||||
+{moreCount}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Tooltip.Group>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const ItemSelectModalFrame = ({
|
||||
actions,
|
||||
innerProps,
|
||||
@@ -541,109 +660,3 @@ export const ItemSelectModal = createModal<{
|
||||
defaultTitle: (t) => t("item.create.title"),
|
||||
size: modalSizeSelect,
|
||||
});
|
||||
|
||||
const WidgetItem = ({
|
||||
item,
|
||||
onSelect,
|
||||
onFocus,
|
||||
onPointerEnter,
|
||||
disabled,
|
||||
loading,
|
||||
connectionStatus,
|
||||
}: {
|
||||
item: {
|
||||
kind: WidgetKind;
|
||||
supportedIntegrations: IntegrationKind[];
|
||||
name: string;
|
||||
description: string;
|
||||
icon: TablerIcon;
|
||||
};
|
||||
onSelect: () => void;
|
||||
onFocus: () => void;
|
||||
onPointerEnter: () => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
connectionStatus: WidgetConnectionStatus;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const statusLabel = t(`item.create.connectionStatus.${connectionStatus}`);
|
||||
|
||||
return (
|
||||
<CatalogItem
|
||||
height={selectGridCardHeight}
|
||||
label={item.name}
|
||||
status={statusLabel}
|
||||
onFocus={onFocus}
|
||||
onPointerEnter={onPointerEnter}
|
||||
onSelect={onSelect}
|
||||
disabled={disabled}
|
||||
busy={loading}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<item.icon size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Tooltip label={item.description} multiline w={250} disabled={!item.description}>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{item.description}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<ConnectionStatusBadge status={connectionStatus} />
|
||||
<SupportedIntegrations integrations={item.supportedIntegrations} />
|
||||
</Stack>
|
||||
</CatalogItem>
|
||||
);
|
||||
};
|
||||
|
||||
const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => {
|
||||
const t = useI18n();
|
||||
const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray";
|
||||
|
||||
return (
|
||||
<Badge variant="light" color={color} size="xs" tt="none" w="fit-content" maw="100%">
|
||||
{t(`item.create.connectionStatus.${status}`)}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => {
|
||||
if (integrations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const countToShow = integrations.length >= 8 ? 6 : 7;
|
||||
const moreCount = integrations.length - countToShow;
|
||||
|
||||
return (
|
||||
<Group gap={2}>
|
||||
<Tooltip.Group closeDelay={100}>
|
||||
<Group gap={2}>
|
||||
{integrations.slice(0, countToShow).map((integration) => (
|
||||
<Tooltip key={integration} label={getIntegrationName(integration)} withArrow>
|
||||
<Avatar src={getIconUrl(integration)} size="xs" radius="xl" />
|
||||
</Tooltip>
|
||||
))}
|
||||
{moreCount > 0 && (
|
||||
<Tooltip
|
||||
withArrow
|
||||
label={
|
||||
<>
|
||||
{integrations.slice(countToShow).map((integration) => (
|
||||
<div key={integration}>{getIntegrationName(integration)}</div>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Avatar radius="xl" size="xs">
|
||||
+{moreCount}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Tooltip.Group>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, use } from "react";
|
||||
import { Suspense, use, useCallback, useMemo, useState } from "react";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import type * as WidgetModalsModule from "@homarr/widgets/modals";
|
||||
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
|
||||
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
|
||||
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
|
||||
import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
|
||||
|
||||
@@ -80,13 +83,46 @@ interface LazyWidgetEditModalContentProps {
|
||||
|
||||
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||
const { WidgetEditModal } = use(loadWidgetEditModal());
|
||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||
const Component = WidgetEditModal.component;
|
||||
const utils = clientApi.useUtils();
|
||||
const [createdIntegrations, setCreatedIntegrations] = useState<IntegrationSelectOption[]>([]);
|
||||
|
||||
const supportedKinds = useMemo(() => {
|
||||
if (!("supportedIntegrations" in props.innerProps.definition)) return [];
|
||||
return (props.innerProps.definition.supportedIntegrations ?? []).filter((k) => k !== "mock");
|
||||
}, [props.innerProps.definition]);
|
||||
|
||||
const handleOpenNewIntegration = useCallback(
|
||||
(onCreated?: (id: string) => void) => {
|
||||
openIntegrationModal({
|
||||
allowedKinds: supportedKinds,
|
||||
onSuccess: (result) => {
|
||||
if (result?.integration) {
|
||||
setCreatedIntegrations((prev) => [...prev, result.integration]);
|
||||
void utils.integration.all.invalidate();
|
||||
onCreated?.(result.integration.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[openIntegrationModal, supportedKinds, utils],
|
||||
);
|
||||
|
||||
const combinedIntegrationData = useMemo(() => {
|
||||
const existing = props.innerProps.integrationData;
|
||||
const additional = createdIntegrations.filter((c) => !existing.some((e) => e.id === c.id));
|
||||
return [...existing, ...additional];
|
||||
}, [props.innerProps.integrationData, createdIntegrations]);
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
innerProps={{
|
||||
...props.innerProps,
|
||||
integrationData: combinedIntegrationData,
|
||||
integrationEditForm: LazyIntegrationEditForm,
|
||||
onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -122,6 +158,5 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
presentation: "inspector",
|
||||
closeOnClickOutside: false,
|
||||
});
|
||||
Reference in new issue
Block a user