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

Commit 1b06b72

Browse files
authored
static-user-onboarding-steps (#9799)
1 parent ecb3e7a commit 1b06b72

File tree

9 files changed

+279
-33
lines changed

9 files changed

+279
-33
lines changed

src/components/views/user-onboarding/UserOnboardingFeedback.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export function UserOnboardingFeedback() {
3030
}
3131

3232
return (
33-
<div className="mx_UserOnboardingFeedback">
33+
<div className="mx_UserOnboardingFeedback" data-testid="user-onboarding-feedback">
3434
<div className="mx_UserOnboardingFeedback_content">
3535
<Heading size="h4" className="mx_UserOnboardingFeedback_title">
3636
{_t("How are you finding %(brand)s so far?", {

src/components/views/user-onboarding/UserOnboardingList.tsx

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,36 +15,35 @@ limitations under the License.
1515
*/
1616

1717
import * as React from "react";
18-
import { useMemo } from "react";
1918

20-
import { UserOnboardingTask as Task } from "../../../hooks/useUserOnboardingTasks";
19+
import { UserOnboardingTaskWithResolvedCompletion } from "../../../hooks/useUserOnboardingTasks";
2120
import { _t } from "../../../languageHandler";
2221
import SdkConfig from "../../../SdkConfig";
2322
import ProgressBar from "../../views/elements/ProgressBar";
2423
import Heading from "../../views/typography/Heading";
2524
import { UserOnboardingFeedback } from "./UserOnboardingFeedback";
2625
import { UserOnboardingTask } from "./UserOnboardingTask";
2726

27+
export const getUserOnboardingCounters = (tasks: UserOnboardingTaskWithResolvedCompletion[]) => {
28+
const completed = tasks.filter((task) => task.completed === true).length;
29+
const waiting = tasks.filter((task) => task.completed === false).length;
30+
31+
return {
32+
completed: completed,
33+
waiting: waiting,
34+
total: completed + waiting,
35+
};
36+
};
37+
2838
interface Props {
29-
completedTasks: Task[];
30-
waitingTasks: Task[];
39+
tasks: UserOnboardingTaskWithResolvedCompletion[];
3140
}
3241

33-
export function UserOnboardingList({ completedTasks, waitingTasks }: Props) {
34-
const completed = completedTasks.length;
35-
const waiting = waitingTasks.length;
36-
const total = completed + waiting;
37-
38-
const tasks = useMemo(
39-
() => [
40-
...completedTasks.map((it): [Task, boolean] => [it, true]),
41-
...waitingTasks.map((it): [Task, boolean] => [it, false]),
42-
],
43-
[completedTasks, waitingTasks],
44-
);
42+
export function UserOnboardingList({ tasks }: Props) {
43+
const { completed, waiting, total } = getUserOnboardingCounters(tasks);
4544

4645
return (
47-
<div className="mx_UserOnboardingList">
46+
<div className="mx_UserOnboardingList" data-testid="user-onboarding-list">
4847
<div className="mx_UserOnboardingList_header">
4948
<Heading size="h3" className="mx_UserOnboardingList_title">
5049
{waiting > 0
@@ -64,8 +63,8 @@ export function UserOnboardingList({ completedTasks, waitingTasks }: Props) {
6463
{waiting === 0 && <UserOnboardingFeedback />}
6564
</div>
6665
<ol className="mx_UserOnboardingList_list">
67-
{tasks.map(([task, completed]) => (
68-
<UserOnboardingTask key={task.id} completed={completed} task={task} />
66+
{tasks.map((task) => (
67+
<UserOnboardingTask key={task.id} completed={task.completed} task={task} />
6968
))}
7069
</ol>
7170
</div>

src/components/views/user-onboarding/UserOnboardingPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export function UserOnboardingPage({ justRegistered = false }: Props) {
4949

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

5454
const initialSyncComplete = useInitialSyncComplete();
5555
const [showList, setShowList] = useState<boolean>(false);
@@ -80,7 +80,7 @@ export function UserOnboardingPage({ justRegistered = false }: Props) {
8080
return (
8181
<AutoHideScrollbar className="mx_UserOnboardingPage">
8282
<UserOnboardingHeader useCase={useCase} />
83-
{showList && <UserOnboardingList completedTasks={completedTasks} waitingTasks={waitingTasks} />}
83+
{showList && <UserOnboardingList tasks={tasks} />}
8484
</AutoHideScrollbar>
8585
);
8686
}

src/components/views/user-onboarding/UserOnboardingTask.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ limitations under the License.
1717
import classNames from "classnames";
1818
import * as React from "react";
1919

20-
import { UserOnboardingTask as Task } from "../../../hooks/useUserOnboardingTasks";
20+
import { UserOnboardingTaskWithResolvedCompletion } from "../../../hooks/useUserOnboardingTasks";
2121
import AccessibleButton from "../../views/elements/AccessibleButton";
2222
import Heading from "../../views/typography/Heading";
2323

2424
interface Props {
25-
task: Task;
25+
task: UserOnboardingTaskWithResolvedCompletion;
2626
completed?: boolean;
2727
}
2828

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

3333
return (
3434
<li
35+
data-testid="user-onboarding-task"
3536
className={classNames("mx_UserOnboardingTask", {
3637
mx_UserOnboardingTask_completed: completed,
3738
})}

src/hooks/useUserOnboardingContext.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ function useUserOnboardingContextValue<T>(defaultValue: T, callback: (cli: Matri
8282
return value;
8383
}
8484

85-
export function useUserOnboardingContext(): UserOnboardingContext | null {
85+
export function useUserOnboardingContext(): UserOnboardingContext {
8686
const hasAvatar = useUserOnboardingContextValue(false, async (cli) => {
8787
const profile = await cli.getProfileInfo(cli.getUserId());
8888
return Boolean(profile?.avatar_url);

src/hooks/useUserOnboardingTasks.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { UseCase } from "../settings/enums/UseCase";
3030
import { useSettingValue } from "./useSettings";
3131
import { UserOnboardingContext } from "./useUserOnboardingContext";
3232

33-
export interface UserOnboardingTask {
33+
interface UserOnboardingTask {
3434
id: string;
3535
title: string | (() => string);
3636
description: string | (() => string);
@@ -41,18 +41,19 @@ export interface UserOnboardingTask {
4141
href?: string;
4242
hideOnComplete?: boolean;
4343
};
44+
completed: (ctx: UserOnboardingContext) => boolean;
4445
}
4546

46-
interface InternalUserOnboardingTask extends UserOnboardingTask {
47-
completed: (ctx: UserOnboardingContext) => boolean;
47+
export interface UserOnboardingTaskWithResolvedCompletion extends Omit<UserOnboardingTask, "completed"> {
48+
completed: boolean;
4849
}
4950

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

55-
const tasks: InternalUserOnboardingTask[] = [
56+
const tasks: UserOnboardingTask[] = [
5657
{
5758
id: "create-account",
5859
title: _t("Create account"),
@@ -143,9 +144,15 @@ const tasks: InternalUserOnboardingTask[] = [
143144
},
144145
];
145146

146-
export function useUserOnboardingTasks(context: UserOnboardingContext): [UserOnboardingTask[], UserOnboardingTask[]] {
147+
export function useUserOnboardingTasks(context: UserOnboardingContext) {
147148
const useCase = useSettingValue<UseCase | null>("FTUE.useCaseSelection") ?? UseCase.Skip;
148-
const relevantTasks = useMemo(() => tasks.filter((it) => !it.relevant || it.relevant.includes(useCase)), [useCase]);
149-
const completedTasks = relevantTasks.filter((it) => context && it.completed(context));
150-
return [completedTasks, relevantTasks.filter((it) => !completedTasks.includes(it))];
149+
150+
return useMemo<UserOnboardingTaskWithResolvedCompletion[]>(() => {
151+
return tasks
152+
.filter((task) => !task.relevant || task.relevant.includes(useCase))
153+
.map((task) => ({
154+
...task,
155+
completed: task.completed(context),
156+
}));
157+
}, [context, useCase]);
151158
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React from "react";
18+
import { screen, render } from "@testing-library/react";
19+
20+
import {
21+
getUserOnboardingCounters,
22+
UserOnboardingList,
23+
} from "../../../../src/components/views/user-onboarding/UserOnboardingList";
24+
import SdkConfig from "../../../../src/SdkConfig";
25+
26+
const tasks = [
27+
{
28+
id: "1",
29+
title: "Lorem ipsum",
30+
description: "Lorem ipsum dolor amet.",
31+
completed: true,
32+
},
33+
{
34+
id: "2",
35+
title: "Lorem ipsum",
36+
description: "Lorem ipsum dolor amet.",
37+
completed: false,
38+
},
39+
];
40+
41+
describe("getUserOnboardingCounters()", () => {
42+
it.each([
43+
{
44+
tasks: [],
45+
expectation: {
46+
completed: 0,
47+
waiting: 0,
48+
total: 0,
49+
},
50+
},
51+
{
52+
tasks: tasks,
53+
expectation: {
54+
completed: 1,
55+
waiting: 1,
56+
total: 2,
57+
},
58+
},
59+
])("should calculate counters correctly", ({ tasks, expectation }) => {
60+
const result = getUserOnboardingCounters(tasks);
61+
expect(result).toStrictEqual(expectation);
62+
});
63+
});
64+
65+
describe("UserOnboardingList", () => {
66+
// This configuration affects rendering of the feedback and needs to be set.
67+
beforeAll(() => {
68+
SdkConfig.put({
69+
bug_report_endpoint_url: "https://bug_report_endpoint_url.com",
70+
});
71+
});
72+
73+
it("should not display feedback when there are waiting tasks", async () => {
74+
render(<UserOnboardingList tasks={tasks} />);
75+
76+
expect(await screen.findByText("Only 1 step to go")).toBeVisible();
77+
expect(await screen.queryByTestId("user-onboarding-feedback")).toBeNull();
78+
expect(await screen.findAllByTestId("user-onboarding-task")).toHaveLength(2);
79+
});
80+
81+
it("should display feedback when all tasks are completed", async () => {
82+
render(<UserOnboardingList tasks={tasks.map((task) => ({ ...task, completed: true }))} />);
83+
84+
expect(await screen.findByText("You did it!")).toBeVisible();
85+
expect(await screen.findByTestId("user-onboarding-feedback")).toBeInTheDocument();
86+
expect(await screen.queryAllByTestId("user-onboarding-task")).toHaveLength(2);
87+
});
88+
});
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React from "react";
18+
import { act, render, RenderResult } from "@testing-library/react";
19+
20+
import { filterConsole, stubClient } from "../../../test-utils";
21+
import { UserOnboardingPage } from "../../../../src/components/views/user-onboarding/UserOnboardingPage";
22+
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
23+
import SdkConfig from "../../../../src/SdkConfig";
24+
25+
jest.mock("../../../../src/components/structures/EmbeddedPage", () => ({
26+
__esModule: true,
27+
default: jest.fn().mockImplementation(({ url }) => <div>{url}</div>),
28+
}));
29+
30+
jest.mock("../../../../src/components/structures/HomePage", () => ({
31+
__esModule: true,
32+
default: jest.fn().mockImplementation(() => <div>home page</div>),
33+
}));
34+
35+
describe("UserOnboardingPage", () => {
36+
let restoreConsole: () => void;
37+
38+
const renderComponent = async (): Promise<RenderResult> => {
39+
const renderResult = render(<UserOnboardingPage />);
40+
await act(async () => {
41+
jest.runAllTimers();
42+
});
43+
return renderResult;
44+
};
45+
46+
beforeAll(() => {
47+
restoreConsole = filterConsole(
48+
// unrelated for this test
49+
"could not update user onboarding context",
50+
);
51+
});
52+
53+
beforeEach(() => {
54+
stubClient();
55+
jest.useFakeTimers();
56+
});
57+
58+
afterEach(() => {
59+
jest.useRealTimers();
60+
jest.restoreAllMocks();
61+
});
62+
63+
afterAll(() => {
64+
restoreConsole();
65+
});
66+
67+
describe("when the user registered before the cutoff date", () => {
68+
beforeEach(() => {
69+
jest.spyOn(MatrixClientPeg, "userRegisteredAfter").mockReturnValue(false);
70+
});
71+
72+
it("should render the home page", async () => {
73+
expect((await renderComponent()).queryByText("home page")).toBeInTheDocument();
74+
});
75+
});
76+
77+
describe("when the user registered after the cutoff date", () => {
78+
beforeEach(() => {
79+
jest.spyOn(MatrixClientPeg, "userRegisteredAfter").mockReturnValue(true);
80+
});
81+
82+
describe("and there is an explicit home page configured", () => {
83+
beforeEach(() => {
84+
jest.spyOn(SdkConfig, "get").mockReturnValue({
85+
embedded_pages: {
86+
home_url: "https://example.com/home",
87+
},
88+
});
89+
});
90+
91+
it("should render the configured page", async () => {
92+
expect((await renderComponent()).queryByText("https://example.com/home")).toBeInTheDocument();
93+
});
94+
});
95+
96+
describe("and there is no home page configured", () => {
97+
it("should render the onboarding", async () => {
98+
expect((await renderComponent()).queryByTestId("user-onboarding-list")).toBeInTheDocument();
99+
});
100+
});
101+
});
102+
});

0 commit comments

Comments
 (0)