feat(custom-widgets): complete Custom JSX v2 (#6390)

This commit is contained in:
Thomas authored and ajnart committed 2026-08-13 19:48:57 +02:00
1 parent 3a1245cbc5
commit acdfcbb18e
416 files changed
+37853 -18066

No files matched your search

@@ -1,36 +1,45 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { IconLayoutBoard, IconPencil, IconPencilOff, IconPlus, IconReplace, IconSettings } from "@tabler/icons-react";
import {
IconBox,
IconBoxAlignTop,
IconChevronDown,
IconLayoutBoard,
IconPencil,
IconPencilOff,
IconPlug,
IconPlus,
IconReplace,
IconResize,
IconSettings,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { hotkeys } from "@homarr/definitions";
import { useConfirmModal } from "@homarr/modals";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
import { TourTarget } from "~/components/layout/header/tour-target";
const loadBoardAddMenu = () => import("./_board-add-menu");
const preloadBoardAddMenu = () => void loadBoardAddMenu().catch(() => undefined);
const BoardAddMenu = dynamic(() => loadBoardAddMenu().then(({ BoardAddMenu: AddMenu }) => AddMenu), {
loading: () => (
<HeaderButton loading>
<IconPlus stroke={1.5} />
</HeaderButton>
),
});
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode();
@@ -43,16 +52,16 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
return (
<>
{isEditMode && <BoardAddMenu />}
{isEditMode && <AddMenu />}
<EditModeMenu demoReadOnly={demoReadOnly} />
{!demoReadOnly && (
<TourTarget id="board-settings">
<OnboardingTour.Target id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
</TourTarget>
</OnboardingTour.Target>
)}
<SelectBoardsMenu />
@@ -60,11 +69,105 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
);
};
const AddMenu = () => {
const board = useRequiredBoard();
const { data: session } = useSession();
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addCategoryToEnd } = useCategoryActions();
const { addDynamicSection } = useDynamicSectionActions();
const { createItem } = useItemActions();
const t = useI18n();
const handleAddCategory = useCallback(
() =>
openCategoryEditModal(
{
category: {
id: "new",
name: "",
},
onSuccess({ name }) {
addCategoryToEnd({ name });
},
submitLabel: t("section.category.create.submit"),
},
{
title: (t) => t("section.category.create.title"),
},
),
[addCategoryToEnd, openCategoryEditModal, t],
);
const handleSelectItem = useCallback(() => {
openItemSelectModal({ boardId: board.id });
}, [board.id, openItemSelectModal]);
const handleSelectApp = useCallback(() => {
openAppSelectModal({
onSelect: (app) => {
createItem({
kind: "app",
options: { appId: app.id },
});
},
withCreate: session?.user.permissions.includes("app-create") ?? false,
});
}, [openAppSelectModal, createItem]);
const handleAddIntegration = useCallback(() => {
openIntegrationSelectModal({});
}, [openIntegrationSelectModal]);
return (
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4} aria-label={t("item.action.create")}>
<Group gap={4} wrap="nowrap">
<IconPlus stroke={1.5} />
<IconChevronDown color="gray" size={16} />
</Group>
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
{t("item.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
{t("app.action.add")}
</Menu.Item>
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{t("integration.action.create")}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
{t("section.category.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
{t("section.dynamic.action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode, { open, close }] = useEditMode();
const board = useRequiredBoard();
const utils = clientApi.useUtils();
const t = useScopedI18n("board.action.edit");
const commonT = useI18n();
const latestBoardRef = useRef(board);
latestBoardRef.current = board;
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
onSuccess() {
showSuccessNotification({
@@ -72,6 +175,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
message: t("notification.success.message"),
});
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync(`/boards/${board.name}`);
close();
},
@@ -91,38 +195,34 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const toggle = useCallback(() => {
if (isEditMode) {
if (demoReadOnly) return discardDemoChanges();
return saveBoard(board);
return saveBoard(latestBoardRef.current);
}
open();
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]);
}, [isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
usePreventLeaveWithDirty(isEditMode);
return (
<TourTarget id="board-edit-mode">
<OnboardingTour.Target id="board-edit-mode">
<HeaderButton
onClick={toggle}
loading={isPending}
onFocus={preloadBoardAddMenu}
onPointerEnter={preloadBoardAddMenu}
aria-label={isEditMode ? commonT("common.action.save") : commonT("common.action.edit")}
>
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
</HeaderButton>
</TourTarget>
</OnboardingTour.Target>
);
};
const SelectBoardsMenu = () => {
const [isOpen, setIsOpen] = useState(false);
const utils = clientApi.useUtils();
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
const preloadBoards = () => void utils.board.getAllBoards.prefetch();
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
return (
<TourTarget id="board-switcher">
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
<OnboardingTour.Target id="board-switcher">
<Box>
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4}>
<IconReplace stroke={1.5} />
@@ -130,11 +230,6 @@ const SelectBoardsMenu = () => {
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
<ScrollArea.Autosize mah={300}>
{isPending && (
<Center p="xs">
<Loader size="xs" />
</Center>
)}
{boards.map((board) => (
<Menu.Item
key={board.id}
@@ -149,7 +244,7 @@ const SelectBoardsMenu = () => {
</Menu.Dropdown>
</Menu>
</Box>
</TourTarget>
</OnboardingTour.Target>
);
};
@@ -162,13 +257,15 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
useEffect(() => {
if (!isDirty) return;
const handleClick = (event: MouseEvent) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>(anchorSelector) : null;
if (!target) return;
const handleClick = (event: Event) => {
const target = (event.target as HTMLElement).closest("a");
if (!target) {
console.warn("No anchor element found for click event", event);
return;
}
event.preventDefault();
event.stopPropagation();
openConfirmModal({
title: t("board.action.edit.confirmLeave.title"),
@@ -194,14 +291,19 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
event.returnValue = true;
};
document.addEventListener("click", handleClick, true);
const anchors = document.querySelectorAll(anchorSelector);
anchors.forEach((link) => {
link.addEventListener("click", handleClick);
});
window.addEventListener("popstate", handlePopState);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
document.removeEventListener("click", handleClick, true);
anchors.forEach((link) => {
link.removeEventListener("click", handleClick);
});
window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [isDirty, openConfirmModal, router, t]);
}, [isDirty]);
};
@@ -1,6 +1,6 @@
"use client";
import { Alert, Input } from "@mantine/core";
import { Alert, Button, Group, Input, Stack } from "@mantine/core";
import { highlight, languages } from "prismjs";
import Editor from "react-simple-code-editor";
@@ -8,42 +8,62 @@ import "~/styles/prismjs.scss";
import { IconInfoCircle } from "@tabler/icons-react";
import type { UseFormReturnType } from "@homarr/form";
import { useForm } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card";
import { InstallCssFromWorkshopButton } from "~/components/workshop-install/install-from-workshop-buttons";
import type { FormValues } from "./_settings-form";
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
import classes from "./customcss.module.css";
interface Props {
form: UseFormReturnType<FormValues>;
board: Board;
}
export const CustomCssSettingsContent = ({ form }: Props) => {
export const CustomCssSettingsContent = ({ board }: Props) => {
const t = useI18n();
const customCssT = useScopedI18n("board.field.customCss");
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
customCss: board.customCss ?? "",
},
});
return (
<SectionCard title={t("board.setting.section.customCss.title")}>
<CustomCssInput {...form.getInputProps("customCss")} />
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<CustomCssInput {...form.getInputProps("customCss")} />
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
{customCssT("customClassesAlert.description")}
</Alert>
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
{customCssT("customClassesAlert.description")}
</Alert>
<InstallCssFromWorkshopButton onSelect={(css) => form.setFieldValue("customCss", css)} />
</SectionCard>
<Group justify="space-between">
<WorkshopCssImportButton onImport={(css) => form.setFieldValue("customCss", css)} />
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};
interface CustomCssInputProps {
value?: string;
onChange: (value: string) => void;
error?: React.ReactNode;
}
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
const customCssT = useScopedI18n("board.field.customCss");
return (
@@ -53,7 +73,6 @@ const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
htmlFor: "custom-css",
}}
description={customCssT("description")}
error={error}
inputWrapperOrder={["label", "description", "input", "error"]}
>
<div className={classes.codeEditorRoot}>
@@ -3,17 +3,16 @@ import { notFound, redirect } from "next/navigation";
import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server";
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { auth } from "@homarr/auth/next";
import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ModalProvider } from "@homarr/modals";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
import { env } from "~/env";
import { BoardTourProvider } from "~/components/onboarding/board-tour";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import type { Board } from "./_types";
import type { Params } from "./(content)/_creator";
@@ -23,6 +22,11 @@ import { BoardMantineProvider } from "./(content)/_theme";
const logger = createLogger({ module: "createBoardLayout" });
const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => {
if (!hasSession) return <>{children}</>;
return <BoardTourProvider>{children}</BoardTourProvider>;
};
interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>;
@@ -40,32 +44,8 @@ export const createBoardLayout = <TParams extends Params>({
}: PropsWithChildren<{
params: Promise<TParams>;
}>) => {
const resolvedParams = await params;
const sessionPromise = auth();
const initialBoardPromise = getInitialBoard(resolvedParams).then(
(board) => ({ status: "fulfilled", board }) as const,
(error: unknown) => ({ status: "rejected", error }) as const,
);
const colorSchemePromise = getCurrentColorSchemeAsync();
const shouldRunBoardTourPromise = sessionPromise.then(async (session) => {
if (!withTour || !session || env.DEMO_MODE) return false;
try {
const user = await getRscUserSettingsAsync(session.user.id);
return user !== undefined && !user.completedBoardTour;
} catch (error) {
logger.error(new Error("Failed to load the board tour status", { cause: error }));
return false;
}
});
const [session, initialBoardResult, colorScheme, shouldRunBoardTour] = await Promise.all([
sessionPromise,
initialBoardPromise,
colorSchemePromise,
shouldRunBoardTourPromise,
]);
if (initialBoardResult.status === "rejected") {
const { error } = initialBoardResult;
const session = await auth();
const initialBoard = await getInitialBoard(await params).catch((error) => {
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
if (!session) {
logger.debug("No home board found for anonymous user, redirecting to login");
@@ -81,25 +61,27 @@ export const createBoardLayout = <TParams extends Params>({
}
throw error;
}
const initialBoard = initialBoardResult.board;
});
const colorScheme = await getCurrentColorSchemeAsync();
return (
<BoardProvider initialBoard={initialBoard}>
<BoardReadyProvider>
<EditModeProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<CustomCss />
<BoardTourGate enabled={shouldRunBoardTour}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardTourGate>
<ModalProvider>
<CustomCss />
<BoardTourWrapper hasSession={withTour && !!session}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardTourWrapper>
</ModalProvider>
</BoardMantineProvider>
</EditModeProvider>
</BoardReadyProvider>
@@ -0,0 +1,148 @@
"use client";
import { useEffect, useState } from "react";
import {
Alert,
Button,
Card,
Center,
PasswordInput,
Select,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { IconCheck, IconKey, IconLock } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
interface RequestDetails {
widgetName: string;
sourceName: string;
kinds: Array<"apiKey" | "username" | "password">;
expiresAt: number;
status: "pending" | "completed";
source: {
baseUrl: string;
networkScope: "public" | "private" | "loopback";
};
}
export function CustomWidgetConfigurationEntry({ token }: { token: string }) {
const t = useScopedI18n("customWidget.secretEntry");
const [details, setDetails] = useState<RequestDetails | null>(null);
const [values, setValues] = useState<Record<string, string>>({});
const [baseUrl, setBaseUrl] = useState("");
const [networkScope, setNetworkScope] = useState<"public" | "private" | "loopback">("public");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
void fetch(`/api/custom-widgets/configuration-request/${encodeURIComponent(token)}`)
.then(async (response) => {
const body = (await response.json()) as RequestDetails | { error: string };
if (!response.ok || "error" in body) throw new Error("error" in body ? body.error : t("unavailable"));
setDetails(body);
setBaseUrl(body.source.baseUrl);
setNetworkScope(body.source.networkScope);
})
.catch((cause: unknown) => setError(cause instanceof Error ? cause.message : t("unavailable")))
.finally(() => setLoading(false));
}, [t, token]);
const submit = async () => {
setSaving(true);
setError(null);
try {
const response = await fetch(`/api/custom-widgets/configuration-request/${encodeURIComponent(token)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ baseUrl, networkScope, secrets: values }),
});
const body = (await response.json()) as { status?: string; error?: string };
if (!response.ok) throw new Error(body.error ?? t("saveError"));
setDetails((current) => (current ? { ...current, status: "completed" } : current));
setValues({});
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("saveError"));
} finally {
setSaving(false);
}
};
return (
<Center mih="100dvh" p="md">
<Card withBorder shadow="md" radius="lg" p="xl" w="100%" maw={480}>
<Stack gap="lg">
<ThemeIcon size={48} radius="xl" variant="light">
<IconLock size={24} />
</ThemeIcon>
<Stack gap={4}>
<Title order={1} size="h2">
{t("title")}
</Title>
<Text c="dimmed">{t("description")}</Text>
</Stack>
{loading && <Text c="dimmed">{t("loading")}</Text>}
{error && <Alert color="red">{error}</Alert>}
{details?.status === "completed" && (
<Alert color="green" icon={<IconCheck size={18} />}>
{t("saved")}
</Alert>
)}
{details?.status === "pending" && (
<Stack gap="md">
<Card withBorder bg="var(--mantine-color-default-hover)">
<Text fw={600}>{details.widgetName}</Text>
<Text size="sm" c="dimmed">
{t("source", { name: details.sourceName })}
</Text>
</Card>
<TextInput
label={t("baseUrl")}
type="url"
value={baseUrl}
onChange={(event) => setBaseUrl(event.currentTarget.value)}
required
/>
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={networkScope}
allowDeselect={false}
onChange={(value) => value && setNetworkScope(value as typeof networkScope)}
/>
{details.kinds.map((kind) => {
const Input = kind === "username" ? TextInput : PasswordInput;
return (
<Input
key={kind}
label={t(`field.${kind}`)}
leftSection={<IconKey size={16} />}
value={values[kind] ?? ""}
onChange={(event) => setValues((current) => ({ ...current, [kind]: event.currentTarget.value }))}
autoComplete="off"
required
/>
);
})}
<Button
loading={saving}
disabled={!URL.canParse(baseUrl) || details.kinds.some((kind) => !values[kind])}
onClick={() => void submit()}
>
{t("save")}
</Button>
<Text size="xs" c="dimmed">
{t("expires", { time: new Date(details.expiresAt).toLocaleTimeString() })}
</Text>
</Stack>
)}
</Stack>
</Card>
</Center>
);
}
@@ -0,0 +1,13 @@
import type { Metadata } from "next";
import { CustomWidgetConfigurationEntry } from "./configuration-entry";
export const metadata: Metadata = {
title: "Configure custom widget API source",
robots: { index: false, follow: false },
};
export default async function CustomWidgetConfigurationPage({ params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
return <CustomWidgetConfigurationEntry token={token} />;
}
+16 -25
View File
@@ -13,20 +13,22 @@ import { notFound } from "next/navigation";
import type { DayOfWeek } from "@mantine/dates";
import { NextIntlClientProvider } from "next-intl";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { env } from "@homarr/auth/env";
import { api } from "@homarr/api/server";
import { env as authEnv } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db";
import { getServerSettingsAsync } from "@homarr/db/queries";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";
import { SettingsProvider } from "@homarr/settings";
import { SpotlightProvider } from "@homarr/spotlight";
import type { SupportedLanguage } from "@homarr/translation";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { WORKSHOP_API_URL } from "@homarr/workshop/schema";
import { Analytics } from "~/components/layout/analytics";
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
import { env } from "~/env";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
@@ -43,8 +45,6 @@ const fontSans = Inter({
variable: "--font-sans",
});
const logger = createLogger({ module: "rootLayout" });
// eslint-disable-next-line no-restricted-syntax
export const generateMetadata = async (): Promise<Metadata> => ({
title: "Homarr",
@@ -80,31 +80,19 @@ export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ locale: SupportedLanguage }>;
}) {
const { locale } = await props.params;
if (!isLocaleSupported(locale)) {
if (!isLocaleSupported((await props.params).locale)) {
notFound();
}
const sessionPromise = auth();
const userPromise = sessionPromise.then((session) =>
session
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error }));
return null;
})
: null,
);
const [session, user, serverSettings, colorScheme] = await Promise.all([
sessionPromise,
userPromise,
getRscServerSettingsAsync(),
getCurrentColorSchemeAsync(),
]);
const direction = isLocaleRTL(locale) ? "rtl" : "ltr";
const session = await auth();
const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
const serverSettings = await getServerSettingsAsync(db);
const colorScheme = await getCurrentColorSchemeAsync();
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
const StackedProvider = composeWrappers([
(innerProps) => {
return <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
return <AuthProvider session={session} logoutUrl={authEnv.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
},
(innerProps) => (
<SettingsProvider
@@ -139,6 +127,8 @@ export default async function Layout(props: {
(innerProps) => <SpotlightProvider {...innerProps} />,
]);
const { locale } = await props.params;
return (
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
<html
@@ -151,6 +141,7 @@ export default async function Layout(props: {
suppressHydrationWarning
>
<head>
<meta name="homarr-workshop-api-url" content={env.WORKSHOP_API_URL ?? WORKSHOP_API_URL} />
<SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
</head>
@@ -1,21 +1,18 @@
"use client";
import { Alert, Anchor, Text } from "@mantine/core";
import { IconFlask } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets";
export function CustomWidgetBetaBanner() {
const t = useScopedI18n("customWidget.betaBanner");
return (
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
<Alert variant="light" color="yellow" title="Custom Widgets are in Beta" icon={<IconFlask />}>
<Text size="sm">
{t("description")}{" "}
This feature is experimental and will probably change a lot in the future. If you encounter bugs, wish it did
more, or find something confusing, then please complain to us as much as possible! Only feedback posted on the{" "}
<Anchor href={FEEDBACK_URL} target="_blank" fw={600}>
{t("link")}
official discussion thread
</Anchor>{" "}
{t("closing")}
will be taken into account. Happy Hacking!
</Text>
</Alert>
);
@@ -1,51 +0,0 @@
import type { EditorView } from "@codemirror/view";
import { Alert, Stack, Text, UnstyledButton } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import type { EditorDiagnostic } from "@homarr/custom-widgets/workbench";
import classes from "./_code-editor.module.css";
interface CodeEditorDiagnosticsProps {
diagnostics: EditorDiagnostic[];
editorView: EditorView | null;
title: string;
formatDiagnostic: (diagnostic: EditorDiagnostic) => string;
}
export function CodeEditorDiagnostics({
diagnostics,
editorView,
title,
formatDiagnostic,
}: CodeEditorDiagnosticsProps) {
if (diagnostics.length === 0) return null;
return (
<Stack component="ul" gap={6} mt="xs" pl={0} style={{ listStyle: "none" }} aria-label={title}>
{diagnostics.map((diagnostic, index) => (
<li key={`${diagnostic.code}-${diagnostic.line ?? 0}-${index}`}>
<UnstyledButton
type="button"
w="100%"
className={classes.diagnosticButton}
onClick={() => {
if (!editorView || diagnostic.index === undefined) return;
const anchor = Math.min(diagnostic.index, editorView.state.doc.length);
editorView.dispatch({ selection: { anchor }, scrollIntoView: true });
editorView.focus();
}}
>
<Alert
color={diagnostic.severity === "error" ? "red" : "yellow"}
variant="light"
p="xs"
icon={<IconAlertTriangle size={15} />}
>
<Text size="xs">{formatDiagnostic(diagnostic)}</Text>
</Alert>
</UnstyledButton>
</li>
))}
</Stack>
);
}
@@ -1,80 +0,0 @@
.root {
margin-top: 4px;
border: 1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
overflow: hidden;
}
.root:focus-within {
border-color: var(--mantine-primary-color-filled);
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent);
outline-offset: 1px;
}
.toolbar,
.footer {
padding: 7px 10px;
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
}
.toolbar {
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.footer {
border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.viewport {
max-height: min(62vh, 680px);
overflow: auto;
}
.viewport :global(.cm-editor) {
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-black), var(--mantine-color-gray-0));
font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
font-size: 12px;
}
.viewport :global(.cm-editor.cm-focused) {
outline: none;
}
.viewport :global(.cm-scroller) {
line-height: 1.65;
overflow: auto;
}
.viewport :global(.cm-gutters) {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.diagnosticButton {
min-height: 44px;
border-radius: var(--mantine-radius-sm);
}
.diagnosticButton:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
@media (max-width: 48em) {
.viewport {
max-height: 52vh;
}
.toolbar,
.footer {
align-items: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
.root {
scroll-behavior: auto;
}
}
@@ -1,271 +1,48 @@
"use client";
import { useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { autocompletion } from "@codemirror/autocomplete";
import type { CompletionSource } from "@codemirror/autocomplete";
import { redo, redoDepth, undo, undoDepth } from "@codemirror/commands";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { linter } from "@codemirror/lint";
import { EditorView } from "@codemirror/view";
import { Badge, Button, Group, Input, Loader, Text, useComputedColorScheme } from "@mantine/core";
import { IconArrowBackUp, IconArrowForwardUp, IconCheck, IconCode, IconFileCode, IconWand } from "@tabler/icons-react";
import { enabledCustomJsxComponents } from "@homarr/custom-widgets/core";
import { ComponentReference } from "@homarr/custom-widgets/workbench";
import type { EditorDiagnostic } from "@homarr/custom-widgets/workbench";
import type { ComponentProps } from "react";
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditorDiagnostics } from "./_code-editor-diagnostics";
import classes from "./_code-editor.module.css";
type SharedEditorProps = ComponentProps<typeof CustomWidgetCodeEditor>;
type CodeEditorProps = Omit<SharedEditorProps, "messages">;
const Editor = dynamic(() => import("@uiw/react-codemirror").then((module) => module.default), {
ssr: false,
loading: () => (
<Group justify="center" p="xl">
<Loader size="sm" />
</Group>
),
});
const customJsxCompletionSource: CompletionSource = (context) => {
const word = context.matchBefore(/[A-Z][\w.]*/u);
if (!word && !context.explicit) return null;
return {
from: word?.from ?? context.pos,
options: enabledCustomJsxComponents.map((component) => ({
label: component.name,
type: "class",
detail: `${component.category} · ${component.safety}`,
})),
};
};
interface CodeEditorProps {
id: string;
value: string;
onChange: (value: string) => void;
label: string;
description?: string;
placeholder?: string;
language: "jsx" | "json";
diagnostics?: EditorDiagnostic[];
error?: React.ReactNode;
required?: boolean;
maxLength?: number;
starter?: string;
}
export function CodeEditor({
id,
value,
onChange,
label,
description,
placeholder,
language,
diagnostics = [],
error,
required,
maxLength,
starter,
}: CodeEditorProps) {
export function CodeEditor(props: CodeEditorProps) {
const t = useScopedI18n("customWidget.editor");
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [editorView, setEditorView] = useState<EditorView | null>(null);
const [historyDepth, setHistoryDepth] = useState({ undo: 0, redo: 0 });
const colorScheme = useComputedColorScheme("light");
const extensions = useMemo(() => {
const diagnosticsExtension = linter((view) =>
diagnostics.map((diagnostic) => {
const from = Math.min(diagnostic.index ?? 0, view.state.doc.length);
return {
from,
to: Math.min(from + 1, view.state.doc.length),
severity: diagnostic.severity,
message: t(`diagnostics.${diagnostic.code}` as never, { value: diagnostic.value ?? "" } as never),
};
}),
);
const accessibilityExtension = EditorView.contentAttributes.of({
id,
"aria-label": label,
});
return language === "jsx"
? [
javascript({ jsx: true }),
autocompletion({ override: [customJsxCompletionSource] }),
diagnosticsExtension,
accessibilityExtension,
]
: [json(), diagnosticsExtension, accessibilityExtension];
}, [diagnostics, id, label, language, t]);
const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
const warningCount = diagnostics.length - errorCount;
const formattedValue = useMemo(() => {
if (language === "json") {
try {
return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`;
} catch {
return value;
}
}
return value
.replace(/\r\n?/gu, "\n")
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
.trim();
}, [language, value]);
return (
<Input.Wrapper
label={label}
description={description}
error={error}
required={required}
labelProps={{ htmlFor: id }}
inputWrapperOrder={["label", "description", "input", "error"]}
>
<div className={classes.root}>
<Group className={classes.toolbar} justify="space-between" gap="xs" wrap="wrap">
<Group gap={6}>
<IconCode size={16} aria-hidden />
<Text size="xs" fw={600}>
{language === "jsx" ? t("language.jsx") : t("language.json")}
</Text>
</Group>
<Group gap={6}>
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconArrowBackUp size={14} />}
onClick={() => editorView && undo(editorView)}
disabled={!editorView || historyDepth.undo === 0}
>
{t("action.undo")}
</Button>
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconArrowForwardUp size={14} />}
onClick={() => editorView && redo(editorView)}
disabled={!editorView || historyDepth.redo === 0}
>
{t("action.redo")}
</Button>
{language === "jsx" && (
<ComponentReference
messages={{
action: t("action.components"),
search: t("componentReference.search"),
empty: t("componentReference.empty"),
count: (count) => t("componentReference.count", { count }),
}}
/>
)}
{starter && value.trim().length === 0 && (
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconFileCode size={14} />}
onClick={() => onChange(starter)}
>
{t("action.insertStarter")}
</Button>
)}
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={<IconWand size={14} />}
onClick={() => onChange(formattedValue)}
disabled={formattedValue === value}
>
{t("action.format")}
</Button>
</Group>
</Group>
<div className={classes.viewport}>
<Editor
id={`${id}-root`}
value={value}
onChange={onChange}
placeholder={placeholder}
extensions={extensions}
theme={colorScheme}
height={language === "jsx" ? "320px" : "220px"}
basicSetup={{
autocompletion: language !== "jsx",
bracketMatching: true,
closeBrackets: true,
foldGutter: true,
history: true,
highlightActiveLine: true,
highlightActiveLineGutter: true,
indentOnInput: true,
lineNumbers: true,
}}
onCreateEditor={setEditorView}
onUpdate={(update) => {
if (!update.selectionSet && !update.docChanged) return;
const head = update.state.selection.main.head;
const line = update.state.doc.lineAt(head);
setCursor({ line: line.number, column: head - line.from + 1 });
setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) });
}}
/>
</div>
<Group className={classes.footer} justify="space-between" gap="xs">
<Group gap={6}>
{errorCount > 0 ? (
<Badge size="xs" color="red" variant="light">
{t("status.errors", { count: errorCount })}
</Badge>
) : warningCount > 0 ? (
<Badge size="xs" color="yellow" variant="light">
{t("status.warnings", { count: warningCount })}
</Badge>
) : (
<Group gap={4}>
<IconCheck size={13} color="var(--mantine-color-green-6)" aria-hidden />
<Text size="xs" c="dimmed">
{t("status.ready")}
</Text>
</Group>
)}
<Text size="xs" c="dimmed">
{t("status.position", cursor)}
</Text>
</Group>
<Text
size="xs"
c={maxLength && value.length > maxLength * 0.95 ? (value.length > maxLength ? "red" : "orange") : "dimmed"}
>
{maxLength
? t("status.charactersWithLimit", { count: value.length, limit: maxLength })
: t("status.characters", { count: value.length })}
</Text>
</Group>
</div>
<CodeEditorDiagnostics
diagnostics={diagnostics}
editorView={editorView}
title={t("diagnostics.title")}
formatDiagnostic={(diagnostic) =>
<CustomWidgetCodeEditor
{...props}
messages={{
languageJsx: t("language.jsx"),
languageJson: t("language.json"),
undo: t("action.undo"),
redo: t("action.redo"),
components: t("action.components"),
componentSearch: t("componentReference.search"),
componentEmpty: t("componentReference.empty"),
componentCount: (count) => t("componentReference.count", { count }),
insertStarter: t("action.insertStarter"),
format: t("action.format"),
copy: t("action.copy"),
copied: t("action.copied"),
schema: t("action.schema"),
schemaTab: t("reference.schema"),
minimalTab: t("reference.minimal"),
fullTab: t("reference.full"),
errors: (count) => t("status.errors", { count }),
warnings: (count) => t("status.warnings", { count }),
ready: t("status.ready"),
position: (cursor) => t("status.position", cursor),
characters: (count, limit) =>
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
diagnosticsTitle: t("diagnostics.title"),
diagnostic: (diagnostic) =>
`${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t(
`diagnostics.${diagnostic.code}` as never,
{ value: diagnostic.value ?? "" } as never,
)}`
}
/>
</Input.Wrapper>
)}`,
}}
/>
);
}
@@ -0,0 +1,37 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
describe("Copy AI prompt", () => {
it("uses one self-contained prompt without an embedded-skill branch", () => {
const source = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`,
"utf8",
);
expect(source).toContain("buildCustomWidgetAiPrompt");
expect(source).not.toContain("embedded-authoring-prompt");
expect(source).not.toContain("Menu");
});
it("copies a complete prompt within the public size limit", () => {
const prompt = buildCustomWidgetAiPrompt(undefined, null, null, "Create a Pokédex");
expect(prompt.length).toBeLessThanOrEqual(12_000);
expect(prompt).toContain("exactly one complete `json` fenced block");
expect(prompt).toContain("one complete `jsx` fenced block");
});
it("keeps AI actions and section navigation readable on narrow screens", () => {
const card = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-ai-card.tsx`,
"utf8",
);
const styles = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.module.css`,
"utf8",
);
expect(card).toContain("<SimpleGrid cols={{ base: 1, sm: 2 }}>");
expect(styles).toContain(".sectionNav > a");
expect(styles).toContain("flex: 0 0 auto");
});
});
@@ -1,68 +1,50 @@
"use client";
import { useEffect, useState } from "react";
import { Button, Popover, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconCopy, IconSparkles } from "@tabler/icons-react";
import { Button } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/core";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
interface CopyAiPromptButtonProps {
rawResponse?: string | null;
currentConfig?: Record<string, unknown> | null;
request?: string | null;
documentationUrl?: string | null;
}
export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptButtonProps) => {
export const CopyAiPromptButton = ({
rawResponse,
currentConfig,
request,
documentationUrl,
}: CopyAiPromptButtonProps) => {
const t = useScopedI18n("customWidget");
const [opened, { open, close }] = useDisclosure(false);
const [mounted, setMounted] = useState(false);
const { data: schema, isLoading } = clientApi.customWidget.schema.useQuery();
useEffect(() => setMounted(true), []);
const handleCopy = async () => {
if (!schema) return;
try {
await navigator.clipboard.writeText(buildCustomWidgetAiPrompt(schema, rawResponse, currentConfig));
close();
showSuccessNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopied") });
const prompt = buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl);
await navigator.clipboard.writeText(prompt);
showSuccessNotification({
title: t("action.copyAiPrompt"),
message: t("notification.aiPromptCopiedWithCount", { count: prompt.length }),
});
} catch {
showErrorNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopyError") });
}
};
return (
<Popover opened={opened} onClose={close} width={320} position="bottom" shadow="md" withinPortal>
<Popover.Target>
<Button
type="button"
variant="light"
leftSection={<IconSparkles size={16} />}
onClick={open}
loading={!mounted || isLoading}
disabled={!mounted || isLoading || !schema}
fullWidth
size="sm"
>
{t("action.copyAiPrompt")}
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
<Text size="sm">{t("notification.aiPromptDescription")}</Text>
{!rawResponse && (
<Text size="xs" c="dimmed" fs="italic">
{t("notification.aiPromptNoResponse")}
</Text>
)}
<Button type="button" leftSection={<IconCopy size={16} />} onClick={() => void handleCopy()} fullWidth>
{t("notification.aiPromptCopy")}
</Button>
</Stack>
</Popover.Dropdown>
</Popover>
<Button
type="button"
variant="light"
leftSection={<IconSparkles size={16} />}
onClick={() => void handleCopy()}
fullWidth
size="sm"
>
{t("action.copyAiPrompt")}
</Button>
);
};
@@ -1,19 +1,25 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react";
import { IconBuildingStore, IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { getImportReview, parseCustomWidgetClipboard } from "@homarr/custom-widgets/core";
import { ImportReviewDialog } from "@homarr/custom-widgets/workbench";
import {
formatCustomWidgetImportIssues,
looksLikeCustomWidgetClipboard,
parseCustomWidgetClipboardDetailed,
} from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { WorkshopPublishModal } from "~/components/workshop/workshop-publish-modal";
const iconProps = { size: 16, stroke: 1.5 };
@@ -21,6 +27,7 @@ interface WidgetRef {
id: string;
name: string;
enabled: boolean;
valid: boolean;
}
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
@@ -29,6 +36,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const deleteMutation = clientApi.customWidget.delete.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
const utils = clientApi.useUtils();
const [publishOpened, publishControls] = useDisclosure(false);
const handleExport = async () => {
try {
@@ -54,7 +62,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
title: t("action.duplicate"),
message: t("notification.duplicated", { name: result.name }),
});
void utils.customWidget.all.invalidate();
void utils.customWidget.list.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
@@ -77,7 +85,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
title: t("action.delete"),
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.all.invalidate();
void utils.customWidget.list.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
@@ -91,58 +99,54 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
};
return (
<Menu withinPortal position="bottom-end" shadow="md">
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label={t("action.menu")}>
<IconDots {...iconProps} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={handleDuplicate}
leftSection={<IconCopy {...iconProps} />}
disabled={duplicateMutation.isPending}
>
{t("action.duplicate")}
</Menu.Item>
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
{t("action.export")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<IconTrash {...iconProps} />}
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{t("action.delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
<>
<Menu withinPortal position="bottom-end" shadow="md">
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label={t("action.menu")}>
<IconDots {...iconProps} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{widget.valid && (
<>
<Menu.Item
onClick={handleDuplicate}
leftSection={<IconCopy {...iconProps} />}
disabled={duplicateMutation.isPending}
>
{t("action.duplicate")}
</Menu.Item>
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
{t("action.export")}
</Menu.Item>
<Menu.Item onClick={publishControls.open} leftSection={<IconBuildingStore {...iconProps} />}>
{t("action.publishWorkshop")}
</Menu.Item>
<Menu.Divider />
</>
)}
<Menu.Item
color="red"
leftSection={<IconTrash {...iconProps} />}
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{t("action.delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
{widget.valid && <WorkshopPublishModal opened={publishOpened} onClose={publishControls.close} widget={widget} />}
</>
);
};
export const ImportCustomWidgetButton = () => {
const t = useScopedI18n("customWidget");
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingImport, setPendingImport] = useState<Record<string, unknown> | null>(null);
const [pendingImport, setPendingImport] = useState<HomarrCustomWidgetV2 | null>(null);
const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
const utils = clientApi.useUtils();
const importMutation = clientApi.customWidget.import.useMutation({
onSuccess: () => {
closeReview();
setPendingImport(null);
showSuccessNotification({ title: t("action.import"), message: t("notification.imported") });
void utils.customWidget.all.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
},
});
const review = useMemo(() => getImportReview(pendingImport), [pendingImport]);
const queueImport = useCallback(
(value: Record<string, unknown>) => {
(value: HomarrCustomWidgetV2) => {
setPendingImport(value);
openReview();
},
@@ -151,16 +155,23 @@ export const ImportCustomWidgetButton = () => {
useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
const target = event.target as HTMLElement | null;
if (target?.matches("input, textarea, [contenteditable='true']")) return;
const widget = parseCustomWidgetClipboard(event.clipboardData?.getData("text/plain") ?? "");
if (!widget) return;
if (event.target instanceof Element && event.target.matches("input, textarea, [contenteditable='true']")) return;
const text = event.clipboardData?.getData("text/plain") ?? "";
if (!looksLikeCustomWidgetClipboard(text)) return;
event.preventDefault();
queueImport(widget);
const result = parseCustomWidgetClipboardDetailed(text);
if (!result.success) {
showErrorNotification({
title: t("action.import"),
message: formatCustomWidgetImportIssues(result.issues),
});
return;
}
queueImport(result.widget);
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [queueImport]);
}, [queueImport, t]);
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -169,11 +180,14 @@ export const ImportCustomWidgetButton = () => {
const reader = new FileReader();
reader.addEventListener("load", (e) => {
try {
const widget = parseCustomWidgetClipboard(e.target?.result as string);
if (!widget) throw new Error("Invalid import");
queueImport(widget);
} catch {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
const result = parseCustomWidgetClipboardDetailed(e.target?.result as string);
if (!result.success) throw new Error(formatCustomWidgetImportIssues(result.issues));
queueImport(result.widget);
} catch (error) {
showErrorNotification({
title: t("action.import"),
message: error instanceof Error ? error.message : t("notification.importError"),
});
}
});
reader.addEventListener("error", () => {
@@ -189,7 +203,6 @@ export const ImportCustomWidgetButton = () => {
variant="default"
leftSection={<IconUpload size={16} />}
onClick={() => fileInputRef.current?.click()}
loading={importMutation.isPending}
title={t("action.pasteImportHint")}
>
{t("action.import")}
@@ -202,26 +215,12 @@ export const ImportCustomWidgetButton = () => {
onChange={handleImport}
aria-label={t("importReview.fileLabel")}
/>
<ImportReviewDialog
<CustomWidgetImportDialog
opened={reviewOpened}
review={review}
pending={importMutation.isPending}
onClose={closeReview}
onConfirm={() => pendingImport && importMutation.mutate(pendingImport as never)}
messages={{
title: t("importReview.title"),
description: t("importReview.description"),
name: t("importReview.name"),
origin: t("importReview.origin"),
authentication: t("importReview.authentication"),
networkScope: t("importReview.networkScope"),
methods: t("importReview.methods"),
permissions: t("importReview.permissions"),
actionWarningTitle: t("importReview.actionWarning.title"),
actionWarningDescription: t("importReview.actionWarning.description"),
cancel: t("importReview.cancel"),
confirm: t("importReview.confirm"),
permission: (permission) => t(`preview.request.permission.${permission}` as never),
widget={pendingImport}
onClose={() => {
closeReview();
setPendingImport(null);
}}
/>
</>
@@ -0,0 +1,62 @@
"use client";
import { useEffect, useState } from "react";
import { Accordion } from "@mantine/core";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { applyDefinition, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkbenchForm }) {
const t = useScopedI18n("customWidget.workbench.builder");
const serialized = JSON.stringify(
{
$schema: "homarr-custom-widget-v2",
name: form.values.name,
...(form.values.description ? { description: form.values.description } : {}),
...(form.values.iconUrl ? { iconUrl: form.values.iconUrl } : {}),
sources: parseJson(form.values.sources),
requests: parseJson(form.values.requests),
options: parseJson(form.values.options),
template: form.values.template,
},
null,
2,
);
const [draft, setDraft] = useState(serialized);
const [error, setError] = useState<string>();
useEffect(() => setDraft(serialized), [serialized]);
return (
<Accordion variant="contained">
<Accordion.Item value="manifest">
<Accordion.Control>{t("advancedManifest")}</Accordion.Control>
<Accordion.Panel>
<CodeEditor
id="raw-widget-manifest"
label={t("manifestFilename")}
language="json"
value={draft}
error={error}
onChange={(next) => {
setDraft(next);
try {
const parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown);
if (!parsed.success) {
setError(parsed.error.issues[0]?.message ?? t("invalidWidget"));
return;
}
setError(undefined);
applyDefinition(form, parsed.data);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("invalidJson"));
}
}}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}
@@ -0,0 +1,54 @@
"use client";
import { Button, Card, Group, SimpleGrid, Stack, Text, TextInput, Textarea } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client";
import { CopyAiPromptButton } from "./_copy-ai-prompt-button";
interface AiCardProps {
candidate: HomarrCustomWidgetV2 | null;
request: string;
onRequestChange(value: string): void;
documentationUrl: string;
onDocumentationUrlChange(value: string): void;
onPaste(): void;
}
export function CustomWidgetAiCard(props: AiCardProps) {
const t = useScopedI18n("customWidget.workbench.ai");
return (
<Card withBorder p="md">
<Stack gap="sm">
<Group gap="xs">
<IconSparkles size={18} />
<Text fw={600}>{t("title")}</Text>
</Group>
<Textarea
label={t("request")}
value={props.request}
onChange={(event) => props.onRequestChange(event.currentTarget.value)}
autosize
minRows={2}
/>
<TextInput
label={t("documentationUrl")}
value={props.documentationUrl}
onChange={(event) => props.onDocumentationUrlChange(event.currentTarget.value)}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<CopyAiPromptButton
currentConfig={props.candidate}
request={props.request}
documentationUrl={props.documentationUrl}
/>
<Button type="button" variant="light" leftSection={<IconSparkles size={16} />} onClick={props.onPaste}>
{t("paste")}
</Button>
</SimpleGrid>
</Stack>
</Card>
);
}
@@ -0,0 +1,38 @@
import type { CustomWidgetFormValues, EditorDiagnostic } from "@homarr/custom-widgets/workbench";
import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
export function createCustomWidgetCompletions(values: CustomWidgetFormValues, requestIds: string[]) {
const options = parseJson(values.options);
const optionKeys = Object.keys(isRecord(options) ? options : {});
return [
...requestIds.flatMap((id) => [
{ label: `data["${id}"]`, type: "variable", detail: `Response from ${id}` },
{ label: `status["${id}"]`, type: "variable", detail: `Status for ${id}` },
{ label: `requestId="${id}"`, type: "property", detail: "Named request" },
]),
...parseSources(values.sources).map(({ id }) => ({ label: id, type: "constant", detail: "Source ID" })),
...optionKeys.map((key) => ({ label: `options.${key}`, type: "variable", detail: "Widget option" })),
{ label: "inputs", type: "variable", detail: "Temporary bound input values" },
];
}
export function getInvalidCustomWidgetSections(
issues: readonly { path: PropertyKey[] }[],
requestDiagnostics: EditorDiagnostic[],
templateDiagnostics: EditorDiagnostic[],
) {
const result = new Set<string>();
for (const issue of issues) {
const field = String(issue.path[0] ?? "");
if (["name", "description", "iconUrl"].includes(field)) result.add("general");
else if (field === "sources") result.add("sources");
else if (field === "requests") result.add("requests");
else if (field === "options") result.add("options");
else if (field === "template") result.add("jsx");
}
if (requestDiagnostics.some((entry) => entry.severity === "error")) result.add("requests");
if (templateDiagnostics.some((entry) => entry.severity === "error")) result.add("jsx");
if (result.size > 0) result.add("preview");
return result;
}
@@ -1,42 +0,0 @@
const serverToFormFieldMap: Record<string, Record<string, string>> = {
statGrid: { items: "statGridItems" },
countGrid: { items: "countGridItems" },
statusIndicator: { items: "statusItems" },
progressBars: { bars: "progressBars" },
keyValue: { mappings: "mappings" },
table: { columns: "columns", tablePath: "tablePath" },
singleValue: { jsonPath: "jsonPath", label: "label", unit: "unit" },
raw: { jsonPath: "rawJsonPath" },
actionButton: { buttonLabel: "buttonLabel" },
customJsx: { template: "template", requests: "requestManifest", networkScope: "networkScope" },
};
export function extractServerErrors(error: unknown, displayType: string): Record<string, string> {
const errors: Record<string, string> = {};
const trpcError = error as {
data?: { zodError?: { fieldErrors?: Record<string, string[]> } };
message?: string;
};
if (trpcError.data?.zodError?.fieldErrors) {
for (const [field, messages] of Object.entries(trpcError.data.zodError.fieldErrors)) {
if (messages?.[0]) errors[field] = messages[0];
}
return errors;
}
try {
const issues = JSON.parse(trpcError.message ?? "[]") as Array<{ path: (string | number)[]; message: string }>;
const fieldMap = serverToFormFieldMap[displayType] ?? {};
for (const issue of issues) {
const path = [...issue.path];
if (path[0] === "displayConfig") {
path.shift();
const serverField = String(path[0]);
path[0] = fieldMap[serverField] ?? serverField;
}
errors[path.join(".")] = issue.message;
}
} catch {
// Non-validation server errors are handled by the caller's notification.
}
return errors;
}
@@ -0,0 +1,85 @@
"use client";
import type { ReactNode } from "react";
import type { Icon } from "@tabler/icons-react";
import { Button, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconAlertTriangle, IconCheck, IconPlayerPlay } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./_custom-widget-form.module.css";
export function EditorSection({
id,
title,
icon: SectionIcon,
children,
}: {
id: string;
title: string;
icon: Icon;
children: ReactNode;
}) {
return (
<Card id={id} component="section" withBorder p="lg" className={classes.editorSection}>
<Stack gap="md">
<Group gap="sm">
<ThemeIcon variant="light" size="lg">
<SectionIcon size={18} />
</ThemeIcon>
<Text fw={700} size="lg">
{title}
</Text>
</Group>
{children}
</Stack>
</Card>
);
}
export function SaveActions({
dirty,
savePending,
previewPending,
invalid,
mode,
onPreview,
}: {
dirty: boolean;
savePending: boolean;
previewPending: boolean;
invalid: boolean;
mode: "create" | "edit";
onPreview(): void;
}) {
const t = useScopedI18n("customWidget.workbench");
return (
<Group justify="space-between" wrap="wrap">
<Group gap="xs">
{invalid ? (
<IconAlertTriangle size={16} color="var(--mantine-color-red-6)" />
) : (
<IconCheck size={16} color="var(--mantine-color-green-6)" />
)}
<Text size="sm" fw={600}>
{invalid ? t("status.invalid") : dirty ? t("status.unsaved") : t("status.ready")}
</Text>
</Group>
<Group gap="xs" wrap="wrap">
<Button
type="button"
variant="light"
leftSection={<IconPlayerPlay size={16} />}
onClick={onPreview}
loading={previewPending}
disabled={invalid || savePending}
>
{previewPending ? t("action.previewLoading") : t("action.preview")}
</Button>
<Button type="submit" loading={savePending} disabled={invalid || previewPending}>
{mode === "create" ? t("action.create") : t("action.save")}
</Button>
</Group>
</Group>
);
}
@@ -0,0 +1,18 @@
import {
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
CUSTOM_WIDGET_REQUEST_EXAMPLES,
getCustomWidgetOptionsJsonSchema,
getCustomWidgetRequestsJsonSchema,
} from "@homarr/custom-widgets/core";
export const customWidgetRequestReference = {
schema: getCustomWidgetRequestsJsonSchema(),
minimal: CUSTOM_WIDGET_REQUEST_EXAMPLES.minimal,
full: CUSTOM_WIDGET_REQUEST_EXAMPLES.full,
};
export const customWidgetOptionsSchemaReference = {
schema: getCustomWidgetOptionsJsonSchema(),
minimal: CUSTOM_WIDGET_OPTIONS_EXAMPLES.minimal,
full: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full,
};
@@ -1,238 +0,0 @@
import type { RefObject } from "react";
import {
Button,
Fieldset,
Group,
PasswordInput,
SegmentedControl,
Select,
Stack,
Text,
TextInput,
Textarea,
ThemeIcon,
} from "@mantine/core";
import { IconCode, IconLayoutGrid, IconPlayerPlay } from "@tabler/icons-react";
import { IconPicker } from "@homarr/forms-collection";
import { CUSTOM_JSX_STARTER } from "@homarr/custom-widgets/core";
import {
CUSTOM_WIDGET_AUTH_SECRET_FIELDS,
CUSTOM_WIDGET_AUTH_USES_HEADER_NAME,
} from "@homarr/custom-widgets/workbench";
import type { DisplayFieldsProps } from "./_display-field-types";
import { DISPLAY_TYPE_ICONS, DisplayTypeFields, DisplayTypePicker } from "./_display-type-fields";
import classes from "./_custom-widget-form.module.css";
interface SharedSectionProps extends DisplayFieldsProps {
sectionRef: RefObject<HTMLElement | null>;
}
interface FormatSectionProps extends SharedSectionProps {
onContinue: () => void;
}
export function FormatSection({ form, t, sectionRef, onContinue }: FormatSectionProps) {
return (
<section ref={sectionRef} className={classes.formSection} tabIndex={-1}>
<ThemeIcon className={classes.sectionMarker} variant="filled" size={40} radius="xl">
<IconLayoutGrid size={20} />
</ThemeIcon>
<Stack gap="xl">
<SectionHeading label={t("steps.format.label")} description={t("steps.format.description")} />
<Fieldset legend={t("fieldset.general")}>
<Stack gap="sm">
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} {...form.getInputProps("description")} />
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
</Stack>
</Fieldset>
<Fieldset legend={t("field.displayType")}>
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t("steps.format.help")}
</Text>
<DisplayTypePicker
value={form.values.displayType}
onChange={(value) => {
form.setFieldValue("displayType", value);
if (value === "customJsx" && !form.values.template.trim())
form.setFieldValue("template", CUSTOM_JSX_STARTER);
}}
t={t}
/>
</Stack>
</Fieldset>
<ContinueButton label={t("steps.continueTo", { step: t("steps.connection.label") })} onClick={onContinue} />
</Stack>
</section>
);
}
interface ConnectionSectionProps extends SharedSectionProps {
isTesting: boolean;
onTest: () => void;
onContinue: () => void;
}
export function ConnectionSection({ form, t, sectionRef, isTesting, onTest, onContinue }: ConnectionSectionProps) {
const secretFields = CUSTOM_WIDGET_AUTH_SECRET_FIELDS[form.values.authType] ?? [];
const authTypeOptions = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"].map((value) => ({
value,
label: t(`authType.${value}` as never),
}));
const handleAuthTypeChange = (value: string) => {
form.setFieldValue("authType", value);
form.setFieldValue(
"secrets",
(CUSTOM_WIDGET_AUTH_SECRET_FIELDS[value] ?? []).map((field) => ({
kind: field.kind,
value: form.values.secrets.find((secret) => secret.kind === field.kind)?.value ?? "",
})),
);
};
return (
<section ref={sectionRef} className={classes.formSection} tabIndex={-1}>
<ThemeIcon className={classes.sectionMarker} variant="light" size={40} radius="xl">
<IconPlayerPlay size={20} />
</ThemeIcon>
<Stack gap="xl">
<SectionHeading label={t("steps.connection.label")} description={t("steps.connection.description")} />
<Fieldset legend={t("fieldset.connection")}>
<Stack gap="sm">
<Group align="end" wrap="wrap" gap="xs">
<Select
label={t("field.method")}
data={["GET", "POST", "PUT", "DELETE", "PATCH"].map((value) => ({
value,
label: t(`method.${value}` as never),
}))}
w={110}
{...form.getInputProps("method")}
allowDeselect={false}
disabled={form.values.displayType === "customJsx" && form.values.jsxApiVersion === "2"}
/>
<TextInput
label={t("field.url")}
required
placeholder={t("placeholder.url")}
style={{ flex: "1 1 260px" }}
{...form.getInputProps("url")}
/>
<Button
type="button"
size="sm"
variant="light"
leftSection={<IconPlayerPlay size={16} />}
onClick={onTest}
loading={isTesting}
disabled={!form.values.url || form.values.method !== "GET"}
>
{t("preview.test")}
</Button>
</Group>
<div>
<Text size="sm" fw={500} mb={4}>
{t("field.authType")}
</Text>
<Select
hiddenFrom="sm"
value={form.values.authType}
data={authTypeOptions}
onChange={(value) => handleAuthTypeChange(value ?? "none")}
allowDeselect={false}
aria-label={t("field.authType")}
/>
<SegmentedControl
visibleFrom="sm"
fullWidth
value={form.values.authType}
data={authTypeOptions}
onChange={handleAuthTypeChange}
/>
</div>
{CUSTOM_WIDGET_AUTH_USES_HEADER_NAME[form.values.authType] && (
<TextInput
label={t("field.headerName")}
placeholder={t("placeholder.headerName")}
{...form.getInputProps("headerName")}
/>
)}
{secretFields.map((field) => {
const index = form.values.secrets.findIndex((secret) => secret.kind === field.kind);
const secret = form.values.secrets[index];
if (index === -1 || !secret) return null;
const props = {
label: t(`secret.${field.labelKey}` as never),
placeholder: secret.hasValue && !secret.value ? t("secret.savedPlaceholder" as never) : undefined,
...form.getInputProps(`secrets.${index}.value`),
};
return field.isPassword ? (
<PasswordInput key={field.kind} {...props} />
) : (
<TextInput key={field.kind} {...props} />
);
})}
{form.values.method !== "GET" && (
<Textarea label={t("field.requestBody")} minRows={3} {...form.getInputProps("requestBody")} />
)}
</Stack>
</Fieldset>
<ContinueButton label={t("steps.continueTo", { step: t("steps.configure.label") })} onClick={onContinue} />
</Stack>
</section>
);
}
export function ConfigureSection({ form, t, previewJson, sectionRef }: SharedSectionProps) {
const SelectedIcon = DISPLAY_TYPE_ICONS[form.values.displayType as keyof typeof DISPLAY_TYPE_ICONS];
return (
<section ref={sectionRef} className={classes.formSection} tabIndex={-1}>
<ThemeIcon className={classes.sectionMarker} variant="light" size={40} radius="xl">
<IconCode size={20} />
</ThemeIcon>
<Stack gap="xl">
<SectionHeading label={t("steps.configure.label")} description={t("steps.configure.description")} />
<Fieldset legend={t("fieldset.display")}>
<Stack gap="sm">
<Group gap="xs">
<ThemeIcon variant="light" size="lg">
{SelectedIcon ? <SelectedIcon size={20} /> : null}
</ThemeIcon>
<div>
<Text fw={600}>{t(`displayType.${form.values.displayType}` as never)}</Text>
<Text size="xs" c="dimmed">
{t(`displayTypeDescription.${form.values.displayType}` as never)}
</Text>
</div>
</Group>
<DisplayTypeFields form={form} t={t} previewJson={previewJson} />
</Stack>
</Fieldset>
</Stack>
</section>
);
}
function SectionHeading({ label, description }: { label: string; description: string }) {
return (
<div>
<Text fw={700} size="lg">
{label}
</Text>
<Text size="sm" c="dimmed">
{description}
</Text>
</div>
);
}
function ContinueButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<Group justify="flex-end">
<Button type="button" variant="light" size="md" onClick={onClick}>
{label}
</Button>
</Group>
);
}
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from "vitest";
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import {
getChangedSecrets,
getCustomWidgetPreviewOptionIssues,
loadPreviewQueries,
parseSources,
} from "./_custom-widget-form-utils";
const previewQuery = vi.hoisted(() => vi.fn());
vi.mock("@homarr/api/client", () => ({ fetchApi: { customWidget: { previewQuery: { query: previewQuery } } } }));
const definition = customWidgetDefinitionSchema.parse({
$schema: "homarr-custom-widget-v2",
name: "Preview options",
sources: { default: { name: "API", baseUrl: "https://example.com", networkScope: "public", auth: "none" } },
requests: { containers: { path: "/endpoints/{option:environmentId}/containers" } },
options: { environmentId: { label: "Environment", control: "number", default: 1 } },
template: "<Text>Preview</Text>",
});
describe("Custom Widget workbench preview options", () => {
it("shows the default source first regardless of manifest key order", () => {
expect(
parseSources(
JSON.stringify({
secondary: { baseUrl: "https://secondary.example.com", networkScope: "public", auth: "none" },
default: { baseUrl: "https://default.example.com", networkScope: "public", auth: "none" },
}),
).map(({ id }) => id),
).toEqual(["default", "secondary"]);
});
it("keeps load-query option resolution on the server", async () => {
previewQuery.mockResolvedValueOnce({ ok: true, status: 200, data: [] });
await loadPreviewQueries(definition, "preview-1");
expect(previewQuery).toHaveBeenCalledWith({ sessionId: "preview-1", requestId: "containers", params: {} });
});
it("rejects invalid selected options", () => {
expect(getCustomWidgetPreviewOptionIssues(definition, { environmentId: "wrong" })).toEqual([
{ path: "configuration.environmentId", message: "Expected a number" },
]);
});
it("normalizes only changed secrets", () => {
expect(
getChangedSecrets({
name: "Widget",
description: "",
iconUrl: "",
sources: "{}",
requests: "{}",
options: "{}",
template: "<Text />",
secrets: [
{ sourceId: "default", kind: "apiKey", value: "", hasValue: true },
{ sourceId: "other", kind: "password", value: "replacement" },
],
}),
).toEqual([{ sourceId: "other", kind: "password", value: "replacement" }]);
});
});
@@ -0,0 +1,111 @@
import { fetchApi } from "@homarr/api/client";
import {
customWidgetDefinitionSchema,
getCustomWidgetDefaultOptions,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import type { UseFormReturnType } from "@mantine/form";
export type CustomWidgetWorkbenchForm = UseFormReturnType<CustomWidgetFormValues>;
export function buildDefinition(values: CustomWidgetFormValues) {
return customWidgetDefinitionSchema.safeParse({
$schema: "homarr-custom-widget-v2",
name: values.name,
description: values.description || undefined,
iconUrl: values.iconUrl || undefined,
sources: parseJson(values.sources),
requests: parseJson(values.requests),
options: parseJson(values.options),
template: values.template,
});
}
export function getChangedSecrets(values: CustomWidgetFormValues) {
return values.secrets
.filter((secret) => secret.value.trim())
.map(({ sourceId, kind, value }) => ({
sourceId,
kind: kind as "apiKey" | "username" | "password",
value,
}));
}
export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) {
form.setValues({
...form.values,
name: widget.name,
description: widget.description ?? "",
iconUrl: widget.iconUrl ?? "",
sources: JSON.stringify(widget.sources, null, 2),
requests: JSON.stringify(widget.requests, null, 2),
options: JSON.stringify(widget.options, null, 2),
template: widget.template,
});
}
export function parseSources(value: string): Array<CustomWidgetSource & { id: string }> {
const parsed = parseJson(value);
return isRecord(parsed)
? Object.entries(parsed)
.flatMap(([id, entry]) => (isRecord(entry) ? [{ id, ...(entry as CustomWidgetSource) }] : []))
.toSorted((left, right) => Number(right.id === "default") - Number(left.id === "default"))
: [];
}
export function parseJson(value: string): unknown {
try {
return JSON.parse(value) as unknown;
} catch {
return null;
}
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
export function getCustomWidgetPreviewOptionIssues(definition: HomarrCustomWidgetV2, options: Record<string, unknown>) {
return validateCustomWidgetOptions(definition.options, options);
}
export function isRuntimeParams(
value: unknown,
expectedNames: readonly string[],
): value is Record<string, string | number | boolean> {
if (!isRecord(value)) return false;
const names = Object.keys(value).toSorted();
return (
names.length === expectedNames.length &&
names.every((name, index) => name === expectedNames[index]) &&
Object.values(value).every((entry) => ["string", "number", "boolean"].includes(typeof entry))
);
}
export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) {
const data: Record<string, unknown> = {};
const status: Record<string, unknown> = {};
for (const [requestId] of Object.entries(definition.requests).filter(
([, entry]) => entry.kind === "query" && entry.trigger === "load",
)) {
try {
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} });
data[requestId] = result.data;
status[requestId] = { loading: false, ...result };
} catch (error) {
data[requestId] = null;
status[requestId] = {
loading: false,
ok: false,
error: error instanceof Error ? error.message : "Request failed",
};
}
}
return { data, status };
}
export function getDefinitionDefaults(definition: HomarrCustomWidgetV2) {
return getCustomWidgetDefaultOptions(definition.options);
}
@@ -2,6 +2,38 @@
min-width: 0;
}
.sectionNav {
position: sticky;
top: 0.5rem;
z-index: 20;
display: flex;
gap: 0.25rem;
margin-bottom: var(--mantine-spacing-md);
padding: 0.35rem;
overflow-x: auto;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent);
backdrop-filter: blur(10px);
}
.sectionNav > a {
flex: 0 0 auto;
}
.editorSection {
scroll-margin-top: 5rem;
}
.previewCanvas {
display: flex;
justify-content: center;
overflow-x: auto;
padding: var(--mantine-spacing-sm);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-default-hover);
}
.workbench {
display: grid;
gap: var(--mantine-spacing-xl);
@@ -17,6 +49,10 @@
}
@media (max-width: 74.9375em) {
.sectionNav {
top: 3.5rem;
}
.workbench[data-mobile-pane="configure"] .previewPane,
.workbench[data-mobile-pane="preview"] .configuration {
display: none;
@@ -48,78 +84,6 @@
background: var(--mantine-color-body);
}
.formFlow {
--flow-marker-size: 2.5rem;
}
.formSection {
position: relative;
padding: 0 0 var(--mantine-spacing-xl) calc(var(--flow-marker-size) + var(--mantine-spacing-lg));
scroll-margin-top: 5rem;
outline: none;
}
.formSection:not(:last-child)::before {
position: absolute;
top: var(--flow-marker-size);
bottom: 0;
left: calc(var(--flow-marker-size) / 2);
width: 1px;
background: var(--mantine-color-default-border);
content: "";
}
.formSection:focus-visible {
border-radius: var(--mantine-radius-md);
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 4px;
}
.sectionMarker {
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
.displayTypeOption {
display: flex;
align-items: flex-start;
gap: var(--mantine-spacing-sm);
min-height: 7rem;
padding: var(--mantine-spacing-md);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
transition:
border-color 160ms ease,
background-color 160ms ease;
}
.displayTypeOption:hover {
background: var(--mantine-color-default-hover);
}
.displayTypeOption[data-selected] {
border-color: var(--mantine-primary-color-filled);
background: var(--mantine-primary-color-light);
}
.displayTypeOption:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.displayTypeCopy {
min-width: 0;
}
@media (prefers-reduced-motion: reduce) {
.displayTypeOption {
transition: none;
}
}
@media (min-width: 75em) {
.paneSwitcher {
display: none;
@@ -1,29 +1,44 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Box, Button, Group, Paper, SegmentedControl, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Accordion, Box, Button, Paper, SegmentedControl, Stack, TextInput, Textarea } from "@mantine/core";
import {
IconAlertCircle,
IconApi,
IconBraces,
IconCode,
IconDatabase,
IconEye,
IconSettings,
} from "@tabler/icons-react";
import type { CustomWidgetAuthType, CustomWidgetDisplayType } from "@homarr/custom-widgets/core";
import { buildDisplayConfigFromFormValues, CUSTOM_JSX_STARTER } from "@homarr/custom-widgets/core";
import {
analyzeJsxTemplate,
analyzeRequestManifest,
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
customWidgetFormSchema,
parseRequestManifest,
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
} from "@homarr/custom-widgets/workbench";
import { customWidgetOptionsSchema, getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { CopyAiPromptButton } from "./_copy-ai-prompt-button";
import { CustomWidgetPreview } from "./_custom-widget-preview";
import { ConfigureSection, ConnectionSection, FormatSection } from "./_custom-widget-form-sections";
import { extractServerErrors } from "./_custom-widget-form-errors";
import { useCustomWidgetClipboard } from "./_use-custom-widget-clipboard";
import { useCustomWidgetPreview } from "./_use-custom-widget-preview";
import { useZodForm } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { CustomWidgetAiCard } from "./_custom-widget-ai-card";
import { CustomWidgetAdvancedManifest } from "./_custom-widget-advanced-manifest";
import { createCustomWidgetCompletions, getInvalidCustomWidgetSections } from "./_custom-widget-form-analysis";
import { EditorSection, SaveActions } from "./_custom-widget-form-layout";
import { customWidgetOptionsSchemaReference, customWidgetRequestReference } from "./_custom-widget-form-references";
import { buildDefinition, getDefinitionDefaults, isRecord, parseJson } from "./_custom-widget-form-utils";
import { CustomWidgetOptionsEditor } from "./_custom-widget-options-editor";
import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel";
import type { PreviewState } from "./_custom-widget-preview-panel";
import { CustomWidgetRequestsEditor } from "./_custom-widget-requests-editor";
import { createCustomWidgetRenameHandlers } from "./_custom-widget-rename-handlers";
import { CustomWidgetSaveIssuesAlert } from "./_custom-widget-save-issues-alert";
import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor";
import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions";
import classes from "./_custom-widget-form.module.css";
interface CustomWidgetFormProps {
@@ -32,260 +47,243 @@ interface CustomWidgetFormProps {
definitionId?: string;
}
const sectionLinks = [
["general", "section.general", IconSettings],
["sources", "section.sources", IconApi],
["requests", "section.requests", IconDatabase],
["options", "section.options", IconBraces],
["jsx", "section.jsx", IconCode],
["preview", "section.preview", IconEye],
] as const;
export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWidgetFormProps) {
const router = useRouter();
const globalT = useI18n();
const t = useScopedI18n("customWidget");
const utils = clientApi.useUtils();
const createMutation = clientApi.customWidget.create.useMutation();
const updateMutation = clientApi.customWidget.update.useMutation();
const [previewRefreshSignal, setPreviewRefreshSignal] = useState(0);
const w = useScopedI18n("customWidget.workbench");
const formInitialValues: CustomWidgetFormValues = { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues };
const form = useZodForm(customWidgetFormSchema, { initialValues: formInitialValues });
const [mobilePane, setMobilePane] = useState<"configure" | "preview">("configure");
const formatSectionRef = useRef<HTMLElement>(null);
const connectionSectionRef = useRef<HTMLElement>(null);
const configureSectionRef = useRef<HTMLElement>(null);
const scrollToSection = useCallback((section: number) => {
const element = [formatSectionRef.current, connectionSectionRef.current, configureSectionRef.current][section];
if (!element) return;
element.scrollIntoView({ behavior: "smooth", block: "start" });
window.setTimeout(() => element.focus({ preventScroll: true }), 250);
}, []);
const mergedInitialValues = { ...DEFAULT_CUSTOM_WIDGET_FORM_VALUES, ...initialValues };
if (mergedInitialValues.displayType === "customJsx" && !mergedInitialValues.template.trim()) {
mergedInitialValues.template = CUSTOM_JSX_STARTER;
}
const form = useZodForm(customWidgetFormSchema, { initialValues: mergedInitialValues });
const preview = useCustomWidgetPreview({
form,
definitionId,
refreshSignal: previewRefreshSignal,
onOpenPreview: () => setMobilePane("preview"),
t,
const [request, setRequest] = useState("");
const [documentationUrl, setDocumentationUrl] = useState("");
const [preview, setPreview] = useState<PreviewState>({ data: {}, status: {}, session: null, outcome: "idle" });
const [previewSize, setPreviewSize] = useState("standard");
const [previewTheme, setPreviewTheme] = useState<"light" | "dark">("dark");
const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>(() => {
const initialDefinition = buildDefinition(formInitialValues);
return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {};
});
useCustomWidgetClipboard({ enabled: mode === "edit", form, t, onReplace: preview.reset });
const dirtyRef = useRef(false);
dirtyRef.current = form.isDirty();
const requestManifest = parseRequestManifest(form.values.requestManifest);
const requestIds = requestManifest
.map((request) => (request && typeof request === "object" && "id" in request ? request.id : null))
.filter((id): id is string => typeof id === "string");
const editorDiagnostics =
form.values.displayType === "customJsx"
? analyzeJsxTemplate(form.values.template, {
apiVersion: form.values.jsxApiVersion === "2" ? 2 : 1,
requestIds,
})
: [];
const requestDiagnostics =
form.values.displayType === "customJsx" && form.values.jsxApiVersion === "2"
? analyzeRequestManifest(form.values.requestManifest)
: [];
const hasEditorErrors = [...editorDiagnostics, ...requestDiagnostics].some(
(diagnostic) => diagnostic.severity === "error",
const candidate = useMemo(() => buildDefinition(form.values), [form.values]);
const parsedOptions = useMemo(
() => customWidgetOptionsSchema.safeParse(parseJson(form.values.options)),
[form.values.options],
);
const requestIds = useMemo(
() =>
Object.keys(
isRecord(parseJson(form.values.requests)) ? (parseJson(form.values.requests) as Record<string, unknown>) : {},
),
[form.values.requests],
);
const jsxCompletions = useMemo(
() => createCustomWidgetCompletions(form.values, requestIds),
[form.values, requestIds],
);
const templateDiagnostics = useMemo(
() => analyzeJsxTemplate(form.values.template, { requestIds }),
[form.values.template, requestIds],
);
const requestDiagnostics = useMemo(() => analyzeRequestManifest(form.values.requests), [form.values.requests]);
const hasDiagnostics = [...templateDiagnostics, ...requestDiagnostics].some((entry) => entry.severity === "error");
const invalidSections = useMemo(
() =>
getInvalidCustomWidgetSections(
candidate.success ? [] : candidate.error.issues,
requestDiagnostics,
templateDiagnostics,
),
[candidate, requestDiagnostics, templateDiagnostics],
);
const isDirtyRef = useRef(false);
isDirtyRef.current = form.isDirty();
const { save, runPreview, pasteAiResponse, saveIssues, savePending, previewPending } = useCustomWidgetFormActions({
mode,
definitionId,
form,
candidate,
preview,
setPreview,
setMobilePane,
optionsSnapshot,
setOptionsSnapshot,
});
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (isDirtyRef.current) event.preventDefault();
const beforeUnload = (event: BeforeUnloadEvent) => {
if (dirtyRef.current) event.preventDefault();
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
window.addEventListener("beforeunload", beforeUnload);
return () => window.removeEventListener("beforeunload", beforeUnload);
}, []);
useEffect(() => {
if (form.values.displayType === "customJsx" && form.values.jsxApiVersion === "2" && form.values.method !== "GET") {
form.setFieldValue("method", "GET");
form.setFieldValue("requestBody", "");
}
}, [form, form.values.displayType, form.values.jsxApiVersion, form.values.method]);
const focusStepForErrors = (errors: Record<string, unknown>) => {
const fields = Object.keys(errors);
if (fields.some((field) => ["name", "description", "iconUrl", "displayType"].includes(field.split(".")[0] ?? ""))) {
scrollToSection(0);
return;
}
if (
fields.some((field) =>
["url", "method", "authType", "headerName", "requestBody", "secrets"].includes(field.split(".")[0] ?? ""),
)
) {
scrollToSection(1);
return;
}
scrollToSection(2);
};
const handleSubmit = form.onSubmit(async (values) => {
if (hasEditorErrors) return;
const displayConfig = buildDisplayConfigFromFormValues(values);
const payload = {
name: values.name,
description: values.description || undefined,
iconUrl: values.iconUrl || undefined,
url: values.url,
authType: values.authType as CustomWidgetAuthType,
headerName: values.headerName || undefined,
method: values.method as "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
requestBody: values.requestBody || undefined,
displayType: values.displayType as CustomWidgetDisplayType,
displayConfig: displayConfig as never,
secrets: values.secrets
.filter((s) => s.value)
.map((s) => ({
kind: s.kind as "apiKey" | "username" | "password",
value: s.value,
})),
};
try {
if (mode === "create") {
const result = await createMutation.mutateAsync(payload);
showSuccessNotification({
title: t("action.create"),
message: t("notification.created", { name: values.name }),
});
await utils.customWidget.all.invalidate();
router.push(`/manage/custom-widgets/edit/${result.id}`);
} else if (definitionId) {
await updateMutation.mutateAsync({ id: definitionId, ...payload });
showSuccessNotification({
title: t("action.save"),
message: t("notification.updated", { name: values.name }),
});
setPreviewRefreshSignal((n) => n + 1);
await utils.customWidget.all.invalidate();
await utils.customWidget.byId.invalidate({ id: definitionId });
await utils.widget.customApi.getData.invalidate();
form.setInitialValues(values);
form.resetDirty();
}
} catch (err) {
const serverErrors = extractServerErrors(err, values.displayType);
if (Object.keys(serverErrors).length > 0) {
form.setErrors(serverErrors);
focusStepForErrors(serverErrors);
}
const errorKey = mode === "create" ? "notification.createError" : "notification.updateError";
showErrorNotification({
title: t("action.save"),
message: t(errorKey as never),
});
}
}, focusStepForErrors);
const continueToSection = (currentSection: number, field: string, nextSection: number) => {
if (form.validateField(field).hasError) {
scrollToSection(currentSection);
return;
}
scrollToSection(nextSection);
};
if (parsedOptions.success) setOptionsSnapshot(getCustomWidgetDefaultOptions(parsedOptions.data));
}, [parsedOptions]);
const invalid = hasDiagnostics || !candidate.success;
const { renameRequest, renameOption } = createCustomWidgetRenameHandlers({
form,
candidate,
invalidWidgetMessage: w("invalidWidget"),
});
return (
<form onSubmit={handleSubmit} className={classes.form}>
<form onSubmit={save} className={classes.form}>
<SegmentedControl
className={classes.paneSwitcher}
fullWidth
value={mobilePane}
onChange={(value) => setMobilePane(value as "configure" | "preview")}
onChange={(value) => setMobilePane(value as typeof mobilePane)}
data={[
{ value: "configure", label: t("workbench.configure") },
{ value: "preview", label: t("workbench.preview") },
{ value: "configure", label: w("configure") },
{ value: "preview", label: w("section.preview") },
]}
/>
<CustomWidgetSaveIssuesAlert issues={saveIssues} />
<nav className={classes.sectionNav} aria-label={w("sectionNavigation")}>
{sectionLinks.map(([id, key, SectionIcon]) => (
<Button
key={id}
component="a"
href={`#${id}`}
size="compact-sm"
variant="subtle"
color={invalidSections.has(id) ? "red" : undefined}
leftSection={<SectionIcon size={14} />}
rightSection={
invalidSections.has(id) ? <IconAlertCircle size={13} aria-label={w("status.invalid")} /> : undefined
}
>
{w(key)}
</Button>
))}
</nav>
<div className={classes.workbench} data-mobile-pane={mobilePane}>
<Stack gap="xl" className={classes.configuration}>
<Stack gap={0} className={classes.formFlow}>
<FormatSection
form={form}
t={t}
previewJson={preview.json}
sectionRef={formatSectionRef}
onContinue={() => continueToSection(0, "name", 1)}
<Stack gap="lg" className={classes.configuration}>
<CustomWidgetAiCard
candidate={candidate.success ? candidate.data : null}
request={request}
onRequestChange={setRequest}
documentationUrl={documentationUrl}
onDocumentationUrlChange={setDocumentationUrl}
onPaste={() => void pasteAiResponse()}
/>
<CustomWidgetAdvancedManifest form={form} />
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} autosize minRows={2} {...form.getInputProps("description")} />
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
</EditorSection>
<EditorSection id="sources" title={w("sources.title")} icon={IconApi}>
<CustomWidgetSourcesEditor form={form} definitionId={definitionId} />
</EditorSection>
<EditorSection id="requests" title={w("requests.title")} icon={IconDatabase}>
<CustomWidgetRequestsEditor form={form} onRename={renameRequest} />
<Accordion variant="contained">
<Accordion.Item value="raw">
<Accordion.Control>{w("builder.advancedJson")}</Accordion.Control>
<Accordion.Panel>
<CodeEditor
id="requests-editor"
label={w("requests.label")}
description={w("requests.description")}
language="json"
value={form.values.requests}
onChange={(value) => form.setFieldValue("requests", value)}
diagnostics={requestDiagnostics}
error={form.errors.requests}
reference={customWidgetRequestReference}
required
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</EditorSection>
<EditorSection id="options" title={w("options.title")} icon={IconBraces}>
<CustomWidgetOptionsEditor form={form} onRename={renameOption} />
<Accordion variant="contained">
<Accordion.Item value="raw">
<Accordion.Control>{w("builder.advancedJson")}</Accordion.Control>
<Accordion.Panel>
<CodeEditor
id="options"
label={w("options.title")}
language="json"
value={form.values.options}
onChange={(value) => form.setFieldValue("options", value)}
error={form.errors.options}
reference={customWidgetOptionsSchemaReference}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</EditorSection>
<EditorSection id="jsx" title={w("jsx.title")} icon={IconCode}>
<CodeEditor
id="jsx-editor"
label={w("jsx.label")}
language="jsx"
value={form.values.template}
onChange={(value) => form.setFieldValue("template", value)}
diagnostics={templateDiagnostics}
completions={jsxCompletions}
error={form.errors.template}
required
maxLength={50_000}
/>
<ConnectionSection
form={form}
t={t}
previewJson={preview.json}
sectionRef={connectionSectionRef}
isTesting={preview.isTesting}
onTest={() => void preview.test()}
onContinue={() => continueToSection(1, "url", 2)}
/>
<ConfigureSection form={form} t={t} previewJson={preview.json} sectionRef={configureSectionRef} />
</Stack>
</EditorSection>
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
<Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={600}>
{form.isDirty() ? globalT("common.unsavedChanges") : t("action.readyToSave")}
</Text>
<Button
type="submit"
size="md"
loading={createMutation.isPending || updateMutation.isPending}
disabled={hasEditorErrors}
>
{mode === "create" ? t("action.create") : t("action.save")}
</Button>
</Group>
<SaveActions
dirty={form.isDirty()}
savePending={savePending}
previewPending={previewPending}
invalid={invalid}
mode={mode}
onPreview={() => void runPreview()}
/>
</Paper>
</Stack>
<Box component="aside" className={classes.previewPane} aria-label={t("preview.title")}>
<Box id="preview" component="aside" className={classes.previewPane} aria-label={w("widgetPreview")}>
<Paper p="md" className={classes.actionBar} shadow="sm">
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="sm" fw={600}>
{form.isDirty() ? globalT("common.unsavedChanges") : t("action.readyToSave")}
</Text>
<Text size="xs" c="dimmed" visibleFrom="sm">
{hasEditorErrors ? t("editor.saveBlocked") : t("editor.saveReady")}
</Text>
</div>
<Button
type="submit"
size="md"
loading={createMutation.isPending || updateMutation.isPending}
disabled={hasEditorErrors}
miw={160}
>
{mode === "create" ? t("action.create") : t("action.save")}
</Button>
</Group>
<SaveActions
dirty={form.isDirty()}
savePending={savePending}
previewPending={previewPending}
invalid={invalid}
mode={mode}
onPreview={() => void runPreview()}
/>
</Paper>
<CopyAiPromptButton
rawResponse={preview.fetchResult?.rawResponse}
currentConfig={{
$schema: "homarr-custom-widget-v3",
name: form.values.name,
description: form.values.description,
iconUrl: form.values.iconUrl,
url: form.values.url,
authType: form.values.authType,
headerName: form.values.headerName,
method: form.values.method,
requestBody: form.values.requestBody,
displayType: form.values.displayType,
displayConfig: preview.input.displayConfig,
}}
/>
<CustomWidgetPreview
getFormValues={preview.getInput}
formValues={preview.input}
fetchResult={preview.fetchResult}
cachedJson={preview.json}
onTest={() => void preview.test()}
isTesting={preview.isTesting}
isSampleStale={preview.isStale}
testError={preview.testError ? t("notification.previewError") : null}
onInsertDataPath={form.values.displayType === "customJsx" ? preview.insertDataPath : undefined}
onSetPreviewLiveActions={preview.setLiveActions}
isUpdatingPreviewActions={preview.isUpdatingLiveActions}
onSampleDataChange={preview.setSampleData}
<CustomWidgetPreviewPanel
candidate={candidate.success ? candidate.data : null}
validationIssues={
candidate.success
? []
: candidate.error.issues.map((issue) => ({
path: issue.path.map(String).join(".") || undefined,
message: issue.message,
}))
}
preview={preview}
size={previewSize}
onSizeChange={setPreviewSize}
theme={previewTheme}
onThemeChange={setPreviewTheme}
optionsSnapshot={optionsSnapshot}
onOptionsChange={setOptionsSnapshot}
onLiveActionsChange={(enabled) =>
setPreview((current) => ({
...current,
session: current.session ? { ...current.session, liveActions: enabled } : null,
}))
}
/>
</Box>
</div>
@@ -0,0 +1,48 @@
"use client";
import { useEffect, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import { TextInput } from "@mantine/core";
export function CustomWidgetIdentifierInput({
label,
value,
error,
disabled,
style,
onCommit,
}: {
label: string;
value: string;
error?: ReactNode;
disabled?: boolean;
style?: CSSProperties;
onCommit(value: string): void;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);
const commit = () => {
if (draft !== value) onCommit(draft);
};
return (
<TextInput
label={label}
value={draft}
error={error}
disabled={disabled}
style={style}
onChange={(event) => setDraft(event.currentTarget.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
if (event.key === "Escape") {
event.preventDefault();
setDraft(value);
}
}}
/>
);
}
@@ -1,7 +1,7 @@
"use client";
import { ActionIcon, Avatar, Badge, Card, Group, Stack, Switch, Text, Tooltip } from "@mantine/core";
import { IconApi, IconPencil } from "@tabler/icons-react";
import { IconAlertTriangle, IconApi, IconPencil } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -13,21 +13,8 @@ import { Link } from "@homarr/ui";
import { NoResults } from "~/components/no-results";
import { CustomWidgetRowActions } from "./_custom-widget-actions";
const displayTypeBadgeColors: Record<string, string> = {
singleValue: "blue",
keyValue: "green",
table: "orange",
statGrid: "violet",
progressBars: "teal",
statusIndicator: "cyan",
countGrid: "indigo",
raw: "gray",
actionButton: "red",
customJsx: "pink",
};
interface CustomWidgetListProps {
definitions: RouterOutputs["customWidget"]["all"];
definitions: RouterOutputs["customWidget"]["list"];
}
export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
@@ -52,7 +39,7 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
);
};
type WidgetDef = RouterOutputs["customWidget"]["all"][number];
type WidgetDef = RouterOutputs["customWidget"]["list"][number];
function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
const t = useScopedI18n("customWidget");
@@ -63,8 +50,9 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
toggleMutation.mutate(
{ id: widget.id, enabled: !widget.enabled },
{
onSuccess: () => {
void utils.customWidget.all.invalidate();
onSuccess: async () => {
await utils.widget.customApi.getData.cancel();
void utils.customWidget.list.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
@@ -81,8 +69,10 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
return (
<Card
padding="sm"
withBorder={!widget.valid}
bd={!widget.valid ? "1px solid var(--mantine-color-red-6)" : undefined}
style={{
opacity: widget.enabled ? 1 : 0.55,
opacity: widget.valid && !widget.enabled ? 0.55 : 1,
transition: "opacity 150ms ease",
}}
>
@@ -99,42 +89,85 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
{widget.name}
</Text>
{widget.url && (
{!widget.valid && (
<Text size="xs" c="red">
{t("page.list.invalidDefinitionDescription")}
</Text>
)}
{widget.sources[0] && (
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1} style={{ wordBreak: "break-all", minWidth: 0 }}>
{widget.url}
{widget.sources.map((source) => source.origin).join(" · ")}
</Text>
)}
</Stack>
<Badge
color={displayTypeBadgeColors[widget.displayType] ?? "gray"}
size="sm"
variant="light"
style={{ flexShrink: 0 }}
>
{t(`displayType.${widget.displayType}` as never)}
</Badge>
{widget.valid ? (
<Badge color="pink" size="sm" variant="light" style={{ flexShrink: 0 }}>
JSX · {widget.requestCount}
</Badge>
) : (
<Tooltip
label={widget.validationIssues
.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message))
.join("\n")}
multiline
maw={520}
>
<Badge
color="red"
size="sm"
variant="light"
leftSection={<IconAlertTriangle size={12} />}
style={{ flexShrink: 0 }}
>
{t("page.list.invalidDefinition")}
</Badge>
</Tooltip>
)}
{widget.missingSecrets.length > 0 && (
<Tooltip
label={t("page.list.missingCredentialsDescription", { count: widget.missingSecrets.length })}
multiline
maw={320}
>
<Badge
color="yellow"
size="sm"
variant="light"
leftSection={<IconAlertTriangle size={12} />}
style={{ flexShrink: 0 }}
>
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
</Badge>
</Tooltip>
)}
</Group>
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
<Tooltip label={widget.enabled ? t("action.disable") : t("action.enable")}>
<Switch
size="sm"
checked={widget.enabled}
onChange={handleToggle}
disabled={toggleMutation.isPending}
aria-label={widget.enabled ? t("action.disable") : t("action.enable")}
/>
</Tooltip>
<ActionIcon
component={Link}
href={`/manage/custom-widgets/edit/${widget.id}`}
variant="subtle"
color="gray"
aria-label={t("action.edit")}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<CustomWidgetRowActions widget={{ id: widget.id, name: widget.name, enabled: widget.enabled }} />
{widget.valid && (
<>
<Tooltip label={widget.enabled ? t("action.disable") : t("action.enable")}>
<Switch
size="sm"
checked={widget.enabled}
onChange={handleToggle}
disabled={toggleMutation.isPending}
aria-label={widget.enabled ? t("action.disable") : t("action.enable")}
/>
</Tooltip>
<ActionIcon
component={Link}
href={`/manage/custom-widgets/edit/${widget.id}`}
variant="subtle"
color="gray"
aria-label={t("action.edit")}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
</>
)}
<CustomWidgetRowActions
widget={{ id: widget.id, name: widget.name, enabled: widget.enabled, valid: widget.valid }}
/>
</Group>
</Group>
</Card>
@@ -0,0 +1,196 @@
"use client";
import { useEffect, useState } from "react";
import { ActionIcon, Button, Group, MultiSelect, NumberInput, Select, Stack, Switch, TextInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
export function DefaultValueEditor({
id,
control,
value,
choices = [],
onChange,
}: {
id: string;
control: string;
value: unknown;
choices?: Array<{ label: string; value: string | number }>;
onChange: (value: unknown) => void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
if (control === "switch")
return (
<Switch
label={t("default")}
checked={value === true}
onChange={(event) => onChange(event.currentTarget.checked)}
/>
);
if (["number", "slider", "duration"].includes(control))
return <NumberInput label={t("default")} value={typeof value === "number" ? value : 0} onChange={onChange} />;
const choiceData = choices.map((choice) => ({ label: choice.label, value: String(choice.value) }));
if (control === "select" && choices.length > 0)
return (
<Select
label={t("default")}
data={choiceData}
value={value === undefined ? null : String(value)}
onChange={(next) => onChange(coerceChoice(next, choices))}
/>
);
if (control === "multiSelect" && choices.length > 0)
return (
<MultiSelect
label={t("default")}
data={choiceData}
value={Array.isArray(value) ? value.map(String) : []}
onChange={(next) => onChange(next.map((entry) => coerceChoice(entry, choices)))}
/>
);
if (control === "json" || control === "multiSelect")
return <JsonValueEditor id={id} label={t("default")} value={value} onChange={onChange} />;
return (
<TextInput
label={t("default")}
value={typeof value === "string" ? value : ""}
onChange={(event) => onChange(event.currentTarget.value)}
/>
);
}
export function optionForControl(option: Record<string, unknown>, control: string | null): Record<string, unknown> {
const nextControl = control ?? "text";
const next: Record<string, unknown> = { ...option, control: nextControl, default: defaultForControl(nextControl) };
if (!["select", "multiSelect"].includes(nextControl)) {
next.choices = undefined;
next.choicesFrom = undefined;
}
if (!["number", "slider", "duration"].includes(nextControl)) {
next.min = undefined;
next.max = undefined;
next.step = undefined;
}
return next;
}
export function OptionChoicesEditor({
value,
onChange,
}: {
value: Array<{ label: string; value: string | number }>;
onChange(value: Array<{ label: string; value: string | number }>): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
return (
<Stack gap="xs">
{value.map((choice, index) => (
<Group key={index} align="end" wrap="wrap">
<TextInput
style={{ flex: "1 1 10rem" }}
label={t("choiceLabel")}
value={choice.label}
onChange={(event) =>
onChange(
value.map((entry, entryIndex) =>
entryIndex === index ? { ...entry, label: event.currentTarget.value } : entry,
),
)
}
/>
<TextInput
style={{ flex: "1 1 10rem" }}
label={t("choiceValue")}
value={String(choice.value)}
onChange={(event) =>
onChange(
value.map((entry, entryIndex) =>
entryIndex === index ? { ...entry, value: parseChoiceValue(event.currentTarget.value) } : entry,
),
)
}
/>
<ActionIcon
type="button"
color="red"
variant="subtle"
aria-label={t("removeChoice")}
onClick={() => onChange(value.filter((_, entryIndex) => entryIndex !== index))}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
size="compact-sm"
variant="subtle"
leftSection={<IconPlus size={14} />}
onClick={() => onChange([...value, { label: t("newChoice"), value: `choice-${value.length + 1}` }])}
>
{t("addChoice")}
</Button>
</Stack>
);
}
export function JsonValueEditor({
id,
label,
description,
value,
onChange,
}: {
id: string;
label: string;
description?: string;
value: unknown;
onChange(value: unknown): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const serialized = JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized);
const [error, setError] = useState<string>();
useEffect(() => setDraft(serialized), [serialized]);
return (
<CodeEditor
id={`option-${id}`}
label={label}
description={description}
language="json"
value={draft}
height="140px"
error={error}
onChange={(next) => {
setDraft(next);
try {
onChange(JSON.parse(next) as unknown);
setError(undefined);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("invalidJson"));
}
}}
/>
);
}
function defaultForControl(control: string | null) {
if (control === "switch") return false;
if (["number", "slider", "duration"].includes(control ?? "")) return 0;
if (control === "multiSelect") return [];
if (control === "json") return {};
return "";
}
function parseChoiceValue(value: string): string | number {
return /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/u.test(value.trim()) ? Number(value) : value;
}
function coerceChoice(value: string | null, choices: Array<{ value: string | number }>) {
if (value === null) return "";
const numeric = choices.find((choice) => typeof choice.value === "number" && String(choice.value) === value);
return numeric?.value ?? value;
}
@@ -0,0 +1,271 @@
"use client";
import {
Accordion,
ActionIcon,
Button,
Fieldset,
Group,
NumberInput,
Select,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
import { customWidgetOptionControls } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client";
import { isRecord, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { DefaultValueEditor, OptionChoicesEditor, optionForControl } from "./_custom-widget-option-fields";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
export function CustomWidgetOptionsEditor({
form,
onRename,
}: {
form: CustomWidgetWorkbenchForm;
onRename(currentName: string, nextName: string): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const parsed = parseJson(form.values.options);
const entries = isRecord(parsed) ? Object.entries(parsed) : [];
const requests = parseJson(form.values.requests);
const requestIds = isRecord(requests) ? Object.keys(requests) : [];
const commit = (next: Array<[string, unknown]>) =>
form.setFieldValue("options", JSON.stringify(Object.fromEntries(next), null, 2));
const update = (index: number, id: string, option: Record<string, unknown>) =>
commit(entries.map((entry, i) => (i === index ? [id, option] : entry)));
const remove = (index: number) => commit(entries.filter((_, i) => i !== index));
const move = (index: number, direction: -1 | 1) => {
const next = [...entries];
const target = index + direction;
if (!next[index] || !next[target]) return;
[next[index], next[target]] = [next[target], next[index]];
commit(next);
};
const add = () => {
let suffix = entries.length + 1;
while (entries.some(([id]) => id === `option-${suffix}`)) suffix += 1;
commit([...entries, [`option-${suffix}`, { label: `Option ${suffix}`, control: "text", default: "" }]]);
};
return (
<Stack gap="sm">
{entries.map(([id, rawOption], index) => {
const option = isRecord(rawOption) ? rawOption : {};
const control = typeof option.control === "string" ? option.control : "text";
const choicesFrom = isRecord(option.choicesFrom) ? option.choicesFrom : null;
const choices = Array.isArray(option.choices)
? option.choices.flatMap((choice) =>
isRecord(choice) &&
typeof choice.label === "string" &&
(typeof choice.value === "string" || typeof choice.value === "number")
? [{ label: choice.label, value: choice.value }]
: [],
)
: [];
return (
<Fieldset key={index} legend={typeof option.label === "string" ? option.label : id}>
<Stack gap="sm">
<Group grow align="start">
<CustomWidgetIdentifierInput
label={t("optionName")}
value={id}
error={form.errors.options}
onCommit={(value) => onRename(id, value)}
/>
<TextInput
label={t("label")}
value={typeof option.label === "string" ? option.label : ""}
onChange={(event) => update(index, id, { ...option, label: event.currentTarget.value })}
/>
<Select
label={t("control")}
data={[...customWidgetOptionControls]}
value={control}
allowDeselect={false}
onChange={(value) => update(index, id, optionForControl(option, value))}
/>
</Group>
<TextInput
label={t("description")}
value={typeof option.description === "string" ? option.description : ""}
onChange={(event) =>
update(index, id, { ...option, description: event.currentTarget.value || undefined })
}
/>
<DefaultValueEditor
id={`${index}-default`}
control={control}
value={option.default}
choices={choices}
onChange={(value) => update(index, id, { ...option, default: value })}
/>
<Accordion variant="contained">
<Accordion.Item value="advanced">
<Accordion.Control>{t("optionDetails")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
{["number", "slider", "duration"].includes(control) && (
<Group grow>
<NumberInput
label={t("minimum")}
value={typeof option.min === "number" ? option.min : ""}
onChange={(value) =>
update(index, id, { ...option, min: typeof value === "number" ? value : undefined })
}
/>
<NumberInput
label={t("maximum")}
value={typeof option.max === "number" ? option.max : ""}
onChange={(value) =>
update(index, id, { ...option, max: typeof value === "number" ? value : undefined })
}
/>
<NumberInput
label={t("step")}
value={typeof option.step === "number" ? option.step : ""}
onChange={(value) =>
update(index, id, { ...option, step: typeof value === "number" ? value : undefined })
}
/>
</Group>
)}
{["select", "multiSelect"].includes(control) && (
<OptionChoicesEditor
value={choices}
onChange={(nextChoices) =>
update(index, id, {
...option,
choices: nextChoices.length > 0 ? nextChoices : undefined,
choicesFrom: undefined,
default:
control === "select" &&
!nextChoices.some((choice) => Object.is(choice.value, option.default))
? (nextChoices[0]?.value ?? "")
: option.default,
})
}
/>
)}
{["select", "multiSelect"].includes(control) && (
<Group grow align="start">
<Select
label={t("choicesRequest")}
data={requestIds}
value={typeof choicesFrom?.request === "string" ? choicesFrom.request : null}
clearable
onChange={(value) =>
update(index, id, {
...option,
choices: value ? undefined : option.choices,
choicesFrom: value ? { request: value, valuePath: "id", labelPath: "name" } : undefined,
})
}
/>
{choicesFrom && (
<>
<TextInput
label={t("itemsPath")}
placeholder={t("itemsPathPlaceholder")}
value={String(choicesFrom.itemsPath ?? "")}
onChange={(event) =>
update(index, id, {
...option,
choicesFrom: { ...choicesFrom, itemsPath: event.currentTarget.value || undefined },
})
}
/>
<TextInput
label={t("valuePath")}
value={String(choicesFrom.valuePath ?? "")}
onChange={(event) =>
update(index, id, {
...option,
choicesFrom: { ...choicesFrom, valuePath: event.currentTarget.value },
})
}
/>
<TextInput
label={t("labelPath")}
value={String(choicesFrom.labelPath ?? "")}
onChange={(event) =>
update(index, id, {
...option,
choicesFrom: { ...choicesFrom, labelPath: event.currentTarget.value },
})
}
/>
</>
)}
</Group>
)}
<Group grow>
<TextInput
label={t("group")}
value={typeof option.group === "string" ? option.group : ""}
onChange={(event) =>
update(index, id, { ...option, group: event.currentTarget.value || undefined })
}
/>
<Switch
mt="xl"
label={t("advancedOption")}
checked={option.advanced === true}
onChange={(event) => update(index, id, { ...option, advanced: event.currentTarget.checked })}
/>
</Group>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Group justify="space-between">
<Group gap="xs">
<ActionIcon
type="button"
variant="subtle"
aria-label={t("moveOptionUp")}
disabled={index === 0}
onClick={() => move(index, -1)}
>
<IconArrowUp size={16} />
</ActionIcon>
<ActionIcon
type="button"
variant="subtle"
aria-label={t("moveOptionDown")}
disabled={index === entries.length - 1}
onClick={() => move(index, 1)}
>
<IconArrowDown size={16} />
</ActionIcon>
</Group>
<Button
type="button"
color="red"
variant="subtle"
leftSection={<IconTrash size={16} />}
onClick={() => remove(index)}
>
{t("removeOption")}
</Button>
</Group>
</Stack>
</Fieldset>
);
})}
<Button type="button" variant="light" leftSection={<IconPlus size={16} />} onClick={add}>
{t("addOption")}
</Button>
{form.errors.options && (
<Text c="red" size="xs">
{form.errors.options}
</Text>
)}
</Stack>
);
}
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { Button, Fieldset, Stack, Textarea } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { collectCustomWidgetRequestReferences, getCustomWidgetConfirmation } from "@homarr/custom-widgets/core";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { isRuntimeParams, parseJson } from "./_custom-widget-form-utils";
export function PreviewActionControl({
request,
sessionId,
}: {
request: CustomJsxRequest & { id: string };
sessionId?: string;
}) {
const t = useScopedI18n("customWidget.workbench.preview");
const { openConfirmModal } = useConfirmModal();
const actionMutation = clientApi.customWidget.previewAction.useMutation();
const expectedParams = [...collectCustomWidgetRequestReferences(request).params].toSorted();
const initialParams = Object.fromEntries(expectedParams.map((name) => [name, ""]));
const [params, setParams] = useState(JSON.stringify(initialParams, null, 2));
const parsed = parseJson(params);
const validParams = isRuntimeParams(parsed, expectedParams) ? parsed : null;
const confirmation = getCustomWidgetConfirmation(request);
const run = async () => {
if (!sessionId || !validParams) return;
const needsConfirmation = request.confirmation !== undefined || request.method === "DELETE";
if (needsConfirmation) {
const confirmed = await new Promise<boolean>((resolve) => {
openConfirmModal({
title: confirmation?.title ?? request.id,
children: confirmation?.message ?? t("deleteConfirmation"),
labels: { confirm: confirmation?.confirmLabel },
confirmProps: { color: confirmation?.destructive || request.method === "DELETE" ? "red.9" : "blue" },
onConfirm: () => resolve(true),
onCancel: () => resolve(false),
});
});
if (!confirmed) return;
}
actionMutation.mutate(
{ sessionId, requestId: request.id, params: validParams, confirmed: needsConfirmation },
{
onSuccess: (result) =>
showSuccessNotification({
title: request.id,
message: result.simulated ? t("simulated") : t("httpStatus", { status: result.status }),
}),
onError: (error) => showErrorNotification({ title: request.id, message: error.message }),
},
);
};
return (
<Fieldset legend={`${request.id} · ${request.method}`}>
<Stack gap="xs">
{expectedParams.length > 0 && (
<Textarea
label={t("actionParameters")}
autosize
minRows={2}
value={params}
error={validParams ? undefined : t("invalidParameters")}
onChange={(event) => setParams(event.currentTarget.value)}
/>
)}
<Button
type="button"
variant="light"
color={confirmation?.destructive || request.method === "DELETE" ? "red" : undefined}
disabled={!sessionId || !validParams}
loading={actionMutation.isPending}
onClick={() => void run()}
>
{confirmation?.confirmLabel ??
(confirmation?.destructive || request.method === "DELETE" ? t("runDestructive") : t("runAction"))}
</Button>
</Stack>
</Fieldset>
);
}
@@ -0,0 +1,51 @@
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
interface PreviewSnapshot {
data: Record<string, unknown>;
status: Record<string, unknown>;
session: { id: string; liveActions: boolean } | null;
}
export function createPreviewDisplayData({
candidate,
fixture,
preview,
options,
fixtureError,
}: {
candidate: HomarrCustomWidgetV2 | null;
fixture: "live" | "loading" | "empty" | "error";
preview: PreviewSnapshot;
options: Record<string, unknown>;
fixtureError: string;
}): Record<string, unknown> | null {
if (!candidate) return null;
const requests = Object.entries(candidate.requests);
const data = fixture === "empty" ? Object.fromEntries(requests.map(([id]) => [id, []])) : preview.data;
const status =
fixture === "loading"
? Object.fromEntries(requests.map(([id]) => [id, { loading: true }]))
: fixture === "error"
? Object.fromEntries(requests.map(([id]) => [id, { loading: false, ok: false, error: fixtureError }]))
: preview.status;
return {
template: candidate.template,
data,
status,
options,
requestCapabilities: requests.map(([id, { kind, method, trigger, permission, confirmation, invalidates }]) => ({
id,
kind,
method,
trigger,
minimumBoardPermission: permission,
confirmation:
typeof confirmation === "string" ? { title: "Confirm action", message: confirmation } : confirmation,
invalidates,
})),
previewSessionId: preview.session?.id,
previewLiveActions: preview.session?.liveActions ?? false,
queriesDisabled: fixture !== "live",
isEditMode: fixture !== "live",
};
}
@@ -0,0 +1,225 @@
// @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, test, vi } from "vitest";
import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { CustomWidgetPreviewPanel } from "./_custom-widget-preview-panel";
vi.mock("@homarr/api/client", () => ({
clientApi: {
customWidget: {
setPreviewLiveActions: { useMutation: () => ({ isPending: false, mutate: vi.fn() }) },
previewJournal: { useQuery: () => ({ data: [] }) },
},
},
}));
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() }));
const rendererAttempt = vi.hoisted(() => vi.fn());
vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({
default: ({ data }: { data: Record<string, unknown> }) => {
rendererAttempt(data);
if ((data.options as Record<string, unknown> | undefined)?.throwPreview === true) {
throw new Error("authored widget failed");
}
return <div>widget-renderer</div>;
},
}));
vi.mock("./_code-editor", () => ({ CodeEditor: ({ label }: { label: string }) => <div>{label}</div> }));
vi.mock("./_custom-widget-preview-action", () => ({ PreviewActionControl: () => <div>action-control</div> }));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
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(() => root.unmount());
host.remove();
vi.unstubAllGlobals();
});
describe("Custom Widget preview panel", () => {
test("contains invalid definitions inside the widget canvas while keeping every inspection tab", async () => {
await act(async () => {
root.render(
<MantineProvider>
<CustomWidgetPreviewPanel
candidate={null}
validationIssues={[{ path: "sources.0.baseUrl", message: "Enter a complete URL." }]}
preview={{ data: { previous: true }, status: {}, session: null, outcome: "idle" }}
size="standard"
onSizeChange={vi.fn()}
theme="dark"
onThemeChange={vi.fn()}
optionsSnapshot={{}}
onOptionsChange={vi.fn()}
onLiveActionsChange={vi.fn()}
/>
</MantineProvider>,
);
});
const previewCanvas = host.querySelector('[class*="previewCanvas"]');
expect(previewCanvas?.textContent).toContain("invalid");
for (const tab of ["widget", "data", "options", "actions", "diagnostics"]) {
expect(host.textContent).toContain(`tab.${tab}`);
}
const dataTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
element.textContent?.includes("tab.data"),
);
await act(async () => dataTab?.click());
expect(host.textContent).toContain("requestData");
expect(host.textContent).not.toContain("invalid");
const optionsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
element.textContent?.includes("tab.options"),
);
await act(async () => optionsTab?.click());
expect(host.textContent).toContain("instanceOptions");
expect(host.textContent).not.toContain("invalid");
const actionsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
element.textContent?.includes("tab.actions"),
);
await act(async () => actionsTab?.click());
expect(host.textContent).toContain("liveActions");
expect(host.textContent).not.toContain("invalid");
const diagnosticsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
element.textContent?.includes("tab.diagnostics"),
);
expect(diagnosticsTab).toBeDefined();
await act(async () => diagnosticsTab?.click());
expect(host.textContent).toContain("sources.0.baseUrl");
expect(host.textContent).toContain("Enter a complete URL.");
});
test("contains renderer failures inside the widget tab and keeps the inspection tabs usable", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
rendererAttempt.mockClear();
const bundledWidget = BUNDLED_CUSTOM_WIDGETS.at(0);
if (!bundledWidget) throw new Error("Expected at least one bundled Custom Widget");
const candidate = customWidgetDefinitionSchema.parse(bundledWidget.widget);
const preview = { data: {}, status: {}, session: null, outcome: "idle" } as const;
const failingOptions = { throwPreview: true };
await act(async () => {
root.render(
<MantineProvider>
<CustomWidgetPreviewPanel
candidate={{
...candidate,
requests: Object.fromEntries(
Object.entries(candidate.requests).map(([id, request]) => [id, { ...request }]),
),
}}
validationIssues={[]}
preview={preview}
size="standard"
onSizeChange={vi.fn()}
theme="dark"
onThemeChange={vi.fn()}
optionsSnapshot={{ ...failingOptions }}
onOptionsChange={vi.fn()}
onLiveActionsChange={vi.fn()}
/>
</MantineProvider>,
);
});
expect(host.textContent).toContain("title");
expect(host.querySelector('[class*="previewCanvas"]')?.textContent).toContain("title");
for (const tab of ["widget", "data", "options", "actions", "diagnostics"]) {
expect(host.textContent).toContain(`tab.${tab}`);
}
const attemptsAfterFailure = rendererAttempt.mock.calls.length;
await act(async () => {
root.render(
<MantineProvider>
<CustomWidgetPreviewPanel
candidate={candidate}
validationIssues={[]}
preview={preview}
size="standard"
onSizeChange={vi.fn()}
theme="dark"
onThemeChange={vi.fn()}
optionsSnapshot={failingOptions}
onOptionsChange={vi.fn()}
onLiveActionsChange={vi.fn()}
/>
</MantineProvider>,
);
});
expect(rendererAttempt).toHaveBeenCalledTimes(attemptsAfterFailure);
const dataTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
element.textContent?.includes("tab.data"),
);
await act(async () => dataTab?.click());
expect(host.textContent).toContain("requestData");
const diagnosticsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
element.textContent?.includes("tab.diagnostics"),
);
await act(async () => diagnosticsTab?.click());
expect(host.textContent).toContain("validation.title");
const widgetTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
element.textContent?.includes("tab.widget"),
);
await act(async () => widgetTab?.click());
await act(async () => {
root.render(
<MantineProvider>
<CustomWidgetPreviewPanel
candidate={candidate}
validationIssues={[]}
preview={preview}
size="standard"
onSizeChange={vi.fn()}
theme="dark"
onThemeChange={vi.fn()}
optionsSnapshot={{}}
onOptionsChange={vi.fn()}
onLiveActionsChange={vi.fn()}
/>
</MantineProvider>,
);
});
expect(host.textContent).toContain("widget-renderer");
});
});
@@ -0,0 +1,273 @@
"use client";
import { useState } from "react";
import {
Alert,
Badge,
Box,
Card,
Group,
MantineProvider,
Paper,
SegmentedControl,
Stack,
Switch,
Tabs,
Text,
} from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { analyzeCustomWidgetAccessibility, PreviewErrorBoundary } from "@homarr/custom-widgets/workbench";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
import { CodeEditor } from "./_code-editor";
import { getPreviewSummary, PreviewResult, PreviewStatusDot } from "./_custom-widget-preview-status";
import type { PreviewOutcome } from "./_custom-widget-preview-status";
import { PreviewActionControl } from "./_custom-widget-preview-action";
import { createPreviewDisplayData } from "./_custom-widget-preview-data";
import { JsonPreviewEditor } from "./_json-preview-editor";
import classes from "./_custom-widget-form.module.css";
export interface PreviewState {
data: Record<string, unknown>;
status: Record<string, unknown>;
session: { id: string; expiresAt: number; liveActions: boolean } | null;
outcome: PreviewOutcome;
}
interface PreviewPanelProps {
candidate: HomarrCustomWidgetV2 | null;
validationIssues: Array<{ path?: string; message: string }>;
preview: PreviewState;
size: string;
onSizeChange(value: string): void;
theme: "light" | "dark";
onThemeChange(value: "light" | "dark"): void;
optionsSnapshot: Record<string, unknown>;
onOptionsChange(value: Record<string, unknown>): void;
onLiveActionsChange(enabled: boolean): void;
}
export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
const t = useScopedI18n("customWidget.workbench.preview");
const errorBoundaryMessages = useScopedI18n("customWidget.editor.errorBoundary");
const [fixture, setFixture] = useState<"live" | "loading" | "empty" | "error">("live");
const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
const journalQuery = clientApi.customWidget.previewJournal.useQuery(
{ sessionId: props.preview.session?.id ?? "" },
{ enabled: Boolean(props.preview.session), refetchInterval: props.preview.session ? 2_000 : false },
);
const widths: Record<string, number> = { compact: 320, standard: 480, wide: 720 };
const candidate = props.candidate;
const accessibilityIssues = candidate ? analyzeCustomWidgetAccessibility(candidate.template) : [];
const displayData = createPreviewDisplayData({
candidate,
fixture,
preview: props.preview,
options: props.optionsSnapshot,
fixtureError: t("fixtureError"),
});
const previewSummary = getPreviewSummary(props.preview.status);
const rendererResetKey = JSON.stringify({
template: candidate?.template,
requests: candidate?.requests,
fixture,
data: props.preview.data,
status: props.preview.status,
sessionId: props.preview.session?.id,
options: props.optionsSnapshot,
});
const previewResult =
props.preview.outcome === "success"
? {
title: t("result.success.title"),
description: t("result.success.description", previewSummary),
}
: props.preview.outcome === "error"
? {
title: t("result.error.title"),
description: t("result.error.description", previewSummary),
}
: props.preview.outcome === "loading"
? { title: t("result.loading.title"), description: t("result.loading.description") }
: { title: "", description: "" };
return (
<Card withBorder p="md">
<Stack gap="md">
<Group grow>
<SegmentedControl
size="xs"
value={props.size}
onChange={props.onSizeChange}
data={[
{ value: "compact", label: t("size.compact") },
{ value: "standard", label: t("size.standard") },
{ value: "wide", label: t("size.wide") },
]}
/>
<SegmentedControl
size="xs"
value={props.theme}
onChange={(value) => props.onThemeChange(value as "light" | "dark")}
data={[
{ value: "light", label: t("theme.light") },
{ value: "dark", label: t("theme.dark") },
]}
/>
</Group>
<SegmentedControl
size="xs"
value={fixture}
disabled={!candidate}
onChange={(value) => setFixture(value as typeof fixture)}
data={[
{ value: "live", label: t("fixture.live") },
{ value: "loading", label: t("fixture.loading") },
{ value: "empty", label: t("fixture.empty") },
{ value: "error", label: t("fixture.error") },
]}
/>
{candidate && (
<PreviewResult
outcome={props.preview.outcome}
title={previewResult.title}
description={previewResult.description}
/>
)}
<Tabs defaultValue="widget" keepMounted={false}>
<Tabs.List grow>
{(["widget", "data", "options", "actions", "diagnostics"] as const).map((tab) => (
<Tabs.Tab key={tab} value={tab}>
<Group gap={6} wrap="nowrap" justify="center">
{t(`tab.${tab}`)}
{tab === "data" && (
<PreviewStatusDot outcome={props.preview.outcome} label={t(`status.${props.preview.outcome}`)} />
)}
</Group>
</Tabs.Tab>
))}
</Tabs.List>
<Tabs.Panel value="widget" pt="sm">
<Box className={classes.previewCanvas}>
<MantineProvider forceColorScheme={props.theme}>
<Paper withBorder p="sm" h={360} w={widths[props.size] ?? 480} maw="100%" style={{ overflow: "auto" }}>
{displayData ? (
<PreviewErrorBoundary
title={errorBoundaryMessages("title")}
description={t("containedFailures")}
retryLabel={errorBoundaryMessages("retry")}
resetKeys={[rendererResetKey]}
>
<CustomJsxDisplay data={displayData} />
</PreviewErrorBoundary>
) : (
<Alert color="yellow">{t("invalid")}</Alert>
)}
</Paper>
</MantineProvider>
</Box>
</Tabs.Panel>
<Tabs.Panel value="data" pt="sm">
<CodeEditor
id="preview-data"
label={t("requestData")}
language="json"
value={JSON.stringify({ data: props.preview.data, status: props.preview.status }, null, 2)}
onChange={() => undefined}
/>
</Tabs.Panel>
<Tabs.Panel value="options" pt="sm">
<JsonPreviewEditor
id="preview-options"
label={t("instanceOptions")}
value={props.optionsSnapshot}
onChange={props.onOptionsChange}
/>
</Tabs.Panel>
<Tabs.Panel value="actions" pt="sm">
<Stack gap="sm">
<Switch
label={t("liveActions")}
description={t("liveActionsDescription")}
checked={props.preview.session?.liveActions ?? false}
disabled={!props.preview.session || liveActionsMutation.isPending}
onChange={(event) => {
if (!props.preview.session) return;
const enabled = event.currentTarget.checked;
liveActionsMutation.mutate(
{ sessionId: props.preview.session.id, enabled },
{
onSuccess: () => props.onLiveActionsChange(enabled),
onError: (error) => showErrorNotification({ title: t("liveActions"), message: error.message }),
},
);
}}
/>
{Object.entries(candidate?.requests ?? {})
.filter(([, request]) => request.kind === "action")
.map(([id, request]) => (
<PreviewActionControl key={id} request={{ id, ...request }} sessionId={props.preview.session?.id} />
))}
{Object.values(candidate?.requests ?? {}).every((request) => request.kind !== "action") && (
<Text size="sm" c="dimmed">
{t("noActions")}
</Text>
)}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="diagnostics" pt="sm">
<Stack gap="xs">
{props.preview.session ? (
<Badge color="green">{t("sessionActive")}</Badge>
) : (
<Badge color="gray">{t("simulationPending")}</Badge>
)}
<Text size="sm" c="dimmed">
{t("containedFailures")}
</Text>
<Text size="sm" fw={600}>
{t("validation.title")}
</Text>
{props.validationIssues.length === 0 ? (
<Alert color="green">{t("validation.ready")}</Alert>
) : (
props.validationIssues.map((issue, index) => (
<Alert key={`${issue.path ?? "widget"}-${index}`} color="red" title={issue.path}>
{issue.message}
</Alert>
))
)}
<Text size="sm" fw={600}>
{t("accessibility.title")}
</Text>
{!candidate ? (
<Alert color="gray">{t("accessibility.pending")}</Alert>
) : accessibilityIssues.length === 0 ? (
<Alert color="green">{t("accessibility.ready")}</Alert>
) : (
accessibilityIssues.map((issue) => (
<Alert key={issue} color="yellow">
{t(`accessibility.${issue}`)}
</Alert>
))
)}
{props.preview.session && (
<CodeEditor
id="preview-journal"
label={t("journal")}
language="json"
value={JSON.stringify(journalQuery.data ?? [], null, 2)}
onChange={() => undefined}
/>
)}
</Stack>
</Tabs.Panel>
</Tabs>
</Stack>
</Card>
);
}
@@ -0,0 +1,54 @@
"use client";
import { Alert, Box, Text, Tooltip } from "@mantine/core";
import { isRecord } from "./_custom-widget-form-utils";
export type PreviewOutcome = "idle" | "loading" | "success" | "error";
export function PreviewResult({
outcome,
title,
description,
}: {
outcome: PreviewOutcome;
title: string;
description: string;
}) {
if (outcome === "idle") return null;
return (
<Alert color={outcome === "error" ? "red" : outcome === "success" ? "green" : "yellow"}>
<Text size="sm" fw={600}>
{title}
</Text>
<Text size="sm">{description}</Text>
</Alert>
);
}
export function PreviewStatusDot({ outcome, label }: { outcome: PreviewOutcome; label: string }) {
const colors = { idle: "gray", loading: "yellow", success: "green", error: "red" } as const;
return (
<Tooltip label={label}>
<Box
component="output"
aria-label={label}
w={8}
h={8}
bg={`var(--mantine-color-${colors[outcome]}-6)`}
style={{ borderRadius: "50%", flexShrink: 0 }}
/>
</Tooltip>
);
}
export function getPreviewSummary(status: Record<string, unknown>) {
return Object.values(status).reduce<{ succeeded: number; failed: number }>(
(summary, value) => {
if (isRecord(value) && value.ok === false) summary.failed += 1;
else summary.succeeded += 1;
return summary;
},
{ succeeded: 0, failed: 0 },
);
}
@@ -1,277 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { Card, Stack, Tabs } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type {
CustomWidgetAuthType,
CustomWidgetDisplayType,
CustomWidgetMethod,
CustomWidgetSecretKind,
DisplayConfig,
} from "@homarr/custom-widgets/core";
import { extractDisplayData } from "@homarr/custom-widgets/core";
import {
analyzeJsxTemplate,
getPreviewLegacyMethods,
getPreviewNamedRequests,
PreviewDiagnosticsPanel,
PreviewErrorBoundary,
PreviewHeader,
PreviewRequestPanel,
PreviewResponsePanel,
redactPreviewUrl,
} from "@homarr/custom-widgets/workbench";
import { CUSTOM_JSX_METHOD_COLORS } from "@homarr/widgets/custom-api/custom-jsx-display";
import { useScopedI18n } from "@homarr/translation/client";
import { PreviewWidgetPanel } from "./_preview-widget-panel";
export interface PreviewInput {
url: string;
method: CustomWidgetMethod;
authType: CustomWidgetAuthType;
headerName?: string;
requestBody?: string;
displayType: CustomWidgetDisplayType;
displayConfig: DisplayConfig;
secrets: Array<{ kind: CustomWidgetSecretKind; value: string }>;
definitionId?: string;
}
export interface PreviewFetchResult {
success: boolean;
error?: string;
responseInfo: { status: number; statusText: string } | null;
rawResponse: string | null;
previewSession?: { id: string; expiresAt: number; liveActions: boolean } | null;
}
interface CustomWidgetPreviewProps {
getFormValues: () => PreviewInput;
formValues: PreviewInput;
fetchResult: PreviewFetchResult | null;
cachedJson: unknown;
onTest: () => void;
isTesting: boolean;
isSampleStale?: boolean;
testError?: string | null;
onInsertDataPath?: (path: string) => void;
onSetPreviewLiveActions?: (enabled: boolean) => void;
isUpdatingPreviewActions?: boolean;
onSampleDataChange?: (value: unknown) => void;
}
const methodColor = (method: string) => CUSTOM_JSX_METHOD_COLORS[method] ?? "gray";
export function CustomWidgetPreview(props: CustomWidgetPreviewProps) {
const t = useScopedI18n("customWidget.preview");
return (
<PreviewErrorBoundary
title={t("errorBoundary.title")}
description={t("errorBoundary.description")}
retryLabel={t("errorBoundary.retry")}
>
<CustomWidgetPreviewContent {...props} />
</PreviewErrorBoundary>
);
}
function CustomWidgetPreviewContent({
getFormValues,
formValues,
fetchResult,
cachedJson,
onTest,
isTesting,
isSampleStale = false,
testError,
onInsertDataPath,
onSetPreviewLiveActions,
isUpdatingPreviewActions = false,
onSampleDataChange,
}: CustomWidgetPreviewProps) {
const t = useScopedI18n("customWidget");
const editorT = useScopedI18n("customWidget.editor");
const [previewSize, setPreviewSize] = useState("standard");
const displayConfig = formValues.displayConfig as DisplayConfig & Record<string, unknown>;
const previewSessionId = fetchResult?.previewSession?.id ?? "";
const journalQuery = clientApi.customWidget.previewJournal.useQuery(
{ sessionId: previewSessionId },
{ enabled: previewSessionId.length > 0, retry: false, refetchInterval: 1_000 },
);
const displayData = useMemo((): Record<string, unknown> | null => {
const { displayType } = formValues;
if (displayType === "actionButton") {
return extractDisplayData(null, displayType, displayConfig) as Record<string, unknown>;
}
if (!fetchResult?.success || cachedJson == null) return null;
const extracted = extractDisplayData(cachedJson, displayType, displayConfig) as Record<string, unknown>;
return displayType === "customJsx" && fetchResult.previewSession
? {
...extracted,
previewSessionId: fetchResult.previewSession.id,
previewLiveActions: fetchResult.previewSession.liveActions,
}
: extracted;
}, [fetchResult, cachedJson, formValues, displayConfig]);
const namedRequests = useMemo(() => getPreviewNamedRequests(displayConfig), [displayConfig]);
const httpMethods = useMemo(() => {
if (formValues.displayType !== "customJsx") return [];
if (namedRequests.length > 0) return [...new Set(namedRequests.map((request) => request.method))];
return getPreviewLegacyMethods((displayConfig.template as string) ?? "");
}, [formValues.displayType, displayConfig.template, namedRequests]);
const hasNamedActions = namedRequests.some((request) => request.kind === "action");
const templateDiagnostics = useMemo(
() =>
formValues.displayType === "customJsx"
? analyzeJsxTemplate((displayConfig.template as string) ?? "", {
apiVersion: displayConfig.jsxApiVersion === 2 ? 2 : 1,
requestIds: namedRequests.map((request) => request.id),
})
: [],
[displayConfig, formValues.displayType, namedRequests],
);
const handleTest = () => {
const values = getFormValues();
if (!values.url || values.method !== "GET") return;
onTest();
};
return (
<Card withBorder p="lg">
<Stack gap="md">
<PreviewHeader
method={formValues.method}
url={formValues.url}
methods={httpMethods}
hasNamedActions={hasNamedActions}
hasPreviewSession={Boolean(fetchResult?.previewSession)}
liveActions={fetchResult?.previewSession?.liveActions ?? false}
isUpdatingLiveActions={isUpdatingPreviewActions}
isTesting={isTesting}
isSampleStale={isSampleStale}
testError={testError}
responseError={fetchResult?.success === false ? fetchResult.error : undefined}
responseStatus={fetchResult?.success === false ? fetchResult.responseInfo : undefined}
onTest={handleTest}
onSetLiveActions={onSetPreviewLiveActions}
methodColor={methodColor}
messages={{
title: t("preview.title"),
interactive: t("preview.capabilities.interactive"),
capabilitiesTitle: t("preview.capabilities.title"),
capabilitiesDescription: t("preview.capabilities.description"),
liveActions: t("preview.capabilities.liveActions"),
liveActionsDescription: t("preview.capabilities.liveActionsDescription"),
runTestFirst: t("preview.capabilities.runTestFirst"),
simulated: t("preview.capabilities.simulated"),
test: t("preview.test"),
mutationDisabled: t("preview.mutationDisabled"),
staleTitle: t("preview.stale.title"),
staleDescription: t("preview.stale.description"),
}}
/>
<Tabs defaultValue="widget" keepMounted={false}>
<Tabs.List grow>
<Tabs.Tab value="widget">{t("preview.tab.widget")}</Tabs.Tab>
<Tabs.Tab value="response">{t("preview.tab.response")}</Tabs.Tab>
<Tabs.Tab value="request">{t("preview.tab.request")}</Tabs.Tab>
<Tabs.Tab value="diagnostics">{t("preview.tab.diagnostics")}</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="widget" pt="sm">
<PreviewWidgetPanel
data={displayData}
displayType={formValues.displayType}
success={fetchResult?.success ?? false}
responseInfo={fetchResult?.responseInfo ?? null}
previewSize={previewSize}
onPreviewSizeChange={setPreviewSize}
/>
</Tabs.Panel>
<Tabs.Panel value="response" pt="sm">
<PreviewResponsePanel
value={cachedJson}
rawResponse={fetchResult?.rawResponse}
onInsertDataPath={onInsertDataPath}
onSampleDataChange={onSampleDataChange}
messages={{
empty: t("preview.response.empty"),
sampleHint: t("preview.response.sampleHint"),
editSample: t("preview.response.editSample"),
addSample: t("preview.response.addSample"),
copied: t("preview.response.copied"),
copy: t("preview.response.copy"),
sampleLabel: t("preview.response.sampleLabel"),
sampleDescription: t("preview.response.sampleDescription"),
invalidSample: t("preview.response.invalidSample"),
cancelSample: t("preview.response.cancelSample"),
applySample: t("preview.response.applySample"),
copyPath: t("preview.response.copyPath"),
pathCopied: t("preview.response.pathCopied"),
insertPath: t("preview.response.insertPath"),
openRaw: t("preview.rawResponse"),
}}
/>
</Tabs.Panel>
<Tabs.Panel value="request" pt="sm">
<PreviewRequestPanel
method={formValues.method}
authentication={formValues.authType}
endpoint={redactPreviewUrl(formValues.url)}
status={fetchResult?.responseInfo?.status}
namedRequests={namedRequests}
journal={journalQuery.data}
methodColor={methodColor}
messages={{
method: t("preview.request.method"),
authentication: t("preview.request.authentication"),
endpoint: t("preview.request.endpoint"),
status: t("preview.request.status"),
notRun: t("preview.request.notRun"),
named: t("preview.request.named", { count: namedRequests.length }),
journal: t("preview.request.journal"),
journalEmpty: t("preview.request.journalEmpty"),
simulated: t("preview.request.simulated"),
redacted: t("preview.request.redacted"),
permission: (permission) => t(`preview.request.permission.${permission}` as never),
duration: (duration) => t("preview.request.duration", { duration }),
}}
/>
</Tabs.Panel>
<Tabs.Panel value="diagnostics" pt="sm">
<PreviewDiagnosticsPanel
templateLength={(displayConfig.template as string | undefined)?.length ?? 0}
namedRequestCount={namedRequests.length}
methods={httpMethods.join(", ") || "GET"}
networkScope={(displayConfig.networkScope as string | undefined) ?? t("preview.diagnostics.legacy")}
diagnostics={templateDiagnostics}
messages={{
templateSize: t("preview.diagnostics.templateSize"),
characters: (count) => t("preview.diagnostics.characters", { count }),
namedRequests: t("preview.diagnostics.namedRequests"),
methods: t("preview.diagnostics.methods"),
networkScope: t("preview.diagnostics.networkScope"),
ready: t("preview.diagnostics.ready"),
diagnostic: (diagnostic) =>
`${diagnostic.line ? `${editorT("diagnostics.line", { line: diagnostic.line })}: ` : ""}${editorT(
`diagnostics.${diagnostic.code}` as never,
{ value: diagnostic.value ?? "" } as never,
)}`,
}}
/>
</Tabs.Panel>
</Tabs>
</Stack>
</Card>
);
}
@@ -0,0 +1,38 @@
import { renameCustomWidgetOption, renameCustomWidgetRequest } from "@homarr/custom-widgets/workbench";
import { applyDefinition } from "./_custom-widget-form-utils";
import type { buildDefinition, CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
interface RenameHandlersInput {
form: CustomWidgetWorkbenchForm;
candidate: ReturnType<typeof buildDefinition>;
invalidWidgetMessage: string;
}
export function createCustomWidgetRenameHandlers(input: RenameHandlersInput) {
const renameRequest = (currentId: string, nextId: string) => {
if (!input.candidate.success) {
input.form.setFieldError("requests", input.invalidWidgetMessage);
return;
}
try {
applyDefinition(input.form, renameCustomWidgetRequest(input.candidate.data, currentId, nextId));
input.form.clearFieldError("requests");
} catch (error) {
input.form.setFieldError("requests", error instanceof Error ? error.message : input.invalidWidgetMessage);
}
};
const renameOption = (currentName: string, nextName: string) => {
if (!input.candidate.success) {
input.form.setFieldError("options", input.invalidWidgetMessage);
return;
}
try {
applyDefinition(input.form, renameCustomWidgetOption(input.candidate.data, currentName, nextName));
input.form.clearFieldError("options");
} catch (error) {
input.form.setFieldError("options", error instanceof Error ? error.message : input.invalidWidgetMessage);
}
};
return { renameRequest, renameOption };
}
@@ -0,0 +1,228 @@
"use client";
import { useEffect, useState } from "react";
import { ActionIcon, Button, Group, Select, Stack, Text, TextInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { isRecord } from "./_custom-widget-form-utils";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
export function QueryValuesEditor({
value,
optionNames,
allowParams,
onChange,
}: {
value: Record<string, unknown>;
optionNames: string[];
allowParams: boolean;
onChange(value: Record<string, unknown>): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const entries = Object.entries(value);
const [nameErrors, setNameErrors] = useState<Record<number, string>>({});
return (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("queryValues")}
</Text>
{entries.map(([name, bound], index) => {
const kind =
isRecord(bound) && typeof bound.$option === "string"
? "option"
: isRecord(bound) && typeof bound.$param === "string"
? "param"
: "literal";
const current =
kind === "option"
? String((bound as Record<string, unknown>).$option)
: kind === "param"
? String((bound as Record<string, unknown>).$param)
: String(bound ?? "");
const commit = (nextName: string, nextKind: string, nextValue: string) =>
onChange(
Object.fromEntries(
entries.map(([key, child], i) =>
i === index
? [
nextName,
nextKind === "option"
? { $option: nextValue }
: nextKind === "param"
? { $param: nextValue }
: parseLiteral(nextValue),
]
: [key, child],
),
),
);
const sourceChoices = [
{ value: "literal", label: t("literal") },
{ value: "option", label: t("widgetOption") },
...(allowParams ? [{ value: "param", label: t("invocationParameter") }] : []),
];
return (
<Group key={index} align="end" wrap="wrap">
<CustomWidgetIdentifierInput
style={{ flex: "1 1 10rem" }}
label={t("queryKey")}
value={name}
error={nameErrors[index]}
onCommit={(nextName) => {
if (
!/^[A-Za-z][A-Za-z0-9_-]*$/u.test(nextName) ||
entries.some(([entryName], entryIndex) => entryIndex !== index && entryName === nextName)
) {
setNameErrors((currentErrors) => ({ ...currentErrors, [index]: t("queryKeyInvalid") }));
return;
}
setNameErrors((currentErrors) => {
const nextErrors = { ...currentErrors };
delete nextErrors[index];
return nextErrors;
});
commit(nextName, kind, current);
}}
/>
<Select
style={{ flex: "1 1 10rem" }}
label={t("valueSource")}
data={sourceChoices}
value={kind}
allowDeselect={false}
onChange={(nextKind) => commit(name, nextKind ?? "literal", current)}
/>
{kind === "option" ? (
<Select
style={{ flex: "1 1 10rem" }}
label={t("option")}
data={optionNames}
value={current}
searchable
onChange={(next) => commit(name, kind, next ?? "")}
/>
) : (
<TextInput
style={{ flex: "1 1 10rem" }}
label={kind === "param" ? t("parameterName") : t("value")}
value={current}
onChange={(event) => commit(name, kind, event.currentTarget.value)}
/>
)}
<ActionIcon
type="button"
color="red"
variant="subtle"
aria-label={t("removeQueryValue")}
onClick={() => onChange(Object.fromEntries(entries.filter((_, i) => i !== index)))}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
);
})}
<Button
type="button"
size="compact-sm"
variant="subtle"
leftSection={<IconPlus size={14} />}
onClick={() => onChange({ ...value, [`value${entries.length + 1}`]: "" })}
>
{t("addQueryValue")}
</Button>
</Stack>
);
}
export function RequestBodyEditor({
id,
value,
optionNames,
allowParams,
onChange,
}: {
id: string;
value: unknown;
optionNames: string[];
allowParams: boolean;
onChange(value: unknown): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const serialized = value === undefined ? "" : JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized);
const [error, setError] = useState<string>();
const [optionName, setOptionName] = useState<string | null>(optionNames[0] ?? null);
const [parameterName, setParameterName] = useState("");
const [insertion, setInsertion] = useState({ text: "", key: 0 });
useEffect(() => setDraft(serialized), [serialized]);
return (
<Stack gap="xs">
<CodeEditor
id={`${id}-request-body-editor`}
label={t("jsonBody")}
description={t("jsonBodyDescription")}
language="json"
value={draft}
height="160px"
error={error}
insertText={insertion.text}
insertKey={insertion.key}
onChange={(next) => {
setDraft(next);
try {
onChange(next.trim() ? (JSON.parse(next) as unknown) : undefined);
setError(undefined);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("invalidJson"));
}
}}
/>
<Group align="end" wrap="wrap">
<Select label={t("option")} data={optionNames} value={optionName} searchable onChange={setOptionName} />
<Button
type="button"
variant="light"
disabled={!optionName}
onClick={() =>
optionName &&
setInsertion((current) => ({ text: JSON.stringify({ $option: optionName }), key: current.key + 1 }))
}
>
{t("insertOptionReference")}
</Button>
{allowParams && (
<>
<TextInput
label={t("parameterName")}
value={parameterName}
onChange={(event) => setParameterName(event.currentTarget.value)}
/>
<Button
type="button"
variant="light"
disabled={!parameterName.trim()}
onClick={() =>
setInsertion((current) => ({
text: JSON.stringify({ $param: parameterName.trim() }),
key: current.key + 1,
}))
}
>
{t("insertParameterReference")}
</Button>
</>
)}
</Group>
</Stack>
);
}
function parseLiteral(value: string): string | number | boolean {
if (value === "true") return true;
if (value === "false") return false;
if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/u.test(value.trim())) return Number(value);
return value;
}
@@ -0,0 +1,232 @@
"use client";
import { Accordion, ActionIcon, Button, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { isRecord, parseJson } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { QueryValuesEditor, RequestBodyEditor } from "./_custom-widget-request-value-editors";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"];
export function CustomWidgetRequestsEditor({
form,
onRename,
}: {
form: CustomWidgetWorkbenchForm;
onRename(currentId: string, nextId: string): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const parsed = parseJson(form.values.requests);
const entries = isRecord(parsed) ? Object.entries(parsed) : [];
const sources = parseJson(form.values.sources);
const sourceIds = isRecord(sources) ? Object.keys(sources) : ["default"];
const commit = (next: Array<[string, unknown]>) =>
form.setFieldValue("requests", JSON.stringify(Object.fromEntries(next), null, 2));
const update = (index: number, id: string, request: Record<string, unknown>) =>
commit(entries.map((entry, i) => (i === index ? [id, request] : entry)));
const remove = (index: number) => commit(entries.filter((_, i) => i !== index));
const move = (index: number, direction: -1 | 1) => {
const next = [...entries];
const target = index + direction;
if (!next[index] || !next[target]) return;
[next[index], next[target]] = [next[target], next[index]];
commit(next);
};
const add = () => {
let suffix = entries.length + 1;
while (entries.some(([id]) => id === `request-${suffix}`)) suffix += 1;
commit([...entries, [`request-${suffix}`, { path: "/api/data" }]]);
};
return (
<Stack gap="sm">
{entries.map(([id, rawRequest], index) => {
const request = isRecord(rawRequest) ? rawRequest : {};
const kind = request.kind === "action" ? "action" : "query";
const trigger = request.trigger === "manual" ? "manual" : "load";
return (
<Fieldset key={index} legend={id || t("request", { count: index + 1 })}>
<Stack gap="sm">
<Group grow align="start">
<CustomWidgetIdentifierInput
label={t("requestId")}
value={id}
error={form.errors.requests}
onCommit={(value) => onRename(id, value)}
/>
<Select
label={t("type")}
data={["query", "action"]}
value={kind}
allowDeselect={false}
onChange={(value) =>
update(index, id, {
...request,
kind: value,
...(value === "action" ? { trigger: "manual" } : {}),
})
}
/>
<Select
label={t("method")}
data={methods}
value={typeof request.method === "string" ? request.method : "GET"}
allowDeselect={false}
onChange={(value) =>
update(index, id, {
...request,
method: value,
...(value === "DELETE" ? { permission: "full" } : {}),
})
}
/>
</Group>
<Group grow align="start">
<Select
label={t("source")}
data={sourceIds}
value={typeof request.source === "string" ? request.source : "default"}
allowDeselect={false}
onChange={(value) => update(index, id, { ...request, source: value })}
/>
{kind === "query" && (
<Select
label={t("run")}
data={[
{ value: "load", label: t("runLoad") },
{ value: "manual", label: t("runManual") },
]}
value={trigger}
allowDeselect={false}
onChange={(value) => update(index, id, { ...request, trigger: value })}
/>
)}
<Select
label={t("permission")}
data={["view", "modify", "full"]}
value={
typeof request.permission === "string" ? request.permission : kind === "action" ? "modify" : "view"
}
allowDeselect={false}
onChange={(value) => update(index, id, { ...request, permission: value })}
/>
</Group>
<TextInput
label={t("path")}
description={t("pathDescription")}
value={typeof request.path === "string" ? request.path : ""}
onChange={(event) => update(index, id, { ...request, path: event.currentTarget.value })}
/>
<Accordion variant="contained">
<Accordion.Item value="values">
<Accordion.Control>{t("requestDetails")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<QueryValuesEditor
value={isRecord(request.query) ? request.query : {}}
optionNames={Object.keys(
isRecord(parseJson(form.values.options))
? (parseJson(form.values.options) as Record<string, unknown>)
: {},
)}
allowParams={trigger === "manual" || kind === "action"}
onChange={(query) => update(index, id, { ...request, query })}
/>
<RequestBodyEditor
id={id}
value={request.body}
optionNames={Object.keys(
isRecord(parseJson(form.values.options))
? (parseJson(form.values.options) as Record<string, unknown>)
: {},
)}
allowParams={trigger === "manual" || kind === "action"}
onChange={(body) => update(index, id, { ...request, body })}
/>
{kind === "action" && (
<TextInput
label={t("confirmation")}
value={typeof request.confirmation === "string" ? request.confirmation : ""}
onChange={(event) =>
update(index, id, { ...request, confirmation: event.currentTarget.value || undefined })
}
/>
)}
{kind === "action" && (
<TextInput
label={t("invalidation")}
description={t("invalidationDescription")}
value={Array.isArray(request.invalidates) ? request.invalidates.join(", ") : ""}
onChange={(event) =>
update(index, id, {
...request,
invalidates: event.currentTarget.value
.split(",")
.map((value) => value.trim())
.filter(Boolean),
})
}
/>
)}
<Switch
label={t("noAuth")}
checked={request.auth === "none"}
onChange={(event) =>
update(index, id, { ...request, auth: event.currentTarget.checked ? "none" : "inherit" })
}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Group justify="space-between">
<Group gap="xs">
<ActionIcon
type="button"
variant="subtle"
aria-label={t("moveRequestUp")}
disabled={index === 0}
onClick={() => move(index, -1)}
>
<IconArrowUp size={16} />
</ActionIcon>
<ActionIcon
type="button"
variant="subtle"
aria-label={t("moveRequestDown")}
disabled={index === entries.length - 1}
onClick={() => move(index, 1)}
>
<IconArrowDown size={16} />
</ActionIcon>
</Group>
<Button
type="button"
color="red"
variant="subtle"
leftSection={<IconTrash size={16} />}
onClick={() => remove(index)}
>
{t("removeRequest")}
</Button>
</Group>
</Stack>
</Fieldset>
);
})}
<Button type="button" variant="light" leftSection={<IconPlus size={16} />} onClick={add}>
{t("addRequest")}
</Button>
{form.errors.requests && (
<Text c="red" size="xs">
{form.errors.requests}
</Text>
)}
</Stack>
);
}
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { extractCustomWidgetSaveIssues } from "./_custom-widget-save-errors";
describe("extractCustomWidgetSaveIssues", () => {
it("extracts every flattened server validation message", () => {
expect(
extractCustomWidgetSaveIssues({
data: {
zodError: {
fieldErrors: {
template: [
"UNKNOWN_COMPONENT: 'Radio.Group' is not available. Did you mean 'RadioGroup'?",
"UNKNOWN_COMPONENT: 'Radio.Card' is not available. Did you mean 'RadioCard'?",
],
},
},
},
}),
).toEqual([
{ path: "template", message: "UNKNOWN_COMPONENT: 'Radio.Group' is not available. Did you mean 'RadioGroup'?" },
{ path: "template", message: "UNKNOWN_COMPONENT: 'Radio.Card' is not available. Did you mean 'RadioCard'?" },
]);
});
it("extracts paths and messages from a serialized Zod issue list", () => {
const message = JSON.stringify([
{
code: "custom",
message: "UNKNOWN_COMPONENT: 'Radio.Indicator' is not available. Did you mean 'RadioIndicator'?",
path: ["template"],
},
]);
expect(extractCustomWidgetSaveIssues({ message })).toEqual([
{
path: "template",
message: "UNKNOWN_COMPONENT: 'Radio.Indicator' is not available. Did you mean 'RadioIndicator'?",
},
]);
});
it("preserves array indexes in serialized Zod issue paths", () => {
const message = JSON.stringify([
{
code: "custom",
message: "A load parameter requires an explicit binding.",
path: ["requests", "containers", "path"],
},
]);
expect(extractCustomWidgetSaveIssues({ message })).toEqual([
{
path: "requests.containers.path",
message: "A load parameter requires an explicit binding.",
},
]);
});
it("extracts root validation errors from the tRPC error shape", () => {
expect(
extractCustomWidgetSaveIssues({
shape: {
data: {
zodError: {
formErrors: ["The widget definition is invalid."],
fieldErrors: {},
},
},
},
}),
).toEqual([{ message: "The widget definition is invalid." }]);
});
});
@@ -0,0 +1,64 @@
export interface CustomWidgetSaveIssue {
path?: string;
message: string;
}
const MAX_SAVE_ISSUES = 20;
export function extractCustomWidgetSaveIssues(error: unknown): CustomWidgetSaveIssue[] {
const record = asRecord(error);
const data = asRecord(record?.data) ?? asRecord(asRecord(record?.shape)?.data);
const zodError = asRecord(data?.zodError);
const fieldErrors = asRecord(zodError?.fieldErrors);
const flattened = fieldErrors
? Object.entries(fieldErrors).flatMap(([path, value]) =>
Array.isArray(value)
? value.flatMap((message) => (typeof message === "string" ? [{ path, message }] : []))
: [],
)
: [];
const formErrors = Array.isArray(zodError?.formErrors)
? zodError.formErrors.flatMap((message) => (typeof message === "string" ? [{ message }] : []))
: [];
if (flattened.length > 0 || formErrors.length > 0) return uniqueIssues([...formErrors, ...flattened]);
const message = typeof record?.message === "string" ? record.message : null;
if (!message) return [];
const parsed = parseZodIssueMessage(message);
return parsed.length > 0 ? uniqueIssues(parsed) : [{ message }];
}
function parseZodIssueMessage(message: string): CustomWidgetSaveIssue[] {
try {
const parsed = JSON.parse(message) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.flatMap((candidate) => {
const issue = asRecord(candidate);
if (typeof issue?.message !== "string") return [];
const path = Array.isArray(issue.path)
? issue.path
.filter((part) => typeof part === "string" || typeof part === "number")
.map(String)
.join(".")
: undefined;
return [{ path: path || undefined, message: issue.message }];
});
} catch {
return [];
}
}
function uniqueIssues(issues: CustomWidgetSaveIssue[]) {
return issues
.filter(
(issue, index) =>
issues.findIndex((candidate) => candidate.path === issue.path && candidate.message === issue.message) === index,
)
.slice(0, MAX_SAVE_ISSUES);
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
@@ -0,0 +1,37 @@
"use client";
import { Alert, Button, CopyButton, Text, Textarea } from "@mantine/core";
import { IconAlertCircle, IconCheck, IconCopy } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
export function CustomWidgetSaveIssuesAlert({ issues }: { issues: CustomWidgetSaveIssue[] }) {
const t = useScopedI18n("customWidget.workbench.saveError");
if (issues.length === 0) return null;
const value = issues.map((issue) => `${issue.path ? `${issue.path}: ` : ""}${issue.message}`).join("\n");
return (
<Alert color="red" title={t("title")} icon={<IconAlertCircle size={18} />}>
<Text size="sm" mb="xs">
{t("description")}
</Text>
<Textarea value={value} readOnly autosize minRows={2} maxRows={10} aria-label={t("errors")} mb="xs" />
<CopyButton value={value} timeout={2_000}>
{({ copied, copy }) => (
<Button
type="button"
size="compact-sm"
variant="light"
color={copied ? "green" : "red"}
leftSection={copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
onClick={copy}
>
{copied ? t("copied") : t("copy")}
</Button>
)}
</CopyButton>
</Alert>
);
}
@@ -0,0 +1,278 @@
"use client";
import { Accordion, Button, Fieldset, Group, PasswordInput, Select, Stack, Text, TextInput } from "@mantine/core";
import { IconKey, IconPlus, IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
const secretFields: Record<string, Array<{ kind: "apiKey" | "username" | "password" }>> = {
bearer: [{ kind: "apiKey" }],
basic: [{ kind: "username" }, { kind: "password" }],
apiKeyHeader: [{ kind: "apiKey" }],
apiKeyQuery: [{ kind: "apiKey" }],
};
const sourceUrlErrorKeys: Record<CustomWidgetSourceUrlIssue, `baseUrlError.${CustomWidgetSourceUrlIssue}`> = {
invalid: "baseUrlError.invalid",
protocol: "baseUrlError.protocol",
credentials: "baseUrlError.credentials",
queryOrFragment: "baseUrlError.queryOrFragment",
};
export function CustomWidgetSourcesEditor({
form,
definitionId,
}: {
form: CustomWidgetWorkbenchForm;
definitionId?: string;
}) {
const t = useScopedI18n("customWidget.workbench.sources");
const utils = clientApi.useUtils();
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
const sources = parseSources(form.values.sources);
const update = (index: number, changes: Partial<CustomWidgetSource> & { id?: string }) => {
const previousId = sources[index]?.id;
const nextId = changes.id ?? previousId;
if (nextId && sources.some((source, sourceIndex) => sourceIndex !== index && source.id === nextId)) {
form.setFieldError("sources", t("duplicateSourceId"));
return;
}
if (form.errors.sources === t("duplicateSourceId")) form.clearFieldError("sources");
const sourceEntries = sources.map(({ id, ...source }) => [id, source] as const);
form.setFieldValue(
"sources",
JSON.stringify(
Object.fromEntries(
sourceEntries.map(([id, source], i) => [
i === index ? nextId : id,
i === index ? { ...source, ...changes, id: undefined } : source,
]),
),
null,
2,
),
);
if (previousId && nextId && previousId !== nextId) {
const parsedRequests = parseJson(form.values.requests);
const requests = isRecord(parsedRequests)
? Object.fromEntries(
Object.entries(parsedRequests).map(([id, request]) => [
id,
isRecord(request) && request.source === previousId ? { ...request, source: nextId } : request,
]),
)
: {};
form.setFieldValue("requests", JSON.stringify(requests, null, 2));
form.setFieldValue(
"secrets",
form.values.secrets.map((secret) =>
secret.sourceId === previousId ? { ...secret, sourceId: nextId } : secret,
),
);
}
};
const setAuth = (index: number, type: string) => {
const auth =
type === "apiKeyHeader" ? { type, name: "X-API-Key" } : type === "apiKeyQuery" ? { type, name: "api_key" } : type;
update(index, { auth: auth as CustomWidgetSource["auth"] });
};
const setSecret = (sourceId: string, kind: string, value: string) => {
const current = form.values.secrets.filter((secret) => !(secret.sourceId === sourceId && secret.kind === kind));
const existing = form.values.secrets.find((secret) => secret.sourceId === sourceId && secret.kind === kind);
form.setFieldValue("secrets", [...current, { sourceId, kind, value, hasValue: existing?.hasValue }]);
};
const addSource = () => {
const id = sources.length === 0 ? "default" : `source-${sources.length + 1}`;
form.setFieldValue(
"sources",
JSON.stringify(
Object.fromEntries([
...sources.map(({ id: sourceId, ...source }) => [sourceId, source] as const),
[
id,
{
name: t("newName", { count: sources.length + 1 }),
baseUrl: "https://example.com",
networkScope: sources.length === 0 ? "public" : "private",
auth: "none",
},
],
]),
null,
2,
),
);
};
const removeSource = (index: number) => {
const removedId = sources[index]?.id;
form.setFieldValue(
"sources",
JSON.stringify(
Object.fromEntries(sources.filter((_, i) => i !== index).map(({ id, ...source }) => [id, source])),
null,
2,
),
);
if (!removedId) return;
form.setFieldValue(
"requests",
JSON.stringify(
Object.fromEntries(
Object.entries(
isRecord(parseJson(form.values.requests))
? (parseJson(form.values.requests) as Record<string, unknown>)
: {},
).filter(([, request]) => !isRecord(request) || request.source !== removedId),
),
null,
2,
),
);
form.setFieldValue(
"secrets",
form.values.secrets.filter((secret) => secret.sourceId !== removedId),
);
};
const clearSecret = async (sourceId: string, kind: "apiKey" | "username" | "password") => {
if (!definitionId) return;
await clearSecretMutation.mutateAsync({ definitionId, sourceId, kind });
const current = form.values.secrets.filter((secret) => !(secret.sourceId === sourceId && secret.kind === kind));
form.setFieldValue("secrets", [...current, { sourceId, kind, value: "", hasValue: false }]);
await utils.customWidget.get.invalidate({ id: definitionId });
showSuccessNotification({ title: t("credentialRemoved"), message: t("credentialRemovedDescription") });
};
return (
<Stack gap="sm">
{sources.map((source, index) => {
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
return (
<Fieldset key={index} legend={index === 0 ? t("primary") : source.name}>
<Stack gap="sm">
<Group grow align="start">
<CustomWidgetIdentifierInput
label={t("id")}
value={source.id}
disabled={source.id === "default" || Boolean(definitionId)}
error={form.errors.sources}
onCommit={(value) => update(index, { id: value })}
/>
<TextInput
label={t("name")}
value={source.name}
onChange={(event) => update(index, { name: event.currentTarget.value })}
/>
</Group>
<TextInput
label={t("baseUrl")}
type="url"
value={source.baseUrl}
error={baseUrlIssue ? t(sourceUrlErrorKeys[baseUrlIssue]) : undefined}
onChange={(event) => update(index, { baseUrl: event.currentTarget.value })}
/>
<Group grow align="start">
<Select
label={t("networkScope")}
data={["public", "private", "loopback"]}
value={source.networkScope}
onChange={(value) =>
value && update(index, { networkScope: value as CustomWidgetSource["networkScope"] })
}
allowDeselect={false}
/>
<Select
label={t("authentication")}
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
value={typeof source.auth === "string" ? source.auth : source.auth.type}
onChange={(value) => setAuth(index, value ?? "none")}
allowDeselect={false}
/>
</Group>
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
<TextInput
label={t("headerName")}
value={source.auth.name}
onChange={(event) =>
update(index, { auth: { type: "apiKeyHeader", name: event.currentTarget.value } })
}
/>
)}
{typeof source.auth === "object" && source.auth.type === "apiKeyQuery" && (
<TextInput
label={t("queryParameter")}
value={source.auth.name}
onChange={(event) =>
update(index, { auth: { type: "apiKeyQuery", name: event.currentTarget.value } })
}
/>
)}
{(secretFields[typeof source.auth === "string" ? source.auth : source.auth.type] ?? []).map((field) => {
const secret = form.values.secrets.find(
(entry) => entry.sourceId === source.id && entry.kind === field.kind,
);
const Input = field.kind === "username" ? TextInput : PasswordInput;
return (
<Group key={field.kind} align="end" wrap="nowrap">
<Input
style={{ flex: 1 }}
label={t(`secret.${field.kind}`)}
value={secret?.value ?? ""}
placeholder={secret?.hasValue ? t("configured") : undefined}
leftSection={<IconKey size={15} />}
onChange={(event) => setSecret(source.id, field.kind, event.currentTarget.value)}
/>
{definitionId && secret?.hasValue && (
<Button
type="button"
color="red"
variant="subtle"
loading={clearSecretMutation.isPending}
onClick={() => void clearSecret(source.id, field.kind)}
>
{t("clear")}
</Button>
)}
</Group>
);
})}
{index > 0 && (
<Button
type="button"
color="red"
variant="subtle"
leftSection={<IconTrash size={16} />}
onClick={() => removeSource(index)}
>
{t("remove")}
</Button>
)}
</Stack>
</Fieldset>
);
})}
<Accordion variant="contained">
<Accordion.Item value="advanced">
<Accordion.Control>{t("advanced")}</Accordion.Control>
<Accordion.Panel>
<Button type="button" variant="light" leftSection={<IconPlus size={16} />} onClick={addSource}>
{t("add")}
</Button>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
{form.errors.sources && (
<Text c="red" size="xs">
{form.errors.sources}
</Text>
)}
</Stack>
);
}
@@ -1,39 +0,0 @@
export const listItemDefaults = {
mapping: { label: "", jsonPath: "$", unit: "" },
column: { header: "", jsonPath: "$" },
statGridItem: { label: "", jsonPath: "$", unit: "", color: "blue" },
progressBar: { label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" },
statusItem: { label: "", jsonPath: "$", goodValues: "online,true" },
countGridItem: { label: "", jsonPath: "$", unit: "" },
} as const;
export function cloneLast<T extends Record<string, unknown>>(items: T[], fallback: T): T {
return { ...(items.at(-1) ?? fallback) };
}
export const MANTINE_COLORS = [
"blue",
"teal",
"green",
"red",
"orange",
"yellow",
"violet",
"pink",
"cyan",
"grape",
"indigo",
"lime",
] as const;
export const REQUEST_MANIFEST_STARTER = `[
{
"id": "details",
"kind": "query",
"method": "GET",
"pathTemplate": "/api/details/{id}",
"parameters": { "id": "string" },
"auth": "inherit",
"minimumBoardPermission": "view",
"cacheTtlSeconds": 30
}
]`;
@@ -1,12 +0,0 @@
import type { useZodForm } from "@homarr/form";
import type { useScopedI18n } from "@homarr/translation/client";
import type { customWidgetFormSchema } from "@homarr/custom-widgets/workbench";
export type CustomWidgetFormInstance = ReturnType<typeof useZodForm<typeof customWidgetFormSchema>>;
export type CustomWidgetTranslator = ReturnType<typeof useScopedI18n<"customWidget">>;
export interface DisplayFieldsProps {
form: CustomWidgetFormInstance;
t: CustomWidgetTranslator;
previewJson: unknown;
}
@@ -1,178 +0,0 @@
import { ActionIcon, Box, Button, Group, NumberInput, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
import { cloneLast, listItemDefaults } from "./_display-field-shared";
import type { DisplayFieldsProps } from "./_display-field-types";
export function BasicDisplayFields({ form, t, previewJson }: DisplayFieldsProps) {
const dt = form.values.displayType;
if (dt === "singleValue") {
return (
<>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
description={t("field.jsonPathHint")}
required
placeholder={t("placeholder.jsonPath")}
{...form.getInputProps("jsonPath")}
/>
<TextInput
label={t("field.label")}
placeholder={t("placeholder.exampleLabel")}
{...form.getInputProps("label")}
/>
<TextInput label={t("field.unit")} placeholder={t("placeholder.exampleUnit")} {...form.getInputProps("unit")} />
<Group grow>
<Select
label={t("field.valueSize")}
data={["sm", "md", "lg", "xl"].map((v) => ({
value: v,
label: t(`sizeOption.${v}` as never),
}))}
{...form.getInputProps("valueSize")}
allowDeselect={false}
/>
<Select
label={t("field.labelPosition")}
data={["above", "below"].map((v) => ({
value: v,
label: t(`labelPositionOption.${v}` as never),
}))}
{...form.getInputProps("labelPosition")}
allowDeselect={false}
/>
</Group>
</>
);
}
if (dt === "keyValue") {
return (
<Stack gap="xs">
<Group grow>
<Select
label={t("field.kvLayout")}
data={["list", "grid"].map((v) => ({
value: v,
label: t(`layoutOption.${v}` as never),
}))}
{...form.getInputProps("kvLayout")}
allowDeselect={false}
/>
{form.values.kvLayout === "grid" && (
<NumberInput label={t("field.gridColumns")} min={1} max={3} {...form.getInputProps("kvColumns")} />
)}
</Group>
<Text size="sm" fw={500}>
{t("field.mappings")}
</Text>
{form.values.mappings.map((_m, i) => (
<Group key={i} align="end" wrap="nowrap">
<TextInput
label={t("field.label")}
placeholder={t("placeholder.exampleLabelShort")}
style={{ flex: 1 }}
{...form.getInputProps(`mappings.${i}.label`)}
/>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
placeholder={t("placeholder.jsonPath")}
{...form.getInputProps(`mappings.${i}.jsonPath`)}
/>
</Box>
<TextInput
label={t("field.unit")}
placeholder={t("placeholder.exampleUnit")}
style={{ width: 80 }}
{...form.getInputProps(`mappings.${i}.unit`)}
/>
<ActionIcon
type="button"
color="red"
variant="subtle"
onClick={() => form.removeListItem("mappings", i)}
disabled={form.values.mappings.length <= 1}
aria-label={t("action.removeMapping")}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() => form.insertListItem("mappings", cloneLast(form.values.mappings, listItemDefaults.mapping))}
>
{t("action.addMapping")}
</Button>
</Stack>
);
}
if (dt === "table") {
return (
<Stack gap="xs">
<JsonPathTreePicker
json={previewJson}
label={t("field.tablePath")}
description={t("field.tablePathHint")}
required
placeholder={t("placeholder.tablePath")}
{...form.getInputProps("tablePath")}
/>
<Group grow>
<Switch label={t("field.striped")} {...form.getInputProps("striped", { type: "checkbox" })} />
<Switch label={t("field.compact")} {...form.getInputProps("compact", { type: "checkbox" })} />
</Group>
<Text size="sm" fw={500}>
{t("field.columns")}
</Text>
{form.values.columns.map((_col, i) => (
<Group key={i} align="end" wrap="nowrap">
<TextInput
label={t("field.header")}
placeholder={t("placeholder.exampleHeader")}
style={{ flex: 1 }}
{...form.getInputProps(`columns.${i}.header`)}
/>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
placeholder={t("placeholder.columnJsonPath")}
{...form.getInputProps(`columns.${i}.jsonPath`)}
/>
</Box>
<ActionIcon
type="button"
color="red"
variant="subtle"
onClick={() => form.removeListItem("columns", i)}
disabled={form.values.columns.length <= 1}
aria-label={t("action.removeColumn")}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() => form.insertListItem("columns", cloneLast(form.values.columns, listItemDefaults.column))}
>
{t("action.addColumn")}
</Button>
</Stack>
);
}
return null;
}
@@ -1,97 +0,0 @@
import { Alert, SegmentedControl, Select, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { CUSTOM_JSX_STARTER } from "@homarr/custom-widgets/core";
import {
analyzeJsxTemplate,
analyzeRequestManifest,
CUSTOM_JSX_TEMPLATE_LIMIT,
parseRequestManifest,
} from "@homarr/custom-widgets/workbench";
import { CodeEditor } from "./_code-editor";
import { REQUEST_MANIFEST_STARTER } from "./_display-field-shared";
import type { DisplayFieldsProps } from "./_display-field-types";
export function CustomJsxDisplayFields({ form, t }: DisplayFieldsProps) {
const dt = form.values.displayType;
if (dt === "customJsx") {
const parsedRequests = parseRequestManifest(form.values.requestManifest);
const requestIds = parsedRequests
.map((request) => (request && typeof request === "object" && "id" in request ? request.id : null))
.filter((id): id is string => typeof id === "string");
const templateDiagnostics = analyzeJsxTemplate(form.values.template, {
apiVersion: form.values.jsxApiVersion === "2" ? 2 : 1,
requestIds,
});
const manifestDiagnostics =
form.values.jsxApiVersion === "2" ? analyzeRequestManifest(form.values.requestManifest) : [];
return (
<Stack gap="md">
<div>
<Text size="sm" fw={500} mb={4}>
{t("field.jsxApiVersion.label")}
</Text>
<SegmentedControl
fullWidth
value={form.values.jsxApiVersion}
onChange={(value) => form.setFieldValue("jsxApiVersion", value)}
data={[
{ value: "2", label: t("field.jsxApiVersion.v2") },
{ value: "1", label: t("field.jsxApiVersion.v1") },
]}
/>
</div>
{form.values.jsxApiVersion === "1" ? (
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />}>
<Text size="sm">{t("field.jsxApiVersion.legacyWarning")}</Text>
</Alert>
) : (
<Stack gap="sm">
<Select
label={t("field.networkScope.label")}
description={t(`field.networkScope.description.${form.values.networkScope}` as never)}
value={form.values.networkScope}
onChange={(value) => form.setFieldValue("networkScope", value ?? "public")}
data={(["public", "private", "loopback"] as const).map((value) => ({
value,
label: t(`field.networkScope.option.${value}` as never),
}))}
allowDeselect={false}
/>
<CodeEditor
id="custom-widget-request-manifest"
language="json"
label={t("field.requestManifest.label")}
description={t("field.requestManifest.description")}
value={form.values.requestManifest}
onChange={(value) => form.setFieldValue("requestManifest", value)}
placeholder={REQUEST_MANIFEST_STARTER}
starter={REQUEST_MANIFEST_STARTER}
diagnostics={manifestDiagnostics}
error={form.errors.requestManifest ? t("field.requestManifest.invalid") : undefined}
/>
</Stack>
)}
<CodeEditor
id="custom-widget-jsx-template"
language="jsx"
label={t("field.template.label")}
description={t("field.template.description")}
value={form.values.template}
onChange={(value) => form.setFieldValue("template", value)}
placeholder={CUSTOM_JSX_STARTER}
starter={CUSTOM_JSX_STARTER}
diagnostics={templateDiagnostics}
error={form.errors.template}
required
maxLength={CUSTOM_JSX_TEMPLATE_LIMIT}
/>
</Stack>
);
}
return null;
}
@@ -1,167 +0,0 @@
import { ActionIcon, Box, Button, Group, Select, Stack, Switch, Text, TextInput, NumberInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
import { cloneLast, listItemDefaults, MANTINE_COLORS } from "./_display-field-shared";
import type { DisplayFieldsProps } from "./_display-field-types";
export function MetricDisplayFields({ form, t, previewJson }: DisplayFieldsProps) {
const dt = form.values.displayType;
if (dt === "statGrid") {
return (
<Stack gap="xs">
<Group grow>
<NumberInput label={t("field.gridColumns")} min={1} max={4} {...form.getInputProps("statGridColumns")} />
<Select
label={t("field.cardStyle")}
data={["filled", "outline", "subtle"].map((v) => ({
value: v,
label: t(`cardStyleOption.${v}` as never),
}))}
{...form.getInputProps("cardStyle")}
allowDeselect={false}
/>
</Group>
<Text size="sm" fw={500}>
{t("field.items")}
</Text>
{form.values.statGridItems.map((_item, i) => (
<Group key={i} align="end" wrap="nowrap">
<TextInput
label={t("field.label")}
placeholder={t("placeholder.exampleLabelShort")}
style={{ flex: 1 }}
{...form.getInputProps(`statGridItems.${i}.label`)}
/>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
placeholder={t("placeholder.jsonPath")}
{...form.getInputProps(`statGridItems.${i}.jsonPath`)}
/>
</Box>
<TextInput
label={t("field.unit")}
placeholder={t("placeholder.exampleUnit")}
style={{ width: 80 }}
{...form.getInputProps(`statGridItems.${i}.unit`)}
/>
<Select
label={t("field.color")}
data={MANTINE_COLORS.map((c) => ({ value: c, label: c }))}
style={{ width: 100 }}
{...form.getInputProps(`statGridItems.${i}.color`)}
allowDeselect={false}
/>
<ActionIcon
type="button"
color="red"
variant="subtle"
onClick={() => form.removeListItem("statGridItems", i)}
disabled={form.values.statGridItems.length <= 1}
aria-label={t("action.removeItem")}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() =>
form.insertListItem("statGridItems", cloneLast(form.values.statGridItems, listItemDefaults.statGridItem))
}
>
{t("action.addItem")}
</Button>
</Stack>
);
}
if (dt === "progressBars") {
return (
<Stack gap="xs">
<Group grow>
<Switch label={t("field.showPercentage")} {...form.getInputProps("showPercentage", { type: "checkbox" })} />
<Select
label={t("field.barSize")}
data={["sm", "md", "lg"].map((v) => ({
value: v,
label: t(`sizeOption.${v}` as never),
}))}
{...form.getInputProps("barSize")}
allowDeselect={false}
/>
</Group>
<Text size="sm" fw={500}>
{t("field.bars")}
</Text>
{form.values.progressBars.map((_bar, i) => (
<Group key={i} align="end" wrap="nowrap">
<TextInput
label={t("field.label")}
placeholder={t("placeholder.exampleLabelShort")}
style={{ flex: 1 }}
{...form.getInputProps(`progressBars.${i}.label`)}
/>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.valuePath")}
placeholder={t("placeholder.valuePath")}
{...form.getInputProps(`progressBars.${i}.valuePath`)}
/>
</Box>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.maxPath")}
placeholder={t("placeholder.maxPath")}
{...form.getInputProps(`progressBars.${i}.maxPath`)}
/>
</Box>
<TextInput
label={t("field.unit")}
placeholder={t("placeholder.exampleUnit")}
style={{ width: 60 }}
{...form.getInputProps(`progressBars.${i}.unit`)}
/>
<Select
label={t("field.color")}
data={MANTINE_COLORS.map((c) => ({ value: c, label: c }))}
style={{ width: 100 }}
{...form.getInputProps(`progressBars.${i}.color`)}
allowDeselect={false}
/>
<ActionIcon
type="button"
color="red"
variant="subtle"
onClick={() => form.removeListItem("progressBars", i)}
disabled={form.values.progressBars.length <= 1}
aria-label={t("action.removeItem")}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() =>
form.insertListItem("progressBars", cloneLast(form.values.progressBars, listItemDefaults.progressBar))
}
>
{t("action.addBar")}
</Button>
</Stack>
);
}
return null;
}
@@ -1,198 +0,0 @@
import { ActionIcon, Box, Button, Group, NumberInput, Select, Stack, Text, TextInput } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
import { cloneLast, listItemDefaults, MANTINE_COLORS } from "./_display-field-shared";
import type { DisplayFieldsProps } from "./_display-field-types";
export function StatusDisplayFields({ form, t, previewJson }: DisplayFieldsProps) {
const dt = form.values.displayType;
if (dt === "statusIndicator") {
return (
<Stack gap="xs">
<Group grow>
<Select
label={t("field.kvLayout")}
data={["list", "grid"].map((v) => ({
value: v,
label: t(`layoutOption.${v}` as never),
}))}
{...form.getInputProps("statusLayout")}
allowDeselect={false}
/>
<Select
label={t("field.dotSize")}
data={["sm", "md", "lg"].map((v) => ({
value: v,
label: t(`sizeOption.${v}` as never),
}))}
{...form.getInputProps("dotSize")}
allowDeselect={false}
/>
</Group>
<Text size="sm" fw={500}>
{t("field.items")}
</Text>
{form.values.statusItems.map((_item, i) => (
<Group key={i} align="end" wrap="nowrap">
<TextInput
label={t("field.label")}
placeholder={t("placeholder.exampleLabelShort")}
style={{ flex: 1 }}
{...form.getInputProps(`statusItems.${i}.label`)}
/>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
placeholder={t("placeholder.jsonPath")}
{...form.getInputProps(`statusItems.${i}.jsonPath`)}
/>
</Box>
<TextInput
label={t("field.goodValues")}
placeholder={t("placeholder.goodValues")}
style={{ flex: 1 }}
{...form.getInputProps(`statusItems.${i}.goodValues`)}
/>
<ActionIcon
type="button"
color="red"
variant="subtle"
onClick={() => form.removeListItem("statusItems", i)}
disabled={form.values.statusItems.length <= 1}
aria-label={t("action.removeItem")}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() =>
form.insertListItem("statusItems", cloneLast(form.values.statusItems, listItemDefaults.statusItem))
}
>
{t("action.addItem")}
</Button>
</Stack>
);
}
if (dt === "countGrid") {
return (
<Stack gap="xs">
<Group grow>
<NumberInput label={t("field.gridColumns")} min={2} max={4} {...form.getInputProps("countGridColumns")} />
<Select
label={t("field.valueSize")}
data={["sm", "md", "lg"].map((v) => ({
value: v,
label: t(`sizeOption.${v}` as never),
}))}
{...form.getInputProps("countValueSize")}
allowDeselect={false}
/>
</Group>
<Text size="sm" fw={500}>
{t("field.items")}
</Text>
{form.values.countGridItems.map((_item, i) => (
<Group key={i} align="end" wrap="nowrap">
<TextInput
label={t("field.label")}
placeholder={t("placeholder.exampleLabelShort")}
style={{ flex: 1 }}
{...form.getInputProps(`countGridItems.${i}.label`)}
/>
<Box style={{ flex: 1 }}>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
placeholder={t("placeholder.jsonPath")}
{...form.getInputProps(`countGridItems.${i}.jsonPath`)}
/>
</Box>
<TextInput
label={t("field.unit")}
placeholder={t("placeholder.exampleUnit")}
style={{ width: 80 }}
{...form.getInputProps(`countGridItems.${i}.unit`)}
/>
<ActionIcon
type="button"
color="red"
variant="subtle"
onClick={() => form.removeListItem("countGridItems", i)}
disabled={form.values.countGridItems.length <= 1}
aria-label={t("action.removeItem")}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
<Button
type="button"
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() =>
form.insertListItem("countGridItems", cloneLast(form.values.countGridItems, listItemDefaults.countGridItem))
}
>
{t("action.addItem")}
</Button>
</Stack>
);
}
if (dt === "raw") {
return (
<>
<JsonPathTreePicker
json={previewJson}
label={t("field.jsonPath")}
description={t("field.jsonPathHint")}
placeholder={t("placeholder.jsonPath")}
{...form.getInputProps("rawJsonPath")}
/>
<NumberInput
label={t("field.maxHeight")}
min={50}
max={1000}
step={50}
{...form.getInputProps("rawMaxHeight")}
/>
</>
);
}
if (dt === "actionButton") {
return (
<Stack gap="sm">
<TextInput label={t("field.buttonLabel")} required {...form.getInputProps("buttonLabel")} />
<Select
label={t("field.buttonColor")}
data={MANTINE_COLORS.map((c) => ({ value: c, label: c }))}
{...form.getInputProps("buttonColor")}
allowDeselect={false}
/>
<TextInput
label={t("field.confirmText")}
description={t("field.confirmTextHint")}
{...form.getInputProps("confirmText")}
/>
<TextInput
label={t("field.successMessage")}
description={t("field.successMessageHint")}
{...form.getInputProps("successMessage")}
/>
</Stack>
);
}
return null;
}
@@ -1,89 +0,0 @@
import { SimpleGrid, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
import {
Icon123,
IconActivityHeartbeat,
IconBraces,
IconCode,
IconHash,
IconLayoutGrid,
IconListDetails,
IconPointer,
IconProgress,
IconTable,
} from "@tabler/icons-react";
import type { useScopedI18n } from "@homarr/translation/client";
import type { CustomWidgetDisplayType } from "@homarr/custom-widgets/core";
import { customWidgetDisplayDescriptors } from "@homarr/custom-widgets/core";
import { BasicDisplayFields } from "./_display-fields-basic";
import { CustomJsxDisplayFields } from "./_display-fields-jsx";
import { MetricDisplayFields } from "./_display-fields-metric";
import { StatusDisplayFields } from "./_display-fields-status";
import type { DisplayFieldsProps } from "./_display-field-types";
import classes from "./_custom-widget-form.module.css";
export const DISPLAY_TYPE_ICONS = {
number: Icon123,
keyValue: IconListDetails,
table: IconTable,
grid: IconLayoutGrid,
progress: IconProgress,
status: IconActivityHeartbeat,
count: IconHash,
json: IconBraces,
action: IconPointer,
code: IconCode,
} as const;
export function DisplayTypePicker({
value,
onChange,
t,
}: {
value: string;
onChange: (value: CustomWidgetDisplayType) => void;
t: ReturnType<typeof useScopedI18n<"customWidget">>;
}) {
return (
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="sm">
{customWidgetDisplayDescriptors.map((descriptor) => {
const displayType = descriptor.type;
const Icon = DISPLAY_TYPE_ICONS[descriptor.icon];
const selected = value === displayType;
return (
<UnstyledButton
type="button"
key={displayType}
className={classes.displayTypeOption}
data-selected={selected || undefined}
onClick={() => onChange(displayType)}
aria-pressed={selected}
>
<ThemeIcon variant={selected ? "filled" : "light"} size={42} radius="md">
<Icon size={22} />
</ThemeIcon>
<div className={classes.displayTypeCopy}>
<Text fw={600} size="sm">
{t(`displayType.${displayType}` as never)}
</Text>
<Text size="xs" c="dimmed" lineClamp={3}>
{t(`displayTypeDescription.${displayType}` as never)}
</Text>
</div>
</UnstyledButton>
);
})}
</SimpleGrid>
);
}
export function DisplayTypeFields(props: DisplayFieldsProps) {
return (
<>
<BasicDisplayFields {...props} />
<MetricDisplayFields {...props} />
<StatusDisplayFields {...props} />
<CustomJsxDisplayFields {...props} />
</>
);
}
@@ -0,0 +1,35 @@
"use client";
import { useEffect, useState } from "react";
import { CodeEditor } from "./_code-editor";
import { isRecord, parseJson } from "./_custom-widget-form-utils";
export function JsonPreviewEditor({
id,
label,
value,
onChange,
}: {
id: string;
label: string;
value: Record<string, unknown>;
onChange(value: Record<string, unknown>): void;
}) {
const serialized = JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized);
useEffect(() => setDraft(serialized), [serialized]);
return (
<CodeEditor
id={id}
label={label}
language="json"
value={draft}
onChange={(next) => {
setDraft(next);
const parsed = parseJson(next);
if (isRecord(parsed)) onChange(parsed);
}}
/>
);
}
@@ -1,99 +0,0 @@
"use client";
import { useMemo } from "react";
import { Badge, Button, Center, Code, Paper, ScrollArea, SegmentedControl, Stack, Text } from "@mantine/core";
import { IconPlayerPlay } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { createDisplayComponents } from "@homarr/widgets/custom-api/component";
interface PreviewWidgetPanelProps {
data: Record<string, unknown> | null;
displayType: string;
success: boolean;
responseInfo: { status: number; statusText: string } | null;
previewSize: string;
onPreviewSizeChange: (value: string) => void;
}
const HEIGHTS: Record<string, number> = { compact: 240, standard: 360, wide: 520 };
export function PreviewWidgetPanel({
data,
displayType,
success,
responseInfo,
previewSize,
onPreviewSizeChange,
}: PreviewWidgetPanelProps) {
const t = useScopedI18n("customWidget");
const height = HEIGHTS[previewSize] ?? HEIGHTS.standard;
return (
<Stack gap="xs">
<SegmentedControl
size="xs"
fullWidth
value={previewSize}
onChange={onPreviewSizeChange}
data={(["compact", "standard", "wide"] as const).map((value) => ({
value,
label: t(`preview.size.${value}` as never),
}))}
/>
{data && (displayType === "actionButton" || success) ? (
<>
{success && (
<Badge
size="xs"
color={responseInfo ? "green" : "gray"}
variant="light"
style={{ alignSelf: "flex-start" }}
>
{responseInfo ? `${responseInfo.status} ${responseInfo.statusText}` : t("preview.response.localSample")}
</Badge>
)}
<Paper withBorder p="xs" h={height} style={{ overflow: "auto" }}>
<PreviewDisplay data={data} />
</Paper>
</>
) : (
<Center h={height}>
<Stack gap={4} align="center" maw={280}>
<IconPlayerPlay size={22} color="var(--mantine-color-dimmed)" />
<Text size="sm" fw={500} ta="center">
{t("preview.empty.title")}
</Text>
<Text size="xs" c="dimmed" ta="center">
{t("preview.hint")}
</Text>
</Stack>
</Center>
)}
</Stack>
);
}
function PreviewDisplay({ data }: { data: Record<string, unknown> }) {
const t = useScopedI18n("customWidget");
const widgetT = useScopedI18n("widget.customApi");
const displayComponents = useMemo(() => createDisplayComponents(widgetT("openJson")), [widgetT]);
const dataType = data.type as string | undefined;
if (dataType === "actionButton") {
return (
<Center p="sm" h="100%">
<Button type="button" size="sm" color={(data.buttonColor as string) ?? "blue"} disabled>
{(data.buttonLabel as string) ?? t("preview.execute")}
</Button>
</Center>
);
}
const Component = dataType ? displayComponents[dataType] : undefined;
if (Component) return <Component data={data} />;
return (
<ScrollArea h="100%">
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(data, null, 2)}
</Code>
</ScrollArea>
);
}
@@ -1,87 +0,0 @@
"use client";
import { useCallback, useEffect } from "react";
import { Alert, Badge, Group, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { buildDisplayFormValues, getImportReview, parseCustomWidgetClipboard } from "@homarr/custom-widgets/core";
import { CUSTOM_WIDGET_AUTH_SECRET_FIELDS } from "@homarr/custom-widgets/workbench";
import { useConfirmModal } from "@homarr/modals";
import { showSuccessNotification } from "@homarr/notifications";
import type { CustomWidgetFormInstance, CustomWidgetTranslator } from "./_display-field-types";
interface UseCustomWidgetClipboardOptions {
enabled: boolean;
form: CustomWidgetFormInstance;
t: CustomWidgetTranslator;
onReplace: () => void;
}
export function useCustomWidgetClipboard({ enabled, form, t, onReplace }: UseCustomWidgetClipboardOptions) {
const { openConfirmModal } = useConfirmModal();
const queueReplacement = useCallback(
(widget: Record<string, unknown>) => {
const review = getImportReview(widget);
if (!review) return;
openConfirmModal({
title: t("importReview.replaceTitle"),
children: (
<Stack gap="sm">
<Text size="sm">{t("importReview.replaceDescription", { name: review.name, origin: review.origin })}</Text>
<Group gap={6}>
{review.methods.map((method) => (
<Badge key={method} color={{ DELETE: "red", GET: "blue" }[method] ?? "orange"}>
{method}
</Badge>
))}
</Group>
{review.hasActions && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
<Text size="sm">{t("importReview.actionWarning.description")}</Text>
</Alert>
)}
</Stack>
),
labels: { confirm: t("importReview.replaceConfirm"), cancel: t("importReview.cancel") },
onConfirm: () => {
const displayType = String(widget.displayType);
const displayConfig = widget.displayConfig as Record<string, unknown>;
const authType = typeof widget.authType === "string" ? widget.authType : "none";
const secrets = (CUSTOM_WIDGET_AUTH_SECRET_FIELDS[authType] ?? []).map((field) => {
const existing = form.values.secrets.find((secret) => secret.kind === field.kind);
return { kind: field.kind, value: existing?.value ?? "", hasValue: existing?.hasValue ?? false };
});
form.setValues({
...form.values,
name: String(widget.name),
description: typeof widget.description === "string" ? widget.description : "",
iconUrl: typeof widget.iconUrl === "string" ? widget.iconUrl : "",
url: String(widget.url),
authType,
headerName: typeof widget.headerName === "string" ? widget.headerName : "",
method: typeof widget.method === "string" ? widget.method : "GET",
requestBody: typeof widget.requestBody === "string" ? widget.requestBody : "",
...buildDisplayFormValues(displayType, displayConfig),
secrets,
});
onReplace();
showSuccessNotification({ title: t("action.import"), message: t("notification.pastedForReplacement") });
},
});
},
[form, onReplace, openConfirmModal, t],
);
useEffect(() => {
if (!enabled) return;
const handlePaste = (event: ClipboardEvent) => {
const widget = parseCustomWidgetClipboard(event.clipboardData?.getData("text/plain") ?? "");
if (!widget) return;
event.preventDefault();
queueReplacement(widget);
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [enabled, queueReplacement]);
}
@@ -0,0 +1,231 @@
"use client";
import { useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { useRouter } from "next/navigation";
import type { UseFormReturnType } from "@mantine/form";
import { clientApi } from "@homarr/api/client";
import {
customWidgetDefinitionSchema,
formatCustomWidgetImportIssues,
parseCustomWidgetAiResponse,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import {
applyDefinition,
buildDefinition,
getChangedSecrets,
getDefinitionDefaults,
getCustomWidgetPreviewOptionIssues,
isRecord,
loadPreviewQueries,
} from "./_custom-widget-form-utils";
import type { PreviewState } from "./_custom-widget-preview-panel";
import { extractCustomWidgetSaveIssues } from "./_custom-widget-save-errors";
import type { CustomWidgetSaveIssue } from "./_custom-widget-save-errors";
interface FormActionsInput {
mode: "create" | "edit";
definitionId?: string;
form: UseFormReturnType<CustomWidgetFormValues>;
candidate: ReturnType<typeof buildDefinition>;
preview: PreviewState;
setPreview: Dispatch<SetStateAction<PreviewState>>;
setMobilePane: Dispatch<SetStateAction<"configure" | "preview">>;
optionsSnapshot: Record<string, unknown>;
setOptionsSnapshot: Dispatch<SetStateAction<Record<string, unknown>>>;
}
export function useCustomWidgetFormActions(input: FormActionsInput) {
const router = useRouter();
const t = useScopedI18n("customWidget");
const w = useScopedI18n("customWidget.workbench");
const utils = clientApi.useUtils();
const createMutation = clientApi.customWidget.create.useMutation();
const updateMutation = clientApi.customWidget.update.useMutation();
const previewMutation = clientApi.customWidget.previewCreate.useMutation();
const [saveIssues, setSaveIssues] = useState<CustomWidgetSaveIssue[]>([]);
const [previewPending, setPreviewPending] = useState(false);
const save = input.form.onSubmit(async (values) => {
clearSaveIssues(input.form, saveIssues);
setSaveIssues([]);
const definition = buildDefinition(values);
if (!definition.success) {
reportSaveIssues(
input.form,
definition.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })),
setSaveIssues,
t("action.save"),
(count) => w("saveError.more", { count }),
);
return;
}
const changedSecrets = getChangedSecrets(values);
try {
if (input.mode === "create") {
const result = await createMutation.mutateAsync({ ...definition.data, secrets: changedSecrets });
await utils.customWidget.list.invalidate();
showSuccessNotification({
title: t("action.create"),
message: t("notification.created", { name: values.name }),
});
router.push(`/manage/custom-widgets/edit/${result.id}`);
} else if (input.definitionId) {
await updateMutation.mutateAsync({
id: input.definitionId,
...definition.data,
secrets: changedSecrets.length ? changedSecrets : undefined,
});
await Promise.all([
utils.customWidget.list.invalidate(),
utils.customWidget.get.invalidate({ id: input.definitionId }),
utils.widget.customApi.getData.invalidate(),
]);
input.form.setInitialValues(values);
input.form.resetDirty();
showSuccessNotification({
title: t("action.save"),
message: t("notification.updated", { name: values.name }),
});
}
} catch (error) {
const issues = extractCustomWidgetSaveIssues(error);
if (issues.length > 0) {
reportSaveIssues(input.form, issues, setSaveIssues, t("action.save"), (count) =>
w("saveError.more", { count }),
);
} else {
showErrorNotification({ title: t("action.save"), message: t("notification.updateError") });
}
}
});
const runPreview = async () => {
if (previewPending) return;
if (!input.candidate.success) {
showErrorNotification({
title: w("section.preview"),
message: input.candidate.error.issues[0]?.message ?? w("invalidWidget"),
});
return;
}
const optionIssues = getCustomWidgetPreviewOptionIssues(input.candidate.data, input.optionsSnapshot);
if (optionIssues.length > 0) {
const issue = optionIssues[0];
showErrorNotification({
title: w("section.preview"),
message: issue ? `${issue.path}: ${issue.message}` : w("invalidWidget"),
});
return;
}
input.setMobilePane("preview");
input.setPreview({ data: {}, status: {}, session: null, outcome: "loading" });
setPreviewPending(true);
try {
const created = await previewMutation.mutateAsync({
definition: input.candidate.data,
definitionId: input.definitionId,
options: input.optionsSnapshot,
secrets: getChangedSecrets(input.form.values),
});
const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id);
const failed = Object.values(snapshot.status).filter((status) => isRecord(status) && status.ok === false).length;
input.setPreview({
...snapshot,
session: created.previewSession,
outcome: failed > 0 ? "error" : "success",
});
if (failed > 0) {
showWarningNotification({
title: w("preview.result.error.title"),
message: w("preview.result.error.description", {
succeeded: Object.keys(snapshot.status).length - failed,
failed,
}),
});
} else {
showSuccessNotification({
title: w("preview.result.success.title"),
message: w("preview.result.success.description", {
succeeded: Object.keys(snapshot.status).length,
failed: 0,
}),
});
}
} catch {
input.setPreview((current) => ({ ...current, outcome: "error" }));
showErrorNotification({ title: w("section.preview"), message: t("notification.previewError") });
} finally {
setPreviewPending(false);
}
};
const pasteAiResponse = async () => {
try {
const result = parseCustomWidgetAiResponse(await navigator.clipboard.readText());
if (!result.success) {
throw new Error(formatCustomWidgetImportIssues(result.issues));
}
applyDefinition(input.form, customWidgetDefinitionSchema.parse(result.widget));
input.setOptionsSnapshot(getDefinitionDefaults(result.widget));
input.setPreview({ data: {}, status: {}, session: null, outcome: "idle" });
showSuccessNotification({
title: w("ai.response"),
message:
result.warnings.length > 0
? `${w("ai.loaded")} ${formatCustomWidgetImportIssues(result.warnings)}`
: w("ai.loaded"),
});
} catch (error) {
showErrorNotification({
title: w("ai.response"),
message:
error instanceof Error ? w("ai.invalidResponseDetail", { message: error.message }) : w("ai.invalidResponse"),
});
}
};
return {
save,
runPreview,
pasteAiResponse,
saveIssues,
savePending: createMutation.isPending || updateMutation.isPending,
previewPending,
};
}
function reportSaveIssues(
form: UseFormReturnType<CustomWidgetFormValues>,
issues: CustomWidgetSaveIssue[],
setIssues: Dispatch<SetStateAction<CustomWidgetSaveIssue[]>>,
title: string,
getRemainingLabel: (count: number) => string,
) {
setIssues(issues);
const messagesByPath = new Map<string, string[]>();
for (const issue of issues) {
if (!issue.path) continue;
const field = issue.path.split(".")[0] ?? issue.path;
messagesByPath.set(field, [...(messagesByPath.get(field) ?? []), issue.message]);
}
for (const [path, messages] of messagesByPath) {
form.setFieldError(path, messages.join(" "));
}
const remaining = issues.length - 1;
showErrorNotification({
title,
message: `${issues[0]?.message ?? ""}${remaining > 0 ? ` ${getRemainingLabel(remaining)}` : ""}`,
});
}
function clearSaveIssues(form: UseFormReturnType<CustomWidgetFormValues>, issues: CustomWidgetSaveIssue[]) {
for (const path of new Set(issues.flatMap((issue) => (issue.path ? [issue.path.split(".")[0] ?? issue.path] : [])))) {
form.clearFieldError(path);
}
}
@@ -1,156 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { clientApi } from "@homarr/api/client";
import type {
CustomWidgetAuthType,
CustomWidgetDisplayType,
CustomWidgetMethod,
CustomWidgetSecretKind,
DisplayConfig,
} from "@homarr/custom-widgets/core";
import { buildDisplayConfigFromFormValues } from "@homarr/custom-widgets/core";
import { showErrorNotification } from "@homarr/notifications";
import type { CustomWidgetFormInstance, CustomWidgetTranslator } from "./_display-field-types";
import type { PreviewFetchResult, PreviewInput } from "./_custom-widget-preview";
interface UseCustomWidgetPreviewOptions {
form: CustomWidgetFormInstance;
definitionId?: string;
refreshSignal: number;
onOpenPreview: () => void;
t: CustomWidgetTranslator;
}
export function useCustomWidgetPreview({
form,
definitionId,
refreshSignal,
onOpenPreview,
t,
}: UseCustomWidgetPreviewOptions) {
const previewMutation = clientApi.customWidget.preview.useMutation();
const liveActionsMutation = clientApi.customWidget.setPreviewLiveActions.useMutation();
const [json, setJson] = useState<unknown>(null);
const [fetchResult, setFetchResult] = useState<PreviewFetchResult | null>(null);
const [lastFingerprint, setLastFingerprint] = useState<string | null>(null);
const hasTestedRef = useRef(false);
const getInput = useCallback((): PreviewInput => {
const values = form.values;
return {
url: values.url,
method: values.method as CustomWidgetMethod,
authType: values.authType as CustomWidgetAuthType,
headerName: values.headerName || undefined,
requestBody: values.requestBody || undefined,
displayType: values.displayType as CustomWidgetDisplayType,
displayConfig: buildDisplayConfigFromFormValues(values) as DisplayConfig,
secrets: values.secrets
.filter((secret) => secret.value)
.map((secret) => ({ kind: secret.kind as CustomWidgetSecretKind, value: secret.value })),
definitionId,
};
}, [definitionId, form.values]);
const test = useCallback(async () => {
const input = getInput();
if (!input.url || input.method !== "GET") return;
onOpenPreview();
setLastFingerprint(JSON.stringify(input));
try {
const result = await previewMutation.mutateAsync(input);
hasTestedRef.current = true;
setFetchResult({
success: result.success,
error: result.success ? undefined : result.error,
responseInfo: result.responseInfo,
rawResponse: result.rawResponse,
previewSession: result.previewSession,
});
if (!result.success || !result.rawResponse) return setJson(null);
try {
setJson(JSON.parse(result.rawResponse));
} catch {
setJson(null);
}
} catch {
setFetchResult({ success: false, error: t("notification.previewError"), responseInfo: null, rawResponse: null });
setJson(null);
}
}, [getInput, onOpenPreview, previewMutation, t]);
const setLiveActions = useCallback(
async (enabled: boolean) => {
const sessionId = fetchResult?.previewSession?.id;
if (!sessionId) return;
try {
const previewSession = await liveActionsMutation.mutateAsync({ sessionId, enabled });
setFetchResult((current) => (current ? { ...current, previewSession } : current));
} catch {
showErrorNotification({
title: t("preview.capabilities.liveActions"),
message: t("notification.previewActionsError"),
});
}
},
[fetchResult?.previewSession?.id, liveActionsMutation, t],
);
const testRef = useRef(test);
testRef.current = test;
const getInputRef = useRef(getInput);
getInputRef.current = getInput;
const pendingRefreshRef = useRef(false);
useEffect(() => {
if (refreshSignal <= 0 || !hasTestedRef.current) return;
if (previewMutation.isPending) return void (pendingRefreshRef.current = true);
if (getInputRef.current().method === "GET") void testRef.current();
// eslint-disable-next-line react-hooks/exhaustive-deps -- refreshSignal is the explicit event source
}, [refreshSignal]);
useEffect(() => {
if (previewMutation.isPending || !pendingRefreshRef.current) return;
pendingRefreshRef.current = false;
if (getInputRef.current().method === "GET" && hasTestedRef.current) void testRef.current();
}, [previewMutation.isPending]);
const input = getInput();
const isStale = lastFingerprint !== null && lastFingerprint !== JSON.stringify(input);
const insertDataPath = useCallback(
(path: string) => {
if (form.values.displayType !== "customJsx") return;
const template = form.values.template.trimEnd();
const snippet = `<Text>{${path}}</Text>`;
form.setFieldValue("template", template ? `${template}\n${snippet}` : snippet);
},
[form],
);
const setSampleData = useCallback((value: unknown) => {
setJson(value);
setFetchResult({ success: true, responseInfo: null, rawResponse: JSON.stringify(value, null, 2) });
setLastFingerprint(null);
}, []);
const reset = useCallback(() => {
setJson(null);
setFetchResult(null);
setLastFingerprint(null);
}, []);
return {
input,
getInput,
json,
fetchResult,
isStale,
test,
setLiveActions,
insertDataPath,
setSampleData,
reset,
isTesting: previewMutation.isPending,
testError: previewMutation.error,
isUpdatingLiveActions: liveActionsMutation.isPending,
};
}
@@ -3,7 +3,6 @@ import { Container, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { buildDisplayFormValues } from "@homarr/custom-widgets/core";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
@@ -11,40 +10,18 @@ import { CustomWidgetBetaBanner } from "../../_beta-banner";
import { CustomWidgetForm } from "../../_custom-widget-form";
import { FormErrorBoundary } from "../../_form-error-boundary";
const authTypeExpectedSecrets: Record<string, string[]> = {
bearer: ["apiKey"],
basic: ["username", "password"],
apiKeyHeader: ["apiKey"],
apiKeyQuery: ["apiKey"],
};
function buildInitialSecrets(authType: string, dbSecrets: Array<{ kind: string; hasValue?: boolean }>) {
const expected = authTypeExpectedSecrets[authType] ?? [];
const existingKinds = new Set(dbSecrets.map((s) => s.kind));
const result = dbSecrets.map((s) => ({ kind: s.kind, value: "", hasValue: s.hasValue ?? false }));
for (const kind of expected) {
if (!existingKinds.has(kind)) {
result.push({ kind, value: "", hasValue: false });
}
}
return result;
}
interface EditCustomWidgetPageProps {
params: Promise<{ id: string }>;
}
export default async function EditCustomWidgetPage(props: EditCustomWidgetPageProps) {
const session = await auth();
if (!session || !session.user.permissions.includes("admin")) {
if (!session || !session.user.permissions.includes("custom-widget-manage")) {
redirect("/manage/custom-widgets");
}
const params = await props.params;
const definition = await api.customWidget.byId({ id: params.id }).catch(catchTrpcNotFound);
const displayConfig = definition.displayConfig as Record<string, unknown>;
const displayValues = buildDisplayFormValues(definition.displayType, displayConfig);
const definition = await api.customWidget.get({ id: params.id }).catch(catchTrpcNotFound);
return (
<>
@@ -61,13 +38,16 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr
name: definition.name,
description: definition.description ?? "",
iconUrl: definition.iconUrl ?? "",
url: definition.url,
authType: definition.authType,
headerName: definition.headerName ?? "",
method: definition.method,
requestBody: definition.requestBody ?? "",
...displayValues,
secrets: buildInitialSecrets(definition.authType, definition.secrets),
sources: JSON.stringify(definition.sources, null, 2),
requests: JSON.stringify(definition.requests, null, 2),
options: JSON.stringify(definition.options, null, 2),
template: definition.template,
secrets: definition.secrets.map((secret) => ({
sourceId: secret.sourceId,
kind: secret.kind,
value: "",
hasValue: true,
})),
}}
/>
</FormErrorBoundary>
@@ -11,7 +11,7 @@ import { FormErrorBoundary } from "../_form-error-boundary";
export default async function NewCustomWidgetPage() {
const session = await auth();
if (!session || !session.user.permissions.includes("admin")) {
if (!session || !session.user.permissions.includes("custom-widget-manage")) {
redirect("/manage/custom-widgets");
}
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { Group } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { IconExternalLink, IconPlus } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
@@ -9,19 +9,19 @@ import { Link } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { InstallWidgetFromWorkshopButton } from "~/components/workshop-install/install-from-workshop-buttons";
import { CustomWidgetBetaBanner } from "./_beta-banner";
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
import { CustomWidgetList } from "./_custom-widget-list";
export default async function CustomWidgetsPage() {
const session = await auth();
if (!session || !session.user.permissions.includes("admin")) {
if (!session || !session.user.permissions.includes("custom-widget-manage")) {
redirect(session ? "/" : "/auth/login");
}
const definitions = await api.customWidget.all();
const definitions = await api.customWidget.list();
const t = await getScopedI18n("customWidget");
return (
@@ -29,7 +29,17 @@ export default async function CustomWidgetsPage() {
title={t("page.list.title")}
primaryAction={
<Group gap="xs">
<InstallWidgetFromWorkshopButton />
<MobileAffixButton
component="a"
href="https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget"
target="_blank"
rel="noopener noreferrer"
leftSection={<IconExternalLink size={16} />}
variant="default"
>
{t("action.downloadSkill")}
</MobileAffixButton>
<WorkshopInstallButton />
<ImportCustomWidgetButton />
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
{t("action.create")}
@@ -0,0 +1,66 @@
import { notFound, redirect } from "next/navigation";
import { Badge, Box, Container, Group, Paper, Stack, Text, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
export default async function CustomWidgetPreviewPage({ params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user.permissions.includes("custom-widget-manage")) redirect("/");
const { id } = await params;
const preview = await api.customWidget.previewGet({ sessionId: id }).catch(() => null);
if (!preview) notFound();
const loadRequests = preview.requests.filter((request) => request.kind === "query" && request.trigger === "load");
const results = await Promise.all(
loadRequests.map(async (request) => {
const result = await api.customWidget
.previewQuery({ sessionId: id, requestId: request.id, params: {} })
.catch((cause: unknown) => ({
ok: false,
status: 0,
statusText: "Error",
data: null,
error: cause instanceof Error ? cause.message : "Request failed",
}));
return [request.id, result] as const;
}),
);
const data = Object.fromEntries(results.map(([requestId, result]) => [requestId, result.data]));
const status = Object.fromEntries(
results.map(([requestId, result]) => [
requestId,
{ loading: false, ok: result.ok, status: result.status, error: result.error },
]),
);
return (
<Container size="xl" py="xl">
<Stack gap="lg">
<Group justify="space-between" align="end">
<Box>
<Text size="sm" c="dimmed" fw={600}>
CUSTOM WIDGET PREVIEW
</Text>
<Title>{preview.name}</Title>
</Box>
<Badge variant="light">Expires {new Date(preview.expiresAt).toLocaleTimeString()}</Badge>
</Group>
<Paper withBorder radius="lg" p="md" mih={360} style={{ overflow: "auto" }}>
<CustomJsxDisplay
data={{
template: preview.template,
data,
status,
options: preview.options,
requestCapabilities: preview.requests,
previewSessionId: preview.id,
previewLiveActions: preview.liveActions,
}}
/>
</Paper>
</Stack>
</Container>
);
}
+18 -33
View File
@@ -5,6 +5,7 @@ import {
IconApi,
IconBook2,
IconBox,
IconBuildingStore,
IconBrandDiscord,
IconBrandDocker,
IconBrandGithub,
@@ -27,10 +28,8 @@ import {
IconUsersGroup,
} from "@tabler/icons-react";
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createDocumentationLink } from "@homarr/definitions";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { env } from "@homarr/docker/env";
@@ -41,29 +40,11 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import type { NavigationLink } from "~/components/layout/navigation";
import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell";
import { ManageTourGate } from "~/components/onboarding/manage-tour-gate";
import { env as nextEnv } from "~/env";
const logger = createLogger({ module: "manageLayout" });
import { ManageTourProvider } from "~/components/onboarding/manage-tour";
export default async function ManageLayout({ children }: PropsWithChildren) {
const sessionPromise = auth();
const shouldRunManageTourPromise = sessionPromise.then(async (session) => {
if (!session || nextEnv.DEMO_MODE) return false;
try {
const user = await getRscUserSettingsAsync(session.user.id);
return user !== undefined && !user.completedManageTour;
} catch (error) {
logger.error(new Error("Failed to load the management tour status", { cause: error }));
return false;
}
});
const [t, session, shouldRunManageTour] = await Promise.all([
getScopedI18n("management.navbar"),
sessionPromise,
shouldRunManageTourPromise,
]);
const t = await getScopedI18n("management.navbar");
const session = await auth();
const navigationLinks: NavigationLink[] = [
{
label: t("items.home"),
@@ -81,7 +62,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconBox,
href: "/manage/apps",
label: t("items.apps"),
hidden: !session?.user.permissions.includes("app-create"),
hidden: !session,
iconProps: {
strokeWidth: 2.5,
},
@@ -91,20 +72,26 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconAffiliateFilled,
href: "/manage/integrations",
label: t("items.integrations"),
hidden: !session?.user.permissions.includes("integration-create"),
hidden: !session,
"data-onboarding-tour-id": "manage-integrations",
},
{
icon: IconApi,
href: "/manage/custom-widgets",
label: t("items.customWidgets"),
hidden: !session?.user.permissions.includes("admin"),
hidden: !session?.user.permissions.includes("custom-widget-manage"),
},
{
icon: IconBuildingStore,
href: "/manage/workshop",
label: t("items.workshop"),
hidden: !session?.user.permissions.includes("custom-widget-manage"),
},
{
icon: IconSearch,
href: "/manage/search-engines",
label: t("items.searchEngies"),
hidden: !session?.user.permissions.includes("search-engine-create"),
hidden: !session,
iconProps: {
strokeWidth: 2.5,
},
@@ -114,7 +101,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconPhotoFilled,
href: "/manage/medias",
label: t("items.medias"),
hidden: !session?.user.permissions.includes("media-upload"),
hidden: !session,
"data-onboarding-tour-id": "manage-medias",
},
{
@@ -245,9 +232,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
</ClientShell>
);
return (
<ManageTourGate enabled={shouldRunManageTour} isAdmin={isAdmin}>
{shell}
</ManageTourGate>
);
if (!session) return shell;
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
}
@@ -0,0 +1,18 @@
import { redirect } from "next/navigation";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { WorkshopInstaller } from "~/components/workshop/workshop-installer";
export default async function WorkshopPage() {
const session = await auth();
if (!session?.user.permissions.includes("custom-widget-manage")) redirect(session ? "/" : "/auth/login");
const t = await getScopedI18n("workshop");
return (
<ManagePageLayout title={t("title")} size="xl">
<WorkshopInstaller />
</ManagePageLayout>
);
}
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt";
export const dynamic = "force-static";
export function GET() {
return NextResponse.json({
version: 2,
prompt: CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
resources: [
"homarr://custom-widgets/schema",
"homarr://custom-widgets/components",
"homarr://custom-widgets/skill",
],
httpResources: ["/api/custom-widgets/schema", "/api/custom-widgets/components", "/api/custom-widgets/skill"],
});
}
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { getCustomWidgetComponentCatalog } from "@homarr/custom-widgets/authoring-resources";
export const dynamic = "force-static";
export function GET() {
return NextResponse.json(getCustomWidgetComponentCatalog());
}
@@ -0,0 +1,182 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
claimCustomWidgetConfigurationRequest,
completeCustomWidgetConfigurationRequest,
configurePreviewSessionSource,
getCustomWidgetConfigurationRequest,
parseStoredCustomWidgetDefinition,
releaseCustomWidgetConfigurationRequest,
serializeCustomWidgetDefinition,
} from "@homarr/api/custom-widget-configuration";
import { encryptSecret } from "@homarr/common/server";
import { invalidateCustomWidgetResponseCache } from "@homarr/custom-widgets/server";
import { customWidgetSourceSchema, hasSameCustomWidgetSourceAuthentication } from "@homarr/custom-widgets/core";
import { and, db, eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
interface RouteContext {
params: Promise<{ token: string }>;
}
export async function GET(_request: NextRequest, context: RouteContext) {
const { token } = await context.params;
const request = await getCustomWidgetConfigurationRequest(token);
if (!request)
return NextResponse.json({ error: "This source configuration request is invalid or expired." }, { status: 404 });
return NextResponse.json(
{
widgetName: request.widgetName,
sourceName: request.sourceName,
kinds: request.kinds,
source: request.source,
expiresAt: request.expiresAt,
status: request.status,
},
{ headers: { "Cache-Control": "no-store" } },
);
}
export async function POST(request: NextRequest, context: RouteContext) {
const { token } = await context.params;
const pending = await getCustomWidgetConfigurationRequest(token);
if (!pending || pending.status !== "pending") {
return NextResponse.json({ error: "This credential request is invalid, completed, or expired." }, { status: 404 });
}
const body = (await request.json().catch(() => null)) as {
baseUrl?: unknown;
networkScope?: unknown;
secrets?: Record<string, unknown>;
} | null;
const sourceResult = customWidgetSourceSchema.safeParse({
...pending.source,
baseUrl: body?.baseUrl,
networkScope: body?.networkScope,
});
if (!sourceResult.success) {
return NextResponse.json(
{ error: sourceResult.error.issues[0]?.message ?? "Enter a valid server URL." },
{ status: 400 },
);
}
const secrets = pending.kinds.flatMap((kind) => {
const value = body?.secrets?.[kind];
return typeof value === "string" && value.length > 0 && value.length <= 8192
? [{ sourceId: pending.sourceId, kind, value }]
: [];
});
if (secrets.length !== pending.kinds.length) {
return NextResponse.json({ error: "Enter every requested credential field." }, { status: 400 });
}
const claimed = await claimCustomWidgetConfigurationRequest(token);
if (!claimed) {
return NextResponse.json(
{ error: "This source configuration request is already being completed." },
{ status: 409 },
);
}
try {
if (claimed.target.type === "preview") {
await configurePreviewSessionSource(
claimed.target.id,
claimed.userId,
claimed.sourceId,
sourceResult.data,
secrets,
);
invalidateCustomWidgetResponseCache([`custom-jsx:preview:${claimed.target.id}:`]);
} else {
const definition = await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, claimed.target.id),
});
if (!definition) return NextResponse.json({ error: "The custom widget no longer exists." }, { status: 404 });
const parsed = parseStoredCustomWidgetDefinition(definition);
const currentSource = parsed.sources[claimed.sourceId];
if (!currentSource) {
return NextResponse.json(
{ error: "This API source changed after the setup link was created. Request a new setup link." },
{ status: 409 },
);
}
if (!hasSameCustomWidgetSourceAuthentication(currentSource, claimed.source)) {
return NextResponse.json(
{
error: "This API source authentication changed after the setup link was created. Request a new setup link.",
},
{ status: 409 },
);
}
const configuredSource = customWidgetSourceSchema.parse({
...currentSource,
baseUrl: sourceResult.data.baseUrl,
networkScope: sourceResult.data.networkScope,
});
const definitionChanges = {
...serializeCustomWidgetDefinition({
...parsed,
sources: { ...parsed.sources, [claimed.sourceId]: configuredSource },
}),
updatedAt: new Date(),
};
const secretRows = secrets.map((secret) => ({
definitionId: claimed.target.id,
sourceId: secret.sourceId,
kind: secret.kind,
encryptedValue: encryptSecret(secret.value),
updatedAt: new Date(),
}));
await handleTransactionsAsync(db, {
async handleAsync(database, schema) {
await database.transaction(async (transaction) => {
await transaction
.update(schema.customWidgetDefinitions)
.set(definitionChanges)
.where(eq(schema.customWidgetDefinitions.id, claimed.target.id));
for (const secret of secretRows) {
await transaction
.delete(schema.customWidgetSecrets)
.where(
and(
eq(schema.customWidgetSecrets.definitionId, claimed.target.id),
eq(schema.customWidgetSecrets.sourceId, secret.sourceId),
eq(schema.customWidgetSecrets.kind, secret.kind),
),
);
await transaction.insert(schema.customWidgetSecrets).values(secret);
}
});
},
handleSync(database) {
database.transaction((transaction) => {
transaction
.update(customWidgetDefinitions)
.set(definitionChanges)
.where(eq(customWidgetDefinitions.id, claimed.target.id))
.run();
for (const secret of secretRows) {
transaction
.delete(customWidgetSecrets)
.where(
and(
eq(customWidgetSecrets.definitionId, claimed.target.id),
eq(customWidgetSecrets.sourceId, secret.sourceId),
eq(customWidgetSecrets.kind, secret.kind),
),
)
.run();
transaction.insert(customWidgetSecrets).values(secret).run();
}
});
},
});
}
await completeCustomWidgetConfigurationRequest(token);
return NextResponse.json({ status: "completed" }, { headers: { "Cache-Control": "no-store" } });
} finally {
await releaseCustomWidgetConfigurationRequest(token);
}
}
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
export const dynamic = "force-static";
export function GET() {
return NextResponse.json(getCustomWidgetJsonSchema());
}
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
export const dynamic = "force-static";
export function GET() {
return NextResponse.json(getCustomWidgetSkill());
}
@@ -3,13 +3,28 @@ import type { NextRequest } from "next/server";
import { userAgent } from "next/server";
import { createMcpHandler } from "mcp-handler";
import type { McpTool } from "trpc-to-mcp";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import {
CallToolRequestSchema,
GetPromptRequestSchema,
ListPromptsRequestSchema,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { ipAddressFromHeaders } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-prompt";
import {
getCustomWidgetComponent,
getCustomWidgetComponentCatalog,
getCustomWidgetSkillContent,
} from "@homarr/custom-widgets/authoring-resources";
import { customJsxExamples, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
import { db } from "@homarr/db";
import { getPackageVersion } from "~/versions/package-reader";
@@ -168,6 +183,91 @@ If \`hasUseAccess\` is false, the API key owner lacks permission for that integr
const mcpHandler = createMcpHandler(
(server) => {
server.server.setRequestHandler(ListPromptsRequestSchema, () => ({
prompts: [
{
name: "homarr-custom-widget-author",
description: "Author and iterate on one Homarr Custom JSX v2 widget.",
arguments: [
{ name: "request", description: "The widget the user wants.", required: false },
{ name: "documentationUrl", description: "External API documentation URL.", required: false },
],
},
],
}));
server.server.setRequestHandler(GetPromptRequestSchema, (request) => {
if (request.params.name !== "homarr-custom-widget-author") throw new Error("Prompt not found");
return {
description: "Current Homarr Custom Widget authoring workflow.",
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: buildCustomWidgetMcpPrompt(
request.params.arguments?.request,
request.params.arguments?.documentationUrl,
),
},
},
],
};
});
server.server.setRequestHandler(ListResourcesRequestSchema, () => ({
resources: [
{ uri: "homarr://custom-widgets/schema", name: "Custom Widget schema", mimeType: "application/schema+json" },
{ uri: "homarr://custom-widgets/components", name: "Custom Widget components", mimeType: "application/json" },
{ uri: "homarr://custom-widgets/skill", name: "Custom Widget skill", mimeType: "text/markdown" },
...customJsxExamples.map((example) => ({
uri: `homarr://custom-widgets/examples/${example.id}`,
name: `Custom Widget example: ${example.title}`,
mimeType: "application/json",
})),
],
}));
server.server.setRequestHandler(ListResourceTemplatesRequestSchema, () => ({
resourceTemplates: [
{
uriTemplate: "homarr://custom-widgets/components/{name}",
name: "Custom Widget component",
description: "Metadata for one installed Custom Widget component.",
mimeType: "application/json",
},
{
uriTemplate: "homarr://custom-widgets/examples/{name}",
name: "Custom Widget example",
description: "One canonical Custom Widget example.",
mimeType: "application/json",
},
],
}));
server.server.setRequestHandler(ReadResourceRequestSchema, (request) => {
const { uri } = request.params;
let text: string;
let mimeType = "application/json";
if (uri === "homarr://custom-widgets/schema") text = JSON.stringify(getCustomWidgetJsonSchema());
else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog());
else if (uri === "homarr://custom-widgets/skill") {
text = getCustomWidgetSkillContent();
mimeType = "text/markdown";
} else if (uri.startsWith("homarr://custom-widgets/components/")) {
const name = decodeURIComponent(uri.slice("homarr://custom-widgets/components/".length));
const component = getCustomWidgetComponent(name);
if (!component) throw new Error("Component resource not found");
text = JSON.stringify(component);
} else if (uri.startsWith("homarr://custom-widgets/examples/")) {
const name = decodeURIComponent(uri.slice("homarr://custom-widgets/examples/".length));
const example = customJsxExamples.find((candidate) => candidate.id === name);
if (!example) throw new Error("Example resource not found");
text = JSON.stringify(example);
} else throw new Error("Resource not found");
return { contents: [{ uri, mimeType, text }] };
});
server.server.setRequestHandler(ListToolsRequestSchema, () => ({
tools: getTools(),
}));
@@ -219,7 +319,7 @@ const mcpHandler = createMcpHandler(
});
},
{
capabilities: { tools: {} },
capabilities: { tools: {}, prompts: {}, resources: {} },
instructions: SERVER_INSTRUCTIONS,
serverInfo: {
name: "homarr",
@@ -1,21 +1,17 @@
import type { ComponentType, MutableRefObject } from "react";
import { Suspense, use, useRef } from "react";
import type { CardProps } from "@mantine/core";
import { Badge, Card, Center, Loader } from "@mantine/core";
import type { MutableRefObject } from "react";
import { useRef } from "react";
import { Badge, Card } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
import type { FallbackProps } from "react-error-boundary";
import { ErrorBoundary } from "react-error-boundary";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors";
import { getWidgetQueryKeys } from "@homarr/widgets/definition";
import type { WidgetComponentProps, WidgetDefinition } from "@homarr/widgets/definition";
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import classes from "../sections/item.module.css";
@@ -23,7 +19,6 @@ import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { BoardItemMenu } from "./item-menu";
import { WidgetContextMenu } from "./widget-context-menu";
import { removeWidgetDataQueries } from "./widget-query-recovery";
interface BoardItemContentProps {
item: SectionItem;
@@ -35,53 +30,6 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
return undefined;
};
type BoardItemCardProps = CardProps & {
item: SectionItem;
innerRef: (element: HTMLDivElement | null) => void;
};
const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => {
const board = useRequiredBoard();
return (
<Card
{...cardProps}
ref={innerRef}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
{children}
</Card>
);
};
const WidgetDefinitionLoadError = ({
error,
resetErrorBoundary,
item,
innerRef,
}: FallbackProps & Pick<BoardItemCardProps, "item" | "innerRef">) => (
<BoardItemCard item={item} innerRef={innerRef}>
<Center h="100%">
<WidgetError error={error} resetErrorBoundary={resetErrorBoundary} />
</Center>
</BoardItemCard>
);
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard();
@@ -89,28 +37,29 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
return (
<>
<ErrorBoundary
resetKeys={[item.kind]}
fallbackRender={(fallbackProps) => <WidgetDefinitionLoadError {...fallbackProps} item={item} innerRef={ref} />}
>
<Suspense
fallback={
<BoardItemCard item={item} innerRef={ref}>
<Center h="100%">
<Loader size="sm" />
</Center>
</BoardItemCard>
}
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
<LoadedBoardItemContent
item={item}
width={width}
height={height}
widgetStateRef={widgetStateRef}
innerRef={ref}
/>
</Suspense>
</ErrorBoundary>
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
</Card>
</WidgetContextMenu>
{item.advancedOptions.title?.trim() && (
<Badge
pos="absolute"
@@ -136,72 +85,40 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
);
};
interface LoadedBoardItemContentProps extends Omit<InnerContentProps, "definition" | "Component"> {
innerRef: (element: HTMLDivElement | null) => void;
}
const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => {
const { definition, Component } = use(loadWidgetResources(item.kind));
return (
<WidgetContextMenu item={item} definition={definition} widgetStateRef={innerContentProps.widgetStateRef}>
<BoardItemCard item={item} innerRef={innerRef}>
<ErrorBoundary
resetKeys={[item.kind]}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
<BoardItemMenu offset={4} item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
</>
)}
>
<InnerContent item={item} definition={definition} Component={Component} {...innerContentProps} />
</ErrorBoundary>
</BoardItemCard>
</WidgetContextMenu>
);
};
interface InnerContentProps {
item: SectionItem;
width: number;
height: number;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
definition: WidgetDefinition;
Component: ComponentType<WidgetComponentProps<SectionItem["kind"]>>;
}
const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => {
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const settings = useSettings();
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options);
const Comp = loadWidgetDynamic(item.kind);
const { definition } = widgetImports[item.kind];
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
const newItem = { ...item, options };
const { updateItemOptions } = useItemActions();
const { removeItem, updateItemOptions } = useItemActions();
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
updateItemOptions({ itemId: item.id, newOptions });
const widgetSupportsIntegrations =
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) >= 1;
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
const queryClient = useQueryClient();
const widgetQueryKeys = getWidgetQueryKeys(definition, item.kind);
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={() => {
removeWidgetDataQueries(queryClient, widgetQueryKeys);
void queryClient.invalidateQueries({ queryKey: [["widget"]] });
reset();
}}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
<BoardItemMenu
offset={4}
item={newItem}
definition={definition}
resetErrorBoundary={resetErrorBoundary}
/>
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
</>
)}
>
@@ -213,25 +130,24 @@ const InnerContent = ({ item, definition, Component, ...dimensions }: InnerConte
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
}
/>
<BoardItemMenu offset={4} item={newItem} definition={definition} />
<div data-homarr-widget-ready={item.kind} style={{ display: "contents" }}>
<Component
options={options as never}
integrationIds={item.integrationIds}
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}
setOptions={(partialNewOptions) =>
updateOptions({
newOptions: {
...options,
...partialNewOptions.newOptions,
},
})
}
{...dimensions}
/>
</div>
<BoardItemMenu offset={4} item={newItem} />
<Comp
options={options as never}
integrationIds={item.integrationIds}
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}
removeItem={() => removeItem({ itemId: item.id })}
setOptions={(partialNewOptions) =>
updateOptions({
newOptions: {
...options,
...partialNewOptions.newOptions,
},
})
}
{...dimensions}
/>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
@@ -1,65 +1,47 @@
import { useMemo, useRef, useState } from "react";
import { useMemo, useState } from "react";
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
import { IconApi } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
import { createId, objectEntries } from "@homarr/common";
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { 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 { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
import { useItemActions } from "./item-actions";
import { 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 };
innerProps: void;
integrationData: RouterOutputs["integration"]["all"] | undefined;
customWidgetDefs: RouterOutputs["customWidget"]["all"] | undefined;
ensureIntegrationDataAsync: () => Promise<RouterOutputs["integration"]["all"]>;
}
const ItemSelectModalContent = ({
actions,
integrationData,
customWidgetDefs,
ensureIntegrationDataAsync,
}: ItemSelectModalContentProps) => {
export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, innerProps }) => {
const [search, setSearch] = useState("");
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 { openModal: openEditModal } = useModalAction(WidgetEditModal);
const { data: integrationData } = clientApi.integration.all.useQuery();
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery({ boardId: innerProps.boardId });
const settings = useSettings();
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
const items = useMemo(
() =>
widgetKinds
.filter((kind) => kind !== "customApi")
.map((kind) => {
return {
kind,
supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter(
(integration) => integration !== "mock",
),
icon: widgetCatalogIcons[kind],
name: t(`widget.${kind}.name`),
description: t(`widget.${kind}.description`),
};
})
objectEntries(widgetImports)
.filter(([kind]) => kind !== "customApi")
.map(([kind, value]) => ({
kind,
supportedIntegrations:
"supportedIntegrations" in value.definition
? value.definition.supportedIntegrations.filter((integration) => integration !== "mock")
: [],
icon: value.definition.icon,
name: t(`widget.${kind}.name`),
description: t(`widget.${kind}.description`),
}))
.sort((itemA, itemB) => {
if (itemA.kind === "app") return -1;
if (itemB.kind === "app") return 1;
@@ -80,86 +62,81 @@ const ItemSelectModalContent = ({
}, [items, search]);
const filteredCustomWidgets = useMemo(
() =>
(customWidgetDefs ?? []).filter((def) => def.enabled && def.name.toLowerCase().includes(search.toLowerCase())),
() => (customWidgetDefs ?? []).filter((def) => def.name.toLowerCase().includes(search.toLowerCase())),
[customWidgetDefs, search],
);
const notifyDefinitionLoadError = (error: unknown) => {
showErrorNotification({
title: t("common.error"),
message: error instanceof Error ? error.message : String(error),
});
const handleAddCustomWidget = (definition: NonNullable<typeof customWidgetDefs>[number]) => {
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings);
const options = {
...defaultOptions,
definitionId: definition.id,
configuration: definition.defaultOptions,
configurationVersion: definition.updatedAt.getTime(),
};
actions.closeModal();
openEditModal(
{
kind: "customApi",
value: {
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
options,
integrationIds: [],
},
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
updateItemOptions({ itemId, newOptions: configuredOptions });
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
},
integrationData: [],
integrationSupport: false,
settings,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${definition.name}`,
},
);
};
const handleAddCustomWidget = async (definitionId: string) => {
if (!tryLockSelection(selectionLock)) return;
setLoadingSelection(`custom:${definitionId}`);
try {
const definition = await loadWidgetDefinition("customApi");
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
actions.closeModal();
} catch (error) {
notifyDefinitionLoadError(error);
} finally {
unlockSelection(selectionLock);
setLoadingSelection(null);
}
};
const handleAdd = (kind: WidgetKind) => {
const definition = widgetImports[kind].definition;
const hasIntegrationSupport = "supportedIntegrations" in definition;
const handleAdd = async (kind: WidgetKind) => {
if (!tryLockSelection(selectionLock)) return;
setLoadingSelection(kind);
preloadWidgetEditModal();
try {
const definition = await loadWidgetDefinition(kind);
const hasIntegrationSupport = "supportedIntegrations" in definition;
const matchingIntegrations = hasIntegrationSupport
? (integrationData ?? []).filter((integration) =>
(definition.supportedIntegrations as string[]).includes(integration.kind),
)
: [];
const matchingIntegrations = await resolveMatchingIntegrationsAsync({
hasIntegrationSupport,
supportedIntegrations: definition.supportedIntegrations ?? [],
currentData: integrationData,
ensureDataAsync: ensureIntegrationDataAsync,
});
const integrationIds = matchingIntegrations.map((i) => i.id);
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings);
const integrationIds = matchingIntegrations.map((i) => i.id);
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
createItem({ id: itemId, kind, integrationIds });
actions.closeModal();
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,
openEditModal(
{
kind,
value: {
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
options: defaultOptions,
integrationIds,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
updateItemOptions({ itemId, newOptions: options });
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
},
);
} catch (error) {
notifyDefinitionLoadError(error);
} finally {
unlockSelection(selectionLock);
setLoadingSelection(null);
}
integrationData: matchingIntegrations,
integrationSupport: hasIntegrationSupport,
settings,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
},
);
};
return (
@@ -168,9 +145,9 @@ const ItemSelectModalContent = ({
onSearchChange={setSearch}
placeholder={`${t("item.create.search")}...`}
onSearchKeyDown={(event) => {
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
const [item] = filteredItems;
if (item) void handleAdd(item.kind);
if (event.key === "Enter" && filteredItems.length === 1) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
handleAdd(filteredItems[0]!.kind);
}
}}
>
@@ -178,76 +155,64 @@ const ItemSelectModalContent = ({
<WidgetItem
key={item.kind}
item={item}
onSelect={() => void handleAdd(item.kind)}
onIntent={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
}}
disabled={loadingSelection !== null}
loading={loadingSelection === item.kind}
onSelect={() => handleAdd(item.kind)}
hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))}
/>
))}
{filteredCustomWidgets.length > 0 && (
<>
<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.id)}
variant="light"
size="xs"
fullWidth
disabled={loadingSelection !== null}
loading={loadingSelection === `custom:${def.id}`}
>
{t("item.create.addToBoard")}
</Button>
</Box>
</Card>
))}
</>
)}
<Divider
label={t("customWidget.page.list.title")}
labelPosition="center"
my="sm"
style={{ gridColumn: "1 / -1" }}
/>
{filteredCustomWidgets.map((def) => (
<Card
key={def.id}
h={selectGridCardHeight}
withBorder
pos="relative"
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
>
<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
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
opacity: "var(--_hover-opacity)",
transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button onClick={() => handleAddCustomWidget(def)} variant="light" size="xs" fullWidth>
{t("item.create.addToBoard")}
</Button>
</Box>
</Card>
))}
<Box style={{ gridColumn: "1 / -1" }}>
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
</Box>
{filteredItems.length === 0 && filteredCustomWidgets.length === 0 && (
<Center p="xl">
@@ -256,24 +221,7 @@ const ItemSelectModalContent = ({
)}
</SelectGridLayout>
);
};
const ItemSelectModalFrame = (props: Pick<ItemSelectModalContentProps, "actions" | "innerProps">) => {
const utils = clientApi.useUtils();
const { data: integrationData } = clientApi.integration.all.useQuery();
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
return (
<ItemSelectModalContent
{...props}
integrationData={integrationData}
customWidgetDefs={customWidgetDefs}
ensureIntegrationDataAsync={() => utils.integration.all.ensureData()}
/>
);
};
export const ItemSelectModal = createModal<void>((props) => <ItemSelectModalFrame {...props} />).withOptions({
}).withOptions({
defaultTitle: (t) => t("item.create.title"),
size: modalSizeSelect,
});
@@ -281,9 +229,6 @@ export const ItemSelectModal = createModal<void>((props) => <ItemSelectModalFram
const WidgetItem = ({
item,
onSelect,
onIntent,
disabled,
loading,
hasMatchingIntegration,
}: {
item: {
@@ -294,27 +239,23 @@ const WidgetItem = ({
icon: TablerIcon;
};
onSelect: () => void;
onIntent: () => void;
disabled: boolean;
loading: boolean;
hasMatchingIntegration: boolean;
}) => {
const t = useI18n();
return (
<Card
className={classes.card}
h={selectGridCardHeight}
withBorder
pos="relative"
style={{
overflow: "hidden",
"--_hover-opacity": "0",
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined,
borderWidth: hasMatchingIntegration ? 2 : undefined,
}}
onFocus={onIntent}
onPointerEnter={onIntent}
aria-busy={loading || undefined}
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
@@ -331,17 +272,18 @@ const WidgetItem = ({
<SupportedIntegrations integrations={item.supportedIntegrations} />
</Stack>
<Box
className={classes.action}
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
opacity: "var(--_hover-opacity)",
transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button onClick={onSelect} variant="light" size="xs" fullWidth disabled={disabled} loading={loading}>
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
{t(`item.create.addToBoard`)}
</Button>
</Box>
@@ -0,0 +1,139 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import {
applyCustomWidgetSourceSetup,
getCustomWidgetSourceSetups,
getImportReview,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetSecretKind, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import {
createCustomWidgetSourceSetupValues,
CustomWidgetSourceSetupPanel,
ImportReviewDialog,
isCustomWidgetSourceSetupReady,
} from "@homarr/custom-widgets/workbench";
import type { CustomWidgetSourceSetupValue } from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
interface CustomWidgetImportDialogProps {
opened: boolean;
widget: HomarrCustomWidgetV2 | null;
stackId?: string;
zIndex?: number;
onClose(): void;
onImported?(result: { id: string }): void;
}
export function CustomWidgetImportDialog({
opened,
widget,
stackId,
zIndex,
onClose,
onImported,
}: CustomWidgetImportDialogProps) {
const t = useScopedI18n("customWidget");
const router = useRouter();
const utils = clientApi.useUtils();
const setups = useMemo(() => getCustomWidgetSourceSetups(widget?.sources ?? {}), [widget]);
const [values, setValues] = useState<Record<string, CustomWidgetSourceSetupValue>>({});
const configuredWidget = useMemo(() => {
if (!widget) return null;
return {
...widget,
sources: applyCustomWidgetSourceSetup(
widget.sources,
Object.fromEntries(
Object.entries(values).map(([sourceId, value]) => [
sourceId,
{ baseUrl: value.baseUrl, networkScope: value.networkScope },
]),
),
),
};
}, [values, widget]);
const review = useMemo(() => getImportReview(configuredWidget), [configuredWidget]);
const mutation = clientApi.customWidget.import.useMutation({
onSuccess: (result) => {
showSuccessNotification({ title: t("action.import"), message: t("notification.imported") });
void utils.customWidget.list.invalidate();
void utils.customWidget.available.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh());
onImported?.(result);
onClose();
},
onError: (error) => {
showErrorNotification({ title: t("action.import"), message: error.message || t("notification.importError") });
},
});
useEffect(() => {
if (opened) setValues(createCustomWidgetSourceSetupValues(setups));
}, [opened, setups]);
const importWidget = () => {
if (!configuredWidget) return;
const secrets = Object.entries(values).flatMap(([sourceId, value]) =>
Object.entries(value.secrets).flatMap(([kind, secret]) =>
secret?.trim() ? [{ sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [],
),
);
mutation.mutate({ widget: configuredWidget, secrets });
};
return (
<ImportReviewDialog
opened={opened}
stackId={stackId}
zIndex={zIndex}
review={review}
pending={mutation.isPending}
confirmDisabled={!isCustomWidgetSourceSetupReady(setups, values)}
onClose={onClose}
onConfirm={importWidget}
messages={{
title: t("importReview.title"),
description: t("importReview.description"),
name: t("importReview.name"),
origin: t("importReview.origin"),
authentication: t("importReview.authentication"),
networkScope: t("importReview.networkScope"),
methods: t("importReview.methods"),
permissions: t("importReview.permissions"),
actionWarningTitle: t("importReview.actionWarning.title"),
actionWarningDescription: t("importReview.actionWarning.description"),
cancel: t("importReview.cancel"),
confirm: t("importReview.confirm"),
permission: (permission) => t(`preview.request.permission.${permission}` as never),
}}
>
<CustomWidgetSourceSetupPanel
setups={setups}
values={values}
onChange={(sourceId, value) => setValues((current) => ({ ...current, [sourceId]: value }))}
messages={{
title: t("importReview.sourceSetup.title"),
description: t("importReview.sourceSetup.description"),
suggestedUrl: t("importReview.sourceSetup.suggestedUrl"),
baseUrl: t("workbench.sources.baseUrl"),
networkScope: t("workbench.sources.networkScope"),
authentication: t("workbench.sources.authentication"),
confirmUrl: t("importReview.sourceSetup.confirmUrl"),
ready: t("importReview.sourceSetup.ready"),
needsUrl: t("importReview.sourceSetup.needsUrl"),
credentialsMissing: t("importReview.sourceSetup.credentialsMissing"),
credentialsOptional: t("importReview.sourceSetup.credentialsOptional"),
configured: t("workbench.sources.configured"),
secret: (kind) => t(`importReview.credentials.field.${kind}`),
urlError: (issue) => t(`workbench.sources.baseUrlError.${issue}`),
}}
/>
</ImportReviewDialog>
);
}
@@ -0,0 +1,523 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Card,
Code,
Group,
Image,
Loader,
Modal,
Pagination,
Select,
SimpleGrid,
Skeleton,
Stack,
Text,
Textarea,
TextInput,
Title,
} from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import {
IconAlertTriangle,
IconBuildingStore,
IconDownload,
IconFlag,
IconSearch,
IconThumbDown,
IconThumbUp,
} from "@tabler/icons-react";
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import {
useWorkshopCssImportMutation,
useWorkshopQuery,
useWorkshopReportMutation,
useWorkshopSubmissionQuery,
useWorkshopVoteMutation,
useWorkshopWidgetImportMutation,
} from "@homarr/workshop/backend";
import type {
WorkshopReport,
WorkshopSubmissionDetail,
WorkshopSubmissionType,
WorkshopUser,
} from "@homarr/workshop/schema";
import {
validateWorkshopContent,
validateWorkshopWidget,
WORKSHOP_CSS_SCHEMA,
workshopExportFilename,
} from "@homarr/workshop/schema";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { createWorkshopClient } from "./workshop-client";
function downloadWorkshopSubmission(submission: WorkshopSubmissionDetail) {
const url = URL.createObjectURL(
new Blob([submission.content], { type: submission.type === "customCss" ? "text/css" : "application/json" }),
);
const link = document.createElement("a");
link.href = url;
link.download = workshopExportFilename(submission.title, submission.type);
link.click();
URL.revokeObjectURL(url);
}
interface WorkshopBrowserProps {
type?: WorkshopSubmissionType;
onInstall?(widget: HomarrCustomWidgetV2): Promise<void>;
onUseCss?(css: string): void;
}
export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }: WorkshopBrowserProps) {
const t = useScopedI18n("workshop");
const client = useMemo(createWorkshopClient, []);
const [user, setUser] = useState<WorkshopUser | null>(null);
const [page, setPage] = useState(1);
const [sort, setSort] = useState<"top" | "newest">("top");
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [reportOpened, reportControls] = useDisclosure(false);
const [reportCategory, setReportCategory] = useState<WorkshopReport["category"]>("other");
const [reportExplanation, setReportExplanation] = useState("");
const [loginPending, setLoginPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [cssAwaitingConfirmation, setCssAwaitingConfirmation] = useState<string | null>(null);
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
void client.refreshAuth().then(setUser);
return unsubscribe;
}, [client]);
const list = useWorkshopQuery(client, { page, perPage: 12, type, sort, search: debouncedSearch });
const detail = useWorkshopSubmissionQuery(client, selectedId ?? "");
const detailValidation = useMemo(() => {
if (!detail.data) return null;
return type === "customCss"
? validateWorkshopContent("customCss", detail.data.content)
: validateWorkshopWidget(detail.data.content);
}, [detail.data, type]);
const expectedSchema = type === "customCss" ? WORKSHOP_CSS_SCHEMA : CUSTOM_WIDGET_SCHEMA;
const detailCompatible =
detail.data?.type === type && detailValidation?.success === true && detail.data.widgetSchema === expectedSchema;
const signIn = () => {
setLoginPending(true);
setLoginError(null);
void client
.signInWithGitHub()
.then(setUser)
.catch((cause: unknown) => {
const message = cause instanceof Error ? cause.message : t("signInError");
setLoginError(message);
showErrorNotification({ title: t("signIn"), message });
})
.finally(() => setLoginPending(false));
};
const install = useWorkshopWidgetImportMutation(onInstall, () => setSelectedId(null));
const useCss = useWorkshopCssImportMutation(onUseCss, () => {
setCssAwaitingConfirmation(null);
setSelectedId(null);
});
const vote = useWorkshopVoteMutation(client);
const report = useWorkshopReportMutation(client);
const submitReport = () =>
report.mutate(
{ submission: selectedId ?? "", category: reportCategory, explanation: reportExplanation },
{
onSuccess: () => {
reportControls.close();
setReportExplanation("");
showSuccessNotification({ title: t("reportSent"), message: t("reportSentDescription") });
},
},
);
return (
<Stack gap="lg">
<Card withBorder radius="lg" p="lg">
<Group justify="space-between" align="flex-start">
<Group align="flex-start">
<Box
p="sm"
bg="var(--mantine-color-default-hover)"
style={{ borderRadius: "var(--mantine-radius-md)", lineHeight: 0 }}
>
<IconBuildingStore size={24} />
</Box>
<Box>
<Title order={2}>{t("title")}</Title>
<Text c="dimmed" size="sm" maw={640}>
{type === "customCss" ? t("descriptionCss") : t("description")}
</Text>
</Box>
</Group>
{user ? (
<Group gap="xs">
<Badge variant="light">{user.displayName}</Badge>
<Button
size="xs"
variant="subtle"
onClick={() => {
client.signOut();
setUser(null);
}}
>
{t("signOut")}
</Button>
</Group>
) : (
<Button size="xs" loading={loginPending} onClick={signIn}>
{t("signIn")}
</Button>
)}
</Group>
</Card>
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
{type === "customCss" ? t("securityNoticeCss") : t("securityNotice")}
</Alert>
{loginError && <Alert color="red">{loginError}</Alert>}
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<TextInput
label={type === "customCss" ? t("searchCss") : t("search")}
leftSection={<IconSearch size={16} />}
rightSection={list.isFetching ? <Loader size={16} /> : undefined}
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setPage(1);
}}
/>
<Select
label={t("sort")}
value={sort}
onChange={(value) => {
setSort(value === "newest" ? "newest" : "top");
setPage(1);
}}
data={[
{ value: "top", label: t("sortTop") },
{ value: "newest", label: t("sortNewest") },
]}
allowDeselect={false}
/>
</SimpleGrid>
{list.isLoading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
<Skeleton h={280} />
<Skeleton h={280} />
<Skeleton h={280} />
</SimpleGrid>
) : list.isError ? (
<Alert color="red">{list.error instanceof Error ? list.error.message : t("unavailable")}</Alert>
) : list.data?.items.length ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
{list.data.items.map((item) => (
<Card key={item.id} withBorder radius="md" p="md">
{item.screenshots[0] ? (
<Card.Section>
<Image src={client.fileUrl(item.id, item.screenshots[0], "480x320")} h={170} alt="" />
</Card.Section>
) : (
<Card.Section>
<Box
h={170}
bg="var(--mantine-color-default-hover)"
style={{ display: "grid", placeItems: "center" }}
>
<Code>{item.type === "customCss" ? "CSS" : "JSX"}</Code>
</Box>
</Card.Section>
)}
<Stack gap="xs" mt="md">
<Group justify="space-between">
<Text size="xs" c="dimmed">
{t("author", { name: item.authorName })}
</Text>
<Text size="sm" c="dimmed">
<IconThumbUp size={13} /> {item.upvotes} · <IconThumbDown size={13} /> {item.downvotes}
</Text>
</Group>
<Badge color={item.widgetSchema === expectedSchema ? "green" : "red"} variant="light">
{item.widgetSchema}
</Badge>
<Group gap="xs">
{item.outdated && <Badge color="yellow">Outdated</Badge>}
{item.reportCount > 0 && (
<Badge color="red">
{item.reportCount} report{item.reportCount === 1 ? "" : "s"}
</Badge>
)}
</Group>
<Text fw={700} lineClamp={1}>
{item.title}
</Text>
<Text size="sm" c="dimmed" lineClamp={2}>
{item.description || t("noDescription")}
</Text>
<Button variant="light" onClick={() => setSelectedId(item.id)}>
{type === "customCss" ? t("inspectCss") : t("inspect")}
</Button>
</Stack>
</Card>
))}
</SimpleGrid>
) : (
<Text c="dimmed" ta="center" py="xl">
{type === "customCss" ? t("emptyCss") : t("empty")}
</Text>
)}
{(list.data?.totalPages ?? 0) > 1 && (
<Pagination total={list.data?.totalPages ?? 1} value={page} onChange={setPage} mx="auto" />
)}
<Modal
opened={selectedId !== null}
onClose={() => setSelectedId(null)}
title={detail.data?.title ?? t("details")}
size="xl"
>
{detail.isPending ? (
<Skeleton h={400} />
) : detail.isError || !detail.data ? (
<Alert color="red">{detail.error instanceof Error ? detail.error.message : t("loadError")}</Alert>
) : (
<Stack>
<Text>{detail.data.description}</Text>
<Text size="sm" c="dimmed">
{t("author", { name: detail.data.authorName })}
</Text>
{detail.data.outdated && (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
The author marked this submission as outdated. Review it carefully before importing.
</Alert>
)}
{detail.data.reportCount > 0 && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
This submission has {detail.data.reportCount} open community report
{detail.data.reportCount === 1 ? "" : "s"}. It remains installable, but you should review its content
first.
</Alert>
)}
{detail.data.screenshots.length > 0 && (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
{detail.data.screenshots.map((file, index) => (
<Image
key={file}
src={client.fileUrl(detail.data.id, file, "960x640")}
radius="md"
alt={`${detail.data.title} screenshot ${index + 1}`}
/>
))}
</SimpleGrid>
)}
{type === "customWidget" && detailValidation?.success && typeof detailValidation.data !== "string" && (
<CapabilitySummary widget={detailValidation.data} />
)}
{detailValidation && !detailValidation.success && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
{t("installErrorDescription")} {detailValidation.error}
</Alert>
)}
{detailValidation?.success && !detailCompatible && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
{t("installErrorDescription")}
</Alert>
)}
<Code block mah={360} style={{ overflow: "auto", whiteSpace: "pre" }}>
{detail.data.content}
</Code>
<Group justify="space-between">
<Group gap="xs">
<Button
variant="default"
leftSection={<IconDownload size={16} />}
onClick={() => downloadWorkshopSubmission(detail.data)}
>
{t("export")}
</Button>
<Button
variant="subtle"
leftSection={<IconThumbUp size={16} />}
loading={vote.isPending && vote.variables?.value === 1}
disabled={!user || vote.isPending}
onClick={() => vote.mutate({ submission: detail.data.id, value: 1 })}
>
{detail.data.upvotes}
</Button>
<Button
variant="subtle"
leftSection={<IconThumbDown size={16} />}
loading={vote.isPending && vote.variables?.value === -1}
disabled={!user || vote.isPending}
onClick={() => vote.mutate({ submission: detail.data.id, value: -1 })}
>
{detail.data.downvotes}
</Button>
<Button
variant="subtle"
color="red"
leftSection={<IconFlag size={16} />}
disabled={!user}
onClick={reportControls.open}
>
{t("report")}
</Button>
</Group>
{onInstall && (
<Button
loading={install.isPending}
disabled={!detailCompatible}
onClick={() =>
detailValidation?.success &&
typeof detailValidation.data !== "string" &&
install.mutate(detailValidation.data)
}
>
{t("install")}
</Button>
)}
{onUseCss && (
<Button
disabled={!detailCompatible}
onClick={() =>
detailValidation?.success &&
typeof detailValidation.data === "string" &&
setCssAwaitingConfirmation(detailValidation.data)
}
>
{t("useCss")}
</Button>
)}
</Group>
{!user && (
<Text size="xs" c="dimmed">
{t("signInHint")}
</Text>
)}
{vote.error && <Alert color="red">{vote.error.message || t("voteError")}</Alert>}
{install.error && <Alert color="red">{install.error.message}</Alert>}
{useCss.error && <Alert color="red">{useCss.error.message}</Alert>}
</Stack>
)}
</Modal>
<Modal opened={reportOpened} onClose={reportControls.close} title={t("reportTitle")}>
<Stack>
<Select
label={t("reportReason")}
value={reportCategory}
onChange={(value) => setReportCategory((value as WorkshopReport["category"]) ?? "other")}
data={["malicious", "spam", "copyright", "inappropriate", "other"]}
allowDeselect={false}
/>
<Textarea
label={t("reportExplanation")}
minRows={4}
value={reportExplanation}
onChange={(event) => setReportExplanation(event.currentTarget.value)}
/>
{report.error && <Alert color="red">{report.error.message}</Alert>}
<Button loading={report.isPending} disabled={reportExplanation.trim().length < 3} onClick={submitReport}>
{t("reportSend")}
</Button>
</Stack>
</Modal>
<Modal
opened={cssAwaitingConfirmation !== null}
onClose={() => setCssAwaitingConfirmation(null)}
title="Import this Custom CSS?"
>
<Stack>
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
Review community CSS before applying it. Importing replaces the current Custom CSS editor value, but nothing
is saved until you save the board settings.
</Alert>
<Group justify="flex-end">
<Button variant="default" onClick={() => setCssAwaitingConfirmation(null)}>
Cancel
</Button>
<Button
loading={useCss.isPending}
onClick={() => {
if (cssAwaitingConfirmation !== null) useCss.mutate(cssAwaitingConfirmation);
}}
>
Import CSS
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}
function CapabilitySummary({ widget }: { widget: HomarrCustomWidgetV2 }) {
const t = useScopedI18n("workshop");
return (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<Card withBorder p="sm">
<Text fw={600} size="sm" mb="xs">
{t("sources")}
</Text>
<Stack gap={6}>
{Object.entries(widget.sources).map(([sourceId, source]) => (
<Box key={sourceId}>
<Text size="sm" fw={500}>
{source.name}
</Text>
<Group gap={4}>
<Badge size="xs" variant="light">
{new URL(source.baseUrl).origin}
</Badge>
<Badge size="xs" variant="outline">
{source.networkScope}
</Badge>
<Badge size="xs" variant="outline">
{typeof source.auth === "string" ? source.auth : source.auth.type}
</Badge>
</Group>
</Box>
))}
</Stack>
</Card>
<Card withBorder p="sm">
<Text fw={600} size="sm" mb="xs">
{t("requests")}
</Text>
{Object.keys(widget.requests).length === 0 ? (
<Text size="xs" c="dimmed">
{t("noRequests")}
</Text>
) : (
<Stack gap={6}>
{Object.entries(widget.requests).map(([requestId, request]) => (
<Group key={requestId} justify="space-between" wrap="nowrap">
<Text size="xs" ff="monospace" truncate>
{requestId}
</Text>
<Group gap={4} wrap="nowrap">
<Badge size="xs">{request.kind}</Badge>
<Badge size="xs" variant="light">
{request.method}
</Badge>
<Badge size="xs" variant="outline">
{request.permission}
</Badge>
</Group>
</Group>
))}
</Stack>
)}
</Card>
</SimpleGrid>
);
}
@@ -0,0 +1,14 @@
import { WorkshopBackend } from "@homarr/workshop/backend";
import { WORKSHOP_API_URL } from "@homarr/workshop/schema";
let browserClient: WorkshopBackend | undefined;
export function createWorkshopClient() {
if (typeof document === "undefined") return new WorkshopBackend(WORKSHOP_API_URL);
if (browserClient) return browserClient;
const runtimeUrl = document.querySelector<HTMLMetaElement>('meta[name="homarr-workshop-api-url"]')?.content;
browserClient = new WorkshopBackend(runtimeUrl || WORKSHOP_API_URL);
return browserClient;
}
@@ -0,0 +1,28 @@
"use client";
import { useState } from "react";
import { Button, Modal } from "@mantine/core";
import { IconDownload } from "@tabler/icons-react";
import { WorkshopBrowser } from "./workshop-browser";
export function WorkshopCssImportButton({ onImport }: { onImport(css: string): void }) {
const [opened, setOpened] = useState(false);
return (
<>
<Button type="button" variant="light" leftSection={<IconDownload size={16} />} onClick={() => setOpened(true)}>
Import from Workshop
</Button>
<Modal opened={opened} onClose={() => setOpened(false)} title="Import Custom CSS" size="xl">
<WorkshopBrowser
type="customCss"
onUseCss={(css) => {
onImport(css);
setOpened(false);
}}
/>
</Modal>
</>
);
}
@@ -0,0 +1,62 @@
"use client";
import type { ReactNode } from "react";
import { useState } from "react";
import { Button, getDefaultZIndex, Modal, useModalsStack } from "@mantine/core";
import { IconBuildingStore } from "@tabler/icons-react";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { WorkshopBrowser } from "./workshop-browser";
interface WorkshopInstallButtonProps {
children?: ReactNode;
fullWidth?: boolean;
}
export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallButtonProps = {}) {
const t = useScopedI18n("workshop");
const stack = useModalsStack(["workshop", "review"]);
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
const zIndex = getDefaultZIndex("modal") + 10;
const closeReview = () => {
stack.close("review");
setPendingWidget(null);
};
return (
<>
<Button
variant="default"
leftSection={<IconBuildingStore size={16} />}
onClick={() => stack.open("workshop")}
fullWidth={fullWidth}
>
{children ?? t("title")}
</Button>
<Modal.Stack>
<Modal {...stack.register("workshop")} title={t("installDialog")} size="90%" zIndex={zIndex}>
<WorkshopBrowser
onInstall={async (widget) => {
setPendingWidget(widget);
stack.open("review");
}}
/>
</Modal>
<CustomWidgetImportDialog
{...stack.register("review")}
widget={pendingWidget}
zIndex={zIndex + 1}
onClose={closeReview}
onImported={() => {
stack.closeAll();
setPendingWidget(null);
}}
/>
</Modal.Stack>
</>
);
}
@@ -0,0 +1,36 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { WorkshopBrowser } from "./workshop-browser";
export function WorkshopInstaller() {
const router = useRouter();
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
const [reviewOpened, reviewControls] = useDisclosure(false);
const install = async (widget: HomarrCustomWidgetV2) => {
setPendingWidget(widget);
reviewControls.open();
};
return (
<>
<WorkshopBrowser onInstall={install} />
<CustomWidgetImportDialog
opened={reviewOpened}
widget={pendingWidget}
onClose={() => {
reviewControls.close();
setPendingWidget(null);
}}
onImported={(result) => router.push(`/manage/custom-widgets/edit/${result.id}`)}
/>
</>
);
}
@@ -0,0 +1,142 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Alert, Button, Checkbox, FileInput, Group, Modal, Stack, Textarea, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification } from "@homarr/notifications";
import { useWorkshopCreateMutation } from "@homarr/workshop/backend";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { useScopedI18n } from "@homarr/translation/client";
import { createWorkshopClient } from "./workshop-client";
export function WorkshopPublishModal({
opened,
onClose,
widget,
}: {
opened: boolean;
onClose(): void;
widget: { id: string; name: string };
}) {
const t = useScopedI18n("workshop");
const client = useMemo(createWorkshopClient, []);
const utils = clientApi.useUtils();
const [user, setUser] = useState<WorkshopUser | null>(null);
const [title, setTitle] = useState(widget.name);
const [description, setDescription] = useState("");
const [screenshots, setScreenshots] = useState<File[]>([]);
const createSubmission = useWorkshopCreateMutation(client);
const [error, setError] = useState<string | null>(null);
const [published, setPublished] = useState(false);
const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false);
const definition = clientApi.customWidget.get.useQuery({ id: widget.id }, { enabled: opened });
const privateSources = Object.entries(definition.data?.sources ?? {}).filter(
([, source]) => source.networkScope !== "public",
);
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
void client.refreshAuth().then(setUser);
return unsubscribe;
}, [client]);
useEffect(() => {
if (!opened) return;
setTitle(widget.name);
setDescription("");
setScreenshots([]);
setError(null);
setPublished(false);
setSourceUrlsReviewed(false);
}, [opened, widget.name]);
const publish = async () => {
setError(null);
try {
const definition = await utils.customWidget.export.fetch({ id: widget.id });
await createSubmission.mutateAsync({
input: { type: "customWidget", title, description, content: JSON.stringify(definition) },
screenshots,
});
setPublished(true);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("publish.error"));
}
};
return (
<Modal opened={opened} onClose={onClose} title={t("publish.title")} size="lg">
<Stack>
{published ? (
<Alert color="green">{t("publish.success")}</Alert>
) : (
<>
{!user && (
<Alert color="blue">
<Group justify="space-between">
<span>{t("publish.signInHint")}</span>
<Button
size="xs"
onClick={() =>
void client
.signInWithGitHub()
.then(setUser)
.catch((cause: Error) => {
setError(cause.message);
showErrorNotification({ title: t("signIn"), message: cause.message });
})
}
>
{t("signIn")}
</Button>
</Group>
</Alert>
)}
<TextInput
label={t("publish.titleField")}
value={title}
onChange={(event) => setTitle(event.currentTarget.value)}
required
/>
<Textarea
label={t("publish.descriptionField")}
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
minRows={3}
/>
<FileInput
label={t("publish.screenshots")}
description={t("publish.screenshotsDescription")}
accept="image/png,image/jpeg,image/webp"
multiple
value={screenshots}
onChange={setScreenshots}
/>
{privateSources.length > 0 && (
<Alert color="yellow">
{t("publish.privateSourceWarning", {
sources: privateSources.map(([sourceId, source]) => source.name ?? sourceId).join(", "),
})}
<Checkbox
mt="sm"
checked={sourceUrlsReviewed}
onChange={(event) => setSourceUrlsReviewed(event.currentTarget.checked)}
label={t("publish.privateSourceConfirmation")}
/>
</Alert>
)}
{error && <Alert color="red">{error}</Alert>}
<Button
loading={createSubmission.isPending}
disabled={!user || title.trim().length < 3 || (privateSources.length > 0 && !sourceUrlsReviewed)}
onClick={() => void publish()}
>
{t("publish.action")}
</Button>
</>
)}
</Stack>
</Modal>
);
}
+3
View File
@@ -1,3 +1,5 @@
import { z } from "zod/v4";
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
export const env = createEnv({
@@ -5,6 +7,7 @@ export const env = createEnv({
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
WORKSHOP_API_URL: z.string().url().optional(),
},
experimental__runtimeEnv: process.env,
});