diff --git a/.vscode/launch.json b/.vscode/launch.json index 0c0046ce..1033fd1c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,6 +6,12 @@ "request": "launch", "port": 9003 }, + { + "name": "Listen for Xdebug 2 (Legacy)", + "type": "php", + "request": "launch", + "port": 9000 + }, { "name": "Listen for Xdebug (in docker)", "type": "php", diff --git a/Topic_category_cartesgouv.xlsx b/Topic_category_cartesgouv.xlsx new file mode 100644 index 00000000..79dae27c Binary files /dev/null and b/Topic_category_cartesgouv.xlsx differ diff --git a/assets/@types/app_espaceco.ts b/assets/@types/app_espaceco.ts index 0359da8f..48bfeba9 100644 --- a/assets/@types/app_espaceco.ts +++ b/assets/@types/app_espaceco.ts @@ -1,3 +1,5 @@ +import { BasicRecipients, EmailPlannerDTO, GridDTO, ReportStatusesDTO, SharedGeorem, SharedThemesDTO, ThemeDTO, UserDTO } from "./espaceco"; + export type GetResponse = { content: T[]; totalPages: number; @@ -5,5 +7,123 @@ export type GetResponse = { nextPage: number; }; -export const arrCommunityListFilters = ["public", "iam_member", "affiliation"]; +export const arrCommunityListFilters = ["public", "iam_member", "affiliation"] as const; export type CommunityListFilter = (typeof arrCommunityListFilters)[number]; + +export type Address = { + country: string; + city: string; + x: number; + y: number; + zipcode: string; + street: string; + classification: number; + kind: string; + fulltext: string; + metropole: boolean; +}; + +export type Poi = { + country: string; + city: string; + x: number; + y: number; + zipcode: string; + zipcodes: string[]; + poiType: string[]; + street: string; + classification: number; + kind: string; + fulltext: string; + metropole: boolean; +}; + +export type SearchResult = { + status: string; + results: (Address | Poi)[]; +}; + +export type SearchGridFilters = { + searchBy?: ("name" | "title")[]; + fields?: ("name" | "title" | "type" | "extent" | "deleted")[]; + adm?: boolean; +}; + +export type UserType = { + user_id: number; + username: string; + firstname: string | null; + surname: string | null; +}; + +export type Role = "pending" | "member" | "admin" | "invited"; +export type CommunityMember = UserType & { + grids: GridDTO[]; + role: Role; + active: boolean; + date: string; +}; + +export type CommunityMemberDetailed = { + user_id: number; + community_name: string; + community_id: number; + grids: GridDTO[]; + role: Role; + active: boolean; + date: string; +}; + +export type Profile = { + community_id: number; + themes: ThemeDTO[]; +}; + +export type UserMe = { + id: number; + email: string; + username: string; + surname: string | null; + description: string | null; + administrator: boolean; + profile: Profile; + shared_themes: SharedThemesDTO[]; + communities_member: CommunityMemberDetailed[]; +}; + +/* FORMULAIRES */ +export type ReportFormType = { + attributes: ThemeDTO[]; + report_statuses: ReportStatusesDTO; + email_planners?: EmailPlannerDTO[]; + shared_themes?: SharedThemesDTO[]; + shared_georem: SharedGeorem; + all_members_can_valid: boolean; +}; + +export type DescriptionFormType = { + name: string; + description?: string; + keywords?: string[]; +}; + +/* email planners */ +export const BasicRecipientsArray: string[] = [...BasicRecipients] as string[]; + +export const EmailPlannerTypes = ["basic", "personal"] as const; +export type EmailPlannerType = (typeof EmailPlannerTypes)[number]; + +export type EmailPlannerAddType = Omit; + +export type EmailPlannerFormType = Omit & { + id?: number; + event: string; + cancel_event: string; + recipients: string[]; + statuses?: string[]; + themes?: string[]; +}; + +export const isUser = (v: UserDTO | string): v is UserDTO => { + return (v as UserDTO).username !== undefined; +}; diff --git a/assets/@types/espaceco.ts b/assets/@types/espaceco.ts index 019b579f..4ac9ebf3 100644 --- a/assets/@types/espaceco.ts +++ b/assets/@types/espaceco.ts @@ -1,29 +1,220 @@ +import statuses from "../data/report_statuses.json"; + +export interface ConstraintsDTO { + minLength?: number; + maxLength?: number; + minValue?: string; + maxValue?: string; + pattern?: string; +} + +export const AttributeTypes = ["text", "integer", "double", "checkbox", "list", "date"]; + +export type AttributeType = (typeof AttributeTypes)[number]; +export type AttributeDTO = { + name: string; + type: AttributeType; + default?: string | null; + mandatory?: boolean; + multiple?: boolean; + values?: string[] | null; + help?: string | null; + title?: string; + input_constraints?: ConstraintsDTO | null; + json_schema?: object | null; + required?: boolean; + condition_field?: string; +}; + +export interface ThemeDTO { + theme: string; + database?: string; + table?: string; + attributes: AttributeDTO[]; + help?: string; + global?: boolean; +} + +export type UserSharedThemesDTO = { + community_id: number; + community_name: string; + themes: ThemeDTO[]; +}; + +export type SharedThemesDTO = { + community_id: number; + community_name: string; + themes: string[]; +}; + +export type ReportStatusesType = keyof typeof statuses; + +export type ReportStatusParams = { + title: string; + description?: string; + active: boolean; +}; +export type ReportStatusesDTO = Record; + +/* Email planners */ +export const TriggerEvents = ["georem_created", "georem_status_changed"] as const; +export type TriggerEventType = (typeof TriggerEvents)[number]; + +export const CancelEvents = ["georem_status_changed", "none"] as const; +export type CancelEventType = (typeof CancelEvents)[number]; + +export const BasicRecipients = ["Auteur", "Gestionnaire", "Majec"] as const; +export type RecipientType = (typeof BasicRecipients)[number]; + +export type EmailPlannerDTO = { + id: number; + subject: string; + body: string; + delay: number; + repeat: boolean; + recipients: string[]; + event: TriggerEventType; + cancel_event: CancelEventType; + condition: { status: string[] } | null; + themes: string[]; +}; + +const SharedGeoremOptions = ["all", "restrained", "personal"]; +export type SharedGeorem = (typeof SharedGeoremOptions)[number]; export interface CommunityResponseDTO { id: number; description: string | null; detailed_description?: string | null; name: string; active: boolean; + listed: boolean; shared_georem: "all" | "restrained" | "personal"; + shared_extractions: boolean; email: string | null; - attributes: object[]; + attributes: ThemeDTO[]; default_comment: string | null; - position: string; + position: string | null; zoom: number; + zoom_min: number | null; + zoom_max: number | null; + extent: number[] | null; all_members_can_valid: boolean; open_without_affiliation: boolean; open_with_email?: string[]; offline_allowed: boolean; - shared_extractions: boolean; /** @format date-time */ creation: string; - grids: Grids[]; - logo_url: string; + grids: GridDTO[]; + logo_url: string | null; + keywords?: string[]; + documents?: DocumentDTO[]; + report_statuses?: ReportStatusesDTO; + shared_themes?: SharedThemesDTO[]; +} + +export interface UserDTO { + id: number; + username: string; + firstname?: string; + surname?: string; +} + +export interface DocumentDTO { + id: number; + title: string; + description: string | null; + short_fileName: string; + mime_type: string; + size: number | null; + width: number | null; + height: number | null; + date: string; + geometry: string | null; + uri: string | null; + download_uri: string; } -export interface Grids { +export interface GridDTO { name: string; title: string; - type: string; + type: GridType; deleted: boolean; + extent: number[]; +} + +export interface GridType { + name: string; + title: string; +} +export interface CommunityPatchDTO extends Partial> { + logo: File | null; +} + +export interface PermissionResponseDTO { + id: number; + database: number; + table: number | null; + column: number | null; + level: "NONE" | "VIEW" | "EXPORT" | "EDIT" | "ADMIN"; +} + +export interface ColumnDTO { + table_id: number; + crs: string | null; + enum: object | string[] | null; + default_value: string | null; + read_only: boolean; + id: number; + type: string; + target_table: string | null; + target_entity: string | null; + name: string; + short_name: string; + title: string; + description: string | null; + min_length: number | null; + max_length: number | null; + nullable: boolean; + unique: boolean; + srid: number | null; + position: number; + min_value: string | null; + max_value: string | null; + pattern: string | null; + is3d: boolean; + constraint: object | null; + condition_field: string | null; + computed: boolean; + automatic: boolean; + custom_id: boolean; + formula: string | null; + json_schema: object | null; + jeux_attributs: object | null; + queryable: boolean; + required: boolean; + mime_types: string | null; } + +export interface TableResponseDTO { + database_id: number; + database: string; + database_versioning: boolean; + full_name: string; + id_name: string; + geometry_name: string; + min_zoom_level: number | null; + max_zoom_level: number | null; + tile_zoom_level: number | null; + read_only: boolean; + id: number; + name: string; + title: string; + description: string | null; + thematic_ids: string[] | null; + position: number; + wfs: string; + wfs_transactions: string; + columns: ColumnDTO[]; +} + +export { SharedGeoremOptions }; diff --git a/assets/components/Input/AutocompleteSelect.tsx b/assets/components/Input/AutocompleteSelect.tsx index a88cd8e4..5054476c 100644 --- a/assets/components/Input/AutocompleteSelect.tsx +++ b/assets/components/Input/AutocompleteSelect.tsx @@ -1,14 +1,16 @@ import { fr } from "@codegouvfr/react-dsfr"; import MuiDsfrThemeProvider from "@codegouvfr/react-dsfr/mui"; import { Autocomplete, AutocompleteFreeSoloValueMapping, AutocompleteValue, CreateFilterOptionsConfig, TextField, createFilterOptions } from "@mui/material"; -import { CSSProperties, useId } from "react"; +import { CSSProperties, ReactNode, useId } from "react"; import { ControllerRenderProps } from "react-hook-form"; import { symToStr } from "tsafe/symToStr"; +import "../../../assets/sass/components/autocomplete.scss"; + interface AutocompleteSelectProps { id?: string; label: string; - hintText: string; + hintText?: ReactNode; state?: "default" | "error" | "success"; stateRelatedMessage?: string; defaultValue?: T[]; @@ -76,7 +78,6 @@ const AutocompleteSelect = (props: AutocompleteSelectProps) => { {label} {hintText && {hintText}} - void; } const InputCollection: FC = (props: InputCollectionProps) => { - const { t } = useTranslation("Common"); + const { label, hintText, state, stateRelatedMessage, minLength = 0, validator = "none", value = [], onChange } = props; - const { label, hintText, state, stateRelatedMessage, value = [], onChange } = props; + const { t: tCommon } = useTranslation("Common"); + const { t } = useTranslation("InputCollection"); - /* Calcul de la valeur finale */ - const compute = useCallback((values: Record) => { - const result: string[] = Object.values(values).filter((v) => v.trim().length); - return [...new Set(result)]; - }, []); + const _validator: (value) => boolean = validator === "email" ? isEmail : undefined; - const [internals, setInternals] = useState>(() => { - // On met une ligne par defaut - const def = [...value]; - if (def.length === 0) { - def.push(""); - } - - const values: Record = {}; - def.forEach((value) => { - const uuid = uuidv4(); - values[uuid] = value; - }); - return values; + const schema = yup.object({ + text: yup + .string() + .min(minLength, t("min_length_error", { min: minLength })) + .test({ + name: "check", + test(value, ctx) { + if (!value) return true; + if (_validator) { + if (!_validator(value)) { + return ctx.createError({ message: `${value} n'est pas un email valide` }); + } + } + return true; + }, + }), }); - const num = Object.keys(internals).length; + const { + register, + formState: { errors }, + getValues: getFormValues, + resetField, + handleSubmit, + } = useForm<{ text?: string }>({ + mode: "onChange", + resolver: yupResolver(schema), + }); - /* Ajout d'une ligne */ - const handleAdd = () => { - const d = { ...internals }; - d[uuidv4()] = ""; - setInternals(d); + const onSubmit = () => { + const v = getFormValues("text"); + const values = value ? [...value] : []; + if (v) { + values.push(v); + onChange(Array.from(new Set(values))); + resetField("text"); + } }; /* Suppression d'une ligne */ - const handleRemove = (key: string) => { - const datas = { ...internals }; - const value = datas[key]; - delete datas[key]; - - setInternals(datas); - if (value) { - onChange(compute(datas)); - } + const handleRemove = (text: string) => { + const values = value.filter((v) => text !== v); + onChange(values); }; - /* Modification d'une valeur */ - const handleChangeValue = useCallback( - (key: string, value: string) => { - const datas = { ...internals }; - datas[key] = value; - setInternals(datas); - - onChange(compute(datas)); - }, - [internals, compute, onChange] - ); - return (
{label && } {hintText && {hintText}} -
-
- {Object.keys(internals).map((key) => ( -
-
- handleChangeValue(key, e.currentTarget.value), - }} - /> -
-
+ ))}
- ))} + )} + {state !== "default" && (

= (props: InputCollectionProps) }; export default InputCollection; + +// traductions +export const { i18n } = declareComponentKeys< + { K: "min_length_error"; P: { min: number | null }; R: string } | { K: "validator_error"; P: { type: ValidatorType; value: string | null }; R: string } +>()("InputCollection"); + +export const InputCollectionFrTranslations: Translations<"fr">["InputCollection"] = { + min_length_error: ({ min }) => `La chaîne doit faire au mois ${min} caractères `, + validator_error: ({ type, value }) => { + switch (type) { + case "none": + return ""; + case "email": + return `${value} n'est pas un email valide`; + } + }, +}; + +export const InputCollectionEnTranslations: Translations<"en">["InputCollection"] = { + min_length_error: ({ min }) => `Minimum string length is ${min}`, + validator_error: ({ type, value }) => { + switch (type) { + case "none": + return ""; + case "email": + return `${value} is not a valid email`; + } + }, +}; diff --git a/assets/components/Input/MarkdownEditor.tsx b/assets/components/Input/MarkdownEditor.tsx index 971b54fc..df9db7c9 100644 --- a/assets/components/Input/MarkdownEditor.tsx +++ b/assets/components/Input/MarkdownEditor.tsx @@ -1,14 +1,18 @@ import { fr } from "@codegouvfr/react-dsfr"; import { useIsDark } from "@codegouvfr/react-dsfr/useIsDark"; -import MDEditor from "@uiw/react-md-editor"; -import { CSSProperties, FC } from "react"; +import MDEditor, { ICommand } from "@uiw/react-md-editor"; +import { CSSProperties, FC, ReactNode } from "react"; -import getLocaleCommands from "../../modules/react-md/react-md-commands"; +import { getLocaleCommands } from "../../modules/react-md/commands"; import Translator from "../../modules/Translator"; +import { useLang } from "../../i18n/i18n"; type MarkdownEditorProps = { label?: string; - hintText?: string; + hintText?: ReactNode; + /* Les commandes supplémentaires standard peuvent être récupérées via + getExtraCommands dans @uiw/react-md-editor */ + extraCommands?: ICommand[]; value: string; onChange: (values: string) => void; state?: "default" | "error" | "success"; @@ -17,8 +21,9 @@ type MarkdownEditorProps = { }; const MarkdownEditor: FC = (props) => { - const { label, hintText, value, state, stateRelatedMessage, placeholder, onChange } = props; + const { label, hintText, extraCommands, value, state, stateRelatedMessage, placeholder, onChange } = props; const { isDark } = useIsDark(); + const { lang } = useLang(); const customStyle: CSSProperties = { backgroundColor: fr.colors.decisions.background.contrast.grey.default, @@ -39,8 +44,8 @@ const MarkdownEditor: FC = (props) => { void; center?: number[]; @@ -25,7 +32,7 @@ type ZoomRangeProps = { const ZoomRange: FC = (props) => { const { data: capabilities } = useCapabilities(); - const { min, max, values, center = olDefaults.center, onChange } = props; + const { label, hintText, min, max, disableSlider, values, onChange, small = false, center = olDefaults.center } = props; // References sur les deux cartes const leftMapRef = useRef(); @@ -88,18 +95,18 @@ const ZoomRange: FC = (props) => { useEffect(() => { if (leftMapTargetRef.current) { - leftMapRef.current = createMap(leftMapTargetRef.current, olDefaults.zoom_levels.TOP); + leftMapRef.current = createMap(leftMapTargetRef.current, Math.max(min, olDefaults.zoom_levels.TOP)); } if (rightMapTargetRef.current) { - rightMapRef.current = createMap(rightMapTargetRef.current, olDefaults.zoom_levels.BOTTOM); + rightMapRef.current = createMap(rightMapTargetRef.current, Math.min(max, olDefaults.zoom_levels.BOTTOM)); } return () => { leftMapRef.current?.setTarget(undefined); rightMapRef.current?.setTarget(undefined); }; - }, [createMap]); + }, [min, max, createMap]); useEffect(() => { leftMapRef.current?.getView().setZoom(values[0]); @@ -111,11 +118,43 @@ const ZoomRange: FC = (props) => { return (

-
-
-
+ {label && ( + + )} +
+
+
- onChange(newValues)} /> + { + const v = [...values]; + v[0] = Number(e.currentTarget.value); + onChange(v); + }, + }, + { + value: values[1], + onChange: (e) => { + const v = [...values]; + v[1] = Number(e.currentTarget.value); + onChange(v); + }, + }, + ]} + />
); }; diff --git a/assets/config/datastoreNavItems.ts b/assets/config/datastoreNavItems.ts index 20746dd2..2f36eef7 100644 --- a/assets/config/datastoreNavItems.ts +++ b/assets/config/datastoreNavItems.ts @@ -55,6 +55,11 @@ export const datastoreNavItems = (currentDatastore?: Datastore): MainNavigationP }); } + navItems.push({ + text: t("espaceco"), + linkProps: routes.espaceco_community_list().link, + }); + navItems.push(assistanceNavItems(tNavItems)); return navItems; @@ -62,7 +67,7 @@ export const datastoreNavItems = (currentDatastore?: Datastore): MainNavigationP // traductions export const { i18n } = declareComponentKeys< - "dashboard" | "data" | "members" | "manage_storage" | "consumption_monitoring" | "permissions_granted" | "my_account" | "my_access_keys" + "dashboard" | "data" | "members" | "manage_storage" | "consumption_monitoring" | "permissions_granted" | "my_account" | "my_access_keys" | "espaceco" >()("datastoreNavItems"); export const datastoreNavItemsFrTranslations: Translations<"fr">["datastoreNavItems"] = { @@ -74,6 +79,7 @@ export const datastoreNavItemsFrTranslations: Translations<"fr">["datastoreNavIt permissions_granted: "Permissions accordées", my_account: "Mon compte", my_access_keys: "Mes clés d’accès", + espaceco: "Espace Collaboratif", }; export const datastoreNavItemsEnTranslations: Translations<"en">["datastoreNavItems"] = { @@ -85,4 +91,5 @@ export const datastoreNavItemsEnTranslations: Translations<"en">["datastoreNavIt permissions_granted: undefined, my_account: undefined, my_access_keys: undefined, + espaceco: undefined, }; diff --git a/assets/data/doc_thumbnail.json b/assets/data/doc_thumbnail.json new file mode 100644 index 00000000..9471fc20 --- /dev/null +++ b/assets/data/doc_thumbnail.json @@ -0,0 +1,378 @@ +{ + "7z": { + "src": "build/img/vignettes/7z.png", + "title": "Document de type 7Z" + }, + "abw": { + "src": "build/img/vignettes/abw.png", + "title": "Document de type ABW" + }, + "ai": { + "src": "build/img/vignettes/ai.png", + "title": "Document de type AI" + }, + "aiff": { + "src": "build/img/vignettes/aiff.png", + "title": "Document de type AIFF" + }, + "asf": { + "src": "build/img/vignettes/asf.png", + "title": "Document de type ASF" + }, + "avi": { + "src": "build/img/vignettes/avi.png", + "title": "Document de type AVI" + }, + "bin": { + "src": "build/img/vignettes/bin.png", + "title": "Document de type BIN" + }, + "blend": { + "src": "build/img/vignettes/blend.png", + "title": "Document de type BLEND" + }, + "bmp": { + "src": "build/img/vignettes/bmp.png", + "title": "Document de type BMP" + }, + "bz2": { + "src": "build/img/vignettes/bz2.png", + "title": "Document de type BZ2" + }, + "c": { + "src": "build/img/vignettes/c.png", + "title": "Document de type C" + }, + "crq": { + "src": "build/img/vignettes/crq.png", + "title": "Document de type CRQ" + }, + "css": { + "src": "build/img/vignettes/css.png", + "title": "Document de type CSS" + }, + "csv": { + "src": "build/img/vignettes/csv.png", + "title": "Document de type CSV" + }, + "deb": { + "src": "build/img/vignettes/deb.png", + "title": "Document de type DEB" + }, + "defaut": { + "src": "build/img/vignettes/defaut.png", + "title": "Document de type DEFAUT" + }, + "djvu": { + "src": "build/img/vignettes/djvu.png", + "title": "Document de type DJVU" + }, + "doc": { + "src": "build/img/vignettes/doc.png", + "title": "Document de type DOC" + }, + "docx": { + "src": "build/img/vignettes/docx.png", + "title": "Document de type DOCX" + }, + "dvi": { + "src": "build/img/vignettes/dvi.png", + "title": "Document de type DVI" + }, + "dxf": { + "src": "build/img/vignettes/dxf.png", + "title": "Document de type DXF" + }, + "eps": { + "src": "build/img/vignettes/eps.png", + "title": "Document de type EPS" + }, + "extension_thumbnail": { + "src": "build/img/vignettes/extension_thumbnail.png", + "title": "Document de type EXTENSION_THUMBNAIL" + }, + "flv": { + "src": "build/img/vignettes/flv.png", + "title": "Document de type FLV" + }, + "gif": { + "src": "build/img/vignettes/gif.png", + "title": "Document de type GIF" + }, + "gpx": { + "src": "build/img/vignettes/gpx.png", + "title": "Document de type GPX" + }, + "gxt": { + "src": "build/img/vignettes/gxt.png", + "title": "Document de type GXT" + }, + "gz": { + "src": "build/img/vignettes/gz.png", + "title": "Document de type GZ" + }, + "h": { + "src": "build/img/vignettes/h.png", + "title": "Document de type H" + }, + "html": { + "src": "build/img/vignettes/html.png", + "title": "Document de type HTML" + }, + "jpg": { + "src": "build/img/vignettes/jpg.png", + "title": "Document de type JPG" + }, + "kml": { + "src": "build/img/vignettes/kml.png", + "title": "Document de type KML" + }, + "kmz": { + "src": "build/img/vignettes/kmz.png", + "title": "Document de type KMZ" + }, + "mid": { + "src": "build/img/vignettes/mid.png", + "title": "Document de type MID" + }, + "mka": { + "src": "build/img/vignettes/mka.png", + "title": "Document de type MKA" + }, + "mkv": { + "src": "build/img/vignettes/mkv.png", + "title": "Document de type MKV" + }, + "mng": { + "src": "build/img/vignettes/mng.png", + "title": "Document de type MNG" + }, + "mov": { + "src": "build/img/vignettes/mov.png", + "title": "Document de type MOV" + }, + "mp3": { + "src": "build/img/vignettes/mp3.png", + "title": "Document de type MP3" + }, + "mp4": { + "src": "build/img/vignettes/mp4.png", + "title": "Document de type MP4" + }, + "mpg": { + "src": "build/img/vignettes/mpg.png", + "title": "Document de type MPG" + }, + "odb": { + "src": "build/img/vignettes/odb.png", + "title": "Document de type ODB" + }, + "odc": { + "src": "build/img/vignettes/odc.png", + "title": "Document de type ODC" + }, + "odf": { + "src": "build/img/vignettes/odf.png", + "title": "Document de type ODF" + }, + "odg": { + "src": "build/img/vignettes/odg.png", + "title": "Document de type ODG" + }, + "odi": { + "src": "build/img/vignettes/odi.png", + "title": "Document de type ODI" + }, + "odm": { + "src": "build/img/vignettes/odm.png", + "title": "Document de type ODM" + }, + "odp": { + "src": "build/img/vignettes/odp.png", + "title": "Document de type ODP" + }, + "ods": { + "src": "build/img/vignettes/ods.png", + "title": "Document de type ODS" + }, + "odt": { + "src": "build/img/vignettes/odt.png", + "title": "Document de type ODT" + }, + "ogg": { + "src": "build/img/vignettes/ogg.png", + "title": "Document de type OGG" + }, + "otg": { + "src": "build/img/vignettes/otg.png", + "title": "Document de type OTG" + }, + "otp": { + "src": "build/img/vignettes/otp.png", + "title": "Document de type OTP" + }, + "ots": { + "src": "build/img/vignettes/ots.png", + "title": "Document de type OTS" + }, + "ott": { + "src": "build/img/vignettes/ott.png", + "title": "Document de type OTT" + }, + "pas": { + "src": "build/img/vignettes/pas.png", + "title": "Document de type PAS" + }, + "pdf": { + "src": "build/img/vignettes/pdf.png", + "title": "Document de type PDF" + }, + "pgn": { + "src": "build/img/vignettes/pgn.png", + "title": "Document de type PGN" + }, + "png": { + "src": "build/img/vignettes/png.png", + "title": "Document de type PNG" + }, + "pps": { + "src": "build/img/vignettes/pps.png", + "title": "Document de type PPS" + }, + "ppt": { + "src": "build/img/vignettes/ppt.png", + "title": "Document de type PPT" + }, + "pptx": { + "src": "build/img/vignettes/pptx.png", + "title": "Document de type PPTX" + }, + "ps": { + "src": "build/img/vignettes/ps.png", + "title": "Document de type PS" + }, + "psd": { + "src": "build/img/vignettes/psd.png", + "title": "Document de type PSD" + }, + "qt": { + "src": "build/img/vignettes/qt.png", + "title": "Document de type QT" + }, + "ra": { + "src": "build/img/vignettes/ra.png", + "title": "Document de type RA" + }, + "ram": { + "src": "build/img/vignettes/ram.png", + "title": "Document de type RAM" + }, + "rm": { + "src": "build/img/vignettes/rm.png", + "title": "Document de type RM" + }, + "rpm": { + "src": "build/img/vignettes/rpm.png", + "title": "Document de type RPM" + }, + "rtf": { + "src": "build/img/vignettes/rtf.png", + "title": "Document de type RTF" + }, + "sdd": { + "src": "build/img/vignettes/sdd.png", + "title": "Document de type SDD" + }, + "sdw": { + "src": "build/img/vignettes/sdw.png", + "title": "Document de type SDW" + }, + "sit": { + "src": "build/img/vignettes/sit.png", + "title": "Document de type SIT" + }, + "smil": { + "src": "build/img/vignettes/smil.png", + "title": "Document de type SMIL" + }, + "spip": { + "src": "build/img/vignettes/spip.png", + "title": "Document de type SPIP" + }, + "svg": { + "src": "build/img/vignettes/svg.png", + "title": "Document de type SVG" + }, + "swf": { + "src": "build/img/vignettes/swf.png", + "title": "Document de type SWF" + }, + "sxc": { + "src": "build/img/vignettes/sxc.png", + "title": "Document de type SXC" + }, + "sxi": { + "src": "build/img/vignettes/sxi.png", + "title": "Document de type SXI" + }, + "sxw": { + "src": "build/img/vignettes/sxw.png", + "title": "Document de type SXW" + }, + "tex": { + "src": "build/img/vignettes/tex.png", + "title": "Document de type TEX" + }, + "tgz": { + "src": "build/img/vignettes/tgz.png", + "title": "Document de type TGZ" + }, + "tif": { + "src": "build/img/vignettes/tif.png", + "title": "Document de type TIF" + }, + "tiff": { + "src": "build/img/vignettes/tiff.png", + "title": "Document de type TIFF" + }, + "torrent": { + "src": "build/img/vignettes/torrent.png", + "title": "Document de type TORRENT" + }, + "ttf": { + "src": "build/img/vignettes/ttf.png", + "title": "Document de type TTF" + }, + "txt": { + "src": "build/img/vignettes/txt.png", + "title": "Document de type TXT" + }, + "wav": { + "src": "build/img/vignettes/wav.png", + "title": "Document de type WAV" + }, + "wmv": { + "src": "build/img/vignettes/wmv.png", + "title": "Document de type WMV" + }, + "xcf": { + "src": "build/img/vignettes/xcf.png", + "title": "Document de type XCF" + }, + "xls": { + "src": "build/img/vignettes/xls.png", + "title": "Document de type XLS" + }, + "xlsx": { + "src": "build/img/vignettes/xlsx.png", + "title": "Document de type XLSX" + }, + "xml": { + "src": "build/img/vignettes/xml.png", + "title": "Document de type XML" + }, + "zip": { + "src": "build/img/vignettes/zip.png", + "title": "Document de type ZIP" + } +} \ No newline at end of file diff --git a/assets/data/report_statuses.json b/assets/data/report_statuses.json new file mode 100644 index 00000000..73121f2c --- /dev/null +++ b/assets/data/report_statuses.json @@ -0,0 +1,11 @@ +{ + "submit": "Reçu dans nos services", + "pending0": "En demande de qualification", + "pending": "En cours de traitement", + "pending1": "En attente de saisie", + "pending2": "En attente de validation", + "valid": "Pris en compte", + "valid0": "Déjà pris en compte", + "reject": "Rejeté (hors spéc.)", + "reject0": "Rejeté (hors de propos)" +} \ No newline at end of file diff --git a/assets/doc_thumbnail.js b/assets/doc_thumbnail.js new file mode 100644 index 00000000..f41e94b5 --- /dev/null +++ b/assets/doc_thumbnail.js @@ -0,0 +1,376 @@ +const docThumbnails = { + "7z": { + src: "img/vignettes/7z.png", + title: "Document de type 7Z", + }, + abw: { + src: "img/vignettes/abw.png", + title: "Document de type ABW", + }, + ai: { + src: "img/vignettes/ai.png", + title: "Document de type AI", + }, + aiff: { + src: "img/vignettes/aiff.png", + title: "Document de type AIFF", + }, + asf: { + src: "img/vignettes/asf.png", + title: "Document de type ASF", + }, + avi: { + src: "img/vignettes/avi.png", + title: "Document de type AVI", + }, + bin: { + src: "img/vignettes/bin.png", + title: "Document de type BIN", + }, + blend: { + src: "img/vignettes/blend.png", + title: "Document de type BLEND", + }, + bmp: { + src: "img/vignettes/bmp.png", + title: "Document de type BMP", + }, + bz2: { + src: "img/vignettes/bz2.png", + title: "Document de type BZ2", + }, + c: { + src: "img/vignettes/c.png", + title: "Document de type C", + }, + crq: { + src: "img/vignettes/crq.png", + title: "Document de type CRQ", + }, + css: { + src: "img/vignettes/css.png", + title: "Document de type CSS", + }, + csv: { + src: "img/vignettes/csv.png", + title: "Document de type CSV", + }, + deb: { + src: "img/vignettes/deb.png", + title: "Document de type DEB", + }, + defaut: { + src: "img/vignettes/defaut.png", + title: "Document de type DEFAUT", + }, + djvu: { + src: "img/vignettes/djvu.png", + title: "Document de type DJVU", + }, + doc: { + src: "img/vignettes/doc.png", + title: "Document de type DOC", + }, + docx: { + src: "img/vignettes/docx.png", + title: "Document de type DOCX", + }, + dvi: { + src: "img/vignettes/dvi.png", + title: "Document de type DVI", + }, + dxf: { + src: "img/vignettes/dxf.png", + title: "Document de type DXF", + }, + eps: { + src: "img/vignettes/eps.png", + title: "Document de type EPS", + }, + flv: { + src: "img/vignettes/flv.png", + title: "Document de type FLV", + }, + gif: { + src: "img/vignettes/gif.png", + title: "Document de type GIF", + }, + gpx: { + src: "img/vignettes/gpx.png", + title: "Document de type GPX", + }, + gxt: { + src: "img/vignettes/gxt.png", + title: "Document de type GXT", + }, + gz: { + src: "img/vignettes/gz.png", + title: "Document de type GZ", + }, + h: { + src: "img/vignettes/h.png", + title: "Document de type H", + }, + html: { + src: "img/vignettes/html.png", + title: "Document de type HTML", + }, + jpg: { + src: "img/vignettes/jpg.png", + title: "Document de type JPG", + }, + kml: { + src: "img/vignettes/kml.png", + title: "Document de type KML", + }, + kmz: { + src: "img/vignettes/kmz.png", + title: "Document de type KMZ", + }, + mid: { + src: "img/vignettes/mid.png", + title: "Document de type MID", + }, + mka: { + src: "img/vignettes/mka.png", + title: "Document de type MKA", + }, + mkv: { + src: "img/vignettes/mkv.png", + title: "Document de type MKV", + }, + mng: { + src: "img/vignettes/mng.png", + title: "Document de type MNG", + }, + mov: { + src: "img/vignettes/mov.png", + title: "Document de type MOV", + }, + mp3: { + src: "img/vignettes/mp3.png", + title: "Document de type MP3", + }, + mp4: { + src: "img/vignettes/mp4.png", + title: "Document de type MP4", + }, + mpg: { + src: "img/vignettes/mpg.png", + title: "Document de type MPG", + }, + odb: { + src: "img/vignettes/odb.png", + title: "Document de type ODB", + }, + odc: { + src: "img/vignettes/odc.png", + title: "Document de type ODC", + }, + odf: { + src: "img/vignettes/odf.png", + title: "Document de type ODF", + }, + odg: { + src: "img/vignettes/odg.png", + title: "Document de type ODG", + }, + odi: { + src: "img/vignettes/odi.png", + title: "Document de type ODI", + }, + odm: { + src: "img/vignettes/odm.png", + title: "Document de type ODM", + }, + odp: { + src: "img/vignettes/odp.png", + title: "Document de type ODP", + }, + ods: { + src: "img/vignettes/ods.png", + title: "Document de type ODS", + }, + odt: { + src: "img/vignettes/odt.png", + title: "Document de type ODT", + }, + ogg: { + src: "img/vignettes/ogg.png", + title: "Document de type OGG", + }, + otg: { + src: "img/vignettes/otg.png", + title: "Document de type OTG", + }, + otp: { + src: "img/vignettes/otp.png", + title: "Document de type OTP", + }, + ots: { + src: "img/vignettes/ots.png", + title: "Document de type OTS", + }, + ott: { + src: "img/vignettes/ott.png", + title: "Document de type OTT", + }, + pas: { + src: "img/vignettes/pas.png", + title: "Document de type PAS", + }, + pdf: { + src: "img/vignettes/pdf.png", + title: "Document de type PDF", + }, + pgn: { + src: "img/vignettes/pgn.png", + title: "Document de type PGN", + }, + png: { + src: "img/vignettes/png.png", + title: "Document de type PNG", + }, + pps: { + src: "img/vignettes/pps.png", + title: "Document de type PPS", + }, + ppt: { + src: "img/vignettes/ppt.png", + title: "Document de type PPT", + }, + pptx: { + src: "img/vignettes/pptx.png", + title: "Document de type PPTX", + }, + ps: { + src: "img/vignettes/ps.png", + title: "Document de type PS", + }, + psd: { + src: "img/vignettes/psd.png", + title: "Document de type PSD", + }, + qt: { + src: "img/vignettes/qt.png", + title: "Document de type QT", + }, + ra: { + src: "img/vignettes/ra.png", + title: "Document de type RA", + }, + ram: { + src: "img/vignettes/ram.png", + title: "Document de type RAM", + }, + rm: { + src: "img/vignettes/rm.png", + title: "Document de type RM", + }, + rpm: { + src: "img/vignettes/rpm.png", + title: "Document de type RPM", + }, + rtf: { + src: "img/vignettes/rtf.png", + title: "Document de type RTF", + }, + sdd: { + src: "img/vignettes/sdd.png", + title: "Document de type SDD", + }, + sdw: { + src: "img/vignettes/sdw.png", + title: "Document de type SDW", + }, + sit: { + src: "img/vignettes/sit.png", + title: "Document de type SIT", + }, + smil: { + src: "img/vignettes/smil.png", + title: "Document de type SMIL", + }, + spip: { + src: "img/vignettes/spip.png", + title: "Document de type SPIP", + }, + svg: { + src: "img/vignettes/svg.png", + title: "Document de type SVG", + }, + swf: { + src: "img/vignettes/swf.png", + title: "Document de type SWF", + }, + sxc: { + src: "img/vignettes/sxc.png", + title: "Document de type SXC", + }, + sxi: { + src: "img/vignettes/sxi.png", + title: "Document de type SXI", + }, + sxw: { + src: "img/vignettes/sxw.png", + title: "Document de type SXW", + }, + tex: { + src: "img/vignettes/tex.png", + title: "Document de type TEX", + }, + tgz: { + src: "img/vignettes/tgz.png", + title: "Document de type TGZ", + }, + tif: { + src: "img/vignettes/tif.png", + title: "Document de type TIF", + }, + tiff: { + src: "img/vignettes/tiff.png", + title: "Document de type TIFF", + }, + torrent: { + src: "img/vignettes/torrent.png", + title: "Document de type TORRENT", + }, + ttf: { + src: "img/vignettes/ttf.png", + title: "Document de type TTF", + }, + txt: { + src: "img/vignettes/txt.png", + title: "Document de type TXT", + }, + wav: { + src: "img/vignettes/wav.png", + title: "Document de type WAV", + }, + wmv: { + src: "img/vignettes/wmv.png", + title: "Document de type WMV", + }, + xcf: { + src: "img/vignettes/xcf.png", + title: "Document de type XCF", + }, + xls: { + src: "img/vignettes/xls.png", + title: "Document de type XLS", + }, + xlsx: { + src: "img/vignettes/xlsx.png", + title: "Document de type XLSX", + }, + xml: { + src: "img/vignettes/xml.png", + title: "Document de type XML", + }, + zip: { + src: "img/vignettes/zip.png", + title: "Document de type ZIP", + }, +}; + +export default docThumbnails; diff --git a/assets/entrepot/api/community.ts b/assets/entrepot/api/community.ts index 48fc2954..ad7cd5c0 100644 --- a/assets/entrepot/api/community.ts +++ b/assets/entrepot/api/community.ts @@ -24,7 +24,7 @@ const getMembers = (communityId: string, otherOptions: RequestInit = {}) => { * @returns */ const updateMember = (communityId: string, formData: object) => { - const url = SymfonyRouting.generate("cartesgouvfr_api_community_add_member", { communityId }); + const url = SymfonyRouting.generate("cartesgouvfr_api_community_update_member", { communityId }); return jsonFetch( url, { diff --git a/assets/entrepot/pages/dashboard/DashboardPro.tsx b/assets/entrepot/pages/dashboard/DashboardPro.tsx index 3499feb1..8382193c 100644 --- a/assets/entrepot/pages/dashboard/DashboardPro.tsx +++ b/assets/entrepot/pages/dashboard/DashboardPro.tsx @@ -1,5 +1,4 @@ import { fr } from "@codegouvfr/react-dsfr"; -import Button from "@codegouvfr/react-dsfr/Button"; import { Tile } from "@codegouvfr/react-dsfr/Tile"; import { useMutation, useQuery } from "@tanstack/react-query"; import { declareComponentKeys } from "i18nifty"; @@ -21,6 +20,7 @@ import { getArrayRange } from "../../../utils"; import api from "../../api"; import avatarSvgUrl from "@codegouvfr/react-dsfr/dsfr/artwork/pictograms/digital/avatar.svg"; +import internetSvgUrl from "@codegouvfr/react-dsfr/dsfr/artwork/pictograms/digital/internet.svg"; import mailSendSvgUrl from "@codegouvfr/react-dsfr/dsfr/artwork/pictograms/digital/mail-send.svg"; import humanCoopSvgUrl from "@codegouvfr/react-dsfr/dsfr/artwork/pictograms/environment/human-cooperation.svg"; import padlockSvgUrl from "@codegouvfr/react-dsfr/dsfr/artwork/pictograms/system/padlock.svg"; @@ -184,8 +184,16 @@ const DashboardPro = () => {
{isApiEspaceCoDefined() && ( -
- +
+
+ +
)} diff --git a/assets/entrepot/pages/datastore/ManagePermissions/AddPermissionForm.tsx b/assets/entrepot/pages/datastore/ManagePermissions/AddPermissionForm.tsx index 5309015c..f211feec 100644 --- a/assets/entrepot/pages/datastore/ManagePermissions/AddPermissionForm.tsx +++ b/assets/entrepot/pages/datastore/ManagePermissions/AddPermissionForm.tsx @@ -196,7 +196,7 @@ const AddPermissionForm: FC = ({ datastoreId }) => { + render={({ field: { value, onChange } }) => type === "COMMUNITY" ? ( = ({ datastoreId }) => { ) diff --git a/assets/espaceco/api/community.ts b/assets/espaceco/api/community.ts index dabd71da..8a5d394d 100644 --- a/assets/espaceco/api/community.ts +++ b/assets/espaceco/api/community.ts @@ -1,17 +1,23 @@ import SymfonyRouting from "../../modules/Routing"; -import { CommunityListFilter, GetResponse } from "../../@types/app_espaceco"; +import { CommunityListFilter, CommunityMember, GetResponse, Role } from "../../@types/app_espaceco"; import { type CommunityResponseDTO } from "../../@types/espaceco"; import { jsonFetch } from "../../modules/jsonFetch"; const get = (queryParams: { page: number; limit: number }, signal: AbortSignal) => { const params = { ...queryParams, sort: "name:ASC" }; + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_community_get", params); return jsonFetch>(url, { signal: signal, }); }; +const getCommunitiesName = () => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_community_get_names"); + return jsonFetch(url); +}; + const searchByName = (name: string, filter: CommunityListFilter, signal: AbortSignal) => { const queryParams = { name: `%${name}%`, filter: filter, sort: "name:ASC" }; const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_community_search", queryParams); @@ -28,6 +34,113 @@ 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 }); + return jsonFetch(url); +}; + +const getCommunityMembers = (communityId: number, page: number, limit: number = 10, signal: AbortSignal) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_community_get_members", { + communityId, + page: page, + limit: limit, + roles: ["member", "admin"], + }); + return jsonFetch>(url, { + signal: signal, + }); +}; + +const getCommunityMembershipRequests = (communityId: number, signal: AbortSignal) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_community_get_members", { communityId, page: 1, limit: 50, roles: ["pending"] }); + return jsonFetch>(url, { + signal: signal, + }); +}; + +const addMembers = (communityId: number, members: (number | string)[]) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_community_add_members", { communityId }); + return jsonFetch( + url, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + { members: members } + ); +}; + +const updateMemberRole = (communityId: number, userId: number, role: Role) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_community_update_member_role", { communityId, userId }); + return jsonFetch( + url, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + { role: role } + ); +}; + +const updateMemberGrids = (communityId: number, userId: number, grids: string[]) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_community_update_member_grids", { communityId, userId }); + return jsonFetch( + url, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + { grids: grids } + ); +}; + +const updateLogo = (communityId: number, formData: FormData) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_community_update_logo", { communityId }); + return jsonFetch<{ logo_url: string }>( + url, + { + method: "POST", + }, + formData, + true + ); +}; + +const removeLogo = (communityId: number) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_community_remove_logo", { communityId }); + return jsonFetch(url, { method: "DELETE" }); +}; + +const removeMember = (communityId: number, userId: number) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_community_remove_member", { communityId, userId }); + return jsonFetch<{ user_id: number }>(url, { + method: "DELETE", + }); +}; + +const community = { + get, + getCommunitiesName, + getCommunity, + getCommunityMembers, + getCommunityMembershipRequests, + addMembers, + searchByName, + getAsMember, + updateMemberRole, + updateMemberGrids, + removeMember, + updateLogo, + removeLogo, +}; export default community; diff --git a/assets/espaceco/api/communityDocuments.ts b/assets/espaceco/api/communityDocuments.ts new file mode 100644 index 00000000..cdeb600e --- /dev/null +++ b/assets/espaceco/api/communityDocuments.ts @@ -0,0 +1,56 @@ +import { DocumentDTO } from "../../@types/espaceco"; +import { jsonFetch } from "../../modules/jsonFetch"; +import SymfonyRouting from "../../modules/Routing"; + +const getAll = (communityId: number, fields: string[], signal: AbortSignal) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_community_document_get_all", { + communityId, + fields: fields, + }); + return jsonFetch(url, { + signal: signal, + }); +}; + +const add = (communityId: number, data: FormData) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_community_document_add", { communityId }); + return jsonFetch( + url, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + data, + true + ); +}; + +const update = (communityId: number, documentId: number, data: object) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_community_document_update", { communityId, documentId }); + return jsonFetch( + url, + { + method: "PATCH", + }, + data + ); +}; + +const remove = (communityId: number, documentId: number) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_community_document_delete", { communityId, documentId }); + return jsonFetch(url, { + method: "DELETE", + }); +}; + +const communityDocuments = { + getAll, + add, + update, + remove, +}; + +export default communityDocuments; diff --git a/assets/espaceco/api/emailplanner.ts b/assets/espaceco/api/emailplanner.ts new file mode 100644 index 00000000..76f875c5 --- /dev/null +++ b/assets/espaceco/api/emailplanner.ts @@ -0,0 +1,54 @@ +import { EmailPlannerDTO } from "../../@types/espaceco"; +import { jsonFetch } from "../../modules/jsonFetch"; +import SymfonyRouting from "../../modules/Routing"; + +const getAll = (communityId: number) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_emailplanner_get", { communityId }); + return jsonFetch(url); +}; + +const add = (communityId: number, data: object) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_emailplanner_add", { communityId }); + return jsonFetch( + url, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + data + ); +}; + +const update = (communityId: number, emailPlannerId: number, data: object) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_emailplanner_update", { communityId, emailPlannerId }); + return jsonFetch( + url, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + data + ); +}; + +const remove = (communityId: number, emailplannerId: number) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_emailplanner_remove", { communityId, emailplannerId }); + return jsonFetch<{ emailplanner_id: number }>(url, { + method: "DELETE", + }); +}; + +const emailplanner = { + getAll, + add, + update, + remove, +}; + +export default emailplanner; diff --git a/assets/espaceco/api/grid.ts b/assets/espaceco/api/grid.ts new file mode 100644 index 00000000..d92e7cf8 --- /dev/null +++ b/assets/espaceco/api/grid.ts @@ -0,0 +1,32 @@ +import { GetResponse, SearchGridFilters } from "../../@types/app_espaceco"; +import { GridDTO } from "../../@types/espaceco"; +import { jsonFetch } from "../../modules/jsonFetch"; +import SymfonyRouting from "../../modules/Routing"; + +const search = (text: string, filters: SearchGridFilters, otherOptions: RequestInit = {}) => { + const queryParams = { text: `${text}%` }; + ["searchBy", "fields"].forEach((p) => { + if (filters[p] !== undefined) { + queryParams[p] = filters[p].join(","); + } + }); + if (filters.adm !== undefined) { + queryParams["adm"] = new Boolean(filters.adm).toString(); + } + + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_grid_search", queryParams); + return jsonFetch>(url, { + ...otherOptions, + }); +}; + +const fromNames = (names: string[], otherOptions: RequestInit = {}) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_grid_get_by_names", { names: names }); + return jsonFetch>(url, { + ...otherOptions, + }); +}; + +const grid = { search, fromNames }; + +export default grid; diff --git a/assets/espaceco/api/index.ts b/assets/espaceco/api/index.ts index e206c40a..0e623a2b 100644 --- a/assets/espaceco/api/index.ts +++ b/assets/espaceco/api/index.ts @@ -1,7 +1,17 @@ import community from "./community"; +import communityDocuments from "./communityDocuments"; +import emailplanner from "./emailplanner"; +import grid from "./grid"; +import permission from "./permission"; +import user from "./users"; const api = { + user, community, + communityDocuments, + emailplanner, + permission, + grid, }; export default api; diff --git a/assets/espaceco/api/permission.ts b/assets/espaceco/api/permission.ts new file mode 100644 index 00000000..a3fa19d3 --- /dev/null +++ b/assets/espaceco/api/permission.ts @@ -0,0 +1,14 @@ +import { TableResponseDTO } from "../../@types/espaceco"; +import { jsonFetch } from "../../modules/jsonFetch"; +import SymfonyRouting from "../../modules/Routing"; + +const getThemableTables = (communityId: number, signal: AbortSignal) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_permission_get_themable_tables_by_community", { communityId: communityId }); + return jsonFetch[]>(url, { + signal: signal, + }); +}; + +const permission = { getThemableTables }; + +export default permission; diff --git a/assets/espaceco/api/users.ts b/assets/espaceco/api/users.ts new file mode 100644 index 00000000..569b65eb --- /dev/null +++ b/assets/espaceco/api/users.ts @@ -0,0 +1,29 @@ +import { UserMe } from "../../@types/app_espaceco"; +import { UserDTO, UserSharedThemesDTO } from "../../@types/espaceco"; +import { jsonFetch } from "../../modules/jsonFetch"; +import SymfonyRouting from "../../modules/Routing"; + +const getMe = (signal: AbortSignal) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_user_me"); + return jsonFetch(url, { + method: "GET", + signal: signal, + }); +}; + +const getSharedThemes = () => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_user_shared_themes"); + return jsonFetch(url); +}; + +const search = (search: string, signal: AbortSignal) => { + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_user_search", { search }); + return jsonFetch(url, { + method: "GET", + signal: signal, + }); +}; + +const user = { getMe, search, getSharedThemes }; + +export default user; diff --git a/assets/espaceco/pages/communities/Communities.tsx b/assets/espaceco/pages/communities/Communities.tsx index 22580128..5eaf6d59 100644 --- a/assets/espaceco/pages/communities/Communities.tsx +++ b/assets/espaceco/pages/communities/Communities.tsx @@ -4,9 +4,12 @@ import { Pagination } from "@codegouvfr/react-dsfr/Pagination"; import RadioButtons from "@codegouvfr/react-dsfr/RadioButtons"; import { useQuery } from "@tanstack/react-query"; import { FC, useMemo, useState } from "react"; + import { CommunityListFilter, GetResponse, arrCommunityListFilters } from "../../../@types/app_espaceco"; import { CommunityResponseDTO } from "../../../@types/espaceco"; +import AppLayout from "../../../components/Layout/AppLayout"; import Skeleton from "../../../components/Utils/Skeleton"; +import { datastoreNavItems } from "../../../config/datastoreNavItems"; import { useTranslation } from "../../../i18n/i18n"; import RQKeys from "../../../modules/espaceco/RQKeys"; import { CartesApiException } from "../../../modules/jsonFetch"; @@ -17,6 +20,8 @@ import SearchCommunity from "./SearchCommunity"; const defaultLimit = 10; +const navItems = datastoreNavItems(); + type QueryParamsType = { page: number; limit: number; @@ -25,7 +30,8 @@ type QueryParamsType = { const Communities: FC = () => { const route = useRoute(); - const { t } = useTranslation("EspaceCoCommunities"); + const { t } = useTranslation("CommunityList"); + const { t: tBreadcrumb } = useTranslation("Breadcrumb"); const filter = useMemo(() => { const f = route.params["filter"]; @@ -45,18 +51,18 @@ const Communities: FC = () => { const [community, setCommunity] = useState(null); const communityQuery = useQuery, CartesApiException>({ - queryKey: RQKeys.community_list(queryParams.page, queryParams.limit), + queryKey: RQKeys.communityList(queryParams.page, queryParams.limit), queryFn: ({ signal }) => api.community.get(queryParams, signal), staleTime: 3600000, - retry: false, + //retry: false, enabled: filter === "public", }); const communitiesAsMember = useQuery, CartesApiException>({ - queryKey: RQKeys.communities_as_member(queryParams.pending ?? false, queryParams.page, queryParams.limit), + queryKey: RQKeys.communitiesAsMember(queryParams.pending ?? false, queryParams.page, queryParams.limit), queryFn: ({ signal }) => api.community.getAsMember(queryParams, signal), staleTime: 3600000, - retry: false, + //retry: false, enabled: filter === "iam_member" || filter === "affiliation", }); @@ -66,7 +72,15 @@ const Communities: FC = () => { }; return ( -
+

{t("title")}

{communityQuery.isError && } @@ -151,7 +165,7 @@ const Communities: FC = () => { )}
-
+ ); }; diff --git a/assets/espaceco/pages/communities/CommunityListItem.tsx b/assets/espaceco/pages/communities/CommunityListItem.tsx index e832e7b6..87535e30 100644 --- a/assets/espaceco/pages/communities/CommunityListItem.tsx +++ b/assets/espaceco/pages/communities/CommunityListItem.tsx @@ -9,21 +9,36 @@ import { useTranslation } from "../../../i18n/i18n"; import placeholder1x1 from "../../../img/placeholder.1x1.png"; import "../../../sass/pages/espaceco/community.scss"; +import { routes } from "../../../router/router"; +import { useApiEspaceCoStore } from "../../../stores/ApiEspaceCoStore"; type CommunityListItemProps = { className?: string; community: CommunityResponseDTO; }; const CommunityListItem: FC = ({ className, community }) => { - const { t } = useTranslation("EspaceCoCommunities"); + const { t } = useTranslation("CommunityList"); + const { t: tCommon } = useTranslation("Common"); + + /* TODO PROVISOIRE A SUPPRIMER ------------- */ + const url = useApiEspaceCoStore((state) => state.api_espaceco_url); + const espacecoUrl = url?.replace("/gcms/api", ""); + const appEnv = document.getElementById("root")?.dataset?.appEnv?.toLowerCase(); + const link = + appEnv === "dev" + ? routes.espaceco_manage_community({ communityId: community.id }).link + : espacecoUrl + ? { href: `${espacecoUrl}/group/${community.id}`, target: "_blank" } + : { href: "#" }; + /* ----------------------------------------- */ 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/CreateCommunity.tr.tsx b/assets/espaceco/pages/communities/CreateCommunity.tr.tsx new file mode 100644 index 00000000..935980e1 --- /dev/null +++ b/assets/espaceco/pages/communities/CreateCommunity.tr.tsx @@ -0,0 +1,32 @@ +import { declareComponentKeys } from "i18nifty"; +import { Translations } from "../../../i18n/i18n"; + +// traductions +export const { i18n } = declareComponentKeys<"title" | { K: "step_title"; P: { stepNumber: number }; R: string }>()("CreateCommunity"); + +export const CreateCommunityFrTranslations: Translations<"fr">["CreateCommunity"] = { + title: "Création d'un guichet", + step_title: ({ stepNumber }) => { + switch (stepNumber) { + case 1: + return "Description"; + case 2: + return "Base de données"; + case 3: + return "Couches"; + case 4: + return "Zoom et centrage"; + case 5: + return "Outils"; + case 6: + return "Signalements"; + default: + return ""; + } + }, +}; + +export const CreateCommunityEnTranslations: Translations<"en">["CreateCommunity"] = { + title: "Create community", + step_title: ({ stepNumber }) => `step ${stepNumber}`, +}; diff --git a/assets/espaceco/pages/communities/CreateCommunity.tsx b/assets/espaceco/pages/communities/CreateCommunity.tsx new file mode 100644 index 00000000..a8581220 --- /dev/null +++ b/assets/espaceco/pages/communities/CreateCommunity.tsx @@ -0,0 +1,56 @@ +import Stepper from "@codegouvfr/react-dsfr/Stepper"; +import { FC, useState } from "react"; +import { useTranslation } from "../../../i18n/i18n"; +import { datastoreNavItems } from "../../../config/datastoreNavItems"; +import AppLayout from "../../../components/Layout/AppLayout"; +import { routes } from "../../../router/router"; +import Description from "./management/Description"; +import { CommunityResponseDTO } from "../../../@types/espaceco"; + +const STEPS = { + DESCRIPTION: 1, + DATABASE: 2, + LAYERS: 3, + ZOOM_AND_CENTERING: 4, + TOOLS: 5, + REPORTS: 6, +}; + +const navItems = datastoreNavItems(); + +const CreateCommunity: FC = () => { + const { t: tBreadcrumb } = useTranslation("Breadcrumb"); + const { t } = useTranslation("CreateCommunity"); + + const [community, setCommunity] = useState(); + const [currentStep, setCurrentStep] = useState(STEPS.DESCRIPTION); + + return ( + +
+

{t("title")}

+ + {/* TODO */} +
{currentStep === STEPS.DESCRIPTION && console.log(datas)} />}
+
+
+ ); +}; + +export default CreateCommunity; diff --git a/assets/espaceco/pages/communities/ManageCommunity.tsx b/assets/espaceco/pages/communities/ManageCommunity.tsx new file mode 100644 index 00000000..71f5d5c2 --- /dev/null +++ b/assets/espaceco/pages/communities/ManageCommunity.tsx @@ -0,0 +1,114 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Alert from "@codegouvfr/react-dsfr/Alert"; +import Button from "@codegouvfr/react-dsfr/Button"; +import Tabs from "@codegouvfr/react-dsfr/Tabs"; +import { useQuery } from "@tanstack/react-query"; +import { FC, useState } from "react"; +import { CommunityResponseDTO } from "../../../@types/espaceco"; +import AppLayout from "../../../components/Layout/AppLayout"; +import LoadingText from "../../../components/Utils/LoadingText"; +import { datastoreNavItems } from "../../../config/datastoreNavItems"; +import { useTranslation } from "../../../i18n/i18n"; +import RQKeys from "../../../modules/espaceco/RQKeys"; +import { CartesApiException } from "../../../modules/jsonFetch"; +import { routes } from "../../../router/router"; +import api from "../../api"; +import Description from "./management/Description"; +import Grid from "./management/Grid"; +import Layer from "./management/Layer"; +import Reports from "./management/Reports"; +import ZoomAndCentering from "./management/ZoomAndCentering"; +import Members from "./management/Members"; + +type ManageCommunityProps = { + communityId: number; +}; + +const navItems = datastoreNavItems(); + +const ManageCommunity: FC = ({ communityId }) => { + const { t } = useTranslation("ManageCommunity"); + const { t: tBreadcrumb } = useTranslation("Breadcrumb"); + + 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.isError ? ( + +

{communityQuery.error?.message}

+ + + } + /> + ) : communityQuery.isLoading ? ( + + ) : ( + communityQuery.data && ( +
+ + <> + {(() => { + switch (selectedTabId) { + case "tab1": + return ; + case "tab3": + return ; + case "tab4": + return ; + case "tab6": + return ; + case "tab7": + return ; // TODO + case "tab8": + return ; + default: + return

`Content of ${selectedTabId}`

; + } + })()} + +
+
+ ) + )} +
+ ); +}; + +export default ManageCommunity; diff --git a/assets/espaceco/pages/communities/ManageCommunityTr.tsx b/assets/espaceco/pages/communities/ManageCommunityTr.tsx new file mode 100644 index 00000000..04d48567 --- /dev/null +++ b/assets/espaceco/pages/communities/ManageCommunityTr.tsx @@ -0,0 +1,301 @@ +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" + | "tab7" + | "tab8" + | "desc.tab.title" + | "desc.name" + | "desc.hint_name" + | "desc.description" + | "desc.hint_description" + | "desc.logo" + | "desc.logo.title" + | { K: "logo_action"; P: { action: logoAction }; R: string } + | { K: "running_action"; P: { action: logoAction }; R: string } + | "logo_confirm_delete_modal.title" + | "modal.logo.title" + | "modal.logo.file_hint" + | "desc.keywords" + | "desc.documents" + | "desc.documents_hint" + | "desc.no_documents" + | "desc.adding_document" + | "desc.updating_document" + | "desc.confirm_remove_document" + | "desc.removing_document" + | "modal.document.title" + | "modal.document.title_field" + | "modal.document.description" + | "modal.document.file_hint" + | "zoom.consistant_error" + | "zoom.tab.title" + | "zoom.position" + | "zoom.position_hint" + | "zoom.zoom_range" + | "zoom.zoom_range_hint" + | "zoom.manage_extent" + | "zoom.extent" + | "zoom.extent_hint" + | "zoom.choice.autocomplete" + | "zoom.choice.manual" + | "zoom.extent_enter_manually" + | "zoom.xmin" + | "zoom.xmax" + | "zoom.ymin" + | "zoom.ymax" + | "layer.tab.title" + | "layer.tabl" + | "layer.tab2" + | "layer.tab3" + | "report.configure_themes" + | "report.configure_themes.explain" + | "report.configure_shared_themes" + | "report.configure_shared_themes.explain" + | "report.configure_statuses" + | "report.configure_statuses.explain" + | "report.manage.emailplanners" + | "report.manage.emailplanners_explain" + | "report.manage_permissions" + | "report.manage_permissions.shared_report" + | "report.manage_permissions.shared_report_hint" + | { K: "report.manage_permissions.shared_report.option"; P: { option: string }; R: string } + | "report.manage_permissions.report_answers" + | "report.manage_permissions.authorize" + | "report.manage_permissions.authorize_hint" + | "grid.grids" + | { K: "grid.explain"; R: JSX.Element } +>()("ManageCommunity"); + +/** + * "thumbnail_modal.action_being": ({ action }) => { + switch (action) { + case "add": + return "Ajout de la vignette en cours ..."; + case "modify": + return "Remplacement de la vignette en cours ..."; + case "delete": + return "Suppression de la vignette en cours ..."; + } + }, + */ + +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", + tab7: "Emprises", + tab8: "Membres", + "desc.tab.title": "Décrire le guichet", + "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"; + } + }, + running_action: ({ action }) => { + switch (action) { + case "add": + return "Ajout du logo en cours ..."; + case "modify": + return "Mise à jour du logo en cours ..."; + case "delete": + return "Suppression du logo en cours ..."; + } + }, + "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)", + "desc.documents": "Documents additionnels (optionnel)", + "desc.documents_hint": + "Lorem ipsum dolor sit amet consectetur adipisicing elit. Dicta suscipit tempora culpa, ea quis illo veniam vero consequuntur soluta nesciunt.", + "desc.no_documents": "Aucun document", + "desc.adding_document": "Ajout d'un document en cours ...", + "desc.updating_document": "Modification d'un document en cours ...", + "desc.confirm_remove_document": "Êtes-vous sûr de vouloir supprimer ce document ?", + "desc.removing_document": "Suppression du document en cours ...", + "modal.document.title": "Ajouter un document", + "modal.document.title_field": "Titre", + "modal.document.description": "Description (optionnel)", + "modal.document.file_hint": "Taille maximale : 5 Mo.", + "zoom.consistant_error": "Emprise et position ne sont pas cohérents", + "zoom.tab.title": "Définir l’état initial de la carte à l’ouverture du guichet", + "zoom.position": "Position", + "zoom.position_hint": "Fixer la position et définissez le niveau de zoom (utilisez votre souris ou la barre de recherche ci-dessous", + "zoom.zoom_range": "Gérer les niveaux de zoom minimum et maximum permis (optionnel)", + "zoom.zoom_range_hint": + "Lorem ipsum dolor sit amet consectetur, adipisicing elit. Libero quisquam hic veritatis, ex ipsum illo labore sint perspiciatis quidem architecto!", + "zoom.manage_extent": "Gérer les bornes de navigation (optionnel)", + "zoom.extent": "Bornes de navigation", + "zoom.extent_hint": + "Lorem ipsum dolor sit amet consectetur adipisicing elit. Maxime vitae maiores suscipit tempore sequi reiciendis nulla optio doloremque! Unde, illo nemo ab accusantium fugiat minus? Natus inventore dolore velit, nostrum dolores molestiae sint laborum, obcaecati, ullam provident repellat consectetur accusamus sunt rerum nobis sequi? Sed maxime fugit dolore! Ipsam, veritatis.", + "zoom.choice.autocomplete": "Recherche d'une emprise administrative", + "zoom.choice.manual": "Saisie manuelle", + "zoom.extent_enter_manually": "Entrer les coordonnées (lon,lat)", + "zoom.xmin": "X min", + "zoom.xmax": "X max", + "zoom.ymin": "Y min", + "zoom.ymax": "Y max", + "layer.tab.title": "Gérer les couches de la carte", + "layer.tabl": "Mes données", + "layer.tab2": "Données de la géoplateforme", + "layer.tab3": "Fonds de carte", + "report.configure_themes": "Configurer les thèmes et attributs des signalements (optionnel)", + "report.configure_themes.explain": + "Afin de permettre aux membres de votre groupe de soumettre des signalements sur d'autres thématiques que celles IGN (Adresse, Bâti, Points d'intérêts...), vous pouvez ajouter vos propres thèmes et personnaliser le formulaire de saisie d'un nouveau signalement pour l'adapter à vos besoins métier. Les membres de votre groupe verront ces thèmes, en plus ou à la place des thèmes IGN, sur l'interface de saisie d'un nouveau signalement sur l'espace collaboratif, les plugins SIG et l'application mobile.", + "report.configure_shared_themes": "Afficher des thèmes partagés (optionnel)", + "report.configure_shared_themes.explain": "Vous pouvez également choisir des thèmes partagés qui apparaitront sur ce guichet.", + "report.configure_statuses": "Paramétrer les status des signalements (optionnel)", + "report.configure_statuses.explain": + "Vous pouvez supprimer un maximum de 2 status en les décochant, changer leur nom et ajouter une explication pour améliorer la compréhension de vos utilisateurs.", + "report.manage.emailplanners": "Gérer les emails de suivi des nouveaux signalements (optionnel)", + "report.manage.emailplanners_explain": + "Générer des emails de suivi des signalements automatiques. Vous pouvez ajouter des adresses email dont les destinataires recevront des emails simples et pré-configurés pour tout nouveau signalement, ou configurer vous même les emails pour un meilleur suivi des signalements.", + + "report.manage_permissions": "Gérer les permissions (optionnel)", + "report.manage_permissions.shared_report": "Partage des signalements", + "report.manage_permissions.shared_report_hint": + "Vous pouvez déterminer quels utilisateurs ont accès aux signalements du groupe. Choisissez si les signalements du groupe sont :", + "report.manage_permissions.shared_report.option": ({ option }) => { + switch (option) { + case "all": + return "Visibles de tout le monde"; + case "restrained": + return "Visibles uniquement des membres du guichet"; + case "personal": + return "Visibles uniquement de leur auteur et des gestionnaires du guichet"; + default: + return ""; + } + }, + "report.manage_permissions.report_answers": "Réponses aux signalements", + "report.manage_permissions.authorize": "Autoriser", + "report.manage_permissions.authorize_hint": + "Tous les membres d'un groupe peuvent répondre aux signalements le concernant mais seuls les gestionnaires peuvent valider ces réponses et donc clore les signalements. En cochant la case suivante vous autorisez tous les membres de ce groupe à apporter des réponses sans validation.", + "grid.grids": "Emprises du guichet (optionnel)", + "grid.explain": ( +

+ Ajouter une ou plusieurs emprises. +
+ Tous les membres du guichet y auront accès et pourront donc réaliser des contributions directes sur la base de données. +

+ ), +}; + +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, + tab7: undefined, + tab8: undefined, + "desc.tab.title": 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"; + } + }, + running_action: ({ action }) => `${action} running`, + "logo_confirm_delete_modal.title": undefined, + "modal.logo.title": undefined, + "modal.logo.file_hint": undefined, + "desc.keywords": undefined, + "desc.documents": undefined, + "desc.documents_hint": undefined, + "desc.no_documents": "No document", + "desc.adding_document": undefined, + "desc.updating_document": undefined, + "desc.confirm_remove_document": undefined, + "desc.removing_document": undefined, + "modal.document.title": "Add document", + "modal.document.title_field": "Title", + "modal.document.description": "Description (optional)", + "modal.document.file_hint": "Maximum file size : 5 Mo.", + "zoom.consistant_error": undefined, + "zoom.tab.title": undefined, + "zoom.position": "Position", + "zoom.position_hint": undefined, + "zoom.zoom_range": undefined, + "zoom.zoom_range_hint": undefined, + "zoom.manage_extent": undefined, + "zoom.extent": undefined, + "zoom.extent_hint": undefined, + "zoom.choice.autocomplete": undefined, + "zoom.choice.manual": undefined, + "zoom.extent_enter_manually": undefined, + "zoom.xmin": "X min", + "zoom.xmax": "X max", + "zoom.ymin": "Y min", + "zoom.ymax": "Y max", + "layer.tab.title": undefined, + "layer.tabl": "My datas", + "layer.tab2": "Geoplateforme datas", + "layer.tab3": "Base maps", + "report.configure_themes": undefined, + "report.configure_themes.explain": undefined, + "report.configure_shared_themes": undefined, + "report.configure_shared_themes.explain": undefined, + "report.configure_statuses": undefined, + "report.configure_statuses.explain": undefined, + "report.manage.emailplanners": undefined, + "report.manage.emailplanners_explain": undefined, + "report.manage_permissions": undefined, + "report.manage_permissions.shared_report": undefined, + "report.manage_permissions.shared_report_hint": undefined, + "report.manage_permissions.shared_report.option": ({ option }) => { + return `${option}`; + }, + "report.manage_permissions.report_answers": undefined, + "report.manage_permissions.authorize": undefined, + "report.manage_permissions.authorize_hint": undefined, + "grid.grids": undefined, + "grid.explain": undefined, +}; diff --git a/assets/espaceco/pages/communities/MemberInvitation.tsx b/assets/espaceco/pages/communities/MemberInvitation.tsx new file mode 100644 index 00000000..c8002595 --- /dev/null +++ b/assets/espaceco/pages/communities/MemberInvitation.tsx @@ -0,0 +1,275 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Alert, { AlertProps } from "@codegouvfr/react-dsfr/Alert"; +import ButtonsGroup from "@codegouvfr/react-dsfr/ButtonsGroup"; +import CallOut from "@codegouvfr/react-dsfr/CallOut"; +import { cx } from "@codegouvfr/react-dsfr/tools/cx"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { FC, useEffect, useMemo, useState } from "react"; +import { CommunityMember, Role, UserMe } from "../../../@types/app_espaceco"; +import { CommunityResponseDTO } from "../../../@types/espaceco"; +import AppLayout from "../../../components/Layout/AppLayout"; +import LoadingText from "../../../components/Utils/LoadingText"; +import Wait from "../../../components/Utils/Wait"; +import { datastoreNavItems } from "../../../config/datastoreNavItems"; +import { declareComponentKeys, Translations, useTranslation } from "../../../i18n/i18n"; +import RQKeys from "../../../modules/espaceco/RQKeys"; +import { CartesApiException } from "../../../modules/jsonFetch"; +import { routes } from "../../../router/router"; +import { useApiEspaceCoStore } from "../../../stores/ApiEspaceCoStore"; +import api from "../../api"; + +import "../../../../assets/sass/pages/espaceco/member_invitation.scss"; + +type MemberInvitationProps = { + communityId: number; +}; + +type ErrorMessage = { + message: string | JSX.Element; + type: AlertProps.Severity; +}; + +const MemberInvitation: FC = ({ communityId }) => { + const { t } = useTranslation("MemberInvitation"); + const { t: tBreadcrumb } = useTranslation("Breadcrumb"); + + const apiEspaceCoUrl = useApiEspaceCoStore((state) => state.api_espaceco_url); + const espaceCoUrl = useMemo(() => (apiEspaceCoUrl ? apiEspaceCoUrl.replace("/gcms/api", "/login") : undefined), [apiEspaceCoUrl]); + + const navItems = useMemo(() => datastoreNavItems(), []); + + const [errorMessage, setErrorMessage] = useState(); + + const meQuery = useQuery({ + queryKey: RQKeys.getMe(), + queryFn: ({ signal }) => api.user.getMe(signal), + staleTime: 3600000, + retry: false, + }); + + useEffect(() => { + if (meQuery.error) { + if (meQuery.error.code === 403 && meQuery.error?.message.includes("CGU")) { + setErrorMessage({ + message: t("espaceco_accept_cgu", { url: espaceCoUrl }), + type: "warning", + }); + } else + setErrorMessage({ + message: meQuery.error.message, + type: "warning", + }); + } + }, [espaceCoUrl, meQuery, t]); + + const query = useQuery({ + queryKey: RQKeys.community(communityId), + queryFn: () => api.community.getCommunity(communityId), + staleTime: 3600000, + enabled: meQuery.data !== undefined, + }); + + const myRole = useMemo(() => { + let role: Role | undefined; + if (meQuery.data) { + const user_id = meQuery.data.id; + const members = meQuery.data.communities_member.filter((m) => m.community_id === communityId && m.user_id === user_id); + if (members.length === 1) { + role = members[0].role; + } + } + return role; + }, [meQuery.data, communityId]); + + /* Invitation : role : "invited" => "member" */ + const updateRoleMutation = useMutation({ + mutationFn: () => { + if (meQuery.data?.id) { + return api.community.updateMemberRole(communityId, meQuery.data.id, "member"); + } + return Promise.resolve(undefined); + }, + }); + + /* Suppression du membre, Mais comme il a créé son compte, il reste inscrit sur cartes.gouv. */ + const removeMemberMutation = useMutation<{ user_id: number } | undefined, CartesApiException>({ + mutationFn: () => { + if (meQuery.data?.id) { + return api.community.removeMember(communityId, meQuery.data.id); + } + return Promise.resolve(undefined); + }, + onSuccess: () => routes.espaceco_community_list().push(), + }); + + const community = useMemo(() => query.data, [query.data]); + + return ( + +

{t("document_title")}

+ {meQuery.isLoading && } + + {query.isLoading && } + {query.isError && } + + {updateRoleMutation.isError && } + {updateRoleMutation.isPending && ( + +
+ +
+
+ )} + + {removeMemberMutation.isError && } + {removeMemberMutation.isPending && ( + +
+ +
+
+ )} + + {errorMessage ? ( + + ) : ( + community && + (myRole === "invited" ? ( +
+ + {t("logo")} + {t("community_name", { name: community.name })} +
+ } + > +
+ {t("community_description", { description: community.description })} + removeMemberMutation.mutate(), + }, + { + children: t("accept"), + priority: "primary", + onClick: () => updateRoleMutation.mutate(), + }, + ]} + inlineLayoutWhen="always" + alignment="left" + className={fr.cx("fr-mt-2w")} + /> +
+ +
+ ) : myRole !== undefined ? ( +

{t("already_member")}

+ ) : ( + + )) + )} + + ); +}; + +export default MemberInvitation; + +export const { i18n } = declareComponentKeys< + | "document_title" + | "community_loading" + | "community_loading_failed" + | "userme_loading" + | "userme_loading_failed" + | "logo" + | { K: "community_name"; P: { name: string }; R: JSX.Element } + | { K: "community_description"; P: { description: string | null }; R: JSX.Element } + | { K: "invitation"; R: JSX.Element } + | { K: "espaceco_accept_cgu"; P: { url: string | undefined }; R: JSX.Element } + | "already_member" + | "not_member" + | "accept" + | "reject" + | "inviting" + | "rejecting" +>()("MemberInvitation"); + +export const MemberInvitationFrTranslations: Translations<"fr">["MemberInvitation"] = { + document_title: "Invitation", + community_loading: "Chargement du guichet en cours ...", + community_loading_failed: "La récupération des informations du guichet a échoué.", + userme_loading: "Chargement de vos données utilisateur en cours ...", + userme_loading_failed: "La récupération de vous données d'utilisateur a échoué.", + logo: "Logo du guichet", + community_name: ({ name }) => ( +
+ Guichet {name} +
+ ), + community_description: ({ description }) => { + return description ?

:

Aucune description

; + }, + invitation:

Vous avez reçu une invitation à rejoindre le guichet :

, + espaceco_accept_cgu: ({ url }) => ( +
+

{"Bonjour, vous n'avez pas encore accepté les conditions générales d'utilisation de l'espace collaboratif."}

+

+ {"Veuillez vous connecter avec votre nom d'utilisateur sur "} + {url ? ( + + {"l'espace collaboratif"} + + ) : ( + "l'espace collaboratif" + )} + {" et accepter les CGU."} +
+ {"Vous pourrez ensuite continuer la navigation sur cartes.gouv.fr."} +

+
+ ), + already_member: "Vous êtes déjà membre de ce guichet", + not_member: "Vous n'êtes pas membre de ce guichet", + accept: "Accepter et rejoindre le guichet", + reject: "Refuser l'invitation", + inviting: "Invitation en cours ...", + rejecting: "Refus en cours ...", +}; + +export const MemberInvitationEnTranslations: Translations<"en">["MemberInvitation"] = { + document_title: "Invitation", + community_loading: "Community loading ...", + community_loading_failed: undefined, + userme_loading: undefined, + userme_loading_failed: undefined, + logo: undefined, + community_name: ({ name }) => ( +

+ Community `${name}` +

+ ), + community_description: undefined, + invitation: undefined, + espaceco_accept_cgu: ({ url }) =>

{url}

, + already_member: undefined, + not_member: undefined, + accept: undefined, + reject: undefined, + inviting: undefined, + rejecting: undefined, +}; diff --git a/assets/espaceco/pages/communities/SearchCommunity.tsx b/assets/espaceco/pages/communities/SearchCommunity.tsx index ed34fff9..e35b20d7 100644 --- a/assets/espaceco/pages/communities/SearchCommunity.tsx +++ b/assets/espaceco/pages/communities/SearchCommunity.tsx @@ -17,12 +17,12 @@ type SearchCommunityProps = { }; const SearchCommunity: FC = ({ filter, onChange }) => { - const { t } = useTranslation("EspaceCoCommunities"); + const { t } = useTranslation("CommunityList"); const [search, setSearch] = useDebounceValue("", 500); const searchQuery = useQuery({ - queryKey: RQKeys.search(search, filter), + queryKey: RQKeys.searchCommunities(search, filter), queryFn: ({ signal }) => api.community.searchByName(search, filter, signal), enabled: search.length > 3, }); @@ -39,6 +39,7 @@ const SearchCommunity: FC = ({ filter, onChange }) => { noOptionsText={t("no_options")} getOptionLabel={(option) => option.name} options={searchQuery.data || []} + filterOptions={(x) => x} renderInput={(params) => ( { + return create<{ + tab1: DescriptionFormType | null; + setData: ({ tab, data }: setDataType) => void; + }>((set) => ({ + tab1: { name: initProps.name, description: initProps.description ?? undefined, keywords: initProps.keywords }, + setData: ({ tab, data }) => + set((state) => ({ + ...state, + [tab]: data, + })), + })); +}; + +export default useCommunityFormStore; diff --git a/assets/espaceco/pages/communities/management/CommunityLogo.tsx b/assets/espaceco/pages/communities/management/CommunityLogo.tsx new file mode 100644 index 00000000..ee4774af --- /dev/null +++ b/assets/espaceco/pages/communities/management/CommunityLogo.tsx @@ -0,0 +1,278 @@ +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 Alert from "@codegouvfr/react-dsfr/Alert"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { CommunityResponseDTO } from "../../../../@types/espaceco"; +import placeholder1x1 from "../../../../img/placeholder.1x1.png"; +import RQKeys from "../../../../modules/espaceco/RQKeys"; +import { CartesApiException } from "../../../../modules/jsonFetch"; +import "../../../../sass/components/buttons.scss"; +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(() => { + setIsValid(false); + + 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<{ logo_url: string }, CartesApiException>({ + mutationFn: () => { + const form = new FormData(); + form.append("logo", upload); + return api.community.updateLogo(communityId, form); + }, + onSuccess: (response) => { + AddLogoModal.close(); + + queryClient.setQueryData(RQKeys.community(communityId), (community) => + community ? { ...community, logo_url: response.logo_url } : community + ); + }, + onSettled: () => { + reset(); + }, + }); + + // Suppression de la vignette + const removeLogoMutation = useMutation({ + mutationFn: () => { + if (communityId) { + return api.community.removeLogo(communityId); + } + return Promise.resolve(null); + }, + onSuccess: () => { + queryClient.setQueryData(RQKeys.community(communityId), (community) => + community ? { ...community, logo_url: null } : community + ); + }, + 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(); + updateLogoMutation.reset(); + }, + doClosesModal: true, + priority: "secondary", + }, + { + children: t("logo_action", { action: action }), + onClick: handleSubmit(onSubmit), + doClosesModal: false, + priority: "primary", + }, + ]; + + return btns; + }, [action, updateLogoMutation, handleSubmit, onSubmit, reset, t, tCommon]); + + return ( +
+ +
+ { + currentTarget.onerror = null; // prevents looping + currentTarget.src = placeholder1x1; + }} + /> + {logoIsHovered && ( +
+
+ )} +
+ {removeLogoMutation.isError && ( + + )} + {removeLogoMutation.isPending && ( +
+ +
{t("running_action", { action: "delete" })}
+
+ )} + {createPortal( + +
+ {updateLogoMutation.isError && ( + + )} +
+ +
+
+ +
+
+ {updateLogoMutation.isPending && ( +
+ +
{t("running_action", { action: action })}
+
+ )} +
, + document.body + )} + { + removeLogoMutation.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..1c62b426 --- /dev/null +++ b/assets/espaceco/pages/communities/management/Description.tsx @@ -0,0 +1,192 @@ +import Alert from "@codegouvfr/react-dsfr/Alert"; +import Input from "@codegouvfr/react-dsfr/Input"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { useQuery } from "@tanstack/react-query"; +import { TranslationFunction } from "i18nifty/typeUtils/TranslationFunction"; +import { FC, useCallback, useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; +import * as yup from "yup"; +import { CommunityResponseDTO, DocumentDTO } from "../../../../@types/espaceco"; +import AutocompleteSelect from "../../../../components/Input/AutocompleteSelect"; +import MarkdownEditor from "../../../../components/Input/MarkdownEditor"; +import LoadingText from "../../../../components/Utils/LoadingText"; +import categories from "../../../../data/topic_categories.json"; +import { ComponentKey, declareComponentKeys, Translations, useTranslation } from "../../../../i18n/i18n"; +import RQKeys from "../../../../modules/espaceco/RQKeys"; +import { type CartesApiException } from "../../../../modules/jsonFetch"; +import "../../../../sass/pages/espaceco/community.scss"; +import api from "../../../api"; +import CommunityLogo from "./CommunityLogo"; +import DocumentList from "./description/DocumentList2"; +import { fr } from "@codegouvfr/react-dsfr"; +import { Button } from "@mui/material"; + +type DocumentForm = { + title: string; + description?: string; + file: File; +}; + +type DescriptionForm = { + name: string; + description?: string; + keywords?: string[]; + documents?: DocumentForm[]; +}; + +type DescriptionProps = { + community?: CommunityResponseDTO; + onSubmit: (datas: DescriptionForm) => void; +}; + +const Description: FC = ({ community }) => { + const { t: tCommon } = useTranslation("Common"); + const { t: tValid } = useTranslation("ManageCommunityValidations"); + const { t: tmc } = useTranslation("ManageCommunity"); + const { t } = useTranslation("Description"); + + const communityNamesQuery = useQuery({ + queryKey: RQKeys.communitiesName(), + queryFn: () => api.community.getCommunitiesName(), + staleTime: 3600000, + }); + + /* const communityDocumentsQuery = useQuery({ + queryKey: RQKeys.communityDocuments(community?.id), + queryFn: ({ signal }) => { + if (community) { + return api.communityDocuments.getAll(community.id, [], signal); + } + return Promise.resolve(null); + }, + staleTime: 3600000, + enabled: community !== undefined, + }); */ + + const communityNames = useMemo(() => { + const name = community?.name; + return communityNamesQuery.data?.filter((n) => n !== name) ?? []; + }, [community, communityNamesQuery]); + + // TODO AJOUTER LES TRADUCTIONS POUR LES VALIDATIONS DES DOCUMENTS + 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")) + .test("is-unique", tValid("description.name.unique"), (name) => { + if (name === undefined) return true; + return !communityNames.includes(name.trim()); + }) + .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()), + documents: yup.array().of( + yup.object({ + title: yup.string(), + description: yup.string(), + file: yup.mixed(), + }) + ), + }); + }; + + const { + control, + register, + formState: { errors }, + getValues: getFormValues, + handleSubmit, + } = useForm({ + resolver: yupResolver(schema(tValid)), + mode: "onChange", + values: { + name: community?.name || "", + description: community?.description ?? "", + keywords: community?.keywords ?? [], + documents: [], + }, + }); + + const onSubmitForm = () => { + const values = getFormValues(); + + /* Suppression des tableaux vides et des valeurs nulles ou undefined */ + Object.keys(values).forEach((key) => { + if ((Array.isArray(values[key]) && values[key].length === 0) || values[key] === null || values[key] === undefined) { + delete values[key]; + } + }); + }; + return ( + <> +

{tmc("desc.tab.title")}

+
+

{tCommon("mandatory_fields")}

+ + ( + { + field.onChange(values); + }} + /> + )} + /> + {/* TODO A VOIR */} + {/* */} + ( + field.onChange(value)} + /> + )} + /> + {/* TODO A VOIR */} + + {/* {communityDocumentsQuery.data && } */} +
+ + + ); +}; + +export default Description; + +// traductions +export const { i18n } = declareComponentKeys<"loading_documents">()("Description"); + +export const DescriptionFrTranslations: Translations<"fr">["Description"] = { + loading_documents: "Chargement des documents", +}; + +export const DescriptionEnTranslations: Translations<"en">["Description"] = { + loading_documents: undefined, +}; diff --git a/assets/espaceco/pages/communities/management/Grid.tsx b/assets/espaceco/pages/communities/management/Grid.tsx new file mode 100644 index 00000000..2f071a54 --- /dev/null +++ b/assets/espaceco/pages/communities/management/Grid.tsx @@ -0,0 +1,43 @@ +import { FC } from "react"; +import { useForm } from "react-hook-form"; +import { GridDTO } from "../../../../@types/espaceco"; +import { useTranslation } from "../../../../i18n/i18n"; +import GridList from "./GridList"; + +type GridProps = { + grids: GridDTO[]; +}; + +type GridForm = { + grids: string[]; +}; + +const Grid: FC = ({ grids }) => { + const { t } = useTranslation("ManageCommunity"); + + const form = useForm({ + mode: "onSubmit", + values: { + grids: Array.from(grids, (g) => g.name), + }, + }); + const { setValue: setFormValue } = form; + + return ( + <> +

{t("grid.grids")}

+ {t("grid.explain")} + { + setFormValue( + "grids", + Array.from(grids, (g) => g.name) + ); + }} + /> + + ); +}; + +export default Grid; diff --git a/assets/espaceco/pages/communities/management/GridList.tsx b/assets/espaceco/pages/communities/management/GridList.tsx new file mode 100644 index 00000000..b7a2eba0 --- /dev/null +++ b/assets/espaceco/pages/communities/management/GridList.tsx @@ -0,0 +1,75 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Button from "@codegouvfr/react-dsfr/Button"; +import Table from "@codegouvfr/react-dsfr/Table"; +import { FC, ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { GridDTO } from "../../../../@types/espaceco"; +import SearchGrids from "./ZoomAndCentering/SearchGrids"; + +type GridListProps = { + grids?: GridDTO[]; + onChange: (grids: GridDTO[]) => void; +}; + +const GridList: FC = ({ grids = [], onChange }) => { + const [grid, setGrid] = useState(null); + const [internal, setInternal] = useState([]); + + useEffect(() => { + setInternal([...grids]); + }, [grids]); + + const handleRemove = useCallback( + (gridName: string) => { + const grids = internal.filter((grid) => grid.name !== gridName); + setInternal(grids); + onChange(grids); + }, + [internal, onChange] + ); + + const handleAdd = () => { + if (grid) { + const grids = Array.from(new Set([...internal, grid])); + setInternal(grids); + onChange(grids); + } + }; + const data: ReactNode[][] = useMemo(() => { + return Array.from(internal, (grid) => [ + grid.name, + grid.title, + grid.type.title, +
+
, + ]); + }, [internal, handleRemove]); + + return ( +
+
+
+ { + if (grid) { + setGrid(grid); + } + }} + /> +
+
+
+
+
+
+ + + ); +}; + +export default GridList; diff --git a/assets/espaceco/pages/communities/management/Layer.tsx b/assets/espaceco/pages/communities/management/Layer.tsx new file mode 100644 index 00000000..d62b9ed1 --- /dev/null +++ b/assets/espaceco/pages/communities/management/Layer.tsx @@ -0,0 +1,71 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Tabs from "@codegouvfr/react-dsfr/Tabs"; +import { Options, optionsFromCapabilities } from "ol/source/WMTS"; +import { FC, useMemo, useState } from "react"; +import useCapabilities from "../../../../hooks/useCapabilities"; +import { useTranslation } from "../../../../i18n/i18n"; + +const baseMaps = ["GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2", "GEOGRAPHICALGRIDSYSTEMS.MAPS.BDUNI.J1", "ORTHOIMAGERY.ORTHOPHOTOS", "CADASTRALPARCELS.PARCELS"]; + +const Layer: FC = () => { + const { t } = useTranslation("ManageCommunity"); + const { data: capabilities } = useCapabilities(); + + /* const cbBaseMapsOptions = useMemo(() => { + if (!capabilities) return []; + + + Array.from(baseMaps, (m) =>
); + }, [capabilities]); */ + + const cbBaseMapsOptions = useMemo(() => { + if (!capabilities) return []; + + const options: Options[] = []; + baseMaps.forEach((m) => { + const wmtsOptions = optionsFromCapabilities(capabilities, { layer: m, style: "Légende générique" }); + if (wmtsOptions) { + options.push(wmtsOptions); + } + }); + return options; + }, [capabilities]); + console.log(cbBaseMapsOptions); + + const [selectedTabId, setSelectedTabId] = useState("tab1"); + + return ( + <> +

{t("layer.tab.title")}

+
+
+ + <> + {(() => { + switch (selectedTabId) { + case "my_datas": + return

`Content of ${selectedTabId}`

; + case "gp_datas": + return

`Content of ${selectedTabId}`

; + case "base_maps": + return

`Content of ${selectedTabId}`

; + } + })()} + +
+
+
+
+ + ); +}; + +export default Layer; diff --git a/assets/espaceco/pages/communities/management/Members.tsx b/assets/espaceco/pages/communities/management/Members.tsx new file mode 100644 index 00000000..8600a57b --- /dev/null +++ b/assets/espaceco/pages/communities/management/Members.tsx @@ -0,0 +1,445 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Accordion from "@codegouvfr/react-dsfr/Accordion"; +import Alert from "@codegouvfr/react-dsfr/Alert"; +import Button from "@codegouvfr/react-dsfr/Button"; +import MuiDsfrThemeProvider from "@codegouvfr/react-dsfr/mui"; +import Select from "@codegouvfr/react-dsfr/Select"; +import Table from "@codegouvfr/react-dsfr/Table"; +import Pagination from "@mui/material/Pagination"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { FC, ReactNode, useCallback, useMemo, useState } from "react"; +import { v4 as uuidv4 } from "uuid"; +import { CommunityMember, GetResponse, Role } from "../../../../@types/app_espaceco"; +import { CommunityResponseDTO } from "../../../../@types/espaceco"; +import ConfirmDialog, { ConfirmDialogModal } from "../../../../components/Utils/ConfirmDialog"; +import LoadingIcon from "../../../../components/Utils/LoadingIcon"; +import LoadingText from "../../../../components/Utils/LoadingText"; +import Wait from "../../../../components/Utils/Wait"; +import { declareComponentKeys, Translations, useTranslation } from "../../../../i18n/i18n"; +import RQKeys from "../../../../modules/espaceco/RQKeys"; +import { CartesApiException } from "../../../../modules/jsonFetch"; +import { routes } from "../../../../router/router"; +import api from "../../../api"; +import { AddMembersDialog, AddMembersDialogModal } from "./member/AddMembersDialog"; +import { ManageGridsDialog, ManageGridsDialogModal } from "./member/ManageGridsDialog"; + +export type membersQueryParams = { + page: number; + limit: number; +}; + +type MembersProps = { + community: CommunityResponseDTO; +}; + +const maxFetchedMembers = 10; +const getName = (firstname: string | null, surname: string | null) => `${firstname ? firstname : ""} ${surname ? surname : ""}`; + +const Members: FC = ({ community }) => { + const { t } = useTranslation("EscoCommunityMembers"); + + const queryClient = useQueryClient(); + + const [currentPage, setCurrentPage] = useState(1); + + const [action, setAction] = useState<"remove" | "reject" | undefined>(undefined); + const [currentMember, setCurrentMember] = useState(undefined); + + // Les demandes d'affiliation + const membershipRequestsQuery = useQuery, CartesApiException>({ + queryKey: RQKeys.communityMembershipRequests(community.id), + queryFn: ({ signal }) => api.community.getCommunityMembershipRequests(community.id, signal), + staleTime: 60000, + }); + + // Les membres non en demande d'affiliation + const membersQuery = useQuery, CartesApiException>({ + queryKey: RQKeys.communityMembers(community.id, currentPage, maxFetchedMembers), + queryFn: ({ signal }) => api.community.getCommunityMembers(community.id, currentPage, maxFetchedMembers, signal), + staleTime: 60000, + }); + + /* Mise a jour du role de l'utilisateur */ + const updateRoleMutation = useMutation({ + mutationFn: (params) => { + return api.community.updateMemberRole(params.communityId, params.userId, params.role); + }, + onSuccess(response) { + /* Mise à jour des données de la requête membersQuery */ + queryClient.setQueryData>(RQKeys.communityMembers(community.id, currentPage, maxFetchedMembers), (datas) => { + if (datas) { + datas.content.forEach((member) => { + if (member.user_id === response.user_id) { + member.role = response.role; // Mise a jour du role + } + }); + } + return datas; + }); + + /* Mise à jour des données de la requête membershipRequestsQuery */ + queryClient.setQueryData(RQKeys.communityMembershipRequests(community.id), (datas) => { + if (datas) { + datas = datas.filter((member) => member.user_id !== response.user_id); + } + return datas; + }); + }, + }); + + /* Mise a jour des grids de l'utilisateur */ + const updateGridsMutation = useMutation({ + mutationFn: (params) => { + return api.community.updateMemberGrids(params.communityId, params.userId, params.grids); + }, + onSuccess(response) { + /* Mise à jour des données de la requête membersQuery */ + queryClient.setQueryData>(RQKeys.communityMembers(community.id, currentPage, maxFetchedMembers), (datas) => { + if (datas) { + datas.content.forEach((member) => { + if (member.user_id === response.user_id) { + member.grids = response.grids; // Mise a jour des grids + } + }); + } + return datas; + }); + }, + }); + + /* Suppression d'un membre */ + const removeMemberMutation = useMutation<{ user_id: number }, CartesApiException, { communityId: number; userId: number }>({ + mutationFn: ({ communityId, userId }) => { + return api.community.removeMember(communityId, userId); + }, + onSuccess(response) { + /* Mise à jour des données de la requête membersQuery */ + queryClient.setQueryData>(RQKeys.communityMembers(community.id, currentPage, maxFetchedMembers), (datas) => { + if (datas) { + datas.content = datas.content.filter((member) => member.user_id !== response.user_id); + } + return datas; + }); + + /* Mise à jour des données de la requête membershipRequestsQuery */ + queryClient.setQueryData(RQKeys.communityMembershipRequests(community.id), (datas) => { + if (datas) { + datas = datas.filter((member) => member.user_id !== response.user_id); + } + return datas; + }); + }, + onSettled: () => { + setAction(undefined); + }, + }); + + const addMembersMutation = useMutation({ + mutationFn: ({ communityId, members }) => { + return api.community.addMembers(communityId, members); + }, + onSuccess() { + queryClient.refetchQueries({ queryKey: RQKeys.communityMembers(community.id, currentPage, maxFetchedMembers) }); + }, + }); + + const alert = useCallback((title: string, error: string) => { + return ; + }, []); + + const displayWait = useCallback( + (text) => ( + +
+
+
+ +
+
+
{text}
+
+
+
+
+ ), + [] + ); + + const headers = useMemo(() => [t("username_header"), t("name_header"), t("status_header"), t("grids_header"), ""], [t]); + const pendingHeaders = useMemo(() => [t("username_header"), t("name_header"), t("date_header"), ""], [t]); + + const memberData: ReactNode[][] = useMemo(() => { + const datas = membersQuery.data?.content ?? []; + return ( + datas.map((m) => [ + m.username, + getName(m.firstname, m.surname), + , + , +
+
, + ]) ?? [] + ); + }, [community, t, membersQuery.data, updateRoleMutation]); + + const pendingData: ReactNode[][] = useMemo(() => { + const datas = membershipRequestsQuery.data?.content ?? []; + return ( + datas.map((m) => [ + m.username, + getName(m.firstname, m.surname), + m.date, +
+ + +
, + ]) ?? [] + ); + }, [community, membershipRequestsQuery.data, updateRoleMutation, removeMemberMutation, t]); + + return ( +
+ {/* les requêtes ont échoué */} + {membersQuery.isError && ( + +

{membersQuery.error.message}

+ + + } + /> + )} + + {/* LES ERREURS */} + {membershipRequestsQuery.isError && alert(t("fetch_affiliate_members_failed"), membershipRequestsQuery.error.message)} + {updateRoleMutation.isError && alert(t("update_role_failed"), updateRoleMutation.error.message)} + {updateGridsMutation.isError && alert(t("update_grids_failed"), updateGridsMutation.error.message)} + {addMembersMutation.isError && alert(t("add_members_failed"), addMembersMutation.error.message)} + {removeMemberMutation.isError && alert(t("remove_member_failed"), removeMemberMutation.error.message)} + + {/* LES ACTIONS EN COURS */} + {(membershipRequestsQuery.isLoading || membersQuery.isLoading) && } + {updateRoleMutation.isPending && displayWait(t("updating_role"))} + {updateGridsMutation.isPending && displayWait(t("updating_grids"))} + {addMembersMutation.isPending && displayWait(t("adding_members"))} + {removeMemberMutation.isPending && displayWait(t("removing_action", { action: action }))} + + {membershipRequestsQuery.data?.content && membershipRequestsQuery.data.content.length > 0 && ( + +
+ + )} + {membersQuery.data?.content && membersQuery.data.content.length > 0 && ( +
+
+ +
+
+
+ + setCurrentPage(v)} + /> + +
+ + )} + { + if (currentMember !== undefined) { + removeMemberMutation.mutate({ communityId: community.id, userId: currentMember.user_id }); + } + }} + /> + addMembersMutation.mutate({ communityId: community.id, members: ids })} /> + { + if (currentMember !== undefined) { + updateGridsMutation.mutate({ communityId: community.id, userId: currentMember.user_id, grids }); + } + }} + /> + + ); +}; + +export default Members; + +// traductions +export const { i18n } = declareComponentKeys< + | "fetch_members_failed" + | "fetch_affiliate_members_failed" + | "update_role_failed" + | "update_grids_failed" + | "add_members_failed" + | "remove_member_failed" + | "back_to_list" + | "loading_members" + | "loading_membership_requests" + | { K: "membership_requests"; P: { count: number }; R: string } + | "adding_members" + | "username_header" + | "name_header" + | "status_header" + | "grids_header" + | { K: "role"; P: { role: Role }; R: string } + | "date_header" + | "updating_role" + | "updating_grids" + | { K: "removing_action"; P: { action: "remove" | "reject" | undefined }; R: string } + | "confirm_remove" + | "accept" + | "accept_title" + | "reject" + | "reject_title" + | "remove_title" + | "manage_grids" + | "invite" +>()("EscoCommunityMembers"); + +export const EscoCommunityMembersFrTranslations: Translations<"fr">["EscoCommunityMembers"] = { + fetch_members_failed: "La récupération des membres du guichet a échoué", + fetch_affiliate_members_failed: "La récupération des demandes d'affiliation pour ce guichet a échoué", + update_role_failed: "La mise à jour du rôle de ce membre a échoué", + update_grids_failed: "La mise à jour des emprises de ce membre a échoué", + add_members_failed: "L'ajout de nouveaux membres a échoué", + remove_member_failed: "La suppression d'un membre a échoué", + back_to_list: "Retour à la liste des guichets", + loading_members: "Chargement des membres du guichet ...", + loading_membership_requests: "Chargement des demandes d’affiliation ...", + membership_requests: ({ count }) => `Demandes d’affiliation (${count})`, + adding_members: "Ajout de nouveaux membres en cours ...", + username_header: "Nom de l'utilisateur", + name_header: "Nom, prénom", + status_header: "Statut", + grids_header: "Emprises individuelles", + role: ({ role }) => { + switch (role) { + case "admin": + return "Gestionnaire"; + case "member": + return "Membre"; + case "pending": + return "En attente de demande d'affiliation"; + case "invited": + return "Invité"; + } + }, + date_header: "Date de la demande", + updating_role: "Mise à jour du rôle de l'utilisateur en cours ...", + updating_grids: "Mise à jour des emprises de l'utilisateur en cours ...", + removing_action: ({ action }) => { + switch (action) { + case "remove": + return "Suppression de l'utilisateur en cours ..."; + case "reject": + return "Rejet de la demande d'affiliation en cours ..."; + default: + return ""; + } + }, + confirm_remove: "Êtes-vous sûr de vouloir supprimer cet utilisateur ?", + accept: "Accepter", + accept_title: "Accepter la demande", + reject: "Rejeter", + reject_title: "Rejeter la demande", + remove_title: "Supprimer l'utilisateur", + manage_grids: "Gérer", + invite: "Inviter des membres", +}; + +export const EscoCommunityMembersEnTranslations: Translations<"en">["EscoCommunityMembers"] = { + fetch_members_failed: undefined, + fetch_affiliate_members_failed: undefined, + update_role_failed: undefined, + update_grids_failed: undefined, + add_members_failed: undefined, + remove_member_failed: undefined, + back_to_list: undefined, + loading_members: undefined, + loading_membership_requests: undefined, + membership_requests: ({ count }) => `Membership requests (${count})`, + adding_members: "Adding new members ...", + username_header: "username", + name_header: undefined, + status_header: "Status", + grids_header: undefined, + role: ({ role }) => `${role}`, + date_header: undefined, + updating_role: undefined, + updating_grids: undefined, + removing_action: ({ action }) => `${action}`, + confirm_remove: undefined, + accept: undefined, + accept_title: undefined, + reject: undefined, + reject_title: undefined, + remove_title: undefined, + manage_grids: "Manage", + invite: undefined, +}; diff --git a/assets/espaceco/pages/communities/management/Reports.tsx b/assets/espaceco/pages/communities/management/Reports.tsx new file mode 100644 index 00000000..52fae8cc --- /dev/null +++ b/assets/espaceco/pages/communities/management/Reports.tsx @@ -0,0 +1,268 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Alert from "@codegouvfr/react-dsfr/Alert"; +import Button from "@codegouvfr/react-dsfr/Button"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { useQuery } from "@tanstack/react-query"; +import { FC, useMemo } from "react"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; +import { ReportFormType } from "../../../../@types/app_espaceco"; +import { + CommunityResponseDTO, + EmailPlannerDTO, + ReportStatusesType, + SharedGeoremOptions, + SharedThemesDTO, + TableResponseDTO, + UserSharedThemesDTO, +} from "../../../../@types/espaceco"; +import LoadingText from "../../../../components/Utils/LoadingText"; +import statuses from "../../../../data/report_statuses.json"; +import { declareComponentKeys, Translations, useTranslation } from "../../../../i18n/i18n"; +import RQKeys from "../../../../modules/espaceco/RQKeys"; +import { CartesApiException } from "../../../../modules/jsonFetch"; +import api from "../../../api"; +import Answers from "./reports/Answers"; +import EmailPlanners from "./reports/EmailPlanners"; +import Permissions from "./reports/Permissions"; +import ReportStatuses from "./reports/ReportStatuses"; +import type { UserSharedThemesType } from "./reports/SetSharedThemesDialog"; +import SharedThemes from "./reports/SharedThemes"; +import ThemeList from "./reports/ThemeList"; +import { countActiveStatus, getDefaultStatuses, getMinAuthorizedStatus } from "./reports/Utils"; + +type ReportsProps = { + community: CommunityResponseDTO; +}; + +const minStatuses = getMinAuthorizedStatus(); + +const Reports: FC = ({ community }) => { + const { t: tCommon } = useTranslation("Common"); + const { t: tStatus } = useTranslation("ReportStatuses"); + const { t } = useTranslation("Reports"); + + const schema: yup.ObjectSchema = yup.object({ + attributes: yup + .array() + .of( + yup.object({ + theme: yup.string().required(), + database: yup.string(), + table: yup.string(), + attributes: yup + .array() + .of( + yup.object({ + name: yup.string().required(), + type: yup.string().required(), + default: yup.string().nullable(), + mandatory: yup.boolean(), + multiple: yup.boolean(), + values: yup + .array() + .test({ + name: "check-values", + test: (list) => { + if (!list) return true; + for (const element of list) { + if (element !== null && typeof element !== "string") return false; + } + return true; + }, + }) + .nullable(), + help: yup.string().nullable(), + title: yup.string(), + input_constraints: yup + .object({ + minLength: yup.number(), + minValue: yup.string(), + maxValue: yup.string(), + pattern: yup.string(), + }) + .nullable(), + json_schema: yup.object().nullable(), + required: yup.boolean(), + condition_field: yup.string(), + }) + ) + .required(), + }) + ) + .required(), + report_statuses: yup.lazy(() => { + const rs = {}; + Object.keys(statuses).forEach((status) => { + const s = status as ReportStatusesType; + rs[s] = yup.object({ + title: yup.string().required(), + description: yup.string().nullable(), + active: yup.boolean().required(), + }); + }); + return yup + .object() + .shape(rs) + .test("minStatuses", tStatus("min_statuses"), (statuses) => { + if (!statuses) return false; + const c = countActiveStatus(statuses); + return c >= minStatuses; + }) + .required(); + }), + email_planners: yup.array().of( + yup.object({ + id: yup.number().required(), + subject: yup.string().required(), + body: yup.string().required(), + delay: yup.number().required(), + repeat: yup.boolean().required(), + recipients: yup.array().of(yup.string().required()).required(), + event: yup.string().oneOf(["georem_created", "georem_status_changed"]).required(), + cancel_event: yup.string().oneOf(["none", "georem_status_changed"]).required(), + condition: yup.object({ + status: yup.array().of(yup.string().required()).required(), + }), + themes: yup.array().of(yup.string().required()).required(), + }) + ), + shared_themes: yup.array().of( + yup.object({ + community_id: yup.number().required(), + community_name: yup.string().required(), + themes: yup.array().of(yup.string().required()).required(), + }) + ), + shared_georem: yup.string().oneOf(SharedGeoremOptions).required(), + all_members_can_valid: yup.boolean().required(), + }); + + // Tables + const tablesQuery = useQuery[], CartesApiException>({ + queryKey: RQKeys.tables(community.id), + queryFn: ({ signal }) => api.permission.getThemableTables(community.id, signal), + staleTime: 60000, + }); + + // Themes partagés + const sharedThemesQuery = useQuery({ + queryKey: RQKeys.userSharedThemes(), + queryFn: () => api.user.getSharedThemes(), + staleTime: 3600000, + }); + + // Email planners + const emailPlannersQuery = useQuery({ + queryKey: RQKeys.emailPlanners(community.id), + queryFn: () => api.emailplanner.getAll(community.id), + staleTime: 3600000, + }); + + // Filtrage des themes partages qui sont déjà dans la communauté + const userSharedThemes = useMemo(() => { + if (sharedThemesQuery.data) { + const communities = sharedThemesQuery.data.filter((sht) => { + return sht.community_id !== community.id; + }); + const ret: UserSharedThemesType = {}; + communities.forEach((comm) => { + const themes = Array.from(comm.themes, (t) => t.theme); + ret[comm.community_id] = { communityName: comm.community_name, themes: themes }; + }); + return ret; + } + return {}; + }, [community, sharedThemesQuery.data]); + + /** + * On regarde la conformité entre les thèmes partagés de l'utilisateur et les thèmes + * partagés de la communauté + */ + const sharedThemes = useMemo(() => { + const shared = community.shared_themes ?? []; + + const ret: SharedThemesDTO[] = []; + if (userSharedThemes) { + shared + .filter((s) => s.community_id in userSharedThemes) + .forEach((s) => { + const themes = s.themes.filter((theme) => userSharedThemes[s.community_id].themes.indexOf(theme) >= 0); + if (themes.length) { + ret.push({ ...s, themes: themes }); + } + }); + } + return ret; + }, [community, userSharedThemes]); + + const form = useForm({ + resolver: yupResolver(schema), + mode: "onChange", + values: { + attributes: community.attributes ?? [], + report_statuses: community.report_statuses ?? getDefaultStatuses(), + email_planners: emailPlannersQuery.data ?? [], + shared_themes: sharedThemes, + shared_georem: community.shared_georem, + all_members_can_valid: community.all_members_can_valid, + }, + }); + + const { + handleSubmit, + getValues: getFormValues, + formState: { errors }, + } = form; + + const onSubmit = () => { + console.log(getFormValues()); + }; + + return ( +
+ {tablesQuery.isError && } + {sharedThemesQuery.isError && } + {emailPlannersQuery.isError && } + {tablesQuery.isLoading && } + {sharedThemesQuery.isLoading && } + {emailPlannersQuery.isLoading && } + {tablesQuery.data && sharedThemesQuery.data && emailPlannersQuery.data && ( +
+ + + + + + +
+ +
+
+ )} +
+ ); +}; + +export default Reports; + +// traductions +export const { i18n } = declareComponentKeys<"loading_tables" | "loading_shared_themes" | "loading_email_planners">()("Reports"); + +export const ReportsFrTranslations: Translations<"fr">["Reports"] = { + loading_tables: "Recherche des tables pour la configuration des thèmes ...", + loading_shared_themes: "Recherche des thèmes partagés ...", + loading_email_planners: "Recherche des emails de suivi ...", +}; + +export const ReportsEnTranslations: Translations<"en">["Reports"] = { + loading_tables: undefined, + loading_shared_themes: undefined, + loading_email_planners: undefined, +}; diff --git a/assets/espaceco/pages/communities/management/SearchTr.tsx b/assets/espaceco/pages/communities/management/SearchTr.tsx new file mode 100644 index 00000000..4595f6d2 --- /dev/null +++ b/assets/espaceco/pages/communities/management/SearchTr.tsx @@ -0,0 +1,15 @@ +import { declareComponentKeys } from "i18nifty"; +import { Translations } from "../../../../i18n/i18n"; + +// traductions +export const { i18n } = declareComponentKeys<"no_results" | "loading">()("Search"); + +export const SearchFrTranslations: Translations<"fr">["Search"] = { + no_results: "Aucun résultat", + loading: "Recherche en cours ...", +}; + +export const SearchEnTranslations: Translations<"en">["Search"] = { + no_results: "No results", + loading: "Searching ...", +}; diff --git a/assets/espaceco/pages/communities/management/ZoomAndCentering.tsx b/assets/espaceco/pages/communities/management/ZoomAndCentering.tsx new file mode 100644 index 00000000..9b15cfb9 --- /dev/null +++ b/assets/espaceco/pages/communities/management/ZoomAndCentering.tsx @@ -0,0 +1,137 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Alert from "@codegouvfr/react-dsfr/Alert"; +import Button from "@codegouvfr/react-dsfr/Button"; +import { containsCoordinate, Extent } from "ol/extent"; +import WKT from "ol/format/WKT"; +import { FC, useCallback, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { CommunityResponseDTO } from "../../../../@types/espaceco"; +import ZoomRange from "../../../../components/Utils/ZoomRange"; +import olDefaults from "../../../../data/ol-defaults.json"; +import { useTranslation } from "../../../../i18n/i18n"; +import { ExtentDialog, ExtentDialogModal } from "./ZoomAndCentering/ExtentDialog"; +import RMap from "./ZoomAndCentering/RMap"; +import Search from "./ZoomAndCentering/Search"; +import { Point } from "ol/geom"; + +type ZoomAndCenteringProps = { + community: CommunityResponseDTO; +}; + +export type ZoomAndCenteringFormType = { + position: number[]; + zoom: number; + zoomMin: number; + zoomMax: number; + extent?: Extent | null; +}; + +const ZoomAndCentering: FC = ({ community }) => { + const { t: tCommon } = useTranslation("Common"); + const { t } = useTranslation("ManageCommunity"); + + // Cohérence entre l'extent et la position + const [consistent, setConsistent] = useState(true); + + const getValues = useCallback(() => { + let p; + if (community.position) { + const feature = new WKT().readFeature(community.position, { + dataProjection: "EPSG:4326", + }); + p = feature.getGeometry() ? (feature.getGeometry() as Point).getCoordinates() : olDefaults.center; + } else p = olDefaults.center; + + return { + position: p, + zoom: community.zoom ?? olDefaults.zoom, + zoomMin: community.zoom_min ?? olDefaults.zoom_levels.TOP, + zoomMax: community.zoom_max ?? olDefaults.zoom_levels.BOTTOM, + extent: community.extent, + }; + }, [community]); + + const form = useForm({ + mode: "onSubmit", + values: getValues(), + }); + const { watch, getValues: getFormValues, setValue: setFormValue } = form; + console.log(watch()); + + const position = watch("position"); + const extent = watch("extent"); + + useEffect(() => { + if (position && extent) { + setConsistent(containsCoordinate(extent, position)); + } + return; + }, [position, extent]); + + return ( +
+ {consistent === false && ( + + )} +

{t("zoom.tab.title")}

+
+
+ { + if (newPosition) { + setFormValue("position", newPosition); + } + }} + /> + { + const oldZoom = getFormValues("zoom"); + setFormValue("zoomMin", v[0]); + setFormValue("zoomMax", v[1]); + setFormValue("zoom", oldZoom < v[1] ? oldZoom : v[1]); + }} + /> +
+ +
+
+
+ setFormValue("position", position)} + onZoomChanged={(zoom) => setFormValue("zoom", zoom)} + /> +
+ ExtentDialogModal.close()} + onApply={(e) => { + setFormValue("extent", e); + ExtentDialogModal.close(); + }} + /> +
+
+ ); +}; + +export default ZoomAndCentering; diff --git a/assets/espaceco/pages/communities/management/ZoomAndCentering/ExtentDialog.tsx b/assets/espaceco/pages/communities/management/ZoomAndCentering/ExtentDialog.tsx new file mode 100644 index 00000000..e4703a69 --- /dev/null +++ b/assets/espaceco/pages/communities/management/ZoomAndCentering/ExtentDialog.tsx @@ -0,0 +1,249 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Input from "@codegouvfr/react-dsfr/Input"; +import { createModal } from "@codegouvfr/react-dsfr/Modal"; +import RadioButtons from "@codegouvfr/react-dsfr/RadioButtons"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { Extent } from "ol/extent"; +import { FC, useCallback, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; +import { SearchGridFilters } from "../../../../../@types/app_espaceco"; +import { useTranslation } from "../../../../../i18n/i18n"; +import SearchGrids from "./SearchGrids"; + +type ExtentDialogProps = { + onCancel: () => void; + onApply: (extent: Extent) => void; +}; + +const ExtentDialogModal = createModal({ + id: "extent-modal", + isOpenedByDefault: false, +}); + +type SearchOption = "autocomplete" | "manual"; +type FieldName = "xmin" | "xmax" | "ymin" | "ymax"; + +const filters: SearchGridFilters = { + fields: ["name", "title", "extent"], + adm: true, +}; + +const transform = (value, origin) => (origin === "" ? undefined : value); + +const ExtentDialog: FC = ({ onCancel, onApply }) => { + const { t: tCommon } = useTranslation("Common"); + const { t: tValid } = useTranslation("ManageCommunityValidations"); + const { t } = useTranslation("ManageCommunity"); + + const [choice, setChoice] = useState("manual"); + + const schema = {}; + schema["manual"] = yup.object({ + xmin: yup + .number() + .typeError(tValid("zoom.extent.nan", { field: "${path}" })) + .min(-180, tValid("zoom.greater_than", { field: "${path}", v: -180 })) + .max(180, tValid("zoom.less_than", { field: "${path}", v: 180 })) + .required(tValid("zoom.extent.mandatory", { field: "${path}" })) + .transform(transform) + .test({ + name: "xmin_check", + message: tValid("zoom.f1_less_than_f2", { field1: "xmin", field2: "xmax" }), + test: (value, context) => { + const xmax = context.parent.xmax; + if (value) { + return xmax !== undefined ? value < xmax : true; + } + return true; + }, + }), + ymin: yup + .number() + .typeError(tValid("zoom.extent.nan", { field: "${path}" })) + .min(-90, tValid("zoom.greater_than", { field: "${path}", v: -90 })) + .max(90, tValid("zoom.less_than", { field: "${path}", v: 90 })) + .required(tValid("zoom.extent.mandatory", { field: "${path}" })) + .transform(transform) + .test({ + name: "ymin_check", + message: tValid("zoom.f1_less_than_f2", { field1: "ymin", field2: "ymax" }), + test: (value, context) => { + const ymax = context.parent.ymax; + return ymax !== undefined ? value < ymax : true; + }, + }), + xmax: yup + .number() + .typeError(tValid("zoom.extent.nan", { field: "${path}" })) + .min(-180, tValid("zoom.greater_than", { field: "${path}", v: -180 })) + .max(180, tValid("zoom.less_than", { field: "${path}", v: 180 })) + .required(tValid("zoom.extent.mandatory", { field: "${path}" })) + .transform(transform) + .test({ + name: "xmax_check", + message: tValid("zoom.f1_less_than_f2", { field1: "xmin", field2: "xmax" }), + test: (value, context) => { + const xmin = context.parent.xmin; + return xmin !== undefined ? value > xmin : true; + }, + }), + ymax: yup + .number() + .typeError(tValid("zoom.extent.nan", { field: "${path}" })) + .min(-90, tValid("zoom.greater_than", { field: "${path}", v: -90 })) + .max(90, tValid("zoom.less_than", { field: "${path}", v: 90 })) + .required(tValid("zoom.extent.mandatory", { field: "${path}" })) + .transform(transform) + .test({ + name: "ymax_check", + message: tValid("zoom.f1_less_than_f2", { field1: "ymin", field2: "ymax" }), + test: (value, context) => { + const ymin = context.parent.ymin; + return ymin !== undefined ? value > ymin : true; + }, + }), + }); + schema["autocomplete"] = yup.object({ + extent: yup.array().of(yup.number()).required(tValid("zoom.extent.required")), + }); + + const form = useForm({ + mode: "onChange", + resolver: yupResolver(schema[choice]), + }); + const { + register, + getValues: getFormValues, + setValue: setFormValue, + formState: { errors }, + clearErrors, + handleSubmit, + resetField, + } = form; + + const onChoiceChanged = (v) => { + setChoice(v); + }; + + const clear = useCallback(() => { + ["xmin", "ymin", "xmax", "ymax"].forEach((f) => resetField(f as FieldName, undefined)); + }, [resetField]); + + useEffect(() => { + choice === "autocomplete" ? clear() : resetField("extent", undefined); + }, [choice, clear, resetField]); + + const onSubmit = () => { + ExtentDialogModal.close(); + + const values = getFormValues(); + const extent = choice === "autocomplete" ? values.extent : [values.xmin, values.ymin, values.xmax, values.ymax]; + onApply(extent); + + if (choice !== "manual") { + setChoice("manual"); + } else clear(); + }; + + return ( + <> + {createPortal( + { + setChoice("manual"); + clear(); + onCancel(); + }, + priority: "secondary", + }, + { + children: tCommon("apply"), + doClosesModal: false, + onClick: handleSubmit(onSubmit), + priority: "primary", + }, + ]} + > + <> + onChoiceChanged("autocomplete"), + }, + }, + { + label: t("zoom.choice.manual"), + nativeInputProps: { + checked: choice === "manual", + onChange: () => onChoiceChanged("manual"), + }, + }, + ]} + /> + {choice === "autocomplete" ? ( +
+ { + setFormValue("extent", grid ? grid.extent : undefined); + clearErrors(); + }} + /> +
+ ) : ( +
+ +
+
+ + +
+
+ + +
+
+
+ )} + +
, + document.body + )} + + ); +}; + +export { ExtentDialog, ExtentDialogModal }; diff --git a/assets/espaceco/pages/communities/management/ZoomAndCentering/RMap.tsx b/assets/espaceco/pages/communities/management/ZoomAndCentering/RMap.tsx new file mode 100644 index 00000000..22f44841 --- /dev/null +++ b/assets/espaceco/pages/communities/management/ZoomAndCentering/RMap.tsx @@ -0,0 +1,117 @@ +import { Feature } from "ol"; +import { defaults as defaultControls, ScaleLine } from "ol/control"; +import { Coordinate } from "ol/coordinate"; +import Point from "ol/geom/Point"; +import { DragPan, MouseWheelZoom } from "ol/interaction"; +import BaseLayer from "ol/layer/Base"; +import TileLayer from "ol/layer/Tile"; +import VectorLayer from "ol/layer/Vector"; +import Map from "ol/Map"; +import { fromLonLat, toLonLat } from "ol/proj"; +import VectorSource from "ol/source/Vector"; +import WMTS, { optionsFromCapabilities } from "ol/source/WMTS"; +import Icon from "ol/style/Icon"; +import Style from "ol/style/Style"; +import View from "ol/View"; +import { CSSProperties, FC, useEffect, useMemo, useRef } from "react"; +import { UseFormReturn } from "react-hook-form"; +import olDefaults from "../../../../../data/ol-defaults.json"; +import useCapabilities from "../../../../../hooks/useCapabilities"; +import punaise from "../../../../../img/punaise.png"; +import { ZoomAndCenteringFormType } from "../ZoomAndCentering"; + +const mapStyle: CSSProperties = { + height: "400px", +}; + +type RMapProps = { + form: UseFormReturn; + onPositionChanged: (position: Coordinate) => void; + onZoomChanged: (zoom: number) => void; +}; + +const RMap: FC = ({ form, onPositionChanged, onZoomChanged }) => { + const mapTargetRef = useRef(null); + const mapRef = useRef(); + + // Création de la couche openlayers de fond (bg layer) + const { data: capabilities } = useCapabilities(); + + const { watch, getValues: getFormValues } = form; + const position = watch("position"); + + const position3857 = useMemo(() => fromLonLat(position), [position]); + + // Création de la carte une fois bg layer créée + useEffect(() => { + if (!capabilities) return; + + const feature = new Feature(new Point(position3857)); + + // layer punaise + const source = new VectorSource(); + source.addFeatures([feature]); + const layer = new VectorLayer({ + source: source, + style: new Style({ + image: new Icon({ + src: punaise, + // ancrage de la punaise (non centrée) + anchor: [0.5, 0], + anchorOrigin: "bottom-left", + }), + }), + }); + + const layers: BaseLayer[] = []; + + const wmtsOptions = optionsFromCapabilities(capabilities, { + layer: olDefaults.default_background_layer, + }); + if (wmtsOptions) { + const bkgLayer = new TileLayer({ + source: new WMTS(wmtsOptions), + }); + layers.push(bkgLayer); + } + layers.push(layer); + + mapRef.current = new Map({ + target: mapTargetRef.current as HTMLElement, + layers: layers, + controls: defaultControls().extend([new ScaleLine()]), + interactions: [ + new DragPan(), + new MouseWheelZoom({ + useAnchor: false, + }), + ], + view: new View({ + center: position3857, + zoom: getFormValues("zoom"), + minZoom: getFormValues("zoomMin"), + maxZoom: getFormValues("zoomMax"), + }), + }); + + mapRef.current.on("moveend", (e) => { + const map = e.map; + const centerView = map.getView().getCenter() as Coordinate; + const z = map.getView().getZoom() as number; + + if (z !== getFormValues("zoom")) { + onZoomChanged(Math.round(z)); + } + + if (Math.abs(centerView[0] - position3857[0]) > 1 && Math.abs(centerView[1] - position3857[1]) > 1) { + onPositionChanged(toLonLat(centerView)); + } + }); + + return () => mapRef.current?.setTarget(undefined); + }, [capabilities, position3857, getFormValues, onPositionChanged, onZoomChanged]); + + return
; +}; + +export default RMap; diff --git a/assets/espaceco/pages/communities/management/ZoomAndCentering/Search.tsx b/assets/espaceco/pages/communities/management/ZoomAndCentering/Search.tsx new file mode 100644 index 00000000..b63f1e28 --- /dev/null +++ b/assets/espaceco/pages/communities/management/ZoomAndCentering/Search.tsx @@ -0,0 +1,63 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import MuiDsfrThemeProvider from "@codegouvfr/react-dsfr/mui"; +import Autocomplete from "@mui/material/Autocomplete"; +import TextField from "@mui/material/TextField"; +import { useQuery } from "@tanstack/react-query"; +import { Coordinate } from "ol/coordinate"; +import { FC, ReactNode } from "react"; +import { useDebounceValue } from "usehooks-ts"; +import { SearchResult } from "../../../../../@types/app_espaceco"; +import { useTranslation } from "../../../../../i18n/i18n"; +import RQKeys from "../../../../../modules/espaceco/RQKeys"; +import { jsonFetch } from "../../../../../modules/jsonFetch"; + +type SearchProps = { + label: ReactNode; + hintText?: ReactNode; + filter: Record; + onChange: (value: Coordinate | null) => void; +}; + +const autocompleteUrl = "https://data.geopf.fr/geocodage/completion"; + +const Search: FC = ({ label, hintText, filter, onChange }) => { + const { t } = useTranslation("Search"); + + const [text, setText] = useDebounceValue("", 500); + + const searchQuery = useQuery({ + queryKey: RQKeys.searchAddress(text), + queryFn: async ({ signal }) => { + const qParams = new URLSearchParams({ text: text, ...filter }).toString(); + return jsonFetch(`${autocompleteUrl}?${qParams}`, { signal }); + }, + enabled: text.length >= 3, + }); + + return ( +
+ + + option.fulltext} + options={searchQuery.data?.results ?? []} + filterOptions={(x) => x} + renderInput={(params) => } + isOptionEqualToValue={(option, v) => option.fulltext === v.fulltext} + onInputChange={(_, v) => setText(v)} + onChange={(_, v) => { + if (v) onChange([v.x, v.y]); + }} + /> + +
+ ); +}; + +export default Search; diff --git a/assets/espaceco/pages/communities/management/ZoomAndCentering/SearchGrids.tsx b/assets/espaceco/pages/communities/management/ZoomAndCentering/SearchGrids.tsx new file mode 100644 index 00000000..5e68c8f6 --- /dev/null +++ b/assets/espaceco/pages/communities/management/ZoomAndCentering/SearchGrids.tsx @@ -0,0 +1,102 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import MuiDsfrThemeProvider from "@codegouvfr/react-dsfr/mui"; +import Autocomplete from "@mui/material/Autocomplete"; +import TextField from "@mui/material/TextField"; +import { useQuery } from "@tanstack/react-query"; +import { FC, ReactNode } from "react"; +import { useDebounceValue } from "usehooks-ts"; +import { GetResponse, SearchGridFilters } from "../../../../../@types/app_espaceco"; +import { GridDTO } from "../../../../../@types/espaceco"; +import { useTranslation } from "../../../../../i18n/i18n"; +import RQKeys from "../../../../../modules/espaceco/RQKeys"; +import api from "../../../../api"; + +import "../../../../../sass/components/autocomplete.scss"; + +export type SearchGridsProps = { + label: ReactNode; + hintText?: ReactNode; + filters: SearchGridFilters; + state?: "default" | "error" | "success"; + stateRelatedMessage?: string; + onChange: (grid: GridDTO | null) => void; +}; + +const SearchGrids: FC = ({ label, hintText, filters, state, stateRelatedMessage, onChange }) => { + const { t } = useTranslation("Search"); + + const [text, setText] = useDebounceValue("", 500); + + const searchQuery = useQuery>({ + queryKey: RQKeys.searchGrids(text), + queryFn: ({ signal }) => { + return api.grid.search(text, filters, { signal }); + }, + staleTime: 1000 * 60, + enabled: text.length >= 2, + }); + + return ( +
+ + + { + return ( + + {ownerState.getOptionLabel(option)} + + ); + }} */ + size={"small"} + loading={searchQuery.isLoading} + loadingText={t("loading")} + noOptionsText={t("no_results")} + getOptionLabel={(option) => `${option.name} : ${option.title}`} + options={searchQuery.data?.content ?? []} + filterOptions={(x) => x} + renderInput={(params) => } + isOptionEqualToValue={(option, v) => option.name === v.name} + onInputChange={(_, v) => setText(v)} + onChange={(_, v) => { + onChange(v); + }} + /> + + {state !== "default" && ( +

{ + switch (state) { + case "error": + return "fr-error-text"; + case "success": + return "fr-valid-text"; + } + })(), + "fr-mb-1v" + )} + > + {stateRelatedMessage} +

+ )} +
+ ); +}; + +export default SearchGrids; diff --git a/assets/espaceco/pages/communities/management/description/AddDocumentDialog.tsx b/assets/espaceco/pages/communities/management/description/AddDocumentDialog.tsx new file mode 100644 index 00000000..6f127d00 --- /dev/null +++ b/assets/espaceco/pages/communities/management/description/AddDocumentDialog.tsx @@ -0,0 +1,176 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Input from "@codegouvfr/react-dsfr/Input"; +import { createModal } 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, useCallback, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; +import { ComponentKey, useTranslation } from "../../../../../i18n/i18n"; +import placeholder1x1 from "../../../../../img/placeholder.1x1.png"; + +import "../../../../../sass/pages/espaceco/community.scss"; + +const AddDocumentDialogModal = createModal({ + id: "add-document-modal", + isOpenedByDefault: false, +}); + +type AddDocumentDialogProps = { + onAdd: (data: FormData) => void; +}; + +const AddDocumentDialog: FC = ({ onAdd }) => { + const { t: tCommon } = useTranslation("Common"); + const { t: tValid } = useTranslation("ManageCommunityValidations"); + const { t } = useTranslation("ManageCommunity"); + + const getSchema = useCallback( + (t: TranslationFunction<"ManageCommunityValidations", ComponentKey>) => + yup.object().shape({ + title: yup.string().min(10, t("description.modal.document.name.minlength")).required(t("description.modal.document.name.mandatory")), + description: yup.string(), + document: yup + .mixed() + .test({ + name: "exists", + test(files, ctx) { + const file = files?.[0]; + return file === undefined ? ctx.createError({ message: t("description.modal.document.file.mandatory") }) : true; + }, + }) + .test("check-file-size", t("description.modal.document.file.size_error"), (files) => { + const file = files?.[0]; + if (file === undefined) return true; + + const size = file.size / 1024 / 1024; + return size < 5; + }), + }), + [] + ); + + const schema = getSchema(tValid); + type FormType = yup.InferType; + + const form = useForm({ + mode: "onSubmit", + resolver: yupResolver(schema), + }); + + const { + register, + watch, + getValues: getFormValues, + formState: { errors }, + handleSubmit, + resetField, + } = form; + + const clear = () => { + resetField("title"); + resetField("description"); + resetField("document"); + }; + + const onSubmit = () => { + const values = getFormValues(); + + const data = new FormData(); + data.append("title", values.title); + data.append("description", values.description ?? ""); + data.append("document", values.document?.[0] as File); + onAdd(data); + clear(); + }; + + const documentFile: File = watch("document")?.[0]; + const [modalImageUrl, setModalImageUrl] = useState(""); + + useEffect(() => { + setModalImageUrl(""); + + if (!documentFile) { + return; + } + if (/image/.test(documentFile.type)) { + const reader = new FileReader(); + reader.onload = () => { + setModalImageUrl(reader.result as string); + }; + reader.readAsDataURL(documentFile); + } + }, [documentFile]); + + return ( + <> + {createPortal( + { + clear(); + AddDocumentDialogModal.close(); + }, + priority: "secondary", + }, + { + children: tCommon("add"), + doClosesModal: false, + onClick: handleSubmit(onSubmit), + priority: "primary", + }, + ]} + > +
+ + +
+
+ +
+
+
+ +
+
+
+
+
, + document.body + )} + + ); +}; + +export { AddDocumentDialog, AddDocumentDialogModal }; diff --git a/assets/espaceco/pages/communities/management/description/DocumentList.tsx b/assets/espaceco/pages/communities/management/description/DocumentList.tsx new file mode 100644 index 00000000..15ef93f4 --- /dev/null +++ b/assets/espaceco/pages/communities/management/description/DocumentList.tsx @@ -0,0 +1,251 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Alert from "@codegouvfr/react-dsfr/Alert"; +import Button from "@codegouvfr/react-dsfr/Button"; +import { createModal } from "@codegouvfr/react-dsfr/Modal"; +import Table from "@codegouvfr/react-dsfr/Table"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { FC, ReactNode, useCallback, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { v4 as uuidv4 } from "uuid"; +import { DocumentDTO } from "../../../../../@types/espaceco"; +import LoadingText from "../../../../../components/Utils/LoadingText"; +import Wait from "../../../../../components/Utils/Wait"; +import thumbnails from "../../../../../data/doc_thumbnail.json"; +import { useTranslation } from "../../../../../i18n/i18n"; +import RQKeys from "../../../../../modules/espaceco/RQKeys"; +import { CartesApiException } from "../../../../../modules/jsonFetch"; +import { appRoot } from "../../../../../router/router"; +import { useSnackbarStore } from "../../../../../stores/SnackbarStore"; +import { getFileExtension } from "../../../../../utils"; +import api from "../../../../api"; +import { AddDocumentDialog, AddDocumentDialogModal } from "./AddDocumentDialog"; +import { EditDocumentDialog, EditDocumentDialogModal } from "./EditDocumentDialog"; + +type DocumentListProps = { + communityId: number; + documents: DocumentDTO[]; +}; + +const ConfirmRemoveDocumentDialogModal = createModal({ + id: `confirm-delete-document-${uuidv4()}`, + isOpenedByDefault: false, +}); + +const DocumentList: FC = ({ communityId, documents }) => { + const { t: tCommon } = useTranslation("Common"); + const { t } = useTranslation("ManageCommunity"); + + const setMessage = useSnackbarStore((state) => state.setMessage); + const [currentDocument, setCurrentDocument] = useState(); + + const copyToClipboard = useCallback( + (uri) => { + navigator.clipboard.writeText(uri); + setMessage(tCommon("url_copied")); + }, + [tCommon, setMessage] + ); + + const getThumbnail = useCallback((fileName: string) => { + const extension = getFileExtension(fileName); + + let src = thumbnails.defaut; + if (extension) { + src = thumbnails[extension].src; + } + return `${appRoot}/${src}`; + }, []); + + const queryClient = useQueryClient(); + + const addDocumentMutation = useMutation({ + mutationFn: (data) => api.communityDocuments.add(communityId, data), + onSuccess: (document) => { + queryClient.setQueryData(RQKeys.communityDocuments(communityId), (oldDocuments) => { + const documents = oldDocuments ? [...oldDocuments] : []; + documents.push(document); + return documents; + }); + }, + }); + + const updateDocumentMutation = useMutation({ + mutationFn: (data) => { + if (currentDocument) { + return api.communityDocuments.update(communityId, currentDocument.id, data); + } + return Promise.reject(); + }, + onSuccess: (document) => { + queryClient.setQueryData(RQKeys.communityDocuments(communityId), (oldDocuments) => { + const documents = oldDocuments ? [...oldDocuments] : []; + + const index = documents.findIndex((d) => d.id === document.id); + if (index >= 0) { + documents[index] = { ...document }; + } + return documents; + }); + }, + onSettled: () => setCurrentDocument(undefined), + }); + + const removeDocumentMutation = useMutation({ + mutationFn: () => { + if (currentDocument) { + return api.communityDocuments.remove(communityId, currentDocument.id); + } + return Promise.reject(); + }, + onSuccess: () => { + queryClient.setQueryData(RQKeys.communityDocuments(communityId), (oldDocuments) => { + const documents = oldDocuments ? [...oldDocuments] : []; + return documents.filter((d) => d.id !== currentDocument?.id); + }); + }, + onSettled: () => setCurrentDocument(undefined), + }); + + const datas: ReactNode[][] = useMemo(() => { + return documents.map((d) => { + const element = d.uri ? ( + + ) : ( + + ); + return [ +
+ {element} + {d.title} +
, +
+ {d.description} +
, +
+ {d.uri && ( +
, + ]; + }); + }, [tCommon, documents, getThumbnail, copyToClipboard]); + + return ( +
+ {addDocumentMutation.isError && } + {updateDocumentMutation.isError && } + {removeDocumentMutation.isError && } + {addDocumentMutation.isPending && ( + +
+ +
+
+ )} + {updateDocumentMutation.isPending && ( + +
+ +
+
+ )} + {removeDocumentMutation.isPending && ( + +
+ +
+
+ )} + {datas.length ? ( +
+ ) : ( +
+

{t("desc.documents")}

+
{t("desc.no_documents")}
+
+ )} + + { + addDocumentMutation.mutate(data); + AddDocumentDialogModal.close(); + }} + /> + { + updateDocumentMutation.mutate(data); + EditDocumentDialogModal.close(); + }} + /> + {createPortal( + { + if (currentDocument) { + removeDocumentMutation.mutate(); + } + }, + }, + ]} + > +
+ , + document.body + )} +
+ ); +}; + +export default DocumentList; diff --git a/assets/espaceco/pages/communities/management/description/EditDocumentDialog.tsx b/assets/espaceco/pages/communities/management/description/EditDocumentDialog.tsx new file mode 100644 index 00000000..8710df19 --- /dev/null +++ b/assets/espaceco/pages/communities/management/description/EditDocumentDialog.tsx @@ -0,0 +1,136 @@ +import Input from "@codegouvfr/react-dsfr/Input"; +import { createModal } from "@codegouvfr/react-dsfr/Modal"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { TranslationFunction } from "i18nifty/typeUtils/TranslationFunction"; +import { FC, useCallback, useMemo } from "react"; +import { createPortal } from "react-dom"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; +import { ComponentKey, useTranslation } from "../../../../../i18n/i18n"; + +import { DocumentDTO } from "../../../../../@types/espaceco"; +import "../../../../../sass/pages/espaceco/community.scss"; + +const EditDocumentDialogModal = createModal({ + id: "edit-document-modal", + isOpenedByDefault: false, +}); + +type EditDocumentDialogProps = { + editDocument?: DocumentDTO; + onModify: (data: object) => void; +}; + +const EditDocumentDialog: FC = ({ editDocument, onModify }) => { + const { t: tCommon } = useTranslation("Common"); + const { t: tValid } = useTranslation("ManageCommunityValidations"); + const { t } = useTranslation("ManageCommunity"); + + const getSchema = useCallback( + (t: TranslationFunction<"ManageCommunityValidations", ComponentKey>) => + yup.object().shape({ + title: yup.string().min(10, t("description.modal.document.name.minlength")).required(t("description.modal.document.name.mandatory")), + description: yup.string(), + }), + [] + ); + + const schema = getSchema(tValid); + type FormType = yup.InferType; + + const originalValues = useMemo(() => { + return editDocument + ? { + title: editDocument.title, + description: editDocument.description ?? "", + } + : { title: "", description: "" }; + }, [editDocument]); + + const form = useForm({ + mode: "onChange", + resolver: yupResolver(schema), + values: originalValues, + }); + + const { + register, + getValues: getFormValues, + formState: { errors }, + handleSubmit, + resetField, + } = form; + + const clear = () => { + resetField("title"); + resetField("description"); + }; + + const onSubmit = () => { + const formValues = getFormValues(); + + const body = {}; + if (formValues.title !== originalValues.title) { + body["title"] = formValues.title; + } + if (formValues.description !== originalValues.description) { + body["description"] = originalValues.description; + } + + if (Object.keys(body).length) { + onModify(body); + } + clear(); + }; + + return ( + <> + {createPortal( + { + clear(); + EditDocumentDialogModal.close(); + }, + priority: "secondary", + }, + { + children: tCommon("modify"), + doClosesModal: false, + onClick: handleSubmit(onSubmit), + priority: "primary", + }, + ]} + > +
+ + +
+
, + document.body + )} + + ); +}; + +export { EditDocumentDialog, EditDocumentDialogModal }; diff --git a/assets/espaceco/pages/communities/management/member/AddMembersDialog.tsx b/assets/espaceco/pages/communities/management/member/AddMembersDialog.tsx new file mode 100644 index 00000000..a8367333 --- /dev/null +++ b/assets/espaceco/pages/communities/management/member/AddMembersDialog.tsx @@ -0,0 +1,136 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import { createModal } from "@codegouvfr/react-dsfr/Modal"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { FC } from "react"; +import { createPortal } from "react-dom"; +import { Controller, useForm } from "react-hook-form"; +import * as yup from "yup"; +import { UserDTO } from "../../../../../@types/espaceco"; +import { declareComponentKeys, Translations, useTranslation } from "../../../../../i18n/i18n"; +import SearchUsers from "./SearchUsers"; +import { isUser } from "../../../../../@types/app_espaceco"; + +const AddMembersDialogModal = createModal({ + id: "add-esco-member-modal", + isOpenedByDefault: false, +}); + +type AddMembersDialogProps = { + onAdd: (ids: (string | number)[]) => void; +}; + +const AddMembersDialog: FC = ({ onAdd }) => { + const { t: tCommon } = useTranslation("Common"); + const { t } = useTranslation("AddMembersDialog"); + + const schema = yup.object({ + users: yup.array().of(yup.mixed().required()).min(1, t("min_users_error")).required(), + }); + + const { + control, + reset, + getValues: getFormValues, + formState: { errors }, + handleSubmit, + } = useForm({ + mode: "onChange", + resolver: yupResolver(schema), + defaultValues: { + users: [], + }, + }); + + const onSubmit = () => { + AddMembersDialogModal.close(); + + const values = getFormValues(); + const users = values.users as (UserDTO | string)[]; + + const ids = Array.from(users, (u) => { + return isUser(u) ? u.id : u; + }); + onAdd(ids); + reset({ users: [] }); + }; + + return ( + <> + {createPortal( + { + AddMembersDialogModal.close(); + }, + }, + { + priority: "primary", + children: t("invite"), + doClosesModal: false, + onClick: handleSubmit(onSubmit), + }, + ]} + > +
+ { + return ( + onChange(users)} + /> + ); + }} + /> +
+
, + document.body + )} + + ); +}; + +export { AddMembersDialog, AddMembersDialogModal }; + +// traductions +export const { i18n } = declareComponentKeys<"invite_title" | "invite" | { K: "users_hint"; R: JSX.Element } | "min_users_error">()("AddMembersDialog"); + +export const AddMembersDialogFrTranslations: Translations<"fr">["AddMembersDialog"] = { + invite_title: "Inviter des membres", + invite: "Inviter", + users_hint: ( + <> +
+
    +
  • Invitez un utilisateur de la géoplateforme par son login ou son nom d’utilisateur (autocomplétion).
  • +
  • + Invitez un utilisateur qui ne fait pas partie de la géoplateforme par son adresse email. Un message lui sera envoyé pour créer un compte +
  • +
  • Vous pouvez inviter plusieurs membres à ce guichet en une seule fois
  • +
+
+
+
Une fois les membres invités, vous pourrez ensuite en désigner certains comme gestionnaires.
+ + ), + min_users_error: "Au minimum, un utilisateur ou un email est requis", +}; + +export const AddMembersDialogEnTranslations: Translations<"en">["AddMembersDialog"] = { + invite_title: "Invite members", + invite: "Invite", + users_hint: undefined, + min_users_error: undefined, +}; diff --git a/assets/espaceco/pages/communities/management/member/ManageGridsDialog.tsx b/assets/espaceco/pages/communities/management/member/ManageGridsDialog.tsx new file mode 100644 index 00000000..ad4457ae --- /dev/null +++ b/assets/espaceco/pages/communities/management/member/ManageGridsDialog.tsx @@ -0,0 +1,188 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Accordion from "@codegouvfr/react-dsfr/Accordion"; +import { createModal } from "@codegouvfr/react-dsfr/Modal"; +import Table from "@codegouvfr/react-dsfr/Table"; +import { FC, useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { GridDTO } from "../../../../../@types/espaceco"; +import { declareComponentKeys, Translations, useTranslation } from "../../../../../i18n/i18n"; +import GridList from "../GridList"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; + +const ManageGridsDialogModal = createModal({ + id: "manage-grids-modal", + isOpenedByDefault: false, +}); + +type ManageGridsDialogProps = { + communityGrids: GridDTO[]; + userGrids: GridDTO[]; + onApply: (grids: string[]) => void; +}; + +const ManageGridsDialog: FC = ({ communityGrids, userGrids, onApply }) => { + const { t: tCommon } = useTranslation("Common"); + const { t } = useTranslation("ManageGridsDialog"); + + const communityGridsData = useMemo(() => { + return communityGrids.map((g) => [g.name, g.title, g.type.title]); + }, [communityGrids]); + + const [grids, setGrids] = useState([]); + useEffect(() => { + setGrids([...userGrids]); + }, [userGrids]); + + const schema = yup.object({ + user_grids: yup.array().of(yup.string().required()), + }); + + const { + reset, + setValue: setFormValue, + getValues: getFormValues, + handleSubmit, + } = useForm({ + mode: "onSubmit", + resolver: yupResolver(schema), + values: { + user_grids: Array.from(grids, (g) => g.name), + }, + }); + + const getHeaders = useCallback( + (main: boolean) => { + const headers = [t("num_header"), t("title_header"), t("type_header")]; + if (!main) { + headers.push(""); + } + return headers; + }, + [t] + ); + + const onSubmit = () => { + ManageGridsDialogModal.close(); + + const userGrids = getFormValues("user_grids") ?? []; + onApply(userGrids); + reset({ user_grids: [] }); + }; + + return ( + <> + {createPortal( + { + ManageGridsDialogModal.close(); + }, + }, + { + priority: "primary", + children: t("add_grids"), + doClosesModal: false, + onClick: handleSubmit(onSubmit), + }, + ]} + > + <> + +
+ + {communityGridsData.length ? ( +
+ ) : ( +
+

{t("no_main_grids")}

+
+ )} + + + {/*
+ + {communityGridsData.length ? ( +
+ ) : ( +
+

{t("no_main_grids")}

+
+ )} + */} +
+ + { + const names = Array.from(grids, (g) => g.name); + setFormValue("user_grids", names); + }} + /> +
+ + , + document.body + )} + + ); +}; + +export { ManageGridsDialog, ManageGridsDialogModal }; + +// traductions +export const { i18n } = declareComponentKeys< + | "title" + | { K: "explain"; R: JSX.Element } + | "main_grids" + | "user_grids" + | "main_grids_explain" + | "no_main_grids" + | "add_grids" + | "num_header" + | "title_header" + | "type_header" +>()("ManageGridsDialog"); + +export const ManageGridsDialogFrTranslations: Translations<"fr">["ManageGridsDialog"] = { + title: "Gérer les emprises individuelles du membre", + explain: ( +

+ + Attention: Un utilisateur qui n’a aucune emprise géographique ne pourra faire que des signalements et aucune contribution + directe. + +

+ ), + main_grids: "Emprises générales du guichet", + user_grids: "Emprises individuelles du membre", + main_grids_explain: "Tous les membres du guichet héritent des emprises du guichet. Elles ne peuvent pas être supprimées.", + no_main_grids: "Aucune", + add_grids: "Ajouter les emprises individuelles", + num_header: "Numéro", + title_header: "Nom", + type_header: "Type", +}; + +export const ManageGridsDialogEnTranslations: Translations<"en">["ManageGridsDialog"] = { + title: undefined, + explain: undefined, + main_grids: undefined, + user_grids: undefined, + main_grids_explain: undefined, + no_main_grids: "None", + add_grids: "Add individual grids", + num_header: undefined, + title_header: undefined, + type_header: undefined, +}; diff --git a/assets/espaceco/pages/communities/management/member/SearchUsers.tsx b/assets/espaceco/pages/communities/management/member/SearchUsers.tsx new file mode 100644 index 00000000..457b302a --- /dev/null +++ b/assets/espaceco/pages/communities/management/member/SearchUsers.tsx @@ -0,0 +1,100 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import MuiDsfrThemeProvider from "@codegouvfr/react-dsfr/mui"; +import Autocomplete from "@mui/material/Autocomplete"; +import TextField from "@mui/material/TextField"; +import { useQuery } from "@tanstack/react-query"; +import { FC, ReactNode, useMemo } from "react"; +import { useDebounceValue } from "usehooks-ts"; +import { isUser } from "../../../../../@types/app_espaceco"; +import { UserDTO } from "../../../../../@types/espaceco"; +import { useTranslation } from "../../../../../i18n/i18n"; +import RQKeys from "../../../../../modules/espaceco/RQKeys"; +import api from "../../../../api"; +import isEmail from "validator/lib/isEmail"; + +import "../../../../../sass/components/autocomplete.scss"; + +export type SearchUsersProps = { + label: ReactNode; + hintText?: ReactNode; + value?: (UserDTO | string)[]; + state?: "default" | "error" | "success"; + stateRelatedMessage?: string; + onChange: (users: (UserDTO | string)[]) => void; +}; + +const SearchUsers: FC = ({ label, hintText, value, state, stateRelatedMessage, onChange }) => { + const { t } = useTranslation("Search"); + + const [search, setSearch] = useDebounceValue("", 500); + + const searchQuery = useQuery({ + queryKey: RQKeys.searchUsers(search), + queryFn: ({ signal }) => { + return api.user.search(search, signal); + }, + staleTime: 1000 * 60, + enabled: search.length >= 3, + }); + + const usernames = useMemo(() => { + return value?.map((v) => (isUser(v) ? v.username : v)); + }, [value]); + + return ( +
+ + + (typeof option === "string" ? option : option.username)} + options={searchQuery.data?.filter((u) => !usernames?.includes(u.username)) ?? []} + renderInput={(params) => } + isOptionEqualToValue={(option, v) => { + if (isUser(option) && isUser(v)) return option.username === v.username; + if (typeof option !== typeof v) return false; + return option === v; + }} + onInputChange={(_, v) => setSearch(v)} + onChange={(_, value) => { + if (value && Array.isArray(value)) { + value = value.filter((v) => { + return isUser(v) || isEmail(v); + }); + onChange(value); + } + }} + /> + + {state !== "default" && ( +

{ + switch (state) { + case "error": + return "fr-error-text"; + case "success": + return "fr-valid-text"; + } + })(), + "fr-mb-1v" + )} + > + {stateRelatedMessage} +

+ )} +
+ ); +}; + +export default SearchUsers; diff --git a/assets/espaceco/pages/communities/management/reports/AddAttributeDialog.tsx b/assets/espaceco/pages/communities/management/reports/AddAttributeDialog.tsx new file mode 100644 index 00000000..b44d87ab --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/AddAttributeDialog.tsx @@ -0,0 +1,218 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Input from "@codegouvfr/react-dsfr/Input"; +import { createModal } from "@codegouvfr/react-dsfr/Modal"; +import RadioButtons from "@codegouvfr/react-dsfr/RadioButtons"; +import ToggleSwitch from "@codegouvfr/react-dsfr/ToggleSwitch"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { FC, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; +import { AttributeDTO, AttributeType, AttributeTypes } from "../../../../../@types/espaceco"; +import { useTranslation } from "../../../../../i18n/i18n"; +import { AttributeValidations, validateList } from "./AttributeValidations"; +import { AddOrEditAttributeFormType, getInputType, normalizeAttribute } from "./ThemeUtils"; + +type AddAttributeDialogProps = { + modal: ReturnType; + attributes: AttributeDTO[]; + onAdd: (attribute: AttributeDTO) => void; +}; + +const defaultValues: AddOrEditAttributeFormType = { + name: "", + type: "text", + mandatory: false, + default: null, + help: null, + multiple: false, + values: null, +}; + +const AddAttributeDialog: FC = ({ modal, attributes, onAdd }) => { + const { t: tCommon } = useTranslation("Common"); + const { t } = useTranslation("Theme"); + + // Etrange, le register devrait suffire + const [type, setType] = useState("text"); + + const attributeNames: string[] = useMemo(() => { + return Array.from(attributes, (a) => a.name); + }, [attributes]); + + const schema = yup.object({ + name: yup + .string() + .trim(t("trimmed_error")) + .strict(true) + .required(t("dialog.add_attribute.name_mandatory_error")) + .test("is-unique", t("dialog.add_attribute.name_unique_error"), (value) => { + const v = value.trim(); + return !attributeNames.includes(v); + }), + type: yup.string().required(), + mandatory: yup.boolean(), + default: yup + .string() + .nullable() + .test({ + name: "check-value", + test: (value, context) => { + const validator = new AttributeValidations(context); + return validator.validateValue(value); + }, + }), + help: yup.string().nullable(), + multiple: yup.boolean(), + values: yup + .string() + .nullable() + .test({ + name: "check-values", + test: (value, context) => { + return validateList(value, context); + }, + }), + }); + + const { + register, + watch, + formState: { errors }, + getValues: getFormValues, + setValue: setFormValue, + reset, + clearErrors, + handleSubmit, + } = useForm({ + mode: "onSubmit", + defaultValues: defaultValues, + resolver: yupResolver(schema), + }); + + const mandatory = watch("mandatory"); + const multiple = watch("multiple"); + + useEffect(() => { + setFormValue("default", ""); + clearErrors(["default", "values"]); + if (type !== "list") { + setFormValue("values", ""); + setFormValue("multiple", false); + } + }, [type, setFormValue, clearErrors]); + + useEffect(() => { + setFormValue("type", type); + }, [setFormValue, type]); + + const onSubmit = () => { + modal.close(); + onAdd(normalizeAttribute(getFormValues())); + reset(defaultValues); + }; + + return ( + <> + {createPortal( + { + reset(defaultValues); + modal.close(); + }, + }, + { + priority: "primary", + children: tCommon("add"), + doClosesModal: false, + onClick: handleSubmit(onSubmit), + }, + ]} + > +
+

{tCommon("mandatory_fields")}

+ + ({ + label: t("dialog.add_attribute.get_type", { type: attrType }), + nativeInputProps: { + checked: attrType === type, + onChange: () => setType(attrType), + }, + }))} + orientation={"horizontal"} + state={errors.type ? "error" : "default"} + stateRelatedMessage={errors?.type?.message} + /> + { + setFormValue("mandatory", checked); + }} + /> + {type === "list" && ( + <> + + { + setFormValue("multiple", checked); + }} + /> + + )} + + +
+
, + document.body + )} + + ); +}; + +export { AddAttributeDialog }; diff --git a/assets/espaceco/pages/communities/management/reports/AddThemeDialog.tsx b/assets/espaceco/pages/communities/management/reports/AddThemeDialog.tsx new file mode 100644 index 00000000..18eae228 --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/AddThemeDialog.tsx @@ -0,0 +1,190 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Input from "@codegouvfr/react-dsfr/Input"; +import { createModal } from "@codegouvfr/react-dsfr/Modal"; +import Select from "@codegouvfr/react-dsfr/Select"; +import ToggleSwitch from "@codegouvfr/react-dsfr/ToggleSwitch"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { FC, useEffect, useMemo } from "react"; +import { createPortal } from "react-dom"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; +import { TableResponseDTO, ThemeDTO } from "../../../../../@types/espaceco"; +import { useTranslation } from "../../../../../i18n/i18n"; +import { normalizeTheme } from "./ThemeUtils"; + +const AddThemeDialogModal = createModal({ + id: "add-theme", + isOpenedByDefault: false, +}); + +type AddThemeDialogProps = { + themes: ThemeDTO[]; + tables: Partial[]; + onAdd: (theme: ThemeDTO) => void; +}; + +type AddThemeFormType = { + theme: string; + fullname?: string; + help?: string; + global?: boolean; +}; + +const normalize = (theme: AddThemeFormType): ThemeDTO => { + const result = { ...theme }; + + result["attributes"] = []; + if (theme.fullname) { + const words = theme.fullname.split(":"); + result["database"] = words[0]; + result["table"] = words[1]; + delete result.fullname; + } + return normalizeTheme(result as ThemeDTO); +}; + +const defaultValues: AddThemeFormType = { + theme: "", + fullname: "", + help: "", + global: false, +}; + +const AddThemeDialog: FC = ({ themes, tables, onAdd }) => { + const { t: tCommon } = useTranslation("Common"); + const { t } = useTranslation("Theme"); + + const themeNames: string[] = useMemo(() => { + return Array.from(themes, (a) => a.theme); + }, [themes]); + + const tableOptions = useMemo(() => { + const a = Array.from(tables, (t) => ( + + )); + a.unshift( + + ); + return a; + }, [t, tables]); + + const schema = yup.object({ + theme: yup + .string() + .trim(t("trimmed_error")) + .strict(true) + .required(t("dialog.edit_theme.name_mandatory_error")) + .test("is-unique", t("dialog.add_theme.name_unique_error"), (value) => { + const v = value.trim(); + return !themeNames.includes(v); + }), + fullname: yup.string(), + help: yup.string(), + global: yup.boolean(), + }); + + const { + register, + watch, + formState: { errors }, + setValue: setFormValue, + getValues: getFormValues, + reset, + handleSubmit, + } = useForm({ + mode: "onSubmit", + defaultValues: defaultValues, + resolver: yupResolver(schema), + }); + + const tableFullName = watch("fullname"); + const global = watch("global"); + + useEffect(() => { + if (tableFullName) { + setFormValue("global", false); + } + }, [setFormValue, tableFullName]); + + const onSubmit = () => { + AddThemeDialogModal.close(); + onAdd(normalize(getFormValues())); + reset(defaultValues); + }; + + return ( + <> + {createPortal( + { + reset(defaultValues); + AddThemeDialogModal.close(); + }, + }, + { + priority: "primary", + children: tCommon("add"), + doClosesModal: false, + onClick: handleSubmit(onSubmit), + }, + ]} + > +
+

{tCommon("mandatory_fields")}

+ + + + { + setFormValue("global", checked); + }} + /> +
+
, + document.body + )} + + ); +}; + +export { AddThemeDialog, AddThemeDialogModal }; diff --git a/assets/espaceco/pages/communities/management/reports/Answers.tsx b/assets/espaceco/pages/communities/management/reports/Answers.tsx new file mode 100644 index 00000000..dfeff804 --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/Answers.tsx @@ -0,0 +1,40 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import ToggleSwitch from "@codegouvfr/react-dsfr/ToggleSwitch"; +import { FC } from "react"; +import { Controller, UseFormReturn } from "react-hook-form"; +import { ReportFormType } from "../../../../../@types/app_espaceco"; +import { useTranslation } from "../../../../../i18n/i18n"; + +type AnswersProps = { + form: UseFormReturn; +}; + +const Answers: FC = ({ form }) => { + const { t } = useTranslation("ManageCommunity"); + + const { control, watch } = form; + + const allMembersCanValid = watch("all_members_can_valid"); + + return ( +
+
{t("report.manage_permissions.report_answers")}
+ ( + + )} + /> +
+ ); +}; + +export default Answers; diff --git a/assets/espaceco/pages/communities/management/reports/AttributeList.tsx b/assets/espaceco/pages/communities/management/reports/AttributeList.tsx new file mode 100644 index 00000000..fcfa4697 --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/AttributeList.tsx @@ -0,0 +1,112 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Button from "@codegouvfr/react-dsfr/Button"; +import { createModal } from "@codegouvfr/react-dsfr/Modal"; +import { FC, useCallback, useMemo } from "react"; +import { UseFormReturn } from "react-hook-form"; +import { v4 as uuidv4 } from "uuid"; +import { ReportFormType } from "../../../../../@types/app_espaceco"; +import { ThemeDTO } from "../../../../../@types/espaceco"; +import { useTranslation } from "../../../../../i18n/i18n"; +import { AddAttributeDialog } from "./AddAttributeDialog"; +import EditAttributeDialog from "./EditAttributeDialog"; +import ThemesHelper from "./ThemesHelper"; + +type AttributeListProps = { + form: UseFormReturn; + theme: ThemeDTO; +}; + +const AttributeList: FC = ({ form, theme }) => { + const { t } = useTranslation("Theme"); + + const { watch, setValue: setFormValue } = form; + const themes: ThemeDTO[] = watch("attributes"); + + // Suppression d'un attribut de theme + const handleRemoveAttribute = (attribute: string) => { + const tm = ThemesHelper.removeAttribute(theme.theme, attribute, themes); + setFormValue("attributes", tm); + }; + + const AddAttributeDialogModal: ReturnType = useMemo( + () => + createModal({ + id: `add-attribute-${uuidv4()}`, + isOpenedByDefault: false, + }), + [] + ); + + const getEditModal = useCallback((): ReturnType => { + return createModal({ + id: `edit-attribute-${uuidv4()}`, + isOpenedByDefault: false, + }); + }, []); + + return ( +
+
+
    + {theme?.attributes.map((a) => { + const modal = getEditModal(); + + return ( +
  • +
    +
    {a.name}
    +
    +
    +
    +
    +
    + { + const tm = ThemesHelper.updateAttribute(theme.theme, a.name, newAttribute, themes); + setFormValue("attributes", tm); + }} + /> +
  • + ); + })} +
+ {/* Pas d'ajout d'attributs pour les thèmes liés à des tables */} + {!theme.table && ( + + )} +
+ { + const tm = ThemesHelper.addAttribute(theme.theme, attribute, themes); + setFormValue("attributes", tm); + }} + /> +
+ ); +}; + +export default AttributeList; diff --git a/assets/espaceco/pages/communities/management/reports/AttributeValidations.tsx b/assets/espaceco/pages/communities/management/reports/AttributeValidations.tsx new file mode 100644 index 00000000..7be9416c --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/AttributeValidations.tsx @@ -0,0 +1,100 @@ +import * as yup from "yup"; +import { getTranslation } from "../../../../../i18n/i18n"; +import { isInt, isFloat, isDate } from "validator"; + +const { t } = getTranslation("Theme"); + +class AttributeValidations { + #context: yup.TestContext; + + constructor(context: yup.TestContext) { + this.#context = context; + } + + validateValue = (value?: string | null) => { + if (value === undefined || value === null) return true; + + const { + parent: { type }, + } = this.#context; + + const v = value.trim(); + + switch (type) { + case "text": + return true; + case "integer": + return this.#validateInteger(v); + case "double": + return this.#validateFloat(v); + case "checkbox": { + if (!["0", "1"].includes(v)) { + return this.#context.createError({ message: t("dialog.add_attribute.value.not_a_valid_checkbox") }); + } + return true; + } + case "list": + return this.#validateInList(v); + case "date": + return this.#validateDate(v); + } + }; + + #validateInteger = (value: string): yup.ValidationError | boolean => { + if (!value) return true; + if (!isInt(value, { allow_leading_zeroes: false })) { + return this.#context.createError({ message: t("dialog.add_attribute.value.not_a_valid_integer") }); + } + return true; + }; + + #validateFloat = (value: string): yup.ValidationError | boolean => { + if (!value) return true; + if (!isFloat(value)) { + return this.#context.createError({ message: t("dialog.add_attribute.value.not_a_valid_double") }); + } + return true; + }; + + #validateDate = (value: string): yup.ValidationError | boolean => { + if (!value) return true; + if (!isDate(value)) { + return this.#context.createError({ message: t("dialog.add_attribute.value.not_a_valid_date") }); + } + return true; + }; + + #validateInList = (value: string): yup.ValidationError | boolean => { + const { + parent: { values }, + } = this.#context; + + const list: string[] = values ? values.split("|") : []; + if (!list.includes(value)) { + return this.#context.createError({ message: t("dialog.add_attribute.value_not_in_list_error") }); + } + return true; + }; +} + +const validateList = (value: string | null | undefined, context: yup.TestContext): yup.ValidationError | boolean => { + const { + parent: { type }, + } = context; + + if (type !== "list") { + return true; + } + + const list: string[] = value ? value.split("|") : []; + if (!list.length) { + return context.createError({ message: t("dialog.add_attribute.type_list_not_empty_error") }); + } + const duplicates = list.filter((item, index) => list.indexOf(item) !== index); + if (duplicates.length) { + return context.createError({ message: t("dialog.add_attribute.list_duplicates_error") }); + } + return true; +}; + +export { AttributeValidations, validateList }; diff --git a/assets/espaceco/pages/communities/management/reports/EditAttributeDialog.tsx b/assets/espaceco/pages/communities/management/reports/EditAttributeDialog.tsx new file mode 100644 index 00000000..43796fba --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/EditAttributeDialog.tsx @@ -0,0 +1,188 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Input from "@codegouvfr/react-dsfr/Input"; +import { createModal } from "@codegouvfr/react-dsfr/Modal"; +import ToggleSwitch from "@codegouvfr/react-dsfr/ToggleSwitch"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { FC, useMemo } from "react"; +import { createPortal } from "react-dom"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; +import { AttributeDTO, ThemeDTO } from "../../../../../@types/espaceco"; +import { useTranslation } from "../../../../../i18n/i18n"; +import { AttributeValidations, validateList } from "./AttributeValidations"; +import { AddOrEditAttributeFormType, getInputType, normalizeAttribute } from "./ThemeUtils"; + +type EditAttributeDialogProps = { + modal: ReturnType; + theme: ThemeDTO; + attribute: AttributeDTO; + onModify: (newAttribute: AttributeDTO) => void; +}; + +const EditAttributeDialog: FC = ({ modal, theme, attribute, onModify }) => { + const { t: tCommon } = useTranslation("Common"); + const { t } = useTranslation("Theme"); + + const attributeNames: string[] = useMemo(() => { + return Array.from( + theme.attributes.filter((a) => a.name !== attribute.name), + (a) => a.name + ); + }, [theme, attribute]); + + const schema = yup.lazy(() => { + const s = { + name: yup + .string() + .trim(t("trimmed_error")) + .strict(true) + .required(t("dialog.edit_attribute.name_mandatory_error")) + .test("is-unique", t("dialog.edit_attribute.name_unique_error"), (value) => { + const v = value.trim(); + return !attributeNames.includes(v); + }), + type: yup.string().required(), + mandatory: yup.boolean(), + default: yup + .string() + .nullable() + .test({ + name: "check-value", + test: (value, context) => { + const validator = new AttributeValidations(context); + return validator.validateValue(value); + }, + }), + help: yup.string().nullable(), + }; + if (attribute.type === "list") { + s["values"] = yup.string().test({ + name: "check-values", + test: (value, context) => { + return validateList(value, context); + }, + }); + s["multiple"] = yup.boolean(); + } + return yup.object().shape(s); + }); + + const { + register, + watch, + formState: { errors }, + getValues: getFormValues, + setValue: setFormValue, + handleSubmit, + } = useForm({ + mode: "onSubmit", + values: { + name: attribute.name, + type: attribute.type, + mandatory: attribute.mandatory, + values: attribute.values ? attribute.values.join("|") : null, + default: attribute.default, + multiple: attribute.multiple, + help: attribute.help, + }, + resolver: yupResolver(schema), + }); + + const onSubmit = () => { + modal.close(); + onModify(normalizeAttribute(getFormValues())); + }; + + const mandatory = watch("mandatory"); + const multiple = watch("multiple"); + + return ( + <> + {createPortal( + { + modal.close(); + }, + }, + { + priority: "primary", + children: tCommon("modify"), + doClosesModal: false, + onClick: handleSubmit(onSubmit), + }, + ]} + > +
+

{tCommon("mandatory_fields")}

+ + { + setFormValue("mandatory", checked); + }} + /> + {attribute.type === "list" && ( + <> + + { + setFormValue("multiple", checked); + }} + /> + + )} + + +
+
, + document.body + )} + + ); +}; + +export default EditAttributeDialog; diff --git a/assets/espaceco/pages/communities/management/reports/EditReportStatusDialog.tsx b/assets/espaceco/pages/communities/management/reports/EditReportStatusDialog.tsx new file mode 100644 index 00000000..6c6f2d74 --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/EditReportStatusDialog.tsx @@ -0,0 +1,107 @@ +import Button from "@codegouvfr/react-dsfr/Button"; +import Input from "@codegouvfr/react-dsfr/Input"; +import { createModal } from "@codegouvfr/react-dsfr/Modal"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { FC } from "react"; +import { createPortal } from "react-dom"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; +import { ReportStatusesType, ReportStatusParams } from "../../../../../@types/espaceco"; +import { useTranslation } from "../../../../../i18n/i18n"; +import { getDefaultStatuses } from "./Utils"; + +const EditReportParameterModal = createModal({ + id: "status-modal", + isOpenedByDefault: false, +}); + +const defaultStatuses = getDefaultStatuses(); + +type EditReportStatusDialogProps = { + status?: ReportStatusesType; + statusParams?: ReportStatusParams; + onModify: (values: Omit) => void; +}; + +const EditReportStatusDialog: FC = ({ status, statusParams, onModify }) => { + const { t: tCommon } = useTranslation("Common"); + const { t } = useTranslation("ReportStatuses"); + + const schema = yup.object({ + title: yup.string().trim(tCommon("trimmed_error")).strict(true).required(), + description: yup.string(), + }); + + const { + register, + setValue: setFormValue, + getValues: getFormValues, + formState: { errors }, + handleSubmit, + } = useForm<{ title: string; description?: string }>({ + mode: "onSubmit", + resolver: yupResolver(schema), + values: { + title: statusParams?.title ?? "", + description: statusParams?.description ?? "", + }, + }); + + const onSubmit = () => { + EditReportParameterModal.close(); + onModify(getFormValues()); + }; + + return createPortal( + +
+ { + const title = getFormValues("title"); + if (status && title !== defaultStatuses[status].title) { + setFormValue("title", defaultStatuses[status].title); + } + }} + /> + } + state={errors?.[`report_statuses.${status}.title`] ? "error" : "default"} + stateRelatedMessage={errors?.[`report_statuses.${status}.title`]?.message} + nativeInputProps={{ + ...register("title"), + }} + /> + +
+
, + document.body + ); +}; + +export { EditReportParameterModal, EditReportStatusDialog }; diff --git a/assets/espaceco/pages/communities/management/reports/EditReportStatusDialog_save.tsx b/assets/espaceco/pages/communities/management/reports/EditReportStatusDialog_save.tsx new file mode 100644 index 00000000..333821f5 --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/EditReportStatusDialog_save.tsx @@ -0,0 +1,105 @@ +import Button from "@codegouvfr/react-dsfr/Button"; +import Input from "@codegouvfr/react-dsfr/Input"; +import { createModal } from "@codegouvfr/react-dsfr/Modal"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { FC } from "react"; +import { createPortal } from "react-dom"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; +import { ReportStatusesType, ReportStatusParams } from "../../../../../@types/espaceco"; +import { useTranslation } from "../../../../../i18n/i18n"; +import { getDefaultStatuses } from "./Utils"; + +const EditReportParameterModal = createModal({ + id: "status-modal", + isOpenedByDefault: false, +}); + +const defaultStatuses = getDefaultStatuses(); + +type EditReportStatusDialogProps = { + status: ReportStatusesType; + statusParams: ReportStatusParams; + onModify: (values: Omit) => void; +}; + +const EditReportStatusDialog: FC = ({ status, statusParams, onModify }) => { + const { t: tCommon } = useTranslation("Common"); + const { t } = useTranslation("ReportStatuses"); + + const schema = yup.object({ + title: yup.string().trim(tCommon("trimmed_error")).strict(true).required(), + description: yup.string(), + }); + + const { + register, + setValue: setFormValue, + getValues: getFormValues, + formState: { errors }, + handleSubmit, + } = useForm<{ title: string; description?: string }>({ + mode: "onSubmit", + resolver: yupResolver(schema), + values: { + title: statusParams?.title ?? "", + description: statusParams?.description ?? "", + }, + }); + + const onSubmit = () => { + EditReportParameterModal.close(); + onModify(getFormValues()); + }; + + return createPortal( + +
+ { + setFormValue("title", defaultStatuses[status].title); + }} + /> + } + state={errors.title ? "error" : "default"} + stateRelatedMessage={errors?.title?.message} + nativeInputProps={{ + ...register("title"), + }} + /> + +
+
, + document.body + ); +}; + +export { EditReportParameterModal, EditReportStatusDialog }; diff --git a/assets/espaceco/pages/communities/management/reports/EditThemeDialog.tsx b/assets/espaceco/pages/communities/management/reports/EditThemeDialog.tsx new file mode 100644 index 00000000..3589b89e --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/EditThemeDialog.tsx @@ -0,0 +1,137 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Input from "@codegouvfr/react-dsfr/Input"; +import { createModal } from "@codegouvfr/react-dsfr/Modal"; +import ToggleSwitch from "@codegouvfr/react-dsfr/ToggleSwitch"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { FC, useMemo } from "react"; +import { createPortal } from "react-dom"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; +import { ThemeDTO } from "../../../../../@types/espaceco"; +import { useTranslation } from "../../../../../i18n/i18n"; + +export type EditThemeFormType = { + theme: string; + table?: string; + global?: boolean; + help?: string; +}; + +type EditThemeDialogProps = { + themes: ThemeDTO[]; + currentTheme?: ThemeDTO; + onModify: (oldName: string, newTheme: EditThemeFormType) => void; +}; + +const EditThemeDialogModal = createModal({ + id: "edit-theme", + isOpenedByDefault: false, +}); + +const EditThemeDialog: FC = ({ themes, currentTheme, onModify }) => { + const { t: tCommon } = useTranslation("Common"); + const { t } = useTranslation("Theme"); + + const themeNames: string[] = useMemo(() => { + return Array.from( + themes.filter((t) => t.theme !== currentTheme?.theme), + (t) => t.theme + ); + }, [themes, currentTheme]); + + const schema = yup.object({ + theme: yup + .string() + .required(t("dialog.edit_theme.name_mandatory_error")) + .test("is-unique", t("dialog.edit_theme.name_unique_error"), (value) => !themeNames.includes(value)), + table: yup.string(), + help: yup.string(), + global: yup.boolean(), + }); + + const { + register, + formState: { errors }, + setValue: setFormValue, + getValues: getFormValues, + handleSubmit, + } = useForm({ + mode: "onSubmit", + values: { + theme: currentTheme?.theme ?? "", + table: currentTheme?.database ? `${currentTheme?.database}:${currentTheme?.table}` : "", + global: currentTheme?.global === undefined ? false : currentTheme?.global, + help: currentTheme?.help, + }, + resolver: yupResolver(schema), + }); + + const onSubmit = () => { + EditThemeDialogModal.close(); + if (currentTheme) { + const values = getFormValues(); + onModify(currentTheme?.theme, values); + } + }; + + return ( + <> + {createPortal( + +
+

{tCommon("mandatory_fields")}

+ + + {currentTheme?.table ? ( +
+ ) : ( + { + setFormValue("global", checked); + }} + /> + )} +
+ , + document.body + )} + + ); +}; + +export { EditThemeDialogModal, EditThemeDialog }; diff --git a/assets/espaceco/pages/communities/management/reports/EmailPlanners.tsx b/assets/espaceco/pages/communities/management/reports/EmailPlanners.tsx new file mode 100644 index 00000000..36485b1c --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/EmailPlanners.tsx @@ -0,0 +1,296 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Alert from "@codegouvfr/react-dsfr/Alert"; +import Button from "@codegouvfr/react-dsfr/Button"; +import Table from "@codegouvfr/react-dsfr/Table"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { FC, ReactNode, useMemo, useState } from "react"; +import { UseFormReturn, useWatch } from "react-hook-form"; +import { EmailPlannerAddType, ReportFormType } from "../../../../../@types/app_espaceco"; +import { EmailPlannerDTO, ReportStatusesDTO, ThemeDTO } from "../../../../../@types/espaceco"; +import { ConfirmDialog, ConfirmDialogModal } from "../../../../../components/Utils/ConfirmDialog"; +import LoadingText from "../../../../../components/Utils/LoadingText"; +import Wait from "../../../../../components/Utils/Wait"; +import { declareComponentKeys, Translations, useTranslation } from "../../../../../i18n/i18n"; +import RQKeys from "../../../../../modules/espaceco/RQKeys"; +import { CartesApiException } from "../../../../../modules/jsonFetch"; +import api from "../../../../api"; +import { AddEmailPlannerDialog, AddEmailPlannerDialogModal } from "./emailplanners/AddEmailPlannerDialog"; +import { EditEmailPlannerDialog, EditEmailPlannerDialogModal } from "./emailplanners/EditEmailPlannerDialog"; + +type EmailPlannersProps = { + communityId: number; + form: UseFormReturn; + emailPlanners: EmailPlannerDTO[]; +}; + +const checkStatus = (ep: EmailPlannerDTO, statuses: ReportStatusesDTO) => { + const condition = ep.condition ?? { status: [] }; + if (!("status" in condition)) { + return true; + } + // Verification du status + for (const s of condition["status"]) { + if (!(s in statuses)) { + return false; + } + } + + return true; +}; + +const checkThemes = (ep: EmailPlannerDTO, themeNames: string[]) => { + const epThemes = ep.themes ?? []; + for (const theme of epThemes) { + if (!themeNames.includes(theme)) { + return false; + } + } + return true; +}; + +const EmailPlanners: FC = ({ communityId, form, emailPlanners }) => { + const { t: tmc } = useTranslation("ManageCommunity"); + const { t } = useTranslation("EmailPlanners"); + + const { control } = form; + const themes: ThemeDTO[] = useWatch({ control, name: "attributes" }); + const reportStatuses: ReportStatusesDTO = useWatch({ control, name: "report_statuses" }); + + // Les status actifs + const activeStatuses = useMemo(() => { + return Object.keys(reportStatuses).reduce((accumulator, status) => { + if (reportStatuses[status].active) accumulator[status] = reportStatuses[status]; + return accumulator; + }, {}); + }, [reportStatuses]); + + // Les thèmes du groupe + const themeNames = useMemo(() => themes.map((t) => t.theme), [themes]); + + const [currentEmailPlanner, setCurrentEmailPlanner] = useState(); + + const queryClient = useQueryClient(); + + // Ajout d'un email de suivi + const addPlannerMutation = useMutation({ + mutationFn: (data) => { + return api.emailplanner.add(communityId, data); + }, + onSuccess: (planner) => { + setCurrentEmailPlanner(undefined); + queryClient.setQueryData(RQKeys.emailPlanners(communityId), (oldPlanners: EmailPlannerDTO[]) => { + const emailPlanners = [...oldPlanners]; + emailPlanners.push(planner); + return emailPlanners; + }); + }, + }); + + // Mise à jour d'un email de suivi + const updatePlannerMutation = useMutation({ + mutationFn: (data) => { + if (currentEmailPlanner) { + return api.emailplanner.update(communityId, currentEmailPlanner.id, data); + } + return Promise.reject(); + }, + onSuccess: (planner) => { + setCurrentEmailPlanner(undefined); + queryClient.setQueryData(RQKeys.emailPlanners(communityId), (oldPlanners: EmailPlannerDTO[]) => { + const emailPlanners = [...oldPlanners].filter((ep) => ep.id !== currentEmailPlanner?.id); + emailPlanners.push(planner); + return emailPlanners; + }); + }, + }); + + // Suppression d'un email de suivi + const removePlannerMutation = useMutation<{ emailplanner_id: number }, CartesApiException, number>({ + mutationFn: (emailplannerId) => { + return api.emailplanner.remove(communityId, emailplannerId!); + }, + onSuccess: (data) => { + setCurrentEmailPlanner(undefined); + queryClient.setQueryData(RQKeys.emailPlanners(communityId), () => { + return emailPlanners?.filter((ep) => ep.id !== data.emailplanner_id); + }); + }, + onSettled: () => setCurrentEmailPlanner(undefined), + }); + + const datas = useMemo(() => { + return emailPlanners.map((ep) => { + // Verification du status et des themes + const statusOk: boolean = checkStatus(ep, activeStatuses); + const themesOK: boolean = checkThemes(ep, themeNames); + + const tools: ReactNode = ( +
+
+ ); + + const data: ReactNode[] = [ + ep.event, + ep.subject, + ep.body, + ep.delay, + ep.recipients.join(", "), + ep.cancel_event, + new Boolean(ep.repeat).toString(), + tools, + ]; + data.unshift( + !statusOk || !themesOK ? ( +
+ + addPlannerMutation.mutate(values)} /> + { + if (currentEmailPlanner) { + updatePlannerMutation.mutate(data); + } + }} + /> + { + if (currentEmailPlanner) { + removePlannerMutation.mutate(currentEmailPlanner.id); + } + }} + /> + + ); +}; + +export default EmailPlanners; + +// traductions +export const { i18n } = declareComponentKeys< + | "event_header" + | "subject_header" + | "body_header" + | "delay_header" + | "recipients_header" + | "cancel_event_header" + | "repeat_header" + | "add" + | "adding" + | "modify" + | "modifying" + | "remove" + | "removing" + | "confirm_remove_title" +>()("EmailPlanners"); + +export const EmailPlannersFrTranslations: Translations<"fr">["EmailPlanners"] = { + event_header: "Evénement déclencheur", + subject_header: "Sujet de l’email", + body_header: "Corps de l’email", + delay_header: "Délai en jours (aprés l’évènement déclencheur)", + recipients_header: "Destinataires", + cancel_event_header: "Evénement annulateur", + repeat_header: "Répétition", + add: "Ajouter un email de suivi", + adding: "Ajout de l'email de suivi en cours ...", + modify: "Modifier l'email de suivi", + modifying: "Modification de l'email de suivi en cours ...", + remove: "Supprimer l'email de suivi", + removing: "Suppression de l'email de suivi en cours ...", + confirm_remove_title: "Êtes-vous sûr de vouloir supprimer cet email de suivi ?", +}; + +export const EmailPlannersEnTranslations: Translations<"en">["EmailPlanners"] = { + event_header: undefined, + subject_header: undefined, + body_header: undefined, + delay_header: undefined, + recipients_header: undefined, + cancel_event_header: undefined, + repeat_header: undefined, + add: undefined, + adding: undefined, + modify: undefined, + modifying: undefined, + remove: undefined, + removing: undefined, + confirm_remove_title: undefined, +}; diff --git a/assets/espaceco/pages/communities/management/reports/Permissions.tsx b/assets/espaceco/pages/communities/management/reports/Permissions.tsx new file mode 100644 index 00000000..5fd86d35 --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/Permissions.tsx @@ -0,0 +1,42 @@ +import Checkbox from "@codegouvfr/react-dsfr/Checkbox"; +import { FC } from "react"; +import { UseFormReturn } from "react-hook-form"; +import { ReportFormType } from "../../../../../@types/app_espaceco"; +import { useTranslation } from "../../../../../i18n/i18n"; +import { fr } from "@codegouvfr/react-dsfr"; + +type PermissionsProps = { + form: UseFormReturn; +}; + +const Permissions: FC = ({ form }) => { + const { t } = useTranslation("ManageCommunity"); + + const { + register, + formState: { errors }, + } = form; + + return ( +
+

{t("report.manage_permissions")}

+ { + return { + label: t("report.manage_permissions.shared_report.option", { option: option }), + nativeInputProps: { + ...register("shared_georem"), + value: option, + }, + }; + })} + state={errors.shared_themes ? "error" : "default"} + stateRelatedMessage={errors?.shared_themes?.message} + /> +
+ ); +}; + +export default Permissions; diff --git a/assets/espaceco/pages/communities/management/reports/ReportStatuses.tsx b/assets/espaceco/pages/communities/management/reports/ReportStatuses.tsx new file mode 100644 index 00000000..ea8f8b53 --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/ReportStatuses.tsx @@ -0,0 +1,107 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Button from "@codegouvfr/react-dsfr/Button"; +import Checkbox from "@codegouvfr/react-dsfr/Checkbox"; +import { FC, useState } from "react"; +import { UseFormReturn } from "react-hook-form"; +import { ReportFormType } from "../../../../../@types/app_espaceco"; +import { ReportStatusesType } from "../../../../../@types/espaceco"; +import { useTranslation } from "../../../../../i18n/i18n"; +import { EditReportParameterModal, EditReportStatusDialog } from "./EditReportStatusDialog"; +import { statusesAlwaysActive } from "./Utils"; + +type ReportStatusesProps = { + form: UseFormReturn; + state?: "default" | "error" | "success"; +}; + +// const minStatuses = getMinAuthorizedStatus(); + +const ReportStatuses: FC = ({ form, state }) => { + const { t: tStatus } = useTranslation("ReportStatuses"); + const { t } = useTranslation("ManageCommunity"); + + const { + watch, + register, + setValue: setFormValue, + formState: { errors }, + } = form; + const statuses = watch("report_statuses"); + + const [currentStatus, setCurrentStatus] = useState(); + + // Changement d'etat d'un checkbox + /*const handleOnChange = (status: string, checked: boolean) => { + const v = { ...statuses }; + const num = countActiveStatus(v); + if ((!checked && num > minStatuses) || checked) { + v[status].active = checked; + } + setFormValue("report_statuses", v); + }; */ + + return ( +
+

{t("report.configure_statuses")}

+ {t("report.configure_statuses.explain")} +
+ { + const label = ( +
+ {statuses[s].title} +
+ ); + return { + label: label, + nativeInputProps: { + ...register(`report_statuses.${s}.active`), + disabled: statusesAlwaysActive.includes(s), + }, + }; + })} + /> +
+ {state !== "default" && ( +

{ + switch (state) { + case "error": + return "fr-error-text"; + case "success": + return "fr-valid-text"; + } + })() + )} + > + {errors.report_statuses?.root?.message} +

+ )} + { + if (currentStatus) { + const v = { ...statuses }; + v[currentStatus] = { ...v[currentStatus], ...values }; + setFormValue("report_statuses", v); + } + }} + /> +
+ ); +}; + +export default ReportStatuses; diff --git a/assets/espaceco/pages/communities/management/reports/ReportStatusesTr.tsx b/assets/espaceco/pages/communities/management/reports/ReportStatusesTr.tsx new file mode 100644 index 00000000..4d759c3a --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/ReportStatusesTr.tsx @@ -0,0 +1,23 @@ +import { declareComponentKeys, Translations } from "../../../../../i18n/i18n"; + +export const { i18n } = declareComponentKeys<"parameter" | "title" | "description" | "description_placeholder" | "back_to_default" | "min_statuses">()( + "ReportStatuses" +); + +export const ReportStatusesFrTranslations: Translations<"fr">["ReportStatuses"] = { + parameter: "Paramétrer", + title: "Titre", + description: "Description", + description_placeholder: "Entrer le texte d'aide pour vos utilisateurs.", + back_to_default: "Revenir à la valeur par défault", + min_statuses: "Vous pouvez supprimer un maximum de 2 statuts", +}; + +export const ReportStatusesEnTranslations: Translations<"en">["ReportStatuses"] = { + parameter: "Parameter", + title: "Title", + description: "Description", + description_placeholder: "Enter help text for your users.", + back_to_default: "Go back to default value", + min_statuses: "You can delete a maximum of 2 statuses", +}; diff --git a/assets/espaceco/pages/communities/management/reports/SetSharedThemesDialog.tsx b/assets/espaceco/pages/communities/management/reports/SetSharedThemesDialog.tsx new file mode 100644 index 00000000..30be1b91 --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/SetSharedThemesDialog.tsx @@ -0,0 +1,128 @@ +import { createModal } from "@codegouvfr/react-dsfr/Modal"; +import { FC, useMemo } from "react"; +import { useTranslation } from "../../../../../i18n/i18n"; +import { createPortal } from "react-dom"; +import * as yup from "yup"; +import { useForm } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import Checkbox from "@codegouvfr/react-dsfr/Checkbox"; +import { fr } from "@codegouvfr/react-dsfr"; +import { SharedThemesDTO } from "../../../../../@types/espaceco"; + +export type UserSharedThemesType = Record; +export type SharedThemesType = Record; + +const SetSharedThemesDialogModal = createModal({ + id: "set-shared-themes", + isOpenedByDefault: false, +}); + +type SetSharedThemesDialogProps = { + userSharedThemes: UserSharedThemesType; + sharedThemes: SharedThemesType; + onApply: (values: SharedThemesDTO[]) => void; +}; + +type formType = { + shared_themes: Record; +}; + +const SetSharedThemesDialog: FC = ({ userSharedThemes, sharedThemes, onApply }) => { + const { t: tCommon } = useTranslation("Common"); + const { t } = useTranslation("SharedThemes"); + + const schema = yup + .object({ + shared_themes: yup.lazy(() => { + const ret = {}; + Object.keys(userSharedThemes).forEach((community) => { + ret[community] = yup.array().of(yup.string()).required(); + }); + return yup.object().shape(ret).required(); + }), + }) + .required(); + + const defaultValues = useMemo(() => { + const def: formType["shared_themes"] = {}; + + Object.keys(userSharedThemes).forEach((community) => { + const themes: string[] = userSharedThemes[community].themes.filter((th) => { + return community in sharedThemes && sharedThemes[community].includes(th); + }); + def[community] = themes; + }); + return { shared_themes: def }; + }, [userSharedThemes, sharedThemes]); + + const { + watch, + register, + getValues: getFormValues, + handleSubmit, + } = useForm({ + mode: "onSubmit", + resolver: yupResolver(schema), + values: defaultValues, + }); + + const shared = watch("shared_themes"); + + const onSubmit = () => { + SetSharedThemesDialogModal.close(); + const values = getFormValues("shared_themes"); + + const sharedThemes: SharedThemesDTO[] = []; + Object.keys(values).forEach((community) => { + if (values[community].length) { + const communityName = userSharedThemes[community].communityName; + sharedThemes.push({ community_id: Number(community), community_name: communityName, themes: values[community] }); + } + }); + onApply(sharedThemes); + }; + + return createPortal( + +
+ {Object.keys(userSharedThemes).map((community) => { + const options = userSharedThemes[community].themes.map((theme) => ({ + label: theme, + nativeInputProps: { + value: theme, + checked: shared[community].includes(theme), + // @ts-expect-error ??? + ...register(`shared_themes.${community}`), + }, + })); + return ( + + ); + })} +
+
, + document.body + ); +}; + +export { SetSharedThemesDialogModal, SetSharedThemesDialog }; diff --git a/assets/espaceco/pages/communities/management/reports/SharedThemes.tsx b/assets/espaceco/pages/communities/management/reports/SharedThemes.tsx new file mode 100644 index 00000000..3a9f1e5d --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/SharedThemes.tsx @@ -0,0 +1,135 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Button from "@codegouvfr/react-dsfr/Button"; +import { CSSProperties, FC } from "react"; +import { UseFormReturn } from "react-hook-form"; +import { ReportFormType } from "../../../../../@types/app_espaceco"; +import { declareComponentKeys, Translations, useTranslation } from "../../../../../i18n/i18n"; +import { SetSharedThemesDialogModal, SetSharedThemesDialog, UserSharedThemesType, SharedThemesType } from "./SetSharedThemesDialog"; +import { SharedThemesDTO } from "../../../../../@types/espaceco"; + +type SharedThemesProps = { + form: UseFormReturn; + userSharedThemes: UserSharedThemesType; +}; + +const style: CSSProperties = { + backgroundColor: fr.colors.decisions.background.contrast.grey.default, +}; + +const SharedThemes: FC = ({ form, userSharedThemes }) => { + const { t: tmc } = useTranslation("ManageCommunity"); + const { t } = useTranslation("SharedThemes"); + + const { watch, setValue: setFormValue } = form; + const sharedThemes = watch("shared_themes") ?? []; + + const workingSharedThemes: SharedThemesType = {}; + sharedThemes.forEach((st) => (workingSharedThemes[st.community_id] = st.themes)); + + const handleRemoveCommunity = (communityId: number) => { + const v = sharedThemes.filter((st) => st.community_id !== communityId); + setFormValue("shared_themes", v); + }; + + const handleRemoveTheme = (communityId: number, theme: string) => { + const result: SharedThemesDTO[] = []; + sharedThemes.forEach((st) => { + if (st.community_id === communityId) { + const shTheme = { ...st, themes: st.themes.filter((th) => th !== theme) }; + if (shTheme.themes.length) { + result.push(shTheme); + } + } else result.push(st); + }); + setFormValue("shared_themes", result); + }; + + return ( +
+

{tmc("report.configure_shared_themes")}

+ {tmc("report.configure_shared_themes.explain")} +
+ + {sharedThemes?.map((st) => ( +
+
+
{st.community_name}
+
+
+
+
+
+
+
+
    + {st.themes.map((theme) => ( +
  • +
    +
    {theme}
    +
    +
    +
    +
    +
    +
  • + ))} +
+
+
+
+ ))} +
+ {workingSharedThemes && ( + setFormValue("shared_themes", sharedThemes)} + /> + )} +
+ ); +}; + +export default SharedThemes; + +export const { i18n } = declareComponentKeys< + { K: "delete_community"; P: { text: string }; R: string } | { K: "delete_theme"; P: { text: string }; R: string } | "manage" | "dialog.title" +>()("SharedThemes"); + +export const SharedThemesFrTranslations: Translations<"fr">["SharedThemes"] = { + delete_community: ({ text }) => `Supprimer tous les thèmes de la communauté [${text}]`, + delete_theme: ({ text }) => `Remove theme [${text}]`, + manage: "Gérer", + "dialog.title": "Sélectionner les thèmes partagés à afficher", +}; + +export const SharedThemesEnTranslations: Translations<"en">["SharedThemes"] = { + delete_community: ({ text }) => `Remove all themes of the community [${text}]`, + delete_theme: ({ text }) => `Supprimer le thème [${text}]`, + manage: "Manage", + "dialog.title": undefined, +}; diff --git a/assets/espaceco/pages/communities/management/reports/ThemeList.tsx b/assets/espaceco/pages/communities/management/reports/ThemeList.tsx new file mode 100644 index 00000000..b26e2616 --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/ThemeList.tsx @@ -0,0 +1,133 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Button from "@codegouvfr/react-dsfr/Button"; +import { cx } from "@codegouvfr/react-dsfr/tools/cx"; +import { CSSProperties, FC, useState } from "react"; +import { UseFormReturn } from "react-hook-form"; +import { ReportFormType } from "../../../../../@types/app_espaceco"; +import { TableResponseDTO, ThemeDTO } from "../../../../../@types/espaceco"; +import { useTranslation } from "../../../../../i18n/i18n"; +import { AddThemeDialog, AddThemeDialogModal } from "./AddThemeDialog"; +import AttributeList from "./AttributeList"; +import { EditThemeDialog, EditThemeDialogModal } from "./EditThemeDialog"; +import ThemesHelper from "./ThemesHelper"; + +const customStyle: CSSProperties = { + border: "solid 1.5px", + borderColor: fr.colors.decisions.border.actionHigh.blueFrance.default, +}; + +const themeStyle: CSSProperties = { + backgroundColor: fr.colors.decisions.background.contrast.grey.default, +}; + +type ThemeListProps = { + form: UseFormReturn; + tables: Partial[]; + state?: "default" | "error" | "success"; +}; + +const ThemeList: FC = ({ form, tables, state }) => { + const { t } = useTranslation("ManageCommunity"); + const { t: tTheme } = useTranslation("Theme"); + + const { watch, setValue: setFormValue } = form; + const themes: ThemeDTO[] = watch("attributes"); + + const [currentTheme, setCurrentTheme] = useState(); + + // Supression d'un theme + const handleRemoveTheme = (theme: string) => { + const th = ThemesHelper.removeTheme(theme, themes); + setFormValue("attributes", th); + }; + + return ( +
+

{t("report.configure_themes")}

+ {t("report.configure_themes.explain")} +
+ + {themes.map((t) => { + return ( +
+
+
+ {t.theme} + {t.table && ( + + + + )} + {t.global !== undefined && t.global === true && ( + + + + )} +
+
+
+
+
+
+ {!t.table && } +
+ ); + })} +
+ {state !== "default" && ( +

{ + switch (state) { + case "error": + return "fr-error-text"; + case "success": + return "fr-valid-text"; + } + })() + )} + > + {tTheme("attributes_not_conform")} +

+ )} + { + const th = ThemesHelper.updateTheme(oldName, newTheme, themes); + setFormValue("attributes", th); + }} + /> + { + const th = ThemesHelper.addTheme(themes, theme); + setFormValue("attributes", th); + }} + /> +
+ ); +}; + +export default ThemeList; diff --git a/assets/espaceco/pages/communities/management/reports/ThemeTr.tsx b/assets/espaceco/pages/communities/management/reports/ThemeTr.tsx new file mode 100644 index 00000000..45cb519a --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/ThemeTr.tsx @@ -0,0 +1,198 @@ +import { declareComponentKeys, Translations } from "../../../../../i18n/i18n"; + +// traductions +export const { i18n } = declareComponentKeys< + | "add_theme" + | "add_attribute" + | "trimmed_error" + | "attributes_not_conform" + | { K: "modify_theme"; P: { text: string }; R: string } + | { K: "delete_theme"; P: { text: string }; R: string } + | { K: "modify_attribute"; P: { text: string }; R: string } + | { K: "delete_attribute"; P: { text: string }; R: string } + | "dialog.add_theme.name" + | "dialog.add_theme.name_mandatory_error" + | "dialog.add_theme.name_unique_error" + | "dialog.add_theme.help" + | "dialog.add_theme.help_hint" + | "dialog.add_theme.link_to_table" + | "dialog.add_theme.link_to_table_hint" + | "dialog.add_theme.not_link" + | "dialog.add_theme.global" + | "dialog.add_theme.global_hint" + | "dialog.edit_theme.name" + | "dialog.edit_theme.name_hint" + | "dialog.edit_theme.name_mandatory_error" + | "dialog.edit_theme.name_unique_error" + | "dialog.edit_theme.help" + | "dialog.edit_theme.global" + | "dialog.edit_theme.global_hint" + | "dialog.add_attribute.name" + | "dialog.add_attribute.mandatory" + | "dialog.add_attribute.name" + | "dialog.add_attribute.name_mandatory_error" + | "dialog.add_attribute.name_unique_error" + | "dialog.add_attribute.mandatory" + | "dialog.add_attribute.type" + | { K: "dialog.add_attribute.get_type"; P: { type: string }; R: string } + | "dialog.add_attribute.list.multiple" + | "dialog.add_attribute.list.values" + | "dialog.add_attribute.value" + | "dialog.add_attribute.value.not_a_valid_integer" + | "dialog.add_attribute.value.not_a_valid_double" + | "dialog.add_attribute.value.not_a_valid_checkbox" + | "dialog.add_attribute.value.not_a_valid_date" + | "dialog.add_attribute.type_list_not_empty_error" + | "dialog.add_attribute.list_duplicates_error" + | "dialog.add_attribute.value_not_in_list_error" + | "dialog.add_attribute.description" + | "dialog.edit_attribute.name" + | "dialog.edit_attribute.name_mandatory_error" + | "dialog.edit_attribute.name_unique_error" + | "dialog.edit_attribute.mandatory" + | "dialog.edit_attribute.list.multiple" + | "dialog.edit_attribute.list.values" + | "dialog.edit_attribute.value" + | "dialog.edit_attribute.description" +>()("Theme"); + +export const ThemeFrTranslations: Translations<"fr">["Theme"] = { + add_theme: "Ajouter un thème", + add_attribute: "Ajouter un attribut", + trimmed_error: "La chaîne de caractères ne doit contenir aucun espace en début et fin", + attributes_not_conform: "Les attributs ne sont pas conformes", + modify_theme: ({ text }) => `Modifier le thème [${text}]`, + delete_theme: ({ text }) => `Supprimer le thème [${text}]`, + modify_attribute: ({ text }) => `Modifier l'attribut [${text}]`, + delete_attribute: ({ text }) => `Supprimer l'attribut [${text}]`, + "dialog.add_theme.name": "Nom du thème", + "dialog.add_theme.name_mandatory_error": "Le nom du thème est obligatoire", + "dialog.add_theme.name_unique_error": "Le nom doit être unique", + "dialog.add_theme.help": "Texte d'aide (optionnel)", + "dialog.add_theme.help_hint": "Cette description aidera les membres à décrire plus précisément leur signalement", + "dialog.add_theme.link_to_table": "Lier le thème à une base de données et à une table", + "dialog.add_theme.link_to_table_hint": "Un thème lié à une base de données et à une table ne pourra pas être modifié", + "dialog.add_theme.not_link": "Ne pas lier à une table", + "dialog.add_theme.global": "Partage", + "dialog.add_theme.global_hint": "Partager ce thème le rendra visible et sélectionnable pour des signalement non liés à des guichets", + "dialog.edit_theme.name": "Nouveau nom", + "dialog.edit_theme.name_hint": + "Attention, si vous modifiez le nom du thème, les utilisateurs devront recocher le thème dans leur profil pour y avoir accès.", + "dialog.edit_theme.name_mandatory_error": "Le nom du thème est obligatoire", + "dialog.edit_theme.name_unique_error": "Le nom doit être unique", + "dialog.edit_theme.help": "Nouvelle description (optionnel)", + "dialog.edit_theme.global": "Partage", + "dialog.edit_theme.global_hint": "Partager ce thème le rendra visible et sélectionnable pour des signalement non liés à des guichets", + "dialog.add_attribute.name": "Nom de l'attribut", + "dialog.add_attribute.mandatory": "Attribut obligatoire", + "dialog.add_attribute.name_mandatory_error": "Le nom de l'attribut est obligatoire", + "dialog.add_attribute.name_unique_error": "Le nom doit être unique", + "dialog.add_attribute.type": "Type", + "dialog.add_attribute.get_type": ({ type }) => { + switch (type) { + case "text": + return "Texte"; + case "integer": + return "Entier"; + case "double": + return "Double"; + case "checkbox": + return "Case à cocher"; + case "list": + return "Liste déroulante"; + case "date": + return "Date"; + default: + return ""; + } + }, + "dialog.add_attribute.list.multiple": "Choix multiple", + "dialog.add_attribute.list.values": "Valeurs (à séparer par des '|')", + "dialog.add_attribute.value": "Valeur par défaut (optionnel sauf pour le type Liste)", + "dialog.add_attribute.value.not_a_valid_integer": "La valeur n'est pas un entier valide", + "dialog.add_attribute.value.not_a_valid_double": "La valeur n'est pas un double valide", + "dialog.add_attribute.value.not_a_valid_checkbox": "La valeur doit être 0 ou 1", + "dialog.add_attribute.value.not_a_valid_date": "La valeur n'est pas une date valide", + "dialog.add_attribute.type_list_not_empty_error": "La liste de valeurs ne doit pas être vide", + "dialog.add_attribute.list_duplicates_error": "Il y a des valeurs en double dans la liste", + "dialog.add_attribute.value_not_in_list_error": "La valeur doit être dans la liste", + "dialog.add_attribute.description": "Description (optionnel)", + "dialog.edit_attribute.name": "Nouveau nom", + "dialog.edit_attribute.name_mandatory_error": "Le nom de l'attribut est obligatoire", + "dialog.edit_attribute.name_unique_error": "Le nom doit être unique", + "dialog.edit_attribute.mandatory": "Attribut obligatoire", + "dialog.edit_attribute.list.multiple": "Choix multiple", + "dialog.edit_attribute.list.values": "Valeurs (à séparer par des '|')", + "dialog.edit_attribute.value": "Valeur par défaut (optionnel sauf pour le type Liste)", + "dialog.edit_attribute.description": "Nouvelle description", +}; + +export const ThemeEnTranslations: Translations<"en">["Theme"] = { + add_theme: "Add Theme", + add_attribute: undefined, + trimmed_error: undefined, + attributes_not_conform: undefined, + modify_theme: ({ text }) => `Modify theme [${text}]`, + delete_theme: ({ text }) => `Delete theme [${text}]`, + modify_attribute: ({ text }) => `Modify attribute [${text}]`, + delete_attribute: ({ text }) => `Delete attribute [${text}]`, + "dialog.add_theme.name": undefined, + "dialog.add_theme.name_mandatory_error": undefined, + "dialog.add_theme.name_unique_error": undefined, + "dialog.add_theme.help": undefined, + "dialog.add_theme.help_hint": undefined, + "dialog.add_theme.link_to_table": undefined, + "dialog.add_theme.link_to_table_hint": undefined, + "dialog.add_theme.not_link": undefined, + "dialog.add_theme.global": undefined, + "dialog.add_theme.global_hint": undefined, + "dialog.edit_theme.name": undefined, + "dialog.edit_theme.name_hint": undefined, + "dialog.edit_theme.name_mandatory_error": undefined, + "dialog.edit_theme.name_unique_error": undefined, + "dialog.edit_theme.help": undefined, + "dialog.edit_theme.global": undefined, + "dialog.edit_theme.global_hint": undefined, + "dialog.add_attribute.name": undefined, + "dialog.add_attribute.mandatory": undefined, + "dialog.add_attribute.name_mandatory_error": undefined, + "dialog.add_attribute.name_unique_error": undefined, + "dialog.add_attribute.type": undefined, + "dialog.add_attribute.get_type": ({ type }) => { + switch (type) { + case "text": + return "Text"; + case "integer": + return "Integer"; + case "double": + return "Double"; + case "checkbox": + return "Checkbox"; + case "list": + return "List"; + case "date": + return "Date"; + default: + return ""; + } + }, + "dialog.add_attribute.list.multiple": undefined, + "dialog.add_attribute.list.values": undefined, + "dialog.add_attribute.value": undefined, + "dialog.add_attribute.value.not_a_valid_integer": undefined, + "dialog.add_attribute.value.not_a_valid_double": undefined, + "dialog.add_attribute.value.not_a_valid_checkbox": undefined, + "dialog.add_attribute.value.not_a_valid_date": undefined, + "dialog.add_attribute.type_list_not_empty_error": undefined, + "dialog.add_attribute.list_duplicates_error": undefined, + "dialog.add_attribute.value_not_in_list_error": undefined, + "dialog.add_attribute.description": undefined, + "dialog.edit_attribute.name": undefined, + "dialog.edit_attribute.name_mandatory_error": undefined, + "dialog.edit_attribute.name_unique_error": undefined, + "dialog.edit_attribute.mandatory": undefined, + "dialog.edit_attribute.list.multiple": undefined, + "dialog.edit_attribute.list.values": undefined, + "dialog.edit_attribute.value": undefined, + "dialog.edit_attribute.description": undefined, +}; diff --git a/assets/espaceco/pages/communities/management/reports/ThemeUtils.tsx b/assets/espaceco/pages/communities/management/reports/ThemeUtils.tsx new file mode 100644 index 00000000..d4f450e7 --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/ThemeUtils.tsx @@ -0,0 +1,53 @@ +import { AttributeDTO, AttributeType, ThemeDTO } from "../../../../../@types/espaceco"; + +export type AddOrEditAttributeFormType = { + name: string; + type: string; + mandatory?: boolean; + default?: string | null; + help?: string | null; + multiple?: boolean; + values?: string | null; +}; + +const normalizeTheme = (theme: ThemeDTO): ThemeDTO => { + const result = { ...theme }; + ["global", "help"].forEach((f) => { + if (f in result && !result[f]) { + delete result[f]; + } + }); + return result; +}; + +const normalizeAttribute = (attribute: AddOrEditAttributeFormType): AttributeDTO => { + const result: AttributeDTO = { + name: attribute.name, + type: attribute.type, + }; + + if (attribute.type === "list") { + result.values = attribute.values?.split("|") ?? []; + } else if (attribute.default !== "") { + result.default = attribute.default === "" ? null : attribute.default; + } + + if (attribute.help) { + result.help = attribute.help; + } + + ["mandatory", "multiple"].forEach((f) => { + if (attribute[f]) { + result[f] = attribute[f]; + } + }); + + return result; +}; + +/* Recuperation de input type à partir de type */ +const getInputType = (type?: AttributeType) => { + return type === "date" ? "date" : "text"; +}; + +export { normalizeTheme, normalizeAttribute, getInputType }; diff --git a/assets/espaceco/pages/communities/management/reports/ThemesHelper.tsx b/assets/espaceco/pages/communities/management/reports/ThemesHelper.tsx new file mode 100644 index 00000000..14cfffcd --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/ThemesHelper.tsx @@ -0,0 +1,53 @@ +import { AttributeDTO, ThemeDTO } from "../../../../../@types/espaceco"; +import { normalizeTheme } from "./ThemeUtils"; + +export default class ThemesHelper { + static addTheme(themes: ThemeDTO[], theme: ThemeDTO): ThemeDTO[] { + return [...themes, ...[theme]]; + } + + static updateTheme(name: string, newTheme: Partial, themes: ThemeDTO[]): ThemeDTO[] { + const tm = [...themes]; + return tm.map((t) => (name === t.theme ? normalizeTheme({ ...t, ...newTheme }) : t)); + } + + static removeTheme(name: string, themes: ThemeDTO[]): ThemeDTO[] { + return themes.filter((a) => a.theme !== name); + } + + static addAttribute(theme: string, attribute: AttributeDTO, themes: ThemeDTO[]): ThemeDTO[] { + return Array.from(themes, (t) => { + if (t.theme === theme) { + const attr = [...t.attributes]; + attr.push(attribute); + return { ...t, attributes: attr }; + } + return t; + }); + } + + static updateAttribute(theme: string, attribute: string, newAttribute: AttributeDTO, themes: ThemeDTO[]): ThemeDTO[] { + return Array.from(themes, (t) => { + if (t.theme === theme) { + const newAttributes = Array.from(t.attributes, (a) => { + if (a.name === attribute) { + return newAttribute; + } + return a; + }); + t.attributes = newAttributes; + } + return t; + }); + } + + static removeAttribute(theme: string, attribute: string, themes: ThemeDTO[]): ThemeDTO[] { + return Array.from(themes, (t) => { + if (t.theme === theme) { + const attr = t.attributes.filter((a) => a.name !== attribute); + return { ...t, attributes: attr }; + } + return t; + }); + } +} diff --git a/assets/espaceco/pages/communities/management/reports/Utils.tsx b/assets/espaceco/pages/communities/management/reports/Utils.tsx new file mode 100644 index 00000000..2dcd1e0a --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/Utils.tsx @@ -0,0 +1,24 @@ +import { ReportStatusesDTO } from "../../../../../@types/espaceco"; +import statuses from "../../../../../data/report_statuses.json"; + +const getDefaultStatuses = (): ReportStatusesDTO => { + const result = {}; + Object.keys(statuses).forEach((s) => { + result[s] = { title: statuses[s], active: true }; + }); + return result as ReportStatusesDTO; +}; + +const getMinAuthorizedStatus = (): number => { + return Object.keys(statuses).length - 2; +}; + +const countActiveStatus = (statuses: ReportStatusesDTO) => { + let c = 0; + Object.keys(statuses).forEach((s) => (c += statuses[s].active ? 1 : 0)); + return c; +}; + +const statusesAlwaysActive = ["submit", "valid"]; + +export { getDefaultStatuses, getMinAuthorizedStatus, countActiveStatus, statusesAlwaysActive }; diff --git a/assets/espaceco/pages/communities/management/reports/emailplanners/AddEmailPlannerDialog.tsx b/assets/espaceco/pages/communities/management/reports/emailplanners/AddEmailPlannerDialog.tsx new file mode 100644 index 00000000..37742f64 --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/emailplanners/AddEmailPlannerDialog.tsx @@ -0,0 +1,167 @@ +import { createModal } from "@codegouvfr/react-dsfr/Modal"; +import RadioButtons from "@codegouvfr/react-dsfr/RadioButtons"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { FC, useCallback, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { Controller, useForm } from "react-hook-form"; +import isEmail from "validator/lib/isEmail"; +import { BasicRecipientsArray, EmailPlannerAddType, EmailPlannerFormType, EmailPlannerTypes } from "../../../../../../@types/app_espaceco"; +import { CancelEventType, ReportStatusesDTO, TriggerEventType } from "../../../../../../@types/espaceco"; +import AutocompleteSelect from "../../../../../../components/Input/AutocompleteSelect"; +import { useTranslation } from "../../../../../../i18n/i18n"; +import { getAddDefaultValues } from "./Defaults"; +import PersonalEmailPlanner from "./PersonalEmailPlanner"; +import { getBasicSchema, getPersonalSchema } from "./schemas"; + +const AddEmailPlannerDialogModal = createModal({ + id: "add-emailplanner", + isOpenedByDefault: false, +}); + +type AddEmailPlannerDialogProps = { + themes: string[]; + statuses: ReportStatusesDTO; + onAdd: (values: EmailPlannerAddType) => void; +}; + +const AddEmailPlannerDialog: FC = ({ themes, statuses, onAdd }) => { + const { t: tCommon } = useTranslation("Common"); + const { t } = useTranslation("AddOrEditEmailPlanner"); + + const [type, setType] = useState<"basic" | "personal">("basic"); + + const schemas = {}; + schemas["basic"] = getBasicSchema(); + schemas["personal"] = getPersonalSchema(themes, statuses); + + const form = useForm({ + mode: "onChange", + resolver: yupResolver(schemas[type]), + defaultValues: getAddDefaultValues(type), + }); + const { + control, + formState: { errors }, + getValues: getFormValues, + reset, + handleSubmit, + } = form; + + useEffect(() => { + reset(getAddDefaultValues(type)); + }, [reset, type]); + + const resetForm = useCallback(() => { + /* si type est déjà basic, setType ne déclenchera pas le useEffect précédent + et ne fera donc pas un reset du formulaire avec les valeurs par défaut */ + type === "basic" ? reset(getAddDefaultValues(type)) : setType("basic"); + }, [type, reset]); + + const onSubmit = () => { + const values = getFormValues(); + + let form: EmailPlannerAddType = { + subject: values.subject, + event: values.event as TriggerEventType, + cancel_event: values.cancel_event as CancelEventType, + body: values.body, + recipients: values.recipients, + themes: values.themes ?? [], + condition: null, + delay: values.delay, + repeat: values.repeat, + }; + + if (values.event === "georem_status_changed") { + const statuses = values.statuses ?? []; + if (statuses.length) { + form = { ...form, condition: { status: statuses } }; + } + } + + onAdd(form); + + resetForm(); + AddEmailPlannerDialogModal.close(); + }; + + return ( + <> + {createPortal( + { + resetForm(); + AddEmailPlannerDialogModal.close(); + }, + }, + { + children: tCommon("add"), + priority: "primary", + doClosesModal: false, + onClick: handleSubmit(onSubmit), + }, + ]} + > +
+
{t("choose_email_type")}
+ ({ + label: t("email_planner_type", { type: ept }), + nativeInputProps: { + onChange: () => setType(ept), + checked: type === ept, + }, + }))} + /> +
+

{tCommon("mandatory_fields")}

+ {type === "basic" ? ( + <> +
{t("dialog.recipients")}
+ ( + { + return option === value; + }} + searchFilter={{ limit: 10 }} + onChange={(_, value) => { + if (value && Array.isArray(value)) { + value = value.filter((v) => { + if (BasicRecipientsArray.includes(v)) return true; + return isEmail(v); + }); + onChange(value); + } + }} + value={value} + /> + )} + /> + + ) : ( + + )} +
+
, + document.body + )} + + ); +}; + +export { AddEmailPlannerDialog, AddEmailPlannerDialogModal }; diff --git a/assets/espaceco/pages/communities/management/reports/emailplanners/AddOrEditEmailPlannerTr.tsx b/assets/espaceco/pages/communities/management/reports/emailplanners/AddOrEditEmailPlannerTr.tsx new file mode 100644 index 00000000..fc83f329 --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/emailplanners/AddOrEditEmailPlannerTr.tsx @@ -0,0 +1,144 @@ +import { ReactNode } from "react"; +import { CancelEventType, RecipientType, TriggerEventType } from "../../../../../../@types/espaceco"; +import { declareComponentKeys, Translations } from "../../../../../../i18n/i18n"; + +// traductions +export const { i18n } = declareComponentKeys< + | { K: "title"; P: { edit: boolean }; R: string } + | "choose_email_type" + | { K: "email_planner_type"; P: { type: string }; R: string } + | { K: "trigger_event"; P: { event: TriggerEventType }; R: string } + | { K: "cancel_event"; P: { event: CancelEventType }; R: string } + | { K: "recipient"; P: { name: RecipientType }; R: string } + | "dialog.title_1" + | "dialog.trigger_event" + | "dialog.themes" + | "dialog.themes_hint" + | "dialog.status" + | "dialog.delay" + | "dialog.delay_hint" + | "dialog.title_2" + | "dialog.cancel_event" + | "dialog.cancel_event_hint" + | "dialog.repeat" + | "dialog.repeat_hint" + | "dialog.recipients" + | "dialog.title_4" + | "dialog.title_4_explain" + | "dialog.subject" + | "dialog.body" + | { K: "dialog.body_explain"; R: ReactNode } + | "dialog.body.keywords" + | "validation.subject.mandatory" + | "validation.body.mandatory" + | "validation.delay.mandatory" + | "validation.delay.positive" + | "validation.themes.mandatory" + | "validation.condition.mandatory" + | { K: "validation.error.email_not_valid"; P: { value: string }; R: string } + | "validation.error.email.min" +>()("AddOrEditEmailPlanner"); + +export const AddOrEditEmailPlannerFrTranslations: Translations<"fr">["AddOrEditEmailPlanner"] = { + title: ({ edit }) => `${edit ? "Modification de l'email de suivi" : "Ajout d'un email de suivi"}`, + choose_email_type: "Choisir le type d’email de suivi à ajouter", + email_planner_type: ({ type }) => `${type === "basic" ? "Email basique pré-configuré" : "Email personnel à configurer"}`, + trigger_event: ({ event }) => { + switch (event) { + case "georem_created": + return "Création d'un signalement"; + case "georem_status_changed": + return "Modification du statut"; + } + }, + cancel_event: ({ event }) => { + switch (event) { + case "none": + return "Aucun"; + case "georem_status_changed": + return "Modification du statut"; + } + }, + recipient: ({ name }) => { + switch (name) { + case "Auteur": + return "Auteur du signalement"; + case "Gestionnaire": + return "Gestionnaire du guichet"; + case "Majec": + return "Majec (collecteur IGN de la zone géographique dans laquelle se trouve le signalement)"; + } + }, + "dialog.title_1": "Conditions d'envoi", + "dialog.trigger_event": "Evénement déclencheur", + "dialog.themes": "Thèmes", + "dialog.themes_hint": "Si le signalement concerne un des thèmes (il est possible d'en sélectionner plusieurs)", + "dialog.status": "Si le statut devient : ", + "dialog.delay": "Délai (en jours après l'événement déclencheur)", + "dialog.delay_hint": "Un email sera envoyé x jours plus tard (décalé si c'est un jour férié ou tombe un week-end).", + "dialog.title_2": "Conditions d'annulation", + "dialog.cancel_event": "Evénement annulateur", + "dialog.cancel_event_hint": "Si cet événement se produit avant l'expiration du délai, l'envoi de l'email est annulé.", + "dialog.repeat": "Répétition", + "dialog.repeat_hint": "Si oui, le même email sera replanifié après envoi en appliquant le même délai", + "dialog.recipients": "Destinataires de l'email", + "dialog.title_4": "Contenu de l'email", + "dialog.title_4_explain": "Ne pas mettre de formules de politesse, celles-ci seront ajoutées automatiquement lors de l'envoi.", + "dialog.subject": "Sujet de l'email à envoyer", + "dialog.body": "Corps de l'email à envoyer", + "dialog.body_explain": ( + <> + { + "Cet espace permet de tester les fonctions d’alimentation et de diffusion de la Géoplateforme. Les services publiés dans cet espace ne sont pas visibles sur le catalogue." + } +
+
+ {"Cliquez sur les mots clés pour insérer dans le corps de l'email"} + + ), + "dialog.body.keywords": "Cliquez sur les mots clés pour insérer dans le corps de l'email", + "validation.subject.mandatory": "Le sujet de l'email est obligatoire", + "validation.body.mandatory": "Le corps de l'email est obligatoire", + "validation.delay.mandatory": "Le délai est obligatoire", + "validation.delay.positive": "Le délai doit être supérieur à 0", + "validation.themes.mandatory": "Les thèmes sont obligatoires", + "validation.condition.mandatory": "Vous devez sélectionner au moins un statut si l'évènement déclencheur est [Modification du statut]", + "validation.error.email_not_valid": ({ value }) => `La chaîne ${value} n'est pas un email valide`, + "validation.error.email.min": "Il doit y avoir au moins un email destinataire", +}; + +export const AddOrEditEmailPlannerEnTranslations: Translations<"en">["AddOrEditEmailPlanner"] = { + title: ({ edit }) => `${edit}`, + choose_email_type: undefined, + email_planner_type: ({ type }) => `${type}`, + trigger_event: ({ event }) => `${event}`, + cancel_event: ({ event }) => `${event}`, + recipient: ({ name }) => `${name}`, + "dialog.title_1": undefined, + "dialog.trigger_event": undefined, + "dialog.themes": undefined, + "dialog.themes_hint": undefined, + "dialog.status": undefined, + "dialog.delay": undefined, + "dialog.delay_hint": undefined, + "dialog.title_2": undefined, + "dialog.cancel_event": undefined, + "dialog.cancel_event_hint": undefined, + "dialog.repeat": undefined, + "dialog.repeat_hint": undefined, + "dialog.recipients": undefined, + "dialog.title_4": undefined, + "dialog.title_4_explain": undefined, + "dialog.subject": undefined, + "dialog.body": undefined, + "dialog.body_explain": undefined, + "dialog.body.keywords": undefined, + "validation.subject.mandatory": "Email subject is mandatory", + "validation.body.mandatory": "Email body is mandatory", + "validation.delay.mandatory": undefined, + "validation.delay.positive": undefined, + "validation.themes.mandatory": undefined, + "validation.condition.mandatory": undefined, + "validation.error.email_not_valid": ({ value }) => `String ${value} is not a valid email`, + "validation.error.email.min": undefined, +}; diff --git a/assets/espaceco/pages/communities/management/reports/emailplanners/Defaults.tsx b/assets/espaceco/pages/communities/management/reports/emailplanners/Defaults.tsx new file mode 100644 index 00000000..349cc1ec --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/emailplanners/Defaults.tsx @@ -0,0 +1,37 @@ +import { EmailPlannerFormType, EmailPlannerType } from "../../../../../../@types/app_espaceco"; +import { EmailPlannerDTO } from "../../../../../../@types/espaceco"; + +const getAddDefaultValues = (type: EmailPlannerType): EmailPlannerFormType => { + return { + event: "georem_created", + delay: 1, + cancel_event: "georem_status_changed", + repeat: false, + subject: type === "personal" ? "" : "Nouveau signalement", + body: type === "personal" ? "" : "Le signalement n° _id_ a été envoyé le _openingDate_ par _author_", + recipients: [], + themes: [], + }; +}; + +const getEditDefaultValues = (emailPlanner: EmailPlannerDTO): EmailPlannerFormType => { + let statuses: string[] = []; + if (emailPlanner.condition && "status" in emailPlanner.condition) { + statuses = emailPlanner.condition["status"]; + } + + return { + id: emailPlanner.id, + event: emailPlanner.event, + delay: emailPlanner.delay, + cancel_event: emailPlanner.cancel_event, + repeat: emailPlanner.repeat, + recipients: emailPlanner.recipients, + subject: emailPlanner.subject, + body: emailPlanner.body, + statuses: statuses, + themes: emailPlanner.themes ?? [], + }; +}; + +export { getAddDefaultValues, getEditDefaultValues }; diff --git a/assets/espaceco/pages/communities/management/reports/emailplanners/EditEmailPlannerDialog.tsx b/assets/espaceco/pages/communities/management/reports/emailplanners/EditEmailPlannerDialog.tsx new file mode 100644 index 00000000..22fece11 --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/emailplanners/EditEmailPlannerDialog.tsx @@ -0,0 +1,94 @@ +import { createModal } from "@codegouvfr/react-dsfr/Modal"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { FC } from "react"; +import { createPortal } from "react-dom"; +import { useForm } from "react-hook-form"; +import { EmailPlannerAddType, EmailPlannerFormType } from "../../../../../../@types/app_espaceco"; +import { CancelEventType, EmailPlannerDTO, ReportStatusesDTO, TriggerEventType } from "../../../../../../@types/espaceco"; +import { useTranslation } from "../../../../../../i18n/i18n"; +import { getAddDefaultValues, getEditDefaultValues } from "./Defaults"; +import PersonalEmailPlanner from "./PersonalEmailPlanner"; +import { getPersonalSchema } from "./schemas"; + +const EditEmailPlannerDialogModal = createModal({ + id: "edit-emailplanner", + isOpenedByDefault: false, +}); + +type EditEmailPlannerDialogProps = { + emailPlanner?: EmailPlannerDTO; + themes: string[]; + statuses: ReportStatusesDTO; + onModify: (values: EmailPlannerAddType) => void; +}; + +const EditEmailPlannerDialog: FC = ({ emailPlanner, themes, statuses, onModify }) => { + const { t: tCommon } = useTranslation("Common"); + const { t } = useTranslation("AddOrEditEmailPlanner"); + + const schema = getPersonalSchema(themes, statuses); + + const form = useForm({ + mode: "onSubmit", + values: emailPlanner ? getEditDefaultValues(emailPlanner) : getAddDefaultValues("personal"), + resolver: yupResolver(schema), + }); + + const { handleSubmit, getValues: getFormValues } = form; + + const onSubmit = () => { + const values = getFormValues(); + + let form: EmailPlannerAddType = { + subject: values.subject, + event: values.event as TriggerEventType, + cancel_event: values.cancel_event as CancelEventType, + body: values.body, + recipients: values.recipients, + themes: values.themes ?? [], + condition: null, + delay: values.delay, + repeat: values.repeat, + }; + + if (values.event === "georem_status_changed") { + const statuses = values.statuses ?? []; + if (statuses.length) { + form = { ...form, condition: { status: statuses } }; + } + } + + onModify(form); + EditEmailPlannerDialogModal.close(); + }; + + return ( + <> + {createPortal( + +
+ +
+
, + document.body + )} + + ); +}; +export { EditEmailPlannerDialog, EditEmailPlannerDialogModal }; diff --git a/assets/espaceco/pages/communities/management/reports/emailplanners/EmailPlannerKeywords.tsx b/assets/espaceco/pages/communities/management/reports/emailplanners/EmailPlannerKeywords.tsx new file mode 100644 index 00000000..cad1067e --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/emailplanners/EmailPlannerKeywords.tsx @@ -0,0 +1,97 @@ +import { ICommand } from "@uiw/react-md-editor"; +import { declareComponentKeys, getTranslation, Translations } from "../../../../../../i18n/i18n"; + +const { t } = getTranslation("EmailPlannerKeywords"); + +const emailPlannerKeywords = ["id", "author", "group", "comment", "status", "openingDate", "updatingDate", "closingDate", "validator"] as const; +type KeywordsType = (typeof emailPlannerKeywords)[number]; + +const getKeywordsExtraCommands = (): ICommand[] => { + const extraCommands: ICommand[] = emailPlannerKeywords.map((keyword) => { + return { + name: keyword, + keyCommand: keyword, + render: (command, disabled, executeCommand) => { + return ( + + ); + }, + execute: (_, api) => { + const text = `_${keyword}_`; + api.replaceSelection(text); + }, + }; + }); + + extraCommands.unshift({ keyCommand: "divider" }); + return extraCommands; +}; + +export default getKeywordsExtraCommands; + +// traductions +export const { i18n } = declareComponentKeys< + { K: "getTitle"; P: { keyword: KeywordsType }; R: string } | { K: "getText"; P: { keyword: KeywordsType }; R: string } +>()("EmailPlannerKeywords"); + +export const EmailPlannerKeywordsFrTranslations: Translations<"fr">["EmailPlannerKeywords"] = { + getTitle: ({ keyword }) => { + switch (keyword) { + case "author": + return "Insérer le nom de l'auteur du signalement"; + case "closingDate": + return "Insérer la date de validation du signalement"; + case "comment": + return "Insérer le commentaire du signalement"; + case "group": + return "Insérer le nom du guichet associé au signalement"; + case "id": + return "Insérer l'identifiant du signalement"; + case "openingDate": + return "Insérer la date de création du signalement"; + case "status": + return "Insérer le statut du signalement"; + case "updatingDate": + return "Insérer la date de modification du signalement"; + case "validator": + return "Insérer l'identifiant du validateur "; + } + }, + getText: ({ keyword }) => { + switch (keyword) { + case "author": + return "Auteur"; + case "closingDate": + return "Date de validation"; + case "comment": + return "Commentaire"; + case "group": + return "Guichet"; + case "id": + return "Identifiant"; + case "openingDate": + return "Date de création"; + case "status": + return "Statut"; + case "updatingDate": + return "Date de modification"; + case "validator": + return "Validateur "; + } + }, +}; + +export const EmailPlannerKeywordsEnTranslations: Translations<"en">["EmailPlannerKeywords"] = { + getTitle: ({ keyword }) => `Insert ${keyword}`, + getText: ({ keyword }) => `${keyword}`, +}; diff --git a/assets/espaceco/pages/communities/management/reports/emailplanners/PersonalEmailPlanner.tsx b/assets/espaceco/pages/communities/management/reports/emailplanners/PersonalEmailPlanner.tsx new file mode 100644 index 00000000..c7df13a8 --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/emailplanners/PersonalEmailPlanner.tsx @@ -0,0 +1,231 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Input from "@codegouvfr/react-dsfr/Input"; +import Select from "@codegouvfr/react-dsfr/Select"; +import ToggleSwitch from "@codegouvfr/react-dsfr/ToggleSwitch"; +import { FC, useCallback, useEffect, useMemo } from "react"; +import { Controller, UseFormReturn, useWatch } from "react-hook-form"; +import isEmail from "validator/lib/isEmail"; +import { BasicRecipientsArray, EmailPlannerFormType } from "../../../../../../@types/app_espaceco"; +import { CancelEvents, ReportStatusesDTO, ReportStatusesType, TriggerEvents } from "../../../../../../@types/espaceco"; +import AutocompleteSelect from "../../../../../../components/Input/AutocompleteSelect"; +import MarkdownEditor from "../../../../../../components/Input/MarkdownEditor"; +import { useTranslation } from "../../../../../../i18n/i18n"; +import getKeywordsExtraCommands from "./EmailPlannerKeywords"; + +import "../../../../../../sass/components/react-md-editor.scss"; + +type PersonalEmailPlannerProps = { + form: UseFormReturn; + themes: string[]; + statuses: ReportStatusesDTO; +}; + +type StatusAutocompleteOption = { + status: ReportStatusesType; + title: string; +}; + +const PersonalEmailPlanner: FC = ({ form, themes, statuses }) => { + const { t } = useTranslation("AddOrEditEmailPlanner"); + + const { + control, + watch, + register, + formState: { errors }, + resetField, + setValue: setFormValue, + } = form; + + const event = watch("event"); + const repeat = useWatch({ + control: control, + name: "repeat", + }); + + useEffect(() => { + if (event === "georem_created") { + resetField("statuses", { defaultValue: [] }); + } + }, [resetField, event]); + + const options = useMemo(() => { + const options = Object.keys(statuses).reduce((accumulator, status) => { + accumulator[status] = { title: statuses[status].title, status: status }; + return accumulator; + }, {}); + return Object.values(options); + }, [statuses]); + + const getValue = useCallback( + (value?: string[]) => { + if (!value) return []; + + const options = Object.keys(statuses).reduce((accumulator, status) => { + if (value.includes(status)) { + accumulator[status] = { title: statuses[status].title, status: status }; + } + return accumulator; + }, {}); + return Object.values(options); + }, + [statuses] + ); + + return ( +
+ +
{t("dialog.title_1")}
+ + { + return ( + field.onChange(value)} + value={field.value} + /> + ); + }} + /> + {event === "georem_status_changed" && ( + { + return ( + (option as StatusAutocompleteOption).title} + isOptionEqualToValue={(option, value) => { + const optionStatus = (option as StatusAutocompleteOption).status; + const valueStatus = (value as StatusAutocompleteOption).status; + return optionStatus === valueStatus; + }} + searchFilter={{ limit: 10 }} + onChange={(_, value) => { + const selected = value as StatusAutocompleteOption[]; + field.onChange(selected.map((s) => s.status)); + }} + value={getValue(field.value)} + /> + ); + }} + /> + )} + +
{t("dialog.title_2")}
+ + setFormValue("repeat", checked)} + /> +
{t("dialog.recipients")}
+ ( + { + return option === value; + }} + searchFilter={{ limit: 10 }} + onChange={(_, value) => { + if (value && Array.isArray(value)) { + value = value.filter((v) => { + if (BasicRecipientsArray.includes(v)) return true; + return isEmail(v); + }); + onChange(value); + } + }} + value={value} + /> + )} + /> +
{t("dialog.title_4")}
+ ( + { + field.onChange(values); + }} + /> + )} + /> +
+ ); +}; + +export default PersonalEmailPlanner; diff --git a/assets/espaceco/pages/communities/management/reports/emailplanners/schemas.tsx b/assets/espaceco/pages/communities/management/reports/emailplanners/schemas.tsx new file mode 100644 index 00000000..d8d89251 --- /dev/null +++ b/assets/espaceco/pages/communities/management/reports/emailplanners/schemas.tsx @@ -0,0 +1,92 @@ +import isEmail from "validator/lib/isEmail"; +import * as yup from "yup"; +import { BasicRecipientsArray } from "../../../../../../@types/app_espaceco"; +import { CancelEvents, ReportStatusesDTO, TriggerEvents } from "../../../../../../@types/espaceco"; +import { getTranslation } from "../../../../../../i18n/i18n"; + +const { t } = getTranslation("AddOrEditEmailPlanner"); + +const cloneEvents = [...TriggerEvents] as string[]; +const cloneCancelEvents = [...CancelEvents] as string[]; + +const recipientsSchema = yup.object({ + recipients: yup + .array() + .of(yup.string().required()) + .test({ + name: "check", + test: (value, ctx) => { + if (value === undefined) return true; + if (!value.length) { + return ctx.createError({ message: t("validation.error.email.min") }); + } + for (const v of value) { + if (BasicRecipientsArray.includes(v)) continue; + if (!isEmail(v)) { + return ctx.createError({ message: t("validation.error.email_not_valid", { value: v }) }); + } + } + return true; + }, + }) + .required(), +}); + +const getBasicSchema = () => { + return recipientsSchema; +}; + +const getPersonalSchema = (themes: string[], statuses: ReportStatusesDTO) => { + return recipientsSchema.concat( + yup.object({ + subject: yup.string().required(t("validation.subject.mandatory")), + body: yup.string().required(t("validation.body.mandatory")), + delay: yup.number().min(1, t("validation.delay.positive")).required(t("validation.delay.mandatory")), + repeat: yup.boolean().required(), + event: yup.string().required().oneOf(cloneEvents), + cancel_event: yup + .string() + .required() + .oneOf([...cloneCancelEvents]), + statuses: yup + .array() + .of(yup.string().oneOf(Object.keys(statuses)).required()) + .test({ + name: "validate-status", + test: (value, context) => { + if (!value) return true; + const { + parent: { event }, + } = context; + + if (event === "georem_status_changed") { + if (value && value.length === 0) { + return context.createError({ message: t("validation.condition.mandatory") }); + } + } + + return true; + }, + }), + themes: yup + .array() + .of(yup.string().oneOf(themes).required()) + .test({ + name: "validate-status", + test: (value, context) => { + const v = value ?? []; + const { + parent: { event }, + } = context; + + if (event === "georem_created" && v.length === 0) { + return context.createError({ message: t("validation.themes.mandatory") }); + } + return true; + }, + }), + }) + ); +}; + +export { getBasicSchema, getPersonalSchema }; diff --git a/assets/espaceco/pages/communities/management/validationTr.tsx b/assets/espaceco/pages/communities/management/validationTr.tsx new file mode 100644 index 00000000..98561c76 --- /dev/null +++ b/assets/espaceco/pages/communities/management/validationTr.tsx @@ -0,0 +1,72 @@ +import { declareComponentKeys } from "i18nifty"; +import { Translations } from "../../../../i18n/i18n"; + +// traductions +export const { i18n } = declareComponentKeys< + | "trimmed_error" + | "description.name.mandatory" + | "description.name.unique" + | "description.name.minlength" + | "description.name.maxlength" + | "description.desc.mandatory" + | "description.desc.maxlength" + | "description.logo.size_error" + | "description.logo.dimensions_error" + | "description.logo.format_error" + | { K: "zoom.extent.nan"; P: { field: string }; R: string } + | { K: "zoom.extent.mandatory"; P: { field: string }; R: string } + | { K: "zoom.f1_less_than_f2"; P: { field1: string; field2: string }; R: string } + | { K: "zoom.less_than"; P: { field: string; v: number }; R: string } + | { K: "zoom.greater_than"; P: { field: string; v: number }; R: string } + | "zoom.extent.required" + | "description.modal.document.name.mandatory" + | "description.modal.document.name.minlength" + | "description.modal.document.file.mandatory" + | "description.modal.document.file.size_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.unique": "Ce nom existe déjà", + "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", + "zoom.extent.nan": ({ field }) => `${field} n'est pas un nombre`, + "zoom.extent.mandatory": ({ field }) => `La valeur ${field} est obligatoire`, + "zoom.f1_less_than_f2": ({ field1, field2 }) => `La valeur de ${field1} doit être inférieure à la valeur de ${field2}`, + "zoom.less_than": ({ field, v }) => `La valeur de ${field} doit être inférieure ou égale à ${v}`, + "zoom.greater_than": ({ field, v }) => `La valeur de ${field} doit être supérieure ou égale à ${v}`, + "zoom.extent.required": "La boîte englobante est obligatoire", + "description.modal.document.name.mandatory": "Le nom est obligatoire", + "description.modal.document.name.minlength": "Le nom doit faire au moins 10 caractères", + "description.modal.document.file.mandatory": "Le fichier est obligatoire", + "description.modal.document.file.size_error": "La taille du fichier ne peut excéder 5 Mo", +}; + +export const ManageCommunityValidationsEnTranslations: Translations<"en">["ManageCommunityValidations"] = { + trimmed_error: undefined, + "description.name.mandatory": undefined, + "description.name.unique": "Name already exists", + "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, + "zoom.extent.nan": ({ field }) => `${field} is not a number`, + "zoom.extent.mandatory": ({ field }) => `${field} value is mandatory`, + "zoom.f1_less_than_f2": ({ field1, field2 }) => `${field1} value must be less then ${field2} value`, + "zoom.less_than": ({ field, v }) => `${field} value must be less or equal to ${v}`, + "zoom.greater_than": ({ field, v }) => `${field} value must be greater or equal to ${v}`, + "zoom.extent.required": undefined, + "description.modal.document.name.mandatory": undefined, + "description.modal.document.name.minlength": undefined, + "description.modal.document.file.mandatory": undefined, + "description.modal.document.file.size_error": undefined, +}; diff --git a/assets/i18n/Breadcrumb.tsx b/assets/i18n/Breadcrumb.tsx index e9f133a4..ad74df34 100644 --- a/assets/i18n/Breadcrumb.tsx +++ b/assets/i18n/Breadcrumb.tsx @@ -40,6 +40,10 @@ export const { i18n } = declareComponentKeys< | "datastore_pyramid_vector_tms_service_new" | "datastore_pyramid_vector_tms_service_edit" | "datastore_service_view" + | "espaceco_community_list" + | "espaceco_member_invitation" + | "espaceco_create_community" + | { K: "espaceco_manage_community"; P: { communityName?: string }; R: string } >()("Breadcrumb"); export const BreadcrumbFrTranslations: Translations<"fr">["Breadcrumb"] = { @@ -82,6 +86,10 @@ export const BreadcrumbFrTranslations: Translations<"fr">["Breadcrumb"] = { datastore_pyramid_vector_tms_service_new: "Création d'un service TMS", datastore_pyramid_vector_tms_service_edit: "Modification d'un service TMS", datastore_service_view: "Prévisualisation d'un service", + espaceco_community_list: "Espace collaboratif", + espaceco_create_community: "Création d'un guichet", + espaceco_manage_community: ({ communityName }) => `Gérer le guichet ${communityName ?? ""}`, + espaceco_member_invitation: "Invitation", }; export const BreadcrumbEnTranslations: Translations<"en">["Breadcrumb"] = { @@ -124,4 +132,8 @@ export const BreadcrumbEnTranslations: Translations<"en">["Breadcrumb"] = { datastore_pyramid_vector_tms_service_new: "Create a TMS service", datastore_pyramid_vector_tms_service_edit: "Modify a TMS service", datastore_service_view: "Preview a service", + espaceco_community_list: "Collaborative space", + espaceco_create_community: "Create community", + espaceco_manage_community: ({ communityName }) => `Manage community ${communityName ?? ""}`, + espaceco_member_invitation: "Invitation", }; diff --git a/assets/i18n/Common.tsx b/assets/i18n/Common.tsx index 7e83649f..6a7a6639 100644 --- a/assets/i18n/Common.tsx +++ b/assets/i18n/Common.tsx @@ -6,12 +6,15 @@ export const { i18n } = declareComponentKeys< | "add" | "adding" | "modify" + | "apply" + | "record" | "modifying" | "removing" | "loading" | "continue" | "validate" | "submit" + | "save" | "copy" | "send" | "cancel" @@ -19,6 +22,8 @@ export const { i18n } = declareComponentKeys< | "see" | "yes" | "no" + | "accept" + | "reject" | "publish" | "unpublish" | "published" @@ -31,6 +36,8 @@ export const { i18n } = declareComponentKeys< | "next_step" | "url_copied" | "copy_to_clipboard" + | "download" + | "trimmed_error" >()("Common"); export const commonFrTranslations: Translations<"fr">["Common"] = { @@ -39,12 +46,15 @@ export const commonFrTranslations: Translations<"fr">["Common"] = { add: "Ajouter", adding: "Ajout en cours ...", modify: "Modifier", + apply: "Appliquer", + record: "Enregistrer", modifying: "Modification en cours ...", removing: "Suppression en cours ...", loading: "Chargement ...", continue: "Continuer", validate: "Valider", submit: "Soumettre", + save: "Sauvegarder", copy: "Copier", send: "Envoyer", cancel: "Annuler", @@ -52,6 +62,8 @@ export const commonFrTranslations: Translations<"fr">["Common"] = { see: "Consulter", yes: "Oui", no: "Non", + accept: "Accepter", + reject: "Refuser", publish: "Publier", unpublish: "Dépublier", published: "Publié", @@ -64,6 +76,8 @@ export const commonFrTranslations: Translations<"fr">["Common"] = { next_step: "Étape suivante", url_copied: "URL copiée", copy_to_clipboard: "Copier dans le presse-papier", + download: "Télécharger", + trimmed_error: "La chaîne de caractères ne doit contenir aucun espace en début et fin", }; export const commonEnTranslations: Translations<"en">["Common"] = { @@ -72,12 +86,15 @@ export const commonEnTranslations: Translations<"en">["Common"] = { add: "Add", adding: "Adding ...", modify: "Modify", + apply: "Apply", + record: "Record", modifying: "modifying ...", removing: "Removing ...", loading: "Loading ...", continue: "Continue", validate: "Validate", submit: "Submit", + save: "Save", copy: "Copy", send: "Send", cancel: "Cancel", @@ -85,6 +102,8 @@ export const commonEnTranslations: Translations<"en">["Common"] = { see: "Check", yes: "Yes", no: "No", + accept: "Accept", + reject: "Reject", publish: "Publish", unpublish: "Unpublish", published: "Published", @@ -97,4 +116,6 @@ export const commonEnTranslations: Translations<"en">["Common"] = { next_step: "Next step", url_copied: "URL copied", copy_to_clipboard: "Copier dans le presse-papier", + download: "Download", + trimmed_error: "The character string must not contain any spaces at the beginning and end", }; diff --git a/assets/i18n/i18n.ts b/assets/i18n/i18n.ts index 603425d5..fe2bfb85 100644 --- a/assets/i18n/i18n.ts +++ b/assets/i18n/i18n.ts @@ -20,6 +20,7 @@ export type ComponentKey = | typeof import("./Breadcrumb").i18n | typeof import("./Rights").i18n | typeof import("./Style").i18n + | typeof import("../components/Input/InputCollection").i18n | typeof import("../entrepot/pages/users/Me").i18n | typeof import("../entrepot/pages/communities/AddMember").i18n | typeof import("../entrepot/pages/communities/CommunityMembers").i18n @@ -50,7 +51,23 @@ 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/CreateCommunity.tr").i18n + | typeof import("../espaceco/pages/communities/ManageCommunityTr").i18n + | typeof import("../espaceco/pages/communities/management/validationTr").i18n + | typeof import("../espaceco/pages/communities/management/SearchTr").i18n + | typeof import("../espaceco/pages/communities/management/Description").i18n + | typeof import("../espaceco/pages/communities/management/Reports").i18n + | typeof import("../espaceco/pages/communities/management/reports/ThemeTr").i18n + | typeof import("../espaceco/pages/communities/management/reports/ReportStatusesTr").i18n + | typeof import("../espaceco/pages/communities/management/reports/SharedThemes").i18n + | typeof import("../espaceco/pages/communities/management/reports/EmailPlanners").i18n + | typeof import("../espaceco/pages/communities/management/reports/emailplanners/AddOrEditEmailPlannerTr").i18n + | typeof import("../espaceco/pages/communities/management/reports/emailplanners/EmailPlannerKeywords").i18n + | typeof import("../espaceco/pages/communities/management/Members").i18n + | typeof import("../espaceco/pages/communities/management/member/AddMembersDialog").i18n + | typeof import("../espaceco/pages/communities/management/member/ManageGridsDialog").i18n + | typeof import("../espaceco/pages/communities/MemberInvitation").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..8f5743d8 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,22 +24,41 @@ 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 { CreateCommunityEnTranslations } from "../../espaceco/pages/communities/CreateCommunity.tr"; +import { ManageCommunityEnTranslations } from "../../espaceco/pages/communities/ManageCommunityTr"; +import { ManageCommunityValidationsEnTranslations } from "../../espaceco/pages/communities/management/validationTr"; +import { MemberInvitationEnTranslations } from "../../espaceco/pages/communities/MemberInvitation"; +import { SearchEnTranslations } from "../../espaceco/pages/communities/management/SearchTr"; 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 { ThemeEnTranslations } from "../../espaceco/pages/communities/management/reports/ThemeTr"; +import { ReportStatusesEnTranslations } from "../../espaceco/pages/communities/management/reports/ReportStatusesTr"; +import { SharedThemesEnTranslations } from "../../espaceco/pages/communities/management/reports/SharedThemes"; +import { EscoCommunityMembersEnTranslations } from "../../espaceco/pages/communities/management/Members"; +import { AddMembersDialogEnTranslations } from "../../espaceco/pages/communities/management/member/AddMembersDialog"; +import { ManageGridsDialogEnTranslations } from "../../espaceco/pages/communities/management/member/ManageGridsDialog"; +import { DescriptionEnTranslations } from "../../espaceco/pages/communities/management/Description"; +import { ReportsEnTranslations } from "../../espaceco/pages/communities/management/Reports"; +import { EmailPlannersEnTranslations } from "../../espaceco/pages/communities/management/reports/EmailPlanners"; +import { AddOrEditEmailPlannerEnTranslations } from "../../espaceco/pages/communities/management/reports/emailplanners/AddOrEditEmailPlannerTr"; +import { EmailPlannerKeywordsEnTranslations } from "../../espaceco/pages/communities/management/reports/emailplanners/EmailPlannerKeywords"; +import { InputCollectionEnTranslations } from "../../components/Input/InputCollection"; + import type { Translations } from "../i18n"; -import { DatasheetUploadFormEnTranslations } from "../../entrepot/pages/datasheet/DatasheetNew/DatasheetUploadForm"; export const translations: Translations<"en"> = { Common: commonEnTranslations, Breadcrumb: BreadcrumbEnTranslations, Rights: RightsEnTranslations, Style: StyleEnTranslations, + InputCollection: InputCollectionEnTranslations, Me: MeEnTranslations, AddMember: AddMemberEnTranslations, CommunityMembers: CommunityMembersEnTranslations, @@ -67,8 +86,24 @@ export const translations: Translations<"en"> = { TableSelection: TableSelectionEnTranslations, UploadStyleFile: UploadStyleFileEnTranslations, PyramidVectorTmsServiceForm: PyramidVectorTmsServiceFormEnTranslations, - EspaceCoCommunities: EspaceCoCommunitiesEnTranslations, DatasheetUploadForm: DatasheetUploadFormEnTranslations, DatasheetList: DatasheetListEnTranslations, AccessRestrictions: AccessRestrictionsEnTranslations, + CommunityList: CommunityListEnTranslations, + CreateCommunity: CreateCommunityEnTranslations, + ManageCommunity: ManageCommunityEnTranslations, + ManageCommunityValidations: ManageCommunityValidationsEnTranslations, + MemberInvitation: MemberInvitationEnTranslations, + Theme: ThemeEnTranslations, + Description: DescriptionEnTranslations, + Reports: ReportsEnTranslations, + EmailPlanners: EmailPlannersEnTranslations, + AddOrEditEmailPlanner: AddOrEditEmailPlannerEnTranslations, + EmailPlannerKeywords: EmailPlannerKeywordsEnTranslations, + ReportStatuses: ReportStatusesEnTranslations, + SharedThemes: SharedThemesEnTranslations, + Search: SearchEnTranslations, + EscoCommunityMembers: EscoCommunityMembersEnTranslations, + AddMembersDialog: AddMembersDialogEnTranslations, + ManageGridsDialog: ManageGridsDialogEnTranslations, }; diff --git a/assets/i18n/languages/fr.tsx b/assets/i18n/languages/fr.tsx index 3ace06bb..dcf76c3a 100644 --- a/assets/i18n/languages/fr.tsx +++ b/assets/i18n/languages/fr.tsx @@ -24,15 +24,33 @@ 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 { CreateCommunityFrTranslations } from "../../espaceco/pages/communities/CreateCommunity.tr"; +import { ManageCommunityFrTranslations } from "../../espaceco/pages/communities/ManageCommunityTr"; +import { ManageCommunityValidationsFrTranslations } from "../../espaceco/pages/communities/management/validationTr"; +import { MemberInvitationFrTranslations } from "../../espaceco/pages/communities/MemberInvitation"; +import { SearchFrTranslations } from "../../espaceco/pages/communities/management/SearchTr"; 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 { DescriptionFrTranslations } from "../../espaceco/pages/communities/management/Description"; +import { ReportsFrTranslations } from "../../espaceco/pages/communities/management/Reports"; +import { EmailPlannersFrTranslations } from "../../espaceco/pages/communities/management/reports/EmailPlanners"; +import { AddOrEditEmailPlannerFrTranslations } from "../../espaceco/pages/communities/management/reports/emailplanners/AddOrEditEmailPlannerTr"; +import { EmailPlannerKeywordsFrTranslations } from "../../espaceco/pages/communities/management/reports/emailplanners/EmailPlannerKeywords"; +import { ThemeFrTranslations } from "../../espaceco/pages/communities/management/reports/ThemeTr"; +import { ReportStatusesFrTranslations } from "../../espaceco/pages/communities/management/reports/ReportStatusesTr"; +import { SharedThemesFrTranslations } from "../../espaceco/pages/communities/management/reports/SharedThemes"; +import { EscoCommunityMembersFrTranslations } from "../../espaceco/pages/communities/management/Members"; +import { AddMembersDialogFrTranslations } from "../../espaceco/pages/communities/management/member/AddMembersDialog"; +import { ManageGridsDialogFrTranslations } from "../../espaceco/pages/communities/management/member/ManageGridsDialog"; +import { InputCollectionFrTranslations } from "../../components/Input/InputCollection"; + import type { Translations } from "../i18n"; export const translations: Translations<"fr"> = { @@ -40,6 +58,7 @@ export const translations: Translations<"fr"> = { Breadcrumb: BreadcrumbFrTranslations, Rights: RightsFrTranslations, Style: StyleFrTranslations, + InputCollection: InputCollectionFrTranslations, Me: MeFrTranslations, AddMember: AddMemberFrTranslations, CommunityMembers: CommunityMembersFrTranslations, @@ -67,8 +86,24 @@ export const translations: Translations<"fr"> = { TableSelection: TableSelectionFrTranslations, UploadStyleFile: UploadStyleFileFrTranslations, PyramidVectorTmsServiceForm: PyramidVectorTmsServiceFormFrTranslations, - EspaceCoCommunities: EspaceCoCommunitiesFrTranslations, DatasheetUploadForm: DatasheetUploadFormFrTranslations, DatasheetList: DatasheetListFrTranslations, AccessRestrictions: AccessRestrictionsFrTranslations, + CommunityList: CommunityListFrTranslations, + ManageCommunity: ManageCommunityFrTranslations, + CreateCommunity: CreateCommunityFrTranslations, + ManageCommunityValidations: ManageCommunityValidationsFrTranslations, + MemberInvitation: MemberInvitationFrTranslations, + Reports: ReportsFrTranslations, + Description: DescriptionFrTranslations, + EmailPlanners: EmailPlannersFrTranslations, + AddOrEditEmailPlanner: AddOrEditEmailPlannerFrTranslations, + EmailPlannerKeywords: EmailPlannerKeywordsFrTranslations, + Theme: ThemeFrTranslations, + ReportStatuses: ReportStatusesFrTranslations, + SharedThemes: SharedThemesFrTranslations, + Search: SearchFrTranslations, + EscoCommunityMembers: EscoCommunityMembersFrTranslations, + AddMembersDialog: AddMembersDialogFrTranslations, + ManageGridsDialog: ManageGridsDialogFrTranslations, }; diff --git a/assets/img/punaise.png b/assets/img/punaise.png new file mode 100644 index 00000000..0e0786a4 Binary files /dev/null and b/assets/img/punaise.png differ diff --git a/assets/img/vignettes/7z.png b/assets/img/vignettes/7z.png new file mode 100644 index 00000000..603b701d Binary files /dev/null and b/assets/img/vignettes/7z.png differ diff --git a/assets/img/vignettes/abw.png b/assets/img/vignettes/abw.png new file mode 100644 index 00000000..1d4e3226 Binary files /dev/null and b/assets/img/vignettes/abw.png differ diff --git a/assets/img/vignettes/ai.png b/assets/img/vignettes/ai.png new file mode 100644 index 00000000..b7fa8f0d Binary files /dev/null and b/assets/img/vignettes/ai.png differ diff --git a/assets/img/vignettes/aiff.png b/assets/img/vignettes/aiff.png new file mode 100644 index 00000000..bade6331 Binary files /dev/null and b/assets/img/vignettes/aiff.png differ diff --git a/assets/img/vignettes/asf.png b/assets/img/vignettes/asf.png new file mode 100644 index 00000000..c4d738b2 Binary files /dev/null and b/assets/img/vignettes/asf.png differ diff --git a/assets/img/vignettes/avi.png b/assets/img/vignettes/avi.png new file mode 100644 index 00000000..c4d738b2 Binary files /dev/null and b/assets/img/vignettes/avi.png differ diff --git a/assets/img/vignettes/bin.png b/assets/img/vignettes/bin.png new file mode 100644 index 00000000..5dcd476e Binary files /dev/null and b/assets/img/vignettes/bin.png differ diff --git a/assets/img/vignettes/blend.png b/assets/img/vignettes/blend.png new file mode 100644 index 00000000..d525803e Binary files /dev/null and b/assets/img/vignettes/blend.png differ diff --git a/assets/img/vignettes/bmp.png b/assets/img/vignettes/bmp.png new file mode 100644 index 00000000..41c71675 Binary files /dev/null and b/assets/img/vignettes/bmp.png differ diff --git a/assets/img/vignettes/bz2.png b/assets/img/vignettes/bz2.png new file mode 100644 index 00000000..27776c90 Binary files /dev/null and b/assets/img/vignettes/bz2.png differ diff --git a/assets/img/vignettes/c.png b/assets/img/vignettes/c.png new file mode 100644 index 00000000..0f13b7b9 Binary files /dev/null and b/assets/img/vignettes/c.png differ diff --git a/assets/img/vignettes/crq.png b/assets/img/vignettes/crq.png new file mode 100644 index 00000000..dca327cc Binary files /dev/null and b/assets/img/vignettes/crq.png differ diff --git a/assets/img/vignettes/css.png b/assets/img/vignettes/css.png new file mode 100644 index 00000000..1a94efd4 Binary files /dev/null and b/assets/img/vignettes/css.png differ diff --git a/assets/img/vignettes/csv.png b/assets/img/vignettes/csv.png new file mode 100644 index 00000000..89479cdd Binary files /dev/null and b/assets/img/vignettes/csv.png differ diff --git a/assets/img/vignettes/deb.png b/assets/img/vignettes/deb.png new file mode 100644 index 00000000..5770e190 Binary files /dev/null and b/assets/img/vignettes/deb.png differ diff --git a/assets/img/vignettes/defaut.png b/assets/img/vignettes/defaut.png new file mode 100644 index 00000000..83de589b Binary files /dev/null and b/assets/img/vignettes/defaut.png differ diff --git a/assets/img/vignettes/djvu.png b/assets/img/vignettes/djvu.png new file mode 100644 index 00000000..2352a88d Binary files /dev/null and b/assets/img/vignettes/djvu.png differ diff --git a/assets/img/vignettes/doc.png b/assets/img/vignettes/doc.png new file mode 100644 index 00000000..6b0c91e6 Binary files /dev/null and b/assets/img/vignettes/doc.png differ diff --git a/assets/img/vignettes/docx.png b/assets/img/vignettes/docx.png new file mode 100644 index 00000000..6b0c91e6 Binary files /dev/null and b/assets/img/vignettes/docx.png differ diff --git a/assets/img/vignettes/dvi.png b/assets/img/vignettes/dvi.png new file mode 100644 index 00000000..5fe36239 Binary files /dev/null and b/assets/img/vignettes/dvi.png differ diff --git a/assets/img/vignettes/dxf.png b/assets/img/vignettes/dxf.png new file mode 100644 index 00000000..7aad1f2e Binary files /dev/null and b/assets/img/vignettes/dxf.png differ diff --git a/assets/img/vignettes/eps.png b/assets/img/vignettes/eps.png new file mode 100644 index 00000000..8dd016b5 Binary files /dev/null and b/assets/img/vignettes/eps.png differ diff --git a/assets/img/vignettes/flv.png b/assets/img/vignettes/flv.png new file mode 100644 index 00000000..2f565869 Binary files /dev/null and b/assets/img/vignettes/flv.png differ diff --git a/assets/img/vignettes/gif.png b/assets/img/vignettes/gif.png new file mode 100644 index 00000000..41c71675 Binary files /dev/null and b/assets/img/vignettes/gif.png differ diff --git a/assets/img/vignettes/gpx.png b/assets/img/vignettes/gpx.png new file mode 100644 index 00000000..f0659226 Binary files /dev/null and b/assets/img/vignettes/gpx.png differ diff --git a/assets/img/vignettes/gxt.png b/assets/img/vignettes/gxt.png new file mode 100644 index 00000000..cd8a732f Binary files /dev/null and b/assets/img/vignettes/gxt.png differ diff --git a/assets/img/vignettes/gz.png b/assets/img/vignettes/gz.png new file mode 100644 index 00000000..dd1f8a8c Binary files /dev/null and b/assets/img/vignettes/gz.png differ diff --git a/assets/img/vignettes/h.png b/assets/img/vignettes/h.png new file mode 100644 index 00000000..78941cac Binary files /dev/null and b/assets/img/vignettes/h.png differ diff --git a/assets/img/vignettes/html.png b/assets/img/vignettes/html.png new file mode 100644 index 00000000..e79e80a3 Binary files /dev/null and b/assets/img/vignettes/html.png differ diff --git a/assets/img/vignettes/jpg.png b/assets/img/vignettes/jpg.png new file mode 100644 index 00000000..41c71675 Binary files /dev/null and b/assets/img/vignettes/jpg.png differ diff --git a/assets/img/vignettes/kml.png b/assets/img/vignettes/kml.png new file mode 100644 index 00000000..ce6e436f Binary files /dev/null and b/assets/img/vignettes/kml.png differ diff --git a/assets/img/vignettes/kmz.png b/assets/img/vignettes/kmz.png new file mode 100644 index 00000000..ce6e436f Binary files /dev/null and b/assets/img/vignettes/kmz.png differ diff --git a/assets/img/vignettes/mid.png b/assets/img/vignettes/mid.png new file mode 100644 index 00000000..64a83e17 Binary files /dev/null and b/assets/img/vignettes/mid.png differ diff --git a/assets/img/vignettes/mka.png b/assets/img/vignettes/mka.png new file mode 100644 index 00000000..50665d39 Binary files /dev/null and b/assets/img/vignettes/mka.png differ diff --git a/assets/img/vignettes/mkv.png b/assets/img/vignettes/mkv.png new file mode 100644 index 00000000..c4d738b2 Binary files /dev/null and b/assets/img/vignettes/mkv.png differ diff --git a/assets/img/vignettes/mng.png b/assets/img/vignettes/mng.png new file mode 100644 index 00000000..c4d738b2 Binary files /dev/null and b/assets/img/vignettes/mng.png differ diff --git a/assets/img/vignettes/mov.png b/assets/img/vignettes/mov.png new file mode 100644 index 00000000..4a4df8cc Binary files /dev/null and b/assets/img/vignettes/mov.png differ diff --git a/assets/img/vignettes/mp3.png b/assets/img/vignettes/mp3.png new file mode 100644 index 00000000..50665d39 Binary files /dev/null and b/assets/img/vignettes/mp3.png differ diff --git a/assets/img/vignettes/mp4.png b/assets/img/vignettes/mp4.png new file mode 100644 index 00000000..c4d738b2 Binary files /dev/null and b/assets/img/vignettes/mp4.png differ diff --git a/assets/img/vignettes/mpg.png b/assets/img/vignettes/mpg.png new file mode 100644 index 00000000..c4d738b2 Binary files /dev/null and b/assets/img/vignettes/mpg.png differ diff --git a/assets/img/vignettes/odb.png b/assets/img/vignettes/odb.png new file mode 100644 index 00000000..9eee4c31 Binary files /dev/null and b/assets/img/vignettes/odb.png differ diff --git a/assets/img/vignettes/odc.png b/assets/img/vignettes/odc.png new file mode 100644 index 00000000..ec2c926f Binary files /dev/null and b/assets/img/vignettes/odc.png differ diff --git a/assets/img/vignettes/odf.png b/assets/img/vignettes/odf.png new file mode 100644 index 00000000..51cc1e82 Binary files /dev/null and b/assets/img/vignettes/odf.png differ diff --git a/assets/img/vignettes/odg.png b/assets/img/vignettes/odg.png new file mode 100644 index 00000000..823dc206 Binary files /dev/null and b/assets/img/vignettes/odg.png differ diff --git a/assets/img/vignettes/odi.png b/assets/img/vignettes/odi.png new file mode 100644 index 00000000..56923bb4 Binary files /dev/null and b/assets/img/vignettes/odi.png differ diff --git a/assets/img/vignettes/odm.png b/assets/img/vignettes/odm.png new file mode 100644 index 00000000..4a9986fa Binary files /dev/null and b/assets/img/vignettes/odm.png differ diff --git a/assets/img/vignettes/odp.png b/assets/img/vignettes/odp.png new file mode 100644 index 00000000..87ac0163 Binary files /dev/null and b/assets/img/vignettes/odp.png differ diff --git a/assets/img/vignettes/ods.png b/assets/img/vignettes/ods.png new file mode 100644 index 00000000..6e8b4965 Binary files /dev/null and b/assets/img/vignettes/ods.png differ diff --git a/assets/img/vignettes/odt.png b/assets/img/vignettes/odt.png new file mode 100644 index 00000000..150a637b Binary files /dev/null and b/assets/img/vignettes/odt.png differ diff --git a/assets/img/vignettes/ogg.png b/assets/img/vignettes/ogg.png new file mode 100644 index 00000000..7f02f486 Binary files /dev/null and b/assets/img/vignettes/ogg.png differ diff --git a/assets/img/vignettes/otg.png b/assets/img/vignettes/otg.png new file mode 100644 index 00000000..823dc206 Binary files /dev/null and b/assets/img/vignettes/otg.png differ diff --git a/assets/img/vignettes/otp.png b/assets/img/vignettes/otp.png new file mode 100644 index 00000000..87ac0163 Binary files /dev/null and b/assets/img/vignettes/otp.png differ diff --git a/assets/img/vignettes/ots.png b/assets/img/vignettes/ots.png new file mode 100644 index 00000000..6e8b4965 Binary files /dev/null and b/assets/img/vignettes/ots.png differ diff --git a/assets/img/vignettes/ott.png b/assets/img/vignettes/ott.png new file mode 100644 index 00000000..150a637b Binary files /dev/null and b/assets/img/vignettes/ott.png differ diff --git a/assets/img/vignettes/pas.png b/assets/img/vignettes/pas.png new file mode 100644 index 00000000..509011e9 Binary files /dev/null and b/assets/img/vignettes/pas.png differ diff --git a/assets/img/vignettes/pdf.png b/assets/img/vignettes/pdf.png new file mode 100644 index 00000000..b32187c3 Binary files /dev/null and b/assets/img/vignettes/pdf.png differ diff --git a/assets/img/vignettes/pgn.png b/assets/img/vignettes/pgn.png new file mode 100644 index 00000000..24e650fe Binary files /dev/null and b/assets/img/vignettes/pgn.png differ diff --git a/assets/img/vignettes/png.png b/assets/img/vignettes/png.png new file mode 100644 index 00000000..41c71675 Binary files /dev/null and b/assets/img/vignettes/png.png differ diff --git a/assets/img/vignettes/pps.png b/assets/img/vignettes/pps.png new file mode 100644 index 00000000..abe4b046 Binary files /dev/null and b/assets/img/vignettes/pps.png differ diff --git a/assets/img/vignettes/ppt.png b/assets/img/vignettes/ppt.png new file mode 100644 index 00000000..abe4b046 Binary files /dev/null and b/assets/img/vignettes/ppt.png differ diff --git a/assets/img/vignettes/pptx.png b/assets/img/vignettes/pptx.png new file mode 100644 index 00000000..abe4b046 Binary files /dev/null and b/assets/img/vignettes/pptx.png differ diff --git a/assets/img/vignettes/ps.png b/assets/img/vignettes/ps.png new file mode 100644 index 00000000..8dd016b5 Binary files /dev/null and b/assets/img/vignettes/ps.png differ diff --git a/assets/img/vignettes/psd.png b/assets/img/vignettes/psd.png new file mode 100644 index 00000000..cbe6cbef Binary files /dev/null and b/assets/img/vignettes/psd.png differ diff --git a/assets/img/vignettes/qt.png b/assets/img/vignettes/qt.png new file mode 100644 index 00000000..4a4df8cc Binary files /dev/null and b/assets/img/vignettes/qt.png differ diff --git a/assets/img/vignettes/ra.png b/assets/img/vignettes/ra.png new file mode 100644 index 00000000..b9daeab9 Binary files /dev/null and b/assets/img/vignettes/ra.png differ diff --git a/assets/img/vignettes/ram.png b/assets/img/vignettes/ram.png new file mode 100644 index 00000000..b9daeab9 Binary files /dev/null and b/assets/img/vignettes/ram.png differ diff --git a/assets/img/vignettes/rm.png b/assets/img/vignettes/rm.png new file mode 100644 index 00000000..b9daeab9 Binary files /dev/null and b/assets/img/vignettes/rm.png differ diff --git a/assets/img/vignettes/rpm.png b/assets/img/vignettes/rpm.png new file mode 100644 index 00000000..f3d73a53 Binary files /dev/null and b/assets/img/vignettes/rpm.png differ diff --git a/assets/img/vignettes/rtf.png b/assets/img/vignettes/rtf.png new file mode 100644 index 00000000..1d4e3226 Binary files /dev/null and b/assets/img/vignettes/rtf.png differ diff --git a/assets/img/vignettes/sdd.png b/assets/img/vignettes/sdd.png new file mode 100644 index 00000000..87ac0163 Binary files /dev/null and b/assets/img/vignettes/sdd.png differ diff --git a/assets/img/vignettes/sdw.png b/assets/img/vignettes/sdw.png new file mode 100644 index 00000000..150a637b Binary files /dev/null and b/assets/img/vignettes/sdw.png differ diff --git a/assets/img/vignettes/sit.png b/assets/img/vignettes/sit.png new file mode 100644 index 00000000..55a4f13f Binary files /dev/null and b/assets/img/vignettes/sit.png differ diff --git a/assets/img/vignettes/smil.png b/assets/img/vignettes/smil.png new file mode 100644 index 00000000..ef9bd0e6 Binary files /dev/null and b/assets/img/vignettes/smil.png differ diff --git a/assets/img/vignettes/spip.png b/assets/img/vignettes/spip.png new file mode 100644 index 00000000..837ce5af Binary files /dev/null and b/assets/img/vignettes/spip.png differ diff --git a/assets/img/vignettes/svg.png b/assets/img/vignettes/svg.png new file mode 100644 index 00000000..b155f066 Binary files /dev/null and b/assets/img/vignettes/svg.png differ diff --git a/assets/img/vignettes/swf.png b/assets/img/vignettes/swf.png new file mode 100644 index 00000000..009ee747 Binary files /dev/null and b/assets/img/vignettes/swf.png differ diff --git a/assets/img/vignettes/sxc.png b/assets/img/vignettes/sxc.png new file mode 100644 index 00000000..6e8b4965 Binary files /dev/null and b/assets/img/vignettes/sxc.png differ diff --git a/assets/img/vignettes/sxi.png b/assets/img/vignettes/sxi.png new file mode 100644 index 00000000..87ac0163 Binary files /dev/null and b/assets/img/vignettes/sxi.png differ diff --git a/assets/img/vignettes/sxw.png b/assets/img/vignettes/sxw.png new file mode 100644 index 00000000..150a637b Binary files /dev/null and b/assets/img/vignettes/sxw.png differ diff --git a/assets/img/vignettes/tex.png b/assets/img/vignettes/tex.png new file mode 100644 index 00000000..5fe36239 Binary files /dev/null and b/assets/img/vignettes/tex.png differ diff --git a/assets/img/vignettes/tgz.png b/assets/img/vignettes/tgz.png new file mode 100644 index 00000000..6b6a82e9 Binary files /dev/null and b/assets/img/vignettes/tgz.png differ diff --git a/assets/img/vignettes/tif.png b/assets/img/vignettes/tif.png new file mode 100644 index 00000000..41c71675 Binary files /dev/null and b/assets/img/vignettes/tif.png differ diff --git a/assets/img/vignettes/tiff.png b/assets/img/vignettes/tiff.png new file mode 100644 index 00000000..41c71675 Binary files /dev/null and b/assets/img/vignettes/tiff.png differ diff --git a/assets/img/vignettes/torrent.png b/assets/img/vignettes/torrent.png new file mode 100644 index 00000000..0653af84 Binary files /dev/null and b/assets/img/vignettes/torrent.png differ diff --git a/assets/img/vignettes/ttf.png b/assets/img/vignettes/ttf.png new file mode 100644 index 00000000..8390b808 Binary files /dev/null and b/assets/img/vignettes/ttf.png differ diff --git a/assets/img/vignettes/txt.png b/assets/img/vignettes/txt.png new file mode 100644 index 00000000..6e446db0 Binary files /dev/null and b/assets/img/vignettes/txt.png differ diff --git a/assets/img/vignettes/wav.png b/assets/img/vignettes/wav.png new file mode 100644 index 00000000..3595590f Binary files /dev/null and b/assets/img/vignettes/wav.png differ diff --git a/assets/img/vignettes/wmv.png b/assets/img/vignettes/wmv.png new file mode 100644 index 00000000..c4d738b2 Binary files /dev/null and b/assets/img/vignettes/wmv.png differ diff --git a/assets/img/vignettes/xcf.png b/assets/img/vignettes/xcf.png new file mode 100644 index 00000000..08dac6c3 Binary files /dev/null and b/assets/img/vignettes/xcf.png differ diff --git a/assets/img/vignettes/xls.png b/assets/img/vignettes/xls.png new file mode 100644 index 00000000..548eba35 Binary files /dev/null and b/assets/img/vignettes/xls.png differ diff --git a/assets/img/vignettes/xlsx.png b/assets/img/vignettes/xlsx.png new file mode 100644 index 00000000..548eba35 Binary files /dev/null and b/assets/img/vignettes/xlsx.png differ diff --git a/assets/img/vignettes/xml.png b/assets/img/vignettes/xml.png new file mode 100644 index 00000000..e87d623c Binary files /dev/null and b/assets/img/vignettes/xml.png differ diff --git a/assets/img/vignettes/zip.png b/assets/img/vignettes/zip.png new file mode 100644 index 00000000..603b701d Binary files /dev/null and b/assets/img/vignettes/zip.png differ diff --git a/assets/modules/entrepot/breadcrumbs.ts b/assets/modules/entrepot/breadcrumbs.ts index acd87397..f1c8bbb8 100644 --- a/assets/modules/entrepot/breadcrumbs.ts +++ b/assets/modules/entrepot/breadcrumbs.ts @@ -1,9 +1,9 @@ import { BreadcrumbProps } from "@codegouvfr/react-dsfr/Breadcrumb"; import { Route } from "type-route"; +import { Datastore } from "../../@types/app"; import { getTranslation } from "../../i18n/i18n"; import { routes } from "../../router/router"; -import { Datastore } from "../../@types/app"; const { t } = getTranslation("Breadcrumb"); @@ -187,7 +187,6 @@ const getBreadcrumb = (route: Route, datastore?: Datastore): Brea ]; return { ...defaultProps, currentPageLabel: t(route.name) }; - case "espaceco_community_list": case "home": default: return undefined; diff --git a/assets/modules/espaceco/RQKeys.ts b/assets/modules/espaceco/RQKeys.ts index b2bd8210..f31fe0ed 100644 --- a/assets/modules/espaceco/RQKeys.ts +++ b/assets/modules/espaceco/RQKeys.ts @@ -1,16 +1,34 @@ import { CommunityListFilter } from "../../@types/app_espaceco"; const RQKeys = { - community_list: (page: number, limit: number): string[] => ["community", page.toString(), limit.toString()], - search: (search: string, filter: CommunityListFilter): string[] => { - return ["search", "community", filter, search]; + communityList: (page: number, limit: number): string[] => ["communities", page.toString(), limit.toString()], + communitiesName: (): string[] => ["communities_names"], + community: (communityId: number): string[] => ["community", communityId.toString()], + communityMembershipRequests: (communityId: number): string[] => ["community", "members", "pending", communityId.toString()], + communityMembers: (communityId: number, page: number, limit: number): string[] => [ + "community", + "members", + communityId.toString(), + page.toString(), + limit.toString(), + ], + searchCommunities: (search: string, filter: CommunityListFilter): string[] => { + return ["searchCommunities", filter, search]; }, - communities_as_member: (pending: boolean, page: number, limit: number): string[] => [ + communitiesAsMember: (pending: boolean, page: number, limit: number): string[] => [ "communities_as_member", new Boolean(pending).toString(), page.toString(), limit.toString(), ], + communityDocuments: (communityId?: number): string[] => ["community", "documents", communityId ? communityId.toString() : ""], + userSharedThemes: (): string[] => ["user", "shared_themes"], + searchAddress: (search: string): string[] => ["searchAddress", search], + searchGrids: (text: string): string[] => ["searchGrids", text], + getMe: (): string[] => ["espaceco", "users", "me"], + searchUsers: (text: string): string[] => ["searchUsers", text], + tables: (communityId: number): string[] => ["feature_types", communityId.toString()], + emailPlanners: (communityId: number): string[] => ["emailplanners", communityId.toString()], }; export default RQKeys; diff --git a/assets/modules/react-md/commands.ts b/assets/modules/react-md/commands.ts new file mode 100644 index 00000000..0a9382fb --- /dev/null +++ b/assets/modules/react-md/commands.ts @@ -0,0 +1,61 @@ +import { getCommands, getExtraCommands, ICommand } from "@uiw/react-md-editor"; +import { Language } from "../../i18n/i18n"; +import { MDCommandTranslations } from "./translations"; + +export const getLocaleCommands = (lang: Language = "fr") => { + const commands = getCommands(); + if (lang === "en") { + return commands; + } + + return commands.map((cmd) => { + if (cmd.keyCommand === "divider" || !cmd.buttonProps) return cmd; + + switch (cmd.keyCommand) { + case "group": { + if (cmd.name === "title") { + cmd.buttonProps["aria-label"] = cmd.buttonProps.title = MDCommandTranslations[lang][cmd.name!]; + + const children = cmd.children as ICommand[]; + for (const child of children) { + if (child.buttonProps) { + child.buttonProps["aria-label"] = child.buttonProps.title = MDCommandTranslations[lang][child.name!]; + } + } + } + break; + } + default: { + cmd.buttonProps["aria-label"] = cmd.buttonProps.title = MDCommandTranslations[lang][cmd.name!]; + break; + } + } + return cmd; + }); +}; + +export const getLocaleDefaultExtraCommands = (lang: Language = "fr") => { + const commands = getExtraCommands(); + if (lang === "en") { + return commands; + } + + return commands.map((cmd) => { + if (cmd.keyCommand === "divider") return cmd; + switch (cmd.name) { + case "edit": + cmd.buttonProps!["aria-label"] = cmd.buttonProps!.title = MDCommandTranslations[lang]["codeEdit"]; + break; + case "live": + cmd.buttonProps!["aria-label"] = cmd.buttonProps!.title = MDCommandTranslations[lang]["codeLive"]; + break; + case "preview": + cmd.buttonProps!["aria-label"] = cmd.buttonProps!.title = MDCommandTranslations[lang]["codePreview"]; + break; + case "fullscrenn": + cmd.buttonProps!["aria-label"] = cmd.buttonProps!.title = MDCommandTranslations[lang]["fullscreen"]; + break; + } + return cmd; + }); +}; diff --git a/assets/modules/react-md/react-md-commands.ts b/assets/modules/react-md/react-md-commands.ts deleted file mode 100644 index 74599e8a..00000000 --- a/assets/modules/react-md/react-md-commands.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { - ICommand, - bold, - italic, - strikethrough, - code, - codeBlock, - comment, - divider, - group, - hr, - image, - link, - quote, - title1, - title2, - title3, - title4, - title5, - title6, - unorderedListCommand, - orderedListCommand, - checkedListCommand, -} from "@uiw/react-md-editor"; - -import { ReactMDShortcuts, ReactMDLocales } from "./react-md-locales"; - -const getLocaleTitles: (locale: string) => ICommand[] = (locale = "fr") => { - const title = ReactMDLocales[locale].title.title; - const titles: ICommand[] = [title1, title2, title3, title4, title5, title6].map((t, index) => { - return { ...t, buttonProps: { "aria-label": `${title} ${index + 1}`, title: `${title} ${index + 1} (ctrl + ${index + 1})` } }; - }); - return titles; -}; - -/** - * Retourne la liste des commandes pour l'editeur markdown en fonction de la locale - * @param locale - * @returns - */ -const getLocaleCommands: (locale: string) => ICommand[] = (locale = "fr") => { - const trans = ReactMDLocales[locale]; - - return [ - { ...bold, buttonProps: { "aria-label": trans.bold.title, title: `${trans.bold.title} (${ReactMDShortcuts.bold})` } }, - { ...italic, buttonProps: { "aria-label": trans.italic.title, title: `${trans.italic.title} (${ReactMDShortcuts.italic})` } }, - { - ...strikethrough, - buttonProps: { "aria-label": trans.strikethrough.title, title: `${trans.strikethrough.title} (${ReactMDShortcuts.strikethrough})` }, - }, - { ...hr, buttonProps: { "aria-label": trans.hr.title, title: `${trans.hr.title} (${ReactMDShortcuts.hr})` } }, - group(getLocaleTitles(locale), { name: "title", groupName: "title", buttonProps: { "aria-label": trans.title.title, title: trans.title.title } }), - divider, - { ...link, buttonProps: { "aria-label": trans.link.title, title: `${trans.link.title} (${ReactMDShortcuts.link})` } }, - { ...quote, buttonProps: { "aria-label": trans.quote.title, title: `${trans.quote.title} (${ReactMDShortcuts.quote})` } }, - { ...code, buttonProps: { "aria-label": trans.code.title, title: `${trans.code.title} (${ReactMDShortcuts.code})` } }, - { ...codeBlock, buttonProps: { "aria-label": trans.codeBlock.title, title: `${trans.codeBlock.title} (${ReactMDShortcuts.codeBlock})` } }, - { ...comment, buttonProps: { "aria-label": trans.comment.title, title: `${trans.comment.title} (${ReactMDShortcuts.comment})` } }, - { ...image, buttonProps: { "aria-label": trans.image.title, title: `${trans.image.title} (${ReactMDShortcuts.image})` } }, - divider, - { - ...unorderedListCommand, - buttonProps: { - "aria-label": trans.unorderedListCommand.title, - title: `${trans.unorderedListCommand.title} (${ReactMDShortcuts.unorderedListCommand})`, - }, - }, - { - ...orderedListCommand, - buttonProps: { - "aria-label": trans.orderedListCommand.title, - title: `${trans.orderedListCommand.title} (${ReactMDShortcuts.orderedListCommand})`, - }, - }, - { - ...checkedListCommand, - buttonProps: { - "aria-label": trans.checkedListCommand.title, - title: `${trans.checkedListCommand.title} (${ReactMDShortcuts.checkedListCommand})`, - }, - }, - ]; -}; - -export default getLocaleCommands; diff --git a/assets/modules/react-md/react-md-locales.ts b/assets/modules/react-md/react-md-locales.ts deleted file mode 100644 index 0da7c010..00000000 --- a/assets/modules/react-md/react-md-locales.ts +++ /dev/null @@ -1,64 +0,0 @@ -const ReactMDShortcuts = { - bold: "ctrl + b", - italic: "ctrl + i", - strikethrough: "ctrl + shift + x", - hr: "ctrl + h", - link: "ctrl + l", - quote: "ctrl + q", - code: "ctrl + j", - codeBlock: "ctrl + shift + j", - comment: "ctrl + /", - image: "ctrl + k", - unorderedListCommand: "ctrl + shift + u", - orderedListCommand: "ctrl + shift + o", - checkedListCommand: "ctrl + shift + c", -}; - -const ReactMDLocales = { - fr: { - bold: { - title: "Ajouter du texte en gras", - }, - italic: { - title: "Ajouter du texte en italique", - }, - strikethrough: { - title: "Ajouter du texte barré", - }, - hr: { - title: "Ajouter une barre horizontale", - }, - title: { - title: "Insérer un titre", - }, - link: { - title: "Ajouter un lien", - }, - quote: { - title: "Insérer une quote", - }, - code: { - title: "Insérer du code", - }, - codeBlock: { - title: "Insérer un bloc de code", - }, - comment: { - title: "Insérer un commentaire", - }, - image: { - title: "Ajouter une image", - }, - unorderedListCommand: { - title: "Ajouter une liste non ordonnée", - }, - orderedListCommand: { - title: "Ajouter une liste ordonnée", - }, - checkedListCommand: { - title: "Ajouter une liste de choix", - }, - }, -}; - -export { ReactMDShortcuts, ReactMDLocales }; diff --git a/assets/modules/react-md/translations.ts b/assets/modules/react-md/translations.ts new file mode 100644 index 00000000..0d81f6ea --- /dev/null +++ b/assets/modules/react-md/translations.ts @@ -0,0 +1,30 @@ +export const MDCommandTranslations = { + fr: { + bold: "Ajouter du texte en gras (ctrl + b)", + italic: "Ajouter du texte en italique (ctrl + i)", + strikethrough: "Ajouter du texte barré (ctrl + shift + x)", + hr: "Ajouter une barre horizontale (ctrl + h)", + title: "Insérer un titre", + title1: "Insérer un titre 1 (ctrl + 1)", + title2: "Insérer un titre 2 (ctrl + 2)", + title3: "Insérer un titre 3 (ctrl + 3)", + title4: "Insérer un titre 4 (ctrl + 4)", + title5: "Insérer un titre 5 (ctrl + 5)", + title6: "Insérer un titre 6 (ctrl + 6)", + link: "Ajouter un lien (ctrl + l)", + quote: "Insérer une quote (ctrl + q)", + code: "Insérer du code (ctrl + j)", + codeBlock: "Insérer un bloc de code (ctrl + shift +j)", + comment: "Insérer un commentaire (ctrl + /)", + image: "Ajouter une image (ctrl + k)", + table: "Ajouter une table", + "unordered-list": "Ajouter une liste non ordonnée (ctrl + shift + u)", + "ordered-list": "Ajouter une liste ordonnée (ctrl + shift + o)", + "checked-list": "Ajouter une liste de choix (ctrl + shift + c)", + help: "Ouvrir l'aide en ligne", + codeEdit: "Editer le code (ctrl + 7)", + codeLive: "Code en direct (ctrl + 8)", + codePreview: "Prévisualiser le code (ctrl + 9)", + fullscreen: "Passer en mode plein écran (ctrl+ 0)", + }, +}; diff --git a/assets/ol/controls/DisplayCenterControl.ts b/assets/ol/controls/DisplayCenterControl.ts new file mode 100644 index 00000000..44407968 --- /dev/null +++ b/assets/ol/controls/DisplayCenterControl.ts @@ -0,0 +1,47 @@ +/* Copyright (c) 2024 P.Prevautel + released under the CeCILL-B license (French BSD license) + (http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.txt). +*/ +import * as olColor from "ol/color"; +import Control from "ol/control/Control"; + +import "../../sass/pages/espaceco/drawcenter.scss"; + +type DisplayCenterOptions = { + color?: number[] | string; + width?: number; +}; + +const defaultColor = "#000"; + +/** DisplayCenter draw a target at the center of the map. + * @param + * - color {ol.Color or string} line color + * - width {integer} line width + */ +class DisplayCenterControl extends Control { + constructor(options: DisplayCenterOptions) { + const { color = defaultColor, width = 1 } = options; + + let c: string = defaultColor; + try { + if (Array.isArray(color)) { + c = olColor.asString(color); + } else if (typeof color === "string") { + olColor.fromString(color) as olColor.Color; + c = color; + } + } catch (e) { + c = "#000"; + } + + const div = document.createElement("div"); + div.className = "ol-target ol-unselectable ol-control"; + div.style.setProperty("--drawcenter-background", c); + div.style.setProperty("--drawcenter-width", `${width}px`); + + super({ element: div }); + } +} + +export default DisplayCenterControl; diff --git a/assets/router/RouterRenderer.tsx b/assets/router/RouterRenderer.tsx index 42f2ba8e..a3e59b4f 100644 --- a/assets/router/RouterRenderer.tsx +++ b/assets/router/RouterRenderer.tsx @@ -2,6 +2,7 @@ import { FC, JSX, Suspense, lazy, useMemo } from "react"; import AppLayout from "../components/Layout/AppLayout"; import LoadingText from "../components/Utils/LoadingText"; +import MemberInvitation from "../espaceco/pages/communities/MemberInvitation"; import { I18nFetchingSuspense } from "../i18n/i18n"; import Home from "../pages/Home"; import RedirectToLogin from "../pages/RedirectToLogin"; @@ -57,6 +58,8 @@ 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 EspaceCoCreateCommunity = lazy(() => import("../espaceco/pages/communities/CreateCommunity")); +const EspaceCoManageCommunity = lazy(() => import("../espaceco/pages/communities/ManageCommunity")); const RouterRenderer: FC = () => { const route = useRoute(); @@ -184,6 +187,12 @@ const RouterRenderer: FC = () => { return ; case "espaceco_community_list": return ; + case "espaceco_create_community": + return ; + case "espaceco_manage_community": + return ; + case "espaceco_member_invitation": + return ; default: return ; } diff --git a/assets/router/router.ts b/assets/router/router.ts index 8a50d07f..7c4994c8 100644 --- a/assets/router/router.ts +++ b/assets/router/router.ts @@ -228,7 +228,23 @@ const routeDefs = { page: param.query.optional.number.default(1), filter: param.query.optional.string.default("public"), }, - () => `${appRoot}/espaceco/community` + () => `${appRoot}/espace-collaboratif` + ), + + espaceco_create_community: defineRoute(`${appRoot}/espace-collaboratif/creer-un-guichet`), + + espaceco_manage_community: defineRoute( + { + communityId: param.path.number, + }, + (p) => `${appRoot}/espace-collaboratif/${p.communityId}/gerer-le-guichet` + ), + + espaceco_member_invitation: defineRoute( + { + communityId: param.path.number, + }, + (p) => `${appRoot}/espace-collaboratif/${p.communityId}/invitation` ), }; diff --git a/assets/sass/components/autocomplete.scss b/assets/sass/components/autocomplete.scss new file mode 100644 index 00000000..69a31d1b --- /dev/null +++ b/assets/sass/components/autocomplete.scss @@ -0,0 +1,4 @@ +.MuiAutocomplete-option { + padding: 0.25rem; + height: 1.25rem; +} diff --git a/assets/sass/components/react-md-editor.scss b/assets/sass/components/react-md-editor.scss new file mode 100644 index 00000000..81521ea6 --- /dev/null +++ b/assets/sass/components/react-md-editor.scss @@ -0,0 +1,16 @@ +.w-md-editor-toolbar li > button.frx-keywords-btn { + all: unset; + + color: var(--text-inverted-blue-france); + background-color: var(--background-action-high-blue-france); + --hover: var(--background-action-high-blue-france-hover); + --active: var(--background-action-high-blue-france-active); + + border: 1px solid grey; + font-weight: 500; + font-size: 0.8rem; + line-height: 1rem; + min-height: 1.5rem; + padding: 0 0.4rem; + margin: 0.25rem; +} diff --git a/assets/sass/components/zoom-range.scss b/assets/sass/components/zoom-range.scss index 13dbdb14..789f53c4 100644 --- a/assets/sass/components/zoom-range.scss +++ b/assets/sass/components/zoom-range.scss @@ -1,17 +1,17 @@ -.zoom-range-map { - height: 300px; -} - -.ui-map-zoom-levels { +.frx-zoom-range { display: flex; - .ui-top-zoom-level, - .ui-bottom-zoom-level { + .frx-top-zoom, + .frx-bottom-zoom { height: 300px; border: 1px solid lightgray; flex: 0 1 50%; margin: 0 1em; } - .ui-bottom-zoom-level { + .frx-bottom-zoom { margin-left: 0; } + + .frx-zoom-range-sm { + height: 150px; + } } diff --git a/assets/sass/pages/espaceco/community.scss b/assets/sass/pages/espaceco/community.scss index d573fc4e..ce42c311 100644 --- a/assets/sass/pages/espaceco/community.scss +++ b/assets/sass/pages/espaceco/community.scss @@ -1,3 +1,44 @@ .frx-community-even { background-color: var(--background-alt-grey); } + +/*.frx-community-desc-documents { + padding: 0.4rem; + border: 1px solid; + border-color: var(--border-default-grey); +}*/ + +.frx-document-tile { + display: flex; + flex-direction: column; + align-items: center; + border: 1px solid; + border-color: var(--border-default-grey); + background-color: var(--background-contrast-grey); + padding: 1rem; + margin: 0.5rem; + gap: 0.4rem; + justify-content: space-between; + img { + width: 48px; + height: 48px; + max-width: 48px; + max-height: 48px; + } +} + +.frx-document-image { + width: 48px; + height: 48px; + max-width: 48px; + max-height: 48px; +} + +.frx-image-modal { + height: 64px; + width: 64px; + img { + max-height: 64px; + max-width: 64px; + } +} diff --git a/assets/sass/pages/espaceco/drawcenter.scss b/assets/sass/pages/espaceco/drawcenter.scss new file mode 100644 index 00000000..b1c1291b --- /dev/null +++ b/assets/sass/pages/espaceco/drawcenter.scss @@ -0,0 +1,26 @@ +.ol-target { + inset: 0; + pointer-events: none !important; + background-color: transparent !important ; +} + +.ol-target:before, +.ol-target:after { + content: ""; + position: absolute; + background: var(--drawcenter-background); +} + +.ol-target:before { + width: 100%; + left: 0; + top: 50%; + height: var(--drawcenter-width, 1px); +} + +.ol-target:after { + height: 100%; + left: 50%; + top: 0; + width: var(--drawcenter-width, 1px); +} diff --git a/assets/sass/pages/espaceco/member_invitation.scss b/assets/sass/pages/espaceco/member_invitation.scss new file mode 100644 index 00000000..1d6062ea --- /dev/null +++ b/assets/sass/pages/espaceco/member_invitation.scss @@ -0,0 +1,4 @@ +.frx-invitation-img { + max-width: 48px; + max-height: 48px; +} diff --git a/assets/utils.ts b/assets/utils.ts index dcc79148..3cc0a8c2 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) { @@ -221,6 +246,9 @@ const trimObject = (obj: object): object => { return newObject; }; +// Utilisée dans yup.transform sur une chaine . Si la valeur est undefined ou égale à "" +const setToNull = (value) => (value === undefined || value === "" ? null : value); + export { getInspireKeywords, getLanguages, @@ -233,8 +261,10 @@ export { niceBytes, getProjectionCode, getFileExtension, + getImageSize, formatDateFromISO, formatDateWithoutTimeFromISO, getArrayRange, trimObject, + setToNull, }; diff --git a/assets/validations/SldStyleValidationErrorsTr.tsx b/assets/validations/SldStyleValidationErrorsTr.tsx index d9c23540..a4700b30 100644 --- a/assets/validations/SldStyleValidationErrorsTr.tsx +++ b/assets/validations/SldStyleValidationErrorsTr.tsx @@ -1,4 +1,4 @@ -import { declareComponentKeys } from "i18nifty/declareComponentKeys"; +import { declareComponentKeys } from "i18nifty"; import { Translations } from "../i18n/i18n"; export const { i18n } = declareComponentKeys< diff --git a/src/Controller/Entrepot/CommunityController.php b/src/Controller/Entrepot/CommunityController.php index 853212ba..a567b496 100644 --- a/src/Controller/Entrepot/CommunityController.php +++ b/src/Controller/Entrepot/CommunityController.php @@ -55,7 +55,7 @@ public function getMembers(string $communityId): JsonResponse } } - #[Route('/update_member', name: 'add_member', methods: ['PUT'], + #[Route('/update_member', name: 'update_member', methods: ['PUT'], options: ['expose' => true], condition: 'request.isXmlHttpRequest()') ] diff --git a/src/Controller/EspaceCo/CommunityController.php b/src/Controller/EspaceCo/CommunityController.php index 10525978..6e729429 100644 --- a/src/Controller/EspaceCo/CommunityController.php +++ b/src/Controller/EspaceCo/CommunityController.php @@ -2,15 +2,24 @@ namespace App\Controller\EspaceCo; +use App\Controller\ApiControllerInterface; +use App\Dto\Espaceco\Members\AddMembersDTO; 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\CommunityDocumentApiService; use App\Services\EspaceCoApi\UserApiService; +use App\Services\MailerService; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; +use Symfony\Component\Filesystem\Filesystem; 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\HttpKernel\Attribute\MapRequestPayload; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Uid\Uuid; #[Route( '/api/espaceco/community', @@ -20,12 +29,19 @@ )] class CommunityController extends AbstractController implements ApiControllerInterface { - const SEARCH_LIMIT = 20; + public const SEARCH_LIMIT = 20; + + private string $varDataPath; public function __construct( + ParameterBagInterface $parameters, + private Filesystem $fs, + private MailerService $mailerService, private CommunityApiService $communityApiService, - private UserApiService $userApiService + private CommunityDocumentApiService $documentApiService, + private UserApiService $userApiService, ) { + $this->varDataPath = $parameters->get('upload_path'); } #[Route('/get', name: 'get', methods: ['GET'])] @@ -34,44 +50,55 @@ 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); } } + #[Route('/get_names', name: 'get_names', methods: ['GET'])] + public function getCommunitiesName(): JsonResponse + { + try { + $names = $this->communityApiService->getCommunitiesName(); + + return new JsonResponse($names); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } + #[Route('/get_as_member', name: 'get_as_member', methods: ['GET'])] public function getMeMember( #[MapQueryParameter] bool $pending, #[MapQueryParameter] ?int $page = 1, - #[MapQueryParameter] ?int $limit = 10 - ): JsonResponse - { + #[MapQueryParameter] ?int $limit = 10, + ): 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 +117,215 @@ 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); + } + } + + /** + * @param array $fields + */ + #[Route('/{communityId}', name: 'get_community', methods: ['GET'])] + public function getCommunity(int $communityId, #[MapQueryParameter] ?array $fields = []): JsonResponse + { + try { + $community = $this->communityApiService->getCommunity($communityId, $fields); + $community['documents'] = $this->documentApiService->getDocuments($communityId); + + return new JsonResponse($community); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } + + /** + * @param array $roles + */ + #[Route('/{communityId}/members', name: 'get_members', methods: ['GET'])] + public function getMembers( + int $communityId, + #[MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, options: ['regexp' => '/^admin|member|pending$/'])] array $roles = [], + #[MapQueryParameter] ?int $page = 1, + #[MapQueryParameter(options: ['min_range' => 1, 'max_range' => 50])] ?int $limit = 10, + ): JsonResponse { + try { + $response = $this->communityApiService->getCommunityMembers($communityId, $roles, $page, $limit); + return new JsonResponse($response); } catch (ApiException $ex) { throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); } } + #[Route('/{communityId}/members', name: 'add_members', methods: ['POST'])] + public function addMembers( + int $communityId, + #[MapRequestPayload] AddMembersDTO $dto): JsonResponse + { + try { + $community = $this->communityApiService->getCommunity($communityId); + + $members = []; + foreach ($dto->members as $userId) { + $email = filter_var($userId, FILTER_VALIDATE_EMAIL); + $member = $this->communityApiService->addMember($communityId, $userId); + if ($email && 'invited' === $member['role']) { + $this->_sendEmail($email, $community); + } else { + $members[] = $member; + } + } + + return new JsonResponse($members); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } + + #[Route('/{communityId}/member/{userId}/update_role', name: 'update_member_role', methods: ['PATCH'])] + public function updateMemberRole(int $communityId, int $userId, Request $request): JsonResponse + { + try { + $data = json_decode($request->getContent(), true); + $member = $this->communityApiService->updateMember($communityId, $userId, 'role', $data['role']); + + return new JsonResponse($member); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } + + #[Route('/{communityId}/member/{userId}/update_grids', name: 'update_member_grids', methods: ['PATCH'])] + public function updateMemberGrids(int $communityId, int $userId, Request $request): JsonResponse + { + try { + $data = json_decode($request->getContent(), true); + $member = $this->communityApiService->updateMember($communityId, $userId, 'grids', $data['grids']); + + return new JsonResponse($member); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } + + #[Route('/{communityId}/update_logo', name: 'update_logo', methods: ['POST'])] + public function updateLogo(int $communityId, Request $request): JsonResponse + { + try { + $logo = $request->files->get('logo'); + + $uuid = Uuid::v4(); + $tempFileDir = join(DIRECTORY_SEPARATOR, [$this->varDataPath, $uuid]); + $tempFilePath = join(DIRECTORY_SEPARATOR, [$tempFileDir, $logo->getClientOriginalName()]); + + $logo->move($tempFileDir, $logo->getClientOriginalName()); + + $this->communityApiService->updateLogo($communityId, $tempFilePath); + $this->fs->remove($tempFileDir); + + $community = $this->communityApiService->getCommunity($communityId, ['logo_url']); + + return new JsonResponse(['logo_url' => $community['logo_url']]); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } + + #[Route('/{communityId}/remove_logo', name: 'remove_logo', methods: ['DELETE'])] + public function removeLogo(int $communityId): JsonResponse + { + try { + $this->communityApiService->removeLogo($communityId); + + return new JsonResponse(null, JsonResponse::HTTP_NO_CONTENT); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } + + #[Route('/{communityId}/member/{userId}/remove', name: 'remove_member', methods: ['DELETE'])] + public function removeMember(int $communityId, int $userId): JsonResponse + { + try { + $this->communityApiService->removeMember($communityId, $userId); + + return new JsonResponse(['user_id' => $userId]); + } 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; }); } + + /** + * @param array $community + */ + private function _sendEmail(string $email, array $community): void + { + $communityId = $community['id']; + $communityName = $community['name']; + $subject = "[cartes.gouv.fr] Invitation au guichet $communityName"; + + $url = $this->generateUrl('cartesgouvfr_app', [], UrlGeneratorInterface::ABSOLUTE_URL)."espace-collaboratif/$communityId/invitation"; + $this->mailerService->sendMail($email, $subject, 'Mailer/espaceco/member_invitation.html.twig', ['community' => $community, 'link' => $url]); + } } diff --git a/src/Controller/EspaceCo/CommunityDocumentController.php b/src/Controller/EspaceCo/CommunityDocumentController.php new file mode 100644 index 00000000..cc372d9a --- /dev/null +++ b/src/Controller/EspaceCo/CommunityDocumentController.php @@ -0,0 +1,102 @@ + true], + condition: 'request.isXmlHttpRequest()' +)] +class CommunityDocumentController extends AbstractController implements ApiControllerInterface +{ + private string $varDataPath; + + public function __construct( + ParameterBagInterface $parameters, + private Filesystem $fs, + private CommunityDocumentApiService $communityDocumentApiService, + ) { + $this->varDataPath = $parameters->get('upload_path'); + } + + /** + * @param array $fields + */ + #[Route('/get_all', name: 'get_all', methods: ['GET'])] + public function getAll( + int $communityId, + #[MapQueryParameter] ?array $fields = [], + ): JsonResponse { + try { + $response = $this->communityDocumentApiService->getDocuments($communityId, $fields); + + return new JsonResponse($response); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } + + #[Route('/add', name: 'add', methods: ['POST'])] + public function addDocument(int $communityId, Request $request): JsonResponse + { + try { + $title = $request->request->get('title'); + $description = $request->request->get('description'); + + $document = $request->files->get('document'); + + $uuid = Uuid::v4(); + $tempFileDir = join(DIRECTORY_SEPARATOR, [$this->varDataPath, $uuid]); + $tempFilePath = join(DIRECTORY_SEPARATOR, [$tempFileDir, $document->getClientOriginalName()]); + + $document->move($tempFileDir, $document->getClientOriginalName()); + + $response = $this->communityDocumentApiService->addDocument($communityId, $title, $description, $tempFilePath); + $this->fs->remove($tempFileDir); + + return new JsonResponse($response); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } + + #[Route('/update/{documentId}', name: 'update', methods: ['PATCH'])] + public function updateDocument(int $communityId, int $documentId, Request $request): JsonResponse + { + try { + $data = json_decode($request->getContent(), true); + + $response = $this->communityDocumentApiService->updateDocument($communityId, $documentId, $data); + + return new JsonResponse($response); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } + + #[Route('/delete/{documentId}', name: 'delete', methods: ['DELETE'])] + public function deleteDocument(int $communityId, int $documentId): JsonResponse + { + try { + $this->communityDocumentApiService->deleteDocument($communityId, $documentId); + + return new JsonResponse(null, JsonResponse::HTTP_NO_CONTENT); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } +} diff --git a/src/Controller/EspaceCo/EmailPlannerController.php b/src/Controller/EspaceCo/EmailPlannerController.php new file mode 100644 index 00000000..8f191638 --- /dev/null +++ b/src/Controller/EspaceCo/EmailPlannerController.php @@ -0,0 +1,78 @@ + true], + condition: 'request.isXmlHttpRequest()' +)] +class EmailPlannerController extends AbstractController implements ApiControllerInterface +{ + public const SEARCH_LIMIT = 20; + + public function __construct( + private EmailPlannerApiService $emailPlannerApiService, + ) { + } + + #[Route('/{communityId}', name: 'get', methods: ['GET'])] + public function get(int $communityId): JsonResponse + { + try { + $response = $this->emailPlannerApiService->getAll($communityId); + + return new JsonResponse($response); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } + + #[Route('/{communityId}', name: 'add', methods: ['POST'])] + public function add(int $communityId, Request $request): JsonResponse + { + try { + $data = json_decode($request->getContent(), true); + $response = $this->emailPlannerApiService->add($communityId, $data); + + return new JsonResponse($response); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } + + #[Route('/{communityId}/update/{emailPlannerId}', name: 'update', methods: ['PUT'])] + public function update(int $communityId, int $emailPlannerId, Request $request): JsonResponse + { + try { + $data = json_decode($request->getContent(), true); + $response = $this->emailPlannerApiService->update($communityId, $emailPlannerId, $data); + + return new JsonResponse($response); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } + + #[Route('/{communityId}/remove/{emailPlannerId}', name: 'remove', methods: ['DELETE'])] + public function removeEmailPlanners(int $communityId, int $emailPlannerId): JsonResponse + { + try { + $this->emailPlannerApiService->remove($communityId, $emailPlannerId); + + return new JsonResponse(['emailplanner_id' => $emailPlannerId]); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } +} diff --git a/src/Controller/EspaceCo/GridController.php b/src/Controller/EspaceCo/GridController.php new file mode 100644 index 00000000..670a21fb --- /dev/null +++ b/src/Controller/EspaceCo/GridController.php @@ -0,0 +1,66 @@ + true], + condition: 'request.isXmlHttpRequest()' +)] +class GridController extends AbstractController implements ApiControllerInterface +{ + public const SEARCH_LIMIT = 20; + + public function __construct( + private GridApiService $gridApiService + ) { + } + + /** + * @param array $names + */ + #[Route('/get_by_names', name: 'get_by_names', methods: ['GET'])] + public function getFromArray( + #[MapQueryParameter] array $names + ): JsonResponse { + try { + if (!is_array($names) || 0 == count($names)) { + throw new ApiException('names is not an array or is empty'); + } + + $response = $this->gridApiService->getGridsFromNames($names); + + return new JsonResponse($response); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } + + #[Route('/search', name: 'search', methods: ['GET'])] + public function get( + #[MapQueryParameter] string $text, + #[MapQueryParameter] ?string $searchBy, + #[MapQueryParameter] ?string $fields, + #[MapQueryParameter] ?string $adm, + #[MapQueryParameter] ?int $page = 1, + #[MapQueryParameter] ?int $limit = self::SEARCH_LIMIT, + ): JsonResponse { + try { + $response = $this->gridApiService->getGrids($text, $searchBy, $fields, $adm, $page, $limit); + + return new JsonResponse($response); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } +} diff --git a/src/Controller/EspaceCo/PermissionController.php b/src/Controller/EspaceCo/PermissionController.php new file mode 100644 index 00000000..e6eb6fe3 --- /dev/null +++ b/src/Controller/EspaceCo/PermissionController.php @@ -0,0 +1,91 @@ + true], + condition: 'request.isXmlHttpRequest()' +)] +class PermissionController extends AbstractController implements ApiControllerInterface +{ + public const LEVELS = [ + 'NONE' => 0, + 'VIEW' => 1, + 'EXPORT' => 2, + 'EDIT' => 3, + 'ADMIN' => 4, + ]; + + public function __construct( + private PermissionApiService $permissionApiService, + private DatabaseApiService $databaseApiService + ) { + } + + /** + * Recupere les tables pouvant être utilisées pour theme d'une communauté. + */ + #[Route('/get_themable_tables/{communityId}', name: 'get_themable_tables_by_community', methods: ['GET'])] + public function getThemableTables(int $communityId): JsonResponse + { + try { + $tablesToremove = []; // Les tables a supprimer (celles qui ont une permission NONE ou ADMIN) + + $response = []; + + $permissions = $this->permissionApiService->getAllByCommunity($communityId); + foreach ($permissions as $permission) { + $tableId = $permission['table']; + if ('NONE' === $permission['level'] || 'ADMIN' === $permission['level']) { + if (!is_null($tableId)) { + // Table a supprimer + $fullName = $this->databaseApiService->getTableFullName($permission['database'], $tableId); + if (!in_array($fullName, $tablesToremove)) { + $tablesToremove[] = $fullName; + } + } + continue; + } + + if (is_null($permission['table'])) { // Ajout de toutes les tables + // TODO Ajouter columns + $tables = $this->databaseApiService->getAllTables($permission['database'], ['id', 'database_id', 'full_name'/* , 'columns' */]); + foreach ($tables as $table) { + $response[] = $table; + } + } + } + + $response = array_filter($response, function ($table, $name) use ($tablesToremove) { + return !in_array($name, $tablesToremove); + }, ARRAY_FILTER_USE_BOTH); + + usort($response, function ($a, $b) { + $fna = $a['full_name']; + $fnb = $b['full_name']; + if ($fna === $fnb) { + return 0; + } + + return ($fna < $fnb) ? -1 : 1; + }); + + $t = array_values(array_unique($response, SORT_REGULAR)); + + return new JsonResponse($t); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } +} diff --git a/src/Controller/EspaceCo/UserController.php b/src/Controller/EspaceCo/UserController.php index 60867501..67584eb1 100644 --- a/src/Controller/EspaceCo/UserController.php +++ b/src/Controller/EspaceCo/UserController.php @@ -3,10 +3,13 @@ namespace App\Controller\EspaceCo; use App\Controller\ApiControllerInterface; +use App\Exception\ApiException; +use App\Exception\CartesApiException; use App\Services\EspaceCoApi\UserApiService; -use Symfony\Component\Routing\Annotation\Route; -use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; +use Symfony\Component\Routing\Annotation\Route; #[Route( '/api/espaceco/user', @@ -22,9 +25,39 @@ public function __construct( } #[Route('/me', name: 'me')] - public function getCurrentUser(): JsonResponse + public function getMe(): JsonResponse + { + try { + $me = $this->userApiService->getMe(); + + return $this->json($me); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } + + #[Route('/search', name: 'search')] + public function search( + #[MapQueryParameter] string $search, + ): JsonResponse { + try { + $users = $this->userApiService->search($search); + + return new JsonResponse($users); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } + + #[Route('/me/shared_themes', name: 'shared_themes')] + public function getSharedThemes(): JsonResponse { - $me = $this->userApiService->getMe(); - return $this->json($me); + try { + $me = $this->userApiService->getSharedThemes(); + + return $this->json($me); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } } -} \ No newline at end of file +} diff --git a/src/Dto/Espaceco/AddMembersDTO.php b/src/Dto/Espaceco/AddMembersDTO.php new file mode 100644 index 00000000..a4ad5e56 --- /dev/null +++ b/src/Dto/Espaceco/AddMembersDTO.php @@ -0,0 +1,24 @@ + $members + */ + public function __construct( + #[Assert\All([ + new Assert\AtLeastOneOf([ + new Assert\Positive(), + new Assert\Email([ + 'message' => 'espaceco.add_members.email_error', + ]), + ]), + ])] + public readonly array $members = [], + ) { + } +} 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..02dffab0 100644 --- a/src/Services/EspaceCoApi/CommunityApiService.php +++ b/src/Services/EspaceCoApi/CommunityApiService.php @@ -2,16 +2,33 @@ namespace App\Services\EspaceCoApi; +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Contracts\HttpClient\HttpClientInterface; + class CommunityApiService extends BaseEspaceCoApiService { - public function getCommunities(string $name, int $page,int $limit, string $sort): array + public function __construct(HttpClientInterface $httpClient, + ParameterBagInterface $parameters, + Filesystem $filesystem, + RequestStack $requestStack, + LoggerInterface $logger, + private UserApiService $userApiService, + private GridApiService $gridApiService, + ) { + parent::__construct($httpClient, $parameters, $filesystem, $requestStack, $logger); + } + + 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 [ @@ -22,11 +39,110 @@ public function getCommunities(string $name, int $page,int $limit, string $sort) ]; } + public function getCommunitiesName(): array + { + $communities = $this->requestAll('communities', ['fields' => 'name', 'sort' => 'name:ASC']); + + return array_map(fn ($community) => $community['name'], $communities); + } + /** - * @param string $communityId + * @param array $fields + * * @return array */ - public function getCommunity(string $communityId) : array { - return $this->request('GET', "communities/$communityId"); + public function getCommunity(int $communityId, array $fields = []): array + { + $query = empty($fields) ? [] : ['fields' => implode(',', $fields)]; + + return $this->request('GET', "communities/$communityId", [], $query); + } + + /** + * @param array $roles + * + * @return array + */ + public function getCommunityMembers(int $communityId, array $roles, int $page, int $limit): array + { + $query = ['fields' => 'user_id, grids, role, active, date', 'page' => $page, 'limit' => $limit]; + $query['roles'] = count($roles) ? $roles : ['member', 'admin']; + + $response = $this->request('GET', "communities/$communityId/members", [], $query, [], false, true, true); + + $contentRange = $response['headers']['content-range'][0]; + $totalPages = $this->getResultsPageCount($contentRange, $limit); + $previousPage = 1 === $page ? null : $page - 1; + $nextPage = $page + 1 > $totalPages ? null : $page + 1; + + $members = $response['content']; + foreach ($members as &$member) { + $user = $this->userApiService->getUser($member['user_id'], ['fields' => ['username', 'firstname', 'surname']]); + $member = array_merge($member, $user); + + // Ajout des grids + $member['grids'] = $this->_transformGrids($member['grids']); + } + + usort($members, function ($mb1, $mb2) { + if ($mb1['username'] == $mb2['username']) { + return 0; + } + + return (mb_strtolower($mb1['username'], 'UTF-8') < mb_strtolower($mb2['username'], 'UTF-8')) ? -1 : 1; + }); + + return [ + 'content' => $members, + 'totalPages' => $totalPages, + 'previousPage' => $previousPage, + 'nextPage' => $nextPage, + ]; + } + + public function addMember(int $communityId, int $userId): array + { + return $this->request('POST', "communities/$communityId/members/$userId", ['user_id' => $userId]); + } + + public function updateMember(int $communityId, int $userId, string $field, mixed $value): array + { + $member = $this->request('PATCH', "communities/$communityId/members/$userId", [$field => $value]); + if ('grids' === $field) { // On recupere les grids, pas seulement leur nom + $member['grids'] = $this->_transformGrids($member['grids']); + } + + return $member; + } + + public function removeMember(int $communityId, int $userId): array + { + return $this->request('DELETE', "communities/$communityId/members/$userId"); + } + + public function updateLogo(int $communityId, string $filePath): array + { + return $this->sendFile('POST', "communities/$communityId/logo", $filePath, [], [], 'logo'); + } + + public function removeLogo(int $communityId): array + { + return $this->request('DELETE', "communities/$communityId/logo"); + } + + /** + * @param array $grids + */ + private function _transformGrids(array $grids): array + { + $result = []; + foreach ($grids as $name) { + $grid = $this->gridApiService->getGrid($name); + if (!$grid['deleted']) { + $grids[] = $grid; + } + } + + return $result; } -} \ No newline at end of file +} diff --git a/src/Services/EspaceCoApi/CommunityDocumentApiService.php b/src/Services/EspaceCoApi/CommunityDocumentApiService.php new file mode 100644 index 00000000..5b7cc41d --- /dev/null +++ b/src/Services/EspaceCoApi/CommunityDocumentApiService.php @@ -0,0 +1,59 @@ + $fields + */ + public function getDocuments(int $communityId, array $fields = []): array + { + return $this->request('GET', "communities/$communityId/documents", ['fields' => $fields]); + } + + public function addDocument(int $communityId, string $title, string $description, string $tempFilePath): array + { + $formFields = [ + 'title' => $title, + 'description' => $description, + ]; + $formFields['document'] = DataPart::fromPath($tempFilePath); + + $formData = new FormDataPart($formFields); + $body = $formData->bodyToIterable(); + $headers = $formData->getPreparedHeaders()->toArray(); + + return $this->request('POST', "communities/$communityId/documents", $body, [], $headers, true); + } + + /** + * @param array $data + */ + public function updateDocument(int $communityId, int $documentId, array $data): array + { + return $this->request('PATCH', "communities/$communityId/documents/$documentId", $data); + } + + public function deleteDocument(int $communityId, int $documentId): array + { + return $this->request('DELETE', "communities/$communityId/documents/$documentId"); + } +} diff --git a/src/Services/EspaceCoApi/DatabaseApiService.php b/src/Services/EspaceCoApi/DatabaseApiService.php new file mode 100644 index 00000000..4c41424a --- /dev/null +++ b/src/Services/EspaceCoApi/DatabaseApiService.php @@ -0,0 +1,29 @@ +request('GET', "databases/$databaseId/tables/$tableId"); + } + + public function getTableFullName(int $databaseId, int $tableId): string + { + return $this->request('GET', "databases/$databaseId/tables/$tableId", [], ['fields' => 'full_name']); + } + + /** + * @param array $fields + */ + public function getAllTables(int $databaseId, array $fields = []): array + { + return $this->requestAll("databases/$databaseId/tables", ['fields' => $fields]); + } + + public function getColumns(int $databaseId, int $tableId): array + { + return $this->request('GET', "databases/$databaseId/tables/$tableId", [], ['fields' => 'columns']); + } +} diff --git a/src/Services/EspaceCoApi/EmailPlannerApiService.php b/src/Services/EspaceCoApi/EmailPlannerApiService.php new file mode 100644 index 00000000..2511e473 --- /dev/null +++ b/src/Services/EspaceCoApi/EmailPlannerApiService.php @@ -0,0 +1,50 @@ + + */ + public function getAll(int $communityId): array + { + return $this->requestAll("communities/$communityId/emailplanners"); + } + + /** + * @param array $data + */ + public function add(int $communityId, array $data): array + { + return $this->request('POST', "communities/$communityId/emailplanners", $data); + } + + /** + * @param array $data + */ + public function update(int $communityId, int $emailPlannerId, array $data): array + { + return $this->request('PUT', "communities/$communityId/emailplanners/$emailPlannerId", $data); + } + + public function remove(int $communityId, int $emailPlannerId): array + { + return $this->request('DELETE', "communities/$communityId/emailplanners/$emailPlannerId"); + } +} diff --git a/src/Services/EspaceCoApi/GridApiService.php b/src/Services/EspaceCoApi/GridApiService.php new file mode 100644 index 00000000..c84c6c94 --- /dev/null +++ b/src/Services/EspaceCoApi/GridApiService.php @@ -0,0 +1,56 @@ + $text, 'page' => $page, 'limit' => $limit]; + if (!is_null($searchBy)) { + $query['searchBy'] = $searchBy; + } + if (!is_null($fields)) { + $query['fields'] = $fields; + } + if (!is_null($adm)) { + $query['adm'] = $adm; + } + + $response = $this->request('GET', 'grids', [], $query, [], false, true, true); + + $contentRange = $response['headers']['content-range'][0]; + $totalPages = $this->getResultsPageCount($contentRange, $limit); + + $previousPage = 1 === $page ? null : $page - 1; + $nextPage = $page + 1 > $totalPages ? null : $page + 1; + + return [ + 'content' => $response['content'], + 'totalPages' => $totalPages, + 'previousPage' => $previousPage, + 'nextPage' => $nextPage, + ]; + } + + public function getGrid(string $gridName): array + { + return $this->request('GET', "grids/$gridName", [], ['fields' => ['name', 'title', 'deleted', 'type']]); + } + + /** + * @param array $names + */ + public function getGridsFromNames(array $names): array + { + $grids = []; + foreach ($names as $gridName) { + $grid = $this->getGrid($gridName); + if (!$grid['deleted']) { + $grids[] = $grid; + } + } + + return $grids; + } +} diff --git a/src/Services/EspaceCoApi/PermissionApiService.php b/src/Services/EspaceCoApi/PermissionApiService.php new file mode 100644 index 00000000..f0054478 --- /dev/null +++ b/src/Services/EspaceCoApi/PermissionApiService.php @@ -0,0 +1,11 @@ +requestAll('permissions', ['group' => $communityId]); + } +} diff --git a/src/Services/EspaceCoApi/UserApiService.php b/src/Services/EspaceCoApi/UserApiService.php index 7d8c6009..2083ba1f 100644 --- a/src/Services/EspaceCoApi/UserApiService.php +++ b/src/Services/EspaceCoApi/UserApiService.php @@ -8,4 +8,27 @@ public function getMe(): array { return $this->request('GET', 'users/me'); } -} \ No newline at end of file + + public function getSharedThemes(): array + { + $result = $this->request('GET', 'users/me', [], ['fields' => 'shared_themes']); + if (is_array($result) && array_key_exists('shared_themes', $result)) { + return $result['shared_themes']; + } + + return []; + } + + /** + * @param array $query + */ + public function getUser(int $userId, array $query = []): array + { + return $this->request('GET', "users/$userId", [], $query); + } + + public function search(string $search): array + { + return $this->request('GET', 'users', [], ['search' => $search, 'fields' => ['id', 'username', 'firstname', 'surname']]); + } +} diff --git a/templates/Mailer/espaceco/member_invitation.html.twig b/templates/Mailer/espaceco/member_invitation.html.twig new file mode 100644 index 00000000..d42ac430 --- /dev/null +++ b/templates/Mailer/espaceco/member_invitation.html.twig @@ -0,0 +1,7 @@ +{% extends 'Mailer/base.html.twig' %} +{% trans_default_domain 'cartesgouvfr' %} + +{% block body %} +

{{ 'mailer.espaceco.member_invitation'|trans({'%name%': community.name, '%link%': link})|raw }}

+ +{% endblock %} diff --git a/translations/cartesgouvfr.fr.yml b/translations/cartesgouvfr.fr.yml index 94a593e1..ab8949b4 100644 --- a/translations/cartesgouvfr.fr.yml +++ b/translations/cartesgouvfr.fr.yml @@ -28,6 +28,8 @@ mailer: first_name: "Prénom" user_id: "Identifiant" date: "Date de la demande" + espaceco: + member_invitation: "Vous avez été invité à rejoindre la communauté %name%.
Vous pouvez accepter ou rejeter cette invitation en cliquant sur ce lien %link%." # pages: home: diff --git a/translations/validators/validators.fr.yml b/translations/validators/validators.fr.yml index 3f6e4a98..d7fbd6ab 100644 --- a/translations/validators/validators.fr.yml +++ b/translations/validators/validators.fr.yml @@ -103,3 +103,7 @@ common: attribution_url_error: "L'URL de l'attribution n'est pas valide" resolution_error: "La résolution n'est pas valide" share_with_error: "La restriction n'est pas valide" + +espaceco: + add_members: + email_error: "l'email n'est pas valide"