fix(onboarding): replace stepper with progress bar and fix finish flow
- Replace Mantine Stepper with a Progress bar + current integration avatar/name. The stepper broke layout with 10+ integrations on mobile. - Remove useEffect for setupIntegrations — call it directly when advancing past the last integration or clicking skip, avoiding double-call risks (cf. Cloudflare outage pattern). - Call router.refresh() after setupIntegrations so the server-rendered InitFinish component renders properly with login links. - Remove DB query from init-finish.tsx — redirect to root path instead of looking up an arbitrary board name.
This commit is contained in:
2 files changed
+38
-113
No files matched your search
@@ -4,27 +4,21 @@ import { IconBook2, IconLayoutDashboard, IconMailForward } from "@tabler/icons-r
|
||||
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { getMantineColor } from "@homarr/common";
|
||||
import { db } from "@homarr/db";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
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>
|
||||
|
||||
<InternalLinkButton
|
||||
href={`/auth/login?callbackUrl=/boards/${boardName}`}
|
||||
iconProps={{ icon: IconLayoutDashboard, color: "blue" }}
|
||||
>
|
||||
{tFinish("action.goToBoard", { name: boardName })}
|
||||
<InternalLinkButton href="/auth/login?callbackUrl=/" iconProps={{ icon: IconLayoutDashboard, color: "blue" }}>
|
||||
{tFinish("action.goToBoard", { name: "dashboard" })}
|
||||
</InternalLinkButton>
|
||||
|
||||
{isProviderEnabled("credentials") && (
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"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 { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, Card, Center, Group, Loader, Progress, Stack, Text } from "@mantine/core";
|
||||
import { IconArrowRight } 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 { getIntegrationName } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar, Link } from "@homarr/ui";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
|
||||
import { IntegrationMultiSelectGrid } from "~/components/integration/integration-multi-select-grid";
|
||||
@@ -21,6 +21,7 @@ export const InitIntegrations = () => {
|
||||
const [selectedKinds, setSelectedKinds] = useState<IntegrationKind[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const t = useScopedI18n("init.step.integrations");
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation();
|
||||
|
||||
@@ -33,13 +34,15 @@ export const InitIntegrations = () => {
|
||||
setPhase("configure");
|
||||
};
|
||||
|
||||
const handleSkipAll = () => {
|
||||
const finishSetupAsync = async () => {
|
||||
setPhase("done");
|
||||
await setupIntegrations();
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const advanceOrFinish = () => {
|
||||
if (currentIndex + 1 >= selectedKinds.length) {
|
||||
setPhase("done");
|
||||
void finishSetupAsync();
|
||||
} else {
|
||||
setCurrentIndex((i) => i + 1);
|
||||
}
|
||||
@@ -51,44 +54,43 @@ export const InitIntegrations = () => {
|
||||
};
|
||||
|
||||
if (phase === "done") {
|
||||
return <FinishingPhase setupIntegrations={setupIntegrations} />;
|
||||
return (
|
||||
<Card w={64 * 8} maw="90vw" withBorder>
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="md">
|
||||
<Loader size="lg" />
|
||||
<Text>{t("configure.finishing")}</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "configure") {
|
||||
const currentKind = selectedKinds[currentIndex];
|
||||
if (currentKind === undefined) {
|
||||
return (
|
||||
<Card w={64 * 14} maw="90vw" withBorder>
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- currentIndex is always within bounds of selectedKinds
|
||||
const currentKind = selectedKinds[currentIndex]!;
|
||||
const progress = ((currentIndex + 1) / selectedKinds.length) * 100;
|
||||
|
||||
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>
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" justify="center">
|
||||
<IntegrationAvatar kind={currentKind} size="sm" />
|
||||
<Text fw={500}>{getIntegrationName(currentKind)}</Text>
|
||||
</Group>
|
||||
<Progress value={progress} size="sm" />
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<NewIntegrationForm
|
||||
key={currentKind}
|
||||
kind={currentKind}
|
||||
onSuccess={advanceOrFinish}
|
||||
onCancel={backToSelect}
|
||||
isOnboarding
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -103,7 +105,7 @@ export const InitIntegrations = () => {
|
||||
<IntegrationMultiSelectGrid onSelectionChange={handleSelectionChange} />
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" onClick={handleSkipAll}>
|
||||
<Button variant="subtle" onClick={() => void finishSetupAsync()}>
|
||||
{t("action.skip")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -118,74 +120,3 @@ export const InitIntegrations = () => {
|
||||
</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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user