From 97e39ced44a476ddc026da49917dc62a8ce459ee Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 14 May 2026 16:54:31 +0200 Subject: [PATCH] refactor(integrations): make onSuccess required and simplify new page - Make onSuccess a required prop on NewIntegrationForm so the consumer always controls post-creation behavior. Remove internal router.push. - Add IntegrationNewFormWrapper client component to handle navigation for the server-rendered manage page. - Replace IntegrationPickerPage with a simple "back to overview" button when an invalid integration kind is provided. Delete unused integration-picker-page.tsx. --- .../new/_integration-new-form-wrapper.tsx | 26 +++++ .../new/_integration-new-form.tsx | 102 ++++++++++-------- .../[locale]/manage/integrations/new/page.tsx | 31 ++++-- .../integration/integration-picker-page.tsx | 25 ----- 4 files changed, 101 insertions(+), 83 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form-wrapper.tsx delete mode 100644 apps/nextjs/src/components/integration/integration-picker-page.tsx diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form-wrapper.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form-wrapper.tsx new file mode 100644 index 000000000..7d7768ec2 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form-wrapper.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import type { IntegrationKind } from "@homarr/definitions"; + +import { NewIntegrationForm } from "./_integration-new-form"; + +interface IntegrationNewFormWrapperProps { + kind: IntegrationKind; + initialUrl?: string; + initialName?: string; +} + +export const IntegrationNewFormWrapper = ({ kind, initialUrl, initialName }: IntegrationNewFormWrapperProps) => { + const router = useRouter(); + + return ( + router.push("/manage/integrations")} + /> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index f0577bd76..04d0f8b14 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -1,7 +1,6 @@ "use client"; import { startTransition, useState } from "react"; -import { useRouter } from "next/navigation"; import { Alert, Button, @@ -48,8 +47,9 @@ interface NewIntegrationFormProps { kind: IntegrationKind; initialUrl?: string; initialName?: string; - onSuccess?: () => void; + onSuccess: () => void; onCancel?: () => void; + isOnboarding?: boolean; } const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and( @@ -60,13 +60,19 @@ const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and( }), ); -export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, onCancel }: NewIntegrationFormProps) => { +export const NewIntegrationForm = ({ + kind, + initialUrl, + initialName, + onSuccess, + onCancel, + isOnboarding = false, +}: NewIntegrationFormProps) => { const t = useI18n(); const secretKinds = getAllSecretKindOptions(kind); const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url")); - const router = useRouter(); const { data: session } = useSession(); - const canCreateApps = session?.user.permissions.includes("app-create") ?? false; + const canCreateApps = !isOnboarding && (session?.user.permissions.includes("app-create") ?? false); let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? ""; if (hasUrlSecret) { @@ -80,18 +86,21 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o kind, value: "", })), - attemptSearchEngineCreation: true, - hasApp: true, - appHref: url, + attemptSearchEngineCreation: !isOnboarding, + hasApp: !isOnboarding, + appHref: isOnboarding ? null : url, appId: null, }, }); - const { mutateAsync: createIntegrationAsync, isPending } = clientApi.integration.create.useMutation({ + const { mutateAsync: createIntegrationAsync, isPending: isCreatePending } = clientApi.integration.create.useMutation({ async onSuccess() { await revalidatePathActionAsync("/manage/integrations"); }, }); + const { mutateAsync: createOnboardingIntegrationAsync, isPending: isOnboardingCreatePending } = + clientApi.onboard.createIntegration.useMutation(); + const isPending = isCreatePending || isOnboardingCreatePending; const [error, setError] = useState(null); const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => { @@ -99,6 +108,39 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o ? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin : values.url; + const onMutationSuccess = (data: { error?: AnyMappedTestConnectionError } | undefined | void) => { + if (data && "error" in data && data.error) { + setError(data.error); + showErrorNotification({ + title: t("integration.page.create.notification.error.title"), + message: t("integration.page.create.notification.error.message"), + }); + return; + } + + showSuccessNotification({ + title: t("integration.page.create.notification.success.title"), + message: t("integration.page.create.notification.success.message"), + }); + + onSuccess(); + }; + + const onMutationError = () => { + showErrorNotification({ + title: t("integration.page.create.notification.error.title"), + message: t("integration.page.create.notification.error.message"), + }); + }; + + if (isOnboarding) { + await createOnboardingIntegrationAsync( + { kind, name: values.name, url, secrets: values.secrets }, + { onSuccess: onMutationSuccess, onError: onMutationError }, + ); + return; + } + const hasCustomHref = appHref !== null && appHref.trim().length >= 1; const app = hasApp @@ -114,42 +156,8 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o : undefined; await createIntegrationAsync( - { - kind, - ...values, - url, - app, - }, - { - onSuccess(data) { - // We do it this way as we are unable to send a typesafe error through onError - if (data?.error) { - setError(data.error); - showErrorNotification({ - title: t("integration.page.create.notification.error.title"), - message: t("integration.page.create.notification.error.message"), - }); - return; - } - - showSuccessNotification({ - title: t("integration.page.create.notification.success.title"), - message: t("integration.page.create.notification.success.message"), - }); - - if (onSuccess) { - onSuccess(); - } else { - router.push("/manage/integrations"); - } - }, - onError: () => { - showErrorNotification({ - title: t("integration.page.create.notification.error.title"), - message: t("integration.page.create.notification.error.message"), - }); - }, - }, + { kind, ...values, url, app }, + { onSuccess: onMutationSuccess, onError: onMutationError }, ); }; @@ -185,7 +193,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o {error !== null && } - {supportsSearchEngine && ( + {!isOnboarding && supportsSearchEngine && ( )} - + {!isOnboarding && } {onCancel ? ( diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx index 803504329..3aeb2057c 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx @@ -1,16 +1,15 @@ import { notFound } from "next/navigation"; -import { Container, Group, Stack, Title } from "@mantine/core"; +import { Button, Center, Container, Group, Stack, Text, Title } from "@mantine/core"; +import { IconArrowLeft } from "@tabler/icons-react"; import { z } from "zod/v4"; import { auth } from "@homarr/auth/next"; import { getIntegrationName, integrationKinds } from "@homarr/definitions"; import { getScopedI18n } from "@homarr/translation/server"; -import { IntegrationAvatar } from "@homarr/ui"; +import { IntegrationAvatar, Link } from "@homarr/ui"; -import { IntegrationPickerPage } from "~/components/integration/integration-picker-page"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; -import { env } from "~/env"; -import { NewIntegrationForm } from "./_integration-new-form"; +import { IntegrationNewFormWrapper } from "./_integration-new-form-wrapper"; interface NewIntegrationPageProps { searchParams: Promise<{ @@ -34,11 +33,21 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps return ( <> - - - {t("action.create")} - - + +
+ + {t("action.create")} + {t("page.list.search")} + + +
); @@ -57,7 +66,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps {tCreate("title", { name: getIntegrationName(currentKind) })}
- + diff --git a/apps/nextjs/src/components/integration/integration-picker-page.tsx b/apps/nextjs/src/components/integration/integration-picker-page.tsx deleted file mode 100644 index cd4618ef7..000000000 --- a/apps/nextjs/src/components/integration/integration-picker-page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"use client"; - -import { useCallback } from "react"; -import { useRouter } from "next/navigation"; - -import type { IntegrationKind } from "@homarr/definitions"; - -import { IntegrationSelectGrid } from "./integration-select-grid"; - -interface IntegrationPickerPageProps { - enableMockIntegration: boolean; -} - -export const IntegrationPickerPage = ({ enableMockIntegration }: IntegrationPickerPageProps) => { - const router = useRouter(); - - const handleSelect = useCallback( - (kind: IntegrationKind) => { - router.push(`/manage/integrations/new?kind=${kind}`); - }, - [router], - ); - - return ; -};