diff --git a/src/components/customAGGrid/cell-renderers.tsx b/src/components/customAGGrid/cell-renderers.tsx new file mode 100644 index 000000000..c7348bece --- /dev/null +++ b/src/components/customAGGrid/cell-renderers.tsx @@ -0,0 +1,264 @@ +/** + * Copyright (c) 2023, 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, Checkbox, Tooltip } from '@mui/material'; +import { ReactNode, useEffect, useRef, useState } from 'react'; +import { isBlankOrEmpty } from '../../utils/conversionUtils'; +import { ICellRendererParams } from 'ag-grid-community'; +import { CustomCellRendererProps } from 'ag-grid-react'; +import { mergeSx, type MuiStyles } from '../../utils/styles'; +import { useIntl } from 'react-intl'; + +const styles = { + tableCell: (theme) => ({ + fontSize: 'small', + cursor: 'inherit', + display: 'flex', + '&:before': { + content: '""', + position: 'absolute', + left: theme.spacing(0.5), + right: theme.spacing(0.5), + bottom: 0, + }, + }), + overflow: { + whiteSpace: 'pre', + textOverflow: 'ellipsis', + overflow: 'hidden', + }, + numericValue: { + marginLeft: 'inherit', + }, +} as const satisfies MuiStyles; + +const FORMULA_ERROR_KEY = 'spreadsheet/formula/error'; + +interface BaseCellRendererProps { + value: string | undefined; + tooltip?: string; +} + +export const BooleanCellRenderer = (props: any) => { + const isChecked = props.value; + return ( +
+ {props.value !== undefined && ( + + )} +
+ ); +}; + +export const BooleanNullableCellRenderer = (props: any) => { + return ( +
+ +
+ ); +}; + +const formatNumericCell = (value: number, fractionDigits?: number) => { + if (value === null || isNaN(value)) { + return { value: null }; + } + return { value: value.toFixed(fractionDigits ?? 2), tooltip: value?.toString() }; +}; + +const formatCell = (props: any) => { + let value = props?.valueFormatted || props.value; + let tooltipValue = undefined; + // we use valueGetter only if value is not defined + if (!value && props.colDef.valueGetter) { + props.colDef.valueGetter(props); + } + if (value != null && props.colDef.context?.numeric && props.colDef.context?.fractionDigits) { + // only numeric rounded cells have a tooltip (their raw numeric value) + tooltipValue = value; + value = parseFloat(value).toFixed(props.colDef.context.fractionDigits); + } + if (props.colDef.context?.numeric && isNaN(value)) { + value = null; + } + return { value: value, tooltip: tooltipValue }; +}; + +export interface NumericCellRendererProps extends CustomCellRendererProps { + fractionDigits?: number; +} + +export const NumericCellRenderer = (props: NumericCellRendererProps) => { + const numericalValue = typeof props.value === 'number' ? props.value : Number.parseFloat(props.value); + const cellValue = formatNumericCell(numericalValue, props.fractionDigits); + return ( + + + {cellValue.value} + + + ); +}; + +const BaseCellRenderer = ({ value, tooltip }: BaseCellRendererProps) => ( + + + {value} + + +); + +export const ErrorCellRenderer = (props: CustomCellRendererProps) => { + const intl = useIntl(); + const errorMessage = intl.formatMessage({ id: props.value?.error }); + const errorValue = intl.formatMessage({ id: FORMULA_ERROR_KEY }); + return ; +}; + +export const DefaultCellRenderer = (props: CustomCellRendererProps) => { + const cellValue = formatCell(props).value?.toString(); + return ; +}; + +export const NetworkModificationNameCellRenderer = (props: CustomCellRendererProps) => { + return ( + + + {props.value} + + + ); +}; + +export const MessageLogCellRenderer = ({ + param, + highlightColor, + currentHighlightColor, + searchTerm, + currentResultIndex, + searchResults, +}: { + param: ICellRendererParams; + highlightColor?: string; + currentHighlightColor?: string; + searchTerm?: string; + currentResultIndex?: number; + searchResults?: number[]; +}) => { + const marginLeft = (param.data?.depth ?? 0) * 2; // add indentation based on depth + const textRef = useRef(null); + const [isEllipsisActive, setIsEllipsisActive] = useState(false); + + const checkEllipsis = () => { + if (textRef.current) { + const zoomLevel = window.devicePixelRatio; + const adjustedScrollWidth = textRef.current.scrollWidth / zoomLevel; + const adjustedClientWidth = textRef.current.clientWidth / zoomLevel; + setIsEllipsisActive(adjustedScrollWidth > adjustedClientWidth); + } + }; + + useEffect(() => { + checkEllipsis(); + const resizeObserver = new ResizeObserver(() => checkEllipsis()); + if (textRef.current) { + resizeObserver.observe(textRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [param.value]); + + const escapeRegExp = (string: string) => { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + }; + + const renderHighlightedText = (value: string) => { + if (!searchTerm || searchTerm === '') { + return value; + } + + const escapedSearchTerm = escapeRegExp(searchTerm); + const parts = value.split(new RegExp(`(${escapedSearchTerm})`, 'gi')); + return ( + + {parts.map((part: string, index: number) => + part.toLowerCase() === searchTerm.toLowerCase() ? ( + + {part} + + ) : ( + part + ) + )} + + ); + }; + + return ( + + + + {renderHighlightedText(param.value)} + + + + ); +}; + +export const ContingencyCellRenderer = ({ value }: { value: { cellValue: ReactNode; tooltipValue: ReactNode } }) => { + const { cellValue, tooltipValue } = value ?? {}; + + if (cellValue == null || tooltipValue == null) { + return null; + } + + return ( + + {tooltipValue}}> + {cellValue} + + + ); +}; diff --git a/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-autocomplete-filter.tsx b/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-autocomplete-filter.tsx new file mode 100644 index 000000000..25f74d4b0 --- /dev/null +++ b/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-autocomplete-filter.tsx @@ -0,0 +1,86 @@ +/** + * 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 React, { FunctionComponent, SyntheticEvent, useCallback, useEffect, useState } from 'react'; +import { Autocomplete, TextField } from '@mui/material'; +import { useIntl } from 'react-intl'; +import { useCustomAggridFilter } from './hooks/use-custom-aggrid-filter'; +import { isNonEmptyStringOrArray } from '../../../utils/types-utils'; +import { CustomAggridFilterParams, FILTER_TEXT_COMPARATORS } from './custom-aggrid-filter.type'; + +export interface CustomAggridAutocompleteFilterParams extends CustomAggridFilterParams { + getOptionLabel?: (value: string) => string; // Used for translation of enum values in the filter + options?: string[]; +} + +export const CustomAggridAutocompleteFilter: FunctionComponent = ({ + api, + colId, + filterParams, + getOptionLabel, + options, +}) => { + const intl = useIntl(); + const { selectedFilterData, handleChangeFilterValue } = useCustomAggridFilter(api, colId, filterParams); + const [computedFilterOptions, setComputedFilterOptions] = useState(options ?? []); + + const getUniqueValues = useCallback(() => { + const uniqueValues = new Set(); + let allNumbers = true; + api.forEachNode((node) => { + const value = api.getCellValue({ + rowNode: node, + colKey: colId, + }); + if (value !== undefined && value !== null && value !== '') { + uniqueValues.add(value); + if (allNumbers && isNaN(Number(value))) { + allNumbers = false; + } + } + }); + // sort the values if they are all numbers + if (allNumbers) { + return Array.from(uniqueValues).sort((a, b) => Number(a) - Number(b)); + } + return Array.from(uniqueValues); + }, [api, colId]); + + useEffect(() => { + if (!options) { + setComputedFilterOptions(getUniqueValues()); + } + }, [options, getUniqueValues]); + + const handleFilterAutoCompleteChange = (_: SyntheticEvent, data: string[]) => { + handleChangeFilterValue({ value: data, type: FILTER_TEXT_COMPARATORS.EQUALS }); + }; + + return ( + String(getOptionLabel ? getOptionLabel(option) : option)} + onChange={handleFilterAutoCompleteChange} + size="small" + disableCloseOnSelect + renderInput={(params) => ( + + )} + fullWidth + /> + ); +}; diff --git a/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-boolean-filter.tsx b/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-boolean-filter.tsx new file mode 100644 index 000000000..20eb7832b --- /dev/null +++ b/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-boolean-filter.tsx @@ -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 { FunctionComponent } from 'react'; +import { IconButton, MenuItem, Select } from '@mui/material'; +import ClearIcon from '@mui/icons-material/Clear'; +import { useIntl } from 'react-intl'; +import { SelectChangeEvent } from '@mui/material/Select/SelectInput'; +import { useCustomAggridFilter } from './hooks/use-custom-aggrid-filter'; +import { isNonEmptyStringOrArray } from '../../../../utils/types-utils'; +import { mergeSx, type MuiStyles } from '../../../../utils/styles'; +import { BooleanFilterValue } from './utils/aggrid-filters-utils'; +import { CustomAggridFilterParams, FILTER_DATA_TYPES, FILTER_TEXT_COMPARATORS } from './custom-aggrid-filter.type'; + +const styles = { + input: { + minWidth: '250px', + maxWidth: '40%', + paddingRight: '0px', + }, +} as const satisfies MuiStyles; + +export const CustomAggridBooleanFilter: FunctionComponent = ({ + api, + colId, + filterParams, +}) => { + const intl = useIntl(); + + const { selectedFilterData, handleChangeFilterValue } = useCustomAggridFilter(api, colId, filterParams); + + const handleValueChange = (event: SelectChangeEvent) => { + const newValue = event.target.value; + handleChangeFilterValue({ + value: newValue, + type: FILTER_TEXT_COMPARATORS.EQUALS, + dataType: FILTER_DATA_TYPES.BOOLEAN, + }); + }; + + const handleClearFilter = () => { + handleChangeFilterValue({ + value: undefined, + }); + }; + + return ( + + ); +}; diff --git a/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-comparator-filter.tsx b/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-comparator-filter.tsx new file mode 100644 index 000000000..3000c8e4c --- /dev/null +++ b/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-comparator-filter.tsx @@ -0,0 +1,45 @@ +/** + * 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 { CustomAggridComparatorSelector } from './custom-aggrid-comparator-selector'; +import { CustomAggridTextFilter } from './custom-aggrid-text-filter'; +import { Grid } from '@mui/material'; +import { useCustomAggridComparatorFilter } from './hooks/use-custom-aggrid-comparator-filter'; + +import { CustomAggridFilterParams } from './custom-aggrid-filter.type'; + +export const CustomAggridComparatorFilter = ({ api, colId, filterParams }: CustomAggridFilterParams) => { + const { + selectedFilterData, + selectedFilterComparator, + decimalAfterDot, + isNumberInput, + handleFilterComparatorChange, + handleFilterTextChange, + handleClearFilter, + } = useCustomAggridComparatorFilter(api, colId, filterParams); + + const { + comparators = [], // used for text filter as a UI type (examples: contains, startsWith..) + } = filterParams; + + return ( + + + + + ); +}; diff --git a/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-comparator-selector.tsx b/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-comparator-selector.tsx new file mode 100644 index 000000000..ffd106278 --- /dev/null +++ b/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-comparator-selector.tsx @@ -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 React from 'react'; +import { MenuItem, Select, type SelectChangeEvent } from '@mui/material'; +import { useIntl } from 'react-intl'; +import { type MuiStyles } from '../../../utils/styles'; + +const styles = { + input: { + minWidth: '250px', + maxWidth: '40%', + }, +} as const satisfies MuiStyles; + +interface CustomAggridComparatorSelectorProps { + value: string; + onChange: (event: SelectChangeEvent) => void; + options: string[]; +} + +export const CustomAggridComparatorSelector: React.FC = ({ + value, + onChange, + options, +}) => { + const intl = useIntl(); + + return ( + + ); +}; diff --git a/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-duration-filter.tsx b/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-duration-filter.tsx new file mode 100644 index 000000000..7868d905e --- /dev/null +++ b/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-duration-filter.tsx @@ -0,0 +1,195 @@ +/** + * 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 { ChangeEvent, FunctionComponent, useCallback, useEffect, useState } from 'react'; +import { Grid, IconButton, InputAdornment, TextField, Typography } from '@mui/material'; +import { useIntl } from 'react-intl'; +import ClearIcon from '@mui/icons-material/Clear'; +import { type MuiStyles } from '../../../../utils/styles'; +import { CustomAggridComparatorSelector } from './custom-aggrid-comparator-selector'; +import { SelectChangeEvent } from '@mui/material/Select/SelectInput'; +import { useCustomAggridFilter } from './hooks/use-custom-aggrid-filter'; +import { CustomAggridFilterParams } from './custom-aggrid-filter.type'; + +const styles = { + containerStyle: { + width: '250px', + }, + iconStyle: { + padding: '0', + }, + flexCenter: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + noArrows: { + '& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button': { + display: 'none', + }, + '& input[type=number]': { + MozAppearance: 'textfield', + }, + }, +} as const satisfies MuiStyles; + +const CustomAggridDurationFilter: FunctionComponent = ({ api, colId, filterParams }) => { + const intl = useIntl(); + + const { selectedFilterData, selectedFilterComparator, handleChangeFilterValue, handleChangeComparator } = + useCustomAggridFilter(api, colId, filterParams); + + const { + comparators = [], // used for text filter as a UI type (examples: contains, startsWith..) + } = filterParams; + + const handleFilterComparatorChange = useCallback( + (event: SelectChangeEvent) => { + const newType = event.target.value; + handleChangeComparator(newType); + }, + [handleChangeComparator] + ); + + const handleClearFilter = useCallback(() => { + handleChangeFilterValue({ + value: undefined, + }); + }, [handleChangeFilterValue]); + + const handleFilterDurationChange = useCallback( + (value?: string) => { + handleChangeFilterValue({ + value, + }); + }, + [handleChangeFilterValue] + ); + + // Initialize minutes and seconds based on the initial value prop + const parseInitialValue = useCallback(() => { + if (selectedFilterData !== undefined && selectedFilterData !== '') { + const numericValue = Number(selectedFilterData); + if (!isNaN(numericValue)) { + return { + minutes: Math.floor(numericValue / 60).toString(), + seconds: (numericValue % 60).toString(), + }; + } + } + return { minutes: '', seconds: '' }; + }, [selectedFilterData]); + const { minutes: initialMinutes, seconds: initialSeconds } = parseInitialValue(); + const [minutes, setMinutes] = useState(initialMinutes); + const [seconds, setSeconds] = useState(initialSeconds); + + useEffect(() => { + if (!minutes && !seconds) { + setMinutes(initialMinutes); + setSeconds(initialSeconds); + } + }, [initialMinutes, initialSeconds, minutes, seconds]); + + const handleTimeChange = useCallback( + (newMinutes: string, newSeconds: string) => { + // If both minutes and seconds are empty, clear the value + if (newMinutes === '' && newSeconds === '') { + handleFilterDurationChange(''); + } else { + const totalSeconds = Number(newMinutes) * 60 + Number(newSeconds); + handleFilterDurationChange(totalSeconds.toString()); + } + }, + [handleFilterDurationChange] + ); + + const handleMinutesChange = useCallback( + (event: ChangeEvent) => { + const newValue = event.target.value; + setMinutes(newValue); + handleTimeChange(newValue, seconds); + }, + [handleTimeChange, seconds] + ); + + const handleSecondsChange = useCallback( + (event: ChangeEvent) => { + const newValue = event.target.value; + if (Number(newValue) > 59) { + return; + } // Prevents seconds from being greater than 59 + setSeconds(newValue); + handleTimeChange(minutes, newValue); + }, + [handleTimeChange, minutes] + ); + + const clearValue = useCallback(() => { + handleClearFilter(); // Clears the value + setMinutes(''); // Reset minutes state + setSeconds(''); // Reset seconds state + }, [handleClearFilter]); + + return ( + + + + + mn, + inputProps: { min: 0 }, + }} + sx={styles.noArrows} + /> + + + : + + + s, + inputProps: { min: 0, max: 59 }, + }} + sx={styles.noArrows} + /> + + {selectedFilterData !== undefined && selectedFilterData !== '' && ( + + + + + + )} + + + ); +}; + +export default CustomAggridDurationFilter; diff --git a/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-filter.tsx b/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-filter.tsx new file mode 100644 index 000000000..b2d6b294a --- /dev/null +++ b/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-filter.tsx @@ -0,0 +1,99 @@ +/** + * 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 React, { ComponentType, MouseEvent, useMemo, useState } from 'react'; +import { Popover } from '@mui/material'; +import { type MuiStyles } from '../../../utils/styles'; +import { CustomFilterIcon } from './custom-filter-icon'; +import { useCustomAggridFilter } from './hooks/use-custom-aggrid-filter'; +import { CustomAggridAutocompleteFilterParams } from './custom-aggrid-autocomplete-filter'; +import { CustomAggridFilterParams } from './custom-aggrid-filter.type'; + +const styles = { + input: { + minWidth: '250px', + maxWidth: '40%', + }, + autoCompleteInput: { + width: '30%', + }, +} as const satisfies MuiStyles; + +interface CustomAggridFilterWrapperParams { + filterComponent: ComponentType; + filterComponentParams: F; + isHoveringColumnHeader: boolean; + forceDisplayFilterIcon: boolean; + handleCloseFilter: () => void; +} + +export const CustomAggridFilter = ({ + filterComponent: FilterComponent, + filterComponentParams, + isHoveringColumnHeader, + forceDisplayFilterIcon = false, + handleCloseFilter, +}: CustomAggridFilterWrapperParams) => { + const [filterAnchorElement, setFilterAnchorElement] = useState(null); + + const { selectedFilterData } = useCustomAggridFilter( + filterComponentParams.api, + filterComponentParams.colId, + filterComponentParams.filterParams + ); + + const handleShowFilter = (event: MouseEvent) => { + setFilterAnchorElement(event.currentTarget); + }; + + const onClose = () => { + handleCloseFilter(); + setFilterAnchorElement(null); + }; + + const shouldDisplayFilterIcon = useMemo( + () => + (!!FilterComponent && isHoveringColumnHeader) || + (Array.isArray(selectedFilterData) ? selectedFilterData.length > 0 : !!selectedFilterData) || + !!filterAnchorElement || + forceDisplayFilterIcon, + [FilterComponent, filterAnchorElement, forceDisplayFilterIcon, isHoveringColumnHeader, selectedFilterData] + ); + + return ( + <> + {shouldDisplayFilterIcon && ( + + )} + + + + + ); +}; diff --git a/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-filter.type.ts b/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-filter.type.ts new file mode 100644 index 000000000..68060cce9 --- /dev/null +++ b/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-filter.type.ts @@ -0,0 +1,75 @@ +/** + * 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 { ColDef, GridApi, IFilterOptionDef } from 'ag-grid-community'; +import { FilterParams } from '../../../types/custom-aggrid-types'; +import React, { ComponentType } from 'react'; +import { SortParams } from '../hooks/use-custom-aggrid-sort'; +import { COLUMN_TYPES, CustomCellType } from '../custom-aggrid-header.type'; +import type { UUID } from 'node:crypto'; + +export enum FILTER_DATA_TYPES { + TEXT = 'text', + NUMBER = 'number', + BOOLEAN = 'boolean', +} + +export enum FILTER_TEXT_COMPARATORS { + EQUALS = 'equals', + CONTAINS = 'contains', + STARTS_WITH = 'startsWith', +} + +export enum FILTER_NUMBER_COMPARATORS { + EQUALS = 'equals', + NOT_EQUAL = 'notEqual', + LESS_THAN_OR_EQUAL = 'lessThanOrEqual', + GREATER_THAN_OR_EQUAL = 'greaterThanOrEqual', +} + +// not visible in the base interface : +export enum UNDISPLAYED_FILTER_NUMBER_COMPARATORS { + GREATER_THAN = 'greaterThan', + LESS_THAN = 'lessThan', +} + +export type FilterEnumsType = Record; + +export interface CustomAggridFilterParams { + api: GridApi; + colId: string; + filterParams: FilterParams; +} + +export interface ColumnContext { + agGridFilterParams?: { + filterOptions: IFilterOptionDef[]; + }; + tabUuid?: UUID; + columnType?: COLUMN_TYPES; + columnWidth?: number; + fractionDigits?: number; + isDefaultSort?: boolean; + numeric?: boolean; + forceDisplayFilterIcon?: boolean; + tabIndex?: number; + isCustomColumn?: boolean; + // Type intentionally kept generic to avoid app-specific dependency + Menu?: React.FC; + filterComponent?: ComponentType; + //We omit colId and api here to avoid duplicating its declaration, we reinject it later inside CustomHeaderComponent + filterComponentParams?: Omit; + sortParams?: SortParams; +} + +export type CustomAggridValue = boolean | string | number | CustomCellType | unknown; + +export interface CustomColDef + extends ColDef { + colId: string; + context?: ColumnContext; +} diff --git a/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-text-filter.tsx b/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-text-filter.tsx new file mode 100644 index 000000000..90b471abb --- /dev/null +++ b/src/components/customAGGrid/custom-aggrid-filters/custom-aggrid-text-filter.tsx @@ -0,0 +1,82 @@ +/** + * 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 React, { useMemo } from 'react'; +import { Grid, IconButton, InputAdornment, TextField } from '@mui/material'; +import ClearIcon from '@mui/icons-material/Clear'; +import { DisplayRounding } from '../display-rounding'; +import { useIntl } from 'react-intl'; +import { mergeSx, type MuiStyles } from '../../../../utils/styles'; +import { FILTER_DATA_TYPES } from './custom-aggrid-filter.type'; + +const styles = { + input: { + minWidth: '250px', + maxWidth: '40%', + }, + noArrows: { + '& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button': { + display: 'none', + }, + '& input[type=number]': { + MozAppearance: 'textfield', + }, + }, +} as const satisfies MuiStyles; + +interface CustomAggridTextFilterProps { + value: unknown; + onChange: (event: React.ChangeEvent) => void; + onClear: () => void; + isNumberInput: boolean; + decimalAfterDot: number; +} + +export const CustomAggridTextFilter: React.FC = ({ + value, + onChange, + onClear, + isNumberInput, + decimalAfterDot = 0, +}) => { + const intl = useIntl(); + + const isRoundingDisplayed = useMemo(() => !!(isNumberInput && value), [isNumberInput, value]); + + return ( + + + + + + + + ) : null, + }} + /> + + {isRoundingDisplayed && ( + + + + )} + + ); +}; diff --git a/src/components/customAGGrid/custom-aggrid-filters/custom-filter-icon.tsx b/src/components/customAGGrid/custom-aggrid-filters/custom-filter-icon.tsx new file mode 100644 index 000000000..21aaedbf4 --- /dev/null +++ b/src/components/customAGGrid/custom-aggrid-filters/custom-filter-icon.tsx @@ -0,0 +1,37 @@ +/** + * 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 React, { MouseEventHandler } from 'react'; +import { Badge, Grid, IconButton } from '@mui/material'; +import { FilterAlt } from '@mui/icons-material'; +import { type MuiStyles } from '../../../utils/styles'; +import { isNonEmptyStringOrArray } from '../../../utils/types-utils'; + +const styles = { + iconSize: { fontSize: '1rem' }, + gridRoot: { overflow: 'visible' }, +} as const satisfies MuiStyles; + +interface CustomFilterIconProps { + handleShowFilter: MouseEventHandler | undefined; + selectedFilterData: unknown; +} + +export const CustomFilterIcon = ({ handleShowFilter, selectedFilterData }: CustomFilterIconProps) => ( + + + + + + + + + +); diff --git a/src/components/customAGGrid/custom-aggrid-filters/hooks/index.ts b/src/components/customAGGrid/custom-aggrid-filters/hooks/index.ts new file mode 100644 index 000000000..59ee19281 --- /dev/null +++ b/src/components/customAGGrid/custom-aggrid-filters/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './use-custom-aggrid-comparator-filter'; +export * from './use-custom-aggrid-filter'; diff --git a/src/components/customAGGrid/custom-aggrid-filters/hooks/use-custom-aggrid-comparator-filter.ts b/src/components/customAGGrid/custom-aggrid-filters/hooks/use-custom-aggrid-comparator-filter.ts new file mode 100644 index 000000000..44baf6791 --- /dev/null +++ b/src/components/customAGGrid/custom-aggrid-filters/hooks/use-custom-aggrid-comparator-filter.ts @@ -0,0 +1,67 @@ +/** + * 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 { ChangeEvent, useMemo } from 'react'; +import { useSnackMessage } from '../../../../hooks/useSnackMessage'; +import { SelectChangeEvent } from '@mui/material/Select/SelectInput'; +import { countDecimalPlacesFromString } from '../../../../utils/rounding'; +import { useCustomAggridFilter } from './use-custom-aggrid-filter'; +import { GridApi } from 'ag-grid-community'; +import { computeTolerance } from '../utils/filter-tolerance-utils'; +import { FilterParams } from '../../../../types/custom-aggrid-types'; +import { FILTER_DATA_TYPES } from '../custom-aggrid-filter.type'; + +export const useCustomAggridComparatorFilter = (api: GridApi, colId: string, filterParams: FilterParams) => { + const { dataType = FILTER_DATA_TYPES.TEXT } = filterParams; + + const isNumberInput = dataType === FILTER_DATA_TYPES.NUMBER; + + const { selectedFilterData, selectedFilterComparator, handleChangeFilterValue, handleChangeComparator } = + useCustomAggridFilter(api, colId, filterParams); + + const { snackWarning } = useSnackMessage(); + + const handleFilterComparatorChange = (event: SelectChangeEvent) => { + const newType = event.target.value; + handleChangeComparator(newType); + }; + + const handleClearFilter = () => { + handleChangeFilterValue({ + value: undefined, + }); + }; + const handleFilterTextChange = (event: ChangeEvent) => { + const value = event.target.value.toUpperCase(); + handleChangeFilterValue({ + value, + tolerance: isNumberInput ? computeTolerance(value) : undefined, + }); + }; + + const decimalAfterDot = useMemo(() => { + if (isNumberInput) { + let decimalAfterDot: number = countDecimalPlacesFromString(String(selectedFilterData)); + if (decimalAfterDot >= 13) { + snackWarning({ + headerId: 'filter.warnRounding', + }); + } + return decimalAfterDot; + } + return 0; + }, [isNumberInput, selectedFilterData, snackWarning]); + + return { + selectedFilterData, + selectedFilterComparator, + decimalAfterDot, + isNumberInput, + handleFilterComparatorChange, + handleFilterTextChange, + handleClearFilter, + }; +}; diff --git a/src/components/customAGGrid/custom-aggrid-filters/hooks/use-custom-aggrid-filter.ts b/src/components/customAGGrid/custom-aggrid-filters/hooks/use-custom-aggrid-filter.ts new file mode 100644 index 000000000..971bd6593 --- /dev/null +++ b/src/components/customAGGrid/custom-aggrid-filters/hooks/use-custom-aggrid-filter.ts @@ -0,0 +1,127 @@ +/** + * 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 { useCallback, useEffect, useState } from 'react'; +import { debounce } from '@mui/material'; +import { GridApi } from 'ag-grid-community'; +import { computeTolerance } from '../utils/filter-tolerance-utils'; +import { FilterConfig, FilterData, FilterParams } from '../../../../types/custom-aggrid-types'; +import { FILTER_DATA_TYPES } from '../custom-aggrid-filter.type'; + +const removeElementFromArrayWithFieldValue = (filtersArrayToRemoveFieldValueFrom: FilterConfig[], field: string) => { + return filtersArrayToRemoveFieldValueFrom.filter((f) => f.column !== field); +}; + +const changeValueFromArrayWithFieldValue = ( + filtersArrayToModify: FilterConfig[], + field: string, + newData: FilterConfig +) => { + const filterIndex = filtersArrayToModify.findIndex((f) => f.column === field); + if (filterIndex === -1) { + return [...filtersArrayToModify, newData]; + } else { + const updatedArray = [...filtersArrayToModify]; + updatedArray[filterIndex] = newData; + return updatedArray; + } +}; + +export const useCustomAggridFilter = ( + api: GridApi, + colId: string, + { type, tab, dataType, comparators = [], debounceMs = 1000, updateFilterCallback, filters, setFilters }: FilterParams +) => { + const [selectedFilterComparator, setSelectedFilterComparator] = useState(''); + const [selectedFilterData, setSelectedFilterData] = useState(); + const [tolerance, setTolerance] = useState(); + + // Store-agnostic: if consumer does not provide external filters, maintain a local list + const [localFilters, setLocalFilters] = useState([]); + const currentFilters = filters ?? localFilters; + const pushFilters = setFilters ?? setLocalFilters; + + const updateFilter = useCallback( + (colId: string, data: FilterData): void => { + const newFilter = { + column: colId, + dataType: data.dataType, + tolerance: data.dataType === FILTER_DATA_TYPES.NUMBER ? computeTolerance(data.value) : undefined, + type: data.type, + value: data.value, + }; + + let updatedFilters: FilterConfig[]; + if (!data.value) { + updatedFilters = removeElementFromArrayWithFieldValue(currentFilters, colId); + } else { + updatedFilters = changeValueFromArrayWithFieldValue(currentFilters, colId, newFilter); + } + + updateFilterCallback && updateFilterCallback(api, updatedFilters); + pushFilters(updatedFilters); + }, + [updateFilterCallback, api, pushFilters, currentFilters] + ); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const debouncedUpdateFilter = useCallback( + debounce((data) => updateFilter(colId, data), debounceMs), + [colId, debounceMs] + ); + + const handleChangeFilterValue = useCallback( + (filterData: FilterData) => { + setSelectedFilterData(filterData.value); + setTolerance(filterData.tolerance); + debouncedUpdateFilter({ + value: filterData.value, + type: filterData.type ?? selectedFilterComparator, + dataType, + tolerance: filterData.tolerance, + }); + }, + [dataType, debouncedUpdateFilter, selectedFilterComparator] + ); + + const handleChangeComparator = useCallback( + (newType: string) => { + setSelectedFilterComparator(newType); + if (selectedFilterData) { + updateFilter(colId, { + value: selectedFilterData, + type: newType, + dataType, + tolerance: tolerance, + }); + } + }, + [colId, dataType, selectedFilterData, tolerance, updateFilter] + ); + + useEffect(() => { + if (!selectedFilterComparator) { + setSelectedFilterComparator(comparators[0]); + } + }, [selectedFilterComparator, comparators]); + + useEffect(() => { + const filterObject = currentFilters?.find((filter) => filter.column === colId); + if (filterObject) { + setSelectedFilterData(filterObject.value); + setSelectedFilterComparator(filterObject.type ?? ''); + } else { + setSelectedFilterData(undefined); + } + }, [currentFilters, colId]); + + return { + selectedFilterData, + selectedFilterComparator, + handleChangeFilterValue, + handleChangeComparator, + }; +}; diff --git a/src/components/customAGGrid/custom-aggrid-filters/index.ts b/src/components/customAGGrid/custom-aggrid-filters/index.ts new file mode 100644 index 000000000..c44feae47 --- /dev/null +++ b/src/components/customAGGrid/custom-aggrid-filters/index.ts @@ -0,0 +1,10 @@ +export * from './custom-aggrid-autocomplete-filter'; +export * from './custom-aggrid-boolean-filter'; +export * from './custom-aggrid-comparator-filter'; +export { default as CustomAggridDurationFilter } from './custom-aggrid-duration-filter'; +export * from './custom-aggrid-duration-filter'; +export * from './custom-aggrid-filter'; +export * from './custom-aggrid-filter.type'; +export * from './custom-aggrid-text-filter'; +export * from './hooks'; +export * from './utils'; diff --git a/src/components/customAGGrid/custom-aggrid-filters/utils/aggrid-filters-utils.ts b/src/components/customAGGrid/custom-aggrid-filters/utils/aggrid-filters-utils.ts new file mode 100644 index 000000000..b1cd58132 --- /dev/null +++ b/src/components/customAGGrid/custom-aggrid-filters/utils/aggrid-filters-utils.ts @@ -0,0 +1,111 @@ +/** + * 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 { GridApi } from 'ag-grid-community'; +import { addToleranceToFilter } from './filter-tolerance-utils'; +import { FilterConfig } from '../../../../types/custom-aggrid-types'; +import { FILTER_DATA_TYPES, FILTER_NUMBER_COMPARATORS } from '../custom-aggrid-filter.type'; + +export enum BooleanFilterValue { + TRUE = 'true', + FALSE = 'false', + UNDEFINED = 'undefinedValue', +} + +interface FilterModel { + [colId: string]: any; +} + +const generateEnumFilterModel = (filter: FilterConfig) => { + const filterValue = filter.value as string[]; + return { + type: 'text', + filterType: 'customInRange', + filter: filterValue, + }; +}; + +const formatCustomFiltersForAgGrid = (filters: FilterConfig[]): FilterModel => { + const agGridFilterModel: FilterModel = {}; + const groupedFilters: { [key: string]: FilterConfig[] } = {}; + + // Group filters by column + filters.forEach((filter) => { + if (groupedFilters[filter.column]) { + groupedFilters[filter.column].push(filter); + } else { + groupedFilters[filter.column] = [filter]; + } + }); + + // Transform groups of filters into a FilterModel + Object.keys(groupedFilters).forEach((column) => { + const filters = groupedFilters[column]; + if (filters.length === 1) { + const filter = filters[0]; + if (Array.isArray(filter.value)) { + agGridFilterModel[column] = generateEnumFilterModel(filter); + } else { + agGridFilterModel[column] = { + type: filter.type, + tolerance: filter.tolerance, + filterType: filter.dataType, + filter: filter.dataType === FILTER_DATA_TYPES.NUMBER ? Number(filter.value) : filter.value, + }; + } + } else { + // Multiple filters on the same column + const conditions = filters.map((filter) => ({ + type: filter.type, + tolerance: filter.tolerance, + filterType: filter.dataType, + filter: filter.dataType === FILTER_DATA_TYPES.NUMBER ? Number(filter.value) : filter.value, + })); + // Determine operator based on filter types + let operator = 'OR'; + if (filters.length === 2 && filters.every((f) => f?.originalType === FILTER_NUMBER_COMPARATORS.EQUALS)) { + operator = 'AND'; // For EQUALS with tolerance + } + + // Create a combined filter model with 'OR' for all conditions + agGridFilterModel[column] = { + type: filters[0].type, + tolerance: filters[0].tolerance, + operator: operator, + conditions, + }; + } + }); + + return agGridFilterModel; +}; + +export const updateFilters = (api: GridApi | undefined, filters: FilterConfig[] | undefined) => { + // Check if filters are provided and if the AG Grid API is accessible + if (!filters || !api) { + return; // Exit if no filters are provided or if the grid API is not accessible + } + + // Retrieve the current column definitions from AG Grid + const currentColumnDefs = api.getColumns(); + + // Filter out any filters that reference columns which are not visible or don't exist in the current column definitions + const validFilters = filters.filter((filter) => + currentColumnDefs?.some((col) => col.getColId() === filter.column && col.isVisible()) + ); + + // If we have any valid filters, apply them + if (validFilters.length > 0) { + const filterWithTolerance = addToleranceToFilter(validFilters); + // Format the valid filters for AG Grid and apply them using setFilterModel + const formattedFilters = formatCustomFiltersForAgGrid(filterWithTolerance); + api.setFilterModel(formattedFilters); + } else { + // Clear filters if no valid filters exist + api.setFilterModel(null); + } +}; diff --git a/src/components/customAGGrid/custom-aggrid-filters/utils/filter-tolerance-utils.ts b/src/components/customAGGrid/custom-aggrid-filters/utils/filter-tolerance-utils.ts new file mode 100644 index 000000000..cbdfcd86e --- /dev/null +++ b/src/components/customAGGrid/custom-aggrid-filters/utils/filter-tolerance-utils.ts @@ -0,0 +1,111 @@ +/** + * 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 { isNumber } from 'mathjs'; +import { countDecimalPlaces, countDecimalPlacesFromString } from '../../../../utils/rounding'; +import { FilterConfig } from '../../../../types/custom-aggrid-types'; +import { + FILTER_DATA_TYPES, + FILTER_NUMBER_COMPARATORS, + UNDISPLAYED_FILTER_NUMBER_COMPARATORS, +} from '../custom-aggrid-filter.type'; + +/** + * Compute the tolerance that should be applied when comparing filter values to database values + * @param value value entered in the filter + */ +export const computeTolerance = (value: unknown) => { + if (!value) { + return 0; + } + let decimalPrecision: number; + // the reference for the comparison is the number of digits after the decimal point in 'value' + // extra digits are ignored, but the user may add '0's after the decimal point in order to get a better precision + if (isNumber(value)) { + decimalPrecision = countDecimalPlaces(value); + } else { + decimalPrecision = countDecimalPlacesFromString(value as string); + } + // tolerance is multiplied by 0.5 to simulate the fact that the database value is rounded (in the front, from the user viewpoint) + // more than 13 decimal after dot will likely cause rounding errors due to double precision + return (1 / Math.pow(10, decimalPrecision)) * 0.5; +}; + +export const addToleranceToFilter = ( + filters: FilterConfig[], + tolerance: number | undefined = undefined +): FilterConfig[] => { + let finalTolerance: number; + if (tolerance !== undefined) { + finalTolerance = tolerance; + } + return filters + .map((filter): FilterConfig | FilterConfig[] => { + // Attempt to convert filter value to a number if it's a string, otherwise keep it as is + let valueAsNumber = typeof filter.value === 'string' ? parseFloat(filter.value) : filter.value; + // If the value is successfully converted to a number, apply tolerance adjustments + if ( + typeof valueAsNumber === 'number' && + !isNaN(valueAsNumber) && + filter.dataType === FILTER_DATA_TYPES.NUMBER + ) { + if (tolerance === undefined) { + // better to use the string value (filter.value) in order not to lose the decimal precision for values like 420.0000000 + finalTolerance = computeTolerance(filter.value); + } + + // Depending on the filter type, adjust the filter value by adding or subtracting the tolerance + switch (filter.type) { + // Creates two conditions to test we are not in [value-tolerance..value+tolerance] (handles rounded decimal precision) + case FILTER_NUMBER_COMPARATORS.NOT_EQUAL: + return [ + { + ...filter, + type: UNDISPLAYED_FILTER_NUMBER_COMPARATORS.GREATER_THAN, + originalType: filter.type, + value: valueAsNumber + finalTolerance, + }, + { + ...filter, + type: UNDISPLAYED_FILTER_NUMBER_COMPARATORS.LESS_THAN, + originalType: filter.type, + value: valueAsNumber - finalTolerance, + }, + ]; + case FILTER_NUMBER_COMPARATORS.EQUALS: + return [ + { + ...filter, + type: FILTER_NUMBER_COMPARATORS.GREATER_THAN_OR_EQUAL, + originalType: filter.type, + value: valueAsNumber - finalTolerance, + }, + { + ...filter, + type: FILTER_NUMBER_COMPARATORS.LESS_THAN_OR_EQUAL, + originalType: filter.type, + value: valueAsNumber + finalTolerance, + }, + ]; + case FILTER_NUMBER_COMPARATORS.LESS_THAN_OR_EQUAL: + // Adjust the value upwards by the tolerance + return { + ...filter, + value: valueAsNumber + finalTolerance, + }; + case FILTER_NUMBER_COMPARATORS.GREATER_THAN_OR_EQUAL: + return { + ...filter, + value: valueAsNumber - finalTolerance, + }; + default: + return filter; + } + } + return filter; + }) + .flat(); // Flatten the array in case any filters were expanded into multiple conditions +}; diff --git a/src/components/customAGGrid/custom-aggrid-filters/utils/index.ts b/src/components/customAGGrid/custom-aggrid-filters/utils/index.ts new file mode 100644 index 000000000..8e6f3d8a9 --- /dev/null +++ b/src/components/customAGGrid/custom-aggrid-filters/utils/index.ts @@ -0,0 +1,2 @@ +export * from './aggrid-filters-utils'; +export * from './filter-tolerance-utils'; diff --git a/src/components/customAGGrid/custom-aggrid-header.tsx b/src/components/customAGGrid/custom-aggrid-header.tsx new file mode 100644 index 000000000..7c95994a2 --- /dev/null +++ b/src/components/customAGGrid/custom-aggrid-header.tsx @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2023, 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 React, { ComponentType, useCallback, useState } from 'react'; +import { Grid } from '@mui/material'; +import { type MuiStyles } from '../../utils/styles'; +import { CustomAggridFilter } from './custom-aggrid-filters/custom-aggrid-filter'; +import { CustomAggridSort } from './custom-aggrid-sort'; +import { SortParams, useCustomAggridSort } from './hooks/use-custom-aggrid-sort'; +import { CustomMenu, CustomMenuProps } from './custom-aggrid-menu'; +import { CustomHeaderProps } from 'ag-grid-react'; +import { CustomAggridFilterParams } from './custom-aggrid-filters/custom-aggrid-filter.type'; + +const styles = { + displayName: { + overflow: 'hidden', + textOverflow: 'ellipsis', + }, +} as const satisfies MuiStyles; + +interface CustomHeaderComponentProps extends CustomHeaderProps { + displayName: string; + sortParams?: SortParams; + menu?: CustomMenuProps; + forceDisplayFilterIcon: boolean; + filterComponent: ComponentType; + filterComponentParams: F; +} + +const CustomHeaderComponent = ({ + column, + displayName, + sortParams, + menu, + forceDisplayFilterIcon, + filterComponent, + filterComponentParams, + api, +}: CustomHeaderComponentProps) => { + const [isHoveringColumnHeader, setIsHoveringColumnHeader] = useState(false); + + const { handleSortChange } = useCustomAggridSort(column.getId(), sortParams); + const isSortable = !!sortParams; + const handleClickHeader = () => { + handleSortChange && handleSortChange(); + }; + + const handleCloseFilter = () => { + setIsHoveringColumnHeader(false); + }; + + const handleMouseEnter = useCallback(() => { + setIsHoveringColumnHeader(true); + }, []); + + const handleMouseLeave = useCallback(() => { + setIsHoveringColumnHeader(false); + }, []); + + return ( + + + {/* We tweak flexBasis to stick the column filter and custom menu either next the column name or on the far right of the header */} + + + + {displayName} + {sortParams && ( + + + + )} + + + + + {filterComponent && ( + + )} + {menu && } + + + + ); +}; + +export default CustomHeaderComponent; diff --git a/src/components/customAGGrid/custom-aggrid-header.type.ts b/src/components/customAGGrid/custom-aggrid-header.type.ts new file mode 100644 index 000000000..47fa6abc4 --- /dev/null +++ b/src/components/customAGGrid/custom-aggrid-header.type.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) 2023, 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 enum COLUMN_TYPES { + TEXT = 'TEXT', + ENUM = 'ENUM', + NUMBER = 'NUMBER', + BOOLEAN = 'BOOLEAN', +} + +export type CustomCellType = { + cellValue: number; + tooltipValue: number; +}; diff --git a/src/components/customAGGrid/custom-aggrid-menu.tsx b/src/components/customAGGrid/custom-aggrid-menu.tsx new file mode 100644 index 000000000..c1256f8e8 --- /dev/null +++ b/src/components/customAGGrid/custom-aggrid-menu.tsx @@ -0,0 +1,45 @@ +/** + * 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 React, { useRef, useState } from 'react'; +import { Badge, Grid, IconButton } from '@mui/material'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import { type MuiStyles } from '../../utils/styles'; + +const styles = { + iconSize: { + fontSize: '1rem', + }, +} as const satisfies MuiStyles; + +export interface CustomMenuProps { + Menu: React.FC; + menuParams: T; +} + +export interface DialogMenuProps { + open: boolean; + anchorEl: HTMLElement | null; + onClose: () => void; +} + +export const CustomMenu = ({ Menu, menuParams }: CustomMenuProps) => { + const [menuOpen, setMenuOpen] = useState(false); + const menuButtonRef = useRef(null); + + return ( + <> + + setMenuOpen(true)}> + + + + + + setMenuOpen(false)} anchorEl={menuButtonRef.current} {...menuParams} /> + + ); +}; diff --git a/src/components/customAGGrid/custom-aggrid-sort.tsx b/src/components/customAGGrid/custom-aggrid-sort.tsx new file mode 100644 index 000000000..e00a76e39 --- /dev/null +++ b/src/components/customAGGrid/custom-aggrid-sort.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 { IconButton } from '@mui/material'; +import { SortParams } from './hooks/use-custom-aggrid-sort'; +import { ArrowDownward, ArrowUpward } from '@mui/icons-material'; +import React from 'react'; +import { type MuiStyles } from '../../utils/styles'; +import { useCustomAggridSort } from './hooks/use-custom-aggrid-sort'; +import { SortWay } from '../../types/custom-aggrid-types'; + +const styles = { + iconSize: { + fontSize: '1rem', + }, +} as const satisfies MuiStyles; + +interface CustomAggridSortProps { + colId: string; + sortParams: SortParams; +} + +export const CustomAggridSort = ({ colId, sortParams }: CustomAggridSortProps) => { + const { columnSort, handleSortChange } = useCustomAggridSort(colId, sortParams); + const handleClick = () => { + handleSortChange(); + }; + const isColumnSorted = !!columnSort; + + return ( + isColumnSorted && ( + + {columnSort.sort === SortWay.ASC ? ( + + ) : ( + + )} + + ) + ); +}; diff --git a/src/components/customAGGrid/display-rounding.tsx b/src/components/customAGGrid/display-rounding.tsx new file mode 100644 index 000000000..fd8b93593 --- /dev/null +++ b/src/components/customAGGrid/display-rounding.tsx @@ -0,0 +1,56 @@ +/** + * 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, FormHelperText } from '@mui/material'; +import React from 'react'; +import { type MuiStyles } from '../../utils/styles'; + +const styles = { + exponent: { + position: 'relative', + bottom: '1ex', + fontSize: '80%', + }, +} as const satisfies MuiStyles; + +/** + * displays a rounding precision like this : 'Rounded to 10^decimalAfterDot' or as a decimal number if decimalAfterDot <= 4 + */ +interface DisplayRoundingProps { + decimalAfterDot: number; +} + +export function DisplayRounding({ decimalAfterDot }: Readonly) { + const intl = useIntl(); + const roundedTo1 = decimalAfterDot === 0; + const displayAsPower10 = decimalAfterDot > 4; + const baseMessage = + intl.formatMessage({ + id: roundedTo1 ? 'filter.roundedToOne' : 'filter.rounded', + }) + ' '; + + const decimalAfterDotStr = -decimalAfterDot; + let roundingPrecision = null; + if (!roundedTo1) { + roundingPrecision = displayAsPower10 ? ( + <> + 10 + + {decimalAfterDotStr} + + + ) : ( + 1 / Math.pow(10, decimalAfterDot) + ); + } + return ( + + {baseMessage} + {roundingPrecision} + + ); +} diff --git a/src/components/customAGGrid/hooks/index.ts b/src/components/customAGGrid/hooks/index.ts new file mode 100644 index 000000000..951ac47a4 --- /dev/null +++ b/src/components/customAGGrid/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-custom-aggrid-sort'; diff --git a/src/components/customAGGrid/hooks/use-custom-aggrid-sort.ts b/src/components/customAGGrid/hooks/use-custom-aggrid-sort.ts new file mode 100644 index 000000000..567122f5d --- /dev/null +++ b/src/components/customAGGrid/hooks/use-custom-aggrid-sort.ts @@ -0,0 +1,45 @@ +/** + * 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 { useCallback, useMemo } from 'react'; +import { SortConfig, SortWay } from '../../../types/custom-aggrid-types'; + +export type SortParams = { + sortConfig?: SortConfig[]; + isChildren?: boolean; + onChange: (updatedSortConfig: SortConfig[]) => void; +}; + +export const useCustomAggridSort = (colId: string, sortParams?: SortParams) => { + const columnSort = useMemo( + () => sortParams?.sortConfig?.find((value) => value.colId === colId), + [sortParams?.sortConfig, colId] + ); + const isColumnSorted = !!columnSort; + + const handleSortChange = useCallback(() => { + if (!sortParams || !sortParams.sortConfig) { + return; + } + + let newSort: SortWay; + if (!isColumnSorted) { + newSort = SortWay.ASC; + } else if (columnSort!.sort === SortWay.DESC) { + newSort = SortWay.ASC; + } else { + newSort = SortWay.DESC; + } + + const updatedSortConfig = sortParams.sortConfig + .filter((sort) => (sort.children ?? false) !== (sortParams.isChildren ?? false)) + .concat({ colId, sort: newSort, children: sortParams.isChildren }); + + sortParams.onChange(updatedSortConfig); + }, [sortParams, isColumnSorted, columnSort?.sort, colId]); + + return { columnSort, handleSortChange }; +}; diff --git a/src/components/customAGGrid/index.ts b/src/components/customAGGrid/index.ts index b2a25f9ae..1c29416a0 100644 --- a/src/components/customAGGrid/index.ts +++ b/src/components/customAGGrid/index.ts @@ -8,3 +8,12 @@ export * from './customAggrid.style'; export * from './customAggrid'; export * from './separatorCellRenderer'; +export { default as CustomHeaderComponent } from './custom-aggrid-header'; +export * from './custom-aggrid-header.type'; +export * from './custom-aggrid-menu'; +export * from './custom-aggrid-sort'; +export * from './cell-renderers'; +export * from './rowindex-cell-renderer'; +export * from './custom-aggrid-filters'; +export * from './hooks'; +export * from './utils'; diff --git a/src/components/customAGGrid/rowindex-cell-renderer.tsx b/src/components/customAGGrid/rowindex-cell-renderer.tsx new file mode 100644 index 000000000..f1f713205 --- /dev/null +++ b/src/components/customAGGrid/rowindex-cell-renderer.tsx @@ -0,0 +1,158 @@ +/** + * Copyright (c) 2023, 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, Checkbox, IconButton, Menu, MenuItem } from '@mui/material'; +import { useState } from 'react'; +import CalculateIcon from '@mui/icons-material/Calculate'; +import { CustomCellRendererProps } from 'ag-grid-react'; +import { useIntl } from 'react-intl'; +import { type MuiStyles } from '../../utils/styles'; +// Store-agnostic component: interactions are provided via ag-Grid column context + +const styles = { + menuItemLabel: { + marginLeft: 1, + }, + calculationButton: { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + padding: 0, + minWidth: 'auto', + minHeight: 'auto', + }, +} as const satisfies MuiStyles; + +// local helpers to avoid app-specific imports +const isCalculationRow = (rowType: any) => rowType === 'CALCULATION' || rowType === 'CALCULATION_BUTTON'; +const CalculationRowType = { + CALCULATION_BUTTON: 'CALCULATION_BUTTON', + CALCULATION: 'CALCULATION', +} as const; +const CalculationType = { + AVERAGE: 'AVERAGE', + SUM: 'SUM', + MIN: 'MIN', + MAX: 'MAX', +} as const; + +export const RowIndexCellRenderer = (props: CustomCellRendererProps) => { + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + const intl = useIntl(); + + // Get tab UUID from context passed via column definition + const tabUuid = props.colDef?.context?.tabUuid || ''; + + // Accessors provided via column context by the hosting application (optional) + const getCalculationSelections = props.colDef?.context?.getCalculationSelections as + | ((tabUuid: string) => string[]) + | undefined; + const setCalculationSelections = props.colDef?.context?.setCalculationSelections as + | ((tabUuid: string, selections: string[]) => void) + | undefined; + + // Get selections for current tab (fallback to empty array if not provided) + const selections = (getCalculationSelections && getCalculationSelections(tabUuid)) || []; + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleSelectionChange = (option: (typeof CalculationType)[keyof typeof CalculationType]) => { + const newSelections = selections.includes(option) + ? selections.filter((item) => item !== option) + : [...selections, option]; + setCalculationSelections && setCalculationSelections(tabUuid, newSelections); + }; + + if (isCalculationRow(props.data?.rowType)) { + if (props.data?.rowType === CalculationRowType.CALCULATION_BUTTON) { + return ( + + + + + + handleSelectionChange(CalculationType.AVERAGE)}> + + + {intl.formatMessage({ id: 'spreadsheet/calculation/average' })} + + + handleSelectionChange(CalculationType.SUM)}> + + + {intl.formatMessage({ id: 'spreadsheet/calculation/sum' })} + + + handleSelectionChange(CalculationType.MIN)}> + + + {intl.formatMessage({ id: 'spreadsheet/calculation/min' })} + + + handleSelectionChange(CalculationType.MAX)}> + + + {intl.formatMessage({ id: 'spreadsheet/calculation/max' })} + + + + + ); + } + + // Row with calculation results - show appropriate label + if (props.data?.rowType === CalculationRowType.CALCULATION) { + let label = ''; + switch (props.data.calculationType) { + case CalculationType.SUM: + label = intl.formatMessage({ id: 'spreadsheet/calculation/sum_abbrev' }); + break; + case CalculationType.AVERAGE: + label = intl.formatMessage({ id: 'spreadsheet/calculation/average_abbrev' }); + break; + case CalculationType.MIN: + label = intl.formatMessage({ id: 'spreadsheet/calculation/min_abbrev' }); + break; + case CalculationType.MAX: + label = intl.formatMessage({ id: 'spreadsheet/calculation/max_abbrev' }); + break; + } + + return ( + + {label} + + ); + } + + return null; + } + + // For normal rows, return the row index number + return props.value; +}; diff --git a/src/components/customAGGrid/utils/custom-aggrid-header-utils.ts b/src/components/customAGGrid/utils/custom-aggrid-header-utils.ts new file mode 100644 index 000000000..3cd84a437 --- /dev/null +++ b/src/components/customAGGrid/utils/custom-aggrid-header-utils.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2023, 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 CustomHeaderComponent from '../custom-aggrid-header'; +import { CustomAggridFilterParams, CustomColDef } from '../custom-aggrid-filters/custom-aggrid-filter.type'; + +export const makeAgGridCustomHeaderColumn = ({ + context, + ...props // agGrid column props +}: CustomColDef) => { + const { + sortParams, + forceDisplayFilterIcon, + filterComponent, + filterComponentParams, + tabIndex, + isCustomColumn, + Menu, + fractionDigits, + numeric, + } = context || {}; + const { headerName, field = '' } = props; + const isSortable = !!sortParams; + + let minWidth = 75; + if (isSortable) { + minWidth += 30; + } + if (!!filterComponent) { + minWidth += 30; + } + + return { + headerTooltip: headerName, + minWidth, + headerComponent: CustomHeaderComponent, + headerComponentParams: { + field, + displayName: headerName, + sortParams, + customMenuParams: { + tabIndex: tabIndex, + isCustomColumn: isCustomColumn, + Menu: Menu, + }, + forceDisplayFilterIcon: forceDisplayFilterIcon, + filterComponent: filterComponent, + filterComponentParams, + }, + filterParams: context?.agGridFilterParams || undefined, + ...props, + context: { + ...context, + fractionDigits: numeric && !fractionDigits ? 2 : fractionDigits, + }, + }; +}; diff --git a/src/components/customAGGrid/utils/format-values-utils.ts b/src/components/customAGGrid/utils/format-values-utils.ts new file mode 100644 index 000000000..f6b5f4ddd --- /dev/null +++ b/src/components/customAGGrid/utils/format-values-utils.ts @@ -0,0 +1,33 @@ +/** + * 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 { IntlShape } from 'react-intl'; + +export const NA_Value = 'N/A'; + +export const formatNAValue = (value: string, intl: IntlShape): string => { + return value === NA_Value ? intl.formatMessage({ id: 'Undefined' }) : value; +}; + +export const convertDuration = (duration: number) => { + if (!duration || isNaN(duration)) { + return ''; + } + + const minutes = Math.floor(duration / 60); + const seconds = duration % 60; + + if (seconds === 0) { + return minutes + ' mn'; + } + + if (minutes === 0) { + return seconds + ' s'; + } + + return `${minutes}' ${seconds}"`; +}; diff --git a/src/components/customAGGrid/utils/index.ts b/src/components/customAGGrid/utils/index.ts new file mode 100644 index 000000000..a513a852a --- /dev/null +++ b/src/components/customAGGrid/utils/index.ts @@ -0,0 +1,2 @@ +export * from './custom-aggrid-header-utils'; +export * from './format-values-utils'; diff --git a/src/components/dialogs/customMuiDialog/CustomMuiDialog.tsx b/src/components/dialogs/customMuiDialog/CustomMuiDialog.tsx index 736d749f1..f997271c5 100644 --- a/src/components/dialogs/customMuiDialog/CustomMuiDialog.tsx +++ b/src/components/dialogs/customMuiDialog/CustomMuiDialog.tsx @@ -8,7 +8,7 @@ import { type MouseEvent, type ReactNode, useCallback, useState } from 'react'; import { FieldErrors, FieldValues, SubmitHandler, UseFormReturn } from 'react-hook-form'; import { FormattedMessage } from 'react-intl'; -import { Dialog, DialogActions, DialogContent, DialogProps, DialogTitle, Grid, LinearProgress } from '@mui/material'; +import { Dialog, DialogActions, DialogContent, DialogProps, DialogTitle, LinearProgress } from '@mui/material'; import { type ObjectSchema } from 'yup'; import { SubmitButton } from '../../inputs/reactHookForm/utils/SubmitButton'; import { CancelButton } from '../../inputs/reactHookForm/utils/CancelButton'; @@ -165,9 +165,7 @@ export function CustomMuiDialog({ > {isDataFetching && } - - - + {children} diff --git a/src/components/index.ts b/src/components/index.ts index e5fecb8e4..a5f3a83f9 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -27,3 +27,5 @@ export * from './notifications'; export * from './icons'; export * from './parameters'; export * from './menus'; +export * from './muiTable'; +export * from './resizablePanels'; diff --git a/src/components/inputs/reactHookForm/DirectoryItemsInput.tsx b/src/components/inputs/reactHookForm/DirectoryItemsInput.tsx index 20e38ffe4..d00255139 100644 --- a/src/components/inputs/reactHookForm/DirectoryItemsInput.tsx +++ b/src/components/inputs/reactHookForm/DirectoryItemsInput.tsx @@ -5,7 +5,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { FormControl, Grid, IconButton, Tooltip } from '@mui/material'; +import { FormControl, IconButton, Tooltip } from '@mui/material'; import { Folder as FolderIcon } from '@mui/icons-material'; import { ComponentType, useCallback, useEffect, useMemo, useState } from 'react'; import { FieldValues, useController, useFieldArray, useWatch } from 'react-hook-form'; @@ -25,28 +25,21 @@ import { OverflowableChip, OverflowableChipProps } from './OverflowableChip'; import { RawReadOnlyInput } from './RawReadOnlyInput'; const styles = { - formDirectoryElements1: { + formDirectoryElements: { display: 'flex', gap: '8px', flexWrap: 'wrap', flexDirection: 'row', + alignContent: 'flex-start', + alignItems: 'center', border: '2px solid lightgray', - padding: '4px', + padding: '10px', borderRadius: '4px', overflow: 'hidden', }, formDirectoryElementsError: (theme) => ({ borderColor: theme.palette.error.main, }), - formDirectoryElements2: { - display: 'flex', - gap: '8px', - flexWrap: 'wrap', - flexDirection: 'row', - marginTop: 0, - padding: '4px', - overflow: 'hidden', - }, addDirectoryElements: { marginTop: '-5px', }, @@ -67,6 +60,7 @@ export interface DirectoryItemsInputProps; chipProps?: Partial; + fullHeight?: boolean; } export function DirectoryItemsInput({ @@ -84,6 +78,7 @@ export function DirectoryItemsInput>) { const { snackError } = useSnackMessage(); const intl = useIntl(); @@ -200,90 +195,80 @@ export function DirectoryItemsInput + + + { + if (shouldReplaceElement) { + handleChipClick(0); + } else { + setDirectoryItemSelectorOpen(true); + if (allowMultiSelect) { + setMultiSelect(true); + } + } + }} + > + + + + + {elements?.map((item, index) => { + const elementName = + watchedElements?.[index]?.[NAME] ?? + getValues(`${name}.${index}.${NAME}`) ?? + (item as FieldValues)?.[NAME]; + + const equipmentTypeShortLabel = getEquipmentTypeShortLabel(item?.specificMetadata?.equipmentType); + + const { sx: chipSx, ...otherChipProps } = chipProps ?? {}; + + return ( + removeElements(index)} + onClick={() => handleChipClick(index)} + label={ + elementName ? ( + + ) : ( + intl.formatMessage({ id: 'elementNotFound' }) + ) + } + {...(equipmentTypeShortLabel && { + helperText: intl.formatMessage({ + id: equipmentTypeShortLabel, + }), + })} + sx={mergeSx( + !elementName + ? (theme) => ({ + backgroundColor: theme.palette.error.light, + borderColor: theme.palette.error.main, + color: theme.palette.error.contrastText, + }) + : undefined, + chipSx + )} + {...(otherChipProps as CP)} + /> + ); + })} {elements?.length === 0 && label && ( )} - {elements?.length > 0 && ( - - {elements.map((item, index) => { - const elementName = - watchedElements?.[index]?.[NAME] ?? - getValues(`${name}.${index}.${NAME}`) ?? - (item as FieldValues)?.[NAME]; - - const equipmentTypeShortLabel = getEquipmentTypeShortLabel( - item?.specificMetadata?.equipmentType - ); - - const { sx: chipSx, ...otherChipProps } = chipProps ?? {}; - - return ( - removeElements(index)} - onClick={() => handleChipClick(index)} - label={ - elementName ? ( - - ) : ( - intl.formatMessage({ id: 'elementNotFound' }) - ) - } - {...(equipmentTypeShortLabel && { - helperText: intl.formatMessage({ - id: equipmentTypeShortLabel, - }), - })} - sx={mergeSx( - !elementName - ? (theme) => ({ - backgroundColor: theme.palette.error.light, - borderColor: theme.palette.error.main, - color: theme.palette.error.contrastText, - }) - : undefined, - chipSx - )} - {...(otherChipProps as CP)} - /> - ); - })} - - )} - - - - - { - if (shouldReplaceElement) { - handleChipClick(0); - } else { - setDirectoryItemSelectorOpen(true); - if (allowMultiSelect) { - setMultiSelect(true); - } - } - }} - > - - - - - - {!hideErrorMessage && } ) { + return ( + + + + ); +} + +export default OverflowableTableCell; diff --git a/src/components/muiTable/OverflowableTableCellWithCheckbox.tsx b/src/components/muiTable/OverflowableTableCellWithCheckbox.tsx new file mode 100644 index 000000000..eb950439c --- /dev/null +++ b/src/components/muiTable/OverflowableTableCellWithCheckbox.tsx @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +import { Checkbox, TableCell } from '@mui/material'; +import { OverflowableText, OverflowableTextProps } from '../overflowableText'; + +export interface OverflowableTableCellProps extends OverflowableTextProps { + checked: boolean; +} + +export function OverflowableTableCellWithCheckbox({ + checked, + ...overflowableTextProps +}: Readonly) { + return ( + + + + + ); +} + +export default OverflowableTableCellWithCheckbox; diff --git a/src/components/muiTable/index.ts b/src/components/muiTable/index.ts new file mode 100644 index 000000000..7676340a0 --- /dev/null +++ b/src/components/muiTable/index.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +export * from './OverflowableTableCell'; +export * from './OverflowableTableCellWithCheckbox'; diff --git a/src/components/resizablePanels/ResizeHandle.tsx b/src/components/resizablePanels/ResizeHandle.tsx new file mode 100644 index 000000000..048315f29 --- /dev/null +++ b/src/components/resizablePanels/ResizeHandle.tsx @@ -0,0 +1,52 @@ +/** + * 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 { PanelResizeHandle } from 'react-resizable-panels'; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import { Theme, useTheme } from '@mui/material'; + +interface ResizeHandleProps { + visible?: boolean; + rotated?: boolean; + style?: (theme: Theme) => React.CSSProperties; +} + +const getStyles = ( + theme: Theme, + visible: boolean, + rotated: boolean, + customStyle?: (theme: Theme) => React.CSSProperties +) => ({ + handle: { + display: visible ? 'flex' : 'none', + alignItems: 'center', + backgroundColor: theme.palette.background.paper, + borderLeft: !rotated ? `1px solid ${theme.palette.divider}` : 'none', + borderTop: rotated ? `1px solid ${theme.palette.divider}` : 'none', + justifyContent: 'center', + ...(customStyle ? customStyle(theme) : {}), + }, + icon: { + transform: rotated ? 'rotate(90deg)' : 'none', + transition: 'transform 0.2s', + color: 'inherit', + cursor: 'ns-resize', + }, +}); + +export const ResizeHandle = ({ visible = true, rotated = false, style }: ResizeHandleProps) => { + const theme = useTheme(); + const styles = getStyles(theme, visible, rotated, style); + + return ( + + + + ); +}; + +export default ResizeHandle; diff --git a/src/components/resizablePanels/index.ts b/src/components/resizablePanels/index.ts new file mode 100644 index 000000000..569a22326 --- /dev/null +++ b/src/components/resizablePanels/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +export * from './ResizeHandle'; diff --git a/src/index.ts b/src/index.ts index 7704009da..c40fa7f64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,5 +10,6 @@ export * from './hooks'; export * from './redux'; export * from './services'; export * from './utils'; +export * from './types/custom-aggrid-types'; export * from './translations/en'; export * from './translations/fr'; diff --git a/src/types/custom-aggrid-types.ts b/src/types/custom-aggrid-types.ts new file mode 100644 index 000000000..19d934bb4 --- /dev/null +++ b/src/types/custom-aggrid-types.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import type { GridApi } from 'ag-grid-community'; + +// Minimal, app-agnostic types for commons-ui AG Grid helpers + +export type SortConfig = { + colId: string; + sort: SortWay; + children?: boolean; +}; + +export enum SortWay { + ASC = 'asc', + DESC = 'desc', +} + +export type FilterData = { + dataType?: string; + type?: string; + originalType?: string; + value: unknown; + tolerance?: number; +}; + +export type FilterConfig = FilterData & { + column: string; +}; + +export type FilterParams = { + // kept as string to avoid coupling to app-specific enums + type: string; + tab: string; + dataType?: string; + comparators?: string[]; + debounceMs?: number; + updateFilterCallback?: (api?: GridApi, filters?: FilterConfig[]) => void; + // store-agnostic filtering: provide current filters and a setter + filters?: FilterConfig[]; + setFilters?: (newFilters: FilterConfig[]) => void; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index decfdf2e0..f0c2a6893 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -18,3 +18,5 @@ export * from './types'; export * from './validation-functions'; export * from './labelUtils'; export { default as yupConfig } from './yupConfig'; +export * from './rounding'; +export * from './types-utils'; diff --git a/src/utils/rounding.ts b/src/utils/rounding.ts new file mode 100644 index 000000000..fe6093cdc --- /dev/null +++ b/src/utils/rounding.ts @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2023, 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/. + */ + +// add some little margin of error to 64bit doubles which have 15-17 decimal digits +// this allows for computations or conversions which result in a number very close to a nice short decimal number to be shown as the nice number +// in exchange for getting nicer shorter numbers more often, we choose to drop all precision beyond this limit to have uniform precision. +// The number of significant digits, e.g. 12345.67890123 or 1.234567890123 or 1234567890123 or 0.00000001234567890123 +// note: this is not the digits after the decimal point, this is the total number of digits after the first non zero digit +export const GRIDSUITE_DEFAULT_PRECISION = 13; + +// convert to rounded decimal string and reparse to get a nicer number +// example: with precision=13, +// round most numbers in approximately the range [0x1.3333333333000p+0, 0x1.3333333334000] +// to 0x1.3333333333333p+0 (in decimal "1.1999999999999999555910790149937383830547332763671875" or just "1.2" in the nice short form) +// so we get +// "1.1999999999995" => "1.199999999999" +// "1.1999999999996" => "1.2" +// .. many numbers in between +// "1.2000000000004" => "1.2" +// "1.2000000000005" => "1.200000000001" +// Note: this is not guaranteed to always round in the same direction: +// roundToPrecision(300000.00000365, 13) => 300000.0000037 +// roundToPrecision(900000.00000365, 13) => 900000.0000036 +export const roundToPrecision = (num: number, precision: number) => Number(num.toPrecision(precision)); +export const roundToDefaultPrecision = (num: number) => roundToPrecision(num, GRIDSUITE_DEFAULT_PRECISION); + +/** + * Counts the number of decimal places in a given number. + * Converts the number to a string and checks for a decimal point. + * If a decimal point is found, it returns the length of the sequence following the decimal point. + * Returns 0 if there is no decimal part. + * @param {number} number - The number whose decimal places are to be counted. + * @returns {number} The number of decimal places in the input number. + */ +export const countDecimalPlaces = (number: number) => { + // Convert the number to a string for easier manipulation + const numberAsString = number.toString(); + return countDecimalPlacesFromString(numberAsString); +}; + +export const countDecimalPlacesFromString = (numberAsString: string) => { + // Check if the number has a decimal part + if (numberAsString.includes('.')) { + // Return the length of the part after the decimal point + return numberAsString.split('.')[1].length; + } + + // If the number does not have a decimal part, return 0 + return 0; +}; + +export const truncateNumber = (value: number, decimalPrecision: number) => { + // Calculate the factor based on the decimal precision (e.g., 100 for two decimal places) + let factor = Math.pow(10, decimalPrecision); + + // Truncate the number to maintain precision + // Here, 'value' is multiplied by a factor before being floored. + // This truncation helps in eliminating floating point arithmetic issues like 0.1 + 0.2 not exactly equaling 0.3. + // After flooring, the value is divided by the same factor to revert it to its original scale but truncated. + let truncatedNumber = Math.floor(value * factor) / factor; + + return truncatedNumber; +}; diff --git a/src/utils/types-utils.ts b/src/utils/types-utils.ts new file mode 100644 index 000000000..921a49678 --- /dev/null +++ b/src/utils/types-utils.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +export const isNonEmptyStringOrArray = (value: unknown): value is string | unknown[] => { + if (typeof value === 'string' && value.length > 0) { + return true; + } + return Array.isArray(value) && value.length > 0; +}; + +export const simpleConverterToString = (value: T) => `${value}`; diff --git a/src/utils/validation-functions.ts b/src/utils/validation-functions.ts index d72a6952c..007e8675c 100644 --- a/src/utils/validation-functions.ts +++ b/src/utils/validation-functions.ts @@ -34,3 +34,21 @@ export function validateValueIsANumber(value?: string | number | null | boolean) } return !Number.isNaN(toNumber(value)); } + +/* + * Returns true if value is either undefined, null, empty or only contains whitespaces. + * Otherwise, if value is a boolean or a number, returns false. + */ +export function isBlankOrEmpty(value: T) { + if (value === undefined || value === null) { + return true; + } + if (typeof value === 'string') { + return /^\s*$/.test(value); + } + return false; +} + +export function isNotBlankOrEmpty(value: T): value is NonNullable { + return !isBlankOrEmpty(value); +}