From f705e05580c76b8ec6a9ad774a9ad4dd431be23e Mon Sep 17 00:00:00 2001 From: Sanchit Malhotra Date: Mon, 22 Jun 2026 17:04:57 +0000 Subject: [PATCH 1/2] ailab: convert redux connect() to hooks; fix selector stability Replace connect() with typed react-redux hooks (useAppSelector / useAppDispatch) across App and all UIComponents, adding src/hooks.ts as the single typed-hook source. Fix the useSelector "returned a different result when called with the same parameters" warnings for getPanelButtons and getDatasetDetails. Both read the I18n global, so a state-keyed reselect memo would go stale across a locale change; instead they stay plain functions and the warning is silenced by deduping at the call site with an equality fn (shallowEqual for the flat getDatasetDetails result, a prev/next-aware fn for getPanelButtons). Stop selecting getTrainedModelDataToSave reactively (heavy, deeply nested, only needed on Save). The save payload is now composed in index.tsx's startSaveTrainedModel from store.getState(), where the store already lives, so App passes the callback through untouched. Flagged for folding into a thunk during the RTK migration. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/packages/labs/ailab/src/App.tsx | 71 +++++----------- .../src/UIComponents/AddFeatureButton.tsx | 19 ++--- .../UIComponents/ColumnDataTypeDropdown.tsx | 15 ++-- .../UIComponents/ColumnDetailsCategorical.tsx | 25 ++---- .../UIComponents/ColumnDetailsNumerical.tsx | 20 +---- .../src/UIComponents/ColumnInspector.tsx | 31 ++----- .../labs/ailab/src/UIComponents/CrossTab.tsx | 18 ++-- .../labs/ailab/src/UIComponents/DataCard.tsx | 39 +++------ .../ailab/src/UIComponents/DataDisplay.tsx | 15 +--- .../labs/ailab/src/UIComponents/DataTable.tsx | 58 ++++--------- .../src/UIComponents/GenerateResults.tsx | 23 ++--- .../labs/ailab/src/UIComponents/ModelCard.tsx | 48 +++-------- .../labs/ailab/src/UIComponents/Predict.tsx | 64 ++++---------- .../labs/ailab/src/UIComponents/Results.tsx | 46 +++------- .../ailab/src/UIComponents/ResultsDetails.tsx | 43 +++------- .../ailab/src/UIComponents/ResultsTable.tsx | 51 ++++------- .../ailab/src/UIComponents/ResultsToggle.tsx | 26 ++---- .../labs/ailab/src/UIComponents/SaveModel.tsx | 50 +++-------- .../ailab/src/UIComponents/ScatterPlot.tsx | 20 +---- .../ailab/src/UIComponents/SelectDataset.tsx | 84 ++++++------------- .../src/UIComponents/SelectLabelButton.tsx | 19 ++--- .../labs/ailab/src/UIComponents/Statement.tsx | 47 ++++++----- .../ailab/src/UIComponents/TrainModel.tsx | 20 ++--- .../src/UIComponents/UniqueOptionsWarning.tsx | 14 +--- .../labs/ailab/src/helpers/datasetDetails.ts | 4 + frontend/packages/labs/ailab/src/hooks.ts | 17 ++++ frontend/packages/labs/ailab/src/index.tsx | 12 ++- frontend/packages/labs/ailab/src/redux.ts | 3 + 28 files changed, 288 insertions(+), 614 deletions(-) create mode 100644 frontend/packages/labs/ailab/src/hooks.ts diff --git a/frontend/packages/labs/ailab/src/App.tsx b/frontend/packages/labs/ailab/src/App.tsx index 60002b92ae9c2..48a462544b306 100644 --- a/frontend/packages/labs/ailab/src/App.tsx +++ b/frontend/packages/labs/ailab/src/App.tsx @@ -1,22 +1,16 @@ import {faSpinner} from '@fortawesome/free-solid-svg-icons'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; import type React from 'react'; -import {connect} from 'react-redux'; -import type {Dispatch} from 'redux'; import {styles} from './constants'; import { isSaveComplete, shouldDisplaySaveStatus, } from './helpers/navigationValidation'; +import {shallowEqual, useAppDispatch, useAppSelector} from './hooks'; import I18n from './i18n'; -import type {RootState} from './redux'; -import { - getPanelButtons, - setCurrentPanel, - getTrainedModelDataToSave, -} from './redux'; -import type {PrevNextButtons, ModelDataToSave, SaveResponseData} from './types'; +import {getPanelButtons, setCurrentPanel} from './redux'; +import type {PrevNextButtons, SaveResponseData} from './types'; import ColumnInspector from './UIComponents/ColumnInspector'; import DataCard from './UIComponents/DataCard'; import DataDisplay from './UIComponents/DataDisplay'; @@ -33,8 +27,7 @@ interface PanelButtonsProps { currentPanel: string; setCurrentPanel: (panel: string) => void; onContinue: () => void; - startSaveTrainedModel: (dataToSave: ModelDataToSave) => void; - dataToSave: ModelDataToSave; + startSaveTrainedModel: () => void; saveStatus: string; saveResponseData: SaveResponseData | undefined; isSaveComplete: (saveStatus: string) => boolean; @@ -47,7 +40,6 @@ const PanelButtons = ({ setCurrentPanel, onContinue, startSaveTrainedModel, - dataToSave, saveStatus, saveResponseData, isSaveComplete: isSaveCompleteProp, @@ -64,7 +56,7 @@ const PanelButtons = ({ if (['continue', 'finish'].includes(panelButtons.next.panel)) { onContinue(); } else if (currentPanel === 'saveModel') { - startSaveTrainedModel(dataToSave); + startSaveTrainedModel(); } else { setCurrentPanel(panelButtons.next.panel); } @@ -198,28 +190,24 @@ const ContainerFullWidth = ({children}: ContainerFullWidthProps) => { }; interface AppProps { - panelButtons: PrevNextButtons; - currentPanel: string; - setCurrentPanel: (panel: string) => void; onContinue: () => void; - resultsPhase: number | undefined; - startSaveTrainedModel: (dataToSave: ModelDataToSave) => void; - dataToSave: ModelDataToSave; - saveStatus: string; - saveResponseData: SaveResponseData | undefined; + startSaveTrainedModel: () => void; } -const App = ({ - panelButtons, - currentPanel, - setCurrentPanel, - onContinue, - resultsPhase, - startSaveTrainedModel, - dataToSave, - saveStatus, - saveResponseData, -}: AppProps) => { +// getPanelButtons builds a fresh {prev, next} each call (and reads I18n for +// button text), so compare its two sub-objects to avoid rerendering on a new +// reference with unchanged contents. +const panelButtonsEqual = (a: PrevNextButtons, b: PrevNextButtons) => + shallowEqual(a.prev, b.prev) && shallowEqual(a.next, b.next); + +const App = ({onContinue, startSaveTrainedModel}: AppProps) => { + const dispatch = useAppDispatch(); + const panelButtons = useAppSelector(getPanelButtons, panelButtonsEqual); + const currentPanel = useAppSelector(state => state.currentPanel); + const resultsPhase = useAppSelector(state => state.resultsPhase); + const saveStatus = useAppSelector(state => state.saveStatus); + const saveResponseData = useAppSelector(state => state.saveResponseData); + return (
{currentPanel === 'selectDataset' && ( @@ -293,10 +281,9 @@ const App = ({ dispatch(setCurrentPanel(panel))} onContinue={onContinue} startSaveTrainedModel={startSaveTrainedModel} - dataToSave={dataToSave} saveStatus={saveStatus} saveResponseData={saveResponseData} isSaveComplete={isSaveComplete} @@ -306,18 +293,4 @@ const App = ({ ); }; -export default connect( - (state: RootState) => ({ - panelButtons: getPanelButtons(state), - currentPanel: state.currentPanel, - resultsPhase: state.resultsPhase, - dataToSave: getTrainedModelDataToSave(state), - saveStatus: state.saveStatus, - saveResponseData: state.saveResponseData, - }), - (dispatch: Dispatch) => ({ - setCurrentPanel(panel: string) { - dispatch(setCurrentPanel(panel)); - }, - }), -)(App); +export default App; diff --git a/frontend/packages/labs/ailab/src/UIComponents/AddFeatureButton.tsx b/frontend/packages/labs/ailab/src/UIComponents/AddFeatureButton.tsx index f5f662a17d561..94a55283d56e0 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/AddFeatureButton.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/AddFeatureButton.tsx @@ -1,21 +1,18 @@ /* React component to handle selecting columns as features. */ -import {connect} from 'react-redux'; - import {styles} from '../constants'; +import {useAppDispatch} from '../hooks'; import I18n from '../i18n'; import {addSelectedFeature} from '../redux'; interface AddFeatureButtonProps { column?: string; - addSelectedFeature: (column: string) => void; } -const AddFeatureButton = ({ - column, - addSelectedFeature, -}: AddFeatureButtonProps) => { +const AddFeatureButton = ({column}: AddFeatureButtonProps) => { + const dispatch = useAppDispatch(); + const addFeature = (event: React.MouseEvent, column: string) => { - addSelectedFeature(column); + dispatch(addSelectedFeature(column)); event.preventDefault(); }; @@ -31,8 +28,4 @@ const AddFeatureButton = ({ ); }; -export default connect(null, dispatch => ({ - addSelectedFeature(column: string) { - dispatch(addSelectedFeature(column)); - }, -}))(AddFeatureButton); +export default AddFeatureButton; diff --git a/frontend/packages/labs/ailab/src/UIComponents/ColumnDataTypeDropdown.tsx b/frontend/packages/labs/ailab/src/UIComponents/ColumnDataTypeDropdown.tsx index 0bd393414eab2..51f28d224d972 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/ColumnDataTypeDropdown.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/ColumnDataTypeDropdown.tsx @@ -1,27 +1,26 @@ /* React component to handle setting datatype for selected columns. */ -import {connect} from 'react-redux'; - import {ColumnTypes} from '../constants'; +import {useAppDispatch} from '../hooks'; import I18n from '../i18n'; import {setColumnsByDataType} from '../redux'; interface ColumnDataTypeDropdownProps { columnId?: string; currentDataType?: string; - setColumnsByDataType: (column: string, dataType: string) => void; } const ColumnDataTypeDropdown = ({ columnId, currentDataType, - setColumnsByDataType, }: ColumnDataTypeDropdownProps) => { + const dispatch = useAppDispatch(); + const handleChangeDataType = ( event: React.ChangeEvent, feature: string, ) => { event.preventDefault(); - setColumnsByDataType(feature, event.target.value); + dispatch(setColumnsByDataType(feature, event.target.value)); }; return ( @@ -42,8 +41,4 @@ const ColumnDataTypeDropdown = ({ ); }; -export default connect(null, dispatch => ({ - setColumnsByDataType(column: string, dataType: string) { - dispatch(setColumnsByDataType(column, dataType)); - }, -}))(ColumnDataTypeDropdown); +export default ColumnDataTypeDropdown; diff --git a/frontend/packages/labs/ailab/src/UIComponents/ColumnDetailsCategorical.tsx b/frontend/packages/labs/ailab/src/UIComponents/ColumnDetailsCategorical.tsx index 58ddfd8412f5c..f60d74515536e 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/ColumnDetailsCategorical.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/ColumnDetailsCategorical.tsx @@ -1,18 +1,11 @@ /* React component to handle showing details of categorical columns. */ import {Bar} from 'react-chartjs-2'; -import {connect} from 'react-redux'; import {colors, styles} from '../constants'; import {getLocalizedValue} from '../helpers/valueDetails'; +import {useAppSelector} from '../hooks'; import I18n from '../i18n'; -import type {RootState} from '../redux'; import {getCategoricalColumnDetails} from '../selectors/currentColumnSelectors'; -import type {CategoricalColumnDetails} from '../types'; - -interface ColumnDetailsCategoricalProps { - columnDetails: CategoricalColumnDetails; - datasetId: string; -} const chartOptions = { scales: { @@ -28,10 +21,10 @@ const chartOptions = { maintainAspectRatio: false, }; -const ColumnDetailsCategorical = ({ - columnDetails, - datasetId, -}: ColumnDetailsCategoricalProps) => { +const ColumnDetailsCategorical = () => { + const columnDetails = useAppSelector(getCategoricalColumnDetails); + const datasetId = useAppSelector(state => state.metadata?.name || 'unknown'); + const {id, uniqueOptions, frequencies} = columnDetails; const labels = uniqueOptions && Object.values(uniqueOptions); const localizedLabels = labels.map(option => @@ -74,10 +67,4 @@ const ColumnDetailsCategorical = ({ ); }; -export default connect( - (state: RootState) => ({ - columnDetails: getCategoricalColumnDetails(state), - datasetId: state.metadata?.name || 'unknown', - }), - {}, -)(ColumnDetailsCategorical); +export default ColumnDetailsCategorical; diff --git a/frontend/packages/labs/ailab/src/UIComponents/ColumnDetailsNumerical.tsx b/frontend/packages/labs/ailab/src/UIComponents/ColumnDetailsNumerical.tsx index 48d37547a8233..677cf2b48ab95 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/ColumnDetailsNumerical.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/ColumnDetailsNumerical.tsx @@ -1,19 +1,12 @@ /* React component to handle showing details of numerical columns. */ -import {connect} from 'react-redux'; - import {styles} from '../constants'; +import {useAppSelector} from '../hooks'; import I18n from '../i18n'; -import type {RootState} from '../redux'; import {getNumericalColumnDetails} from '../selectors/currentColumnSelectors'; -import type {NumericalColumnDetails} from '../types'; -interface ColumnDetailsNumericalProps { - columnDetails: NumericalColumnDetails; -} +const ColumnDetailsNumerical = () => { + const columnDetails = useAppSelector(getNumericalColumnDetails); -const ColumnDetailsNumerical = ({ - columnDetails, -}: ColumnDetailsNumericalProps) => { const {extrema, containsOnlyNumbers} = columnDetails; return ( @@ -35,9 +28,4 @@ const ColumnDetailsNumerical = ({ ); }; -export default connect( - (state: RootState) => ({ - columnDetails: getNumericalColumnDetails(state), - }), - {}, -)(ColumnDetailsNumerical); +export default ColumnDetailsNumerical; diff --git a/frontend/packages/labs/ailab/src/UIComponents/ColumnInspector.tsx b/frontend/packages/labs/ailab/src/UIComponents/ColumnInspector.tsx index 9df6b8d2c8b77..96b9fd02f207f 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/ColumnInspector.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/ColumnInspector.tsx @@ -2,14 +2,11 @@ React component to handle displaying details, including data visualizations, for selected columns. */ -import {connect} from 'react-redux'; - import {styles, ColumnTypes} from '../constants'; import {getLocalizedColumnName} from '../helpers/columnDetails'; +import {useAppSelector} from '../hooks'; import I18n from '../i18n'; -import type {RootState} from '../redux'; import {getCurrentColumnDetails} from '../selectors/currentColumnSelectors'; -import type {CurrentColumnInspector} from '../types'; import AddFeatureButton from './AddFeatureButton'; import ColumnDataTypeDropdown from './ColumnDataTypeDropdown'; @@ -21,17 +18,12 @@ import ScrollableContent from './ScrollableContent'; import SelectLabelButton from './SelectLabelButton'; import UniqueOptionsWarning from './UniqueOptionsWarning'; -interface ColumnInspectorProps { - currentColumnDetails: CurrentColumnInspector | undefined; - currentPanel: string; - datasetId: string | undefined; -} - -const ColumnInspector = ({ - currentColumnDetails, - currentPanel, - datasetId, -}: ColumnInspectorProps) => { +const ColumnInspector = () => { + const currentColumnDetails = useAppSelector(getCurrentColumnDetails); + const currentPanel = useAppSelector(state => state.currentPanel); + const datasetId = useAppSelector( + state => state.metadata && state.metadata.name, + ); const selectingFeatures = currentPanel === 'dataDisplayFeatures'; const selectingLabel = currentPanel === 'dataDisplayLabel'; @@ -106,11 +98,4 @@ const ColumnInspector = ({ ); }; -export default connect( - (state: RootState) => ({ - currentColumnDetails: getCurrentColumnDetails(state), - currentPanel: state.currentPanel, - datasetId: state.metadata && state.metadata.name, - }), - {}, -)(ColumnInspector); +export default ColumnInspector; diff --git a/frontend/packages/labs/ailab/src/UIComponents/CrossTab.tsx b/frontend/packages/labs/ailab/src/UIComponents/CrossTab.tsx index 68f4bd61db0c4..f930f1ee33fa4 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/CrossTab.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/CrossTab.tsx @@ -4,23 +4,18 @@ label values, with a heatmap style applied. */ import {useCallback} from 'react'; -import {connect} from 'react-redux'; import {styles} from '../constants'; import {getLocalizedValue} from '../helpers/valueDetails'; +import {useAppSelector} from '../hooks'; import I18n from '../i18n'; -import type {RootState} from '../redux'; import {getCrossTabData} from '../selectors/visualizationSelectors'; -import type {CrossTabData} from '../types'; import ScrollableContent from './ScrollableContent'; -interface CrossTabProps { - crossTabData: CrossTabData | null; - datasetId: string; -} - -const CrossTab = ({crossTabData, datasetId}: CrossTabProps) => { +const CrossTab = () => { + const crossTabData = useAppSelector(getCrossTabData); + const datasetId = useAppSelector(state => state.metadata?.name || 'unknown'); const getCellStyle = useCallback((percent: number) => { return { ...(styles as Record)[ @@ -139,7 +134,4 @@ const CrossTab = ({crossTabData, datasetId}: CrossTabProps) => { ); }; -export default connect((state: RootState) => ({ - crossTabData: getCrossTabData(state), - datasetId: state.metadata?.name || 'unknown', -}))(CrossTab); +export default CrossTab; diff --git a/frontend/packages/labs/ailab/src/UIComponents/DataCard.tsx b/frontend/packages/labs/ailab/src/UIComponents/DataCard.tsx index a6cdd089cfaa8..958c3479ea5ac 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/DataCard.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/DataCard.tsx @@ -1,40 +1,29 @@ /* React component to show information about the currently-selected data set. */ -import {connect} from 'react-redux'; - import {styles} from '../constants'; import {getDatasetDetails} from '../helpers/datasetDetails'; +import {shallowEqual, useAppSelector} from '../hooks'; import I18n from '../i18n'; -import type {RootState} from '../redux'; -import type {Metadata, DatasetDetails} from '../types'; import ScrollableContent from './ScrollableContent'; -interface DataCardProps { - name?: string; - metadata: Metadata; - datasetDetails: DatasetDetails; - dataLength?: number; - removedRowsCount?: number; -} +const DataCard = () => { + const name = useAppSelector(state => state.name); + const metadata = useAppSelector(state => state.metadata); + const datasetDetails = useAppSelector(getDatasetDetails, shallowEqual); + const dataLength = useAppSelector(state => state.data.length); + const removedRowsCount = useAppSelector(state => state.removedRowsCount); -const DataCard = ({ - name, - metadata, - datasetDetails, - dataLength, - removedRowsCount, -}: DataCardProps) => { const card = metadata?.card; const dataLengthLimit = 20000; - if (dataLength! > dataLengthLimit) { + if (dataLength > dataLengthLimit) { window.alert( I18n.t('dataCardWarningLargeDataset', {rowCount: dataLengthLimit}), ); } const removedRowsMsg = - removedRowsCount! > 0 + removedRowsCount > 0 ? I18n.t('dataCardRemovedRows', {rowCount: removedRowsCount}) : null; @@ -94,7 +83,7 @@ const DataCard = ({ )}
)} - {!card && dataLength! > 0 && ( + {!card && dataLength > 0 && (

@@ -117,10 +106,4 @@ const DataCard = ({ ); }; -export default connect((state: RootState) => ({ - name: state.name, - metadata: state.metadata, - datasetDetails: getDatasetDetails(state), - dataLength: state.data.length, - removedRowsCount: state.removedRowsCount, -}))(DataCard); +export default DataCard; diff --git a/frontend/packages/labs/ailab/src/UIComponents/DataDisplay.tsx b/frontend/packages/labs/ailab/src/UIComponents/DataDisplay.tsx index ae65c7ec3dbf1..bea18a560b74b 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/DataDisplay.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/DataDisplay.tsx @@ -1,19 +1,14 @@ /* React component to handle displaying imported data. */ -import {connect} from 'react-redux'; - import {styles} from '../constants'; +import {useAppSelector} from '../hooks'; import I18n from '../i18n'; -import type {RootState} from '../redux'; -import type {DataRow} from '../types'; import DataTable from './DataTable'; import Statement from './Statement'; -interface DataDisplayProps { - data: DataRow[]; -} +const DataDisplay = () => { + const data = useAppSelector(state => state.data); -const DataDisplay = ({data}: DataDisplayProps) => { if (data.length === 0) { return null; } @@ -38,6 +33,4 @@ const DataDisplay = ({data}: DataDisplayProps) => { ); }; -export default connect((state: RootState) => ({ - data: state.data, -}))(DataDisplay); +export default DataDisplay; diff --git a/frontend/packages/labs/ailab/src/UIComponents/DataTable.tsx b/frontend/packages/labs/ailab/src/UIComponents/DataTable.tsx index a69039716f01a..3d55a6aa771ca 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/DataTable.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/DataTable.tsx @@ -1,24 +1,11 @@ /* React component to handle displaying imported data. */ -import {connect} from 'react-redux'; -import type {Dispatch} from 'redux'; - import {styles} from '../constants'; import {getLocalizedColumnName} from '../helpers/columnDetails'; import {getLocalizedValue} from '../helpers/valueDetails'; -import type {RootState} from '../redux'; +import {useAppDispatch, useAppSelector} from '../hooks'; import {getTableData, setCurrentColumn, setHighlightColumn} from '../redux'; -import type {DataRow} from '../types'; interface DataTableProps { - currentPanel: string; - data: DataRow[]; - datasetId: string; - labelColumn: string; - selectedFeatures: string[]; - setCurrentColumn: (column?: string) => void; - setHighlightColumn: (column?: string) => void; - currentColumn?: string; - highlightColumn?: string; reducedColumns?: boolean; singleRow?: number; startingRow?: number; @@ -28,21 +15,26 @@ interface DataTableProps { } const DataTable = ({ - currentPanel, - data, - datasetId, - labelColumn, - selectedFeatures, - setCurrentColumn: setCurrentColumnProp, - setHighlightColumn: setHighlightColumnProp, - currentColumn, - highlightColumn, reducedColumns, singleRow, startingRow, noLabel, hideLabel, + useResultsData, }: DataTableProps) => { + const dispatch = useAppDispatch(); + const data = useAppSelector(state => getTableData(state, !!useResultsData)); + const datasetId = useAppSelector(state => state.metadata?.name || 'unknown'); + const labelColumn = useAppSelector(state => state.labelColumn || 'unknown'); + const selectedFeatures = useAppSelector(state => state.selectedFeatures); + const currentColumn = useAppSelector(state => state.currentColumn); + const highlightColumn = useAppSelector(state => state.highlightColumn); + const currentPanel = useAppSelector(state => state.currentPanel); + + const setCurrentColumnProp = (column: string | undefined) => + dispatch(setCurrentColumn(column as string)); + const setHighlightColumnProp = (column: string | undefined) => + dispatch(setHighlightColumn(column as string)); const getColumnHeaderStyle = (key: string) => { let style; @@ -172,22 +164,4 @@ const DataTable = ({ ); }; -export default connect( - (state: RootState, props: {useResultsData?: boolean}) => ({ - data: getTableData(state, !!props.useResultsData), - datasetId: state.metadata?.name || 'unknown', - labelColumn: state.labelColumn || 'unknown', - selectedFeatures: state.selectedFeatures, - currentColumn: state.currentColumn, - highlightColumn: state.highlightColumn, - currentPanel: state.currentPanel, - }), - (dispatch: Dispatch) => ({ - setCurrentColumn(column: string | undefined) { - dispatch(setCurrentColumn(column as string)); - }, - setHighlightColumn(column: string | undefined) { - dispatch(setHighlightColumn(column as string)); - }, - }), -)(DataTable); +export default DataTable; diff --git a/frontend/packages/labs/ailab/src/UIComponents/GenerateResults.tsx b/frontend/packages/labs/ailab/src/UIComponents/GenerateResults.tsx index ca8eefc12ecf6..b7da0f414ea28 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/GenerateResults.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/GenerateResults.tsx @@ -1,13 +1,11 @@ /* React component to handle training. */ import {useState, useEffect, useRef, useCallback} from 'react'; -import {connect} from 'react-redux'; import {imageUrl} from '../assetPath'; import {styles, getFadeOpacity} from '../constants'; +import {useAppSelector} from '../hooks'; import I18n from '../i18n'; -import type {RootState} from '../redux'; import {getTableData} from '../redux'; -import type {DataRow} from '../types'; import {TestingAnimationDescription} from './AnimationDescriptions'; import DataTable from './DataTable'; @@ -15,15 +13,11 @@ import DataTable from './DataTable'; const framesPerCycle = 80; const maxNumItems = 7; -interface GenerateResultsProps { - data: DataRow[]; - instructionsOverlayActive: boolean; -} - -const GenerateResults = ({ - data, - instructionsOverlayActive, -}: GenerateResultsProps) => { +const GenerateResults = () => { + const data = useAppSelector(state => getTableData(state, true)); + const instructionsOverlayActive = useAppSelector( + state => state.instructionsOverlayActive, + ); const [frame, setFrame] = useState(0); const [, setFinished] = useState(false); const frameRef = useRef(0); @@ -206,7 +200,4 @@ const GenerateResults = ({ ); }; -export default connect((state: RootState) => ({ - data: getTableData(state, true), - instructionsOverlayActive: state.instructionsOverlayActive, -}))(GenerateResults); +export default GenerateResults; diff --git a/frontend/packages/labs/ailab/src/UIComponents/ModelCard.tsx b/frontend/packages/labs/ailab/src/UIComponents/ModelCard.tsx index e7c750d4edfae..0f4576c91d061 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/ModelCard.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/ModelCard.tsx @@ -1,42 +1,26 @@ /* React component to handle displaying the model card. */ -import {connect} from 'react-redux'; - import {imageUrl} from '../assetPath'; import {styles} from '../constants'; import {getPercentCorrect} from '../helpers/accuracy'; import {getLocalizedColumnName} from '../helpers/columnDetails'; import {getDatasetDetails} from '../helpers/datasetDetails'; import {getLocalizedValue} from '../helpers/valueDetails'; +import {shallowEqual, useAppSelector} from '../hooks'; import I18n from '../i18n'; -import type {RootState} from '../redux'; import {getLabelToSave, getFeaturesToSave} from '../redux'; -import type { - ModelCardColumn, - TrainedModelDetailsSave, - DatasetDetails, -} from '../types'; import Statement from './Statement'; -interface ModelCardProps { - trainedModelDetails: TrainedModelDetailsSave; - selectedFeatures: string[]; - percentCorrect: string; - label: ModelCardColumn; - features: ModelCardColumn[]; - datasetDetails: DatasetDetails; - datasetId: string; -} - -const ModelCard = ({ - trainedModelDetails, - selectedFeatures, - percentCorrect, - label, - features, - datasetDetails, - datasetId, -}: ModelCardProps) => { +const ModelCard = () => { + const trainedModelDetails = useAppSelector( + state => state.trainedModelDetails, + ); + const selectedFeatures = useAppSelector(state => state.selectedFeatures); + const percentCorrect = useAppSelector(getPercentCorrect); + const label = useAppSelector(getLabelToSave); + const features = useAppSelector(getFeaturesToSave); + const datasetDetails = useAppSelector(getDatasetDetails, shallowEqual); + const datasetId = useAppSelector(state => state.metadata?.name || 'unknown'); const localizedLabel = getLocalizedColumnName(datasetDetails.name, label.id); const localizedFeatures = selectedFeatures.map(feature => getLocalizedColumnName(datasetDetails.name, feature), @@ -181,12 +165,4 @@ const ModelCard = ({ ); }; -export default connect((state: RootState) => ({ - trainedModelDetails: state.trainedModelDetails, - selectedFeatures: state.selectedFeatures, - percentCorrect: getPercentCorrect(state), - label: getLabelToSave(state), - features: getFeaturesToSave(state), - datasetDetails: getDatasetDetails(state), - datasetId: state.metadata?.name || 'unknown', -}))(ModelCard); +export default ModelCard; diff --git a/frontend/packages/labs/ailab/src/UIComponents/Predict.tsx b/frontend/packages/labs/ailab/src/UIComponents/Predict.tsx index 65ddc142d4f16..ed9937b293a5e 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/Predict.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/Predict.tsx @@ -1,15 +1,13 @@ /* React component to handle predicting and displaying predictions. */ import type React from 'react'; -import {connect} from 'react-redux'; -import type {Dispatch} from 'redux'; import {imageUrl} from '../assetPath'; import {styles} from '../constants'; import {getLocalizedColumnName} from '../helpers/columnDetails'; import {getConvertedPredictedLabel} from '../helpers/valueConversion'; import {getLocalizedValue} from '../helpers/valueDetails'; +import {useAppDispatch, useAppSelector} from '../hooks'; import I18n from '../i18n'; -import type {RootState} from '../redux'; import {setTestData, getPredictAvailable} from '../redux'; import { getSelectedCategoricalFeatures, @@ -22,36 +20,27 @@ import train from '../train'; import ScrollableContent from './ScrollableContent'; -interface PredictProps { - labelColumn: string; - selectedCategoricalFeatures: string[]; - selectedNumericalFeatures: string[]; - uniqueOptionsByColumn: Record; - testData: Record; - setTestData: (feature: string, value: string | number) => void; - predictedLabel: string | number; - getPredictAvailable: boolean; - extremaByColumn: Record; - datasetId: string; -} +const Predict = () => { + const dispatch = useAppDispatch(); + const testData = useAppSelector(state => state.testData); + const predictedLabel = useAppSelector(getConvertedPredictedLabel); + const labelColumn = useAppSelector(state => state.labelColumn || 'unknown'); + const selectedNumericalFeatures = useAppSelector( + getSelectedNumericalFeatures, + ); + const selectedCategoricalFeatures = useAppSelector( + getSelectedCategoricalFeatures, + ); + const uniqueOptionsByColumn = useAppSelector(getUniqueOptionsByColumn); + const predictAvailable = useAppSelector(getPredictAvailable); + const extremaByColumn = useAppSelector(getExtremaByColumn); + const datasetId = useAppSelector(state => state.metadata?.name || 'unknown'); -const Predict = ({ - labelColumn, - selectedCategoricalFeatures, - selectedNumericalFeatures, - uniqueOptionsByColumn, - testData, - setTestData, - predictedLabel, - getPredictAvailable: predictAvailable, - extremaByColumn, - datasetId, -}: PredictProps) => { const handleChange = ( event: React.ChangeEvent, feature: string, ) => { - setTestData(feature, event.target.value); + dispatch(setTestData(feature, event.target.value)); }; const onClickPredict = () => { @@ -145,21 +134,4 @@ const Predict = ({ ); }; -export default connect( - (state: RootState) => ({ - testData: state.testData, - predictedLabel: getConvertedPredictedLabel(state), - labelColumn: state.labelColumn || 'unknown', - selectedNumericalFeatures: getSelectedNumericalFeatures(state), - selectedCategoricalFeatures: getSelectedCategoricalFeatures(state), - uniqueOptionsByColumn: getUniqueOptionsByColumn(state), - getPredictAvailable: getPredictAvailable(state), - extremaByColumn: getExtremaByColumn(state), - datasetId: state.metadata?.name || 'unknown', - }), - (dispatch: Dispatch) => ({ - setTestData(feature: string, value: string | number) { - dispatch(setTestData(feature, value)); - }, - }), -)(Predict); +export default Predict; diff --git a/frontend/packages/labs/ailab/src/UIComponents/Results.tsx b/frontend/packages/labs/ailab/src/UIComponents/Results.tsx index f134d25b95a90..d12aedcb0e65e 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/Results.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/Results.tsx @@ -1,42 +1,31 @@ /* React component to handle displaying accuracy results. */ import {useEffect, useCallback} from 'react'; -import {connect} from 'react-redux'; -import type {Dispatch} from 'redux'; import {styles} from '../constants'; +import {useAppDispatch, useAppSelector} from '../hooks'; import I18n from '../i18n'; -import type {RootState} from '../redux'; import {setShowResultsDetails, setResultsPhase} from '../redux'; -import type {HistoricResult} from '../types'; import ResultsDetails from './ResultsDetails'; import ScrollableContent from './ScrollableContent'; import {UnconnectedStatement} from './Statement'; -interface ResultsProps { - historicResults: HistoricResult[]; - showResultsDetails: boolean; - setShowResultsDetails: (show: boolean) => void; - setResultsPhase: (phase: number) => void; -} +const Results = () => { + const historicResults = useAppSelector(state => state.historicResults); + const showResultsDetails = useAppSelector(state => state.showResultsDetails); + const dispatch = useAppDispatch(); -const Results = ({ - historicResults, - showResultsDetails, - setShowResultsDetails, - setResultsPhase, -}: ResultsProps) => { useEffect(() => { - setResultsPhase(0); + dispatch(setResultsPhase(0)); const timer = setTimeout(() => { - setResultsPhase(1); + dispatch(setResultsPhase(1)); }, 1000); return () => clearTimeout(timer); - }, [setResultsPhase]); + }, [dispatch]); const showDetails = useCallback(() => { - setShowResultsDetails(true); - }, [setShowResultsDetails]); + dispatch(setShowResultsDetails(true)); + }, [dispatch]); return (
@@ -101,17 +90,4 @@ const Results = ({ ); }; -export default connect( - (state: RootState) => ({ - historicResults: state.historicResults, - showResultsDetails: state.showResultsDetails, - }), - (dispatch: Dispatch) => ({ - setResultsPhase(phase: number) { - dispatch(setResultsPhase(phase)); - }, - setShowResultsDetails(show: boolean) { - dispatch(setShowResultsDetails(show)); - }, - }), -)(Results); +export default Results; diff --git a/frontend/packages/labs/ailab/src/UIComponents/ResultsDetails.tsx b/frontend/packages/labs/ailab/src/UIComponents/ResultsDetails.tsx index 834ed1249e82c..9a64c331c6738 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/ResultsDetails.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/ResultsDetails.tsx @@ -2,8 +2,6 @@ import {faTimes} from '@fortawesome/free-solid-svg-icons'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; import {useCallback} from 'react'; -import {connect} from 'react-redux'; -import type {Dispatch} from 'redux'; import {ResultsGrades, styles} from '../constants'; import { @@ -11,31 +9,22 @@ import { getCorrectResults, getIncorrectResults, } from '../helpers/accuracy'; -import type {RootState} from '../redux'; +import {useAppDispatch, useAppSelector} from '../hooks'; import {setShowResultsDetails} from '../redux'; -import type {ResultsData} from '../types'; import ResultsTable from './ResultsTable'; import ResultsToggle from './ResultsToggle'; -interface ResultsDetailsProps { - resultsTab: string; - percentCorrect: string; - setShowResultsDetails: (show: boolean) => void; - correctResults: ResultsData; - incorrectResults: ResultsData; -} +const ResultsDetails = () => { + const dispatch = useAppDispatch(); + const resultsTab = useAppSelector(state => state.resultsTab); + const percentCorrect = useAppSelector(getPercentCorrect); + const correctResults = useAppSelector(getCorrectResults); + const incorrectResults = useAppSelector(getIncorrectResults); -const ResultsDetails = ({ - resultsTab, - percentCorrect, - setShowResultsDetails, - correctResults, - incorrectResults, -}: ResultsDetailsProps) => { const onClose = useCallback(() => { - setShowResultsDetails(false); - }, [setShowResultsDetails]); + dispatch(setShowResultsDetails(false)); + }, [dispatch]); const results = resultsTab === ResultsGrades.CORRECT ? correctResults : incorrectResults; @@ -59,16 +48,4 @@ const ResultsDetails = ({ ); }; -export default connect( - (state: RootState) => ({ - resultsTab: state.resultsTab, - percentCorrect: getPercentCorrect(state), - correctResults: getCorrectResults(state), - incorrectResults: getIncorrectResults(state), - }), - (dispatch: Dispatch) => ({ - setShowResultsDetails(show: boolean) { - dispatch(setShowResultsDetails(show)); - }, - }), -)(ResultsDetails); +export default ResultsDetails; diff --git a/frontend/packages/labs/ailab/src/UIComponents/ResultsTable.tsx b/frontend/packages/labs/ailab/src/UIComponents/ResultsTable.tsx index 7145bef524bc1..e13d9eedbf84b 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/ResultsTable.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/ResultsTable.tsx @@ -1,35 +1,31 @@ /* React component to handle displaying test data and A.I. Bot's guesses. */ import {useCallback} from 'react'; -import {connect} from 'react-redux'; -import type {Dispatch} from 'redux'; import {styles, colors, REGRESSION_ERROR_TOLERANCE} from '../constants'; import {getLocalizedColumnName, isRegression} from '../helpers/columnDetails'; import {getLocalizedValue} from '../helpers/valueDetails'; +import {useAppDispatch, useAppSelector} from '../hooks'; import I18n from '../i18n'; -import type {RootState} from '../redux'; import {setResultsHighlightRow} from '../redux'; import type {ResultsData} from '../types'; interface ResultsTableProps { - selectedFeatures: string[]; - labelColumn: string; results: ResultsData; - isRegression: boolean; - setResultsHighlightRow: (row: number | undefined) => void; - resultsHighlightRow: number | undefined; - datasetId: string; } -const ResultsTable = ({ - selectedFeatures, - labelColumn, - results, - isRegression: isRegressionMode, - setResultsHighlightRow, - resultsHighlightRow, - datasetId, -}: ResultsTableProps) => { +const ResultsTable = ({results}: ResultsTableProps) => { + const dispatch = useAppDispatch(); + const selectedFeatures = useAppSelector(state => state.selectedFeatures); + const labelColumn = useAppSelector(state => state.labelColumn || 'unknown'); + const isRegressionMode = useAppSelector(isRegression); + const resultsHighlightRow = useAppSelector( + state => state.resultsHighlightRow, + ); + const datasetId = useAppSelector(state => state.metadata?.name || 'unknown'); + + const highlightRow = (row: number | undefined) => + dispatch(setResultsHighlightRow(row as number)); + const getRowCellStyle = useCallback( (index: number) => { return { @@ -122,8 +118,8 @@ const ResultsTable = ({ return ( setResultsHighlightRow(index)} - onMouseLeave={() => setResultsHighlightRow(undefined)} + onMouseEnter={() => highlightRow(index)} + onMouseLeave={() => highlightRow(undefined)} > {examples.map((example, i) => { return ( @@ -151,17 +147,4 @@ const ResultsTable = ({ ); }; -export default connect( - (state: RootState) => ({ - selectedFeatures: state.selectedFeatures, - labelColumn: state.labelColumn || 'unknown', - isRegression: isRegression(state), - resultsHighlightRow: state.resultsHighlightRow, - datasetId: state.metadata?.name || 'unknown', - }), - (dispatch: Dispatch) => ({ - setResultsHighlightRow(column: number | undefined) { - dispatch(setResultsHighlightRow(column as number)); - }, - }), -)(ResultsTable); +export default ResultsTable; diff --git a/frontend/packages/labs/ailab/src/UIComponents/ResultsToggle.tsx b/frontend/packages/labs/ailab/src/UIComponents/ResultsToggle.tsx index 65f6f667bd89e..2201e164234e8 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/ResultsToggle.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/ResultsToggle.tsx @@ -1,19 +1,16 @@ /* React component to handle toggling between correct/incorrect test results */ import {faTimes, faCheck} from '@fortawesome/free-solid-svg-icons'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; -import {connect} from 'react-redux'; import {ResultsGrades, styles} from '../constants'; +import {useAppDispatch, useAppSelector} from '../hooks'; import I18n from '../i18n'; -import type {RootState} from '../redux'; import {setResultsTab} from '../redux'; -interface ResultsToggleProps { - resultsTab?: string; - setResultsTab?: (key: string) => void; -} +const ResultsToggle = () => { + const resultsTab = useAppSelector(state => state.resultsTab); + const dispatch = useAppDispatch(); -const ResultsToggle = ({resultsTab, setResultsTab}: ResultsToggleProps) => { const getTogglePillStyle = (key: string) => { let style; if (key === resultsTab) { @@ -46,8 +43,8 @@ const ResultsToggle = ({resultsTab, setResultsTab}: ResultsToggleProps) => {
setResultsTab!(tab.key)} - onKeyDown={() => setResultsTab!(tab.key)} + onClick={() => dispatch(setResultsTab(tab.key))} + onKeyDown={() => dispatch(setResultsTab(tab.key))} role="button" tabIndex={0} > @@ -60,13 +57,4 @@ const ResultsToggle = ({resultsTab, setResultsTab}: ResultsToggleProps) => { ); }; -export default connect( - (state: RootState) => ({ - resultsTab: state.resultsTab, - }), - dispatch => ({ - setResultsTab(key: string) { - dispatch(setResultsTab(key)); - }, - }), -)(ResultsToggle); +export default ResultsToggle; diff --git a/frontend/packages/labs/ailab/src/UIComponents/SaveModel.tsx b/frontend/packages/labs/ailab/src/UIComponents/SaveModel.tsx index 0d6ac3e44f5ed..7983c53885428 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/SaveModel.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/SaveModel.tsx @@ -1,8 +1,6 @@ /* React component to handle saving a trained model. */ import type React from 'react'; import {useState} from 'react'; -import {connect} from 'react-redux'; -import type {Dispatch} from 'redux'; import {styles, ModelNameMaxLength} from '../constants'; import {getLocalizedColumnName} from '../helpers/columnDetails'; @@ -10,35 +8,24 @@ import { getDatasetDescription, isUserUploadedDataset, } from '../helpers/datasetDetails'; +import {useAppDispatch, useAppSelector} from '../hooks'; import I18n from '../i18n'; -import type {RootState} from '../redux'; import {setTrainedModelDetail} from '../redux'; import {getSelectedColumnsDescriptions} from '../selectors'; import ScrollableContent from './ScrollableContent'; import Statement from './Statement'; -interface SaveModelProps { - setTrainedModelDetail: ( - field: string, - value: string, - isColumn: boolean, - ) => void; - labelColumn: string | undefined; - columnDescriptions: {id: string; description: string | null}[]; - dataDescription: string | undefined; - isUserUploadedDataset: boolean; - datasetId: string | undefined; -} +const SaveModel = () => { + const dispatch = useAppDispatch(); + const labelColumn = useAppSelector(state => state.labelColumn); + const columnDescriptions = useAppSelector(getSelectedColumnsDescriptions); + const dataDescription = useAppSelector(getDatasetDescription); + const isUserUploaded = useAppSelector(isUserUploadedDataset); + const datasetId = useAppSelector( + state => state.metadata && state.metadata.name, + ); -const SaveModel = ({ - setTrainedModelDetail, - labelColumn, - columnDescriptions, - dataDescription, - isUserUploadedDataset: isUserUploaded, - datasetId, -}: SaveModelProps) => { const [showColumnDescriptions, setShowColumnDescriptions] = useState(isUserUploaded); @@ -51,7 +38,7 @@ const SaveModel = ({ field: string, isColumn: boolean, ) => { - setTrainedModelDetail(field, event.target.value, isColumn); + dispatch(setTrainedModelDetail(field, event.target.value, isColumn)); }; const getColumnFields = () => { @@ -250,17 +237,4 @@ const SaveModel = ({ ); }; -export default connect( - (state: RootState) => ({ - labelColumn: state.labelColumn, - columnDescriptions: getSelectedColumnsDescriptions(state), - dataDescription: getDatasetDescription(state), - isUserUploadedDataset: isUserUploadedDataset(state), - datasetId: state.metadata && state.metadata.name, - }), - (dispatch: Dispatch) => ({ - setTrainedModelDetail(field: string, value: string, isColumn: boolean) { - dispatch(setTrainedModelDetail(field, value, isColumn)); - }, - }), -)(SaveModel); +export default SaveModel; diff --git a/frontend/packages/labs/ailab/src/UIComponents/ScatterPlot.tsx b/frontend/packages/labs/ailab/src/UIComponents/ScatterPlot.tsx index 4d79605799608..881b7ef505e91 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/ScatterPlot.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/ScatterPlot.tsx @@ -1,9 +1,8 @@ import {Scatter} from 'react-chartjs-2'; -import {connect} from 'react-redux'; import {styles, colors} from '../constants'; +import {useAppSelector} from '../hooks'; import I18n from '../i18n'; -import type {RootState} from '../redux'; import {getScatterPlotData} from '../selectors/visualizationSelectors'; const scatterDataBase = { @@ -55,15 +54,9 @@ const chartOptionsBase = { maintainAspectRatio: false, }; -interface ScatterPlotProps { - scatterPlotData: { - coordinates: {x: number; y: number}[]; - feature: string; - label: string; - } | null; -} +const ScatterPlot = () => { + const scatterPlotData = useAppSelector(getScatterPlotData); -const ScatterPlot = ({scatterPlotData}: ScatterPlotProps) => { const scatterDataCombined = { ...scatterDataBase, }; @@ -93,9 +86,4 @@ const ScatterPlot = ({scatterPlotData}: ScatterPlotProps) => { ); }; -export default connect( - (state: RootState) => ({ - scatterPlotData: getScatterPlotData(state), - }), - {}, -)(ScatterPlot); +export default ScatterPlot; diff --git a/frontend/packages/labs/ailab/src/UIComponents/SelectDataset.tsx b/frontend/packages/labs/ailab/src/UIComponents/SelectDataset.tsx index 37db22db5da7c..2381838740dbb 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/SelectDataset.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/SelectDataset.tsx @@ -1,50 +1,40 @@ /* React component to handle importing CSVs and pushing data to Redux store. */ import {useState} from 'react'; -import {connect} from 'react-redux'; -import type {Dispatch} from 'redux'; import {styles} from '../constants'; import {parseCSV, MIN_CSV_ROWS, MIN_CSV_COLUMNS} from '../csvReaderWrapper'; import {getDatasets, getAvailableDatasets} from '../datasetManifest'; +import {useAppDispatch, useAppSelector} from '../hooks'; import I18n from '../i18n'; import {parseJSON} from '../jsonReaderWrapper'; -import type {RootState} from '../redux'; import { - setSelectedName, - setSelectedCSV, - setSelectedJSON, - resetState, + setSelectedName as setSelectedNameAction, + setSelectedCSV as setSelectedCSVAction, + setSelectedJSON as setSelectedJSONAction, + resetState as resetStateAction, getSpecifiedDatasets, - setHighlightDataset, + setHighlightDataset as setHighlightDatasetAction, } from '../redux'; import ScrollableContent from './ScrollableContent'; -interface SelectDatasetProps { - setSelectedName: (name: string) => void; - setSelectedCSV: (csvfilePath: string | File) => void; - setSelectedJSON: (jsonfilePath: string) => void; - setHighlightDataset: (id: string | undefined) => void; - csvfile: string | object | undefined; - jsonfile: string | object | undefined; - resetState: () => void; - specifiedDatasets: string[] | undefined; - name: string | undefined; - highlightDataset: string | undefined; - invalidData: string | undefined; -} - -const SelectDataset = ({ - setSelectedName, - setSelectedCSV, - setSelectedJSON, - setHighlightDataset, - resetState, - specifiedDatasets, - name, - highlightDataset, - invalidData, -}: SelectDatasetProps) => { +const SelectDataset = () => { + const dispatch = useAppDispatch(); + const specifiedDatasets = useAppSelector(getSpecifiedDatasets); + const name = useAppSelector(state => state.name); + const highlightDataset = useAppSelector(state => state.highlightDataset); + const invalidData = useAppSelector(state => state.invalidData); + + const resetState = () => dispatch(resetStateAction()); + const setSelectedName = (datasetName: string) => + dispatch(setSelectedNameAction(datasetName)); + const setSelectedCSV = (csvfilePath: string | File) => + dispatch(setSelectedCSVAction(csvfilePath as string)); + const setSelectedJSON = (jsonfilePath: string) => + dispatch(setSelectedJSONAction(jsonfilePath)); + const setHighlightDataset = (id: string | undefined) => + dispatch(setHighlightDatasetAction(id as string)); + const [, setDownload] = useState(false); const handleDatasetClick = (id: string) => { @@ -161,30 +151,4 @@ const SelectDataset = ({ ); }; -export default connect( - (state: RootState) => ({ - csvfile: state.csvfile, - jsonfile: state.jsonfile, - specifiedDatasets: getSpecifiedDatasets(state), - name: state.name, - highlightDataset: state.highlightDataset, - invalidData: state.invalidData, - }), - (dispatch: Dispatch) => ({ - resetState() { - dispatch(resetState()); - }, - setSelectedName(name: string) { - dispatch(setSelectedName(name)); - }, - setSelectedCSV(csvfilePath: string | File) { - dispatch(setSelectedCSV(csvfilePath as string)); - }, - setSelectedJSON(jsonfilePath: string) { - dispatch(setSelectedJSON(jsonfilePath)); - }, - setHighlightDataset(id: string | undefined) { - dispatch(setHighlightDataset(id as string)); - }, - }), -)(SelectDataset); +export default SelectDataset; diff --git a/frontend/packages/labs/ailab/src/UIComponents/SelectLabelButton.tsx b/frontend/packages/labs/ailab/src/UIComponents/SelectLabelButton.tsx index 3862b6b07f7f8..00e53b9bcd4d2 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/SelectLabelButton.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/SelectLabelButton.tsx @@ -1,21 +1,18 @@ /* React component to handle selecting a column as the label. */ -import {connect} from 'react-redux'; - import {styles} from '../constants'; +import {useAppDispatch} from '../hooks'; import I18n from '../i18n'; import {setLabelColumn} from '../redux'; interface SelectLabelButtonProps { column?: string; - setLabelColumn: (column: string) => void; } -const SelectLabelButton = ({ - column, - setLabelColumn, -}: SelectLabelButtonProps) => { +const SelectLabelButton = ({column}: SelectLabelButtonProps) => { + const dispatch = useAppDispatch(); + const setPredictColumn = (event: React.MouseEvent, column: string) => { - setLabelColumn(column); + dispatch(setLabelColumn(column)); event.preventDefault(); }; @@ -31,8 +28,4 @@ const SelectLabelButton = ({ ); }; -export default connect(null, dispatch => ({ - setLabelColumn(column: string) { - dispatch(setLabelColumn(column)); - }, -}))(SelectLabelButton); +export default SelectLabelButton; diff --git a/frontend/packages/labs/ailab/src/UIComponents/Statement.tsx b/frontend/packages/labs/ailab/src/UIComponents/Statement.tsx index e66383da250a4..0d2410f6e47f9 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/Statement.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/Statement.tsx @@ -1,12 +1,11 @@ /* React component to display a statement about our model. */ import {faTimesCircle} from '@fortawesome/free-solid-svg-icons'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; -import {connect} from 'react-redux'; import {styles} from '../constants'; import {getLocalizedColumnName} from '../helpers/columnDetails'; +import {useAppDispatch, useAppSelector} from '../hooks'; import I18n from '../i18n'; -import type {RootState} from '../redux'; import {setLabelColumn, removeSelectedFeature} from '../redux'; interface StatementProps { @@ -153,22 +152,32 @@ const Statement = ({ ); }; +// The presentational component, for callers that supply props directly (e.g. +// the historic-results list in Results). export const UnconnectedStatement = Statement; -export default connect( - (state: RootState) => ({ - shouldShow: state.data.length !== 0, - currentPanel: state.currentPanel, - labelColumn: state.labelColumn, - selectedFeatures: state.selectedFeatures, - datasetId: state.metadata && state.metadata.name, - }), - dispatch => ({ - setLabelColumn(labelColumn: string | null) { - dispatch(setLabelColumn(labelColumn as string)); - }, - removeSelectedFeature(labelColumn: string) { - dispatch(removeSelectedFeature(labelColumn)); - }, - }), -)(Statement); +// Store-connected container: the default export used in the live panels. +const ConnectedStatement = () => { + const dispatch = useAppDispatch(); + const shouldShow = useAppSelector(state => state.data.length !== 0); + const currentPanel = useAppSelector(state => state.currentPanel); + const labelColumn = useAppSelector(state => state.labelColumn); + const selectedFeatures = useAppSelector(state => state.selectedFeatures); + const datasetId = useAppSelector( + state => state.metadata && state.metadata.name, + ); + + return ( + dispatch(setLabelColumn(column as string))} + removeSelectedFeature={id => dispatch(removeSelectedFeature(id))} + /> + ); +}; + +export default ConnectedStatement; diff --git a/frontend/packages/labs/ailab/src/UIComponents/TrainModel.tsx b/frontend/packages/labs/ailab/src/UIComponents/TrainModel.tsx index e7e7a0e33b7da..a023cf6254d69 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/TrainModel.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/TrainModel.tsx @@ -1,15 +1,13 @@ /* React component to handle training. */ import {useState, useEffect, useRef} from 'react'; -import {connect} from 'react-redux'; import {imageUrl} from '../assetPath'; import {styles, getFadeOpacity} from '../constants'; +import {useAppSelector} from '../hooks'; import I18n from '../i18n'; -import type {RootState} from '../redux'; import {getTableData} from '../redux'; import {store} from '../store'; import train from '../train'; -import type {DataRow} from '../types'; import {TrainingAnimationDescription} from './AnimationDescriptions'; import DataTable from './DataTable'; @@ -17,12 +15,11 @@ import DataTable from './DataTable'; const framesPerCycle = 80; const maxNumItems = 7; -interface TrainModelProps { - data: DataRow[]; - instructionsOverlayActive: boolean; -} - -const TrainModel = ({data, instructionsOverlayActive}: TrainModelProps) => { +const TrainModel = () => { + const data = useAppSelector(state => getTableData(state, false)); + const instructionsOverlayActive = useAppSelector( + state => state.instructionsOverlayActive, + ); const [frame, setFrame] = useState(0); const [headOpen, setHeadOpen] = useState(false); const [, setFinished] = useState(false); @@ -183,7 +180,4 @@ const TrainModel = ({data, instructionsOverlayActive}: TrainModelProps) => { ); }; -export default connect((state: RootState) => ({ - data: getTableData(state, false), - instructionsOverlayActive: state.instructionsOverlayActive, -}))(TrainModel); +export default TrainModel; diff --git a/frontend/packages/labs/ailab/src/UIComponents/UniqueOptionsWarning.tsx b/frontend/packages/labs/ailab/src/UIComponents/UniqueOptionsWarning.tsx index 250270c7f8f71..0455461cc2083 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/UniqueOptionsWarning.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/UniqueOptionsWarning.tsx @@ -1,16 +1,12 @@ /* React component to handle showing warning for excessive unique options. */ -import {connect} from 'react-redux'; - import {styles, UNIQUE_OPTIONS_MAX} from '../constants'; +import {useAppSelector} from '../hooks'; import I18n from '../i18n'; -import type {RootState} from '../redux'; import {hasTooManyUniqueOptions} from '../selectors/currentColumnSelectors'; -interface UniqueOptionsWarningProps { - showWarning?: boolean; -} +const UniqueOptionsWarning = () => { + const showWarning = useAppSelector(hasTooManyUniqueOptions); -const UniqueOptionsWarning = ({showWarning}: UniqueOptionsWarningProps) => { if (!showWarning) { return null; } @@ -27,6 +23,4 @@ const UniqueOptionsWarning = ({showWarning}: UniqueOptionsWarningProps) => { ); }; -export default connect((state: RootState) => ({ - showWarning: hasTooManyUniqueOptions(state), -}))(UniqueOptionsWarning); +export default UniqueOptionsWarning; diff --git a/frontend/packages/labs/ailab/src/helpers/datasetDetails.ts b/frontend/packages/labs/ailab/src/helpers/datasetDetails.ts index a440191804290..4d3c715d82f41 100644 --- a/frontend/packages/labs/ailab/src/helpers/datasetDetails.ts +++ b/frontend/packages/labs/ailab/src/helpers/datasetDetails.ts @@ -4,6 +4,10 @@ import type {DatasetDetails} from '../types'; /* Helper functions for getting information about the selected dataset. */ +// Reads the I18n global, so it is intentionally NOT memoized on Redux state +// (a state-keyed cache would go stale across a locale change). Callers that +// select it should pass `shallowEqual` to useSelector — the result is a flat +// object, so that dedupes the reference and avoids unnecessary rerenders. export function getDatasetDetails(state: RootState): DatasetDetails { const datasetDetails: DatasetDetails = { name: state.metadata?.name || '', diff --git a/frontend/packages/labs/ailab/src/hooks.ts b/frontend/packages/labs/ailab/src/hooks.ts new file mode 100644 index 0000000000000..462d2360987e5 --- /dev/null +++ b/frontend/packages/labs/ailab/src/hooks.ts @@ -0,0 +1,17 @@ +import { + type TypedUseSelectorHook, + shallowEqual, + useDispatch, + useSelector, +} from 'react-redux'; + +import type {RootState} from './redux'; + +// Typed react-redux hooks for use throughout the package, in place of connect(). +export const useAppSelector: TypedUseSelectorHook = useSelector; +export const useAppDispatch = useDispatch; + +// Re-exported for selectors that build a fresh object/array each call (e.g. +// derived shapes that also read non-Redux globals like I18n and so can't be +// memoized on state): pass it to useAppSelector to dedupe by value. +export {shallowEqual}; diff --git a/frontend/packages/labs/ailab/src/index.tsx b/frontend/packages/labs/ailab/src/index.tsx index f60a2bfd0db31..896ae657aafcb 100644 --- a/frontend/packages/labs/ailab/src/index.tsx +++ b/frontend/packages/labs/ailab/src/index.tsx @@ -18,6 +18,7 @@ import { setReserveLocation, setInstructionsDismissed, setFirehoseMetricsLogger, + getTrainedModelDataToSave, } from './redux'; import {store} from './store'; import type {Mode, ModelDataToSave, SaveResponse} from './types'; @@ -36,7 +37,7 @@ let saveTrainedModel: | undefined = null; let onContinue: (() => void) | null | undefined = null; -interface InitAllOptions { +export interface InitAllOptions { i18n?: Record; mode?: Mode; onContinue?: () => void; @@ -123,7 +124,11 @@ const processMode = (mode: Mode | undefined): void => { }; // Do the asynchronous save of a model. -const startSaveTrainedModel = (dataToSave: ModelDataToSave): void => { +// TODO: In the RTK migration, fold this into a thunk action so the payload is +// composed from getState() in the thunk body and App can simply dispatch it, +// rather than the store being reached into here. +const startSaveTrainedModel = (): void => { + const dataToSave = getTrainedModelDataToSave(store.getState()); store.dispatch(setSaveStatus('started')); saveTrainedModel!(dataToSave, (response: SaveResponse) => { store.dispatch(setSaveStatus(response.status, response.data)); @@ -135,3 +140,6 @@ const startSaveTrainedModel = (dataToSave: ModelDataToSave): void => { } }); }; + +// Export a few types. +export {type SaveResponse, type ModelDataToSave} from './types'; diff --git a/frontend/packages/labs/ailab/src/redux.ts b/frontend/packages/labs/ailab/src/redux.ts index 27f0156651942..b739b9d7d036a 100644 --- a/frontend/packages/labs/ailab/src/redux.ts +++ b/frontend/packages/labs/ailab/src/redux.ts @@ -820,6 +820,9 @@ export function getPredictAvailable(state: RootState): boolean { ); } +// Builds {prev, next} and reads the I18n global for button text, so it is not +// memoized on Redux state. App selects it with a prev/next-aware equality fn so +// the new object reference doesn't trigger unnecessary rerenders. export function getPanelButtons(state: RootState): PrevNextButtons { return prevNextButtons(state); } From 794131a686453384d899ac485b0183b03bca62df Mon Sep 17 00:00:00 2001 From: Sanchit Malhotra Date: Mon, 22 Jun 2026 18:50:13 +0000 Subject: [PATCH 2/2] ailab: dedupe nested selector results to quiet useSelector warnings The connect()->hooks conversion exposed more "returned a different result when called with the same parameters" warnings: getTableData (results path) on GenerateResults/DataTable, and getLabelToSave / getFeaturesToSave on ModelCard. Each rebuilds a fresh nested value (array of rows, or a column object with a values[] field) per call. These all read the I18n global transitively (or, for getTableData, route through accuracy helpers), so a state-keyed reselect memo isn't the right tool. Dedupe by value at the call site instead, matching the shallowEqual/panelButtonsEqual approach already used here. shallowEqual won't do because the values are nested, so add a small structural deepEqual to hooks.ts and pass it as the useAppSelector equality fn. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../labs/ailab/src/UIComponents/DataTable.tsx | 7 +++-- .../src/UIComponents/GenerateResults.tsx | 4 +-- .../labs/ailab/src/UIComponents/ModelCard.tsx | 6 ++-- frontend/packages/labs/ailab/src/hooks.ts | 31 +++++++++++++++++++ 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/frontend/packages/labs/ailab/src/UIComponents/DataTable.tsx b/frontend/packages/labs/ailab/src/UIComponents/DataTable.tsx index 3d55a6aa771ca..201c4bf164a5f 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/DataTable.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/DataTable.tsx @@ -2,7 +2,7 @@ import {styles} from '../constants'; import {getLocalizedColumnName} from '../helpers/columnDetails'; import {getLocalizedValue} from '../helpers/valueDetails'; -import {useAppDispatch, useAppSelector} from '../hooks'; +import {deepEqual, useAppDispatch, useAppSelector} from '../hooks'; import {getTableData, setCurrentColumn, setHighlightColumn} from '../redux'; interface DataTableProps { @@ -23,7 +23,10 @@ const DataTable = ({ useResultsData, }: DataTableProps) => { const dispatch = useAppDispatch(); - const data = useAppSelector(state => getTableData(state, !!useResultsData)); + const data = useAppSelector( + state => getTableData(state, !!useResultsData), + deepEqual, + ); const datasetId = useAppSelector(state => state.metadata?.name || 'unknown'); const labelColumn = useAppSelector(state => state.labelColumn || 'unknown'); const selectedFeatures = useAppSelector(state => state.selectedFeatures); diff --git a/frontend/packages/labs/ailab/src/UIComponents/GenerateResults.tsx b/frontend/packages/labs/ailab/src/UIComponents/GenerateResults.tsx index b7da0f414ea28..016815e80fc46 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/GenerateResults.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/GenerateResults.tsx @@ -3,7 +3,7 @@ import {useState, useEffect, useRef, useCallback} from 'react'; import {imageUrl} from '../assetPath'; import {styles, getFadeOpacity} from '../constants'; -import {useAppSelector} from '../hooks'; +import {deepEqual, useAppSelector} from '../hooks'; import I18n from '../i18n'; import {getTableData} from '../redux'; @@ -14,7 +14,7 @@ const framesPerCycle = 80; const maxNumItems = 7; const GenerateResults = () => { - const data = useAppSelector(state => getTableData(state, true)); + const data = useAppSelector(state => getTableData(state, true), deepEqual); const instructionsOverlayActive = useAppSelector( state => state.instructionsOverlayActive, ); diff --git a/frontend/packages/labs/ailab/src/UIComponents/ModelCard.tsx b/frontend/packages/labs/ailab/src/UIComponents/ModelCard.tsx index 0f4576c91d061..3dbd9d2193011 100644 --- a/frontend/packages/labs/ailab/src/UIComponents/ModelCard.tsx +++ b/frontend/packages/labs/ailab/src/UIComponents/ModelCard.tsx @@ -5,7 +5,7 @@ import {getPercentCorrect} from '../helpers/accuracy'; import {getLocalizedColumnName} from '../helpers/columnDetails'; import {getDatasetDetails} from '../helpers/datasetDetails'; import {getLocalizedValue} from '../helpers/valueDetails'; -import {shallowEqual, useAppSelector} from '../hooks'; +import {deepEqual, shallowEqual, useAppSelector} from '../hooks'; import I18n from '../i18n'; import {getLabelToSave, getFeaturesToSave} from '../redux'; @@ -17,8 +17,8 @@ const ModelCard = () => { ); const selectedFeatures = useAppSelector(state => state.selectedFeatures); const percentCorrect = useAppSelector(getPercentCorrect); - const label = useAppSelector(getLabelToSave); - const features = useAppSelector(getFeaturesToSave); + const label = useAppSelector(getLabelToSave, deepEqual); + const features = useAppSelector(getFeaturesToSave, deepEqual); const datasetDetails = useAppSelector(getDatasetDetails, shallowEqual); const datasetId = useAppSelector(state => state.metadata?.name || 'unknown'); const localizedLabel = getLocalizedColumnName(datasetDetails.name, label.id); diff --git a/frontend/packages/labs/ailab/src/hooks.ts b/frontend/packages/labs/ailab/src/hooks.ts index 462d2360987e5..5b263c9860d94 100644 --- a/frontend/packages/labs/ailab/src/hooks.ts +++ b/frontend/packages/labs/ailab/src/hooks.ts @@ -15,3 +15,34 @@ export const useAppDispatch = useDispatch; // derived shapes that also read non-Redux globals like I18n and so can't be // memoized on state): pass it to useAppSelector to dedupe by value. export {shallowEqual}; + +// Structural equality for useAppSelector, for selectors whose result is nested +// (arrays of objects, or objects with array fields) and so isn't deduped by +// shallowEqual. Like shallowEqual it lets a selector that rebuilds an equal +// value each call avoid spurious rerenders without being memoized on state. +export function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) { + return true; + } + if ( + typeof a !== 'object' || + typeof b !== 'object' || + a === null || + b === null + ) { + return false; + } + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) { + return false; + } + return keysA.every( + key => + Object.prototype.hasOwnProperty.call(b, key) && + deepEqual( + (a as Record)[key], + (b as Record)[key], + ), + ); +}