From 02c7f7f2357a0b7556220861744ced02f89e295a Mon Sep 17 00:00:00 2001
From: Mailine Nguyen <64129348+MailineN@users.noreply.github.com>
Date: Thu, 6 Nov 2025 16:49:20 +0100
Subject: [PATCH 1/3] fix: focus on the last row when user add/remove a row
---
server/src/index.ts | 4 +-
.../orchestrator/hooks/interrogation/utils.ts | 1 -
.../orchestrator/slotComponents/Loop.test.tsx | 150 ++++++++++++++++++
.../orchestrator/slotComponents/Loop.tsx | 34 +++-
.../utils/focusLastRowInput.test.ts | 127 +++++++++++++++
.../orchestrator/utils/focusLastRowInput.ts | 40 +++++
src/main.tsx | 6 +-
src/models/api/leafStateState.ts | 2 +-
src/models/stateData.ts | 6 +-
9 files changed, 356 insertions(+), 14 deletions(-)
create mode 100644 src/components/orchestrator/slotComponents/Loop.test.tsx
create mode 100644 src/components/orchestrator/utils/focusLastRowInput.test.ts
create mode 100644 src/components/orchestrator/utils/focusLastRowInput.ts
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/main.tsx b/src/main.tsx
index 614a1d64..787dc05d 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -5,7 +5,7 @@ import ReactDOM from 'react-dom/client'
import { App } from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
-
-
- ,
+ //
+ ,
+ // ,
)
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'
From e3fcc76f763a63fc8e31061f9eb9fcb7f02060ca Mon Sep 17 00:00:00 2001
From: Mailine Nguyen <64129348+MailineN@users.noreply.github.com>
Date: Thu, 6 Nov 2025 16:57:34 +0100
Subject: [PATCH 2/3] fix: build
---
src/main.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/main.tsx b/src/main.tsx
index 787dc05d..d432a691 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -5,7 +5,7 @@ import ReactDOM from 'react-dom/client'
import { App } from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
- //
- ,
- // ,
+
+ ,
+ ,
)
From 86119dfea6d081f8caf248db1cadaa57147f6118 Mon Sep 17 00:00:00 2001
From: Mailine Nguyen <64129348+MailineN@users.noreply.github.com>
Date: Thu, 6 Nov 2025 17:02:14 +0100
Subject: [PATCH 3/3] Fix rendering of App component in main.tsx
---
src/main.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/main.tsx b/src/main.tsx
index d432a691..614a1d64 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -6,6 +6,6 @@ import { App } from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
- ,
+
,
)