Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
VITE_API_URL=
VITE_IDENTITY_PROVIDER=

VITE_BASE_EXIT_URL=

# OIDC Configurations
VITE_OIDC_ISSUER=
VITE_OIDC_ENABLED=false
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
5 changes: 4 additions & 1 deletion src/components/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export enum ButtonStyle {
Primary,
Secondary,
Transparent,
}

export enum ButtonSize {
Expand Down Expand Up @@ -39,7 +40,9 @@
${
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'

Check warning on line 45 in src/components/Button.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=InseeFr_walking-papers&issues=AZ8nDoxJVFgs1PNCRAPl&open=AZ8nDoxJVFgs1PNCRAPl&pullRequest=64
} ${buttonSize === ButtonSize.md ? 'px-4 py-3 min-w-40' : 'px-2 py-1'}`}
{...props}
>
Expand Down
38 changes: 29 additions & 9 deletions src/components/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
/** Children to render inside the dialog trigger button. */
children?: React.ReactElement
/** Title of the dialog. */
Expand All @@ -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. */
Expand All @@ -47,13 +50,26 @@ export default function Dialog({
setControlledOpen,
cancelLabel,
validateLabel,
loadingLabel,
}: Readonly<DialogProps>) {
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 (
<UIDialog.Root open={open} onOpenChange={setOpen}>
{children && <UIDialog.Trigger render={children}></UIDialog.Trigger>}
Expand All @@ -68,23 +84,27 @@ export default function Dialog({
</UIDialog.Description>
<div className="flex justify-end gap-4">
{onCancel ? (
<Button onClick={onCancel}>
<Button onClick={onCancel} disabled={isLoading}>
{cancelLabel || t('common.cancel')}
</Button>
) : (
<UIDialog.Close
render={<Button>{cancelLabel || t('common.cancel')}</Button>}
render={
<Button disabled={isLoading}>
{cancelLabel || t('common.cancel')}
</Button>
}
/>
)}
{onValidate ? (
<Button
onClick={() => {
onValidate()
setOpen(false)
}}
onClick={handleValidate}
buttonStyle={ButtonStyle.Primary}
disabled={isLoading}
>
{validateLabel || t('common.validate')}
{isLoading
? loadingLabel || validateLabel || t('common.validate')
: validateLabel || t('common.validate')}
</Button>
) : null}
</div>
Expand Down
120 changes: 120 additions & 0 deletions src/components/Header.test.tsx
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -17,4 +45,96 @@ describe('Header', () => {
const { getByText } = renderWithI18n(<Header />)
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(<Header />)

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(<Header />)

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(<Header />)

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(<Header />)

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(<Header />)

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(<Header />)

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`)
})
})
25 changes: 17 additions & 8 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
import { useTranslation } from 'react-i18next'

import SaveAndExitButton from './SaveAndExitButton'

export default function Header() {
const { t } = useTranslation()

return (
<div className="h-16 bg-gray-800 border-b relative flex items-center justify-end px-4">
<span className="font-bold text-xl text-white absolute left-1/2 transform -translate-x-1/2">
{t('common.appName')}
</span>
<span className="text-white italic">
Version {import.meta.env.APP_VERSION} | Lunatic{' '}
{import.meta.env.LUNATIC_VERSION.replace('^', '')}
</span>
<div className="h-16 bg-gray-800 border-b relative flex items-center justify-between">
<div className="flex flex-col px-4">
<span className="font-bold text-xl text-white">
{t('common.appName')}
</span>
<span className="text-white italic text-sm">
{t('common.version', {
appVersion: import.meta.env.APP_VERSION,
lunaticVersion: import.meta.env.LUNATIC_VERSION.replace('^', ''),
})}
</span>
</div>
<div className="flex flex-col items-start">
<SaveAndExitButton />
</div>
</div>
)
}
61 changes: 61 additions & 0 deletions src/components/SaveAndExitButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Button
buttonStyle={ButtonStyle.Transparent}
onClick={handleGoToInterrogation}
IconLeft={<ExitIcon />}
>
{t('common.leaveQuestionnaire')}
</Button>
<Dialog
title={t('common.leaveConfirmation')}
body={t('common.leaveBody')}
onCancel={() => setIsDialogOpen(false)}
onValidate={handleSaveAndLeave}
controlledOpen={isDialogOpen}
setControlledOpen={setIsDialogOpen}
validateLabel={t('common.leaveAndSave')}
loadingLabel={t('common.saving')}
/>
</>
)
}
20 changes: 20 additions & 0 deletions src/components/icons/ExitIcon.tsx
Original file line number Diff line number Diff line change
@@ -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<React.ComponentProps<'svg'>>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height={height}
viewBox="0 -960 960 960"
width={width}
{...props}
>
<path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h280v80H200Zm440-160-55-58 102-102H360v-80h327L585-622l55-58 200 200-200 200Z" />
</svg>
)
}
Loading
Loading