diff --git a/.env b/.env index c21eff4..e85fb19 100644 --- a/.env +++ b/.env @@ -1,6 +1,8 @@ VITE_API_URL= VITE_IDENTITY_PROVIDER= +VITE_BASE_EXIT_URL= + # OIDC Configurations VITE_OIDC_ISSUER= VITE_OIDC_ENABLED=false diff --git a/CHANGELOG.md b/CHANGELOG.md index e28b915..c74bc64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add a button to leave the questionnaire and go back to the base app + ### Changed - Authentication: diff --git a/package.json b/package.json index eed4dfa..14ed632 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "orchestrateur-saisie-papier", "private": true, - "version": "1.0.3", + "version": "1.0.4-rc-exit-redirection.0", "type": "module", "packageManager": "pnpm@11.8.0", "scripts": { diff --git a/src/components/Button.tsx b/src/components/Button.tsx index c059a13..8b9b725 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -1,6 +1,7 @@ export enum ButtonStyle { Primary, Secondary, + Transparent, } export enum ButtonSize { @@ -39,7 +40,9 @@ export default function Button({ ${ buttonStyle === ButtonStyle.Primary ? 'bg-primary text-negative disabled:bg-primary-disabled hover:enabled:bg-primary-accent active:enabled:bg-primary-active' - : 'bg-white text-primary fill-action-primary disabled:bg-disabled disabled:text-disabled hover:enabled:bg-accent active:enabled:bg-active disabled:border-default border-primary' + : buttonStyle === ButtonStyle.Transparent + ? 'bg-transparent text-negative fill-current border-transparent hover:enabled:bg-white/10 active:enabled:bg-white/20 disabled:opacity-50' + : 'bg-white text-primary fill-action-primary disabled:bg-disabled disabled:text-disabled hover:enabled:bg-accent active:enabled:bg-active disabled:border-default border-primary' } ${buttonSize === ButtonSize.md ? 'px-4 py-3 min-w-40' : 'px-2 py-1'}`} {...props} > diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index abe8624..e9446ad 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -15,11 +15,12 @@ interface DialogProps { */ onCancel?: () => void /** - * Function to execute if the user click on "validate". + * Async function to execute if the user click on "validate". * * The validate button is only present if this function is provided. + * While the promise is pending, buttons are disabled and the validate label shows `loadingLabel`. */ - onValidate?: () => void + onValidate?: () => void | Promise /** Children to render inside the dialog trigger button. */ children?: React.ReactElement /** Title of the dialog. */ @@ -34,6 +35,8 @@ interface DialogProps { cancelLabel?: string /** Custom label for the validate button. */ validateLabel?: string + /** Custom label for the validate button while onValidate is pending. */ + loadingLabel?: string } /** Display a button that opens a confirmation dialog. */ @@ -47,13 +50,26 @@ export default function Dialog({ setControlledOpen, cancelLabel, validateLabel, + loadingLabel, }: Readonly) { const { t } = useTranslation() const [uncontrolledOpen, setUncontrolledOpen] = useState(false) + const [isLoading, setIsLoading] = useState(false) const open = controlledOpen ?? uncontrolledOpen const setOpen = setControlledOpen ?? setUncontrolledOpen + const handleValidate = async () => { + if (!onValidate) return + setIsLoading(true) + try { + await onValidate() + } finally { + setIsLoading(false) + setOpen(false) + } + } + return ( {children && } @@ -68,23 +84,27 @@ export default function Dialog({
{onCancel ? ( - ) : ( {cancelLabel || t('common.cancel')}} + render={ + + } /> )} {onValidate ? ( ) : null}
diff --git a/src/components/Header.test.tsx b/src/components/Header.test.tsx index 24584a0..7a4628a 100644 --- a/src/components/Header.test.tsx +++ b/src/components/Header.test.tsx @@ -1,11 +1,39 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { executePreLogoutActions } from '@/features/orchestrator/hooks/usePreLogoutAction' import { renderWithI18n } from '@/testing/render' import Header from './Header' +interface InterrogationMatch { + params: { + interrogationId: string + } +} + +const mockUseMatch = vi.hoisted(() => + vi.fn<(...args: object[]) => InterrogationMatch | undefined>(), +) + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router') + return { + ...actual, + useMatch: mockUseMatch, + } +}) + +vi.mock('@/features/orchestrator/hooks/usePreLogoutAction', () => ({ + executePreLogoutActions: vi.fn(), +})) + describe('Header', () => { beforeEach(() => { vi.stubEnv('APP_VERSION', '1.0.0') vi.stubEnv('LUNATIC_VERSION', '^3.6.9') + vi.stubEnv('VITE_BASE_EXIT_URL', '') + mockUseMatch.mockReturnValue(undefined) }) it('display app name', async () => { @@ -17,4 +45,96 @@ describe('Header', () => { const { getByText } = renderWithI18n(
) expect(getByText('Version 1.0.0 | Lunatic 3.6.9')).toBeInTheDocument() }) + + it('does not show button when redirect URL is not configured', () => { + mockUseMatch.mockReturnValue({ + params: { interrogationId: 'test-123' }, + }) + + renderWithI18n(
) + + expect( + screen.queryByText('Leave the questionnaire'), + ).not.toBeInTheDocument() + }) + + it('does not show button when interrogation match is not found', () => { + vi.stubEnv('VITE_BASE_EXIT_URL', 'https://base-app.url') + renderWithI18n(
) + + expect( + screen.queryByText('Leave the questionnaire'), + ).not.toBeInTheDocument() + }) + + it('shows button when interrogation match is found', () => { + vi.stubEnv('VITE_BASE_EXIT_URL', 'https://base-app.url') + mockUseMatch.mockReturnValue({ + params: { interrogationId: 'test-123' }, + }) + + renderWithI18n(
) + + expect(screen.getByText('Leave the questionnaire')).toBeInTheDocument() + }) + + it('opens dialog when button is clicked', async () => { + vi.stubEnv('VITE_BASE_EXIT_URL', 'https://base-app.url') + const user = userEvent.setup() + mockUseMatch.mockReturnValue({ + params: { interrogationId: 'test-123' }, + }) + + renderWithI18n(
) + + await user.click(screen.getByText('Leave the questionnaire')) + + expect( + screen.getByRole('button', { name: 'Save and leave' }), + ).toBeInTheDocument() + expect( + screen.getByText( + 'Do you want to leave the questionnaire? Your data will be saved before leaving.', + ), + ).toBeInTheDocument() + }) + + it('closes dialog when cancel is clicked', async () => { + vi.stubEnv('VITE_BASE_EXIT_URL', 'https://base-app.url') + const user = userEvent.setup() + mockUseMatch.mockReturnValue({ + params: { interrogationId: 'test-123' }, + }) + + renderWithI18n(
) + + await user.click(screen.getByText('Leave the questionnaire')) + await user.click(screen.getByText('Cancel')) + + expect( + screen.queryByRole('button', { name: 'Save and leave' }), + ).not.toBeInTheDocument() + }) + + it('redirects to base app on save and leave', async () => { + const BASE_URL = 'https://base-app.url' + vi.stubEnv('VITE_BASE_EXIT_URL', BASE_URL) + const user = userEvent.setup() + mockUseMatch.mockReturnValue({ + params: { interrogationId: 'test-123' }, + }) + + Object.defineProperty(window, 'location', { + writable: true, + value: { href: '' }, + }) + + renderWithI18n(
) + + await user.click(screen.getByText('Leave the questionnaire')) + await user.click(screen.getByRole('button', { name: 'Save and leave' })) + + expect(executePreLogoutActions).toHaveBeenCalledOnce() + expect(window.location.href).toBe(`${BASE_URL}/interrogations/test-123`) + }) }) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 8dd8a07..a978b5f 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,17 +1,26 @@ import { useTranslation } from 'react-i18next' +import SaveAndExitButton from './SaveAndExitButton' + export default function Header() { const { t } = useTranslation() return ( -
- - {t('common.appName')} - - - Version {import.meta.env.APP_VERSION} | Lunatic{' '} - {import.meta.env.LUNATIC_VERSION.replace('^', '')} - +
+
+ + {t('common.appName')} + + + {t('common.version', { + appVersion: import.meta.env.APP_VERSION, + lunaticVersion: import.meta.env.LUNATIC_VERSION.replace('^', ''), + })} + +
+
+ +
) } diff --git a/src/components/SaveAndExitButton.tsx b/src/components/SaveAndExitButton.tsx new file mode 100644 index 0000000..df2a178 --- /dev/null +++ b/src/components/SaveAndExitButton.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react' + +import { useMatch } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' + +import { executePreLogoutActions } from '@/features/orchestrator/hooks/usePreLogoutAction' + +import Button, { ButtonStyle } from './Button' +import Dialog from './Dialog' +import ExitIcon from './icons/ExitIcon' + +export default function SaveAndExitButton() { + const { t } = useTranslation() + const [isDialogOpen, setIsDialogOpen] = useState(false) + + const interrogationMatch = useMatch({ + from: '/interrogations/$interrogationId', + shouldThrow: false, + }) + + const exitUrl = import.meta.env.VITE_BASE_EXIT_URL + + const handleGoToInterrogation = () => { + if (interrogationMatch?.params?.interrogationId) { + setIsDialogOpen(true) + } + } + + const handleSaveAndLeave = async () => { + if (!interrogationMatch?.params?.interrogationId) return + + await executePreLogoutActions() + window.location.href = `${exitUrl}/interrogations/${interrogationMatch.params.interrogationId}` + } + + if (!interrogationMatch || !exitUrl) { + return null + } + + return ( + <> + + setIsDialogOpen(false)} + onValidate={handleSaveAndLeave} + controlledOpen={isDialogOpen} + setControlledOpen={setIsDialogOpen} + validateLabel={t('common.leaveAndSave')} + loadingLabel={t('common.saving')} + /> + + ) +} diff --git a/src/components/icons/ExitIcon.tsx b/src/components/icons/ExitIcon.tsx new file mode 100644 index 0000000..09ce4d6 --- /dev/null +++ b/src/components/icons/ExitIcon.tsx @@ -0,0 +1,20 @@ +/** + * Icon of exit symbol which should be used when one wants to go back to the base app. + */ +export default function ExitIcon({ + height = '24px', + width = '24px', + ...props +}: Readonly>) { + return ( + + + + ) +} diff --git a/src/features/orchestrator/Orchestrator.tsx b/src/features/orchestrator/Orchestrator.tsx index 8ee7e92..29b260a 100644 --- a/src/features/orchestrator/Orchestrator.tsx +++ b/src/features/orchestrator/Orchestrator.tsx @@ -22,6 +22,7 @@ import Navigation from './Navigation' import { EndPage } from './customPages/EndPage' import { useInterrogation } from './hooks/useInterrogation' import { useNavigation } from './hooks/useNavigation' +import { useAddPreLogoutAction } from './hooks/usePreLogoutAction' import { useUpdateEffect } from './hooks/useUpdateEffect' import { computeInterrogation, @@ -177,6 +178,12 @@ export default function Orchestrator(props: OrchestratorProps) { } } + useAddPreLogoutAction(async () => { + if (mode === MODE_TYPE.COLLECT && !hasBeenSent(initialState)) { + await triggerDataAndStateUpdate(true) + } + }) + return (
diff --git a/src/features/orchestrator/hooks/usePreLogoutAction.ts b/src/features/orchestrator/hooks/usePreLogoutAction.ts new file mode 100644 index 0000000..0b302aa --- /dev/null +++ b/src/features/orchestrator/hooks/usePreLogoutAction.ts @@ -0,0 +1,26 @@ +import { useEffect } from 'react' + +import { useEvent } from '@/hooks/useEvent' + +const actions = new Set<() => Promise | void>() + +/* + Execute pre-logout actions, i.e only save data for now (from Stromae-DSFR) +*/ +export async function executePreLogoutActions() { + for (const action of actions) { + await action() + } +} + +export function useAddPreLogoutAction( + action: () => Promise | void, +): void { + const stableAction = useEvent(action) + useEffect(() => { + actions.add(stableAction) + return () => { + actions.delete(stableAction) + } + }, [stableAction]) +} diff --git a/src/hooks/useEvent.ts b/src/hooks/useEvent.ts new file mode 100644 index 0000000..ec7f63a --- /dev/null +++ b/src/hooks/useEvent.ts @@ -0,0 +1,11 @@ +import { useCallback, useInsertionEffect, useRef } from 'react' + +export function useEvent( + callback: (...args: Args) => Return, +): (...args: Args) => Return { + const ref = useRef(callback) + useInsertionEffect(() => { + ref.current = callback + }) + return useCallback((...args: Args) => ref.current(...args), []) +} diff --git a/src/libs/i18n/locales/en.json b/src/libs/i18n/locales/en.json index c3d52fb..ac8e670 100644 --- a/src/libs/i18n/locales/en.json +++ b/src/libs/i18n/locales/en.json @@ -6,7 +6,14 @@ "validateData": "Validate data", "previous": "Previous", "cancel": "Cancel", - "validate": "Validate" + "validate": "Validate", + "leaveQuestionnaire": "Leave the questionnaire", + "version": "Version {{appVersion}} | Lunatic {{lunaticVersion}}", + "quit": "Quit", + "leaveConfirmation": "Save and leave", + "leaveBody": "Do you want to leave the questionnaire? Your data will be saved before leaving.", + "leaveAndSave": "Save and leave", + "saving": "Saving..." }, "welcomeModal": { "title": "Welcome to Walking Papers", diff --git a/src/libs/i18n/locales/fr.json b/src/libs/i18n/locales/fr.json index faca0ea..ba11fc3 100644 --- a/src/libs/i18n/locales/fr.json +++ b/src/libs/i18n/locales/fr.json @@ -6,7 +6,14 @@ "validateData": "Valider les données saisies", "previous": "Précédent", "cancel": "Annuler", - "validate": "Valider" + "validate": "Valider", + "leaveQuestionnaire": "Quitter le questionnaire", + "version": "Version {{appVersion}} | Lunatic {{lunaticVersion}}", + "quit": "Quitter", + "leaveConfirmation": "Sauvegarder et quitter", + "leaveBody": "Voulez vous quitter le questionnaire ? Vos données seront sauvegardées.", + "leaveAndSave": "Sauvegarder et quitter", + "saving": "Sauvegarde en cours..." }, "welcomeModal": { "title": "Bienvenue dans Walking Papers", diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index de7a129..e7f87a0 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,6 +5,7 @@ type ImportMetaEnv = { // You probably want to add `/src/vite-env.d.ts` to your .prettierignore VITE_API_URL: string VITE_IDENTITY_PROVIDER: string + VITE_BASE_EXIT_URL: string VITE_OIDC_ISSUER: string VITE_OIDC_ENABLED: string VITE_OIDC_CLIENT_ID: string