feat(onboarding): add integration configuration stepper

Add 3-phase integration setup to onboarding: multi-select grid,
per-integration configuration with NewIntegrationForm stepper, and
finishing phase that populates the dashboard and shows final actions.
Merge finish step actions into the integrations step. Add translation
keys for configure phase.
This commit is contained in:
Thomas Camlong committed 2026-05-01 09:58:54 +02:00
1 parent e3fb8572cd
commit 7985ff953a
5 files changed
+223 -16

No files matched your search

@@ -1,6 +1,6 @@
import type { MantineColor } from "@mantine/core";
import { Button, Card, Stack, Text } from "@mantine/core";
import { IconBook2, IconCategoryPlus, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
import { IconBook2, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
import { isProviderEnabled } from "@homarr/auth/server";
import { getMantineColor } from "@homarr/common";
@@ -13,27 +13,19 @@ import type { TablerIcon } from "@homarr/ui";
export const InitFinish = async () => {
const firstBoard = await db.query.boards.findFirst({ columns: { name: true } });
const tFinish = await getScopedI18n("init.step.finish");
const boardName = firstBoard?.name ?? "dashboard";
return (
<Card w={64 * 6} maw="90vw" withBorder>
<Stack>
<Text>{tFinish("description")}</Text>
{firstBoard ? (
<InternalLinkButton
href={`/auth/login?callbackUrl=/boards/${firstBoard.name}`}
iconProps={{ icon: IconLayoutDashboard, color: "blue" }}
>
{tFinish("action.goToBoard", { name: firstBoard.name })}
</InternalLinkButton>
) : (
<InternalLinkButton
href="/auth/login?callbackUrl=/manage/boards"
iconProps={{ icon: IconCategoryPlus, color: "blue" }}
>
{tFinish("action.createBoard")}
</InternalLinkButton>
)}
<InternalLinkButton
href={`/auth/login?callbackUrl=/boards/${boardName}`}
iconProps={{ icon: IconLayoutDashboard, color: "blue" }}
>
{tFinish("action.goToBoard", { name: boardName })}
</InternalLinkButton>
{isProviderEnabled("credentials") && (
<InternalLinkButton
@@ -0,0 +1,185 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Card, Center, Group, Loader, Stack, Stepper, Text } from "@mantine/core";
import { IconArrowRight, IconBook2, IconCheck, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getMantineColor } from "@homarr/common";
import type { IntegrationKind } from "@homarr/definitions";
import { createDocumentationLink, getIntegrationName } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { IntegrationAvatar, Link } from "@homarr/ui";
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
import { IntegrationMultiSelectGrid } from "~/components/integration/integration-multi-select-grid";
type Phase = "select" | "configure" | "done";
export const InitIntegrations = () => {
const [phase, setPhase] = useState<Phase>("select");
const [selectedKinds, setSelectedKinds] = useState<IntegrationKind[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const t = useScopedI18n("init.step.integrations");
const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation();
const handleSelectionChange = useCallback((kinds: IntegrationKind[]) => {
setSelectedKinds(kinds);
}, []);
const handleContinueToConfig = () => {
setCurrentIndex(0);
setPhase("configure");
};
const handleSkipAll = () => {
setPhase("done");
};
const advanceOrFinish = () => {
if (currentIndex + 1 >= selectedKinds.length) {
setPhase("done");
} else {
setCurrentIndex((i) => i + 1);
}
};
const backToSelect = () => {
setPhase("select");
setCurrentIndex(0);
};
if (phase === "done") {
return <FinishingPhase setupIntegrations={setupIntegrations} />;
}
if (phase === "configure") {
const currentKind = selectedKinds[currentIndex];
if (currentKind === undefined) {
return null;
}
return (
<Card w={64 * 14} maw="90vw" withBorder>
<Stack>
<Stepper active={currentIndex} size="sm" styles={{ stepIcon: { borderWidth: 0 } }}>
{selectedKinds.map((kind, index) => (
<Stepper.Step
key={kind}
icon={<IntegrationAvatar kind={kind} size="sm" />}
completedIcon={<IconCheck size={14} />}
label={index === currentIndex ? getIntegrationName(kind) : undefined}
/>
))}
</Stepper>
<Text size="sm" c="dimmed" ta="center">
{t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })}
</Text>
<NewIntegrationForm
key={currentKind}
kind={currentKind}
onSuccess={advanceOrFinish}
onCancel={backToSelect}
/>
</Stack>
</Card>
);
}
return (
<Card w={64 * 16} maw="90vw" withBorder>
<Stack>
<Text>{t("description")}</Text>
<IntegrationMultiSelectGrid onSelectionChange={handleSelectionChange} />
<Group justify="space-between">
<Button variant="subtle" onClick={handleSkipAll}>
{t("action.skip")}
</Button>
<Button
onClick={handleContinueToConfig}
disabled={selectedKinds.length === 0}
rightSection={<IconArrowRight size={16} stroke={1.5} />}
>
{t("action.continue")} {selectedKinds.length > 0 && `(${selectedKinds.length})`}
</Button>
</Group>
</Stack>
</Card>
);
};
const FinishingPhase = ({ setupIntegrations }: { setupIntegrations: () => Promise<void> }) => {
const t = useScopedI18n("init.step");
const [isReady, setIsReady] = useState(false);
const calledRef = useRef(false);
useEffect(() => {
if (calledRef.current) return;
calledRef.current = true;
void setupIntegrations().then(() => {
setIsReady(true);
});
}, [setupIntegrations]);
if (!isReady) {
return (
<Card w={64 * 8} maw="90vw" withBorder>
<Center py="xl">
<Stack align="center" gap="md">
<Loader size="lg" />
<Text>{t("integrations.configure.finishing")}</Text>
</Stack>
</Center>
</Card>
);
}
return (
<Card w={64 * 8} maw="90vw" withBorder>
<Stack>
<Stack gap={4} align="center">
<IconCheck size={48} color={getMantineColor("green", 6)} />
<Text fw={500} size="lg">
{t("finish.title")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("finish.description")}
</Text>
</Stack>
<Button
variant="default"
component={Link}
href="/auth/login?callbackUrl=/boards/dashboard"
leftSection={<IconLayoutDashboard color={getMantineColor("blue", 6)} size={16} stroke={1.5} />}
>
{t("finish.action.goToBoard", { name: "dashboard" })}
</Button>
<Button
variant="default"
component={Link}
href="/auth/login?callbackUrl=/manage/users/invites"
leftSection={<IconMailForward color={getMantineColor("pink", 6)} size={16} stroke={1.5} />}
>
{t("finish.action.inviteUser")}
</Button>
<Button
variant="default"
component="a"
href={createDocumentationLink("/docs/getting-started/after-the-installation")}
leftSection={<IconBook2 color={getMantineColor("yellow", 6)} size={16} stroke={1.5} />}
>
{t("finish.action.docs")}
</Button>
</Stack>
</Card>
);
};
@@ -13,6 +13,7 @@ import { BackToStart } from "./_steps/back";
import { InitFinish } from "./_steps/finish/init-finish";
import { InitGroup } from "./_steps/group/init-group";
import { InitImport } from "./_steps/import/init-import";
import { InitIntegrations } from "./_steps/integrations/init-integrations";
import { InitSettings } from "./_steps/settings/init-settings";
import { InitStart } from "./_steps/start/init-start";
import { InitUser } from "./_steps/user/init-user";
@@ -23,6 +24,7 @@ const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Elem
user: InitUser,
group: InitGroup,
settings: InitSettings,
integrations: InitIntegrations,
finish: InitFinish,
};
+14
View File
@@ -90,6 +90,20 @@
"title": "Settings",
"subtitle": "Configure server settings"
},
"integrations": {
"title": "Integrations",
"subtitle": "Select the services you use.",
"description": "Choose the integrations you'd like to connect. We'll set up your dashboard with matching widgets automatically.",
"action": {
"continue": "Continue",
"skip": "Skip for now"
},
"configure": {
"progress": "{current} of {total}",
"skip": "Skip this integration",
"finishing": "Setting up your dashboard..."
}
},
"finish": {
"title": "Finish set-up",
"subtitle": "Congratulations, you are ready to go!",
+14
View File
@@ -90,6 +90,20 @@
"title": "Settings",
"subtitle": "Configure server settings."
},
"integrations": {
"title": "Integrations",
"subtitle": "Select the services you use.",
"description": "Choose the integrations you'd like to connect. We'll set up your dashboard with matching widgets automatically.",
"action": {
"continue": "Continue",
"skip": "Skip for now"
},
"configure": {
"progress": "{current} of {total}",
"skip": "Skip this integration",
"finishing": "Setting up your dashboard..."
}
},
"finish": {
"title": "Finish setup",
"subtitle": "You are ready to go!",