fix(custom-widgets): address v2 merge review (#6446)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-23 01:55:15 +02:00
1 parent 6aceb2fd1f
commit df967751f6
123 files changed
+8632 -1477

No files matched your search

@@ -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")}