diff --git a/CHANGELOG.md b/CHANGELOG.md index a4e80578..e1cc912f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to `dash-ag-grid` will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). Links "DE#nnn" prior to version 2.0 point to the Dash Enterprise closed-source Dash AG Grid repo +## UNRELEASED + +### Changed +- Component is refactored to be a function component rather than a class + ## [32.3.0rc0] - 2025-04-15 ### Fixed diff --git a/package-lock.json b/package-lock.json index 505552ac..5b824820 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10022,6 +10022,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "dev": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -10063,6 +10064,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "dev": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -10827,6 +10829,7 @@ "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "dev": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -12824,8 +12827,7 @@ "version": "7.21.0-placeholder-for-preset-env.2", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "dev": true, - "requires": {} + "dev": true }, "@babel/plugin-syntax-async-generators": { "version": "7.8.4", @@ -13827,8 +13829,7 @@ "@emotion/use-insertion-effect-with-fallbacks": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", - "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", - "requires": {} + "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==" }, "@emotion/utils": { "version": "1.2.1", @@ -14316,8 +14317,7 @@ "@mui/types": { "version": "7.2.13", "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.13.tgz", - "integrity": "sha512-qP9OgacN62s+l8rdDhSFRe05HWtLLJ5TGclC9I1+tQngbssu0m2dmFZs+Px53AcOs9fD7TbYd4gc9AXzVqO/+g==", - "requires": {} + "integrity": "sha512-qP9OgacN62s+l8rdDhSFRe05HWtLLJ5TGclC9I1+tQngbssu0m2dmFZs+Px53AcOs9fD7TbYd4gc9AXzVqO/+g==" }, "@mui/utils": { "version": "5.15.7", @@ -14671,22 +14671,19 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz", "integrity": "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==", - "dev": true, - "requires": {} + "dev": true }, "@webpack-cli/info": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-2.0.2.tgz", "integrity": "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==", - "dev": true, - "requires": {} + "dev": true }, "@webpack-cli/serve": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-2.0.5.tgz", "integrity": "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==", - "dev": true, - "requires": {} + "dev": true }, "@xtuc/ieee754": { "version": "1.2.0", @@ -14710,15 +14707,13 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", - "dev": true, - "requires": {} + "dev": true }, "acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "requires": {} + "dev": true }, "ag-charts-community": { "version": "10.3.4", @@ -14810,8 +14805,7 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "requires": {} + "dev": true }, "ansi-regex": { "version": "5.0.1", @@ -15908,8 +15902,7 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", - "dev": true, - "requires": {} + "dev": true }, "eslint-import-resolver-node": { "version": "0.3.9", @@ -16752,8 +16745,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", - "dev": true, - "requires": {} + "dev": true }, "ignore": { "version": "5.3.1", @@ -19088,8 +19080,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", - "dev": true, - "requires": {} + "dev": true }, "postcss-modules-local-by-default": { "version": "4.0.4", @@ -19200,6 +19191,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "dev": true, "requires": { "loose-envify": "^1.1.0" } @@ -19234,6 +19226,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "dev": true, "requires": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -19761,6 +19754,7 @@ "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "dev": true, "requires": { "loose-envify": "^1.1.0" } @@ -20136,8 +20130,7 @@ "version": "3.3.4", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz", "integrity": "sha512-0WqXzrsMTyb8yjZJHDqwmnwRJvhALK9LfRtRc6B4UTWe8AijYLZYZ9thuJTZc2VfQWINADW/j+LiJnfy2RoC1w==", - "dev": true, - "requires": {} + "dev": true }, "style-to-object": { "version": "0.4.4", diff --git a/src/lib/components/AgGrid.react.js b/src/lib/components/AgGrid.react.js index 05b460e8..5813dd3b 100644 --- a/src/lib/components/AgGrid.react.js +++ b/src/lib/components/AgGrid.react.js @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; import LazyLoader from '../LazyLoader'; -import React, {Component, lazy, Suspense} from 'react'; +import React, {lazy, Suspense, useState, useCallback, useEffect} from 'react'; const RealAgGrid = lazy(LazyLoader.agGrid); const RealAgGridEnterprise = lazy(LazyLoader.agGridEnterprise); @@ -12,21 +12,13 @@ function getGrid(enable) { /** * Dash interface to AG Grid, a powerful tabular data component. */ -class DashAgGrid extends Component { - constructor(props) { - super(props); +function DashAgGrid(props) { + const [state, setState] = useState({ + mounted: false, + rowTransaction: null, + }); - this.state = { - mounted: false, - rowTransaction: null, - }; - - this.buildArray = this.buildArray.bind(this); - } - - static dashRenderType = true; - - buildArray(arr1, arr2) { + const buildArray = useCallback((arr1, arr2) => { if (arr1) { if (!arr1.includes(arr2)) { return [...arr1, arr2]; @@ -34,33 +26,32 @@ class DashAgGrid extends Component { return arr1; } return [JSON.parse(JSON.stringify(arr2))]; - } - - UNSAFE_componentWillReceiveProps(nextProps) { - if (this.props.rowTransaction && !this.state.mounted) { - if (nextProps.rowTransaction !== this.props.rowTransaction) { - this.setState({ - rowTransaction: this.buildArray( - this.state.rowTransaction, - this.props.rowTransaction - ), - }); - } + }, []); + + useEffect(() => { + if (props.rowTransaction && !state.mounted) { + setState((prevState) => ({ + ...prevState, + rowTransaction: buildArray( + prevState.rowTransaction, + props.rowTransaction + ), + })); } - } + }, [props.rowTransaction, state.mounted, buildArray]); - render() { - const {enableEnterpriseModules} = this.props; + const {enableEnterpriseModules} = props; + const RealComponent = getGrid(enableEnterpriseModules); - const RealComponent = getGrid(enableEnterpriseModules); - return ( - - - - ); - } + return ( + + + + ); } +DashAgGrid.dashRenderType = true; + DashAgGrid.defaultProps = { className: 'ag-theme-alpine', resetColumnState: false, diff --git a/src/lib/fragments/AgGrid.react.js b/src/lib/fragments/AgGrid.react.js index 06c273f2..52e6a907 100644 --- a/src/lib/fragments/AgGrid.react.js +++ b/src/lib/fragments/AgGrid.react.js @@ -1,4 +1,4 @@ -import React, {Component} from 'react'; +import React, {useCallback, useRef, useState, useMemo, useEffect} from 'react'; import PropTypes from 'prop-types'; import * as evaluate from 'static-eval'; import * as esprima from 'esprima'; @@ -37,7 +37,6 @@ import { PROPS_NOT_FOR_AG_GRID, GRID_DANGEROUS_FUNCTIONS, OMIT_PROP_RENDER, - OMIT_STATE_RENDER, OBJ_MAYBE_FUNCTION_OR_MAP_MAYBE_FUNCTIONS, } from '../utils/propCategories'; import debounce from '../utils/debounce'; @@ -116,100 +115,188 @@ function stringifyId(id) { return '{' + parts.join(',') + '}'; } -export default class DashAgGrid extends Component { - constructor(props) { - super(props); - - this.onGridReady = this.onGridReady.bind(this); - this.onSelectionChanged = this.onSelectionChanged.bind(this); - this.onCellClicked = this.onCellClicked.bind(this); - this.onCellDoubleClicked = this.onCellDoubleClicked.bind(this); - this.onCellValueChanged = this.onCellValueChanged.bind(this); - this.afterCellValueChanged = this.afterCellValueChanged.bind(this); - this.onRowDataUpdated = this.onRowDataUpdated.bind(this); - this.onFilterChanged = this.onFilterChanged.bind(this); - this.onSortChanged = this.onSortChanged.bind(this); - this.onRowGroupOpened = this.onRowGroupOpened.bind(this); - this.onDisplayedColumnsChanged = - this.onDisplayedColumnsChanged.bind(this); - this.onColumnResized = this.onColumnResized.bind(this); - this.onGridSizeChanged = this.onGridSizeChanged.bind(this); - this.updateColumnWidths = this.updateColumnWidths.bind(this); - this.handleDynamicStyle = this.handleDynamicStyle.bind(this); - this.generateRenderer = this.generateRenderer.bind(this); - this.resetColumnState = this.resetColumnState.bind(this); - this.exportDataAsCsv = this.exportDataAsCsv.bind(this); - this.setSelection = this.setSelection.bind(this); - this.memoizeOne = this.memoizeOne.bind(this); - this.convertFunction = this.convertFunction.bind(this); - this.convertMaybeFunction = this.convertMaybeFunction.bind(this); - this.convertCol = this.convertCol.bind(this); - this.convertOne = this.convertOne.bind(this); - this.convertAllProps = this.convertAllProps.bind(this); - this.buildArray = this.buildArray.bind(this); - this.onAsyncTransactionsFlushed = - this.onAsyncTransactionsFlushed.bind(this); - this.onPaginationChanged = this.onPaginationChanged.bind(this); - this.scrollTo = this.scrollTo.bind(this); - - // Additional Exposure - this.selectAll = this.selectAll.bind(this); - this.deselectAll = this.deselectAll.bind(this); - this.updateColumnState = this.updateColumnState.bind(this); - this.deleteSelectedRows = this.deleteSelectedRows.bind(this); - this.rowTransaction = this.rowTransaction.bind(this); - this.getRowData = this.getRowData.bind(this); - this.syncRowData = this.syncRowData.bind(this); - this.isDatasourceLoadedForInfiniteScrolling = - this.isDatasourceLoadedForInfiniteScrolling.bind(this); - this.getDatasource = this.getDatasource.bind(this); - this.applyRowTransaction = this.applyRowTransaction.bind(this); - this.parseFunction = this.parseFunction.bind(this); - - const customComponents = window.dashAgGridComponentFunctions || {}; - const newComponents = map(this.generateRenderer, customComponents); - this.active = true; - this.customSetProps = (propsToSet) => { - if (this.active) { - this.props.setProps(propsToSet); +function usePrevious(value) { + const ref = useRef(); + + useEffect(() => { + setTimeout(() => { + ref.current = value; + }, 1); + }, [value]); + + return ref.current; +} + +export function DashAgGrid(props) { + const active = useRef(true); + + // const customSetProps = props.setProps; + const customSetProps = useCallback( + (propsToSet) => { + if (active.current) { + props.setProps(propsToSet); } - }; - this.setEventData = (data) => { + }, + [props.setProps] + ); + + const setEventData = useCallback( + (data) => { const timestamp = Date.now(); - this.customSetProps({ + customSetProps({ eventData: { data, timestamp, }, }); - }; + }, + [customSetProps] + ); - this.convertedPropCache = {}; + const parseFunction = useMemo( + () => + memoizeWith(String, (funcString) => { + const parsedCondition = + esprima.parse(funcString).body[0].expression; + const context = { + d3, + dash_clientside, + ...customFunctions, + ...window.dashAgGridFunctions, + }; + return (params) => + evaluate(parsedCondition, {params, ...context}); + }), + [] + ); - this.state = { - ...this.props.parentState, - components: { - rowMenu: this.generateRenderer(RowMenuRenderer), - markdown: this.generateRenderer(MarkdownRenderer), - ...newComponents, - }, - rerender: 0, - openGroups: {}, - gridApi: null, - columnState_push: true, - }; + const parseFunctionEvent = useMemo( + () => + memoizeWith(String, (funcString) => { + const parsedCondition = + esprima.parse(funcString).body[0].expression; + const context = { + d3, + dash_clientside, + ...customFunctions, + ...window.dashAgGridFunctions, + setGridProps: customSetProps, + setEventData: setEventData, + }; + return (params) => + evaluate(parsedCondition, {params, ...context}); + }), + [customSetProps, setEventData] + ); - this.selectionEventFired = false; - this.pauseSelections = false; - this.reference = React.createRef(); - this.pendingChanges = null; - this.dataUpdates = false; - } + const parseFunctionNoParams = useMemo( + () => + memoizeWith(String, (funcString) => { + const parsedCondition = + esprima.parse(funcString).body[0].expression; + const context = { + d3, + ...customFunctions, + ...window.dashAgGridFunctions, + }; + return evaluate(parsedCondition, context); + }), + [] + ); + + /** + * @params AG-Grid Styles rules attribute. + * Cells: https://www.ag-grid.com/react-grid/cell-styles/#cell-style-cell-class--cell-class-rules-params + * Rows: https://www.ag-grid.com/react-grid/row-styles/#row-style-row-class--row-class-rules-params + */ + const handleDynamicStyle = useCallback( + (cellStyle) => { + const {styleConditions, defaultStyle} = cellStyle; + const _defaultStyle = defaultStyle || null; + + if (styleConditions && styleConditions.length) { + const tests = styleConditions.map(({condition, style}) => ({ + test: parseFunction(condition), + style, + })); + return (params) => { + for (const {test, style} of tests) { + if (params) { + if (params.node.id && params.node.id !== null) { + if (test(params)) { + return style; + } + } + } + } + return _defaultStyle; + }; + } - onPaginationChanged() { - const {gridApi} = this.state; + return _defaultStyle; + }, + [parseFunction] + ); + + const generateRenderer = useCallback( + (Renderer) => { + const {dangerously_allow_code} = props; + + return (cellProps) => ( + { + customSetProps({ + cellRendererData: { + value, + colId: cellProps.column.colId, + rowIndex: cellProps.node.sourceRowIndex, + rowId: cellProps.node.id, + timestamp: Date.now(), + }, + }); + }} + dangerously_allow_code={dangerously_allow_code} + {...cellProps} + > + ); + }, + [props.dangerously_allow_code, customSetProps] + ); + + const customComponents = window.dashAgGridComponentFunctions || {}; + const newComponents = map(generateRenderer, customComponents); + + const [gridApi, setGridApi] = useState(null); + const [, forceRerender] = useState({}); + const [openGroups, setOpenGroups] = useState({}); + const [columnState_push, setColumnState_push] = useState(true); + const [rowTransactionState, setRowTransactionState] = useState(null); + + const components = useMemo( + () => ({ + rowMenu: generateRenderer(RowMenuRenderer), + markdown: generateRenderer(MarkdownRenderer), + ...newComponents, + }), + [generateRenderer, newComponents] + ); + + const prevProps = usePrevious(props); + const prevGridApi = usePrevious(gridApi); + + const convertedPropCache = useRef({}); + + const selectionEventFired = useRef(false); + const pauseSelections = useRef(false); + const reference = useRef(); + const dataUpdates = useRef(false); + const getDetailParams = useRef(); + const getRowsParams = useRef(null); + const pendingCellValueChanges = useRef(null); + + const onPaginationChanged = useCallback(() => { if (gridApi && !gridApi?.isDestroyed()) { - this.customSetProps({ + customSetProps({ paginationInfo: { isLastPageFound: gridApi.paginationIsLastPageFound(), pageSize: gridApi.paginationGetPageSize(), @@ -219,1282 +306,1300 @@ export default class DashAgGrid extends Component { }, }); } - } + }, [gridApi, customSetProps]); - setSelection(selection, gridApi = this.state?.gridApi) { - const {getRowId} = this.props; - if (gridApi && selection && !gridApi?.isDestroyed()) { - this.pauseSelections = true; - const nodeData = []; - if (has('function', selection)) { - const test = this.parseFunction(selection.function); + const setSelection = useCallback( + (selection) => { + const {getRowId} = props; + if (gridApi && selection && !gridApi?.isDestroyed()) { + pauseSelections.current = true; + const nodeData = []; + if (has('function', selection)) { + const test = parseFunction(selection.function); - gridApi.forEachNode((node) => { - if (test(node)) { - nodeData.push(node); - } - }); - } else if (has('ids', selection)) { - const mapId = {}; - selection.ids.forEach((id) => { - mapId[id] = true; - }); - gridApi.forEachNode((node) => { - if (mapId[node.id]) { - nodeData.push(node); - } - }); - } else { - if (selection.length) { - if (getRowId) { - const parsedCondition = esprima.parse( - getRowId.replaceAll('params.data.', '') - ).body[0].expression; - const mapId = {}; - selection.forEach((params) => { - mapId[evaluate(parsedCondition, params)] = true; - }); - gridApi.forEachNode((node) => { - if (mapId[node.id]) { - nodeData.push(node); - } - }); - } else { - gridApi.forEachNode((node) => { - if (includes(node.data, selection)) { - nodeData.push(node); - } - }); + gridApi.forEachNode((node) => { + if (test(node)) { + nodeData.push(node); + } + }); + } else if (has('ids', selection)) { + const mapId = {}; + selection.ids.forEach((id) => { + mapId[id] = true; + }); + gridApi.forEachNode((node) => { + if (mapId[node.id]) { + nodeData.push(node); + } + }); + } else { + if (selection.length) { + if (getRowId) { + const parsedCondition = esprima.parse( + getRowId.replaceAll('params.data.', '') + ).body[0].expression; + const mapId = {}; + selection.forEach((params) => { + mapId[evaluate(parsedCondition, params)] = true; + }); + gridApi.forEachNode((node) => { + if (mapId[node.id]) { + nodeData.push(node); + } + }); + } else { + gridApi.forEachNode((node) => { + if (includes(node.data, selection)) { + nodeData.push(node); + } + }); + } } } + gridApi.deselectAll(); + gridApi.setNodesSelected({ + nodes: nodeData, + newValue: true, + }); + setTimeout(() => { + pauseSelections.current = false; + }, 1); } - gridApi.deselectAll(); - gridApi.setNodesSelected({nodes: nodeData, newValue: true}); - setTimeout(() => { - this.pauseSelections = false; - }, 1); - } - } + }, + [gridApi, props.getRowId, parseFunction] + ); - memoizeOne(converter, obj, target) { - const cache = this.convertedPropCache[target]; - if (cache && obj === cache[0]) { - return cache[1]; - } - const result = converter(obj, target); - this.convertedPropCache[target] = [obj, result]; - return result; - } + const memoizeOne = useCallback( + (converter, obj, target) => { + const cache = convertedPropCache.current[target]; + if (cache && obj === cache[0]) { + return cache[1]; + } + const result = converter(obj, target); + convertedPropCache.current[target] = [obj, result]; + return result; + }, + [convertedPropCache] + ); - convertFunction(func) { - // TODO: do we want this? ie allow the form `{function: }` even when - // we're expecting just a string? - if (has('function', func)) { - return this.convertFunction(func.function); - } + const convertFunction = useCallback( + (func) => { + // TODO: do we want this? ie allow the form `{function: }` even when + // we're expecting just a string? + if (has('function', func)) { + return convertFunction(func.function); + } - try { - if (typeof func !== 'string') { - throw new Error('tried to parse non-string as function', func); + try { + if (typeof func !== 'string') { + throw new Error( + 'tried to parse non-string as function', + func + ); + } + return parseFunction(func); + } catch (err) { + console.log(err); } - return this.parseFunction(func); - } catch (err) { - console.log(err); - } - return ''; - } + return ''; + }, + [parseFunction] + ); - convertFunctionNoParams(func) { - // TODO: do we want this? ie allow the form `{function: }` even when - // we're expecting just a string? - if (has('function', func)) { - return this.convertFunctionNoParams(func.function); - } + const convertFunctionNoParams = useCallback( + (func) => { + // TODO: do we want this? ie allow the form `{function: }` even when + // we're expecting just a string? + if (has('function', func)) { + return convertFunctionNoParams(func.function); + } - try { - if (typeof func !== 'string') { - throw new Error('tried to parse non-string as function', func); + try { + if (typeof func !== 'string') { + throw new Error( + 'tried to parse non-string as function', + func + ); + } + return parseFunctionNoParams(func); + } catch (err) { + console.log(err); } - return this.parseFunctionNoParams(func); - } catch (err) { - console.log(err); - } - return ''; - } + return ''; + }, + [parseFunctionNoParams] + ); - convertMaybeFunction(maybeFunc, stringsEvalContext) { - if (has('function', maybeFunc)) { - return this.convertFunction(maybeFunc.function); - } + const convertMaybeFunction = useCallback( + (maybeFunc, stringsEvalContext) => { + if (has('function', maybeFunc)) { + return convertFunction(maybeFunc.function); + } - if ( - stringsEvalContext && - typeof maybeFunc === 'string' && - !this.props.dangerously_allow_code - ) { - xssMessage(stringsEvalContext); - return null; - } - return maybeFunc; - } + if ( + stringsEvalContext && + typeof maybeFunc === 'string' && + !props.dangerously_allow_code + ) { + xssMessage(stringsEvalContext); + return null; + } + return maybeFunc; + }, + [props.dangerously_allow_code, convertFunction] + ); - convertMaybeFunctionNoParams(maybeFunc, stringsEvalContext) { - if (has('function', maybeFunc)) { - return this.convertFunctionNoParams(maybeFunc.function); - } + const convertMaybeFunctionNoParams = useCallback( + (maybeFunc, stringsEvalContext) => { + if (has('function', maybeFunc)) { + return convertFunctionNoParams(maybeFunc.function); + } - if ( - stringsEvalContext && - typeof maybeFunc === 'string' && - !this.props.dangerously_allow_code - ) { - xssMessage(stringsEvalContext); - return null; - } - return maybeFunc; - } + if ( + stringsEvalContext && + typeof maybeFunc === 'string' && + !props.dangerously_allow_code + ) { + xssMessage(stringsEvalContext); + return null; + } + return maybeFunc; + }, + [props.dangerously_allow_code, convertFunctionNoParams] + ); - suppressGetDetail(colName) { + const suppressGetDetail = useCallback((colName) => { return (params) => { params.successCallback(params.data[colName]); }; - } + }, []); - callbackGetDetail = (params) => { + const callbackGetDetail = useCallback((params) => { const {data} = params; - this.getDetailParams = params; + getDetailParams.current = params; // Adding the current time in ms forces Dash to trigger a callback // when the same row is closed and re-opened. - this.customSetProps({ + customSetProps({ getDetailRequest: {data: data, requestTime: Date.now()}, }); - }; + }, []); - convertCol(columnDef) { - if (typeof columnDef === 'function') { - return columnDef; - } - - return mapObjIndexed((value, target) => { - if ( - target === 'cellStyle' && - (has('styleConditions', value) || has('defaultStyle', value)) - ) { - return this.handleDynamicStyle(value); - } - if (OBJ_OF_FUNCTIONS[target]) { - return map(this.convertFunction, value); - } - if (COLUMN_DANGEROUS_FUNCTIONS[target]) { - // the second argument tells convertMaybeFunction - // that a plain string is dangerous, - // and provides the context for error reporting - const field = columnDef.field || columnDef.headerName; - return this.convertMaybeFunction(value, {target, field}); - } - if (COLUMN_MAYBE_FUNCTIONS[target]) { - return this.convertMaybeFunction(value); - } - if (COLUMN_MAYBE_FUNCTIONS_NO_PARAMS[target]) { - return this.convertMaybeFunctionNoParams(value); + const convertCol = useCallback( + (columnDef) => { + if (typeof columnDef === 'function') { + return columnDef; } - if (COLUMN_ARRAY_NESTED_FUNCTIONS[target] && Array.isArray(value)) { - return value.map((c) => { - if (typeof c === 'object') { - return this.convertCol(c); - } - return c; - }); - } - if (OBJ_MAYBE_FUNCTION_OR_MAP_MAYBE_FUNCTIONS[target]) { - if ('function' in value) { - if (typeof value.function === 'string') { - return this.convertMaybeFunctionNoParams(value); - } + + return mapObjIndexed((value, target) => { + if ( + target === 'cellStyle' && + (has('styleConditions', value) || + has('defaultStyle', value)) + ) { + return handleDynamicStyle(value); + } + if (OBJ_OF_FUNCTIONS[target]) { + return map(convertFunction, value); + } + if (COLUMN_DANGEROUS_FUNCTIONS[target]) { + // the second argument tells convertMaybeFunction + // that a plain string is dangerous, + // and provides the context for error reporting + const field = columnDef.field || columnDef.headerName; + return convertMaybeFunction(value, {target, field}); + } + if (COLUMN_MAYBE_FUNCTIONS[target]) { + return convertMaybeFunction(value); } - return map((v) => { - if (typeof v === 'object') { - if (typeof v.function === 'string') { - return this.convertMaybeFunctionNoParams(v); + if (COLUMN_MAYBE_FUNCTIONS_NO_PARAMS[target]) { + return convertMaybeFunctionNoParams(value); + } + if ( + COLUMN_ARRAY_NESTED_FUNCTIONS[target] && + Array.isArray(value) + ) { + return value.map((c) => { + if (typeof c === 'object') { + return convertCol(c); + } + return c; + }); + } + if (OBJ_MAYBE_FUNCTION_OR_MAP_MAYBE_FUNCTIONS[target]) { + if ('function' in value) { + if (typeof value.function === 'string') { + return convertMaybeFunctionNoParams(value); } - return this.convertCol(v); } - return v; - }, value); - } - if (COLUMN_NESTED_FUNCTIONS[target] && typeof value === 'object') { - return this.convertCol(value); - } - if (COLUMN_NESTED_OR_OBJ_OF_FUNCTIONS[target]) { - if (has('function', value)) { - return this.convertMaybeFunction(value); + return map((v) => { + if (typeof v === 'object') { + if (typeof v.function === 'string') { + return convertMaybeFunctionNoParams(v); + } + return convertCol(v); + } + return v; + }, value); } - return this.convertCol(value); - } - // not one of those categories - pass it straight through - return value; - }, columnDef); - } - - convertOne(value, target) { - if (value) { - if (target === 'columnDefs') { - return value.map(this.convertCol); - } - if (GRID_COLUMN_CONTAINERS[target]) { - return this.convertCol(value); - } - if (OBJ_MAYBE_FUNCTION_OR_MAP_MAYBE_FUNCTIONS[target]) { - if ('function' in value) { - if (typeof value.function === 'string') { - return this.convertMaybeFunctionNoParams(value); + if ( + COLUMN_NESTED_FUNCTIONS[target] && + typeof value === 'object' + ) { + return convertCol(value); + } + if (COLUMN_NESTED_OR_OBJ_OF_FUNCTIONS[target]) { + if (has('function', value)) { + return convertMaybeFunction(value); } + return convertCol(value); } - return mapObjIndexed((v) => { - if (typeof v === 'object') { - if ('function' in v) { - if (typeof v.function === 'string') { - return this.convertMaybeFunctionNoParams(v); + // not one of those categories - pass it straight through + return value; + }, columnDef); + }, + [ + handleDynamicStyle, + convertFunction, + convertMaybeFunction, + convertMaybeFunctionNoParams, + ] + ); + + const convertOneRef = useRef(); + const convertAllPropsRef = useRef(); + + const convertOne = useCallback( + (value, target) => { + if (value) { + if (target === 'columnDefs') { + return value.map(convertCol); + } + if (GRID_COLUMN_CONTAINERS[target]) { + return convertCol(value); + } + if (OBJ_MAYBE_FUNCTION_OR_MAP_MAYBE_FUNCTIONS[target]) { + if ('function' in value) { + if (typeof value.function === 'string') { + return convertMaybeFunctionNoParams(value); + } + } + return mapObjIndexed((v) => { + if (typeof v === 'object') { + if ('function' in v) { + if (typeof v.function === 'string') { + return convertMaybeFunctionNoParams(v); + } + } else { + return convertCol(v); } - } else { - return this.convertCol(v); } + return v; + }, value); + } + if (GRID_NESTED_FUNCTIONS[target]) { + let adjustedVal = value; + if ('suppressCallback' in value) { + adjustedVal = { + ...adjustedVal, + getDetailRowData: value.suppressCallback + ? suppressGetDetail(value.detailColName) + : callbackGetDetail, + }; } - return v; - }, value); - } - if (GRID_NESTED_FUNCTIONS[target]) { - let adjustedVal = value; - if ('suppressCallback' in value) { - adjustedVal = { - ...adjustedVal, - getDetailRowData: value.suppressCallback - ? this.suppressGetDetail(value.detailColName) - : this.callbackGetDetail, - }; + if ('detailGridOptions' in value) { + adjustedVal = assocPath( + ['detailGridOptions', 'components'], + components, + adjustedVal + ); + } + return convertAllPropsRef.current(adjustedVal); } - if ('detailGridOptions' in value) { - adjustedVal = assocPath( - ['detailGridOptions', 'components'], - this.state.components, - adjustedVal - ); + if (GRID_DANGEROUS_FUNCTIONS[target]) { + return convertMaybeFunctionNoParams(value, {prop: target}); + } + if (target === 'getRowId') { + return convertFunction(value); + } + if ( + target === 'getRowStyle' && + (has('styleConditions', value) || + has('defaultStyle', value)) + ) { + return handleDynamicStyle(value); + } + if (OBJ_OF_FUNCTIONS[target]) { + return map(convertFunction, value); + } + if (GRID_ONLY_FUNCTIONS[target]) { + return convertFunction(value); + } + if (GRID_MAYBE_FUNCTIONS[target]) { + return convertMaybeFunction(value); + } + if (GRID_MAYBE_FUNCTIONS_NO_PARAMS[target]) { + return convertMaybeFunctionNoParams(value); } - return this.convertAllProps(adjustedVal); - } - if (GRID_DANGEROUS_FUNCTIONS[target]) { - return this.convertMaybeFunctionNoParams(value, {prop: target}); - } - if (target === 'getRowId') { - return this.convertFunction(value); - } - if ( - target === 'getRowStyle' && - (has('styleConditions', value) || has('defaultStyle', value)) - ) { - return this.handleDynamicStyle(value); - } - if (OBJ_OF_FUNCTIONS[target]) { - return map(this.convertFunction, value); - } - if (GRID_ONLY_FUNCTIONS[target]) { - return this.convertFunction(value); - } - if (GRID_MAYBE_FUNCTIONS[target]) { - return this.convertMaybeFunction(value); - } - if (GRID_MAYBE_FUNCTIONS_NO_PARAMS[target]) { - return this.convertMaybeFunctionNoParams(value); - } + return value; + } return value; - } - return value; - } + }, + [ + convertCol, + convertMaybeFunctionNoParams, + suppressGetDetail, + callbackGetDetail, + components, + convertAllPropsRef.current, + convertFunction, + handleDynamicStyle, + convertMaybeFunction, + ] + ); - convertAllProps(props) { - return mapObjIndexed( - (value, target) => this.memoizeOne(this.convertOne, value, target), - props - ); - } + const convertAllProps = useCallback( + (props) => { + return mapObjIndexed( + (value, target) => + memoizeOne(convertOneRef.current, value, target), + props + ); + }, + [memoizeOne, convertOneRef.current] + ); + + convertOneRef.current = convertOne; + convertAllPropsRef.current = convertAllProps; + + const virtualRowData = useCallback(() => { + const {rowModelType} = props; + const virtualRowData = []; + if (rowModelType === 'clientSide' && gridApi) { + gridApi.forEachNodeAfterFilterAndSort((node) => { + if (node.data) { + virtualRowData.push(node.data); + } + }); + } + return virtualRowData; + }, [props.rowModelType, gridApi]); - onFilterChanged() { - const {rowModelType} = this.props; - if (!this.state.gridApi) { + const onFilterChanged = useCallback(() => { + const {rowModelType} = props; + if (!gridApi) { return; } - const filterModel = this.state.gridApi.getFilterModel(); + const filterModel = gridApi.getFilterModel(); const propsToSet = {filterModel}; if (rowModelType === 'clientSide') { - propsToSet.virtualRowData = this.virtualRowData(); + propsToSet.virtualRowData = virtualRowData(); } - this.customSetProps(propsToSet); - } + customSetProps(propsToSet); + }, [props.rowModelType, gridApi, virtualRowData, customSetProps]); - getRowData() { + const getRowData = useCallback(() => { const newRowData = []; - this.state.gridApi.forEachLeafNode((node) => { + gridApi.forEachLeafNode((node) => { newRowData.push(node.data); }); return newRowData; - } - - virtualRowData() { - const {rowModelType} = this.props; - const {gridApi} = this.state; - const virtualRowData = []; - if (rowModelType === 'clientSide' && gridApi) { - gridApi.forEachNodeAfterFilterAndSort((node) => { - if (node.data) { - virtualRowData.push(node.data); - } - }); - } - return virtualRowData; - } + }, [gridApi]); - syncRowData() { - const {rowData} = this.props; + const syncRowData = useCallback(() => { + const {rowData} = props; if (rowData) { - const virtualRowData = this.virtualRowData(); - const newRowData = this.getRowData(); + const virtualRowDataResult = virtualRowData(); + const newRowData = getRowData(); if (rowData !== newRowData) { - this.customSetProps({rowData: newRowData, virtualRowData}); + customSetProps({ + rowData: newRowData, + virtualRowData: virtualRowDataResult, + }); } else { - this.customSetProps({virtualRowData}); + customSetProps({virtualRowData: virtualRowDataResult}); } } - } + }, [props.rowData, virtualRowData, getRowData, customSetProps]); - onSortChanged() { - const {rowModelType} = this.props; + const onSortChanged = useCallback(() => { + const {rowModelType} = props; const propsToSet = {}; if (rowModelType === 'clientSide') { - propsToSet.virtualRowData = this.virtualRowData(); + propsToSet.virtualRowData = virtualRowData(); } - if (!this.state.gridApi.isDestroyed()) { + if (!gridApi.isDestroyed()) { propsToSet.columnState = JSON.parse( - JSON.stringify(this.state.gridApi.getColumnState()) + JSON.stringify(gridApi.getColumnState()) ); } - this.customSetProps(propsToSet); - } + customSetProps(propsToSet); + }, [props.rowModelType, virtualRowData, gridApi, customSetProps]); - componentDidMount() { - const {id} = this.props; - if (id) { - agGridRefs[id] = this.reference.current; - eventBus.dispatch(id); - } - } + const onRowDataUpdated = useCallback(() => { + // Handles preserving existing selections when rowData is updated in a callback + const {selectedRows, rowData, rowModelType, filterModel} = props; - componentWillUnmount() { - this.setState({mounted: false, gridApi: null}); - this.active = false; - if (this.props.id) { - delete agGridRefs[this.props.id]; - eventBus.remove(this.props.id); - } - } + if (gridApi && !gridApi?.isDestroyed()) { + dataUpdates.current = true; + pauseSelections.current = true; + setSelection(selectedRows); - shouldComponentUpdate(nextProps, nextState) { - const {gridApi} = this.state; - const {columnState, filterModel, selectedRows} = nextProps; + if (rowData && rowModelType === 'clientSide') { + const virtualRowDataResult = virtualRowData(); - if ( - !equals( - {...omit(OMIT_PROP_RENDER, nextProps)}, - {...omit(OMIT_PROP_RENDER, this.props)} - ) && - (nextProps?.dashRenderType !== 'internal' || - !equals(nextProps.rowData, this.props.rowData) || - !equals(nextProps.selectedRows, this.props.selectedRows)) - ) { - return true; - } - if ( - !equals( - {...omit(OMIT_STATE_RENDER, nextState)}, - {...omit(OMIT_STATE_RENDER, this.state)} - ) - ) { - return true; - } - if (gridApi && !gridApi?.isDestroyed()) { - if (nextProps?.dashRenderType !== 'internal') { - if (columnState) { - if (columnState !== this.props.columnState) { - return true; - } - } - if (filterModel) { - if (!equals(filterModel, gridApi.getFilterModel())) { - return true; + customSetProps({virtualRowData: virtualRowDataResult}); + } + + // When the rowData is updated, reopen any row groups if they previously existed in the table + // Iterate through all nodes in the grid. Unfortunately there's no way to iterate through only nodes representing groups + if (!isEmpty(openGroups)) { + gridApi.forEachNode((node) => { + // Check if it's a group row based on whether it has the __hasChildren prop + if (node.__hasChildren) { + // If the key for the node (i.e. the group name) is the same as an + if (openGroups[node.key]) { + gridApi.setRowNodeExpanded(node, true); + } } - } + }); } - if (selectedRows) { - if (!equals(selectedRows, gridApi.getSelectedRows())) { - return true; - } + if (!isEmpty(filterModel)) { + gridApi.setFilterModel(filterModel); } - return false; + setTimeout(() => { + dataUpdates.current = false; + }, 1); } - return false; - } + }, [ + props.selectedRows, + props.rowData, + props.rowModelType, + props.filterModel, + gridApi, + openGroups, + setSelection, + virtualRowData, + customSetProps, + ]); + + const onRowGroupOpened = useCallback((e) => { + setOpenGroups((prevOpenGroups) => + e.expanded + ? assoc(e.node.key, 1, prevOpenGroups) + : omit([e.node.key], prevOpenGroups) + ); + }, []); - componentDidUpdate(prevProps, prevState) { - const { - selectedRows, - getDetailResponse, - detailCellRendererParams, - masterDetail, - id, - resetColumnState, - csvExportParams, - exportDataAsCsv, - selectAll, - deselectAll, - deleteSelectedRows, - filterModel, - columnState, - columnSize, - paginationGoTo, - scrollTo, - rowTransaction, - updateColumnState, - loading_state, - } = this.props; + const onSelectionChanged = useCallback(() => { + setTimeout(() => { + if (!pauseSelections.current) { + const selectedRows = gridApi.getSelectedRows(); + if (!equals(selectedRows, props.selectedRows)) { + // Flag that the selection event was fired + selectionEventFired.current = true; + customSetProps({selectedRows}); + } + } + }, 1); + }, [gridApi, props.selectedRows, customSetProps]); - if ( - this.state.gridApi && - (!loading_state || prevProps.loading_state?.is_loading) - ) { - if ( - this.props.columnState !== prevProps.columnState && - !this.state.columnState_push - ) { - this.setState({columnState_push: true}); - } - } - - if (id !== prevProps.id) { - if (id) { - agGridRefs[id] = this.reference.current; - eventBus.dispatch(id); - } - if (prevProps.id) { - delete agGridRefs[prevProps.id]; - eventBus.remove(prevProps.id); - } - } + const isDatasourceLoadedForInfiniteScrolling = useCallback(() => { + return ( + props.rowModelType === 'infinite' && + getRowsParams.current && + props.getRowsResponse + ); + }, [props.rowModelType, getRowsParams.current, props.getRowsResponse]); - if (this.state.gridApi && this.state.gridApi !== prevState.gridApi) { - const propsToSet = {}; - this.updateColumnWidths(false); + const getDatasource = useCallback(() => { + return { + getRows(params) { + getRowsParams.current = params; + customSetProps({getRowsRequest: params}); + }, - const groups = {}; - this.state.gridApi.forEachNode((node) => { - if (node.expanded) { - groups[node.key] = 1; + destroy() { + getRowsParams.current = null; + }, + }; + }, [getRowsParams.current, customSetProps]); + + const applyRowTransaction = useCallback( + (data, gridApiParam = gridApi) => { + const {selectedRows} = props; + if (data.async === false) { + gridApiParam.applyTransaction(data); + if (selectedRows) { + setSelection(selectedRows); } - }); - - if (this.state.rowTransaction) { - this.state.rowTransaction.map((data) => - this.applyRowTransaction(data, this.state.gridApi) - ); - this.setState({rowTransaction: null}); - this.syncRowData(); - } - - // Handles applying selections when a selection was persisted by Dash - this.setSelection(selectedRows); - - if (this.reference.current.props.pagination) { - this.onPaginationChanged(); + } else { + gridApiParam.applyTransactionAsync(data); } + }, + [gridApi, props.selectedRows, setSelection] + ); - if (!isEmpty(filterModel)) { - this.state.gridApi.setFilterModel(filterModel); - } + const onGridReady = useCallback( + (params) => { + // Applying Infinite Row Model + // see: https://www.ag-grid.com/javascript-grid/infinite-scrolling/ + const {rowModelType, eventListeners} = props; - if (columnState) { - this.setColumnState(); + if (rowModelType === 'infinite') { + params.api.setGridOption('datasource', getDatasource()); } - if (paginationGoTo || paginationGoTo === 0) { - this.paginationGoTo(false); - propsToSet.paginationGoTo = null; + if (eventListeners) { + Object.entries(eventListeners).map(([key, v]) => { + v.map((func) => { + params.api.addEventListener( + key, + parseFunctionEvent(func) + ); + }); + }); } + setGridApi(params.api); + }, + [ + props.rowModelType, + props.eventListeners, + getDatasource, + parseFunctionEvent, + setGridApi, + ] + ); - if (scrollTo) { - this.scrollTo(false); - propsToSet.scrollTo = null; - } + const onCellClicked = useCallback( + ({value, column: {colId}, rowIndex, node}) => { + const timestamp = Date.now(); + customSetProps({ + cellClicked: { + value, + colId, + rowIndex, + rowId: node.id, + timestamp, + }, + }); + }, + [customSetProps] + ); - if (resetColumnState) { - this.resetColumnState(false); - propsToSet.resetColumnState = false; - } + const onCellDoubleClicked = useCallback( + ({value, column: {colId}, rowIndex, node}) => { + const timestamp = Date.now(); + customSetProps({ + cellDoubleClicked: { + value, + colId, + rowIndex, + rowId: node.id, + timestamp, + }, + }); + }, + [customSetProps] + ); - if (exportDataAsCsv) { - this.exportDataAsCsv(csvExportParams, false); - propsToSet.exportDataAsCsv = false; + const onCellValueChanged = useCallback( + ({oldValue, value, column: {colId}, rowIndex, data, node}) => { + const timestamp = Date.now(); + // Collect new change. + const newChange = { + rowIndex, + rowId: node.id, + data, + oldValue, + value, + colId, + timestamp, + }; + // Append it to current change session. + if (!pendingCellValueChanges.current) { + pendingCellValueChanges.current = [newChange]; + } else { + pendingCellValueChanges.current.push(newChange); } + }, + [pendingCellValueChanges.current] + ); - if (selectAll) { - this.selectAll(selectAll, false); - propsToSet.selectAll = false; - } + const afterCellValueChanged = useCallback(() => { + // Guard against multiple invocations of the same change session. + if (!pendingCellValueChanges.current) { + return; + } + // Send update(s) for current change session to Dash. + const virtualRowDataResult = virtualRowData(); + customSetProps({ + cellValueChanged: pendingCellValueChanges.current, + virtualRowData: virtualRowDataResult, + }); + syncRowData(); + // Mark current change session as ended. + pendingCellValueChanges.current = null; + }, [ + pendingCellValueChanges.current, + virtualRowData, + customSetProps, + syncRowData, + ]); + + const updateColumnState = useCallback(() => { + if (!gridApi) { + return; + } + if (!gridApi.isDestroyed()) { + var columnState = JSON.parse( + JSON.stringify(gridApi.getColumnState()) + ); - if (deselectAll) { - this.deselectAll(false); - propsToSet.deselectAll = false; - } + customSetProps({ + columnState, + updateColumnState: false, + }); + } else { + customSetProps({ + updateColumnState: false, + }); + } + }, [gridApi, customSetProps]); - if (deleteSelectedRows) { - this.deleteSelectedRows(false); - propsToSet.deleteSelectedRows = false; + const updateColumnWidths = useCallback( + (setColumns = true) => { + const {columnSize, columnSizeOptions} = props; + if (gridApi && !gridApi?.isDestroyed()) { + const { + keys, + skipHeader, + defaultMinWidth, + defaultMaxWidth, + columnLimits, + } = columnSizeOptions || {}; + if (columnSize === 'autoSize') { + if (keys) { + gridApi.autoSizeColumns(keys, skipHeader); + } else { + gridApi.autoSizeAllColumns(skipHeader); + } + } else if ( + columnSize === 'sizeToFit' || + columnSize === 'responsiveSizeToFit' + ) { + gridApi.sizeColumnsToFit({ + defaultMinWidth, + defaultMaxWidth, + columnLimits, + }); + } + if (columnSize !== 'responsiveSizeToFit') { + customSetProps({columnSize: null}); + } + if (setColumns) { + updateColumnState(); + } } + }, + [ + props.columnSize, + props.columnSizeOptions, + gridApi, + customSetProps, + updateColumnState, + ] + ); - if (!isEmpty(propsToSet)) { - this.customSetProps(propsToSet); - } - // Hydrate virtualRowData - this.onFilterChanged(true); - this.setState({ - mounted: true, - openGroups: groups, - columnState_push: false, - }); - this.updateColumnState(); + const onDisplayedColumnsChanged = useCallback(() => { + if (props.columnSize === 'responsiveSizeToFit') { + updateColumnWidths(); } + updateColumnState(); + }, [props.columnSize, updateColumnWidths, updateColumnState]); - if (this.isDatasourceLoadedForInfiniteScrolling()) { - const {rowData, rowCount} = this.props.getRowsResponse; - this.getRowsParams.successCallback(rowData, rowCount); - this.customSetProps({getRowsResponse: null}); + const onColumnResized = useCallback(() => { + if (props.columnSize !== 'responsiveSizeToFit') { + updateColumnState(); } + }, [props.columnSize, updateColumnState]); - if ( - masterDetail && - !detailCellRendererParams.suppressCallback && - getDetailResponse - ) { - this.getDetailParams.successCallback(getDetailResponse); - this.customSetProps({getDetailResponse: null}); + const onGridSizeChanged = useCallback(() => { + if (props.columnSize === 'responsiveSizeToFit') { + updateColumnWidths(); } - // Call the API to select rows unless the update was triggered by a selection made in the UI - if ( - !equals(selectedRows, prevProps.selectedRows) && - // eslint-disable-next-line no-undefined - !(typeof loading_state !== 'undefined' - ? loading_state && this.selectionEventFired - : this.selectionEventFired) - ) { - if (!this.dataUpdates) { - setTimeout(() => { - if (!this.dataUpdates) { - this.setSelection(selectedRows); - } - }, 10); - } + }, [props.columnSize, updateColumnWidths]); + + const setColumnState = useCallback(() => { + if (!gridApi || props.updateColumnState) { + return; } - this.dataUpdates = false; + if (columnState_push) { + gridApi.applyColumnState({ + state: props.columnState, + applyOrder: true, + }); + setColumnState_push(false); + } + }, [gridApi, props.updateColumnState, columnState_push]); - if (this.state.gridApi && this.state.gridApi === prevState.gridApi) { - if (filterModel) { - if (this.state.gridApi) { - if (this.state.gridApi.getFilterModel() !== filterModel) { - this.state.gridApi.setFilterModel(filterModel); - } - } + const exportDataAsCsv = useCallback( + (csvExportParams, reset = true) => { + if (!gridApi) { + return; } - - if (paginationGoTo || paginationGoTo === 0) { - this.paginationGoTo(); + gridApi.exportDataAsCsv(convertAllProps(csvExportParams)); + if (reset) { + customSetProps({ + exportDataAsCsv: false, + }); } + }, + [gridApi, convertAllProps, customSetProps] + ); - if (scrollTo) { - this.scrollTo(); + const paginationGoTo = useCallback( + (reset = true) => { + if (!gridApi) { + return; + } + switch (props.paginationGoTo) { + case 'next': + gridApi.paginationGoToNextPage(); + break; + case 'previous': + gridApi.paginationGoToPreviousPage(); + break; + case 'last': + gridApi.paginationGoToLastPage(); + break; + case 'first': + gridApi.paginationGoToFirstPage(); + break; + default: + gridApi.paginationGoToPage(props.paginationGoTo); + } + if (reset) { + customSetProps({ + paginationGoTo: null, + }); } + }, + [gridApi, props.paginationGoTo, customSetProps] + ); - if (columnSize) { - this.updateColumnWidths(); + const scrollTo = useCallback( + (reset = true) => { + const {scrollTo, getRowId} = props; + if (!gridApi) { + return; + } + const rowPosition = scrollTo.rowPosition + ? scrollTo.rowPosition + : 'top'; + if (scrollTo.rowIndex || scrollTo.rowIndex === 0) { + gridApi.ensureIndexVisible(scrollTo.rowIndex, rowPosition); + } else if (typeof scrollTo.rowId !== 'undefined') { + const node = gridApi.getRowNode(scrollTo.rowId); + gridApi.ensureNodeVisible(node, rowPosition); + } else if (scrollTo.data) { + if (getRowId) { + const parsedCondition = esprima.parse( + getRowId.replaceAll('params.data.', '') + ).body[0].expression; + const node = gridApi.getRowNode( + evaluate(parsedCondition, scrollTo.data) + ); + gridApi.ensureNodeVisible(node, rowPosition); + } else { + let scrolled = false; + gridApi.forEachNodeAfterFilterAndSort((node) => { + if (!scrolled && equals(node.data, scrollTo.data)) { + gridApi.ensureNodeVisible(node, rowPosition); + scrolled = true; + } + }); + } } - - if (resetColumnState) { - this.resetColumnState(); + if (scrollTo.column) { + const columnPosition = scrollTo.columnPosition + ? scrollTo.columnPosition + : 'auto'; + gridApi.ensureColumnVisible(scrollTo.column, columnPosition); } - - if (exportDataAsCsv) { - this.exportDataAsCsv(csvExportParams); + if (reset) { + customSetProps({ + scrollTo: null, + }); } + }, + [gridApi, props.scrollTo, props.getRowId, customSetProps] + ); - if (selectAll) { - this.selectAll(selectAll); + const resetColumnState = useCallback( + (reset = true) => { + if (!gridApi) { + return; } - - if (deselectAll) { - this.deselectAll(); + gridApi.resetColumnState(); + if (reset) { + customSetProps({ + resetColumnState: false, + }); + updateColumnState(); } + }, + [gridApi, customSetProps, updateColumnState] + ); - if (deleteSelectedRows) { - this.deleteSelectedRows(); + const selectAll = useCallback( + (opts, reset = true) => { + if (!gridApi) { + return; } - - if (rowTransaction) { - this.rowTransaction(rowTransaction); + if (opts?.filtered) { + gridApi.selectAllFiltered(); + } else { + gridApi.selectAll(); } - if (updateColumnState) { - this.updateColumnState(); - } else if (this.state.columnState_push) { - this.setColumnState(); + if (reset) { + customSetProps({ + selectAll: false, + }); } - } - - // Reset selection event flag - this.selectionEventFired = false; - } - - onRowDataUpdated() { - // Handles preserving existing selections when rowData is updated in a callback - const {selectedRows, rowData, rowModelType, filterModel} = this.props; - const {openGroups, gridApi} = this.state; - - if (gridApi && !gridApi?.isDestroyed()) { - this.dataUpdates = true; - this.pauseSelections = true; - this.setSelection(selectedRows); - - if (rowData && rowModelType === 'clientSide') { - const virtualRowData = this.virtualRowData(); + }, + [gridApi, customSetProps] + ); - this.customSetProps({virtualRowData}); + const deselectAll = useCallback( + (reset = true) => { + if (!gridApi) { + return; + } + gridApi.deselectAll(); + if (reset) { + customSetProps({ + deselectAll: false, + }); } + }, + [gridApi, customSetProps] + ); - // When the rowData is updated, reopen any row groups if they previously existed in the table - // Iterate through all nodes in the grid. Unfortunately there's no way to iterate through only nodes representing groups - if (!isEmpty(openGroups)) { - gridApi.forEachNode((node) => { - // Check if it's a group row based on whether it has the __hasChildren prop - if (node.__hasChildren) { - // If the key for the node (i.e. the group name) is the same as an - if (openGroups[node.key]) { - gridApi.setRowNodeExpanded(node, true); - } - } + const deleteSelectedRows = useCallback( + (reset = true) => { + if (!gridApi) { + return; + } + const sel = gridApi.getSelectedRows(); + gridApi.applyTransaction({remove: sel}); + if (reset) { + customSetProps({ + deleteSelectedRows: false, }); + syncRowData(); } - if (!isEmpty(filterModel)) { - gridApi.setFilterModel(filterModel); + }, + [gridApi, customSetProps, syncRowData] + ); + + const buildArray = useCallback((arr1, arr2) => { + if (arr1) { + if (!arr1.includes(arr2)) { + return [...arr1, arr2]; } - setTimeout(() => { - this.dataUpdates = false; - }, 1); + return arr1; } - } - - onRowGroupOpened(e) { - this.setState(({openGroups}) => ({ - openGroups: e.expanded - ? assoc(e.node.key, 1, openGroups) - : omit([e.node.key], openGroups), - })); - } + return [JSON.parse(JSON.stringify(arr2))]; + }, []); - onSelectionChanged() { - setTimeout(() => { - if (!this.pauseSelections) { - const selectedRows = this.state.gridApi.getSelectedRows(); - if (!equals(selectedRows, this.props.selectedRows)) { - // Flag that the selection event was fired - this.selectionEventFired = true; - this.customSetProps({selectedRows}); + const rowTransaction = useCallback( + (data) => { + const rowTransaction = rowTransactionState; + if (gridApi && !gridApi?.isDestroyed()) { + if (rowTransaction) { + rowTransaction.forEach(applyRowTransaction); + setRowTransactionState(null); } + applyRowTransaction(data); + customSetProps({ + rowTransaction: null, + }); + syncRowData(); + } else { + setRowTransactionState( + rowTransaction + ? buildArray(rowTransaction, data) + : [JSON.parse(JSON.stringify(data))] + ); } - }, 1); - } - - isDatasourceLoadedForInfiniteScrolling() { - return ( - this.props.rowModelType === 'infinite' && - this.getRowsParams && - this.props.getRowsResponse - ); - } + }, + [ + rowTransactionState, + gridApi, + applyRowTransaction, + setRowTransactionState, + customSetProps, + syncRowData, + buildArray, + ] + ); - getDatasource() { - const self = this; + const onAsyncTransactionsFlushed = useCallback(() => { + const {selectedRows} = props; + if (selectedRows) { + setSelection(selectedRows); + } + syncRowData(); + }, [props.selectedRows, setSelection, syncRowData]); - return { - getRows(params) { - self.getRowsParams = params; - self.customSetProps({getRowsRequest: params}); - }, + // Mount and unmount effect + useEffect(() => { + const {id} = props; + if (id) { + agGridRefs[id] = reference.current; + eventBus.dispatch(id); + } - destroy() { - self.getRowsParams = null; - }, + return () => { + setGridApi(null); + active.current = false; + if (props.id) { + delete agGridRefs[props.id]; + eventBus.remove(props.id); + } }; - } + }, []); - applyRowTransaction(data, gridApi = this.state.gridApi) { - const {selectedRows} = this.props; - if (data.async === false) { - gridApi.applyTransaction(data); - if (selectedRows) { - this.setSelection(selectedRows); + useEffect(() => { + // Apply selections + if (gridApi) { + const selectedRows = gridApi.getSelectedRows(); + if (!equals(selectedRows, props.selectedRows)) { + setSelection(props.selectedRows); } - } else { - gridApi.applyTransactionAsync(data); } - } + }, [props.selectedRows, gridApi]); - onGridReady(params) { - // Applying Infinite Row Model - // see: https://www.ag-grid.com/javascript-grid/infinite-scrolling/ - const {rowModelType, eventListeners} = this.props; + // Handle gridApi initialization - basic setup + useEffect(() => { + if (gridApi && gridApi !== prevGridApi) { + updateColumnWidths(false); - if (rowModelType === 'infinite') { - params.api.setGridOption('datasource', this.getDatasource()); + // Handle pagination initialization + if (reference.current.props.pagination) { + onPaginationChanged(); + } } + }, [gridApi, prevGridApi, updateColumnWidths, onPaginationChanged]); - if (eventListeners) { - Object.entries(eventListeners).map(([key, v]) => { - v.map((func) => { - params.api.addEventListener( - key, - this.parseFunctionEvent(func) - ); - }); + // Handle gridApi initialization - expanded groups tracking + useEffect(() => { + if (gridApi && gridApi !== prevGridApi) { + const groups = {}; + gridApi.forEachNode((node) => { + if (node.expanded) { + groups[node.key] = 1; + } }); + setOpenGroups(groups); } + }, [gridApi, prevGridApi, setOpenGroups]); - this.setState({ - gridApi: params.api, - }); - } - - onCellClicked({value, column: {colId}, rowIndex, node}) { - const timestamp = Date.now(); - this.customSetProps({ - cellClicked: {value, colId, rowIndex, rowId: node.id, timestamp}, - }); - } - - onCellDoubleClicked({value, column: {colId}, rowIndex, node}) { - const timestamp = Date.now(); - this.customSetProps({ - cellDoubleClicked: { - value, - colId, - rowIndex, - rowId: node.id, - timestamp, - }, - }); - } - - onCellValueChanged({ - oldValue, - value, - column: {colId}, - rowIndex, - data, - node, - }) { - const timestamp = Date.now(); - // Collect new change. - const newChange = { - rowIndex, - rowId: node.id, - data, - oldValue, - value, - colId, - timestamp, - }; - // Append it to current change session. - if (!this.pendingCellValueChanges) { - this.pendingCellValueChanges = [newChange]; - } else { - this.pendingCellValueChanges.push(newChange); - } - } - - afterCellValueChanged() { - // Guard against multiple invocations of the same change session. - if (!this.pendingCellValueChanges) { - return; - } - // Send update(s) for current change session to Dash. - const virtualRowData = this.virtualRowData(); - this.customSetProps({ - cellValueChanged: this.pendingCellValueChanges, - virtualRowData, - }); - this.syncRowData(); - // Mark current change session as ended. - this.pendingCellValueChanges = null; - } - - onDisplayedColumnsChanged() { - if (this.props.columnSize === 'responsiveSizeToFit') { - this.updateColumnWidths(); - } - if (this.state.mounted) { - this.updateColumnState(); - } - } - - onColumnResized() { + // Handle gridApi initialization - row transactions + useEffect(() => { + if (gridApi && gridApi !== prevGridApi && rowTransactionState) { + rowTransactionState.map((data) => + applyRowTransaction(data, gridApi) + ); + setRowTransactionState(null); + syncRowData(); + } + }, [ + gridApi, + prevGridApi, + rowTransactionState, + applyRowTransaction, + setRowTransactionState, + syncRowData, + ]); + + // Handle gridApi initialization - filter model application + useEffect(() => { + if (gridApi && gridApi !== prevGridApi && !isEmpty(props.filterModel)) { + gridApi.setFilterModel(props.filterModel); + } + }, [gridApi, prevGridApi, props.filterModel]); + + // Handle gridApi initialization - column state application + useEffect(() => { + if (gridApi && gridApi !== prevGridApi && props.columnState) { + setColumnState(); + } + }, [gridApi, prevGridApi, props.columnState, setColumnState]); + + // Handle gridApi initialization - finalization + useEffect(() => { + if (gridApi && gridApi !== prevGridApi) { + // Hydrate virtualRowData and finalize setup + onFilterChanged(true); + updateColumnState(); + setColumnState_push(false); + } + }, [ + gridApi, + prevGridApi, + onFilterChanged, + setColumnState_push, + updateColumnState, + ]); + + // Handle columnState push changes + useEffect(() => { if ( - this.state.mounted && - this.props.columnSize !== 'responsiveSizeToFit' + gridApi && + (!props.loading_state || prevProps?.loading_state?.is_loading) ) { - this.updateColumnState(); - } - } + const existingColumnState = gridApi.getColumnState(); + const realStateChange = + props.columnState && + !equals(props.columnState, existingColumnState); - onGridSizeChanged() { - if (this.props.columnSize === 'responsiveSizeToFit') { - this.updateColumnWidths(); + if (realStateChange && !columnState_push) { + setColumnState_push(true); + } } - } + }, [props.columnState, props.loading_state, columnState_push]); - updateColumnWidths(setColumns = true) { - const {columnSize, columnSizeOptions} = this.props; - const {gridApi} = this.state; - if (gridApi && !gridApi?.isDestroyed()) { - const { - keys, - skipHeader, - defaultMinWidth, - defaultMaxWidth, - columnLimits, - } = columnSizeOptions || {}; - if (columnSize === 'autoSize') { - if (keys) { - gridApi.autoSizeColumns(keys, skipHeader); - } else { - gridApi.autoSizeAllColumns(skipHeader); - } - } else if ( - columnSize === 'sizeToFit' || - columnSize === 'responsiveSizeToFit' - ) { - gridApi.sizeColumnsToFit({ - defaultMinWidth, - defaultMaxWidth, - columnLimits, - }); - } - if (columnSize !== 'responsiveSizeToFit') { - this.customSetProps({columnSize: null}); + // Handle ID changes + useEffect(() => { + if (props.id !== prevProps?.id) { + if (props.id) { + agGridRefs[props.id] = reference.current; + eventBus.dispatch(props.id); } - if (setColumns) { - this.updateColumnState(); + if (prevProps?.id) { + delete agGridRefs[prevProps.id]; + eventBus.remove(prevProps.id); } } - } + }, [props.id]); - parseFunction = memoizeWith(String, (funcString) => { - const parsedCondition = esprima.parse(funcString).body[0].expression; - const context = { - d3, - dash_clientside, - ...customFunctions, - ...window.dashAgGridFunctions, - }; - return (params) => evaluate(parsedCondition, {params, ...context}); - }); - - parseFunctionEvent = memoizeWith(String, (funcString) => { - const parsedCondition = esprima.parse(funcString).body[0].expression; - const context = { - d3, - dash_clientside, - ...customFunctions, - ...window.dashAgGridFunctions, - setGridProps: this.customSetProps, - setEventData: this.setEventData, - }; - return (params) => evaluate(parsedCondition, {params, ...context}); - }); - - parseFunctionNoParams = memoizeWith(String, (funcString) => { - const parsedCondition = esprima.parse(funcString).body[0].expression; - const context = { - d3, - ...customFunctions, - ...window.dashAgGridFunctions, - }; - return evaluate(parsedCondition, context); - }); - - /** - * @params AG-Grid Styles rules attribute. - * Cells: https://www.ag-grid.com/react-grid/cell-styles/#cell-style-cell-class--cell-class-rules-params - * Rows: https://www.ag-grid.com/react-grid/row-styles/#row-style-row-class--row-class-rules-params - */ - handleDynamicStyle(cellStyle) { - const {styleConditions, defaultStyle} = cellStyle; - const _defaultStyle = defaultStyle || null; - - if (styleConditions && styleConditions.length) { - const tests = styleConditions.map(({condition, style}) => ({ - test: this.parseFunction(condition), - style, - })); - return (params) => { - for (const {test, style} of tests) { - if (params) { - if (params.node.id && params.node.id !== null) { - if (test(params)) { - return style; - } - } - } - } - return _defaultStyle; - }; + // Handle infinite scrolling datasource + useEffect(() => { + if (isDatasourceLoadedForInfiniteScrolling()) { + const {rowData, rowCount} = props.getRowsResponse; + getRowsParams.current.successCallback(rowData, rowCount); + customSetProps({getRowsResponse: null}); } + }, [props.getRowsResponse]); - return _defaultStyle; - } - - generateRenderer(Renderer) { - const {dangerously_allow_code} = this.props; - - return (props) => ( - { - this.customSetProps({ - cellRendererData: { - value, - colId: props.column.colId, - rowIndex: props.node.sourceRowIndex, - rowId: props.node.id, - timestamp: Date.now(), - }, - }); - }} - dangerously_allow_code={dangerously_allow_code} - {...props} - > - ); - } - - setColumnState() { - if (!this.state.gridApi || this.props.updateColumnState) { - return; - } - - if (this.state.columnState_push) { - this.state.gridApi.applyColumnState({ - state: this.props.columnState, - applyOrder: true, - }); - this.setState({columnState_push: false}); - } - } + // Handle master detail response + useEffect(() => { + if ( + props.masterDetail && + !props.detailCellRendererParams.suppressCallback && + props.getDetailResponse + ) { + getDetailParams.current.successCallback(props.getDetailResponse); + customSetProps({getDetailResponse: null}); + } + }, [ + props.getDetailResponse, + props.masterDetail, + props.detailCellRendererParams, + ]); + + // Handle dataUpdates reset + useEffect(() => { + dataUpdates.current = false; + }); - // Event actions that reset - exportDataAsCsv(csvExportParams, reset = true) { - if (!this.state.gridApi) { - return; - } - this.state.gridApi.exportDataAsCsv( - this.convertAllProps(csvExportParams) - ); - if (reset) { - this.customSetProps({ - exportDataAsCsv: false, - }); + // Handle filter model updates + useEffect(() => { + if ( + gridApi && + gridApi === prevGridApi && + props.filterModel && + gridApi.getFilterModel() !== props.filterModel + ) { + gridApi.setFilterModel(props.filterModel); } - } + }, [props.filterModel, gridApi, prevGridApi]); - paginationGoTo(reset = true) { - const {gridApi} = this.state; - if (!gridApi) { - return; - } - switch (this.props.paginationGoTo) { - case 'next': - gridApi.paginationGoToNextPage(); - break; - case 'previous': - gridApi.paginationGoToPreviousPage(); - break; - case 'last': - gridApi.paginationGoToLastPage(); - break; - case 'first': - gridApi.paginationGoToFirstPage(); - break; - default: - gridApi.paginationGoToPage(this.props.paginationGoTo); - } - if (reset) { - this.customSetProps({ - paginationGoTo: null, - }); - } - } + // Handle pagination actions + useEffect(() => { + if ( + gridApi && + gridApi === prevGridApi && + (props.paginationGoTo || props.paginationGoTo === 0) + ) { + paginationGoTo(); + } + }, [props.paginationGoTo, gridApi, prevGridApi, paginationGoTo]); + + // Handle scroll actions + useEffect(() => { + if (gridApi && props.scrollTo) { + scrollTo(); + } + }, [props.scrollTo, gridApi, prevGridApi, scrollTo]); + + // Handle column size updates + useEffect(() => { + if (gridApi && props.columnSize) { + updateColumnWidths(); + } + }, [props.columnSize, gridApi, prevGridApi, updateColumnWidths]); + + // Handle column state reset + useEffect(() => { + if (gridApi && props.resetColumnState) { + resetColumnState(); + } + }, [props.resetColumnState, gridApi, prevGridApi, resetColumnState]); + + // Handle CSV export + useEffect(() => { + if (gridApi && props.exportDataAsCsv) { + exportDataAsCsv(props.csvExportParams); + } + }, [ + props.exportDataAsCsv, + props.csvExportParams, + gridApi, + prevGridApi, + exportDataAsCsv, + ]); + + // Handle row selection actions + useEffect(() => { + if (gridApi) { + if (props.selectAll) { + selectAll(props.selectAll); + } + if (props.deselectAll) { + deselectAll(); + } + if (props.deleteSelectedRows) { + deleteSelectedRows(); + } + } + }, [ + props.selectAll, + props.deselectAll, + props.deleteSelectedRows, + gridApi, + prevGridApi, + selectAll, + deselectAll, + deleteSelectedRows, + ]); + + // Handle row transactions + useEffect(() => { + if (gridApi && props.rowTransaction) { + rowTransaction(props.rowTransaction); + } + }, [props.rowTransaction, gridApi, prevGridApi, rowTransaction]); + + // Handle column state updates + useEffect(() => { + if (gridApi) { + if (props.updateColumnState) { + updateColumnState(); + } else if (columnState_push) { + setColumnState(); + } + } + }, [ + props.updateColumnState, + columnState_push, + gridApi, + prevGridApi, + updateColumnState, + setColumnState, + ]); + + const {id, style, className, dashGridOptions, ...restProps} = props; + const passingProps = pick(PASSTHRU_PROPS, restProps); + const convertedProps = convertAllProps( + omit(NO_CONVERT_PROPS, {...dashGridOptions, ...restProps}) + ); - scrollTo(reset = true) { - const {gridApi} = this.state; - const {scrollTo, getRowId} = this.props; - if (!gridApi) { - return; - } - const rowPosition = scrollTo.rowPosition ? scrollTo.rowPosition : 'top'; - if (scrollTo.rowIndex || scrollTo.rowIndex === 0) { - gridApi.ensureIndexVisible(scrollTo.rowIndex, rowPosition); - } else if (typeof scrollTo.rowId !== 'undefined') { - const node = gridApi.getRowNode(scrollTo.rowId); - gridApi.ensureNodeVisible(node, rowPosition); - } else if (scrollTo.data) { - if (getRowId) { - const parsedCondition = esprima.parse( - getRowId.replaceAll('params.data.', '') - ).body[0].expression; - const node = gridApi.getRowNode( - evaluate(parsedCondition, scrollTo.data) - ); - gridApi.ensureNodeVisible(node, rowPosition); - } else { - let scrolled = false; - gridApi.forEachNodeAfterFilterAndSort((node) => { - if (!scrolled && equals(node.data, scrollTo.data)) { - gridApi.ensureNodeVisible(node, rowPosition); - scrolled = true; - } + let alignedGrids; + if (dashGridOptions) { + if ('alignedGrids' in dashGridOptions) { + alignedGrids = []; + const addGrid = (id) => { + const strId = stringifyId(id); + eventBus.on(props.id, strId, () => { + forceRerender({}); }); - } - } - if (scrollTo.column) { - const columnPosition = scrollTo.columnPosition - ? scrollTo.columnPosition - : 'auto'; - gridApi.ensureColumnVisible(scrollTo.column, columnPosition); - } - if (reset) { - this.customSetProps({ - scrollTo: null, - }); - } - } - - resetColumnState(reset = true) { - if (!this.state.gridApi) { - return; - } - this.state.gridApi.resetColumnState(); - if (reset) { - this.customSetProps({ - resetColumnState: false, - }); - this.updateColumnState(); - } - } - - selectAll(opts, reset = true) { - if (!this.state.gridApi) { - return; - } - if (opts?.filtered) { - this.state.gridApi.selectAllFiltered(); - } else { - this.state.gridApi.selectAll(); - } - if (reset) { - this.customSetProps({ - selectAll: false, - }); - } - } - - deselectAll(reset = true) { - if (!this.state.gridApi) { - return; - } - this.state.gridApi.deselectAll(); - if (reset) { - this.customSetProps({ - deselectAll: false, - }); - } - } - - deleteSelectedRows(reset = true) { - if (!this.state.gridApi) { - return; - } - const sel = this.state.gridApi.getSelectedRows(); - this.state.gridApi.applyTransaction({remove: sel}); - if (reset) { - this.customSetProps({ - deleteSelectedRows: false, - }); - this.syncRowData(); - } - } - - // end event actions - - updateColumnState() { - if (!this.state.gridApi || !this.state.mounted) { - return; - } - if (!this.state.gridApi.isDestroyed()) { - var columnState = JSON.parse( - JSON.stringify(this.state.gridApi.getColumnState()) - ); - - this.customSetProps({ - columnState, - updateColumnState: false, - }); - } else { - this.customSetProps({ - updateColumnState: false, - }); - } - } - - buildArray(arr1, arr2) { - if (arr1) { - if (!arr1.includes(arr2)) { - return [...arr1, arr2]; - } - return arr1; - } - return [JSON.parse(JSON.stringify(arr2))]; - } - - rowTransaction(data) { - const {rowTransaction, gridApi, mounted} = this.state; - if (mounted) { - if (gridApi && !gridApi?.isDestroyed()) { - if (rowTransaction) { - rowTransaction.forEach(this.applyRowTransaction); - this.setState({rowTransaction: null}); + if (!agGridRefs[strId]) { + agGridRefs[strId] = {api: null}; } - this.applyRowTransaction(data); - this.customSetProps({ - rowTransaction: null, - }); - this.syncRowData(); + alignedGrids.push(agGridRefs[strId]); + }; + eventBus.remove(props.id); + if (Array.isArray(dashGridOptions.alignedGrids)) { + dashGridOptions.alignedGrids.map(addGrid); } else { - this.setState({ - rowTransaction: rowTransaction - ? this.buildArray(rowTransaction, data) - : [JSON.parse(JSON.stringify(data))], - }); + addGrid(dashGridOptions.alignedGrids); } } } - onAsyncTransactionsFlushed() { - const {selectedRows} = this.props; - if (selectedRows) { - this.setSelection(selectedRows); - } - this.syncRowData(); - } - - render() { - const {id, style, className, dashGridOptions, ...restProps} = - this.props; - - const passingProps = pick(PASSTHRU_PROPS, restProps); - - const convertedProps = this.convertAllProps( - omit(NO_CONVERT_PROPS, {...dashGridOptions, ...restProps}) - ); - - let alignedGrids; - if (dashGridOptions) { - if ('alignedGrids' in dashGridOptions) { - alignedGrids = []; - const addGrid = (id) => { - const strId = stringifyId(id); - eventBus.on(this.props.id, strId, () => { - this.setState(({rerender}) => ({ - rerender: rerender + 1, - })); - }); - if (!agGridRefs[strId]) { - agGridRefs[strId] = {api: null}; - } - alignedGrids.push(agGridRefs[strId]); - }; - eventBus.remove(this.props.id); - if (Array.isArray(dashGridOptions.alignedGrids)) { - dashGridOptions.alignedGrids.map(addGrid); - } else { - addGrid(dashGridOptions.alignedGrids); - } - } - } - - return ( -
- -
- ); - } + return ( +
+ +
+ ); } DashAgGrid.defaultProps = _defaultProps; @@ -1505,3 +1610,28 @@ export const defaultProps = DashAgGrid.defaultProps; var dagfuncs = (window.dash_ag_grid = window.dash_ag_grid || {}); dagfuncs.useGridFilter = useGridFilter; + +const MemoizedAgGrid = React.memo(DashAgGrid, (prevProps, nextProps) => { + // Check if props are equal (excluding render-specific props) + const relevantNextProps = {...omit(OMIT_PROP_RENDER, nextProps)}; + const relevantPrevProps = {...omit(OMIT_PROP_RENDER, prevProps)}; + + const isInternalChange = nextProps?.dashRenderType === 'internal'; + const propsHaveChanged = !equals(relevantNextProps, relevantPrevProps); + const rowDataChanged = !equals(nextProps.rowData, prevProps.rowData); + const selectedRowsChanged = !equals( + nextProps.selectedRows, + prevProps.selectedRows + ); + + if ( + propsHaveChanged && + (!isInternalChange || rowDataChanged || selectedRowsChanged) + ) { + return false; // Props changed, re-render + } + + return true; +}); + +export default MemoizedAgGrid; diff --git a/src/lib/fragments/AgGridEnterprise.react.js b/src/lib/fragments/AgGridEnterprise.react.js index d1ef1a1f..3df1e599 100644 --- a/src/lib/fragments/AgGridEnterprise.react.js +++ b/src/lib/fragments/AgGridEnterprise.react.js @@ -1,15 +1,13 @@ -import React, {Component} from 'react'; +import React from 'react'; import {LicenseManager} from 'ag-grid-enterprise'; -import DashAgGrid, {propTypes} from './AgGrid.react'; +import MemoizedAgGrid, {propTypes} from './AgGrid.react'; -export default class DashAgGridEnterprise extends Component { - render() { - const {licenseKey} = this.props; - if (licenseKey) { - LicenseManager.setLicenseKey(licenseKey); - } - return ; +export default function DashAgGridEnterprise(props) { + const {licenseKey} = props; + if (licenseKey) { + LicenseManager.setLicenseKey(licenseKey); } + return ; } DashAgGridEnterprise.propTypes = propTypes; diff --git a/src/lib/utils/propCategories.js b/src/lib/utils/propCategories.js index e6995f85..64f6c511 100644 --- a/src/lib/utils/propCategories.js +++ b/src/lib/utils/propCategories.js @@ -317,10 +317,12 @@ export const PASSTHRU_PROPS = ['rowData']; * in the render() method, so they don't need to be listed here */ export const PROPS_NOT_FOR_AG_GRID = [ + 'children', 'setProps', 'loading_state', 'enableEnterpriseModules', 'parentState', + 'persistence', 'persisted_props', 'persistence_type', 'virtualRowData', @@ -335,6 +337,7 @@ export const PROPS_NOT_FOR_AG_GRID = [ 'alignedGrids', 'resetColumnState', 'exportDataAsCsv', + 'selectedRows', 'selectAll', 'deselectAll', 'deleteSelectedRows', @@ -357,7 +360,6 @@ export const OMIT_PROP_RENDER = [ 'virtualRowData', 'columnState', 'filterModel', - 'selectedRows', 'getRowRequest', 'getDetailRequest', 'cellValueChanged', diff --git a/tests/test_column_state.py b/tests/test_column_state.py index c771d6d8..5b573b0b 100644 --- a/tests/test_column_state.py +++ b/tests/test_column_state.py @@ -247,6 +247,9 @@ def loadState(n): ) dash_duo.find_element("#load-column-defs").click() + + time.sleep(0.5) # pausing to emulate separation because user inputs + until( lambda: json.dumps(alt_colState) in dash_duo.find_element("#reset-column-state-grid-pre").text, diff --git a/tests/test_event_listeners.py b/tests/test_event_listeners.py index 74634fc8..70bac7f8 100644 --- a/tests/test_event_listeners.py +++ b/tests/test_event_listeners.py @@ -5,6 +5,7 @@ import json from selenium.webdriver.common.by import By from dash.testing.wait import until +import time df = px.data.medals_wide() @@ -52,7 +53,7 @@ def test_el001_event_listener(dash_duo): # Test left click. grid.get_cell(1, 2).click() - until(lambda: json.loads(dash_duo.find_element('#log').text).get('value') == 15, timeout=3) + until(lambda: json.loads(dash_duo.find_element('#log').text or "{}").get('value') == 15, timeout=3) # Test right click action = utils.ActionChains(dash_duo.driver) diff --git a/tests/test_pagination.py b/tests/test_pagination.py index e6f1e9be..fd3d8bb0 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -4,6 +4,7 @@ import pandas as pd import json from dash.testing.wait import until +import time from . import utils @@ -74,6 +75,7 @@ def updatePage(_): grid = utils.Grid(dash_duo, "grid") + time.sleep(1) # wait for the grid to load until(lambda: "Australia" == grid.get_cell(500, 0).text, timeout=3) oldValue = '{"isLastPageFound": true, "pageSize": 100, "currentPage": 5, "totalPages": 87, "rowCount": 8618}' until(lambda: oldValue == dash_duo.find_element("#grid-info").text, timeout=3) diff --git a/tests/test_scroll_to.py b/tests/test_scroll_to.py index 052207a6..4e8546e5 100644 --- a/tests/test_scroll_to.py +++ b/tests/test_scroll_to.py @@ -122,6 +122,7 @@ def reset_columnState(n, s): def update_scrollTo(n_clicks): return scroll_to_inputs[n_clicks - 1] + dash_duo.driver.set_window_size(800, 600) # Make window small enough to scroll things dash_duo.start_server(app) grid = utils.Grid(dash_duo, "grid") @@ -230,6 +231,7 @@ def reset_columnState(n, s): state[0]['width'] = s[0]['width'] - n + 1 return state + dash_duo.driver.set_window_size(800, 600) # Make window small enough to scroll things dash_duo.start_server(app) grid = utils.Grid(dash_duo, "grid") diff --git a/tests/test_sizing_buttons.py b/tests/test_sizing_buttons.py index 4737c560..94339025 100644 --- a/tests/test_sizing_buttons.py +++ b/tests/test_sizing_buttons.py @@ -448,6 +448,7 @@ def selected(state): assert oldValue == dash_duo.find_element("#columnState").get_attribute( "innerText" ) + time.sleep(.2) # allow window size to change dash_duo.find_element(f"#{x}").click() until( lambda: oldValue @@ -457,4 +458,4 @@ def selected(state): oldValue = dash_duo.find_element("#columnState").text dash_duo.driver.set_window_size(1000, 1000) - time.sleep(.2) + time.sleep(.2) # allow oldValue to change to the bigger size