From 6f25debde5a15698ada521412d099fd734ebf86f Mon Sep 17 00:00:00 2001 From: Emily Bonar Date: Fri, 18 Oct 2024 13:58:38 -0400 Subject: [PATCH] Update tables to use hook and fix selection logic --- .../src/components/Searches/Searches.tsx | 123 ++--------- webui/react/src/components/TableActionBar.tsx | 85 +++++--- webui/react/src/hooks/useSelection.ts | 10 +- .../src/pages/F_ExpList/F_ExperimentList.tsx | 193 ++++-------------- .../FlatRuns/FlatRunActionButton.test.tsx | 2 + .../pages/FlatRuns/FlatRunActionButton.tsx | 39 ++-- webui/react/src/pages/FlatRuns/FlatRuns.tsx | 170 +++------------ webui/react/src/services/api.ts | 44 ++++ webui/react/src/services/apiConfig.ts | 72 +++++++ webui/react/src/services/types.ts | 12 ++ 10 files changed, 296 insertions(+), 454 deletions(-) diff --git a/webui/react/src/components/Searches/Searches.tsx b/webui/react/src/components/Searches/Searches.tsx index 89a385356ff0..f6b2af0c4ce1 100644 --- a/webui/react/src/components/Searches/Searches.tsx +++ b/webui/react/src/components/Searches/Searches.tsx @@ -1,4 +1,3 @@ -import { CompactSelection, GridSelection } from '@glideapps/glide-data-grid'; import { isLeft } from 'fp-ts/lib/Either'; import Column from 'hew/Column'; import { @@ -11,15 +10,7 @@ import { MIN_COLUMN_WIDTH, MULTISELECT, } from 'hew/DataGrid/columns'; -import DataGrid, { - DataGridHandle, - HandleSelectionChangeType, - RangelessSelectionType, - SelectionType, - Sort, - validSort, - ValidSort, -} from 'hew/DataGrid/DataGrid'; +import DataGrid, { DataGridHandle, Sort, validSort, ValidSort } from 'hew/DataGrid/DataGrid'; import { MenuItem } from 'hew/Dropdown'; import Icon from 'hew/Icon'; import Link from 'hew/Link'; @@ -56,6 +47,7 @@ import { useDebouncedSettings } from 'hooks/useDebouncedSettings'; import { useGlasbey } from 'hooks/useGlasbey'; import useMobile from 'hooks/useMobile'; import usePolling from 'hooks/usePolling'; +import useSelection from 'hooks/useSelection'; import { useSettings } from 'hooks/useSettings'; import { useTypedParams } from 'hooks/useTypedParams'; import { paths } from 'routes/utils'; @@ -75,7 +67,6 @@ import { Project, ProjectColumn, RunState, - SelectionType as SelectionState, } from 'types'; import handleError from 'utils/error'; import { getProjectExperimentForExperimentItem } from 'utils/experiment'; @@ -183,6 +174,13 @@ const Searches: React.FC = ({ project }) => { const isMobile = useMobile(); const { openToast } = useToast(); + const { selectionSize, dataGridSelection, handleSelectionChange, rowRangeToIds } = useSelection({ + records: experiments.map((loadable) => loadable.map((exp) => exp.experiment)), + selection: settings.selection, + total, + updateSettings, + }); + const handlePinnedColumnsCountChange = useCallback( (newCount: number) => updateSettings({ pinnedColumnsCount: newCount }), [updateSettings], @@ -264,36 +262,6 @@ const Searches: React.FC = ({ project }) => { return selectedMap; }, [isLoadingSettings, allSelectedExperimentIds, experiments]); - const selection = useMemo(() => { - let rows = CompactSelection.empty(); - if (settings.selection.type === 'ONLY_IN') { - loadedSelectedExperimentIds.forEach((exp) => { - rows = rows.add(exp.index); - }); - } else if (settings.selection.type === 'ALL_EXCEPT') { - rows = rows.add([0, total.getOrElse(1) - 1]); - settings.selection.exclusions.forEach((exc) => { - const excIndex = loadedSelectedExperimentIds.get(exc)?.index; - if (excIndex !== undefined) { - rows = rows.remove(excIndex); - } - }); - } - return { - columns: CompactSelection.empty(), - rows, - }; - }, [loadedSelectedExperimentIds, settings.selection, total]); - - const selectionSize = useMemo(() => { - if (settings.selection.type === 'ONLY_IN') { - return settings.selection.selections.length; - } else if (settings.selection.type === 'ALL_EXCEPT') { - return total.getOrElse(0) - settings.selection.exclusions.length; - } - return 0; - }, [settings.selection, total]); - const colorMap = useGlasbey([...loadedSelectedExperimentIds.keys()]); const experimentFilters = useMemo(() => { @@ -418,71 +386,6 @@ const Searches: React.FC = ({ project }) => { }; }, [canceler, stopPolling]); - const rowRangeToIds = useCallback( - (range: [number, number]) => { - const slice = experiments.slice(range[0], range[1]); - return Loadable.filterNotLoaded(slice).map(({ experiment }) => experiment.id); - }, - [experiments], - ); - - const handleSelectionChange: HandleSelectionChangeType = useCallback( - (selectionType: SelectionType | RangelessSelectionType, range?: [number, number]) => { - let newSettings: SelectionState = { ...settings.selection }; - - switch (selectionType) { - case 'add': - if (!range) return; - if (newSettings.type === 'ALL_EXCEPT') { - const excludedSet = new Set(newSettings.exclusions); - rowRangeToIds(range).forEach((id) => excludedSet.delete(id)); - newSettings.exclusions = Array.from(excludedSet); - } else { - const includedSet = new Set(newSettings.selections); - rowRangeToIds(range).forEach((id) => includedSet.add(id)); - newSettings.selections = Array.from(includedSet); - } - - break; - case 'add-all': - newSettings = { - exclusions: [], - type: 'ALL_EXCEPT' as const, - }; - - break; - case 'remove': - if (!range) return; - if (newSettings.type === 'ALL_EXCEPT') { - const excludedSet = new Set(newSettings.exclusions); - rowRangeToIds(range).forEach((id) => excludedSet.add(id)); - newSettings.exclusions = Array.from(excludedSet); - } else { - const includedSet = new Set(newSettings.selections); - rowRangeToIds(range).forEach((id) => includedSet.delete(id)); - newSettings.selections = Array.from(includedSet); - } - - break; - case 'remove-all': - newSettings = DEFAULT_SELECTION; - - break; - case 'set': - if (!range) return; - newSettings = { - ...DEFAULT_SELECTION, - selections: Array.from(rowRangeToIds(range)), - }; - - break; - } - - updateSettings({ selection: newSettings }); - }, - [rowRangeToIds, settings.selection, updateSettings], - ); - const handleActionComplete = useCallback(async () => { /** * Deselect selected rows since their states may have changed where they @@ -658,7 +561,7 @@ const Searches: React.FC = ({ project }) => { const gridColumns = [...STATIC_COLUMNS, ...columnsIfLoaded] .map((columnName) => { if (columnName === MULTISELECT) { - return (columnDefs[columnName] = defaultSelectionColumn(selection.rows, false)); + return (columnDefs[columnName] = defaultSelectionColumn(dataGridSelection.rows, false)); } if (!Loadable.isLoaded(projectColumnsMap)) { @@ -731,7 +634,7 @@ const Searches: React.FC = ({ project }) => { columnsIfLoaded, appTheme, isDarkMode, - selection.rows, + dataGridSelection.rows, users, ]); @@ -904,8 +807,10 @@ const Searches: React.FC = ({ project }) => { projectColumns={projectColumns} rowHeight={globalSettings.rowHeight} selectedExperimentIds={allSelectedExperimentIds} + selection={settings.selection} selectionSize={selectionSize} sorts={sorts} + tableFilterString={filtersString} total={total} onActionComplete={handleActionComplete} onActionSuccess={handleActionSuccess} @@ -965,7 +870,7 @@ const Searches: React.FC = ({ project }) => { ); }} rowHeight={rowHeightMap[globalSettings.rowHeight as RowHeight]} - selection={selection} + selection={dataGridSelection} sorts={sorts} staticColumns={STATIC_COLUMNS} onColumnResize={handleColumnWidthChange} diff --git a/webui/react/src/components/TableActionBar.tsx b/webui/react/src/components/TableActionBar.tsx index bd0578ba550b..18b9316197b8 100644 --- a/webui/react/src/components/TableActionBar.tsx +++ b/webui/react/src/components/TableActionBar.tsx @@ -25,17 +25,18 @@ import useMobile from 'hooks/useMobile'; import usePermissions from 'hooks/usePermissions'; import { defaultExperimentColumns } from 'pages/F_ExpList/expListColumns'; import { - activateExperiments, - archiveExperiments, + archiveSearches, cancelExperiments, - deleteExperiments, + deleteSearches, getExperiments, - killExperiments, + killSearches, openOrCreateTensorBoard, - pauseExperiments, - unarchiveExperiments, + pauseSearches, + resumeSearches, + unarchiveSearches, } from 'services/api'; import { V1LocationType } from 'services/api-ts-sdk'; +import { SearchBulkActionParams } from 'services/types'; import { BulkActionResult, BulkExperimentItem, @@ -43,11 +44,13 @@ import { Project, ProjectColumn, ProjectExperiment, + SelectionType, } from 'types'; import handleError, { ErrorLevel } from 'utils/error'; import { canActionExperiment, getActionsForExperimentsUnion, + getIdsFilter, getProjectExperimentForExperimentItem, } from 'utils/experiment'; import { capitalizeWord } from 'utils/string'; @@ -107,6 +110,7 @@ interface Props { projectColumns: Loadable; rowHeight: RowHeight; selectedExperimentIds: number[]; + selection: SelectionType; selectionSize: number; sorts: Sort[]; pinnedColumnsCount?: number; @@ -117,11 +121,13 @@ interface Props { bannedFilterColumns?: Set; bannedSortColumns?: Set; entityCopy?: string; + tableFilterString: string; } const TableActionBar: React.FC = ({ compareViewOn, formStore, + tableFilterString, heatmapBtnVisible, heatmapOn, initialVisibleColumns, @@ -152,6 +158,7 @@ const TableActionBar: React.FC = ({ bannedSortColumns, entityCopy, selectionSize, + selection, }) => { const permissions = usePermissions(); const [batchAction, setBatchAction] = useState(); @@ -209,29 +216,41 @@ const TableActionBar: React.FC = ({ ); const availableBatchActions = useMemo(() => { - const experiments = selectedExperimentIds.map((id) => experimentMap[id]) ?? []; - return getActionsForExperimentsUnion(experiments, [...batchActions], permissions); - // Spreading batchActions is so TypeScript doesn't complain that it's readonly. - }, [selectedExperimentIds, experimentMap, permissions]); + if (selection.type === 'ONLY_IN') { + const experiments = selectedExperimentIds.map((id) => experimentMap[id]) ?? []; + return getActionsForExperimentsUnion(experiments, [...batchActions], permissions); // Spreading batchActions is so TypeScript doesn't complain that it's readonly. + } else if (selection.type === 'ALL_EXCEPT') { + return batchActions.filter( + (action) => + action !== ExperimentAction.OpenTensorBoard && action !== ExperimentAction.Cancel, + ); + } + return []; // should never be reached + }, [selection.type, selectedExperimentIds, permissions, experimentMap]); const sendBatchActions = useCallback( async (action: BatchAction): Promise => { - const validExperimentIds = selectedExperiments - .filter((exp) => !exp.unmanaged && canActionExperiment(action, exp)) - .map((exp) => exp.id); - const params = { - experimentIds: validExperimentIds, - projectId: project.id, - }; + const params: SearchBulkActionParams = { projectId: project.id }; + if (selection.type === 'ONLY_IN') { + const validSearchIds = selectedExperiments + .filter((exp) => !exp.unmanaged && canActionExperiment(action, exp)) + .map((exp) => exp.id); + params.searchIds = validSearchIds; + } else if (selection.type === 'ALL_EXCEPT') { + const filters = JSON.parse(tableFilterString); + params.filter = JSON.stringify(getIdsFilter(filters, selection)); + } + switch (action) { case ExperimentAction.OpenTensorBoard: { - if (validExperimentIds.length !== selectedExperiments.length) { + if (params.searchIds === undefined) break; + if (params.searchIds.length !== selectedExperiments.length) { // if unmanaged experiments are selected, open experimentTensorBoardModal openExperimentTensorBoardModal(); } else { openCommandResponse( await openOrCreateTensorBoard({ - experimentIds: params.experimentIds, + experimentIds: params.searchIds, workspaceId: project?.workspaceId, }), ); @@ -243,27 +262,34 @@ const TableActionBar: React.FC = ({ case ExperimentAction.RetainLogs: return ExperimentRetainLogsModal.open(); case ExperimentAction.Activate: - return await activateExperiments(params); + return await resumeSearches(params); case ExperimentAction.Archive: - return await archiveExperiments(params); + return await archiveSearches(params); case ExperimentAction.Cancel: - return await cancelExperiments(params); + if (params.searchIds === undefined) break; + return await cancelExperiments({ + experimentIds: params.searchIds, + projectId: params.projectId, + }); case ExperimentAction.Kill: - return await killExperiments(params); + return await killSearches(params); case ExperimentAction.Pause: - return await pauseExperiments(params); + return await pauseSearches(params); case ExperimentAction.Unarchive: - return await unarchiveExperiments(params); + return await unarchiveSearches(params); case ExperimentAction.Delete: - return await deleteExperiments(params); + return await deleteSearches(params); } }, [ + project.id, + project?.workspaceId, + selection, selectedExperiments, + tableFilterString, ExperimentMoveModal, ExperimentRetainLogsModal, openExperimentTensorBoardModal, - project, ], ); @@ -320,8 +346,7 @@ const TableActionBar: React.FC = ({ closeable: true, description: `${action} succeeded for ${numSuccesses} out of ${ numFailures + numSuccesses - } eligible - ${labelPlural.toLowerCase()}`, + } ${labelPlural.toLowerCase()}`, severity: 'Warning', title: `Partial ${action} Failure`, }); @@ -419,7 +444,7 @@ const TableActionBar: React.FC = ({ onVisibleColumnChange={onVisibleColumnChange} /> - {selectedExperimentIds.length > 0 && ( + {selectionSize > 0 && (