diff --git a/.yarnrc.yml b/.yarnrc.yml index 429ce3777..7a61d4e2f 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -3,7 +3,8 @@ nodeLinker: pnpm packageExtensions: "@codegouvfr/react-dsfr@*": dependencies: - react: "*" + "@types/react": "^18" + "react": "^18" "@turf/dissolve@*": dependencies: "@turf/flatten": "*" diff --git a/frontend/jest.polyfills.js b/frontend/jest.polyfills.js index 4b9531e01..e01c42954 100644 --- a/frontend/jest.polyfills.js +++ b/frontend/jest.polyfills.js @@ -10,6 +10,7 @@ const { TextDecoder, TextEncoder } = require('node:util'); const { ReadableStream, TransformStream } = require('node:stream/web'); +const { BroadcastChannel } = require('node:worker_threads') Object.defineProperties(globalThis, { ReadableStream: { value: ReadableStream }, @@ -22,13 +23,14 @@ const { Blob, File } = require('node:buffer'); const { fetch, Headers, FormData, Request, Response } = require('undici'); Object.defineProperties(globalThis, { - fetch: { value: fetch, writable: true }, + fetch: { value: fetch, writable: true, configurable: true }, Blob: { value: Blob }, File: { value: File }, Headers: { value: Headers }, FormData: { value: FormData }, Request: { value: Request, configurable: true }, - Response: { value: Response, configurable: true } + Response: { value: Response, configurable: true }, + BroadcastChannel: { value: BroadcastChannel }, }); Object.defineProperty(window, 'matchMedia', { diff --git a/frontend/package.json b/frontend/package.json index 97ba83f70..390a0e0f5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,7 +23,7 @@ ] }, "dependencies": { - "@codegouvfr/react-dsfr": "1.9.16", + "@codegouvfr/react-dsfr": "1.16.0", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@hookform/resolvers": "^3.9.1", @@ -36,6 +36,7 @@ "@maplibre/maplibre-gl-style-spec": "^20.3.1", "@mui/material": "^5.16.7", "@reduxjs/toolkit": "^1.9.7", + "@tanstack/react-table": "^8.20.5", "@turf/turf": "^7.1.0", "@typeform/embed-react": "^2.31.0", "@zerologementvacant/models": "workspace:*", @@ -77,8 +78,8 @@ "@craco/craco": "^7.1.0", "@faker-js/faker": "^8.4.1", "@testing-library/dom": "^10.4.0", - "@testing-library/jest-dom": "^6.5.0", - "@testing-library/react": "^16.0.1", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", "@tsconfig/create-react-app": "^2.0.5", "@types/async": "^3.2.24", @@ -89,12 +90,13 @@ "@types/prop-types": "^15.7.13", "@types/qs": "^6", "@types/randomstring": "^1.3.0", - "@types/react": "^18.3.11", - "@types/react-dom": "^18.3.0", + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", "@types/uuid": "^9.0.8", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-extended": "^4.0.2", + "jest-sorted": "^1.0.15", "jest-watch-typeahead": "^2.2.2", "randomstring": "^1.3.0", "react-dev-utils": "^12.0.1", diff --git a/frontend/public/index.html b/frontend/public/index.html index a7d140c86..715af73b6 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -6,13 +6,13 @@ href="https://unpkg.com/maplibre-gl@2.4.0/dist/maplibre-gl.css" rel="stylesheet" /> - - - - + + + + - - + + } /> } /> - } /> + } /> } /> + extends Omit, 'data' | 'getCoreRowModel'>, + PaginationProps { + data?: Data[]; + isLoading?: boolean; + paginationProps?: Omit< + TablePaginationProps, + 'count' | 'defaultPage' | 'getPageLinkProps' + >; + tableProps?: Omit; +} + +interface PaginationProps { + /** + * @default true + */ + paginate?: boolean; + page?: number; + pageCount?: number; + perPage?: number; + onPageChange?(page: number): void; + onPerPageChange?(perPage: number): void; +} + +const ROW_SIZE = 64; +const PER_PAGE_OPTIONS: SelectProps.Option[] = [ + '50', + '200', + '500' +].map((nb) => { + return { label: `${nb} résultats par page`, value: nb }; +}); + +function AdvancedTable(props: AdvancedTableProps) { + const table = useReactTable({ + manualPagination: true, + manualSorting: true, + ...props, + data: props.data ?? [], + getCoreRowModel: getCoreRowModel() + }); + const headers: ReactNode[] = table + .getLeafHeaders() + .map((header) => + flexRender(header.column.columnDef.header, header.getContext()) + ); + const data: ReactNode[][] = table + .getRowModel() + .rows.map((row) => row.getVisibleCells()) + .map((cells) => + cells.map((cell) => + flexRender(cell.column.columnDef.cell, cell.getContext()) + ) + ); + + const paginate = props.paginate ?? true; + + if (props?.isLoading) { + return ( + + ); + } + + return ( + + + {paginate ? ( + +
- {hasPagination && ( - <> -
- -
-
- - - -
- - )} + + + + + + Vous êtes sur le point de supprimer ce destinataire de la campagne. + + ); } diff --git a/frontend/src/components/Campaign/CampaignStatusBadge.tsx b/frontend/src/components/Campaign/CampaignStatusBadge.tsx index 007b7003b..3cc750242 100644 --- a/frontend/src/components/Campaign/CampaignStatusBadge.tsx +++ b/frontend/src/components/Campaign/CampaignStatusBadge.tsx @@ -1,3 +1,5 @@ +import { BadgeProps } from '@codegouvfr/react-dsfr/Badge'; + import AppBadge from '../_app/AppBadge/AppBadge'; import { CAMPAIGN_STATUS_LABELS, @@ -6,6 +8,7 @@ import { interface Props { status: CampaignStatus; + badgeProps?: Omit; } function CampaignStatusBadge(props: Readonly) { @@ -20,7 +23,11 @@ function CampaignStatusBadge(props: Readonly) { const color = colors[props.status]; const text = texts[props.status]; - return {text}; + return ( + + {text} + + ); } export default CampaignStatusBadge; diff --git a/frontend/src/components/Campaign/CampaignTable.tsx b/frontend/src/components/Campaign/CampaignTable.tsx new file mode 100644 index 000000000..b3a51f24a --- /dev/null +++ b/frontend/src/components/Campaign/CampaignTable.tsx @@ -0,0 +1,233 @@ +import { fr } from '@codegouvfr/react-dsfr'; +import Button, { ButtonProps } from '@codegouvfr/react-dsfr/Button'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { format } from 'date-fns'; +import { PropsWithChildren, useMemo, useState } from 'react'; + +import AdvancedTable from '../AdvancedTable/AdvancedTable'; +import AppLink from '../_app/AppLink/AppLink'; +import CampaignStatusBadge from './CampaignStatusBadge'; +import { useUser } from '../../hooks/useUser'; +import { Campaign, CampaignSort } from '../../models/Campaign'; +import { createColumnHelper } from '@tanstack/react-table'; +import { DefaultPagination } from '../../store/reducers/housingReducer'; +import { usePagination } from '../../hooks/usePagination'; +import { useSort } from '../../hooks/useSort'; +import { useFindCampaignsQuery } from '../../services/campaign.service'; +import { displayCount } from '../../utils/stringUtils'; + +interface CampaignTableProps { + onArchive?(campaign: Campaign): void; + onRemove?(campaign: Campaign): void; +} + +const columnHelper = createColumnHelper(); + +function CampaignTable(props: CampaignTableProps) { + const { onArchive, onRemove } = props; + + const { isVisitor } = useUser(); + + const [pagination, setPagination] = useState(DefaultPagination); + const { page, pageCount, perPage, changePage, changePerPage } = usePagination( + { + pagination, + setPagination + } + ); + const { sort, getSortButton } = useSort({ + default: { + createdAt: 'desc' + } + }); + const { data: campaigns, isLoading } = useFindCampaignsQuery({ sort }); + + const columns = useMemo( + () => [ + columnHelper.accessor('title', { + header: () => ( + + Titre + {getSortButton('title', 'Trier par titre')} + + ), + cell: ({ row }) => { + const campaign = row.original; + return ( + + {campaign.title} + + ); + } + }), + columnHelper.accessor('status', { + header: () => ( + + Statut + {getSortButton('status', 'Trier par statut')} + + ), + cell: ({ cell }) => ( + + ) + }), + columnHelper.accessor('createdAt', { + header: () => ( + + Date de création + {getSortButton('createdAt', 'Trier par date de création')} + + ), + cell: ({ cell }) => format(new Date(cell.getValue()), 'dd/MM/yyyy') + }), + columnHelper.accessor('sentAt', { + header: () => ( + + Date d’envoi + {getSortButton('sentAt', 'Trier par date d’envoi')} + + ), + cell: ({ cell }) => { + const value = cell.getValue(); + return value ? format(new Date(value), 'dd/MM/yyyy') : null; + } + }), + columnHelper.display({ + id: 'actions', + header: () => ( + + Actions + + ), + cell: ({ row }) => { + const campaign = row.original; + const buttons: ButtonProps[] = []; + + if (!['draft', 'sending'].includes(campaign.status)) { + buttons.push({ + children: 'Suivre', + priority: 'secondary', + size: 'small', + linkProps: { + to: `/parc-de-logements/campagnes/${campaign.id}` + } + }); + } + if (!isVisitor) { + if (['draft', 'sending'].includes(campaign.status)) { + buttons.push({ + children: 'Accéder', + priority: 'secondary', + size: 'small', + linkProps: { + to: `/campagnes/${campaign.id}` + } + }); + } + if (campaign.status === 'in-progress') { + buttons.push({ + title: 'Archiver la campagne', + priority: 'tertiary', + iconId: 'fr-icon-archive-line', + size: 'small', + onClick() { + onArchive?.(campaign); + } + }); + } + if (campaign.status !== 'archived') { + buttons.push({ + title: 'Supprimer la campagne', + priority: 'tertiary', + iconId: 'ri-delete-bin-line', + size: 'small', + onClick() { + onRemove?.(campaign); + } + }); + } + } + + return ( + + {buttons.map((buttonProps, i) => ( + - ) - }; - - let columns = [ - rowNumberColumn, - addressColumn, - ownerColumn, - occupancyColumn, - campaignColumn, - statusColumn - ]; + }), + columnHelper.accessor( + (value) => ({ status: value.status, subStatus: value.subStatus }), + { + id: 'status', + header: () => ( + + ), + cell: ({ cell }) => { + const { status, subStatus } = cell.getValue(); + return ( + + + {subStatus && ( + + {subStatus} + + )} + + ); + } + } + ), + columnHelper.display({ + id: 'action', + header: () => Action, + cell: ({ row }) => { + if (actions) { + return <>{actions(row.original)}; + } - if (!isVisitor) { - columns = [selectColumn, ...columns, actionColumn]; - } + return ( + +
`${h.id}_${h.owner.id}`} - data={housingList.map((_, index) => ({ - ..._, - rowNumber: rowNumber(index) - }))} - columns={columns} - fixedLayout={true} - className={classNames( - 'zlv-table', - 'with-modify-last', - 'with-row-number', - !isVisitor ? { 'with-select': onSelectHousing } : undefined - )} - data-testid="housing-table" - /> - {hasPagination && ( - <> -
- -
-
- - - -
- - )} - - )} + + + + + setUpdatingHousing(undefined)} /> - + ); -}; +} export default HousingList; diff --git a/frontend/src/components/HousingListFilters/CampaignFilter.tsx b/frontend/src/components/HousingListFilters/CampaignFilter.tsx index 8bb9fa496..bfc741d3d 100644 --- a/frontend/src/components/HousingListFilters/CampaignFilter.tsx +++ b/frontend/src/components/HousingListFilters/CampaignFilter.tsx @@ -2,16 +2,16 @@ import { fr } from '@codegouvfr/react-dsfr'; import Checkbox from '@codegouvfr/react-dsfr/Checkbox'; import { MenuItem, Select, SelectChangeEvent } from '@mui/material'; import { List, Set } from 'immutable'; -import { ChangeEvent, useId, useRef } from 'react'; +import { useId, useRef } from 'react'; import { match } from 'ts-pattern'; import { byCreatedAt, - byStatus, + CAMPAIGN_STATUS_VALUES, CampaignStatus, isCampaignStatus } from '@zerologementvacant/models'; -import { desc } from '@zerologementvacant/utils'; +import { DEFAULT_ORDER, desc } from '@zerologementvacant/utils'; import { Campaign } from '../../models/Campaign'; import CampaignStatusBadge from '../Campaign/CampaignStatusBadge'; import styles from './housing-list-filters.module.scss'; @@ -83,7 +83,7 @@ function CampaignFilter(props: Props) { } } - function noop(event: ChangeEvent): void { + function noop(event: { stopPropagation(): void }): void { event.stopPropagation(); } @@ -260,7 +260,14 @@ function groupByStatus(campaigns: ReadonlyArray) { return List(campaigns) .groupBy((campaign) => campaign.status) .map((campaigns) => campaigns.sort(desc(byCreatedAt))) - .sortBy((_, status) => status, byStatus); + .sortBy( + (_, status) => status, + (first, second) => + DEFAULT_ORDER( + CAMPAIGN_STATUS_VALUES.indexOf(first), + CAMPAIGN_STATUS_VALUES.indexOf(second) + ) + ); } export default CampaignFilter; diff --git a/frontend/src/components/HousingStatusBadge/HousingStatusBadge.tsx b/frontend/src/components/HousingStatusBadge/HousingStatusBadge.tsx index 4631b1b00..a4df29631 100644 --- a/frontend/src/components/HousingStatusBadge/HousingStatusBadge.tsx +++ b/frontend/src/components/HousingStatusBadge/HousingStatusBadge.tsx @@ -1,14 +1,17 @@ +import { BadgeProps } from '@codegouvfr/react-dsfr/Badge'; + +import { HousingStatus } from '@zerologementvacant/models'; import { getHousingState } from '../../models/HousingState'; import styles from './housing-status-badge.module.scss'; import AppBadge from '../_app/AppBadge/AppBadge'; -import { HousingStatus } from '@zerologementvacant/models'; interface Props { + badgeProps?: Omit; status?: HousingStatus; inline?: boolean; } -function HousingStatusBadge({ status, inline }: Props) { +function HousingStatusBadge({ badgeProps, status, inline }: Props) { return status !== undefined ? (
diff --git a/frontend/src/components/OccupancyTag/OccupancyTag.tsx b/frontend/src/components/OccupancyTag/OccupancyTag.tsx new file mode 100644 index 000000000..765947fa3 --- /dev/null +++ b/frontend/src/components/OccupancyTag/OccupancyTag.tsx @@ -0,0 +1,17 @@ +import Tag, { TagProps } from '@codegouvfr/react-dsfr/Tag'; +import { MarkOptional } from 'ts-essentials'; + +import { Occupancy } from '@zerologementvacant/models'; +import { OCCUPANCY_LABELS } from '../../models/Housing'; + +interface OccupancyTagProps { + occupancy: Occupancy; + tagProps: MarkOptional; +} + +function OccupancyTag(props: OccupancyTagProps) { + const label = OCCUPANCY_LABELS[props.occupancy]; + return {label}; +} + +export default OccupancyTag; diff --git a/frontend/src/components/OwnerCard/OwnerCard.tsx b/frontend/src/components/OwnerCard/OwnerCard.tsx index 31eb35ad6..5dcc43490 100644 --- a/frontend/src/components/OwnerCard/OwnerCard.tsx +++ b/frontend/src/components/OwnerCard/OwnerCard.tsx @@ -54,26 +54,28 @@ function OwnerCard(props: OwnerCardProps) { /> Date de naissance - {birthdate(props.owner.birthDate)} ({age(props.owner.birthDate)} ans) + + {birthdate(props.owner.birthDate)} ({age(props.owner.birthDate)}{' '} + ans) + ) : null} Adresse fiscale (source: DGFIP) - {props.owner.rawAddress ? props.owner.rawAddress.join(' ') : 'Inconnue'} + + {props.owner.rawAddress + ? props.owner.rawAddress.join(' ') + : 'Inconnue'} + - Adresse postale (source: Base Adresse Nationale) - {props.owner.banAddress ? formatAddress(props.owner.banAddress).join(' ') : 'Inconnue'} + + {props.owner.banAddress + ? formatAddress(props.owner.banAddress).join(' ') + : 'Inconnue'} + - {!isBanEligible(props.owner.banAddress) && ( - L’adresse Base Adresse Nationale ne correspond pas à celle de la DGFIP. + L’adresse Base Adresse Nationale ne correspond pas à celle + de la DGFIP. - Nous vous recommandons de vérifier en cliquant sur "Modifier". + Nous vous recommandons de vérifier en cliquant sur + "Modifier". } diff --git a/frontend/src/components/OwnerEditionSideMenu/OwnerEditionSideMenu.tsx b/frontend/src/components/OwnerEditionSideMenu/OwnerEditionSideMenu.tsx index 1d508152b..f9837e6ec 100644 --- a/frontend/src/components/OwnerEditionSideMenu/OwnerEditionSideMenu.tsx +++ b/frontend/src/components/OwnerEditionSideMenu/OwnerEditionSideMenu.tsx @@ -21,8 +21,12 @@ interface Props { function OwnerEditionSideMenu(props: Props) { const { active, setActive, toggle } = useToggle(); - const storedWarningVisible = localStorage.getItem('OwnerEdition.warningVisible'); - const [warningVisible, setWarningVisible] = useState(storedWarningVisible === null || storedWarningVisible === 'true'); + const storedWarningVisible = localStorage.getItem( + 'OwnerEdition.warningVisible' + ); + const [warningVisible, setWarningVisible] = useState( + storedWarningVisible === null || storedWarningVisible === 'true' + ); const shape = { address: banAddressValidator.optional(), @@ -52,7 +56,10 @@ function OwnerEditionSideMenu(props: Props) { const [updateOwner, mutation] = useUpdateOwnerMutation(); async function save(event: FormEvent): Promise { - localStorage.setItem('OwnerEdition.warningVisible', warningVisible.toString()); + localStorage.setItem( + 'OwnerEdition.warningVisible', + warningVisible.toString() + ); event.preventDefault(); await form.validate(async () => { await updateOwner({ @@ -75,11 +82,11 @@ function OwnerEditionSideMenu(props: Props) { return ( ); } @@ -88,7 +95,7 @@ function OwnerEditionSideMenu(props: Props) { <>
{selectedCount} - - - - - )} - + 0 + ? fr.colors.decisions.background.actionLow.blueCumulus.default + : undefined, + padding: '0.5rem 0.5rem 0.5rem 1rem', + margin: '1rem 0' + }} + > + {selectedCount} + + + + ); } diff --git a/frontend/src/components/_app/AppTextInput/AppTextInputNext.tsx b/frontend/src/components/_app/AppTextInput/AppTextInputNext.tsx index 5076adddf..3044df9b8 100644 --- a/frontend/src/components/_app/AppTextInput/AppTextInputNext.tsx +++ b/frontend/src/components/_app/AppTextInput/AppTextInputNext.tsx @@ -11,44 +11,43 @@ export type AppTextInputNextProps = InputProps & { * A text input to be used with react-hook-form and validated using yup. */ function AppTextInputNext(props: AppTextInputNextProps) { + const { nativeInputProps, nativeTextAreaProps, textArea, ...rest } = props; const { field, fieldState } = useController({ name: props.name, disabled: props.disabled }); - const isTextArea = props.textArea === true; + const regularInputProps: Pick = { + nativeInputProps: { + ...nativeInputProps, + // Avoid browser validation which prevents react-hook-form to work + formNoValidate: true, + name: field.name, + ref: field.ref, + value: field.value, + onBlur: field.onBlur, + onChange: field.onChange + } + }; + const textAreaProps: Pick< + InputProps.TextArea, + 'nativeTextAreaProps' | 'textArea' + > = { + textArea: true, + nativeTextAreaProps: { + ...nativeTextAreaProps, + name: field.name, + ref: field.ref, + value: field.value, + onBlur: field.onBlur, + onChange: field.onChange + } + }; return ( diff --git a/frontend/src/components/modals/ConfirmationModal/ConfirmationModal.tsx b/frontend/src/components/modals/ConfirmationModal/ConfirmationModal.tsx index 965c4b079..d658f9c1b 100644 --- a/frontend/src/components/modals/ConfirmationModal/ConfirmationModal.tsx +++ b/frontend/src/components/modals/ConfirmationModal/ConfirmationModal.tsx @@ -3,7 +3,7 @@ import { Container } from '../../_dsfr'; import { createModal } from '@codegouvfr/react-dsfr/Modal'; import Button, { ButtonProps } from '@codegouvfr/react-dsfr/Button'; import AppLinkAsButton, { - AppLinkAsButtonProps, + AppLinkAsButtonProps } from '../../_app/AppLinkAsButton/AppLinkAsButton'; interface Props { @@ -13,7 +13,7 @@ interface Props { onOpen?(openModal: () => void): Promise | void; onSubmit(param?: any): Promise | void; size?: 'small' | 'medium' | 'large'; - openingButtonProps?: Omit; + openingButtonProps?: Exclude; openingAppLinkAsButtonProps?: Omit; } @@ -25,15 +25,15 @@ function ConfirmationModal({ onSubmit, size, openingButtonProps, - openingAppLinkAsButtonProps, + openingAppLinkAsButtonProps }: Props) { const modal = useMemo( () => createModal({ id: `confirmation-modal-${modalId}`, - isOpenedByDefault: false, + isOpenedByDefault: false }), - [modalId], + [modalId] ); function open() { @@ -52,9 +52,7 @@ function ConfirmationModal({ return ( <> {openingButtonProps !== undefined ? ( - + - + Adresse fiscale (source: DGFIP) - Cette adresse est issue du fichier LOVAC, récupérée via le fichier 1767BIS-COM. Celle-ci n’est pas modifiable. - {housingOwner.rawAddress ? housingOwner.rawAddress.join(' ') : 'Inconnue'} + + Cette adresse est issue du fichier LOVAC, récupérée via le fichier + 1767BIS-COM. Celle-ci n’est pas modifiable. + + + {housingOwner.rawAddress + ? housingOwner.rawAddress.join(' ') + : 'Inconnue'} + - + diff --git a/frontend/src/components/modals/ModalStepper/ModalGraphStepper.tsx b/frontend/src/components/modals/ModalStepper/ModalGraphStepper.tsx index 056c0157a..c165b1ae6 100644 --- a/frontend/src/components/modals/ModalStepper/ModalGraphStepper.tsx +++ b/frontend/src/components/modals/ModalStepper/ModalGraphStepper.tsx @@ -5,7 +5,7 @@ import { PropsWithoutRef, RefAttributes, useRef, - useState, + useState } from 'react'; import { v4 as uuidv4 } from 'uuid'; @@ -24,13 +24,13 @@ interface Props > { title: string; steps: Step[]; - openingButtonProps?: Omit; + openingButtonProps: Exclude; onFinish?: () => void; } const modal = createModal({ id: uuidv4(), - isOpenedByDefault: false, + isOpenedByDefault: false }); function ModalGraphStepper(props: Props) { @@ -52,7 +52,7 @@ function ModalGraphStepper(props: Props) { } stepper.reset(); modal.close(); - }, + } }, { children: 'Confirmer', @@ -73,8 +73,8 @@ function ModalGraphStepper(props: Props) { } finally { setIsLoading(false); } - }, - }, + } + } ]; function open() { diff --git a/frontend/src/components/modals/ModalStepper/ModalStepper.tsx b/frontend/src/components/modals/ModalStepper/ModalStepper.tsx index 9efffd8f6..fe5f790b8 100644 --- a/frontend/src/components/modals/ModalStepper/ModalStepper.tsx +++ b/frontend/src/components/modals/ModalStepper/ModalStepper.tsx @@ -2,6 +2,7 @@ import Button, { ButtonProps } from '@codegouvfr/react-dsfr/Button'; import { createModal, ModalProps } from '@codegouvfr/react-dsfr/Modal'; import fp from 'lodash/fp'; import { ForwardRefExoticComponent, RefAttributes, useRef } from 'react'; +import { MarkOptional } from 'ts-essentials'; import { v4 as uuidv4 } from 'uuid'; import { useStepper } from '../../../hooks/useStepper'; @@ -18,13 +19,16 @@ interface Props > { title: string; steps: Step[]; - openingButtonProps?: ButtonProps; + openingButtonProps: MarkOptional< + Exclude, + 'onClick' + >; onFinish?: () => void; } const modal = createModal({ id: uuidv4(), - isOpenedByDefault: false, + isOpenedByDefault: false }); /** @@ -60,7 +64,7 @@ function ModalStepper(props: Props) { } stepper.forceStep(0); modal.close(); - }, + } }, { children: 'Confirmer', @@ -74,8 +78,8 @@ function ModalStepper(props: Props) { stepper.next(); } } - }, - }, + } + } ]; return ( diff --git a/frontend/src/hooks/usePagination.tsx b/frontend/src/hooks/usePagination.tsx index 857c09a78..08b51ae9f 100644 --- a/frontend/src/hooks/usePagination.tsx +++ b/frontend/src/hooks/usePagination.tsx @@ -1,6 +1,11 @@ import { Pagination } from '@zerologementvacant/models'; import config from '../utils/config'; +/** + * @todo Refactor this to remove the duplicated `pagination` + * and `extends Partial { pagination: Pagination; count?: number; @@ -21,21 +26,23 @@ export function usePagination(opts: PaginationOptions) { opts.setPagination({ ...opts.pagination, page: 1, - perPage, + perPage }); }; const changePage = (page: number) => { opts.setPagination({ ...opts.pagination, - page, + page }); }; return { pageCount, rowNumber, hasPagination, - changePerPage, + page, changePage, + perPage, + changePerPage }; } diff --git a/frontend/src/hooks/useSelection.tsx b/frontend/src/hooks/useSelection.tsx index 6398bae76..4898aaa11 100644 --- a/frontend/src/hooks/useSelection.tsx +++ b/frontend/src/hooks/useSelection.tsx @@ -8,7 +8,7 @@ export interface Selection { export function useSelection(itemCount: number = 0) { const [selected, setSelected] = useState({ all: false, - ids: [], + ids: [] }); const hasSelected = useMemo( @@ -18,6 +18,11 @@ export function useSelection(itemCount: number = 0) { [selected.all, selected.ids, itemCount] ); + const hasSelectedAll = useMemo( + () => selected.all && selected.ids.length === 0, + [selected.all, selected.ids] + ); + const selectedCount = useMemo( () => selected.all ? itemCount - selected.ids.length : selected.ids.length, @@ -27,7 +32,7 @@ export function useSelection(itemCount: number = 0) { function select(id: string): void { setSelected((state) => ({ ...state, - ids: [...state.ids, id], + ids: [...state.ids, id] })); } @@ -36,7 +41,7 @@ export function useSelection(itemCount: number = 0) { ...state, ids: state.ids.includes(id) ? state.ids.filter((_) => _ !== id) - : [...state.ids, id], + : [...state.ids, id] })); } @@ -47,9 +52,9 @@ export function useSelection(itemCount: number = 0) { forceValue !== undefined ? forceValue : state.ids.length > 0 && state.all - ? state.all - : !state.all, - ids: [], + ? state.all + : !state.all, + ids: [] }; }); } @@ -57,10 +62,17 @@ export function useSelection(itemCount: number = 0) { function unselect(id: string): void { setSelected((state) => ({ ...state, - ids: state.ids.filter((stateId) => stateId !== id), + ids: state.ids.filter((stateId) => stateId !== id) })); } + function unselectAll(): void { + setSelected({ + all: false, + ids: [] + }); + } + function isSelected(id: string): boolean { return ( (selected.all && !selected.ids.includes(id)) || @@ -70,6 +82,7 @@ export function useSelection(itemCount: number = 0) { return { hasSelected, + hasSelectedAll, selectedCount, isSelected, select, @@ -78,5 +91,6 @@ export function useSelection(itemCount: number = 0) { toggleSelect, toggleSelectAll, unselect, + unselectAll }; } diff --git a/frontend/src/hooks/useSort.tsx b/frontend/src/hooks/useSort.tsx index 8732b7d54..2cc617786 100644 --- a/frontend/src/hooks/useSort.tsx +++ b/frontend/src/hooks/useSort.tsx @@ -1,8 +1,9 @@ -import { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { Direction, Sort } from '../models/Sort'; import Button from '@codegouvfr/react-dsfr/Button'; -import classNames from 'classnames'; +import { match } from 'ts-pattern'; +import type { FrIconClassName, RiIconClassName } from '@codegouvfr/react-dsfr'; interface UseSortOptions { onSort?(sort: Sort): void | Promise; @@ -16,24 +17,23 @@ export function useSort( options?.default ); - function getSortButton(key: keyof Sortable, title: string): JSX.Element { - const direction = sort?.[key]; + function getSortButton( + key: keyof Sortable, + title: string + ): React.ReactElement { + const direction: Direction | undefined = sort?.[key]; return ( + /> ); } @@ -49,27 +49,23 @@ export function useSort( const next = nextDirection(key); if (!next) { // Filter out undefined values - setSort( - Object.keys(sort ?? {}) - .filter((k) => k !== key) - .reduce>((acc, k) => { - return { - ...acc, - [k]: sort ? sort[k as keyof Sortable] : undefined - }; - }, {}) - ); + const value = Object.keys(sort ?? {}) + .filter((k) => k !== key) + .reduce>((acc, k) => { + return { + ...acc, + [k]: sort ? sort[k as keyof Sortable] : undefined + }; + }, {}); + setSort(value); + options?.onSort?.(value); return; } - setSort({ ...sort, [key]: next }); - } - useEffect(() => { - if (sort) { - options?.onSort?.(sort); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sort]); + const value: Sort = { ...sort, [key]: next }; + setSort(value); + options?.onSort?.(value); + } return { cycleSort, diff --git a/frontend/src/mocks/handlers/campaign-handlers.ts b/frontend/src/mocks/handlers/campaign-handlers.ts index f4c17496c..fa8325596 100644 --- a/frontend/src/mocks/handlers/campaign-handlers.ts +++ b/frontend/src/mocks/handlers/campaign-handlers.ts @@ -1,17 +1,24 @@ import { faker } from '@faker-js/faker'; import { constants } from 'http2'; +import { List } from 'immutable'; import fp from 'lodash/fp'; import { http, HttpResponse, RequestHandler } from 'msw'; import { + byCreatedAt, + bySentAt, + byStatus, + byTitle, CampaignCreationPayloadDTO, CampaignDTO, CampaignUpdatePayloadDTO, HousingDTO } from '@zerologementvacant/models'; +import { combineAll, desc, Ord } from '@zerologementvacant/utils'; import data from './data'; import config from '../../utils/config'; import { isDefined } from '../../utils/compareUtils'; +import { CampaignSortable, isCampaignSortable } from '../../models/Campaign'; type CampaignParams = { id: string; @@ -23,8 +30,12 @@ export const campaignHandlers: RequestHandler[] = [ ({ request }) => { const url = new URL(request.url); const groups = url.searchParams.get('groups')?.split(','); + const order = url.searchParams.get('sort')?.split(','); - const campaigns = fp.pipe(filter({ groups }))(data.campaigns); + const campaigns = fp.pipe( + filter({ groups }), + sort(order) + )(data.campaigns); return HttpResponse.json(campaigns); } ), @@ -147,6 +158,22 @@ export const campaignHandlers: RequestHandler[] = [ }); } + data.campaigns = data.campaigns.filter( + (campaign) => campaign.id !== params.id + ); + data.campaignDrafts.delete(params.id); + data.campaignHousings.delete(params.id); + data.draftCampaigns.forEach((campaign, draftId, map) => { + if (campaign.id === params.id) { + map.delete(draftId); + } + }); + data.housingCampaigns.forEach((campaigns, housingId, map) => { + map.set( + housingId, + campaigns.filter((campaign) => campaign.id !== params.id) + ); + }); return HttpResponse.json(null, { status: constants.HTTP_STATUS_NO_CONTENT }); @@ -219,3 +246,28 @@ function filter( : campaigns ); } + +function sort(keys?: ReadonlyArray) { + const ordering: Partial>> = { + title: byTitle, + status: byStatus, + createdAt: byCreatedAt, + sentAt: bySentAt + }; + + const sortFns = List(keys) + .map((key) => { + const keyWithoutMinus = key.startsWith('-') ? key.slice(1) : key; + if (!isCampaignSortable(keyWithoutMinus)) { + return null; + } + return key.startsWith('-') + ? desc(ordering[keyWithoutMinus]) + : ordering[keyWithoutMinus]; + }) + .filter((fn) => !!fn); + const sortFn = combineAll(sortFns.toArray()); + + return (campaigns: CampaignDTO[]): CampaignDTO[] => + keys?.length ? campaigns.toSorted(sortFn) : campaigns; +} diff --git a/frontend/src/models/Campaign.tsx b/frontend/src/models/Campaign.tsx index 3882a4b3d..bf9c603ec 100644 --- a/frontend/src/models/Campaign.tsx +++ b/frontend/src/models/Campaign.tsx @@ -36,11 +36,18 @@ export const campaignStep = (campaign: Campaign) => { : CampaignSteps.InProgress; }; -export type CampaignSortable = Pick & { +export type CampaignSortable = Pick< + Campaign, + 'title' | 'createdAt' | 'sentAt' +> & { status: string; }; export type CampaignSort = Sort; +export function isCampaignSortable(key: string): key is keyof CampaignSortable { + return ['title', 'status', 'createdAt', 'sentAt'].includes(key); +} + export const campaignSort = (c1: Campaign, c2: Campaign) => dateSort(new Date(c2.createdAt), new Date(c1.createdAt)); diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts index d024f409e..e95647301 100644 --- a/frontend/src/setupTests.ts +++ b/frontend/src/setupTests.ts @@ -4,6 +4,7 @@ // learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom'; import 'jest-extended'; +import 'jest-sorted'; import { mockAPI } from './mocks/mock-api'; import EventSourceMock from '../test/event-source-mock'; diff --git a/frontend/src/views/Account/AccountCreation/AccountEmailCreationView.tsx b/frontend/src/views/Account/AccountCreation/AccountEmailCreationView.tsx index f40c32e10..d8942c26e 100644 --- a/frontend/src/views/Account/AccountCreation/AccountEmailCreationView.tsx +++ b/frontend/src/views/Account/AccountCreation/AccountEmailCreationView.tsx @@ -122,14 +122,11 @@ function AccountEmailCreationView() { target: '_self' }} priority="tertiary" - role="link" > Retour à la page d’accueil - + diff --git a/frontend/src/views/Campaign/CampaignListView.tsx b/frontend/src/views/Campaign/CampaignListView.tsx index 14d51ba4a..86725f596 100644 --- a/frontend/src/views/Campaign/CampaignListView.tsx +++ b/frontend/src/views/Campaign/CampaignListView.tsx @@ -1,65 +1,83 @@ +import { Alert } from '@codegouvfr/react-dsfr/Alert'; +import Button from '@codegouvfr/react-dsfr/Button'; +import { createModal } from '@codegouvfr/react-dsfr/Modal'; +import Typography from '@mui/material/Typography'; import { useState } from 'react'; + import { useDocumentTitle } from '../../hooks/useDocumentTitle'; import MainContainer from '../../components/MainContainer/MainContainer'; -import Button from '@codegouvfr/react-dsfr/Button'; -import { useCampaignList } from '../../hooks/useCampaignList'; -import Table from '@codegouvfr/react-dsfr/Table'; -import { format } from 'date-fns'; -import { - Campaign, - CampaignSort, - CampaignSortable, - isCampaignDeletable, -} from '../../models/Campaign'; -import AppLink from '../../components/_app/AppLink/AppLink'; -import CampaignStatusBadge from '../../components/Campaign/CampaignStatusBadge'; -import { displayCount } from '../../utils/stringUtils'; -import { Text } from '../../components/_dsfr'; -import ConfirmationModal from '../../components/modals/ConfirmationModal/ConfirmationModal'; -import { useMatomo } from '@jonkoops/matomo-tracker-react'; -import { - TrackEventActions, - TrackEventCategories, -} from '../../models/TrackEvent'; -import { Alert } from '@codegouvfr/react-dsfr/Alert'; -import styles from './campaign.module.scss'; +import { Campaign } from '../../models/Campaign'; import { useRemoveCampaignMutation, - useUpdateCampaignMutation, + useUpdateCampaignMutation } from '../../services/campaign.service'; -import { useSort } from '../../hooks/useSort'; -import { useUser } from '../../hooks/useUser'; +import { useNotification } from '../../hooks/useNotification'; +import CampaignTable from '../../components/Campaign/CampaignTable'; -const CampaignsListView = () => { +const archiveCampaignModal = createModal({ + id: 'archive-campaign-modal', + isOpenedByDefault: false +}); +const removeCampaignModal = createModal({ + id: 'remove-campaign-modal', + isOpenedByDefault: false +}); + +function CampaignListView() { useDocumentTitle('Campagnes'); - const { trackEvent } = useMatomo(); - const { isVisitor } = useUser(); - const [sort, setSort] = useState({ createdAt: 'desc' }); - const campaigns = useCampaignList({ sort }); + const [removeCampaign, campaignRemovalMutation] = useRemoveCampaignMutation(); + const [updateCampaign, campaignUpdateMutation] = useUpdateCampaignMutation(); + useNotification({ + toastId: 'remove-campaign', + isLoading: campaignRemovalMutation.isLoading, + isError: campaignRemovalMutation.isError, + isSuccess: campaignRemovalMutation.isSuccess, + message: { + error: 'Erreur lors de la suppression de la campagne', + loading: 'Suppression de la campagne...', + success: 'Campagne supprimée !' + } + }); + useNotification({ + toastId: 'archive-campaign', + isLoading: campaignUpdateMutation.isLoading, + isError: campaignUpdateMutation.isError, + isSuccess: campaignUpdateMutation.isSuccess, + message: { + error: 'Erreur lors de l’archivage de la campagne', + loading: 'Archivage de la campagne...', + success: 'Campagne archivée !' + } + }); - const [removeCampaign] = useRemoveCampaignMutation(); - const onDeleteCampaign = async (campaignId: string): Promise => { - await removeCampaign(campaignId).unwrap(); - trackEvent({ - category: TrackEventCategories.Campaigns, - action: TrackEventActions.Campaigns.Delete, - }); - }; + const [selected, setSelected] = useState(null); - const [updateCampaign] = useUpdateCampaignMutation(); - const onArchiveCampaign = async (campaign: Campaign): Promise => { - await updateCampaign({ ...campaign, status: 'archived' }).unwrap(); - trackEvent({ - category: TrackEventCategories.Campaigns, - action: TrackEventActions.Campaigns.Archive, - }); - }; + function onArchive(campaign: Campaign) { + setSelected(campaign); + archiveCampaignModal.open(); + } - const { getSortButton } = useSort({ - onSort: setSort, - default: sort, - }); + async function confirmArchiving() { + if (selected) { + updateCampaign({ ...selected, status: 'archived' }); + archiveCampaignModal.close(); + setSelected(null); + } + } + + function onRemove(campaign: Campaign) { + setSelected(campaign); + removeCampaignModal.open(); + } + + function confirmRemoval() { + if (selected) { + removeCampaign(selected.id); + removeCampaignModal.close(); + setSelected(null); + } + } return ( { priority="secondary" linkProps={{ to: 'https://zlv.notion.site/R-diger-un-courrier-15e88e19d2bc404eaf371ddcb4ca42c5', - target: '_blank', + target: '_blank' }} className="float-right" > @@ -79,116 +97,54 @@ const CampaignsListView = () => { } > - {campaigns && ( - <> -
- {displayCount(campaigns.length, 'campagne', { - capitalize: true, - feminine: true, - })} -
-
[ - `#${index + 1}`, - - {campaign.title} - , - , - format(new Date(campaign.createdAt), 'dd/MM/yyyy'), - campaign.sentAt - ? format(new Date(campaign.sentAt), 'dd/MM/yyyy') - : '', -
- { !(campaign.status === 'draft' || campaign.status === 'sending') && ( - - )} - { (!isVisitor && (campaign.status === 'draft' || campaign.status === 'sending')) && ( - - )} - { !isVisitor && campaign.status === 'in-progress' && ( - onArchiveCampaign(campaign)} - modalId={`archive-${campaign.id}`} - openingButtonProps={{ - priority: 'tertiary', - iconId: 'fr-icon-archive-fill', - className: styles.buttonInGroup, - }} - > - - Êtes-vous sûr de vouloir archiver cette campagne ? - - - )} - { !isVisitor && isCampaignDeletable(campaign) && ( - onDeleteCampaign(campaign.id)} - modalId={`delete-${campaign.id}`} - openingButtonProps={{ - priority: 'tertiary', - iconId: 'fr-icon-delete-bin-fill', - className: styles.buttonInGroup, - }} - > - - Êtes-vous sûr de vouloir supprimer cette campagne ? - - - - )} -
, - ])} - /> - - )} + + + + + Êtes-vous sûr de vouloir archiver cette campagne ? + + + + + + Êtes-vous sûr de vouloir supprimer cette campagne ? + + + ); -}; +} -export default CampaignsListView; +export default CampaignListView; diff --git a/frontend/src/views/Campaign/test/CampaignListView.test.tsx b/frontend/src/views/Campaign/test/CampaignListView.test.tsx new file mode 100644 index 000000000..006594ce0 --- /dev/null +++ b/frontend/src/views/Campaign/test/CampaignListView.test.tsx @@ -0,0 +1,165 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; + +import { CAMPAIGN_STATUS_LABELS } from '@zerologementvacant/models'; +import { genCampaignDTO } from '@zerologementvacant/models/fixtures'; +import configureTestStore from '../../../utils/test/storeUtils'; +import CampaignListView from '../CampaignListView'; +import data from '../../../mocks/handlers/data'; +import { DEFAULT_ORDER } from '@zerologementvacant/utils'; + +describe('CampaignListView', () => { + const user = userEvent.setup(); + + beforeAll(() => { + const campaigns = Array.from({ length: 10 }, () => genCampaignDTO()); + data.campaigns.push(...campaigns); + }); + + function setup() { + const store = configureTestStore(); + const router = createMemoryRouter( + [ + { + path: '/campagnes', + element: + } + ], + { initialEntries: ['/campagnes'] } + ); + + render( + + + + ); + } + + async function resetSort() { + let sort = await screen.findByRole('button', { + name: 'Trier par date de création' + }); + await user.click(sort); + sort = await screen.findByRole('button', { + name: 'Trier par date de création' + }); + await user.click(sort); + } + + describe('Title', () => { + it('should sort by title', async () => { + setup(); + + await resetSort(); + const table = await screen.findByRole('table'); + const sort = await within(table).findByRole('button', { + name: 'Trier par titre' + }); + await user.click(sort); + const links = await within(table).findAllByRole('link', { + name: (content) => + data.campaigns.some((campaign) => campaign.title === content) + }); + const titles = links.map((link) => link.textContent); + expect(titles.length).toBeGreaterThan(0); + expect(titles).toBeSorted({ + descending: true + }); + }); + + it('should link to the campaign page', async () => { + setup(); + + const table = await screen.findByRole('table'); + const links = await within(table).findAllByRole('link', { + name: (content) => + data.campaigns.some((campaign) => campaign.title === content) + }); + expect(links.length).toBeGreaterThan(0); + expect(links).toSatisfyAll((link) => { + return /\/campagnes\/.+$/.test(link.href); + }); + }); + }); + + describe('Status', () => { + it('should sort by status', async () => { + setup(); + + await resetSort(); + const table = await screen.findByRole('table'); + const sort = await within(table).findByRole('button', { + name: 'Trier par statut' + }); + await user.click(sort); + const statuses = await within(table).findAllByText((content) => + Object.values(CAMPAIGN_STATUS_LABELS).includes(content) + ); + expect(statuses.length).toBeGreaterThan(0); + expect(statuses).toBeSorted({ + key: 'textContent', + descending: true + }); + }); + }); + + describe('Creation date', () => { + it('should sort by creation date', async () => { + setup(); + + await resetSort(); + const table = await screen.findByRole('table'); + const sort = await within(table).findByRole('button', { + name: 'Trier par date de création' + }); + await user.click(sort); + const dates = await within(table) + .findAllByText(/^\d{2}\/\d{2}\/\d{4}$/) + .then((elements) => elements.map((element) => element.textContent)); + expect(dates.length).toBeGreaterThan(0); + expect(dates).toBeSorted({ + descending: true, + compare: (a: string, b: string) => { + const map = (date: string): Date => + new Date(date.split('/').toReversed().join('-')); + console.log(a, b); + return DEFAULT_ORDER(map(a), map(b)); + } + }); + }); + }); + + describe('Sending date', () => { + // Conflicts with createdAt + it.todo('should sort by sending date'); + }); + + describe('Actions', () => { + it('should remove a campaign', async () => { + async function count() { + return screen + .findByText(/^\d+ campagnes$/) + .then((element) => element.textContent?.split(' ').at(0) ?? '') + .then((count) => Number(count)); + } + + setup(); + + const table = await screen.findByRole('table'); + const countBefore = await count(); + const [remove] = await within(table).findAllByRole('button', { + name: 'Supprimer la campagne' + }); + await user.click(remove); + const dialog = await screen.findByRole('dialog'); + const confirm = await within(dialog).findByRole('button', { + name: 'Confirmer' + }); + await user.click(confirm); + const countAfter = await count(); + expect(countAfter).toBe(countBefore ? countBefore - 1 : false); + }); + }); +}); diff --git a/frontend/src/views/Campaign/test/CampaignView.test.tsx b/frontend/src/views/Campaign/test/CampaignView.test.tsx index 53583feaa..729307738 100644 --- a/frontend/src/views/Campaign/test/CampaignView.test.tsx +++ b/frontend/src/views/Campaign/test/CampaignView.test.tsx @@ -145,7 +145,7 @@ describe('Campaign view', () => { await user.click(tab); const rowsBefore = screen.getAllByRole('row').slice(1); // Remove headers const remove = screen.getAllByRole('button', { - name: /^Supprimer le propriétaire/ + name: /^Supprimer le destinataire/ })[index]; await user.click(remove); const dialog = await screen.findByRole('dialog'); @@ -275,7 +275,7 @@ describe('Campaign view', () => { const tab = await screen.findByRole('tab', { name: /^Destinataires/ }); await user.click(tab); const [edit] = await screen.findAllByRole('button', { - name: /^Éditer l’adresse/ + name: /^Éditer/ }); await user.click(edit); const [aside] = await screen.findAllByRole('complementary'); @@ -297,7 +297,7 @@ describe('Campaign view', () => { const tab = await screen.findByRole('tab', { name: /^Destinataires/ }); await user.click(tab); const [edit] = await screen.findAllByRole('button', { - name: /^Éditer l’adresse/ + name: /^Éditer/ }); await user.click(edit); const [aside] = await screen.findAllByRole('complementary'); diff --git a/frontend/src/views/HousingList/HousingListTab.tsx b/frontend/src/views/HousingList/HousingListTab.tsx index 5b91d1af6..b63baf87f 100644 --- a/frontend/src/views/HousingList/HousingListTab.tsx +++ b/frontend/src/views/HousingList/HousingListTab.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'; import { useSelection } from '../../hooks/useSelection'; import HousingList from '../../components/HousingList/HousingList'; import SelectableListHeaderActions from '../../components/SelectableListHeader/SelectableListHeaderActions'; -import { Row } from '../../components/_dsfr'; import CampaignCreationModal from '../../components/modals/CampaignCreationModal/CampaignCreationModal'; import HousingListEditionSideMenu from '../../components/HousingEdition/HousingListEditionSideMenu'; import SelectableListHeader from '../../components/SelectableListHeader/SelectableListHeader'; @@ -236,11 +235,12 @@ const HousingListTab = ({ })} )} + }> {filteredHousingCount !== undefined && filteredHousingCount > 0 && ( - + <> {selectedCount > 1 && (