Skip to content

Commit d3e978c

Browse files
committed
Merge branch 'main' into RHOAIENG-51780-display-external-vector-stores-table-in-AAE-tab
2 parents afea978 + d758191 commit d3e978c

115 files changed

Lines changed: 5761 additions & 2591 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import * as React from 'react';
2+
import SearchSelector from '#~/components/searchSelector/SearchSelector';
3+
import useTableColumnSort from '#~/components/table/useTableColumnSort';
4+
import { MlflowExperiment, MlflowSelectorStatus } from './types';
5+
import { mlflowExperimentColumns } from './columns';
6+
import useMlflowExperiments from './useMlflowExperiments';
7+
import MlflowExperimentTable from './MlflowExperimentTable';
8+
9+
type MlflowExperimentSelectorProps = {
10+
workspace: string;
11+
filter?: string;
12+
selection?: string;
13+
onSelect: (experiment: MlflowExperiment) => void;
14+
onStatusChange?: (status: MlflowSelectorStatus) => void;
15+
};
16+
17+
const MlflowExperimentSelector: React.FC<MlflowExperimentSelectorProps> = ({
18+
workspace,
19+
filter,
20+
selection,
21+
onSelect,
22+
onStatusChange,
23+
}) => {
24+
const { data: experiments, loaded, error } = useMlflowExperiments({ workspace, filter });
25+
const [search, setSearch] = React.useState('');
26+
27+
const statusCallbackRef = React.useRef(onStatusChange);
28+
statusCallbackRef.current = onStatusChange;
29+
30+
React.useEffect(() => {
31+
statusCallbackRef.current?.({ loaded, error });
32+
}, [loaded, error]);
33+
34+
const filtered = React.useMemo(() => {
35+
if (!search) {
36+
return experiments;
37+
}
38+
const lower = search.toLowerCase();
39+
return experiments.filter((e) => e.name.toLowerCase().includes(lower));
40+
}, [experiments, search]);
41+
42+
const { transformData, getColumnSort } = useTableColumnSort(mlflowExperimentColumns, [], 0);
43+
const sorted = transformData(filtered);
44+
const experimentCount = experiments.length;
45+
const experimentLabel = experimentCount === 1 ? 'experiment' : 'experiments';
46+
47+
let toggleLabel = 'Select an experiment';
48+
if (error) {
49+
toggleLabel = 'Error loading experiments';
50+
} else if (!loaded) {
51+
toggleLabel = 'Loading experiments';
52+
} else if (experiments.length === 0) {
53+
toggleLabel = 'No experiments available';
54+
} else if (selection) {
55+
toggleLabel = selection;
56+
}
57+
58+
return (
59+
<SearchSelector
60+
dataTestId="mlflow-experiment-selector"
61+
onSearchChange={setSearch}
62+
onSearchClear={() => setSearch('')}
63+
searchValue={search}
64+
isLoading={!loaded && !error}
65+
isFullWidth
66+
toggleContent={toggleLabel}
67+
searchHelpText={`Type a name to search your ${experimentCount} ${experimentLabel}.`}
68+
isDisabled={!loaded || !!error || experimentCount === 0}
69+
>
70+
{({ menuClose }) => (
71+
<MlflowExperimentTable
72+
data={sorted}
73+
loaded={loaded}
74+
onSelect={onSelect}
75+
menuClose={menuClose}
76+
onClearSearch={() => setSearch('')}
77+
getColumnSort={getColumnSort}
78+
/>
79+
)}
80+
</SearchSelector>
81+
);
82+
};
83+
84+
export default MlflowExperimentSelector;
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import * as React from 'react';
2+
import { EmptyStateVariant, MenuContent } from '@patternfly/react-core';
3+
import { TableVariant, Td, Tr } from '@patternfly/react-table';
4+
import { TableBase } from '#~/components/table';
5+
import DashboardEmptyTableView from '#~/concepts/dashboard/DashboardEmptyTableView';
6+
import { relativeTime } from '#~/utilities/time';
7+
import { MlflowExperiment } from './types';
8+
import { mlflowExperimentColumns } from './columns';
9+
import { EXPERIMENT_NAME_COLUMN_WIDTH, EXPERIMENT_UPDATED_COLUMN_WIDTH } from './const';
10+
11+
type RowProps = {
12+
experiment: MlflowExperiment;
13+
onSelect: () => void;
14+
menuClose: () => void;
15+
};
16+
17+
const MlflowExperimentRow: React.FC<RowProps> = ({ experiment, onSelect, menuClose }) => {
18+
const handleClick = React.useCallback(() => {
19+
onSelect();
20+
menuClose();
21+
}, [onSelect, menuClose]);
22+
23+
return (
24+
<Tr onRowClick={handleClick} isClickable>
25+
<Td width={EXPERIMENT_NAME_COLUMN_WIDTH} modifier="truncate" tooltip={null}>
26+
{experiment.name}
27+
</Td>
28+
<Td width={EXPERIMENT_UPDATED_COLUMN_WIDTH}>
29+
{experiment.lastUpdateTime
30+
? relativeTime(Date.now(), new Date(experiment.lastUpdateTime).getTime())
31+
: '-'}
32+
</Td>
33+
</Tr>
34+
);
35+
};
36+
37+
type MlflowExperimentTableProps = {
38+
data: MlflowExperiment[];
39+
loaded: boolean;
40+
onSelect: (experiment: MlflowExperiment) => void;
41+
menuClose: () => void;
42+
onClearSearch: () => void;
43+
getColumnSort: React.ComponentProps<typeof TableBase>['getColumnSort'];
44+
};
45+
46+
const MlflowExperimentTable: React.FC<MlflowExperimentTableProps> = ({
47+
data,
48+
loaded,
49+
onSelect,
50+
menuClose,
51+
onClearSearch,
52+
getColumnSort,
53+
}) => {
54+
const renderRow = React.useCallback(
55+
(row: MlflowExperiment) => (
56+
<MlflowExperimentRow
57+
key={row.id}
58+
experiment={row}
59+
onSelect={() => onSelect(row)}
60+
menuClose={menuClose}
61+
/>
62+
),
63+
[onSelect, menuClose],
64+
);
65+
66+
return (
67+
<MenuContent>
68+
<TableBase
69+
itemCount={data.length}
70+
loading={!loaded}
71+
emptyTableView={
72+
<DashboardEmptyTableView
73+
hasIcon={false}
74+
onClearFilters={onClearSearch}
75+
variant={EmptyStateVariant.xs}
76+
/>
77+
}
78+
data-testid="mlflow-experiment-selector-table-list"
79+
borders={false}
80+
variant={TableVariant.compact}
81+
columns={mlflowExperimentColumns}
82+
data={data}
83+
rowRenderer={renderRow}
84+
getColumnSort={getColumnSort}
85+
/>
86+
</MenuContent>
87+
);
88+
};
89+
90+
export default MlflowExperimentTable;
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import * as React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import '@testing-library/jest-dom';
4+
import MlflowExperimentSelector from '#~/concepts/mlflow/MlflowExperimentSelector';
5+
import useMlflowExperiments from '#~/concepts/mlflow/useMlflowExperiments';
6+
import useTableColumnSort from '#~/components/table/useTableColumnSort';
7+
8+
jest.mock('#~/concepts/mlflow/useMlflowExperiments');
9+
jest.mock('#~/components/table/useTableColumnSort');
10+
jest.mock('#~/concepts/mlflow/MlflowExperimentTable', () => {
11+
const MockMlflowExperimentTable = (props: { data: unknown[] }) => (
12+
<div data-testid="mlflow-experiment-table">{props.data.length}</div>
13+
);
14+
MockMlflowExperimentTable.displayName = 'MockMlflowExperimentTable';
15+
return MockMlflowExperimentTable;
16+
});
17+
jest.mock('#~/components/searchSelector/SearchSelector', () => {
18+
const MockSearchSelector = ({
19+
toggleContent,
20+
searchHelpText,
21+
isLoading,
22+
isDisabled,
23+
children,
24+
}: {
25+
toggleContent: string;
26+
searchHelpText: string;
27+
isLoading: boolean;
28+
isDisabled: boolean;
29+
children: (args: { menuClose: () => void }) => React.ReactNode;
30+
}) => (
31+
<div
32+
data-testid="mlflow-search-selector"
33+
data-loading={String(isLoading)}
34+
data-disabled={String(isDisabled)}
35+
>
36+
<div data-testid="selector-toggle-content">{toggleContent}</div>
37+
<div data-testid="selector-help-text">{searchHelpText}</div>
38+
{children({ menuClose: () => undefined })}
39+
</div>
40+
);
41+
MockSearchSelector.displayName = 'MockSearchSelector';
42+
return MockSearchSelector;
43+
});
44+
45+
const useMlflowExperimentsMock = jest.mocked(useMlflowExperiments);
46+
const useTableColumnSortMock = jest.mocked(useTableColumnSort);
47+
48+
const defaultExperiments = [
49+
{
50+
id: 'exp-1',
51+
name: 'Experiment A',
52+
},
53+
{
54+
id: 'exp-2',
55+
name: 'Experiment B',
56+
},
57+
];
58+
59+
const renderSelector = (props?: Partial<React.ComponentProps<typeof MlflowExperimentSelector>>) =>
60+
render(
61+
<MlflowExperimentSelector
62+
workspace="test-workspace"
63+
onSelect={jest.fn()}
64+
selection={undefined}
65+
{...props}
66+
/>,
67+
);
68+
69+
describe('MlflowExperimentSelector', () => {
70+
beforeEach(() => {
71+
jest.clearAllMocks();
72+
useMlflowExperimentsMock.mockReturnValue({
73+
data: defaultExperiments,
74+
loaded: true,
75+
error: undefined,
76+
refresh: jest.fn(),
77+
});
78+
useTableColumnSortMock.mockReturnValue({
79+
transformData: (data) => data,
80+
getColumnSort: jest.fn(),
81+
isCustomOrder: false,
82+
});
83+
});
84+
85+
it('should show loading toggle content while experiments are loading', () => {
86+
useMlflowExperimentsMock.mockReturnValue({
87+
data: [],
88+
loaded: false,
89+
error: undefined,
90+
refresh: jest.fn(),
91+
});
92+
93+
renderSelector();
94+
95+
expect(screen.getByTestId('selector-toggle-content')).toHaveTextContent('Loading experiments');
96+
expect(screen.getByTestId('mlflow-search-selector')).toHaveAttribute('data-loading', 'true');
97+
expect(screen.getByTestId('mlflow-search-selector')).toHaveAttribute('data-disabled', 'true');
98+
});
99+
100+
it('should show error toggle content when experiments fail to load', () => {
101+
useMlflowExperimentsMock.mockReturnValue({
102+
data: [],
103+
loaded: false,
104+
error: new Error('failed to load'),
105+
refresh: jest.fn(),
106+
});
107+
108+
renderSelector();
109+
110+
expect(screen.getByTestId('selector-toggle-content')).toHaveTextContent(
111+
'Error loading experiments',
112+
);
113+
expect(screen.getByTestId('mlflow-search-selector')).toHaveAttribute('data-loading', 'false');
114+
expect(screen.getByTestId('mlflow-search-selector')).toHaveAttribute('data-disabled', 'true');
115+
});
116+
117+
it('should show empty-state toggle content when no experiments are available', () => {
118+
useMlflowExperimentsMock.mockReturnValue({
119+
data: [],
120+
loaded: true,
121+
error: undefined,
122+
refresh: jest.fn(),
123+
});
124+
125+
renderSelector();
126+
127+
expect(screen.getByTestId('selector-toggle-content')).toHaveTextContent(
128+
'No experiments available',
129+
);
130+
expect(screen.getByTestId('selector-help-text')).toHaveTextContent(
131+
'Type a name to search your 0 experiments.',
132+
);
133+
});
134+
135+
it('should show selected experiment when provided', () => {
136+
renderSelector({ selection: 'Experiment B' });
137+
138+
expect(screen.getByTestId('selector-toggle-content')).toHaveTextContent('Experiment B');
139+
});
140+
141+
it('should use singular search help text when there is one experiment', () => {
142+
useMlflowExperimentsMock.mockReturnValue({
143+
data: [{ id: 'exp-1', name: 'Experiment A' }],
144+
loaded: true,
145+
error: undefined,
146+
refresh: jest.fn(),
147+
});
148+
149+
renderSelector();
150+
151+
expect(screen.getByTestId('selector-help-text')).toHaveTextContent(
152+
'Type a name to search your 1 experiment.',
153+
);
154+
});
155+
156+
it('should emit status changes to parent through onStatusChange', () => {
157+
const onStatusChange = jest.fn();
158+
const error = new Error('api error');
159+
useMlflowExperimentsMock.mockReturnValue({
160+
data: [],
161+
loaded: true,
162+
error,
163+
refresh: jest.fn(),
164+
});
165+
166+
renderSelector({ onStatusChange });
167+
168+
expect(onStatusChange).toHaveBeenCalledTimes(1);
169+
expect(onStatusChange).toHaveBeenCalledWith({ loaded: true, error });
170+
});
171+
});

0 commit comments

Comments
 (0)