Files
homarr/apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts
T

169 lines
6.7 KiB
TypeScript

"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, isCustomWidgetSourceSetupReady } from "@homarr/custom-widgets/workbench";
import type {
CustomWidgetSourceSetupMessages,
CustomWidgetSourceSetupValue,
ImportReviewContentMessages,
} from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
interface UseCustomWidgetImportOptions {
widget: HomarrCustomWidgetV2 | null;
/** Set when replacing an existing v1 widget instead of creating a new one. */
legacyId?: string;
onImported?(result: { id: string }): void;
}
/**
* The single import pipeline behind every "add this custom widget" surface:
* source setup, security review and the mutation itself. Keeping it in one hook
* means the Workshop page and the file/clipboard dialog can never drift apart.
*/
export function useCustomWidgetImport({ widget, legacyId, onImported }: UseCustomWidgetImportOptions) {
const t = useI18n("customWidget");
const tCommon = useI18n("common");
const router = useRouter();
const utils = clientApi.useUtils();
// Keyed on the sources themselves rather than the widget object: a background
// refetch (window focus, or the invalidation a vote triggers) hands us a new but
// identical object, and resetting on that would silently wipe credentials the
// user is part-way through typing.
const sourcesKey = JSON.stringify(widget?.sources ?? {});
const setups = useMemo(
() => getCustomWidgetSourceSetups(widget?.sources ?? {}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[sourcesKey],
);
const [values, setValues] = useState<Record<string, CustomWidgetSourceSetupValue>>({});
useEffect(() => setValues(createCustomWidgetSourceSetupValues(setups)), [setups]);
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]);
// A widget must only ever be imported once per visit: the mutation creates a new
// record every call, so a second click would silently duplicate it.
const [succeeded, setSucceeded] = useState(false);
useEffect(() => setSucceeded(false), [widget]);
const onSuccess = (result: { id: string }) => {
setSucceeded(true);
showSuccessNotification({
title: legacyId ? t("action.migrate") : tCommon("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();
// Hand over to the caller before the server-side revalidation round-trip so
// navigation is immediate rather than waiting on it.
onImported?.(result);
void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh());
};
const onError = (error: { message?: string }) => {
showErrorNotification({
title: legacyId ? t("action.migrate") : tCommon("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;
const importWidget = () => {
if (!configuredWidget || pending || succeeded) 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 }] : [],
),
);
if (legacyId)
migrateMutation.mutate({
id: legacyId,
widget: configuredWidget,
secrets,
});
else importMutation.mutate({ widget: configuredWidget, secrets });
};
const reviewMessages: ImportReviewContentMessages = {
description: legacyId ? t("importReview.migrationDescription") : 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"),
permission: (permission) => t(`preview.request.permission.${permission}` as never),
};
const setupMessages: CustomWidgetSourceSetupMessages = {
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(`secret.${kind}`),
urlError: (issue) => t(`workbench.sources.baseUrlError.${issue}`),
};
return {
review,
setups,
values,
setValue: (sourceId: string, value: CustomWidgetSourceSetupValue) =>
setValues((current) => ({ ...current, [sourceId]: value })),
ready: isCustomWidgetSourceSetupReady(setups, values),
pending,
/** True once the import went through; callers keep the action locked while navigating away. */
succeeded,
importWidget,
reviewMessages,
setupMessages,
};
}