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.
This commit is contained in:
Thomas Camlong committed 2026-05-14 16:54:31 +02:00
1 parent 947504a16e
commit 97e39ced44
4 files changed
+101 -83

No files matched your search

@@ -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 (
<NewIntegrationForm
kind={kind}
initialUrl={initialUrl}
initialName={initialName}
onSuccess={() => router.push("/manage/integrations")}
/>
);
};
@@ -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 | AnyMappedTestConnectionError>(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 && <IntegrationTestConnectionError error={error} url={form.values.url} />}
{supportsSearchEngine && (
{!isOnboarding && supportsSearchEngine && (
<Checkbox
label={t("integration.field.attemptSearchEngineCreation.label")}
description={t("integration.field.attemptSearchEngineCreation.description", {
@@ -195,7 +203,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
/>
)}
<AppForm form={form} canCreateApps={canCreateApps} />
{!isOnboarding && <AppForm form={form} canCreateApps={canCreateApps} />}
<Group justify="end" align="center">
{onCancel ? (
@@ -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 (
<>
<DynamicBreadcrumb />
<Container size="xl">
<Stack>
<Title>{t("action.create")}</Title>
<IntegrationPickerPage enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
</Stack>
<Container>
<Center py="xl">
<Stack align="center" gap="md">
<Title order={3}>{t("action.create")}</Title>
<Text c="dimmed">{t("page.list.search")}</Text>
<Button
variant="default"
component={Link}
href="/manage/integrations"
leftSection={<IconArrowLeft size={16} />}
>
{t("page.list.title")}
</Button>
</Stack>
</Center>
</Container>
</>
);
@@ -57,7 +66,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
<IntegrationAvatar kind={currentKind} size="md" />
<Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title>
</Group>
<NewIntegrationForm kind={currentKind} initialUrl={searchParams.url} initialName={searchParams.name} />
<IntegrationNewFormWrapper kind={currentKind} initialUrl={searchParams.url} initialName={searchParams.name} />
</Stack>
</Container>
</>
@@ -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 <IntegrationSelectGrid onSelect={handleSelect} enableMockIntegration={enableMockIntegration} />;
};