diff --git a/src/components/views/user-onboarding/UserOnboardingFeedback.tsx b/src/components/views/user-onboarding/UserOnboardingFeedback.tsx index cd4d21acd3..567880c686 100644 --- a/src/components/views/user-onboarding/UserOnboardingFeedback.tsx +++ b/src/components/views/user-onboarding/UserOnboardingFeedback.tsx @@ -30,7 +30,7 @@ export function UserOnboardingFeedback() { } return ( -
+
{_t("How are you finding %(brand)s so far?", { diff --git a/src/components/views/user-onboarding/UserOnboardingList.tsx b/src/components/views/user-onboarding/UserOnboardingList.tsx index 29bdd98b41..0214e6ac29 100644 --- a/src/components/views/user-onboarding/UserOnboardingList.tsx +++ b/src/components/views/user-onboarding/UserOnboardingList.tsx @@ -15,9 +15,8 @@ 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"; @@ -25,26 +24,26 @@ 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 ( -
+
{waiting > 0 @@ -64,8 +63,8 @@ export function UserOnboardingList({ completedTasks, waitingTasks }: Props) { {waiting === 0 && }
    - {tasks.map(([task, completed]) => ( - + {tasks.map((task) => ( + ))}
diff --git a/src/components/views/user-onboarding/UserOnboardingPage.tsx b/src/components/views/user-onboarding/UserOnboardingPage.tsx index 5c0844b628..a38d792edf 100644 --- a/src/components/views/user-onboarding/UserOnboardingPage.tsx +++ b/src/components/views/user-onboarding/UserOnboardingPage.tsx @@ -49,7 +49,7 @@ export function UserOnboardingPage({ justRegistered = false }: Props) { const useCase = useSettingValue("FTUE.useCaseSelection"); const context = useUserOnboardingContext(); - const [completedTasks, waitingTasks] = useUserOnboardingTasks(context); + const tasks = useUserOnboardingTasks(context); const initialSyncComplete = useInitialSyncComplete(); const [showList, setShowList] = useState(false); @@ -80,7 +80,7 @@ export function UserOnboardingPage({ justRegistered = false }: Props) { return ( - {showList && } + {showList && } ); } diff --git a/src/components/views/user-onboarding/UserOnboardingTask.tsx b/src/components/views/user-onboarding/UserOnboardingTask.tsx index b413c86a29..3d8828e781 100644 --- a/src/components/views/user-onboarding/UserOnboardingTask.tsx +++ b/src/components/views/user-onboarding/UserOnboardingTask.tsx @@ -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; } @@ -32,6 +32,7 @@ export function UserOnboardingTask({ task, completed = false }: Props) { return (
  • (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); diff --git a/src/hooks/useUserOnboardingTasks.ts b/src/hooks/useUserOnboardingTasks.ts index bd96961c96..32ee6a4beb 100644 --- a/src/hooks/useUserOnboardingTasks.ts +++ b/src/hooks/useUserOnboardingTasks.ts @@ -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); @@ -41,10 +41,11 @@ export interface UserOnboardingTask { href?: string; hideOnComplete?: boolean; }; + completed: (ctx: UserOnboardingContext) => boolean; } -interface InternalUserOnboardingTask extends UserOnboardingTask { - completed: (ctx: UserOnboardingContext) => boolean; +export interface UserOnboardingTaskWithResolvedCompletion extends Omit { + completed: boolean; } const onClickStartDm = (ev: ButtonEvent) => { @@ -52,7 +53,7 @@ const onClickStartDm = (ev: ButtonEvent) => { defaultDispatcher.dispatch({ action: "view_create_chat" }); }; -const tasks: InternalUserOnboardingTask[] = [ +const tasks: UserOnboardingTask[] = [ { id: "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("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(() => { + return tasks + .filter((task) => !task.relevant || task.relevant.includes(useCase)) + .map((task) => ({ + ...task, + completed: task.completed(context), + })); + }, [context, useCase]); } diff --git a/test/components/views/user-onboarding/UserOnboardingList-test.tsx b/test/components/views/user-onboarding/UserOnboardingList-test.tsx new file mode 100644 index 0000000000..4c63b391eb --- /dev/null +++ b/test/components/views/user-onboarding/UserOnboardingList-test.tsx @@ -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(); + + 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( ({ ...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); + }); +}); diff --git a/test/components/views/user-onboarding/UserOnboardingPage-test.tsx b/test/components/views/user-onboarding/UserOnboardingPage-test.tsx new file mode 100644 index 0000000000..0ff637e2c9 --- /dev/null +++ b/test/components/views/user-onboarding/UserOnboardingPage-test.tsx @@ -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 }) =>
    {url}
    ), +})); + +jest.mock("../../../../src/components/structures/HomePage", () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() =>
    home page
    ), +})); + +describe("UserOnboardingPage", () => { + let restoreConsole: () => void; + + const renderComponent = async (): Promise => { + const renderResult = render(); + 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(); + }); + }); + }); +}); diff --git a/test/hooks/useUserOnboardingTasks-test.tsx b/test/hooks/useUserOnboardingTasks-test.tsx new file mode 100644 index 0000000000..f2d65382a4 --- /dev/null +++ b/test/hooks/useUserOnboardingTasks-test.tsx @@ -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"); + }); +});