Skip to content

Commit

Permalink
refactor: sépare la logique de vignette dans un composant séparé (#207)
Browse files Browse the repository at this point in the history
  • Loading branch information
ocruze authored Dec 11, 2023
1 parent d994455 commit e02dc3f
Show file tree
Hide file tree
Showing 4 changed files with 306 additions and 274 deletions.
5 changes: 2 additions & 3 deletions assets/api/annexe.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import SymfonyRouting from "../modules/Routing";

import { jsonFetch } from "../modules/jsonFetch";
import { AnnexDetailResponseDto } from "../types/entrepot";
import { DatasheetThumbnailAnnexe } from "../types/app";

const addThumbnail = (datastoreId: string, data: object) => {
const url = SymfonyRouting.generate("cartesgouvfr_api_annexe_thumbnail_add", { datastoreId });
return jsonFetch<AnnexDetailResponseDto & { url: string }>(
return jsonFetch<DatasheetThumbnailAnnexe>(
url,
{
method: "POST",
Expand Down
294 changes: 294 additions & 0 deletions assets/pages/datasheet/DatasheetView/DatasheetThumbnail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
import { fr } from "@codegouvfr/react-dsfr";
import Alert from "@codegouvfr/react-dsfr/Alert";
import Button from "@codegouvfr/react-dsfr/Button";
import { createModal, ModalProps } from "@codegouvfr/react-dsfr/Modal";
import { Upload } from "@codegouvfr/react-dsfr/Upload";
import { yupResolver } from "@hookform/resolvers/yup";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { TranslationFunction } from "i18nifty/typeUtils/TranslationFunction";
import { FC, useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import * as yup from "yup";

import { useForm } from "react-hook-form";
import api from "../../../api";
import functions from "../../../functions";
import { ComponentKey, useTranslation } from "../../../i18n/i18n";
import { CartesApiException } from "../../../modules/jsonFetch";
import RQKeys from "../../../modules/RQKeys";
import type { Datasheet, DatasheetDetailed, DatasheetThumbnailAnnexe } from "../../../types/app";

import "../../../sass/components/buttons.scss";

const defaultImgUrl = "//www.gouvernement.fr/sites/default/files/static_assets/placeholder.1x1.png";

const addThumbnailModal = createModal({
id: "add-thumbnail-modal",
isOpenedByDefault: false,
});

export type ThumbnailAction = "add" | "modify" | "delete";

const schema = (t: TranslationFunction<"DatasheetView", ComponentKey>) =>
yup.object().shape({
file: yup
.mixed()
.test("required", t("file_validation.required_error"), (files) => {
const file = files?.[0] ?? undefined;
return file !== undefined;
})
.test("check-file-size", t("file_validation.size_error"), (files) => {
const file = files?.[0] ?? undefined;

if (file instanceof File) {
const size = file.size / 1024 / 1024;
return size < 2;
}
return true;
})
.test("check-file-type", t("file_validation.format_error"), (files) => {
const file = files?.[0] ?? undefined;
if (file) {
const extension = functions.path.getFileExtension(file.name);
if (!extension) {
return false;
}
return ["jpg", "jpeg", "png"].includes(extension);
}
return true;
}),
});

type DatasheetThumbnailProps = {
datastoreId: string;
datasheetName: string;
datasheet?: Datasheet;
};
const DatasheetThumbnail: FC<DatasheetThumbnailProps> = ({ datastoreId, datasheetName, datasheet }) => {
const queryClient = useQueryClient();
const { t: tCommon } = useTranslation("Common");
const { t } = useTranslation("DatasheetView");

// Boite modale, gestion de l'image
const [modalImageUrl, setModalImageUrl] = useState<string>("");
const [thumbnailAddBtnHover, setThumbnailAddBtnHover] = useState(false);

// Ajout/modification d'une vignette
const addThumbnailMutation = useMutation<DatasheetThumbnailAnnexe, CartesApiException>({
mutationFn: () => {
const form = new FormData();
form.append("datasheetName", datasheetName);
form.append("file", upload);
return api.annexe.addThumbnail(datastoreId, form);
},
onSuccess: (response) => {
addThumbnailModal.close();

// Mise à jour du contenu de la réponse de datasheetQuery
queryClient.setQueryData<DatasheetDetailed>(RQKeys.datastore_datasheet(datastoreId, datasheetName), (datasheet) => {
if (datasheet) {
datasheet.thumbnail = response;
}
return datasheet;
});

// Mise à jour du contenu de la réponse de datasheetListQuery
queryClient.setQueryData<Datasheet[]>(RQKeys.datastore_datasheet_list(datastoreId), (datasheetList = []) => {
return datasheetList.map((datasheet) => {
if (datasheet.name === datasheetName) {
datasheet.thumbnail = response;
}
return datasheet;
});
});
},
onSettled: () => {
reset();
},
});

// Suppression d'une vignette
const deleteThumbnailMutation = useMutation<null, CartesApiException>({
mutationFn: () => {
if (datasheet?.thumbnail?._id) {
return api.annexe.removeThumbnail(datastoreId, datasheet?.thumbnail?._id);
}
return Promise.resolve(null);
},
onSuccess: () => {
addThumbnailModal.close();

// mise à jour du contenu de la réponse de datasheetQuery
queryClient.setQueryData<DatasheetDetailed>(RQKeys.datastore_datasheet(datastoreId, datasheetName), (datasheet) => {
if (datasheet) {
datasheet.thumbnail = undefined;
}
return datasheet;
});

// mise à jour du contenu de la réponse de datasheetListQuery
queryClient.setQueryData<Datasheet[]>(RQKeys.datastore_datasheet_list(datastoreId), (datasheetList = []) => {
return datasheetList.map((datasheet) => {
if (datasheet.name === datasheetName) {
datasheet.thumbnail = undefined;
}
return datasheet;
});
});
},
onSettled: () => {
reset();
},
});

const {
register,
formState: { errors },
watch,
resetField,
handleSubmit,
} = useForm({ resolver: yupResolver(schema(t)), mode: "onChange" });

const upload: File = watch("file")?.[0];
useEffect(() => {
if (upload !== undefined) {
const reader = new FileReader();
reader.onload = () => {
setModalImageUrl(reader.result as string);
};
reader.readAsDataURL(upload);
}
}, [upload]);

const reset = useCallback(() => {
resetField("file");
setModalImageUrl("");
}, [resetField]);

const onSubmit = useCallback(async () => {
if (upload) {
// Ajout dans les annexes
addThumbnailMutation.mutate();
}
}, [addThumbnailMutation, upload]);

const action: ThumbnailAction = useMemo(() => (datasheet?.thumbnail?.url ? "modify" : "add"), [datasheet?.thumbnail?.url]);

// Boutons de la boite de dialogue
const thumbnailModalButtons: [ModalProps.ActionAreaButtonProps, ...ModalProps.ActionAreaButtonProps[]] = useMemo(() => {
const btns: [ModalProps.ActionAreaButtonProps, ...ModalProps.ActionAreaButtonProps[]] = [
{
children: tCommon("cancel"),
onClick: () => {
reset();
addThumbnailMutation.reset();
},
doClosesModal: true,
priority: "secondary",
},
];

if (datasheet?.thumbnail?._id) {
btns.push({
children: tCommon("delete"),
iconId: "fr-icon-delete-line",
onClick: () => {
deleteThumbnailMutation.mutate();
},
doClosesModal: false,
priority: "secondary",
});
}
btns.push({
children: t("thumbnail_action", { action: action }),
onClick: handleSubmit(onSubmit),
doClosesModal: false,
priority: "primary",
});

return btns;
}, [action, addThumbnailMutation, datasheet?.thumbnail, deleteThumbnailMutation, handleSubmit, onSubmit, reset, t, tCommon]);

return (
<>
<Button
priority="tertiary no outline"
onClick={addThumbnailModal.open}
nativeButtonProps={{
"aria-label": t("button.title"),
title: t("button.title"),
onMouseOver: () => setThumbnailAddBtnHover(true),
onMouseOut: () => setThumbnailAddBtnHover(false),
}}
className="frx-btn--hover"
>
<img
className={thumbnailAddBtnHover ? "frx-btn--transparent fr-img--transparent-transition" : ""}
loading="lazy"
src={datasheet?.thumbnail?.url === undefined ? defaultImgUrl : datasheet?.thumbnail?.url}
width="128px"
height="128px"
/>
{thumbnailAddBtnHover && (
<div className="frx-btn--hover-icon">
<span className={fr.cx("fr-icon-edit-line")} />
</div>
)}
</Button>
{createPortal(
<addThumbnailModal.Component title={t("thumbnail_modal.title")} buttons={thumbnailModalButtons}>
{addThumbnailMutation.isError && (
<Alert
severity="error"
closable
title={tCommon("error")}
description={addThumbnailMutation.error.message}
className={fr.cx("fr-my-3w")}
/>
)}
{deleteThumbnailMutation.isError && (
<Alert
severity="error"
closable
title={tCommon("error")}
description={deleteThumbnailMutation.error.message}
className={fr.cx("fr-my-3w")}
/>
)}
<div className={fr.cx("fr-grid-row")}>
<div className={fr.cx("fr-col-9")}>
<Upload
label={""}
hint={t("thumbnail_modal.file_hint")}
state={errors.file ? "error" : "default"}
stateRelatedMessage={errors?.file?.message}
nativeInputProps={{
...register("file"),
accept: ".png, .jpg, .jpeg",
}}
/>
</div>
<div className={fr.cx("fr-col-3")}>
<img src={modalImageUrl === "" ? defaultImgUrl : modalImageUrl} width="128px" />
</div>
</div>
{addThumbnailMutation.isPending && (
<div className={fr.cx("fr-grid-row", "fr-grid-row--middle")}>
<i className={fr.cx("fr-icon-refresh-line", "fr-icon--lg", "fr-mr-2v") + " icons-spin"} />
<h6 className={fr.cx("fr-m-0")}>{t("thumbnail_modal.action_being", { action: action })}</h6>
</div>
)}
{deleteThumbnailMutation.isPending && (
<div className={fr.cx("fr-grid-row", "fr-grid-row--middle")}>
<i className={fr.cx("fr-icon-refresh-line", "fr-icon--lg", "fr-mr-2v") + " icons-spin"} />
<h6 className={fr.cx("fr-m-0")}>{t("thumbnail_modal.action_being", { action: "delete" })}</h6>
</div>
)}
</addThumbnailModal.Component>,
document.body
)}
</>
);
};

export default DatasheetThumbnail;
Loading

0 comments on commit e02dc3f

Please sign in to comment.