diff --git a/assets/@types/espaceco.ts b/assets/@types/espaceco.ts index 019b579f..f222de4a 100644 --- a/assets/@types/espaceco.ts +++ b/assets/@types/espaceco.ts @@ -18,7 +18,8 @@ export interface CommunityResponseDTO { /** @format date-time */ creation: string; grids: Grids[]; - logo_url: string; + logo_url: string | null; + keywords?: string[]; } export interface Grids { @@ -27,3 +28,7 @@ export interface Grids { type: string; deleted: boolean; } + +export interface CommunityPatchDTO extends Partial> { + logo: File | null; +} diff --git a/assets/components/Input/AutocompleteSelect.tsx b/assets/components/Input/AutocompleteSelect.tsx index a88cd8e4..45cd3503 100644 --- a/assets/components/Input/AutocompleteSelect.tsx +++ b/assets/components/Input/AutocompleteSelect.tsx @@ -8,7 +8,7 @@ import { symToStr } from "tsafe/symToStr"; interface AutocompleteSelectProps { id?: string; label: string; - hintText: string; + hintText?: string; state?: "default" | "error" | "success"; stateRelatedMessage?: string; defaultValue?: T[]; diff --git a/assets/espaceco/api/community.ts b/assets/espaceco/api/community.ts index dabd71da..4eaa8738 100644 --- a/assets/espaceco/api/community.ts +++ b/assets/espaceco/api/community.ts @@ -28,6 +28,26 @@ const getAsMember = (queryParams: Record, signal: AbortSignal) }); }; -const community = { get, searchByName, getAsMember }; +const getCommunity = (communityId: number) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_community_get_community", { communityId: communityId }); + return jsonFetch(url); +}; + +const updateLogo = (communityId: number, formData: FormData) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_community_update_logo", { communityId: communityId }); + return jsonFetch( + url, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + formData + ); +}; + +const community = { get, getCommunity, searchByName, getAsMember, updateLogo }; export default community; diff --git a/assets/espaceco/pages/communities/Communities.tsx b/assets/espaceco/pages/communities/Communities.tsx index 11e755dd..af88cc3f 100644 --- a/assets/espaceco/pages/communities/Communities.tsx +++ b/assets/espaceco/pages/communities/Communities.tsx @@ -30,7 +30,7 @@ type QueryParamsType = { const Communities: FC = () => { const route = useRoute(); - const { t } = useTranslation("EspaceCoCommunities"); + const { t } = useTranslation("CommunityList"); const filter = useMemo(() => { const f = route.params["filter"]; diff --git a/assets/espaceco/pages/communities/CommunityListItem.tsx b/assets/espaceco/pages/communities/CommunityListItem.tsx index e832e7b6..9f2e02a7 100644 --- a/assets/espaceco/pages/communities/CommunityListItem.tsx +++ b/assets/espaceco/pages/communities/CommunityListItem.tsx @@ -9,21 +9,23 @@ import { useTranslation } from "../../../i18n/i18n"; import placeholder1x1 from "../../../img/placeholder.1x1.png"; import "../../../sass/pages/espaceco/community.scss"; +import { routes } from "../../../router/router"; type CommunityListItemProps = { className?: string; community: CommunityResponseDTO; }; const CommunityListItem: FC = ({ className, community }) => { - const { t } = useTranslation("EspaceCoCommunities"); + const { t } = useTranslation("CommunityList"); + const { t: tCommon } = useTranslation("Common"); const [showDescription, toggleShowDescription] = useToggle(false); return ( <> -
+
-
+
-
-
+
+
+
+
+
{community.detailed_description &&
} diff --git a/assets/espaceco/pages/communities/EspaceCoCommunitiesTr.ts b/assets/espaceco/pages/communities/CommunityListTr.ts similarity index 87% rename from assets/espaceco/pages/communities/EspaceCoCommunitiesTr.ts rename to assets/espaceco/pages/communities/CommunityListTr.ts index d4171b66..391cee39 100644 --- a/assets/espaceco/pages/communities/EspaceCoCommunitiesTr.ts +++ b/assets/espaceco/pages/communities/CommunityListTr.ts @@ -14,9 +14,9 @@ export const { i18n } = declareComponentKeys< | "no_options" | "loading" | "show_details" ->()("EspaceCoCommunities"); +>()("CommunityList"); -export const EspaceCoCommunitiesFrTranslations: Translations<"fr">["EspaceCoCommunities"] = { +export const CommunityListFrTranslations: Translations<"fr">["CommunityList"] = { title: "Liste des guichets", filters: "Filtres", all_public_communities: "Tous les guichets publics", @@ -36,7 +36,7 @@ export const EspaceCoCommunitiesFrTranslations: Translations<"fr">["EspaceCoComm show_details: "Afficher les détails", }; -export const EspaceCoCommunitiesEnTranslations: Translations<"en">["EspaceCoCommunities"] = { +export const CommunityListEnTranslations: Translations<"en">["CommunityList"] = { title: "List of communities", filters: "Filters", all_public_communities: undefined, diff --git a/assets/espaceco/pages/communities/ManageCommunity.tsx b/assets/espaceco/pages/communities/ManageCommunity.tsx new file mode 100644 index 00000000..471d5b47 --- /dev/null +++ b/assets/espaceco/pages/communities/ManageCommunity.tsx @@ -0,0 +1,85 @@ +import { useQuery } from "@tanstack/react-query"; +import { FC, useState } from "react"; +import RQKeys from "../../../modules/espaceco/RQKeys"; +import api from "../../api"; +import { datastoreNavItems } from "../../../config/datastoreNavItems"; +import AppLayout from "../../../components/Layout/AppLayout"; +import { useTranslation } from "../../../i18n/i18n"; +import LoadingText from "../../../components/Utils/LoadingText"; +import { fr } from "@codegouvfr/react-dsfr"; +import Alert from "@codegouvfr/react-dsfr/Alert"; +import Button from "@codegouvfr/react-dsfr/Button"; +import { routes } from "../../../router/router"; +import Tabs from "@codegouvfr/react-dsfr/Tabs"; +import Description from "./management/Description"; +import { CommunityResponseDTO } from "../../../@types/espaceco"; +import { CartesApiException } from "../../../modules/jsonFetch"; + +type ManageCommunityProps = { + communityId: number; +}; + +const navItems = datastoreNavItems(); + +const ManageCommunity: FC = ({ communityId }) => { + const { t } = useTranslation("ManageCommunity"); + + const communityQuery = useQuery({ + queryKey: RQKeys.community(communityId), + queryFn: () => api.community.getCommunity(communityId), + staleTime: 3600000, + }); + + const [selectedTabId, setSelectedTabId] = useState("tab1"); + + return ( + +

{t("title", { name: communityQuery.data?.name })}

+ {communityQuery.isLoading ? ( + + ) : communityQuery.isError ? ( + +

{communityQuery.error?.message}

+ + + } + /> + ) : ( + communityQuery.data !== undefined && ( +
+ + <> + {(() => { + switch (selectedTabId) { + case "tab1": + return ; + default: + return

`Content of ${selectedTabId}`

; + } + })()} + +
+
+ ) + )} +
+ ); +}; + +export default ManageCommunity; diff --git a/assets/espaceco/pages/communities/ManageCommunityTr.ts b/assets/espaceco/pages/communities/ManageCommunityTr.ts new file mode 100644 index 00000000..43bc9e60 --- /dev/null +++ b/assets/espaceco/pages/communities/ManageCommunityTr.ts @@ -0,0 +1,95 @@ +import { declareComponentKeys } from "i18nifty"; +import { Translations } from "../../../i18n/i18n"; + +export type logoAction = "add" | "modify" | "delete"; + +// traductions +export const { i18n } = declareComponentKeys< + | { K: "title"; P: { name: string | undefined }; R: string } + | "loading" + | "fetch_failed" + | "back_to_list" + | "tab1" + | "tab2" + | "tab3" + | "tab4" + | "tab5" + | "tab6" + | "desc.name" + | "desc.hint_name" + | "desc.description" + | "desc.hint_description" + | "desc.logo" + | "desc.logo.title" + | { K: "logo_action"; P: { action: logoAction }; R: string } + | "logo_confirm_delete_modal.title" + | "modal.logo.title" + | "modal.logo.file_hint" + | "desc.keywords" +>()("ManageCommunity"); + +export const ManageCommunityFrTranslations: Translations<"fr">["ManageCommunity"] = { + title: ({ name }) => (name === undefined ? "Gérer le guichet" : `Gérer le guichet - ${name}`), + loading: "Recherche du guichet en cours ...", + fetch_failed: "La récupération des informations sur le guichet a échoué", + back_to_list: "Retour à la liste des guichets", + tab1: "Description", + tab2: "Bases de données", + tab3: "Zoom, centrage", + tab4: "Couches de la carte", + tab5: "Outils", + tab6: "Signalements", + "desc.name": "Nom du guichet", + "desc.hint_name": "Donnez un nom clair et compréhensible", + "desc.description": "Description", + "desc.hint_description": "Bref résumé narratif de l'objectif du guichet", + "desc.logo": "Logo (optionnel)", + "desc.logo.title": "Ajouter, modifier ou supprimer le logo du guichet", + logo_action: ({ action }) => { + switch (action) { + case "add": + return "Ajouter un logo"; + case "modify": + return "Remplacer le logo"; + case "delete": + return "Supprimer le logo"; + } + }, + "logo_confirm_delete_modal.title": "Êtes-vous sûr de vouloir supprimer le logo de ce guichet ?", + "modal.logo.title": "Logo du guichet", + "modal.logo.file_hint": "Taille maximale : 5 Mo. Formats acceptés : jpg, png", + "desc.keywords": "Mots-clés (optionnel)", +}; + +export const ManageCommunityEnTranslations: Translations<"en">["ManageCommunity"] = { + title: ({ name }) => (name === undefined ? "Manage front office" : `Manage front office - ${name}`), + loading: undefined, + fetch_failed: undefined, + back_to_list: undefined, + tab1: undefined, + tab2: undefined, + tab3: undefined, + tab4: undefined, + tab5: undefined, + tab6: undefined, + "desc.name": undefined, + "desc.hint_name": undefined, + "desc.description": undefined, + "desc.hint_description": undefined, + "desc.logo": undefined, + "desc.logo.title": undefined, + logo_action: ({ action }) => { + switch (action) { + case "add": + return "Add logo"; + case "modify": + return "Replace logo"; + case "delete": + return "Delete logo"; + } + }, + "logo_confirm_delete_modal.title": undefined, + "modal.logo.title": undefined, + "modal.logo.file_hint": undefined, + "desc.keywords": undefined, +}; diff --git a/assets/espaceco/pages/communities/SearchCommunity.tsx b/assets/espaceco/pages/communities/SearchCommunity.tsx index ed34fff9..7ea8ed44 100644 --- a/assets/espaceco/pages/communities/SearchCommunity.tsx +++ b/assets/espaceco/pages/communities/SearchCommunity.tsx @@ -17,7 +17,7 @@ type SearchCommunityProps = { }; const SearchCommunity: FC = ({ filter, onChange }) => { - const { t } = useTranslation("EspaceCoCommunities"); + const { t } = useTranslation("CommunityList"); const [search, setSearch] = useDebounceValue("", 500); diff --git a/assets/espaceco/pages/communities/management/CommunityLogo.tsx b/assets/espaceco/pages/communities/management/CommunityLogo.tsx new file mode 100644 index 00000000..b270649b --- /dev/null +++ b/assets/espaceco/pages/communities/management/CommunityLogo.tsx @@ -0,0 +1,261 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Button from "@codegouvfr/react-dsfr/Button"; +import { createModal, ModalProps } from "@codegouvfr/react-dsfr/Modal"; +import { cx } from "@codegouvfr/react-dsfr/tools/cx"; +import { Upload } from "@codegouvfr/react-dsfr/Upload"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { TranslationFunction } from "i18nifty/typeUtils/TranslationFunction"; +import { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useForm } from "react-hook-form"; +import { useHover } from "usehooks-ts"; +import * as yup from "yup"; +import { ConfirmDialog, ConfirmDialogModal } from "../../../../components/Utils/ConfirmDialog"; +import { ComponentKey, useTranslation } from "../../../../i18n/i18n"; +import { getFileExtension, getImageSize, ImageSize } from "../../../../utils"; +import { logoAction } from "../ManageCommunityTr"; + +import placeholder1x1 from "../../../../img/placeholder.1x1.png"; +import "../../../../sass/components/buttons.scss"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { CartesApiException } from "../../../../modules/jsonFetch"; +import { CommunityResponseDTO } from "../../../../@types/espaceco"; +import api from "../../../api"; + +type CommunityLogoProps = { + communityId: number; + logoUrl: string | null; +}; + +const AddLogoModal = createModal({ + id: "add-logo-modal", + isOpenedByDefault: false, +}); + +const schema = (t: TranslationFunction<"ManageCommunityValidations", ComponentKey>) => + yup.object().shape({ + logo: yup + .mixed() + .test("check-file-size", t("description.logo.size_error"), (files) => { + const file = files?.[0] ?? undefined; + + if (file instanceof File) { + const size = file.size / 1024 / 1024; + return size < 5; + } + return true; + }) + .test("check-file-dimensions", t("description.logo.dimensions_error"), async (files) => { + const file = files?.[0] ?? undefined; + if (file) { + const size: ImageSize = await getImageSize(file); + if (size.width > 400 || size.height > 400) { + return false; + } + } + return true; + }) + .test("check-file-type", t("description.logo.format_error"), (files) => { + const file = files?.[0] ?? undefined; + if (file) { + const extension = getFileExtension(file.name); + if (!extension) { + return false; + } + return ["jpg", "jpeg", "png"].includes(extension); + } + return true; + }), + }); + +const CommunityLogo: FC = ({ communityId, logoUrl }) => { + const { t: tCommon } = useTranslation("Common"); + const { t: tValidation } = useTranslation("ManageCommunityValidations"); + const { t } = useTranslation("ManageCommunity"); + + const [isValid, setIsValid] = useState(false); + useEffect(() => { + if (logoUrl) { + fetch(logoUrl).then((res) => { + setIsValid(() => res.status === 200); + }); + } + }, [logoUrl]); + + const action: logoAction = useMemo(() => (isValid ? "modify" : "add"), [isValid]); + + // Boite modale, gestion de l'image + const [modalImageUrl, setModalImageUrl] = useState(""); + const logoDivRef = useRef(null); + const logoIsHovered = useHover(logoDivRef); + + // const queryClient = useQueryClient(); + + // Ajout/modification du logo + const updateLogoMutation = useMutation({ + mutationFn: () => { + const form = new FormData(); + form.append("logo", upload); + return api.community.updateLogo(communityId, form); + }, + onSuccess: (response) => { + AddLogoModal.close(); + + // Mise à jour du contenu de la réponse de datasheetQuery + /*queryClient.setQueryData(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(RQKeys.datastore_datasheet_list(datastoreId), (datasheetList = []) => { + return datasheetList.map((datasheet) => { + if (datasheet.name === datasheetName) { + datasheet.thumbnail = response; + } + return datasheet; + }); + });*/ + }, + onSettled: () => { + reset(); + }, + }); + + const { + register, + formState: { errors }, + watch, + resetField, + handleSubmit, + } = useForm({ resolver: yupResolver(schema(tValidation)), mode: "onChange" }); + + const upload: File = watch("logo")?.[0]; + + useEffect(() => { + if (upload !== undefined) { + const reader = new FileReader(); + reader.onload = () => { + setModalImageUrl(reader.result as string); + }; + reader.readAsDataURL(upload); + } + }, [upload]); + + const reset = useCallback(() => { + resetField("logo"); + setModalImageUrl(""); + }, [resetField]); + + const onSubmit = useCallback(async () => { + if (upload) { + // Ajout du logo + updateLogoMutation.mutate(); + } + }, [updateLogoMutation, upload]); + + // Boutons de la boite de dialogue + const AddModalButtons: [ModalProps.ActionAreaButtonProps, ...ModalProps.ActionAreaButtonProps[]] = useMemo(() => { + const btns: [ModalProps.ActionAreaButtonProps, ...ModalProps.ActionAreaButtonProps[]] = [ + { + children: tCommon("cancel"), + onClick: () => { + reset(); + // addThumbnailMutation.reset(); + }, + doClosesModal: true, + priority: "secondary", + }, + { + children: t("logo_action", { action: action }), + onClick: handleSubmit(onSubmit), + doClosesModal: false, + priority: "primary", + }, + ]; + + return btns; + }, [action, /*addThumbnailMutation,*/ handleSubmit, onSubmit, reset, t, tCommon]); + + return ( +
+ +
+ { + currentTarget.onerror = null; // prevents looping + currentTarget.src = placeholder1x1; + }} */ + /> + {logoIsHovered && ( +
+
+ )} +
+ {createPortal( + + {/* {addThumbnailMutation.isError && ( + + )} */} +
+
+ +
+
+ +
+
+ {/* {addThumbnailMutation.isPending && ( +
+ +
{t("thumbnail_modal.action_being", { action: action })}
+
+ )} */} +
, + document.body + )} + { + // deleteThumbnailMutation.mutate(); + }} + /> +
+ ); +}; + +export default memo(CommunityLogo); diff --git a/assets/espaceco/pages/communities/management/Description.tsx b/assets/espaceco/pages/communities/management/Description.tsx new file mode 100644 index 00000000..b45ea4c0 --- /dev/null +++ b/assets/espaceco/pages/communities/management/Description.tsx @@ -0,0 +1,102 @@ +import { FC } from "react"; +import { CommunityResponseDTO } from "../../../../@types/espaceco"; +import { ComponentKey, useTranslation } from "../../../../i18n/i18n"; +import { TranslationFunction } from "i18nifty/typeUtils/TranslationFunction"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { Controller, useForm } from "react-hook-form"; +import Input from "@codegouvfr/react-dsfr/Input"; +import MarkdownEditor from "../../../../components/Input/MarkdownEditor"; +import CommunityLogo from "./CommunityLogo"; +import AutocompleteSelect from "../../../../components/Input/AutocompleteSelect"; +import categories from "../../../../data/topic_categories.json"; + +type DescriptionProps = { + community: CommunityResponseDTO; +}; + +const Description: FC = ({ community }) => { + const { t: tCommon } = useTranslation("Common"); + const { t: tValid } = useTranslation("ManageCommunityValidations"); + const { t } = useTranslation("ManageCommunity"); + + const schema = (t: TranslationFunction<"ManageCommunityValidations", ComponentKey>) => { + return yup.object({ + name: yup + .string() + .trim(t("trimmed_error")) + .strict(true) + .min(2, t("description.name.minlength")) + .max(80, t("description.name.maxlength")) + .required(t("description.name.mandatory")), + description: yup.string().max(1024, t("description.desc.maxlength")).required(t("description.desc.mandatory")), + keywords: yup.array().of(yup.string()), + }); + }; + + const { + control, + register, + getValues: getFormValues, + formState: { errors }, + } = useForm({ + resolver: yupResolver(schema(tValid)), + mode: "onChange", + values: { + name: community.name, + description: community.description ?? "", + // TODO keywords: community.keywords ?? [] + keywords: [], + }, + }); + + return ( +
+

{tCommon("mandatory_fields")}

+ + ( + { + field.onChange(values); + }} + /> + )} + /> + + ( + field.onChange(value)} + /> + )} + /> +
+ ); +}; + +export default Description; diff --git a/assets/espaceco/pages/communities/management/validationTr.tsx b/assets/espaceco/pages/communities/management/validationTr.tsx new file mode 100644 index 00000000..f97b36f3 --- /dev/null +++ b/assets/espaceco/pages/communities/management/validationTr.tsx @@ -0,0 +1,39 @@ +import { declareComponentKeys } from "i18nifty"; +import { Translations } from "../../../../i18n/i18n"; + +// traductions +export const { i18n } = declareComponentKeys< + | "trimmed_error" + | "description.name.mandatory" + | "description.name.minlength" + | "description.name.maxlength" + | "description.desc.mandatory" + | "description.desc.maxlength" + | "description.logo.size_error" + | "description.logo.dimensions_error" + | "description.logo.format_error" +>()("ManageCommunityValidations"); + +export const ManageCommunityValidationsFrTranslations: Translations<"fr">["ManageCommunityValidations"] = { + trimmed_error: "La chaîne de caractères ne doit contenir aucun espace en début et fin", + "description.name.mandatory": "Le nom est obligatoire", + "description.name.minlength": "Le nom doit faire au moins 2 caractères", + "description.name.maxlength": "Le nom ne doit pas dépasser 80 caractères", + "description.desc.mandatory": "La description est obligatoire", + "description.desc.maxlength": "La description ne doit pas faire plus de 1024 caractères", + "description.logo.size_error": "La taille du fichier ne peut excéder 5 Mo", + "description.logo.dimensions_error": "Les dimensions maximales de l'image sont de 400px x 400px", + "description.logo.format_error": "Le fichier doit être au format jpeg ou png", +}; + +export const ManageCommunityValidationsEnTranslations: Translations<"en">["ManageCommunityValidations"] = { + trimmed_error: undefined, + "description.name.mandatory": undefined, + "description.name.minlength": undefined, + "description.name.maxlength": undefined, + "description.desc.mandatory": undefined, + "description.desc.maxlength": undefined, + "description.logo.size_error": undefined, + "description.logo.dimensions_error": undefined, + "description.logo.format_error": undefined, +}; diff --git a/assets/i18n/i18n.ts b/assets/i18n/i18n.ts index 083281ab..7eab0422 100644 --- a/assets/i18n/i18n.ts +++ b/assets/i18n/i18n.ts @@ -50,7 +50,9 @@ export type ComponentKey = | typeof import("../entrepot/pages/service/TableSelection").i18n | typeof import("../entrepot/pages/service/AccessRestrictions").i18n | typeof import("../entrepot/pages/service/wms-vector/UploadStyleFile").i18n - | typeof import("../espaceco/pages/communities/EspaceCoCommunitiesTr").i18n; + | typeof import("../espaceco/pages/communities/CommunityListTr").i18n + | typeof import("../espaceco/pages/communities/ManageCommunityTr").i18n + | typeof import("../espaceco/pages/communities/management/validationTr").i18n; export type Translations = GenericTranslations; export type LocalizedString = Parameters[0]; diff --git a/assets/i18n/languages/en.tsx b/assets/i18n/languages/en.tsx index 72a3c27c..97bc6053 100644 --- a/assets/i18n/languages/en.tsx +++ b/assets/i18n/languages/en.tsx @@ -3,9 +3,9 @@ import { navItemsEnTranslations } from "../../config/navItems"; import { AccessesRequestEnTranslations } from "../../entrepot/pages/AccessesRequest"; import { AddMemberEnTranslations } from "../../entrepot/pages/communities/AddMember"; import { CommunityMembersEnTranslations } from "../../entrepot/pages/communities/CommunityMembers"; -import { contactEnTranslations } from "../../pages/assistance/contact/Contact"; import { DashboardProEnTranslations } from "../../entrepot/pages/dashboard/DashboardPro"; import { DatasheetListEnTranslations } from "../../entrepot/pages/datasheet/DatasheetList/DatasheetList"; +import { DatasheetUploadFormEnTranslations } from "../../entrepot/pages/datasheet/DatasheetNew/DatasheetUploadForm"; import { PyramidListItemFrTranslations } from "../../entrepot/pages/datasheet/DatasheetView/DatasetListTab/PyramidList/PyramidListItem"; import { VectorDbListItemEnTranslations } from "../../entrepot/pages/datasheet/DatasheetView/DatasetListTab/VectorDbList/VectorDbListItem"; import { DatasheetViewEnTranslations } from "../../entrepot/pages/datasheet/DatasheetView/DatasheetView"; @@ -24,16 +24,18 @@ import { MyAccessKeysEnTranslations } from "../../entrepot/pages/users/MyAccessK import { UserKeyEnTranslations } from "../../entrepot/pages/users/keys/UserKeyTr"; import { UserKeysListTabEnTranslations } from "../../entrepot/pages/users/keys/UserKeysListTab"; import { PermissionsEnTranslations } from "../../entrepot/pages/users/permissions/PermissionsTr"; -import { EspaceCoCommunitiesEnTranslations } from "../../espaceco/pages/communities/EspaceCoCommunitiesTr"; +import { CommunityListEnTranslations } from "../../espaceco/pages/communities/CommunityListTr"; +import { ManageCommunityEnTranslations } from "../../espaceco/pages/communities/ManageCommunityTr"; +import { ManageCommunityValidationsEnTranslations } from "../../espaceco/pages/communities/management/validationTr"; import { TMSStyleFilesManagerEnTranslations } from "../../modules/Style/TMSStyleFilesManager"; +import { contactEnTranslations } from "../../pages/assistance/contact/Contact"; import { mapboxStyleValidationEnTranslations } from "../../validations/MapboxStyleValidator"; import { SldStyleValidationErrorsEnTranslations } from "../../validations/SldStyleValidationErrorsTr"; -import { commonEnTranslations } from "../Common"; import { BreadcrumbEnTranslations } from "../Breadcrumb"; +import { commonEnTranslations } from "../Common"; import { RightsEnTranslations } from "../Rights"; import { StyleEnTranslations } from "../Style"; import type { Translations } from "../i18n"; -import { DatasheetUploadFormEnTranslations } from "../../entrepot/pages/datasheet/DatasheetNew/DatasheetUploadForm"; export const translations: Translations<"en"> = { Common: commonEnTranslations, @@ -67,8 +69,10 @@ export const translations: Translations<"en"> = { TableSelection: TableSelectionEnTranslations, UploadStyleFile: UploadStyleFileEnTranslations, PyramidVectorTmsServiceForm: PyramidVectorTmsServiceFormEnTranslations, - EspaceCoCommunities: EspaceCoCommunitiesEnTranslations, DatasheetUploadForm: DatasheetUploadFormEnTranslations, DatasheetList: DatasheetListEnTranslations, AccessRestrictions: AccessRestrictionsEnTranslations, + CommunityList: CommunityListEnTranslations, + ManageCommunity: ManageCommunityEnTranslations, + ManageCommunityValidations: ManageCommunityValidationsEnTranslations, }; diff --git a/assets/i18n/languages/fr.tsx b/assets/i18n/languages/fr.tsx index 3ace06bb..de206d00 100644 --- a/assets/i18n/languages/fr.tsx +++ b/assets/i18n/languages/fr.tsx @@ -24,13 +24,15 @@ import { MyAccessKeysFrTranslations } from "../../entrepot/pages/users/MyAccessK import { UserKeyFrTranslations } from "../../entrepot/pages/users/keys/UserKeyTr"; import { UserKeysListTabFrTranslations } from "../../entrepot/pages/users/keys/UserKeysListTab"; import { PermissionsFrTranslations } from "../../entrepot/pages/users/permissions/PermissionsTr"; -import { EspaceCoCommunitiesFrTranslations } from "../../espaceco/pages/communities/EspaceCoCommunitiesTr"; +import { CommunityListFrTranslations } from "../../espaceco/pages/communities/CommunityListTr"; +import { ManageCommunityFrTranslations } from "../../espaceco/pages/communities/ManageCommunityTr"; +import { ManageCommunityValidationsFrTranslations } from "../../espaceco/pages/communities/management/validationTr"; import { TMSStyleFilesManagerFrTranslations } from "../../modules/Style/TMSStyleFilesManager"; import { contactFrTranslations } from "../../pages/assistance/contact/Contact"; import { mapboxStyleValidationFrTranslations } from "../../validations/MapboxStyleValidator"; import { SldStyleValidationErrorsFrTranslations } from "../../validations/SldStyleValidationErrorsTr"; -import { commonFrTranslations } from "../Common"; import { BreadcrumbFrTranslations } from "../Breadcrumb"; +import { commonFrTranslations } from "../Common"; import { RightsFrTranslations } from "../Rights"; import { StyleFrTranslations } from "../Style"; import type { Translations } from "../i18n"; @@ -67,8 +69,10 @@ export const translations: Translations<"fr"> = { TableSelection: TableSelectionFrTranslations, UploadStyleFile: UploadStyleFileFrTranslations, PyramidVectorTmsServiceForm: PyramidVectorTmsServiceFormFrTranslations, - EspaceCoCommunities: EspaceCoCommunitiesFrTranslations, DatasheetUploadForm: DatasheetUploadFormFrTranslations, DatasheetList: DatasheetListFrTranslations, AccessRestrictions: AccessRestrictionsFrTranslations, + CommunityList: CommunityListFrTranslations, + ManageCommunity: ManageCommunityFrTranslations, + ManageCommunityValidations: ManageCommunityValidationsFrTranslations, }; diff --git a/assets/modules/espaceco/RQKeys.ts b/assets/modules/espaceco/RQKeys.ts index b2bd8210..5dae8416 100644 --- a/assets/modules/espaceco/RQKeys.ts +++ b/assets/modules/espaceco/RQKeys.ts @@ -1,7 +1,8 @@ import { CommunityListFilter } from "../../@types/app_espaceco"; const RQKeys = { - community_list: (page: number, limit: number): string[] => ["community", page.toString(), limit.toString()], + community: (communityId: number): string[] => ["community", communityId.toString()], + community_list: (page: number, limit: number): string[] => ["communities", page.toString(), limit.toString()], search: (search: string, filter: CommunityListFilter): string[] => { return ["search", "community", filter, search]; }, diff --git a/assets/router/RouterRenderer.tsx b/assets/router/RouterRenderer.tsx index 0bf578fa..341a84e2 100644 --- a/assets/router/RouterRenderer.tsx +++ b/assets/router/RouterRenderer.tsx @@ -57,6 +57,7 @@ const PyramidVectorTmsServiceForm = lazy(() => import("../entrepot/pages/service const ServiceView = lazy(() => import("../entrepot/pages/service/view/ServiceView")); const EspaceCoCommunityList = lazy(() => import("../espaceco/pages/communities/Communities")); +const EspaceCoManageCommunity = lazy(() => import("../espaceco/pages/communities/ManageCommunity")); const RouterRenderer: FC = () => { const route = useRoute(); @@ -180,6 +181,8 @@ const RouterRenderer: FC = () => { return ; case "espaceco_community_list": return ; + case "espaceco_manage_community": + return ; default: return ; } diff --git a/assets/router/router.ts b/assets/router/router.ts index b659afff..3cf7ddea 100644 --- a/assets/router/router.ts +++ b/assets/router/router.ts @@ -228,6 +228,13 @@ const routeDefs = { }, () => `${appRoot}/espaceco/community` ), + + espaceco_manage_community: defineRoute( + { + communityId: param.path.number, + }, + (p) => `${appRoot}/espaceco/community/${p.communityId}/gestion` + ), }; export const { RouteProvider, useRoute, routes, session } = createRouter(routeDefs); diff --git a/assets/utils.ts b/assets/utils.ts index dcc79148..5be235ac 100644 --- a/assets/utils.ts +++ b/assets/utils.ts @@ -162,6 +162,31 @@ const getFileExtension = (filename: string) => { return filename.split(".").pop()?.toLowerCase(); }; +export type ImageSize = { + width: number; + height: number; +}; +const getImageSize = async (image: File): Promise => { + return new Promise((resolve, reject) => { + try { + const fileReader = new FileReader(); + fileReader.onload = () => { + const img = new Image(); + img.onload = () => { + resolve({ width: img.width, height: img.height }); + }; + if (typeof fileReader.result === "string") { + img.src = fileReader.result; + } + }; + + fileReader.readAsDataURL(image); + } catch (e) { + reject(e); + } + }); +}; + const formatDateFromISO = (isoDateString: string): string => { const m = isoDateString.match(/([\d\-T:]+)\+\d{2}:\d{2}?/); // "2023-06-02T06:01:46+00:00" if (m) { @@ -233,6 +258,7 @@ export { niceBytes, getProjectionCode, getFileExtension, + getImageSize, formatDateFromISO, formatDateWithoutTimeFromISO, getArrayRange, diff --git a/src/Controller/EspaceCo/CommunityController.php b/src/Controller/EspaceCo/CommunityController.php index 10525978..a37ab18c 100644 --- a/src/Controller/EspaceCo/CommunityController.php +++ b/src/Controller/EspaceCo/CommunityController.php @@ -2,15 +2,16 @@ namespace App\Controller\EspaceCo; +use App\Controller\ApiControllerInterface; use App\Exception\ApiException; use App\Exception\CartesApiException; -use App\Controller\ApiControllerInterface; -use Symfony\Component\Routing\Attribute\Route; use App\Services\EspaceCoApi\CommunityApiService; use App\Services\EspaceCoApi\UserApiService; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Routing\Attribute\Route; #[Route( '/api/espaceco/community', @@ -20,7 +21,7 @@ )] class CommunityController extends AbstractController implements ApiControllerInterface { - const SEARCH_LIMIT = 20; + public const SEARCH_LIMIT = 20; public function __construct( private CommunityApiService $communityApiService, @@ -34,10 +35,10 @@ public function get( #[MapQueryParameter] ?int $page = 1, #[MapQueryParameter] ?int $limit = 10, #[MapQueryParameter] ?string $sort = 'name:DESC', - ): JsonResponse - { + ): JsonResponse { try { $response = $this->communityApiService->getCommunities($name, $page, $limit, $sort); + return new JsonResponse($response); } catch (ApiException $ex) { throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); @@ -49,29 +50,28 @@ public function getMeMember( #[MapQueryParameter] bool $pending, #[MapQueryParameter] ?int $page = 1, #[MapQueryParameter] ?int $limit = 10 - ): JsonResponse - { + ): JsonResponse { try { $me = $this->userApiService->getMe(); $members = array_map(function ($member) { - return ['id'=> $member['community_id'], 'name' => $member['community_name'], 'role' => $member['role']]; - },$me['communities_member']); + return ['id' => $member['community_id'], 'name' => $member['community_name'], 'role' => $member['role']]; + }, $me['communities_member']); - $members = array_filter($members, function($member) use ($pending) { - return $pending ? $member['role'] === 'pending' : $member['role'] !== 'pending'; + $members = array_filter($members, function ($member) use ($pending) { + return $pending ? 'pending' === $member['role'] : 'pending' !== $member['role']; }); $this->_sortMembersByName($members); $members = array_slice(array_values($members), ($page - 1) * $limit, $limit); - + $communities = []; - foreach($members as $member) { + foreach ($members as $member) { $community = $this->communityApiService->getCommunity($member['id']); $communities[] = $community; } $totalPages = floor(count($members) / $limit) + 1; - $previousPage = $page === 1 ? null : $page - 1; + $previousPage = 1 === $page ? null : $page - 1; $nextPage = $page + 1 > $totalPages ? null : $page + 1; return new JsonResponse([ @@ -90,69 +90,96 @@ public function search( #[MapQueryParameter] string $name, #[MapQueryParameter] string $filter, #[MapQueryParameter] string $sort, - ): JsonResponse - { + ): JsonResponse { try { - if (! in_array($filter, ['public','iam_member','affiliation'])) { - throw new ApiException("Le filtre doit être public, iam_member ou affiliation"); + if (!in_array($filter, ['public', 'iam_member', 'affiliation'])) { + throw new ApiException('Le filtre doit être public, iam_member ou affiliation'); } - if ($filter == 'public') { + if ('public' == $filter) { $result = $this->communityApiService->getCommunities($name, 1, self::SEARCH_LIMIT, $sort); $response = $result['content']; } else { - $response = $this->_search($name, $filter !== 'iam_member' ); + $response = $this->_search($name, 'iam_member' !== $filter); } + return new JsonResponse($response); } catch (ApiException $ex) { throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); } } + #[Route('/{communityId}', name: 'get_community', methods: ['GET'])] + public function getCommunity(int $communityId): JsonResponse + { + try { + $response = $this->communityApiService->getCommunity($communityId); + + return new JsonResponse($response); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } + + #[Route('/{communityId}/update_logo', name: 'update_logo', methods: ['PATCH'])] + public function updateLogo(int $communityId, Request $request): JsonResponse + { + try { + $community = $this->communityApiService->getCommunity($communityId); + + $logo = $request->files->get('logo'); + $this->communityApiService->updateLogo($communityId, $logo); + + return new JsonResponse($community); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } + /** - * @param string $name - * @param boolean $pending * @return array */ - private function _search(string $name, bool $pending) : array { + private function _search(string $name, bool $pending): array + { $me = $this->userApiService->getMe(); $members = array_map(function ($member) { - return ['id'=> $member['community_id'], 'name' => $member['community_name'], 'role' => $member['role']]; - },$me['communities_member']); + return ['id' => $member['community_id'], 'name' => $member['community_name'], 'role' => $member['role']]; + }, $me['communities_member']); $regex = mb_strtoupper(str_replace('%', '', $name)); - $members = array_filter($members, function($member) use ($regex, $pending) { + $members = array_filter($members, function ($member) use ($regex, $pending) { $communityName = mb_strtoupper($member['name']); $match = preg_match("/$regex/", $communityName); - return $pending ? ($member['role'] === 'pending' && $match) : ($member['role'] !== 'pending' && $match); + + return $pending ? ('pending' === $member['role'] && $match) : ('pending' !== $member['role'] && $match); }); $this->_sortMembersByName($members); $members = array_slice(array_values($members), 0, self::SEARCH_LIMIT); - + $communities = []; - foreach($members as $member) { + foreach ($members as $member) { $community = $this->communityApiService->getCommunity($member['id']); $communities[] = $community; } - return $communities; + return $communities; } - + /** * @param array $members - * @return void */ - private function _sortMembersByName(array &$members) : void + private function _sortMembersByName(array &$members): void { - usort($members, function($m1, $m2) { + usort($members, function ($m1, $m2) { $upM1 = mb_strtoupper($m1['name']); $upM2 = mb_strtoupper($m2['name']); if ($upM1 == $upM2) { return 0; } - return ($upM1 < $upM2) ? -1 : 1; + + return ($upM1 < $upM2) ? -1 : 1; }); } } diff --git a/src/Services/EspaceCoApi/BaseEspaceCoApiService.php b/src/Services/EspaceCoApi/BaseEspaceCoApiService.php index 99974ade..a2a9e2ed 100644 --- a/src/Services/EspaceCoApi/BaseEspaceCoApiService.php +++ b/src/Services/EspaceCoApi/BaseEspaceCoApiService.php @@ -2,16 +2,15 @@ namespace App\Services\EspaceCoApi; -use Psr\Log\LoggerInterface; use App\Exception\ApiException; use App\Services\AbstractApiService; +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\Filesystem\Filesystem; -use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\RequestStack; -use Symfony\Contracts\HttpClient\ResponseInterface; +use Symfony\Component\HttpFoundation\Response; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; - +use Symfony\Contracts\HttpClient\ResponseInterface; class BaseEspaceCoApiService extends AbstractApiService { @@ -43,9 +42,8 @@ protected function handleResponse(ResponseInterface $response, bool $expectJson) return $content; } else { - $errorMsg = 'EspaceCo API Error'; $errorResponse = $response->toArray(false); - throw new ApiException($errorMsg, $statusCode, $errorResponse['message']); + throw new ApiException($errorResponse['message'], $statusCode); } } } diff --git a/src/Services/EspaceCoApi/CommunityApiService.php b/src/Services/EspaceCoApi/CommunityApiService.php index ff622e81..c9a0f04c 100644 --- a/src/Services/EspaceCoApi/CommunityApiService.php +++ b/src/Services/EspaceCoApi/CommunityApiService.php @@ -2,16 +2,18 @@ namespace App\Services\EspaceCoApi; +use Symfony\Component\HttpFoundation\File\UploadedFile; + class CommunityApiService extends BaseEspaceCoApiService { - public function getCommunities(string $name, int $page,int $limit, string $sort): array + public function getCommunities(string $name, int $page, int $limit, string $sort): array { - $response = $this->request('GET', "communities", [], ['name' => $name, 'page' => $page, 'limit' => $limit, 'sort' => $sort], [], false, true, true); - + $response = $this->request('GET', 'communities', [], ['name' => $name, 'page' => $page, 'limit' => $limit, 'sort' => $sort], [], false, true, true); + $contentRange = $response['headers']['content-range'][0]; $totalPages = $this->getResultsPageCount($contentRange, $limit); - $previousPage = $page === 1 ? null : $page - 1; + $previousPage = 1 === $page ? null : $page - 1; $nextPage = $page + 1 > $totalPages ? null : $page + 1; return [ @@ -23,10 +25,15 @@ public function getCommunities(string $name, int $page,int $limit, string $sort) } /** - * @param string $communityId * @return array */ - public function getCommunity(string $communityId) : array { + public function getCommunity(int $communityId): array + { return $this->request('GET', "communities/$communityId"); } -} \ No newline at end of file + + public function updateLogo(int $communityId, UploadedFile $file): array + { + return $this->request('PATCH', "communities/$communityId", ['logo' => $file], [], [], true); + } +}