diff --git a/CHANGELOG.md b/CHANGELOG.md index ae97e8586..a770621a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Page to overview released and pending releases of a questionnaire +- Form to create a release of a questionnaire + ### Changed - change end-point to get units in legacy client (according to the API) diff --git a/next/src/api/models/releaseDTO.ts b/next/src/api/models/releaseDTO.ts new file mode 100644 index 000000000..12752ed27 --- /dev/null +++ b/next/src/api/models/releaseDTO.ts @@ -0,0 +1,63 @@ +import type { TargetMode } from '@/models/questionnaires' + +/** Model of the registry release returned by the Pogues API's releases endpoint. */ +export type RegistryReleaseDTO = { + collectionInstrumentId: string + version: number + author: string + releaseDate: number + poguesVersionId: string + releaseDescription: string + mode: TargetMode + context: ReleaseContextDTO + overrideGenerationParameters: OverrideGenerationParametersDTO | null + visualizeUrl: string +} + +/** Model of the release request returned by the Pogues API's release-requests endpoint. */ +export type ReleaseRequestDTO = { + trackerId: number + author: string + requestDate: number + currentStep: CurrentStepDTO + status: ReleaseRequestStatusDTO + statusDescription: string + poguesVersionId: string + poguesId: string + releaseDescription: string + mode: TargetMode + context: ReleaseContextDTO + overrideGenerationParameters: OverrideGenerationParametersDTO +} + +export type CreateReleaseDTO = { + poguesId: string + releaseDescription: string + mode: TargetMode + context: ReleaseContextDTO + overrideGenerationParameters: OverrideGenerationParametersDTO +} + +export type OverrideGenerationParametersDTO = { + questionNumberingMode: QuestionNumberingModeDTO + responseTimeQuestion: boolean +} + +export type ReleaseContextDTO = 'HOUSEHOLD' | 'BUSINESS' + +export type CurrentStepDTO = + | 'BUILD_PARAMETERS' + | 'PUBLISH_PRERELEASE' + | 'GENERATE_DDI' + | 'GENERATE_LUNATIC' + | 'PUBLISH_DDI' + | 'PUBLISH_LUNATIC' + | 'FINISHED' + +export type ReleaseRequestStatusDTO = + | 'CREATED' + | 'RUNNING' + | 'COMPLETED' + | 'FAILED' + +export type QuestionNumberingModeDTO = 'NONE' | 'SEQUENCE' | 'ALL' diff --git a/next/src/api/releases.test.ts b/next/src/api/releases.test.ts new file mode 100644 index 000000000..c4e3f0c18 --- /dev/null +++ b/next/src/api/releases.test.ts @@ -0,0 +1,69 @@ +import nock from 'nock' + +import type { CreateReleaseDTO } from './models/releaseDTO' +import { + MOCK_PUBLICATIONS, + MOCK_REQUESTS, + deleteReleaseRequest, + getPendingReleases, + getReleases, + postRelease, +} from './releases' + +vi.mock('@/lib/auth/oidc') + +it('Get releases works', async () => { + // const releases: RegistryReleaseDTO[] = [registryReleaseDTO] + + // 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(MOCK_PUBLICATIONS) +}) + +it('Get pending releases works', async () => { + // const requests: ReleaseRequestDTO[] = [releaseRequestDTO] + + // 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(MOCK_REQUESTS) +}) + +it('Post release works', async () => { + const createRelease: CreateReleaseDTO = { + poguesId: 'my-questionnaire', + releaseDescription: 'New release', + mode: 'CAWI', + context: 'HOUSEHOLD', + overrideGenerationParameters: { + questionNumberingMode: 'SEQUENCE', + responseTimeQuestion: true, + }, + } + + nock('https://mock-api') + .post('/persistence/questionnaire/my-questionnaire/releases') + .reply(201) + + const res = await postRelease('my-questionnaire', createRelease) + expect(res.status).toEqual(201) +}) + +it('Delete release request works', async () => { + nock('https://mock-api') + .delete('/persistence/questionnaire/my-questionnaire/release-requests/42') + .reply(204) + + const res = await deleteReleaseRequest('my-questionnaire', 42) + expect(res.status).toEqual(204) +}) diff --git a/next/src/api/releases.ts b/next/src/api/releases.ts new file mode 100644 index 000000000..19a17da2e --- /dev/null +++ b/next/src/api/releases.ts @@ -0,0 +1,185 @@ +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 } 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, + pending: (questionnaireId: string) => ['pending', questionnaireId] as const, +} + +/** + * Used to retrieve questionnaire registry releases associated to its id. + */ +export const releasesQueryOptions = (questionnaireId: string) => + queryOptions({ + queryKey: releasesKeys.released(questionnaireId), + queryFn: () => getReleases(questionnaireId), + }) + +/** + * Used to retrieve pending questionnaire registry releases associated to its id. + */ +export const pendingReleasesQueryOptions = (questionnaireId: string) => + queryOptions({ + queryKey: releasesKeys.pending(questionnaireId), + queryFn: () => getPendingReleases(questionnaireId), + }) + +/** Retrieve questionnaire releases by the questionnaire id. */ +export async function getReleases( + questionnaireId: string, +): Promise { + 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 { + 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. */ +export async function postRelease( + questionnaireId: string, + release: CreateReleaseDTO, +): Promise { + return instance.post( + `/persistence/questionnaire/${questionnaireId}/releases`, + release, + { + headers: { 'Content-Type': 'application/json' }, + }, + ) +} + +/** Delete a release request by tracker id. */ +export async function deleteReleaseRequest( + questionnaireId: string, + trackerId: number, +): Promise { + return instance.delete( + `/persistence/questionnaire/${questionnaireId}/release-requests/${trackerId}`, + ) +} diff --git a/next/src/api/utils/releases.test.ts b/next/src/api/utils/releases.test.ts new file mode 100644 index 000000000..08ab60977 --- /dev/null +++ b/next/src/api/utils/releases.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' + +import { TargetModes } from '@/models/questionnaires' + +import type { + RegistryReleaseDTO, + ReleaseRequestDTO, +} from '../models/releaseDTO' +import { + computeRegistryRelease, + computeRegistryReleaseDTO, + computeReleaseRequest, + computeReleaseRequestDTO, +} from './releases' + +describe('computeRegistryRelease', () => { + it.each([ + ['CAWI', TargetModes.CAWI], + ['CAPI', TargetModes.CAPI], + ['PAPI', TargetModes.PAPI], + ['CATI', TargetModes.CATI], + ] as const)( + 'should compute a registry release with %s mode correctly', + (modeDTO, modeModel) => { + const dto: RegistryReleaseDTO = { + collectionInstrumentId: '550e8400-e29b-41d4-a716-446655440001', + version: 3, + author: 'xbeltv', + releaseDate: 1780560000000, + poguesVersionId: '93d1e85c-327d-4153-a5fa-e04f54ca0e3e', + releaseDescription: 'ESA 2026 PROD', + mode: modeDTO, + context: 'HOUSEHOLD', + overrideGenerationParameters: { + questionNumberingMode: 'NONE', + responseTimeQuestion: false, + }, + visualizeUrl: 'https://visu.example.com/esa-2026-prod', + } + + const result = computeRegistryRelease(dto) + expect(result).toEqual({ + ...dto, + mode: modeModel, + }) + + const resultDTO = computeRegistryReleaseDTO(result) + expect(resultDTO).toEqual(dto) + }, + ) + + it('should compute a registry release with null overrideGenerationParameters', () => { + const dto: RegistryReleaseDTO = { + collectionInstrumentId: '550e8400-e29b-41d4-a716-446655440001', + version: 1, + author: 'bcbab8', + releaseDate: 1780560000000, + poguesVersionId: 'b77e7cad-475d-4d83-b036-fa7a98a84a8a', + releaseDescription: 'Old publication', + mode: 'CAWI', + context: 'HOUSEHOLD', + overrideGenerationParameters: null, + visualizeUrl: 'https://visu.example.com/old', + } + + const result = computeRegistryRelease(dto) + expect(result).toEqual({ + ...dto, + mode: TargetModes.CAWI, + }) + + const resultDTO = computeRegistryReleaseDTO(result) + expect(resultDTO).toEqual(dto) + }) +}) + +describe('computeReleaseRequest', () => { + it.each([ + ['CAWI', TargetModes.CAWI], + ['CAPI', TargetModes.CAPI], + ['PAPI', TargetModes.PAPI], + ['CATI', TargetModes.CATI], + ] as const)( + 'should compute a release request with %s mode correctly', + (modeDTO, modeModel) => { + const dto: 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: modeDTO, + context: 'HOUSEHOLD', + overrideGenerationParameters: { + questionNumberingMode: 'SEQUENCE', + responseTimeQuestion: true, + }, + } + + const result = computeReleaseRequest(dto) + expect(result).toEqual({ + ...dto, + mode: modeModel, + }) + + const resultDTO = computeReleaseRequestDTO(result) + expect(resultDTO).toEqual(dto) + }, + ) +}) diff --git a/next/src/api/utils/releases.ts b/next/src/api/utils/releases.ts new file mode 100644 index 000000000..e7300d960 --- /dev/null +++ b/next/src/api/utils/releases.ts @@ -0,0 +1,90 @@ +import { TargetModes } from '@/models/questionnaires' +import type { RegistryRelease, ReleaseRequest } from '@/models/releases' + +import type { + RegistryReleaseDTO, + ReleaseRequestDTO, + TargetModesDTO, +} from '../models/releaseDTO' + +/** Compute registry releases from API data. */ +export function computeRegistryReleases( + dtos: RegistryReleaseDTO[], +): RegistryRelease[] { + return dtos.map((dto) => computeRegistryRelease(dto)) +} + +/** Compute a registry release from API data. */ +export function computeRegistryRelease( + dto: RegistryReleaseDTO, +): RegistryRelease { + return { + ...dto, + mode: computeTargetModes(dto.mode), + } +} + +/** Compute a registry release DTO from model data. */ +export function computeRegistryReleaseDTO( + release: RegistryRelease, +): RegistryReleaseDTO { + return { + ...release, + mode: computeTargetModesDTO(release.mode), + } +} + +/** Compute release requests from API data. */ +export function computeReleaseRequests( + dtos: ReleaseRequestDTO[], +): ReleaseRequest[] { + return dtos.map((dto) => computeReleaseRequest(dto)) +} + +/** Compute a release request from API data. */ +export function computeReleaseRequest(dto: ReleaseRequestDTO): ReleaseRequest { + return { + ...dto, + mode: computeTargetModes(dto.mode), + } +} + +/** Compute a release request DTO from model data. */ +export function computeReleaseRequestDTO( + request: ReleaseRequest, +): ReleaseRequestDTO { + return { + ...request, + mode: computeTargetModesDTO(request.mode), + } +} + +function computeTargetModes(mode: TargetModesDTO): TargetModes { + switch (mode) { + case 'CAWI': + return TargetModes.CAWI + case 'CAPI': + return TargetModes.CAPI + case 'PAPI': + return TargetModes.PAPI + case 'CATI': + return TargetModes.CATI + default: + throw new Error('Unknown target mode') + } +} + +function computeTargetModesDTO(mode: TargetModes): TargetModesDTO { + switch (mode) { + case TargetModes.CAWI: + return 'CAWI' + case TargetModes.CAPI: + return 'CAPI' + case TargetModes.PAPI: + return 'PAPI' + case TargetModes.CATI: + return 'CATI' + default: + throw new Error('Unknown target mode') + } +} 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 release 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..2949edab2 --- /dev/null +++ b/next/src/components/release/ReleaseOverview.tsx @@ -0,0 +1,80 @@ +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' + +interface ReleaseOveriewProps { + pendingRequests: ReleaseRequest[] + releases: RegistryRelease[] +} + +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({ + pendingRequests, + releases, +}: Readonly) { + const { t } = useTranslation() + + const [requests, setRequests] = useState(pendingRequests) + const publications = releases + + 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..310e3be5d --- /dev/null +++ b/next/src/components/release/create/CreateReleaseForm.tsx @@ -0,0 +1,82 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from '@tanstack/react-router' +import { isAxiosError } from 'axios' +import toast from 'react-hot-toast' +import { useTranslation } from 'react-i18next' + +import { instance } from '@/api/instance' +import { questionnairesKeys } from '@/api/questionnaires' +import type { 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) => { + try { + const response = await instance.post( + `/api/questionnaire/${questionnaireId}/releases`, + { + poguesId: questionnaireId, + ...formValues, + }, + ) + return response.data + } catch (error) { + if (isAxiosError(error) && error.response?.status === 404) { + throw new Error(t('release.create.notFound')) + } + throw error + } + }, + 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.test.tsx b/next/src/components/release/create/CreateReleaseLayout.test.tsx new file mode 100644 index 000000000..cd67170ce --- /dev/null +++ b/next/src/components/release/create/CreateReleaseLayout.test.tsx @@ -0,0 +1,14 @@ +import { renderWithRouter } from '@/testing/render' + +import CreateReleaseLayout from './CreateReleaseLayout' + +describe('CreateCodesListLayout', () => { + it('displays title and children', async () => { + const { getByText } = await renderWithRouter( + Hello world, + ) + + expect(getByText('New release')).toBeInTheDocument() + expect(getByText('Hello world')).toBeInTheDocument() + }) +}) 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.test.tsx b/next/src/components/release/form/ReleaseForm.test.tsx new file mode 100644 index 000000000..e1c563184 --- /dev/null +++ b/next/src/components/release/form/ReleaseForm.test.tsx @@ -0,0 +1,185 @@ +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { TargetModes } from '@/models/questionnaires' +import { renderWithRouter } from '@/testing/render' + +import ReleaseForm from './ReleaseForm' + +const TARGET_MODES = new Set([ + TargetModes.CAWI, + TargetModes.CAPI, + TargetModes.CATI, +]) + +describe('ReleaseForm', () => { + it('should disable submit button when description is empty', async () => { + await renderWithRouter( + , + ) + + await waitFor(() => { + expect(screen.getByTestId('form-submit-button')).toBeDisabled() + }) + }) + + it('should enable submit button when description is filled', async () => { + const user = userEvent.setup() + + const { getByRole } = await renderWithRouter( + , + ) + + await waitFor(() => { + expect(screen.getByTestId('form-submit-button')).toBeDisabled() + }) + + await user.type( + getByRole('textbox', { name: /Description/i }), + 'My release', + ) + + await waitFor(() => { + expect(screen.getByTestId('form-submit-button')).toBeEnabled() + }) + }) + + it('should call onSubmit with form values when submitted', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn() + + const { getByRole } = await renderWithRouter( + , + ) + + await user.type( + getByRole('textbox', { name: /Description/i }), + 'My release', + ) + + await user.click(screen.getByTestId('form-submit-button')) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + releaseDescription: 'My release', + mode: 'CAWI', + context: 'HOUSEHOLD', + overrideGenerationParameters: expect.objectContaining({ + responseTimeQuestion: false, + questionNumberingMode: 'SEQUENCE', + }), + }), + expect.anything(), + ) + }) + }) + + it('should show optional parameters section for BUSINESS context', async () => { + const user = userEvent.setup() + + await renderWithRouter( + , + ) + + const contextGroup = screen.getByRole('radiogroup', { name: /Context/ }) + const contextRadios = within(contextGroup).getAllByRole('radio') + await user.click(contextRadios[1]) + + await waitFor(() => { + expect(screen.getByText('Optional parameters')).toBeInTheDocument() + }) + }) + + it('should hide optional parameters section for HOUSEHOLD context', async () => { + await renderWithRouter( + , + ) + + expect(screen.queryByText('Optional parameters')).not.toBeInTheDocument() + }) + + it('should toggle optional parameters section when switching context', async () => { + const user = userEvent.setup() + + await renderWithRouter( + , + ) + + const contextGroup = screen.getByRole('radiogroup', { name: /Context/ }) + const contextRadios = within(contextGroup).getAllByRole('radio') + + expect(screen.queryByText('Optional parameters')).not.toBeInTheDocument() + + await user.click(contextRadios[1]) + + await waitFor(() => { + expect(screen.getByText('Optional parameters')).toBeInTheDocument() + }) + + await user.click(contextRadios[0]) + + await waitFor(() => { + expect(screen.queryByText('Optional parameters')).not.toBeInTheDocument() + }) + }) + + it('should disable submit button when series is missing', async () => { + await renderWithRouter( + , + ) + + await waitFor(() => { + expect(screen.getByTestId('form-submit-button')).toBeDisabled() + }) + }) +}) diff --git a/next/src/components/release/form/ReleaseForm.tsx b/next/src/components/release/form/ReleaseForm.tsx new file mode 100644 index 000000000..34b111ee2 --- /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 type { 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: { + releaseDescription: '', + mode: 'CAWI', + context: 'HOUSEHOLD', + overrideGenerationParameters: { + responseTimeQuestion: false, + questionNumberingMode: 'SEQUENCE', + }, + }, + resolver: zodResolver(schema), + }) + + const contextValue = watch('context') + + 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')} + + + + + + )} +

+
+ +
+ ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + {contextValue === 'BUSINESS' ? ( +
+

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

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

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

+ ) : null} + + )} + /> +
+ +
+
+ ( + + {t('release.form.questionNumbering.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..e0daa7b48 --- /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: 'HOUSEHOLD' as const }, + { + label: t('release.form.contexte.entreprise'), + value: 'BUSINESS' as const, + }, +] + +export const NUMEROTATION_OPTIONS = [ + { + 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/form/schema.ts b/next/src/components/release/form/schema.ts new file mode 100644 index 000000000..0aeefc42b --- /dev/null +++ b/next/src/components/release/form/schema.ts @@ -0,0 +1,24 @@ +import z from 'zod' + +import i18next from '@/lib/i18n' + +export const schema = z.object({ + releaseDescription: z + .string() + .min(1, { message: i18next.t('release.form.description.required') }) + .max(249, { message: i18next.t('release.form.description.maxLength') }), + mode: z.enum(['CAWI', 'CAPI', 'CATI'], { + error: i18next.t('release.form.collectMode.required'), + }), + context: z.enum(['HOUSEHOLD', 'BUSINESS'], { + error: i18next.t('release.form.contexte.required'), + }), + overrideGenerationParameters: z.object({ + responseTimeQuestion: z.boolean().default(true), + questionNumberingMode: z + .enum(['NONE', 'SEQUENCE', 'ALL']) + .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..9a0bb350b --- /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('By 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..51d84ec71 --- /dev/null +++ b/next/src/components/release/overview/RegistryReleaseTile.tsx @@ -0,0 +1,120 @@ +import { useTranslation } from 'react-i18next' + +import { TargetModes } from '@/models/questionnaires' +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.collectInstrument')} :{' '} + + {release.collectionInstrumentId}{' '} + +
+
+ + {t('release.version')} :{' '} + + {release.version} +
+
+ +
+ {release.context === 'BUSINESS' && + release.overrideGenerationParameters && ( +
+ +
+ )} +
+ +
+ + {t('release.collectMode')} :{' '} + + {TargetModes[release.mode]} +
+
+ + {t('release.contexte')} :{' '} + + {release.context} +
+
+ +
+ + {t('release.poguesSave')} :{' '} + + + {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..2f4d1f689 --- /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('By 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..0159e9f81 --- /dev/null +++ b/next/src/components/release/overview/ReleaseOptionalParametersDisplay.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from 'react-i18next' + +import type { + QuestionNumberingMode, + ReleaseRequest, +} from '../../../models/releases' + +type OverrideGenerationParameters = + ReleaseRequest['overrideGenerationParameters'] + +const QUESTION_NUMBERING_LABEL_KEY: Record< + QuestionNumberingMode, + 'none' | 'sequence' | 'questionnaire' +> = { + NONE: 'none', + SEQUENCE: 'sequence', + ALL: 'questionnaire', +} + +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.questionNumbering.label')} :{' '} + + + {t( + `release.form.questionNumbering.${QUESTION_NUMBERING_LABEL_KEY[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..d0cc3e013 --- /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('No numbering')).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..62c479f90 --- /dev/null +++ b/next/src/components/release/overview/ReleaseRequestTile.tsx @@ -0,0 +1,164 @@ +import type { TFunction } from 'i18next' +import { useTranslation } from 'react-i18next' + +import { useState } from 'react' + +import ButtonIcon, { ButtonIconStyle } from '@/components/ui/ButtonIcon' +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' +import { ReleaseOptionalParametersDisplay } from './ReleaseOptionalParametersDisplay' + +function getStatusLabel( + status: ReleaseRequest['status'], + t: TFunction, +): string { + switch (status) { + case 'COMPLETED': + return t('release.status.completed') + case 'FAILED': + return t('release.status.failed') + default: + return t('release.status.inProgress') + } +} + +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)} +
+ ) + + return ( +
+ + {isExpanded && ( +
+
+
+
+ + {t('release.collectMode')} :{' '} + + {TargetModes[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) && ( + onDelete(request.trackerId)} + buttonStyle={ButtonIconStyle.Delete} + /> + )} +
+
+ )} +
+ ) +} 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 (