From 9cddf386f9c97a5d50693e5fa7ea12860f31f8d6 Mon Sep 17 00:00:00 2001 From: Mailine Nguyen <64129348+MailineN@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:05:06 +0200 Subject: [PATCH 1/6] feat: add release overview and form pages --- .../layout/QuestionnaireSidebar.tsx | 7 + .../release/ReleaseOverview.test.tsx | 47 +++ .../components/release/ReleaseOverview.tsx | 171 ++++++++++ .../release/ReleasesOverviewLayout.test.tsx | 50 +++ .../release/ReleasesOverviewLayout.tsx | 34 ++ .../release/create/CreateRelease.tsx | 24 ++ .../release/create/CreateReleaseForm.tsx | 78 +++++ .../release/create/CreateReleaseLayout.tsx | 17 + .../components/release/form/ReleaseForm.tsx | 305 ++++++++++++++++++ next/src/components/release/form/consts.ts | 24 ++ next/src/components/release/form/schema.ts | 23 ++ .../release/overview/CopyButton.test.tsx | 43 +++ .../release/overview/CopyButton.tsx | 37 +++ .../overview/RegistryReleaseTile.test.tsx | 134 ++++++++ .../release/overview/RegistryReleaseTile.tsx | 119 +++++++ .../ReleaseOptionalParametersDisplay.test.tsx | 77 +++++ .../ReleaseOptionalParametersDisplay.tsx | 44 +++ .../overview/ReleaseRequestTile.test.tsx | 174 ++++++++++ .../release/overview/ReleaseRequestTile.tsx | 177 ++++++++++ next/src/components/ui/ExpandButton.tsx | 5 +- .../src/components/ui/icons/CopyIcon.test.tsx | 8 + next/src/components/ui/icons/CopyIcon.tsx | 20 ++ next/src/components/ui/icons/InfoIcon.tsx | 17 + .../components/ui/icons/ReleaseIcon.test.tsx | 8 + next/src/components/ui/icons/ReleaseIcon.tsx | 17 + .../ui/icons/VisualizeIcon.test.tsx | 8 + .../src/components/ui/icons/VisualizeIcon.tsx | 18 ++ .../__snapshots__/CopyIcon.test.tsx.snap | 16 + .../__snapshots__/ReleaseIcon.test.tsx.snap | 16 + .../__snapshots__/VisualizeIcon.test.tsx.snap | 16 + next/src/lib/i18n/locales/en/crumb.json | 1 + next/src/lib/i18n/locales/en/release.json | 82 +++++ next/src/lib/i18n/locales/en/translation.ts | 2 + next/src/lib/i18n/locales/fr/crumb.json | 1 + next/src/lib/i18n/locales/fr/history.json | 2 +- next/src/lib/i18n/locales/fr/release.json | 82 +++++ next/src/lib/i18n/locales/fr/translation.ts | 2 + next/src/models/releases.ts | 54 ++++ next/src/routeTree.gen.ts | 81 +++++ .../_layout-q/releases/index.tsx | 36 +++ .../_layout-q/releases/new.tsx | 39 +++ .../_layout-q/releases/route.tsx | 9 + 42 files changed, 2123 insertions(+), 2 deletions(-) create mode 100644 next/src/components/release/ReleaseOverview.test.tsx create mode 100644 next/src/components/release/ReleaseOverview.tsx create mode 100644 next/src/components/release/ReleasesOverviewLayout.test.tsx create mode 100644 next/src/components/release/ReleasesOverviewLayout.tsx create mode 100644 next/src/components/release/create/CreateRelease.tsx create mode 100644 next/src/components/release/create/CreateReleaseForm.tsx create mode 100644 next/src/components/release/create/CreateReleaseLayout.tsx create mode 100644 next/src/components/release/form/ReleaseForm.tsx create mode 100644 next/src/components/release/form/consts.ts create mode 100644 next/src/components/release/form/schema.ts create mode 100644 next/src/components/release/overview/CopyButton.test.tsx create mode 100644 next/src/components/release/overview/CopyButton.tsx create mode 100644 next/src/components/release/overview/RegistryReleaseTile.test.tsx create mode 100644 next/src/components/release/overview/RegistryReleaseTile.tsx create mode 100644 next/src/components/release/overview/ReleaseOptionalParametersDisplay.test.tsx create mode 100644 next/src/components/release/overview/ReleaseOptionalParametersDisplay.tsx create mode 100644 next/src/components/release/overview/ReleaseRequestTile.test.tsx create mode 100644 next/src/components/release/overview/ReleaseRequestTile.tsx create mode 100644 next/src/components/ui/icons/CopyIcon.test.tsx create mode 100644 next/src/components/ui/icons/CopyIcon.tsx create mode 100644 next/src/components/ui/icons/InfoIcon.tsx create mode 100644 next/src/components/ui/icons/ReleaseIcon.test.tsx create mode 100644 next/src/components/ui/icons/ReleaseIcon.tsx create mode 100644 next/src/components/ui/icons/VisualizeIcon.test.tsx create mode 100644 next/src/components/ui/icons/VisualizeIcon.tsx create mode 100644 next/src/components/ui/icons/__snapshots__/CopyIcon.test.tsx.snap create mode 100644 next/src/components/ui/icons/__snapshots__/ReleaseIcon.test.tsx.snap create mode 100644 next/src/components/ui/icons/__snapshots__/VisualizeIcon.test.tsx.snap create mode 100644 next/src/lib/i18n/locales/en/release.json create mode 100644 next/src/lib/i18n/locales/fr/release.json create mode 100644 next/src/models/releases.ts create mode 100644 next/src/routes/_layout/questionnaire.$questionnaireId/_layout-q/releases/index.tsx create mode 100644 next/src/routes/_layout/questionnaire.$questionnaireId/_layout-q/releases/new.tsx create mode 100644 next/src/routes/_layout/questionnaire.$questionnaireId/_layout-q/releases/route.tsx diff --git a/next/src/components/layout/QuestionnaireSidebar.tsx b/next/src/components/layout/QuestionnaireSidebar.tsx index 84e2809b2..4dab05c2f 100644 --- a/next/src/components/layout/QuestionnaireSidebar.tsx +++ b/next/src/components/layout/QuestionnaireSidebar.tsx @@ -14,6 +14,7 @@ import VariableIcon from '@/components/ui/icons/VariableIcon' import { useAltIcon } from '@/hooks/useAltIcon' import ProtocolesIcon from '../ui/icons/ProtocolesIcon' +import ReleaseIcon from '../ui/icons/ReleaseIcon' import Sidebar from './sidebar/Sidebar' import SidebarItem from './sidebar/SidebarItem' import SidebarItemGroup from './sidebar/SidebarItemGroup' @@ -156,6 +157,12 @@ export default function QuestionnaireSidebar() { path="/questionnaire/$questionnaireId/versions" questionnaireId={questionnaireId} /> + { + beforeEach(() => { + vi.stubEnv('VITE_TROMBI_URL', 'https://trombi.example.com') + }) + + it('renders both sections with correct headers', () => { + const { getByText } = renderWithI18n() + + expect(getByText('My publication requests')).toBeInTheDocument() + expect(getByText('My publications')).toBeInTheDocument() + }) + + it('renders all request tiles', () => { + const { getByText, getAllByText } = renderWithI18n() + + expect( + getAllByText('Recette intégrée oct 2025 pour SRCV_REINTERRO 2026').length, + ).toBe(2) + expect( + getByText('Autre demande de publication pour SRCV_REINTERRO 2026'), + ).toBeInTheDocument() + }) + + it('renders all publication tiles', () => { + const { getByText } = renderWithI18n() + + expect(getByText('ESA 2026 PROD')).toBeInTheDocument() + expect(getByText('ESA 2026 TEST TERRAIN')).toBeInTheDocument() + expect(getByText("Publication la plus ancienne d'ESA")).toBeInTheDocument() + }) + + it('renders requests sorted by date descending', () => { + const { getAllByText } = renderWithI18n() + + const descriptions = getAllByText( + (content) => + content.includes('SRCV_REINTERRO 2026') && + !content.includes('Failed') && + !content.includes('In progress'), + ) + expect(descriptions.length).toBeGreaterThanOrEqual(2) + }) +}) diff --git a/next/src/components/release/ReleaseOverview.tsx b/next/src/components/release/ReleaseOverview.tsx new file mode 100644 index 000000000..292b92f68 --- /dev/null +++ b/next/src/components/release/ReleaseOverview.tsx @@ -0,0 +1,171 @@ +import { useTranslation } from 'react-i18next' + +import { useState } from 'react' + +import type { RegistryRelease, ReleaseRequest } from '../../models/releases' +import { RegistryReleaseTile } from './overview/RegistryReleaseTile' +import { ReleaseRequestTile } from './overview/ReleaseRequestTile' + +const MOCK_REQUESTS: ReleaseRequest[] = [ + { + trackerId: 1, + author: 'xbeltv', + requestDate: new Date('2026-07-06T10:30:00').getTime(), + currentStep: 'BUILD_PARAMETERS', + status: 'RUNNING', + statusDescription: '', + poguesVersionId: '550e8400-e29b-41d4-a716-446655440000', + poguesId: 'SRCV_REINTERRO', + releaseDescription: 'Recette intégrée oct 2025 pour SRCV_REINTERRO 2026', + mode: 'CAPI', + context: 'HOUSEHOLD', + overrideGenerationParameters: { + questionNumberingMode: 'SEQUENCE', + responseTimeQuestion: true, + }, + }, + { + trackerId: 2, + author: 'nazdsn', + requestDate: new Date('2026-07-05T14:15:00').getTime(), + currentStep: 'GENERATE_DDI', + status: 'FAILED', + statusDescription: 'Erreur lors de la génération Eno', + poguesVersionId: '550e8400-e29b-41d4-a716-446655440001', + poguesId: 'SRCV_REINTERRO', + releaseDescription: 'Autre demande de publication pour SRCV_REINTERRO 2026', + mode: 'CAPI', + context: 'BUSINESS', + overrideGenerationParameters: { + questionNumberingMode: 'NONE', + responseTimeQuestion: false, + }, + }, + { + trackerId: 3, + author: 'bcbab8', + requestDate: new Date('2026-07-04T09:00:00').getTime(), + currentStep: 'PUBLISH_DDI', + status: 'FAILED', + statusDescription: 'Erreur 409 - Conflit de version', + poguesVersionId: '550e8400-e29b-41d4-a716-446655440002', + poguesId: 'SRCV_REINTERRO', + releaseDescription: 'Recette intégrée oct 2025 pour SRCV_REINTERRO 2026', + mode: 'CAPI', + context: 'HOUSEHOLD', + overrideGenerationParameters: { + questionNumberingMode: 'ALL', + responseTimeQuestion: true, + }, + }, +] + +const MOCK_PUBLICATIONS: RegistryRelease[] = [ + { + collectionInstrumentId: '550e8400-e29b-41d4-a716-446655440001', + version: 3, + author: 'xbeltv', + releaseDate: new Date('2026-07-04T08:00:00').getTime(), + poguesVersionId: '93d1e85c-327d-4153-a5fa-e04f54ca0e3e', + releaseDescription: 'ESA 2026 PROD', + mode: 'CAWI', + context: 'HOUSEHOLD', + overrideGenerationParameters: { + questionNumberingMode: 'NONE', + responseTimeQuestion: false, + }, + visualizeUrl: 'https://visu.example.com/esa-2026-prod', + }, + { + collectionInstrumentId: '550e8400-e29b-41d4-a716-446655440002', + version: 2, + author: 'nazdsn', + releaseDate: new Date('2026-06-28T10:00:00').getTime(), + poguesVersionId: '5ddf61df-00c8-4018-a763-fa7bc91b0162', + releaseDescription: 'ESA 2026 TEST TERRAIN', + mode: 'CAPI', + context: 'BUSINESS', + overrideGenerationParameters: { + questionNumberingMode: 'NONE', + responseTimeQuestion: false, + }, + visualizeUrl: 'https://visu.example.com/esa-2026-test', + }, + { + collectionInstrumentId: '550e8400-e29b-41d4-a716-446655440003', + version: 1, + author: 'bcbab8', + releaseDate: new Date('2026-06-15T11:30:00').getTime(), + poguesVersionId: 'b77e7cad-475d-4d83-b036-fa7a98a84a8a', + releaseDescription: "Publication la plus ancienne d'ESA", + mode: 'CAWI', + context: 'HOUSEHOLD', + overrideGenerationParameters: null, + visualizeUrl: 'https://visu.example.com/esa-2026-old', + }, +] + +function sortByRequestDateDesc(items: ReleaseRequest[]): ReleaseRequest[] { + return [...items].sort((a, b) => b.requestDate - a.requestDate) +} + +function sortByReleaseDateDesc(items: RegistryRelease[]): RegistryRelease[] { + return [...items].sort((a, b) => b.releaseDate - a.releaseDate) +} + +export default function ReleaseOverview() { + const { t } = useTranslation() + + const [requests, setRequests] = useState(MOCK_REQUESTS) + const publications = MOCK_PUBLICATIONS + + const hasAnyContent = requests.length > 0 || publications.length > 0 + + function handleDeleteRequest(trackerId: number) { + setRequests((prev) => prev.filter((d) => d.trackerId !== trackerId)) + } + + const sortedRequests = sortByRequestDateDesc(requests) + const sortedPublications = sortByReleaseDateDesc(publications) + + if (!hasAnyContent) { + return ( +
+

{t('release.noPublication')}

+
+ ) + } + + return ( +
+ {sortedRequests.length > 0 && ( +
+
+

{t('release.publicationRequests')}

+
+ {sortedRequests.map((request) => ( + + ))} +
+ )} + + {sortedPublications.length > 0 && ( +
+
+

{t('release.myPublications')}

+
+ {sortedPublications.map((release) => ( + + ))} +
+ )} +
+ ) +} diff --git a/next/src/components/release/ReleasesOverviewLayout.test.tsx b/next/src/components/release/ReleasesOverviewLayout.test.tsx new file mode 100644 index 000000000..7622ae266 --- /dev/null +++ b/next/src/components/release/ReleasesOverviewLayout.test.tsx @@ -0,0 +1,50 @@ +import { renderWithI18n } from '@/testing/render' + +import ReleaseOverviewLayout from './ReleasesOverviewLayout' + +vi.mock('@/components/ui/ButtonLink', () => ({ + default: ({ children, to }: { children: React.ReactNode; to: string }) => ( + {children} + ), +})) + +vi.mock('@/components/ui/Breadcrumb', () => ({ + default: () => null, +})) + +describe('ReleaseOverviewLayout', () => { + it('renders title and create button', () => { + const { getByText } = renderWithI18n( + +

child content

+
, + ) + + expect(getByText('Releases')).toBeInTheDocument() + expect(getByText('Create a release')).toBeInTheDocument() + }) + + it('renders children', () => { + const { getByText } = renderWithI18n( + +

child content

+
, + ) + + expect(getByText('child content')).toBeInTheDocument() + }) + + it('renders create button with correct link', () => { + const { container } = renderWithI18n( + +

child content

+
, + ) + + const link = container.querySelector('a') + expect(link).toHaveAttribute( + 'href', + '/questionnaire/$questionnaireId/releases/new', + ) + }) +}) diff --git a/next/src/components/release/ReleasesOverviewLayout.tsx b/next/src/components/release/ReleasesOverviewLayout.tsx new file mode 100644 index 000000000..cb9b54c60 --- /dev/null +++ b/next/src/components/release/ReleasesOverviewLayout.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from 'react-i18next' + +import ContentWrapper from '@/components/layout/ContentWrapper' + +import ButtonLink from '../ui/ButtonLink' + +type Props = { + children: React.ReactNode + questionnaireId?: string +} + +/** Display "release" title and use default content style. */ +export default function ReleaseOverviewLayout({ + children, + questionnaireId = '', +}: Readonly) { + const { t } = useTranslation() + + return ( + + {t('release.create')} + + } + title={t('release.title')} + > + {children} + + ) +} diff --git a/next/src/components/release/create/CreateRelease.tsx b/next/src/components/release/create/CreateRelease.tsx new file mode 100644 index 000000000..9ed1be842 --- /dev/null +++ b/next/src/components/release/create/CreateRelease.tsx @@ -0,0 +1,24 @@ +import type { TargetModes } from '@/models/questionnaires' + +import CreateReleaseForm from './CreateReleaseForm' + +type Props = { + questionnaireId: string + targetModes: Set +} + +export default function CreateRelease({ + questionnaireId, + targetModes, +}: Readonly) { + return ( +
+ +
+ ) +} diff --git a/next/src/components/release/create/CreateReleaseForm.tsx b/next/src/components/release/create/CreateReleaseForm.tsx new file mode 100644 index 000000000..13d6638f7 --- /dev/null +++ b/next/src/components/release/create/CreateReleaseForm.tsx @@ -0,0 +1,78 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from '@tanstack/react-router' +import toast from 'react-hot-toast' +import { useTranslation } from 'react-i18next' + +import { questionnairesKeys } from '@/api/questionnaires' +import { TargetModes } from '@/models/questionnaires' + +import ReleaseForm from '../form/ReleaseForm' +import { type FormValues } from '../form/schema' + +type Props = { + questionnaireId: string + seriesId?: string + seriesLabel?: string + targetModes: Set +} + +export default function CreateReleaseForm({ + questionnaireId, + seriesId, + seriesLabel, + targetModes, +}: Readonly) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const navigate = useNavigate() + + const mutation = useMutation({ + mutationFn: async (formValues: FormValues) => { + // TODO: Replace with actual API call when release endpoint is available + const response = await fetch('/api/releases', { + method: 'POST', + body: JSON.stringify({ + questionnaireId, + ...formValues, + }), + headers: { 'Content-Type': 'application/json' }, + }) + if (!response.ok) { + throw new Error('Failed to create release') + } + return response.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: questionnairesKeys.detail(questionnaireId), + }) + }, + }) + + const onSubmit = async (formValues: FormValues) => { + const promise = mutation.mutateAsync(formValues, { + onSuccess: () => { + navigate({ + to: '/questionnaire/$questionnaireId/releases', + params: { questionnaireId }, + }) + }, + }) + toast.promise(promise, { + loading: t('common.loading'), + success: t('release.create.success'), + error: (err: Error) => err.toString(), + }) + } + + return ( + + ) +} diff --git a/next/src/components/release/create/CreateReleaseLayout.tsx b/next/src/components/release/create/CreateReleaseLayout.tsx new file mode 100644 index 000000000..7791e75d3 --- /dev/null +++ b/next/src/components/release/create/CreateReleaseLayout.tsx @@ -0,0 +1,17 @@ +import { useTranslation } from 'react-i18next' + +import ContentWrapper from '@/components/layout/ContentWrapper' + +type Props = { + children: React.ReactNode +} + +export default function CreateReleaseLayout({ children }: Readonly) { + const { t } = useTranslation() + + return ( + + {children} + + ) +} diff --git a/next/src/components/release/form/ReleaseForm.tsx b/next/src/components/release/form/ReleaseForm.tsx new file mode 100644 index 000000000..3cc5bc05b --- /dev/null +++ b/next/src/components/release/form/ReleaseForm.tsx @@ -0,0 +1,305 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useNavigate } from '@tanstack/react-router' +import { Controller, type SubmitHandler, useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import Tooltip from '@/components/ui/Tooltip' +import Checkbox from '@/components/ui/form/Checkbox' +import Field from '@/components/ui/form/Field' +import Form from '@/components/ui/form/Form' +import Input from '@/components/ui/form/Input' +import RadioGroup from '@/components/ui/form/RadioGroup' +import Select from '@/components/ui/form/Select' +import InfoIcon from '@/components/ui/icons/InfoIcon' +import { TargetModes } from '@/models/questionnaires' + +import { CONTEXTE_OPTIONS, NUMEROTATION_OPTIONS } from './consts' +import { type FormValues, schema } from './schema' + +type Props = { + questionnaireId: string + seriesId?: string + seriesLabel?: string + targetModes: Set + onSubmit: SubmitHandler + submitLabel: string +} + +export default function ReleaseForm({ + questionnaireId, + seriesId, + seriesLabel, + onSubmit, + submitLabel, +}: Readonly) { + const { t } = useTranslation() + const navigate = useNavigate() + + const { + control, + handleSubmit, + formState: { isDirty, isSubmitted, isValid }, + watch, + } = useForm({ + mode: 'onChange', + defaultValues: { + description: '', + collectMode: TargetModes.CAWI, + contexte: 'menages', + pageTempsReponse: false, + numerotationQuestions: 'sequence', + }, + resolver: zodResolver(schema), + }) + + const contexteValue = watch('contexte') + + const isSeriesMissing = !seriesId || !seriesLabel + const isFormValid = isValid && !isSeriesMissing + + const handleCancel = () => { + navigate({ + to: '/questionnaire/$questionnaireId/releases', + params: { questionnaireId }, + ignoreBlocker: true, + }) + } + + return ( +
+

+ {t('release.form.introduction')} +

+ +

+ {t('release.form.seriesInfo')} +

+
+

+ + {t('release.form.series.id')} :{' '} + + {seriesId ? ( + {seriesId} + ) : ( + <> + + {t('release.form.series.missingId')} + + + + + + )} +

+

+ + {t('release.form.series.label')} :{' '} + + {seriesLabel || ( + <> + + {t('release.form.series.missingLabel')} + + + + + + )} +

+
+ +
+ ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + {contexteValue === 'entreprise' ? ( +
+

+ {t('release.form.optionalParameters')} +

+ +
+ ( + <> + + {!value ? ( +

+ {t('release.form.pageTempsReponse.warning')} +

+ ) : null} + + )} + /> +
+ +
+
+ ( + + {t('release.form.numerotationQuestions.label')} + + + + +
+ } + name={name} + touched={isTouched} + > + + options={NUMEROTATION_OPTIONS} + value={value} + onChange={onChange} + /> + + )} + /> +
+
+
+ ) : null} + +
+ ) +} diff --git a/next/src/components/release/form/consts.ts b/next/src/components/release/form/consts.ts new file mode 100644 index 000000000..2bbe0c710 --- /dev/null +++ b/next/src/components/release/form/consts.ts @@ -0,0 +1,24 @@ +import { t } from 'i18next' + +export const CONTEXTE_OPTIONS = [ + { label: t('release.form.contexte.menages'), value: 'menages' as const }, + { + label: t('release.form.contexte.entreprise'), + value: 'entreprise' as const, + }, +] + +export const NUMEROTATION_OPTIONS = [ + { + label: t('release.form.numerotationQuestions.none'), + value: 'none' as const, + }, + { + label: t('release.form.numerotationQuestions.sequence'), + value: 'sequence' as const, + }, + { + label: t('release.form.numerotationQuestions.questionnaire'), + value: 'questionnaire' as const, + }, +] diff --git a/next/src/components/release/form/schema.ts b/next/src/components/release/form/schema.ts new file mode 100644 index 000000000..077b3dfef --- /dev/null +++ b/next/src/components/release/form/schema.ts @@ -0,0 +1,23 @@ +import z from 'zod' + +import i18next from '@/lib/i18n' +import { TargetModes } from '@/models/questionnaires' + +export const schema = z.object({ + description: z + .string() + .min(1, { message: i18next.t('release.form.description.required') }) + .max(249, { message: i18next.t('release.form.description.maxLength') }), + collectMode: z.enum(TargetModes, { + error: i18next.t('release.form.collectMode.required'), + }), + contexte: z.enum(['menages', 'entreprise'], { + error: i18next.t('release.form.contexte.required'), + }), + pageTempsReponse: z.boolean().default(true), + numerotationQuestions: z + .enum(['none', 'sequence', 'questionnaire']) + .default('sequence'), +}) + +export type FormValues = z.input diff --git a/next/src/components/release/overview/CopyButton.test.tsx b/next/src/components/release/overview/CopyButton.test.tsx new file mode 100644 index 000000000..517f92a54 --- /dev/null +++ b/next/src/components/release/overview/CopyButton.test.tsx @@ -0,0 +1,43 @@ +import userEvent from '@testing-library/user-event' + +import { renderWithI18n } from '@/testing/render' + +import { CopyButton } from './CopyButton' + +describe('CopyButton', () => { + beforeEach(() => { + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: vi.fn().mockResolvedValue(undefined), + }, + configurable: true, + writable: true, + }) + }) + + it('renders with copy icon initially', () => { + const { container } = renderWithI18n() + + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + }) + + it('has correct accessible name before copy', () => { + const { getByRole } = renderWithI18n() + + expect( + getByRole('button', { name: 'Copy to clipboard' }), + ).toBeInTheDocument() + }) + + it('copies text and shows copied label after click', async () => { + const user = userEvent.setup() + const { findByText, container } = renderWithI18n( + , + ) + + await user.click(container.querySelector('button')!) + + expect(await findByText('Copied')).toBeInTheDocument() + }) +}) diff --git a/next/src/components/release/overview/CopyButton.tsx b/next/src/components/release/overview/CopyButton.tsx new file mode 100644 index 000000000..39c4c8c14 --- /dev/null +++ b/next/src/components/release/overview/CopyButton.tsx @@ -0,0 +1,37 @@ +import { useTranslation } from 'react-i18next' + +import { useState } from 'react' + +import CopyIcon from '@/components/ui/icons/CopyIcon' + +export function CopyButton({ text }: Readonly<{ text: string }>) { + const { t } = useTranslation() + const [copied, setCopied] = useState(false) + + async function handleCopy() { + try { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + //idl what to put here + } + } + + return ( + + ) +} diff --git a/next/src/components/release/overview/RegistryReleaseTile.test.tsx b/next/src/components/release/overview/RegistryReleaseTile.test.tsx new file mode 100644 index 000000000..eb9014e64 --- /dev/null +++ b/next/src/components/release/overview/RegistryReleaseTile.test.tsx @@ -0,0 +1,134 @@ +import { TargetModes } from '@/models/questionnaires' +import { renderWithI18n } from '@/testing/render' +import { computeDayFromDate } from '@/utils/date' + +import type { RegistryRelease } from '../../../models/releases' +import { RegistryReleaseTile } from './RegistryReleaseTile' + +describe('RegistryReleaseTile', () => { + const mockRelease: RegistryRelease = { + collectionInstrumentId: 'COL-123', + version: 2, + author: 'maelle', + releaseDate: new Date('2025-06-15T10:00:00Z').getTime(), + poguesVersionId: '550e8400-e29b-41d4-a716-446655440001', + releaseDescription: 'ESA 2026', + mode: TargetModes.CAPI, + context: 'BUSINESS', + overrideGenerationParameters: { + questionNumberingMode: 'SEQUENCE', + responseTimeQuestion: true, + }, + visualizeUrl: 'https://visu.example.com/esa-2026', + } + + beforeEach(() => { + vi.stubEnv('VITE_TROMBI_URL', 'https://trombi.example.com') + }) + + it('displays all publication information', () => { + const { getByText } = renderWithI18n( + , + ) + + expect(getByText('ESA 2026')).toBeInTheDocument() + expect(getByText('COL-123')).toBeInTheDocument() + expect(getByText('2')).toBeInTheDocument() + expect(getByText('BUSINESS')).toBeInTheDocument() + expect( + getByText('550e8400-e29b-41d4-a716-446655440001'), + ).toBeInTheDocument() + expect(getByText('maelle')).toBeInTheDocument() + expect( + getByText(computeDayFromDate(new Date(mockRelease.releaseDate))), + ).toBeInTheDocument() + }) + + it('shows optional parameters when context is BUSINESS', () => { + const { getByText } = renderWithI18n( + , + ) + + expect( + getByText((content) => content.startsWith('Optional parameters')), + ).toBeInTheDocument() + expect(getByText('SEQUENCE')).toBeInTheDocument() + }) + + it('does not show optional parameters when context is not BUSINESS', () => { + const release: RegistryRelease = { + ...mockRelease, + context: 'HOUSEHOLD', + } + const { queryByText } = renderWithI18n( + , + ) + + expect( + queryByText((content) => content.startsWith('Optional parameters')), + ).toBeNull() + }) + + it('does not show optional parameters when overrideGenerationParameters is null', () => { + const release: RegistryRelease = { + ...mockRelease, + overrideGenerationParameters: null, + } + const { queryByText } = renderWithI18n( + , + ) + + expect( + queryByText((content) => content.startsWith('Optional parameters')), + ).toBeNull() + }) + + it('renders visualize link with visualizeUrl', () => { + const { getAllByRole } = renderWithI18n( + , + ) + + const links = getAllByRole('link') + expect(links[0]).toHaveAttribute( + 'href', + 'https://visu.example.com/esa-2026', + ) + expect(links[0]).toHaveAttribute('target', '_blank') + }) + + it('renders poguesVersionId link with visualizeUrl', () => { + const { getAllByRole } = renderWithI18n( + , + ) + + const links = getAllByRole('link') + expect(links[1]).toHaveAttribute( + 'href', + 'https://visu.example.com/esa-2026', + ) + expect(links[1]).toHaveAttribute('target', '_blank') + }) + + it('renders author link with trombi URL', () => { + const { getByText } = renderWithI18n( + , + ) + + const authorLink = getByText('maelle').closest('a') + expect(authorLink).toHaveAttribute( + 'href', + 'https://trombi.example.com/maelle', + ) + expect(authorLink).toHaveAttribute('target', '_blank') + }) + + it('renders a copy button for the collection instrument ID', () => { + const { getByRole } = renderWithI18n( + , + ) + + expect( + getByRole('button', { name: 'Copy to clipboard' }), + ).toBeInTheDocument() + }) +}) diff --git a/next/src/components/release/overview/RegistryReleaseTile.tsx b/next/src/components/release/overview/RegistryReleaseTile.tsx new file mode 100644 index 000000000..a98931bc0 --- /dev/null +++ b/next/src/components/release/overview/RegistryReleaseTile.tsx @@ -0,0 +1,119 @@ +import { useTranslation } from 'react-i18next' + +import { computeDayFromDate, computeFullDateFromDate } from '@/utils/date' + +import type { RegistryRelease } from '../../../models/releases' +import OpenInNewIcon from '../../ui/icons/OpenInNewIcon' +import VisualizeIcon from '../../ui/icons/VisualizeIcon' +import { CopyButton } from './CopyButton' +import { ReleaseOptionalParametersDisplay } from './ReleaseOptionalParametersDisplay' + +export function RegistryReleaseTile({ + release, +}: Readonly<{ + release: RegistryRelease +}>) { + const { t } = useTranslation() + const trombiUrl = import.meta.env.VITE_TROMBI_URL + const releaseDate = new Date(release.releaseDate) + + return ( +
+
+
+ {release.releaseDescription} + + + +
+ +
+
+ + {t('release.instrumentCollecte')} :{' '} + + {release.collectionInstrumentId}{' '} + +
+
+ + {t('release.version')} :{' '} + + {release.version} +
+
+ +
+ {release.context === 'BUSINESS' && + release.overrideGenerationParameters && ( +
+ +
+ )} +
+ +
+ + {t('release.collectMode')} :{' '} + + {release.mode} +
+
+ + {t('release.contexte')} :{' '} + + {release.context} +
+
+ +
+ + {t('release.sauvegardePogues')} :{' '} + + + {release.poguesVersionId} + + +
+
+ + {t('release.author')} :{' '} + + + {release.author} + + +
+
+ + {t('release.publicationDate')} :{' '} + + +
+
+
+
+ ) +} diff --git a/next/src/components/release/overview/ReleaseOptionalParametersDisplay.test.tsx b/next/src/components/release/overview/ReleaseOptionalParametersDisplay.test.tsx new file mode 100644 index 000000000..b14a4ee16 --- /dev/null +++ b/next/src/components/release/overview/ReleaseOptionalParametersDisplay.test.tsx @@ -0,0 +1,77 @@ +import { renderWithI18n } from '@/testing/render' + +import { ReleaseOptionalParametersDisplay } from './ReleaseOptionalParametersDisplay' + +describe('ReleaseOptionalParametersDisplay', () => { + it('renders optional parameters header', () => { + const { getByText } = renderWithI18n( + , + ) + + expect( + getByText((content) => content.startsWith('Optional parameters')), + ).toBeInTheDocument() + }) + + it('renders checkbox checked when responseTimeQuestion is true', () => { + const { getByRole } = renderWithI18n( + , + ) + + const checkbox = getByRole('checkbox') as HTMLInputElement + expect(checkbox.checked).toBe(true) + expect(checkbox).toBeDisabled() + }) + + it('renders checkbox unchecked when responseTimeQuestion is false', () => { + const { getByRole } = renderWithI18n( + , + ) + + const checkbox = getByRole('checkbox') as HTMLInputElement + expect(checkbox.checked).toBe(false) + }) + + it('displays questionNumberingMode value', () => { + const { getByText } = renderWithI18n( + , + ) + + expect(getByText('SEQUENCE')).toBeInTheDocument() + }) + + it('applies custom innerClassName', () => { + const { container } = renderWithI18n( + , + ) + + const inner = container.querySelector('.custom-class') + expect(inner).toBeInTheDocument() + }) +}) diff --git a/next/src/components/release/overview/ReleaseOptionalParametersDisplay.tsx b/next/src/components/release/overview/ReleaseOptionalParametersDisplay.tsx new file mode 100644 index 000000000..4b7897fa2 --- /dev/null +++ b/next/src/components/release/overview/ReleaseOptionalParametersDisplay.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from 'react-i18next' + +import type { ReleaseRequest } from '../../../models/releases' + +type OverrideGenerationParameters = + ReleaseRequest['overrideGenerationParameters'] + +export function ReleaseOptionalParametersDisplay({ + overrideGenerationParameters, + innerClassName = 'space-y-1', +}: Readonly<{ + overrideGenerationParameters: OverrideGenerationParameters + innerClassName?: string +}>) { + const { t } = useTranslation() + + return ( + <> +
{t('release.optionalParameters')} :
+
+
+ + {t('release.form.pageTempsReponse.label')} + {': '} + + +
+
+ + {t('release.form.numerotationQuestions.label')} :{' '} + + + {overrideGenerationParameters.questionNumberingMode} + +
+
+ + ) +} diff --git a/next/src/components/release/overview/ReleaseRequestTile.test.tsx b/next/src/components/release/overview/ReleaseRequestTile.test.tsx new file mode 100644 index 000000000..f7e0762c2 --- /dev/null +++ b/next/src/components/release/overview/ReleaseRequestTile.test.tsx @@ -0,0 +1,174 @@ +import userEvent from '@testing-library/user-event' + +import { TargetModes } from '@/models/questionnaires' +import { renderWithI18n } from '@/testing/render' +import { computeDayFromDate } from '@/utils/date' + +import type { ReleaseRequest } from '../../../models/releases' +import { ReleaseRequestTile } from './ReleaseRequestTile' + +describe('ReleaseRequestTile', () => { + const baseRequest: ReleaseRequest = { + trackerId: 1, + author: 'maelle', + requestDate: new Date('2025-06-15T10:00:00Z').getTime(), + currentStep: 'BUILD_PARAMETERS', + status: 'RUNNING', + statusDescription: '', + poguesVersionId: '550e8400-e29b-41d4-a716-446655440000', + poguesId: 'ESA2026', + releaseDescription: 'Publication ESA 2026', + mode: TargetModes.CAWI, + context: 'BUSINESS', + overrideGenerationParameters: { + questionNumberingMode: 'NONE', + responseTimeQuestion: false, + }, + } + + beforeEach(() => { + vi.stubEnv('VITE_TROMBI_URL', 'https://trombi.example.com') + }) + + it('displays request description', () => { + const { getByText } = renderWithI18n( + , + ) + + expect(getByText('Publication ESA 2026')).toBeInTheDocument() + }) + + it('shows in progress status badge', () => { + const { getByText } = renderWithI18n( + , + ) + + expect( + getByText((content) => content.startsWith('In progress')), + ).toBeInTheDocument() + }) + + it('shows failed status badge', () => { + const request: ReleaseRequest = { ...baseRequest, status: 'FAILED' } + const { getByText } = renderWithI18n( + , + ) + + expect( + getByText((content) => content.startsWith('Failed')), + ).toBeInTheDocument() + }) + + it('shows error message when failed with statusDescription', async () => { + const user = userEvent.setup() + const request: ReleaseRequest = { + ...baseRequest, + status: 'FAILED', + statusDescription: 'Error during publication', + } + const { getByText, getByRole } = renderWithI18n( + , + ) + + await user.click(getByRole('button', { name: /Publication ESA 2026/ })) + + expect(getByText('Error during publication')).toBeInTheDocument() + }) + + it('does not show error message when in progress', () => { + const request: ReleaseRequest = { + ...baseRequest, + status: 'RUNNING', + statusDescription: 'Should not appear', + } + const { queryByText } = renderWithI18n( + , + ) + + expect(queryByText('Should not appear')).toBeNull() + }) + + it('displays expanded content on click', async () => { + const user = userEvent.setup() + const { getByText, getByRole } = renderWithI18n( + , + ) + + await user.click(getByRole('button', { name: /Publication ESA 2026/ })) + + expect(getByText('BUSINESS')).toBeInTheDocument() + expect(getByText('maelle')).toBeInTheDocument() + expect( + getByText(computeDayFromDate(new Date(baseRequest.requestDate))), + ).toBeInTheDocument() + expect( + getByText((content) => content.startsWith('Optional parameters')), + ).toBeInTheDocument() + expect(getByText('NONE')).toBeInTheDocument() + }) + + it('collapses content on click', async () => { + const user = userEvent.setup() + const { getByRole, queryByText } = renderWithI18n( + , + ) + + const header = getByRole('button', { name: /Publication ESA 2026/ }) + await user.click(header) + await user.click(header) + + expect( + queryByText((content) => content.startsWith('Optional parameters')), + ).toBeNull() + }) + + it('shows delete button when request has failed', async () => { + const user = userEvent.setup() + const request: ReleaseRequest = { ...baseRequest, status: 'FAILED' } + const { getByRole } = renderWithI18n( + , + ) + + await user.click(getByRole('button', { name: /Publication ESA 2026/ })) + + expect( + getByRole('button', { name: 'Delete this request' }), + ).toBeInTheDocument() + }) + + it('shows delete button when request is completed', async () => { + const user = userEvent.setup() + const request: ReleaseRequest = { ...baseRequest, status: 'COMPLETED' } + const { getByRole } = renderWithI18n( + , + ) + + await user.click(getByRole('button', { name: /Publication ESA 2026/ })) + + expect( + getByRole('button', { name: 'Delete this request' }), + ).toBeInTheDocument() + }) + + it('does not show delete button when request is in progress', () => { + const { queryByRole } = renderWithI18n( + , + ) + + expect(queryByRole('button', { name: 'Delete this request' })).toBeNull() + }) + + it('calls onDelete with trackerId when delete button is clicked', async () => { + const user = userEvent.setup() + const onDelete = vi.fn() + const request: ReleaseRequest = { ...baseRequest, status: 'FAILED' } + const { getByRole } = renderWithI18n( + , + ) + + await user.click(getByRole('button', { name: /Publication ESA 2026/ })) + await user.click(getByRole('button', { name: 'Delete this request' })) + + expect(onDelete).toHaveBeenCalledWith(1) + }) +}) diff --git a/next/src/components/release/overview/ReleaseRequestTile.tsx b/next/src/components/release/overview/ReleaseRequestTile.tsx new file mode 100644 index 000000000..b73f3e787 --- /dev/null +++ b/next/src/components/release/overview/ReleaseRequestTile.tsx @@ -0,0 +1,177 @@ +import { useTranslation } from 'react-i18next' + +import { useState } from 'react' + +import ExpandButton from '@/components/ui/ExpandButton' +import Tooltip from '@/components/ui/Tooltip' +import DeleteIcon from '@/components/ui/icons/DeleteIcon' +import OpenInNewIcon from '@/components/ui/icons/OpenInNewIcon' +import { computeDayFromDate, computeFullDateFromDate } from '@/utils/date' + +import type { ReleaseRequest } from '../../../models/releases' +import { ReleaseOptionalParametersDisplay } from './ReleaseOptionalParametersDisplay' + +function getStatusLabel( + status: ReleaseRequest['status'], + t: (key: string) => string, +): string { + switch (status) { + case 'COMPLETED': + return t('release.status.completed') + case 'FAILED': + return t('release.status.failed') + default: + return t('release.status.inProgress') + } +} + +function getStatusIcon(status: ReleaseRequest['status']): string { + switch (status) { + case 'COMPLETED': + return '\u2705' + case 'FAILED': + return '\u274C' + default: + return '\u2699\uFE0F' + } +} + +function isFailedOrCompleted(status: ReleaseRequest['status']): boolean { + return status === 'FAILED' || status === 'COMPLETED' +} + +export function ReleaseRequestTile({ + request, + onDelete, +}: Readonly<{ + request: ReleaseRequest + onDelete: (trackerId: number) => void +}>) { + const { t } = useTranslation() + const [isExpanded, setIsExpanded] = useState(false) + const contentId = `request-content-${request.trackerId}` + const trombiUrl = import.meta.env.VITE_TROMBI_URL + const requestDate = new Date(request.requestDate) + + const statusBadge = ( +
+ {getStatusLabel(request.status, t)} + {getStatusIcon(request.status)} +
+ ) + + return ( +
+ + {isExpanded && ( +
+
+
+
+ + {t('release.collectMode')} :{' '} + + {request.mode} +
+
+
+ +
+ +
+ +
+ + {t('release.contexte')} :{' '} + + {request.context} +
+
+
+ +
+ + {t('release.author')} :{' '} + + + {request.author} + + +
+
+ + {t('release.demandDate')} :{' '} + + +
+
+
+ {request.status === 'FAILED' && request.statusDescription && ( +
+ {request.statusDescription} +
+ )} +
+
+ {isFailedOrCompleted(request.status) && ( + + )} +
+
+ )} +
+ ) +} diff --git a/next/src/components/ui/ExpandButton.tsx b/next/src/components/ui/ExpandButton.tsx index fe7490ca2..05c76b968 100644 --- a/next/src/components/ui/ExpandButton.tsx +++ b/next/src/components/ui/ExpandButton.tsx @@ -18,7 +18,10 @@ export default function ExpandButton({ return ( + onClick={() => onDelete(request.trackerId)} + buttonStyle={ButtonIconStyle.Delete} + /> )}
diff --git a/next/src/lib/i18n/locales/en/release.json b/next/src/lib/i18n/locales/en/release.json index b13e44d59..75a2de6c0 100644 --- a/next/src/lib/i18n/locales/en/release.json +++ b/next/src/lib/i18n/locales/en/release.json @@ -18,6 +18,7 @@ "optionalParameters": "Optional parameters", "demandDate": "Demand Date", "status": { + "completed": "Validated", "inProgress": "In progress ", "failed": "Failed " }, @@ -27,10 +28,10 @@ "copiedTooltip": "Copied!", "copiedLabel": "Copied" }, - "instrumentCollecte": "Collection instrument", + "collectInstrument": "Collection instrument", "version": "Version", "publicationDate": "Publication date", - "sauvegardePogues": "Pogues save", + "poguesSave": "Pogues save", "form": { "introduction": "You are about to publish in the survey questionnaire register a version of your questionnaire based on the latest save. Make sure you have completed your review beforehand.", "seriesInfo": "Series information", @@ -72,7 +73,7 @@ "label": "Response time page", "warning": "Risk of anomaly if ending on a loop and legal obligation" }, - "numerotationQuestions": { + "questionNumbering": { "label": "Question numbering", "none": "No numbering", "sequence": "By sequence", diff --git a/next/src/lib/i18n/locales/fr/release.json b/next/src/lib/i18n/locales/fr/release.json index c608aebfa..cdb019f51 100644 --- a/next/src/lib/i18n/locales/fr/release.json +++ b/next/src/lib/i18n/locales/fr/release.json @@ -18,6 +18,7 @@ "optionalParameters": "Paramètres optionnels", "demandDate": "Date de demande", "status": { + "completed": "Validé", "inProgress": "En cours ", "failed": "Échec " }, @@ -27,10 +28,10 @@ "copiedTooltip": "Copié !", "copiedLabel": "Copié" }, - "instrumentCollecte": "Instrument de collecte", + "collectInstrument": "Instrument de collecte", "version": "Version", "publicationDate": "Date de publication", - "sauvegardePogues": "Sauvegarde Pogues", + "poguesSave": "Sauvegarde Pogues", "form": { "introduction": "Vous allez publier dans le registre de questionnaires de la filière d'enquêtes une version de votre questionnaire basée sur la dernière sauvegarde. Assurez-vous au préalable d'avoir terminé votre recette.", "seriesInfo": "Informations sur la série", @@ -72,7 +73,7 @@ "label": "Page sur les temps de réponse", "warning": "Risque d'anomalie si termine sur boucle et obligation légale" }, - "numerotationQuestions": { + "questionNumbering": { "label": "Numérotation des questions", "none": "Pas de numérotation", "sequence": "Par séquence", From 215cdf1490c8cc3354d125a974eb26d24dc1d7af Mon Sep 17 00:00:00 2001 From: Mailine Nguyen <64129348+MailineN@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:00:01 +0200 Subject: [PATCH 4/6] fix(style): dropdown styling in release form --- next/src/components/release/form/consts.ts | 8 ++++---- .../components/release/overview/RegistryReleaseTile.tsx | 2 +- next/src/components/ui/form/Select.tsx | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/next/src/components/release/form/consts.ts b/next/src/components/release/form/consts.ts index c3b7099a7..e0daa7b48 100644 --- a/next/src/components/release/form/consts.ts +++ b/next/src/components/release/form/consts.ts @@ -9,14 +9,14 @@ export const CONTEXTE_OPTIONS = [ ] export const NUMEROTATION_OPTIONS = [ - { - label: t('release.form.questionNumbering.none'), - value: 'NONE' as const, - }, { label: t('release.form.questionNumbering.sequence'), value: 'SEQUENCE' as const, }, + { + label: t('release.form.questionNumbering.none'), + value: 'NONE' as const, + }, { label: t('release.form.questionNumbering.questionnaire'), value: 'ALL' as const, diff --git a/next/src/components/release/overview/RegistryReleaseTile.tsx b/next/src/components/release/overview/RegistryReleaseTile.tsx index ba32dac6d..9e644b8f6 100644 --- a/next/src/components/release/overview/RegistryReleaseTile.tsx +++ b/next/src/components/release/overview/RegistryReleaseTile.tsx @@ -31,7 +31,7 @@ export function RegistryReleaseTile({
-
+
{t('release.collectInstrument')} :{' '} diff --git a/next/src/components/ui/form/Select.tsx b/next/src/components/ui/form/Select.tsx index c0a62335f..e0284d702 100644 --- a/next/src/components/ui/form/Select.tsx +++ b/next/src/components/ui/form/Select.tsx @@ -73,6 +73,7 @@ export default function Select({ From ed4b67de1f040d1d5f40cb7d411358fa88fd222e Mon Sep 17 00:00:00 2001 From: Mailine Nguyen <64129348+MailineN@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:39:34 +0200 Subject: [PATCH 5/6] fix: typing & api calls --- next/src/api/models/releaseDTO.ts | 10 +- next/src/api/releases.test.ts | 80 +++------- next/src/api/releases.ts | 144 +++++++++++++++--- .../release/ReleaseOverview.test.tsx | 18 ++- .../components/release/ReleaseOverview.tsx | 111 ++------------ .../release/overview/RegistryReleaseTile.tsx | 3 +- .../release/overview/ReleaseRequestTile.tsx | 3 +- next/src/models/questionnaires.ts | 2 + .../_layout-q/releases/index.tsx | 20 ++- 9 files changed, 193 insertions(+), 198 deletions(-) diff --git a/next/src/api/models/releaseDTO.ts b/next/src/api/models/releaseDTO.ts index 3421fe21e..12752ed27 100644 --- a/next/src/api/models/releaseDTO.ts +++ b/next/src/api/models/releaseDTO.ts @@ -1,3 +1,5 @@ +import type { TargetMode } from '@/models/questionnaires' + /** Model of the registry release returned by the Pogues API's releases endpoint. */ export type RegistryReleaseDTO = { collectionInstrumentId: string @@ -6,7 +8,7 @@ export type RegistryReleaseDTO = { releaseDate: number poguesVersionId: string releaseDescription: string - mode: TargetModesDTO + mode: TargetMode context: ReleaseContextDTO overrideGenerationParameters: OverrideGenerationParametersDTO | null visualizeUrl: string @@ -23,7 +25,7 @@ export type ReleaseRequestDTO = { poguesVersionId: string poguesId: string releaseDescription: string - mode: TargetModesDTO + mode: TargetMode context: ReleaseContextDTO overrideGenerationParameters: OverrideGenerationParametersDTO } @@ -31,7 +33,7 @@ export type ReleaseRequestDTO = { export type CreateReleaseDTO = { poguesId: string releaseDescription: string - mode: TargetModesDTO + mode: TargetMode context: ReleaseContextDTO overrideGenerationParameters: OverrideGenerationParametersDTO } @@ -41,8 +43,6 @@ export type OverrideGenerationParametersDTO = { responseTimeQuestion: boolean } -export type TargetModesDTO = 'CAWI' | 'CAPI' | 'PAPI' | 'CATI' - export type ReleaseContextDTO = 'HOUSEHOLD' | 'BUSINESS' export type CurrentStepDTO = diff --git a/next/src/api/releases.test.ts b/next/src/api/releases.test.ts index e2dae51ee..c4e3f0c18 100644 --- a/next/src/api/releases.test.ts +++ b/next/src/api/releases.test.ts @@ -1,14 +1,9 @@ import nock from 'nock' -import { TargetModes } from '@/models/questionnaires' -import type { RegistryRelease, ReleaseRequest } from '@/models/releases' - -import type { - CreateReleaseDTO, - RegistryReleaseDTO, - ReleaseRequestDTO, -} from './models/releaseDTO' +import type { CreateReleaseDTO } from './models/releaseDTO' import { + MOCK_PUBLICATIONS, + MOCK_REQUESTS, deleteReleaseRequest, getPendingReleases, getReleases, @@ -17,70 +12,31 @@ import { vi.mock('@/lib/auth/oidc') -const registryReleaseDTO: RegistryReleaseDTO = { - collectionInstrumentId: '550e8400-e29b-41d4-a716-446655440001', - version: 3, - author: 'xbeltv', - releaseDate: 1780560000000, - poguesVersionId: '93d1e85c-327d-4153-a5fa-e04f54ca0e3e', - releaseDescription: 'ESA 2026 PROD', - mode: 'CAWI', - context: 'HOUSEHOLD', - overrideGenerationParameters: { - questionNumberingMode: 'NONE', - responseTimeQuestion: false, - }, - visualizeUrl: 'https://visu.example.com/esa-2026-prod', -} - -const registryRelease: RegistryRelease = { - ...registryReleaseDTO, - mode: TargetModes.CAWI, -} - -const releaseRequestDTO: ReleaseRequestDTO = { - trackerId: 1, - author: 'xbeltv', - requestDate: 1780560000000, - currentStep: 'BUILD_PARAMETERS', - status: 'RUNNING', - statusDescription: '', - poguesVersionId: '550e8400-e29b-41d4-a716-446655440000', - poguesId: 'SRCV_REINTERRO', - releaseDescription: 'Release description', - mode: 'CAPI', - context: 'HOUSEHOLD', - overrideGenerationParameters: { - questionNumberingMode: 'SEQUENCE', - responseTimeQuestion: true, - }, -} - -const releaseRequest: ReleaseRequest = { - ...releaseRequestDTO, - mode: TargetModes.CAPI, -} - it('Get releases works', async () => { - const releases: RegistryReleaseDTO[] = [registryReleaseDTO] + // const releases: RegistryReleaseDTO[] = [registryReleaseDTO] - nock('https://mock-api') - .get('/persistence/questionnaire/my-questionnaire/releases') - .reply(200, releases) + // nock('https://mock-api') + // .get('/persistence/questionnaire/my-questionnaire/releases') + // .reply(200, releases) + + // const res = await getReleases('my-questionnaire') + // expect(res).toEqual([registryRelease]) const res = await getReleases('my-questionnaire') - expect(res).toEqual([registryRelease]) + expect(res).toEqual(MOCK_PUBLICATIONS) }) it('Get pending releases works', async () => { - const requests: ReleaseRequestDTO[] = [releaseRequestDTO] + // const requests: ReleaseRequestDTO[] = [releaseRequestDTO] - nock('https://mock-api') - .get('/persistence/questionnaire/my-questionnaire/release-requests') - .reply(200, requests) + // nock('https://mock-api') + // .get('/persistence/questionnaire/my-questionnaire/release-requests') + // .reply(200, requests) + // const res = await getPendingReleases('my-questionnaire') + // expect(res).toEqual([releaseRequest]) const res = await getPendingReleases('my-questionnaire') - expect(res).toEqual([releaseRequest]) + expect(res).toEqual(MOCK_REQUESTS) }) it('Post release works', async () => { diff --git a/next/src/api/releases.ts b/next/src/api/releases.ts index 4435a09b5..19a17da2e 100644 --- a/next/src/api/releases.ts +++ b/next/src/api/releases.ts @@ -1,17 +1,109 @@ import { queryOptions } from '@tanstack/react-query' +import { TargetModes } from '@/models/questionnaires' import type { RegistryRelease, ReleaseRequest } from '@/models/releases' import { instance } from './instance' -import type { - CreateReleaseDTO, - RegistryReleaseDTO, - ReleaseRequestDTO, -} from './models/releaseDTO' -import { - computeRegistryReleases, - computeReleaseRequests, -} from './utils/releases' +import type { CreateReleaseDTO } from './models/releaseDTO' + +export const MOCK_REQUESTS: ReleaseRequest[] = [ + { + trackerId: 1, + author: 'xbeltv', + requestDate: new Date('2026-07-06T10:30:00').getTime(), + currentStep: 'BUILD_PARAMETERS', + status: 'RUNNING', + statusDescription: '', + poguesVersionId: '550e8400-e29b-41d4-a716-446655440000', + poguesId: 'SRCV_REINTERRO', + releaseDescription: 'Recette intégrée oct 2025 pour SRCV_REINTERRO 2026', + mode: TargetModes.CAPI, + context: 'HOUSEHOLD', + overrideGenerationParameters: { + questionNumberingMode: 'SEQUENCE', + responseTimeQuestion: true, + }, + }, + { + trackerId: 2, + author: 'nazdsn', + requestDate: new Date('2026-07-05T14:15:00').getTime(), + currentStep: 'GENERATE_DDI', + status: 'FAILED', + statusDescription: 'Erreur lors de la génération Eno', + poguesVersionId: '550e8400-e29b-41d4-a716-446655440001', + poguesId: 'SRCV_REINTERRO', + releaseDescription: 'Autre demande de publication pour SRCV_REINTERRO 2026', + mode: TargetModes.CAWI, + context: 'BUSINESS', + overrideGenerationParameters: { + questionNumberingMode: 'NONE', + responseTimeQuestion: false, + }, + }, + { + trackerId: 3, + author: 'bcbab8', + requestDate: new Date('2026-07-04T09:00:00').getTime(), + currentStep: 'PUBLISH_DDI', + status: 'FAILED', + statusDescription: 'Erreur 409 - Conflit de version', + poguesVersionId: '550e8400-e29b-41d4-a716-446655440002', + poguesId: 'SRCV_REINTERRO', + releaseDescription: 'Recette intégrée oct 2025 pour SRCV_REINTERRO 2026', + mode: TargetModes.CAWI, + context: 'HOUSEHOLD', + overrideGenerationParameters: { + questionNumberingMode: 'ALL', + responseTimeQuestion: true, + }, + }, +] + +export const MOCK_PUBLICATIONS: RegistryRelease[] = [ + { + collectionInstrumentId: '550e8400-e29b-41d4-a716-446655440001', + version: 3, + author: 'xbeltv', + releaseDate: new Date('2026-07-04T08:00:00').getTime(), + poguesVersionId: '93d1e85c-327d-4153-a5fa-e04f54ca0e3e', + releaseDescription: 'ESA 2026 PROD', + mode: TargetModes.CAWI, + context: 'HOUSEHOLD', + overrideGenerationParameters: { + questionNumberingMode: 'NONE', + responseTimeQuestion: false, + }, + visualizeUrl: 'https://visu.example.com/esa-2026-prod', + }, + { + collectionInstrumentId: '550e8400-e29b-41d4-a716-446655440002', + version: 2, + author: 'nazdsn', + releaseDate: new Date('2026-06-28T10:00:00').getTime(), + poguesVersionId: '5ddf61df-00c8-4018-a763-fa7bc91b0162', + releaseDescription: 'ESA 2026 TEST TERRAIN', + mode: TargetModes.CAPI, + context: 'BUSINESS', + overrideGenerationParameters: { + questionNumberingMode: 'NONE', + responseTimeQuestion: false, + }, + visualizeUrl: 'https://visu.example.com/esa-2026-test', + }, + { + collectionInstrumentId: '550e8400-e29b-41d4-a716-446655440003', + version: 1, + author: 'bcbab8', + releaseDate: new Date('2026-06-15T11:30:00').getTime(), + poguesVersionId: 'b77e7cad-475d-4d83-b036-fa7a98a84a8a', + releaseDescription: "Publication la plus ancienne d'ESA", + mode: TargetModes.CAWI, + context: 'HOUSEHOLD', + overrideGenerationParameters: null, + visualizeUrl: 'https://visu.example.com/esa-2026-old', + }, +] export const releasesKeys = { released: (questionnaireId: string) => ['released', questionnaireId] as const, @@ -40,26 +132,32 @@ export const pendingReleasesQueryOptions = (questionnaireId: string) => export async function getReleases( questionnaireId: string, ): Promise { - return instance - .get(`/persistence/questionnaire/${questionnaireId}/releases`, { - headers: { Accept: 'application/json' }, - }) - .then(({ data }: { data: RegistryReleaseDTO[] }) => { - return computeRegistryReleases(data) - }) + console.log('questionnaireId', questionnaireId) + + // return instance + // .get(`/persistence/questionnaire/${questionnaireId}/releases`, { + // headers: { Accept: 'application/json' }, + // }) + // .then(({ data }: { data: RegistryReleaseDTO[] }) => { + // return computeRegistryReleases(data) + // }) + return Promise.resolve(MOCK_PUBLICATIONS) } /** Retrieve questionnaire pending releases by the questionnaire id. */ export async function getPendingReleases( questionnaireId: string, ): Promise { - return instance - .get(`/persistence/questionnaire/${questionnaireId}/release-requests`, { - headers: { Accept: 'application/json' }, - }) - .then(({ data }: { data: ReleaseRequestDTO[] }) => { - return computeReleaseRequests(data) - }) + console.log('questionnaireId', questionnaireId) + + // return instance + // .get(`/persistence/questionnaire/${questionnaireId}/release-requests`, { + // headers: { Accept: 'application/json' }, + // }) + // .then(({ data }: { data: ReleaseRequestDTO[] }) => { + // return computeReleaseRequests(data) + // }) + return Promise.resolve(MOCK_REQUESTS) } /** Create a new release. */ diff --git a/next/src/components/release/ReleaseOverview.test.tsx b/next/src/components/release/ReleaseOverview.test.tsx index 91420945f..40d5e00c9 100644 --- a/next/src/components/release/ReleaseOverview.test.tsx +++ b/next/src/components/release/ReleaseOverview.test.tsx @@ -1,21 +1,29 @@ +import { MOCK_PUBLICATIONS, MOCK_REQUESTS } from '@/api/releases' import { renderWithI18n } from '@/testing/render' import ReleaseOverview from './ReleaseOverview' +const defaultProps = { + pendingRequests: MOCK_REQUESTS, + releases: MOCK_PUBLICATIONS, +} + describe('ReleaseOverview', () => { beforeEach(() => { vi.stubEnv('VITE_TROMBI_URL', 'https://trombi.example.com') }) it('renders both sections with correct headers', () => { - const { getByText } = renderWithI18n() + const { getByText } = renderWithI18n() expect(getByText('My release requests')).toBeInTheDocument() expect(getByText('My publications')).toBeInTheDocument() }) it('renders all request tiles', () => { - const { getByText, getAllByText } = renderWithI18n() + const { getByText, getAllByText } = renderWithI18n( + , + ) expect( getAllByText('Recette intégrée oct 2025 pour SRCV_REINTERRO 2026').length, @@ -26,7 +34,7 @@ describe('ReleaseOverview', () => { }) it('renders all publication tiles', () => { - const { getByText } = renderWithI18n() + const { getByText } = renderWithI18n() expect(getByText('ESA 2026 PROD')).toBeInTheDocument() expect(getByText('ESA 2026 TEST TERRAIN')).toBeInTheDocument() @@ -34,7 +42,9 @@ describe('ReleaseOverview', () => { }) it('renders requests sorted by date descending', () => { - const { getAllByText } = renderWithI18n() + const { getAllByText } = renderWithI18n( + , + ) const descriptions = getAllByText( (content) => diff --git a/next/src/components/release/ReleaseOverview.tsx b/next/src/components/release/ReleaseOverview.tsx index 292b92f68..2949edab2 100644 --- a/next/src/components/release/ReleaseOverview.tsx +++ b/next/src/components/release/ReleaseOverview.tsx @@ -6,104 +6,10 @@ import type { RegistryRelease, ReleaseRequest } from '../../models/releases' import { RegistryReleaseTile } from './overview/RegistryReleaseTile' import { ReleaseRequestTile } from './overview/ReleaseRequestTile' -const MOCK_REQUESTS: ReleaseRequest[] = [ - { - trackerId: 1, - author: 'xbeltv', - requestDate: new Date('2026-07-06T10:30:00').getTime(), - currentStep: 'BUILD_PARAMETERS', - status: 'RUNNING', - statusDescription: '', - poguesVersionId: '550e8400-e29b-41d4-a716-446655440000', - poguesId: 'SRCV_REINTERRO', - releaseDescription: 'Recette intégrée oct 2025 pour SRCV_REINTERRO 2026', - mode: 'CAPI', - context: 'HOUSEHOLD', - overrideGenerationParameters: { - questionNumberingMode: 'SEQUENCE', - responseTimeQuestion: true, - }, - }, - { - trackerId: 2, - author: 'nazdsn', - requestDate: new Date('2026-07-05T14:15:00').getTime(), - currentStep: 'GENERATE_DDI', - status: 'FAILED', - statusDescription: 'Erreur lors de la génération Eno', - poguesVersionId: '550e8400-e29b-41d4-a716-446655440001', - poguesId: 'SRCV_REINTERRO', - releaseDescription: 'Autre demande de publication pour SRCV_REINTERRO 2026', - mode: 'CAPI', - context: 'BUSINESS', - overrideGenerationParameters: { - questionNumberingMode: 'NONE', - responseTimeQuestion: false, - }, - }, - { - trackerId: 3, - author: 'bcbab8', - requestDate: new Date('2026-07-04T09:00:00').getTime(), - currentStep: 'PUBLISH_DDI', - status: 'FAILED', - statusDescription: 'Erreur 409 - Conflit de version', - poguesVersionId: '550e8400-e29b-41d4-a716-446655440002', - poguesId: 'SRCV_REINTERRO', - releaseDescription: 'Recette intégrée oct 2025 pour SRCV_REINTERRO 2026', - mode: 'CAPI', - context: 'HOUSEHOLD', - overrideGenerationParameters: { - questionNumberingMode: 'ALL', - responseTimeQuestion: true, - }, - }, -] - -const MOCK_PUBLICATIONS: RegistryRelease[] = [ - { - collectionInstrumentId: '550e8400-e29b-41d4-a716-446655440001', - version: 3, - author: 'xbeltv', - releaseDate: new Date('2026-07-04T08:00:00').getTime(), - poguesVersionId: '93d1e85c-327d-4153-a5fa-e04f54ca0e3e', - releaseDescription: 'ESA 2026 PROD', - mode: 'CAWI', - context: 'HOUSEHOLD', - overrideGenerationParameters: { - questionNumberingMode: 'NONE', - responseTimeQuestion: false, - }, - visualizeUrl: 'https://visu.example.com/esa-2026-prod', - }, - { - collectionInstrumentId: '550e8400-e29b-41d4-a716-446655440002', - version: 2, - author: 'nazdsn', - releaseDate: new Date('2026-06-28T10:00:00').getTime(), - poguesVersionId: '5ddf61df-00c8-4018-a763-fa7bc91b0162', - releaseDescription: 'ESA 2026 TEST TERRAIN', - mode: 'CAPI', - context: 'BUSINESS', - overrideGenerationParameters: { - questionNumberingMode: 'NONE', - responseTimeQuestion: false, - }, - visualizeUrl: 'https://visu.example.com/esa-2026-test', - }, - { - collectionInstrumentId: '550e8400-e29b-41d4-a716-446655440003', - version: 1, - author: 'bcbab8', - releaseDate: new Date('2026-06-15T11:30:00').getTime(), - poguesVersionId: 'b77e7cad-475d-4d83-b036-fa7a98a84a8a', - releaseDescription: "Publication la plus ancienne d'ESA", - mode: 'CAWI', - context: 'HOUSEHOLD', - overrideGenerationParameters: null, - visualizeUrl: 'https://visu.example.com/esa-2026-old', - }, -] +interface ReleaseOveriewProps { + pendingRequests: ReleaseRequest[] + releases: RegistryRelease[] +} function sortByRequestDateDesc(items: ReleaseRequest[]): ReleaseRequest[] { return [...items].sort((a, b) => b.requestDate - a.requestDate) @@ -113,11 +19,14 @@ function sortByReleaseDateDesc(items: RegistryRelease[]): RegistryRelease[] { return [...items].sort((a, b) => b.releaseDate - a.releaseDate) } -export default function ReleaseOverview() { +export default function ReleaseOverview({ + pendingRequests, + releases, +}: Readonly) { const { t } = useTranslation() - const [requests, setRequests] = useState(MOCK_REQUESTS) - const publications = MOCK_PUBLICATIONS + const [requests, setRequests] = useState(pendingRequests) + const publications = releases const hasAnyContent = requests.length > 0 || publications.length > 0 diff --git a/next/src/components/release/overview/RegistryReleaseTile.tsx b/next/src/components/release/overview/RegistryReleaseTile.tsx index 9e644b8f6..51d84ec71 100644 --- a/next/src/components/release/overview/RegistryReleaseTile.tsx +++ b/next/src/components/release/overview/RegistryReleaseTile.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next' +import { TargetModes } from '@/models/questionnaires' import { computeDayFromDate, computeFullDateFromDate } from '@/utils/date' import type { RegistryRelease } from '../../../models/releases' @@ -65,7 +66,7 @@ export function RegistryReleaseTile({ {t('release.collectMode')} :{' '} - {release.mode} + {TargetModes[release.mode]}
diff --git a/next/src/components/release/overview/ReleaseRequestTile.tsx b/next/src/components/release/overview/ReleaseRequestTile.tsx index 857d3a44c..62c479f90 100644 --- a/next/src/components/release/overview/ReleaseRequestTile.tsx +++ b/next/src/components/release/overview/ReleaseRequestTile.tsx @@ -8,6 +8,7 @@ import ExpandButton from '@/components/ui/ExpandButton' import Tooltip from '@/components/ui/Tooltip' import DeleteIcon from '@/components/ui/icons/DeleteIcon' import OpenInNewIcon from '@/components/ui/icons/OpenInNewIcon' +import { TargetModes } from '@/models/questionnaires' import { computeDayFromDate, computeFullDateFromDate } from '@/utils/date' import type { ReleaseRequest } from '../../../models/releases' @@ -90,7 +91,7 @@ export function ReleaseRequestTile({ {t('release.collectMode')} :{' '} - {request.mode} + {TargetModes[request.mode]}
diff --git a/next/src/models/questionnaires.ts b/next/src/models/questionnaires.ts index cd1d4ef25..ea3c674c7 100644 --- a/next/src/models/questionnaires.ts +++ b/next/src/models/questionnaires.ts @@ -22,6 +22,8 @@ export enum TargetModes { CATI, } +export type TargetMode = keyof typeof TargetModes + export enum FlowLogics { Filter = 'FILTER', Redirection = 'REDIRECTION', diff --git a/next/src/routes/_layout/questionnaire.$questionnaireId/_layout-q/releases/index.tsx b/next/src/routes/_layout/questionnaire.$questionnaireId/_layout-q/releases/index.tsx index aad9d69c4..26da21e4a 100644 --- a/next/src/routes/_layout/questionnaire.$questionnaireId/_layout-q/releases/index.tsx +++ b/next/src/routes/_layout/questionnaire.$questionnaireId/_layout-q/releases/index.tsx @@ -1,5 +1,10 @@ +import { useSuspenseQuery } from '@tanstack/react-query' import { createFileRoute } from '@tanstack/react-router' +import { + pendingReleasesQueryOptions, + releasesQueryOptions, +} from '@/api/releases' import ErrorComponent from '@/components/layout/ErrorComponent' import ReleaseOverview from '@/components/release/ReleaseOverview' import ReleaseOverviewLayout from '@/components/release/ReleasesOverviewLayout' @@ -13,14 +18,27 @@ export const Route = createFileRoute( ), + loader: async ({ context: { queryClient }, params: { questionnaireId } }) => { + await Promise.all([ + queryClient.ensureQueryData(releasesQueryOptions(questionnaireId)), + queryClient.ensureQueryData(pendingReleasesQueryOptions(questionnaireId)), + ]) + }, }) function RouteComponent() { const questionnaireId = Route.useParams().questionnaireId + const { data: requests } = useSuspenseQuery( + pendingReleasesQueryOptions(questionnaireId), + ) + const { data: releases } = useSuspenseQuery( + releasesQueryOptions(questionnaireId), + ) + return ( - + ) } From 4d7d7ea0034eb0b7b856beb223df80103f66909d Mon Sep 17 00:00:00 2001 From: Mailine Nguyen <64129348+MailineN@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:45:51 +0200 Subject: [PATCH 6/6] bump: 3.6.2-rc-releases-page.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6199771db..45fe9f8dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pogues", - "version": "3.6.1", + "version": "3.6.2-rc-releases-page.0", "description": "Outil de conception et de test de questionnaires.", "scripts": { "test:next": "cd next && pnpm test",