forked from github/plane
refactor: new onboarding workflow (#1724)
* refactor: new onboarding workflow * refactor: new onboarding workflow
This commit is contained in:
parent
e8f748a67d
commit
7ad0466d65
@ -1,12 +1,9 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
import useSWR, { mutate } from "swr";
|
|
||||||
|
|
||||||
// react-hook-form
|
// react-hook-form
|
||||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||||
// services
|
// services
|
||||||
import workspaceService from "services/workspace.service";
|
import workspaceService from "services/workspace.service";
|
||||||
import userService from "services/user.service";
|
|
||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// ui
|
// ui
|
||||||
@ -14,16 +11,15 @@ import { CustomSelect, Input, PrimaryButton, SecondaryButton } from "components/
|
|||||||
// icons
|
// icons
|
||||||
import { PlusIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
import { PlusIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
// types
|
// types
|
||||||
import { ICurrentUserResponse, IWorkspace, OnboardingSteps } from "types";
|
import { ICurrentUserResponse, IWorkspace, TOnboardingSteps } from "types";
|
||||||
// fetch-keys
|
|
||||||
import { CURRENT_USER, USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys";
|
|
||||||
// constants
|
// constants
|
||||||
import { ROLE } from "constants/workspace";
|
import { ROLE } from "constants/workspace";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
workspace: IWorkspace | undefined;
|
finishOnboarding: () => Promise<void>;
|
||||||
|
stepChange: (steps: Partial<TOnboardingSteps>) => Promise<void>;
|
||||||
user: ICurrentUserResponse | undefined;
|
user: ICurrentUserResponse | undefined;
|
||||||
stepChange: (steps: Partial<OnboardingSteps>) => Promise<void>;
|
workspace: IWorkspace | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EmailRole = {
|
type EmailRole = {
|
||||||
@ -35,7 +31,12 @@ type FormValues = {
|
|||||||
emails: EmailRole[];
|
emails: EmailRole[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const InviteMembers: React.FC<Props> = ({ workspace, user, stepChange }) => {
|
export const InviteMembers: React.FC<Props> = ({
|
||||||
|
finishOnboarding,
|
||||||
|
stepChange,
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
}) => {
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -49,38 +50,14 @@ export const InviteMembers: React.FC<Props> = ({ workspace, user, stepChange })
|
|||||||
name: "emails",
|
name: "emails",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: invitations } = useSWR(USER_WORKSPACE_INVITATIONS, () =>
|
|
||||||
workspaceService.userWorkspaceInvitations()
|
|
||||||
);
|
|
||||||
|
|
||||||
const nextStep = async () => {
|
const nextStep = async () => {
|
||||||
if (!user || !invitations) return;
|
const payload: Partial<TOnboardingSteps> = {
|
||||||
|
|
||||||
const payload: Partial<OnboardingSteps> = {
|
|
||||||
workspace_invite: true,
|
workspace_invite: true,
|
||||||
|
workspace_join: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// update onboarding status from this step if no invitations are present
|
|
||||||
if (invitations.length === 0) {
|
|
||||||
payload.workspace_join = true;
|
|
||||||
|
|
||||||
mutate<ICurrentUserResponse>(
|
|
||||||
CURRENT_USER,
|
|
||||||
(prevData) => {
|
|
||||||
if (!prevData) return prevData;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...prevData,
|
|
||||||
is_onboarded: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
await userService.updateUserOnBoard({ userRole: user.role }, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
await stepChange(payload);
|
await stepChange(payload);
|
||||||
|
await finishOnboarding();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (formData: FormValues) => {
|
const onSubmit = async (formData: FormValues) => {
|
||||||
|
@ -4,7 +4,6 @@ import useSWR, { mutate } from "swr";
|
|||||||
|
|
||||||
// services
|
// services
|
||||||
import workspaceService from "services/workspace.service";
|
import workspaceService from "services/workspace.service";
|
||||||
import userService from "services/user.service";
|
|
||||||
// hooks
|
// hooks
|
||||||
import useUser from "hooks/use-user";
|
import useUser from "hooks/use-user";
|
||||||
// ui
|
// ui
|
||||||
@ -14,17 +13,23 @@ import { CheckCircleIcon } from "@heroicons/react/24/outline";
|
|||||||
// helpers
|
// helpers
|
||||||
import { truncateText } from "helpers/string.helper";
|
import { truncateText } from "helpers/string.helper";
|
||||||
// types
|
// types
|
||||||
import { ICurrentUserResponse, IUser, IWorkspaceMemberInvitation, OnboardingSteps } from "types";
|
import { IWorkspaceMemberInvitation, TOnboardingSteps } from "types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { CURRENT_USER, USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys";
|
import { USER_WORKSPACES, USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys";
|
||||||
// constants
|
// constants
|
||||||
import { ROLE } from "constants/workspace";
|
import { ROLE } from "constants/workspace";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
stepChange: (steps: Partial<OnboardingSteps>) => Promise<void>;
|
finishOnboarding: () => Promise<void>;
|
||||||
|
stepChange: (steps: Partial<TOnboardingSteps>) => Promise<void>;
|
||||||
|
updateLastWorkspace: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const JoinWorkspaces: React.FC<Props> = ({ stepChange }) => {
|
export const JoinWorkspaces: React.FC<Props> = ({
|
||||||
|
finishOnboarding,
|
||||||
|
stepChange,
|
||||||
|
updateLastWorkspace,
|
||||||
|
}) => {
|
||||||
const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false);
|
const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false);
|
||||||
const [invitationsRespond, setInvitationsRespond] = useState<string[]>([]);
|
const [invitationsRespond, setInvitationsRespond] = useState<string[]>([]);
|
||||||
|
|
||||||
@ -47,25 +52,13 @@ export const JoinWorkspaces: React.FC<Props> = ({ stepChange }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// complete onboarding
|
const handleNextStep = async () => {
|
||||||
const finishOnboarding = async () => {
|
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
mutate<ICurrentUserResponse>(
|
|
||||||
CURRENT_USER,
|
|
||||||
(prevData) => {
|
|
||||||
if (!prevData) return prevData;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...prevData,
|
|
||||||
is_onboarded: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
await userService.updateUserOnBoard({ userRole: user.role }, user);
|
|
||||||
await stepChange({ workspace_join: true });
|
await stepChange({ workspace_join: true });
|
||||||
|
|
||||||
|
if (user.onboarding_step.workspace_create && user.onboarding_step.workspace_invite)
|
||||||
|
await finishOnboarding();
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitInvitations = async () => {
|
const submitInvitations = async () => {
|
||||||
@ -77,11 +70,12 @@ export const JoinWorkspaces: React.FC<Props> = ({ stepChange }) => {
|
|||||||
.joinWorkspaces({ invitations: invitationsRespond })
|
.joinWorkspaces({ invitations: invitationsRespond })
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await mutateInvitations();
|
await mutateInvitations();
|
||||||
await finishOnboarding();
|
await mutate(USER_WORKSPACES);
|
||||||
|
await updateLastWorkspace();
|
||||||
|
|
||||||
setIsJoiningWorkspaces(false);
|
await handleNextStep();
|
||||||
})
|
})
|
||||||
.catch(() => setIsJoiningWorkspaces(false));
|
.finally(() => setIsJoiningWorkspaces(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -142,14 +136,15 @@ export const JoinWorkspaces: React.FC<Props> = ({ stepChange }) => {
|
|||||||
type="submit"
|
type="submit"
|
||||||
size="md"
|
size="md"
|
||||||
onClick={submitInvitations}
|
onClick={submitInvitations}
|
||||||
disabled={isJoiningWorkspaces || invitationsRespond.length === 0}
|
disabled={invitationsRespond.length === 0}
|
||||||
|
loading={isJoiningWorkspaces}
|
||||||
>
|
>
|
||||||
Accept & Join
|
Accept & Join
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
className="border border-none bg-transparent"
|
className="border border-none bg-transparent"
|
||||||
size="md"
|
size="md"
|
||||||
onClick={finishOnboarding}
|
onClick={handleNextStep}
|
||||||
>
|
>
|
||||||
Skip for now
|
Skip for now
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
|
@ -3,17 +3,25 @@ import { useState } from "react";
|
|||||||
// ui
|
// ui
|
||||||
import { SecondaryButton } from "components/ui";
|
import { SecondaryButton } from "components/ui";
|
||||||
// types
|
// types
|
||||||
import { ICurrentUserResponse, OnboardingSteps } from "types";
|
import { ICurrentUserResponse, IWorkspace, TOnboardingSteps } from "types";
|
||||||
// constants
|
// constants
|
||||||
import { CreateWorkspaceForm } from "components/workspace";
|
import { CreateWorkspaceForm } from "components/workspace";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
user: ICurrentUserResponse | undefined;
|
finishOnboarding: () => Promise<void>;
|
||||||
|
stepChange: (steps: Partial<TOnboardingSteps>) => Promise<void>;
|
||||||
updateLastWorkspace: () => Promise<void>;
|
updateLastWorkspace: () => Promise<void>;
|
||||||
stepChange: (steps: Partial<OnboardingSteps>) => Promise<void>;
|
user: ICurrentUserResponse | undefined;
|
||||||
|
workspaces: IWorkspace[] | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Workspace: React.FC<Props> = ({ user, updateLastWorkspace, stepChange }) => {
|
export const Workspace: React.FC<Props> = ({
|
||||||
|
finishOnboarding,
|
||||||
|
stepChange,
|
||||||
|
updateLastWorkspace,
|
||||||
|
user,
|
||||||
|
workspaces,
|
||||||
|
}) => {
|
||||||
const [defaultValues, setDefaultValues] = useState({
|
const [defaultValues, setDefaultValues] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
slug: "",
|
slug: "",
|
||||||
@ -23,12 +31,21 @@ export const Workspace: React.FC<Props> = ({ user, updateLastWorkspace, stepChan
|
|||||||
const completeStep = async () => {
|
const completeStep = async () => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
await stepChange({
|
const payload: Partial<TOnboardingSteps> = {
|
||||||
workspace_create: true,
|
workspace_create: true,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
await stepChange(payload);
|
||||||
await updateLastWorkspace();
|
await updateLastWorkspace();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const secondaryButtonAction = async () => {
|
||||||
|
if (workspaces && workspaces.length > 0) {
|
||||||
|
await stepChange({ workspace_create: true, workspace_invite: true, workspace_join: true });
|
||||||
|
await finishOnboarding();
|
||||||
|
} else await stepChange({ profile_complete: false, workspace_join: false });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-7 sm:space-y-10">
|
<div className="w-full space-y-7 sm:space-y-10">
|
||||||
<h4 className="text-xl sm:text-2xl font-semibold">Create your workspace</h4>
|
<h4 className="text-xl sm:text-2xl font-semibold">Create your workspace</h4>
|
||||||
@ -43,9 +60,11 @@ export const Workspace: React.FC<Props> = ({ user, updateLastWorkspace, stepChan
|
|||||||
default: "Continue",
|
default: "Continue",
|
||||||
}}
|
}}
|
||||||
secondaryButton={
|
secondaryButton={
|
||||||
<SecondaryButton onClick={() => stepChange({ profile_complete: false })}>
|
workspaces ? (
|
||||||
Back
|
<SecondaryButton onClick={secondaryButtonAction}>
|
||||||
|
{workspaces.length > 0 ? "Skip & continue" : "Back"}
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -24,7 +24,7 @@ import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
|
|||||||
import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.svg";
|
import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.svg";
|
||||||
import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.svg";
|
import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.svg";
|
||||||
// types
|
// types
|
||||||
import { ICurrentUserResponse, IUser, OnboardingSteps } from "types";
|
import { ICurrentUserResponse, IUser, TOnboardingSteps } from "types";
|
||||||
import type { NextPage } from "next";
|
import type { NextPage } from "next";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { CURRENT_USER, USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys";
|
import { CURRENT_USER, USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys";
|
||||||
@ -43,33 +43,35 @@ const Onboarding: NextPage = () => {
|
|||||||
workspaceService.userWorkspaceInvitations()
|
workspaceService.userWorkspaceInvitations()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// update last active workspace details
|
||||||
const updateLastWorkspace = async () => {
|
const updateLastWorkspace = async () => {
|
||||||
if (!userWorkspaces) return;
|
if (!workspaces) return;
|
||||||
|
|
||||||
mutate<ICurrentUserResponse>(
|
await mutate<ICurrentUserResponse>(
|
||||||
CURRENT_USER,
|
CURRENT_USER,
|
||||||
(prevData) => {
|
(prevData) => {
|
||||||
if (!prevData) return prevData;
|
if (!prevData) return prevData;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...prevData,
|
...prevData,
|
||||||
last_workspace_id: userWorkspaces[0]?.id,
|
last_workspace_id: workspaces[0]?.id,
|
||||||
workspace: {
|
workspace: {
|
||||||
...prevData.workspace,
|
...prevData.workspace,
|
||||||
fallback_workspace_id: userWorkspaces[0]?.id,
|
fallback_workspace_id: workspaces[0]?.id,
|
||||||
fallback_workspace_slug: userWorkspaces[0]?.slug,
|
fallback_workspace_slug: workspaces[0]?.slug,
|
||||||
last_workspace_id: userWorkspaces[0]?.id,
|
last_workspace_id: workspaces[0]?.id,
|
||||||
last_workspace_slug: userWorkspaces[0]?.slug,
|
last_workspace_slug: workspaces[0]?.slug,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
await userService.updateUser({ last_workspace_id: userWorkspaces?.[0]?.id });
|
await userService.updateUser({ last_workspace_id: workspaces?.[0]?.id });
|
||||||
};
|
};
|
||||||
|
|
||||||
const stepChange = async (steps: Partial<OnboardingSteps>) => {
|
// handle step change
|
||||||
|
const stepChange = async (steps: Partial<TOnboardingSteps>) => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
const payload: Partial<IUser> = {
|
const payload: Partial<IUser> = {
|
||||||
@ -95,16 +97,44 @@ const Onboarding: NextPage = () => {
|
|||||||
await userService.updateUser(payload);
|
await userService.updateUser(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// complete onboarding
|
||||||
|
const finishOnboarding = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
mutate<ICurrentUserResponse>(
|
||||||
|
CURRENT_USER,
|
||||||
|
(prevData) => {
|
||||||
|
if (!prevData) return prevData;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prevData,
|
||||||
|
is_onboarded: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
await userService.updateUserOnBoard({ userRole: user.role }, user);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleStepChange = async () => {
|
const handleStepChange = async () => {
|
||||||
if (!user || !userWorkspaces || !invitations) return;
|
if (!user || !invitations) return;
|
||||||
|
|
||||||
const onboardingStep = user.onboarding_step;
|
const onboardingStep = user.onboarding_step;
|
||||||
|
|
||||||
if (!onboardingStep.profile_complete && step !== 1) setStep(1);
|
if (!onboardingStep.profile_complete && step !== 1) setStep(1);
|
||||||
|
|
||||||
if (onboardingStep.profile_complete && !onboardingStep.workspace_create && step !== 2)
|
if (onboardingStep.profile_complete) {
|
||||||
|
if (!onboardingStep.workspace_join && invitations.length > 0 && step !== 2 && step !== 4)
|
||||||
|
setStep(4);
|
||||||
|
else if (
|
||||||
|
!onboardingStep.workspace_create &&
|
||||||
|
(step !== 4 || onboardingStep.workspace_join) &&
|
||||||
|
step !== 2
|
||||||
|
)
|
||||||
setStep(2);
|
setStep(2);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
onboardingStep.profile_complete &&
|
onboardingStep.profile_complete &&
|
||||||
@ -113,21 +143,10 @@ const Onboarding: NextPage = () => {
|
|||||||
step !== 3
|
step !== 3
|
||||||
)
|
)
|
||||||
setStep(3);
|
setStep(3);
|
||||||
|
|
||||||
if (
|
|
||||||
onboardingStep.profile_complete &&
|
|
||||||
onboardingStep.workspace_create &&
|
|
||||||
onboardingStep.workspace_invite &&
|
|
||||||
!onboardingStep.workspace_join &&
|
|
||||||
step !== 4
|
|
||||||
) {
|
|
||||||
if (invitations.length > 0) setStep(4);
|
|
||||||
else await Router.push("/");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
handleStepChange();
|
handleStepChange();
|
||||||
}, [user, invitations, userWorkspaces, step]);
|
}, [user, invitations, step]);
|
||||||
|
|
||||||
if (userLoading || step === null)
|
if (userLoading || step === null)
|
||||||
return (
|
return (
|
||||||
@ -167,14 +186,27 @@ const Onboarding: NextPage = () => {
|
|||||||
<UserDetails user={user} />
|
<UserDetails user={user} />
|
||||||
) : step === 2 ? (
|
) : step === 2 ? (
|
||||||
<Workspace
|
<Workspace
|
||||||
user={user}
|
finishOnboarding={finishOnboarding}
|
||||||
updateLastWorkspace={updateLastWorkspace}
|
|
||||||
stepChange={stepChange}
|
stepChange={stepChange}
|
||||||
|
updateLastWorkspace={updateLastWorkspace}
|
||||||
|
user={user}
|
||||||
|
workspaces={workspaces}
|
||||||
/>
|
/>
|
||||||
) : step === 3 ? (
|
) : step === 3 ? (
|
||||||
<InviteMembers workspace={userWorkspaces?.[0]} user={user} stepChange={stepChange} />
|
<InviteMembers
|
||||||
|
finishOnboarding={finishOnboarding}
|
||||||
|
stepChange={stepChange}
|
||||||
|
user={user}
|
||||||
|
workspace={userWorkspaces?.[0]}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
step === 4 && <JoinWorkspaces stepChange={stepChange} />
|
step === 4 && (
|
||||||
|
<JoinWorkspaces
|
||||||
|
finishOnboarding={finishOnboarding}
|
||||||
|
stepChange={stepChange}
|
||||||
|
updateLastWorkspace={updateLastWorkspace}
|
||||||
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{step !== 4 && (
|
{step !== 4 && (
|
||||||
|
4
apps/app/types/users.d.ts
vendored
4
apps/app/types/users.d.ts
vendored
@ -27,7 +27,7 @@ export interface IUser {
|
|||||||
properties: Properties;
|
properties: Properties;
|
||||||
groupBy: NestedKeyOf<IIssue> | null;
|
groupBy: NestedKeyOf<IIssue> | null;
|
||||||
} | null;
|
} | null;
|
||||||
onboarding_step: OnboardingSteps;
|
onboarding_step: TOnboardingSteps;
|
||||||
role: string;
|
role: string;
|
||||||
token: string;
|
token: string;
|
||||||
theme: ICustomTheme;
|
theme: ICustomTheme;
|
||||||
@ -140,7 +140,7 @@ export type UserAuth = {
|
|||||||
isGuest: boolean;
|
isGuest: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OnboardingSteps = {
|
export type TOnboardingSteps = {
|
||||||
profile_complete: boolean;
|
profile_complete: boolean;
|
||||||
workspace_create: boolean;
|
workspace_create: boolean;
|
||||||
workspace_invite: boolean;
|
workspace_invite: boolean;
|
||||||
|
Loading…
Reference in New Issue
Block a user