static-user-onboarding-steps (#9799)
This commit is contained in:
parent
ecb3e7a197
commit
1b06b72b67
9 changed files with 279 additions and 33 deletions
|
@ -30,7 +30,7 @@ export function UserOnboardingFeedback() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx_UserOnboardingFeedback">
|
<div className="mx_UserOnboardingFeedback" data-testid="user-onboarding-feedback">
|
||||||
<div className="mx_UserOnboardingFeedback_content">
|
<div className="mx_UserOnboardingFeedback_content">
|
||||||
<Heading size="h4" className="mx_UserOnboardingFeedback_title">
|
<Heading size="h4" className="mx_UserOnboardingFeedback_title">
|
||||||
{_t("How are you finding %(brand)s so far?", {
|
{_t("How are you finding %(brand)s so far?", {
|
||||||
|
|
|
@ -15,9 +15,8 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as React from "react";
|
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 { _t } from "../../../languageHandler";
|
||||||
import SdkConfig from "../../../SdkConfig";
|
import SdkConfig from "../../../SdkConfig";
|
||||||
import ProgressBar from "../../views/elements/ProgressBar";
|
import ProgressBar from "../../views/elements/ProgressBar";
|
||||||
|
@ -25,26 +24,26 @@ import Heading from "../../views/typography/Heading";
|
||||||
import { UserOnboardingFeedback } from "./UserOnboardingFeedback";
|
import { UserOnboardingFeedback } from "./UserOnboardingFeedback";
|
||||||
import { UserOnboardingTask } from "./UserOnboardingTask";
|
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 {
|
interface Props {
|
||||||
completedTasks: Task[];
|
tasks: UserOnboardingTaskWithResolvedCompletion[];
|
||||||
waitingTasks: Task[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserOnboardingList({ completedTasks, waitingTasks }: Props) {
|
export function UserOnboardingList({ tasks }: Props) {
|
||||||
const completed = completedTasks.length;
|
const { completed, waiting, total } = getUserOnboardingCounters(tasks);
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx_UserOnboardingList">
|
<div className="mx_UserOnboardingList" data-testid="user-onboarding-list">
|
||||||
<div className="mx_UserOnboardingList_header">
|
<div className="mx_UserOnboardingList_header">
|
||||||
<Heading size="h3" className="mx_UserOnboardingList_title">
|
<Heading size="h3" className="mx_UserOnboardingList_title">
|
||||||
{waiting > 0
|
{waiting > 0
|
||||||
|
@ -64,8 +63,8 @@ export function UserOnboardingList({ completedTasks, waitingTasks }: Props) {
|
||||||
{waiting === 0 && <UserOnboardingFeedback />}
|
{waiting === 0 && <UserOnboardingFeedback />}
|
||||||
</div>
|
</div>
|
||||||
<ol className="mx_UserOnboardingList_list">
|
<ol className="mx_UserOnboardingList_list">
|
||||||
{tasks.map(([task, completed]) => (
|
{tasks.map((task) => (
|
||||||
<UserOnboardingTask key={task.id} completed={completed} task={task} />
|
<UserOnboardingTask key={task.id} completed={task.completed} task={task} />
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -49,7 +49,7 @@ export function UserOnboardingPage({ justRegistered = false }: Props) {
|
||||||
|
|
||||||
const useCase = useSettingValue<UseCase | null>("FTUE.useCaseSelection");
|
const useCase = useSettingValue<UseCase | null>("FTUE.useCaseSelection");
|
||||||
const context = useUserOnboardingContext();
|
const context = useUserOnboardingContext();
|
||||||
const [completedTasks, waitingTasks] = useUserOnboardingTasks(context);
|
const tasks = useUserOnboardingTasks(context);
|
||||||
|
|
||||||
const initialSyncComplete = useInitialSyncComplete();
|
const initialSyncComplete = useInitialSyncComplete();
|
||||||
const [showList, setShowList] = useState<boolean>(false);
|
const [showList, setShowList] = useState<boolean>(false);
|
||||||
|
@ -80,7 +80,7 @@ export function UserOnboardingPage({ justRegistered = false }: Props) {
|
||||||
return (
|
return (
|
||||||
<AutoHideScrollbar className="mx_UserOnboardingPage">
|
<AutoHideScrollbar className="mx_UserOnboardingPage">
|
||||||
<UserOnboardingHeader useCase={useCase} />
|
<UserOnboardingHeader useCase={useCase} />
|
||||||
{showList && <UserOnboardingList completedTasks={completedTasks} waitingTasks={waitingTasks} />}
|
{showList && <UserOnboardingList tasks={tasks} />}
|
||||||
</AutoHideScrollbar>
|
</AutoHideScrollbar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,12 +17,12 @@ limitations under the License.
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { UserOnboardingTask as Task } from "../../../hooks/useUserOnboardingTasks";
|
import { UserOnboardingTaskWithResolvedCompletion } from "../../../hooks/useUserOnboardingTasks";
|
||||||
import AccessibleButton from "../../views/elements/AccessibleButton";
|
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||||
import Heading from "../../views/typography/Heading";
|
import Heading from "../../views/typography/Heading";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
task: Task;
|
task: UserOnboardingTaskWithResolvedCompletion;
|
||||||
completed?: boolean;
|
completed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -32,6 +32,7 @@ export function UserOnboardingTask({ task, completed = false }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
|
data-testid="user-onboarding-task"
|
||||||
className={classNames("mx_UserOnboardingTask", {
|
className={classNames("mx_UserOnboardingTask", {
|
||||||
mx_UserOnboardingTask_completed: completed,
|
mx_UserOnboardingTask_completed: completed,
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -82,7 +82,7 @@ function useUserOnboardingContextValue<T>(defaultValue: T, callback: (cli: Matri
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUserOnboardingContext(): UserOnboardingContext | null {
|
export function useUserOnboardingContext(): UserOnboardingContext {
|
||||||
const hasAvatar = useUserOnboardingContextValue(false, async (cli) => {
|
const hasAvatar = useUserOnboardingContextValue(false, async (cli) => {
|
||||||
const profile = await cli.getProfileInfo(cli.getUserId());
|
const profile = await cli.getProfileInfo(cli.getUserId());
|
||||||
return Boolean(profile?.avatar_url);
|
return Boolean(profile?.avatar_url);
|
||||||
|
|
|
@ -30,7 +30,7 @@ import { UseCase } from "../settings/enums/UseCase";
|
||||||
import { useSettingValue } from "./useSettings";
|
import { useSettingValue } from "./useSettings";
|
||||||
import { UserOnboardingContext } from "./useUserOnboardingContext";
|
import { UserOnboardingContext } from "./useUserOnboardingContext";
|
||||||
|
|
||||||
export interface UserOnboardingTask {
|
interface UserOnboardingTask {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | (() => string);
|
title: string | (() => string);
|
||||||
description: string | (() => string);
|
description: string | (() => string);
|
||||||
|
@ -41,10 +41,11 @@ export interface UserOnboardingTask {
|
||||||
href?: string;
|
href?: string;
|
||||||
hideOnComplete?: boolean;
|
hideOnComplete?: boolean;
|
||||||
};
|
};
|
||||||
|
completed: (ctx: UserOnboardingContext) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InternalUserOnboardingTask extends UserOnboardingTask {
|
export interface UserOnboardingTaskWithResolvedCompletion extends Omit<UserOnboardingTask, "completed"> {
|
||||||
completed: (ctx: UserOnboardingContext) => boolean;
|
completed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClickStartDm = (ev: ButtonEvent) => {
|
const onClickStartDm = (ev: ButtonEvent) => {
|
||||||
|
@ -52,7 +53,7 @@ const onClickStartDm = (ev: ButtonEvent) => {
|
||||||
defaultDispatcher.dispatch({ action: "view_create_chat" });
|
defaultDispatcher.dispatch({ action: "view_create_chat" });
|
||||||
};
|
};
|
||||||
|
|
||||||
const tasks: InternalUserOnboardingTask[] = [
|
const tasks: UserOnboardingTask[] = [
|
||||||
{
|
{
|
||||||
id: "create-account",
|
id: "create-account",
|
||||||
title: _t("Create account"),
|
title: _t("Create account"),
|
||||||
|
@ -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 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 useMemo<UserOnboardingTaskWithResolvedCompletion[]>(() => {
|
||||||
return [completedTasks, relevantTasks.filter((it) => !completedTasks.includes(it))];
|
return tasks
|
||||||
|
.filter((task) => !task.relevant || task.relevant.includes(useCase))
|
||||||
|
.map((task) => ({
|
||||||
|
...task,
|
||||||
|
completed: task.completed(context),
|
||||||
|
}));
|
||||||
|
}, [context, useCase]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
49
test/hooks/useUserOnboardingTasks-test.tsx
Normal file
49
test/hooks/useUserOnboardingTasks-test.tsx
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
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 { renderHook } from "@testing-library/react-hooks";
|
||||||
|
|
||||||
|
import { useUserOnboardingTasks } from "../../src/hooks/useUserOnboardingTasks";
|
||||||
|
|
||||||
|
describe("useUserOnboardingTasks", () => {
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
context: {
|
||||||
|
hasAvatar: false,
|
||||||
|
hasDevices: false,
|
||||||
|
hasDmRooms: false,
|
||||||
|
hasNotificationsEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
context: {
|
||||||
|
hasAvatar: true,
|
||||||
|
hasDevices: false,
|
||||||
|
hasDmRooms: false,
|
||||||
|
hasNotificationsEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])("sequence should stay static", async ({ context }) => {
|
||||||
|
const { result } = renderHook(() => useUserOnboardingTasks(context));
|
||||||
|
|
||||||
|
expect(result.current).toHaveLength(5);
|
||||||
|
expect(result.current[0].id).toBe("create-account");
|
||||||
|
expect(result.current[1].id).toBe("find-friends");
|
||||||
|
expect(result.current[2].id).toBe("download-apps");
|
||||||
|
expect(result.current[3].id).toBe("setup-profile");
|
||||||
|
expect(result.current[4].id).toBe("permission-notifications");
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue