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

@@ -11,6 +11,7 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -47,19 +48,21 @@ export default async function AppsPage(props: AppsPageProps) {
<Group justify="space-between" align="center">
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />
{session.user.permissions.includes("app-create") && (
<MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")}
</MobileAffixButton>
<TourTarget id="manage-apps-create">
<MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")}
</MobileAffixButton>
</TourTarget>
)}
</Group>
{apps.length === 0 && <AppNoResults />}
{apps.length > 0 && (
<TourTarget id="manage-apps-list">
<Stack gap="sm">
{apps.length === 0 && <AppNoResults />}
{apps.map((app) => (
<AppCard key={app.id} app={app} />
))}
</Stack>
)}
</TourTarget>
{/* Added margin to not hide pagination behind affix-button */}
<Group justify="end" mb={48}>
@@ -23,6 +23,7 @@ import { getScopedI18n } from "@homarr/translation/server";
import { Link, UserAvatar } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
@@ -40,16 +41,22 @@ export default async function ManageBoardsPage() {
<Stack>
<Group justify="space-between">
<Title mb="md">{t("title")}</Title>
{canCreateBoards && <CreateBoardButton />}
{canCreateBoards && (
<TourTarget id="manage-boards-create">
<CreateBoardButton />
</TourTarget>
)}
</Group>
<Grid mb={{ base: "xl", md: 0 }}>
{boards.map((board) => (
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
<BoardCard board={board} />
</GridCol>
))}
</Grid>
<TourTarget id="manage-boards-list">
<Grid mb={{ base: "xl", md: 0 }}>
{boards.map((board) => (
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
<BoardCard board={board} />
</GridCol>
))}
</Grid>
</TourTarget>
</Stack>
</ManageContainer>
);
@@ -37,6 +37,7 @@ import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
@@ -84,18 +85,22 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
</IntegrationSelectMenu>
</Box>
<Box visibleFrom="md">
<IntegrationSelectMenu>
<MenuTarget>
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
</MenuTarget>
</IntegrationSelectMenu>
</Box>
<TourTarget id="manage-integrations-create">
<Box visibleFrom="md">
<IntegrationSelectMenu>
<MenuTarget>
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
</MenuTarget>
</IntegrationSelectMenu>
</Box>
</TourTarget>
</>
)}
</Group>
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
<TourTarget id="manage-integrations-list">
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
</TourTarget>
</Stack>
</ManageContainer>
);
@@ -7,14 +7,10 @@ import { clientApi } from "@homarr/api/client";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
interface ResetToursProps {
completedManageTour: boolean;
completedBoardTour: boolean;
}
export const ResetTours = ({ completedManageTour, completedBoardTour }: ResetToursProps) => {
export const ResetTours = () => {
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
const utils = clientApi.useUtils();
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
onSuccess() {
showSuccessNotification({
@@ -25,15 +21,15 @@ export const ResetTours = ({ completedManageTour, completedBoardTour }: ResetTou
},
});
const tourStatusMap: Record<string, { key: "management" | "dashboard"; completed: boolean }> = {
management: { key: "management", completed: completedManageTour },
dashboard: { key: "dashboard", completed: completedBoardTour },
};
const tours = [
{ key: "management" as const, completed: tourStatus?.completedManageTour ?? false },
{ key: "dashboard" as const, completed: tourStatus?.completedBoardTour ?? false },
];
return (
<Stack gap="xs">
<Group gap="xs">
{Object.values(tourStatusMap).map(({ key, completed }) => (
{tours.map(({ key, completed }) => (
<Badge key={key} color={completed ? "green" : "gray"} variant="light">
{t(`name.${key}`)}: {completed ? t("status.completed") : t("status.notStarted")}
</Badge>
@@ -122,10 +122,7 @@ export default async function EditUserPage(props: Props) {
{session?.user.id === user.id && (
<Stack mb="lg">
<Title order={2}>{tGeneral("item.onboardingTours.title")}</Title>
<ResetTours
completedManageTour={user.completedManageTour}
completedBoardTour={user.completedBoardTour}
/>
<ResetTours />
</Stack>
)}
@@ -12,6 +12,8 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { TourTarget } from "~/components/layout/header/tour-target";
interface UserListComponentProps {
initialUserList: RouterOutputs["user"]["getAll"];
credentialsProviderEnabled: boolean;
@@ -69,12 +71,6 @@ export const UserListComponent = ({ initialUserList, credentialsProviderEnabled
enableFullScreenToggle: false,
layoutMode: "grid-no-grow",
getRowId: (row) => row.id,
renderTopToolbarCustomActions: () =>
credentialsProviderEnabled ? (
<Button component={Link} href="/manage/users/create">
{t("management.page.user.create.title")}
</Button>
) : null,
state: {
isLoading,
},
@@ -82,7 +78,18 @@ export const UserListComponent = ({ initialUserList, credentialsProviderEnabled
return (
<>
<Title mb="md">{tUserList("title")}</Title>
<Group justify="space-between" align="center" mb="md">
<TourTarget id="manage-users-list">
<Title>{tUserList("title")}</Title>
</TourTarget>
{credentialsProviderEnabled && (
<TourTarget id="manage-users-create">
<Button component={Link} href="/manage/users/create">
{t("management.page.user.create.title")}
</Button>
</TourTarget>
)}
</Group>
<MantineReactTable table={table} />
</>
);
+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>
);
};