diff --git a/package-lock.json b/package-lock.json index 1375047..0cd0068 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,21 +11,24 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", - "@gridsuite/commons-ui": "0.95.0", + "@gridsuite/commons-ui": "0.100.0", "@hookform/resolvers": "^4.0.0", "@mui/icons-material": "^5.16.14", "@mui/lab": "5.0.0-alpha.175", "@mui/material": "^5.16.14", + "@mui/x-date-pickers": "^7.29.3", "@mui/x-tree-view": "^7.28.1", "@reduxjs/toolkit": "^2.5.1", "ag-grid-community": "^33.1.0", "ag-grid-react": "^33.1.0", "core-js": "^3.40.0", + "date-fns": "^4.1.0", "notistack": "^3.0.2", "oidc-client": "^1.11.5", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.2", + "react-hook-form-mui": "^7.6.0", "react-intl": "^7.1.6", "react-redux": "^9.2.0", "react-router": "^7.4.1", @@ -2180,9 +2183,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz", - "integrity": "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" @@ -3071,19 +3074,22 @@ } }, "node_modules/@gridsuite/commons-ui": { - "version": "0.95.0", - "resolved": "https://registry.npmjs.org/@gridsuite/commons-ui/-/commons-ui-0.95.0.tgz", - "integrity": "sha512-squCtrVorLTHo0R4DitiHN9EAnuABc02sm1x1q5UstIbRZNPO/wS9YkZMcFO6ce+hJ1KBDSP7rmy5BXnJxswrg==", + "version": "0.100.0", + "resolved": "https://registry.npmjs.org/@gridsuite/commons-ui/-/commons-ui-0.100.0.tgz", + "integrity": "sha512-eQfRkNh4NIfFUz+hPLiu4XXQz7wzUg6jpMcQsJ0D5oqvztS1amIHgtdFkSxUBhPzwIvQ22mGpo2yeSdIi/IY/Q==", "license": "MPL-2.0", "dependencies": { "@ag-grid-community/locale": "^33.1.0", "@hello-pangea/dnd": "^18.0.1", + "@material-symbols/svg-400": "^0.31.2", + "@mui/base": "^5.0.0-beta.40-0", "@react-querybuilder/dnd": "^8.2.0", "@react-querybuilder/material": "^8.2.0", "autosuggest-highlight": "^3.3.4", "clsx": "^2.1.1", "jwt-decode": "^4.0.0", "localized-countries": "^2.0.0", + "mui-nested-menu": "^4.0.0", "oidc-client": "^1.11.5", "prop-types": "^15.8.1", "react-csv-downloader": "^3.3.0", @@ -3791,6 +3797,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@material-symbols/svg-400": { + "version": "0.31.2", + "resolved": "https://registry.npmjs.org/@material-symbols/svg-400/-/svg-400-0.31.2.tgz", + "integrity": "sha512-XKl1pC00ogBHw2NGqJt31XgwXX5bLVk9BgujxPe6lAE015L5Ji5BIG8le6/6xf5QViIqcRj4IWyh69JlJO/Gyg==", + "license": "Apache-2.0" + }, "node_modules/@mui/base": { "version": "5.0.0-beta.40-0", "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.40-0.tgz", @@ -4046,10 +4058,13 @@ } }, "node_modules/@mui/types": { - "version": "7.2.21", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.21.tgz", - "integrity": "sha512-6HstngiUxNqLU+/DPqlUJDIPbzUBxIVHb1MmXP0eTWDIROiCR2viugXpEif0PPe2mLqqakPzzRClWAnK+8UJww==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.1.tgz", + "integrity": "sha512-gUL8IIAI52CRXP/MixT1tJKt3SI6tVv4U/9soFsTtAsHzaJQptZ42ffdHZV3niX1ei0aUgMvOxBBN0KYqdG39g==", "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.0" + }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -4089,6 +4104,92 @@ } } }, + "node_modules/@mui/x-date-pickers": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.29.3.tgz", + "integrity": "sha512-/A0/8fpLnEFeJKr5YQsI8jqlWPJlOtgfCGcqXHVDOLxgV3lW49+Kh5TZAc1yi6HKT3AG6k4DkNwTuu/RjJeMFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0", + "@mui/x-internals": "7.29.0", + "@types/react-transition-group": "^4.4.11", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2 || ^3.0.0", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, + "node_modules/@mui/x-date-pickers/node_modules/@mui/x-internals": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz", + "integrity": "sha512-+Gk6VTZIFD70XreWvdXBwKd8GZ2FlSCuecQFzm6znwqXg1ZsndavrhG9tkxpxo2fM1Zf7Tk8+HcOO0hCbhTQFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@mui/x-internals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.28.0.tgz", @@ -7159,6 +7260,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -12104,6 +12215,31 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mui-nested-menu": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/mui-nested-menu/-/mui-nested-menu-4.0.1.tgz", + "integrity": "sha512-o/UaG3oXvHI+phKZzTJdX/fAqgJXQC5xjo/KjMrJq8XtShs+n+JmVYCqD6ATIyoTamEt7+5LAjcIy4iyARcKdg==", + "license": "MIT", + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material": "^5.0.0 || ^6.0.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -13136,6 +13272,34 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-hook-form-mui": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/react-hook-form-mui/-/react-hook-form-mui-7.6.0.tgz", + "integrity": "sha512-TGNL5N3vr7fe7MihuC0T1D1yKuXrB/km8Y92AtlVBGA2LvICaPU8unfcVzeTWj9/1pS1lUZAMjCsDsT/KU3jZg==", + "license": "MIT", + "workspaces": [ + "apps/*", + "packages/*" + ], + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@mui/icons-material": ">= 5.x <8", + "@mui/material": ">= 5.x <8", + "@mui/x-date-pickers": ">=7.17.0 <8", + "react": ">=17 <20", + "react-hook-form": ">=7.33.1" + }, + "peerDependenciesMeta": { + "@mui/icons-material": { + "optional": true + }, + "@mui/x-date-pickers": { + "optional": true + } + } + }, "node_modules/react-intl": { "version": "7.1.6", "resolved": "https://registry.npmjs.org/react-intl/-/react-intl-7.1.6.tgz", @@ -13162,9 +13326,9 @@ } }, "node_modules/react-is": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", - "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz", + "integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==", "license": "MIT" }, "node_modules/react-papaparse": { diff --git a/package.json b/package.json index 7d022aa..4b1890e 100644 --- a/package.json +++ b/package.json @@ -16,21 +16,24 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", - "@gridsuite/commons-ui": "0.95.0", + "@gridsuite/commons-ui": "0.100.0", "@hookform/resolvers": "^4.0.0", "@mui/icons-material": "^5.16.14", "@mui/lab": "5.0.0-alpha.175", "@mui/material": "^5.16.14", + "@mui/x-date-pickers": "^7.29.3", "@mui/x-tree-view": "^7.28.1", "@reduxjs/toolkit": "^2.5.1", "ag-grid-community": "^33.1.0", "ag-grid-react": "^33.1.0", "core-js": "^3.40.0", + "date-fns": "^4.1.0", "notistack": "^3.0.2", "oidc-client": "^1.11.5", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.2", + "react-hook-form-mui": "^7.6.0", "react-intl": "^7.1.6", "react-redux": "^9.2.0", "react-router": "^7.4.1", diff --git a/src/components/App/app-top-bar.tsx b/src/components/App/app-top-bar.tsx index 8e79691..0293ec7 100644 --- a/src/components/App/app-top-bar.tsx +++ b/src/components/App/app-top-bar.tsx @@ -15,7 +15,7 @@ import { useState, } from 'react'; import { capitalize, Tab, Tabs, useTheme } from '@mui/material'; -import { Groups, ManageAccounts, PeopleAlt } from '@mui/icons-material'; +import { Groups, ManageAccounts, NotificationImportant, PeopleAlt } from '@mui/icons-material'; import { fetchAppsMetadata, logout, Metadata, TopBar } from '@gridsuite/commons-ui'; import { useParameterState } from '../parameters'; import { APP_NAME, PARAM_LANGUAGE, PARAM_THEME } from '../../utils/config-params'; @@ -73,6 +73,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 = () => { @@ -96,6 +110,7 @@ const AppTopBar: FunctionComponent = () => { const [languageLocal, handleChangeLanguage] = useParameterState(PARAM_LANGUAGE); const [appsAndUrls, setAppsAndUrls] = useState([]); + useEffect(() => { if (user !== null) { fetchAppsMetadata().then((res) => { diff --git a/src/components/App/app-wrapper.tsx b/src/components/App/app-wrapper.tsx index 84e7afe..9dc16ae 100644 --- a/src/components/App/app-wrapper.tsx +++ b/src/components/App/app-wrapper.tsx @@ -6,14 +6,18 @@ */ import App from './app'; -import { FunctionComponent, useMemo } from 'react'; +import { FunctionComponent, type PropsWithChildren, useMemo } from 'react'; import { CssBaseline, responsiveFontSizes, ThemeOptions } from '@mui/material'; import { createTheme, StyledEngineProvider, Theme, ThemeProvider } from '@mui/material/styles'; import { enUS as MuiCoreEnUS, frFR as MuiCoreFrFR } from '@mui/material/locale'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; +import { enUS as MuiDatePickersEnUS, frFR as MuiDatePickersFrFR } from '@mui/x-date-pickers/locales'; +import { enUS as dateFnsEnUS, fr as dateFnsFr } from 'date-fns/locale'; import { + CardErrorBoundary, cardErrorBoundaryEn, cardErrorBoundaryFr, - CardErrorBoundary, GsLangUser, GsTheme, LANG_ENGLISH, @@ -21,12 +25,12 @@ import { LIGHT_THEME, loginEn, loginFr, + NotificationsProvider, SnackbarProvider, topBarEn, topBarFr, - NotificationsProvider, } from '@gridsuite/commons-ui'; -import { IntlConfig, IntlProvider } from 'react-intl'; +import { type IntlConfig, IntlProvider } from 'react-intl'; import { Provider, useSelector } from 'react-redux'; import messages_en from '../../translations/en.json'; import messages_fr from '../../translations/fr.json'; @@ -103,7 +107,8 @@ const getMuiTheme = (theme: GsTheme, locale: GsLangUser): Theme => { return responsiveFontSizes( createTheme( theme === LIGHT_THEME ? lightTheme : darkTheme, - locale === LANG_FRENCH ? MuiCoreFrFR : MuiCoreEnUS // MUI core translations + locale === LANG_FRENCH ? MuiCoreFrFR : MuiCoreEnUS, // MUI core translations + locale === LANG_FRENCH ? MuiDatePickersFrFR : MuiDatePickersEnUS // MUI x-date-pickers translations ) ); }; @@ -125,10 +130,21 @@ const messages: Record = { const basename = new URL(document.baseURI ?? '').pathname; +function intlToDateFnsLocale(lng: GsLangUser) { + switch (lng) { + case LANG_FRENCH: + return dateFnsFr; + case LANG_ENGLISH: + return dateFnsEnUS; + default: + return undefined; + } +} + /** * Layer injecting Theme, Internationalization (i18n) and other tools (snackbar, error boundary, ...) */ -const AppWrapperRouterLayout: typeof App = (props, context) => { +const AppWrapperRouterLayout: typeof App = (props: Readonly>) => { const computedLanguage = useSelector((state: AppState) => state.computedLanguage); const theme = useSelector((state: AppState) => state[PARAM_THEME]); const themeCompiled = useMemo(() => getMuiTheme(theme, computedLanguage), [computedLanguage, theme]); @@ -137,14 +153,20 @@ const AppWrapperRouterLayout: typeof App = (props, context) => { - - - - - {props.children} - - - + + + + + + {props.children} + + + + diff --git a/src/components/App/app.tsx b/src/components/App/app.tsx index e08d06e..86746c1 100644 --- a/src/components/App/app.tsx +++ b/src/components/App/app.tsx @@ -5,10 +5,16 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { FunctionComponent, PropsWithChildren, useCallback, useEffect } from 'react'; +import { type PropsWithChildren, useCallback, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Grid } from '@mui/material'; -import { CardErrorBoundary, useNotificationsListener, useSnackMessage } from '@gridsuite/commons-ui'; +import { + AnnouncementNotification, + CardErrorBoundary, + NotificationsUrlKeys, + useNotificationsListener, + useSnackMessage, +} from '@gridsuite/commons-ui'; import { selectComputedLanguage, selectLanguage, selectTheme } from '../../redux/actions'; import { AppState } from '../../redux/reducer'; import { ConfigParameters, ConfigSrv } from '../../services'; @@ -17,9 +23,8 @@ import { getComputedLanguage } from '../../utils/language'; import AppTopBar from './app-top-bar'; import { useDebugRender } from '../../utils/hooks'; import { AppDispatch } from '../../redux/store'; -import { NOTIFICATIONS_URL_KEYS } from '../../utils/notifications-provider'; -const App: FunctionComponent> = (props, context) => { +export default function App({ children }: Readonly>) { useDebugRender('app'); const { snackError } = useSnackMessage(); const dispatch = useDispatch(); @@ -59,7 +64,7 @@ const App: FunctionComponent> = (props, context) => { [updateParams, snackError] ); - useNotificationsListener(NOTIFICATIONS_URL_KEYS.CONFIG, { listenerCallbackMessage: updateConfig }); + useNotificationsListener(NotificationsUrlKeys.CONFIG, { listenerCallbackMessage: updateConfig }); useEffect(() => { if (user !== null) { @@ -93,10 +98,12 @@ const App: FunctionComponent> = (props, context) => { sx={{ height: '100vh', width: '100vw' }} > + + + - {/*Router outlet ->*/ props.children} + {/*Router outlet ->*/ children} ); -}; -export default App; +} diff --git a/src/components/Grid/AgGrid.tsx b/src/components/Grid/AgGrid.tsx index 000d941..92c2f9f 100644 --- a/src/components/Grid/AgGrid.tsx +++ b/src/components/Grid/AgGrid.tsx @@ -37,7 +37,7 @@ type ForwardRef = typeof forwardRef; type ForwardRefComponent = ReturnType>; interface AgGridWithRef extends FunctionComponent> { - ( + ( props: PropsWithoutRef> & RefAttributes> ): ReturnType, AgGridRef>>; } diff --git a/src/module-mui.d.ts b/src/module-mui.d.ts index d60c6d1..015fddf 100644 --- a/src/module-mui.d.ts +++ b/src/module-mui.d.ts @@ -8,6 +8,9 @@ import { CSSObject } from '@mui/styled-engine'; import { Theme as MuiTheme, ThemeOptions as MuiThemeOptions } from '@mui/material/styles/createTheme'; +// https://mui.com/x/react-date-pickers/quickstart/#theme-augmentation +import type {} from '@mui/x-date-pickers/themeAugmentation'; + declare module '@mui/material/styles/createTheme' { export * from '@mui/material/styles/createTheme'; diff --git a/src/pages/announcements/add-announcement-form.tsx b/src/pages/announcements/add-announcement-form.tsx new file mode 100644 index 0000000..d7bd256 --- /dev/null +++ b/src/pages/announcements/add-announcement-form.tsx @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2025, 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 { useCallback, useMemo } from 'react'; +import { Grid } from '@mui/material'; +import { type DateOrTimeView } from '@mui/x-date-pickers'; +import { useIntl } from 'react-intl'; +import { SubmitButton, useSnackMessage } from '@gridsuite/commons-ui'; +import yup from '../../utils/yup-config'; +import { type InferType } from 'yup'; +import { type SubmitHandler, useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { FormContainer, SelectElement, TextareaAutosizeElement } from 'react-hook-form-mui'; +import { DateTimePickerElement, type DateTimePickerElementProps } from 'react-hook-form-mui/date-pickers'; +import { UserAdminSrv } from '../../services'; +import { getErrorMessage, handleAnnouncementCreationErrors } from '../../utils/error'; + +export const MESSAGE = 'message'; +export const START_DATE = 'startDate'; +export const END_DATE = 'endDate'; +export const SEVERITY = 'severity'; + +export type AddAnnouncementFormProps = { + onAnnouncementCreated?: () => void; +}; + +const formSchema = yup + .object() + .shape({ + [MESSAGE]: yup.string().nullable().trim().min(1).required(), + [START_DATE]: yup.string().nullable().datetime().required(), + [END_DATE]: yup + .string() + .nullable() + .datetime() + .required() + .when(START_DATE, (startDate, schema) => + schema.test( + 'is-after-start', + 'End date must be after start date', + (endDate) => !startDate || !endDate || new Date(endDate) > new Date(startDate as unknown as string) + ) + ), + [SEVERITY]: yup + .string() + .nullable() + .oneOf(Object.values(UserAdminSrv.AnnouncementSeverity)) + .required(), + }) + .required(); +type FormSchema = InferType; + +const datetimePickerTransform: NonNullable['transform']> = { + input: (value) => (value ? new Date(value) : null), + output: (value) => value?.toISOString() ?? '', +}; +const pickerView = ['year', 'month', 'day', 'hours', 'minutes'] as const satisfies readonly DateOrTimeView[]; + +export default function AddAnnouncementForm({ onAnnouncementCreated }: Readonly) { + const intl = useIntl(); + const { snackError } = useSnackMessage(); + + const formContext = useForm({ + resolver: yupResolver(formSchema), + defaultValues: { + // @ts-expect-error: nullable() is called, so null is accepted as default value + [MESSAGE]: null, + // @ts-expect-error: nullable() is called, so null is accepted as default value + [START_DATE]: null, + // @ts-expect-error: nullable() is called, so null is accepted as default value + [END_DATE]: null, + // @ts-expect-error: nullable() is called, so null is accepted as default value + [SEVERITY]: null, + }, + }); + const { getValues } = formContext; + const startDateValue = getValues(START_DATE); + + const onSubmit = useCallback>( + (params) => { + UserAdminSrv.addAnnouncement({ + message: params.message, + startDate: params.startDate, + endDate: params.endDate, + severity: params.severity, + }) + .then(() => onAnnouncementCreated?.()) + .catch((error) => { + let errorMessage = getErrorMessage(error) ?? ''; + if (!handleAnnouncementCreationErrors(errorMessage, snackError)) { + snackError({ headerId: 'announcements.form.errCreateAnnouncement', messageTxt: errorMessage }); + } + }); + }, + [onAnnouncementCreated, snackError] + ); + + return ( + + formContext={formContext} + onSuccess={onSubmit} + //criteriaMode="all" ? + mode="onChange" // or maybe mode "all"? + reValidateMode="onChange" + FormProps={{ style: { height: '100%' } }} + > + + + + + name={START_DATE} + label={intl.formatMessage({ id: 'announcements.table.startDate' })} + transform={datetimePickerTransform} + timezone="system" + views={pickerView} + timeSteps={{ hours: 1, minutes: 1, seconds: 0 }} + disablePast + /> + + + + name={END_DATE} + label={intl.formatMessage({ id: 'announcements.table.endDate' })} + transform={datetimePickerTransform} + timezone="system" + views={pickerView} + timeSteps={{ hours: 1, minutes: 1, seconds: 0 }} + disablePast + minDateTime={startDateValue ? new Date(startDateValue) : undefined} + /> + + + + + name={SEVERITY} + label={intl.formatMessage({ id: 'announcements.severity' })} + options={useMemo( + () => + Object.values(UserAdminSrv.AnnouncementSeverity).map((value) => ({ + id: value, + label: intl.formatMessage({ id: `announcements.severity.${value}` }), + })), + [intl] + )} + fullWidth + /> + + + + name={MESSAGE} + label={intl.formatMessage({ id: 'announcements.form.message' })} + rows={5} // why does it do nothing even if the field is set as multiline?! + fullWidth + /> + + + + + + + ); +} diff --git a/src/pages/announcements/announcements-page.tsx b/src/pages/announcements/announcements-page.tsx new file mode 100644 index 0000000..e48f682 --- /dev/null +++ b/src/pages/announcements/announcements-page.tsx @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2025, 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 type { UUID } from 'crypto'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { Divider, Grid, Typography } from '@mui/material'; +import { useSnackMessage } from '@gridsuite/commons-ui'; +import type { ColDef, GetRowIdParams, ValueFormatterFunc } from 'ag-grid-community'; +import { type GridTableRef } from '../../components/Grid'; +import { Announcement, UserAdminSrv } from '../../services'; +import AddAnnouncementForm from './add-announcement-form'; +import AgGrid from '../../components/Grid/AgGrid'; +import CancelCellRenderer from './cancel-cell-renderer'; +import { getErrorMessage } from '../../utils/error'; + +const defaultColDef: ColDef = { + editable: false, + resizable: true, + minWidth: 50, + cellRenderer: 'agAnimateSlideCellRenderer', + rowDrag: false, + sortable: true, + lockVisible: true, +}; + +function getRowId(params: GetRowIdParams) { + return params.data.id; +} + +export default function AnnouncementsPage() { + const intl = useIntl(); + const gridRef = useRef>(null); + const { snackError } = useSnackMessage(); + + const [data, setData] = useState(null); + + const loadDataAndSave = useCallback(async (): Promise => { + try { + setData(await UserAdminSrv.fetchAnnouncementList()); + } catch (error) { + snackError({ messageTxt: getErrorMessage(error) ?? undefined, headerId: 'table.error.retrieve' }); + } + }, [snackError]); + + const renderSeverity = useCallback>>( + (params) => { + switch (params.value) { + case UserAdminSrv.AnnouncementSeverity.INFO: + return intl.formatMessage({ id: 'announcements.severity.INFO' }); + case UserAdminSrv.AnnouncementSeverity.WARN: + return intl.formatMessage({ id: 'announcements.severity.WARN' }); + default: + return params.value || ''; + } + }, + [intl] + ); + + const renderDate = useCallback>>( + (params) => (params.value ? intl.formatDate(params.value, { dateStyle: 'short', timeStyle: 'short' }) : ''), + [intl] + ); + + const refreshGrid = useCallback(() => { + gridRef.current?.context?.refresh?.(); + }, []); + + const handleDeleteAnnouncement = useCallback( + (announcementId: UUID) => { + UserAdminSrv.deleteAnnouncement(announcementId).then(refreshGrid); + }, + [refreshGrid] + ); + + const columns = useMemo( + (): ColDef[] => [ + { + field: 'startDate', + valueFormatter: renderDate, + headerName: intl.formatMessage({ id: 'announcements.table.startDate' }), + sort: 'asc', + sortIndex: 1, + initialWidth: 150, + }, + { + field: 'endDate', + valueFormatter: renderDate, + headerName: intl.formatMessage({ id: 'announcements.table.endDate' }), + sort: 'asc', + sortIndex: 2, + initialWidth: 150, + }, + { + field: 'severity', + valueFormatter: renderSeverity, + headerName: intl.formatMessage({ id: 'announcements.severity' }), + initialWidth: 150, + }, + { + field: 'message', + cellDataType: 'text', + flex: 1, + headerName: intl.formatMessage({ id: 'announcements.table.message' }), + }, + { + field: 'id', + cellRenderer: CancelCellRenderer, + cellRendererParams: { onClickHandler: handleDeleteAnnouncement }, + headerName: '', + initialWidth: 70, + }, + ], + [renderDate, intl, handleDeleteAnnouncement, renderSeverity] + ); + + const gridContext = useMemo(() => ({ refresh: loadDataAndSave }), [loadDataAndSave]); + + // Note: using for the columns didn't work + return ( + + + + + + + + + + + + + + + + + + + + + + + ref={gridRef} + rowData={data} + alwaysShowVerticalScroll + onGridReady={loadDataAndSave} + columnDefs={columns} + defaultColDef={defaultColDef} + gridId="table-announcements" + getRowId={getRowId} + context={gridContext} + /> + + + + ); +} diff --git a/src/pages/announcements/cancel-cell-renderer.tsx b/src/pages/announcements/cancel-cell-renderer.tsx new file mode 100644 index 0000000..900804e --- /dev/null +++ b/src/pages/announcements/cancel-cell-renderer.tsx @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2025, 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 type { UUID } from 'crypto'; +import { useCallback } from 'react'; +import { IconButton, type IconButtonProps, Tooltip } from '@mui/material'; +import { Delete } from '@mui/icons-material'; +import { FormattedMessage } from 'react-intl'; + +export type CancelButtonCellRendererProps = { value: UUID; onClickHandler: (value: UUID) => void }; + +export default function CancelCellRenderer({ value, onClickHandler }: Readonly) { + const handleClick = useCallback>(() => { + onClickHandler(value); + }, [onClickHandler, value]); + return ( + }> + + + + + ); +} diff --git a/src/pages/announcements/index.ts b/src/pages/announcements/index.ts new file mode 100644 index 0000000..efe0347 --- /dev/null +++ b/src/pages/announcements/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2025, 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-page'; diff --git a/src/pages/index.ts b/src/pages/index.ts index bdfb9db..4c5472f 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -8,3 +8,4 @@ export * from './users'; export * from './profiles'; export * from './groups'; +export * from './announcements'; diff --git a/src/routes/utils.tsx b/src/routes/utils.tsx index db8a25b..427ee52 100644 --- a/src/routes/utils.tsx +++ b/src/routes/utils.tsx @@ -11,11 +11,13 @@ import ErrorPage from './ErrorPage'; import HomePage from './HomePage'; import { getPreLoginPath } from '@gridsuite/commons-ui'; import { FormattedMessage } from 'react-intl'; +import { Announcements } from '../pages/announcements'; export enum MainPaths { users = 'users', profiles = 'profiles', groups = 'groups', + announcements = 'announcements', } export function appRoutes(): RouteObject[] { @@ -49,6 +51,13 @@ export function appRoutes(): RouteObject[] { appBar_tab: MainPaths.groups, }, }, + { + path: `/${MainPaths.announcements}`, + element: , + handle: { + appBar_tab: MainPaths.announcements, + }, + }, ], }, { diff --git a/src/services/user-admin.ts b/src/services/user-admin.ts index 96aa056..bedc663 100644 --- a/src/services/user-admin.ts +++ b/src/services/user-admin.ts @@ -267,3 +267,59 @@ export function addGroup(group: string): Promise { throw reason; }); } + +export enum AnnouncementSeverity { + INFO = 'INFO', + WARN = 'WARN', +} + +export function sanitizeString(val: string | null | undefined) { + const trimmedValue = val?.trim(); + return trimmedValue === '' ? null : trimmedValue; +} + +export type NewAnnouncement = { + startDate: string; + endDate: string; + message: string; + severity: AnnouncementSeverity; +}; +export type Announcement = NewAnnouncement & { + id: UUID; +}; + +export async function addAnnouncement(announcement: NewAnnouncement) { + console.debug(`Creating announcement ...`); + return backendFetchJson( + `${USER_ADMIN_URL}/announcements?startDate=${announcement.startDate}&endDate=${announcement.endDate}&severity=${announcement.severity}`, + { + method: 'put', + headers: { + Accept: 'application/json', + 'Content-Type': 'text/plain', + }, + body: sanitizeString(announcement.message), + } + ).catch((reason) => { + console.error('Error while creating announcement:', reason); + throw reason; + }); +} + +export async function fetchAnnouncementList() { + console.debug(`Fetching announcement ...`); + try { + return await backendFetchJson(`${USER_ADMIN_URL}/announcements`, { method: 'get' }); + } catch (reason) { + console.error('Error while fetching announcement:', reason); + throw reason; + } +} + +export async function deleteAnnouncement(announcementId: UUID): Promise { + console.debug(`Deleting announcement ${announcementId}...`); + await backendFetch(`${USER_ADMIN_URL}/announcements/${announcementId}`, { 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 91a58a1..b26a64f 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -16,6 +16,7 @@ "appBar.tabs.profiles": "Profiles", "appBar.tabs.groups": "Groups", "appBar.tabs.connections": "Connections", + "appBar.tabs.warningBanner": "Warning banner", "table.noRows": "No data", "table.error.retrieve": "Error while retrieving data", @@ -109,5 +110,20 @@ "groups.form.modification.title": "Edit group", "linked.path.display.noLink": "no configuration selected.", - "linked.path.display.invalidLink": "invalid configurations link." + "linked.path.display.invalidLink": "invalid configurations link.", + + "announcements.programNewMessage": "New message scheduling", + "announcements.programmedMessage": "Programmed messages", + "announcements.table.message": "Message", + "announcements.table.startDate": "Start date", + "announcements.table.endDate": "Stop date", + "announcements.table.cancel": "Cancel", + "announcements.severity": "Severity", + "announcements.severity.INFO": "Info", + "announcements.severity.WARN": "Warning", + "announcements.form.message": "Announcement message", + "announcements.form.errCreateAnnouncement": "Error while creating announcement:", + "announcements.form.errCreateAnnouncement.noOverlapAllowedErr": "The date overlaps with another announcement date.", + "announcements.form.errCreateAnnouncement.noSameDateErr": "The announcement start and end date must be different.", + "announcements.form.errCreateAnnouncement.startDateAfterEndDateErr": "The start date cannot be after the end date." } diff --git a/src/translations/fr.json b/src/translations/fr.json index d51f619..fe4c156 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -16,6 +16,7 @@ "appBar.tabs.profiles": "Profils", "appBar.tabs.groups": "Groupes", "appBar.tabs.connections": "Connexions", + "appBar.tabs.warningBanner": "Bandeau d'avertissement", "table.noRows": "No data", "table.id": "ID", @@ -110,5 +111,20 @@ "groups.form.modification.title": "Modifier groupe", "linked.path.display.noLink": "pas de configuration selectionnée.", - "linked.path.display.invalidLink": "lien vers configurations invalide." + "linked.path.display.invalidLink": "lien vers configurations invalide.", + + "announcements.programNewMessage": "Programmer un nouveau message", + "announcements.programmedMessage": "Messages programmés", + "announcements.table.message": "Message", + "announcements.table.startDate": "Date de début", + "announcements.table.endDate": "Date de fin", + "announcements.table.cancel": "Annulé", + "announcements.severity": "Sévérité", + "announcements.severity.INFO": "Information", + "announcements.severity.WARN": "Avertissement", + "announcements.form.message": "Message d'annonce", + "announcements.form.errCreateAnnouncement": "Erreur lors de la création de l'annonce :", + "announcements.form.errCreateAnnouncement.noOverlapAllowedErr": "La date d'annonce chevauche une autre date d'annonce.", + "announcements.form.errCreateAnnouncement.noSameDateErr": "La date de début et de fin d'annonce doivent être différentes.", + "announcements.form.errCreateAnnouncement.startDateAfterEndDateErr": "La date de début d'annonce ne peut pas être après la date de fin." } diff --git a/src/utils/api-rest.ts b/src/utils/api-rest.ts index 01b9e0d..884829e 100644 --- a/src/utils/api-rest.ts +++ b/src/utils/api-rest.ts @@ -5,6 +5,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import type { JsonValue } from 'type-fest'; import { getToken, parseError, Token } from './api'; export type { Token } from './api'; @@ -60,10 +61,10 @@ export function backendFetch(url: Url, init?: InitRequest, token?: Token): Promi return safeFetch(url, prepareRequest(init, token)); } -export function backendFetchText(url: Url, init?: InitRequest, token?: Token): Promise { - return backendFetch(url, init, token).then((safeResponse: Response) => safeResponse.text()); +export function backendFetchText(url: Url, init?: InitRequest, token?: Token) { + return backendFetch(url, init, token).then((safeResponse) => safeResponse.text()) as Promise; } -export function backendFetchJson(url: Url, init?: InitRequest, token?: Token): Promise { - return backendFetch(url, init, token).then((safeResponse: Response) => safeResponse.json()); +export function backendFetchJson(url: Url, init?: InitRequest, token?: Token): Promise { + return backendFetch(url, init, token).then((safeResponse) => safeResponse.json()); } diff --git a/src/utils/api-ws.ts b/src/utils/api-ws.ts index 84b37a9..1689a76 100644 --- a/src/utils/api-ws.ts +++ b/src/utils/api-ws.ts @@ -9,10 +9,7 @@ import { getToken } from './api'; export type * from './api'; -export function getWsBase(): string { - // We use the `baseURI` (from `` in index.html) to build the URL, which is corrected by httpd/nginx - return document.baseURI.replace(/^http(s?):\/\//, 'ws$1://').replace(/\/+$/, '') + import.meta.env.VITE_WS_GATEWAY; -} +export const getWsBase = () => document.baseURI.replace(/^http:\/\//, 'ws://').replace(/^https:\/\//, 'wss://'); export function getUrlWithToken(baseUrl: string): string { const querySymbol = baseUrl.includes('?') ? '&' : '?'; diff --git a/src/utils/error.ts b/src/utils/error.ts index af9d81c..07ddca2 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -24,3 +24,30 @@ export function getErrorMessage(error: unknown): string | null { return JSON.stringify(error ?? undefined) ?? null; } } + +const OVERLAPPING_ANNOUNCEMENTS = 'OVERLAPPING_ANNOUNCEMENTS'; +const SAME_START_END_DATE = 'SAME_START_END_DATE'; +const START_DATE_AFTER_END_DATE = 'START_DATE_AFTER_END_DATE'; + +export function handleAnnouncementCreationErrors(error: string, snackError: Function): boolean { + if (error.includes(OVERLAPPING_ANNOUNCEMENTS)) { + snackError({ + headerId: 'announcements.form.errCreateAnnouncement', + messageId: 'announcements.form.errCreateAnnouncement.noOverlapAllowedErr', + }); + return true; + } else if (error.includes(SAME_START_END_DATE)) { + snackError({ + headerId: 'announcements.form.errCreateAnnouncement', + messageId: 'announcements.form.errCreateAnnouncement.noSameDateErr', + }); + return true; + } else if (error.includes(START_DATE_AFTER_END_DATE)) { + snackError({ + headerId: 'announcements.form.errCreateAnnouncement', + messageId: 'announcements.form.errCreateAnnouncement.startDateAfterEndDateErr', + }); + return true; + } + return false; +} diff --git a/src/utils/notifications-provider.ts b/src/utils/notifications-provider.ts index 6f43da9..77c6420 100644 --- a/src/utils/notifications-provider.ts +++ b/src/utils/notifications-provider.ts @@ -10,12 +10,7 @@ import { useSelector } from 'react-redux'; import type { AppState } from '../redux/reducer'; import { getUrlWithToken, getWsBase } from './api-ws'; import { APP_NAME } from './config-params'; - -export enum NOTIFICATIONS_URL_KEYS { - CONFIG = 'CONFIG', -} - -export const PREFIX_CONFIG_NOTIFICATION_WS = '/config-notification'; +import { NotificationsUrlKeys, PREFIX_CONFIG_NOTIFICATION_WS } from '@gridsuite/commons-ui'; export function useNotificationsUrlGenerator() { // The websocket API doesn't allow relative urls @@ -27,14 +22,15 @@ export function useNotificationsUrlGenerator() { return useMemo( () => ({ - [NOTIFICATIONS_URL_KEYS.CONFIG]: tokenId + [NotificationsUrlKeys.CONFIG]: tokenId ? getUrlWithToken( - `${wsBase}${PREFIX_CONFIG_NOTIFICATION_WS}/notify?${new URLSearchParams({ - appName: APP_NAME, - })}` + `${wsBase}${PREFIX_CONFIG_NOTIFICATION_WS}/notify?${new URLSearchParams({ appName: APP_NAME })}` ) : undefined, - }) satisfies Record, + [NotificationsUrlKeys.GLOBAL_CONFIG]: tokenId + ? getUrlWithToken(`${wsBase}${PREFIX_CONFIG_NOTIFICATION_WS}/global`) + : undefined, + }) satisfies Partial>, [wsBase, tokenId] ); } diff --git a/vite.config.ts b/vite.config.ts index 3a0195a..44b2877 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,6 +11,7 @@ import { CommonServerOptions, defineConfig } from 'vite'; import eslint from 'vite-plugin-eslint'; import svgr from 'vite-plugin-svgr'; import tsconfigPaths from 'vite-tsconfig-paths'; +import path from 'node:path'; const serverSettings: CommonServerOptions = { port: 3002, @@ -43,4 +44,18 @@ export default defineConfig((config) => ({ build: { outDir: 'build', }, + resolve: { + alias: { + /* "@mui/x-date-pickers/AdapterDateFns/AdapterDateFns" do an import from 'date-fns/_lib/format/longFormatters' + * which cause rollup error '[commonjs--resolver] Missing "./_lib/format/longFormatters" specifier in "date-fns" package'. + * - we fix the no default import with a shim that will fix that + * - we do a second alias to resolve the import to a non-exported file to date-fns/_lib/... + */ + 'date-fns/_lib/format/longFormatters': path.resolve(import.meta.dirname, 'vite.shim.x-date-pickers.js'), + 'virtual:date-fns/_lib/format/longFormatters': path.resolve( + import.meta.dirname, + 'node_modules/date-fns/_lib/format/longFormatters' + ), + }, + }, })); diff --git a/vite.shim.x-date-pickers.js b/vite.shim.x-date-pickers.js new file mode 100644 index 0000000..97acc61 --- /dev/null +++ b/vite.shim.x-date-pickers.js @@ -0,0 +1,10 @@ +/* + * Copyright © 2025, 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 { longFormatters } from 'virtual:date-fns/_lib/format/longFormatters'; +export default longFormatters; // patch bad import of @mui/x-date-pickers/AdapterDateFns/AdapterDateFns.js +export * from 'virtual:date-fns/_lib/format/longFormatters';