feat: handle invite user flows

This commit is contained in:
Yunus M 2024-10-29 14:20:18 +05:30
parent 44f41c55f9
commit a1090bfdc5
9 changed files with 270 additions and 63 deletions

View File

@ -1,10 +1,10 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, UsersProps } from 'types/api/user/inviteUsers';
import { SuccessResponse } from 'types/api';
import { InviteUsersResponse, UsersProps } from 'types/api/user/inviteUsers';
const inviteUsers = async (
users: UsersProps,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
): Promise<SuccessResponse<InviteUsersResponse>> => {
const response = await axios.post(`/invite/bulk`, users);
return {

View File

@ -82,7 +82,7 @@ export function AboutSigNozQuestions({
otherInterestInSignoz,
});
logEvent('Onboarding: SigNoz Questions: Next', {
logEvent('User Onboarding: About SigNoz Questions Answered', {
hearAboutSignoz,
otherAboutSignoz,
interestInSignoz,
@ -100,13 +100,6 @@ export function AboutSigNozQuestions({
otherInterestInSignoz,
});
logEvent('Onboarding: SigNoz Questions: Back', {
hearAboutSignoz,
otherAboutSignoz,
interestInSignoz,
otherInterestInSignoz,
});
onBack();
};

View File

@ -29,24 +29,57 @@
}
.questions-form-container {
.error-message-container {
padding: 16px;
margin-top: 16px;
.error-message-container,
.success-message-container,
.partially-sent-invites-container {
border-radius: 4px;
border: 1px solid var(--bg-slate-500, #161922);
background: var(--bg-ink-400, #121317);
width: 100%;
display: flex;
align-items: center;
.error-message {
.error-message,
.success-message {
font-size: 12px;
font-weight: 400;
display: flex;
align-items: center;
gap: 8px;
}
}
.invite-users-error-message-container,
.invite-users-success-message-container {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
.success-message {
color: var(--bg-success-500, #00b37e);
}
}
.partially-sent-invites-container {
margin-top: 16px;
padding: 8px;
border: 1px solid #1d212d;
background-color: #121317;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
.partially-sent-invites-message {
color: var(--bg-warning-500, #fbbd23);
font-size: 12px;
font-weight: 400;
display: flex;
align-items: center;
gap: 8px;
text-transform: capitalize;
}
}
}

View File

@ -2,6 +2,7 @@ import './InviteTeamMembers.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Button, Input, Select, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import inviteUsers from 'api/user/inviteUsers';
import { AxiosError } from 'axios';
import { cloneDeep, debounce, isEmpty } from 'lodash-es';
@ -15,7 +16,12 @@ import {
} from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useMutation } from 'react-query';
import { ErrorResponse } from 'types/api';
import { SuccessResponse } from 'types/api';
import {
FailedInvite,
InviteUsersResponse,
SuccessfulInvite,
} from 'types/api/user/inviteUsers';
import { v4 as uuid } from 'uuid';
interface TeamMember {
@ -46,8 +52,23 @@ function InviteTeamMembers({
{},
);
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
const [hasErrors, setHasErrors] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [inviteUsersErrorResponse, setInviteUsersErrorResponse] = useState<
string[] | null
>(null);
const [inviteUsersSuccessResponse, setInviteUsersSuccessResponse] = useState<
string[] | null
>(null);
const [disableNextButton, setDisableNextButton] = useState<boolean>(false);
const [allInvitesSent, setAllInvitesSent] = useState<boolean>(false);
const defaultTeamMember: TeamMember = {
email: '',
role: 'EDITOR',
@ -100,30 +121,100 @@ function InviteTeamMembers({
return isValid;
};
const handleError = (error: AxiosError): void => {
const errorMessage = error.response?.data as ErrorResponse;
const parseInviteUsersSuccessResponse = (
response: SuccessfulInvite[],
): string[] => response.map((invite) => `${invite.email} - Invite Sent`);
setError(errorMessage.error);
const parseInviteUsersErrorResponse = (response: FailedInvite[]): string[] =>
response.map((invite) => `${invite.email} - ${invite.error}`);
const handleError = (error: AxiosError): void => {
const errorMessage = error.response?.data as InviteUsersResponse;
if (errorMessage?.status === 'failure') {
setHasErrors(true);
const failedInvitesErrorResponse = parseInviteUsersErrorResponse(
errorMessage.failed_invites,
);
setInviteUsersErrorResponse(failedInvitesErrorResponse);
}
};
const { mutate: sendInvites, isLoading: isSendingInvites } = useMutation(
inviteUsers,
{
onSuccess: (): void => {
onNext();
},
onError: (error): void => {
handleError(error as AxiosError);
},
},
const handleInviteUsersSuccess = (
response: SuccessResponse<InviteUsersResponse>,
): void => {
const inviteUsersResponse = response.payload as InviteUsersResponse;
if (inviteUsersResponse?.status === 'success') {
const successfulInvites = parseInviteUsersSuccessResponse(
inviteUsersResponse.successful_invites,
);
setDisableNextButton(true);
setError(null);
setHasErrors(false);
setInviteUsersErrorResponse(null);
setAllInvitesSent(true);
setInviteUsersSuccessResponse(successfulInvites);
setTimeout(() => {
setDisableNextButton(false);
onNext();
}, 1000);
} else if (inviteUsersResponse?.status === 'partial_success') {
const successfulInvites = parseInviteUsersSuccessResponse(
inviteUsersResponse.successful_invites,
);
setInviteUsersSuccessResponse(successfulInvites);
if (inviteUsersResponse.failed_invites.length > 0) {
setHasErrors(true);
setInviteUsersErrorResponse(
parseInviteUsersErrorResponse(inviteUsersResponse.failed_invites),
);
}
}
};
const {
mutate: sendInvites,
isLoading: isSendingInvites,
data: inviteUsersApiResponseData,
} = useMutation(inviteUsers, {
onSuccess: (response: SuccessResponse<InviteUsersResponse>): void => {
logEvent('User Onboarding: Invite Team Members Sent', {
teamMembers: teamMembersToInvite,
});
handleInviteUsersSuccess(response);
},
onError: (error: AxiosError): void => {
console.log('error', error);
logEvent('User Onboarding: Invite Team Members Failed', {
teamMembers: teamMembersToInvite,
error,
});
handleError(error);
},
});
const handleNext = (): void => {
if (validateAllUsers()) {
setTeamMembers(teamMembersToInvite || []);
setError(null);
setHasInvalidEmails(false);
setError(null);
setHasErrors(false);
setInviteUsersErrorResponse(null);
setInviteUsersSuccessResponse(null);
sendInvites({
users: teamMembersToInvite || [],
@ -165,6 +256,11 @@ function InviteTeamMembers({
};
const handleDoLater = (): void => {
logEvent('User Onboarding: Invite Team Members Skipped', {
teamMembers: teamMembersToInvite,
apiResponse: inviteUsersApiResponseData,
});
onNext();
};
@ -246,7 +342,6 @@ function InviteTeamMembers({
</Button>
</div>
</div>
</div>
{hasInvalidEmails && (
<div className="error-message-container">
@ -265,6 +360,51 @@ function InviteTeamMembers({
</div>
)}
{inviteUsersSuccessResponse && (
<div className="success-message-container invite-users-success-message-container">
{inviteUsersSuccessResponse?.map((success, index) => (
<Typography.Text
className="success-message"
// eslint-disable-next-line react/no-array-index-key
key={`${success}-${index}`}
>
<CheckCircle size={14} /> {success}
</Typography.Text>
))}
</div>
)}
{hasErrors && (
<div className="error-message-container invite-users-error-message-container">
{inviteUsersErrorResponse?.map((error, index) => (
<Typography.Text
className="error-message"
type="danger"
// eslint-disable-next-line react/no-array-index-key
key={`${error}-${index}`}
>
<TriangleAlert size={14} /> {error}
</Typography.Text>
))}
</div>
)}
</div>
{/* Partially sent invites */}
{inviteUsersSuccessResponse && inviteUsersErrorResponse && (
<div className="partially-sent-invites-container">
<Typography.Text className="partially-sent-invites-message">
<TriangleAlert size={14} />
Some invites were sent successfully. Please fix the errors above and
resend invites.
</Typography.Text>
<Typography.Text className="partially-sent-invites-message">
You can click on I&apos;ll do this later to go to next step.
</Typography.Text>
</div>
)}
<div className="next-prev-container">
<Button type="default" className="next-button" onClick={onBack}>
<ArrowLeft size={14} />
@ -275,10 +415,11 @@ function InviteTeamMembers({
type="primary"
className="next-button"
onClick={handleNext}
loading={isSendingInvites}
loading={isSendingInvites || disableNextButton}
>
Send Invites
<ArrowRight size={14} />
{allInvitesSent ? 'Invites Sent' : 'Send Invites'}
{allInvitesSent ? <CheckCircle size={14} /> : <ArrowRight size={14} />}
</Button>
</div>

View File

@ -90,7 +90,6 @@
.invite-team-members-container {
max-height: 260px;
padding-right: 8px;
overflow-y: auto;
&::-webkit-scrollbar {

View File

@ -122,7 +122,7 @@ function OptimiseSignozNeeds({
}, [services, hostsPerDay, logsPerDay]);
const handleOnNext = (): void => {
logEvent('Onboarding: Optimise SigNoz Needs: Next', {
logEvent('User Onboarding: Optimise SigNoz Needs Answered', {
logsPerDay,
hostsPerDay,
services,
@ -132,12 +132,6 @@ function OptimiseSignozNeeds({
};
const handleOnBack = (): void => {
logEvent('Onboarding: Optimise SigNoz Needs: Back', {
logsPerDay,
hostsPerDay,
services,
});
onBack();
};
@ -150,7 +144,7 @@ function OptimiseSignozNeeds({
onWillDoLater();
logEvent('Onboarding: Optimise SigNoz Needs: Will do later', {
logEvent('User Onboarding: Optimise SigNoz Needs Skipped', {
logsPerDay: 0,
hostsPerDay: 0,
services: 0,

View File

@ -120,7 +120,7 @@ function OrgQuestions({
familiarity,
});
logEvent('Onboarding: Org Questions: Next', {
logEvent('User Onboarding: Org Questions Answered', {
organisationName,
usesObservability,
observabilityTool,

View File

@ -2,6 +2,7 @@ import './OnboardingQuestionaire.styles.scss';
import { Skeleton } from 'antd';
import { NotificationInstance } from 'antd/es/notification/interface';
import logEvent from 'api/common/logEvent';
import updateProfileAPI from 'api/onboarding/updateProfile';
import getOrgPreference from 'api/preferences/getOrgPreference';
import updateOrgPreferenceAPI from 'api/preferences/updateOrgPreference';
@ -67,7 +68,7 @@ const INITIAL_OPTIMISE_SIGNOZ_DETAILS: OptimiseSignozDetails = {
function OnboardingQuestionaire(): JSX.Element {
const { notifications } = useNotifications();
const [currentStep, setCurrentStep] = useState<number>(1);
const [currentStep, setCurrentStep] = useState<number>(4);
const [orgDetails, setOrgDetails] = useState<OrgDetails>(INITIAL_ORG_DETAILS);
const [signozDetails, setSignozDetails] = useState<SignozDetails>(
INITIAL_SIGNOZ_DETAILS,
@ -91,8 +92,6 @@ function OnboardingQuestionaire(): JSX.Element {
queryKey: ['getOrgUser', org?.[0].id],
});
console.log('orgUsers', orgUsers, isLoadingOrgUsers);
const dispatch = useDispatch<Dispatch<AppActions>>();
const [orgData, setOrgData] = useState<OrgData | null>(null);
const [isOnboardingComplete, setIsOnboardingComplete] = useState<boolean>(
@ -126,13 +125,29 @@ function OnboardingQuestionaire(): JSX.Element {
};
useEffect(() => {
// Only run this effect if the org users and preferences are loaded
if (!isLoadingOrgUsers && !isLoadingOrgPreferences) {
const isFirstUser = checkFirstTimeUser();
if (isOnboardingComplete || !isFirstUser) {
// Redirect to get started if it's not the first user or if the onboarding is complete
if (!isFirstUser || isOnboardingComplete) {
history.push(ROUTES.GET_STARTED);
logEvent('User Onboarding: Redirected to Get Started', {
isFirstUser,
isOnboardingComplete,
});
} else {
logEvent('User Onboarding: Started', {});
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOnboardingComplete, orgUsers]);
}, [
isLoadingOrgUsers,
isLoadingOrgPreferences,
isOnboardingComplete,
orgUsers,
]);
useEffect(() => {
if (org) {
@ -182,8 +197,16 @@ function OnboardingQuestionaire(): JSX.Element {
},
});
logEvent('User Onboarding: Org Name Updated', {
organisationName: orgDetails.organisationName,
});
setCurrentStep(2);
} else {
logEvent('User Onboarding: Org Name Update Failed', {
organisationName: orgDetails.organisationName,
});
notifications.error({
message:
error ||

View File

@ -1,5 +1,7 @@
import { User } from 'types/reducer/app';
import { ErrorResponse } from '..';
export interface UserProps {
name: User['name'];
email: User['email'];
@ -14,3 +16,25 @@ export interface UsersProps {
export interface PayloadProps {
data: string;
}
export interface FailedInvite {
email: string;
error: string;
}
export interface SuccessfulInvite {
email: string;
invite_link: string;
status: string;
}
export interface InviteUsersResponse extends ErrorResponse {
status: string;
summary: {
total_invites: number;
successful_invites: number;
failed_invites: number;
};
successful_invites: SuccessfulInvite[];
failed_invites: FailedInvite[];
}