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:
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,
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user