diff --git a/server/src/index.ts b/server/src/index.ts
index 75d635eb..74b66213 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -28,7 +28,7 @@ app.get('/api/interrogations/:id', (c) => {
id: c.req.param('id'),
questionnaireId: c.req.param('id'),
data: {
- COLLECTED: data
+ COLLECTED: data,
},
stateData,
})
@@ -36,7 +36,7 @@ app.get('/api/interrogations/:id', (c) => {
app.patch('/api/interrogations/:id', async (c) => {
const json = (await c.req.json()) as any
- data = {...data, ...json.data}
+ data = { ...data, ...json.data }
stateData = json.stateData
return c.json({})
})
diff --git a/src/components/orchestrator/hooks/interrogation/utils.ts b/src/components/orchestrator/hooks/interrogation/utils.ts
index d7aa53f1..07a28a71 100644
--- a/src/components/orchestrator/hooks/interrogation/utils.ts
+++ b/src/components/orchestrator/hooks/interrogation/utils.ts
@@ -25,7 +25,6 @@ export function computeUpdatedData(
return currentInterrogationData
}
-
/**
* Retrieve the full data, merging the changes into the current data
*/
diff --git a/src/components/orchestrator/slotComponents/Loop.test.tsx b/src/components/orchestrator/slotComponents/Loop.test.tsx
new file mode 100644
index 00000000..71e7384a
--- /dev/null
+++ b/src/components/orchestrator/slotComponents/Loop.test.tsx
@@ -0,0 +1,150 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import * as focusUtils from '../utils/focusLastRowInput'
+import { Loop } from './Loop'
+
+vi.mock('../utils/focusLastRowInput', () => ({
+ focusLastInput: vi.fn(),
+}))
+
+const mockFocusLastInput = vi.mocked(focusUtils.focusLastInput)
+
+describe('Loop', () => {
+ const defaultProps = {
+ id: 'test-loop',
+ label: 'Test Loop',
+ children:
Loop content
,
+ canControlRows: true,
+ addRow: vi.fn(),
+ removeRow: vi.fn(),
+ executeExpression: vi.fn(),
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render label and children', () => {
+ render()
+
+ expect(screen.getByText('Test Loop')).toBeInTheDocument()
+
+ const label = screen.getByText('Test Loop')
+ expect(label).toHaveAttribute('id', 'label-test-loop')
+
+ expect(screen.getByText('Loop content')).toBeInTheDocument()
+ })
+
+ it('should render description when provided', () => {
+ render()
+
+ expect(screen.getByText('Test description')).toBeInTheDocument()
+ })
+
+ it('should render control buttons when canControlRows is true', () => {
+ render()
+
+ expect(
+ screen.getByRole('button', { name: 'Ajouter une ligne' }),
+ ).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', { name: 'Supprimer la dernière ligne' }),
+ ).toBeInTheDocument()
+ })
+
+ it('should not render control buttons when canControlRows is false', () => {
+ render()
+
+ expect(
+ screen.queryByRole('button', { name: 'Ajouter une ligne' }),
+ ).not.toBeInTheDocument()
+ expect(
+ screen.queryByRole('button', { name: 'Supprimer la dernière ligne' }),
+ ).not.toBeInTheDocument()
+ })
+
+ it('should disable add button when addRow is not provided', () => {
+ render()
+
+ const addButton = screen.getByRole('button', { name: 'Ajouter une ligne' })
+ expect(addButton).toBeDisabled()
+ })
+
+ it('should disable remove button when removeRow is not provided', () => {
+ render()
+
+ const removeButton = screen.getByRole('button', {
+ name: 'Supprimer la dernière ligne',
+ })
+ expect(removeButton).toBeDisabled()
+ })
+
+ it('should render errors when provided', () => {
+ const errors = [
+ {
+ id: 'error1',
+ errorMessage: 'First error',
+ criticality: 'ERROR' as const,
+ },
+ {
+ id: 'error2',
+ errorMessage: 'Second error',
+ criticality: 'INFO' as const,
+ },
+ ]
+
+ render()
+
+ expect(screen.getByRole('alert')).toBeInTheDocument()
+ expect(screen.getByText('First error')).toBeInTheDocument()
+ expect(screen.getByText('Second error')).toBeInTheDocument()
+ })
+
+ it('should not render error container when no errors', () => {
+ render()
+
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ })
+
+ it('should call addRow and focus when add button is clicked', async () => {
+ render()
+
+ const addButton = screen.getByRole('button', { name: 'Ajouter une ligne' })
+ fireEvent.click(addButton)
+
+ expect(defaultProps.addRow).toHaveBeenCalled()
+
+ await waitFor(() => {
+ expect(mockFocusLastInput).toHaveBeenCalled()
+ })
+ })
+
+ it('should call removeRow and focus when remove button is clicked', async () => {
+ render()
+
+ const removeButton = screen.getByRole('button', {
+ name: 'Supprimer la dernière ligne',
+ })
+ fireEvent.click(removeButton)
+
+ expect(defaultProps.removeRow).toHaveBeenCalled()
+
+ await waitFor(() => {
+ expect(mockFocusLastInput).toHaveBeenCalled()
+ })
+ })
+
+ it('should call focusLastInput with correct container', async () => {
+ render()
+
+ const addButton = screen.getByRole('button', { name: 'Ajouter une ligne' })
+ fireEvent.click(addButton)
+
+ await waitFor(() => {
+ expect(mockFocusLastInput).toHaveBeenCalledWith(
+ expect.any(HTMLDivElement),
+ )
+ })
+ })
+})
diff --git a/src/components/orchestrator/slotComponents/Loop.tsx b/src/components/orchestrator/slotComponents/Loop.tsx
index 886f24db..8fe21494 100644
--- a/src/components/orchestrator/slotComponents/Loop.tsx
+++ b/src/components/orchestrator/slotComponents/Loop.tsx
@@ -1,8 +1,12 @@
+import { useRef } from 'react'
+
import { fr } from '@codegouvfr/react-dsfr'
import Alert from '@codegouvfr/react-dsfr/Alert'
import { ButtonsGroup } from '@codegouvfr/react-dsfr/ButtonsGroup'
import type { LunaticSlotComponents } from '@inseefr/lunatic'
+import { focusLastInput } from '../utils/focusLastRowInput'
+
export const Loop: LunaticSlotComponents['Loop'] = (props) => {
const {
declarations,
@@ -15,6 +19,26 @@ export const Loop: LunaticSlotComponents['Loop'] = (props) => {
addRow,
removeRow,
} = props
+ const childrenRef = useRef(null)
+
+ const handleAddRow = () => {
+ addRow?.()
+ setTimeout(() => {
+ if (childrenRef.current) {
+ // Needed to bypass the focuskey being overwritten by react-dsfr
+ focusLastInput(childrenRef.current)
+ }
+ }, 0)
+ }
+
+ const handleRemoveRow = () => {
+ removeRow?.()
+ setTimeout(() => {
+ if (childrenRef.current) {
+ focusLastInput(childrenRef.current)
+ }
+ }, 0)
+ }
if (declarations) {
//TODO throw and handle globaly errors in an alert with a condition to avoid to display alert in prod
@@ -35,7 +59,7 @@ export const Loop: LunaticSlotComponents['Loop'] = (props) => {
if (!error.errorMessage) {
//TODO throw error
console.error(`The error : ${error.id} do not contains message`)
- return
+ return null
}
return (
{
})}
)}
- {children}
+
+ {children}
+
{canControlRows && (
{
{
priority: 'secondary',
children: 'Ajouter une ligne',
- onClick: addRow,
+ onClick: handleAddRow,
disabled: !addRow,
},
{
priority: 'tertiary',
children: 'Supprimer la dernière ligne',
- onClick: removeRow,
+ onClick: handleRemoveRow,
disabled: !removeRow,
},
]}
diff --git a/src/components/orchestrator/utils/focusLastRowInput.test.ts b/src/components/orchestrator/utils/focusLastRowInput.test.ts
new file mode 100644
index 00000000..4a855e5f
--- /dev/null
+++ b/src/components/orchestrator/utils/focusLastRowInput.test.ts
@@ -0,0 +1,127 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { focusLastInput } from './focusLastRowInput'
+
+describe('focusLastInput', () => {
+ let container: HTMLDivElement
+
+ beforeEach(() => {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ document.body.removeChild(container)
+ })
+
+ describe('table structure', () => {
+ it('should focus on first input of last table row', () => {
+ container.innerHTML = `
+
+ `
+
+ const input3 = container.querySelector('#input3') as HTMLInputElement
+ const focusSpy = vi.spyOn(input3, 'focus')
+
+ focusLastInput(container)
+
+ expect(focusSpy).toHaveBeenCalled()
+ })
+
+ it('should handle table without tbody', () => {
+ container.innerHTML = `
+
+ `
+
+ const input2 = container.querySelector('#input2') as HTMLInputElement
+ const focusSpy = vi.spyOn(input2, 'focus')
+
+ focusLastInput(container)
+
+ expect(focusSpy).toHaveBeenCalled()
+ })
+
+ it('should handle empty table', () => {
+ container.innerHTML = ``
+ const containerFocusSpy = vi.spyOn(container, 'focus')
+
+ focusLastInput(container)
+
+ expect(containerFocusSpy).toHaveBeenCalled()
+ })
+
+ it('should handle table row without inputs', () => {
+ container.innerHTML = `
+
+ `
+ const containerFocusSpy = vi.spyOn(container, 'focus')
+
+ focusLastInput(container)
+
+ expect(containerFocusSpy).toHaveBeenCalled()
+ })
+ })
+ it('should focus on last input when no table present', () => {
+ container.innerHTML = `
+
+
+
+
+
+ `
+
+ const input3 = container.querySelector('#input3') as HTMLInputElement
+ const focusSpy = vi.spyOn(input3, 'focus')
+
+ focusLastInput(container)
+
+ expect(focusSpy).toHaveBeenCalled()
+ })
+
+ it('should focus on single input', () => {
+ container.innerHTML = ``
+
+ const input1 = container.querySelector('#input1') as HTMLInputElement
+ const focusSpy = vi.spyOn(input1, 'focus')
+
+ focusLastInput(container)
+
+ expect(focusSpy).toHaveBeenCalled()
+ })
+
+ it('should focus on last div when no inputs available', () => {
+ container.innerHTML = `
+ First div
+ Last div
+ `
+
+ const lastDiv = container.querySelector('#lastDiv') as HTMLElement
+ const focusSpy = vi.spyOn(lastDiv, 'focus')
+
+ focusLastInput(container)
+
+ expect(lastDiv.getAttribute('tabindex')).toBe('-1')
+ expect(focusSpy).toHaveBeenCalled()
+ })
+})
diff --git a/src/components/orchestrator/utils/focusLastRowInput.ts b/src/components/orchestrator/utils/focusLastRowInput.ts
new file mode 100644
index 00000000..1c491520
--- /dev/null
+++ b/src/components/orchestrator/utils/focusLastRowInput.ts
@@ -0,0 +1,40 @@
+/** Focus to the last input element within a container */
+export function focusLastInput(container: HTMLElement) {
+ // It may be interesting to separate each case into different functions or to separate Loop and rosterForLoop components
+ const table = container.querySelector('table')
+
+ if (table) {
+ const allRows = table.querySelectorAll(
+ 'tbody tr, tr',
+ ) as NodeListOf
+ if (allRows.length > 0) {
+ const lastRow = allRows[allRows.length - 1]
+ const firstInputInLastRow = lastRow.querySelector(
+ 'input',
+ ) as HTMLInputElement
+ if (firstInputInLastRow) {
+ firstInputInLastRow.focus()
+ return
+ }
+ }
+ }
+
+ const allInputs = container.querySelectorAll(
+ 'input',
+ ) as NodeListOf
+ if (allInputs.length > 0) {
+ allInputs[allInputs.length - 1].focus()
+ return
+ }
+
+ const lastDiv = container.querySelector('div:last-child') as HTMLElement
+ if (lastDiv) {
+ if (!lastDiv.hasAttribute('tabindex')) {
+ lastDiv.setAttribute('tabindex', '-1')
+ }
+ lastDiv.focus()
+ return
+ }
+
+ container.focus()
+}
diff --git a/src/models/api/leafStateState.ts b/src/models/api/leafStateState.ts
index 9b11cecd..9c4e9558 100644
--- a/src/models/api/leafStateState.ts
+++ b/src/models/api/leafStateState.ts
@@ -17,5 +17,5 @@ export type LeafStateState =
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const LeafStateState = {
COMPLETED: 'COMPLETED',
- INIT: 'INIT'
+ INIT: 'INIT',
} as const
diff --git a/src/models/stateData.ts b/src/models/stateData.ts
index c02499a8..99ca9add 100644
--- a/src/models/stateData.ts
+++ b/src/models/stateData.ts
@@ -10,9 +10,9 @@ export type StateData = {
date: number
currentPage: PageType
leafStates?: {
- state: LeafStateState
- date: number
- }[],
+ state: LeafStateState
+ date: number
+ }[]
multimode?: {
state: null | 'IS_MOVED' | 'IS_SPLIT'