fix: run the tour through Pro's tabs instead of bouncing between routes

This commit is contained in:
Linus Rath
2026-11-03 09:11:21 +01:00
parent 4eb820ce4e
commit def60b4fea
3 changed files with 63 additions and 4 deletions
@@ -38,7 +38,8 @@ vi.mock('@/stores/settings-store', () => ({
}));
const { TourProvider, useTour } = await import('../tour-provider');
const { getTourSteps } = await import('../tour-steps');
const { getTourSteps, isTourStepOnPage, proTabOfTourStep } = await import('../tour-steps');
const { useProTabStore } = await import('@/stores/pro-tab-store');
let api: ReturnType<typeof useTour>;
function Probe() {
@@ -140,3 +141,37 @@ describe('tour run', () => {
expect(env.command).toHaveBeenCalledWith('tour-end');
});
});
describe('tour under the Pro shell', () => {
const stepTo = (id: string) => {
for (let i = 0; i < 30 && api.steps[api.currentStep]?.id !== id; i++) act(() => api.nextStep());
};
beforeEach(() => {
env.phone = false;
env.pathname = '/pro';
useProTabStore.setState({ tabs: [{ id: 'home-mail', kind: 'mail', labelKey: 'mail', closeable: false, paneId: 'main' }], activeTabId: 'home-mail', activeSplitTabId: null, focusedPaneId: 'main', loadedTabIds: ['home-mail'] });
});
it('opens the tab of each step instead of leaving the Pro route', () => {
start();
stepTo('calendar-view');
const focused = () => {
const s = useProTabStore.getState();
return s.tabs.find((t) => t.id === s.activeTabId)?.kind;
};
expect(focused()).toBe('calendar');
expect(env.push).not.toHaveBeenCalled();
});
it('counts a step as on its page there', () => {
expect(isTourStepOnPage({ page: '/calendar' }, '/pro')).toBe(true);
expect(isTourStepOnPage({ page: '/calendar' }, '/settings')).toBe(false);
});
it('leaves the composer step in the compose tab it just opened', () => {
expect(proTabOfTourStep({ target: '[data-tour="composer"]' })).toBeNull();
expect(proTabOfTourStep({ target: '[data-tour="sidebar"]' })).toBe('mail');
expect(proTabOfTourStep({ target: 'x', page: '/settings' })).toBe('settings');
});
});
+9 -1
View File
@@ -8,7 +8,8 @@ import { useCalendarStore } from "@/stores/calendar-store";
import { useWebDAVStore } from "@/stores/webdav-store";
import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { getTourSteps, isTourStepOnPage, type TourStep } from "./tour-steps";
import { getTourSteps, isTourStepOnPage, PRO_ROUTE, proTabOfTourStep, type TourStep } from "./tour-steps";
import { useProTabStore } from "@/stores/pro-tab-store";
import { useIsMobile, useMediaQuery } from "@/hooks/use-media-query";
import { requestAppCommand } from "@/lib/app-commands";
import { TourOverlay } from "./tour-overlay";
@@ -87,6 +88,13 @@ export function TourProvider({ children }: { children: ReactNode }) {
// closes it first.
useEffect(() => {
if (!step) return;
// Under Pro every app is a tab of the one route. Asking for another route
// only comes back here, which went round for as long as the tour ran.
if (pathname === PRO_ROUTE) {
const tab = proTabOfTourStep(step);
if (tab) useProTabStore.getState().openTab(tab);
return;
}
if (!isTourStepOnPage(step, pathname)) {
router.push(step.page ?? "/");
} else if (phone && !step.page && step.view !== "reader") {
+18 -2
View File
@@ -1,3 +1,8 @@
import type { ProAppTabKind } from "@/stores/pro-tab-store";
/** The one route of the Pro shell. */
export const PRO_ROUTE = "/pro";
export interface TourStep {
id: string;
target: string;
@@ -50,8 +55,17 @@ function isCovered(el: Element, r: DOMRect): boolean {
return !!top && top !== el && !el.contains(top) && !top.contains(el);
}
/** Whether the user is in the app the step is about. */
/** The Pro tab that holds the app the step is about. */
export function proTabOfTourStep(step: Pick<TourStep, "page" | "target">): ProAppTabKind | null {
// The composer has a tab of its own, which the step just opened.
if (step.target === COMPOSER_TARGET) return null;
const page = step.page?.replace(/^\//, "");
return page === "calendar" || page === "contacts" || page === "files" || page === "settings" ? page : "mail";
}
/** Whether the user is in the app the step is about. Under Pro the provider opens its tab. */
export function isTourStepOnPage(step: Pick<TourStep, "page">, pathname: string): boolean {
if (pathname === PRO_ROUTE) return true;
const page = step.page && step.page !== "/" ? step.page : null;
if (!page) return pathname === "/" || pathname === "" || pathname === "/mail" || pathname.startsWith("/mail/");
return pathname === page || pathname.startsWith(page + "/");
@@ -62,6 +76,8 @@ function clickTourTarget(selector: string): void {
(findTourTarget(selector) as HTMLElement | null)?.click();
}
const COMPOSER_TARGET = '[data-tour="composer"]';
export const BASE_TOUR_STEPS: TourStep[] = [
{
id: "sidebar",
@@ -162,7 +178,7 @@ export const BASE_TOUR_STEPS: TourStep[] = [
export const DEMO_TOUR_STEPS: TourStep[] = [
{
id: "compose-open",
target: '[data-tour="composer"]',
target: COMPOSER_TARGET,
titleKey: "tour.compose_open_title",
descriptionKey: "tour.compose_open_desc",
placement: "left",