Skip to content

Commit

Permalink
feat: affichage et gestion des incidents, maintenances programmées ou…
Browse files Browse the repository at this point in the history
… autres informations importantes #618 #626
  • Loading branch information
tonai authored Feb 11, 2025
1 parent 196f2b2 commit f6c4ea5
Show file tree
Hide file tree
Showing 36 changed files with 1,193 additions and 64 deletions.
19 changes: 19 additions & 0 deletions assets/@types/alert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export interface IApiAlert {
id: string;
title: string;
description?: string;
link: { url?: string; label?: string };
severity: "info" | "warning" | "alert";
details?: string;
date: string;
visibility: {
homepage: boolean;
contact: boolean;
map: boolean;
serviceLevel: boolean;
};
}

export interface IAlert extends Omit<IApiAlert, "date"> {
date: Date;
}
7 changes: 6 additions & 1 deletion assets/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { FC } from "react";
import ErrorBoundary from "./components/Utils/ErrorBoundary";
import RouterRenderer from "./router/RouterRenderer";
import { RouteProvider } from "./router/router";
import AlertProvider from "./components/Provider/AlertProvider";

import "./sass/helpers.scss";

const queryClient = new QueryClient();

Expand All @@ -21,7 +24,9 @@ const App: FC = () => {

<RouteProvider>
<ErrorBoundary>
<RouterRenderer />
<AlertProvider>
<RouterRenderer />
</AlertProvider>
</ErrorBoundary>
</RouteProvider>
</PersistQueryClientProvider>
Expand Down
12 changes: 7 additions & 5 deletions assets/components/Input/DatePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { fr } from "@codegouvfr/react-dsfr";
import MuiDsfrThemeProvider from "@codegouvfr/react-dsfr/mui";
import { cx } from "@codegouvfr/react-dsfr/tools/cx";
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFnsV3";
import { DatePicker as MuiDatePicker } from "@mui/x-date-pickers/DatePicker";
import { DatePicker as MuiDatePicker, DatePickerProps as MuiDatePickerProps } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider/LocalizationProvider";
import { enGB as enGBLocale, fr as frLocale } from "date-fns/locale";
import { useId } from "react";
Expand All @@ -12,7 +12,7 @@ import { useLang } from "../../i18n/i18n";

const locales = { en: enGBLocale, fr: frLocale };

type DatePickerProps = {
interface DatePickerProps extends Omit<MuiDatePickerProps<Date, false>, "onChange"> {
id?: string;
label: string;
hintText?: string;
Expand All @@ -22,10 +22,10 @@ type DatePickerProps = {
minDate?: Date;
onChange?: (value: Date | undefined) => void;
className?: string;
};
}

const DatePicker = (props: DatePickerProps) => {
const { id, label, hintText, state, stateRelatedMessage, value, minDate, onChange, className } = props;
const { id, label, hintText, state, stateRelatedMessage, value, minDate, onChange, className, ...datePickerProps } = props;

const { lang } = useLang();

Expand All @@ -45,8 +45,10 @@ const DatePicker = (props: DatePickerProps) => {
</label>
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={locales[lang]}>
<MuiDatePicker
{...datePickerProps}
slotProps={{
field: { clearable: true, onClear: () => onChange?.(undefined) },
...datePickerProps.slotProps,
field: { ...datePickerProps.slotProps?.field, clearable: true, onClear: () => onChange?.(undefined) },
}}
sx={{ width: "100%" }}
timezone={"UTC"}
Expand Down
13 changes: 11 additions & 2 deletions assets/components/Input/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Markdown } from "tiptap-markdown";
import "../../sass/components/tiptap.scss";

type MarkdownEditorProps = {
className?: string;
label?: string;
hintText?: string;
value: string;
Expand All @@ -22,11 +23,19 @@ type MarkdownEditorProps = {
};

const MarkdownEditor: FC<MarkdownEditorProps> = (props) => {
const { label, hintText, value, state, stateRelatedMessage, placeholder = "", onChange } = props;
const { className, label, hintText, value, state, stateRelatedMessage, placeholder = "", onChange } = props;
const { isDark } = useIsDark();

const classNames = [fr.cx("fr-input-group")];
if (state === "error") {
classNames.push("fr-input-group--error");
}
if (className) {
classNames.push(className);
}

return (
<div className={fr.cx("fr-input-group", state === "error" && "fr-input-group--error")} data-color-mode={isDark ? "dark" : "light"}>
<div className={classNames.join(" ")} data-color-mode={isDark ? "dark" : "light"}>
{label && (
<label className={fr.cx("fr-label")}>
{label}
Expand Down
9 changes: 5 additions & 4 deletions assets/components/Layout/Main.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
import { fr } from "@codegouvfr/react-dsfr";
import { Breadcrumb, BreadcrumbProps } from "@codegouvfr/react-dsfr/Breadcrumb";
import { Notice } from "@codegouvfr/react-dsfr/Notice";
import { PropsWithChildren, ReactNode, memo, useContext, useMemo } from "react";
import { PropsWithChildren, memo, useContext, useMemo } from "react";

import getBreadcrumb from "../../modules/entrepot/breadcrumbs/Breadcrumb";
import { useRoute } from "../../router/router";
import SessionExpiredAlert from "../Utils/SessionExpiredAlert";
import useDocumentTitle from "../../hooks/useDocumentTitle";
import { datastoreContext } from "../../contexts/datastore";
import { IUseAlert } from "@/hooks/useAlert";

export interface MainProps {
customBreadcrumbProps?: BreadcrumbProps;
infoBannerMsg?: ReactNode;
noticeProps?: IUseAlert;
title?: string;
}

function Main(props: PropsWithChildren<MainProps>) {
const { children, customBreadcrumbProps, infoBannerMsg, title } = props;
const { children, customBreadcrumbProps, noticeProps, title } = props;
const route = useRoute();
useDocumentTitle(title);
const datastoreValue = useContext(datastoreContext);
Expand All @@ -32,7 +33,7 @@ function Main(props: PropsWithChildren<MainProps>) {
return (
<main id="main" role="main">
{/* doit être le premier élément atteignable après le lien d'évitement (Accessibilité) : https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/bandeau-d-information-importante */}
{infoBannerMsg && <Notice title={infoBannerMsg} isClosable={true} />}
{noticeProps && <Notice isClosable {...noticeProps} />}

<div className={fr.cx("fr-container", "fr-my-2w")}>
{breadcrumbProps && <Breadcrumb {...breadcrumbProps} />}
Expand Down
242 changes: 242 additions & 0 deletions assets/components/Modal/CreateAlert/CreateAlert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { yupResolver } from "@hookform/resolvers/yup";
import { FC, useEffect } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { symToStr } from "tsafe/symToStr";
import * as yup from "yup";
import { fr } from "@codegouvfr/react-dsfr";
import Input from "@codegouvfr/react-dsfr/Input";
import { Select } from "@codegouvfr/react-dsfr/SelectNext";
import ToggleSwitch from "@codegouvfr/react-dsfr/ToggleSwitch";
import isURL from "validator/lib/isURL";

import { useTranslation } from "../../../i18n";
import { IAlert } from "../../../@types/alert";

import PreviewAlert from "./PreviewAlert";
import { ModalProps } from "@codegouvfr/react-dsfr/Modal";
import { formatDateTimeLocal } from "../../../utils";
import MarkdownEditor from "../../Input/MarkdownEditor";

const date = new Date();
export const alertSchema = yup.object({
id: yup.string().required(),
title: yup.string().required("Titre requis"),
description: yup.string(),
link: yup.object({
label: yup.string(),
url: yup.string().test("check-url", "La chaîne doit être une url valide", (value) => value === "" || value?.startsWith("/") || isURL(value)),
}),
severity: yup.string().oneOf(["info", "warning", "alert"]).required("Sévérité requise"),
details: yup.string(),
date: yup.date().required("Date requise"),
visibility: yup.object({
homepage: yup.boolean().required(),
contact: yup.boolean().required(),
map: yup.boolean().required(),
serviceLevel: yup.boolean().required(),
}),
});

const severityOptions = [
{ value: "info", label: "info" },
{ value: "warning", label: "warning" },
{ value: "alert", label: "alert" },
];

interface CreateAlertProps {
alert: IAlert;
isEdit: boolean;
ModalComponent: (props: ModalProps) => JSX.Element;
onSubmit: (alert: IAlert) => void;
}

const CreateAlert: FC<CreateAlertProps> = (props) => {
const { alert, isEdit, ModalComponent, onSubmit } = props;
const { t } = useTranslation("alerts");

const methods = useForm({
mode: "onSubmit",
defaultValues: alert,
resolver: yupResolver(alertSchema),
});
const {
control,
formState: { errors },
handleSubmit,
register,
setValue,
} = methods;

useEffect(() => {
setValue("visibility", alert.visibility);
}, [alert.visibility, setValue]);

const addAlert = handleSubmit((values) => {
onSubmit(values);
});

return (
<ModalComponent
title={isEdit ? t("edit_alert") : t("create_alert")}
size="large"
buttons={[
{
doClosesModal: true,
children: t("modal.cancel"),
},
{
doClosesModal: false,
children: isEdit ? t("modal.edit") : t("modal.add"),
onClick: addAlert,
},
]}
>
<FormProvider {...methods}>
<form onSubmit={addAlert}>
<PreviewAlert />
<Input
label={t("alert.title")}
nativeInputProps={{
...register("title"),
}}
state={errors.title ? "error" : "default"}
stateRelatedMessage={errors?.title?.message?.toString()}
/>
<Input
label={t("alert.description")}
nativeInputProps={{
...register("description"),
}}
state={errors.description ? "error" : "default"}
stateRelatedMessage={errors?.description?.message?.toString()}
/>
<div className={fr.cx("fr-grid-row", "fr-grid-row--gutters")}>
<div className={fr.cx("fr-col-12", "fr-col-sm-6")}>
<Input
label={t("alert.linkLabel")}
nativeInputProps={{
...register("link.label"),
}}
state={errors.link?.label ? "error" : "default"}
stateRelatedMessage={errors?.link?.label?.message?.toString()}
/>
</div>
<div className={fr.cx("fr-col-12", "fr-col-sm-6")}>
<Input
label={t("alert.linkUrl")}
nativeInputProps={{
...register("link.url"),
}}
state={errors.link?.url ? "error" : "default"}
stateRelatedMessage={errors?.link?.url?.message?.toString()}
/>
</div>
<div className={fr.cx("fr-col-12", "fr-col-sm-6")}>
<Select
label={t("alert.severity")}
nativeSelectProps={{
...register("severity"),
}}
options={severityOptions}
placeholder="Select an option"
state={errors.severity ? "error" : "default"}
stateRelatedMessage={errors?.severity?.message?.toString()}
/>
</div>
<div className={fr.cx("fr-col-12", "fr-col-sm-6")}>
<Controller
control={control}
name="date"
render={({ field: { onChange, value } }) => (
<Input
label={t("alert.date")}
nativeInputProps={{
value: formatDateTimeLocal(value),
min: formatDateTimeLocal(date),
type: "datetime-local",
onChange,
}}
state={errors.date ? "error" : "default"}
stateRelatedMessage={errors.date?.message?.toString()}
/>
)}
/>
</div>
</div>
<Controller
control={control}
name="details"
render={({ field: { onChange, value } }) => (
<MarkdownEditor
className="fr-mt-3w"
label={t("alert.details")}
hintText={t("alert.details_hint")}
state={errors.details ? "error" : "default"}
stateRelatedMessage={errors?.details?.message?.toString()}
value={value ?? ""}
onChange={onChange}
/>
)}
/>
<div className={fr.cx("fr-grid-row", "fr-grid-row--gutters")}>
<div className={fr.cx("fr-col-12", "fr-col-sm-6")}>
<div className={fr.cx("fr-input-group")}>
<Controller
control={control}
name="visibility.homepage"
render={({ field: { onChange, value } }) => (
<ToggleSwitch inputTitle={t("alert.homepage")} label={t("alert.homepage")} onChange={onChange} checked={value} />
)}
/>
</div>
</div>
<div className={fr.cx("fr-col-12", "fr-col-sm-6")}>
<div className={fr.cx("fr-input-group")}>
<Controller
control={control}
name="visibility.contact"
render={({ field: { onChange, value } }) => (
<ToggleSwitch inputTitle={t("alert.contact")} label={t("alert.contact")} onChange={onChange} checked={value} />
)}
/>
</div>
</div>
</div>
<div className={fr.cx("fr-grid-row", "fr-grid-row--gutters")}>
<div className={fr.cx("fr-col-12", "fr-col-sm-6")}>
<div className={fr.cx("fr-input-group")}>
<Controller
control={control}
name="visibility.map"
render={({ field: { onChange, value } }) => (
<ToggleSwitch inputTitle={t("alert.map")} label={t("alert.map")} onChange={onChange} checked={value} />
)}
/>
</div>
</div>
<div className={fr.cx("fr-col-12", "fr-col-sm-6")}>
<div className={fr.cx("fr-input-group")}>
<Controller
control={control}
name="visibility.serviceLevel"
render={({ field: { onChange, value } }) => (
<ToggleSwitch
inputTitle={t("alert.serviceLevel")}
label={t("alert.serviceLevel")}
onChange={onChange}
checked={value}
/>
)}
/>
</div>
</div>
</div>
<input type="submit" hidden />
</form>
</FormProvider>
</ModalComponent>
);
};
CreateAlert.displayName = symToStr({ CreateAlert });

export default CreateAlert;
Loading

0 comments on commit f6c4ea5

Please sign in to comment.