Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: show selections in ComparisonView on any page (ET-189) #9694

Merged
merged 9 commits into from
Jul 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions webui/react/src/components/CompareMetrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import _ from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';

import MetricBadgeTag from 'components/MetricBadgeTag';
import { MapOfIdsToColors, useGlasbey } from 'hooks/useGlasbey';
import { MapOfIdsToColors } from 'hooks/useGlasbey';
import { RunMetricData } from 'hooks/useMetrics';
import { ExperimentWithTrial, FlatRun, Serie, TrialItem, XAxisDomain, XOR } from 'types';
import handleError from 'utils/error';
import { metricToKey } from 'utils/metric';

interface BaseProps {
metricData: RunMetricData;
colorMap: MapOfIdsToColors;
}

type Props = XOR<
Expand All @@ -25,10 +26,8 @@ const CompareMetrics: React.FC<Props> = ({
trials,
metricData,
selectedRuns,
colorMap,
}) => {
const colorMap = useGlasbey(
selectedRuns ? selectedRuns.map((r) => r.id) : selectedExperiments.map((e) => e.experiment.id),
);
const [xAxis, setXAxis] = useState<XAxisDomain>(XAxisDomain.Batches);
const { scale, setScale } = metricData;

Expand Down
12 changes: 10 additions & 2 deletions webui/react/src/components/ComparisonView.test.mock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,11 +263,15 @@ export const ExperimentComparisonViewWithMocks: React.FC<Props> = ({
return (
<ComparisonView
colorMap={colorMap}
experimentSelection={
empty
? { selections: [], type: 'ONLY_IN' }
: { selections: SELECTED_EXPERIMENTS.map((exp) => exp.experiment.id), type: 'ONLY_IN' }
}
fixedColumnsCount={2}
initialWidth={200}
open={open}
projectId={1}
selectedExperiments={empty ? [] : SELECTED_EXPERIMENTS}
onWidthChange={onWidthChange}>
{children}
</ComparisonView>
Expand All @@ -288,7 +292,11 @@ export const RunComparisonViewWithMocks: React.FC<Props> = ({
initialWidth={200}
open={open}
projectId={1}
selectedRuns={empty ? [] : SELECTED_RUNS}
runSelection={
empty
? { selections: [], type: 'ONLY_IN' }
: { selections: SELECTED_RUNS.map((run) => run.id), type: 'ONLY_IN' }
}
onWidthChange={onWidthChange}>
{children}
</ComparisonView>
Expand Down
15 changes: 15 additions & 0 deletions webui/react/src/components/ComparisonView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,24 @@ import {
ExperimentComparisonViewWithMocks,
METRIC_DATA,
RunComparisonViewWithMocks,
SELECTED_EXPERIMENTS,
SELECTED_RUNS,
} from './ComparisonView.test.mock';
import { ThemeProvider } from './ThemeProvider';

vi.mock('services/api', () => ({
searchExperiments: () => {
return {
experiments: SELECTED_EXPERIMENTS,
};
},
searchRuns: () => {
return {
runs: SELECTED_RUNS,
};
},
}));

vi.mock('hooks/useSettings', async (importOriginal) => {
const useSettings = vi.fn(() => {
const settings = {
Expand Down
118 changes: 104 additions & 14 deletions webui/react/src/components/ComparisonView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,26 @@ import Alert from 'hew/Alert';
import { MIN_COLUMN_WIDTH } from 'hew/DataGrid/columns';
import Message from 'hew/Message';
import Pivot, { PivotProps } from 'hew/Pivot';
import Spinner from 'hew/Spinner';
import SplitPane, { Pane } from 'hew/SplitPane';
import React, { useMemo } from 'react';
import { Loadable, NotLoaded } from 'hew/utils/loadable';
import React, { useMemo, useState } from 'react';

import CompareHyperparameters from 'components/CompareHyperparameters';
import { useAsync } from 'hooks/useAsync';
import { MapOfIdsToColors } from 'hooks/useGlasbey';
import { useMetrics } from 'hooks/useMetrics';
import useMobile from 'hooks/useMobile';
import useScrollbarWidth from 'hooks/useScrollbarWidth';
import { TrialsComparisonTable } from 'pages/ExperimentDetails/TrialsComparisonModal';
import { ExperimentWithTrial, FlatRun, XOR } from 'types';
import { searchExperiments, searchRuns } from 'services/api';
import { ExperimentWithTrial, FlatRun, SelectionType, XOR } from 'types';
import handleError from 'utils/error';
import { getIdsFilter as getExperimentIdsFilter } from 'utils/experiment';
import { getIdsFilter as getRunIdsFilter } from 'utils/flatRun';

import CompareMetrics from './CompareMetrics';
import { INIT_FORMSET } from './FilterForm/components/FilterFormStore';

export const EMPTY_MESSAGE = 'No items selected.';

Expand All @@ -27,9 +35,11 @@ interface BaseProps {
projectId: number;
}

type Props = XOR<{ selectedExperiments: ExperimentWithTrial[] }, { selectedRuns: FlatRun[] }> &
type Props = XOR<{ experimentSelection: SelectionType }, { runSelection: SelectionType }> &
BaseProps;

const SELECTION_LIMIT = 50;

const ComparisonView: React.FC<Props> = ({
children,
colorMap,
Expand All @@ -38,13 +48,68 @@ const ComparisonView: React.FC<Props> = ({
onWidthChange,
fixedColumnsCount,
projectId,
selectedExperiments,
selectedRuns,
experimentSelection,
runSelection,
}) => {
const scrollbarWidth = useScrollbarWidth();
const hasPinnedColumns = fixedColumnsCount > 1;
const isMobile = useMobile();

const [isSelectionLimitReached, setIsSelectionLimitReached] = useState(false);

const loadableSelectedExperiments = useAsync(async () => {
if (experimentSelection) {
try {
const filterFormSet = INIT_FORMSET;
const filter = getExperimentIdsFilter(filterFormSet, experimentSelection);
const response = await searchExperiments({
filter: JSON.stringify(filter),
limit: SELECTION_LIMIT,
});
if (response?.pagination?.total && response?.pagination?.total > SELECTION_LIMIT) {
setIsSelectionLimitReached(true);
} else {
setIsSelectionLimitReached(false);
}
return response.experiments;
} catch (e) {
handleError(e, { publicSubject: 'Unable to fetch experiments for comparison' });
return NotLoaded;
}
}
return NotLoaded;
}, [experimentSelection]);

const selectedExperiments: ExperimentWithTrial[] | undefined = Loadable.getOrElse(
undefined,
loadableSelectedExperiments,
);

const loadableSelectedRuns = useAsync(async () => {
if (runSelection) {
const filterFormSet = INIT_FORMSET;
try {
const filter = getRunIdsFilter(filterFormSet, runSelection);
const response = await searchRuns({
filter: JSON.stringify(filter),
limit: SELECTION_LIMIT,
});
if (response?.pagination?.total && response?.pagination?.total > SELECTION_LIMIT) {
setIsSelectionLimitReached(true);
} else {
setIsSelectionLimitReached(false);
}
return response.runs;
} catch (e) {
handleError(e, { publicSubject: 'Unable to fetch runs for comparison' });
return NotLoaded;
}
}
return NotLoaded;
}, [runSelection]);

const selectedRuns: FlatRun[] | undefined = Loadable.getOrElse(undefined, loadableSelectedRuns);

const minWidths: [number, number] = useMemo(() => {
return [fixedColumnsCount * MIN_COLUMN_WIDTH + scrollbarWidth, 100];
}, [fixedColumnsCount, scrollbarWidth]);
Expand All @@ -64,11 +129,12 @@ const ComparisonView: React.FC<Props> = ({
return [
{
children: selectedRuns ? (
<CompareMetrics metricData={metricData} selectedRuns={selectedRuns} />
<CompareMetrics colorMap={colorMap} metricData={metricData} selectedRuns={selectedRuns} />
) : (
<CompareMetrics
colorMap={colorMap}
metricData={metricData}
selectedExperiments={selectedExperiments}
selectedExperiments={selectedExperiments ?? []}
trials={trials}
/>
),
Expand All @@ -88,7 +154,7 @@ const ComparisonView: React.FC<Props> = ({
colorMap={colorMap}
metricData={metricData}
projectId={projectId}
selectedExperiments={selectedExperiments}
selectedExperiments={selectedExperiments ?? []}
trials={trials}
/>
),
Expand All @@ -114,20 +180,44 @@ const ComparisonView: React.FC<Props> = ({
children
);

const rightPane =
selectedExperiments?.length === 0 || selectedRuns?.length === 0 ? (
<Alert description="Select records you would like to compare." message={EMPTY_MESSAGE} />
) : (
<Pivot items={tabs} />
const getRightPaneContent = () => {
if (experimentSelection) {
if (experimentSelection.type === 'ONLY_IN' && experimentSelection.selections.length === 0) {
return (
<Alert description="Select records you would like to compare." message={EMPTY_MESSAGE} />
);
}
if (selectedExperiments === undefined) {
return <Spinner spinning />;
}
}
if (runSelection) {
if (runSelection.type === 'ONLY_IN' && runSelection.selections.length === 0) {
return (
<Alert description="Select records you would like to compare." message={EMPTY_MESSAGE} />
);
}
if (selectedRuns === undefined) {
return <Spinner spinning />;
}
}
return (
<>
{isSelectionLimitReached && (
<Alert message={`Only up to ${SELECTION_LIMIT} records can be compared`} />
)}
<Pivot items={tabs} />
</>
);
};

return (
<SplitPane
hidePane={!open ? Pane.Right : isMobile ? Pane.Left : undefined}
initialWidth={initialWidth}
leftPane={leftPane}
minimumWidths={{ left: minWidths[0], right: minWidths[1] }}
rightPane={rightPane}
rightPane={getRightPaneContent()}
onChange={onWidthChange}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { useModal } from 'hew/Modal';
import { Failed, NotLoaded } from 'hew/utils/loadable';
import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';

import { FilterFormSetWithoutId, Operator } from 'components/FilterForm/components/type';
import { FilterFormSetWithoutId } from 'components/FilterForm/components/type';
import InterstitialModalComponent, {
onInterstitialCloseActionType,
} from 'components/InterstitialModalComponent';
import { SelectionType } from 'components/Searches/Searches.settings';
import { useAsync } from 'hooks/useAsync';
import { searchRuns } from 'services/api';
import { SelectionType } from 'types';
import { DetError } from 'utils/error';
import { getIdsFilter } from 'utils/flatRun';
import mergeAbortControllers from 'utils/mergeAbortControllers';
import { observable } from 'utils/observable';

Expand Down Expand Up @@ -76,52 +77,7 @@ export const RunFilterInterstitialModalComponent = forwardRef<ControlledModalRef
async (canceler) => {
if (!isOpen) return NotLoaded;
const mergedCanceler = mergeAbortControllers(canceler, closeController.current);
const idToFilter = (operator: Operator, id: number) =>
({
columnName: 'id',
kind: 'field',
location: 'LOCATION_TYPE_RUN',
operator,
type: 'COLUMN_TYPE_NUMBER',
value: id,
}) as const;
const filterGroup: FilterFormSetWithoutId['filterGroup'] =
selection.type === 'ALL_EXCEPT'
? {
children: [
filterFormSet.filterGroup,
{
children: selection.exclusions.map(idToFilter.bind(this, '!=')),
conjunction: 'and',
kind: 'group',
},
],
conjunction: 'and',
kind: 'group',
}
: {
children: selection.selections.map(idToFilter.bind(this, '=')),
conjunction: 'or',
kind: 'group',
};
const filter: FilterFormSetWithoutId = {
...filterFormSet,
filterGroup: {
children: [
filterGroup,
{
columnName: 'searcherType',
kind: 'field',
location: 'LOCATION_TYPE_RUN',
operator: '!=',
type: 'COLUMN_TYPE_TEXT',
value: 'single',
} as const,
],
conjunction: 'and',
kind: 'group',
},
};
const filter = getIdsFilter(filterFormSet, selection);
try {
const results = await searchRuns(
{
Expand Down
13 changes: 1 addition & 12 deletions webui/react/src/components/Searches/Searches.settings.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,10 @@
import * as t from 'io-ts';

import { INIT_FORMSET } from 'components/FilterForm/components/FilterFormStore';
import { RegularSelectionType, SelectionType } from 'types';

import { defaultColumnWidths, defaultExperimentColumns } from './columns';

const SelectAllType = t.type({
exclusions: t.array(t.number),
type: t.literal('ALL_EXCEPT'),
});

const RegularSelectionType = t.type({
selections: t.array(t.number),
type: t.literal('ONLY_IN'),
});

export const SelectionType = t.union([RegularSelectionType, SelectAllType]);
export type SelectionType = t.TypeOf<typeof SelectionType>;
export const DEFAULT_SELECTION: t.TypeOf<typeof RegularSelectionType> = {
selections: [],
type: 'ONLY_IN',
Expand Down
2 changes: 1 addition & 1 deletion webui/react/src/components/Searches/Searches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
Project,
ProjectColumn,
RunState,
SelectionType as SelectionState,
} from 'types';
import handleError from 'utils/error';
import { getProjectExperimentForExperimentItem } from 'utils/experiment';
Expand All @@ -86,7 +87,6 @@ import {
defaultProjectSettings,
ProjectSettings,
ProjectUrlSettings,
SelectionType as SelectionState,
settingsPathForProject,
} from './Searches.settings';

Expand Down
Loading
Loading