Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

static-user-onboarding-steps #9799

Merged
merged 8 commits into from
Jan 2, 2023
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function UserOnboardingFeedback() {
}

return (
<div className="mx_UserOnboardingFeedback">
<div className="mx_UserOnboardingFeedback" data-testid="user-onboarding-feedback">
<div className="mx_UserOnboardingFeedback_content">
<Heading size="h4" className="mx_UserOnboardingFeedback_title">
{_t("How are you finding %(brand)s so far?", {
Expand Down
37 changes: 18 additions & 19 deletions src/components/views/user-onboarding/UserOnboardingList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,36 +15,35 @@ limitations under the License.
*/

import * as React from "react";
import { useMemo } from "react";

import { UserOnboardingTask as Task } from "../../../hooks/useUserOnboardingTasks";
import { UserOnboardingTaskWithResolvedCompletion } from "../../../hooks/useUserOnboardingTasks";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import ProgressBar from "../../views/elements/ProgressBar";
import Heading from "../../views/typography/Heading";
import { UserOnboardingFeedback } from "./UserOnboardingFeedback";
import { UserOnboardingTask } from "./UserOnboardingTask";

export const getUserOnboardingCounters = (tasks: UserOnboardingTaskWithResolvedCompletion[]) => {
const completed = tasks.filter((task) => task.completed === true).length;
const waiting = tasks.filter((task) => task.completed === false).length;

return {
completed: completed,
waiting: waiting,
total: completed + waiting,
};
};

interface Props {
completedTasks: Task[];
waitingTasks: Task[];
tasks: UserOnboardingTaskWithResolvedCompletion[];
}

export function UserOnboardingList({ completedTasks, waitingTasks }: Props) {
const completed = completedTasks.length;
const waiting = waitingTasks.length;
const total = completed + waiting;

const tasks = useMemo(
() => [
...completedTasks.map((it): [Task, boolean] => [it, true]),
...waitingTasks.map((it): [Task, boolean] => [it, false]),
],
[completedTasks, waitingTasks],
);
export function UserOnboardingList({ tasks }: Props) {
const { completed, waiting, total } = getUserOnboardingCounters(tasks);

return (
<div className="mx_UserOnboardingList">
<div className="mx_UserOnboardingList" data-testid="user-onboarding-list">
<div className="mx_UserOnboardingList_header">
<Heading size="h3" className="mx_UserOnboardingList_title">
{waiting > 0
Expand All @@ -64,8 +63,8 @@ export function UserOnboardingList({ completedTasks, waitingTasks }: Props) {
{waiting === 0 && <UserOnboardingFeedback />}
</div>
<ol className="mx_UserOnboardingList_list">
{tasks.map(([task, completed]) => (
<UserOnboardingTask key={task.id} completed={completed} task={task} />
{tasks.map((task) => (
<UserOnboardingTask key={task.id} completed={task.completed} task={task} />
))}
</ol>
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/components/views/user-onboarding/UserOnboardingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function UserOnboardingPage({ justRegistered = false }: Props) {

const useCase = useSettingValue<UseCase | null>("FTUE.useCaseSelection");
const context = useUserOnboardingContext();
const [completedTasks, waitingTasks] = useUserOnboardingTasks(context);
const tasks = useUserOnboardingTasks(context);

const initialSyncComplete = useInitialSyncComplete();
const [showList, setShowList] = useState<boolean>(false);
Expand Down Expand Up @@ -80,7 +80,7 @@ export function UserOnboardingPage({ justRegistered = false }: Props) {
return (
<AutoHideScrollbar className="mx_UserOnboardingPage">
<UserOnboardingHeader useCase={useCase} />
{showList && <UserOnboardingList completedTasks={completedTasks} waitingTasks={waitingTasks} />}
{showList && <UserOnboardingList tasks={tasks} />}
</AutoHideScrollbar>
);
}
5 changes: 3 additions & 2 deletions src/components/views/user-onboarding/UserOnboardingTask.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ limitations under the License.
import classNames from "classnames";
import * as React from "react";

import { UserOnboardingTask as Task } from "../../../hooks/useUserOnboardingTasks";
import { UserOnboardingTaskWithResolvedCompletion } from "../../../hooks/useUserOnboardingTasks";
import AccessibleButton from "../../views/elements/AccessibleButton";
import Heading from "../../views/typography/Heading";

interface Props {
task: Task;
task: UserOnboardingTaskWithResolvedCompletion;
completed?: boolean;
}

Expand All @@ -32,6 +32,7 @@ export function UserOnboardingTask({ task, completed = false }: Props) {

return (
<li
data-testid="user-onboarding-task"
className={classNames("mx_UserOnboardingTask", {
mx_UserOnboardingTask_completed: completed,
})}
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useUserOnboardingContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ function useUserOnboardingContextValue<T>(defaultValue: T, callback: (cli: Matri
return value;
}

export function useUserOnboardingContext(): UserOnboardingContext | null {
export function useUserOnboardingContext(): UserOnboardingContext {
const hasAvatar = useUserOnboardingContextValue(false, async (cli) => {
const profile = await cli.getProfileInfo(cli.getUserId());
return Boolean(profile?.avatar_url);
Expand Down
23 changes: 15 additions & 8 deletions src/hooks/useUserOnboardingTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { UseCase } from "../settings/enums/UseCase";
import { useSettingValue } from "./useSettings";
import { UserOnboardingContext } from "./useUserOnboardingContext";

export interface UserOnboardingTask {
interface UserOnboardingTask {
id: string;
title: string | (() => string);
description: string | (() => string);
Expand All @@ -41,18 +41,19 @@ export interface UserOnboardingTask {
href?: string;
hideOnComplete?: boolean;
};
completed: (ctx: UserOnboardingContext) => boolean;
}

interface InternalUserOnboardingTask extends UserOnboardingTask {
completed: (ctx: UserOnboardingContext) => boolean;
export interface UserOnboardingTaskWithResolvedCompletion extends Omit<UserOnboardingTask, "completed"> {
completed: boolean;
}

const onClickStartDm = (ev: ButtonEvent) => {
PosthogTrackers.trackInteraction("WebUserOnboardingTaskSendDm", ev);
defaultDispatcher.dispatch({ action: "view_create_chat" });
};

const tasks: InternalUserOnboardingTask[] = [
const tasks: UserOnboardingTask[] = [
{
id: "create-account",
title: _t("Create account"),
Expand Down Expand Up @@ -143,9 +144,15 @@ const tasks: InternalUserOnboardingTask[] = [
},
];

export function useUserOnboardingTasks(context: UserOnboardingContext): [UserOnboardingTask[], UserOnboardingTask[]] {
export function useUserOnboardingTasks(context: UserOnboardingContext) {
const useCase = useSettingValue<UseCase | null>("FTUE.useCaseSelection") ?? UseCase.Skip;
const relevantTasks = useMemo(() => tasks.filter((it) => !it.relevant || it.relevant.includes(useCase)), [useCase]);
const completedTasks = relevantTasks.filter((it) => context && it.completed(context));
return [completedTasks, relevantTasks.filter((it) => !completedTasks.includes(it))];

return useMemo<UserOnboardingTaskWithResolvedCompletion[]>(() => {
return tasks
.filter((task) => !task.relevant || task.relevant.includes(useCase))
.map((task) => ({
...task,
completed: task.completed(context),
}));
}, [context, useCase]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import React from "react";
import { screen, render } from "@testing-library/react";

import {
getUserOnboardingCounters,
UserOnboardingList,
} from "../../../../src/components/views/user-onboarding/UserOnboardingList";
import SdkConfig from "../../../../src/SdkConfig";

const tasks = [
{
id: "1",
title: "Lorem ipsum",
description: "Lorem ipsum dolor amet.",
completed: true,
},
{
id: "2",
title: "Lorem ipsum",
description: "Lorem ipsum dolor amet.",
completed: false,
},
];

describe("getUserOnboardingCounters()", () => {
it.each([
{
tasks: [],
expectation: {
completed: 0,
waiting: 0,
total: 0,
},
},
{
tasks: tasks,
expectation: {
completed: 1,
waiting: 1,
total: 2,
},
},
])("should calculate counters correctly", ({ tasks, expectation }) => {
const result = getUserOnboardingCounters(tasks);
expect(result).toStrictEqual(expectation);
});
});

describe("UserOnboardingList", () => {
// This configuration affects rendering of the feedback and needs to be set.
beforeAll(() => {
SdkConfig.put({
bug_report_endpoint_url: "https://bug_report_endpoint_url.com",
});
});

it("should not display feedback when there are waiting tasks", async () => {
render(<UserOnboardingList tasks={tasks} />);

expect(await screen.findByText("Only 1 step to go")).toBeVisible();
expect(await screen.queryByTestId("user-onboarding-feedback")).toBeNull();
expect(await screen.findAllByTestId("user-onboarding-task")).toHaveLength(2);
});

it("should display feedback when all tasks are completed", async () => {
render(<UserOnboardingList tasks={tasks.map((task) => ({ ...task, completed: true }))} />);

expect(await screen.findByText("You did it!")).toBeVisible();
expect(await screen.findByTestId("user-onboarding-feedback")).toBeInTheDocument();
expect(await screen.queryAllByTestId("user-onboarding-task")).toHaveLength(2);
});
});
102 changes: 102 additions & 0 deletions test/components/views/user-onboarding/UserOnboardingPage-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import React from "react";
import { act, render, RenderResult } from "@testing-library/react";

import { filterConsole, stubClient } from "../../../test-utils";
import { UserOnboardingPage } from "../../../../src/components/views/user-onboarding/UserOnboardingPage";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import SdkConfig from "../../../../src/SdkConfig";

jest.mock("../../../../src/components/structures/EmbeddedPage", () => ({
__esModule: true,
default: jest.fn().mockImplementation(({ url }) => <div>{url}</div>),
}));

jest.mock("../../../../src/components/structures/HomePage", () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => <div>home page</div>),
}));

describe("UserOnboardingPage", () => {
let restoreConsole: () => void;

const renderComponent = async (): Promise<RenderResult> => {
const renderResult = render(<UserOnboardingPage />);
await act(async () => {
jest.runAllTimers();
});
return renderResult;
};

beforeAll(() => {
restoreConsole = filterConsole(
// unrelated for this test
"could not update user onboarding context",
);
});

beforeEach(() => {
stubClient();
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});

afterAll(() => {
restoreConsole();
});

describe("when the user registered before the cutoff date", () => {
beforeEach(() => {
jest.spyOn(MatrixClientPeg, "userRegisteredAfter").mockReturnValue(false);
});

it("should render the home page", async () => {
expect((await renderComponent()).queryByText("home page")).toBeInTheDocument();
});
});

describe("when the user registered after the cutoff date", () => {
beforeEach(() => {
jest.spyOn(MatrixClientPeg, "userRegisteredAfter").mockReturnValue(true);
});

describe("and there is an explicit home page configured", () => {
beforeEach(() => {
jest.spyOn(SdkConfig, "get").mockReturnValue({
embedded_pages: {
home_url: "https://example.com/home",
},
});
});

it("should render the configured page", async () => {
expect((await renderComponent()).queryByText("https://example.com/home")).toBeInTheDocument();
});
});

describe("and there is no home page configured", () => {
it("should render the onboarding", async () => {
expect((await renderComponent()).queryByTestId("user-onboarding-list")).toBeInTheDocument();
GoodGuyMarco marked this conversation as resolved.
Show resolved Hide resolved
});
});
});
});
Loading