fix(custom-widgets): address v2 merge review (#6446)
This commit is contained in:
1 parent
6aceb2fd1f
commit
df967751f6
123 files changed
+8632
-1477
No files matched your search
@@ -1,18 +1,23 @@
|
||||
"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="Custom Widgets are in Beta" icon={<IconFlask />}>
|
||||
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
|
||||
<Text size="sm">
|
||||
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{" "}
|
||||
{t("description")}{" "}
|
||||
<Anchor href={FEEDBACK_URL} target="_blank" fw={600}>
|
||||
official discussion thread
|
||||
{t("discussionLink")}
|
||||
</Anchor>{" "}
|
||||
will be taken into account. Happy Hacking!
|
||||
{t("closing")}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconBuildingStore, IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react";
|
||||
import {
|
||||
IconBuildingStore,
|
||||
IconCopy,
|
||||
IconDots,
|
||||
IconDownload,
|
||||
IconSparkles,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import {
|
||||
formatCustomWidgetImportIssues,
|
||||
looksLikeCustomWidgetClipboard,
|
||||
parseCustomWidgetClipboardDetailed,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import { formatCustomWidgetImportIssues, 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";
|
||||
|
||||
@@ -28,15 +31,59 @@ interface WidgetRef {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
valid: boolean;
|
||||
migrationRequired: boolean;
|
||||
}
|
||||
|
||||
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const deleteMutation = clientApi.customWidget.delete.useMutation();
|
||||
const deleteLegacyMutation = clientApi.customWidget.deleteLegacy.useMutation();
|
||||
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const [publishOpened, publishControls] = useDisclosure(false);
|
||||
const [migrationOpened, migrationControls] = useDisclosure(false);
|
||||
const migrationFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [migratedWidget, setMigratedWidget] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||
|
||||
const copyMigrationPrompt = async () => {
|
||||
try {
|
||||
const result = await utils.customWidget.legacyMigrationPrompt.fetch({ id: widget.id });
|
||||
await navigator.clipboard.writeText(result.prompt);
|
||||
showSuccessNotification({
|
||||
title: t("action.copyMigrationPrompt"),
|
||||
message: t("notification.migrationPromptCopied"),
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("action.copyMigrationPrompt"),
|
||||
message: error instanceof Error ? error.message : t("notification.migrationPromptCopyError"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMigrationFile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", (loadEvent) => {
|
||||
const result = parseCustomWidgetClipboardDetailed(loadEvent.target?.result as string);
|
||||
if (!result.success) {
|
||||
showErrorNotification({
|
||||
title: t("action.migrate"),
|
||||
message: formatCustomWidgetImportIssues(result.issues),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setMigratedWidget(result.widget);
|
||||
migrationControls.open();
|
||||
});
|
||||
reader.addEventListener("error", () => {
|
||||
showErrorNotification({ title: t("action.migrate"), message: t("notification.migrationError") });
|
||||
});
|
||||
reader.readAsText(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
@@ -77,23 +124,26 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
title: t("action.delete"),
|
||||
children: t("action.deleteConfirm", { name: widget.name }),
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate(
|
||||
{ id: widget.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("action.delete"),
|
||||
message: t("notification.deleted", { name: widget.name }),
|
||||
});
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
|
||||
},
|
||||
const callbacks = {
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("action.delete"),
|
||||
message: t("notification.deleted", { name: widget.name }),
|
||||
});
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.customWidget.available.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
);
|
||||
onError: () => {
|
||||
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
|
||||
},
|
||||
};
|
||||
if (widget.migrationRequired) {
|
||||
deleteLegacyMutation.mutate({ id: widget.id }, callbacks);
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate({ id: widget.id }, callbacks);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -125,104 +175,52 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
{widget.migrationRequired && (
|
||||
<>
|
||||
<Menu.Item onClick={() => void copyMigrationPrompt()} leftSection={<IconSparkles {...iconProps} />}>
|
||||
{t("action.copyMigrationPrompt")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => migrationFileInputRef.current?.click()}
|
||||
leftSection={<IconUpload {...iconProps} />}
|
||||
>
|
||||
{t("action.importMigration")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash {...iconProps} />}
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
disabled={deleteMutation.isPending || deleteLegacyMutation.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<HomarrCustomWidgetV2 | null>(null);
|
||||
const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
|
||||
const queueImport = useCallback(
|
||||
(value: HomarrCustomWidgetV2) => {
|
||||
setPendingImport(value);
|
||||
openReview();
|
||||
},
|
||||
[openReview],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
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();
|
||||
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, t]);
|
||||
|
||||
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", (e) => {
|
||||
try {
|
||||
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", () => {
|
||||
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
|
||||
});
|
||||
reader.readAsText(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileAffixButton
|
||||
variant="default"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title={t("action.pasteImportHint")}
|
||||
>
|
||||
{t("action.import")}
|
||||
</MobileAffixButton>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,.md,text/markdown,application/json"
|
||||
hidden
|
||||
onChange={handleImport}
|
||||
aria-label={t("importReview.fileLabel")}
|
||||
/>
|
||||
<CustomWidgetImportDialog
|
||||
opened={reviewOpened}
|
||||
widget={pendingImport}
|
||||
onClose={() => {
|
||||
closeReview();
|
||||
setPendingImport(null);
|
||||
}}
|
||||
/>
|
||||
{widget.migrationRequired && (
|
||||
<>
|
||||
<input
|
||||
ref={migrationFileInputRef}
|
||||
type="file"
|
||||
accept=".json,.md,text/markdown,application/json"
|
||||
hidden
|
||||
onChange={handleMigrationFile}
|
||||
aria-label={t("action.importMigration")}
|
||||
/>
|
||||
<CustomWidgetImportDialog
|
||||
opened={migrationOpened}
|
||||
widget={migratedWidget}
|
||||
legacyId={widget.id}
|
||||
onClose={() => {
|
||||
migrationControls.close();
|
||||
setMigratedWidget(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
import {
|
||||
filterSecretsForSourceAuthentication,
|
||||
getChangedSecrets,
|
||||
getCustomWidgetPreviewOptionIssues,
|
||||
loadPreviewQueries,
|
||||
@@ -62,4 +63,18 @@ describe("Custom Widget workbench preview options", () => {
|
||||
}),
|
||||
).toEqual([{ sourceId: "other", kind: "password", value: "replacement" }]);
|
||||
});
|
||||
|
||||
it("drops credentials that do not apply after an authentication transition", () => {
|
||||
expect(
|
||||
filterSecretsForSourceAuthentication(
|
||||
[
|
||||
{ sourceId: "default", kind: "username", value: "admin", hasValue: true },
|
||||
{ sourceId: "default", kind: "password", value: "secret", hasValue: true },
|
||||
{ sourceId: "secondary", kind: "apiKey", value: "unchanged", hasValue: true },
|
||||
],
|
||||
"default",
|
||||
"bearer",
|
||||
),
|
||||
).toEqual([{ sourceId: "secondary", kind: "apiKey", value: "unchanged", hasValue: true }]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { fetchApi } from "@homarr/api/client";
|
||||
import {
|
||||
customWidgetDefinitionSchema,
|
||||
getCustomWidgetDefaultOptions,
|
||||
getCustomWidgetRequiredSecretKinds,
|
||||
validateCustomWidgetOptions,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSource, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
@@ -33,6 +34,15 @@ export function getChangedSecrets(values: CustomWidgetFormValues) {
|
||||
}));
|
||||
}
|
||||
|
||||
export function filterSecretsForSourceAuthentication(
|
||||
secrets: CustomWidgetFormValues["secrets"],
|
||||
sourceId: string,
|
||||
authType: Parameters<typeof getCustomWidgetRequiredSecretKinds>[0],
|
||||
) {
|
||||
const requiredKinds = new Set<string>(getCustomWidgetRequiredSecretKinds(authType));
|
||||
return secrets.filter((secret) => secret.sourceId !== sourceId || requiredKinds.has(secret.kind));
|
||||
}
|
||||
|
||||
export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) {
|
||||
form.setValues({
|
||||
...form.values,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Accordion, Box, Button, Paper, SegmentedControl, Stack, TextInput, Textarea } from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
@@ -39,6 +39,7 @@ import { createCustomWidgetRenameHandlers } from "./_custom-widget-rename-handle
|
||||
import { CustomWidgetSaveIssuesAlert } from "./_custom-widget-save-issues-alert";
|
||||
import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor";
|
||||
import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions";
|
||||
import { useUnsavedChangesGuard } from "./_use-unsaved-changes-guard";
|
||||
import classes from "./_custom-widget-form.module.css";
|
||||
|
||||
interface CustomWidgetFormProps {
|
||||
@@ -71,8 +72,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
const initialDefinition = buildDefinition(formInitialValues);
|
||||
return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {};
|
||||
});
|
||||
const dirtyRef = useRef(false);
|
||||
dirtyRef.current = form.isDirty();
|
||||
useUnsavedChangesGuard(form.isDirty());
|
||||
|
||||
const candidate = useMemo(() => buildDefinition(form.values), [form.values]);
|
||||
const parsedOptions = useMemo(
|
||||
@@ -117,13 +117,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
optionsSnapshot,
|
||||
setOptionsSnapshot,
|
||||
});
|
||||
useEffect(() => {
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (dirtyRef.current) event.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", beforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", beforeUnload);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (parsedOptions.success) setOptionsSnapshot(getCustomWidgetDefaultOptions(parsedOptions.data));
|
||||
}, [parsedOptions]);
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const readSource = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8");
|
||||
|
||||
describe("legacy custom widget migration UI", () => {
|
||||
it("keeps legacy definitions visible and exposes the redacted LLM workflow", () => {
|
||||
const list = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx");
|
||||
const actions = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx");
|
||||
|
||||
expect(list).toContain("widget.migrationRequired");
|
||||
expect(list).toContain('t("page.list.migrationInstructions")');
|
||||
expect(actions).toContain("legacyMigrationPrompt.fetch");
|
||||
expect(actions).toContain("navigator.clipboard.writeText(result.prompt)");
|
||||
expect(actions).toContain("parseCustomWidgetClipboardDetailed");
|
||||
expect(actions).toContain("legacyId={widget.id}");
|
||||
});
|
||||
|
||||
it("replaces the legacy record through migration and refreshes every consumer", () => {
|
||||
const dialog = readSource("apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx");
|
||||
|
||||
expect(dialog).toContain("customWidget.migrateLegacy.useMutation");
|
||||
expect(dialog).toContain("migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets })");
|
||||
expect(dialog).toContain("utils.customWidget.list.invalidate()");
|
||||
expect(dialog).toContain("utils.customWidget.available.invalidate()");
|
||||
expect(dialog).toContain("utils.widget.customApi.getData.invalidate()");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Avatar, Badge, Card, Group, Stack, Switch, Text, Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconApi, IconPencil } from "@tabler/icons-react";
|
||||
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -70,7 +70,13 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
|
||||
<Card
|
||||
padding="sm"
|
||||
withBorder={!widget.valid}
|
||||
bd={!widget.valid ? "1px solid var(--mantine-color-red-6)" : undefined}
|
||||
bd={
|
||||
widget.migrationRequired
|
||||
? "1px solid var(--mantine-color-yellow-6)"
|
||||
: !widget.valid
|
||||
? "1px solid var(--mantine-color-red-6)"
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
opacity: widget.valid && !widget.enabled ? 0.55 : 1,
|
||||
transition: "opacity 150ms ease",
|
||||
@@ -89,18 +95,34 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
|
||||
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
|
||||
{widget.name}
|
||||
</Text>
|
||||
{!widget.valid && (
|
||||
{widget.migrationRequired ? (
|
||||
<Text size="xs" c="yellow.8">
|
||||
{t("page.list.migrationDescription")}
|
||||
</Text>
|
||||
) : !widget.valid ? (
|
||||
<Text size="xs" c="red">
|
||||
{t("page.list.invalidDefinitionDescription")}
|
||||
</Text>
|
||||
)}
|
||||
) : null}
|
||||
{widget.sources[0] && (
|
||||
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1} style={{ wordBreak: "break-all", minWidth: 0 }}>
|
||||
{widget.sources.map((source) => source.origin).join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{widget.valid ? (
|
||||
{widget.migrationRequired ? (
|
||||
<Tooltip label={t("page.list.migrationInstructions")} multiline maw={420}>
|
||||
<Badge
|
||||
color="yellow"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={12} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("page.list.migrationRequired")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : widget.valid ? (
|
||||
<Badge color="pink" size="sm" variant="light" style={{ flexShrink: 0 }}>
|
||||
JSX · {widget.requestCount}
|
||||
</Badge>
|
||||
@@ -166,7 +188,13 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
|
||||
</>
|
||||
)}
|
||||
<CustomWidgetRowActions
|
||||
widget={{ id: widget.id, name: widget.name, enabled: widget.enabled, valid: widget.valid }}
|
||||
widget={{
|
||||
id: widget.id,
|
||||
name: widget.name,
|
||||
enabled: widget.enabled,
|
||||
valid: widget.valid,
|
||||
migrationRequired: widget.migrationRequired,
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getDependentRequestIds, removeDependentRequests } from "./_custom-widget-source-dependencies";
|
||||
|
||||
const requests = JSON.stringify({
|
||||
status: { source: "default", path: "/status" },
|
||||
restart: { source: "secondary", path: "/restart", kind: "action" },
|
||||
logs: { source: "secondary", path: "/logs" },
|
||||
});
|
||||
|
||||
describe("custom widget source dependencies", () => {
|
||||
it("lists every request that would be removed with a source", () => {
|
||||
expect(getDependentRequestIds(requests, "secondary")).toEqual(["restart", "logs"]);
|
||||
});
|
||||
|
||||
it("removes only requests that use the selected source", () => {
|
||||
expect(removeDependentRequests(requests, "secondary")).toEqual({
|
||||
status: { source: "default", path: "/status" },
|
||||
});
|
||||
});
|
||||
});
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
export function getDependentRequestIds(requestsJson: string, sourceId: string) {
|
||||
const requests = parseRequestRecord(requestsJson);
|
||||
return Object.entries(requests).flatMap(([requestId, request]) =>
|
||||
isRecord(request) && request.source === sourceId ? [requestId] : [],
|
||||
);
|
||||
}
|
||||
|
||||
export function removeDependentRequests(requestsJson: string, sourceId: string) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(parseRequestRecord(requestsJson)).filter(
|
||||
([, request]) => !isRecord(request) || request.source !== sourceId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function parseRequestRecord(requestsJson: string): Record<string, unknown> {
|
||||
try {
|
||||
const requests = JSON.parse(requestsJson) as unknown;
|
||||
return isRecord(requests) ? requests : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Fieldset, Group, PasswordInput, Select, Stack, TextInput } from "@mantine/core";
|
||||
import { IconKey, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils";
|
||||
import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input";
|
||||
|
||||
type SecretKind = "apiKey" | "username" | "password";
|
||||
|
||||
const secretFields: Record<string, Array<{ kind: SecretKind }>> = {
|
||||
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",
|
||||
};
|
||||
|
||||
interface CustomWidgetSourceFieldProps {
|
||||
source: CustomWidgetSource & { id: string };
|
||||
index: number;
|
||||
form: CustomWidgetWorkbenchForm;
|
||||
definitionId?: string;
|
||||
clearSecretPending: boolean;
|
||||
onUpdate(index: number, changes: Partial<CustomWidgetSource> & { id?: string }): void;
|
||||
onSetAuthentication(index: number, type: string): void;
|
||||
onSetSecret(sourceId: string, kind: string, value: string): void;
|
||||
onClearSecret(sourceId: string, kind: SecretKind): Promise<void>;
|
||||
onRemove(index: number): void;
|
||||
}
|
||||
|
||||
export function CustomWidgetSourceField({
|
||||
source,
|
||||
index,
|
||||
form,
|
||||
definitionId,
|
||||
clearSecretPending,
|
||||
onUpdate,
|
||||
onSetAuthentication,
|
||||
onSetSecret,
|
||||
onClearSecret,
|
||||
onRemove,
|
||||
}: CustomWidgetSourceFieldProps) {
|
||||
const t = useScopedI18n("customWidget.workbench.sources");
|
||||
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
|
||||
const authType = typeof source.auth === "string" ? source.auth : source.auth.type;
|
||||
|
||||
return (
|
||||
<Fieldset 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) => onUpdate(index, { id: value })}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("name")}
|
||||
value={source.name}
|
||||
onChange={(event) => onUpdate(index, { name: event.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label={t("baseUrl")}
|
||||
type="url"
|
||||
value={source.baseUrl}
|
||||
error={baseUrlIssue ? t(sourceUrlErrorKeys[baseUrlIssue]) : undefined}
|
||||
onChange={(event) => onUpdate(index, { baseUrl: event.currentTarget.value })}
|
||||
/>
|
||||
<Group grow align="start">
|
||||
<Select
|
||||
label={t("networkScope")}
|
||||
data={["public", "private", "loopback"]}
|
||||
value={source.networkScope}
|
||||
onChange={(value) =>
|
||||
value && onUpdate(index, { networkScope: value as CustomWidgetSource["networkScope"] })
|
||||
}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Select
|
||||
label={t("authentication")}
|
||||
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
|
||||
value={authType}
|
||||
onChange={(value) => onSetAuthentication(index, value ?? "none")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
|
||||
<TextInput
|
||||
label={t("headerName")}
|
||||
value={source.auth.name}
|
||||
onChange={(event) => onUpdate(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) => onUpdate(index, { auth: { type: "apiKeyQuery", name: event.currentTarget.value } })}
|
||||
/>
|
||||
)}
|
||||
{(secretFields[authType] ?? []).map(({ kind }) => {
|
||||
const secret = form.values.secrets.find((entry) => entry.sourceId === source.id && entry.kind === kind);
|
||||
const Input = kind === "username" ? TextInput : PasswordInput;
|
||||
return (
|
||||
<Group key={kind} align="end" wrap="nowrap">
|
||||
<Input
|
||||
style={{ flex: 1 }}
|
||||
label={t(`secret.${kind}`)}
|
||||
value={secret?.value ?? ""}
|
||||
placeholder={secret?.hasValue ? t("configured") : undefined}
|
||||
leftSection={<IconKey size={15} />}
|
||||
onChange={(event) => onSetSecret(source.id, kind, event.currentTarget.value)}
|
||||
/>
|
||||
{definitionId && secret?.hasValue && (
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
loading={clearSecretPending}
|
||||
onClick={() => void onClearSecret(source.id, kind)}
|
||||
>
|
||||
{t("clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
{index > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={() => onRemove(index)}
|
||||
>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
+58
-137
@@ -1,31 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Accordion, Button, Fieldset, Group, PasswordInput, Select, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconKey, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { Accordion, Button, List, Stack, Text } from "@mantine/core";
|
||||
import { IconPlus } 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 type { CustomWidgetSource } from "@homarr/custom-widgets/core";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils";
|
||||
import { filterSecretsForSourceAuthentication, 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",
|
||||
};
|
||||
import { getDependentRequestIds, removeDependentRequests } from "./_custom-widget-source-dependencies";
|
||||
import { CustomWidgetSourceField } from "./_custom-widget-source-field";
|
||||
|
||||
export function CustomWidgetSourcesEditor({
|
||||
form,
|
||||
@@ -35,6 +22,8 @@ export function CustomWidgetSourcesEditor({
|
||||
definitionId?: string;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget.workbench.sources");
|
||||
const w = useScopedI18n("customWidget.workbench");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const utils = clientApi.useUtils();
|
||||
const clearSecretMutation = clientApi.customWidget.secretClear.useMutation();
|
||||
const sources = parseSources(form.values.sources);
|
||||
@@ -83,6 +72,14 @@ export function CustomWidgetSourcesEditor({
|
||||
const auth =
|
||||
type === "apiKeyHeader" ? { type, name: "X-API-Key" } : type === "apiKeyQuery" ? { type, name: "api_key" } : type;
|
||||
update(index, { auth: auth as CustomWidgetSource["auth"] });
|
||||
form.setFieldValue(
|
||||
"secrets",
|
||||
filterSecretsForSourceAuthentication(
|
||||
form.values.secrets,
|
||||
sources[index]?.id ?? "",
|
||||
type as Parameters<typeof filterSecretsForSourceAuthentication>[2],
|
||||
),
|
||||
);
|
||||
};
|
||||
const setSecret = (sourceId: string, kind: string, value: string) => {
|
||||
const current = form.values.secrets.filter((secret) => !(secret.sourceId === sourceId && secret.kind === kind));
|
||||
@@ -111,7 +108,7 @@ export function CustomWidgetSourcesEditor({
|
||||
),
|
||||
);
|
||||
};
|
||||
const removeSource = (index: number) => {
|
||||
const removeSourceAndDependents = (index: number) => {
|
||||
const removedId = sources[index]?.id;
|
||||
form.setFieldValue(
|
||||
"sources",
|
||||
@@ -122,25 +119,38 @@ export function CustomWidgetSourcesEditor({
|
||||
),
|
||||
);
|
||||
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("requests", JSON.stringify(removeDependentRequests(form.values.requests, removedId), null, 2));
|
||||
form.setFieldValue(
|
||||
"secrets",
|
||||
form.values.secrets.filter((secret) => secret.sourceId !== removedId),
|
||||
);
|
||||
};
|
||||
const removeSource = (index: number) => {
|
||||
const removedId = sources[index]?.id;
|
||||
if (!removedId) return;
|
||||
const dependentRequestIds = getDependentRequestIds(form.values.requests, removedId);
|
||||
if (dependentRequestIds.length === 0) {
|
||||
removeSourceAndDependents(index);
|
||||
return;
|
||||
}
|
||||
openConfirmModal({
|
||||
title: t("remove"),
|
||||
children: (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{w("requests.title")} ({dependentRequestIds.length})
|
||||
</Text>
|
||||
<List size="sm">
|
||||
{dependentRequestIds.map((requestId) => (
|
||||
<List.Item key={requestId}>{requestId}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
),
|
||||
confirmProps: { children: t("remove") },
|
||||
onConfirm: () => removeSourceAndDependents(index),
|
||||
});
|
||||
};
|
||||
const clearSecret = async (sourceId: string, kind: "apiKey" | "username" | "password") => {
|
||||
if (!definitionId) return;
|
||||
await clearSecretMutation.mutateAsync({ definitionId, sourceId, kind });
|
||||
@@ -153,109 +163,20 @@ export function CustomWidgetSourcesEditor({
|
||||
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>
|
||||
<CustomWidgetSourceField
|
||||
key={source.id}
|
||||
source={source}
|
||||
index={index}
|
||||
form={form}
|
||||
definitionId={definitionId}
|
||||
clearSecretPending={clearSecretMutation.isPending}
|
||||
onUpdate={update}
|
||||
onSetAuthentication={setAuth}
|
||||
onSetSecret={setSecret}
|
||||
onClearSecret={clearSecret}
|
||||
onRemove={removeSource}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Accordion variant="contained">
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import {
|
||||
formatCustomWidgetImportIssues,
|
||||
looksLikeCustomWidgetClipboard,
|
||||
parseCustomWidgetClipboardDetailed,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
|
||||
export const ImportCustomWidgetButton = () => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pendingImport, setPendingImport] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||
const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false);
|
||||
const queueImport = useCallback(
|
||||
(value: HomarrCustomWidgetV2) => {
|
||||
setPendingImport(value);
|
||||
openReview();
|
||||
},
|
||||
[openReview],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
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();
|
||||
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, t]);
|
||||
|
||||
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", (loadEvent) => {
|
||||
try {
|
||||
const result = parseCustomWidgetClipboardDetailed(loadEvent.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", () => {
|
||||
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
|
||||
});
|
||||
reader.readAsText(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileAffixButton
|
||||
variant="default"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title={t("action.pasteImportHint")}
|
||||
>
|
||||
{t("action.import")}
|
||||
</MobileAffixButton>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,.md,text/markdown,application/json"
|
||||
hidden
|
||||
onChange={handleImport}
|
||||
aria-label={t("importReview.fileLabel")}
|
||||
/>
|
||||
<CustomWidgetImportDialog
|
||||
opened={reviewOpened}
|
||||
widget={pendingImport}
|
||||
onClose={() => {
|
||||
closeReview();
|
||||
setPendingImport(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
interface UnsavedChangesGuardOptions {
|
||||
isDirty(): boolean;
|
||||
confirmNavigation(href: string): void;
|
||||
}
|
||||
|
||||
export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: UnsavedChangesGuardOptions) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (!isDirty() || event.defaultPrevented || event.button !== 0) return;
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
|
||||
const eventTarget = event.target instanceof Element ? event.target : null;
|
||||
const anchor = eventTarget?.closest<HTMLAnchorElement>("a[href]");
|
||||
if (!anchor || anchor.target === "_blank" || anchor.hasAttribute("download")) return;
|
||||
|
||||
const destination = new URL(anchor.href, window.location.href);
|
||||
const current = new URL(window.location.href);
|
||||
if (destination.origin !== current.origin) return;
|
||||
if (
|
||||
destination.pathname === current.pathname &&
|
||||
destination.search === current.search &&
|
||||
destination.hash !== current.hash
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
confirmNavigation(`${destination.pathname}${destination.search}${destination.hash}`);
|
||||
};
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!isDirty()) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = true;
|
||||
};
|
||||
|
||||
document.addEventListener("click", handleClick, true);
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClick, true);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { registerUnsavedChangesGuard } from "./_unsaved-changes-guard";
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
afterEach(() => {
|
||||
cleanups.splice(0).forEach((cleanup) => cleanup());
|
||||
document.body.replaceChildren();
|
||||
window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1");
|
||||
});
|
||||
|
||||
describe("custom widget unsaved changes guard", () => {
|
||||
it("blocks client-side link navigation and requests confirmation for a dirty form", () => {
|
||||
window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1");
|
||||
const confirmNavigation = vi.fn();
|
||||
cleanups.push(registerUnsavedChangesGuard({ isDirty: () => true, confirmNavigation }));
|
||||
const link = document.createElement("a");
|
||||
link.href = "/en/manage/custom-widgets";
|
||||
link.textContent = "Custom widgets";
|
||||
document.body.append(link);
|
||||
|
||||
const event = new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 });
|
||||
link.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(confirmNavigation).toHaveBeenCalledWith("/en/manage/custom-widgets");
|
||||
});
|
||||
|
||||
it("allows section links and navigation after the form is clean", () => {
|
||||
window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1");
|
||||
let dirty = true;
|
||||
const confirmNavigation = vi.fn();
|
||||
cleanups.push(registerUnsavedChangesGuard({ isDirty: () => dirty, confirmNavigation }));
|
||||
const sectionLink = document.createElement("a");
|
||||
sectionLink.href = "#sources";
|
||||
document.body.append(sectionLink);
|
||||
|
||||
expect(sectionLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))).toBe(
|
||||
true,
|
||||
);
|
||||
dirty = false;
|
||||
const listLink = document.createElement("a");
|
||||
listLink.href = "/en/manage/custom-widgets";
|
||||
document.body.append(listLink);
|
||||
expect(listLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))).toBe(true);
|
||||
expect(confirmNavigation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { registerUnsavedChangesGuard } from "./_unsaved-changes-guard";
|
||||
|
||||
export function useUnsavedChangesGuard(isDirty: boolean) {
|
||||
const t = useI18n();
|
||||
const router = useRouter();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const dirtyRef = useRef(isDirty);
|
||||
const confirmNavigationRef = useRef<(href: string) => void>(() => undefined);
|
||||
dirtyRef.current = isDirty;
|
||||
confirmNavigationRef.current = (href) => {
|
||||
openConfirmModal({
|
||||
title: t("board.action.edit.confirmLeave.title"),
|
||||
children: t("board.action.edit.confirmLeave.message"),
|
||||
confirmProps: { children: t("common.action.discard") },
|
||||
onConfirm: () => router.push(href),
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
registerUnsavedChangesGuard({
|
||||
isDirty: () => dirtyRef.current,
|
||||
confirmNavigation: (href) => confirmNavigationRef.current(href),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { CustomWidgetBetaBanner } from "./_beta-banner";
|
||||
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
|
||||
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
|
||||
import { ImportCustomWidgetButton } from "./_import-custom-widget-button";
|
||||
import { CustomWidgetList } from "./_custom-widget-list";
|
||||
|
||||
export default async function CustomWidgetsPage() {
|
||||
|
||||
@@ -16,6 +16,8 @@ import { customWidgetSourceSchema, hasSameCustomWidgetSourceAuthentication } fro
|
||||
import { and, db, eq, handleTransactionsAsync } from "@homarr/db";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
|
||||
import { readConfigurationRequestBody } from "../body";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
@@ -44,15 +46,18 @@ export async function POST(request: NextRequest, context: RouteContext) {
|
||||
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 parsedBody = await readConfigurationRequestBody(request);
|
||||
if (parsedBody.status === "too-large") {
|
||||
return NextResponse.json({ error: "The configuration payload is too large." }, { status: 413 });
|
||||
}
|
||||
if (parsedBody.status !== "ok") {
|
||||
return NextResponse.json({ error: "Enter a valid source configuration." }, { status: 400 });
|
||||
}
|
||||
const body = parsedBody.data;
|
||||
const sourceResult = customWidgetSourceSchema.safeParse({
|
||||
...pending.source,
|
||||
baseUrl: body?.baseUrl,
|
||||
networkScope: body?.networkScope,
|
||||
baseUrl: body.baseUrl,
|
||||
networkScope: body.networkScope,
|
||||
});
|
||||
if (!sourceResult.success) {
|
||||
return NextResponse.json(
|
||||
@@ -61,7 +66,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
|
||||
);
|
||||
}
|
||||
const secrets = pending.kinds.flatMap((kind) => {
|
||||
const value = body?.secrets?.[kind];
|
||||
const value = body.secrets[kind];
|
||||
return typeof value === "string" && value.length > 0 && value.length <= 8192
|
||||
? [{ sourceId: pending.sourceId, kind, value }]
|
||||
: [];
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MAX_CONFIGURATION_REQUEST_BODY_BYTES, readConfigurationRequestBody } from "./body";
|
||||
|
||||
describe("custom widget configuration request body", () => {
|
||||
it("rejects a streaming payload once it exceeds the byte budget", async () => {
|
||||
const request = new Request("http://localhost/configure", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
baseUrl: "https://example.com",
|
||||
networkScope: "public",
|
||||
secrets: { apiKey: "x".repeat(MAX_CONFIGURATION_REQUEST_BODY_BYTES) },
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(readConfigurationRequestBody(request)).resolves.toEqual({ status: "too-large" });
|
||||
});
|
||||
|
||||
it("strictly parses a bounded configuration object", async () => {
|
||||
const body = { baseUrl: "https://example.com", networkScope: "public", secrets: { apiKey: "secret" } };
|
||||
await expect(
|
||||
readConfigurationRequestBody(
|
||||
new Request("http://localhost/configure", { method: "POST", body: JSON.stringify(body) }),
|
||||
),
|
||||
).resolves.toEqual({ status: "ok", data: body });
|
||||
|
||||
await expect(
|
||||
readConfigurationRequestBody(
|
||||
new Request("http://localhost/configure", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...body, unexpected: true }),
|
||||
}),
|
||||
),
|
||||
).resolves.toEqual({ status: "invalid" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const MAX_CONFIGURATION_REQUEST_BODY_BYTES = 24 * 1024;
|
||||
const configurationRequestBodySchema = z.strictObject({
|
||||
baseUrl: z.string(),
|
||||
networkScope: z.enum(["public", "private", "loopback"]),
|
||||
secrets: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
export async function readConfigurationRequestBody(
|
||||
request: Request,
|
||||
): Promise<
|
||||
{ status: "ok"; data: z.infer<typeof configurationRequestBodySchema> } | { status: "invalid" | "too-large" }
|
||||
> {
|
||||
const contentLength = request.headers.get("content-length");
|
||||
if (contentLength && Number(contentLength) > MAX_CONFIGURATION_REQUEST_BODY_BYTES) {
|
||||
return { status: "too-large" };
|
||||
}
|
||||
if (!request.body) return { status: "invalid" };
|
||||
|
||||
const reader = request.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let byteLength = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
byteLength += value.byteLength;
|
||||
if (byteLength > MAX_CONFIGURATION_REQUEST_BODY_BYTES) {
|
||||
await reader.cancel();
|
||||
return { status: "too-large" };
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} catch {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(byteLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
try {
|
||||
const candidate = JSON.parse(new TextDecoder().decode(bytes)) as unknown;
|
||||
const result = configurationRequestBodySchema.safeParse(candidate);
|
||||
return result.success ? { status: "ok", data: result.data } : { status: "invalid" };
|
||||
} catch {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
authenticate: vi.fn(),
|
||||
buildPrompt: vi.fn((request?: string, documentationUrl?: string) =>
|
||||
[`Request: ${request ?? ""}`, `Documentation: ${documentationUrl ?? ""}`].join("\n"),
|
||||
),
|
||||
getComponent: vi.fn((name: string) => (name === "Text Input" ? { name, safety: "allowed" } : null)),
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) }));
|
||||
vi.mock("@homarr/api/mcp", () => ({
|
||||
createTRPCContext: vi.fn(() => ({})),
|
||||
mcpRouter: { createCaller: vi.fn(() => ({})) },
|
||||
}));
|
||||
vi.mock("@homarr/auth/api-key", () => ({
|
||||
API_KEY_HEADER_NAME: "ApiKey",
|
||||
getSessionFromApiKeyAsync: mocks.authenticate,
|
||||
}));
|
||||
vi.mock("@homarr/common", () => ({ extractBaseUrlFromHeaders: () => "http://localhost" }));
|
||||
vi.mock("@homarr/common/server", () => ({ ipAddressFromHeaders: () => "127.0.0.1" }));
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@homarr/custom-widgets/authoring-prompt", () => ({ buildCustomWidgetMcpPrompt: mocks.buildPrompt }));
|
||||
vi.mock("@homarr/custom-widgets/authoring-resources", () => ({
|
||||
getCustomWidgetComponent: mocks.getComponent,
|
||||
getCustomWidgetComponentCatalog: () => ({ components: [{ name: "Text Input" }] }),
|
||||
getCustomWidgetSkillContent: () => "# Custom Widget Skill",
|
||||
}));
|
||||
vi.mock("@homarr/custom-widgets/core", () => ({
|
||||
customJsxExamples: [{ id: "status-card", title: "Status card", template: "<Text>Status</Text>" }],
|
||||
getCustomWidgetJsonSchema: () => ({ type: "object", title: "Custom Widget" }),
|
||||
}));
|
||||
vi.mock("@homarr/db", () => ({ db: {} }));
|
||||
vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" }));
|
||||
vi.mock("../_extract-tools", () => ({ extractMcpTools: () => [] }));
|
||||
|
||||
import { POST } from "./route";
|
||||
|
||||
interface JsonRpcResponse {
|
||||
jsonrpc: "2.0";
|
||||
id: number | null;
|
||||
result?: Record<string, unknown>;
|
||||
error?: { code: number; message: string };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.authenticate.mockResolvedValue({ user: { id: "user-1" } });
|
||||
mocks.buildPrompt.mockClear();
|
||||
mocks.getComponent.mockClear();
|
||||
});
|
||||
|
||||
async function callMcp(method: string, params: Record<string, unknown> = {}, id = 1) {
|
||||
const request = new Request("http://localhost/api/mcp/mcp", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json, text/event-stream",
|
||||
ApiKey: "key.secret",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
|
||||
});
|
||||
const response = await POST(request as never);
|
||||
const text = await response.text();
|
||||
const data = response.headers.get("content-type")?.includes("text/event-stream")
|
||||
? text
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("data: "))
|
||||
?.slice("data: ".length)
|
||||
: text;
|
||||
if (!data) throw new Error(`MCP response did not contain JSON-RPC data: ${text}`);
|
||||
return { response, body: JSON.parse(data) as JsonRpcResponse };
|
||||
}
|
||||
|
||||
describe("authenticated MCP prompt protocol", () => {
|
||||
test("initializes and advertises prompt and resource capabilities", async () => {
|
||||
const { response, body } = await callMcp("initialize", {
|
||||
protocolVersion: "2025-03-26",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "route-test", version: "1.0.0" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.error).toBeUndefined();
|
||||
expect(body.result).toMatchObject({
|
||||
protocolVersion: expect.any(String),
|
||||
capabilities: { prompts: {}, resources: {} },
|
||||
serverInfo: { name: "homarr", version: "test-version" },
|
||||
});
|
||||
expect(mocks.authenticate).toHaveBeenCalledWith({}, "key.secret", "127.0.0.1", "MCP route test");
|
||||
});
|
||||
|
||||
test("lists and interpolates the custom-widget authoring prompt", async () => {
|
||||
const listed = await callMcp("prompts/list");
|
||||
expect(listed.body.result).toEqual({
|
||||
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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const rendered = await callMcp("prompts/get", {
|
||||
name: "homarr-custom-widget-author",
|
||||
arguments: { request: "Build a status card", documentationUrl: "https://docs.example.test/api" },
|
||||
});
|
||||
expect(rendered.body.result).toMatchObject({
|
||||
description: "Current Homarr Custom Widget authoring workflow.",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "Request: Build a status card\nDocumentation: https://docs.example.test/api",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mocks.buildPrompt).toHaveBeenCalledWith("Build a status card", "https://docs.example.test/api");
|
||||
});
|
||||
});
|
||||
|
||||
describe("authenticated MCP resource protocol", () => {
|
||||
test("lists concrete resources and URI templates with reviewed MIME types", async () => {
|
||||
const listed = await callMcp("resources/list");
|
||||
expect(listed.body.result).toEqual({
|
||||
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" },
|
||||
{
|
||||
uri: "homarr://custom-widgets/examples/status-card",
|
||||
name: "Custom Widget example: Status card",
|
||||
mimeType: "application/json",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const templates = await callMcp("resources/templates/list");
|
||||
expect(templates.body.result).toEqual({
|
||||
resourceTemplates: [
|
||||
expect.objectContaining({
|
||||
uriTemplate: "homarr://custom-widgets/components/{name}",
|
||||
mimeType: "application/json",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
uriTemplate: "homarr://custom-widgets/examples/{name}",
|
||||
mimeType: "application/json",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
["homarr://custom-widgets/schema", "application/schema+json", { type: "object", title: "Custom Widget" }],
|
||||
["homarr://custom-widgets/components", "application/json", { components: [{ name: "Text Input" }] }],
|
||||
["homarr://custom-widgets/skill", "text/markdown", "# Custom Widget Skill"],
|
||||
[
|
||||
"homarr://custom-widgets/examples/status-card",
|
||||
"application/json",
|
||||
{ id: "status-card", title: "Status card", template: "<Text>Status</Text>" },
|
||||
],
|
||||
["homarr://custom-widgets/components/Text%20Input", "application/json", { name: "Text Input", safety: "allowed" }],
|
||||
])("dispatches %s with its MIME type", async (uri, mimeType, expected) => {
|
||||
const { body } = await callMcp("resources/read", { uri });
|
||||
const contents = body.result?.contents as Array<{ uri: string; mimeType: string; text: string }> | undefined;
|
||||
const content = contents?.[0];
|
||||
expect(content).toMatchObject({ uri, mimeType });
|
||||
expect(mimeType === "text/markdown" ? content?.text : JSON.parse(content?.text ?? "null")).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP protocol errors", () => {
|
||||
test.each([
|
||||
["prompts/get", { name: "unknown" }],
|
||||
["resources/read", { uri: "homarr://custom-widgets/unknown" }],
|
||||
])("returns JSON-RPC errors for unknown %s targets", async (method, params) => {
|
||||
const { body } = await callMcp(method, params);
|
||||
expect(body.result).toBeUndefined();
|
||||
expect(body.error).toEqual(expect.objectContaining({ code: expect.any(Number), message: expect.any(String) }));
|
||||
});
|
||||
|
||||
test("returns a protocol error for a malformed resource request", async () => {
|
||||
const { body } = await callMcp("resources/read", {});
|
||||
expect(body.result).toBeUndefined();
|
||||
expect(body.error).toEqual(expect.objectContaining({ code: -32603, message: expect.any(String) }));
|
||||
});
|
||||
});
|
||||
@@ -249,8 +249,10 @@ const mcpHandler = createMcpHandler(
|
||||
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());
|
||||
if (uri === "homarr://custom-widgets/schema") {
|
||||
text = JSON.stringify(getCustomWidgetJsonSchema());
|
||||
mimeType = "application/schema+json";
|
||||
} else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog());
|
||||
else if (uri === "homarr://custom-widgets/skill") {
|
||||
text = getCustomWidgetSkillContent();
|
||||
mimeType = "text/markdown";
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
interface CustomWidgetImportDialogProps {
|
||||
opened: boolean;
|
||||
widget: HomarrCustomWidgetV2 | null;
|
||||
legacyId?: string;
|
||||
stackId?: string;
|
||||
zIndex?: number;
|
||||
onClose(): void;
|
||||
@@ -33,6 +34,7 @@ interface CustomWidgetImportDialogProps {
|
||||
export function CustomWidgetImportDialog({
|
||||
opened,
|
||||
widget,
|
||||
legacyId,
|
||||
stackId,
|
||||
zIndex,
|
||||
onClose,
|
||||
@@ -59,19 +61,27 @@ export function CustomWidgetImportDialog({
|
||||
};
|
||||
}, [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") });
|
||||
},
|
||||
});
|
||||
const onSuccess = (result: { id: string }) => {
|
||||
showSuccessNotification({
|
||||
title: legacyId ? t("action.migrate") : t("action.import"),
|
||||
message: legacyId ? t("notification.migrated") : t("notification.imported"),
|
||||
});
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.customWidget.available.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh());
|
||||
onImported?.(result);
|
||||
onClose();
|
||||
};
|
||||
const onError = (error: { message?: string }) => {
|
||||
showErrorNotification({
|
||||
title: legacyId ? t("action.migrate") : t("action.import"),
|
||||
message: error.message || (legacyId ? t("notification.migrationError") : t("notification.importError")),
|
||||
});
|
||||
};
|
||||
const importMutation = clientApi.customWidget.import.useMutation({ onSuccess, onError });
|
||||
const migrateMutation = clientApi.customWidget.migrateLegacy.useMutation({ onSuccess, onError });
|
||||
const pending = importMutation.isPending || migrateMutation.isPending;
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setValues(createCustomWidgetSourceSetupValues(setups));
|
||||
@@ -84,7 +94,8 @@ export function CustomWidgetImportDialog({
|
||||
secret?.trim() ? [{ sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [],
|
||||
),
|
||||
);
|
||||
mutation.mutate({ widget: configuredWidget, secrets });
|
||||
if (legacyId) migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets });
|
||||
else importMutation.mutate({ widget: configuredWidget, secrets });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -93,13 +104,13 @@ export function CustomWidgetImportDialog({
|
||||
stackId={stackId}
|
||||
zIndex={zIndex}
|
||||
review={review}
|
||||
pending={mutation.isPending}
|
||||
pending={pending}
|
||||
confirmDisabled={!isCustomWidgetSourceSetupReady(setups, values)}
|
||||
onClose={onClose}
|
||||
onConfirm={importWidget}
|
||||
messages={{
|
||||
title: t("importReview.title"),
|
||||
description: t("importReview.description"),
|
||||
title: legacyId ? t("importReview.migrationTitle") : t("importReview.title"),
|
||||
description: legacyId ? t("importReview.migrationDescription") : t("importReview.description"),
|
||||
name: t("importReview.name"),
|
||||
origin: t("importReview.origin"),
|
||||
authentication: t("importReview.authentication"),
|
||||
@@ -109,7 +120,7 @@ export function CustomWidgetImportDialog({
|
||||
actionWarningTitle: t("importReview.actionWarning.title"),
|
||||
actionWarningDescription: t("importReview.actionWarning.description"),
|
||||
cancel: t("importReview.cancel"),
|
||||
confirm: t("importReview.confirm"),
|
||||
confirm: legacyId ? t("importReview.confirmMigration") : t("importReview.confirm"),
|
||||
permission: (permission) => t(`preview.request.permission.${permission}` as never),
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getPrivateWorkshopSourceNames,
|
||||
serializeWorkshopDefinition,
|
||||
workshopDefinitionChanged,
|
||||
} from "./workshop-publish-definition";
|
||||
|
||||
const definition = {
|
||||
name: "Status",
|
||||
sources: {
|
||||
public: { name: "Public API", networkScope: "public" },
|
||||
private: { name: "Home server", networkScope: "private" },
|
||||
local: { networkScope: "loopback" },
|
||||
},
|
||||
};
|
||||
|
||||
describe("Workshop publish definition inspection", () => {
|
||||
it("reports every source URL that requires review", () => {
|
||||
expect(getPrivateWorkshopSourceNames(definition)).toEqual(["Home server", "local"]);
|
||||
});
|
||||
|
||||
it("detects an export that changed after inspection", () => {
|
||||
expect(workshopDefinitionChanged(definition, definition)).toBe(false);
|
||||
expect(workshopDefinitionChanged(definition, { ...definition, name: "Updated" })).toBe(true);
|
||||
});
|
||||
|
||||
it("submits the same refreshed export that passed the change check", () => {
|
||||
const source = readFileSync(
|
||||
`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx`,
|
||||
"utf8",
|
||||
);
|
||||
expect(source).toContain("customWidget.export.useQuery");
|
||||
expect(source).toContain("await definition.refetch()");
|
||||
expect(source).toContain("workshopDefinitionChanged(inspectedDefinition, refreshed.data)");
|
||||
expect(source).toContain("content: serializeWorkshopDefinition(refreshed.data)");
|
||||
expect(source).toMatch(/disabled=\{[\s\S]*!definition\.data[\s\S]*definition\.isError/u);
|
||||
expect(serializeWorkshopDefinition(definition)).toBe(JSON.stringify(definition));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
interface PublishableWorkshopDefinition {
|
||||
sources: Record<string, { name?: string; networkScope: string }>;
|
||||
}
|
||||
|
||||
export const serializeWorkshopDefinition = (definition: unknown): string => {
|
||||
const serialized = JSON.stringify(definition);
|
||||
if (serialized === undefined) throw new Error("Workshop definition is not serializable");
|
||||
return serialized;
|
||||
};
|
||||
|
||||
export const workshopDefinitionChanged = (previous: unknown, current: unknown) =>
|
||||
serializeWorkshopDefinition(previous) !== serializeWorkshopDefinition(current);
|
||||
|
||||
export const getPrivateWorkshopSourceNames = (definition: PublishableWorkshopDefinition | undefined) =>
|
||||
Object.entries(definition?.sources ?? {}).flatMap(([sourceId, source]) =>
|
||||
source.networkScope === "public" ? [] : [source.name ?? sourceId],
|
||||
);
|
||||
@@ -10,6 +10,11 @@ import type { WorkshopUser } from "@homarr/workshop/schema";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createWorkshopClient } from "./workshop-client";
|
||||
import {
|
||||
getPrivateWorkshopSourceNames,
|
||||
serializeWorkshopDefinition,
|
||||
workshopDefinitionChanged,
|
||||
} from "./workshop-publish-definition";
|
||||
|
||||
export function WorkshopPublishModal({
|
||||
opened,
|
||||
@@ -22,7 +27,6 @@ export function WorkshopPublishModal({
|
||||
}) {
|
||||
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("");
|
||||
@@ -31,10 +35,9 @@ export function WorkshopPublishModal({
|
||||
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",
|
||||
);
|
||||
const definition = clientApi.customWidget.export.useQuery({ id: widget.id }, { enabled: opened });
|
||||
const privateSourceNames = getPrivateWorkshopSourceNames(definition.data);
|
||||
const definitionFingerprint = definition.data ? serializeWorkshopDefinition(definition.data) : null;
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = client.subscribeToAuth(setUser);
|
||||
@@ -50,13 +53,27 @@ export function WorkshopPublishModal({
|
||||
setPublished(false);
|
||||
setSourceUrlsReviewed(false);
|
||||
}, [opened, widget.name]);
|
||||
useEffect(() => setSourceUrlsReviewed(false), [definitionFingerprint]);
|
||||
|
||||
const publish = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const definition = await utils.customWidget.export.fetch({ id: widget.id });
|
||||
if (!definition.data) throw new Error(t("publish.error"));
|
||||
const inspectedDefinition = definition.data;
|
||||
const refreshed = await definition.refetch();
|
||||
if (!refreshed.data) throw new Error(t("publish.error"));
|
||||
if (workshopDefinitionChanged(inspectedDefinition, refreshed.data)) {
|
||||
setSourceUrlsReviewed(false);
|
||||
setError(t("publish.error"));
|
||||
return;
|
||||
}
|
||||
await createSubmission.mutateAsync({
|
||||
input: { type: "customWidget", title, description, content: JSON.stringify(definition) },
|
||||
input: {
|
||||
type: "customWidget",
|
||||
title,
|
||||
description,
|
||||
content: serializeWorkshopDefinition(refreshed.data),
|
||||
},
|
||||
screenshots,
|
||||
});
|
||||
setPublished(true);
|
||||
@@ -113,10 +130,10 @@ export function WorkshopPublishModal({
|
||||
value={screenshots}
|
||||
onChange={setScreenshots}
|
||||
/>
|
||||
{privateSources.length > 0 && (
|
||||
{privateSourceNames.length > 0 && (
|
||||
<Alert color="yellow">
|
||||
{t("publish.privateSourceWarning", {
|
||||
sources: privateSources.map(([sourceId, source]) => source.name ?? sourceId).join(", "),
|
||||
sources: privateSourceNames.join(", "),
|
||||
})}
|
||||
<Checkbox
|
||||
mt="sm"
|
||||
@@ -126,10 +143,17 @@ export function WorkshopPublishModal({
|
||||
/>
|
||||
</Alert>
|
||||
)}
|
||||
{definition.isError && <Alert color="red">{t("publish.error")}</Alert>}
|
||||
{error && <Alert color="red">{error}</Alert>}
|
||||
<Button
|
||||
loading={createSubmission.isPending}
|
||||
disabled={!user || title.trim().length < 3 || (privateSources.length > 0 && !sourceUrlsReviewed)}
|
||||
loading={createSubmission.isPending || definition.isFetching}
|
||||
disabled={
|
||||
!user ||
|
||||
!definition.data ||
|
||||
definition.isError ||
|
||||
title.trim().length < 3 ||
|
||||
(privateSourceNames.length > 0 && !sourceUrlsReviewed)
|
||||
}
|
||||
onClick={() => void publish()}
|
||||
>
|
||||
{t("publish.action")}
|
||||
|
||||
Reference in new issue
Block a user