feat(custom-widgets): complete Custom JSX v2 (#6390)
This commit is contained in:
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>
|
||||
|
||||
+148
@@ -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} />;
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
+62
@@ -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>
|
||||
|
||||
+48
@@ -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",
|
||||
};
|
||||
}
|
||||
+225
@@ -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 };
|
||||
}
|
||||
+228
@@ -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;
|
||||
}
|
||||
+232
@@ -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;
|
||||
}
|
||||
+37
@@ -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]);
|
||||
}
|
||||
+231
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
Reference in new issue
Block a user