diff --git a/package-lock.json b/package-lock.json index c4fd7ba..463da83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "react-window": "^1.8.10", "reconnecting-websocket": "^4.4.0", "redux": "^5.0.1", + "tinyduration": "^3.3.0", "type-fest": "^4.14.0", "typeface-roboto": "^1.1.13", "yup": "^1.4.0" @@ -14481,6 +14482,11 @@ "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" }, + "node_modules/tinyduration": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/tinyduration/-/tinyduration-3.3.0.tgz", + "integrity": "sha512-sLR0iVUnnnyGEX/a3jhTA0QMK7UvakBqQJFLiibiuEYL6U1L85W+qApTZj6DcL1uoWQntYuL0gExoe9NU5B3PA==" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", diff --git a/package.json b/package.json index b64a4db..a5dfa62 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "react-window": "^1.8.10", "reconnecting-websocket": "^4.4.0", "redux": "^5.0.1", + "tinyduration": "^3.3.0", "type-fest": "^4.14.0", "typeface-roboto": "^1.1.13", "yup": "^1.4.0" diff --git a/src/components/App/app-top-bar.tsx b/src/components/App/app-top-bar.tsx index 1f662dc..bf7f099 100644 --- a/src/components/App/app-top-bar.tsx +++ b/src/components/App/app-top-bar.tsx @@ -8,6 +8,7 @@ import { forwardRef, FunctionComponent, ReactElement, useEffect, useMemo, useState } from 'react'; import { capitalize, Tab, Tabs, useTheme } from '@mui/material'; import { ManageAccounts, PeopleAlt } from '@mui/icons-material'; +import { Announcement } from '@mui/icons-material'; import { logout, TopBar } from '@gridsuite/commons-ui'; import { useParameterState } from '../parameters'; import { APP_NAME, PARAM_LANGUAGE, PARAM_THEME } from '../../utils/config-params'; @@ -51,6 +52,20 @@ const tabs = new Map([ ))} />, ], + [ + MainPaths.announcements, + } + label={} + href={`/${MainPaths.announcements}`} + value={MainPaths.announcements} + key={`tab-${MainPaths.announcements}`} + iconPosition="start" + LinkComponent={forwardRef((props, ref) => ( + + ))} + />, + ], ]); const AppTopBar: FunctionComponent = () => { diff --git a/src/components/Grid/GridTable.tsx b/src/components/Grid/GridTable.tsx index 6aec48f..8b81cd9 100644 --- a/src/components/Grid/GridTable.tsx +++ b/src/components/Grid/GridTable.tsx @@ -18,22 +18,14 @@ import { useMemo, useState, } from 'react'; -import { - AppBar, - Box, - Button as MuiButton, - ButtonProps, - ButtonTypeMap, - ExtendButtonBaseTypeMap, - Grid, - Toolbar, -} from '@mui/material'; +import { Button as MuiButton, ButtonProps, ButtonTypeMap, ExtendButtonBaseTypeMap, Grid } from '@mui/material'; import { OverridableComponent, OverridableTypeMap, OverrideProps } from '@mui/material/OverridableComponent'; import { Delete } from '@mui/icons-material'; import { AgGrid, AgGridRef } from './AgGrid'; import { GridOptions } from 'ag-grid-community'; import { useIntl } from 'react-intl'; import { useSnackMessage } from '@gridsuite/commons-ui'; +import { CustomToolbar } from '../../pages/utils/CustomToolbar'; type GridTableExposed = { refresh: () => Promise; @@ -87,25 +79,7 @@ export const GridTable: GridTableWithRef = forwardRef(function AgGridToolbar - - ({ - marginLeft: 1, - '& > *': { - // mui's button set it own margin on itself... - marginRight: `${theme.spacing(1)} !important`, - '&:last-child': { - marginRight: '0 !important', - }, - }, - })} - > - {toolbarContent} - - - + {toolbarContent} diff --git a/src/pages/announcements/Announcements.tsx b/src/pages/announcements/Announcements.tsx new file mode 100644 index 0000000..07732f1 --- /dev/null +++ b/src/pages/announcements/Announcements.tsx @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { FunctionComponent, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { Button, Grid, List } from '@mui/material'; +import { AddCircleOutline } from '@mui/icons-material'; +import { CreateAnnouncementDialog } from './CreateAnnouncementDialog'; +import { useAnnouncements } from './useAnnouncements'; +import { ListItemAnnouncement } from './ListItemAnnouncement'; +import { CustomToolbar } from '../utils/CustomToolbar'; +import { ID } from './utils'; + +const Announcements: FunctionComponent = () => { + const intl = useIntl(); + const [openDialog, setOpenDialog] = useState(false); + const announcements = useAnnouncements(); + + return ( + + + + + + + setOpenDialog(false)} /> + + + {announcements.map((announcement) => ( + + ))} + + + + ); +}; +export default Announcements; diff --git a/src/pages/announcements/CreateAnnouncementDialog.tsx b/src/pages/announcements/CreateAnnouncementDialog.tsx new file mode 100644 index 0000000..d65747d --- /dev/null +++ b/src/pages/announcements/CreateAnnouncementDialog.tsx @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { FunctionComponent, useCallback } from 'react'; +import { Dialog, DialogActions, DialogContent, DialogTitle, Grid } from '@mui/material'; +import { FormattedMessage } from 'react-intl'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { DurationInput } from './DurationInput'; +import { SubmitButton, TextInput, useSnackMessage, CustomFormProvider } from '@gridsuite/commons-ui'; +import { AnnouncementFormData, emptyFormData, formSchema, fromFrontToBack, MESSAGE } from './utils'; +import { UserAdminSrv } from '../../services'; + +interface CreateAnnouncementDialogProps { + open: boolean; + onClose: () => void; +} + +export const CreateAnnouncementDialog: FunctionComponent = (props) => { + const { snackError } = useSnackMessage(); + + const formMethods = useForm({ + defaultValues: emptyFormData, + //@ts-ignore because yup TS is broken + resolver: yupResolver(formSchema), + }); + + const { handleSubmit, reset } = formMethods; + + const onSubmit = useCallback( + (formData: AnnouncementFormData) => { + UserAdminSrv.createAnnouncement(fromFrontToBack(formData)).catch((error) => + snackError({ + messageTxt: error.message, + headerId: 'announcements.error.add', + }) + ); + reset(); + props.onClose(); + }, + [props, reset, snackError] + ); + + return ( + //@ts-ignore because RHF TS is broken + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/pages/announcements/DurationInput.tsx b/src/pages/announcements/DurationInput.tsx new file mode 100644 index 0000000..03bf050 --- /dev/null +++ b/src/pages/announcements/DurationInput.tsx @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Grid, Typography } from '@mui/material'; +import { DAYS, DURATION, HOURS, MINUTES } from './utils'; +import { IntegerInput } from '@gridsuite/commons-ui'; +import { useIntl } from 'react-intl'; + +const centerStyle = { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', +}; + +const DaysAdornment = { + position: 'end', + text: 'd', +}; +const HoursAdornment = { + position: 'end', + text: 'h', +}; +const MinutesAdornment = { + position: 'end', + text: 'm', +}; + +export const DurationInput = () => { + const intl = useIntl(); + + return ( + + + + {intl.formatMessage({ + id: 'announcements.dialog.duration', + })} + + + + + + + : + + + + + + : + + + + + + ); +}; diff --git a/src/pages/announcements/ListItemAnnouncement.tsx b/src/pages/announcements/ListItemAnnouncement.tsx new file mode 100644 index 0000000..191c60f --- /dev/null +++ b/src/pages/announcements/ListItemAnnouncement.tsx @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { IconButton, ListItem, ListItemIcon, ListItemText } from '@mui/material'; +import { FunctionComponent } from 'react'; +import { Announcement, DATE, DAYS, DURATION, HOURS, ID, MESSAGE, MINUTES } from './utils'; +import { Cancel, Message, ScheduleSend, Timelapse } from '@mui/icons-material'; +import { UserAdminSrv } from '../../services'; +import { useSnackMessage } from '@gridsuite/commons-ui'; + +export const ListItemAnnouncement: FunctionComponent = (announcement) => { + const { snackError } = useSnackMessage(); + + return ( + + UserAdminSrv.deleteAnnouncement(announcement[ID]).catch((error) => + snackError({ + messageTxt: error.message, + headerId: 'announcements.error.delete', + }) + ) + } + edge="end" + aria-label="delete" + > + + + } + sx={{ minHeight: 100 }} + > + + + + + + + + + + + + + + ); +}; diff --git a/src/pages/announcements/index.ts b/src/pages/announcements/index.ts new file mode 100644 index 0000000..e53ae07 --- /dev/null +++ b/src/pages/announcements/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +export { default as Announcements } from './Announcements'; diff --git a/src/pages/announcements/useAnnouncements.ts b/src/pages/announcements/useAnnouncements.ts new file mode 100644 index 0000000..1ef2a91 --- /dev/null +++ b/src/pages/announcements/useAnnouncements.ts @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { useEffect, useState } from 'react'; +import ReconnectingWebSocket from 'reconnecting-websocket'; +import { Announcement, fromBackToFront } from './utils'; +import { UserAdminSrv } from '../../services'; +import { getUrlWithToken, getWsBase } from '../../utils/api-ws'; +import { useSnackMessage } from '@gridsuite/commons-ui'; + +const PREFIX_CONFIG_NOTIFICATION_WS = `${getWsBase()}/config-notification`; +const webSocketUrl = `${PREFIX_CONFIG_NOTIFICATION_WS}/global`; + +export function useAnnouncements() { + const { snackError } = useSnackMessage(); + + const [announcements, setAnnouncements] = useState([]); + + useEffect(() => { + function getAnnouncements() { + UserAdminSrv.getAnnouncements() + .then((announcements) => + setAnnouncements(announcements.map((announcement) => fromBackToFront(announcement))) + ) + .catch((error) => + snackError({ + messageTxt: error.message, + headerId: 'announcements.error.get', + }) + ); + } + + const rws = new ReconnectingWebSocket(() => getUrlWithToken(webSocketUrl)); + + rws.addEventListener('open', () => { + console.info('WebSocket for announcements is connected'); + // We retrieve the announcements at start + getAnnouncements(); + }); + + rws.addEventListener('message', (event) => { + // When new message, we just fetch back the latest list of announcements + getAnnouncements(); + }); + + rws.addEventListener('error', (event) => { + console.error('Unexpected announcements WebSocket error : ', event); + }); + + return () => rws.close(); + }, [snackError]); + + return announcements; +} diff --git a/src/pages/announcements/utils.ts b/src/pages/announcements/utils.ts new file mode 100644 index 0000000..a77aa8d --- /dev/null +++ b/src/pages/announcements/utils.ts @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import yup from '../../utils/yup-config'; +import { Duration, parse, serialize } from 'tinyduration'; + +export const ID = 'id'; +export const DATE = 'creationDate'; +export const MESSAGE = 'message'; +export const DURATION = 'duration'; +export const DAYS = 'days'; +export const HOURS = 'hours'; +export const MINUTES = 'minutes'; + +export interface DurationFormData { + [DAYS]: number | null; + [HOURS]: number | null; + [MINUTES]: number | null; +} + +export interface AnnouncementFormData { + [MESSAGE]: string; + [DURATION]: DurationFormData; +} + +export const emptyFormData: AnnouncementFormData = { + [MESSAGE]: '', + [DURATION]: { + [DAYS]: null, + [HOURS]: null, + [MINUTES]: null, + }, +}; + +// const formSchema: ObjectSchema = yup +export const formSchema = yup + .object() + .shape({ + [MESSAGE]: yup.string().max(100).required(), + [DURATION]: yup + .object() + .shape( + { + [DAYS]: getDurationUnitSchema(HOURS, MINUTES), + [HOURS]: getDurationUnitSchema(DAYS, MINUTES), + [MINUTES]: getDurationUnitSchema(DAYS, HOURS), + }, + // to avoid cyclic dependencies + [ + [HOURS, MINUTES], + [DAYS, MINUTES], + [DAYS, HOURS], + ] + ) + .required(), + }) + .required(); + +function getDurationUnitSchema(otherUnitName1: string, otherUnitName2: string) { + return yup.number().when([otherUnitName1, otherUnitName2], { + is: (v1: number | null, v2: number | null) => v1 === null && v2 === null, + then: (schema) => schema.required(), + otherwise: (schema) => schema.nullable(), + }); +} + +export interface AnnouncementServerData { + [ID]: string; + [DATE]: string; + [MESSAGE]: string; + [DURATION]: string; +} + +export function fromFrontToBack(formData: AnnouncementFormData) { + return { + [MESSAGE]: formData[MESSAGE], + [DURATION]: serialize(formData[DURATION] as Duration), // null values also works + }; +} + +export interface Announcement extends AnnouncementFormData { + [ID]: string; + [DATE]: string; +} + +export function fromBackToFront(serverData: AnnouncementServerData): Announcement { + // In server side, duration is stored in hours only, so we need to compute the days + const duration = parse(serverData[DURATION]); + if (duration[HOURS] && duration[HOURS] >= 24) { + duration[DAYS] = Math.trunc(duration[HOURS] / 24); + duration[HOURS] %= 24; + } + return { + [ID]: serverData[ID], + [DATE]: new Date(serverData[DATE]).toLocaleString(), + [MESSAGE]: serverData[MESSAGE], + [DURATION]: duration as DurationFormData, + }; +} diff --git a/src/pages/utils/CustomToolbar.tsx b/src/pages/utils/CustomToolbar.tsx new file mode 100644 index 0000000..511671a --- /dev/null +++ b/src/pages/utils/CustomToolbar.tsx @@ -0,0 +1,26 @@ +import { AppBar, Box, Toolbar } from '@mui/material'; +import { FunctionComponent, PropsWithChildren } from 'react'; + +export const CustomToolbar: FunctionComponent = (props) => { + return ( + + ({ + marginLeft: 1, + '& > *': { + // mui's button set it own margin on itself... + marginRight: `${theme.spacing(1)} !important`, + '&:last-child': { + marginRight: '0 !important', + }, + }, + })} + > + {props.children} + + + + ); +}; diff --git a/src/routes/router.tsx b/src/routes/router.tsx index 1336f03..b4eec13 100644 --- a/src/routes/router.tsx +++ b/src/routes/router.tsx @@ -28,10 +28,12 @@ import { updateUserManagerDestructured } from '../redux/actions'; import HomePage from './HomePage'; import { getErrorMessage } from '../utils/error'; import { AppDispatch } from '../redux/store'; +import { Announcements } from '../pages/announcements'; export enum MainPaths { users = 'users', profiles = 'profiles', + announcements = 'announcements', } export function appRoutes(): RouteObject[] { @@ -51,6 +53,13 @@ export function appRoutes(): RouteObject[] { appBar_tab: MainPaths.users, }, }, + { + path: `/${MainPaths.announcements}`, + element: , + handle: { + appBar_tab: MainPaths.announcements, + }, + }, { path: `/${MainPaths.profiles}`, element: , diff --git a/src/services/user-admin.ts b/src/services/user-admin.ts index 40d5bff..e3afb91 100644 --- a/src/services/user-admin.ts +++ b/src/services/user-admin.ts @@ -8,6 +8,7 @@ import { User } from 'oidc-client'; import { backendFetch, backendFetchJson, getRestBase } from '../utils/api-rest'; import { extractUserSub, getToken, getUser } from '../utils/api'; +import { AnnouncementServerData, DURATION, MESSAGE } from '../pages/announcements/utils'; import { UUID } from 'crypto'; const USER_ADMIN_URL = `${getRestBase()}/user-admin/v1`; @@ -200,3 +201,39 @@ export function deleteProfiles(names: string[]): Promise { throw reason; }); } + +export function getAnnouncements(): Promise { + console.debug('Getting list of announcements...'); + return backendFetchJson(`${USER_ADMIN_URL}/announcements`, { + method: 'get', + cache: 'default', + }).catch((reason) => { + console.error(`Error while getting the list of announcements : ${reason}`); + throw reason; + }) as Promise; +} + +export function createAnnouncement(announcement: { [MESSAGE]: string; [DURATION]: string }) { + const body = JSON.stringify(announcement); + console.debug('Creating announcement...' + body); + return backendFetch(`${USER_ADMIN_URL}/announcements`, { + method: 'post', + headers: { + 'Content-Type': 'application/json', + }, + body, + }).catch((reason) => { + console.error(`Error while creating announcement : ${reason}`); + throw reason; + }); +} + +export function deleteAnnouncement(id: string) { + console.debug('Deleting announcement with ID ' + id); + return backendFetch(`${USER_ADMIN_URL}/announcements/${id}`, { + method: 'delete', + }).catch((reason) => { + console.error(`Error while deleting announcement : ${reason}`); + throw reason; + }); +} diff --git a/src/translations/en.json b/src/translations/en.json index 13c2b0f..9daf091 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -13,6 +13,7 @@ "userQuotaPositive": "User quota must be positive", "appBar.tabs.users": "Users", + "appBar.tabs.announcement": "Announcements", "appBar.tabs.profiles": "Profiles", "appBar.tabs.connections": "Connections", @@ -44,6 +45,19 @@ "users.form.content": "Please fill in new user data.", "users.form.field.username.label": "User ID", + "announcements.add": "New", + "announcements.dialog.title": "Announcement", + "announcements.dialog.input": "Message to send", + "announcements.dialog.duration": "Duration : ", + "announcements.error.get": "Error while getting the list of announcements", + "announcements.error.add": "Error while creating announcement", + "announcements.error.delete": "Error while deleting announcement", + "duration.days": "Days", + "duration.hours": "Hours", + "duration.minutes": "Minutes", + + "YupRequired": "Required", + "users.form.delete.dialog.title": "Delete a user", "users.form.delete.multiple.dialog.message": "{itemsCount} users will be deleted.", "users.form.delete.dialog.message": "{itemName} will be deleted.", diff --git a/src/translations/fr.json b/src/translations/fr.json index 2588c0c..b71c5d2 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -13,6 +13,7 @@ "userQuotaPositive": "Le quota utilisateur doit être positif", "appBar.tabs.users": "Utilisateurs", + "appBar.tabs.announcement": "Annonces", "appBar.tabs.profiles": "Profils", "appBar.tabs.connections": "Connexions", @@ -45,6 +46,19 @@ "users.form.content": "Veuillez renseigner les informations de l'utilisateur.", "users.form.field.username.label": "ID utilisateur", + "announcements.add": "Nouveau", + "announcements.dialog.title": "Annonce", + "announcements.dialog.input": "Message à envoyer", + "announcements.dialog.duration": "Durée : ", + "announcements.error.get": "Erreur lors de la récupération de la liste des annonces", + "announcements.error.add": "Erreur lors de la création d'une annonce", + "announcements.error.delete": "Erreur lors de la suppression d'une annonce", + "duration.days": "Jours", + "duration.hours": "Heures", + "duration.minutes": "Minutes", + + "YupRequired": "Requis", + "users.form.delete.dialog.title": "Supprimer utilisateur", "users.form.delete.dialog.message": "{itemName} va être supprimé.", "users.form.delete.multiple.dialog.message": "{itemsCount} utilisateurs vont être supprimés.", diff --git a/src/utils/yup-config.ts b/src/utils/yup-config.ts new file mode 100644 index 0000000..b918c6a --- /dev/null +++ b/src/utils/yup-config.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import * as yup from 'yup'; + +yup.setLocale({ + mixed: { + required: 'YupRequired', + notType: ({ type }) => { + if (type === 'number') { + return 'YupNotTypeNumber'; + } else { + return 'YupNotTypeDefault'; + } + }, + }, +}); + +export default yup;