diff --git a/package-lock.json b/package-lock.json index df9128e99..78c688fd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@gridsuite/commons-ui": "0.126.0", + "@gridsuite/commons-ui": "0.127.0", "@hookform/resolvers": "^4.1.3", "@mui/icons-material": "^5.18.0", "@mui/lab": "5.0.0-alpha.175", @@ -3121,9 +3121,9 @@ } }, "node_modules/@gridsuite/commons-ui": { - "version": "0.126.0", - "resolved": "https://registry.npmjs.org/@gridsuite/commons-ui/-/commons-ui-0.126.0.tgz", - "integrity": "sha512-PQKNxVOYeygiRMLsKyBdjF+u1HZKG3yZONDc4YHGpaV8rOqmtHC7721qUL6dg5EP7C8b+Wi7nlSxeMd3n/UrBg==", + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@gridsuite/commons-ui/-/commons-ui-0.127.0.tgz", + "integrity": "sha512-TZd6Hx/zOE2zxJSdgd4hnIcSN+PXYpwZvwsJI/3s1oic75rO90GzhP43EpgUlM++txYS5Gbxc2fok1SifK0DLg==", "license": "MPL-2.0", "dependencies": { "@ag-grid-community/locale": "^33.3.2", diff --git a/package.json b/package.json index 0a509f387..a0d82a293 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@gridsuite/commons-ui": "0.126.0", + "@gridsuite/commons-ui": "0.127.0", "@hookform/resolvers": "^4.1.3", "@mui/icons-material": "^5.18.0", "@mui/lab": "5.0.0-alpha.175", diff --git a/src/components/dialogs/contingency-list/contingency-list-utils.ts b/src/components/dialogs/contingency-list/contingency-list-utils.ts index 4742e1c70..59b010b88 100644 --- a/src/components/dialogs/contingency-list/contingency-list-utils.ts +++ b/src/components/dialogs/contingency-list/contingency-list-utils.ts @@ -16,6 +16,7 @@ import { import type { SetRequired } from 'type-fest'; import { prepareContingencyListForBackend } from '../contingency-list-helper'; import { ContingencyListType } from '../../../utils/elementType'; +import { FilterAttributes } from '../../../utils/contingency-list.type'; export interface Identifier { type: 'ID_BASED'; @@ -71,6 +72,15 @@ export const getCriteriaBasedFormDataFromFetchedElement = (response: any, name: ...getCriteriaBasedFormData(response), }); +export const getFilterBasedFormDataFromFetchedElement = (response: any, name: string, description: string) => ({ + [FieldConstants.NAME]: name, + [FieldConstants.DESCRIPTION]: description, + [FieldConstants.CONTINGENCY_LIST_TYPE]: ContingencyListType.FILTERS.id, + [FieldConstants.FILTERS]: response.filters.map((filter: FilterAttributes) => { + return { id: filter.id, name: filter.name, specificMetadata: { equipmentType: filter.equipmentType } }; + }), +}); + export const getExplicitNamingFormDataFromFetchedElement = (response: any, name: string, description: string) => { let result; if (response.identifierContingencyList?.identifiers?.length) { diff --git a/src/components/dialogs/contingency-list/creation/contingency-list-creation-form.tsx b/src/components/dialogs/contingency-list/creation/contingency-list-creation-form.tsx index 9351a4cc2..fc0903b8f 100644 --- a/src/components/dialogs/contingency-list/creation/contingency-list-creation-form.tsx +++ b/src/components/dialogs/contingency-list/creation/contingency-list-creation-form.tsx @@ -42,7 +42,7 @@ export default function ContingencyListCreationForm() { const contingencyListTypeField = ( ); diff --git a/src/components/dialogs/contingency-list/filter-based/contingency-list-filter-based-dialog.tsx b/src/components/dialogs/contingency-list/filter-based/contingency-list-filter-based-dialog.tsx new file mode 100644 index 000000000..a4ecaac55 --- /dev/null +++ b/src/components/dialogs/contingency-list/filter-based/contingency-list-filter-based-dialog.tsx @@ -0,0 +1,180 @@ +/** + * 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 { + CustomMuiDialog, + FieldConstants, + MAX_CHAR_DESCRIPTION, + TreeViewFinderNodeProps, + useSnackMessage, + yupConfig as yup, +} from '@gridsuite/commons-ui'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { useSelector } from 'react-redux'; +import { useCallback, useEffect, useState } from 'react'; +import { UUID } from 'crypto'; +import { ObjectSchema } from 'yup'; +import ContingencyListFilterBasedForm from './contingency-list-filter-based-form'; +import { AppState } from '../../../../redux/types'; +import { + createFilterBasedContingency, + getContingencyList, + saveFilterBasedContingencyList, +} from '../../../../utils/rest-api'; +import { handleNotAllowedError } from '../../../utils/rest-errors'; +import { ContingencyListType } from '../../../../utils/elementType'; +import { getFilterBasedFormDataFromFetchedElement } from '../contingency-list-utils'; +import { FilterBasedContingencyList } from '../../../../utils/contingency-list.type'; + +const schema: ObjectSchema = yup.object().shape({ + [FieldConstants.NAME]: yup.string().required(), + [FieldConstants.DESCRIPTION]: yup.string().max(MAX_CHAR_DESCRIPTION), + [FieldConstants.FILTERS]: yup.array().required(), +}); + +export interface ContingencyListFilterBasedFormData { + [FieldConstants.NAME]: string; + [FieldConstants.DESCRIPTION]?: string; + [FieldConstants.FILTERS]: TreeViewFinderNodeProps[]; +} + +const getContingencyListEmptyFormData = (name = '') => ({ + [FieldConstants.NAME]: name, + [FieldConstants.DESCRIPTION]: '', + [FieldConstants.FILTERS]: [], +}); + +const emptyFormData = (name?: string) => getContingencyListEmptyFormData(name); + +export interface FilterBasedContingencyListProps { + titleId: string; + open: boolean; + onClose: () => void; + name?: string; + description?: string; + id?: UUID; +} + +export default function FilterBasedContingencyListDialog({ + titleId, + open, + onClose, + name, + description, + id, +}: Readonly) { + const activeDirectory = useSelector((state: AppState) => state.activeDirectory); + const { snackError } = useSnackMessage(); + const [isFetching, setIsFetching] = useState(!!id); + + const methods = useForm({ + defaultValues: emptyFormData(), + resolver: yupResolver(schema), + }); + const { + reset, + formState: { errors }, + } = methods; + + useEffect(() => { + if (id) { + setIsFetching(true); + getContingencyList(ContingencyListType.FILTERS.id, id?.toString()) + .then((response) => { + const formData: ContingencyListFilterBasedFormData = getFilterBasedFormDataFromFetchedElement( + response, + name ?? '', + description ?? '' + ); + reset({ ...formData }); + }) + .catch((error) => { + snackError({ + messageTxt: error.message, + headerId: 'cannotRetrieveContingencyList', + }); + }) + .finally(() => setIsFetching(false)); + } + }, [id, name, reset, snackError, description]); + + const closeAndClear = useCallback(() => { + reset(emptyFormData()); + onClose(); + }, [onClose, reset]); + + const onSubmit = useCallback( + (data: ContingencyListFilterBasedFormData) => { + const filterBaseContingencyList: FilterBasedContingencyList = { + filters: data[FieldConstants.FILTERS]?.map((item: TreeViewFinderNodeProps) => { + return { + id: item.id, + }; + }), + }; + + if (id) { + saveFilterBasedContingencyList( + id, + data[FieldConstants.NAME], + data[FieldConstants.DESCRIPTION] ?? '', + filterBaseContingencyList + ) + .then(() => closeAndClear()) + .catch((error) => { + if (handleNotAllowedError(error, snackError)) { + return; + } + snackError({ + messageTxt: error.message, + headerId: 'contingencyListEditingError', + headerValues: { name: data[FieldConstants.NAME] }, + }); + }); + } else { + createFilterBasedContingency( + data[FieldConstants.NAME], + data[FieldConstants.DESCRIPTION] ?? '', + filterBaseContingencyList, + activeDirectory + ) + .then(() => closeAndClear()) + .catch((error) => { + if (handleNotAllowedError(error, snackError)) { + return; + } + snackError({ + messageTxt: error.message, + headerId: 'contingencyListCreationError', + headerValues: { name: data[FieldConstants.NAME] }, + }); + }); + } + }, + [activeDirectory, closeAndClear, id, snackError] + ); + + const nameError = errors[FieldConstants.NAME]; + const isValidating = errors.root?.isValidating; + + return ( + + + + ); +} diff --git a/src/components/dialogs/contingency-list/filter-based/contingency-list-filter-based-form.tsx b/src/components/dialogs/contingency-list/filter-based/contingency-list-filter-based-form.tsx new file mode 100644 index 000000000..3278a462b --- /dev/null +++ b/src/components/dialogs/contingency-list/filter-based/contingency-list-filter-based-form.tsx @@ -0,0 +1,206 @@ +/** + * 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 { + CustomAGGrid, + DescriptionField, + DirectoryItemSelector, + DirectoryItemsInput, + ElementType, + EquipmentType, + FieldConstants, + getFilterEquipmentTypeLabel, + SeparatorCellRenderer, + TreeViewFinderNodeProps, + UniqueNameInput, + unscrollableDialogStyles, + useSnackMessage, +} from '@gridsuite/commons-ui'; +import { Box, Button, Typography } from '@mui/material'; +import { useSelector } from 'react-redux'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { useCallback, useEffect, useState } from 'react'; +import { FolderOutlined } from '@mui/icons-material'; +import { ColDef } from 'ag-grid-community'; +import { blue, brown, green, indigo, lime, red, teal } from '@mui/material/colors'; +import { useWatch } from 'react-hook-form'; +import { UUID } from 'crypto'; +import { AppState } from '../../../../redux/types'; +import { getIdentifiablesFromFitlers } from '../../../../utils/rest-api'; +import { + FilteredIdentifiables, + FilterAttributes, + IdentifiableAttributes, +} from '../../../../utils/contingency-list.type'; + +const separator = '/'; +const defaultDef: ColDef = { + flex: 1, + resizable: false, + sortable: false, +}; + +const equipmentTypes: string[] = [ + EquipmentType.TWO_WINDINGS_TRANSFORMER, + EquipmentType.LINE, + EquipmentType.LOAD, + EquipmentType.GENERATOR, + EquipmentType.SHUNT_COMPENSATOR, + EquipmentType.STATIC_VAR_COMPENSATOR, + EquipmentType.HVDC_LINE, +]; + +const equipmentColorsMap: Map = new Map([ + [EquipmentType.TWO_WINDINGS_TRANSFORMER, blue[700]], + [EquipmentType.LINE, indigo[700]], + [EquipmentType.LOAD, brown[700]], + [EquipmentType.GENERATOR, green[700]], + [EquipmentType.SHUNT_COMPENSATOR, red[700]], + [EquipmentType.STATIC_VAR_COMPENSATOR, lime[700]], + [EquipmentType.HVDC_LINE, teal[700]], +]); + +export default function ContingencyListFilterBasedForm() { + const activeDirectory = useSelector((state: AppState) => state.activeDirectory); + const [selectedStudy, setSelectedStudy] = useState(''); + const [selectedStudyId, setSelectedStudyId] = useState(); + const [selectedFolder, setSelectedFolder] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const [isFetching, setIsFetching] = useState(false); + const [rowsData, setRowsData] = useState([]); + + const intl = useIntl(); + const { snackError } = useSnackMessage(); + const filters = useWatch({ name: FieldConstants.FILTERS }); + + const colDef: ColDef[] = [ + { + headerName: intl.formatMessage({ + id: FieldConstants.EQUIPMENT_ID, + }), + field: FieldConstants.ID, + cellRenderer: ({ data }: { data: IdentifiableAttributes }) => { + if (data.id === 'SEPARATOR') { + return SeparatorCellRenderer({ + value: intl.formatMessage({ id: 'missingFromStudy' }), + }); + } + return data.id; + }, + }, + { + headerName: intl.formatMessage({ + id: FieldConstants.TYPE, + }), + field: FieldConstants.TYPE, + }, + ]; + + useEffect(() => { + if (filters && selectedStudyId) { + setIsFetching(true); + getIdentifiablesFromFitlers( + selectedStudyId, + filters.map((filter: FilterAttributes) => filter.id) + ) + .then((response: FilteredIdentifiables) => { + const SEPARATOR_TYPE = 'SEPARATOR'; + const attributes: IdentifiableAttributes[] = response.equipmentIds.map( + (element: IdentifiableAttributes) => { + const equipmentType: string = getFilterEquipmentTypeLabel(element?.type); + return { + id: element.id, + type: equipmentType ? intl.formatMessage({ id: equipmentType }) : '', + }; + } + ); + if (response.notFoundIds?.length > 0) { + attributes.push({ id: SEPARATOR_TYPE, type: '' }); + response.notFoundIds.forEach((element: IdentifiableAttributes) => { + const equipmentType: string = getFilterEquipmentTypeLabel(element?.type); + attributes.push({ + id: element.id, + type: equipmentType ? intl.formatMessage({ id: equipmentType }) : '', + }); + }); + } + setRowsData(attributes); + }) + .catch((error) => + snackError({ + messageTxt: error.message, + headerId: 'cannotComputeContingencyList', + }) + ) + .finally(() => setIsFetching(false)); + } + }, [filters, intl, selectedStudyId, snackError]); + + const onNodeChanged = useCallback((nodes: TreeViewFinderNodeProps[]) => { + if (nodes.length > 0) { + if (nodes[0].parents && nodes[0].parents.length > 0) { + setSelectedFolder(nodes[0].parents.map((entry) => entry.name).join(separator)); + } + setSelectedStudy(nodes[0].name); + setSelectedStudyId(nodes[0].id); + } + setIsOpen(false); + }, []); + + return ( + <> + + + + + + + + + + + + + + + + {selectedStudy.length > 0 ? ( + + {selectedFolder ? selectedFolder + separator + selectedStudy : selectedStudy} + + ) : ( + + )} + + + + + + + + + ); +} diff --git a/src/components/directory-content-dialog.tsx b/src/components/directory-content-dialog.tsx index fa0271838..04919068b 100644 --- a/src/components/directory-content-dialog.tsx +++ b/src/components/directory-content-dialog.tsx @@ -44,6 +44,7 @@ import * as constants from '../utils/UIconstants'; import type { AppState } from '../redux/types'; import { useParameterState } from './dialogs/use-parameters-dialog'; import type { useDirectoryContent } from '../hooks/useDirectoryContent'; +import FilterBasedContingencyListDialog from './dialogs/contingency-list/filter-based/contingency-list-filter-based-dialog'; export type DirectoryContentDialogApi = { handleClick: (event: CellClickedEvent) => void; @@ -122,6 +123,15 @@ function DirectoryContentDialog( setElementName(''); }, [setActiveElement, setOpenDialog]); + /* Filter based contingency list dialog: window status value for editing a filter based contingency list */ + const [currentFilterBasedContingencyListId, setcurrentFilterBasedContingencyListId] = useState(); + const handleCloseFilterBasedContingency = useCallback(() => { + setOpenDialog(constants.DialogsId.NONE); + setcurrentFilterBasedContingencyListId(undefined); + setActiveElement(undefined); + setElementName(''); + }, [setActiveElement, setOpenDialog]); + const [currentExplicitNamingFilterId, setCurrentExplicitNamingFilterId] = useState(); /* Filters dialog: window status value to edit ExplicitNaming filters */ const handleCloseExplicitNamingFilterDialog = useCallback(() => { @@ -194,6 +204,9 @@ function DirectoryContentDialog( } else if (subtype === ContingencyListType.EXPLICIT_NAMING.id) { setCurrentExplicitNamingContingencyListId(event.data.elementUuid); setOpenDialog(subtype); + } else if (subtype === ContingencyListType.FILTERS.id) { + setcurrentFilterBasedContingencyListId(event.data.elementUuid); + setOpenDialog(subtype); } break; case ElementType.FILTER: @@ -293,6 +306,18 @@ function DirectoryContentDialog( /> ); } + if (currentFilterBasedContingencyListId !== undefined && activeElement) { + return ( + + ); + } if (currentExplicitNamingFilterId !== undefined && activeElement) { return ( { directory: ElementAttributes | null; @@ -233,8 +234,20 @@ export default function DirectoryTreeContextualMenu(props: Readonly handleOpenDialog(DialogsId.ADD_NEW_CONTINGENCY_LIST), icon: , + subMenuItems: [ + { + messageDescriptorId: 'contingencyList.criteriaBasedOrExplicitNaming', + callback: () => + handleOpenDialog(DialogsId.ADD_NEW_EXPLICIT_NAMING_OR_CRITERIA_BASED_CONTINGENCY_LIST), + icon: null, + }, + { + messageDescriptorId: 'contingencyList.filterBased', + callback: () => handleOpenDialog(DialogsId.ADD_NEW_FILTERS_CONTINGENCY_LIST), + icon: null, + }, + ], }, { messageDescriptorId: 'createNewFilter', @@ -358,7 +371,7 @@ export default function DirectoryTreeContextualMenu(props: Readonly; - case DialogsId.ADD_NEW_CONTINGENCY_LIST: + case DialogsId.ADD_NEW_EXPLICIT_NAMING_OR_CRITERIA_BASED_CONTINGENCY_LIST: return ( ); + case DialogsId.ADD_NEW_FILTERS_CONTINGENCY_LIST: + return ( + + ); case DialogsId.ADD_DIRECTORY: return ( } @@ -550,6 +575,18 @@ export function getContingencyList(type: string, id: string) { }); } +export function getIdentifiablesFromFitlers(studyUuid: UUID, filters: UUID[]): Promise { + console.info('get identifiables resulting from application of filters list on study root network'); + + const filtersListsQueryParams = getRequestParamFromList('filtersUuid', filters); + const urlSearchParams = new URLSearchParams(filtersListsQueryParams); + + return backendFetchJson(`${PREFIX_STUDY_QUERIES}/v1/studies/${studyUuid}/filters/elements?${urlSearchParams}`, { + method: 'get', + headers: { 'Content-Type': 'application/json' }, + }); +} + export interface CriteriaBasedEditionFormData { [FieldConstants.NAME]: string; [FieldConstants.DESCRIPTION]?: string; @@ -606,6 +643,30 @@ export function saveCriteriaBasedContingencyList(id: string, form: CriteriaBased }); } +/** + * Saves a Filter contingency list + * @returns {Promise} + */ +export function saveFilterBasedContingencyList( + id: string, + name: string, + description: string, + contingencyList: FilterBasedContingencyList +) { + const urlSearchParams = new URLSearchParams(); + urlSearchParams.append('name', name); + urlSearchParams.append('description', description ?? ''); + urlSearchParams.append('contingencyListType', ContingencyListType.FILTERS.id); + + const url = `${PREFIX_EXPLORE_SERVER_QUERIES}/v1/explore/contingency-lists/${id}?${urlSearchParams.toString()}`; + + return backendFetch(url, { + method: 'put', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(contingencyList), + }); +} + /** * Saves an explicit naming contingency list * @returns {Promise}