diff --git a/src/components/App/app-top-bar.tsx b/src/components/App/app-top-bar.tsx index 7b620ac..1dbcea6 100644 --- a/src/components/App/app-top-bar.tsx +++ b/src/components/App/app-top-bar.tsx @@ -14,7 +14,7 @@ import { useState, } from 'react'; import { capitalize, Tab, Tabs, useTheme } from '@mui/material'; -import { PeopleAlt } from '@mui/icons-material'; +import { ManageAccounts, PeopleAlt } from '@mui/icons-material'; import { logout, TopBar } from '@gridsuite/commons-ui'; import { useParameterState } from '../parameters'; import { @@ -47,6 +47,20 @@ const tabs = new Map([ ))} />, ], + [ + MainPaths.profiles, + } + label={} + href={`/${MainPaths.profiles}`} + value={MainPaths.profiles} + key={`tab-${MainPaths.profiles}`} + iconPosition="start" + LinkComponent={forwardRef((props, ref) => ( + + ))} + />, + ], ]); const AppTopBar: FunctionComponent = () => { diff --git a/src/pages/common/paper-form.tsx b/src/pages/common/paper-form.tsx new file mode 100644 index 0000000..0409dde --- /dev/null +++ b/src/pages/common/paper-form.tsx @@ -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 { FunctionComponent } from 'react'; +import { Paper, PaperProps } from '@mui/material'; + +/* + * is defined in without generics, which default to `PaperProps => PaperProps<'div'>`, + * so we must trick typescript check with a cast + */ +const PaperForm: FunctionComponent< + PaperProps<'form'> & { untypedProps?: PaperProps } +> = (props, context) => { + const { untypedProps, ...formProps } = props; + const othersProps = untypedProps as PaperProps<'form'>; //trust me ts + return ; +}; + +export default PaperForm; diff --git a/src/pages/index.ts b/src/pages/index.ts index 10001a3..c748275 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -6,3 +6,4 @@ */ export * from './users'; +export * from './profiles'; diff --git a/src/pages/profiles/add-profile-dialog.tsx b/src/pages/profiles/add-profile-dialog.tsx new file mode 100644 index 0000000..f11395e --- /dev/null +++ b/src/pages/profiles/add-profile-dialog.tsx @@ -0,0 +1,132 @@ +/** + * 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, RefObject, useCallback } from 'react'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + InputAdornment, + TextField, +} from '@mui/material'; +import { FormattedMessage } from 'react-intl'; +import { Controller, useForm } from 'react-hook-form'; +import { ManageAccounts } from '@mui/icons-material'; +import { UserAdminSrv, UserProfile } from '../../services'; +import { useSnackMessage } from '@gridsuite/commons-ui'; +import { GridTableRef } from '../../components/Grid'; +import PaperForm from '../common/paper-form'; + +export interface AddProfileDialogProps { + gridRef: RefObject>; + open: boolean; + setOpen: (open: boolean) => void; +} + +const AddProfileDialog: FunctionComponent = (props) => { + const { snackError } = useSnackMessage(); + + const { handleSubmit, control, reset, clearErrors } = useForm<{ + name: string; + }>({ + defaultValues: { name: '' }, //need default not undefined value for html input, else react error at runtime + }); + + const addProfile = useCallback( + (name: string) => { + const profileData: UserProfile = { + name: name, + }; + UserAdminSrv.addProfile(profileData) + .catch((error) => + snackError({ + messageTxt: error.message, + headerId: 'profiles.table.error.add', + }) + ) + .then(() => props.gridRef?.current?.context?.refresh?.()); + }, + [props.gridRef, snackError] + ); + + const handleClose = useCallback(() => { + props.setOpen(false); + reset(); + clearErrors(); + }, [clearErrors, props, reset]); + + const onSubmit = useCallback( + (data: { name: string }) => { + addProfile(data.name.trim()); + handleClose(); + }, + [addProfile, handleClose] + ); + + return ( + ( + + )} + > + + + + + + + + ( + + } + type="text" + fullWidth + variant="standard" + inputMode="text" + InputProps={{ + startAdornment: ( + + + + ), + }} + error={fieldState?.invalid} + helperText={fieldState?.error?.message} + /> + )} + /> + + + + + + + ); +}; +export default AddProfileDialog; diff --git a/src/pages/profiles/index.ts b/src/pages/profiles/index.ts new file mode 100644 index 0000000..6ff05f3 --- /dev/null +++ b/src/pages/profiles/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 Profiles } from './profiles-page'; diff --git a/src/pages/profiles/modification/custom-mui-dialog.tsx b/src/pages/profiles/modification/custom-mui-dialog.tsx new file mode 100644 index 0000000..5a87cdc --- /dev/null +++ b/src/pages/profiles/modification/custom-mui-dialog.tsx @@ -0,0 +1,120 @@ +/** + * 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/. + */ + +// TODO: this file is going to be available soon in commons-ui + +import { FunctionComponent } from 'react'; +import { FieldErrors, FormProvider } from 'react-hook-form'; +import { FormattedMessage } from 'react-intl'; +import { + DialogActions, + DialogContent, + Grid, + LinearProgress, + Dialog, + DialogTitle, +} from '@mui/material'; +import { CancelButton, SubmitButton } from '@gridsuite/commons-ui'; + +interface ICustomMuiDialog { + open: boolean; + formSchema: any; + formMethods: any; + onClose: (event: React.MouseEvent) => void; + onSave: (data: any) => void; + onValidationError?: (errors: FieldErrors) => void; + titleId: string; + disabledSave?: boolean; + removeOptional?: boolean; + onCancel?: () => void; + children: React.ReactNode; + isDataFetching?: boolean; +} + +const styles = { + dialogPaper: { + '.MuiDialog-paper': { + width: 'auto', + minWidth: '800px', + margin: 'auto', + }, + }, +}; + +const CustomMuiDialog: FunctionComponent = ({ + open, + formSchema, + formMethods, + onClose, + onSave, + isDataFetching = false, + onValidationError, + titleId, + disabledSave, + removeOptional = false, + onCancel, + children, +}) => { + const { handleSubmit } = formMethods; + + const handleCancel = (event: React.MouseEvent) => { + onCancel && onCancel(); + onClose(event); + }; + + const handleClose = (event: React.MouseEvent, reason?: string) => { + if (reason === 'backdropClick' && onCancel) { + onCancel(); + } + onClose(event); + }; + + const handleValidate = (data: any) => { + onSave(data); + onClose(data); + }; + + const handleValidationError = (errors: FieldErrors) => { + onValidationError && onValidationError(errors); + }; + + return ( + + + {isDataFetching && } + + + + + + {children} + + + + + + + ); +}; + +export default CustomMuiDialog; diff --git a/src/pages/profiles/modification/linked-path-display.tsx b/src/pages/profiles/modification/linked-path-display.tsx new file mode 100644 index 0000000..a29ea57 --- /dev/null +++ b/src/pages/profiles/modification/linked-path-display.tsx @@ -0,0 +1,50 @@ +/** + * 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 { Typography, useTheme } from '@mui/material'; +import { useIntl } from 'react-intl'; +import { FunctionComponent } from 'react'; + +export interface LinkedPathDisplayProps { + nameKey: string; + value?: string; + linkValidity?: boolean; +} + +const LinkedPathDisplay: FunctionComponent = ( + props +) => { + const intl = useIntl(); + const theme = useTheme(); + + return ( + + {intl.formatMessage({ + id: props.nameKey, + }) + + ' : ' + + (props.value + ? props.value + : intl.formatMessage({ + id: + props.linkValidity === false + ? 'linked.path.display.invalidLink' + : 'linked.path.display.noLink', + }))} + + ); +}; + +export default LinkedPathDisplay; diff --git a/src/pages/profiles/modification/parameter-selection.tsx b/src/pages/profiles/modification/parameter-selection.tsx new file mode 100644 index 0000000..688d3eb --- /dev/null +++ b/src/pages/profiles/modification/parameter-selection.tsx @@ -0,0 +1,151 @@ +/** + * 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, useEffect, useState } from 'react'; +import HighlightOffIcon from '@mui/icons-material/HighlightOff'; +import FolderIcon from '@mui/icons-material/Folder'; +import { Grid, IconButton, Tooltip } from '@mui/material'; +import { useIntl } from 'react-intl'; +import { DirectoryItemSelector, ElementType } from '@gridsuite/commons-ui'; +import { useController, useWatch } from 'react-hook-form'; +import { + fetchDirectoryContent, + fetchPath, + fetchRootFolders, +} from 'services/directory'; +import { fetchElementsInfos } from 'services/explore'; +import LinkedPathDisplay from './linked-path-display'; + +export interface ParameterSelectionProps { + elementType: + | ElementType.LOADFLOW_PARAMETERS + | ElementType.SECURITY_ANALYSIS_PARAMETERS + | ElementType.SENSITIVITY_PARAMETERS + | ElementType.VOLTAGE_INIT_PARAMETERS; + parameterFormId: string; +} + +const ParameterSelection: FunctionComponent = ( + props +) => { + const intl = useIntl(); + + const [openDirectorySelector, setOpenDirectorySelector] = + useState(false); + const [selectedElementName, setSelectedElementName] = useState(); + const [parameterLinkValid, setParameterLinkValid] = useState(); + const watchParamId = useWatch({ + name: props.parameterFormId, + }); + const ctlParamId = useController({ + name: props.parameterFormId, + }); + + useEffect(() => { + if (!watchParamId) { + setSelectedElementName(undefined); + setParameterLinkValid(undefined); + } else { + fetchPath(watchParamId) + .then((res: any) => { + setParameterLinkValid(true); + setSelectedElementName( + res + .map((element: any) => element.elementName.trim()) + .reverse() + .join('/') + ); + }) + .catch(() => { + setSelectedElementName(undefined); + setParameterLinkValid(false); + }); + } + }, [watchParamId]); + + const handleSelectFolder = () => { + setOpenDirectorySelector(true); + }; + + const handleResetParameter = () => { + ctlParamId.field.onChange(undefined); + }; + + const handleClose = (selection: any) => { + if (selection.length) { + ctlParamId.field.onChange(selection[0]?.id); + } + setOpenDirectorySelector(false); + }; + + const getParameterTranslationKey = () => { + switch (props.elementType) { + case ElementType.LOADFLOW_PARAMETERS: + return 'profiles.form.modification.loadflow.name'; + } + return 'cannot happen'; + }; + + return ( + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default ParameterSelection; diff --git a/src/pages/profiles/modification/profile-modification-dialog.tsx b/src/pages/profiles/modification/profile-modification-dialog.tsx new file mode 100644 index 0000000..dff450d --- /dev/null +++ b/src/pages/profiles/modification/profile-modification-dialog.tsx @@ -0,0 +1,139 @@ +/* + * 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 ProfileModificationForm, { + LF_PARAM_ID, + PROFILE_NAME, +} from './profile-modification-form'; +import yup from 'utils/yup-config'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { useForm } from 'react-hook-form'; +import { + FunctionComponent, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { useSnackMessage } from '@gridsuite/commons-ui'; +import { getProfile, modifyProfile, UserProfile } from 'services/user-admin'; +import CustomMuiDialog from './custom-mui-dialog'; +import { UUID } from 'crypto'; + +// TODO remove FetchStatus when available in commons-ui (available soon) +export const FetchStatus = { + IDLE: 'IDLE', + FETCHING: 'FETCHING', + FETCH_SUCCESS: 'FETCH_SUCCESS', + FETCH_ERROR: 'FETCH_ERROR', +}; + +export interface ProfileModificationDialogProps { + profileId: UUID | undefined; + open: boolean; + onClose: () => void; + onUpdate: () => void; +} + +const ProfileModificationDialog: FunctionComponent< + ProfileModificationDialogProps +> = ({ profileId, open, onClose, onUpdate }) => { + const { snackError } = useSnackMessage(); + const [dataFetchStatus, setDataFetchStatus] = useState(FetchStatus.IDLE); + + const formSchema = yup + .object() + .shape({ + [PROFILE_NAME]: yup.string().trim().required('nameEmpty'), + [LF_PARAM_ID]: yup.string().optional(), + }) + .required(); + + const formMethods = useForm({ + resolver: yupResolver(formSchema), + }); + + const { reset } = formMethods; + + const onSubmit = useCallback( + (profileFormData: any) => { + if (profileId) { + const profileData: UserProfile = { + id: profileId, + name: profileFormData[PROFILE_NAME], + loadFlowParameterId: profileFormData[LF_PARAM_ID], + }; + modifyProfile(profileData) + .catch((error) => { + snackError({ + messageTxt: error.message, + headerId: 'profiles.form.modification.updateError', + }); + }) + .then(() => { + onUpdate(); + }); + } + }, + [profileId, snackError, onUpdate] + ); + + const onDialogClose = useCallback(() => { + setDataFetchStatus(FetchStatus.IDLE); + onClose(); + }, [onClose]); + + useEffect(() => { + if (profileId && open) { + setDataFetchStatus(FetchStatus.FETCHING); + getProfile(profileId) + .then((response) => { + setDataFetchStatus(FetchStatus.FETCH_SUCCESS); + reset({ + [PROFILE_NAME]: response.name, + [LF_PARAM_ID]: response.loadFlowParameterId + ? response.loadFlowParameterId + : undefined, + }); + }) + .catch((error) => { + setDataFetchStatus(FetchStatus.FETCH_ERROR); + snackError({ + messageTxt: error.message, + headerId: 'profiles.form.modification.readError', + }); + }); + } + }, [profileId, open, reset, snackError]); + + const isDataReady = useMemo( + () => dataFetchStatus === FetchStatus.FETCH_SUCCESS, + [dataFetchStatus] + ); + + const isDataFetching = useMemo( + () => dataFetchStatus === FetchStatus.FETCHING, + [dataFetchStatus] + ); + + return ( + + {isDataReady && } + + ); +}; + +export default ProfileModificationDialog; diff --git a/src/pages/profiles/modification/profile-modification-form.tsx b/src/pages/profiles/modification/profile-modification-form.tsx new file mode 100644 index 0000000..5f01b28 --- /dev/null +++ b/src/pages/profiles/modification/profile-modification-form.tsx @@ -0,0 +1,44 @@ +/* + * 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 { ElementType, TextInput } from '@gridsuite/commons-ui'; +import Grid from '@mui/material/Grid'; +import ParameterSelection from './parameter-selection'; +import { FormattedMessage } from 'react-intl'; +import React, { FunctionComponent } from 'react'; + +export const PROFILE_NAME = 'name'; +export const LF_PARAM_ID = 'lfParamId'; + +const ProfileModificationForm: FunctionComponent = () => { + return ( + + + + + +

+ +

+
+ + + +
+ ); +}; + +export default ProfileModificationForm; diff --git a/src/pages/profiles/profiles-page.tsx b/src/pages/profiles/profiles-page.tsx new file mode 100644 index 0000000..bb2450e --- /dev/null +++ b/src/pages/profiles/profiles-page.tsx @@ -0,0 +1,70 @@ +/* + * 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, useRef, useState } from 'react'; +import { Grid } from '@mui/material'; +import { GridTableRef } from '../../components/Grid'; +import { UserProfile } from '../../services'; +import { RowDoubleClickedEvent } from 'ag-grid-community'; +import ProfileModificationDialog from './modification/profile-modification-dialog'; +import { UUID } from 'crypto'; +import ProfilesTable from './profiles-table'; +import AddProfileDialog from './add-profile-dialog'; + +const ProfilesPage: FunctionComponent = () => { + const gridRef = useRef>(null); + const gridContext = gridRef.current?.context; + const [openProfileModificationDialog, setOpenProfileModificationDialog] = + useState(false); + const [editingProfileId, setEditingProfileId] = useState(); + + const [openAddProfileDialog, setOpenAddProfileDialog] = useState(false); + + const handleCloseProfileModificationDialog = useCallback(() => { + setOpenProfileModificationDialog(false); + setEditingProfileId(undefined); + }, []); + + const handleUpdateProfileModificationDialog = useCallback(() => { + gridContext?.refresh?.(); + handleCloseProfileModificationDialog(); + }, [gridContext, handleCloseProfileModificationDialog]); + + const onRowDoubleClicked = useCallback( + (event: RowDoubleClickedEvent) => { + if (event.data) { + setEditingProfileId(event.data.id); + setOpenProfileModificationDialog(true); + } + }, + [] + ); + + return ( + + + + + + + + ); +}; +export default ProfilesPage; diff --git a/src/pages/profiles/profiles-table.tsx b/src/pages/profiles/profiles-table.tsx new file mode 100644 index 0000000..82eb611 --- /dev/null +++ b/src/pages/profiles/profiles-table.tsx @@ -0,0 +1,175 @@ +/** + * 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, + RefObject, + useCallback, + useMemo, + useState, +} from 'react'; +import { useIntl } from 'react-intl'; +import { + Cancel, + CheckCircle, + ManageAccounts, + RadioButtonUnchecked, +} from '@mui/icons-material'; +import { + GridButton, + GridButtonDelete, + GridTable, + GridTableRef, +} from '../../components/Grid'; +import { UserAdminSrv, UserProfile } from '../../services'; +import { + ColDef, + GetRowIdParams, + RowDoubleClickedEvent, + SelectionChangedEvent, + TextFilterParams, +} from 'ag-grid-community'; +import { useSnackMessage } from '@gridsuite/commons-ui'; + +const defaultColDef: ColDef = { + editable: false, + resizable: true, + minWidth: 50, + cellRenderer: 'agAnimateSlideCellRenderer', + showDisabledCheckboxes: true, + rowDrag: false, + sortable: true, +}; + +export interface ProfilesTableProps { + gridRef: RefObject>; + onRowDoubleClicked: (event: RowDoubleClickedEvent) => void; + setOpenAddProfileDialog: (open: boolean) => void; +} + +const ProfilesTable: FunctionComponent = (props) => { + const intl = useIntl(); + const { snackError } = useSnackMessage(); + + const [rowsSelection, setRowsSelection] = useState([]); + + function getRowId(params: GetRowIdParams): string { + return params.data.id ? params.data.id : ''; + } + + const onSelectionChanged = useCallback( + (event: SelectionChangedEvent) => + setRowsSelection(event.api.getSelectedRows() ?? []), + [setRowsSelection] + ); + + const onAddButton = useCallback( + () => props.setOpenAddProfileDialog(true), + [props] + ); + + const deleteProfiles = useCallback(() => { + let profileNames = rowsSelection.map((userProfile) => userProfile.name); + return UserAdminSrv.deleteProfiles(profileNames) + .catch((error) => { + if (error.status === 422) { + snackError({ + headerId: 'profiles.table.integrity.error.delete', + }); + } else { + snackError({ + messageTxt: error.message, + headerId: 'profiles.table.error.delete', + }); + } + }) + .then(() => props.gridRef?.current?.context?.refresh?.()); + }, [props.gridRef, rowsSelection, snackError]); + + const deleteProfilesDisabled = useMemo( + () => rowsSelection.length <= 0, + [rowsSelection.length] + ); + + const columns = useMemo( + (): ColDef[] => [ + { + field: 'name', + cellDataType: 'text', + flex: 3, + lockVisible: true, + filter: true, + headerName: intl.formatMessage({ id: 'profiles.table.id' }), + headerTooltip: intl.formatMessage({ + id: 'profiles.table.id.description', + }), + headerCheckboxSelection: true, + filterParams: { + caseSensitive: false, + trimInput: true, + } as TextFilterParams, + editable: false, + }, + { + field: 'allParametersLinksValid', + cellDataType: 'boolean', + cellStyle: () => ({ + display: 'flex', + alignItems: 'center', + }), + cellRenderer: (params: any) => { + return params.value == null ? ( + + ) : params.value ? ( + + ) : ( + + ); + }, + flex: 1, + headerName: intl.formatMessage({ + id: 'profiles.table.validity', + }), + headerTooltip: intl.formatMessage({ + id: 'profiles.table.validity.description', + }), + sortable: true, + filter: true, + initialSortIndex: 1, + initialSort: 'asc', + }, + ], + [intl] + ); + + return ( + + ref={props.gridRef} + dataLoader={UserAdminSrv.fetchProfiles} + columnDefs={columns} + defaultColDef={defaultColDef} + gridId="table-profiles" + getRowId={getRowId} + rowSelection="multiple" + onRowDoubleClicked={props.onRowDoubleClicked} + onSelectionChanged={onSelectionChanged} + > + } + color="primary" + onClick={onAddButton} + /> + + + ); +}; +export default ProfilesTable; diff --git a/src/pages/users/UsersPage.tsx b/src/pages/users/UsersPage.tsx index 162d7fb..d814f2f 100644 --- a/src/pages/users/UsersPage.tsx +++ b/src/pages/users/UsersPage.tsx @@ -8,6 +8,7 @@ import { FunctionComponent, useCallback, + useEffect, useMemo, useRef, useState, @@ -22,8 +23,6 @@ import { DialogTitle, Grid, InputAdornment, - Paper, - PaperProps, TextField, } from '@mui/material'; import { AccountCircle, PersonAdd } from '@mui/icons-material'; @@ -33,16 +32,18 @@ import { GridTable, GridTableRef, } from '../../components/Grid'; -import { UserAdminSrv, UserInfos } from '../../services'; +import { UserAdminSrv, UserInfos, UserProfile } from '../../services'; import { useSnackMessage } from '@gridsuite/commons-ui'; import { Controller, SubmitHandler, useForm } from 'react-hook-form'; import { + CellEditingStoppedEvent, ColDef, GetRowIdParams, ICheckboxCellRendererParams, SelectionChangedEvent, TextFilterParams, } from 'ag-grid-community'; +import PaperForm from '../common/paper-form'; const defaultColDef: ColDef = { editable: false, @@ -63,6 +64,24 @@ const UsersPage: FunctionComponent = () => { const { snackError } = useSnackMessage(); const gridRef = useRef>(null); const gridContext = gridRef.current?.context; + const [profileNameOptions, setprofileNameOptions] = useState([]); + + useEffect(() => { + UserAdminSrv.fetchProfilesWithoutValidityCheck() + .then((allProfiles: UserProfile[]) => { + let profiles: string[] = [ + intl.formatMessage({ id: 'users.table.profile.none' }), + ]; + allProfiles?.forEach((p) => profiles.push(p.name)); + setprofileNameOptions(profiles); + }) + .catch((error) => + snackError({ + messageTxt: error.message, + headerId: 'users.table.error.profiles', + }) + ); + }, [intl, snackError]); const columns = useMemo( (): ColDef[] => [ @@ -72,7 +91,7 @@ const UsersPage: FunctionComponent = () => { flex: 3, lockVisible: true, filter: true, - headerName: intl.formatMessage({ id: 'table.id' }), + headerName: intl.formatMessage({ id: 'users.table.id' }), headerTooltip: intl.formatMessage({ id: 'users.table.id.description', }), @@ -82,6 +101,31 @@ const UsersPage: FunctionComponent = () => { trimInput: true, } as TextFilterParams, }, + { + field: 'profileName', + cellDataType: 'text', + flex: 1, + filter: true, + headerName: intl.formatMessage({ + id: 'users.table.profileName', + }), + headerTooltip: intl.formatMessage({ + id: 'users.table.profileName.description', + }), + filterParams: { + caseSensitive: false, + trimInput: true, + } as TextFilterParams, + editable: true, + cellEditor: 'agSelectCellEditor', + cellEditorParams: () => { + return { + values: profileNameOptions, + valueListMaxHeight: 400, + valueListMaxWidth: 300, + }; + }, + }, { field: 'isAdmin', cellDataType: 'boolean', @@ -102,7 +146,7 @@ const UsersPage: FunctionComponent = () => { initialSort: 'asc', }, ], - [intl] + [intl, profileNameOptions] ); const [rowsSelection, setRowsSelection] = useState([]); @@ -111,9 +155,7 @@ const UsersPage: FunctionComponent = () => { return UserAdminSrv.deleteUsers(subs) .catch((error) => snackError({ - messageTxt: `Error while deleting user "${JSON.stringify( - subs - )}"${error.message && ':\n' + error.message}`, + messageTxt: error.message, headerId: 'users.table.error.delete', }) ) @@ -156,6 +198,24 @@ const UsersPage: FunctionComponent = () => { }; const onSubmitForm = handleSubmit(onSubmit); + const handleCellEditingStopped = useCallback( + (event: CellEditingStoppedEvent) => { + if (event.valueChanged && event.data) { + UserAdminSrv.udpateUser(event.data) + .catch((error) => + snackError({ + messageTxt: error.message, + headerId: 'users.table.error.update', + }) + ) + .then(() => gridContext?.refresh?.()); + } else { + gridContext?.refresh?.(); + } + }, + [gridContext, snackError] + ); + return ( @@ -164,6 +224,7 @@ const UsersPage: FunctionComponent = () => { dataLoader={UserAdminSrv.fetchUsers} columnDefs={columns} defaultColDef={defaultColDef} + onCellEditingStopped={handleCellEditingStopped} gridId="table-users" getRowId={getRowId} rowSelection="multiple" @@ -246,15 +307,3 @@ const UsersPage: FunctionComponent = () => { ); }; export default UsersPage; - -/* - * is defined in without generics, which default to `PaperProps => PaperProps<'div'>`, - * so we must trick typescript check with a cast - */ -const PaperForm: FunctionComponent< - PaperProps<'form'> & { untypedProps?: PaperProps } -> = (props, context) => { - const { untypedProps, ...formProps } = props; - const othersProps = untypedProps as PaperProps<'form'>; //trust me ts - return ; -}; diff --git a/src/routes/router.tsx b/src/routes/router.tsx index c6a6a7c..3695585 100644 --- a/src/routes/router.tsx +++ b/src/routes/router.tsx @@ -33,7 +33,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { AppState } from '../redux/reducer'; import { AppsMetadataSrv, UserAdminSrv } from '../services'; import { App } from '../components/App'; -import { Users } from '../pages'; +import { Users, Profiles } from '../pages'; import ErrorPage from './ErrorPage'; import { updateUserManagerDestructured } from '../redux/actions'; import HomePage from './HomePage'; @@ -41,6 +41,7 @@ import { getErrorMessage } from '../utils/error'; export enum MainPaths { users = 'users', + profiles = 'profiles', } export function appRoutes(): RouteObject[] { @@ -60,6 +61,13 @@ export function appRoutes(): RouteObject[] { appBar_tab: MainPaths.users, }, }, + { + path: `/${MainPaths.profiles}`, + element: , + handle: { + appBar_tab: MainPaths.profiles, + }, + }, ], }, { diff --git a/src/services/directory.ts b/src/services/directory.ts new file mode 100644 index 0000000..aa9ace8 --- /dev/null +++ b/src/services/directory.ts @@ -0,0 +1,73 @@ +/* + * 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 { backendFetchJson, getRestBase } from '../utils/api-rest'; +import { UUID } from 'crypto'; + +const DIRECTORY_URL = `${getRestBase()}/directory/v1`; + +export type ElementAttributes = { + elementUuid: UUID; + elementName: string; + type: string; +}; + +export function fetchPath(elementUuid: UUID): Promise { + console.debug(`Fetching element and its parents info...`); + return backendFetchJson(`${DIRECTORY_URL}/elements/${elementUuid}/path`, { + headers: { + Accept: 'application/json', + }, + cache: 'default', + }).catch((reason) => { + console.error(`Error while fetching the servers data : ${reason}`); + throw reason; + }) as Promise; +} + +export function fetchRootFolders( + types: string[] // should be ElementType[] +): Promise { + console.info('Fetching Root Directories...'); + const urlSearchParams = new URLSearchParams( + types?.length ? types.map((param) => ['elementTypes', param]) : [] + ); + return backendFetchJson( + `${DIRECTORY_URL}/root-directories?${urlSearchParams}`, + { + headers: { + Accept: 'application/json', + }, + cache: 'default', + } + ).catch((reason) => { + console.error(`Error while fetching the servers data : ${reason}`); + throw reason; + }) as Promise; +} + +export function fetchDirectoryContent( + directoryUuid: UUID, + types: string[] // should be ElementType[] +): Promise { + console.info('Fetching Directory content...'); + const urlSearchParams = new URLSearchParams( + types?.length ? types.map((param) => ['elementTypes', param]) : [] + ); + return backendFetchJson( + `${DIRECTORY_URL}/directories/${directoryUuid}/elements?${urlSearchParams}`, + { + headers: { + Accept: 'application/json', + }, + cache: 'default', + } + ).catch((reason) => { + console.error(`Error while fetching the servers data : ${reason}`); + throw reason; + }) as Promise; +} diff --git a/src/services/explore.ts b/src/services/explore.ts new file mode 100644 index 0000000..c9f5aa8 --- /dev/null +++ b/src/services/explore.ts @@ -0,0 +1,43 @@ +/* + * 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 { backendFetchJson, getRestBase } from '../utils/api-rest'; +import { UUID } from 'crypto'; + +const EXPLORE_URL = `${getRestBase()}/explore/v1`; + +export type ElementAttributes = { + elementUuid: UUID; + elementName: string; + type: string; +}; + +export function fetchElementsInfos( + ids: UUID[], + elementTypes: string[] // should be ElementType[] +): Promise { + console.info('Fetching elements metadata...'); + const tmp = ids?.filter((id) => id); + const idsParams = tmp?.length ? tmp.map((id) => ['ids', id]) : []; + const elementTypesParams = elementTypes?.length + ? elementTypes.map((type) => ['elementTypes', type]) + : []; + const params = [...idsParams, ...elementTypesParams]; + const urlSearchParams = new URLSearchParams(params).toString(); + return backendFetchJson( + `${EXPLORE_URL}/explore/elements/metadata?${urlSearchParams}`, + { + headers: { + Accept: 'application/json', + }, + cache: 'default', + } + ).catch((reason) => { + console.error(`Error while fetching the servers data : ${reason}`); + throw reason; + }) as Promise; +} diff --git a/src/services/user-admin.ts b/src/services/user-admin.ts index 05079d2..fd96726 100644 --- a/src/services/user-admin.ts +++ b/src/services/user-admin.ts @@ -8,6 +8,7 @@ import { backendFetch, backendFetchJson, getRestBase } from '../utils/api-rest'; import { extractUserSub, getToken, getUser } from '../utils/api'; import { User } from '../utils/auth'; +import { UUID } from 'crypto'; const USER_ADMIN_URL = `${getRestBase()}/user-admin/v1`; @@ -43,6 +44,7 @@ export function fetchValidateUser(user: User): Promise { export type UserInfos = { sub: string; + profileName: string; isAdmin: boolean; }; @@ -60,12 +62,20 @@ export function fetchUsers(): Promise { }) as Promise; } -export function deleteUser(sub: string): Promise { - console.debug(`Deleting sub user "${sub}"...`); - return backendFetch(`${USER_ADMIN_URL}/users/${sub}`, { method: 'delete' }) - .then((response: Response) => undefined) +export function udpateUser(userInfos: UserInfos) { + console.debug(`Updating a user...`); + + return backendFetch(`${USER_ADMIN_URL}/users/${userInfos.sub}`, { + method: 'PUT', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(userInfos), + }) + .then(() => undefined) .catch((reason) => { - console.error(`Error while deleting the servers data : ${reason}`); + console.error(`Error while updating user : ${reason}`); throw reason; }); } @@ -79,7 +89,7 @@ export function deleteUsers(subs: string[]): Promise { }, body: JSON.stringify(subs), }) - .then((response: Response) => undefined) + .then(() => undefined) .catch((reason) => { console.error(`Error while deleting the servers data : ${reason}`); throw reason; @@ -89,9 +99,111 @@ export function deleteUsers(subs: string[]): Promise { export function addUser(sub: string): Promise { console.debug(`Creating sub user "${sub}"...`); return backendFetch(`${USER_ADMIN_URL}/users/${sub}`, { method: 'post' }) - .then((response: Response) => undefined) + .then(() => undefined) + .catch((reason) => { + console.error(`Error while adding user : ${reason}`); + throw reason; + }); +} + +export type UserProfile = { + id?: UUID; + name: string; + allParametersLinksValid?: boolean; + loadFlowParameterId?: UUID; +}; + +export function fetchProfiles(): Promise { + console.debug(`Fetching list of profiles...`); + return backendFetchJson(`${USER_ADMIN_URL}/profiles`, { + headers: { + Accept: 'application/json', + }, + cache: 'default', + }).catch((reason) => { + console.error(`Error while fetching list of profiles : ${reason}`); + throw reason; + }) as Promise; +} + +export function fetchProfilesWithoutValidityCheck(): Promise { + console.debug(`Fetching list of profiles...`); + return backendFetchJson( + `${USER_ADMIN_URL}/profiles?checkLinksValidity=false`, + { + headers: { + Accept: 'application/json', + }, + cache: 'default', + } + ).catch((reason) => { + console.error( + `Error while fetching list of profiles (without check) : ${reason}` + ); + throw reason; + }) as Promise; +} + +export function getProfile(profileId: UUID): Promise { + console.debug(`Fetching a profile...`); + return backendFetchJson(`${USER_ADMIN_URL}/profiles/${profileId}`, { + headers: { + Accept: 'application/json', + }, + cache: 'default', + }).catch((reason) => { + console.error(`Error while fetching profile : ${reason}`); + throw reason; + }) as Promise; +} + +export function modifyProfile(profileData: UserProfile) { + console.debug(`Updating a profile...`); + + return backendFetch(`${USER_ADMIN_URL}/profiles/${profileData.id}`, { + method: 'PUT', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(profileData), + }) + .then(() => undefined) + .catch((reason) => { + console.error(`Error while updating the data : ${reason}`); + throw reason; + }); +} + +export function addProfile(profileData: UserProfile): Promise { + console.debug(`Creating user profile "${profileData.name}"...`); + return backendFetch(`${USER_ADMIN_URL}/profiles`, { + method: 'post', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(profileData), + }) + .then(() => undefined) + .catch((reason) => { + console.error(`Error while pushing adding profile : ${reason}`); + throw reason; + }); +} + +export function deleteProfiles(names: string[]): Promise { + console.debug(`Deleting profiles "${JSON.stringify(names)}"...`); + return backendFetch(`${USER_ADMIN_URL}/profiles`, { + method: 'delete', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(names), + }) + .then(() => undefined) .catch((reason) => { - console.error(`Error while pushing the data : ${reason}`); + console.error(`Error while deleting profiles : ${reason}`); throw reason; }); } diff --git a/src/translations/en.json b/src/translations/en.json index ddc62de..c092079 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -5,15 +5,17 @@ "close": "Close", "ok": "OK", "cancel": "Cancel", + "validate": "Validate", "parameters": "Parameters", + "nameEmpty": "The name is empty", "paramsChangingError": "An error occurred when changing the parameters", "paramsRetrievingError": "An error occurred while retrieving the parameters", "appBar.tabs.users": "Users", + "appBar.tabs.profiles": "Profiles", "appBar.tabs.connections": "Connections", "table.noRows": "No data", - "table.id": "ID", "table.error.retrieve": "Error while retrieving data", "table.toolbar.reset": "Reset", "table.toolbar.reset.label": "Reset table columns & filters", @@ -25,14 +27,45 @@ "table.bool.no": "No", "table.bool.unknown": "Unknown", - "users.table.id.description": "Identifiant de l'utilisateur", + "users.table.id": "ID", + "users.table.id.description": "User identifier", "users.table.isAdmin": "Admin", "users.table.isAdmin.description": "The users is an administrator of GridSuite", + "users.table.profileName": "Profile", + "users.table.profileName.description": "The user's profile", "users.table.error.delete": "Error while deleting user", "users.table.error.add": "Error while adding user", "users.table.toolbar.add": "Add user", + "users.table.profile.none": "", + "users.table.error.profiles": "Error while fetching profiles", "users.table.toolbar.add.label": "Add a user", "users.form.title": "Add a user", "users.form.content": "Please fill in new user data.", - "users.form.field.username.label": "User ID" + "users.form.field.username.label": "User ID", + + "profiles.table.toolbar.add": "Add profile", + "profiles.table.toolbar.add.label": "Add a profile", + "profiles.form.title": "Add a profile", + "profiles.form.content": "Please fill in new profile data.", + "profiles.form.field.profilename.label": "Profile name", + "profiles.table.id": "Name", + "profiles.table.id.description": "Profile name", + "profiles.table.validity": "Parameters links validity", + "profiles.table.validity.description": "Are all parameters links valid?", + "profiles.table.error.add": "Error while adding profile", + "profiles.table.error.delete": "Error while deleting profile", + "profiles.table.integrity.error.delete": "Error while deleting profile : a profile is still referenced by users", + + "profiles.form.modification.title": "Edit profile", + "profiles.form.modification.defaultParameters": "Default parameters", + "profiles.form.modification.parameter.choose.tooltip": "Choose parameters", + "profiles.form.modification.parameter.reset.tooltip": "Set undefined parameters", + "profiles.form.modification.parameterSelection.dialog.title": "Choose parameters", + "profiles.form.modification.parameterSelection.dialog.message": "Please choose parameters", + "profiles.form.modification.loadflow.name": "Loadflow", + "profiles.form.modification.readError": "Error while reading the profile", + "profiles.form.modification.updateError": "Error while updating the profile", + + "linked.path.display.noLink": "no parameters selected.", + "linked.path.display.invalidLink": "invalid parameters link." } diff --git a/src/translations/fr.json b/src/translations/fr.json index 8f67967..35d380c 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -5,11 +5,14 @@ "close": "Fermer", "ok": "OK", "cancel": "Annuler", + "validate": "Valider", "parameters": "Paramètres", + "nameEmpty": "Le nom est vide", "paramsChangingError": "Une erreur est survenue lors de la modification des paramètres", "paramsRetrievingError": "Une erreur est survenue lors de la récupération des paramètres", "appBar.tabs.users": "Utilisateurs", + "appBar.tabs.profiles": "Profils", "appBar.tabs.connections": "Connexions", "table.noRows": "No data", @@ -25,14 +28,45 @@ "table.bool.no": "Non", "table.bool.unknown": "Inconnu", + "users.table.id": "ID", "users.table.id.description": "Identifiant de l'utilisateur", "users.table.isAdmin": "Admin", "users.table.isAdmin.description": "L'utilisateur est administrateur de GridSuite", + "users.table.profileName": "Profil", + "users.table.profileName.description": "Nom du profil associé à l'utilisateur", "users.table.error.delete": "Erreur pendant la suppression de l'utilisateur", "users.table.error.add": "Erreur pendant l'ajout de l'utilisateur", - "users.table.toolbar.add": "Ajouter utilisateur", "users.table.toolbar.add.label": "Ajouter un utilisateur", + "users.table.toolbar.add": "Ajouter utilisateur", + "users.table.profile.none": "", + "users.table.error.profiles": "Erreur pendant la récupération des profils", "users.form.title": "Ajouter un utilisateur", "users.form.content": "Veuillez renseigner les informations de l'utilisateur.", - "users.form.field.username.label": "ID utilisateur" + "users.form.field.username.label": "ID utilisateur", + + "profiles.table.toolbar.add": "Ajouter profil", + "profiles.table.toolbar.add.label": "Ajouter un profil", + "profiles.form.title": "Ajouter un profil", + "profiles.form.content": "Veuillez renseigner les informations du profil.", + "profiles.form.field.profilename.label": "Nom du profil", + "profiles.table.id": "Nom", + "profiles.table.id.description": "Nom du profil", + "profiles.table.validity": "Validité des liens vers les paramètres", + "profiles.table.validity.description": "Tous les liens vers les paramètres sont-ils valides ?", + "profiles.table.error.add": "Erreur pendant l'ajout du profil", + "profiles.table.error.delete": "Erreur pendant la suppression de profil", + "profiles.table.integrity.error.delete": "Erreur pendant la suppression de profil: un profil est toujours référencé par des utilisateurs", + + "profiles.form.modification.title": "Modifier profil", + "profiles.form.modification.defaultParameters": "Paramètres par défaut", + "profiles.form.modification.parameter.choose.tooltip": "Choisir paramètres", + "profiles.form.modification.parameter.reset.tooltip": "Ne pas définir de paramètres", + "profiles.form.modification.parameterSelection.dialog.title": "Choisir des paramètres", + "profiles.form.modification.parameterSelection.dialog.message": "Veuillez choisir des paramètres", + "profiles.form.modification.loadflow.name": "Calcul de répartition", + "profiles.form.modification.readError": "Erreur lors de la lecture du profil", + "profiles.form.modification.updateError": "Erreur lors de la modification du profil", + + "linked.path.display.invalidLink": "lien vers paramètres invalide.", + "linked.path.display.noLink": "pas de paramètres selectionnés." } diff --git a/src/utils/yup-config.js b/src/utils/yup-config.js new file mode 100644 index 0000000..6bf1807 --- /dev/null +++ b/src/utils/yup-config.js @@ -0,0 +1,25 @@ +/** + * 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/. + */ + +// TODO: this file is going to be available soon in commons-ui + +import * as yup from 'yup'; + +yup.setLocale({ + mixed: { + required: 'YupRequired', + notType: ({ type }) => { + if (type === 'number') { + return 'YupNotTypeNumber'; + } else { + return 'YupNotTypeDefault'; + } + }, + }, +}); + +export default yup;