feat(onboarding): add management tour with multi-page navigation

Guide admins through boards, apps, integrations, and users with per-page
steps, TourTarget highlights, optimistic completion, and reset in settings.
This commit is contained in:
Thomas Camlong committed 2026-05-20 09:42:54 +02:00
1 parent 97e9c3258e
commit 1279168538
9 files changed
+253 -167

No files matched your search

+6 -5
View File
@@ -1,11 +1,12 @@
import type { ReactNode } from "react";
import { AppShellHeader, Box, Group, UnstyledButton } from "@mantine/core";
import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
import { Spotlight } from "@homarr/spotlight";
import { Link } from "@homarr/ui";
import { ClientBurger } from "./header/burger";
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { TourTarget } from "./header/tour-target";
import { UserButton } from "./header/user";
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
@@ -25,15 +26,15 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
{logo ?? <HomarrLogoWithTitle size="md" />}
</UnstyledButton>
</Group>
<Box data-onboarding-tour-id="board-search">
<TourTarget id="board-search">
<DesktopSearchInput />
</Box>
</TourTarget>
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
{actions}
<MobileSearchButton />
<Box data-onboarding-tour-id="board-user-menu">
<TourTarget id="board-user-menu">
<UserButton />
</Box>
</TourTarget>
</Group>
</Group>
<Spotlight />
@@ -2,6 +2,7 @@
import type { ReactNode } from "react";
import { usePathname } from "next/navigation";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { NavLink } from "@mantine/core";
import { Link } from "@homarr/ui";
@@ -9,20 +10,22 @@ import { Link } from "@homarr/ui";
export const CommonNavLink = (props: ClientNavigationLink) =>
"href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />;
const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
if (!id) return <>{children}</>;
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
};
const NavLinkHref = (props: NavigationLinkHref) => {
const pathname = usePathname();
const tourId = props["data-onboarding-tour-id"];
return props.external ? (
const link = props.external ? (
<NavLink
component="a"
label={props.label}
leftSection={props.icon}
href={props.href}
style={{
borderRadius: 5,
}}
style={{ borderRadius: 5 }}
target="_blank"
data-onboarding-tour-id={tourId}
/>
) : (
<NavLink
@@ -31,32 +34,23 @@ const NavLinkHref = (props: NavigationLinkHref) => {
leftSection={props.icon}
href={props.href}
active={pathname === props.href}
style={{
borderRadius: 5,
}}
data-onboarding-tour-id={tourId}
style={{ borderRadius: 5 }}
/>
);
return <TourTarget id={tourId}>{link}</TourTarget>;
};
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
const pathname = usePathname();
const isActive = props.items.some((item) => item.href === pathname);
return (
<NavLink
style={{
borderRadius: 5,
}}
label={props.label}
leftSection={props.icon}
defaultOpened={isActive}
data-onboarding-tour-id={props["data-onboarding-tour-id"]}
>
const nav = (
<NavLink style={{ borderRadius: 5 }} label={props.label} leftSection={props.icon} defaultOpened={isActive}>
{props.items.map((item) => (
<NavLinkHref key={item.label} {...item} />
))}
</NavLink>
);
return <TourTarget id={props["data-onboarding-tour-id"]}>{nav}</TourTarget>;
};
interface CommonNavigationLinkProps {
@@ -1,34 +1,101 @@
"use client";
import type { PropsWithChildren } from "react";
import { useMemo } from "react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Box } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import {
IconAffiliateFilled,
IconAppsFilled,
IconLayoutDashboardFilled,
IconPlus,
IconUserFilled,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { SkipHoldButton } from "./skip-hold-button";
import { TourForkStep } from "./tour-fork-step";
import { TourShell } from "./tour-shell";
import { TourStepContent } from "./tour-step-content";
interface ManageTourProviderProps extends PropsWithChildren {
isAdmin: boolean;
}
const stepRoutes: Record<string, string> = {
"manage-welcome": "/manage",
"manage-boards-list": "/manage/boards",
"manage-boards-create": "/manage/boards",
"manage-apps-list": "/manage/apps",
"manage-apps-create": "/manage/apps",
"manage-integrations-list": "/manage/integrations",
"manage-integrations-create": "/manage/integrations",
"manage-users-list": "/manage/users",
"manage-users-create": "/manage/users",
};
const usersStepFocusRevealProps = {
withReveal: false,
popoverProps: {
position: "bottom",
middlewares: { shift: { padding: 16 }, flip: true },
},
};
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
const router = useRouter();
const t = useScopedI18n("onboardingTour.manage");
const utils = clientApi.useUtils();
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: completeTour } = clientApi.user.completeTour.useMutation();
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
onSuccess() {
void utils.user.getTourStatus.invalidate();
},
});
const isMobile = useMediaQuery("(max-width: 48em)");
const pathname = usePathname();
const router = useRouter();
const started = tourStatus !== undefined && !tourStatus.completedManageTour;
const isManageHome = /^(\/[^/]+)?\/manage\/?$/.test(pathname);
const isManageSection = /^(\/[^/]+)?\/manage(\/.*)?$/.test(pathname);
const handleEnd = () => {
const [tourActive, setTourActive] = useState(false);
const [tourDismissed, setTourDismissed] = useState(false);
const wasManageTourCompletedRef = useRef(tourStatus?.completedManageTour);
useEffect(() => {
if (tourDismissed) return;
if (isManageHome && tourStatus !== undefined && !tourStatus.completedManageTour && !isMobile) {
setTourActive(true);
}
}, [isManageHome, tourStatus, isMobile, tourDismissed]);
useEffect(() => {
if (!isManageSection) {
setTourActive(false);
}
}, [isManageSection]);
useEffect(() => {
const wasCompleted = wasManageTourCompletedRef.current;
const isCompleted = tourStatus?.completedManageTour;
if (wasCompleted === true && isCompleted === false) {
setTourDismissed(false);
}
wasManageTourCompletedRef.current = isCompleted;
}, [tourStatus?.completedManageTour]);
const handleEnd = useCallback(() => {
setTourDismissed(true);
setTourActive(false);
utils.user.getTourStatus.setData(undefined, {
completedManageTour: true,
completedBoardTour: tourStatus?.completedBoardTour ?? false,
});
completeTour({ tour: "manage" });
};
router.push("/manage");
}, [completeTour, router, tourStatus?.completedBoardTour, utils.user.getTourStatus]);
const steps = useMemo(() => {
const allSteps: (OnboardingTourStep & { adminOnly?: boolean })[] = [
@@ -36,109 +103,118 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
id: "manage-welcome",
title: t("welcome.title"),
content: (
<TourStepContent description={t("welcome.description")} docPath="/docs/category/management" />
),
footer: (controller: { skipTour: () => void }) => (
<SkipHoldButton onSkip={() => controller.skipTour()} />
),
},
{
id: "manage-boards",
title: t("boards.title"),
content: (
<TourStepContent description={t("boards.description")} docPath="/docs/management/boards" />
),
},
{
id: "manage-apps",
title: t("apps.title"),
content: (
<TourStepContent description={t("apps.description")} docPath="/docs/management/apps" />
),
},
{
id: "manage-integrations",
title: t("integrations.title"),
content: (controller: { endTour: () => void; nextStep: () => void }) => (
<TourForkStep
question={t("integrationsFork.question")}
description={t("integrations.description")}
docPath="/docs/management/integrations"
onYes={() => {
controller.endTour();
router.push("/manage/integrations/new");
}}
onNo={() => controller.nextStep()}
<TourStepContent
description={t("welcome.description")}
documentationHref={createDocumentationLink("/docs/category/management")}
/>
),
},
{
id: "manage-search-engines",
title: t("searchEngines.title"),
content: (
<TourStepContent description={t("searchEngines.description")} docPath="/docs/management/search-engines" />
),
},
{
id: "manage-medias",
title: t("medias.title"),
content: (
<TourStepContent description={t("medias.description")} docPath="/docs/management/media" />
),
},
{
id: "manage-users",
title: t("users.title"),
adminOnly: true,
content: (controller: { endTour: () => void; nextStep: () => void }) => (
<TourForkStep
question={t("usersFork.question")}
description={t("users.description")}
docPath="/docs/management/users"
onYes={() => {
controller.endTour();
router.push("/manage/users/create");
}}
onNo={() => controller.nextStep()}
/>
),
},
{
id: "manage-settings",
title: t("settings.title"),
adminOnly: true,
content: (
<TourStepContent description={t("settings.description")} docPath="/docs/management/settings" />
),
},
{
id: "manage-complete",
title: t("complete.title"),
id: "manage-boards-list",
title: t("boardsList.title"),
content: (
<TourStepContent
description={t("complete.description")}
docPath="/docs/getting-started/after-the-installation"
description={t("boardsList.description")}
documentationHref={createDocumentationLink("/docs/management/boards")}
icon={<IconLayoutDashboardFilled size={18} />}
/>
),
},
{
id: "manage-boards-create",
title: t("boardsCreate.title"),
content: (
<TourStepContent
description={t("boardsCreate.description")}
documentationHref={createDocumentationLink("/docs/management/boards")}
icon={<IconPlus size={18} />}
/>
),
},
{
id: "manage-apps-list",
title: t("appsList.title"),
content: (
<TourStepContent
description={t("appsList.description")}
documentationHref={createDocumentationLink("/docs/management/apps")}
icon={<IconAppsFilled size={18} />}
/>
),
},
{
id: "manage-apps-create",
title: t("appsCreate.title"),
content: (
<TourStepContent
description={t("appsCreate.description")}
documentationHref={createDocumentationLink("/docs/management/apps")}
icon={<IconPlus size={18} />}
/>
),
},
{
id: "manage-integrations-list",
title: t("integrationsList.title"),
content: (
<TourStepContent
description={t("integrationsList.description")}
documentationHref={createDocumentationLink("/docs/management/integrations")}
icon={<IconAffiliateFilled size={18} />}
/>
),
},
{
id: "manage-integrations-create",
title: t("integrationsCreate.title"),
content: (
<TourStepContent
description={t("integrationsCreate.description")}
documentationHref={createDocumentationLink("/docs/management/integrations")}
icon={<IconPlus size={18} />}
/>
),
},
{
id: "manage-users-list",
title: t("usersList.title"),
adminOnly: true,
focusRevealProps: usersStepFocusRevealProps,
content: (
<TourStepContent
description={t("usersList.description")}
documentationHref={createDocumentationLink("/docs/management/users")}
icon={<IconUserFilled size={18} />}
/>
),
},
{
id: "manage-users-create",
title: t("usersCreate.title"),
adminOnly: true,
focusRevealProps: usersStepFocusRevealProps,
content: (
<TourStepContent
description={t("usersCreate.description")}
documentationHref={createDocumentationLink("/docs/management/users")}
icon={<IconPlus size={18} />}
/>
),
},
];
return allSteps.filter((step) => !step.adminOnly || isAdmin) as OnboardingTourStep[];
}, [isAdmin, router, t]);
}, [isAdmin, t]);
return (
<OnboardingTour
tour={steps}
started={started}
onOnboardingTourComplete={handleEnd}
onOnboardingTourSkip={handleEnd}
maw={420}
<TourShell
steps={steps}
started={tourActive}
onEnd={handleEnd}
stepRoutes={stepRoutes}
position={{ base: "bottom", sm: "right" }}
>
<Box
data-onboarding-tour-id="manage-complete"
style={{ position: "fixed", opacity: 0, pointerEvents: "none" }}
/>
{children}
</OnboardingTour>
</TourShell>
);
};