feat: port release-v2 experience improvements from #6582 (audited subset) (#6609)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-18 09:40:02 +02:00
1 parent e3a713d2df
commit e842177d9b
163 files changed
+7390 -1240

No files matched your search

@@ -46,7 +46,7 @@ describe("getAssistantWidgetConfiguration", () => {
boardName: "Home",
kind: "audioStats",
summary: "Show audio statistics",
integrationIds: ["navidrome-1", "audiobookshelf-1", "plex-1"],
integrationIds: ["navidrome-private", "navidrome-1", "audiobookshelf-1", "plex-1"],
},
settings,
[
@@ -55,28 +55,28 @@ describe("getAssistantWidgetConfiguration", () => {
name: "Navidrome",
kind: "navidrome",
url: "https://music.example.com",
permissions: { hasUseAccess: true },
permissions: { hasUseAccess: true, hasInteractAccess: true, hasFullAccess: true },
},
{
id: "audiobookshelf-1",
name: "Audiobookshelf",
kind: "audiobookshelf",
url: "https://books.example.com",
permissions: { hasUseAccess: true },
permissions: { hasUseAccess: true, hasInteractAccess: true, hasFullAccess: true },
},
{
id: "plex-1",
name: "Plex",
kind: "plex",
url: "https://plex.example.com",
permissions: { hasUseAccess: true },
permissions: { hasUseAccess: true, hasInteractAccess: true, hasFullAccess: true },
},
{
id: "navidrome-private",
name: "Private Navidrome",
kind: "navidrome",
url: "https://private.example.com",
permissions: { hasUseAccess: false },
permissions: { hasUseAccess: false, hasInteractAccess: false, hasFullAccess: false },
},
],
);
@@ -6,6 +6,7 @@ import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@ma
import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import type { IntegrationKind } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
@@ -20,9 +21,10 @@ import { useAssistantAutomaticAction } from "./assistant-auto-approval";
import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress";
import { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status";
import type { ConfigureWidgetArgs, ConfigureWidgetResult } from "./assistant-tool-contracts";
import { IntegrationSelectModal } from "../integration/integration-select-modal";
interface AssistantIntegration extends IntegrationSelectOption {
permissions: { hasUseAccess: boolean };
permissions: NonNullable<IntegrationSelectOption["permissions"]>;
}
export const getAssistantWidgetConfiguration = (
@@ -66,6 +68,7 @@ export const AssistantConfigureWidgetTool = ({
}: ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>) => {
const t = useScopedI18n("common.assistant.configureWidget");
const fullT = useI18n();
const { data: session } = useSession();
const settings = useSettings();
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
const definition = args?.kind ? widgetImports[args.kind].definition : undefined;
@@ -80,10 +83,11 @@ export const AssistantConfigureWidgetTool = ({
}, [args, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
const missingRequiredIntegration =
configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0;
const { openModal } = useModalAction(WidgetEditModal);
const { openModal: openWidgetModal } = useModalAction(WidgetEditModal);
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
const autoConfirming = useAssistantAutomaticAction({
toolCallId,
ready: result === undefined && configuration !== null,
ready: result === undefined && configuration !== null && !missingRequiredIntegration,
completed: result !== undefined,
confirm: () => {
if (!args || !configuration) return;
@@ -154,6 +158,26 @@ export const AssistantConfigureWidgetTool = ({
if (!configuration) return null;
const openConfigurationModal = (
value = configuration.value,
integrationData: IntegrationSelectOption[] = configuration.integrationData,
) =>
openWidgetModal(
{
kind: args.kind,
definition: widgetImports[args.kind].definition,
value,
integrationData,
integrationSupport: configuration.integrationSupport,
settings,
onSuccessfulEdit: ({ options, integrationIds }) =>
addResult({ boardId: args.boardId, kind: args.kind, options, integrationIds }),
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${args.kind}.name`)}`,
},
);
if (autoConfirming) {
return (
<Box className={classes.appTool}>
@@ -168,9 +192,32 @@ export const AssistantConfigureWidgetTool = ({
<Stack gap="sm">
<Text size="sm">{t("noIntegrationDescription")}</Text>
<Group gap="xs">
<Button component="a" href="/manage/integrations" size="compact-sm" variant="light">
{t("manageIntegrations")}
</Button>
{session?.user.permissions.includes("integration-create") ? (
<Button
size="compact-sm"
variant="light"
onClick={() =>
openIntegrationModal({
allowedKinds:
definition && "supportedIntegrations" in definition
? (definition.supportedIntegrations as readonly IntegrationKind[])
: [],
onSuccess: (created) => {
if (!created) return;
openConfigurationModal({ ...configuration.value, integrationIds: [created.integration.id] }, [
created.integration,
]);
},
})
}
>
{t("connectIntegration")}
</Button>
) : (
<Button component="a" href="/manage/integrations" size="compact-sm" variant="light">
{t("manageIntegrations")}
</Button>
)}
<Button
size="compact-sm"
variant="default"
@@ -205,28 +252,7 @@ export const AssistantConfigureWidgetTool = ({
</Text>
</Box>
</Group>
<Button
mt="md"
fullWidth
leftSection={<IconSettings size={17} />}
onClick={() =>
openModal(
{
kind: args.kind,
definition: widgetImports[args.kind].definition,
value: configuration.value,
integrationData: configuration.integrationData,
integrationSupport: configuration.integrationSupport,
settings,
onSuccessfulEdit: ({ options, integrationIds }) =>
addResult({ boardId: args.boardId, kind: args.kind, options, integrationIds }),
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${args.kind}.name`)}`,
},
)
}
>
<Button mt="md" fullWidth leftSection={<IconSettings size={17} />} onClick={() => openConfigurationModal()}>
{t("review")}
</Button>
</Box>
@@ -0,0 +1,16 @@
.root {
position: absolute;
z-index: 3;
top: calc(var(--mantine-spacing-xl) * var(--board-canvas-inverse-scale, 1));
right: 0;
left: 0;
display: flex;
justify-content: center;
padding-inline: var(--mantine-spacing-md);
pointer-events: none;
}
.panel {
max-width: 42rem;
pointer-events: auto;
}
@@ -0,0 +1,54 @@
"use client";
import { Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
import { IconBox, IconLayoutDashboard, IconPlug, IconResize } from "@tabler/icons-react";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useScopedI18n } from "@homarr/translation/client";
import { useBoardAddActions } from "./use-board-add-actions";
import classes from "./board-empty-state.module.css";
export const BoardEmptyState = () => {
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const { addWidget, addApp, connectService, canConnectService } = useBoardAddActions();
const t = useScopedI18n("board.emptyState");
const hasBoardContent = board.items.length > 0 || board.sections.some((section) => section.kind !== "empty");
if (!isEditMode || hasBoardContent) return null;
return (
<div className={classes.root}>
<Paper className={classes.panel} withBorder radius="md" p="lg">
<Stack align="center" gap="sm">
<ThemeIcon variant="light" size="xl" radius="md">
<IconLayoutDashboard size={24} />
</ThemeIcon>
<Stack align="center" gap={4}>
<Title order={3} size="h4" ta="center">
{t("title")}
</Title>
<Text c="dimmed" size="sm" ta="center" maw="60ch">
{t("description")}
</Text>
</Stack>
<Group justify="center">
<Button leftSection={<IconResize size={18} />} onClick={addWidget}>
{t("addWidget")}
</Button>
<Button variant="default" leftSection={<IconBox size={18} />} onClick={addApp}>
{t("addApp")}
</Button>
{canConnectService && (
<Button variant="default" leftSection={<IconPlug size={18} />} onClick={connectService}>
{t("connectService")}
</Button>
)}
</Group>
</Stack>
</Paper>
</div>
);
};
@@ -0,0 +1,17 @@
.root {
pointer-events: auto;
max-width: min(24rem, calc(100vw - 2rem));
}
.panel {
width: min(24rem, calc(100vw - 2rem));
}
.step {
border-radius: var(--mantine-radius-md);
padding: var(--mantine-spacing-xs);
}
.step[data-complete] {
background: light-dark(var(--mantine-color-green-0), rgba(47, 158, 68, 0.1));
}
@@ -0,0 +1,6 @@
declare const classes: {
readonly panel: string;
readonly root: string;
readonly step: string;
};
export default classes;
@@ -0,0 +1,131 @@
"use client";
import { ActionIcon, Affix, Button, Group, Paper, Progress, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { IconCheck, IconChevronDown, IconChecklist } from "@tabler/icons-react";
import { useIntegrationsWithUseAccess } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useScopedI18n } from "@homarr/translation/client";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
import { getBoardSetupProgress } from "./board-setup-progress";
import { useBoardAddActions } from "./use-board-add-actions";
import classes from "./board-setup-checklist.module.css";
export const BoardSetupChecklist = () => {
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const integrations = useIntegrationsWithUseAccess();
const { addWidget, addApp, connectService, canConnectService } = useBoardAddActions();
const [collapsed, setCollapsed] = useLocalStorage({
key: `homarr-board-setup-checklist-${board.id}`,
defaultValue: false,
});
const t = useScopedI18n("board.setupChecklist");
const trackSetup = useSetupAnalytics();
const progress = getBoardSetupProgress({
itemKinds: board.items.map((item) => item.kind),
usableIntegrationCount: integrations.length,
});
if (!isEditMode || progress.isComplete) return null;
if (collapsed) {
return (
<Affix position={{ bottom: 16, left: 16 }} className={classes.root}>
<Button
variant="default"
leftSection={<IconChecklist size={18} />}
onClick={() => {
trackSetup("checklist-resumed", { entryPoint: "board", outcome: "continued", hasBoardContext: true });
setCollapsed(false);
}}
>
{t("resume", { completed: progress.completedCount, total: progress.totalCount })}
</Button>
</Affix>
);
}
return (
<Affix position={{ bottom: 16, left: 16 }} className={classes.root}>
<Paper className={classes.panel} withBorder shadow="md" radius="md" p="md">
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap">
<div>
<Text fw={600}>{t("title")}</Text>
<Text c="dimmed" size="xs">
{t("description")}
</Text>
</div>
<Tooltip label={t("collapse")}>
<ActionIcon variant="subtle" aria-label={t("collapse")} onClick={() => setCollapsed(true)}>
<IconChevronDown size={18} />
</ActionIcon>
</Tooltip>
</Group>
<Progress
value={progress.totalCount === 0 ? 0 : (progress.completedCount / progress.totalCount) * 100}
aria-label={t("progress")}
/>
<ChecklistStep
complete={progress.steps.content}
label={t("step.content.label")}
description={t("step.content.description")}
actionLabel={t("step.content.action")}
onAction={addWidget}
/>
<ChecklistStep
complete={progress.steps.app}
label={t("step.app.label")}
description={t("step.app.description")}
actionLabel={t("step.app.action")}
onAction={addApp}
/>
<ChecklistStep
complete={progress.steps.service}
label={t("step.service.label")}
description={canConnectService ? t("step.service.description") : t("step.service.permissionDescription")}
actionLabel={canConnectService ? t("step.service.action") : undefined}
onAction={canConnectService ? connectService : undefined}
/>
</Stack>
</Paper>
</Affix>
);
};
const ChecklistStep = ({
complete,
label,
description,
actionLabel,
onAction,
}: {
complete: boolean;
label: string;
description: string;
actionLabel?: string;
onAction?: () => void;
}) => (
<Group className={classes.step} data-complete={complete || undefined} wrap="nowrap" align="flex-start">
<ThemeIcon variant={complete ? "filled" : "light"} color={complete ? "green" : "gray"} size="md" mt={2}>
{complete ? <IconCheck size={14} /> : <IconChecklist size={14} />}
</ThemeIcon>
<Stack gap={1} style={{ flex: 1 }}>
<Text size="sm" fw={600} td={complete ? "line-through" : undefined}>
{label}
</Text>
<Text size="xs" c="dimmed">
{description}
</Text>
</Stack>
{!complete && onAction && actionLabel && (
<Button variant="subtle" size="compact-xs" onClick={onAction}>
{actionLabel}
</Button>
)}
</Group>
);
@@ -0,0 +1,22 @@
import { describe, expect, test } from "vitest";
import { getBoardSetupProgress } from "./board-setup-progress";
describe("getBoardSetupProgress", () => {
test("derives resumable progress from the board and connected services", () => {
expect(getBoardSetupProgress({ itemKinds: ["clock", "app"], usableIntegrationCount: 1 })).toEqual({
steps: { content: true, app: true, service: true },
completedCount: 3,
totalCount: 3,
isComplete: true,
});
});
test("does not count an app as a data widget", () => {
expect(getBoardSetupProgress({ itemKinds: ["app"], usableIntegrationCount: 0 })).toMatchObject({
steps: { content: false, app: true, service: false },
completedCount: 1,
isComplete: false,
});
});
});
@@ -0,0 +1,22 @@
import type { WidgetKind } from "@homarr/definitions";
export interface BoardSetupProgressInput {
itemKinds: readonly WidgetKind[];
usableIntegrationCount: number;
}
export const getBoardSetupProgress = ({ itemKinds, usableIntegrationCount }: BoardSetupProgressInput) => {
const steps = {
content: itemKinds.some((kind) => kind !== "app"),
app: itemKinds.includes("app"),
service: usableIntegrationCount > 0,
};
const completedCount = Object.values(steps).filter(Boolean).length;
return {
steps,
completedCount,
totalCount: Object.keys(steps).length,
isComplete: completedCount === Object.keys(steps).length,
};
};
@@ -4,8 +4,9 @@ import type { Modify } from "@homarr/common/types";
import { getRootSectionLane } from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions";
import { widgetDefaultSizes } from "@homarr/definitions";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
import type { Board, ContainerSection, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
import { getBoardLaneColumnCount } from "~/components/board/layout";
import { getFirstEmptyPosition } from "./empty-position";
import { getSectionElements } from "./section-elements";
@@ -15,33 +16,43 @@ export interface CreateItemInput {
kind: WidgetKind;
options?: Record<string, unknown>;
integrationIds?: string[];
targetSectionId?: string;
advancedOptions?: BoardItemAdvancedOptions;
}
export const createItemCallback =
({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) =>
({ id, kind, options = {}, integrationIds = [], targetSectionId, advancedOptions }: CreateItemInput) =>
(previous: Board): Board => {
const firstSection = previous.sections
const firstCanvasSection = previous.sections
.filter(
(section): section is EmptySection =>
section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
)
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
const requestedSection = targetSectionId
? previous.sections.find(
(section): section is EmptySection | ContainerSection =>
section.id === targetSectionId && (section.kind === "empty" || section.kind === "container"),
)
: undefined;
let targetSection = requestedSection ?? firstCanvasSection;
if (!firstSection) return previous;
if (!targetSection) return previous;
const itemLayouts = createItemLayouts(previous, firstSection, kind);
let itemLayouts = createItemLayouts(previous, targetSection, kind);
if (itemLayouts.length === 0 && firstCanvasSection && targetSection.id !== firstCanvasSection.id) {
targetSection = firstCanvasSection;
itemLayouts = createItemLayouts(previous, targetSection, kind);
}
if (itemLayouts.length === 0) return previous;
const widget = {
id: id ?? createId(),
kind,
options,
layouts: itemLayouts,
integrationIds,
advancedOptions: {
title: null,
customCssClasses: [],
borderColor: "",
},
advancedOptions: advancedOptions ?? { title: null, customCssClasses: [], borderColor: "" },
} satisfies Modify<
Item,
{
@@ -59,14 +70,25 @@ const defaultItemSize = { width: 1, height: 1 };
const getWidgetItemSize = (kind: WidgetKind) => widgetDefaultSizes[kind] ?? defaultItemSize;
const createItemLayouts = (board: Board, currentSection: EmptySection, kind: WidgetKind): ItemLayout[] => {
const createItemLayouts = (
board: Board,
currentSection: EmptySection | ContainerSection,
kind: WidgetKind,
): ItemLayout[] => {
const layouts = getBoardLayouts(board);
const itemSize = getWidgetItemSize(kind);
return layouts.map((layoutId) => {
return layouts.flatMap((layoutId) => {
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
const columnCount = boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : itemSize.width;
const containerLayout =
currentSection.kind === "container"
? currentSection.layouts.find((layout) => layout.layoutId === layoutId)
: undefined;
const lane = currentSection.kind === "empty" ? getRootSectionLane(currentSection.xOffset) : "main";
if (currentSection.kind === "container" && !containerLayout) return [];
const columnCount = containerLayout?.width ?? (boardLayout ? getBoardLaneColumnCount(boardLayout, lane) : 0);
if (columnCount === 0) return [];
const size = {
width: Math.min(itemSize.width, columnCount),
height: itemSize.height,
@@ -80,12 +102,14 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
throw new Error("Your board is full");
}
return {
width: size.width,
height: size.height,
...emptyPosition,
sectionId: currentSection.id,
layoutId,
};
return [
{
width: size.width,
height: size.height,
...emptyPosition,
sectionId: currentSection.id,
layoutId,
},
];
});
};
@@ -149,6 +149,99 @@ describe("item actions create-item", () => {
});
});
test("places a new item directly in the requested container", () => {
const board = new BoardMockBuilder()
.addEmptySection({ id: "canvas" })
.addSection(
new ContainerSectionMockBuilder({ id: "media" })
.addLayout({ layoutId: "layout", parentSectionId: "canvas", width: 4, height: 3 })
.build(),
)
.build();
const layout = board.layouts.at(0);
if (!layout) throw new Error("Expected a board layout");
layout.id = "layout";
const result = createItemCallback({ id: "calendar", kind: "mediaMissing", targetSectionId: "media" })(board);
expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([
expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 4 }),
]);
});
test("omits layouts where the requested container has no layout entry", () => {
const board = new BoardMockBuilder()
.addLayout({ id: "mobile", name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 })
.addEmptySection({ id: "canvas" })
.addSection(
new ContainerSectionMockBuilder({ id: "media" })
.addLayout({ layoutId: "base", parentSectionId: "canvas", width: 4, height: 3 })
.build(),
)
.build();
const baseLayout = board.layouts.find((layout) => layout.role === "base");
if (!baseLayout) throw new Error("Expected a base layout");
baseLayout.id = "base";
const result = createItemCallback({ id: "calendar", kind: "clock", targetSectionId: "media" })(board);
expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([
expect.objectContaining({ layoutId: "base", sectionId: "media" }),
]);
});
test("sizes a new item for its requested rail and omits layouts where that rail is hidden", () => {
const board = new BoardMockBuilder()
.addLayout({ id: "mobile", name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 })
.addEmptySection({ id: "canvas" })
.addEmptySection({ id: "left-rail", xOffset: -1 })
.build();
const baseLayout = board.layouts.find((layout) => layout.role === "base");
if (!baseLayout) throw new Error("Expected a base layout");
baseLayout.leftGutterColumnCount = 2;
const result = createItemCallback({ id: "missing", kind: "mediaMissing", targetSectionId: "left-rail" })(board);
expect(result.items.find((item) => item.id === "missing")?.layouts).toEqual([
expect.objectContaining({ layoutId: baseLayout.id, sectionId: "left-rail", width: 2 }),
]);
});
test("falls back to the main canvas instead of creating a ghost item for a disabled rail", () => {
const board = new BoardMockBuilder()
.addEmptySection({ id: "canvas" })
.addEmptySection({ id: "left-rail", xOffset: -1 })
.build();
const result = createItemCallback({ id: "clock", kind: "clock", targetSectionId: "left-rail" })(board);
expect(result.items.find((item) => item.id === "clock")?.layouts).toEqual([
expect.objectContaining({ sectionId: "canvas" }),
]);
});
test("falls back to the main canvas when a requested destination no longer exists", () => {
const board = new BoardMockBuilder().addEmptySection({ id: "canvas" }).build();
const layout = board.layouts.at(0);
if (!layout) throw new Error("Expected a board layout");
layout.id = "layout";
const result = createItemCallback({ id: "clock", kind: "clock", targetSectionId: "removed" })(board);
expect(result.items.find((item) => item.id === "clock")?.layouts).toEqual([
expect.objectContaining({ layoutId: "layout", sectionId: "canvas" }),
]);
});
test("commits configured advanced options in the creation transaction", () => {
const board = new BoardMockBuilder().addEmptySection({ id: "canvas" }).build();
const advancedOptions = { title: "Kitchen", customCssClasses: ["compact"], borderColor: "#abc" };
const result = createItemCallback({ id: "clock", kind: "clock", advancedOptions })(board);
expect(result.items.find((item) => item.id === "clock")?.advancedOptions).toEqual(advancedOptions);
});
test("clamps wide defaults and places below a full automatic canvas", () => {
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
const layout = board.layouts[0];
@@ -0,0 +1,77 @@
import { describe, expect, test } from "vitest";
import { getBoardRecipeDismissalKey, getBoardRecipeRecommendations } from "./board-recipe-recommendations";
test("recipe dismissal is scoped to both the board and integration kind", () => {
expect(getBoardRecipeDismissalKey("board-a", "piHole")).not.toBe(getBoardRecipeDismissalKey("board-b", "piHole"));
expect(getBoardRecipeDismissalKey("board-a", "piHole")).not.toBe(getBoardRecipeDismissalKey("board-a", "jellyfin"));
});
describe("getBoardRecipeRecommendations", () => {
test("prioritizes widgets unlocked by the newly connected service", () => {
const recommendations = getBoardRecipeRecommendations({
configuredIntegrationKinds: ["piHole", "jellyfin"],
existingItemKinds: [],
preferredIntegrationKind: "jellyfin",
});
const firstFallbackIndex = recommendations.findIndex(({ isNewlyAvailable }) => !isNewlyAvailable);
expect(recommendations.some(({ isNewlyAvailable }) => isNewlyAvailable)).toBe(true);
if (firstFallbackIndex !== -1) {
expect(recommendations.slice(0, firstFallbackIndex).every(({ isNewlyAvailable }) => isNewlyAvailable)).toBe(true);
expect(recommendations.slice(firstFallbackIndex).every(({ isNewlyAvailable }) => !isNewlyAvailable)).toBe(true);
}
});
test("does not recommend widget kinds already present on the board", () => {
const recommendations = getBoardRecipeRecommendations({
configuredIntegrationKinds: ["piHole"],
existingItemKinds: ["dnsHoleSummary"],
preferredIntegrationKind: "piHole",
});
expect(recommendations.every(({ widgetKind }) => widgetKind !== "dnsHoleSummary")).toBe(true);
expect(recommendations.every(({ integrationKind }) => integrationKind === "piHole")).toBe(true);
});
test("deduplicates configured kinds and emitted widget capabilities", () => {
const recommendations = getBoardRecipeRecommendations({
configuredIntegrationKinds: ["piHole", "piHole"],
existingItemKinds: [],
limit: 10,
});
const widgetKinds = recommendations.map(({ widgetKind }) => widgetKind);
expect(new Set(widgetKinds).size).toBe(widgetKinds.length);
expect(recommendations.every(({ integrationKind }) => integrationKind === "piHole")).toBe(true);
});
test("falls back to other configured integrations when the new service has no missing widgets", () => {
const recommendations = getBoardRecipeRecommendations({
configuredIntegrationKinds: ["piHole", "jellyfin"],
existingItemKinds: ["dnsHoleSummary", "dnsHoleControls"],
preferredIntegrationKind: "piHole",
limit: 1,
});
expect(recommendations).toEqual([
expect.objectContaining({ integrationKind: "jellyfin", isNewlyAvailable: false }),
]);
});
test("returns no recipes without a usable connection or capacity", () => {
expect(
getBoardRecipeRecommendations({
configuredIntegrationKinds: [],
existingItemKinds: [],
}),
).toEqual([]);
expect(
getBoardRecipeRecommendations({
configuredIntegrationKinds: ["piHole"],
existingItemKinds: [],
limit: 0,
}),
).toEqual([]);
});
});
@@ -0,0 +1,60 @@
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { nativeFeatureCapabilities, widgetKinds } from "@homarr/definitions";
export interface BoardRecipeRecommendation {
widgetKind: WidgetKind;
integrationKind: IntegrationKind;
isNewlyAvailable: boolean;
score: number;
}
export const getBoardRecipeDismissalKey = (boardId: string, integrationKind: IntegrationKind) =>
`homarr-board-recipes-${boardId}-${integrationKind}`;
interface BoardRecipeRecommendationInput {
configuredIntegrationKinds: readonly IntegrationKind[];
existingItemKinds: readonly WidgetKind[];
preferredIntegrationKind?: IntegrationKind;
limit?: number;
}
/**
* Suggests missing widgets that can use an existing connection. The newly
* connected service ranks first, followed by specific capabilities with fewer
* unrelated provider matches. Inputs and outputs are deduplicated so this can
* remain a small, explainable client-side recommendation layer.
*/
export const getBoardRecipeRecommendations = ({
configuredIntegrationKinds,
existingItemKinds,
preferredIntegrationKind,
limit = 3,
}: BoardRecipeRecommendationInput): BoardRecipeRecommendation[] => {
const availableKinds = new Set(configuredIntegrationKinds);
const existingKinds = new Set(existingItemKinds);
return widgetKinds
.filter((widgetKind) => !existingKinds.has(widgetKind))
.flatMap((widgetKind) => {
const capability = nativeFeatureCapabilities[widgetKind as keyof typeof nativeFeatureCapabilities];
if (!capability) return [];
const matches = capability.integrations.filter((integrationKind) => availableKinds.has(integrationKind));
if (matches.length === 0) return [];
const preferredMatch = preferredIntegrationKind && matches.includes(preferredIntegrationKind);
const integrationKind = preferredMatch ? preferredIntegrationKind : matches[0];
if (!integrationKind) return [];
return [
{
widgetKind,
integrationKind,
isNewlyAvailable: Boolean(preferredMatch),
score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - capability.integrations.length,
},
];
})
.sort((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
.slice(0, Math.max(0, limit));
};
@@ -40,7 +40,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { integrations: integrationData = [], section } = useSectionContext();
const { integrations: integrationData, section } = useSectionContext();
const settings = useSettings();
const label = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
@@ -77,8 +77,10 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
});
refResetErrorBoundaryOnNextRender.current = true;
},
integrationData: integrationData.filter(
onIntegrationSaved: resetErrorBoundary,
integrationData: (integrationData ?? []).filter(
(integration) =>
integration.permissions.hasUseAccess &&
"supportedIntegrations" in definition &&
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
),
@@ -1,6 +1,44 @@
import { describe, expect, it, vi } from "vitest";
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
import {
getWidgetConnectionStatus,
resolveMatchingIntegrationsAsync,
tryLockSelection,
unlockSelection,
} from "./item-select-data";
describe("getWidgetConnectionStatus", () => {
const availableKinds = new Set(["sonarr" as const]);
it("reports widgets with a configured compatible integration as ready", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["sonarr", "radarr"],
availableKinds,
connectionOptional: false,
}),
).toBe("ready");
});
it("reports required integrations that are not configured as needing setup", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["radarr"],
availableKinds,
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("widget selection lock", () => {
it("rejects repeated selection synchronously until the active selection finishes", () => {
@@ -45,4 +83,20 @@ describe("resolveMatchingIntegrationsAsync", () => {
).resolves.toEqual([]);
expect(ensureDataAsync).not.toHaveBeenCalled();
});
it("excludes matching integrations the user cannot use", async () => {
const currentData = [
{ id: "allowed", kind: "sonarr" as const, permissions: { hasUseAccess: true } },
{ id: "blocked", kind: "sonarr" as const, permissions: { hasUseAccess: false } },
];
await expect(
resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["sonarr"],
currentData,
ensureDataAsync: vi.fn(),
}),
).resolves.toEqual([currentData[0]]);
});
});
@@ -1,5 +1,21 @@
import type { IntegrationKind } from "@homarr/definitions";
export type WidgetConnectionStatus = "ready" | "needsSetup" | "noConnectionRequired";
export const getWidgetConnectionStatus = ({
supportedIntegrations,
availableKinds,
connectionOptional,
}: {
supportedIntegrations: readonly IntegrationKind[];
availableKinds: ReadonlySet<IntegrationKind>;
connectionOptional: boolean;
}): WidgetConnectionStatus => {
if (supportedIntegrations.some((kind) => availableKinds.has(kind))) return "ready";
if (supportedIntegrations.length === 0 || connectionOptional) return "noConnectionRequired";
return "needsSetup";
};
export const tryLockSelection = (lock: { current: boolean }) => {
if (lock.current) return false;
lock.current = true;
@@ -10,7 +26,9 @@ export const unlockSelection = (lock: { current: boolean }) => {
lock.current = false;
};
export const resolveMatchingIntegrationsAsync = async <TIntegration extends { kind: IntegrationKind }>({
export const resolveMatchingIntegrationsAsync = async <
TIntegration extends { kind: IntegrationKind; permissions?: { hasUseAccess: boolean } },
>({
hasIntegrationSupport,
supportedIntegrations,
currentData,
@@ -24,5 +42,8 @@ export const resolveMatchingIntegrationsAsync = async <TIntegration extends { ki
if (!hasIntegrationSupport) return [];
const integrations = currentData ?? (await ensureDataAsync());
return integrations.filter((integration) => supportedIntegrations.includes(integration.kind));
return integrations.filter(
(integration) =>
integration.permissions?.hasUseAccess !== false && supportedIntegrations.includes(integration.kind),
);
};
@@ -1,26 +1,58 @@
import { useMemo, useRef, useState } from "react";
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Avatar,
Badge,
Box,
Button,
Center,
Divider,
Flex,
Group,
Image,
Select,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
import {
getIconUrl,
getBoardLaneColumnCount,
getIntegrationName,
getRootSectionLane,
widgetIntegrationSupport,
widgetKinds,
widgetKindsWithOptionalIntegrations,
} from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import { CatalogItem, SelectGridLayout, selectGridCardHeight } 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 { useItemActions } from "./item-actions";
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
import type { WidgetConnectionStatus } from "./item-select-data";
import {
getWidgetConnectionStatus,
resolveMatchingIntegrationsAsync,
tryLockSelection,
unlockSelection,
} from "./item-select-data";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
import classes from "./item-select-modal.module.css";
interface ItemSelectModalContentProps {
actions: { closeModal: () => void };
@@ -28,6 +60,8 @@ interface ItemSelectModalContentProps {
customWidgetDefs: RouterOutputs["customWidget"]["available"] | undefined;
ensureIntegrationDataAsync: () => Promise<RouterOutputs["integration"]["all"]>;
isAdmin: boolean;
canCreateIntegration: boolean;
initialSearch?: string;
}
const ItemSelectModalContent = ({
@@ -36,16 +70,63 @@ const ItemSelectModalContent = ({
customWidgetDefs,
ensureIntegrationDataAsync,
isAdmin,
canCreateIntegration,
initialSearch = "",
}: ItemSelectModalContentProps) => {
const [search, setSearch] = useState("");
const [search, setSearch] = useState(initialSearch);
const t = useI18n();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
const mainCanvasSection = board.sections
.filter(
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
)
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
const placementOptions = useMemo(
() => [
...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []),
...board.sections.flatMap((section) => {
if (section.kind !== "empty") return [];
const lane = getRootSectionLane(section.xOffset);
if (lane === "main" || !currentLayout || getBoardLaneColumnCount(currentLayout, lane) === 0) return [];
return [
{
value: section.id,
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
},
];
}),
...board.sections
.filter((section) => section.kind === "container")
.map((section) => ({
value: section.id,
label: section.options.title.trim() || t("section.container.untitled"),
})),
],
[board.sections, currentLayout, mainCanvasSection, t],
);
const [targetSectionId, setTargetSectionId] = useState<string | null>(placementOptions[0]?.value ?? null);
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
const selectionLock = useRef(false);
const t = useI18n();
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
const settings = useSettings();
const { createItem, removeItem } = useItemActions();
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
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();
const trackSetup = useSetupAnalytics();
const flowStartedAt = useRef<number | null>(null);
const availableKinds = useMemo(
() => new Set((integrationData ?? []).filter(({ permissions }) => permissions.hasUseAccess).map((i) => i.kind)),
[integrationData],
);
const items = useMemo(
() =>
@@ -93,6 +174,49 @@ const ItemSelectModalContent = ({
});
};
const notifyCreated = (updatedBoard: ReturnType<typeof createItem>, itemId: string, name: string) => {
const createdItem = updatedBoard?.items.find((item) => item.id === itemId);
if (!createdItem) {
showErrorNotification({
title: t("item.create.notification.error.title"),
message: t("item.create.notification.error.message"),
});
return false;
}
const actualSectionId =
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 notificationId = `board-item-created:${itemId}`;
showSuccessNotification({
id: notificationId,
title: t("item.create.notification.success.title"),
message: (
<Group justify="space-between" wrap="nowrap">
<Text size="sm">
{wasRelocated
? t("item.create.notification.success.relocatedMessage", { name, destination: actualDestination })
: t("item.create.notification.success.message", { name })}
</Text>
<Button
variant="subtle"
size="compact-xs"
onClick={() => {
removeItem({ itemId });
notifications.hide(notificationId);
}}
>
{t("common.action.undo")}
</Button>
</Group>
),
autoClose: 10_000,
});
return true;
};
const handleAddCustomWidget = async (customWidgetDefinition: NonNullable<typeof customWidgetDefs>[number]) => {
if (!tryLockSelection(selectionLock)) return;
setLoadingSelection(`custom:${customWidgetDefinition.id}`);
@@ -118,9 +242,15 @@ const ItemSelectModalContent = ({
integrationIds: [],
},
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
updateItemOptions({ itemId, newOptions: configuredOptions });
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
const updatedBoard = createItem({
id: itemId,
kind: "customApi",
integrationIds: [],
options: configuredOptions,
targetSectionId: targetSectionId ?? undefined,
advancedOptions,
});
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
},
integrationData: [],
integrationSupport: false,
@@ -140,6 +270,8 @@ const ItemSelectModalContent = ({
const handleAdd = async (kind: WidgetKind) => {
if (!tryLockSelection(selectionLock)) return;
flowStartedAt.current = performance.now();
trackSetup("widget-started", { entryPoint: "board", intent: kind });
setLoadingSelection(kind);
preloadWidgetEditModal();
try {
@@ -153,35 +285,89 @@ const ItemSelectModalContent = ({
ensureDataAsync: ensureIntegrationDataAsync,
});
const integrationIds = matchingIntegrations.map((i) => i.id);
const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity;
const matchingIntegrationCount = Math.min(matchingIntegrations.length, maxIntegrations);
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
const openEditor = (
availableIntegrations: { id: string; name: string; url: string; kind: IntegrationKind }[],
) => {
const selectedIntegrationIds = availableIntegrations
.slice(0, maxIntegrations)
.map((integration) => integration.id);
openEditModal(
{
kind,
definition,
value: {
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
options: defaultOptions,
integrationIds: selectedIntegrationIds,
},
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
const updatedBoard = createItem({
id: itemId,
kind,
options,
integrationIds: newIntegrationIds,
targetSectionId: targetSectionId ?? undefined,
advancedOptions,
});
if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return;
trackSetup("widget-completed", {
entryPoint: "board",
intent: kind,
outcome: "completed",
elapsedMs: flowStartedAt.current ? Math.round(performance.now() - flowStartedAt.current) : undefined,
});
},
integrationData: availableIntegrations,
integrationSupport: hasIntegrationSupport,
settings,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
},
);
};
const integrationsRequired =
hasIntegrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false);
if (integrationsRequired && matchingIntegrationCount === 0) {
trackSetup("dependency-blocked", {
entryPoint: "board",
intent: kind,
outcome: "blocked",
canResolveInline: canCreateIntegration,
});
if (!canCreateIntegration) {
showErrorNotification({
title: t("item.create.missingIntegration.title"),
message: t("item.create.missingIntegration.noPermission"),
});
return;
}
actions.closeModal();
openIntegrationModal({
allowedKinds: (definition.supportedIntegrations ?? []).filter((integration) => integration !== "mock"),
onSuccess: (result) => {
if (result) {
trackSetup("dependency-resolved-inline", {
entryPoint: "board",
intent: kind,
outcome: "continued",
canResolveInline: true,
});
openEditor([result.integration]);
}
},
});
return;
}
createItem({ id: itemId, kind, integrationIds });
actions.closeModal();
openEditModal(
{
kind,
definition,
value: {
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
options: defaultOptions,
integrationIds,
},
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
updateItemOptions({ itemId, newOptions: options });
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
},
integrationData: matchingIntegrations,
integrationSupport: hasIntegrationSupport,
settings,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
},
);
openEditor(matchingIntegrations);
} catch (error) {
notifyDefinitionLoadError(error);
} finally {
@@ -191,114 +377,123 @@ const ItemSelectModalContent = ({
};
return (
<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);
}
}}
>
{filteredItems.map((item) => (
<WidgetItem
key={item.kind}
item={item}
onSelect={() => void handleAdd(item.kind)}
onIntent={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
<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);
}
}}
disabled={loadingSelection !== null}
loading={loadingSelection === item.kind}
hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))}
/>
))}
{isAdmin && (
<>
<Divider
label={t("customWidget.page.list.title")}
labelPosition="center"
my="sm"
style={{ gridColumn: "1 / -1" }}
/>
{filteredCustomWidgets.map((def) => (
<Card
key={def.id}
className={classes.card}
h={selectGridCardHeight}
withBorder
pos="relative"
style={{ overflow: "hidden" }}
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>
</Stack>
<Box
className={classes.action}
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button
onClick={() => void handleAddCustomWidget(def)}
variant="light"
size="xs"
fullWidth
disabled={loadingSelection !== null}
loading={loadingSelection === `custom:${def.id}`}
>
{t("item.create.addToBoard")}
</Button>
</Box>
</Card>
>
{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),
})}
/>
))}
<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>
</>
)}
{isAdmin && (
<>
<Divider
label={t("customWidget.page.list.title")}
labelPosition="center"
my="sm"
style={{ gridColumn: "1 / -1" }}
/>
{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>
))}
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</SelectGridLayout>
<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>
);
};
const ItemSelectModalFrame = ({
@@ -306,11 +501,13 @@ const ItemSelectModalFrame = ({
innerProps,
}: {
actions: { closeModal: () => void };
innerProps: { boardId: string };
innerProps: { boardId: string; initialIntegrationKind?: IntegrationKind; initialWidgetKind?: WidgetKind };
}) => {
const utils = clientApi.useUtils();
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const canCreateIntegration = session?.user.permissions.includes("integration-create") ?? false;
const t = useI18n();
const { data: integrationData } = clientApi.integration.all.useQuery();
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery(
{ boardId: innerProps.boardId },
@@ -324,13 +521,23 @@ const ItemSelectModalFrame = ({
customWidgetDefs={customWidgetDefs}
ensureIntegrationDataAsync={() => utils.integration.all.ensureData()}
isAdmin={isAdmin}
canCreateIntegration={canCreateIntegration}
initialSearch={
innerProps.initialWidgetKind
? t(`widget.${innerProps.initialWidgetKind}.name`)
: innerProps.initialIntegrationKind
? getIntegrationName(innerProps.initialIntegrationKind)
: undefined
}
/>
);
};
export const ItemSelectModal = createModal<{ boardId: string }>((props) => (
<ItemSelectModalFrame {...props} />
)).withOptions({
export const ItemSelectModal = createModal<{
boardId: string;
initialIntegrationKind?: IntegrationKind;
initialWidgetKind?: WidgetKind;
}>((props) => <ItemSelectModalFrame {...props} />).withOptions({
defaultTitle: (t) => t("item.create.title"),
size: modalSizeSelect,
});
@@ -338,10 +545,11 @@ export const ItemSelectModal = createModal<{ boardId: string }>((props) => (
const WidgetItem = ({
item,
onSelect,
onIntent,
onFocus,
onPointerEnter,
disabled,
loading,
hasMatchingIntegration,
connectionStatus,
}: {
item: {
kind: WidgetKind;
@@ -351,27 +559,25 @@ const WidgetItem = ({
icon: TablerIcon;
};
onSelect: () => void;
onIntent: () => void;
onFocus: () => void;
onPointerEnter: () => void;
disabled: boolean;
loading: boolean;
hasMatchingIntegration: boolean;
connectionStatus: WidgetConnectionStatus;
}) => {
const t = useI18n();
const statusLabel = t(`item.create.connectionStatus.${connectionStatus}`);
return (
<Card
className={classes.card}
h={selectGridCardHeight}
withBorder
pos="relative"
style={{
overflow: "hidden",
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined,
borderWidth: hasMatchingIntegration ? 2 : undefined,
}}
onFocus={onIntent}
onPointerEnter={onIntent}
aria-busy={loading || undefined}
<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">
@@ -385,24 +591,21 @@ const WidgetItem = ({
{item.description}
</Text>
</Tooltip>
<ConnectionStatusBadge status={connectionStatus} />
<SupportedIntegrations integrations={item.supportedIntegrations} />
</Stack>
<Box
className={classes.action}
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button onClick={onSelect} variant="light" size="xs" fullWidth disabled={disabled} loading={loading}>
{t(`item.create.addToBoard`)}
</Button>
</Box>
</Card>
</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>
);
};
@@ -415,7 +618,7 @@ const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind
const moreCount = integrations.length - countToShow;
return (
<Group gap={2} mt="auto">
<Group gap={2}>
<Tooltip.Group closeDelay={100}>
<Group gap={2}>
{integrations.slice(0, countToShow).map((integration) => (
@@ -6,10 +6,15 @@ import { ErrorBoundary } from "react-error-boundary";
import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeForm } from "@homarr/modals";
import { 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";
let widgetEditModalPromise: Promise<typeof import("@homarr/widgets/modals")> | undefined;
import type * as IntegrationEditModalModule from "~/components/integration/embedded-integration-edit-modal";
import type { EmbeddedIntegrationEditModalProps } from "~/components/integration/embedded-integration-edit-modal";
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
export const loadWidgetEditModal = () => {
if (widgetEditModalPromise) return widgetEditModalPromise;
@@ -24,6 +29,59 @@ export const loadWidgetEditModal = () => {
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
let integrationEditModalPromise: Promise<typeof IntegrationEditModalModule> | undefined;
const loadIntegrationEditModal = () => {
if (integrationEditModalPromise) return integrationEditModalPromise;
const currentPromise = import("~/components/integration/embedded-integration-edit-modal");
integrationEditModalPromise = currentPromise;
void currentPromise.catch(() => {
if (integrationEditModalPromise === currentPromise) integrationEditModalPromise = undefined;
});
return currentPromise;
};
const LazyIntegrationEditModalContent = (props: {
actions: { closeModal: () => void };
innerProps: EmbeddedIntegrationEditModalProps;
}) => {
const { EmbeddedIntegrationEditModal } = use(loadIntegrationEditModal());
const Component = EmbeddedIntegrationEditModal.component;
return <Component {...props} />;
};
const LazyIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>((props) => (
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<Center py="xl">
<WidgetError
error={error}
resetErrorBoundary={() => {
integrationEditModalPromise = undefined;
resetErrorBoundary();
}}
/>
</Center>
)}
>
<Suspense
fallback={
<Center py="xl">
<Loader size="sm" />
</Center>
}
>
<LazyIntegrationEditModalContent {...props} />
</Suspense>
</ErrorBoundary>
)).withOptions({
defaultTitle: (t) => t("item.edit.tab.integration"),
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: true,
});
interface LazyWidgetEditModalContentProps {
actions: { closeModal: () => void };
innerProps: WidgetEditModalProps<WidgetKind>;
@@ -32,7 +90,20 @@ interface LazyWidgetEditModalContentProps {
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
const { WidgetEditModal } = use(loadWidgetEditModal());
const Component = WidgetEditModal.component;
return <Component {...props} />;
const { openModal: openIntegrationEditModal } = useModalAction(LazyIntegrationEditModal);
return (
<Component
{...props}
innerProps={{
...props.innerProps,
onEditIntegration: (integrationId) =>
openIntegrationEditModal({
integrationId,
onSuccess: props.innerProps.onIntegrationSaved,
}),
}}
/>
);
};
export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>((props) => (
@@ -65,4 +136,6 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
return t("item.edit.title");
},
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: false,
});
@@ -178,8 +178,11 @@ export const WidgetContextMenu = ({
),
}));
},
integrationData: integrationData.filter((integration) =>
definition.supportedIntegrations?.some((kind) => kind === integration.kind),
onIntegrationSaved: handleRefetch,
integrationData: integrationData.filter(
(integration) =>
definition.supportedIntegrations?.some((kind) => kind === integration.kind) &&
integration.permissions.hasUseAccess,
),
integrationSupport: definition.supportedIntegrations !== undefined,
settings,
@@ -192,6 +195,7 @@ export const WidgetContextMenu = ({
definition,
integrationData,
item,
handleRefetch,
openModal,
persistBoard,
settings,
@@ -0,0 +1,48 @@
"use client";
import { useCallback } from "react";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { useItemActions } from "./items/item-actions";
import { ItemSelectModal } from "./items/item-select-modal";
import { useContainerActions } from "./sections/container/container-actions";
export const useBoardAddActions = () => {
const { data: session } = useSession();
const board = useRequiredBoard();
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addContainer } = useContainerActions();
const { createItem } = useItemActions();
const canCreateApp = session?.user.permissions.includes("app-create") ?? false;
const canConnectService = session?.user.permissions.includes("integration-create") ?? false;
const addWidget = useCallback(() => openItemSelectModal({ boardId: board.id }), [board.id, openItemSelectModal]);
const addApp = useCallback(
() =>
openAppSelectModal({
onSelectMany: (apps) => {
apps.forEach((app) => createItem({ kind: "app", options: { appId: app.id } }));
},
withCreate: canCreateApp,
}),
[canCreateApp, createItem, openAppSelectModal],
);
const connectService = useCallback(
() => openIntegrationSelectModal({ completionBoardId: board.id }),
[board.id, openIntegrationSelectModal],
);
return {
addWidget,
addApp,
connectService,
addContainer,
canConnectService,
};
};
@@ -0,0 +1,28 @@
import { describe, expect, test } from "vitest";
import type { SetupMetricProperties } from "./setup-analytics";
describe("SetupMetricProperties", () => {
test("keeps the measurement contract coarse and configuration-free", () => {
const allowedKeys = [
"entryPoint",
"intent",
"outcome",
"elapsedMs",
"hasBoardContext",
"canResolveInline",
] satisfies (keyof SetupMetricProperties)[];
type ExpectedKey = (typeof allowedKeys)[number];
type ActualKey = keyof SetupMetricProperties;
const exactContract: [ActualKey] extends [ExpectedKey]
? [ExpectedKey] extends [ActualKey]
? true
: false
: false = true;
expect(exactContract).toBe(true);
expect(allowedKeys).not.toContain("url");
expect(allowedKeys).not.toContain("query");
expect(allowedKeys).not.toContain("recordId");
});
});
@@ -0,0 +1,51 @@
"use client";
import { useCallback } from "react";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
export type SetupMetricEvent =
| "surface-opened"
| "intent-selected"
| "widget-started"
| "dependency-blocked"
| "dependency-resolved-inline"
| "widget-completed"
| "completion-recipe-selected"
| "checklist-resumed";
export type SetupMetricEntryPoint = "header" | "spotlight" | "board" | "assistant" | "docker" | "management";
export interface SetupMetricProperties {
entryPoint: SetupMetricEntryPoint;
intent?: string;
outcome?: "completed" | "blocked" | "continued";
elapsedMs?: number;
hasBoardContext?: boolean;
canResolveInline?: boolean;
}
/**
* Emits only coarse workflow measurements through Homarr's existing opt-in analytics gate.
* Never pass record IDs, names, URLs, search text, credentials, or provider responses here.
*/
export const useSetupAnalytics = () => {
const t = useI18n();
const { mutate } = clientApi.analytics.trackFeature.useMutation({
onError() {
showErrorNotification({
title: t("common.error"),
message: t("universalCreate.analyticsError"),
});
},
});
return useCallback(
(event: SetupMetricEvent, properties: SetupMetricProperties) => {
mutate({ feature: `setup:${event}`, properties: { ...properties } });
},
[mutate],
);
};
@@ -0,0 +1,77 @@
import { describe, expect, test } from "vitest";
import type { RankedUniversalCreateAction } from "./universal-create-actions";
import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions";
describe("getUniversalCreateActionDefinitions", () => {
test("only exposes actions allowed without a board context", () => {
const actions = getUniversalCreateActionDefinitions({
hasBoardContext: false,
permissions: ["board-create", "integration-create"],
});
expect(actions.map(({ key }) => key)).toEqual(["integration", "board"]);
});
test("places globally-created apps in the library group", () => {
const actions = getUniversalCreateActionDefinitions({ hasBoardContext: false, permissions: ["app-create"] });
expect(actions).toEqual([expect.objectContaining({ key: "app", group: "library" })]);
});
test("keeps board content actions available without global create permissions", () => {
const actions = getUniversalCreateActionDefinitions({ hasBoardContext: true, permissions: [] });
expect(actions.map(({ key }) => key)).toEqual(["widget", "app", "container"]);
});
test("exposes the complete surface to administrators on a board", () => {
const actions = getUniversalCreateActionDefinitions({
hasBoardContext: true,
permissions: ["admin", "app-create", "integration-create", "board-create"],
});
expect(actions.map(({ key }) => key)).toEqual([
"widget",
"app",
"integration",
"container",
"board",
"workshop",
"customWidget",
]);
});
});
describe("filterAndRankUniversalCreateActions", () => {
const actions = [
{ key: "widget", group: "currentBoard", priority: 100, name: "Add widget", description: "Add content" },
{
key: "integration",
group: "library",
priority: 80,
name: "Connect service",
description: "Create an integration",
keywords: ["provider"],
},
{ key: "board", group: "boards", priority: 60, name: "Create board", description: "Start a dashboard" },
] satisfies RankedUniversalCreateAction[];
test("uses product priority when no search is entered", () => {
expect(filterAndRankUniversalCreateActions(actions, "").map(({ key }) => key)).toEqual([
"widget",
"integration",
"board",
]);
});
test("ranks a name prefix ahead of a description or keyword match", () => {
const results = filterAndRankUniversalCreateActions(actions, "create");
expect(results.map(({ key }) => key)).toEqual(["board", "integration"]);
});
test("matches keywords and removes unrelated actions", () => {
expect(filterAndRankUniversalCreateActions(actions, "provider").map(({ key }) => key)).toEqual(["integration"]);
});
});
@@ -0,0 +1,120 @@
import type { GroupPermissionKey } from "@homarr/definitions";
export const universalCreateActionKeys = [
"widget",
"app",
"integration",
"container",
"board",
"workshop",
"customWidget",
] as const;
export type UniversalCreateActionKey = (typeof universalCreateActionKeys)[number];
export type UniversalCreateActionGroup = "currentBoard" | "library" | "boards";
export interface UniversalCreateContext {
hasBoardContext: boolean;
permissions: readonly GroupPermissionKey[];
}
interface UniversalCreateActionDefinition {
key: UniversalCreateActionKey;
group: UniversalCreateActionGroup;
priority: number;
isVisible: (context: UniversalCreateContext) => boolean;
}
const hasPermission = (context: UniversalCreateContext, permission: GroupPermissionKey) =>
context.permissions.includes(permission);
const definitions: readonly UniversalCreateActionDefinition[] = [
{
key: "widget",
group: "currentBoard",
priority: 100,
isVisible: ({ hasBoardContext }) => hasBoardContext,
},
{
key: "app",
group: "currentBoard",
priority: 90,
isVisible: (context) => context.hasBoardContext || hasPermission(context, "app-create"),
},
{
key: "integration",
group: "library",
priority: 80,
isVisible: (context) => hasPermission(context, "integration-create"),
},
{
key: "container",
group: "currentBoard",
priority: 70,
isVisible: ({ hasBoardContext }) => hasBoardContext,
},
{
key: "board",
group: "boards",
priority: 60,
isVisible: (context) => hasPermission(context, "board-create"),
},
{
key: "workshop",
group: "library",
priority: 40,
isVisible: (context) => hasPermission(context, "admin"),
},
{
key: "customWidget",
group: "library",
priority: 30,
isVisible: (context) => hasPermission(context, "admin"),
},
];
export interface RankedUniversalCreateAction {
key: UniversalCreateActionKey;
group: UniversalCreateActionGroup;
priority: number;
name: string;
description: string;
keywords?: readonly string[];
}
export const getUniversalCreateActionDefinitions = (context: UniversalCreateContext) =>
definitions
.filter((definition) => definition.isVisible(context))
.map((definition) =>
definition.key === "app" && !context.hasBoardContext ? { ...definition, group: "library" as const } : definition,
);
const normalize = (value: string) => value.trim().toLocaleLowerCase();
export const filterAndRankUniversalCreateActions = <TAction extends RankedUniversalCreateAction>(
actions: readonly TAction[],
query: string,
) => {
const normalizedQuery = normalize(query);
return actions
.map((action) => {
const name = normalize(action.name);
const searchableText = normalize([action.name, action.description, ...(action.keywords ?? [])].join(" "));
const relevance =
normalizedQuery.length === 0
? 0
: name === normalizedQuery
? 300
: name.startsWith(normalizedQuery)
? 200
: searchableText.includes(normalizedQuery)
? 100
: -1;
return { action, relevance };
})
.filter(({ relevance }) => relevance >= 0)
.toSorted((left, right) => right.relevance - left.relevance || right.action.priority - left.action.priority)
.map(({ action }) => action);
};
@@ -0,0 +1,21 @@
.action {
width: 100%;
border-radius: var(--mantine-radius-md);
padding: var(--mantine-spacing-sm);
text-align: start;
}
@media (hover: hover) {
.action:hover {
background: var(--mantine-color-default-hover);
}
}
.action:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.actionIcon {
flex: 0 0 auto;
}
@@ -0,0 +1,5 @@
declare const classes: {
readonly action: string;
readonly actionIcon: string;
};
export default classes;
@@ -0,0 +1,178 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Group, Stack, Text, TextInput, ThemeIcon, UnstyledButton } from "@mantine/core";
import {
IconApps,
IconBox,
IconCode,
IconLayoutDashboard,
IconLayoutGridAdd,
IconPlugConnected,
IconSearch,
} from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals";
import { AddBoardModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import classes from "./universal-create-modal.module.css";
import type {
RankedUniversalCreateAction,
UniversalCreateActionGroup,
UniversalCreateActionKey,
} from "./universal-create-actions";
import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions";
import type { SetupMetricEntryPoint } from "./setup-analytics";
import { useSetupAnalytics } from "./setup-analytics";
type BoardActionKey = "widget" | "app" | "integration" | "container";
export interface UniversalCreateModalProps {
boardActions?: Partial<Record<BoardActionKey, () => void>>;
entryPoint?: SetupMetricEntryPoint;
}
const actionIcons = {
widget: IconApps,
app: IconBox,
integration: IconPlugConnected,
container: IconLayoutGridAdd,
board: IconLayoutDashboard,
workshop: IconSearch,
customWidget: IconCode,
} satisfies Record<UniversalCreateActionKey, TablerIcon>;
export const UniversalCreateModal = createModal<UniversalCreateModalProps>(({ actions, innerProps }) => {
const { data: session } = useSession();
const router = useRouter();
const t = useScopedI18n("universalCreate");
const { openModal: openAddBoardModal } = useModalAction(AddBoardModal);
const [query, setQuery] = useState("");
const trackedOpen = useRef(false);
const boardActions = innerProps.boardActions;
const entryPoint = innerProps.entryPoint ?? (boardActions ? "board" : "header");
const trackSetup = useSetupAnalytics();
const permissions = (session?.user.permissions ?? []) as GroupPermissionKey[];
const definitions = getUniversalCreateActionDefinitions({
hasBoardContext: boardActions !== undefined,
permissions,
});
useEffect(() => {
if (trackedOpen.current) return;
trackedOpen.current = true;
trackSetup("surface-opened", {
entryPoint,
hasBoardContext: boardActions !== undefined,
});
}, [boardActions, entryPoint, trackSetup]);
const invokeAction = (key: UniversalCreateActionKey) => {
const boardAction = key in (boardActions ?? {}) ? boardActions?.[key as BoardActionKey] : undefined;
actions.closeModal();
trackSetup("intent-selected", {
entryPoint,
intent: key,
outcome: "continued",
hasBoardContext: boardActions !== undefined,
});
if (boardAction) {
boardAction();
return;
}
switch (key) {
case "app":
router.push("/manage/apps/new");
break;
case "integration":
router.push("/manage/integrations/new");
break;
case "board":
openAddBoardModal(undefined);
break;
case "workshop":
router.push("/manage/workshop");
break;
case "customWidget":
router.push("/manage/custom-widgets/new");
break;
case "widget":
case "container":
break;
}
};
const resolvedActions = definitions.map(
(definition) =>
({
...definition,
name: t(`action.${definition.key}.label`),
description: t(`action.${definition.key}.description`),
keywords: t(`action.${definition.key}.keywords`).split(" "),
}) satisfies RankedUniversalCreateAction,
);
const visibleActions = filterAndRankUniversalCreateActions(resolvedActions, query);
const groups = ["currentBoard", "library", "boards"] satisfies UniversalCreateActionGroup[];
return (
<Stack gap="md">
<TextInput
data-autofocus
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
placeholder={t("search.placeholder")}
aria-label={t("search.label")}
/>
{visibleActions.length === 0 ? (
<Text c="dimmed" ta="center" py="lg">
{t("search.noResults")}
</Text>
) : (
groups.map((group) => {
const groupActions = visibleActions.filter((action) => action.group === group);
if (groupActions.length === 0) return null;
return (
<Stack key={group} gap={4}>
<Text c="dimmed" fw={600} size="xs" tt="uppercase" px="sm">
{t(`group.${group}`)}
</Text>
{groupActions.map((action) => {
const Icon = actionIcons[action.key];
return (
<UnstyledButton key={action.key} className={classes.action} onClick={() => invokeAction(action.key)}>
<Group wrap="nowrap" align="flex-start">
<ThemeIcon className={classes.actionIcon} variant="light" radius="md">
<Icon size={18} stroke={1.5} />
</ThemeIcon>
<Stack gap={1}>
<Text fw={600} size="sm">
{action.name}
</Text>
<Text c="dimmed" size="xs">
{action.description}
</Text>
</Stack>
</Group>
</UnstyledButton>
);
})}
</Stack>
);
})
)}
</Stack>
);
}).withOptions({
defaultTitle: (t) => t("universalCreate.title"),
size: "lg",
});
@@ -0,0 +1,35 @@
"use client";
import { useMemo } from "react";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { UniversalCreateModal } from "./universal-create-modal";
/** Keeps Universal Create available from ordinary Spotlight search, without requiring command mode. */
export const UniversalCreateSpotlightResult = () => {
const t = useScopedI18n("universalCreate");
const { openModal } = useModalAction(UniversalCreateModal);
const item = useMemo(
() => ({
id: "universal-create",
name: t("title"),
description: t("trigger.description"),
icon: IconPlus,
alwaysVisible: true,
interaction: () => ({
type: "javaScript" as const,
onSelect: () => {
openModal({ entryPoint: "spotlight" });
},
}),
}),
[openModal, t],
);
useRegisterSpotlightContextResults("universal-create", [item], [item]);
return null;
};
@@ -0,0 +1,49 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { HeaderButton } from "~/components/layout/header/button";
import { UniversalCreateModal } from "./universal-create-modal";
export const UniversalCreateHeaderAction = () => {
const t = useScopedI18n("universalCreate");
const { openModal } = useModalAction(UniversalCreateModal);
return (
<HeaderButton onClick={() => openModal({ entryPoint: "header" })} aria-label={t("trigger.label")}>
<IconPlus stroke={1.5} />
</HeaderButton>
);
};
export const UniversalCreateQueryGate = () => {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const { openModal } = useModalAction(UniversalCreateModal);
const handled = useRef(false);
const shouldOpen = searchParams.get("create") === "true";
useEffect(() => {
if (!shouldOpen) {
handled.current = false;
return;
}
if (handled.current) return;
handled.current = true;
openModal({ entryPoint: "spotlight" });
const nextSearchParams = new URLSearchParams(searchParams.toString());
nextSearchParams.delete("create");
const query = nextSearchParams.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}, [openModal, pathname, router, searchParams, shouldOpen]);
return null;
};
@@ -0,0 +1,86 @@
"use client";
import { Alert, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import { createModal, modalSizeForm } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
export interface EmbeddedIntegrationEditModalProps {
integrationId: string;
onSuccess?: () => void;
}
export const EmbeddedIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>(
({ actions, innerProps }) => {
const t = useI18n();
const {
data: integration,
isPending,
isError,
refetch,
} = clientApi.integration.byId.useQuery({
id: innerProps.integrationId,
});
if (isPending) {
return (
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
<Skeleton height={36} width="60%" />
<Skeleton height={60} />
<Skeleton height={120} />
</Stack>
);
}
if (isError || !integration) {
return (
<Alert color="red" title={t("common.error")}>
<Stack gap="sm">
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
<Button variant="light" color="red" onClick={() => void refetch()}>
{t("common.action.tryAgain")}
</Button>
</Stack>
</Alert>
);
}
return (
<Stack>
<Group gap="sm" wrap="nowrap">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed">
{getIntegrationName(integration.kind)}
</Text>
</Stack>
</Group>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
{t("item.edit.integration.propagationNotice")}
</Alert>
<EditIntegrationForm
integration={integration}
embedded
onSuccess={() => {
innerProps.onSuccess?.();
actions.closeModal();
}}
/>
</Stack>
);
},
).withOptions({
defaultTitle: (t) => t("item.edit.tab.integration"),
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: true,
});
@@ -0,0 +1,194 @@
"use client";
import { ActionIcon, Button, Divider, Group, List, Stack, Text, ThemeIcon, Title, Tooltip } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { IconAppWindow, IconCheck, IconLayoutDashboard, IconPlugConnected, IconX } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName, getWidgetKindsForIntegration } from "@homarr/definitions";
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import type { CreatedIntegrationResult } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import {
getBoardRecipeDismissalKey,
getBoardRecipeRecommendations,
} from "~/components/board/items/board-recipe-recommendations";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
interface IntegrationCompletionModalProps {
result: CreatedIntegrationResult;
boardId?: string;
}
export const IntegrationCompletionModal = createModal<IntegrationCompletionModalProps>(({ actions, innerProps }) => {
const t = useI18n();
const board = useOptionalBoard();
const { data: session } = useSession();
const { data: integrations = [] } = clientApi.integration.all.useQuery();
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const trackSetup = useSetupAnalytics();
const compatibleWidgets = getWidgetKindsForIntegration(innerProps.result.integration.kind);
const boardMatchesContext = board !== null && board.id === innerProps.boardId;
const canModifyBoard = boardMatchesContext && constructBoardPermissions(board, session).hasChangeAccess;
const dismissalKey = getBoardRecipeDismissalKey(
innerProps.boardId ?? "no-board-context",
innerProps.result.integration.kind,
);
const [recommendationsDismissed, setRecommendationsDismissed] = useLocalStorage({
key: dismissalKey,
defaultValue: false,
});
const configuredIntegrationKinds = [
...new Set<IntegrationKind>([
...integrations.map((integration) => integration.kind),
innerProps.result.integration.kind,
]),
];
const recommendations = boardMatchesContext
? getBoardRecipeRecommendations({
configuredIntegrationKinds,
existingItemKinds: board.items.map((item) => item.kind),
preferredIntegrationKind: innerProps.result.integration.kind,
})
: [];
const showRecommendations = canModifyBoard && !recommendationsDismissed && recommendations.length > 0;
const addWidget = () => {
if (!innerProps.boardId) return;
actions.closeModal();
openItemSelectModal({
boardId: innerProps.boardId,
initialIntegrationKind: innerProps.result.integration.kind,
});
};
const addRecommendedWidget = (widgetKind: (typeof recommendations)[number]["widgetKind"]) => {
if (!innerProps.boardId) return;
trackSetup("completion-recipe-selected", {
entryPoint: "board",
intent: "add-compatible-widget",
outcome: "continued",
hasBoardContext: true,
});
actions.closeModal();
openItemSelectModal({ boardId: innerProps.boardId, initialWidgetKind: widgetKind });
};
return (
<Stack gap="lg">
<Group wrap="nowrap" align="flex-start">
<ThemeIcon color="green" variant="light" size="xl" radius="xl">
<IconCheck size={22} />
</ThemeIcon>
<Stack gap={2}>
<Title order={3} size="h4">
{t("integration.completion.title", { name: innerProps.result.integration.name })}
</Title>
<Text c="dimmed" size="sm">
{t("integration.completion.description")}
</Text>
</Stack>
</Group>
<List
spacing="sm"
icon={
<ThemeIcon color="green" size="sm" radius="xl">
<IconCheck size={12} />
</ThemeIcon>
}
>
<List.Item>
<Group gap="xs">
<IconPlugConnected size={17} />
<Text size="sm">{t("integration.completion.connectionReady")}</Text>
</Group>
</List.Item>
{innerProps.result.appId && (
<List.Item>
<Group gap="xs">
<IconAppWindow size={17} />
<Text size="sm">{t("integration.completion.appReady")}</Text>
</Group>
</List.Item>
)}
<List.Item>
<Group gap="xs">
<IconLayoutDashboard size={17} />
<Text size="sm">
{t("integration.completion.compatibleWidgets", { count: String(compatibleWidgets.length) })}
</Text>
</Group>
</List.Item>
</List>
{showRecommendations && (
<Stack gap="xs">
<Divider />
<Group justify="space-between" wrap="nowrap">
<div>
<Text fw={600} size="sm">
{t("integration.completion.recipes.title")}
</Text>
<Text c="dimmed" size="xs">
{t("integration.completion.recipes.description")}
</Text>
</div>
<Tooltip label={t("integration.completion.recipes.dismiss")}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("integration.completion.recipes.dismiss")}
onClick={() => setRecommendationsDismissed(true)}
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
</Group>
{recommendations.map((recommendation) => (
<Group key={recommendation.widgetKind} justify="space-between" wrap="nowrap" gap="sm">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{t(`widget.${recommendation.widgetKind}.name`)}
</Text>
<Text c="dimmed" size="xs" truncate>
{t(
recommendation.isNewlyAvailable
? "integration.completion.recipes.newConnectionReason"
: "integration.completion.recipes.existingConnectionReason",
{
integration: recommendation.isNewlyAvailable
? innerProps.result.integration.name
: getIntegrationName(recommendation.integrationKind),
},
)}
</Text>
</Stack>
<Button variant="light" size="compact-xs" onClick={() => addRecommendedWidget(recommendation.widgetKind)}>
{t("integration.completion.recipes.add")}
</Button>
</Group>
))}
</Stack>
)}
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{t("common.action.close")}
</Button>
{innerProps.boardId && compatibleWidgets.length > 0 && (
<Button onClick={addWidget}>{t("integration.completion.chooseWidget")}</Button>
)}
</Group>
</Stack>
);
}).withOptions({
defaultTitle: (t) => t("integration.completion.modalTitle"),
size: modalSizeForm,
});
@@ -6,20 +6,25 @@ import { IconArrowLeft } from "@tabler/icons-react";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { createModal, modalSizeSelect } from "@homarr/modals";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { IntegrationAvatar } from "@homarr/ui";
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
import type { CreatedIntegrationResult } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
import { IntegrationSelectGrid } from "./integration-select-grid";
import { IntegrationCompletionModal } from "./integration-completion-modal";
interface IntegrationSelectModalProps {
onSuccess?: () => void;
onSuccess?: (result?: CreatedIntegrationResult) => void;
enableMockIntegration?: boolean;
allowedKinds?: readonly IntegrationKind[];
completionBoardId?: string;
}
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
const [step, setStep] = useState<"select" | "form">("select");
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(null);
const { openModal: openCompletionModal } = useModalAction(IntegrationCompletionModal);
const handleSelect = (kind: IntegrationKind) => {
setSelectedKind(kind);
@@ -31,9 +36,13 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
setSelectedKind(null);
};
const handleSuccess = () => {
innerProps.onSuccess?.();
const handleSuccess = (result?: CreatedIntegrationResult) => {
actions.closeModal();
if (innerProps.onSuccess) {
innerProps.onSuccess(result);
} else if (result) {
openCompletionModal({ result, boardId: innerProps.completionBoardId });
}
};
if (step === "form" && selectedKind) {
@@ -49,7 +58,13 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
);
}
return <IntegrationSelectGrid onSelect={handleSelect} enableMockIntegration={innerProps.enableMockIntegration} />;
return (
<IntegrationSelectGrid
onSelect={handleSelect}
enableMockIntegration={innerProps.enableMockIntegration}
allowedKinds={innerProps.allowedKinds}
/>
);
}).withOptions({
defaultTitle: (t) => t("integration.action.create"),
size: modalSizeSelect,
@@ -3,6 +3,8 @@ import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
import { Link } from "@homarr/ui";
import { UniversalCreateHeaderAction, UniversalCreateQueryGate } from "~/components/create/universal-create-trigger";
import { UniversalCreateSpotlightResult } from "~/components/create/universal-create-spotlight";
import { ClientBurger } from "./header/burger";
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { TourTarget } from "./header/tour-target";
@@ -36,6 +38,7 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
<DesktopSearchInput />
</TourTarget>
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
{hasNavigation && <UniversalCreateHeaderAction />}
{actions}
<MobileSearchButton />
<TourTarget id="board-user-menu">
@@ -43,6 +46,8 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
</TourTarget>
</Group>
</Group>
<UniversalCreateQueryGate />
<UniversalCreateSpotlightResult />
<LazySpotlight />
</AppShellHeader>
);