Skip to content

Commit 3b5d646

Browse files
authored
feat(autorag,automl): Updates AutoRAG and AutoML experiments UX so the main “create” action appears in the page header next to the project selector. (opendatahub-io#6929)
* fix(autorag): refresh AutoRAG marketing copy and create CTA label - Replace configure/experiments intro with test-and-tune RAG quality messaging. - Rename experiments list primary action to 'Create RAG optimization run'. - Update AutoragConfigurePage unit tests for the new description text. * fix(autorag): header create action and list status sync - Move Create RAG optimization run beside project selector; show only when the runs list is loaded and non-empty (empty state unchanged). - Propagate list status from AutoragExperiments via onExperimentsListStatus; re-notify on namespace changes so project switches keep the button in sync. - Drop parent reset on namespace that ran after child effects and left the header action stuck hidden. - Add regression test for re-notification when namespace changes with stable loaded/hasExperiments flags. * feat(automl): header Create AutoML experiment and list status sync - Move primary create action beside project selector; show only when runs list is loaded and non-empty (empty state CTA unchanged). - Report list status from AutomlExperiments via onExperimentsListStatus with namespace in effect deps for correct project switches. - Remove toolbar create button from AutomlRunsTable usage. - Add unit tests for AutomlExperiments including list-status re-notification. * fix(autorag,automl): namespace list-status reset and test mock isolation - AutoragExperimentsPage: clear experimentsListStatus on namespace change via useLayoutEffect so the header CTA cannot reflect the prior project before paint, without reordering after the child useEffect. - AutomlExperiments.spec: use resetAllMocks and default getGenericErrorCode to undefined between tests to avoid order-dependent error-code assertions. * fix(autorag,automl): stabilize list-status effect deps and dedupe notify - Use hasLoadError boolean instead of loadError in experiments list-status useEffect dependencies. - Track last (namespace, hasLoadError, loaded, hasExperiments) in a ref to avoid redundant parent updates when identity churns on error objects. Made-with: Cursor * fix(automl): align experiments page with AutoRAG header state; add page tests - AutomlExperimentsPage: useLayoutEffect to clear list status on namespace change (match AutoragExperimentsPage); document rationale. - AutoragExperimentsPage & AutomlExperimentsPage: pass setExperimentsListStatus directly (redundant useCallback removal). - Add AutoragExperimentsPage.spec and AutomlExperimentsPage.spec for header primary CTA visibility vs list status and empty/invalid project cases. Made-with: Cursor * test(autorag,automl): experiments page mocks useEffect + deps, namespace tests - AutomlExperimentsPage.spec: mock AutomlExperiments with useEffect (prod) and [namespace, loaded, hasExperiments] deps to avoid notify spam. - AutoragExperimentsPage.spec: same mock pattern, uiAtPath with route key, notify log, namespace-switch and layout-before-passive coverage. Made-with: Cursor * fix(automl,autorag): align empty-state CTA with optimization run wording Update empty-state create buttons to use product-specific optimization run labels for AutoML and AutoRAG, and adjust tests to match the new UX copy. Made-with: Cursor * fix(autorag): update empty-state body copy Replace the AutoRAG experiments empty-state description with copy that emphasizes testing retrieval and model configurations. Made-with: Cursor * fix(automl,autorag): align empty-state and create-button copy - AutoML: match empty-state body to optimization messaging; rename header create action to Create AutoML optimization run. - AutoRAG: rename empty-state create action to Create RAG optimization run. Made-with: Cursor * fix(automl): refine experiments empty-state body copy Describe AutoML as testing model configurations for classification, regression, and time series instead of retrieval-focused wording. Made-with: Cursor
1 parent 1566582 commit 3b5d646

14 files changed

Lines changed: 842 additions & 84 deletions

File tree

packages/automl/frontend/src/app/components/empty-states/EmptyExperimentsState.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ const EmptyExperimentsState: React.FC<EmptyExperimentsStateProps> = ({
2929
headingLevel="h2"
3030
>
3131
<EmptyStateBody>
32-
To get started, create an AutoML experiment to configure and run your machine learning
33-
workflow.
32+
Test different model configurations to find the best-performing solution for classification,
33+
regression, and time series problems.
3434
</EmptyStateBody>
3535

3636
<EmptyStateFooter>
@@ -40,7 +40,7 @@ const EmptyExperimentsState: React.FC<EmptyExperimentsStateProps> = ({
4040
variant="primary"
4141
onClick={() => navigate(createExperimentRoute)}
4242
>
43-
Create AutoML experiment
43+
Create AutoML optimization run
4444
</Button>
4545
</EmptyStateActions>
4646
</EmptyStateFooter>

packages/automl/frontend/src/app/components/empty-states/__tests__/EmptyExperimentsState.spec.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe('EmptyExperimentsState', () => {
2626
expect(screen.getByText('No experiments yet')).toBeInTheDocument();
2727
expect(
2828
screen.getByText(
29-
'To get started, create an AutoML experiment to configure and run your machine learning workflow.',
29+
'Test different model configurations to find the best-performing solution for classification, regression, and time series problems.',
3030
),
3131
).toBeInTheDocument();
3232
});
@@ -39,7 +39,7 @@ describe('EmptyExperimentsState', () => {
3939
);
4040

4141
expect(screen.getByTestId('create-experiment-button')).toHaveTextContent(
42-
'Create AutoML experiment',
42+
'Create AutoML optimization run',
4343
);
4444
});
4545

packages/automl/frontend/src/app/components/experiments/AutomlExperiments.tsx

Lines changed: 67 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,6 @@
1-
import {
2-
Alert,
3-
Bullseye,
4-
Button,
5-
Spinner,
6-
ToolbarGroup,
7-
ToolbarItem,
8-
} from '@patternfly/react-core';
1+
import { Alert, Bullseye, Spinner } from '@patternfly/react-core';
92
import React from 'react';
10-
import { useNavigate, useParams } from 'react-router';
3+
import { useParams } from 'react-router';
114
import { getGenericErrorCode } from '@odh-dashboard/internal/api/errorUtils';
125
import UnauthorizedError from '@odh-dashboard/internal/pages/UnauthorizedError';
136
import { AutomlRunsTable } from '~/app/components/AutomlRunsTable';
@@ -18,6 +11,21 @@ import { usePipelineDefinitions } from '~/app/hooks/usePipelineDefinitions';
1811
import { usePipelineRuns } from '~/app/hooks/usePipelineRuns';
1912
import { automlCreatePathname } from '~/app/utilities/routes';
2013

14+
export type AutomlExperimentsListStatus = {
15+
/** True once pipeline definitions and runs have finished loading without a blocking list error. */
16+
loaded: boolean;
17+
/** True when at least one experiment (run) exists; false for empty state and error states. */
18+
hasExperiments: boolean;
19+
};
20+
21+
type AutomlExperimentsProps = {
22+
/**
23+
* Fired when list loading / emptiness changes so the host page can tune chrome (e.g. hide the
24+
* header "Create AutoML optimization run" action while the centered empty state is shown).
25+
*/
26+
onExperimentsListStatus?: (status: AutomlExperimentsListStatus) => void;
27+
};
28+
2129
/**
2230
* Extracts HTTP status from Error.message when handleRestFailures (mod-arch-core)
2331
* has flattened AxiosError to a plain Error, so 403/404/503 branches can still run.
@@ -38,8 +46,7 @@ function parseErrorStatus(error: Error): number | undefined {
3846
* Main experiments list page for AutoML. Renders pipeline runs in a paginated table,
3947
* handles loading/error states (403, 404, 503), and shows empty state when no experiments exist.
4048
*/
41-
function AutomlExperiments(): React.JSX.Element {
42-
const navigate = useNavigate();
49+
function AutomlExperiments({ onExperimentsListStatus }: AutomlExperimentsProps): React.JSX.Element {
4350
const { namespace } = useParams();
4451

4552
const effectiveNamespace = namespace ?? '';
@@ -58,18 +65,58 @@ function AutomlExperiments(): React.JSX.Element {
5865

5966
const loaded = defsLoaded && runsLoaded;
6067
const loadError = defsError || runsError;
68+
const hasLoadError = Boolean(loadError);
69+
70+
const hasExperiments = totalSize > 0;
6171

62-
const handleCreateClick = React.useCallback(() => {
63-
navigate(`${automlCreatePathname}/${effectiveNamespace}`);
64-
}, [navigate, effectiveNamespace]);
72+
const onListStatusRef = React.useRef(onExperimentsListStatus);
73+
onListStatusRef.current = onExperimentsListStatus;
6574

66-
const createButton = (
67-
<Button variant="primary" onClick={handleCreateClick}>
68-
Create AutoML experiment
69-
</Button>
70-
);
75+
const prevListStatusRef = React.useRef<{
76+
effectiveNamespace: string;
77+
hasLoadError: boolean;
78+
loaded: boolean;
79+
hasExperiments: boolean;
80+
} | null>(null);
7181

72-
const hasExperiments = totalSize > 0;
82+
React.useEffect(() => {
83+
const notify = onListStatusRef.current;
84+
if (!notify) {
85+
return;
86+
}
87+
88+
let nextLoaded: boolean;
89+
let nextHasExperiments: boolean;
90+
if (hasLoadError) {
91+
nextLoaded = true;
92+
nextHasExperiments = false;
93+
} else if (!loaded) {
94+
nextLoaded = false;
95+
nextHasExperiments = false;
96+
} else {
97+
nextLoaded = true;
98+
nextHasExperiments = hasExperiments;
99+
}
100+
101+
const prev = prevListStatusRef.current;
102+
if (
103+
prev &&
104+
prev.effectiveNamespace === effectiveNamespace &&
105+
prev.hasLoadError === hasLoadError &&
106+
prev.loaded === loaded &&
107+
prev.hasExperiments === hasExperiments
108+
) {
109+
return;
110+
}
111+
112+
notify({ loaded: nextLoaded, hasExperiments: nextHasExperiments });
113+
prevListStatusRef.current = {
114+
effectiveNamespace,
115+
hasLoadError,
116+
loaded,
117+
hasExperiments,
118+
};
119+
}, [effectiveNamespace, hasLoadError, loaded, hasExperiments]);
73120

74121
const errorCode = loadError
75122
? (getGenericErrorCode(loadError) ??
@@ -119,11 +166,6 @@ function AutomlExperiments(): React.JSX.Element {
119166
pageSize={pageSize}
120167
onPageChange={setPage}
121168
onPerPageChange={setPageSize}
122-
toolbarContent={
123-
<ToolbarGroup align={{ default: 'alignEnd' }} style={{ flex: 1 }}>
124-
<ToolbarItem>{createButton}</ToolbarItem>
125-
</ToolbarGroup>
126-
}
127169
/>
128170
);
129171
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/* eslint-disable camelcase -- PipelineRun type uses snake_case */
2+
import '@testing-library/jest-dom';
3+
import React from 'react';
4+
import { render, screen, waitFor } from '@testing-library/react';
5+
import { MemoryRouter } from 'react-router-dom';
6+
import { useParams } from 'react-router';
7+
import AutomlExperiments from '~/app/components/experiments/AutomlExperiments';
8+
import { usePipelineDefinitions } from '~/app/hooks/usePipelineDefinitions';
9+
import { usePipelineRuns } from '~/app/hooks/usePipelineRuns';
10+
import type { PipelineDefinition, PipelineRun } from '~/app/types';
11+
12+
const mockGetGenericErrorCode = jest.fn();
13+
jest.mock('@odh-dashboard/internal/api/errorUtils', () => ({
14+
getGenericErrorCode: (error: unknown) => mockGetGenericErrorCode(error),
15+
}));
16+
17+
jest.mock('react-router', () => ({
18+
...jest.requireActual('react-router'),
19+
useParams: jest.fn(),
20+
}));
21+
22+
jest.mock('~/app/hooks/usePipelineDefinitions', () => ({
23+
usePipelineDefinitions: jest.fn(),
24+
}));
25+
26+
jest.mock('~/app/hooks/usePipelineRuns', () => ({
27+
usePipelineRuns: jest.fn(),
28+
}));
29+
30+
jest.mock('@odh-dashboard/internal/pages/UnauthorizedError', () => ({
31+
__esModule: true,
32+
default: () => <div data-testid="unauthorized-error">Unauthorized</div>,
33+
}));
34+
35+
jest.mock('~/app/components/AutomlRunsTable', () => {
36+
const MockAutomlRunsTable = ({ runs }: { runs: { run_id: string; display_name: string }[] }) => (
37+
<div data-testid="automl-runs-table">
38+
{runs.map((r) => (
39+
<div key={r.run_id} data-testid={`run-${r.run_id}`}>
40+
{r.display_name}
41+
</div>
42+
))}
43+
</div>
44+
);
45+
return {
46+
__esModule: true,
47+
AutomlRunsTable: MockAutomlRunsTable,
48+
};
49+
});
50+
51+
const mockUseParams = jest.mocked(useParams);
52+
const mockUsePipelineDefinitions = jest.mocked(usePipelineDefinitions);
53+
const mockUsePipelineRuns = jest.mocked(usePipelineRuns);
54+
55+
const mockPipelineDefinitions: PipelineDefinition[] = [
56+
{
57+
pipeline_id: 'p1',
58+
display_name: 'Pipeline 1',
59+
created_at: '2025-01-01',
60+
description: 'Desc 1',
61+
},
62+
];
63+
64+
const mockRuns: PipelineRun[] = [
65+
{
66+
run_id: 'r1',
67+
display_name: 'Run 1',
68+
description: 'Run desc',
69+
state: 'SUCCEEDED',
70+
created_at: '2025-01-17',
71+
pipeline_version_reference: { pipeline_id: 'p1', pipeline_version_id: 'v1' },
72+
},
73+
];
74+
75+
const defaultDefsState = {
76+
pipelineDefinitions: mockPipelineDefinitions,
77+
loaded: true,
78+
error: undefined as Error | undefined,
79+
refresh: jest.fn().mockResolvedValue(undefined),
80+
};
81+
82+
const defaultRunsState = {
83+
runs: mockRuns,
84+
totalSize: mockRuns.length,
85+
nextPageToken: '',
86+
page: 1,
87+
pageSize: 20,
88+
setPage: jest.fn(),
89+
setPageSize: jest.fn(),
90+
loaded: true,
91+
error: undefined as Error | undefined,
92+
refresh: jest.fn().mockResolvedValue(undefined),
93+
};
94+
95+
function renderAutoml(ui: React.ReactElement) {
96+
return render(<MemoryRouter>{ui}</MemoryRouter>);
97+
}
98+
99+
describe('AutomlExperiments', () => {
100+
beforeEach(() => {
101+
jest.resetAllMocks();
102+
mockGetGenericErrorCode.mockReturnValue(undefined);
103+
mockUseParams.mockReturnValue({ namespace: 'my-namespace' });
104+
mockUsePipelineDefinitions.mockReturnValue(defaultDefsState);
105+
mockUsePipelineRuns.mockReturnValue(defaultRunsState);
106+
});
107+
108+
it('should show spinner when loading', () => {
109+
mockUsePipelineDefinitions.mockReturnValue({
110+
...defaultDefsState,
111+
loaded: false,
112+
});
113+
mockUsePipelineRuns.mockReturnValue({
114+
...defaultRunsState,
115+
loaded: false,
116+
});
117+
118+
renderAutoml(<AutomlExperiments />);
119+
120+
expect(screen.getByRole('progressbar')).toBeInTheDocument();
121+
});
122+
123+
it('should show EmptyExperimentsState when no experiments', () => {
124+
mockUsePipelineRuns.mockReturnValue({
125+
...defaultRunsState,
126+
runs: [],
127+
totalSize: 0,
128+
});
129+
130+
renderAutoml(<AutomlExperiments />);
131+
132+
expect(screen.getByTestId('empty-experiments-state')).toBeInTheDocument();
133+
expect(screen.getByText('No experiments yet')).toBeInTheDocument();
134+
expect(screen.getByTestId('create-experiment-button')).toHaveTextContent(
135+
'Create AutoML optimization run',
136+
);
137+
expect(screen.queryByTestId('automl-runs-table')).not.toBeInTheDocument();
138+
});
139+
140+
it('should show AutomlRunsTable when there are experiments', () => {
141+
renderAutoml(<AutomlExperiments />);
142+
143+
expect(screen.getByTestId('automl-runs-table')).toBeInTheDocument();
144+
expect(screen.getByTestId('run-r1')).toHaveTextContent('Run 1');
145+
expect(screen.queryByTestId('empty-experiments-state')).not.toBeInTheDocument();
146+
});
147+
148+
it('re-notifies onExperimentsListStatus when namespace changes even if loaded and hasExperiments stay true', async () => {
149+
const onStatus = jest.fn();
150+
mockUseParams.mockReturnValue({ namespace: 'ns-one' });
151+
const { rerender } = renderAutoml(<AutomlExperiments onExperimentsListStatus={onStatus} />);
152+
153+
await waitFor(() => {
154+
expect(onStatus).toHaveBeenCalledWith({ loaded: true, hasExperiments: true });
155+
});
156+
const callsAfterFirstNs = onStatus.mock.calls.length;
157+
158+
mockUseParams.mockReturnValue({ namespace: 'ns-two' });
159+
rerender(
160+
<MemoryRouter>
161+
<AutomlExperiments onExperimentsListStatus={onStatus} />
162+
</MemoryRouter>,
163+
);
164+
165+
await waitFor(() => {
166+
expect(onStatus.mock.calls.length).toBeGreaterThan(callsAfterFirstNs);
167+
});
168+
expect(onStatus).toHaveBeenLastCalledWith({ loaded: true, hasExperiments: true });
169+
});
170+
171+
it('should show error alert on load error', () => {
172+
mockUsePipelineRuns.mockReturnValue({
173+
...defaultRunsState,
174+
error: new Error('Fetch failed'),
175+
});
176+
177+
renderAutoml(<AutomlExperiments />);
178+
179+
expect(screen.getByText('Failed to load experiments')).toBeInTheDocument();
180+
expect(screen.getByText('Fetch failed')).toBeInTheDocument();
181+
});
182+
183+
it('should show NoPipelineServer for 404 error (no DSPA)', () => {
184+
mockGetGenericErrorCode.mockReturnValue(404);
185+
mockUsePipelineRuns.mockReturnValue({
186+
...defaultRunsState,
187+
error: new Error('Not found'),
188+
});
189+
190+
renderAutoml(<AutomlExperiments />);
191+
192+
expect(screen.getByText('No Pipeline Server in this namespace')).toBeInTheDocument();
193+
});
194+
195+
it('should show UnauthorizedError for 403 error', () => {
196+
mockGetGenericErrorCode.mockReturnValue(403);
197+
mockUsePipelineRuns.mockReturnValue({
198+
...defaultRunsState,
199+
error: new Error('Forbidden'),
200+
});
201+
202+
renderAutoml(<AutomlExperiments />);
203+
204+
expect(screen.getByTestId('unauthorized-error')).toBeInTheDocument();
205+
});
206+
207+
it('should show PipelineServerNotReady for 503 error (DSPA not ready)', () => {
208+
mockGetGenericErrorCode.mockReturnValue(503);
209+
mockUsePipelineRuns.mockReturnValue({
210+
...defaultRunsState,
211+
error: new Error('Service Unavailable'),
212+
});
213+
214+
renderAutoml(<AutomlExperiments />);
215+
216+
expect(screen.getByText('Pipeline Server is not ready')).toBeInTheDocument();
217+
});
218+
});

0 commit comments

Comments
 (0)