(frontend) display onboarding modal when first connection

When the user connect for the first time, we
display a onboarding modal, that explains the
main functionnalities of Docs.
This commit is contained in:
Anthony LC
2026-03-13 17:22:54 +01:00
parent 5f2c472726
commit 3eed542800
8 changed files with 145 additions and 2 deletions
@@ -138,6 +138,7 @@ def create_demo(stdout):
password="!",
is_superuser=False,
is_active=True,
is_first_connection=False,
is_staff=False,
short_name=first_name,
full_name=f"{first_name:s} {random.choice(last_names):s}",
@@ -194,6 +195,7 @@ def create_demo(stdout):
password="!",
is_superuser=False,
is_active=True,
is_first_connection=False,
is_staff=False,
language=dev_user["language"] or random.choice(languages),
)
@@ -81,7 +81,7 @@ test.describe('Help feature', () => {
);
await learnMoreLink.click();
await page.getByRole('button', { name: /understood|compris/i }).click();
await page.getByRole('button', { name: /understood/i }).click();
await expect(modal).toBeHidden();
});
@@ -126,5 +126,52 @@ test.describe('Help feature', () => {
modal.getByRole('button', { name: /Suivant/i }),
).toBeVisible();
});
test('Modal is displayed automatically on first connection', async ({
page,
browserName,
}) => {
await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible();
await expect(page.getByTestId('onboarding-modal')).toBeHidden();
await page.route(/.*\/api\/v1.0\/users\/me\//, async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
id: 'f2bfcf0b-e4b9-4153-b2e5-0d2a9a5a0a5b',
email: `user.test@${browserName.toLowerCase()}.test`,
full_name: `E2E ${browserName}`,
short_name: 'E2E',
language: 'en-us',
is_first_connection: true,
},
});
} else {
await route.continue();
}
});
let onboardingDoneCalled = false;
await page.route(
/.*\/api\/v1.0\/users\/onboarding-done\//,
async (route) => {
const request = route.request();
if (request.method().includes('POST')) {
onboardingDoneCalled = true;
await route.continue();
}
},
);
await page.goto('/');
await expect(page.getByTestId('onboarding-modal')).toBeVisible();
await page.getByRole('button', { name: /skip/i }).click();
await expect(page.getByTestId('onboarding-modal')).toBeHidden();
expect(onboardingDoneCalled).toBeTruthy();
});
});
});
@@ -8,6 +8,7 @@
*/
export interface User {
id: string;
is_first_connection: boolean;
email: string;
full_name: string;
short_name: string;
@@ -0,0 +1,53 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { User } from './types';
import { KEY_AUTH } from './useAuthQuery';
type OnboardingDoneResponse = {
detail: string;
};
export const onboardingDone = async (): Promise<OnboardingDoneResponse> => {
const response = await fetchAPI(`users/onboarding-done/`, {
method: 'POST',
});
if (!response.ok) {
throw new APIError(
'Failed to complete onboarding',
await errorCauses(response),
);
}
return response.json() as Promise<OnboardingDoneResponse>;
};
type UseOnboardingDoneOptions = UseMutationOptions<
OnboardingDoneResponse,
APIError
>;
export function useOnboardingDone(options?: UseOnboardingDoneOptions) {
const queryClient = useQueryClient();
return useMutation<OnboardingDoneResponse, APIError>({
mutationFn: onboardingDone,
onSuccess: (data, variables, onMutateResult, context) => {
queryClient.setQueryData<User>([KEY_AUTH], (oldData) => {
if (!oldData) {
return oldData;
}
return {
...oldData,
is_first_connection: false,
};
});
options?.onSuccess?.(data, variables, onMutateResult, context);
},
});
}
@@ -14,6 +14,8 @@ import {
resetSilent,
} from '../utils';
import { FirstConnection } from './FirstConnection';
export const Auth = ({ children }: PropsWithChildren) => {
const {
isLoading: isAuthLoading,
@@ -21,6 +23,7 @@ export const Auth = ({ children }: PropsWithChildren) => {
isFetchedAfterMount,
authenticated,
fetchStatus,
user,
} = useAuth();
const isLoading = fetchStatus !== 'idle' || isAuthLoading;
const [isRedirecting, setIsRedirecting] = useState(false);
@@ -110,5 +113,10 @@ export const Auth = ({ children }: PropsWithChildren) => {
return <Loading $height="100vh" $width="100vw" />;
}
return children;
return (
<>
{children}
{user && user.is_first_connection && <FirstConnection />}
</>
);
};
@@ -0,0 +1,30 @@
import { useModal } from '@gouvfr-lasuite/cunningham-react';
import { useEffect } from 'react';
import { OnBoarding } from '@/features/help';
import { useOnboardingDone } from '../api/useOnboardingDone';
export const FirstConnection = () => {
const modalOnbording = useModal();
const { mutate: onboardingDone, isPending } = useOnboardingDone();
useEffect(() => {
if (isPending) {
return;
}
modalOnbording.open();
}, [modalOnbording, isPending]);
const onClose = () => {
onboardingDone();
modalOnbording.close();
};
if (!modalOnbording.isOpen && isPending) {
return null;
}
return <OnBoarding isOpen={modalOnbording.isOpen} onClose={onClose} />;
};
@@ -35,6 +35,7 @@ export const DocShareInvitationItem = ({
email: invitation.email,
short_name: invitation.email,
language: 'en-us',
is_first_connection: false,
};
const { toast } = useToastProvider();
@@ -353,6 +353,7 @@ const QuickSearchInviteInputSection = ({
email: userQuery,
short_name: '',
language: '',
is_first_connection: false,
};
const hasEmailInUsers = users.some(