8000 feat(web): welcome tour after the first sign-in by adeelahmad · Pull Request #165 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions nextjs-frontend/__tests__/welcome-tour.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";

import { tourDue, WelcomeTour } from "@/components/home/welcome-tour";

const push = jest.fn();
const finishTour = jest.fn();
let me: Record<string, unknown> | undefined;
jest.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
jest.mock("@/lib/hooks/session", () => ({ useArchive: () => ({ me }) }));
jest.mock("@/lib/api/browser", () => ({ useApiClient: () => ({}), data: (p: Promise<unknown>) => p }));
jest.mock("@/app/openapi-client", () => ({ Auth: { finishTour: (...a: unknown[]) => finishTour(...a) } }));

function show() {
const qc = new QueryClient();
render(
<QueryClientProvider client={qc}>
<WelcomeTour />
</QueryClientProvider>,
);
return qc;
}

beforeEach(() => {
push.mockReset();
finishTour.mockReset().mockResolvedValue({ via: "access", toured_at: "2026-10-05T00:00:00+00:00" });
me = { via: "access", toured_at: null, user: {} };
});

describe("welcome tour", () => {
it("is due once, for someone signed in to the web app", () => {
expect(tourDue({ via: "access", toured_at: null })).toBe(true);
expect(tourDue({ via: "access", toured_at: "2026-10-05" })).toBe(false);
expect(tourDue({ via: "token", toured_at: null })).toBe(false);
expect(tourDue(undefined)).toBe(false);
});

it("asks the assistant in one step and counts as seen", async () => {
const qc = show();
fireEvent.change(screen.getByLabelText("Ask the assistant"), { target: { value: "what's new?" } });
fireEvent.click(screen.getByRole("button", { name: "Ask" }));
expect(push).toHaveBeenCalledWith("/chat?global=1&q=what%27s+new%3F");
await waitFor(() => expect(finishTour).toHaveBeenCalledTimes(1));
await waitFor(() => expect(qc.getQueryData(["me"])).toMatchObject({ toured_at: expect.any(String) }));
});

it("opens Reports, or skips, and closes", async () => {
show();
fireEvent.click(screen.getByRole("button", { name: "Open Reports" }));
expect(push).toHaveBeenCalledWith("/reports");
expect(screen.queryByText("Welcome to Lens")).not.toBeInTheDocument();
await waitFor(() => expect(finishTour).toHaveBeenCalledTimes(1));
});

it("stays shut once seen, and the account menu opens it again without recording anything", async () => {
me = { via: "access", toured_at: "2026-10-05T00:00:00+00:00", user: {} };
show();
expect(screen.queryByText("Welcome to Lens")).not.toBeInTheDocument();
act(() => {
window.dispatchEvent(new CustomEvent("lens:tour"));
});
expect(screen.getByText("Welcome to Lens")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Skip" }));
expect(screen.queryByText("Welcome to Lens")).not.toBeInTheDocument();
expect(finishTour).not.toHaveBeenCalled();
});
});
11 changes: 9 additions & 2 deletions nextjs-frontend/components/app-shell/account-menu.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { KeyRound, Keyboard, LogOut, SunMoon, UserRound, Users } from "lucide-react";
import { KeyRound, Keyboard, LogOut, Signpost, SunMoon, UserRound, Users } from "lucide-react";
import Link from "next/link";
import { signOut } from "next-auth/react";
import { useState } from "react";
Expand Down Expand Up @@ -31,7 +31,7 @@ export function rolesSummary(admin: boolean, roles: Record<string, string>, part
return list.slice(0, 3).join(" · ") + (list.length > 3 ? ` · +${list.length - 3} more` : "");
}

/** Name, roles, profile and password, API tokens, appearance, shortcuts, sign out (Access AC4). */
/** Name, roles, profile and password, API tokens, appearance, shortcuts, the welcome tour, sign out (Access AC4). */
export function AccountMenu({ name, email }: { name?: string | null; email: string }) {
const { me, admin } = useArchive();
const client = useApiClient();
Expand Down Expand Up @@ -95,6 +95,13 @@ export function AccountMenu({ name, email }: { name?: string | null; email: stri
>
Keyboard shortcuts
</MenuItem>
<MenuItem
className={ITEM}
icon={<Signpost />}
=> window.dispatchEvent(new CustomEvent("lens:tour"))}
>
Welcome tour
</MenuItem>
<MenuItem className={ITEM} icon={<LogOut />} => void signOut({ redirectTo: "/login" })}>
Sign out
</MenuItem>
Expand Down
2 changes: 2 additions & 0 deletions nextjs-frontend/components/app-shell/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { CommandPalette } from "@/components/app-shell/command-palette";
import { NamespaceSwitcher } from "@/components/app-shell/namespace-switcher";
import { Nav } from "@/components/app-shell/nav";
import { Shortcuts } from "@/components/app-shell/shortcuts";
import { WelcomeTour } from "@/components/home/welcome-tour";
import { PageChat } from "@/components/page-chat/page-chat";
import { Button, IconButton } from "@/components/ui/button";
import { EmptyState, Skeleton } from "@/components/ui/states";
Expand Down Expand Up @@ -192,6 +193,7 @@ export function AppShell({ user, children }: { user: SessionUser; children: Reac
<PageChat />
</Suspense>
<Shortcuts />
<WelcomeTour />
</div>
);
}
116 changes: 116 additions & 0 deletions nextjs-frontend/components/home/welcome-tour.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"use client";

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ChartNoAxesColumn, Sparkles } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";

import { Auth } from "@/app/openapi-client";
import type { Me } from "@/app/openapi-client/types.gen";
import { assistantChatHref } from "@/components/home/assistant-home";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { Input } from "@/components/ui/field";
import { data, useApiClient } from "@/lib/api/browser";
import { useArchive } from "@/lib/hooks/session";

/** The tour opens by itself for someone signed in to the web app who hasn't finished or skipped it yet. */
export function tourDue(me: Pick<Me, "toured_at" | "via"> | undefined): boolean {
return Boolean(me) && me?.via === "access" && !me?.toured_at;
}

function Stop({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
return (
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
<div className="flex items-center gap-2.5">
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-blue-surface text-blue [&_svg]:size-4">
{icon}
</span>
<h3 className="text-[15.5px] font-bold text-fg">{title}</h3>
</div>
{children}
</section>
);
}

/**
* After someone's first sign-in: the two places people come back for, the assistant and the charts, each one step away.
* Asking, opening Reports, skipping or closing all count as seen, so it opens once; the account menu opens it again.
*/
export function WelcomeTour() {
const { me } = useArchive();
const client = useApiClient();
const queries = useQueryClient();
const router = useRouter();
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const due = tourDue(me);

useEffect(() => {
if (due) setOpen(true);
}, [due]);
useEffect(() => {
const replay = () => setOpen(true);
window.addEventListener("lens:tour", replay);
return () => window.removeEventListener("lens:tour", replay);
}, []);

const seen = useMutation({
mutationFn: () => data(Auth.finishTour({ client })),
onSuccess: (got) => queries.setQueryData(["me"], got),
});
const close = (href?: string) => {
setOpen(false);
if (due && !seen.isPending) seen.mutate();
if (href) router.push(href);
};

return (
<Dialog
open={open}
=> (o ? setOpen(true) : close())}
title="Welcome to Lens"
description="Most people start in one of these two places. Open this again any time from the account menu."
actions={
<Button variant="ghost" => close()}>
Skip
</Button>
}
>
<Stop icon={<Sparkles />} title="Ask the assistant">
<p className="text-[14px] leading-normal text-fg-secondary">
It answers from everything you have added and shows the source of each answer.
</p>
<form
className="flex gap-2"
=> {
e.preventDefault();
close(assistantChatHref({ q: q.trim() || undefined }));
}}
>
<Input
data-autofocus
value={q}
=> setQ(e.target.value)}
placeholder="Ask anything"
aria-label="Ask the assistant"
className="min-w-0 flex-1"
/>
<Button type="submit" variant="primary">
Ask
</Button>
</form>
</Stop>
<Stop icon={<ChartNoAxesColumn />} title="See your charts">
<p className="text-[14px] leading-normal text-fg-secondary">
Reports turn what you have added into charts, per namespace and per recording.
</p>
<div>
<Button variant="secondary" => close("/reports")}>
Open Reports
</Button>
</div>
</Stop>
</Dialog>
);
}
Loading
0