feat: improve async handling for org onboarding cases (#6342)

This commit is contained in:
Yunus M 2024-11-01 23:55:29 +05:30 committed by GitHub
parent 4978fb9599
commit c7d0598ec0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 205 additions and 250 deletions

View File

@ -9,7 +9,7 @@ import ROUTES from 'constants/routes';
import useLicense from 'hooks/useLicense';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { isEmpty } from 'lodash-es';
import { isEmpty, isNull } from 'lodash-es';
import { ReactChild, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
@ -35,13 +35,18 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const location = useLocation();
const { pathname } = location;
const { org, orgPreferences } = useSelector<AppState, AppReducer>(
(state) => state.app,
);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isOnboardingComplete, setIsOnboardingComplete] = useState<
boolean | null
>(null);
const {
org,
orgPreferences,
user,
role,
isUserFetching,
isUserFetchingError,
isLoggedIn: isLoggedInState,
isFetchingOrgPreferences,
} = useSelector<AppState, AppReducer>((state) => state.app);
const mapRoutes = useMemo(
() =>
@ -56,30 +61,19 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
[pathname],
);
useEffect(() => {
if (orgPreferences && !isEmpty(orgPreferences)) {
const onboardingPreference = orgPreferences?.find(
const isOnboardingComplete = useMemo(
() =>
orgPreferences?.find(
(preference: Record<string, any>) => preference.key === 'ORG_ONBOARDING',
)?.value,
[orgPreferences],
);
if (onboardingPreference) {
setIsOnboardingComplete(onboardingPreference.value);
}
}
}, [orgPreferences]);
const {
data: licensesData,
isFetching: isFetchingLicensesData,
} = useLicense();
const {
isUserFetching,
isUserFetchingError,
isLoggedIn: isLoggedInState,
isFetchingOrgPreferences,
} = useSelector<AppState, AppReducer>((state) => state.app);
const { t } = useTranslation(['common']);
const localStorageUserAuthToken = getInitialUserTokenRefreshToken();
@ -135,7 +129,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
// Check if the onboarding should be shown based on the org users and onboarding completion status, wait for org users and preferences to load
const shouldShowOnboarding = (): boolean => {
// Only run this effect if the org users and preferences are loaded
if (!isLoadingOrgUsers) {
if (!isLoadingOrgUsers && !isFetchingOrgPreferences) {
const isFirstUser = checkFirstTimeUser();
// Redirect to get started if it's not the first user or if the onboarding is complete
@ -145,6 +140,26 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
return false;
};
const handleRedirectForOrgOnboarding = (key: string): void => {
if (
isLoggedInState &&
!isFetchingOrgPreferences &&
!isLoadingOrgUsers &&
!isEmpty(orgUsers?.payload) &&
!isNull(orgPreferences)
) {
if (key === 'ONBOARDING' && isOnboardingComplete) {
history.push(ROUTES.APPLICATION);
}
const isFirstTimeUser = checkFirstTimeUser();
if (isFirstTimeUser && !isOnboardingComplete) {
history.push(ROUTES.ONBOARDING);
}
}
};
const handleUserLoginIfTokenPresent = async (
key: keyof typeof ROUTES,
): Promise<void> => {
@ -166,15 +181,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
response.payload.refreshJwt,
);
const showOnboarding = shouldShowOnboarding();
if (
userResponse &&
showOnboarding &&
userResponse.payload.role === 'ADMIN'
) {
history.push(ROUTES.ONBOARDING);
}
handleRedirectForOrgOnboarding(key);
if (
userResponse &&
@ -203,7 +210,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
) {
handleUserLoginIfTokenPresent(key);
} else {
// user does have localstorage values
handleRedirectForOrgOnboarding(key);
navigateToLoginIfNotLoggedIn(isLocalStorageLoggedIn);
}
@ -241,9 +248,9 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
}, [org]);
const handleRouting = (): void => {
const showOnboarding = shouldShowOnboarding();
const showOrgOnboarding = shouldShowOnboarding();
if (showOnboarding) {
if (showOrgOnboarding && !isOnboardingComplete) {
history.push(ROUTES.ONBOARDING);
} else {
history.push(ROUTES.APPLICATION);
@ -251,17 +258,27 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
};
useEffect(() => {
// Only run this effect if the org users and preferences are loaded
if (!isLoadingOrgUsers && isOnboardingComplete !== null) {
const isFirstUser = checkFirstTimeUser();
const { isPrivate } = currentRoute || {
isPrivate: false,
};
// Redirect to get started if it's not the first user or if the onboarding is complete
if (isFirstUser && !isOnboardingComplete) {
history.push(ROUTES.ONBOARDING);
if (isLoggedInState && role && role !== 'ADMIN') {
setIsLoading(false);
}
if (!isPrivate) {
setIsLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoadingOrgUsers, isOnboardingComplete, orgUsers]);
if (
!isEmpty(user) &&
!isFetchingOrgPreferences &&
!isEmpty(orgUsers?.payload) &&
!isNull(orgPreferences)
) {
setIsLoading(false);
}
}, [currentRoute, user, role, orgUsers, orgPreferences]);
// eslint-disable-next-line sonarjs/cognitive-complexity
useEffect(() => {
@ -284,7 +301,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
handlePrivateRoutes(key);
} else {
// no need to fetch the user and make user fetching false
if (getLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN) === 'true') {
handleRouting();
}
@ -311,13 +327,20 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
history.push(ROUTES.SOMETHING_WENT_WRONG);
}
})();
}, [dispatch, isLoggedInState, currentRoute, licensesData]);
}, [
dispatch,
isLoggedInState,
currentRoute,
licensesData,
orgUsers,
orgPreferences,
]);
if (isUserFetchingError) {
return <Redirect to={ROUTES.SOMETHING_WENT_WRONG} />;
}
if (isUserFetching || (isLoggedInState && isFetchingOrgPreferences)) {
if (isUserFetching || isLoading) {
return <Spinner tip="Loading..." />;
}

View File

@ -312,7 +312,7 @@ export default function Onboarding(): JSX.Element {
<div
onClick={(): void => {
logEvent('Onboarding V2: Skip Button Clicked', {});
history.push('/');
history.push(ROUTES.APPLICATION);
}}
className="skip-to-console"
>

View File

@ -10,6 +10,7 @@ import {
ArrowLeft,
ArrowRight,
CheckCircle,
Loader2,
Plus,
TriangleAlert,
X,
@ -33,6 +34,7 @@ interface TeamMember {
}
interface InviteTeamMembersProps {
isLoading: boolean;
teamMembers: TeamMember[] | null;
setTeamMembers: (teamMembers: TeamMember[]) => void;
onNext: () => void;
@ -40,6 +42,7 @@ interface InviteTeamMembersProps {
}
function InviteTeamMembers({
isLoading,
teamMembers,
setTeamMembers,
onNext,
@ -67,8 +70,6 @@ function InviteTeamMembers({
const [disableNextButton, setDisableNextButton] = useState<boolean>(false);
const [allInvitesSent, setAllInvitesSent] = useState<boolean>(false);
const defaultTeamMember: TeamMember = {
email: '',
role: 'EDITOR',
@ -157,7 +158,6 @@ function InviteTeamMembers({
setError(null);
setHasErrors(false);
setInviteUsersErrorResponse(null);
setAllInvitesSent(true);
setInviteUsersSuccessResponse(successfulInvites);
@ -358,7 +358,10 @@ function InviteTeamMembers({
</div>
)}
{inviteUsersSuccessResponse && (
{hasErrors && (
<>
{/* show only when invites are sent successfully & partial error is present */}
{inviteUsersSuccessResponse && inviteUsersErrorResponse && (
<div className="success-message-container invite-users-success-message-container">
{inviteUsersSuccessResponse?.map((success, index) => (
<Typography.Text
@ -372,7 +375,6 @@ function InviteTeamMembers({
</div>
)}
{hasErrors && (
<div className="error-message-container invite-users-error-message-container">
{inviteUsersErrorResponse?.map((error, index) => (
<Typography.Text
@ -385,6 +387,7 @@ function InviteTeamMembers({
</Typography.Text>
))}
</div>
</>
)}
</div>
@ -413,17 +416,23 @@ function InviteTeamMembers({
type="primary"
className="next-button"
onClick={handleNext}
loading={isSendingInvites || disableNextButton}
loading={isSendingInvites || isLoading || disableNextButton}
>
{allInvitesSent ? 'Invites Sent' : 'Send Invites'}
{allInvitesSent ? <CheckCircle size={14} /> : <ArrowRight size={14} />}
Send Invites
<ArrowRight size={14} />
</Button>
</div>
<div className="do-later-container">
<Button type="link" onClick={handleDoLater}>
I&apos;ll do this later
<Button
type="link"
className="do-later-button"
onClick={handleDoLater}
disabled={isSendingInvites || disableNextButton}
>
{isLoading && <Loader2 className="animate-spin" size={16} />}
<span>I&apos;ll do this later</span>
</Button>
</div>
</div>

View File

@ -189,6 +189,15 @@
justify-content: center;
align-items: center;
margin-top: 24px;
.do-later-button {
font-size: 12px;
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
}
}
.question {

View File

@ -314,7 +314,7 @@ function OptimiseSignozNeeds({
<div className="do-later-container">
<Button type="link" onClick={handleWillDoLater}>
Skip for now
I&apos;ll do this later
</Button>
</div>
</div>

View File

@ -1,31 +1,24 @@
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 getAllOrgPreferences from 'api/preferences/getAllOrgPreferences';
import getOrgPreference from 'api/preferences/getOrgPreference';
import updateOrgPreferenceAPI from 'api/preferences/updateOrgPreference';
import getOrgUser from 'api/user/getOrgUser';
import { AxiosError } from 'axios';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import ROUTES from 'constants/routes';
import { InviteTeamMembersProps } from 'container/OrganizationSettings/PendingInvitesContainer';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { isEmpty } from 'lodash-es';
import { Dispatch, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useMutation, useQuery } from 'react-query';
import { useDispatch, useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import {
UPDATE_IS_FETCHING_ORG_PREFERENCES,
UPDATE_ORG_PREFERENCES,
} from 'types/actions/app';
import AppReducer from 'types/reducer/app';
import { USER_ROLES } from 'types/roles';
import {
AboutSigNozQuestions,
@ -70,15 +63,14 @@ const INITIAL_OPTIMISE_SIGNOZ_DETAILS: OptimiseSignozDetails = {
function OnboardingQuestionaire(): JSX.Element {
const { notifications } = useNotifications();
const { org, role, isLoggedIn: isLoggedInState } = useSelector<
AppState,
AppReducer
>((state) => state.app);
const { org } = useSelector<AppState, AppReducer>((state) => state.app);
const dispatch = useDispatch();
const [currentStep, setCurrentStep] = useState<number>(1);
const [orgDetails, setOrgDetails] = useState<OrgDetails>(INITIAL_ORG_DETAILS);
const [signozDetails, setSignozDetails] = useState<SignozDetails>(
INITIAL_SIGNOZ_DETAILS,
);
const [
optimiseSignozDetails,
setOptimiseSignozDetails,
@ -87,113 +79,12 @@ function OnboardingQuestionaire(): JSX.Element {
InviteTeamMembersProps[] | null
>(null);
const { data: orgUsers, isLoading: isLoadingOrgUsers } = useQuery({
queryFn: () =>
getOrgUser({
orgId: (org || [])[0].id,
}),
queryKey: ['getOrgUser', org?.[0].id],
});
const dispatch = useDispatch<Dispatch<AppActions>>();
const [currentOrgData, setCurrentOrgData] = useState<OrgData | null>(null);
const [isOnboardingComplete, setIsOnboardingComplete] = useState<boolean>(
false,
);
const {
data: onboardingPreferenceData,
isLoading: isLoadingOnboardingPreference,
} = useQuery({
queryFn: () => getOrgPreference({ preferenceID: 'ORG_ONBOARDING' }),
queryKey: ['getOrgPreferences', 'ORG_ONBOARDING'],
enabled: role === USER_ROLES.ADMIN,
});
const { data: orgPreferences, isLoading: isLoadingOrgPreferences } = useQuery({
queryFn: () => getAllOrgPreferences(),
queryKey: ['getOrgPreferences'],
enabled: isOnboardingComplete && role === USER_ROLES.ADMIN,
});
useEffect(() => {
if (orgPreferences && !isLoadingOrgPreferences) {
dispatch({
type: UPDATE_IS_FETCHING_ORG_PREFERENCES,
payload: {
isFetchingOrgPreferences: false,
},
});
dispatch({
type: UPDATE_ORG_PREFERENCES,
payload: {
orgPreferences: orgPreferences.payload?.data || null,
},
});
}
}, [orgPreferences, dispatch, isLoadingOrgPreferences]);
useEffect(() => {
if (isLoggedInState && role !== USER_ROLES.ADMIN) {
dispatch({
type: UPDATE_IS_FETCHING_ORG_PREFERENCES,
payload: {
isFetchingOrgPreferences: false,
},
});
}
}, [isLoggedInState, role, dispatch]);
useEffect(() => {
if (
!isLoadingOnboardingPreference &&
!isEmpty(onboardingPreferenceData?.payload?.data)
) {
const preferenceId = onboardingPreferenceData?.payload?.data?.preference_id;
const preferenceValue =
onboardingPreferenceData?.payload?.data?.preference_value;
if (preferenceId === 'ORG_ONBOARDING') {
setIsOnboardingComplete(preferenceValue as boolean);
}
}
}, [onboardingPreferenceData, isLoadingOnboardingPreference]);
const checkFirstTimeUser = (): boolean => {
const users = orgUsers?.payload || [];
const remainingUsers = users.filter(
(user) => user.email !== 'admin@signoz.cloud',
);
return remainingUsers.length === 1;
};
useEffect(() => {
// Only run this effect if the org users and preferences are loaded
if (!isLoadingOrgUsers && !isLoadingOnboardingPreference) {
const isFirstUser = checkFirstTimeUser();
// 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
}, [
isLoadingOrgUsers,
isLoadingOnboardingPreference,
isOnboardingComplete,
orgUsers,
]);
const [
updatingOrgOnboardingStatus,
setUpdatingOrgOnboardingStatus,
] = useState<boolean>(false);
useEffect(() => {
if (org) {
@ -207,6 +98,35 @@ function OnboardingQuestionaire(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [org]);
const { refetch: refetchOrgPreferences } = useQuery({
queryFn: () => getAllOrgPreferences(),
queryKey: ['getOrgPreferences'],
enabled: false,
refetchOnWindowFocus: false,
onSuccess: (response) => {
dispatch({
type: UPDATE_IS_FETCHING_ORG_PREFERENCES,
payload: {
isFetchingOrgPreferences: false,
},
});
dispatch({
type: UPDATE_ORG_PREFERENCES,
payload: {
orgPreferences: response.payload?.data || null,
},
});
setUpdatingOrgOnboardingStatus(false);
history.push(ROUTES.GET_STARTED);
},
onError: () => {
setUpdatingOrgOnboardingStatus(false);
},
});
const isNextDisabled =
optimiseSignozDetails.logsPerDay === 0 &&
optimiseSignozDetails.hostsPerDay === 0 &&
@ -226,10 +146,12 @@ function OnboardingQuestionaire(): JSX.Element {
const { mutate: updateOrgPreference } = useMutation(updateOrgPreferenceAPI, {
onSuccess: () => {
setIsOnboardingComplete(true);
refetchOrgPreferences();
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
setUpdatingOrgOnboardingStatus(false);
},
});
@ -258,6 +180,7 @@ function OnboardingQuestionaire(): JSX.Element {
};
const handleOnboardingComplete = (): void => {
setUpdatingOrgOnboardingStatus(true);
updateOrgPreference({
preferenceID: 'ORG_ONBOARDING',
value: true,
@ -271,14 +194,6 @@ function OnboardingQuestionaire(): JSX.Element {
</div>
<div className="onboarding-questionaire-content">
{(isLoadingOnboardingPreference || isLoadingOrgUsers) && (
<div className="onboarding-questionaire-loading-container">
<Skeleton />
</div>
)}
{!isLoadingOnboardingPreference && !isLoadingOrgUsers && (
<>
{currentStep === 1 && (
<OrgQuestions
currentOrgData={currentOrgData}
@ -313,14 +228,13 @@ function OnboardingQuestionaire(): JSX.Element {
{currentStep === 4 && (
<InviteTeamMembers
isLoading={updatingOrgOnboardingStatus}
teamMembers={teamMembers}
setTeamMembers={setTeamMembers}
onBack={(): void => setCurrentStep(3)}
onNext={handleOnboardingComplete}
/>
)}
</>
)}
</div>
</div>
);

View File

@ -261,7 +261,7 @@ function SignUp({ version }: SignUpProps): JSX.Element {
values,
async (): Promise<void> => {
if (isOnboardingEnabled && isCloudUser()) {
history.push(ROUTES.ONBOARDING);
history.push(ROUTES.GET_STARTED);
} else {
history.push(ROUTES.APPLICATION);
}