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

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