diff --git a/.env b/.env index 8ebfa398..138083be 100644 --- a/.env +++ b/.env @@ -5,6 +5,9 @@ VITE_EXTERNAL_RESOURCES_URL= VITE_OIDC_ISSUER= VITE_OIDC_CLIENT_ID= +# When VITE_RESET_MOVED_ENABLED equals true we enable reset interrogations data after moving (default: not enabled: disabled) +VITE_RESET_MOVED_ENABLED= + # When VITE_TELEMETRY_ENABLED equals true we enable telemetry (default: not enabled: disabled) VITE_TELEMETRY_ENABLED= # Override max delay in ms to wait for before sending a batch (default: 1min) diff --git a/README.md b/README.md index 2b4b88bc..5c7de809 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,52 @@ pnpm install pnpm run dev pnpm run build ``` + +## Architecture explained + +Drama Queen use the clean architecture to create a new feature add a new use case. + +This is the minimal structure to make a useCase work. Feel free to split into multiple file if your use case becomes too big. + +```ts +import { createSelector, createUsecaseActions } from 'redux-clean-architecture' +import { id } from 'tsafe/id' + +import type { State as RootState, Thunks } from '@/core/bootstrap' + +const state = (state: RootState) => state[name] +export const name = 'usecase-name' + +export type State = { + count: number +} + +export const { reducer, actions } = createUsecaseActions({ + name, + initialState: id({ + counter: 0, + }), + reducers: { + increment: (state, { payload }: { payload: number }) => { + state.count = state.count + payload + }, + reset: (state) => { + state.count = 0 + }, + }, +}) + +export const thunks = { + setTo: + (params: { n: number }) => + async (...args) => { + const [dispatch, getState, context] = args + dispatch(actions.reset()) + dispatch(actions.increment(n)) + }, +} satisfies Thunks + +export const selectors = { + count: createSelector(state, (state: State) => state.count), +} +``` diff --git a/package.json b/package.json index d6fdf773..b69c8517 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite --port 5001 --strictPort", "test": "vitest run", + "check": "tsc", "test:watch": "vitest", "test:coverage": "vitest --coverage", "build": "tsc && vite build", diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 00000000..a14702c4 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/server/README.md b/server/README.md new file mode 100644 index 00000000..29381d00 --- /dev/null +++ b/server/README.md @@ -0,0 +1,116 @@ +# Mock server + +This package is used during development to provide a fake server for the Queen API + +```bash +bun run dev +``` + +Then, in the Drama queen .env set + +```env +VITE_QUEEN_API_URL=http://localhost:5000 +``` + +Then you can request a synchronization (http://localhost:5001/queen/synchronize) to get fake data from this API + +# How sync works ? + +When calling `/queen/synchronize` the runs synchronization workflow runs + +- It Uploads local data first +- If upload succeeds, fetch the data remotely and refresh the local data +- When finished (or on error), redirects to `/queen` + +This behavior is implemented by the `synchronizeData` use case (`thunks.ts` and `evt.ts`) and the page `SynchronizeData.tsx`. + +## The SynchronizeData component + +- On mount: calls `synchronizeData.upload()`. +- Subscribes to events; on `uploadError`, `downloadCompleted`, or `downloadFailed`, it posts a `redirect` action and sets `window.location = window.location.origin`. +- Displays progress bars during upload and download. + +### Upload step (local ➜ server) + +Code: `thunks.upload()` in `src/core/usecases/synchronizeData/thunks.ts`. + +- Initializes local sync status in `localSyncStorage` (clears error, success/temp lists). +- Interrogations: + - Reads all locally stored interrogations: `dataStore.getAllInterrogations()`. + - For each interrogation: + - Tries `queenApi.putInterrogation(interrogation)`. + - If the server replies `423` (locked), it is treated as success. + - If the server replies with one of `[400, 403, 404, 500]`, it falls back to `queenApi.postInterrogationInTemp(interrogation)`, then: + - Records the ID in `localSyncStorage.addIdToInterrogationsInTempZone(id)`. + - Deletes local paradata for that interrogation: `dataStore.deleteParadata(id)` and marks that paradata ID as deleted to avoid uploading it later in the same run. + - On success, deletes the local interrogation: `dataStore.deleteInterrogation(id)` and updates progress. +- Paradata (only if telemetry is enabled via `IS_TELEMETRY_ENABLED`): + - Reads all paradata: `dataStore.getAllParadata()`. + - Filters out paradata whose interrogation was just moved to temp (so we don’t upload it): `!deletedParadataIds.has(paradata.idInterrogation)`. + - For each remaining paradata: + - `queenApi.postParadata(paradata)` then `dataStore.deleteParadata(paradata.idInterrogation)` and update progress. +- On success: dispatches `uploadCompleted` and immediately triggers the download step with `dispatch(thunks.download())`. +- On error: flags `localSyncStorage.addError(true)` and dispatches `uploadError` (the page will redirect). + +APIs used during upload: + +- `queenApi.putInterrogation(interrogation)` +- `queenApi.postInterrogationInTemp(interrogation)` +- `queenApi.postParadata(paradata)` + +### Download step (server ➜ local/cache) + +Code: `thunks.download()` in `src/core/usecases/synchronizeData/thunks.ts`. + +- Campaigns & questionnaires: + - `queenApi.getCampaigns()` → list of campaigns; collect all distinct `questionnaireIds` across them. + - For each `questionnaireId`: `queenApi.getQuestionnaire(id)`. + - On each successful questionnaire fetch, updates progress and stores in memory for later steps. +- Interrogations: + - For each campaign ID: + - `queenApi.getInterrogationsIdsAndQuestionnaireIdsByCampaign(campaignId)` returns pairs `{ id, questionnaireId }`. + - For each `id`: `queenApi.getInterrogation(id)` then `dataStore.updateInterrogation(interrogation)` to refresh the local store. + - If the interrogation’s `questionnaireId` is among the successfully fetched questionnaires, record its ID in `localSyncStorage.addIdToInterrogationsSuccess(id)` (used by the UI/redirect info). + - Certain HTTP errors `[400, 403, 404, 500]` are logged and ignored so the sync can continue; other errors are rethrown to fail the sync. +- Nomenclatures: + - From all fetched questionnaires, gather distinct suggester names (nomenclature IDs). + - For each nomenclature ID: call `queenApi.getNomenclature(id)`. + - The app does not store the data itself; it triggers fetches so the Service Worker can cache responses. +- External resources (optional): + - Only if `EXTERNAL_RESOURCES_URL` is configured. + - `getExternalQuestionnaires()` retrieves the list of external questionnaires (via the external resources API, separate from Queen). + - `getExternalQuestionnaireFiltered()` splits them into `neededQuestionnaires` (those referenced by successfully fetched questionnaires) and `notNeededQuestionnaires`. + - For each needed questionnaire: `getResourcesFromExternalQuestionnaire({ questionnaire, callBackTotal, callBackReset, callBackUnit })` which fetches and puts files into the browser Cache Storage, updating progress callbacks along the way. + - Deletes caches for `notNeededQuestionnaires` and any “old” external caches not in the list (`getOldExternalCacheNames` + `caches.delete`). If none are needed, it also deletes the root cache `cache-root-external`. +- Completion: + - After all of the above finish, dispatches `downloadCompleted`. + - On any unexpected error: sets `localSyncStorage.addError(true)` and dispatches `downloadFailed`. + +APIs used during download: + +- `queenApi.getCampaigns()` +- `queenApi.getQuestionnaire(questionnaireId)` +- `queenApi.getInterrogationsIdsAndQuestionnaireIdsByCampaign(campaignId)` +- `queenApi.getInterrogation(id)` +- `queenApi.getNomenclature(id)` +- External resources helpers: `getExternalQuestionnaires`, `getExternalQuestionnaireFiltered`, `getResourcesFromExternalQuestionnaire`, `getOldExternalCacheNames` (use fetch/cache under the hood) + +### What data is fetched from the API? + +- From Queen API: + - List of campaigns + - Questionnaires by ID + - Interrogation IDs (per campaign) and each interrogation’s full data + - Nomenclatures referenced by questionnaires +- From the external resources API (if configured): + - List of external questionnaires + - Each questionnaire’s resource files to cache + +### What is done with the fetched data? + +- Interrogations: stored/updated locally via `dataStore.updateInterrogation` (download) and deleted locally once successfully uploaded (upload). Temp-zone handling is used as a fallback for certain server errors. +- Questionnaires: fetched to ensure local consistency and to derive nomenclature and external-resource needs; not stored directly here besides in-memory usage and SW caching. +- Nomenclatures: fetched to let the Service Worker cache them; progress updated. +- External resources: fetched and placed into Cache Storage; unneeded caches are cleaned up. +- Progress state is kept in Redux-like state (`synchronizeData/state.ts`) to drive the UI’s progress bars. +- `localSyncStorage` tracks success/error and lists of interrogation IDs handled during the run. diff --git a/server/bun.lock b/server/bun.lock new file mode 100644 index 00000000..644fb5ad --- /dev/null +++ b/server/bun.lock @@ -0,0 +1,30 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "server", + "dependencies": { + "hono": "^4.10.8", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], + + "@types/node": ["@types/node@24.10.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA=="], + + "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], + + "hono": ["hono@4.10.8", "", {}, "sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + } +} diff --git a/server/mocks/roundabout.json b/server/mocks/roundabout.json new file mode 100644 index 00000000..2602b728 --- /dev/null +++ b/server/mocks/roundabout.json @@ -0,0 +1,348 @@ +{ + "$schema": "../../../../lunatic-schema.json", + "maxPage": "4", + "articulation": { + "source": "roundabout", + "items": [ + { + "label": "Prénom", + "value": "PRENOMS" + }, + { + "label": "Sexe", + "value": "if SEXE = \"H\" then \"Homme\" else \"Femme\"" + }, + { + "label": "Age", + "value": "cast(AGE, string) || \" ans\"" + } + ] + }, + "components": [ + { + "id": "how", + "componentType": "InputNumber", + "mandatory": false, + "page": "1", + "min": 1, + "max": 10, + "decimals": 0, + "label": { + "value": "\"Combien de personnes vivent habituellement à votre adresse ?\"", + "type": "VTL|MD" + }, + "conditionFilter": { "value": "true", "type": "VTL" }, + "response": { "name": "NB_HAB" } + }, + { + "id": "loop", + "componentType": "Loop", + "page": "2", + "depth": 1, + "paginatedLoop": false, + "conditionFilter": { "value": "true", "type": "VTL" }, + "loopDependencies": ["NHAB"], + "lines": { + "min": { "value": "NB_HAB", "type": "VTL" }, + "max": { "value": "NB_HAB", "type": "VTL" } + }, + "components": [ + { + "id": "prenom", + "componentType": "Input", + "mandatory": false, + "maxLength": 20, + "label": { + "value": "\"Prénom\"))", + "type": "VTL|MD" + }, + "conditionFilter": { + "value": "true", + "type": "VTL" + }, + "response": { "name": "PRENOMS" } + }, + { + "id": "sexe", + "componentType": "CheckboxOne", + "mandatory": false, + "maxLength": 20, + "label": { + "value": "\"Sexe\"", + "type": "VTL|MD" + }, + "conditionFilter": { + "value": "true", + "type": "VTL" + }, + "options": [ + { + "value": "H", + "label": { "value": "\"Homme\"", "type": "VTL|MD" } + }, + + { + "value": "F", + "label": { "value": "\"Femme\"", "type": "VTL|MD" } + } + ], + "response": { "name": "SEXE" } + }, + { + "id": "age", + "componentType": "InputNumber", + "maxLength": 3, + "label": { + "value": "\"Age\"", + "type": "VTL|MD" + }, + "conditionFilter": { + "value": "true", + "type": "VTL" + }, + "response": { "name": "AGE" } + } + ] + }, + { + "id": "roundabout", + "componentType": "Roundabout", + "page": "3", + "conditionFilter": { "value": "true", "type": "VTL" }, + "iterations": { "value": "NB_HAB", "type": "VTL" }, + "label": { "value": "\"Libellé du rondpoint\"", "type": "VTL" }, + "locked": true, + "progressVariable": "PROGRESS", + "item": { + "label": { + "value": "\"Questions de \" || PRENOMS", + "type": "VTL" + }, + "description": { + "value": "if AGE > 18 then \"Aller aux question destinées à \" || PRENOMS else PRENOMS || \" n'est pas majeur, il/elle n'a pas à répondre aux questions\"", + "type": "VTL" + }, + "disabled": { + "value": "AGE < 18", + "type": "VTL" + } + }, + "controls": [], + "components": [ + { + "id": "radio", + "componentType": "Radio", + "mandatory": false, + "page": "3.1", + "label": { + "value": "\"Connaissez-vous le recensement de la population ?\"", + "type": "VTL|MD" + }, + + "conditionFilter": { "value": "true", "type": "VTL" }, + + "options": [ + { "value": "1", "label": { "value": "\"oui\"", "type": "VTL|MD" } }, + + { "value": "2", "label": { "value": "\"non\"", "type": "VTL|MD" } } + ], + "response": { "name": "KNOWREC" } + }, + { + "id": "jsygk7m7", + "componentType": "Subsequence", + "page": "3.2", + "label": { + "value": "\"Deuxième page de questions pour \"|| PRENOMS", + "type": "VTL|MD" + }, + "conditionFilter": { "value": "true", "type": "VTL" } + }, + { + "id": "sexe", + "componentType": "Radio", + "page": "3.2", + "label": { + "value": "\"Sexe\"", + "type": "VTL" + }, + "conditionFilter": { + "value": "true", + "type": "VTL" + }, + "options": [ + { + "value": "1", + "label": { "value": "\"Homme\"", "type": "VTL|MD" } + }, + { + "value": "2", + "label": { "value": "\"Femme\"", "type": "VTL|MD" } + } + ], + "response": { "name": "SEXE" } + }, + { + "id": "jsygk7m7", + "componentType": "Subsequence", + "page": "3.3", + "label": { + "value": "\"Troisième page de questions \" || PRENOMS", + "type": "VTL|MD" + }, + "conditionFilter": { "value": "true", "type": "VTL" } + }, + { + "id": "kmno1n7m", + "componentType": "Input", + "maxLength": 30, + "page": "3.3", + "label": { + "value": "\"Dites quelque chose.\"))", + "type": "VTL|MD" + }, + "conditionFilter": { + "value": "true", + "type": "VTL" + }, + "response": { "name": "SOMETHING" } + } + ] + }, + { + "id": "seq", + "componentType": "Sequence", + "label": { + "value": "\"Merci !\"", + "type": "VTL|MD" + }, + "conditionFilter": { "value": "true", "type": "VTL" }, + "page": "4" + } + ], + "variables": [ + { + "variableType": "COLLECTED", + "name": "NB_HAB", + "values": { + "PREVIOUS": null, + "COLLECTED": 2, + "FORCED": null, + "EDITED": null, + "INPUTTED": null + } + }, + { + "variableType": "COLLECTED", + "name": "SOMETHING", + "values": { + "PREVIOUS": [], + "COLLECTED": [], + "FORCED": [], + "EDITED": [], + "INPUTTED": [] + } + }, + { + "variableType": "COLLECTED", + "name": "SEXE", + "values": { + "PREVIOUS": null, + "COLLECTED": ["H", "F"], + "FORCED": null, + "EDITED": null, + "INPUTTED": null + } + }, + { + "variableType": "COLLECTED", + "name": "AGE", + "values": { + "PREVIOUS": null, + "COLLECTED": [24, 24], + "FORCED": null, + "EDITED": null, + "INPUTTED": null + } + }, + { + "variableType": "COLLECTED", + "name": "SEXE", + "values": { + "PREVIOUS": [], + "COLLECTED": [], + "FORCED": [], + "EDITED": [], + "INPUTTED": [] + } + }, + { + "variableType": "COLLECTED", + "name": "PRENOMS", + "values": { + "PREVIOUS": null, + "COLLECTED": ["Fanny", "Ines"], + "FORCED": null, + "EDITED": null, + "INPUTTED": null + } + }, + { + "variableType": "COLLECTED", + "name": "KNOWREC", + "values": { + "PREVIOUS": [], + "COLLECTED": [], + "FORCED": [], + "EDITED": [], + "INPUTTED": [] + } + }, + { + "variableType": "COLLECTED", + "name": "PROGRESS", + "values": { + "PREVIOUS": [], + "COLLECTED": [0, -1], + "FORCED": [], + "EDITED": [], + "INPUTTED": [] + } + }, + { + "variableType": "CALCULATED", + "name": "PRENOMREF", + "expression": { "value": "first_value(PRENOMS over())", "type": "VTL" }, + "bindingDependencies": ["PRENOMS"], + "inFilter": "true" + }, + { + "variableType": "CALCULATED", + "name": "COMPLETE", + "expression": { + "value": "not(isnull(KNOWREC)) and not(isnull(SEXE)) and not(isnull(SOMETHING))", + "type": "VTL" + }, + "bindingDependencies": ["KNOWREC", "SEXE", "SOMETHING"], + "shapeFrom": "PRENOMS", + "inFilter": "true" + }, + { + "variableType": "CALCULATED", + "name": "PARTIAL", + "expression": { + "value": "not(isnull(KNOWREC)) or not(isnull(SEXE)) or not(isnull(SOMETHING))", + "type": "VTL" + }, + "bindingDependencies": ["KNOWREC", "SEXE", "SOMETHING"], + "shapeFrom": "PRENOMS", + "inFilter": "true" + } + ], + "resizing": { + "NB_HAB": { + "size": "NB_HAB", + "variables": ["PRENOMS", "AGE", "SEXE", "SOMETHING", "DATNAIS"] + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 00000000..75c600f5 --- /dev/null +++ b/server/package.json @@ -0,0 +1,18 @@ +{ + "name": "server", + "module": "index.ts", + "type": "module", + "private": true, + "scripts": { + "dev": "bun run --hot src/index.ts" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "hono": "^4.10.8" + } +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 00000000..90fad529 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,119 @@ +import { Hono } from 'hono' +import { serveStatic } from 'hono/bun' +import { cors } from 'hono/cors' + +import Roundabout from '../mocks/roundabout.json' + +const app = new Hono() + +const waitTime = 1000 // Wait duration to fake a slow API +const questionnaireId = 'q1' + +const defaultStateData = { + state: 'INIT', + date: 0, + currentPage: '1', +} + +// Fake the database with an in memory map +const interrogations = new Map( + Array.from({ length: 10 }).map((_, k) => { + const id = `su${k}` + return [ + id, + { + id: id, + questionnaireId: questionnaireId, + personalization: [], + data: { + EXTERNAL: { + NB_HAB: 2, + }, + COLLECTED: { + PRENOMS: { COLLECTED: ['John', 'Jane'] }, + }, + }, + comment: {}, + stateData: defaultStateData, + }, + ] + }), +) + +app.use('/*', cors()) + +app.use(async (_, next) => { + await new Promise((resolve) => setTimeout(resolve, waitTime)) + await next() +}) + +app.get('/api/healthcheck', (c) => { + return c.json({}) +}) + +/** + * Interrogations + */ +app.get('/api/interrogations/state-data', (c) => { + return c.json([{ id: 'su2' }, { id: 'su3' }]) +}) + +app.post('/api/interrogations/:id/synchronize', async (c) => { + const interrogationId = c.req.param('id') + const interrogation = interrogations.get(interrogationId) + if (!interrogation) { + throw new Error(`Cannot find interrogation ${interrogationId}`) + } + return c.json({ + ...interrogation, + stateData: defaultStateData, + }) +}) + +app.get('/api/interrogations/:id', (c) => { + return c.json(interrogations.get(c.req.param('id'))) +}) + +app.put('/api/interrogations/:id', async (c) => { + const data = await c.req.json() + interrogations.set(c.req.param('id'), data) + return c.json({}) +}) + +/** + * Campaigns + */ +app.get('/api/campaigns', (c) => { + return c.json([ + { + id: 'c1', + label: 'Campaign 1', + sensitivity: 'NORMAL', + questionnaireIds: [questionnaireId], + metadata: 'string', + }, + ]) +}) + +app.get('/api/campaign/:id/interrogations', (c) => { + return c.json( + Array.from(interrogations.values()).map((interrogation) => ({ + id: interrogation.id, + questionnaireId: interrogation.questionnaireId, + })), + ) +}) + +app.get('/api/questionnaire/:id', (c) => { + return c.json({ value: Roundabout }) +}) + +// Serve static files for all non-API routes +app.use('/*', serveStatic({ root: '../dist' })) + +const port = 5000 + +export default { + port, + fetch: app.fetch, +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 00000000..238655f2 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/sonar-project.properties b/sonar-project.properties index fb121db6..c6b52242 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -7,7 +7,7 @@ sonar.projectVersion=1.0 # Path to sources sonar.sources=src -sonar.exclusions=**/*.test.js, **/*.test.jsx, **/*.test.ts, **/*.test.tsx +sonar.exclusions=**/*.test.js, **/*.test.jsx, **/*.test.ts, **/*.test.tsx, server/**/* # Path to tests sonar.test.inclusions=src/**/*.test.js, src/**/*.test.jsx,src/**/*.test.ts, src/**/*.test.tsx @@ -17,4 +17,4 @@ sonar.javascript.lcov.reportPaths=coverage/lcov.info sonar.coverage.exclusions=src/**/*.test.js, src/**/*.test.jsx, src/**/*.test.ts, src/**/*.test.tsx, src/ui/components/orchestrator/lunaticStyle.tsx # Source encoding -sonar.sourceEncoding=UTF-8 \ No newline at end of file +sonar.sourceEncoding=UTF-8 diff --git a/src/core/adapters/queenApi/default.ts b/src/core/adapters/queenApi/default.ts index b8313393..2877f2db 100644 --- a/src/core/adapters/queenApi/default.ts +++ b/src/core/adapters/queenApi/default.ts @@ -14,6 +14,7 @@ import { handleAxiosError } from '@/core/tools/axiosError' import { campaignSchema, idAndQuestionnaireIdSchema, + idsSchema, interrogationSchema, nomenclatureSchema, requiredNomenclaturesSchema, @@ -94,6 +95,16 @@ export function createApiClient(params: { .post(`api/interrogations/${interrogation.id}/temp-zone`, interrogation) .then(() => undefined), + syncInterrogation: (idInterrogation) => + axiosInstance + .post(`api/interrogations/${idInterrogation}/synchronize`) + .then(({ data }) => interrogationSchema.parse(data)), + + fetchMoved: () => + axiosInstance + .get(`api/interrogations/state-data?stateData=IS_MOVED`) + .then(({ data }) => idsSchema.parse(data)), + getCampaigns: () => axiosInstance .get(`api/campaigns`) diff --git a/src/core/adapters/queenApi/mock.ts b/src/core/adapters/queenApi/mock.ts index f369cdba..a48d9de6 100644 --- a/src/core/adapters/queenApi/mock.ts +++ b/src/core/adapters/queenApi/mock.ts @@ -15,6 +15,11 @@ export function createApiClient(): QueenApi { idInterrogation: 'interro2', }), ]), + fetchMoved: () => Promise.resolve([{ id: 'interro2' }]), + syncInterrogation: (idInterrogation) => + Promise.resolve( + createInterrogationMocked({ idInterrogation: idInterrogation }), + ), getInterrogation: (idInterrogation) => Promise.resolve( createInterrogationMocked({ idInterrogation: idInterrogation }), diff --git a/src/core/adapters/queenApi/parserSchema/idsSchema.test.ts b/src/core/adapters/queenApi/parserSchema/idsSchema.test.ts new file mode 100644 index 00000000..dfcea78f --- /dev/null +++ b/src/core/adapters/queenApi/parserSchema/idsSchema.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' + +import { idsSchema } from './idsSchema' + +describe('idsSchema', () => { + it('should validate a valid array of id objects', () => { + const validData = [{ id: 'a1' }, { id: 'b2' }] + + const result = idsSchema.safeParse(validData) + expect(result.success).toBe(true) + expect(result.success && result.data).toEqual(validData) + }) + + it('should validate an empty array', () => { + const result = idsSchema.safeParse([]) + expect(result.success).toBe(true) + if (result.success) expect(result.data).toEqual([]) + }) + + it('should fail when an item is missing the "id" field', () => { + const invalidData = [{ id: 'a1' }, {} as any] + + const result = idsSchema.safeParse(invalidData) + expect(result.success).toBe(false) + }) + + it('should fail when an item has a non-string "id"', () => { + const invalidData = [{ id: 'a1' }, { id: 123 as any }] + + const result = idsSchema.safeParse(invalidData) + expect(result.success).toBe(false) + }) + + it('should fail when the value is not an array', () => { + const notAnArray: any = { id: 'a1' } + + const result = idsSchema.safeParse(notAnArray) + expect(result.success).toBe(false) + }) +}) diff --git a/src/core/adapters/queenApi/parserSchema/idsSchema.ts b/src/core/adapters/queenApi/parserSchema/idsSchema.ts new file mode 100644 index 00000000..bd308328 --- /dev/null +++ b/src/core/adapters/queenApi/parserSchema/idsSchema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' + +export const idsSchema = z.array( + z.object({ + id: z.string(), + }), +) diff --git a/src/core/adapters/queenApi/parserSchema/index.ts b/src/core/adapters/queenApi/parserSchema/index.ts index 32579359..4070e6f5 100644 --- a/src/core/adapters/queenApi/parserSchema/index.ts +++ b/src/core/adapters/queenApi/parserSchema/index.ts @@ -2,3 +2,4 @@ export * from './campaignSchema' export * from './nomenclatureSchema' export * from './interrogationDataSchema' export * from './interrogationSchema' +export * from './idsSchema' diff --git a/src/core/ports/QueenApi.ts b/src/core/ports/QueenApi.ts index 4003c7ef..869c2300 100644 --- a/src/core/ports/QueenApi.ts +++ b/src/core/ports/QueenApi.ts @@ -21,6 +21,8 @@ export type QueenApi = { getInterrogations: () => Promise getInterrogation: (idInterrogation: string) => Promise putInterrogation: (interrogation: Interrogation) => Promise + syncInterrogation: (idInterrogation: string) => Promise + fetchMoved: () => Promise<{ id: string }[]> /** * Endpoint in development * @param diff --git a/src/core/usecases/index.ts b/src/core/usecases/index.ts index eeb4a0b9..7cc96015 100644 --- a/src/core/usecases/index.ts +++ b/src/core/usecases/index.ts @@ -1,4 +1,5 @@ import * as collectSurvey from './collectSurvey' +import * as takeControl from './interrogation/takeControl' import * as reviewSurvey from './reviewSurvey' import * as synchronizeData from './synchronizeData' import * as userAuthentication from './userAuthentication' @@ -10,4 +11,5 @@ export const usecases = { visualizeSurvey, collectSurvey, reviewSurvey, + takeControl, } diff --git a/src/core/usecases/interrogation/takeControl.ts b/src/core/usecases/interrogation/takeControl.ts new file mode 100644 index 00000000..a617a7b9 --- /dev/null +++ b/src/core/usecases/interrogation/takeControl.ts @@ -0,0 +1,68 @@ +import { createSelector, createUsecaseActions } from 'redux-clean-architecture' +import { id } from 'tsafe/id' + +import type { State as RootState, Thunks } from '@/core/bootstrap' +import { getTranslation } from '@/i18n' + +/** + * Cas de reprise en main d'une interrogation commencée par le web + * + * Le questionnaire a été commencé par l'utilisateur en ligne, mais l'enquêteur + * souhaite prendre la main pour réinterroger les personnes. + */ +const { t } = getTranslation('synchronizeMessage') +const state = (state: RootState) => state[name] +export const name = 'takeControl' + +export type State = { + // Current state of what's being synchronized + message: string + // Synchronization is completed + done: boolean + // Error if something went wrong + error: string | null +} + +export const { reducer, actions } = createUsecaseActions({ + name, + initialState: id({ + message: '', + error: null, + done: false, + }), + reducers: { + finished: (state) => { + state.done = true + }, + message: (state, { payload }: { payload: string }) => { + state.message = payload + }, + fail: (state, { payload }: { payload: Error | string }) => { + state.error = payload.toString() + }, + }, +}) + +export const thunks = { + start: + (params: { interrogationId: string }) => + async (...args) => { + const [dispatch, , { queenApi, dataStore }] = args + try { + dispatch(actions.message(t('takingControl'))) + const interrogation = await queenApi.syncInterrogation( + params.interrogationId, + ) + await dataStore.updateInterrogation(interrogation) + dispatch(actions.finished()) + } catch (e) { + dispatch(actions.fail(e as Error)) + } + }, +} satisfies Thunks + +export const selectors = { + state: createSelector(state, (state: State) => { + return state + }), +} diff --git a/src/core/usecases/synchronizeData/selectors.test.ts b/src/core/usecases/synchronizeData/selectors.test.ts index a608b6b0..61503b05 100644 --- a/src/core/usecases/synchronizeData/selectors.test.ts +++ b/src/core/usecases/synchronizeData/selectors.test.ts @@ -7,7 +7,7 @@ const mockStore = (state: any) => ({ }) describe('selectors', () => { - it('should return main selector for running download state', () => { + it('should return progress bars for running download state', () => { const runningDownloadState = { synchronizeData: { stateDescription: 'running', @@ -27,22 +27,22 @@ describe('selectors', () => { const store = mockStore(runningDownloadState) - const result = selectors.main(store.getState()) + const bars = selectors.progressBars(store.getState()) + const stepTitle = selectors.stepTitle(store.getState()) - expect(result).toEqual({ - isDownloading: true, - interrogationProgress: 25, - nomenclatureProgress: 25, - surveyProgress: 25, - externalResourcesProgress: 25, - externalResourcesProgressCount: { - externalResourcesCompleted: 50, - totalExternalResources: 200, - }, - }) + expect(bars).toHaveLength(4) + // questionnaires, nomenclatures, interrogations, external resources + expect(bars.map((b: any) => Math.round(b.progress))).toEqual([ + 25, 25, 25, 25, + ]) + // last bar should include a count string "completed / total" + expect(bars[3].count).toBe('50 / 200') + // step title for download should be a non-empty string + expect(typeof stepTitle).toBe('string') + expect(stepTitle.length).toBeGreaterThan(0) }) - it('should return main selector for running upload state', () => { + it('should return progress bars for running upload state', () => { const runningUploadState = { synchronizeData: { stateDescription: 'running', @@ -56,16 +56,16 @@ describe('selectors', () => { const store = mockStore(runningUploadState) - const result = selectors.main(store.getState()) + const bars = selectors.progressBars(store.getState()) + const stepTitle = selectors.stepTitle(store.getState()) - expect(result).toEqual({ - isUploading: true, - uploadInterrogationProgress: 25, - uploadParadataProgress: 25, - }) + expect(bars).toHaveLength(2) + expect(bars.map((b: any) => Math.round(b.progress))).toEqual([25, 25]) + expect(typeof stepTitle).toBe('string') + expect(stepTitle.length).toBeGreaterThan(0) }) - it('should return main selector for not running state', () => { + it('should return empty progress bars and empty title for not running state', () => { const notRunningState = { synchronizeData: { stateDescription: 'not running', @@ -74,8 +74,10 @@ describe('selectors', () => { const store = mockStore(notRunningState) - const result = selectors.main(store.getState()) + const bars = selectors.progressBars(store.getState()) + const stepTitle = selectors.stepTitle(store.getState()) - expect(result).toEqual({ hideProgress: true }) + expect(bars).toEqual([]) + expect(stepTitle).toBe('') }) }) diff --git a/src/core/usecases/synchronizeData/selectors.ts b/src/core/usecases/synchronizeData/selectors.ts index 4b46001e..99b7af4b 100644 --- a/src/core/usecases/synchronizeData/selectors.ts +++ b/src/core/usecases/synchronizeData/selectors.ts @@ -1,173 +1,103 @@ import { createSelector } from 'redux-clean-architecture' -import { assert } from 'tsafe/assert' import type { State as RootState } from '@/core/bootstrap' +import { getTranslation } from '@/i18n' -import { name } from './state' +import { type State, name } from './state' + +const { t } = getTranslation('synchronizeMessage') const state = (rootState: RootState) => rootState[name] -const downloadingState = createSelector(state, (state) => { - if (state.stateDescription !== 'running') { - return undefined - } +const computeProgress = (count: number, total: number) => { + if (count === 0 && total === 0) return 100 + return (count * 100) / total +} - if (state.type !== 'download') { - return undefined - } +type ProgressBar = { progress: number; label: string; count?: string } - return state -}) +const progressBars = createSelector(state, (state: State) => { + const bars = [] as ProgressBar[] -const interrogationProgress = createSelector(downloadingState, (state) => { - if (state === undefined) { - return undefined - } - if (state.interrogationCompleted === 0 && state.totalInterrogation === 0) - return 100 - return (state.interrogationCompleted * 100) / state.totalInterrogation -}) -const nomenclatureProgress = createSelector(downloadingState, (state) => { - if (state === undefined) { - return undefined - } - if (state.nomenclatureCompleted === 0 && state.totalNomenclature === 0) - return 100 - return (state.nomenclatureCompleted * 100) / state.totalNomenclature -}) -const surveyProgress = createSelector(downloadingState, (state) => { - if (state === undefined) { - return undefined + if (state.stateDescription !== 'running') { + return [] } - if (state.surveyCompleted === 0 && state.totalSurvey === 0) return 100 - return (state.surveyCompleted * 100) / state.totalSurvey -}) -const externalResourcesProgress = createSelector(downloadingState, (state) => { - if (state === undefined) { - return undefined + if (state.type !== 'upload' && state.type !== 'download') { + return [] } - // if there is no external resources, we don't show the progress bar - if (state.totalExternalResourcesByQuestionnaire === undefined) { - return undefined - } - if ( - state.externalResourcesByQuestionnaireCompleted === 0 && - state.totalExternalResourcesByQuestionnaire === 0 - ) - return 100 - return ( - (state.externalResourcesByQuestionnaireCompleted * 100) / - state.totalExternalResourcesByQuestionnaire - ) -}) -const externalResourcesProgressCount = createSelector( - downloadingState, - (state) => { - if (state === undefined) { - return undefined + // Uploading progress bars + if (state.type === 'upload') { + bars.push({ + progress: computeProgress( + state.interrogationCompleted, + state.totalInterrogation, + ), + label: t('interrogationsProgress'), + }) + if (state.totalParadata !== undefined) { + bars.push({ + progress: computeProgress(state.paradataCompleted, state.totalParadata), + label: t('paradataProgress'), + }) } - // if there is no external resources, we don't show the progress bar - if (state.totalExternalResources === undefined) { - return undefined - } - if ( - state.totalExternalResources === 0 && - state.externalResourcesCompleted === 0 - ) - return undefined - return { - externalResourcesCompleted: state.externalResourcesCompleted, - totalExternalResources: state.totalExternalResources, - } - }, -) - -const uploadingState = createSelector(state, (state) => { - if (state.stateDescription !== 'running') { - return undefined + return bars } - if (state.type !== 'upload') { - return undefined - } - - return state -}) - -const uploadInterrogationProgress = createSelector(uploadingState, (state) => { - if (state === undefined) { - return undefined + // Downloading bars + bars.push( + ...[ + { + progress: computeProgress(state.surveyCompleted, state.totalSurvey), + label: t('questionnairesProgress'), + }, + { + progress: computeProgress( + state.nomenclatureCompleted, + state.totalNomenclature, + ), + label: t('nomenclaturesProgress'), + }, + { + progress: computeProgress( + state.interrogationCompleted, + state.totalInterrogation, + ), + label: t('interrogationsProgress'), + }, + ], + ) + if (state.totalExternalResources !== undefined) { + bars.push({ + progress: computeProgress( + state.externalResourcesCompleted, + state.totalExternalResources, + ), + label: t('externalResourcesProgress'), + count: Number.isFinite(state.totalExternalResources) + ? `${state.externalResourcesCompleted} / ${state.totalExternalResources}` + : undefined, + }) } - - if (state.totalInterrogation === 0 && state.interrogationCompleted === 0) - return 100 - return (state.interrogationCompleted * 100) / state.totalInterrogation + return bars }) -const uploadParadataProgress = createSelector(uploadingState, (state) => { - if (state === undefined) { - return undefined +const stepTitle = createSelector(state, (state: State) => { + if (state.stateDescription !== 'running') { + return '' } - - // if total of paradata is undefined (only happening when telemetry is disabled), we don't show the progress bar - if (state.totalParadata === undefined) { - return undefined + switch (state.type) { + case 'upload': + return t('uploadingData') + case 'download': + return t('downloadingData') + default: + return '' } - - if (state.totalParadata === 0 && state.paradataCompleted === 0) return 100 - return (state.paradataCompleted * 100) / state.totalParadata }) -const main = createSelector( - state, - interrogationProgress, - nomenclatureProgress, - surveyProgress, - externalResourcesProgress, - externalResourcesProgressCount, - uploadInterrogationProgress, - uploadParadataProgress, - ( - state, - interrogationProgress, - nomenclatureProgress, - surveyProgress, - externalResourcesProgress, - externalResourcesProgressCount, - uploadInterrogationProgress, - uploadParadataProgress, - ) => { - switch (state.stateDescription) { - case 'not running': - return { hideProgress: true as const } - case 'running': - switch (state.type) { - case 'upload': - assert(uploadInterrogationProgress !== undefined) - return { - isUploading: true as const, - uploadInterrogationProgress, - uploadParadataProgress, - } - case 'download': - assert(interrogationProgress !== undefined) - assert(nomenclatureProgress !== undefined) - assert(surveyProgress !== undefined) - return { - isDownloading: true, - interrogationProgress, - nomenclatureProgress, - surveyProgress, - externalResourcesProgress, - externalResourcesProgressCount, - } - } - } - }, -) - export const selectors = { - main, + progressBars, + stepTitle, } diff --git a/src/core/usecases/synchronizeData/state.ts b/src/core/usecases/synchronizeData/state.ts index 31259650..8fab9926 100644 --- a/src/core/usecases/synchronizeData/state.ts +++ b/src/core/usecases/synchronizeData/state.ts @@ -220,6 +220,13 @@ export const { reducer, actions } = createUsecaseActions({ downloadCompleted: (state) => { return state }, + runningSync: (state) => { + state.stateDescription = 'running' + return state + }, + syncCompleted: (_state) => { + return { stateDescription: 'not running' } + }, downloadFailed: (_state) => { return { stateDescription: 'not running' } }, diff --git a/src/core/usecases/synchronizeData/thunks.test.ts b/src/core/usecases/synchronizeData/thunks.test.ts index 9846199f..61057749 100644 --- a/src/core/usecases/synchronizeData/thunks.test.ts +++ b/src/core/usecases/synchronizeData/thunks.test.ts @@ -13,8 +13,12 @@ import { actions } from './state' import { thunks } from './thunks' const mockDispatch = vi.fn() -const mockGetState: () => { synchronizeData: State.NotRunning } = () => ({ +const mockGetState: () => { + synchronizeData: State.NotRunning + takeControl: any +} = () => ({ synchronizeData: { stateDescription: 'not running' }, + takeControl: {} as any, }) const mockDataStore = { @@ -32,6 +36,8 @@ const mockQueenApi = { getInterrogation: vi.fn(), putInterrogation: vi.fn(), postInterrogationInTemp: vi.fn(), + fetchMoved: vi.fn(), + syncInterrogation: vi.fn(), getNomenclature: vi.fn(), postParadata: vi.fn(), } @@ -66,6 +72,7 @@ describe('download thunk', () => { // override global mock value of external resources url vi.doMock('@/core/constants', () => ({ EXTERNAL_RESOURCES_URL: '', + LUNATIC_MODEL_VERSION_BREAKING: '2.2.10', })) // Re-import after mocking const { thunks } = await import('./thunks') @@ -124,6 +131,7 @@ describe('download thunk', () => { // override global mock value of external resources url vi.doMock('@/core/constants', () => ({ EXTERNAL_RESOURCES_URL: '', + LUNATIC_MODEL_VERSION_BREAKING: '2.2.10', })) // Re-import after mocking const { thunks } = await import('./thunks') @@ -165,6 +173,7 @@ describe('download thunk', () => { // override global mock value of external resources url vi.doMock('@/core/constants', () => ({ EXTERNAL_RESOURCES_URL: '', + LUNATIC_MODEL_VERSION_BREAKING: '2.2.10', })) // Re-import after mocking const { thunks } = await import('./thunks') @@ -175,7 +184,9 @@ describe('download thunk', () => { new Error('Failed to fetch questionnaire'), ) - await thunks.download()(mockDispatch, mockGetState, mockContext as any) + await expect(() => + thunks.download()(mockDispatch, mockGetState, mockContext as any), + ).rejects.toThrowError() expect(mockLocalSyncStorage.addError).toHaveBeenCalledWith(true) expect(mockDispatch).toHaveBeenCalledWith(actions.downloadFailed()) @@ -199,6 +210,7 @@ describe('upload thunk', () => { // override global mock value for enable telemetry vi.doMock('@/core/constants', () => ({ IS_TELEMETRY_ENABLED: true, + LUNATIC_MODEL_VERSION_BREAKING: '2.2.10', })) // Re-import after mocking const { thunks } = await import('./thunks') @@ -231,8 +243,7 @@ describe('upload thunk', () => { * Cannot do directly expect(mockDispatch).toHaveBeenCalledWith(thunks.download()) * since it considers it has been called with [AsyncFunction (anonymous)] */ - expect(mockDispatch).toHaveBeenCalledWith(expect.any(Function)) - expect(mockDispatch).toHaveBeenCalledTimes(7) + expect(mockDispatch).toHaveBeenCalledTimes(6) }) it('should handle interrogation upload failure and retry posting to temp zone', async () => { @@ -290,7 +301,9 @@ describe('upload thunk', () => { new Error('Unexpected error'), ) - await thunks.upload()(mockDispatch, mockGetState, mockContext as any) + await expect(() => + thunks.upload()(mockDispatch, mockGetState, mockContext as any), + ).rejects.toThrowError() expect(mockLocalSyncStorage.addError).toHaveBeenCalledWith(true) expect(mockDispatch).toHaveBeenCalledWith(actions.uploadError()) @@ -342,6 +355,7 @@ describe('upload thunk', () => { // override global mock value for enable telemetry vi.doMock('@/core/constants', () => ({ IS_TELEMETRY_ENABLED: true, + LUNATIC_MODEL_VERSION_BREAKING: '2.2.10', })) // Re-import after mocking const { thunks } = await import('./thunks') @@ -419,6 +433,7 @@ describe('upload thunk', () => { // override global mock value for enable telemetry vi.doMock('@/core/constants', () => ({ IS_TELEMETRY_ENABLED: true, + LUNATIC_MODEL_VERSION_BREAKING: '2.2.10', })) // Re-import after mocking const { thunks } = await import('./thunks') diff --git a/src/core/usecases/synchronizeData/thunks.ts b/src/core/usecases/synchronizeData/thunks.ts index 40e88669..8c7db1f0 100644 --- a/src/core/usecases/synchronizeData/thunks.ts +++ b/src/core/usecases/synchronizeData/thunks.ts @@ -1,6 +1,6 @@ import { AxiosError } from 'axios' -import type { Thunks } from '@/core/bootstrap' +import { type Thunks } from '@/core/bootstrap' import { EXTERNAL_RESOURCES_URL, IS_TELEMETRY_ENABLED } from '@/core/constants' import type { Questionnaire } from '@/core/model' import { @@ -15,6 +15,92 @@ import { actions, name } from './state' const EXTERNAL_RESOURCES_ROOT_CACHE_NAME = 'cache-root-external' export const thunks = { + // Sync the data (upload first, download last) + sync: (params: { resetMoved: boolean }) => async (dispatch, getState) => { + const state = getState()[name] + if (state.stateDescription === 'running') { + return + } + + dispatch(actions.runningSync()) + if (params.resetMoved) { + await dispatch(thunks.resetMoved()) + } + await dispatch(thunks.upload()) + await dispatch(thunks.download()) + dispatch(actions.syncCompleted()) + }, + + // Reset data for interrogations where the interrogated moved + resetMoved: () => async (dispatch, _getState, context) => { + const { queenApi } = context + dispatch(actions.runningDownload()) + try { + const ids = await queenApi.fetchMoved() + dispatch( + actions.updateDownloadTotalInterrogation({ + totalInterrogation: ids.length, + }), + ) + for (const { id } of ids) { + await dispatch(thunks.partialReset({ interrogationId: id })) + dispatch(actions.downloadInterrogationCompleted()) + } + dispatch(actions.downloadCompleted) + } catch (e) { + dispatch(actions.uploadError()) + throw e + } + }, + + // Reset data for a specific interrogation + partialReset: + (params: { interrogationId: string }) => + async (_dispatch, _getState, context) => { + const { queenApi, dataStore } = context + const interrogation = await dataStore.getInterrogation( + params.interrogationId, + ) + + if (!interrogation) { + console.error( + `Cannot find interrogation ${interrogation} in the local store`, + ) + return + } + + // Retrieve questionnaire from the API + const questionnaire = await queenApi.getQuestionnaire( + interrogation.questionnaireId, + ) + + if (!questionnaire) { + console.error( + `Cannot find questionnaire ${interrogation.questionnaireId} from the API`, + ) + return + } + + // Reset data + interrogation.data.CALCULATED = {} + interrogation.data.COLLECTED = {} + // Reset external variables + for (const variable of questionnaire.variables) { + if ( + variable.variableType === 'EXTERNAL' && + variable.isDeletedOnReset && + // eslint-disable-next-line no-prototype-builtins + interrogation.data.EXTERNAL?.hasOwnProperty(variable.name) + ) { + delete interrogation.data.EXTERNAL[variable.name] + } + } + delete interrogation.stateData + + await dataStore.updateInterrogation(interrogation) + }, + + // Download the fresh data from the server download: () => async (...args) => { @@ -24,7 +110,7 @@ export const thunks = { { const state = getState()[name] - if (state.stateDescription === 'running') { + if (state.stateDescription === 'running' && state.type === 'download') { return } } @@ -102,7 +188,6 @@ export const thunks = { /* * Interrogation */ - const prInterrogation = await Promise.all( campaignsIds.map((campaignId) => queenApi @@ -292,20 +377,21 @@ export const thunks = { ) localSyncStorage.addError(true) dispatch(actions.downloadFailed()) + throw error } }, + + // Upload the data to the server upload: () => async (...args) => { const [dispatch, getState, { dataStore, queenApi, localSyncStorage }] = args - { - const state = getState()[name] + const state = getState()[name] - if (state.stateDescription === 'running') { - return - } + if (state.stateDescription === 'running' && state.type === 'upload') { + return } dispatch(actions.runningUpload()) @@ -414,10 +500,10 @@ export const thunks = { } dispatch(actions.uploadCompleted()) - dispatch(thunks.download()) - } catch { + } catch (e) { localSyncStorage.addError(true) dispatch(actions.uploadError()) + throw e } }, } satisfies Thunks diff --git a/src/hooks/useArticulationTable.test.ts b/src/federation/getArticulationTable.test.ts similarity index 74% rename from src/hooks/useArticulationTable.test.ts rename to src/federation/getArticulationTable.test.ts index 80b03f9c..1d00acb1 100644 --- a/src/hooks/useArticulationTable.test.ts +++ b/src/federation/getArticulationTable.test.ts @@ -1,12 +1,10 @@ import { getArticulationState } from '@inseefr/lunatic' -import { renderHook, waitFor } from '@testing-library/react' +import { waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import React from 'react' - import { prCore } from '@/createCore' -import { useArticulationTable } from './useArticulationTable' +import { getArticulationTable } from './getArticulationTable' vi.mock('@/createCore', () => { return { @@ -26,7 +24,7 @@ vi.mock('@inseefr/lunatic', () => ({ const mockLoader = vi.fn() -describe('useArticulationTable', () => { +describe('getArticulationTable', () => { beforeEach(async () => { const core = await prCore core.functions.collectSurvey.loader = mockLoader @@ -72,31 +70,31 @@ describe('useArticulationTable', () => { ], }) - const { result } = renderHook(() => useArticulationTable(React, 'interro1')) + const data = await getArticulationTable('interro1') await waitFor(() => { - expect(result.current).not.toBeNull() - expect(result.current?.headers).toEqual(['name', 'age']) + expect(data).not.toBeNull() + expect(data?.headers).toEqual(['name', 'age']) // first item - expect(result.current?.rows[0].cells).toEqual([ + expect(data?.rows[0].cells).toEqual([ { label: 'name', value: 'Alice' }, { label: 'age', value: '30' }, ]) - expect(result.current?.rows[0].url).toContain( + expect(data?.rows[0].url).toContain( '/queen/interrogations/interro1?page=1.1%231', // for the page, `#` is encoded into `%23` ) - expect(result.current?.rows[0].label).toBe('Continuer') + expect(data?.rows[0].label).toBe('Continuer') // second item - expect(result.current?.rows[1].cells).toEqual([ + expect(data?.rows[1].cells).toEqual([ { label: 'name', value: 'Patrick' }, { label: 'age', value: '25' }, ]) - expect(result.current?.rows[1].url).toContain( + expect(data?.rows[1].url).toContain( '/queen/interrogations/interro1?page=1.1%232', // for the page, `#` is encoded into `%23` ) - expect(result.current?.rows[1].label).toBe('Complété') + expect(data?.rows[1].label).toBe('Complété') }) }) @@ -106,10 +104,10 @@ describe('useArticulationTable', () => { questionnaire: null, }) - const { result } = renderHook(() => useArticulationTable(React, 'interro1')) + const data = await getArticulationTable('interro1') await waitFor(() => { - expect(result.current).toBeNull() + expect(data).toBeNull() }) }) @@ -127,12 +125,12 @@ describe('useArticulationTable', () => { questionnaire: { articulation: { items: [] } }, }) - const { result } = renderHook(() => useArticulationTable(React, 'interro1')) + const data = await getArticulationTable('interro1') await waitFor(() => { - expect(result.current).not.toBeNull() - expect(result.current?.rows).toHaveLength(3) - expect(result.current?.rows.map((r) => r.label)).toEqual([ + expect(data).not.toBeNull() + expect(data?.rows).toHaveLength(3) + expect(data?.rows.map((r) => r.label)).toEqual([ 'Commencer', 'Continuer', 'Complété', @@ -175,17 +173,17 @@ describe('useArticulationTable', () => { questionnaire: { articulation: { items: [] } }, }) - const { result } = renderHook(() => useArticulationTable(React, 'interro1')) + const data = await getArticulationTable('interro1') await waitFor(() => { - expect(result.current).not.toBeNull() - expect(result.current?.rows).toHaveLength(3) - expect(result.current?.rows.map((r) => r.label)).toEqual([ + expect(data).not.toBeNull() + expect(data?.rows).toHaveLength(3) + expect(data?.rows.map((r) => r.label)).toEqual([ 'Commencer', 'Continuer', 'Complété', ]) - expect(result.current?.rows[0].cells).toEqual([ + expect(data?.rows[0].cells).toEqual([ { label: 'Prénom', value: 'Bob' }, { label: 'Sexe', value: 'Homme' }, { label: 'Age', value: '23 ans' }, @@ -200,10 +198,10 @@ describe('useArticulationTable', () => { }) ;(getArticulationState as any).mockReturnValue({ items: [] }) - const { result } = renderHook(() => useArticulationTable(React, 'su4')) + const data = await getArticulationTable('su4') await waitFor(() => { - expect(result.current).toBeNull() + expect(data).toBeNull() }) }) }) diff --git a/src/federation/getArticulationTable.ts b/src/federation/getArticulationTable.ts new file mode 100644 index 00000000..37fb1b66 --- /dev/null +++ b/src/federation/getArticulationTable.ts @@ -0,0 +1,110 @@ +import { type LunaticSource, getArticulationState } from '@inseefr/lunatic' + +import { type ReactNode } from 'react' + +import type { LeafStateState } from '@/core/model' +import { prCore } from '@/createCore' + +export type TableData = { + dates?: number[] + headers: string[] + rows: { + cells: { label: string; value: ReactNode }[] + label: string + page: string | null + url: string | null + }[] +} + +/** + * Generates table data for articulation + * Used to get the progress of a surveys containing a roundabout + * + * Refactored: Expose a plain async function returning the table data + */ +export async function getArticulationTable( + interrogationId: string, +): Promise { + // Retrieve questionnaire source and interrogation data + const { collectSurvey } = (await prCore).functions + const { interrogation, questionnaire } = await collectSurvey.loader({ + interrogationId, + }) + + if (!hasArticulation(questionnaire)) { + return null + } + + // Use leafState + if (!interrogation.data) { + if (!interrogation?.stateData?.leafStates) { + return null + } + return { + dates: interrogation?.stateData?.leafStates.map((s) => s.date), + headers: + interrogation?.stateData.leafStates[0]?.cells?.map((c) => c.label) ?? + [], + rows: interrogation?.stateData.leafStates.map((leafState) => ({ + cells: leafState.cells ?? [], + url: null, + page: null, + label: progressLabel(leafProgress(leafState.state)), + // progress is intentionally not part of the public type + progress: leafProgress(leafState.state), + })), + } + } + + // Extract articulation data + const { items } = getArticulationState(questionnaire, interrogation.data) + if (items.length === 0) { + return null + } + + // Build the result + return { + dates: interrogation?.stateData?.leafStates?.map((s) => s.date), + headers: items[0].cells.map((c) => c.label), + rows: items.map((item) => ({ + ...item, + url: buildUrl(interrogationId, item.page), + label: progressLabel(item.progress), + })), + } +} + +function hasArticulation( + source: LunaticSource | null, +): source is Parameters[0] { + return Boolean(source && 'articulation' in source) +} + +const buildUrl = (interrogationId: string, page: string): string => { + const url = new URL( + `/queen/interrogations/${interrogationId}`, + window.location.origin, + ) + url.searchParams.set('page', page.toString()) + return url.toString() +} + +const progressLabel = (n: number) => { + if (n === -1) { + return 'Commencer' + } + if (n === 0) { + return 'Continuer' + } + return 'Complété' +} + +const leafProgress = (leafState: LeafStateState) => { + if (leafState === 'NOT_INIT') { + return -1 + } + if (leafState === 'INIT') { + return 0 + } + return 1 +} diff --git a/src/federation/partialResetInterrogation.ts b/src/federation/partialResetInterrogation.ts new file mode 100644 index 00000000..13f086fe --- /dev/null +++ b/src/federation/partialResetInterrogation.ts @@ -0,0 +1,6 @@ +import { prCore } from '@/createCore' + +export async function partialResetInterrogation(interrogationId: string) { + const core = await prCore + await core.functions.synchronizeData.partialReset({ interrogationId }) +} diff --git a/src/hooks/useArticulationTable.ts b/src/hooks/useArticulationTable.ts deleted file mode 100644 index 356b63de..00000000 --- a/src/hooks/useArticulationTable.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { type LunaticSource, getArticulationState } from '@inseefr/lunatic' - -import { type ReactNode } from 'react' -import type React from 'react' - -import type { LeafStateState } from '@/core/model' -import { prCore } from '@/createCore' - -type TableData = { - dates?: number[] - headers: string[] - rows: { - cells: { label: string; value: ReactNode }[] - label: string - page: string | null - url: string | null - }[] -} - -/** - * Generates table data for articulation - * Used to get the progress of a surveys containing a roundabout - * - * To ensure the host use the same version of React we require it as a dependency of this function - */ -export function useArticulationTable( - react: typeof React, - interrogationId: string, -) { - const [data, setData] = react.useState(null) - - react.useEffect(() => { - ;(async () => { - // Retrieve questionnaire source and interrogation data - const { collectSurvey } = (await prCore).functions - const { interrogation, questionnaire } = await collectSurvey.loader({ - interrogationId, - }) - - if (!hasArticulation(questionnaire)) { - return - } - - // Use leafState - if (!interrogation.data) { - if (!interrogation?.stateData?.leafStates) { - return null - } - setData({ - dates: interrogation?.stateData?.leafStates.map((s) => s.date), - headers: - interrogation?.stateData.leafStates[0]?.cells?.map( - (c) => c.label, - ) ?? [], - rows: interrogation?.stateData.leafStates.map((leafState) => ({ - cells: leafState.cells ?? [], - url: null, - page: null, - label: progressLabel(leafProgress(leafState.state)), - progress: leafProgress(leafState.state), - })), - }) - return - } - - // Extract articulation data - const { items } = getArticulationState(questionnaire, interrogation.data) - if (items.length === 0) { - return null - } - - // Update the state - return setData({ - dates: interrogation?.stateData?.leafStates?.map((s) => s.date), - headers: items[0].cells.map((c) => c.label), - rows: items.map((item) => ({ - ...item, - url: buildUrl(interrogationId, item.page), - label: progressLabel(item.progress), - })), - }) - })() - }, [interrogationId]) - - return data -} - -function hasArticulation( - source: LunaticSource | null, -): source is Parameters[0] { - return Boolean(source && 'articulation' in source) -} - -const buildUrl = (interrogationId: string, page: string): string => { - const url = new URL( - `/queen/interrogations/${interrogationId}`, - window.location.origin, - ) - url.searchParams.set('page', page.toString()) - return url.toString() -} - -const progressLabel = (n: number) => { - if (n === -1) { - return 'Commencer' - } - if (n === 0) { - return 'Continuer' - } - return 'Complété' -} - -const leafProgress = (leafState: LeafStateState) => { - if (leafState === 'NOT_INIT') { - return -1 - } - if (leafState === 'INIT') { - return 0 - } - return 1 -} diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index c70e6004..b6b8db7a 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -84,6 +84,7 @@ export const translations: Translations<'en'> = { nomenclaturesProgress: 'Nomenclatures', externalResourcesProgress: 'External resources', uploadingData: 'Sending data...', + takingControl: 'Taking control of the questionnaire...', }, visualizeMessage: { visualizePage: 'Questionnaire viewer page', diff --git a/src/i18n/resources/fr.ts b/src/i18n/resources/fr.ts index 7aae0d70..d46851ef 100644 --- a/src/i18n/resources/fr.ts +++ b/src/i18n/resources/fr.ts @@ -86,6 +86,7 @@ export const translations: Translations<'fr'> = { nomenclaturesProgress: 'Nomenclatures', externalResourcesProgress: 'Ressources externes', uploadingData: 'Envoi des données...', + takingControl: 'Reprise du questionnaire...', }, visualizeMessage: { visualizePage: 'Page de visualisation de questionnaire', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 9a60e551..c678f877 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -82,6 +82,7 @@ export type SynchronizeMessage = | 'nomenclaturesProgress' | 'externalResourcesProgress' | 'uploadingData' + | 'takingControl' export type VisualizeMessage = | 'visualizePage' diff --git a/src/routes/pages/synchronize/LoadingDisplay.test.tsx b/src/routes/pages/synchronize/LoadingDisplay.test.tsx index 78373370..2fb0fa5b 100644 --- a/src/routes/pages/synchronize/LoadingDisplay.test.tsx +++ b/src/routes/pages/synchronize/LoadingDisplay.test.tsx @@ -35,7 +35,7 @@ describe('LoadingDisplay Component', () => { syncStepTitle: 'sync step', progressBars: [ { label: 'Progress 1', progress: 50 }, - { label: 'Progress 2', progress: 75, extraTitle: 'Extra Info' }, + { label: 'Progress 2', progress: 75, count: 'Extra Info' }, ], } diff --git a/src/routes/pages/synchronize/LoadingDisplay.tsx b/src/routes/pages/synchronize/LoadingDisplay.tsx index 4058373d..bc658040 100644 --- a/src/routes/pages/synchronize/LoadingDisplay.tsx +++ b/src/routes/pages/synchronize/LoadingDisplay.tsx @@ -12,7 +12,7 @@ type LoadingDisplayProps = { progressBars: { progress: number label?: string - extraTitle?: string + count?: string }[] } @@ -33,7 +33,7 @@ export function LoadingDisplay({ - {progressBars.map(({ label, progress, extraTitle }) => ( + {progressBars.map(({ label, progress, count }) => ( {label !== undefined && ( @@ -43,7 +43,7 @@ export function LoadingDisplay({ className={classes.lightText} > {label} - {extraTitle ? `: ${extraTitle}` : ''} + {count ? `: ${count}` : ''} )} { - synchronizeData.upload() + const resetMovedEnabled = import.meta.env.VITE_RESET_MOVED_ENABLED === "true"; + synchronizeData.sync({ resetMoved: resetMovedEnabled }) }, [synchronizeData]) const { evtSynchronizeData } = useCore().evts @@ -36,72 +24,14 @@ export function SynchronizeData() { (data) => (data.action === 'redirect' ? [data] : null), ctx, () => { - // This is hacky, if anyone has a better solution let's contribute :) - ;(window.location as any) = window.location.origin + window.location.href = window.location.origin }, ) }, []) - if (hideProgress) { + if (bars.length === 0) { return null } - return ( - <> - {isUploading && ( - - )} - {isDownloading && ( - - )} - - ) + return } diff --git a/src/routes/pages/synchronize/SynchronizeInterrogation.tsx b/src/routes/pages/synchronize/SynchronizeInterrogation.tsx new file mode 100644 index 00000000..2aa34c93 --- /dev/null +++ b/src/routes/pages/synchronize/SynchronizeInterrogation.tsx @@ -0,0 +1,44 @@ +import { Alert } from '@mui/material' +import CircularProgress from '@mui/material/CircularProgress' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import { Navigate, useParams } from 'react-router-dom' +import { assert } from 'tsafe' + +import { useEffect } from 'react' + +import { useCore, useCoreState } from '@/core' + +export function SynchronizeInterrogation() { + const { interrogationId } = useParams() + + assert(typeof interrogationId === 'string') + + const state = useCoreState('takeControl', 'state') + + const { start } = useCore().functions.takeControl + + useEffect(() => { + start({ interrogationId }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [interrogationId]) + + if (state.error) { + return ( + + {state.error} + + ) + } + + if (state.done) { + return + } + + return ( + + + {state.message} + + ) +} diff --git a/src/routes/pages/synchronize/synchronizeData.test.tsx b/src/routes/pages/synchronize/synchronizeData.test.tsx deleted file mode 100644 index 7d701397..00000000 --- a/src/routes/pages/synchronize/synchronizeData.test.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { render, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { useCore, useCoreState } from '@/core' - -import { SynchronizeData } from './SynchronizeData' - -vi.mock('@/i18n', () => ({ - useTranslation: () => ({ t: (keyMessage: string) => keyMessage }), - getTranslation: () => ({ t: (keyMessage: string) => keyMessage }), -})) - -vi.mock('@/core') // Global mock of `useCore` and `useCoreState` - -const mockUpload = vi.fn() -const mockAttach = vi.fn() - -describe('SynchronizeData', () => { - beforeEach(() => { - vi.resetAllMocks() - - // Default Mock Implementations - vi.mocked(useCoreState).mockReturnValue({ - hideProgress: false, - isUploading: false, - isDownloading: false, - uploadInterrogationProgress: 0, - }) - - vi.mocked(useCore).mockReturnValue({ - functions: { - synchronizeData: { - upload: mockUpload, - }, - }, - evts: { - evtSynchronizeData: { - $attach: mockAttach, - }, - }, - } as any) // type issues - }) - - afterEach(() => vi.clearAllMocks()) - - it('should call synchronizeData.upload on mount', async () => { - render() - - await waitFor(() => { - expect(mockUpload).toHaveBeenCalled() - }) - }) - - it('should render the loading display with upload progress', async () => { - vi.mocked(useCoreState).mockReturnValue({ - hideProgress: false, - isUploading: true, - isDownloading: false, - uploadInterrogationProgress: 50, - uploadParadataProgress: 30, - }) - - const { getByText, getAllByRole } = render() - - // Check that loading display is rendered with upload progress - expect(getByText('synchronizationInProgress')).toBeInTheDocument() - expect(getByText('uploadingData')).toBeInTheDocument() - - // Check that all progress bars are rendered - const progressBars = getAllByRole('progressbar') - expect(progressBars).toHaveLength(2) - expect(progressBars[0]).toHaveAttribute('aria-valuenow', '50') // interrogation - expect(progressBars[1]).toHaveAttribute('aria-valuenow', '30') // paradata - }) - - it('should render the loading display with download progress', () => { - vi.mocked(useCoreState).mockReturnValue({ - hideProgress: false, - isUploading: false, - isDownloading: true, - surveyProgress: 10, - nomenclatureProgress: 20, - interrogationProgress: 30, - externalResourcesProgress: 15, - externalResourcesProgressCount: { - totalExternalResources: 10, - externalResourcesCompleted: 2, - }, - }) - - const { getByText, getAllByRole } = render() - - // Check that loading display is rendered with download progress - expect(getByText('synchronizationInProgress')).toBeInTheDocument() - expect(getByText('downloadingData')).toBeInTheDocument() - - // Check that all progress bars are rendered - const progressBars = getAllByRole('progressbar') - expect(progressBars).toHaveLength(4) - expect(progressBars[0]).toHaveAttribute('aria-valuenow', '10') // survey - expect(progressBars[1]).toHaveAttribute('aria-valuenow', '20') // nomenclature - expect(progressBars[2]).toHaveAttribute('aria-valuenow', '30') // interrogation - expect(progressBars[3]).toHaveAttribute('aria-valuenow', '15') // external resources - - // Check that extra title for external resources progress is displayed - expect(getByText('externalResourcesProgress: 2 / 10')).toBeInTheDocument() - }) - - it('should not render external resources progress bar only if externalResourcesProgress and externalResourcesProgressCount are undefined', () => { - vi.mocked(useCoreState).mockReturnValue({ - hideProgress: false, - isUploading: false, - isDownloading: true, - surveyProgress: 10, - nomenclatureProgress: 20, - interrogationProgress: 30, - externalResourcesProgress: undefined, // No external resources - externalResourcesProgressCount: undefined, - } as any) - - const { getAllByRole, queryByText } = render() - - // Check that all progress bars are rendered except external resources - const progressBars = getAllByRole('progressbar') - expect(progressBars).toHaveLength(3) - - // Check that the external resources progress is not displayed - expect(queryByText('externalResourcesProgress')).not.toBeInTheDocument() - }) - - it('should not render anything if hideProgress is true', () => { - vi.mocked(useCoreState).mockReturnValue({ - hideProgress: true, - }) - - const { queryByText } = render() - - expect(queryByText('synchronizationInProgress')).toBeNull() - }) - - it('should handle redirect event correctly', async () => { - render() - - // Check that window.location is updated - await waitFor(() => { - // Normalize both URLs before comparison. No better idea since the redirect adds a "/" at the end of url - const actualLocation = window.location.toString().replace(/\/$/, '') - const expectedLocation = window.location.origin.replace(/\/$/, '') - - expect(actualLocation).toBe(expectedLocation) - }) - }) -}) diff --git a/src/routes/routing/routes.tsx b/src/routes/routing/routes.tsx index 2757c33d..beac5ca6 100644 --- a/src/routes/routing/routes.tsx +++ b/src/routes/routing/routes.tsx @@ -5,6 +5,7 @@ import { ErrorPage } from '@/routes/pages/error/Error' import { ExternalRessources } from '@/routes/pages/external/External' import { Review } from '@/routes/pages/review/Review' import { SynchronizeData } from '@/routes/pages/synchronize/SynchronizeData' +import { SynchronizeInterrogation } from '@/routes/pages/synchronize/SynchronizeInterrogation' import { Visualize } from '@/routes/pages/visualize/Visualize' import { Layout } from './Layout' @@ -37,6 +38,11 @@ const getChildrenRoutes = () => { Component: Collect, // This route do not contains UI components, all things are done in loader, if not there is an error loader: collectLoader, }, + { + path: '/interrogations/:interrogationId/synchronize', + Component: SynchronizeInterrogation, + loader: protectedRouteLoader, + }, { path: `/review/interrogations/:interrogationId`, Component: Review, diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 297ecd7a..8f9673c6 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -7,6 +7,7 @@ type ImportMetaEnv = { VITE_EXTERNAL_RESOURCES_URL: string VITE_OIDC_ISSUER: string VITE_OIDC_CLIENT_ID: string + VITE_RESET_MOVED_ENABLED: string VITE_TELEMETRY_ENABLED: string VITE_TELEMETRY_MAX_DELAY: string VITE_TELEMETRY_MAX_LENGTH: string diff --git a/vite.config.ts b/vite.config.ts index 5c98cebb..0203a17e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -33,7 +33,9 @@ export default defineConfig({ filename: 'remoteEntry.js', exposes: { './DramaIndex': './src/bootstrap.tsx', - './useArticulationTable': './src/hooks/useArticulationTable.ts', + './getArticulationTable': './src/federation/getArticulationTable.ts', + './partialResetInterrogation': + './src/federation/partialResetInterrogation.ts', }, shared: ['react', 'react-dom'], }),