[WEB-5317] chore: enable multi-select for use case in onboarding flow (#8049)

* chore: update use_case type from string to array

* chore: convert use_case field to JSONField with array support

* feat: implement multi-select UI for use case in onboarding

* chore: code refactor

* chore: revert backend changes

* chore: code refactor

* chore: code refactor

* chore: code refactor
This commit is contained in:
Anmol Singh Bhatia
2025-12-08 15:48:15 +05:30
committed by GitHub
parent 85daa1572c
commit f41e121e58
4 changed files with 60 additions and 33 deletions

View File

@@ -56,7 +56,11 @@ export const OnboardingHeader = observer(function OnboardingHeader(props: Onboar
// derived values // derived values
const currentStepNumber = getCurrentStepNumber(); const currentStepNumber = getCurrentStepNumber();
const totalSteps = hasInvitations ? 4 : 5; // 4 if invites available, 5 if not const totalSteps = hasInvitations ? 4 : 5; // 4 if invites available, 5 if not
const userName = user?.display_name ?? `${user?.first_name} ${user?.last_name}` ?? user?.email; const userName = user?.display_name
? user?.display_name
: user?.first_name
? `${user?.first_name} ${user?.last_name ?? ""}`
: user?.email;
return ( return (
<div className="flex flex-col gap-4 sticky top-0 z-10"> <div className="flex flex-col gap-4 sticky top-0 z-10">

View File

@@ -16,7 +16,7 @@ import type { IUser, TUserProfile, TOnboardingSteps } from "@plane/types";
// ui // ui
import { Input, PasswordStrengthIndicator, Spinner } from "@plane/ui"; import { Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
// components // components
import { getFileURL, getPasswordStrength } from "@plane/utils"; import { cn, getFileURL, getPasswordStrength } from "@plane/utils";
import { UserImageUploadModal } from "@/components/core/modals/user-image-upload-modal"; import { UserImageUploadModal } from "@/components/core/modals/user-image-upload-modal";
// constants // constants
// helpers // helpers
@@ -33,7 +33,7 @@ type TProfileSetupFormValues = {
password?: string; password?: string;
confirm_password?: string; confirm_password?: string;
role?: string; role?: string;
use_case?: string; use_case?: string[];
}; };
const defaultValues: Partial<TProfileSetupFormValues> = { const defaultValues: Partial<TProfileSetupFormValues> = {
@@ -43,7 +43,7 @@ const defaultValues: Partial<TProfileSetupFormValues> = {
password: undefined, password: undefined,
confirm_password: undefined, confirm_password: undefined,
role: undefined, role: undefined,
use_case: undefined, use_case: [],
}; };
type Props = { type Props = {
@@ -139,7 +139,7 @@ export const ProfileSetup = observer(function ProfileSetup(props: Props) {
avatar_url: formData.avatar_url ?? undefined, avatar_url: formData.avatar_url ?? undefined,
}; };
const profileUpdatePayload: Partial<TUserProfile> = { const profileUpdatePayload: Partial<TUserProfile> = {
use_case: formData.use_case, use_case: formData.use_case && formData.use_case.length > 0 ? formData.use_case.join(". ") : undefined,
role: formData.role, role: formData.role,
}; };
try { try {
@@ -151,7 +151,7 @@ export const ProfileSetup = observer(function ProfileSetup(props: Props) {
captureSuccess({ captureSuccess({
eventName: USER_TRACKER_EVENTS.add_details, eventName: USER_TRACKER_EVENTS.add_details,
payload: { payload: {
use_case: formData.use_case, use_case: profileUpdatePayload.use_case,
role: formData.role, role: formData.role,
}, },
}); });
@@ -212,7 +212,7 @@ export const ProfileSetup = observer(function ProfileSetup(props: Props) {
const handleSubmitUserPersonalization = async (formData: TProfileSetupFormValues) => { const handleSubmitUserPersonalization = async (formData: TProfileSetupFormValues) => {
const profileUpdatePayload: Partial<TUserProfile> = { const profileUpdatePayload: Partial<TUserProfile> = {
use_case: formData.use_case, use_case: formData.use_case && formData.use_case.length > 0 ? formData.use_case.join(". ") : undefined,
role: formData.role, role: formData.role,
}; };
try { try {
@@ -223,7 +223,7 @@ export const ProfileSetup = observer(function ProfileSetup(props: Props) {
captureSuccess({ captureSuccess({
eventName: USER_TRACKER_EVENTS.add_details, eventName: USER_TRACKER_EVENTS.add_details,
payload: { payload: {
use_case: formData.use_case, use_case: profileUpdatePayload.use_case,
role: formData.role, role: formData.role,
}, },
}); });
@@ -519,9 +519,13 @@ export const ProfileSetup = observer(function ProfileSetup(props: Props) {
{USER_ROLE.map((userRole) => ( {USER_ROLE.map((userRole) => (
<div <div
key={userRole} key={userRole}
className={`flex-shrink-0 border-[0.5px] hover:cursor-pointer hover:bg-custom-background-90 ${ className={cn(
value === userRole ? "border-custom-primary-100" : "border-custom-border-300" "flex-shrink-0 border-[0.5px] hover:cursor-pointer hover:bg-custom-background-90 rounded px-3 py-1.5 text-sm font-medium",
} rounded px-3 py-1.5 text-sm font-medium`} {
"border-custom-primary-100": value === userRole,
"border-custom-border-300": value !== userRole,
}
)}
onClick={() => onChange(userRole)} onClick={() => onChange(userRole)}
> >
{userRole} {userRole}
@@ -537,27 +541,38 @@ export const ProfileSetup = observer(function ProfileSetup(props: Props) {
className="text-sm text-custom-text-300 font-medium after:content-['*'] after:ml-0.5 after:text-red-500" className="text-sm text-custom-text-300 font-medium after:content-['*'] after:ml-0.5 after:text-red-500"
htmlFor="use_case" htmlFor="use_case"
> >
What is your domain expertise? Choose one. What is your domain expertise? Choose one or more.
</label> </label>
<Controller <Controller
control={control} control={control}
name="use_case" name="use_case"
rules={{ rules={{
required: "This field is required", required: "Please select at least one option",
validate: (value) => (value && value.length > 0) || "Please select at least one option",
}} }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<div className="flex flex-wrap gap-2 py-2 overflow-auto break-all"> <div className="flex flex-wrap gap-2 py-2 overflow-auto break-all">
{USER_DOMAIN.map((userDomain) => ( {USER_DOMAIN.map((userDomain) => {
<div const isSelected = value?.includes(userDomain) || false;
key={userDomain} return (
className={`flex-shrink-0 border-[0.5px] hover:cursor-pointer hover:bg-custom-background-90 ${ <div
value === userDomain ? "border-custom-primary-100" : "border-custom-border-300" key={userDomain}
} rounded px-3 py-1.5 text-sm font-medium`} className={`flex-shrink-0 border-[0.5px] hover:cursor-pointer hover:bg-custom-background-90 ${
onClick={() => onChange(userDomain)} isSelected ? "border-custom-primary-100" : "border-custom-border-300"
> } rounded px-3 py-1.5 text-sm font-medium`}
{userDomain} onClick={() => {
</div> const currentValue = value || [];
))} if (isSelected) {
onChange(currentValue.filter((item) => item !== userDomain));
} else {
onChange([...currentValue, userDomain]);
}
}}
>
{userDomain}
</div>
);
})}
</div> </div>
)} )}
/> />

View File

@@ -35,7 +35,7 @@ export type TProfileSetupFormValues = {
password?: string; password?: string;
confirm_password?: string; confirm_password?: string;
role?: string; role?: string;
use_case?: string; use_case?: string[];
has_marketing_email_consent?: boolean; has_marketing_email_consent?: boolean;
}; };

View File

@@ -22,7 +22,7 @@ type Props = {
}; };
const defaultValues = { const defaultValues = {
use_case: "", use_case: [] as string[],
}; };
export const UseCaseSetupStep = observer(function UseCaseSetupStep({ handleStepChange }: Props) { export const UseCaseSetupStep = observer(function UseCaseSetupStep({ handleStepChange }: Props) {
@@ -36,7 +36,7 @@ export const UseCaseSetupStep = observer(function UseCaseSetupStep({ handleStepC
} = useForm<TProfileSetupFormValues>({ } = useForm<TProfileSetupFormValues>({
defaultValues: { defaultValues: {
...defaultValues, ...defaultValues,
use_case: profile?.use_case, use_case: profile?.use_case ? profile.use_case.split(". ") : [],
}, },
mode: "onChange", mode: "onChange",
}); });
@@ -44,7 +44,7 @@ export const UseCaseSetupStep = observer(function UseCaseSetupStep({ handleStepC
// handle submit // handle submit
const handleSubmitUserPersonalization = async (formData: TProfileSetupFormValues) => { const handleSubmitUserPersonalization = async (formData: TProfileSetupFormValues) => {
const profileUpdatePayload: Partial<TUserProfile> = { const profileUpdatePayload: Partial<TUserProfile> = {
use_case: formData.use_case, use_case: formData.use_case && formData.use_case.length > 0 ? formData.use_case.join(". ") : undefined,
}; };
try { try {
await Promise.all([ await Promise.all([
@@ -54,7 +54,7 @@ export const UseCaseSetupStep = observer(function UseCaseSetupStep({ handleStepC
captureSuccess({ captureSuccess({
eventName: USER_TRACKER_EVENTS.add_details, eventName: USER_TRACKER_EVENTS.add_details,
payload: { payload: {
use_case: formData.use_case, use_case: profileUpdatePayload.use_case,
}, },
}); });
setToast({ setToast({
@@ -100,25 +100,33 @@ export const UseCaseSetupStep = observer(function UseCaseSetupStep({ handleStepC
{/* Use Case Selection */} {/* Use Case Selection */}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<p className="text-sm font-medium text-custom-text-400">Select any</p> <p className="text-sm font-medium text-custom-text-400">Select one or more</p>
<Controller <Controller
control={control} control={control}
name="use_case" name="use_case"
rules={{ rules={{
required: "This field is required", required: "Please select at least one option",
validate: (value) => (value && value.length > 0) || "Please select at least one option",
}} }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{USE_CASES.map((useCase) => { {USE_CASES.map((useCase) => {
const isSelected = value === useCase; const isSelected = value?.includes(useCase) || false;
return ( return (
<button <button
key={useCase} key={useCase}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onChange(useCase); const currentValue = value || [];
if (isSelected) {
// Remove from array
onChange(currentValue.filter((item) => item !== useCase));
} else {
// Add to array
onChange([...currentValue, useCase]);
}
}} }}
className={`w-full px-3 py-2 rounded-lg border transition-all duration-200 flex items-center gap-2 ${ className={`w-full px-3 py-2 rounded-lg border transition-all duration-200 flex items-center gap-2 ${
isSelected isSelected