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

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

No files matched your search

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