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 = ` + + +
No input here
+ ` + 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'