diff --git a/package-lock.json b/package-lock.json index 5795ffd79..09424c909 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", - "@gridsuite/commons-ui": "0.59.1", + "@gridsuite/commons-ui": "0.59.2", "@hookform/resolvers": "^3.3.4", "@mui/icons-material": "^5.15.14", "@mui/lab": "5.0.0-alpha.169", @@ -2955,9 +2955,9 @@ } }, "node_modules/@gridsuite/commons-ui": { - "version": "0.59.1", - "resolved": "https://registry.npmjs.org/@gridsuite/commons-ui/-/commons-ui-0.59.1.tgz", - "integrity": "sha512-q82yXqDTRb0L7StWbvnhsmUDw+q1U1ImWgzAHB7mmPoupf1LWucEnA2PR7RAfE6M8Pea9mKp74Cr+SsH6S6DPg==", + "version": "0.59.2", + "resolved": "https://registry.npmjs.org/@gridsuite/commons-ui/-/commons-ui-0.59.2.tgz", + "integrity": "sha512-w8UpzOWl5MjH+olf5w5ThV4gl2MqCQIVVY3oNac/wNj6vG4YWIFEOANhznYjphD/jFOm5A/yUVX9+jsCJqjWng==", "dependencies": { "@react-querybuilder/dnd": "^7.2.0", "@react-querybuilder/material": "^7.2.0", diff --git a/package.json b/package.json index 661157639..bbe47f757 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dependencies": { "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", - "@gridsuite/commons-ui": "0.59.1", + "@gridsuite/commons-ui": "0.59.2", "@hookform/resolvers": "^3.3.4", "@mui/icons-material": "^5.15.14", "@mui/lab": "5.0.0-alpha.169", diff --git a/src/components/app-wrapper.jsx b/src/components/app-wrapper.jsx index 147831cb9..68903a895 100644 --- a/src/components/app-wrapper.jsx +++ b/src/components/app-wrapper.jsx @@ -85,7 +85,10 @@ let lightTheme = createTheme({ secondary: '#F4F4F4', hover: '#8E9C9B', }, - aggrid: 'ag-theme-alpine', + aggrid: { + theme: 'ag-theme-alpine', + highlightColor: '#8e9c9b', + }, agGridBackground: { color: 'white', }, @@ -139,7 +142,10 @@ let darkTheme = createTheme({ secondary: '#323232', hover: '#545C5B', }, - aggrid: 'ag-theme-alpine-dark', + aggrid: { + theme: 'ag-theme-alpine-dark', + highlightColor: '#545c5b', + }, agGridBackground: { color: '#383838', }, diff --git a/src/components/directory-content-table.tsx b/src/components/directory-content-table.tsx new file mode 100644 index 000000000..07c080077 --- /dev/null +++ b/src/components/directory-content-table.tsx @@ -0,0 +1,81 @@ +/** + * 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 { defaultColumnDefinition } from './utils/directory-content-utils'; +import { + CustomAGGrid, + ElementType, + ElementAttributes, +} from '@gridsuite/commons-ui'; +import { AgGridReact } from 'ag-grid-react'; +import { GetRowIdParams } from 'ag-grid-community/dist/types/core/interfaces/iCallbackParams'; +import { ColDef, GridReadyEvent, RowClassParams } from 'ag-grid-community'; +import { RefObject } from 'react'; + +interface DirectoryContentTableProps { + gridRef: RefObject>; + rows: ElementAttributes[]; + handleCellContextualMenu: () => void; + handleRowSelected: () => void; + handleCellClick: () => void; + colDef: ColDef[]; +} + +const onGridReady = ({ api }: GridReadyEvent) => { + api?.sizeColumnsToFit(); +}; + +const getRowId = (params: GetRowIdParams) => + params.data?.elementUuid; + +export const CUSTOM_ROW_CLASS = 'custom-row-class'; + +const getRowStyle = (cellData: RowClassParams) => { + const style: Record = { fontSize: '1rem' }; + if ( + cellData.data && + ![ + ElementType.CASE, + ElementType.LOADFLOW_PARAMETERS, + ElementType.SENSITIVITY_PARAMETERS, + ElementType.SECURITY_ANALYSIS_PARAMETERS, + ElementType.VOLTAGE_INIT_PARAMETERS, + ].includes(cellData.data.type) + ) { + style.cursor = 'pointer'; + } + return style; +}; + +export const DirectoryContentTable = ({ + gridRef, + rows, + handleCellContextualMenu, + handleRowSelected, + handleCellClick, + colDef, +}: DirectoryContentTableProps) => { + return ( + + ); +}; diff --git a/src/components/directory-content.jsx b/src/components/directory-content.jsx index 920a1d0de..9b17a929c 100644 --- a/src/components/directory-content.jsx +++ b/src/components/directory-content.jsx @@ -11,42 +11,44 @@ import { setActiveDirectory, setSelectionForCopy } from '../redux/actions'; import { FormattedMessage, useIntl } from 'react-intl'; import * as constants from '../utils/UIconstants'; - -import Chip from '@mui/material/Chip'; -import Tooltip from '@mui/material/Tooltip'; import CircularProgress from '@mui/material/CircularProgress'; import FolderOpenRoundedIcon from '@mui/icons-material/FolderOpenRounded'; -import StickyNote2OutlinedIcon from '@mui/icons-material/StickyNote2Outlined'; -import VirtualizedTable from './virtualized-table'; import { ContingencyListType, FilterType } from '../utils/elementType'; import { ElementType, - DEFAULT_CELL_PADDING, - getFileIcon, - OverflowableText, useSnackMessage, ExplicitNamingFilterEditionDialog, ExpertFilterEditionDialog, CriteriaBasedFilterEditionDialog, DescriptionModificationDialog, - fetchElementsInfos, + noSelectionForCopy, } from '@gridsuite/commons-ui'; -import { Box, Checkbox } from '@mui/material'; +import { Box } from '@mui/material'; import { elementExists, getFilterById, updateElement } from '../utils/rest-api'; import ContentContextualMenu from './menus/content-contextual-menu'; import ContentToolbar from './toolbars/content-toolbar'; import DirectoryTreeContextualMenu from './menus/directory-tree-contextual-menu'; -import CreateIcon from '@mui/icons-material/Create'; import CriteriaBasedEditionDialog from './dialogs/contingency-list/edition/criteria-based/criteria-based-edition-dialog'; import ExplicitNamingEditionDialog from './dialogs/contingency-list/edition/explicit-naming/explicit-naming-edition-dialog'; import ScriptEditionDialog from './dialogs/contingency-list/edition/script/script-edition-dialog'; -import { noSelectionForCopy } from 'utils/constants'; -import NoContentDirectory from './no-content-directory'; import { useParameterState } from './dialogs/parameters-dialog'; import { PARAM_LANGUAGE } from '../utils/config-params'; +import Grid from '@mui/material/Grid'; +import { useDirectoryContent } from '../hooks/useDirectoryContent'; +import { + getColumnsDefinition, + computeCheckedElements, + formatMetadata, + isRowUnchecked, +} from './utils/directory-content-utils'; +import NoContentDirectory from './no-content-directory'; +import { + DirectoryContentTable, + CUSTOM_ROW_CLASS, +} from './directory-content-table'; const circularProgressSize = '70px'; @@ -55,53 +57,18 @@ const styles = { color: theme.link.color, textDecoration: 'none', }), - cell: { - display: 'flex', - alignItems: 'center', - textAlign: 'center', - boxSizing: 'border-box', - flex: 1, - height: '48px', - padding: `${DEFAULT_CELL_PADDING}px`, - overflow: 'hidden', - textOverflow: 'ellipsis', - }, - chip: { - cursor: 'pointer', - }, - icon: (theme) => ({ - marginRight: theme.spacing(1), - width: '18px', - height: '18px', - }), - circularRoot: (theme) => ({ - marginRight: theme.spacing(1), - }), - checkboxes: { - width: '100%', - justifyContent: 'center', - }, circularProgressContainer: { overflow: 'hidden', display: 'flex', flexDirection: 'row', flexGrow: '1', justifyContent: 'center', + textAlign: 'center', + marginTop: '100px', }, centeredCircularProgress: { alignSelf: 'center', }, - tooltip: { - maxWidth: '1000px', - }, - descriptionTooltip: { - display: 'inline-block', - whiteSpace: 'pre', - textOverflow: 'ellipsis', - overflow: 'hidden', - maxWidth: '250px', - maxHeight: '50px', - }, }; const initialMousePosition = { @@ -163,13 +130,6 @@ const DirectoryContent = () => { }; return broadcast; }); - const [childrenMetadata, setChildrenMetadata] = useState({}); - - const [selectedUuids, setSelectedUuids] = useState(new Set()); - - const currentChildren = useSelector((state) => state.currentChildren); - const currentChildrenRef = useRef(); - currentChildrenRef.current = currentChildren; const appsAndUrls = useSelector((state) => state.appsAndUrls); const selectedDirectory = useSelector((state) => state.selectedDirectory); @@ -179,7 +139,11 @@ const DirectoryContent = () => { useState(true); const intl = useIntl(); - const todayStart = new Date().setHours(0, 0, 0, 0); + const gridRef = useRef(); + const [rows, childrenMetadata] = useDirectoryContent( + setIsMissingDataAfterDirChange + ); + const [checkedRows, setCheckedRows] = useState([]); /* Menu states */ const [mousePosition, setMousePosition] = useState(initialMousePosition); @@ -270,7 +234,7 @@ const DirectoryContent = () => { const handleOpenContentMenu = (event) => { setOpenContentMenu(true); - event.stopPropagation(); + event?.stopPropagation(); }; const handleCloseContentMenu = useCallback(() => { @@ -297,47 +261,30 @@ const DirectoryContent = () => { ); const contextualMixPolicy = contextualMixPolicies.ALL; - const onContextMenu = useCallback( + const onCellContextMenu = useCallback( (event) => { - const element = currentChildren?.find( - (e) => e.elementUuid === event.rowData?.elementUuid - ); - - if (selectedDirectory) { - dispatch(setActiveDirectory(selectedDirectory.elementUuid)); - } - - if (element && element.uploading !== null) { - if (element.type !== 'DIRECTORY') { + if (event.data && event.data.uploading !== null) { + if (event.data.type !== 'DIRECTORY') { setActiveElement({ hasMetadata: - childrenMetadata[event.rowData.elementUuid] !== + childrenMetadata[event.data.elementUuid] !== undefined, specificMetadata: - childrenMetadata[event.rowData.elementUuid] + childrenMetadata[event.data.elementUuid] ?.specificMetadata, - ...element, + ...event.data, }); - if (contextualMixPolicy === contextualMixPolicies.BIG) { // If some elements were already selected and the active element is not in them, we deselect the already selected elements. - if ( - selectedUuids?.size && - element?.elementUuid && - !selectedUuids.has(element.elementUuid) - ) { - setSelectedUuids(new Set()); + if (isRowUnchecked(event.data, checkedRows)) { + gridRef.current?.api.deselectAll(); } } else { // If some elements were already selected, we add the active element to the selected list if not already in it. - if ( - selectedUuids?.size && - element?.elementUuid && - !selectedUuids.has(element.elementUuid) - ) { - let updatedSelectedUuids = new Set(selectedUuids); - updatedSelectedUuids.add(element.elementUuid); - setSelectedUuids(updatedSelectedUuids); + if (isRowUnchecked(event.data, checkedRows)) { + gridRef.current?.api + .getRowNode(event.data.elementUuid) + .setSelected(true); } } } @@ -346,7 +293,24 @@ const DirectoryContent = () => { mouseY: event.event.clientY + constants.VERTICAL_SHIFT, }); handleOpenContentMenu(event.event); - } else { + } + }, + [ + checkedRows, + childrenMetadata, + contextualMixPolicies.BIG, + contextualMixPolicy, + ] + ); + + const onContextMenu = useCallback( + (event) => { + //We check if the context menu was triggered from a row to prevent displaying both the directory and the content context menus + const isRow = !!event.target.closest(`.${CUSTOM_ROW_CLASS}`); + if (!isRow) { + if (selectedDirectory) { + dispatch(setActiveDirectory(selectedDirectory.elementUuid)); + } setMousePosition({ mouseX: event.clientX + constants.HORIZONTAL_SHIFT, mouseY: event.clientY + constants.VERTICAL_SHIFT, @@ -354,26 +318,9 @@ const DirectoryContent = () => { handleOpenDirectoryMenu(event); } }, - [ - currentChildren, - dispatch, - selectedDirectory, - selectedUuids, - contextualMixPolicies, - contextualMixPolicy, - childrenMetadata, - ] + [dispatch, selectedDirectory] ); - const abbreviationFromUserName = (name) => { - const tab = name.split(' ').map((x) => x.charAt(0)); - if (tab.length === 1) { - return tab[0]; - } else { - return tab[0] + tab[tab.length - 1]; - } - }; - const handleError = useCallback( (message) => { snackError({ @@ -406,72 +353,96 @@ const DirectoryContent = () => { [appsAndUrls] ); - const handleRowClick = useCallback( + const handleDescriptionIconClick = (e) => { + setActiveElement(e.data); + setOpenDescModificationDialog(true); + }; + + const handleCellClick = useCallback( (event) => { - const element = currentChildren.find( - (e) => e.elementUuid === event.rowData.elementUuid - ); - if (childrenMetadata[element.elementUuid] !== undefined) { - setElementName(childrenMetadata[element.elementUuid]?.name); - const subtype = childrenMetadata[element.elementUuid].subtype; - /** set active directory on the store because it will be used while editing the contingency name */ - dispatch(setActiveDirectory(selectedDirectory?.elementUuid)); - switch (element.type) { - case ElementType.STUDY: - let url = getLink(element.elementUuid, element.type); - url - ? window.open(url, '_blank') - : handleError( - intl.formatMessage( - { id: 'getAppLinkError' }, - { type: element.type } - ) - ); - break; - case ElementType.CONTINGENCY_LIST: - if (subtype === ContingencyListType.CRITERIA_BASED.id) { - setCurrentFiltersContingencyListId( - element.elementUuid - ); - setOpenDialog(subtype); - } else if (subtype === ContingencyListType.SCRIPT.id) { - setCurrentScriptContingencyListId( - element.elementUuid - ); - setOpenDialog(subtype); - } else if ( - subtype === ContingencyListType.EXPLICIT_NAMING.id - ) { - setCurrentExplicitNamingContingencyListId( - element.elementUuid - ); - setOpenDialog(subtype); - } - break; - case ElementType.FILTER: - if (subtype === FilterType.EXPLICIT_NAMING.id) { - setCurrentExplicitNamingFilterId( - element.elementUuid - ); - setOpenDialog(subtype); - } else if (subtype === FilterType.CRITERIA_BASED.id) { - setCurrentCriteriaBasedFilterId( - element.elementUuid + if (event.colDef.field === 'description') { + handleDescriptionIconClick(event); + } else { + if (childrenMetadata[event.data.elementUuid] !== undefined) { + setElementName( + childrenMetadata[event.data.elementUuid]?.elementName + ); + const subtype = + childrenMetadata[event.data.elementUuid] + .specificMetadata.type; + /** set active directory on the store because it will be used while editing the contingency name */ + dispatch( + setActiveDirectory(selectedDirectory?.elementUuid) + ); + switch (event.data.type) { + case ElementType.STUDY: + let url = getLink( + event.data.elementUuid, + event.data.type ); - setOpenDialog(subtype); - } else if (subtype === FilterType.EXPERT.id) { - setCurrentExpertFilterId(element.elementUuid); - setOpenDialog(subtype); - } - break; - default: - break; + url + ? window.open(url, '_blank') + : handleError( + intl.formatMessage( + { id: 'getAppLinkError' }, + { type: event.data.type } + ) + ); + break; + case ElementType.CONTINGENCY_LIST: + if ( + subtype === + ContingencyListType.CRITERIA_BASED.id + ) { + setCurrentFiltersContingencyListId( + event.data.elementUuid + ); + setOpenDialog(subtype); + } else if ( + subtype === ContingencyListType.SCRIPT.id + ) { + setCurrentScriptContingencyListId( + event.data.elementUuid + ); + setOpenDialog(subtype); + } else if ( + subtype === + ContingencyListType.EXPLICIT_NAMING.id + ) { + setCurrentExplicitNamingContingencyListId( + event.data.elementUuid + ); + setOpenDialog(subtype); + } + break; + case ElementType.FILTER: + if (subtype === FilterType.EXPLICIT_NAMING.id) { + setCurrentExplicitNamingFilterId( + event.data.elementUuid + ); + setOpenDialog(subtype); + } else if ( + subtype === FilterType.CRITERIA_BASED.id + ) { + setCurrentCriteriaBasedFilterId( + event.data.elementUuid + ); + setOpenDialog(subtype); + } else if (subtype === FilterType.EXPERT.id) { + setCurrentExpertFilterId( + event.data.elementUuid + ); + setOpenDialog(subtype); + } + break; + default: + break; + } } } }, [ childrenMetadata, - currentChildren, dispatch, getLink, handleError, @@ -480,360 +451,44 @@ const DirectoryContent = () => { ] ); - const getElementTypeTranslation = useCallback( - (type, subtype, formatCase) => { - let translatedType; - switch (type) { - case ElementType.FILTER: - case ElementType.CONTINGENCY_LIST: - translatedType = intl.formatMessage({ - id: subtype + '_' + type, - }); - break; - case ElementType.MODIFICATION: - translatedType = - intl.formatMessage({ id: type }) + - ' (' + - intl.formatMessage({ - id: 'network_modifications.' + subtype, - }) + - ')'; - break; - default: - translatedType = intl.formatMessage({ id: type }); - break; - } - - const translatedFormat = formatCase - ? ' (' + intl.formatMessage({ id: formatCase }) + ')' - : ''; - - return `${translatedType}${translatedFormat}`; - }, - [intl] - ); - - const typeCellRender = useCallback((cellData) => { - const { rowData = {} } = cellData || {}; - return ( - - - - ); - }, []); - - function userCellRender(cellData) { - const user = cellData.rowData[cellData.dataKey]; - return ( - - - - - - ); - } - - function dateCellRender(cellData) { - const data = new Date(cellData.rowData[cellData.dataKey]); - if (data instanceof Date && !isNaN(data)) { - const cellMidnight = new Date(data).setHours(0, 0, 0, 0); - - const time = new Intl.DateTimeFormat(intl.locale, { - timeStyle: 'medium', - hour12: false, - }).format(data); - const displayedDate = - intl.locale === 'en' - ? data.toISOString().substring(0, 10) - : data.toLocaleDateString(intl.locale); - const cellText = todayStart === cellMidnight ? time : displayedDate; - const fullDate = new Intl.DateTimeFormat(intl.locale, { - dateStyle: 'long', - timeStyle: 'long', - hour12: false, - }).format(data); - - return ( - - - {cellText} - - - ); - } - } - const [openDescModificationDialog, setOpenDescModificationDialog] = useState(false); - const descriptionCellRender = useCallback( - (cellData) => { - const element = currentChildren.find( - (e) => e.elementUuid === cellData.rowData.elementUuid - ); - - const description = element.description; - const descriptionLines = description?.split('\n'); - if (descriptionLines?.length > 3) { - descriptionLines[2] = '...'; - } - const tooltip = descriptionLines?.join('\n'); - - const handleDescriptionIconClick = (e) => { - setActiveElement(element); - setOpenDescModificationDialog(true); - e.stopPropagation(); - }; - - const icon = description ? ( - - } - placement="right" - > - - - ) : ( - - ); - return ( - <> - {icon} - - ); - }, - [currentChildren] - ); - - const getDisplayedElementName = useCallback( - (cellData) => { - const { elementName, uploading, elementUuid } = cellData.rowData; - const formatMessage = intl.formatMessage; - if (uploading) { - return elementName + '\n' + formatMessage({ id: 'uploading' }); - } - if (!childrenMetadata[elementUuid]) { - return ( - elementName + - '\n' + - formatMessage({ id: 'creationInProgress' }) - ); - } - return childrenMetadata[elementUuid].name; - }, - [childrenMetadata, intl.formatMessage] - ); - - const isElementCaseOrStudy = (objectType) => { - return ( - objectType === ElementType.STUDY || objectType === ElementType.CASE - ); - }; - - const nameCellRender = useCallback( - (cellData) => { - const element = currentChildren.find( - (e) => e.elementUuid === cellData.rowData.elementUuid - ); - return ( - - {/* Icon */} - {!childrenMetadata[element.elementUuid] && - isElementCaseOrStudy(element.type) && ( - - )} - {childrenMetadata[element.elementUuid] && - getFileIcon(element.type, styles.icon)} - {/* Name */} - - - ); - }, - [childrenMetadata, currentChildren, getDisplayedElementName] - ); - - function toggleSelection(elementUuid) { - let element = currentChildren?.find( - (e) => e.elementUuid === elementUuid - ); - if (element === undefined) { - return; - } - let newSelection = new Set(selectedUuids); - if (!newSelection.delete(elementUuid)) { - newSelection.add(elementUuid); - } - setSelectedUuids(newSelection); - } - - function toggleSelectAll() { - if (selectedUuids.size === 0) { - setSelectedUuids( - new Set( - currentChildren - .filter((e) => !e.uploading) - .map((c) => c.elementUuid) - ) - ); - } else { - setSelectedUuids(new Set()); - } - } - - function selectionHeaderRenderer() { - return ( - { - toggleSelectAll(); - e.stopPropagation(); - }} - sx={styles.checkboxes} - > - 0} - indeterminate={ - selectedUuids.size !== 0 && - selectedUuids.size !== currentChildren.length - } - /> - - ); - } - - function selectionRenderer(cellData) { - const elementUuid = cellData.rowData['elementUuid']; - return ( - { - toggleSelection(elementUuid); - e.stopPropagation(); - }} - sx={styles.checkboxes} - > - - - ); - } - useEffect(() => { if (!selectedDirectory) { return; } setIsMissingDataAfterDirChange(true); + setCheckedRows([]); }, [selectedDirectory, setIsMissingDataAfterDirChange]); - useEffect(() => { - if (!currentChildren?.length) { - setChildrenMetadata({}); - setIsMissingDataAfterDirChange(false); - return; - } - - let metadata = {}; - let childrenToFetchElementsInfos = Object.values(currentChildren) - .filter((e) => !e.uploading) - .map((e) => e.elementUuid); - if (childrenToFetchElementsInfos.length > 0) { - fetchElementsInfos(childrenToFetchElementsInfos) - .then((res) => { - res.forEach((e) => { - metadata[e.elementUuid] = { - name: e.elementName, - subtype: e.specificMetadata - ? e.specificMetadata.type - : null, - format: e.specificMetadata?.format ?? null, - specificMetadata: e.specificMetadata, - }; - }); - }) - .catch((error) => { - if (Object.keys(currentChildrenRef.current).length === 0) { - handleError(error.message); - } - }) - .finally(() => { - // discarding request for older directory - if (currentChildrenRef.current === currentChildren) { - setChildrenMetadata(metadata); - setIsMissingDataAfterDirChange(false); - } - }); - } - setSelectedUuids(new Set()); - }, [handleError, currentChildren, currentChildrenRef]); + const isActiveElementUnchecked = useMemo( + () => + activeElement && + !checkedRows.find( + (children) => children.elementUuid === activeElement.elementUuid + ), + [activeElement, checkedRows] + ); - const getSelectedChildren = () => { - let selectedChildren = []; - if (currentChildren?.length > 0) { - // Adds the previously selected elements - if (selectedUuids?.size) { - selectedChildren = currentChildren - .filter( - (child) => - selectedUuids.has(child.elementUuid) && - child.elementUuid !== activeElement?.elementUuid - ) - .map((child) => { - return { - subtype: - childrenMetadata[child.elementUuid]?.subtype, - hasMetadata: - childrenMetadata[child.elementUuid] !== - undefined, - ...child, - }; - }); - } + const handleRowSelected = useCallback(() => { + setCheckedRows(computeCheckedElements(gridRef, childrenMetadata)); + }, [childrenMetadata]); - // Adds the active element - if (activeElement) { - selectedChildren.push({ - ...activeElement, - subtype: - childrenMetadata[activeElement.elementUuid]?.subtype, - hasMetadata: - childrenMetadata[activeElement.elementUuid] !== - undefined, - }); - } + //It includes checked rows and the row with its context menu open + const fullSelection = useMemo(() => { + const selection = [...checkedRows]; + if (isActiveElementUnchecked) { + selection.push(formatMetadata(activeElement, childrenMetadata)); } - return [...new Set(selectedChildren)]; - }; + return selection; + }, [ + activeElement, + checkedRows, + childrenMetadata, + isActiveElementUnchecked, + ]); - const rows = useMemo( - () => - currentChildren?.map((child) => ({ - ...child, - type: - childrenMetadata[child.elementUuid] && - getElementTypeTranslation( - child.type, - childrenMetadata[child.elementUuid].subtype, - childrenMetadata[child.elementUuid].format - ), - notClickable: child.type === ElementType.CASE, - })), - [childrenMetadata, currentChildren, getElementTypeTranslation] - ); const handleOpenDialog = useCallback(() => { setOpenDialog(constants.DialogsId.ADD_ROOT_DIRECTORY); }, []); @@ -853,13 +508,6 @@ const DirectoryContent = () => { const renderEmptyDirContent = () => { return ( <> - 0 ? getSelectedChildren() : [] - } - />
{ ); }; - const renderTableContent = () => { - return ( - <> - 0 ? getSelectedChildren() : [] - } - /> - onContextMenu(e)} - onRowClick={handleRowClick} - rows={rows} - columns={[ - { - cellRenderer: selectionRenderer, - dataKey: 'selected', - label: '', - headerRenderer: selectionHeaderRenderer, - minWidth: '3%', - }, - { - label: intl.formatMessage({ - id: 'elementName', - }), - dataKey: 'elementName', - cellRenderer: nameCellRender, - minWidth: '31%', - }, - { - label: intl.formatMessage({ - id: 'description', - }), - dataKey: 'description', - minWidth: '10%', - cellRenderer: descriptionCellRender, - }, - { - minWidth: '15%', - label: intl.formatMessage({ - id: 'type', - }), - dataKey: 'type', - cellRenderer: typeCellRender, - }, - { - minWidth: '10%', - label: intl.formatMessage({ - id: 'creator', - }), - dataKey: 'owner', - cellRenderer: userCellRender, - }, - { - minWidth: '10%', - label: intl.formatMessage({ - id: 'created', - }), - dataKey: 'creationDate', - cellRenderer: dateCellRender, - }, - { - minWidth: '11%', - label: intl.formatMessage({ - id: 'modifiedBy', - }), - dataKey: 'lastModifiedBy', - cellRenderer: userCellRender, - }, - { - minWidth: '10%', - label: intl.formatMessage({ - id: 'modified', - }), - dataKey: 'lastModificationDate', - cellRenderer: dateCellRender, - }, - ]} - sortable - /> - - ); - }; - const renderContent = () => { // Here we wait for Metadata for the folder content if (isMissingDataAfterDirChange) { @@ -965,7 +527,7 @@ const DirectoryContent = () => { } // If no selection or currentChildren = null (first time) render nothing - if (!currentChildren || !selectedDirectory) { + if (!rows || !selectedDirectory) { if (treeData.rootDirectories.length === 0 && treeData.initialized) { return ( @@ -975,12 +537,21 @@ const DirectoryContent = () => { } // If empty dir then render an appropriate content - if (currentChildren.length === 0) { + if (rows.length === 0) { return renderEmptyDirContent(); } // Finally if we have elements then render the table - return renderTableContent(); + return ( + + ); }; const renderDialog = (name) => { @@ -1097,17 +668,10 @@ const DirectoryContent = () => { return ( <> -
onContextMenu(e)} - > + {rows && } + {renderContent()} -
- +
{ if ( @@ -1121,7 +685,7 @@ const DirectoryContent = () => { > { } broadcastChannel={broadcastChannel} /> - = ({ inputRef }) => { const elementUuidPath = matchingElement?.pathUuid.reverse(); const promises = elementUuidPath.map((e: string) => { return fetchDirectoryContent(e as UUID) - .then((res: IElement[]) => { + .then((res) => { updateMapData( e, res.filter( diff --git a/src/components/utils/directory-content-utils.ts b/src/components/utils/directory-content-utils.ts new file mode 100644 index 000000000..ab778fdef --- /dev/null +++ b/src/components/utils/directory-content-utils.ts @@ -0,0 +1,131 @@ +/** + * 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 { IntlShape } from 'react-intl'; +import { UUID } from 'crypto'; +import { AgGridReact } from 'ag-grid-react'; +import React from 'react'; +import { ColDef } from 'ag-grid-community'; +import { NameCellRenderer } from './renderers/name-cell-renderer'; +import { DescriptionCellRenderer } from './renderers/description-cell-renderer'; +import { TypeCellRenderer } from './renderers/type-cell-renderer'; +import { UserCellRenderer } from './renderers/user-cell-renderer'; +import { DateCellRenderer } from './renderers/date-cell-renderer'; +import type { ElementAttributes } from '@gridsuite/commons-ui'; + +export const formatMetadata = ( + data: ElementAttributes, + childrenMetadata: Record +) => ({ + ...data, + subtype: childrenMetadata[data.elementUuid]?.specificMetadata.type, + hasMetadata: !!childrenMetadata[data.elementUuid], +}); + +export const computeCheckedElements = ( + gridRef: React.MutableRefObject, + childrenMetadata: Record +) => { + return ( + gridRef.current?.api + ?.getSelectedRows() + .map((row: ElementAttributes) => + formatMetadata(row, childrenMetadata) + ) ?? [] + ); +}; + +export const isRowUnchecked = ( + row: ElementAttributes, + checkedRows: ElementAttributes[] +) => + checkedRows?.length && + row?.elementUuid && + !checkedRows.find( + (checkedRow) => checkedRow.elementUuid === row.elementUuid + ); + +export const defaultColumnDefinition = { + sortable: true, + resizable: false, + lockPinned: true, + wrapHeaderText: true, + autoHeaderHeight: true, + suppressMovable: true, + flex: 1, +}; +export const getColumnsDefinition = ( + childrenMetadata: Record, + intl: IntlShape +): ColDef[] => [ + { + headerName: intl.formatMessage({ + id: 'elementName', + }), + field: 'elementName', + cellRenderer: NameCellRenderer, + cellRendererParams: { + childrenMetadata: childrenMetadata, + }, + headerCheckboxSelection: true, + checkboxSelection: true, + flex: 5, + }, + { + headerName: intl.formatMessage({ + id: 'description', + }), + field: 'description', + cellRenderer: DescriptionCellRenderer, + maxWidth: 150, + }, + { + headerName: intl.formatMessage({ + id: 'type', + }), + field: 'type', + cellRenderer: TypeCellRenderer, + cellRendererParams: { + childrenMetadata: childrenMetadata, + }, + flex: 2, + }, + { + headerName: intl.formatMessage({ + id: 'creator', + }), + field: 'owner', + cellRenderer: UserCellRenderer, + maxWidth: 150, + }, + { + headerName: intl.formatMessage({ + id: 'created', + }), + field: 'creationDate', + cellRenderer: DateCellRenderer, + maxWidth: 150, + flex: 2, + }, + { + headerName: intl.formatMessage({ + id: 'modifiedBy', + }), + field: 'lastModifiedBy', + cellRenderer: UserCellRenderer, + maxWidth: 150, + }, + { + headerName: intl.formatMessage({ + id: 'modified', + }), + field: 'lastModificationDate', + cellRenderer: DateCellRenderer, + maxWidth: 150, + flex: 2, + }, +]; diff --git a/src/components/utils/renderers/date-cell-renderer.tsx b/src/components/utils/renderers/date-cell-renderer.tsx new file mode 100644 index 000000000..aa312734b --- /dev/null +++ b/src/components/utils/renderers/date-cell-renderer.tsx @@ -0,0 +1,42 @@ +/** + * 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 { useIntl } from 'react-intl'; +import { Box } from '@mui/material'; +import Tooltip from '@mui/material/Tooltip'; + +export const DateCellRenderer = ({ value }: { value: string }) => { + const intl = useIntl(); + + const todayStart = new Date().setHours(0, 0, 0, 0); + const dateValue = new Date(value); + if (!isNaN(dateValue.getDate())) { + const cellMidnight = new Date(value).setHours(0, 0, 0, 0); + + const time = new Intl.DateTimeFormat(intl.locale, { + timeStyle: 'medium', + hour12: false, + }).format(dateValue); + const displayedDate = + intl.locale === 'en' + ? dateValue.toISOString().substring(0, 10) + : dateValue.toLocaleDateString(intl.locale); + const cellText = todayStart === cellMidnight ? time : displayedDate; + const fullDate = new Intl.DateTimeFormat(intl.locale, { + dateStyle: 'long', + timeStyle: 'long', + hour12: false, + }).format(dateValue); + + return ( + + + {cellText} + + + ); + } +}; diff --git a/src/components/utils/renderers/description-cell-renderer.tsx b/src/components/utils/renderers/description-cell-renderer.tsx new file mode 100644 index 000000000..d88c68018 --- /dev/null +++ b/src/components/utils/renderers/description-cell-renderer.tsx @@ -0,0 +1,52 @@ +/** + * 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 Tooltip from '@mui/material/Tooltip'; +import { Box } from '@mui/material'; +import StickyNote2OutlinedIcon from '@mui/icons-material/StickyNote2Outlined'; +import CreateIcon from '@mui/icons-material/Create'; +import { ElementAttributes } from '@gridsuite/commons-ui'; + +const styles = { + descriptionTooltip: { + display: 'inline-block', + whiteSpace: 'pre', + textOverflow: 'ellipsis', + overflow: 'hidden', + maxWidth: '250px', + maxHeight: '50px', + cursor: 'pointer', + }, +}; + +export const DescriptionCellRenderer = ({ + data, +}: { + data: ElementAttributes; +}) => { + const description = data.description; + const descriptionLines = description?.split('\n'); + if (descriptionLines?.length > 3) { + descriptionLines[2] = '...'; + } + const tooltip = descriptionLines?.join('\n'); + + const icon = description ? ( + } + placement="right" + > + + + ) : ( + + ); + return ( + + {icon} + + ); +}; diff --git a/src/components/utils/renderers/name-cell-renderer.tsx b/src/components/utils/renderers/name-cell-renderer.tsx new file mode 100644 index 000000000..90571f6a7 --- /dev/null +++ b/src/components/utils/renderers/name-cell-renderer.tsx @@ -0,0 +1,89 @@ +/** + * 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 { UUID } from 'crypto'; +import { IntlShape, useIntl } from 'react-intl'; +import { Box, Theme } from '@mui/material'; +import CircularProgress from '@mui/material/CircularProgress'; +import { + ElementType, + getFileIcon, + OverflowableText, + ElementAttributes, +} from '@gridsuite/commons-ui'; + +const isElementCaseOrStudy = (objectType: ElementType) => { + return objectType === ElementType.STUDY || objectType === ElementType.CASE; +}; + +const getDisplayedElementName = ( + data: ElementAttributes, + childrenMetadata: Record, + intl: IntlShape +) => { + const { elementName, uploading, elementUuid } = data; + const formatMessage = intl.formatMessage; + if (uploading) { + return elementName + '\n' + formatMessage({ id: 'uploading' }); + } + if (!childrenMetadata[elementUuid]) { + return elementName + '\n' + formatMessage({ id: 'creationInProgress' }); + } + return childrenMetadata[elementUuid].elementName; +}; + +const styles = { + tableCell: { + fontSize: '1rem', + display: 'flex', + alignItems: 'center', + }, + circularRoot: (theme: Theme) => ({ + marginRight: theme.spacing(1), + }), + icon: (theme: Theme) => ({ + marginRight: theme.spacing(1), + width: '18px', + height: '18px', + }), + tooltip: { + maxWidth: '1000px', + }, + overflow: { + display: 'inline-block', + whiteSpace: 'pre', + textOverflow: 'ellipsis', + overflow: 'hidden', + lineHeight: 'initial', + verticalAlign: 'middle', + }, +}; +export const NameCellRenderer = ({ + data, + childrenMetadata, +}: { + data: ElementAttributes; + childrenMetadata: Record; +}) => { + const intl = useIntl(); + return ( + + {/* Icon */} + {!childrenMetadata[data.elementUuid] && + isElementCaseOrStudy(data.type) && ( + + )} + {childrenMetadata[data.elementUuid] && + getFileIcon(data.type, styles.icon)} + {/* Name */} + + + ); +}; diff --git a/src/components/utils/renderers/type-cell-renderer.tsx b/src/components/utils/renderers/type-cell-renderer.tsx new file mode 100644 index 000000000..1258d67cf --- /dev/null +++ b/src/components/utils/renderers/type-cell-renderer.tsx @@ -0,0 +1,89 @@ +/** + * 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, + OverflowableText, + ElementAttributes, +} from '@gridsuite/commons-ui'; +import { IntlShape, useIntl } from 'react-intl'; +import { UUID } from 'crypto'; +import { Box } from '@mui/material'; + +const getElementTypeTranslation = ( + type: ElementType, + subtype: string | null, + formatCase: string | null, + intl: IntlShape +) => { + let translatedType; + switch (type) { + case ElementType.FILTER: + case ElementType.CONTINGENCY_LIST: + translatedType = intl.formatMessage({ + id: subtype ? subtype + '_' + type : type, + }); + break; + case ElementType.MODIFICATION: + translatedType = + intl.formatMessage({ id: type }) + + ' (' + + intl.formatMessage({ + id: 'network_modifications.' + subtype, + }) + + ')'; + break; + default: + translatedType = type ? intl.formatMessage({ id: type }) : ''; + break; + } + + const translatedFormat = formatCase + ? ' (' + intl.formatMessage({ id: formatCase }) + ')' + : ''; + + return `${translatedType}${translatedFormat}`; +}; + +const styles = { + tooltip: { + maxWidth: '1000px', + }, + tableCell: { + fontSize: '1rem', + display: 'flex', + alignItems: 'center', + }, +}; + +export const TypeCellRenderer = ({ + data, + childrenMetadata, +}: { + data: ElementAttributes; + childrenMetadata: Record; +}) => { + const intl = useIntl(); + return ( + childrenMetadata[data?.elementUuid] && ( + + + + ) + ); +}; diff --git a/src/components/utils/renderers/user-cell-renderer.tsx b/src/components/utils/renderers/user-cell-renderer.tsx new file mode 100644 index 000000000..4193b74a8 --- /dev/null +++ b/src/components/utils/renderers/user-cell-renderer.tsx @@ -0,0 +1,40 @@ +/** + * 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 { Box } from '@mui/material'; +import Tooltip from '@mui/material/Tooltip'; +import Chip from '@mui/material/Chip'; + +const abbreviationFromUserName = (name: string | null) => { + if (name === null) { + return ''; + } + const tab = name.split(' ').map((x) => x.charAt(0)); + if (tab.length === 1) { + return tab[0]; + } else { + return tab[0] + tab[tab.length - 1]; + } +}; + +const styles = { + chip: { + cursor: 'pointer', + }, +}; + +export const UserCellRenderer = ({ value }: { value: string }) => { + return ( + + + + + + ); +}; diff --git a/src/hooks/useDirectoryContent.ts b/src/hooks/useDirectoryContent.ts new file mode 100644 index 000000000..9f8f4ca2a --- /dev/null +++ b/src/hooks/useDirectoryContent.ts @@ -0,0 +1,79 @@ +/** + * 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 { useSelector } from 'react-redux'; +import React, { useRef, useEffect, useCallback, useState } from 'react'; +import { + ElementAttributes, + fetchElementsInfos, + useSnackMessage, +} from '@gridsuite/commons-ui'; +import { UUID } from 'crypto'; +import { ReduxState } from '../redux/reducer.type'; + +export const useDirectoryContent = ( + setIsMissingDataAfterDirChange: React.Dispatch< + React.SetStateAction + > +) => { + const currentChildren = useSelector( + (state: ReduxState) => state.currentChildren + ); + const [childrenMetadata, setChildrenMetadata] = useState< + Record + >({}); + const { snackError } = useSnackMessage(); + const previousData = useRef(); + previousData.current = currentChildren; + + const handleError = useCallback( + (message: string) => { + snackError({ + messageTxt: message, + }); + }, + [snackError] + ); + + useEffect(() => { + if (!currentChildren?.length) { + setChildrenMetadata({}); + setIsMissingDataAfterDirChange(false); + return; + } + + let metadata: Record = {}; + let childrenToFetchElementsInfos = Object.values(currentChildren) + .filter((e) => !e.uploading) + .map((e) => e.elementUuid); + if (childrenToFetchElementsInfos.length > 0) { + fetchElementsInfos(childrenToFetchElementsInfos) + .then((res) => { + res.forEach((e) => { + metadata[e.elementUuid] = e; + }); + }) + .catch((error) => { + if ( + previousData.current && + Object.keys(previousData.current).length === 0 + ) { + handleError(error.message); + } + }) + .finally(() => { + // discarding request for older directory + if (previousData.current === currentChildren) { + setChildrenMetadata(metadata); + setIsMissingDataAfterDirChange(false); + } + }); + } + }, [handleError, currentChildren, setIsMissingDataAfterDirChange]); + + return [currentChildren, childrenMetadata]; +}; diff --git a/src/redux/reducer.type.ts b/src/redux/reducer.type.ts index 77dd319c9..6e8c4281d 100644 --- a/src/redux/reducer.type.ts +++ b/src/redux/reducer.type.ts @@ -4,7 +4,7 @@ * 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 } from '@gridsuite/commons-ui'; +import { ElementAttributes, ElementType } from '@gridsuite/commons-ui'; import { UUID } from 'crypto'; type UserProfile = { @@ -23,21 +23,8 @@ interface IUser { expires_at: number; } -export interface IElement { - elementUuid: UUID; - elementName: string; - type: ElementType; - owner: string; - subdirectoriesCount: number; - creationDate: string; - lastModificationDate: string; - lastModifiedBy: string; - children: any[]; - parentUuid: null | UUID; -} - // IDirectory is exactly an IElement, with a specific type value -export type IDirectory = IElement & { +export type IDirectory = ElementAttributes & { type: ElementType.DIRECTORY; }; @@ -48,7 +35,8 @@ export interface ITreeData { export interface ReduxState { activeDirectory: UUID; - selectedDirectory: IDirectory; + currentChildren: ElementAttributes[]; + selectedDirectory: ElementAttributes; treeData: ITreeData; user: IUser; }