From 66883b0eedb9fb5d3a3e5ad95461cf22184ba033 Mon Sep 17 00:00:00 2001 From: Riley Bauer <34456002+rileyjbauer@users.noreply.github.com> Date: Tue, 6 Aug 2019 07:09:54 -0700 Subject: [PATCH] Fixes cloning of recurring runs (#1712) * Fixes cloning of recurring runs Simplifies NewRun a little Creates NewRunParameters component * Fixes all NewRun tests * All tests pass * Adds tests for NewRunParameters * Adds clone test to RecurringRunDetails * Clean up --- .../src/components/NewRunParameters.test.tsx | 55 + frontend/src/components/NewRunParameters.tsx | 54 + frontend/src/components/Router.tsx | 1 + .../NewRunParameters.test.tsx.snap | 45 + frontend/src/lib/Buttons.ts | 24 +- frontend/src/lib/RunUtils.ts | 26 +- frontend/src/pages/NewRun.test.tsx | 98 +- frontend/src/pages/NewRun.tsx | 253 +-- .../src/pages/RecurringRunDetails.test.tsx | 18 +- frontend/src/pages/RecurringRunDetails.tsx | 5 +- .../pages/__snapshots__/NewRun.test.tsx.snap | 1414 +++-------------- 11 files changed, 614 insertions(+), 1379 deletions(-) create mode 100644 frontend/src/components/NewRunParameters.test.tsx create mode 100644 frontend/src/components/NewRunParameters.tsx create mode 100644 frontend/src/components/__snapshots__/NewRunParameters.test.tsx.snap diff --git a/frontend/src/components/NewRunParameters.test.tsx b/frontend/src/components/NewRunParameters.test.tsx new file mode 100644 index 00000000000..9e0e4e85122 --- /dev/null +++ b/frontend/src/components/NewRunParameters.test.tsx @@ -0,0 +1,55 @@ +/* + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as React from 'react'; +import { shallow } from 'enzyme'; +import NewRunParameters, { NewRunParametersProps } from './NewRunParameters'; + +describe('NewRunParameters', () => { + it('shows parameters', () => { + const props = { + handleParamChange: jest.fn(), + initialParams: [{ name: 'testParam', value: 'testVal' }], + titleMessage: 'Specify parameters required by the pipeline', + } as NewRunParametersProps; + expect(shallow()).toMatchSnapshot(); + }); + + it('does not display any text fields if there are parameters', () => { + const props = { + handleParamChange: jest.fn(), + initialParams: [], + titleMessage: 'This pipeline has no parameters', + } as NewRunParametersProps; + expect(shallow()).toMatchSnapshot(); + }); + + it('fires handleParamChange callback on change', () => { + const handleParamChange = jest.fn(); + const props = { + handleParamChange, + initialParams: [ + { name: 'testParam1', value: 'testVal1' }, + { name: 'testParam2', value: 'testVal2' } + ], + titleMessage: 'Specify parameters required by the pipeline', + } as NewRunParametersProps; + const tree = shallow(); + tree.find('#newRunPipelineParam1').simulate('change', { target: { value: 'test param value' } }); + expect(handleParamChange).toHaveBeenCalledTimes(1); + expect(handleParamChange).toHaveBeenLastCalledWith(1, 'test param value'); + }); +}); diff --git a/frontend/src/components/NewRunParameters.tsx b/frontend/src/components/NewRunParameters.tsx new file mode 100644 index 00000000000..52a2c653063 --- /dev/null +++ b/frontend/src/components/NewRunParameters.tsx @@ -0,0 +1,54 @@ +/* + * Copyright 2018 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as React from 'react'; +import { commonCss } from '../Css'; +import TextField from '@material-ui/core/TextField'; +import { ApiParameter } from '../apis/pipeline'; + +export interface NewRunParametersProps { + initialParams: ApiParameter[]; + titleMessage: string; + handleParamChange: (index: number, value: string) => void; +} + +class NewRunParameters extends React.Component { + constructor(props: any) { + super(props); + } + + public render(): JSX.Element | null { + const { handleParamChange, initialParams, titleMessage } = this.props; + + return ( +
+
Run parameters
+
{titleMessage}
+ {!!initialParams.length && ( +
+ {initialParams.map((param, i) => + handleParamChange(i, ev.target.value || '')} + style={{ maxWidth: 600 }} className={commonCss.textField}/>)} +
+ )} +
+ ); + } +} + +export default NewRunParameters; diff --git a/frontend/src/components/Router.tsx b/frontend/src/components/Router.tsx index 6ef0514b88e..d6c54ed3a73 100644 --- a/frontend/src/components/Router.tsx +++ b/frontend/src/components/Router.tsx @@ -47,6 +47,7 @@ const css = stylesheet({ export enum QUERY_PARAMS { cloneFromRun = 'cloneFromRun', + cloneFromRecurringRun = 'cloneFromRecurringRun', experimentId = 'experimentId', isRecurring = 'recurring', firstRunInExperiment = 'firstRunInExperiment', diff --git a/frontend/src/components/__snapshots__/NewRunParameters.test.tsx.snap b/frontend/src/components/__snapshots__/NewRunParameters.test.tsx.snap new file mode 100644 index 00000000000..6f6323224fa --- /dev/null +++ b/frontend/src/components/__snapshots__/NewRunParameters.test.tsx.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NewRunParameters does not display any text fields if there are parameters 1`] = ` +
+
+ Run parameters +
+
+ This pipeline has no parameters +
+
+`; + +exports[`NewRunParameters shows parameters 1`] = ` +
+
+ Run parameters +
+
+ Specify parameters required by the pipeline +
+
+ +
+
+`; diff --git a/frontend/src/lib/Buttons.ts b/frontend/src/lib/Buttons.ts index 6956d5b54bf..134360e6609 100644 --- a/frontend/src/lib/Buttons.ts +++ b/frontend/src/lib/Buttons.ts @@ -59,6 +59,17 @@ export default class Buttons { }; } + public cloneRecurringRun(getSelectedIds: () => string[], useCurrentResource: boolean): ToolbarActionConfig { + return { + action: () => this._cloneRun(getSelectedIds(), true), + disabled: !useCurrentResource, + disabledTitle: useCurrentResource ? undefined : 'Select a recurring run to clone', + id: 'cloneBtn', + title: 'Clone recurring run', + tooltip: 'Create a copy from this run\s initial state', + }; + } + public collapseSections(action: () => void): ToolbarActionConfig { return { action, @@ -221,10 +232,19 @@ export default class Buttons { }; } - private _cloneRun(selectedIds: string[]): void { + private _cloneRun(selectedIds: string[], isRecurring?: boolean): void { if (selectedIds.length === 1) { const runId = selectedIds[0]; - const searchString = this._urlParser.build({ [QUERY_PARAMS.cloneFromRun]: runId || '' }); + let searchTerms; + if (isRecurring) { + searchTerms = { + [QUERY_PARAMS.cloneFromRecurringRun]: runId || '', + [QUERY_PARAMS.isRecurring]: '1' + }; + } else { + searchTerms = { [QUERY_PARAMS.cloneFromRun]: runId || '' }; + } + const searchString = this._urlParser.build(searchTerms); this._props.history.push(RoutePage.NEW_RUN + searchString); } } diff --git a/frontend/src/lib/RunUtils.ts b/frontend/src/lib/RunUtils.ts index 6d4ee0a10e2..b7c26ccef15 100644 --- a/frontend/src/lib/RunUtils.ts +++ b/frontend/src/lib/RunUtils.ts @@ -15,8 +15,12 @@ */ import { ApiJob } from '../apis/job'; -import { ApiRun, ApiResourceType, ApiResourceReference } from '../apis/run'; +import { ApiRun, ApiResourceType, ApiResourceReference, ApiRunDetail, ApiPipelineRuntime } from '../apis/run'; import { orderBy } from 'lodash'; +import { ApiParameter } from 'src/apis/pipeline'; +import { Workflow } from 'third_party/argo-ui/argo_template'; +import WorkflowParser from './WorkflowParser'; +import { logger } from './Utils'; export interface MetricMetadata { count: number; @@ -25,6 +29,24 @@ export interface MetricMetadata { name: string; } +function getParametersFromRun(run: ApiRunDetail): ApiParameter[] { + return getParametersFromRuntime(run.pipeline_runtime); +} + +function getParametersFromRuntime(runtime?: ApiPipelineRuntime): ApiParameter[] { + if (!runtime) { + return []; + } + + try { + const workflow = JSON.parse(runtime.workflow_manifest!) as Workflow; + return WorkflowParser.getParameters(workflow); + } catch (err) { + logger.error('Failed to parse runtime workflow manifest', err); + return []; + } +} + function getPipelineId(run?: ApiRun | ApiJob): string | null { return (run && run.pipeline_spec && run.pipeline_spec.pipeline_id) || null; } @@ -106,6 +128,8 @@ export default { getAllExperimentReferences, getFirstExperimentReference, getFirstExperimentReferenceId, + getParametersFromRun, + getParametersFromRuntime, getPipelineId, getPipelineSpec, getRecurringRunId, diff --git a/frontend/src/pages/NewRun.test.tsx b/frontend/src/pages/NewRun.test.tsx index 4c4df1bb706..db7068e8904 100644 --- a/frontend/src/pages/NewRun.test.tsx +++ b/frontend/src/pages/NewRun.test.tsx @@ -29,6 +29,7 @@ class TestNewRun extends NewRun { public _experimentSelectorClosed = super._experimentSelectorClosed; public _pipelineSelectorClosed = super._pipelineSelectorClosed; public _updateRecurringRunState = super._updateRecurringRunState; + public _handleParamChange = super._handleParamChange; } describe('NewRun', () => { @@ -63,8 +64,8 @@ describe('NewRun', () => { function newMockPipeline(): ApiPipeline { return { - id: 'some-mock-pipeline-id', - name: 'some mock pipeline name', + id: 'original-run-pipeline-id', + name: 'original mock pipeline name', parameters: [], }; } @@ -78,7 +79,8 @@ describe('NewRun', () => { id: 'some-mock-run-id', name: 'some mock run name', pipeline_spec: { - pipeline_id: 'original-run-pipeline-id' + pipeline_id: 'original-run-pipeline-id', + workflow_manifest: '{}', }, }, }; @@ -87,7 +89,7 @@ describe('NewRun', () => { function newMockRunWithEmbeddedPipeline(): ApiRunDetail { const runDetail = newMockRunDetail(); delete runDetail.run!.pipeline_spec!.pipeline_id; - runDetail.run!.pipeline_spec!.workflow_manifest = '{"parameters": []}'; + runDetail.run!.pipeline_spec!.workflow_manifest = '{"metadata": {"name": "embedded"}, "parameters": []}'; return runDetail; } @@ -149,7 +151,7 @@ describe('NewRun', () => { expect(updateToolbarSpy).toHaveBeenLastCalledWith({ actions: [], breadcrumbs: [{ displayName: 'Experiments', href: RoutePage.EXPERIMENTS }], - pageTitle: 'Start a new run', + pageTitle: 'Start a run', }); }); @@ -262,7 +264,7 @@ describe('NewRun', () => { href: RoutePage.EXPERIMENT_DETAILS.replace(':' + RouteParams.experimentId, MOCK_EXPERIMENT.id!), }, ], - pageTitle: 'Start a new run', + pageTitle: 'Start a run', }); }); @@ -632,7 +634,7 @@ describe('NewRun', () => { await TestUtils.flushPromises(); expect(updateBannerSpy).toHaveBeenLastCalledWith(expect.objectContaining({ - message: 'Could not find the cloned run\'s pipeline definition.', + message: 'Error: failed to read the clone run\'s pipeline definition. Click Details for more information.', mode: 'error', })); }); @@ -680,53 +682,13 @@ describe('NewRun', () => { await TestUtils.flushPromises(); expect(updateBannerSpy).toHaveBeenCalledTimes(1); - expect(tree.state('pipelineFromRun')).toEqual({ parameters: [] }); - expect(tree.state('usePipelineFromRun')).toBe(true); - }); - - it('shows switching controls when run has embedded pipeline, selects that pipeline by default,' + - ' and hides pipeline selector', async () => { - const props = generateProps(); - props.location.search = `?${QUERY_PARAMS.cloneFromRun}=${MOCK_RUN_WITH_EMBEDDED_PIPELINE.run!.id}`; - - getRunSpy.mockImplementation(() => MOCK_RUN_WITH_EMBEDDED_PIPELINE); - - tree = shallow(); - await TestUtils.flushPromises(); - expect(tree).toMatchSnapshot(); - }); - - it('shows pipeline selector when switching from embedded pipeline to select pipeline', async () => { - const props = generateProps(); - props.location.search = `?${QUERY_PARAMS.cloneFromRun}=${MOCK_RUN_WITH_EMBEDDED_PIPELINE.run!.id}`; - - getRunSpy.mockImplementation(() => MOCK_RUN_WITH_EMBEDDED_PIPELINE); - - tree = shallow(); - await TestUtils.flushPromises(); - tree.find('WithStyles(WithFormControlContext(FormControlLabel))').at(1).simulate('change'); - expect(tree).toMatchSnapshot(); - }); - - it('resets selected pipeline from embedded when switching to select from pipeline list, and back', async () => { - const props = generateProps(); - props.location.search = `?${QUERY_PARAMS.cloneFromRun}=${MOCK_RUN_WITH_EMBEDDED_PIPELINE.run!.id}`; - - getRunSpy.mockImplementation(() => MOCK_RUN_WITH_EMBEDDED_PIPELINE); - - tree = shallow(); - await TestUtils.flushPromises(); - expect(tree.state('pipeline')).toEqual({ parameters: [] }); - tree.find('WithStyles(WithFormControlContext(FormControlLabel))').at(1).simulate('change'); - expect(tree.state('pipeline')).toBeUndefined(); - - tree.find('WithStyles(WithFormControlContext(FormControlLabel))').at(0).simulate('change'); - expect(tree.state('pipeline')).toEqual({ parameters: [] }); + expect(tree.state('workflowFromRun')).toEqual({ metadata: { name: 'embedded' }, parameters: [] }); + expect(tree.state('useWorkflowFromRun')).toBe(true); }); it('shows a page error if the original run\'s workflow_manifest is undefined', async () => { const runDetail = newMockRunDetail(); - runDetail.pipeline_runtime!.workflow_manifest = undefined; + runDetail.run!.pipeline_spec!.workflow_manifest = undefined; const props = generateProps(); props.location.search = `?${QUERY_PARAMS.cloneFromRun}=${runDetail.run!.id}`; @@ -742,8 +704,8 @@ describe('NewRun', () => { }); it('shows a page error if the original run\'s workflow_manifest is invalid JSON', async () => { - const runDetail = newMockRunDetail(); - runDetail.pipeline_runtime!.workflow_manifest = 'not json'; + const runDetail = newMockRunWithEmbeddedPipeline(); + runDetail.run!.pipeline_spec!.workflow_manifest = 'not json'; const props = generateProps(); props.location.search = `?${QUERY_PARAMS.cloneFromRun}=${runDetail.run!.id}`; @@ -753,7 +715,7 @@ describe('NewRun', () => { await TestUtils.flushPromises(); expect(updateBannerSpy).toHaveBeenLastCalledWith(expect.objectContaining({ - message: 'Error: failed to parse the original run\'s runtime. Click Details for more information.', + message: 'Error: failed to read the clone run\'s pipeline definition. Click Details for more information.', mode: 'error', })); }); @@ -778,7 +740,7 @@ describe('NewRun', () => { tree = shallow(); await TestUtils.flushPromises(); - expect(tree.state('pipeline')).toHaveProperty('parameters', originalRunPipelineParams); + expect(tree.state('parameters')).toEqual(originalRunPipelineParams); }); @@ -812,8 +774,8 @@ describe('NewRun', () => { tree = shallow(); await TestUtils.flushPromises(); - expect(tree.state('usePipelineFromRun')).toBe(true); - expect(tree.state('usePipelineFromRunLabel')).toBe('Use pipeline from previous step'); + expect(tree.state('useWorkflowFromRun')).toBe(true); + expect(tree.state('usePipelineFromRunLabel')).toBe('Using pipeline from previous page'); expect(tree).toMatchSnapshot(); }); @@ -824,15 +786,15 @@ describe('NewRun', () => { expect(getRunSpy).toHaveBeenLastCalledWith(MOCK_RUN_WITH_EMBEDDED_PIPELINE.run!.id); }); - it('parses the embedded pipeline and stores it in state', async () => { + it('parses the embedded workflow and stores it in state', async () => { MOCK_RUN_WITH_EMBEDDED_PIPELINE.run!.pipeline_spec!.workflow_manifest = JSON.stringify(MOCK_PIPELINE); tree = shallow(); await TestUtils.flushPromises(); - expect(tree.state('pipeline')).toEqual(MOCK_PIPELINE); - expect(tree.state('pipelineFromRun')).toEqual(MOCK_PIPELINE); - expect(tree.state('pipelineName')).toEqual(MOCK_PIPELINE.name); + expect(tree.state('workflowFromRun')).toEqual(MOCK_PIPELINE); + expect(tree.state('parameters')).toEqual(MOCK_PIPELINE.parameters); + expect(tree.state('useWorkflowFromRun')).toBe(true); }); it('displays a page error if it fails to parse the embedded pipeline', async () => { @@ -909,7 +871,7 @@ describe('NewRun', () => { expect(tree.find('#startNewRunBtn').props()).toHaveProperty('disabled', true); }); - it('sends a request to start a new run when \'Start\' is clicked', async () => { + it('sends a request to Start a run when \'Start\' is clicked', async () => { const props = generateProps(); props.location.search = `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` @@ -942,7 +904,7 @@ describe('NewRun', () => { }); }); - it('updates the pipeline in state when a user fills in its params', async () => { + it('updates the parameters in state on handleParamChange', async () => { const props = generateProps(); const pipeline = newMockPipeline(); pipeline.parameters = [ @@ -957,8 +919,7 @@ describe('NewRun', () => { await TestUtils.flushPromises(); (tree.instance() as TestNewRun).handleChange('runName')({ target: { value: 'test run name' } }); // Fill in the first pipeline parameter - tree.find('#newRunPipelineParam0').simulate('change', { target: { value: 'test param value' } }); - await TestUtils.flushPromises(); + (tree.instance() as TestNewRun)._handleParamChange(0, 'test param value'); tree.find('#startNewRunBtn').simulate('click'); // The start APIs are called in a callback triggered by clicking 'Start', so we wait again @@ -991,13 +952,16 @@ describe('NewRun', () => { await TestUtils.flushPromises(); expect(startRunSpy).toHaveBeenCalledTimes(1); - expect(startRunSpy).toHaveBeenLastCalledWith(expect.objectContaining({ + expect(startRunSpy).toHaveBeenLastCalledWith({ + description: '', + name: 'Clone of ' + MOCK_RUN_WITH_EMBEDDED_PIPELINE.run!.name, pipeline_spec: { parameters: [], pipeline_id: undefined, - workflow_manifest: '{"parameters":[]}', + workflow_manifest: '{"metadata":{"name":"embedded"},"parameters":[]}', }, - })); + resource_references: [], + }); expect(tree).toMatchSnapshot(); }); diff --git a/frontend/src/pages/NewRun.tsx b/frontend/src/pages/NewRun.tsx index 56f96134238..5d2f061fdfa 100644 --- a/frontend/src/pages/NewRun.tsx +++ b/frontend/src/pages/NewRun.tsx @@ -23,15 +23,15 @@ import DialogContent from '@material-ui/core/DialogContent'; import FormControlLabel from '@material-ui/core/FormControlLabel'; import Input from '../atoms/Input'; import InputAdornment from '@material-ui/core/InputAdornment'; +import NewRunParameters from '../components/NewRunParameters'; import Radio from '@material-ui/core/Radio'; -import RunUtils from '../lib/RunUtils'; import ResourceSelector from './ResourceSelector'; -import TextField, { TextFieldProps } from '@material-ui/core/TextField'; +import RunUtils from '../lib/RunUtils'; +import { TextFieldProps } from '@material-ui/core/TextField'; import Trigger from '../components/Trigger'; -import WorkflowParser from '../lib/WorkflowParser'; import { ApiExperiment } from '../apis/experiment'; -import { ApiPipeline } from '../apis/pipeline'; -import { ApiRun, ApiResourceReference, ApiRelationship, ApiResourceType, ApiRunDetail } from '../apis/run'; +import { ApiPipeline, ApiParameter } from '../apis/pipeline'; +import { ApiRun, ApiResourceReference, ApiRelationship, ApiResourceType, ApiRunDetail, ApiPipelineRuntime } from '../apis/run'; import { ApiTrigger, ApiJob } from '../apis/job'; import { Apis, PipelineSortKeys, ExperimentSortKeys } from '../lib/Apis'; import { Link } from 'react-router-dom'; @@ -51,15 +51,15 @@ interface NewRunState { experimentName: string; experimentSelectorOpen: boolean; isBeingStarted: boolean; + isClone: boolean; isFirstRunInExperiment: boolean; isRecurringRun: boolean; maxConcurrentRuns?: string; + parameters: ApiParameter[]; pipeline?: ApiPipeline; // This represents a pipeline from a run that is being cloned, or if a user is creating a run from // a pipeline that was not uploaded to the system (as in the case of runs created from notebooks). - // By storing this here instead of in the 'pipeline' field, we won't lose it if the user selects a - // different pipeline. - pipelineFromRun?: ApiPipeline; + workflowFromRun?: Workflow; // TODO: this is only here to properly display the name in the text field. // There is definitely a way to do this that doesn't necessitate this being in state. // Note: this cannot be undefined/optional or the label animation for the input field will not @@ -70,7 +70,7 @@ interface NewRunState { trigger?: ApiTrigger; unconfirmedSelectedExperiment?: ApiExperiment; unconfirmedSelectedPipeline?: ApiPipeline; - usePipelineFromRun: boolean; + useWorkflowFromRun: boolean; usePipelineFromRunLabel: string; } @@ -106,13 +106,15 @@ class NewRun extends Page<{}, NewRunState> { experimentName: '', experimentSelectorOpen: false, isBeingStarted: false, + isClone: false, isFirstRunInExperiment: false, isRecurringRun: false, + parameters: [], pipelineName: '', pipelineSelectorOpen: false, runName: '', - usePipelineFromRun: false, - usePipelineFromRunLabel: 'Use pipeline from cloned run', + usePipelineFromRunLabel: 'Using pipeline from cloned run', + useWorkflowFromRun: false, }; } @@ -126,27 +128,29 @@ class NewRun extends Page<{}, NewRunState> { public render(): JSX.Element { const { - pipelineFromRun, + workflowFromRun, description, errorMessage, experimentName, experimentSelectorOpen, - isRecurringRun, + isClone, isFirstRunInExperiment, - pipeline, + isRecurringRun, + parameters, pipelineName, pipelineSelectorOpen, runName, unconfirmedSelectedExperiment, unconfirmedSelectedPipeline, - usePipelineFromRun, usePipelineFromRunLabel, + useWorkflowFromRun, } = this.state; - const originalRunId = new URLParser(this.props).get(QUERY_PARAMS.cloneFromRun); + const urlParser = new URLParser(this.props); + const originalRunId = urlParser.get(QUERY_PARAMS.cloneFromRun) || urlParser.get(QUERY_PARAMS.fromRunId); const pipelineDetailsUrl = originalRunId ? RoutePage.PIPELINE_DETAILS.replace(':' + RouteParams.pipelineId + '?', '') + - new URLParser(this.props).build({ [QUERY_PARAMS.fromRunId]: originalRunId }) + urlParser.build({ [QUERY_PARAMS.fromRunId]: originalRunId }) : ''; return ( @@ -156,16 +160,13 @@ class NewRun extends Page<{}, NewRunState> {
Run details
{/* Pipeline selection */} - {!!pipelineFromRun && ( - } - onChange={() => this.setStateSafe({ pipeline: pipelineFromRun, usePipelineFromRun: true })} - checked={usePipelineFromRun} /> - {!!originalRunId && [View pipeline]} - } - onChange={() => this.setStateSafe({ pipeline: undefined, usePipelineFromRun: false })} - checked={!usePipelineFromRun} /> - )} - {!usePipelineFromRun && ( + {!!workflowFromRun && ( +
+ {usePipelineFromRunLabel} + {!!originalRunId && [View pipeline]} +
+ )} + {!useWorkflowFromRun && ( { }} /> - {/* One-off/Recurring Toggle */} + {/* One-off/Recurring Run Type */}
Run Type
- } - onChange={() => this._updateRecurringRunState(false)} - checked={!isRecurringRun} /> - } - onChange={() => this._updateRecurringRunState(true)} - checked={isRecurringRun} /> + {isClone && ( + {isRecurringRun ? 'Recurring' : 'One-off'} + )} + {!isClone && ( + + } + onChange={() => this._updateRecurringRunState(false)} + checked={!isRecurringRun} /> + } + onChange={() => this._updateRecurringRunState(true)} + checked={isRecurringRun} /> + + )} {/* Recurring run controls */} {isRecurringRun && ( @@ -295,17 +303,11 @@ class NewRun extends Page<{}, NewRunState> { )} {/* Run parameters form */} -
Run parameters
-
{this._runParametersMessage(pipeline)}
- {pipeline && Array.isArray(pipeline.parameters) && !!pipeline.parameters.length && ( -
- {pipeline.parameters.map((param, i) => - this._handleParamChange(i, ev.target.value || '')} - style={{ maxWidth: 600 }} className={commonCss.textField}/>)} -
- )} + {/* Create/Cancel buttons */}
@@ -344,21 +346,42 @@ class NewRun extends Page<{}, NewRunState> { // Get clone run id from querystring if any const originalRunId = urlParser.get(QUERY_PARAMS.cloneFromRun); - // If we are not cloning, we may have an embedded pipeline from a run from a Notebook + const originalRecurringRunId = urlParser.get(QUERY_PARAMS.cloneFromRecurringRun); + // If we are not cloning from an existing run, we may have an embedded pipeline from a run from + // a notebook. This is a somewhat hidden path that can be reached via the following steps: + // 1. Create a pipeline and run it from a notebook + // 2. Click [View Pipeline] for this run from one of the list pages + // (Now you will be viewing a pipeline details page for a pipeline that hasn't been uploaded) + // 3. Click Create run const embeddedPipelineRunId = urlParser.get(QUERY_PARAMS.fromRunId); if (originalRunId) { + // If we are cloning a run, fetch the original + try { + const originalRun = await Apis.runServiceApi.getRun(originalRunId); + await this._prepareFormFromClone(originalRun.run, originalRun.pipeline_runtime); + // If the querystring did not contain an experiment ID, try to get one from the run. + if (!experimentId) { + experimentId = RunUtils.getFirstExperimentReferenceId(originalRun.run); + } + } catch (err) { + await this.showPageError(`Error: failed to retrieve original run: ${originalRunId}.`, err); + logger.error(`Failed to retrieve original run: ${originalRunId}`, err); + } + + + } else if (originalRecurringRunId) { + // If we are cloning a recurring run, fetch the original try { - const originalRun = await Apis.runServiceApi.getRun(originalRunId); + const originalRun = await Apis.jobServiceApi.getJob(originalRecurringRunId); await this._prepareFormFromClone(originalRun); - // If the querystring did not contain an experiment ID, try to get one from the run. if (!experimentId) { - experimentId = RunUtils.getFirstExperimentReferenceId(originalRun.run); + experimentId = RunUtils.getFirstExperimentReferenceId(originalRun); } } catch (err) { - await this.showPageError(`Error: failed to retrieve original run: ${originalRunId}.`, err); - logger.error(`Failed to retrieve original run: ${originalRunId}`, err); + await this.showPageError(`Error: failed to retrieve original recurring run: ${originalRunId}.`, err); + logger.error(`Failed to retrieve original recurring run: ${originalRunId}`, err); } - } else if(embeddedPipelineRunId) { + } else if (embeddedPipelineRunId) { this._prepareFormFromEmbeddedPipeline(embeddedPipelineRunId); } else { // Get pipeline id from querystring if any @@ -366,7 +389,11 @@ class NewRun extends Page<{}, NewRunState> { if (possiblePipelineId) { try { const pipeline = await Apis.pipelineServiceApi.getPipeline(possiblePipelineId); - this.setStateSafe({ pipeline, pipelineName: (pipeline && pipeline.name) || '' }); + this.setStateSafe({ + parameters: pipeline.parameters || [], + pipeline, + pipelineName: (pipeline && pipeline.name) || '' + }); } catch (err) { urlParser.clear(QUERY_PARAMS.pipelineId); await this.showPageError( @@ -395,11 +422,10 @@ class NewRun extends Page<{}, NewRunState> { } const isRecurringRun = urlParser.get(QUERY_PARAMS.isRecurring) === '1'; - this.props.updateToolbar({ - actions: this.props.toolbarProps.actions, - breadcrumbs, - pageTitle: isRecurringRun ? 'Start a recurring run' : 'Start a new run', - }); + const titleVerb = originalRunId ? 'Clone' : 'Start'; + const pageTitle = isRecurringRun ? `${titleVerb} a recurring run` : `${titleVerb} a run`; + + this.props.updateToolbar({ actions: this.props.toolbarProps.actions, breadcrumbs, pageTitle }); this.setStateSafe({ experiment, @@ -430,12 +456,14 @@ class NewRun extends Page<{}, NewRunState> { } protected async _pipelineSelectorClosed(confirmed: boolean): Promise { - let { pipeline } = this.state; + let { parameters, pipeline } = this.state; if (confirmed && this.state.unconfirmedSelectedPipeline) { pipeline = this.state.unconfirmedSelectedPipeline; + parameters = pipeline.parameters || []; } this.setStateSafe({ + parameters, pipeline, pipelineName: (pipeline && pipeline.name) || '', pipelineSelectorOpen: false @@ -449,11 +477,18 @@ class NewRun extends Page<{}, NewRunState> { this.setStateSafe({ isRecurringRun }); } + protected _handleParamChange(index: number, value: string): void { + const { parameters } = this.state; + parameters[index].value = value; + this.setStateSafe({ parameters }); + } + private async _prepareFormFromEmbeddedPipeline(embeddedPipelineRunId: string): Promise { let embeddedPipelineSpec: string | null; + let runWithEmbeddedPipeline: ApiRunDetail; try { - const runWithEmbeddedPipeline = await Apis.runServiceApi.getRun(embeddedPipelineRunId); + runWithEmbeddedPipeline = await Apis.runServiceApi.getRun(embeddedPipelineRunId); embeddedPipelineSpec = RunUtils.getPipelineSpec(runWithEmbeddedPipeline.run); } catch (err) { await this.showPageError( @@ -469,13 +504,13 @@ class NewRun extends Page<{}, NewRunState> { } try { - const pipeline = JSON.parse(embeddedPipelineSpec); + const workflow: Workflow = JSON.parse(embeddedPipelineSpec); + const parameters = RunUtils.getParametersFromRun(runWithEmbeddedPipeline); this.setStateSafe({ - pipeline, - pipelineFromRun: pipeline, - pipelineName: (pipeline && pipeline.name) || '', - usePipelineFromRun: true, - usePipelineFromRunLabel: 'Use pipeline from previous step', + parameters, + usePipelineFromRunLabel: 'Using pipeline from previous page', + useWorkflowFromRun: true, + workflowFromRun: workflow, }); } catch (err) { await this.showPageError( @@ -487,78 +522,75 @@ class NewRun extends Page<{}, NewRunState> { this._validate(); } - private async _prepareFormFromClone(originalRun: ApiRunDetail): Promise { - if (!originalRun.run) { + private async _prepareFormFromClone(originalRun?: ApiRun | ApiJob, runtime?: ApiPipelineRuntime): Promise { + if (!originalRun) { logger.error('Could not get cloned run details'); return; } - let pipeline: ApiPipeline; - let workflow: Workflow; - let pipelineFromRun: ApiPipeline; - let usePipelineFromRun = false; + let pipeline: ApiPipeline | undefined; + let workflowFromRun: Workflow | undefined; + let useWorkflowFromRun = false; let usePipelineFromRunLabel = ''; + let name = ''; // This corresponds to a run using a pipeline that has been uploaded - const referencePipelineId = RunUtils.getPipelineId(originalRun.run); + const referencePipelineId = RunUtils.getPipelineId(originalRun); // This corresponds to a run where the pipeline has not been uploaded, such as runs started from // the CLI or notebooks - const embeddedPipelineSpec = RunUtils.getPipelineSpec(originalRun.run); + const embeddedPipelineSpec = RunUtils.getPipelineSpec(originalRun); if (referencePipelineId) { try { pipeline = await Apis.pipelineServiceApi.getPipeline(referencePipelineId); + name = pipeline.name || ''; } catch (err) { await this.showPageError( 'Error: failed to find a pipeline corresponding to that of the original run:' - + ` ${originalRun.run.id}.`, err); + + ` ${originalRun.id}.`, err); return; } } else if (embeddedPipelineSpec) { try { - pipeline = JSON.parse(embeddedPipelineSpec); - pipelineFromRun = pipeline; + workflowFromRun = JSON.parse(embeddedPipelineSpec); + name = workflowFromRun!.metadata.name || ''; } catch (err) { await this.showPageError('Error: failed to read the clone run\'s pipeline definition.', err); return; } - usePipelineFromRun = true; - usePipelineFromRunLabel = 'Use pipeline from cloned run'; + useWorkflowFromRun = true; + usePipelineFromRunLabel = 'Using pipeline from cloned run'; } else { await this.showPageError('Could not find the cloned run\'s pipeline definition.'); return; } - if (originalRun.pipeline_runtime!.workflow_manifest === undefined) { - await this.showPageError(`Error: run ${originalRun.run.id} had no workflow manifest`); - logger.error(originalRun.pipeline_runtime!.workflow_manifest); - return; - } - try { - workflow = JSON.parse(originalRun.pipeline_runtime!.workflow_manifest!) as Workflow; - } catch (err) { - await this.showPageError('Error: failed to parse the original run\'s runtime.', err); - logger.error(originalRun.pipeline_runtime!.workflow_manifest); + if (!originalRun.pipeline_spec || !originalRun.pipeline_spec.workflow_manifest) { + await this.showPageError(`Error: run ${originalRun.id} had no workflow manifest`); return; } - // Set pipeline parameter values from run's workflow - pipeline.parameters = WorkflowParser.getParameters(workflow); + const parameters = runtime + ? await RunUtils.getParametersFromRuntime(runtime) // cloned from run + : originalRun.pipeline_spec.parameters || []; // cloned from recurring run this.setStateSafe({ + isClone: true, + parameters, pipeline, - pipelineFromRun: pipelineFromRun!, - pipelineName: (pipeline && pipeline.name) || '', - runName: this._getCloneName(originalRun.run.name!), - usePipelineFromRun, + pipelineName: name, + runName: this._getCloneName(originalRun.name!), usePipelineFromRunLabel, + useWorkflowFromRun, + workflowFromRun, }); this._validate(); } - private _runParametersMessage(selectedPipeline: ApiPipeline | undefined): string { - if (selectedPipeline) { - if (selectedPipeline.parameters && selectedPipeline.parameters.length) { + + private _runParametersMessage(): string { + if (this.state.pipeline || this.state.workflowFromRun) { + if (this.state.parameters.length) { return 'Specify parameters required by the pipeline'; } else { return 'This pipeline has no parameters'; @@ -568,9 +600,7 @@ class NewRun extends Page<{}, NewRunState> { } private _start(): void { - // TODO: This cannot currently be reached because _validate() is called everywhere and blocks - // the button from being clicked without first having a pipeline. - if (!this.state.pipeline) { + if (!this.state.pipeline && !this.state.workflowFromRun) { this.showErrorDialog('Run creation failed', 'Cannot start run without pipeline'); logger.error('Cannot start run without pipeline'); return; @@ -590,12 +620,12 @@ class NewRun extends Page<{}, NewRunState> { description: this.state.description, name: this.state.runName, pipeline_spec: { - parameters: (this.state.pipeline.parameters || []).map(p => { + parameters: (this.state.parameters || []).map(p => { p.value = (p.value || '').trim(); return p; }), - pipeline_id: this.state.usePipelineFromRun ? undefined : this.state.pipeline.id, - workflow_manifest: this.state.usePipelineFromRun - ? JSON.stringify(this.state.pipelineFromRun) + pipeline_id: this.state.pipeline ? this.state.pipeline.id : undefined, + workflow_manifest: this.state.useWorkflowFromRun + ? JSON.stringify(this.state.workflowFromRun) : undefined, }, resource_references: references, @@ -639,15 +669,6 @@ class NewRun extends Page<{}, NewRunState> { }); } - private _handleParamChange(index: number, value: string): void { - const { pipeline } = this.state; - if (!pipeline || !pipeline.parameters) { - return; - } - pipeline.parameters[index].value = value; - this.setStateSafe({ pipeline }); - } - private _getCloneName(oldName: string): string { const numberRegex = /Clone(?: \(([0-9]*)\))? of (.*)/; const match = oldName.match(numberRegex); @@ -661,9 +682,9 @@ class NewRun extends Page<{}, NewRunState> { private _validate(): void { // Validate state - const { pipeline, maxConcurrentRuns, runName, trigger } = this.state; + const { pipeline, workflowFromRun, maxConcurrentRuns, runName, trigger } = this.state; try { - if (!pipeline) { + if (!pipeline && !workflowFromRun) { throw new Error('A pipeline must be selected'); } if (!runName) { diff --git a/frontend/src/pages/RecurringRunDetails.test.tsx b/frontend/src/pages/RecurringRunDetails.test.tsx index 2c4c651afaf..4408416dc99 100644 --- a/frontend/src/pages/RecurringRunDetails.test.tsx +++ b/frontend/src/pages/RecurringRunDetails.test.tsx @@ -20,7 +20,7 @@ import TestUtils from '../TestUtils'; import { ApiJob, ApiResourceType } from '../apis/job'; import { Apis } from '../lib/Apis'; import { PageProps } from './Page'; -import { RouteParams, RoutePage } from '../components/Router'; +import { RouteParams, RoutePage, QUERY_PARAMS } from '../components/Router'; import { ToolbarActionConfig } from '../components/Toolbar'; import { shallow, ReactWrapper, ShallowWrapper } from 'enzyme'; @@ -185,6 +185,22 @@ describe('RecurringRunDetails', () => { expect(getJobSpy).toHaveBeenCalledTimes(2); }); + + it('has a clone button, clicking it navigates to new run page', async () => { + tree = shallow(); + await TestUtils.flushPromises(); + const instance = tree.instance() as RecurringRunDetails; + const cloneBtn = instance.getInitialToolbarState().actions.find( + b => b.title === 'Clone recurring run'); + expect(cloneBtn).toBeDefined(); + await cloneBtn!.action(); + expect(historyPushSpy).toHaveBeenCalledTimes(1); + expect(historyPushSpy).toHaveBeenLastCalledWith( + RoutePage.NEW_RUN + + `?${QUERY_PARAMS.cloneFromRecurringRun}=${fullTestJob!.id}` + + `&${QUERY_PARAMS.isRecurring}=1`); + }); + it('shows enabled Disable, and disabled Enable buttons if the run is enabled', async () => { tree = shallow(); await TestUtils.flushPromises(); diff --git a/frontend/src/pages/RecurringRunDetails.tsx b/frontend/src/pages/RecurringRunDetails.tsx index 184e6e58c7b..b02008a2625 100644 --- a/frontend/src/pages/RecurringRunDetails.tsx +++ b/frontend/src/pages/RecurringRunDetails.tsx @@ -47,6 +47,7 @@ class RecurringRunDetails extends Page<{}, RecurringRunConfigState> { const buttons = new Buttons(this.props, this.refresh.bind(this)); return { actions: [ + buttons.cloneRecurringRun(() => this.state.run ? [this.state.run.id!] : [], true), buttons.refresh(this.refresh.bind(this)), buttons.enableRecurringRun(() => this.state.run ? this.state.run.id! : ''), buttons.disableRecurringRun(() => this.state.run ? this.state.run.id! : ''), @@ -170,8 +171,8 @@ class RecurringRunDetails extends Page<{}, RecurringRunConfigState> { const pageTitle = run ? run.name! : runId; const toolbarActions = [...this.props.toolbarProps.actions]; - toolbarActions[1].disabled = !!run.enabled; - toolbarActions[2].disabled = !run.enabled; + toolbarActions[2].disabled = !!run.enabled; + toolbarActions[3].disabled = !run.enabled; this.props.updateToolbar({ actions: toolbarActions, breadcrumbs, pageTitle }); diff --git a/frontend/src/pages/__snapshots__/NewRun.test.tsx.snap b/frontend/src/pages/__snapshots__/NewRun.test.tsx.snap index e313498ec79..8a344756df2 100644 --- a/frontend/src/pages/__snapshots__/NewRun.test.tsx.snap +++ b/frontend/src/pages/__snapshots__/NewRun.test.tsx.snap @@ -12,26 +12,18 @@ exports[`NewRun arriving from pipeline details page indicates that a pipeline is > Run details
- - } - label="Use pipeline from previous step" - onChange={[Function]} - /> - - } - label="Select a pipeline from list" - onChange={[Function]} - /> +
+ + Using pipeline from previous page + + + + [View pipeline] + +
} + id="recurringToggle" label="Recurring" onChange={[Function]} /> -
- Run parameters -
-
- This pipeline has no parameters -
+
@@ -548,7 +537,7 @@ exports[`NewRun changes the exit button's text if query params indicate this is "href": "/experiments/details/some-mock-experiment-id", }, ], - "pageTitle": "Start a new run", + "pageTitle": "Start a run", }, ], ], @@ -678,7 +667,7 @@ exports[`NewRun changes the exit button's text if query params indicate this is "href": "/experiments/details/some-mock-experiment-id", }, ], - "pageTitle": "Start a new run", + "pageTitle": "Start a run", }, ], ], @@ -775,20 +764,17 @@ exports[`NewRun changes the exit button's text if query params indicate this is control={ } + id="recurringToggle" label="Recurring" onChange={[Function]} /> -
- Run parameters -
-
- Parameters will appear after you select a pipeline -
+
@@ -968,7 +954,7 @@ exports[`NewRun changes title and form if the new run will recur, based on the r "href": "/experiments/details/some-mock-experiment-id", }, ], - "pageTitle": "Start a new run", + "pageTitle": "Start a run", }, ], Array [ @@ -1046,887 +1032,7 @@ exports[`NewRun changes title and form if the new run will recur, based on the r location={ Object { "pathname": "/runs/new", - "search": "?experimentId=some-mock-experiment-id", - } - } - match="" - selectionChanged={[Function]} - title="Choose an experiment" - toolbarProps={ - Object { - "actions": Array [], - "breadcrumbs": Array [ - Object { - "displayName": "Experiments", - "href": "/experiments", - }, - ], - "pageTitle": "Start a new run", - } - } - updateBanner={ - [MockFunction] { - "calls": Array [ - Array [ - Object {}, - ], - ], - } - } - updateDialog={[MockFunction]} - updateSnackbar={[MockFunction]} - updateToolbar={ - [MockFunction] { - "calls": Array [ - Array [ - Object { - "actions": Array [], - "breadcrumbs": Array [ - Object { - "displayName": "Experiments", - "href": "/experiments", - }, - ], - "pageTitle": "Start a new run", - }, - ], - Array [ - Object { - "actions": Array [], - "breadcrumbs": Array [ - Object { - "displayName": "Experiments", - "href": "/experiments", - }, - Object { - "displayName": "some mock experiment name", - "href": "/experiments/details/some-mock-experiment-id", - }, - ], - "pageTitle": "Start a new run", - }, - ], - Array [ - Object { - "pageTitle": "Start a recurring run", - }, - ], - ], - } - } - /> - - - - Cancel - - - Use this experiment - - - - - -
- This run will be associated with the following experiment -
- - - Choose - - , - "readOnly": true, - } - } - disabled={true} - label="Experiment" - required={true} - value="some mock experiment name" - variant="outlined" - /> -
- Run Type -
- - } - id="oneOffToggle" - label="One-off" - onChange={[Function]} - /> - - } - label="Recurring" - onChange={[Function]} - /> -
- Run trigger -
-
- Choose a method by which new runs will be triggered -
- -
- Run parameters -
-
- Parameters will appear after you select a pipeline -
-
- - - Cancel - -
- A pipeline must be selected -
-
-
-
-`; - -exports[`NewRun changes title and form to default state if the new run is a one-off, based on the radio buttons 1`] = ` -
-
-
- Run details -
- - - Choose - - , - "readOnly": true, - } - } - disabled={true} - label="Pipeline" - required={true} - value="" - variant="outlined" - /> - - - - - - - Cancel - - - Use this pipeline - - - - - - - - - - Cancel - - - Use this experiment - - - - - -
- This run will be associated with the following experiment -
- - - Choose - - , - "readOnly": true, - } - } - disabled={true} - label="Experiment" - required={true} - value="" - variant="outlined" - /> -
- Run Type -
- - } - id="oneOffToggle" - label="One-off" - onChange={[Function]} - /> - - } - label="Recurring" - onChange={[Function]} - /> -
- Run parameters -
-
- Parameters will appear after you select a pipeline -
-
- - - Cancel - -
- A pipeline must be selected -
-
-
-
-`; - -exports[`NewRun cloning from a run shows pipeline selector when switching from embedded pipeline to select pipeline 1`] = ` -
-
-
- Run details -
- - } - label="Use pipeline from cloned run" - onChange={[Function]} - /> - - [View pipeline] - - - } - label="Select a pipeline from list" - onChange={[Function]} - /> - - - Choose - - , - "readOnly": true, - } - } - disabled={true} - label="Pipeline" - required={true} - value="" - variant="outlined" - /> - - - - - - - Cancel - - - Use this pipeline - - - - - -
} + id="recurringToggle" label="Recurring" onChange={[Function]} />
- Run parameters + Run trigger
- Parameters will appear after you select a pipeline + Choose a method by which new runs will be triggered
+ +
+ > + A pipeline must be selected +
`; -exports[`NewRun cloning from a run shows switching controls when run has embedded pipeline, selects that pipeline by default, and hides pipeline selector 1`] = ` +exports[`NewRun changes title and form to default state if the new run is a one-off, based on the radio buttons 1`] = `
@@ -2131,31 +1256,37 @@ exports[`NewRun cloning from a run shows switching controls when run has embedde > Run details
- - } - label="Use pipeline from cloned run" - onChange={[Function]} - /> - - [View pipeline] - - + + + Choose + + , + "readOnly": true, + } } - label="Select a pipeline from list" - onChange={[Function]} + disabled={true} + label="Pipeline" + required={true} + value="" + variant="outlined" /> } + id="recurringToggle" label="Recurring" onChange={[Function]} /> -
- Run parameters -
-
- This pipeline has no parameters -
+
+ > + A pipeline must be selected +
@@ -2564,7 +1704,7 @@ exports[`NewRun fetches the associated pipeline if one is present in the query p disabled={true} label="Pipeline" required={true} - value="some mock pipeline name" + value="original mock pipeline name" variant="outlined" /> } + id="recurringToggle" label="Recurring" onChange={[Function]} /> -
- Run parameters -
-
- This pipeline has no parameters -
+
@@ -3083,7 +2220,7 @@ exports[`NewRun renders the new run page 1`] = ` "href": "/experiments/details/some-mock-experiment-id", }, ], - "pageTitle": "Start a new run", + "pageTitle": "Start a run", }, ], ], @@ -3213,7 +2350,7 @@ exports[`NewRun renders the new run page 1`] = ` "href": "/experiments/details/some-mock-experiment-id", }, ], - "pageTitle": "Start a new run", + "pageTitle": "Start a run", }, ], ], @@ -3310,20 +2447,17 @@ exports[`NewRun renders the new run page 1`] = ` control={ } + id="recurringToggle" label="Recurring" onChange={[Function]} /> -
- Run parameters -
-
- Parameters will appear after you select a pipeline -
+
@@ -3722,9 +2856,9 @@ exports[`NewRun starting a new recurring run includes additional trigger input f control={ } + id="recurringToggle" label="Recurring" onChange={[Function]} /> @@ -3739,14 +2873,11 @@ exports[`NewRun starting a new recurring run includes additional trigger input f -
- Run parameters -
-
- Parameters will appear after you select a pipeline -
+
@@ -3790,32 +2921,18 @@ exports[`NewRun starting a new run copies pipeline from run in the start API cal > Run details
- - } - label="Use pipeline from cloned run" - onChange={[Function]} - /> - - [View pipeline] - - - } - label="Select a pipeline from list" - onChange={[Function]} - /> +
+ + Using pipeline from cloned run + + + + [View pipeline] + +
Run Type
- - } - id="oneOffToggle" - label="One-off" - onChange={[Function]} - /> - - } - label="Recurring" - onChange={[Function]} + + One-off + + -
- Run parameters -
-
- This pipeline has no parameters -
@@ -4216,7 +3311,7 @@ exports[`NewRun starting a new run copies pipeline from run in the start API cal
`; -exports[`NewRun starting a new run updates the pipeline in state when a user fills in its params 1`] = ` +exports[`NewRun starting a new run updates the parameters in state on handleParamChange 1`] = `
@@ -4257,7 +3352,7 @@ exports[`NewRun starting a new run updates the pipeline in state when a user fil disabled={true} label="Pipeline" required={true} - value="some mock pipeline name" + value="original mock pipeline name" variant="outlined" /> } + id="recurringToggle" label="Recurring" onChange={[Function]} /> -
- Run parameters -
-
- Specify parameters required by the pipeline -
-
- - -
+ "name": "param-2", + "value": "prefilled value", + }, + ] + } + titleMessage="Specify parameters required by the pipeline" + />
@@ -4842,7 +3911,7 @@ exports[`NewRun starting a new run updates the pipeline params as user selects d "href": "/experiments/details/some-mock-experiment-id", }, ], - "pageTitle": "Start a new run", + "pageTitle": "Start a run", }, ], ], @@ -4972,7 +4041,7 @@ exports[`NewRun starting a new run updates the pipeline params as user selects d "href": "/experiments/details/some-mock-experiment-id", }, ], - "pageTitle": "Start a new run", + "pageTitle": "Start a run", }, ], ], @@ -5069,20 +4138,17 @@ exports[`NewRun starting a new run updates the pipeline params as user selects d control={ } + id="recurringToggle" label="Recurring" onChange={[Function]} /> -
- Run parameters -
-
- Parameters will appear after you select a pipeline -
+
@@ -5155,7 +4221,7 @@ exports[`NewRun starting a new run updates the pipeline params as user selects d disabled={true} label="Pipeline" required={true} - value="some mock pipeline name" + value="original mock pipeline name" variant="outlined" /> } + id="recurringToggle" label="Recurring" onChange={[Function]} /> -
- Run parameters -
-
- Specify parameters required by the pipeline -
-
- - -
+ "name": "param-2", + "value": "prefilled value 2", + }, + ] + } + titleMessage="Specify parameters required by the pipeline" + />
@@ -5609,7 +4649,7 @@ exports[`NewRun starting a new run updates the pipeline params as user selects d disabled={true} label="Pipeline" required={true} - value="some mock pipeline name" + value="original mock pipeline name" variant="outlined" /> } + id="recurringToggle" label="Recurring" onChange={[Function]} /> -
- Run parameters -
-
- This pipeline has no parameters -
+
@@ -6136,7 +5173,7 @@ exports[`NewRun updates the run's state with the associated experiment if one is "href": "/experiments/details/some-mock-experiment-id", }, ], - "pageTitle": "Start a new run", + "pageTitle": "Start a run", }, ], ], @@ -6266,7 +5303,7 @@ exports[`NewRun updates the run's state with the associated experiment if one is "href": "/experiments/details/some-mock-experiment-id", }, ], - "pageTitle": "Start a new run", + "pageTitle": "Start a run", }, ], ], @@ -6363,20 +5400,17 @@ exports[`NewRun updates the run's state with the associated experiment if one is control={ } + id="recurringToggle" label="Recurring" onChange={[Function]} /> -
- Run parameters -
-
- Parameters will appear after you select a pipeline -
+