diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c3385760c2b..f608a034827 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5862,7 +5862,8 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true + "bundled": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -5880,11 +5881,13 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true + "bundled": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5897,15 +5900,18 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "concat-map": { "version": "0.0.1", - "bundled": true + "bundled": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -6008,7 +6014,8 @@ }, "inherits": { "version": "2.0.3", - "bundled": true + "bundled": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -6018,6 +6025,7 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -6030,17 +6038,20 @@ "minimatch": { "version": "3.0.4", "bundled": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true + "bundled": true, + "optional": true }, "minipass": { "version": "2.3.5", "bundled": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -6057,6 +6068,7 @@ "mkdirp": { "version": "0.5.1", "bundled": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -6129,7 +6141,8 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true + "bundled": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -6139,6 +6152,7 @@ "once": { "version": "1.4.0", "bundled": true, + "optional": true, "requires": { "wrappy": "1" } @@ -6214,7 +6228,8 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true + "bundled": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -6244,6 +6259,7 @@ "string-width": { "version": "1.0.2", "bundled": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -6261,6 +6277,7 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -6299,11 +6316,13 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true + "bundled": true, + "optional": true }, "yallist": { "version": "3.0.3", - "bundled": true + "bundled": true, + "optional": true } } }, diff --git a/frontend/server/package-lock.json b/frontend/server/package-lock.json index cb4f6856b10..8ea93668baf 100644 --- a/frontend/server/package-lock.json +++ b/frontend/server/package-lock.json @@ -1769,7 +1769,8 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true + "bundled": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -1787,11 +1788,13 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true + "bundled": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1804,15 +1807,18 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "concat-map": { "version": "0.0.1", - "bundled": true + "bundled": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -1915,7 +1921,8 @@ }, "inherits": { "version": "2.0.3", - "bundled": true + "bundled": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -1925,6 +1932,7 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -1937,17 +1945,20 @@ "minimatch": { "version": "3.0.4", "bundled": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true + "bundled": true, + "optional": true }, "minipass": { "version": "2.3.5", "bundled": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -1964,6 +1975,7 @@ "mkdirp": { "version": "0.5.1", "bundled": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -2036,7 +2048,8 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true + "bundled": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -2046,6 +2059,7 @@ "once": { "version": "1.4.0", "bundled": true, + "optional": true, "requires": { "wrappy": "1" } @@ -2121,7 +2135,8 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true + "bundled": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -2151,6 +2166,7 @@ "string-width": { "version": "1.0.2", "bundled": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -2168,6 +2184,7 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -2206,11 +2223,13 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true + "bundled": true, + "optional": true }, "yallist": { "version": "3.0.3", - "bundled": true + "bundled": true, + "optional": true } } }, diff --git a/frontend/src/components/CustomTable.tsx b/frontend/src/components/CustomTable.tsx index cf4252c306a..3693b652098 100644 --- a/frontend/src/components/CustomTable.tsx +++ b/frontend/src/components/CustomTable.tsx @@ -674,6 +674,7 @@ const BodyRowSelectionSection: React.FC = ({ expandState === ExpandState.EXPANDED && css.expandButtonExpanded, )} onClick={onExpand} + aria-label='Expand' > diff --git a/frontend/src/components/Router.tsx b/frontend/src/components/Router.tsx index 227bcf5f14d..c43dd68eb67 100644 --- a/frontend/src/components/Router.tsx +++ b/frontend/src/components/Router.tsx @@ -42,6 +42,7 @@ import Toolbar, { ToolbarProps } from './Toolbar'; import { Route, Switch, Redirect } from 'react-router-dom'; import { classes, stylesheet } from 'typestyle'; import { commonCss } from '../Css'; +import NewPipelineVersion from '../pages/NewPipelineVersion'; export type RouteConfig = { path: string; Component: React.ComponentType; view?: any }; @@ -58,6 +59,7 @@ export enum QUERY_PARAMS { isRecurring = 'recurring', firstRunInExperiment = 'firstRunInExperiment', pipelineId = 'pipelineId', + pipelineVersionId = 'pipelineVersionId', fromRunId = 'fromRun', runlist = 'runlist', view = 'view', @@ -66,6 +68,7 @@ export enum QUERY_PARAMS { export enum RouteParams { experimentId = 'eid', pipelineId = 'pid', + pipelineVersionId = 'vid', runId = 'rid', ARTIFACT_TYPE = 'artifactType', EXECUTION_TYPE = 'executionType', @@ -91,9 +94,11 @@ export const RoutePage = { EXPERIMENTS: '/experiments', EXPERIMENT_DETAILS: `/experiments/details/:${RouteParams.experimentId}`, NEW_EXPERIMENT: '/experiments/new', + NEW_PIPELINE_VERSION: '/pipeline_versions/new', NEW_RUN: '/runs/new', PIPELINES: '/pipelines', - PIPELINE_DETAILS: `/pipelines/details/:${RouteParams.pipelineId}?`, // pipelineId is optional + PIPELINE_DETAILS: `/pipelines/details/:${RouteParams.pipelineId}/version/:${RouteParams.pipelineVersionId}?`, + PIPELINE_DETAILS_NO_VERSION: `/pipelines/details/:${RouteParams.pipelineId}?`, // pipelineId is optional RECURRING_RUN: `/recurringrun/details/:${RouteParams.runId}`, RUNS: '/runs', RUN_DETAILS: `/runs/details/:${RouteParams.runId}`, @@ -149,9 +154,11 @@ const Router: React.FC = ({ configs }) => { }, { path: RoutePage.EXPERIMENT_DETAILS, Component: ExperimentDetails }, { path: RoutePage.NEW_EXPERIMENT, Component: NewExperiment }, + { path: RoutePage.NEW_PIPELINE_VERSION, Component: NewPipelineVersion }, { path: RoutePage.NEW_RUN, Component: NewRun }, { path: RoutePage.PIPELINES, Component: PipelineList }, { path: RoutePage.PIPELINE_DETAILS, Component: PipelineDetails }, + { path: RoutePage.PIPELINE_DETAILS_NO_VERSION, Component: PipelineDetails }, { path: RoutePage.RUNS, Component: ExperimentsAndRuns, view: ExperimentsAndRunsTab.RUNS }, { path: RoutePage.RECURRING_RUN, Component: RecurringRunDetails }, { path: RoutePage.RUN_DETAILS, Component: RunDetails }, diff --git a/frontend/src/components/__snapshots__/CustomTable.test.tsx.snap b/frontend/src/components/__snapshots__/CustomTable.test.tsx.snap index f97473f2417..fa91cf699e9 100644 --- a/frontend/src/components/__snapshots__/CustomTable.test.tsx.snap +++ b/frontend/src/components/__snapshots__/CustomTable.test.tsx.snap @@ -133,6 +133,7 @@ exports[`CustomTable renders a collapsed row 1`] = ` color="primary" /> @@ -356,6 +357,7 @@ exports[`CustomTable renders a collapsed row when selection is disabled 1`] = ` className="cell selectionToggle" > @@ -1055,6 +1057,7 @@ exports[`CustomTable renders an expanded row with expanded component below it 1` color="primary" /> diff --git a/frontend/src/components/__snapshots__/Router.test.tsx.snap b/frontend/src/components/__snapshots__/Router.test.tsx.snap index a6d4778fabd..2d85c6dc018 100644 --- a/frontend/src/components/__snapshots__/Router.test.tsx.snap +++ b/frontend/src/components/__snapshots__/Router.test.tsx.snap @@ -58,42 +58,54 @@ exports[`Router initial render 1`] = ` + + diff --git a/frontend/src/lib/Apis.ts b/frontend/src/lib/Apis.ts index a0b9019557f..7b6b5854b4f 100644 --- a/frontend/src/lib/Apis.ts +++ b/frontend/src/lib/Apis.ts @@ -400,3 +400,9 @@ export enum ExperimentSortKeys { ID = 'id', NAME = 'name', } + +// Valid sortKeys as specified by the backend. +export enum PipelineVersionSortKeys { + CREATED_AT = 'created_at', + NAME = 'name', +} diff --git a/frontend/src/lib/Buttons.ts b/frontend/src/lib/Buttons.ts index f9ce33e6325..193282a0ac7 100644 --- a/frontend/src/lib/Buttons.ts +++ b/frontend/src/lib/Buttons.ts @@ -17,12 +17,12 @@ import AddIcon from '@material-ui/icons/Add'; import CollapseIcon from '@material-ui/icons/UnfoldLess'; import ExpandIcon from '@material-ui/icons/UnfoldMore'; +import { QUERY_PARAMS, RoutePage } from '../components/Router'; +import { ToolbarActionMap } from '../components/Toolbar'; import { PageProps } from '../pages/Page'; -import { URLParser } from './URLParser'; -import { RoutePage, QUERY_PARAMS } from '../components/Router'; import { Apis } from './Apis'; +import { URLParser } from './URLParser'; import { errorToMessage, s } from './Utils'; -import { ToolbarActionMap } from '../components/Toolbar'; export enum ButtonKeys { ARCHIVE = 'archive', @@ -36,9 +36,10 @@ export enum ButtonKeys { ENABLE_RECURRING_RUN = 'enableRecurringRun', EXPAND = 'expand', NEW_EXPERIMENT = 'newExperiment', + NEW_PIPELINE_VERSION = 'newPipelineVersion', NEW_RUN = 'newRun', NEW_RECURRING_RUN = 'newRecurringRun', - NEW_RUN_FROM_PIPELINE = 'newRunFromPipeline', + NEW_RUN_FROM_PIPELINE_VERSION = 'newRunFromPipelineVersion', REFRESH = 'refresh', RESTORE = 'restore', TERMINATE_RUN = 'terminateRun', @@ -143,9 +144,11 @@ export default class Buttons { return this; } + // Delete resources of the same type, which can be pipeline, pipeline version, + // or recurring run config. public delete( getSelectedIds: () => string[], - resourceName: 'pipeline' | 'recurring run config', + resourceName: 'pipeline' | 'recurring run config' | 'pipeline version', callback: (selectedIds: string[], success: boolean) => void, useCurrentResource: boolean, ): Buttons { @@ -153,6 +156,8 @@ export default class Buttons { action: () => resourceName === 'pipeline' ? this._deletePipeline(getSelectedIds(), useCurrentResource, callback) + : resourceName === 'pipeline version' + ? this._deletePipelineVersion(getSelectedIds(), useCurrentResource, callback) : this._deleteRecurringRun(getSelectedIds()[0], useCurrentResource, callback), disabled: !useCurrentResource, disabledTitle: useCurrentResource @@ -165,6 +170,32 @@ export default class Buttons { return this; } + // Delete pipelines and pipeline versions simultaneously. + public deletePipelinesAndPipelineVersions( + getSelectedIds: () => string[], + getSelectedVersionIds: () => { [pipelineId: string]: string[] }, + callback: (pipelineId: string | undefined, selectedIds: string[]) => void, + useCurrentResource: boolean, + ): Buttons { + this._map[ButtonKeys.DELETE_RUN] = { + action: () => { + this._dialogDeletePipelinesAndPipelineVersions( + getSelectedIds(), + getSelectedVersionIds(), + callback, + ); + }, + disabled: !useCurrentResource, + disabledTitle: useCurrentResource + ? undefined + : `Select at least one pipeline and/or one pipeline version to delete`, + id: 'deletePipelinesAndPipelineVersionsBtn', + title: 'Delete', + tooltip: 'Delete', + }; + return this; + } + public disableRecurringRun(getId: () => string): Buttons { this._map[ButtonKeys.DISABLE_RECURRING_RUN] = { action: () => this._setRecurringRunEnabledState(getId(), false), @@ -227,9 +258,12 @@ export default class Buttons { return this; } - public newRunFromPipeline(getPipelineId: () => string): Buttons { - this._map[ButtonKeys.NEW_RUN_FROM_PIPELINE] = { - action: () => this._createNewRunFromPipeline(getPipelineId()), + public newRunFromPipelineVersion( + getPipelineId: () => string, + getPipelineVersionId: () => string, + ): Buttons { + this._map[ButtonKeys.NEW_RUN_FROM_PIPELINE_VERSION] = { + action: () => this._createNewRunFromPipelineVersion(getPipelineId(), getPipelineVersionId()), icon: AddIcon, id: 'createNewRunBtn', outlined: true, @@ -254,6 +288,19 @@ export default class Buttons { return this; } + public newPipelineVersion(label: string, getPipelineId?: () => string): Buttons { + this._map[ButtonKeys.NEW_PIPELINE_VERSION] = { + action: () => this._createNewPipelineVersion(getPipelineId ? getPipelineId() : ''), + icon: AddIcon, + id: 'createPipelineVersionBtn', + outlined: true, + style: { minWidth: 160 }, + title: label, + tooltip: 'Upload pipeline or pipeline version', + }; + return this; + } + public refresh(action: () => void): Buttons { this._map[ButtonKeys.REFRESH] = { action, @@ -399,6 +446,24 @@ export default class Buttons { ); } + private _deletePipelineVersion( + selectedIds: string[], + useCurrentResource: boolean, + callback: (selectedIds: string[], success: boolean) => void, + ): void { + this._dialogActionHandler( + selectedIds, + `Do you want to delete ${ + selectedIds.length === 1 ? 'this Pipeline Version' : 'these Pipeline Versions' + }? This action cannot be undone.`, + useCurrentResource, + id => Apis.pipelineServiceApi.deletePipelineVersion(id), + callback, + 'Delete', + 'pipeline version', + ); + } + private _deleteRecurringRun( id: string, useCurrentResource: boolean, @@ -551,16 +616,17 @@ export default class Buttons { this._props.history.push(RoutePage.NEW_RUN + searchString); } - private _createNewRunFromPipeline(pipelineId?: string): void { + private _createNewRunFromPipelineVersion(pipelineId?: string, pipelineVersionId?: string): void { let searchString = ''; const fromRunId = this._urlParser.get(QUERY_PARAMS.fromRunId); if (fromRunId) { searchString = this._urlParser.build(Object.assign({ [QUERY_PARAMS.fromRunId]: fromRunId })); } else { - searchString = this._urlParser.build( - Object.assign({ [QUERY_PARAMS.pipelineId]: pipelineId || '' }), - ); + searchString = this._urlParser.build({ + [QUERY_PARAMS.pipelineId]: pipelineId || '', + [QUERY_PARAMS.pipelineVersionId]: pipelineVersionId || '', + }); } this._props.history.push(RoutePage.NEW_RUN + searchString); @@ -593,4 +659,165 @@ export default class Buttons { } } } + + private _createNewPipelineVersion(pipelineId?: string): void { + const searchString = pipelineId + ? this._urlParser.build({ + [QUERY_PARAMS.pipelineId]: pipelineId, + }) + : ''; + this._props.history.push(RoutePage.NEW_PIPELINE_VERSION + searchString); + } + + private _dialogDeletePipelinesAndPipelineVersions( + selectedIds: string[], + selectedVersionIds: { [pipelineId: string]: string[] }, + callback: (pipelineId: string | undefined, selectedIds: string[]) => void, + ): void { + const numVersionIds = this._deepCountDictionary(selectedVersionIds); + const pipelineMessage = this._nouns(selectedIds.length, `pipeline`, `pipelines`); + const pipelineVersionMessage = this._nouns( + numVersionIds, + `pipeline version`, + `pipeline versions`, + ); + const andMessage = pipelineMessage !== `` && pipelineVersionMessage !== `` ? ` and ` : ``; + this._props.updateDialog({ + buttons: [ + { + onClick: async () => + await this._deletePipelinesAndPipelineVersions( + false, + selectedIds, + selectedVersionIds, + callback, + ), + text: 'Cancel', + }, + { + onClick: async () => + await this._deletePipelinesAndPipelineVersions( + true, + selectedIds, + selectedVersionIds, + callback, + ), + text: 'Delete', + }, + ], + onClose: async () => + await this._deletePipelinesAndPipelineVersions( + false, + selectedIds, + selectedVersionIds, + callback, + ), + title: `Delete ` + pipelineMessage + andMessage + pipelineVersionMessage + `?`, + }); + } + + private async _deletePipelinesAndPipelineVersions( + confirmed: boolean, + selectedIds: string[], + selectedVersionIds: { [pipelineId: string]: string[] }, + callback: (pipelineId: string | undefined, selectedIds: string[]) => void, + ): Promise { + if (!confirmed) { + return; + } + + // Since confirmed, delete pipelines first and then pipeline versions from + // (other) pipelines. + + // Delete pipelines. + const succeededfulIds: Set = new Set(selectedIds); + const unsuccessfulIds: string[] = []; + const errorMessages: string[] = []; + await Promise.all( + selectedIds.map(async id => { + try { + await Apis.pipelineServiceApi.deletePipeline(id); + } catch (err) { + unsuccessfulIds.push(id); + succeededfulIds.delete(id); + const errorMessage = await errorToMessage(err); + errorMessages.push(`Failed to delete pipeline: ${id} with error: "${errorMessage}"`); + } + }), + ); + + // Remove successfully deleted pipelines from selectedVersionIds if exists. + const toBeDeletedVersionIds = Object.fromEntries( + Object.entries(selectedVersionIds).filter( + ([pipelineId, _]) => !succeededfulIds.has(pipelineId), + ), + ); + + // Delete pipeline versions. + const unsuccessfulVersionIds: { [pipelineId: string]: string[] } = {}; + await Promise.all( + Object.keys(toBeDeletedVersionIds).map(pipelineId => { + toBeDeletedVersionIds[pipelineId].map(async versionId => { + try { + unsuccessfulVersionIds[pipelineId] = []; + await Apis.pipelineServiceApi.deletePipelineVersion(versionId); + } catch (err) { + unsuccessfulVersionIds[pipelineId].push(versionId); + const errorMessage = await errorToMessage(err); + errorMessages.push( + `Failed to delete pipeline version: ${versionId} with error: "${errorMessage}"`, + ); + } + }); + }), + ); + const selectedVersionIdsCt = this._deepCountDictionary(selectedVersionIds); + const unsuccessfulVersionIdsCt = this._deepCountDictionary(unsuccessfulVersionIds); + + // Display successful and/or unsuccessful messages. + const pipelineMessage = this._nouns(succeededfulIds.size, `pipeline`, `pipelines`); + const pipelineVersionMessage = this._nouns( + selectedVersionIdsCt - unsuccessfulVersionIdsCt, + `pipeline version`, + `pipeline versions`, + ); + const andMessage = pipelineMessage !== `` && pipelineVersionMessage !== `` ? ` and ` : ``; + if (pipelineMessage !== `` || pipelineVersionMessage !== ``) { + this._props.updateSnackbar({ + message: `Deletion succeeded for ` + pipelineMessage + andMessage + pipelineVersionMessage, + open: true, + }); + } + if (unsuccessfulIds.length > 0 || unsuccessfulVersionIdsCt > 0) { + this._props.updateDialog({ + buttons: [{ text: 'Dismiss' }], + content: errorMessages.join('\n\n'), + title: `Failed to delete some pipelines and/or some pipeline versions`, + }); + } + + // pipelines and pipeline versions that failed deletion will keep to be + // checked. + callback(undefined, unsuccessfulIds); + Object.keys(selectedVersionIds).map(pipelineId => + callback(pipelineId, unsuccessfulVersionIds[pipelineId]), + ); + + // Refresh + this._refresh(); + } + + private _nouns(count: number, singularNoun: string, pluralNoun: string): string { + if (count <= 0) { + return ``; + } else if (count === 1) { + return `${count} ` + singularNoun; + } else { + return `${count} ` + pluralNoun; + } + } + + private _deepCountDictionary(dict: { [pipelineId: string]: string[] }): number { + return Object.keys(dict).reduce((count, pipelineId) => count + dict[pipelineId].length, 0); + } } diff --git a/frontend/src/lib/RunUtils.ts b/frontend/src/lib/RunUtils.ts index ae5a165ae03..f8377d0a2a9 100644 --- a/frontend/src/lib/RunUtils.ts +++ b/frontend/src/lib/RunUtils.ts @@ -14,19 +14,19 @@ * limitations under the License. */ +import { orderBy } from 'lodash'; +import { ApiParameter, ApiPipelineVersion } from 'src/apis/pipeline'; +import { Workflow } from 'third_party/argo-ui/argo_template'; import { ApiJob } from '../apis/job'; import { - ApiRun, - ApiResourceType, + ApiPipelineRuntime, ApiResourceReference, + ApiResourceType, + ApiRun, 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'; +import WorkflowParser from './WorkflowParser'; export interface MetricMetadata { count: number; @@ -66,6 +66,32 @@ function getPipelineName(run?: ApiRun | ApiJob): string | null { return (run && run.pipeline_spec && run.pipeline_spec.pipeline_name) || null; } +function getPipelineVersionId(run?: ApiRun | ApiJob): string | null { + return run && + run.resource_references && + run.resource_references.some( + ref => ref.key && ref.key.type && ref.key.type === ApiResourceType.PIPELINEVERSION, + ) + ? run.resource_references.find( + ref => ref.key && ref.key.type && ref.key.type === ApiResourceType.PIPELINEVERSION, + )!.key!.id! + : null; +} + +function getPipelineIdFromApiPipelineVersion( + pipelineVersion?: ApiPipelineVersion, +): string | undefined { + return pipelineVersion && + pipelineVersion.resource_references && + pipelineVersion.resource_references.some( + ref => ref.key && ref.key.type && ref.key.id && ref.key.type === ApiResourceType.PIPELINE, + ) + ? pipelineVersion.resource_references.find( + ref => ref.key && ref.key.type && ref.key.id && ref.key.type === ApiResourceType.PIPELINE, + )!.key!.id! + : undefined; +} + function getWorkflowManifest(run?: ApiRun | ApiJob): string | null { return (run && run.pipeline_spec && run.pipeline_spec.workflow_manifest) || null; } @@ -151,7 +177,9 @@ export default { getParametersFromRun, getParametersFromRuntime, getPipelineId, + getPipelineIdFromApiPipelineVersion, getPipelineName, + getPipelineVersionId, getRecurringRunId, getWorkflowManifest, runsToMetricMetadataMap, diff --git a/frontend/src/pages/NewPipelineVersion.test.tsx b/frontend/src/pages/NewPipelineVersion.test.tsx new file mode 100644 index 00000000000..09238c11779 --- /dev/null +++ b/frontend/src/pages/NewPipelineVersion.test.tsx @@ -0,0 +1,336 @@ +/* + * 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 NewPipelineVersion, { ImportMethod } from './NewPipelineVersion'; +import TestUtils from '../TestUtils'; +import { shallow, ShallowWrapper, ReactWrapper } from 'enzyme'; +import { PageProps } from './Page'; +import { Apis } from '../lib/Apis'; +import { RoutePage, QUERY_PARAMS } from '../components/Router'; +import { ApiResourceType } from '../apis/pipeline'; + +class TestNewPipelineVersion extends NewPipelineVersion { + public _pipelineSelectorClosed = super._pipelineSelectorClosed; + public _onDropForTest = super._onDropForTest; +} + +describe('NewPipelineVersion', () => { + let tree: ReactWrapper | ShallowWrapper; + + const historyPushSpy = jest.fn(); + const historyReplaceSpy = jest.fn(); + const updateBannerSpy = jest.fn(); + const updateDialogSpy = jest.fn(); + const updateSnackbarSpy = jest.fn(); + const updateToolbarSpy = jest.fn(); + + let getPipelineSpy: jest.SpyInstance<{}>; + let createPipelineSpy: jest.SpyInstance<{}>; + let createPipelineVersionSpy: jest.SpyInstance<{}>; + let uploadPipelineSpy: jest.SpyInstance<{}>; + + let MOCK_PIPELINE = { + id: 'original-run-pipeline-id', + name: 'original mock pipeline name', + default_version: { + id: 'original-run-pipeline-version-id', + name: 'original mock pipeline version name', + resource_references: [ + { + key: { + id: 'original-run-pipeline-id', + type: ApiResourceType.PIPELINE, + }, + relationship: 1, + }, + ], + }, + }; + + let MOCK_PIPELINE_VERSION = { + id: 'original-run-pipeline-version-id', + name: 'original mock pipeline version name', + resource_references: [ + { + key: { + id: 'original-run-pipeline-id', + type: ApiResourceType.PIPELINE, + }, + relationship: 1, + }, + ], + }; + + function generateProps(search?: string): PageProps { + return { + history: { push: historyPushSpy, replace: historyReplaceSpy } as any, + location: { + pathname: RoutePage.NEW_PIPELINE_VERSION, + search: search, + } as any, + match: '' as any, + toolbarProps: TestNewPipelineVersion.prototype.getInitialToolbarState(), + updateBanner: updateBannerSpy, + updateDialog: updateDialogSpy, + updateSnackbar: updateSnackbarSpy, + updateToolbar: updateToolbarSpy, + }; + } + + beforeEach(() => { + jest.clearAllMocks(); + getPipelineSpy = jest + .spyOn(Apis.pipelineServiceApi, 'getPipeline') + .mockImplementation(() => MOCK_PIPELINE); + createPipelineVersionSpy = jest + .spyOn(Apis.pipelineServiceApi, 'createPipelineVersion') + .mockImplementation(() => MOCK_PIPELINE_VERSION); + createPipelineSpy = jest + .spyOn(Apis.pipelineServiceApi, 'createPipeline') + .mockImplementation(() => MOCK_PIPELINE); + uploadPipelineSpy = jest.spyOn(Apis, 'uploadPipeline').mockImplementation(() => MOCK_PIPELINE); + }); + + afterEach(async () => { + // unmount() should be called before resetAllMocks() in case any part of the unmount life cycle + // depends on mocks/spies + if (tree) { + await tree.unmount(); + } + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + + // New pipeline version page has two functionalities: creating a pipeline and creating a version under an existing pipeline. + // Our tests will be divided into 3 parts: switching between creating pipeline or creating version; test pipeline creation; test pipeline version creation. + + describe('switching between creating pipeline and creating pipeline version', () => { + it('creates pipeline is default when landing from pipeline list page', () => { + tree = shallow(); + + // When landing from pipeline list page, the default is to create pipeline + expect(tree.state('newPipeline')).toBe(true); + + // Switch to create pipeline version + tree.find('#createPipelineVersionUnderExistingPipelineBtn').simulate('change'); + expect(tree.state('newPipeline')).toBe(false); + + // Switch back + tree.find('#createNewPipelineBtn').simulate('change'); + expect(tree.state('newPipeline')).toBe(true); + }); + + it('creates pipeline version is default when landing from pipeline details page', () => { + tree = shallow( + , + ); + + // When landing from pipeline list page, the default is to create pipeline + expect(tree.state('newPipeline')).toBe(false); + + // Switch to create pipeline version + tree.find('#createNewPipelineBtn').simulate('change'); + expect(tree.state('newPipeline')).toBe(true); + + // Switch back + tree.find('#createPipelineVersionUnderExistingPipelineBtn').simulate('change'); + expect(tree.state('newPipeline')).toBe(false); + }); + }); + + describe('creating version under an existing pipeline', () => { + it('does not include any action buttons in the toolbar', async () => { + tree = shallow( + , + ); + await TestUtils.flushPromises(); + + expect(updateToolbarSpy).toHaveBeenLastCalledWith({ + actions: {}, + breadcrumbs: [{ displayName: 'Pipeline Versions', href: '/pipeline_versions/new' }], + pageTitle: 'Upload Pipeline or Pipeline Version', + }); + expect(getPipelineSpy).toHaveBeenCalledTimes(1); + }); + + it('allows updating pipeline version name', async () => { + tree = shallow( + , + ); + await TestUtils.flushPromises(); + + (tree.instance() as TestNewPipelineVersion).handleChange('pipelineVersionName')({ + target: { value: 'version name' }, + }); + + expect(tree.state()).toHaveProperty('pipelineVersionName', 'version name'); + expect(getPipelineSpy).toHaveBeenCalledTimes(1); + }); + + it('allows updating package url', async () => { + tree = shallow( + , + ); + await TestUtils.flushPromises(); + + (tree.instance() as TestNewPipelineVersion).handleChange('packageUrl')({ + target: { value: 'https://dummy' }, + }); + + expect(tree.state()).toHaveProperty('packageUrl', 'https://dummy'); + expect(getPipelineSpy).toHaveBeenCalledTimes(1); + }); + + it('allows updating code source', async () => { + tree = shallow( + , + ); + await TestUtils.flushPromises(); + + (tree.instance() as TestNewPipelineVersion).handleChange('codeSourceUrl')({ + target: { value: 'https://dummy' }, + }); + + expect(tree.state()).toHaveProperty('codeSourceUrl', 'https://dummy'); + expect(getPipelineSpy).toHaveBeenCalledTimes(1); + }); + + it("sends a request to create a version when 'Create' is clicked", async () => { + tree = shallow( + , + ); + await TestUtils.flushPromises(); + + (tree.instance() as TestNewPipelineVersion).handleChange('pipelineVersionName')({ + target: { value: 'test version name' }, + }); + (tree.instance() as TestNewPipelineVersion).handleChange('packageUrl')({ + target: { value: 'https://dummy_package_url' }, + }); + await TestUtils.flushPromises(); + + tree.find('#createNewPipelineOrVersionBtn').simulate('click'); + // The APIs are called in a callback triggered by clicking 'Create', so we wait again + await TestUtils.flushPromises(); + + expect(createPipelineVersionSpy).toHaveBeenCalledTimes(1); + expect(createPipelineVersionSpy).toHaveBeenLastCalledWith({ + code_source_url: '', + name: 'test version name', + package_url: { + pipeline_url: 'https://dummy_package_url', + }, + resource_references: [ + { + key: { + id: MOCK_PIPELINE.id, + type: ApiResourceType.PIPELINE, + }, + relationship: 1, + }, + ], + }); + }); + + // TODO(jingzhang36): test error dialog if creating pipeline version fails + }); + + describe('creating new pipeline', () => { + it('renders the new pipeline page', async () => { + tree = shallow(); + await TestUtils.flushPromises(); + expect(tree).toMatchSnapshot(); + }); + + it('switches between import methods', () => { + tree = shallow(); + + // Import method is URL by default + expect(tree.state('importMethod')).toBe(ImportMethod.URL); + + // Click to import by local + tree.find('#localPackageBtn').simulate('change'); + expect(tree.state('importMethod')).toBe(ImportMethod.LOCAL); + + // Click back to URL + tree.find('#remotePackageBtn').simulate('change'); + expect(tree.state('importMethod')).toBe(ImportMethod.URL); + }); + + it('creates pipeline from url', async () => { + tree = shallow(); + + (tree.instance() as TestNewPipelineVersion).handleChange('pipelineName')({ + target: { value: 'test pipeline name' }, + }); + (tree.instance() as TestNewPipelineVersion).handleChange('pipelineDescription')({ + target: { value: 'test pipeline description' }, + }); + (tree.instance() as TestNewPipelineVersion).handleChange('packageUrl')({ + target: { value: 'https://dummy_package_url' }, + }); + await TestUtils.flushPromises(); + + tree.find('#createNewPipelineOrVersionBtn').simulate('click'); + // The APIs are called in a callback triggered by clicking 'Create', so we wait again + await TestUtils.flushPromises(); + + expect(tree.state()).toHaveProperty('newPipeline', true); + expect(tree.state()).toHaveProperty('importMethod', ImportMethod.URL); + expect(createPipelineSpy).toHaveBeenCalledTimes(1); + expect(createPipelineSpy).toHaveBeenLastCalledWith({ + description: 'test pipeline description', + name: 'test pipeline name', + url: { + pipeline_url: 'https://dummy_package_url', + }, + }); + }); + + it('creates pipeline from local file', async () => { + tree = shallow(); + + // Set local file, pipeline name and click create + tree.find('#localPackageBtn').simulate('change'); + (tree.instance() as TestNewPipelineVersion).handleChange('pipelineName')({ + target: { value: 'test pipeline name' }, + }); + const file = new File(['file contents'], 'file_name', { type: 'text/plain' }); + (tree.instance() as TestNewPipelineVersion)._onDropForTest([file]); + tree.find('#createNewPipelineOrVersionBtn').simulate('click'); + + tree.update(); + await TestUtils.flushPromises(); + + expect(tree.state('importMethod')).toBe(ImportMethod.LOCAL); + expect(uploadPipelineSpy).toHaveBeenLastCalledWith('test pipeline name', file); + expect(createPipelineSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/pages/NewPipelineVersion.tsx b/frontend/src/pages/NewPipelineVersion.tsx new file mode 100644 index 00000000000..6d096ae3544 --- /dev/null +++ b/frontend/src/pages/NewPipelineVersion.tsx @@ -0,0 +1,677 @@ +/* + * 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 BusyButton from '../atoms/BusyButton'; +import Button from '@material-ui/core/Button'; +import Buttons from '../lib/Buttons'; +import Dropzone from 'react-dropzone'; +import Input from '../atoms/Input'; +import { Page } from './Page'; +import { RoutePage, QUERY_PARAMS, RouteParams } from '../components/Router'; +import { TextFieldProps } from '@material-ui/core/TextField'; +import { ToolbarProps } from '../components/Toolbar'; +import { URLParser } from '../lib/URLParser'; +import { classes, stylesheet } from 'typestyle'; +import { commonCss, padding, color, fontsize, zIndex } from '../Css'; +import { logger, errorToMessage } from '../lib/Utils'; +import ResourceSelector from './ResourceSelector'; +import InputAdornment from '@material-ui/core/InputAdornment'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import { ApiResourceType } from '../apis/run'; +import { Apis, PipelineSortKeys } from '../lib/Apis'; +import { ApiPipeline, ApiPipelineVersion } from '../apis/pipeline'; +import { CustomRendererProps } from '../components/CustomTable'; +import { Description } from '../components/Description'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Radio from '@material-ui/core/Radio'; +import { ExternalLink } from '../atoms/ExternalLink'; + +interface NewPipelineVersionState { + validationError: string; + isbeingCreated: boolean; + errorMessage: string; + + pipelineDescription: string; + pipelineId?: string; + pipelineName?: string; + pipelineVersionName: string; + pipeline?: ApiPipeline; + + codeSourceUrl: string; + + // Package can be local file or url + importMethod: ImportMethod; + fileName: string; + file: File | null; + packageUrl: string; + dropzoneActive: boolean; + + // Create a new pipeline or not + newPipeline: boolean; + + // Select existing pipeline + pipelineSelectorOpen: boolean; + unconfirmedSelectedPipeline?: ApiPipeline; +} + +export enum ImportMethod { + LOCAL = 'local', + URL = 'url', +} + +const css = stylesheet({ + dropOverlay: { + backgroundColor: color.lightGrey, + border: '2px dashed #aaa', + bottom: 0, + left: 0, + padding: '2.5em 0', + position: 'absolute', + right: 0, + textAlign: 'center', + top: 0, + zIndex: zIndex.DROP_ZONE_OVERLAY, + }, + errorMessage: { + color: 'red', + }, + explanation: { + fontSize: fontsize.small, + }, + nonEditableInput: { + color: color.secondaryText, + }, + selectorDialog: { + // If screen is small, use calc(100% - 120px). If screen is big, use 1200px. + maxWidth: 1200, // override default maxWidth to expand this dialog further + minWidth: 680, + width: 'calc(100% - 120px)', + }, +}); + +const descriptionCustomRenderer: React.FC> = props => { + return ; +}; + +class NewPipelineVersion extends Page<{}, NewPipelineVersionState> { + private _dropzoneRef = React.createRef(); + private _pipelineVersionNameRef = React.createRef(); + private _pipelineNameRef = React.createRef(); + private _pipelineDescriptionRef = React.createRef(); + + private pipelineSelectorColumns = [ + { label: 'Pipeline name', flex: 1, sortKey: PipelineSortKeys.NAME }, + { label: 'Description', flex: 2, customRenderer: descriptionCustomRenderer }, + { label: 'Uploaded on', flex: 1, sortKey: PipelineSortKeys.CREATED_AT }, + ]; + + constructor(props: any) { + super(props); + + const urlParser = new URLParser(props); + const pipelineId = urlParser.get(QUERY_PARAMS.pipelineId); + + this.state = { + codeSourceUrl: '', + dropzoneActive: false, + errorMessage: '', + file: null, + fileName: '', + importMethod: ImportMethod.URL, + isbeingCreated: false, + newPipeline: pipelineId ? false : true, + packageUrl: '', + pipelineDescription: '', + pipelineId: '', + pipelineName: '', + pipelineSelectorOpen: false, + pipelineVersionName: '', + validationError: '', + }; + } + + public getInitialToolbarState(): ToolbarProps { + return { + actions: {}, + breadcrumbs: [{ displayName: 'Pipeline Versions', href: RoutePage.NEW_PIPELINE_VERSION }], + pageTitle: 'Upload Pipeline or Pipeline Version', + }; + } + + public render(): JSX.Element { + const { + packageUrl, + pipelineName, + pipelineVersionName, + isbeingCreated, + validationError, + pipelineSelectorOpen, + unconfirmedSelectedPipeline, + codeSourceUrl, + importMethod, + newPipeline, + pipelineDescription, + fileName, + dropzoneActive, + } = this.state; + + const buttons = new Buttons(this.props, this.refresh.bind(this)); + + return ( +
+
+ {/* Two subpages: one for creating version under existing pipeline and one for creating version under new pipeline */} +
+ } + onChange={() => + this.setState({ + codeSourceUrl: '', + newPipeline: true, + pipelineDescription: '', + pipelineName: '', + pipelineVersionName: '', + }) + } + /> + } + onChange={() => + this.setState({ + codeSourceUrl: '', + newPipeline: false, + pipelineDescription: '', + pipelineName: '', + pipelineVersionName: '', + }) + } + /> +
+ + {/* Form for uploading new pipeline */} + {newPipeline === true && ( + +
Upload pipeline with the specified package.
+ + + + + {/* Choose a local file for package or specify a url for package */} + + {/* Different package explanation based on import method*/} + {this.state.importMethod === ImportMethod.LOCAL && ( + +
+ Choose a pipeline package file from your computer, and give the pipeline a + unique name. +
+ You can also drag and drop the file here. +
+ +
+ )} + {this.state.importMethod === ImportMethod.URL && ( + +
URL must be publicly accessible.
+ +
+ )} + + {/* Different package input field based on import method*/} +
+ } + onChange={() => this.setState({ importMethod: ImportMethod.LOCAL })} + /> + + {dropzoneActive &&
Drop files..
} + + + + ), + readOnly: true, + style: { + maxWidth: 2000, + width: 455, + }, + }} + /> +
+
+
+ } + onChange={() => this.setState({ importMethod: ImportMethod.URL })} + /> + +
+ {/* Fill pipeline version code source url */} + +
+ )} + + {/* Form for uploading new pipeline version */} + {newPipeline === false && ( + +
+ Upload pipeline version with the specified package. +
+ {/* Select pipeline */} + + + + ), + readOnly: true, + }} + /> + this._pipelineSelectorClosed(false)} + PaperProps={{ id: 'pipelineSelectorDialog' }} + > + + { + const response = await Apis.pipelineServiceApi.listPipelines(...args); + return { + nextPageToken: response.next_page_token || '', + resources: response.pipelines || [], + }; + }} + columns={this.pipelineSelectorColumns} + emptyMessage='No pipelines found. Upload a pipeline and then try again.' + initialSortColumn={PipelineSortKeys.CREATED_AT} + selectionChanged={(selectedPipeline: ApiPipeline) => + this.setStateSafe({ unconfirmedSelectedPipeline: selectedPipeline }) + } + toolbarActionMap={buttons + .upload(() => this.setStateSafe({ pipelineSelectorOpen: false })) + .getToolbarActionMap()} + /> + + + + + + + + {/* Set pipeline version name */} + + + {/* Fill pipeline package url */} + + + {/* Fill pipeline version code source url */} + +
+ )} + + {/* Create pipeline or pipeline version */} +
+ + +
{validationError}
+
+
+
+ ); + } + + public async refresh(): Promise { + return; + } + + public async componentDidMount(): Promise { + const urlParser = new URLParser(this.props); + const pipelineId = urlParser.get(QUERY_PARAMS.pipelineId); + if (pipelineId) { + const apiPipeline = await Apis.pipelineServiceApi.getPipeline(pipelineId); + this.setState({ pipelineId, pipelineName: apiPipeline.name, pipeline: apiPipeline }); + // Suggest a version name based on pipeline name + const currDate = new Date(); + this.setState({ + pipelineVersionName: apiPipeline.name + '_version_at_' + currDate.toISOString(), + }); + } + + this._validate(); + } + + public handleChange = (name: string) => (event: any) => { + const value = (event.target as TextFieldProps).value; + this.setState({ [name]: value } as any, this._validate.bind(this)); + + // When pipeline name is changed, we have some special logic + if (name === 'pipelineName') { + // Suggest a version name based on pipeline name + const currDate = new Date(); + this.setState( + { pipelineVersionName: value + '_version_at_' + currDate.toISOString() }, + this._validate.bind(this), + ); + } + }; + + protected async _pipelineSelectorClosed(confirmed: boolean): Promise { + let { pipeline } = this.state; + const currDate = new Date(); + if (confirmed && this.state.unconfirmedSelectedPipeline) { + pipeline = this.state.unconfirmedSelectedPipeline; + } + + this.setStateSafe( + { + pipeline, + pipelineId: (pipeline && pipeline.id) || '', + pipelineName: (pipeline && pipeline.name) || '', + pipelineSelectorOpen: false, + // Suggest a version name based on pipeline name + pipelineVersionName: + (pipeline && pipeline.name + '_version_at_' + currDate.toISOString()) || '', + }, + () => this._validate(), + ); + } + + // To call _onDrop from test, so make a protected method + protected _onDropForTest(files: File[]): void { + this._onDrop(files); + } + + private async _create(): Promise { + this.setState({ isbeingCreated: true }, async () => { + try { + // 3 use case for now: + // (1) new pipeline (and a default version) from local file + // (2) new pipeline (and a default version) from url + // (3) new pipeline version (under an existing pipeline) from url + const response = + this.state.newPipeline && this.state.importMethod === ImportMethod.LOCAL + ? (await Apis.uploadPipeline(this.state.pipelineName!, this.state.file!)) + .default_version! + : this.state.newPipeline && this.state.importMethod === ImportMethod.URL + ? (await Apis.pipelineServiceApi.createPipeline({ + description: this.state.pipelineDescription, + name: this.state.pipelineName!, + url: { pipeline_url: this.state.packageUrl }, + })).default_version! + : await this._createPipelineVersion(); + + // If success, go to pipeline details page of the new version + this.props.history.push( + RoutePage.PIPELINE_DETAILS.replace( + `:${RouteParams.pipelineId}`, + response.resource_references![0].key!.id! /* pipeline id of this version */, + ).replace(`:${RouteParams.pipelineVersionId}`, response.id!), + ); + this.props.updateSnackbar({ + autoHideDuration: 10000, + message: `Successfully created new pipeline version: ${response.name}`, + open: true, + }); + } catch (err) { + const errorMessage = await errorToMessage(err); + await this.showErrorDialog('Pipeline version creation failed', errorMessage); + logger.error('Error creating pipeline version:', err); + this.setState({ isbeingCreated: false }); + } + }); + } + + private async _createPipelineVersion(): Promise { + const getPipelineId = async () => { + if (this.state.pipelineId) { + // Get existing pipeline's id. + return this.state.pipelineId; + } else { + // Get the new pipeline's id. + // The new pipeline version is going to be put under this new pipeline + // instead of an eixsting pipeline. So create this new pipeline first. + const newPipeline: ApiPipeline = { + description: this.state.pipelineDescription, + name: this.state.pipelineName, + url: { pipeline_url: this.state.packageUrl }, + }; + const response = await Apis.pipelineServiceApi.createPipeline(newPipeline); + return response.id!; + } + }; + + const newPipelineVersion: ApiPipelineVersion = { + code_source_url: this.state.codeSourceUrl, + name: this.state.pipelineVersionName, + package_url: { pipeline_url: this.state.packageUrl }, + resource_references: [ + { key: { id: await getPipelineId(), type: ApiResourceType.PIPELINE }, relationship: 1 }, + ], + }; + return Apis.pipelineServiceApi.createPipelineVersion(newPipelineVersion); + } + + private _validate(): void { + // Validate state + // 3 valid use case for now: + // (1) new pipeline (and a default version) from local file + // (2) new pipeline (and a default version) from url + // (3) new pipeline version (under an existing pipeline) from url + const { fileName, pipeline, pipelineVersionName, packageUrl, newPipeline } = this.state; + try { + if (newPipeline) { + if (!packageUrl && !fileName) { + throw new Error('Must specify either package url or file'); + } + } else { + if (!pipeline) { + throw new Error('Pipeline is required'); + } + if (!pipelineVersionName) { + throw new Error('Pipeline version name is required'); + } + if (!packageUrl) { + throw new Error('Please specify a pipeline package in .yaml, .zip, or .tar.gz'); + } + } + this.setState({ validationError: '' }); + } catch (err) { + this.setState({ validationError: err.message }); + } + } + + private _onDropzoneDragEnter(): void { + this.setState({ dropzoneActive: true }); + } + + private _onDropzoneDragLeave(): void { + this.setState({ dropzoneActive: false }); + } + + private _onDrop(files: File[]): void { + this.setStateSafe( + { + dropzoneActive: false, + file: files[0], + fileName: files[0].name, + pipelineName: this.state.pipelineName || files[0].name.split('.')[0], + }, + () => { + this._validate(); + }, + ); + } +} + +export default NewPipelineVersion; + +const DocumentationCompilePipeline: React.FC = () => ( +
+ For expected file format, refer to{' '} + + Compile Pipeline Documentation + + . +
+); diff --git a/frontend/src/pages/NewRun.test.tsx b/frontend/src/pages/NewRun.test.tsx index 14d09eae2aa..42065deecf8 100644 --- a/frontend/src/pages/NewRun.test.tsx +++ b/frontend/src/pages/NewRun.test.tsx @@ -22,12 +22,13 @@ import { PageProps } from './Page'; import { Apis } from '../lib/Apis'; import { RoutePage, RouteParams, QUERY_PARAMS } from '../components/Router'; import { ApiExperiment } from '../apis/experiment'; -import { ApiPipeline } from '../apis/pipeline'; +import { ApiPipeline, ApiPipelineVersion } from '../apis/pipeline'; import { ApiResourceType, ApiRunDetail, ApiParameter, ApiRelationship } from '../apis/run'; class TestNewRun extends NewRun { public _experimentSelectorClosed = super._experimentSelectorClosed; public _pipelineSelectorClosed = super._pipelineSelectorClosed; + public _pipelineVersionSelectorClosed = super._pipelineVersionSelectorClosed; public _updateRecurringRunState = super._updateRecurringRunState; public _handleParamChange = super._handleParamChange; } @@ -40,6 +41,7 @@ describe('NewRun', () => { const startRunSpy = jest.spyOn(Apis.runServiceApi, 'createRun'); const getExperimentSpy = jest.spyOn(Apis.experimentServiceApi, 'getExperiment'); const getPipelineSpy = jest.spyOn(Apis.pipelineServiceApi, 'getPipeline'); + const getPipelineVersionSpy = jest.spyOn(Apis.pipelineServiceApi, 'getPipelineVersion'); const getRunSpy = jest.spyOn(Apis.runServiceApi, 'getRun'); const historyPushSpy = jest.fn(); const historyReplaceSpy = jest.fn(); @@ -50,6 +52,7 @@ describe('NewRun', () => { let MOCK_EXPERIMENT = newMockExperiment(); let MOCK_PIPELINE = newMockPipeline(); + let MOCK_PIPELINE_VERSION = newMockPipelineVersion(); let MOCK_RUN_DETAIL = newMockRunDetail(); let MOCK_RUN_WITH_EMBEDDED_PIPELINE = newMockRunWithEmbeddedPipeline(); @@ -66,6 +69,10 @@ describe('NewRun', () => { id: 'original-run-pipeline-id', name: 'original mock pipeline name', parameters: [], + default_version: { + id: 'original-run-pipeline-version-id', + name: 'original mock pipeline version name', + }, }; } @@ -83,6 +90,17 @@ describe('NewRun', () => { value: '', }, ], + default_version: { + id: 'original-run-pipeline-version-id', + name: 'original mock pipeline version name', + }, + }; + } + + function newMockPipelineVersion(): ApiPipelineVersion { + return { + id: 'original-run-pipeline-version-id', + name: 'original mock pipeline version name', }; } @@ -134,6 +152,7 @@ describe('NewRun', () => { startRunSpy.mockImplementation(() => ({ id: 'new-run-id' })); getExperimentSpy.mockImplementation(() => MOCK_EXPERIMENT); getPipelineSpy.mockImplementation(() => MOCK_PIPELINE); + getPipelineVersionSpy.mockImplementation(() => MOCK_PIPELINE_VERSION); getRunSpy.mockImplementation(() => MOCK_RUN_DETAIL); MOCK_EXPERIMENT = newMockExperiment(); @@ -335,13 +354,16 @@ describe('NewRun', () => { it('fetches the associated pipeline if one is present in the query params', async () => { const props = generateProps(); - props.location.search = `?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`; + props.location.search = `?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}&${ + QUERY_PARAMS.pipelineVersionId + }=${MOCK_PIPELINE.default_version!.id}`; tree = shallow(); await TestUtils.flushPromises(); expect(tree.state()).toHaveProperty('pipeline', MOCK_PIPELINE); expect(tree.state()).toHaveProperty('pipelineName', MOCK_PIPELINE.name); + expect(tree.state()).toHaveProperty('pipelineVersion', MOCK_PIPELINE_VERSION); expect(tree.state()).toHaveProperty('errorMessage', 'Run name is required'); expect(tree).toMatchSnapshot(); }); @@ -364,6 +386,24 @@ describe('NewRun', () => { ); }); + it('shows a page error if getPipelineVersion fails', async () => { + const props = generateProps(); + props.location.search = `?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`; + + TestUtils.makeErrorResponseOnce(getPipelineVersionSpy, 'test error message'); + + tree = shallow(); + await TestUtils.flushPromises(); + + expect(updateBannerSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + additionalInfo: 'test error message', + message: `Error: failed to retrieve pipeline version: ${MOCK_PIPELINE_VERSION.id}. Click Details for more information.`, + mode: 'error', + }), + ); + }); + it('renders a warning message if there are pipeline parameters with empty values', async () => { tree = TestUtils.mountWithRouter(); await TestUtils.flushPromises(); @@ -980,9 +1020,9 @@ describe('NewRun', () => { expect(tree.find('#startNewRunBtn').props()).toHaveProperty('disabled', true); }); - it("enables the 'Start' new run button if pipeline ID in query params and run name entered", async () => { + it("enables the 'Start' new run button if pipeline ID and pipeline version ID in query params and run name entered", async () => { const props = generateProps(); - props.location.search = `?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`; + props.location.search = `?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`; tree = shallow(); (tree.instance() as TestNewRun).handleChange('runName')({ target: { value: 'run name' } }); @@ -991,9 +1031,9 @@ describe('NewRun', () => { expect(tree.find('#startNewRunBtn').props()).toHaveProperty('disabled', false); }); - it("re-disables the 'Start' new run button if pipeline ID in query params and run name entered then cleared", async () => { + it("re-disables the 'Start' new run button if pipeline ID and pipeline version ID in query params and run name entered then cleared", async () => { const props = generateProps(); - props.location.search = `?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`; + props.location.search = `?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`; tree = shallow(); (tree.instance() as TestNewRun).handleChange('runName')({ target: { value: 'run name' } }); @@ -1008,7 +1048,8 @@ describe('NewRun', () => { const props = generateProps(); props.location.search = `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` + - `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`; + `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` + + `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`; tree = shallow(); (tree.instance() as TestNewRun).handleChange('runName')({ @@ -1029,7 +1070,6 @@ describe('NewRun', () => { name: 'test run name', pipeline_spec: { parameters: MOCK_PIPELINE.parameters, - pipeline_id: MOCK_PIPELINE.id, }, resource_references: [ { @@ -1039,6 +1079,13 @@ describe('NewRun', () => { }, relationship: ApiRelationship.OWNER, }, + { + key: { + id: MOCK_PIPELINE_VERSION.id, + type: ApiResourceType.PIPELINEVERSION, + }, + relationship: ApiRelationship.CREATOR, + }, ], }); }); @@ -1046,13 +1093,15 @@ describe('NewRun', () => { it('updates the parameters in state on handleParamChange', async () => { const props = generateProps(); const pipeline = newMockPipeline(); - pipeline.parameters = [ + const pipelineVersion = newMockPipelineVersion(); + pipelineVersion.parameters = [ { name: 'param-1', value: '' }, { name: 'param-2', value: 'prefilled value' }, ]; - props.location.search = `?${QUERY_PARAMS.pipelineId}=${pipeline.id}`; + props.location.search = `?${QUERY_PARAMS.pipelineId}=${pipeline.id}&${QUERY_PARAMS.pipelineVersionId}=${pipelineVersion.id}`; getPipelineSpy.mockImplementation(() => pipeline); + getPipelineVersionSpy.mockImplementation(() => pipelineVersion); tree = shallow(); await TestUtils.flushPromises(); @@ -1074,7 +1123,6 @@ describe('NewRun', () => { { name: 'param-1', value: 'test param value' }, { name: 'param-2', value: 'prefilled value' }, ], - pipeline_id: pipeline.id, }, }), ); @@ -1117,17 +1165,21 @@ describe('NewRun', () => { // No parameters should be showing expect(tree).toMatchSnapshot(); - // Select a pipeline with parameters - const pipelineWithParams = newMockPipeline(); - pipelineWithParams.id = 'pipeline-with-params'; - pipelineWithParams.parameters = [ + // Select a pipeline version with parameters + const pipeline = newMockPipeline(); + const pipelineVersionWithParams = newMockPipelineVersion(); + pipelineVersionWithParams.id = 'pipeline-version-with-params'; + pipelineVersionWithParams.parameters = [ { name: 'param-1', value: 'prefilled value 1' }, { name: 'param-2', value: 'prefilled value 2' }, ]; - getPipelineSpy.mockImplementationOnce(() => pipelineWithParams); - tree.setState({ unconfirmedSelectedPipeline: pipelineWithParams }); + getPipelineSpy.mockImplementationOnce(() => pipeline); + getPipelineVersionSpy.mockImplementationOnce(() => pipelineVersionWithParams); + tree.setState({ unconfirmedSelectedPipeline: pipeline }); + tree.setState({ unconfirmedSelectedPipelineVersion: pipelineVersionWithParams }); const instance = tree.instance() as TestNewRun; instance._pipelineSelectorClosed(true); + instance._pipelineVersionSelectorClosed(true); await TestUtils.flushPromises(); expect(tree).toMatchSnapshot(); @@ -1135,9 +1187,15 @@ describe('NewRun', () => { const noParamsPipeline = newMockPipeline(); noParamsPipeline.id = 'no-params-pipeline'; noParamsPipeline.parameters = []; + const noParamsPipelineVersion = newMockPipelineVersion(); + noParamsPipelineVersion.id = 'no-params-pipeline-version'; + noParamsPipelineVersion.parameters = []; getPipelineSpy.mockImplementationOnce(() => noParamsPipeline); + getPipelineVersionSpy.mockImplementationOnce(() => noParamsPipelineVersion); tree.setState({ unconfirmedSelectedPipeline: noParamsPipeline }); + tree.setState({ unconfirmedSelectedPipelineVersion: noParamsPipelineVersion }); instance._pipelineSelectorClosed(true); + instance._pipelineVersionSelectorClosed(true); await TestUtils.flushPromises(); expect(tree).toMatchSnapshot(); }); @@ -1147,16 +1205,20 @@ describe('NewRun', () => { await TestUtils.flushPromises(); // Select a pipeline with parameters - const pipelineWithParams = newMockPipeline(); - pipelineWithParams.id = 'pipeline-with-params'; - pipelineWithParams.parameters = [ + const pipeline = newMockPipeline(); + const pipelineVersionWithParams = newMockPipeline(); + pipelineVersionWithParams.id = 'pipeline-version-with-params'; + pipelineVersionWithParams.parameters = [ { name: 'param-1', value: ' whitespace on either side ' }, { name: 'param-2', value: 'value 2' }, ]; - getPipelineSpy.mockImplementationOnce(() => pipelineWithParams); - tree.setState({ unconfirmedSelectedPipeline: pipelineWithParams }); + getPipelineSpy.mockImplementationOnce(() => pipeline); + getPipelineVersionSpy.mockImplementationOnce(() => pipelineVersionWithParams); + tree.setState({ unconfirmedSelectedPipeline: pipeline }); + tree.setState({ unconfirmedSelectedPipelineVersion: pipelineVersionWithParams }); const instance = tree.instance() as TestNewRun; instance._pipelineSelectorClosed(true); + instance._pipelineVersionSelectorClosed(true); tree.find('#startNewRunBtn').simulate('click'); await TestUtils.flushPromises(); @@ -1169,7 +1231,6 @@ describe('NewRun', () => { { name: 'param-1', value: 'whitespace on either side' }, { name: 'param-2', value: 'value 2' }, ], - pipeline_id: 'pipeline-with-params', }, resource_references: [ { @@ -1179,6 +1240,13 @@ describe('NewRun', () => { }, relationship: ApiRelationship.OWNER, }, + { + key: { + id: 'pipeline-version-with-params', + type: ApiResourceType.PIPELINEVERSION, + }, + relationship: ApiRelationship.CREATOR, + }, ], }); }); @@ -1187,7 +1255,8 @@ describe('NewRun', () => { const props = generateProps(); props.location.search = `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` + - `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`; + `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` + + `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`; tree = shallow(); (tree.instance() as TestNewRun).handleChange('runName')({ @@ -1204,7 +1273,8 @@ describe('NewRun', () => { const props = generateProps(); props.location.search = `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` + - `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`; + `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` + + `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`; tree = shallow(); (tree.instance() as TestNewRun).handleChange('runName')({ @@ -1224,7 +1294,7 @@ describe('NewRun', () => { it('navigates to the AllRuns page upon successful start if there was not an experiment', async () => { const props = generateProps(); // No experiment in query params - props.location.search = `?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`; + props.location.search = `?${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`; tree = shallow(); (tree.instance() as TestNewRun).handleChange('runName')({ @@ -1243,7 +1313,8 @@ describe('NewRun', () => { const props = generateProps(); props.location.search = `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` + - `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`; + `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` + + `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`; TestUtils.makeErrorResponseOnce(startRunSpy, 'test error message'); @@ -1280,7 +1351,7 @@ describe('NewRun', () => { expect(updateDialogSpy).toHaveBeenCalledTimes(1); expect(updateDialogSpy.mock.calls[0][0]).toMatchObject({ - content: 'Cannot start run without pipeline', + content: 'Cannot start run without pipeline version', title: 'Run creation failed', }); }); @@ -1289,7 +1360,8 @@ describe('NewRun', () => { const props = generateProps(); props.location.search = `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` + - `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`; + `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` + + `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`; TestUtils.makeErrorResponseOnce(startRunSpy, 'test error message'); @@ -1310,7 +1382,8 @@ describe('NewRun', () => { const props = generateProps(); props.location.search = `?${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` + - `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`; + `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` + + `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`; tree = shallow(); (tree.instance() as TestNewRun).handleChange('runName')({ @@ -1357,7 +1430,8 @@ describe('NewRun', () => { props.location.search = `?${QUERY_PARAMS.isRecurring}=1` + `&${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` + - `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`; + `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` + + `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`; tree = TestUtils.mountWithRouter(); const instance = tree.instance() as TestNewRun; @@ -1381,7 +1455,6 @@ describe('NewRun', () => { name: 'test run name', pipeline_spec: { parameters: MOCK_PIPELINE.parameters, - pipeline_id: MOCK_PIPELINE.id, }, resource_references: [ { @@ -1391,6 +1464,13 @@ describe('NewRun', () => { }, relationship: ApiRelationship.OWNER, }, + { + key: { + id: MOCK_PIPELINE_VERSION.id, + type: ApiResourceType.PIPELINEVERSION, + }, + relationship: ApiRelationship.CREATOR, + }, ], // Default trigger trigger: { @@ -1408,7 +1488,8 @@ describe('NewRun', () => { props.location.search = `?${QUERY_PARAMS.isRecurring}=1` + `&${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` + - `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`; + `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` + + `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`; tree = shallow(); (tree.instance() as TestNewRun).handleChange('runName')({ @@ -1434,7 +1515,8 @@ describe('NewRun', () => { props.location.search = `?${QUERY_PARAMS.isRecurring}=1` + `&${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` + - `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`; + `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` + + `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`; tree = shallow(); (tree.instance() as TestNewRun).handleChange('runName')({ @@ -1460,7 +1542,8 @@ describe('NewRun', () => { props.location.search = `?${QUERY_PARAMS.isRecurring}=1` + `&${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` + - `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`; + `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` + + `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`; tree = shallow(); (tree.instance() as TestNewRun).handleChange('runName')({ @@ -1486,7 +1569,8 @@ describe('NewRun', () => { props.location.search = `?${QUERY_PARAMS.isRecurring}=1` + `&${QUERY_PARAMS.experimentId}=${MOCK_EXPERIMENT.id}` + - `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}`; + `&${QUERY_PARAMS.pipelineId}=${MOCK_PIPELINE.id}` + + `&${QUERY_PARAMS.pipelineVersionId}=${MOCK_PIPELINE_VERSION.id}`; tree = shallow(); (tree.instance() as TestNewRun).handleChange('runName')({ diff --git a/frontend/src/pages/NewRun.tsx b/frontend/src/pages/NewRun.tsx index 5d4ed9a9df7..c6458237b40 100644 --- a/frontend/src/pages/NewRun.tsx +++ b/frontend/src/pages/NewRun.tsx @@ -31,7 +31,7 @@ import RunUtils from '../lib/RunUtils'; import { TextFieldProps } from '@material-ui/core/TextField'; import Trigger from '../components/Trigger'; import { ApiExperiment } from '../apis/experiment'; -import { ApiPipeline, ApiParameter } from '../apis/pipeline'; +import { ApiPipeline, ApiParameter, ApiPipelineVersion } from '../apis/pipeline'; import { ApiRun, ApiResourceReference, @@ -41,7 +41,7 @@ import { ApiPipelineRuntime, } from '../apis/run'; import { ApiTrigger, ApiJob } from '../apis/job'; -import { Apis, PipelineSortKeys, ExperimentSortKeys } from '../lib/Apis'; +import { Apis, PipelineSortKeys, PipelineVersionSortKeys, ExperimentSortKeys } from '../lib/Apis'; import { Link } from 'react-router-dom'; import { Page } from './Page'; import { RoutePage, RouteParams, QUERY_PARAMS } from '../components/Router'; @@ -68,6 +68,7 @@ interface NewRunState { maxConcurrentRuns?: string; parameters: ApiParameter[]; pipeline?: ApiPipeline; + pipelineVersion?: ApiPipelineVersion; // 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). workflowFromRun?: Workflow; @@ -76,11 +77,14 @@ interface NewRunState { // Note: this cannot be undefined/optional or the label animation for the input field will not // work properly. pipelineName: string; + pipelineVersionName: string; pipelineSelectorOpen: boolean; + pipelineVersionSelectorOpen: boolean; runName: string; trigger?: ApiTrigger; unconfirmedSelectedExperiment?: ApiExperiment; unconfirmedSelectedPipeline?: ApiPipeline; + unconfirmedSelectedPipelineVersion?: ApiPipelineVersion; useWorkflowFromRun: boolean; uploadDialogOpen: boolean; usePipelineFromRunLabel: string; @@ -109,6 +113,14 @@ class NewRun extends Page<{}, NewRunState> { { label: 'Uploaded on', flex: 1, sortKey: PipelineSortKeys.CREATED_AT }, ]; + private pipelineVersionSelectorColumns = [ + { label: 'Version name', flex: 1, sortKey: PipelineVersionSortKeys.NAME }, + // TODO(jingzhang36): version doesn't have description field; remove it and + // fix the rendering. + { label: 'Description', flex: 2, customRenderer: descriptionCustomRenderer }, + { label: 'Uploaded on', flex: 1, sortKey: PipelineVersionSortKeys.CREATED_AT }, + ]; + private experimentSelectorColumns = [ { label: 'Experiment name', flex: 1, sortKey: ExperimentSortKeys.NAME }, { label: 'Description', flex: 2 }, @@ -130,6 +142,8 @@ class NewRun extends Page<{}, NewRunState> { parameters: [], pipelineName: '', pipelineSelectorOpen: false, + pipelineVersionName: '', + pipelineVersionSelectorOpen: false, runName: '', uploadDialogOpen: false, usePipelineFromRunLabel: 'Using pipeline from cloned run', @@ -157,10 +171,13 @@ class NewRun extends Page<{}, NewRunState> { isRecurringRun, parameters, pipelineName, + pipelineVersionName, pipelineSelectorOpen, + pipelineVersionSelectorOpen, runName, unconfirmedSelectedExperiment, unconfirmedSelectedPipeline, + unconfirmedSelectedPipelineVersion, usePipelineFromRunLabel, useWorkflowFromRun, } = this.state; @@ -169,8 +186,10 @@ class NewRun extends Page<{}, NewRunState> { const originalRunId = urlParser.get(QUERY_PARAMS.cloneFromRun) || urlParser.get(QUERY_PARAMS.fromRunId); const pipelineDetailsUrl = originalRunId - ? RoutePage.PIPELINE_DETAILS.replace(':' + RouteParams.pipelineId + '?', '') + - urlParser.build({ [QUERY_PARAMS.fromRunId]: originalRunId }) + ? RoutePage.PIPELINE_DETAILS.replace( + ':' + RouteParams.pipelineId + '/version/:' + RouteParams.pipelineVersionId + '?', + '', + ) + urlParser.build({ [QUERY_PARAMS.fromRunId]: originalRunId }) : ''; const buttons = new Buttons(this.props, this.refresh.bind(this)); @@ -183,7 +202,7 @@ class NewRun extends Page<{}, NewRunState> { {/* Pipeline selection */} {!!workflowFromRun && (
- {usePipelineFromRunLabel} + {usePipelineFromRunLabel} {!!originalRunId && [View pipeline]}
)} @@ -212,6 +231,31 @@ class NewRun extends Page<{}, NewRunState> { }} /> )} + {!useWorkflowFromRun && ( + + + + ), + readOnly: true, + }} + /> + )} {/* Pipeline selector dialog */} { + {/* Pipeline version selector dialog */} + this._pipelineVersionSelectorClosed(false)} + PaperProps={{ id: 'pipelineVersionSelectorDialog' }} + > + + { + const response = await Apis.pipelineServiceApi.listPipelineVersions( + 'PIPELINE', + this.state.pipeline ? this.state.pipeline!.id! : '', + args[1] /* page size */, + args[0] /* page token*/, + args[2] /* sort by */, + args[3] /* filter */, + ); + return { + nextPageToken: response.next_page_token || '', + resources: response.versions || [], + }; + }} + columns={this.pipelineVersionSelectorColumns} + emptyMessage='No pipeline versions found. Select or upload a pipeline then try again.' + initialSortColumn={PipelineVersionSortKeys.CREATED_AT} + selectionChanged={(selectedPipelineVersion: ApiPipelineVersion) => + this.setStateSafe({ unconfirmedSelectedPipelineVersion: selectedPipelineVersion }) + } + toolbarActionMap={buttons + .upload(() => + this.setStateSafe({ + pipelineVersionSelectorOpen: false, + uploadDialogOpen: true, + }), + ) + .getToolbarActionMap()} + /> + + + + + + + { // 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) + // 2. Click [View Pipeline Version] for this run from one of the list pages + // (Now you will be viewing a pipeline details page for a pipeline version that hasn't been uploaded) // 3. Click Create run - const embeddedPipelineRunId = urlParser.get(QUERY_PARAMS.fromRunId); + const embeddedRunId = urlParser.get(QUERY_PARAMS.fromRunId); if (originalRunId) { // If we are cloning a run, fetch the original try { @@ -497,10 +602,12 @@ class NewRun extends Page<{}, NewRunState> { ); logger.error(`Failed to retrieve original recurring run: ${originalRunId}`, err); } - } else if (embeddedPipelineRunId) { - this._prepareFormFromEmbeddedPipeline(embeddedPipelineRunId); + } else if (embeddedRunId) { + // If we create run from a workflow manifest that is acquried from an existing run. + this._prepareFormFromEmbeddedPipeline(embeddedRunId); } else { - // Get pipeline id from querystring if any + // If we create a run from an existing pipeline version. + // Get pipeline and pipeline version id from querystring if any const possiblePipelineId = urlParser.get(QUERY_PARAMS.pipelineId); if (possiblePipelineId) { try { @@ -519,6 +626,26 @@ class NewRun extends Page<{}, NewRunState> { logger.error(`Failed to retrieve pipeline: ${possiblePipelineId}`, err); } } + const possiblePipelineVersionId = urlParser.get(QUERY_PARAMS.pipelineVersionId); + if (possiblePipelineVersionId) { + try { + const pipelineVersion = await Apis.pipelineServiceApi.getPipelineVersion( + possiblePipelineVersionId, + ); + this.setStateSafe({ + parameters: pipelineVersion.parameters || [], + pipelineVersion, + pipelineVersionName: (pipelineVersion && pipelineVersion.name) || '', + }); + } catch (err) { + urlParser.clear(QUERY_PARAMS.pipelineVersionId); + await this.showPageError( + `Error: failed to retrieve pipeline version: ${possiblePipelineVersionId}.`, + err, + ); + logger.error(`Failed to retrieve pipeline version: ${possiblePipelineVersionId}`, err); + } + } } let experiment: ApiExperiment | undefined; @@ -578,10 +705,17 @@ class NewRun extends Page<{}, NewRunState> { } protected async _pipelineSelectorClosed(confirmed: boolean): Promise { - let { parameters, pipeline } = this.state; + let { parameters, pipeline, pipelineVersion } = this.state; if (confirmed && this.state.unconfirmedSelectedPipeline) { pipeline = this.state.unconfirmedSelectedPipeline; - parameters = pipeline.parameters || []; + // Get the default version of selected pipeline to auto-fill the version + // input field. + if (pipeline.default_version) { + pipelineVersion = await Apis.pipelineServiceApi.getPipelineVersion( + pipeline.default_version.id!, + ); + parameters = pipelineVersion.parameters || []; + } } this.setStateSafe( @@ -590,6 +724,26 @@ class NewRun extends Page<{}, NewRunState> { pipeline, pipelineName: (pipeline && pipeline.name) || '', pipelineSelectorOpen: false, + pipelineVersion, + pipelineVersionName: (pipelineVersion && pipelineVersion.name) || '', + }, + () => this._validate(), + ); + } + + protected async _pipelineVersionSelectorClosed(confirmed: boolean): Promise { + let { parameters, pipelineVersion } = this.state; + if (confirmed && this.state.unconfirmedSelectedPipelineVersion) { + pipelineVersion = this.state.unconfirmedSelectedPipelineVersion; + parameters = pipelineVersion.parameters || []; + } + + this.setStateSafe( + { + parameters, + pipelineVersion, + pipelineVersionName: (pipelineVersion && pipelineVersion.name) || '', + pipelineVersionSelectorOpen: false, }, () => this._validate(), ); @@ -647,25 +801,25 @@ class NewRun extends Page<{}, NewRunState> { } } - private async _prepareFormFromEmbeddedPipeline(embeddedPipelineRunId: string): Promise { + private async _prepareFormFromEmbeddedPipeline(embeddedRunId: string): Promise { let embeddedPipelineSpec: string | null; let runWithEmbeddedPipeline: ApiRunDetail; try { - runWithEmbeddedPipeline = await Apis.runServiceApi.getRun(embeddedPipelineRunId); + runWithEmbeddedPipeline = await Apis.runServiceApi.getRun(embeddedRunId); embeddedPipelineSpec = RunUtils.getWorkflowManifest(runWithEmbeddedPipeline.run); } catch (err) { await this.showPageError( - `Error: failed to retrieve the specified run: ${embeddedPipelineRunId}.`, + `Error: failed to retrieve the specified run: ${embeddedRunId}.`, err, ); - logger.error(`Failed to retrieve the specified run: ${embeddedPipelineRunId}`, err); + logger.error(`Failed to retrieve the specified run: ${embeddedRunId}`, err); return; } if (!embeddedPipelineSpec) { await this.showPageError( - `Error: somehow the run provided in the query params: ${embeddedPipelineRunId} had no embedded pipeline.`, + `Error: somehow the run provided in the query params: ${embeddedRunId} had no embedded pipeline.`, ); return; } @@ -684,10 +838,7 @@ class NewRun extends Page<{}, NewRunState> { `Error: failed to parse the embedded pipeline's spec: ${embeddedPipelineSpec}.`, err, ); - logger.error( - `Failed to parse the embedded pipeline's spec from run: ${embeddedPipelineRunId}`, - err, - ); + logger.error(`Failed to parse the embedded pipeline's spec from run: ${embeddedRunId}`, err); return; } @@ -704,17 +855,40 @@ class NewRun extends Page<{}, NewRunState> { } let pipeline: ApiPipeline | undefined; + let pipelineVersion: ApiPipelineVersion | undefined; let workflowFromRun: Workflow | undefined; let useWorkflowFromRun = false; let usePipelineFromRunLabel = ''; let name = ''; + let pipelineVersionName = ''; - // This corresponds to a run using a pipeline that has been uploaded + // Case 1: a legacy run refers to a pipeline without specifying version. const referencePipelineId = RunUtils.getPipelineId(originalRun); - // This corresponds to a run where the pipeline has not been uploaded, such as runs started from + // Case 2: a run refers to a pipeline version. + const referencePipelineVersionId = RunUtils.getPipelineVersionId(originalRun); + // Case 3: a run whose pipeline (version) has not been uploaded, such as runs started from // the CLI or notebooks const embeddedPipelineSpec = RunUtils.getWorkflowManifest(originalRun); - if (referencePipelineId) { + if (referencePipelineVersionId) { + try { + // TODO(jingzhang36): optimize this part to make only one api call. + pipelineVersion = await Apis.pipelineServiceApi.getPipelineVersion( + referencePipelineVersionId, + ); + pipelineVersionName = pipelineVersion && pipelineVersion.name ? pipelineVersion.name : ''; + pipeline = await Apis.pipelineServiceApi.getPipeline( + RunUtils.getPipelineIdFromApiPipelineVersion(pipelineVersion)!, + ); + name = pipeline.name || ''; + } catch (err) { + await this.showPageError( + 'Error: failed to find a pipeline version corresponding to that of the original run:' + + ` ${originalRun.id}.`, + err, + ); + return; + } + } else if (referencePipelineId) { try { pipeline = await Apis.pipelineServiceApi.getPipeline(referencePipelineId); name = pipeline.name || ''; @@ -755,6 +929,8 @@ class NewRun extends Page<{}, NewRunState> { parameters, pipeline, pipelineName: name, + pipelineVersion, + pipelineVersionName, runName: this._getCloneName(originalRun.name!), usePipelineFromRunLabel, useWorkflowFromRun, @@ -776,9 +952,9 @@ class NewRun extends Page<{}, NewRunState> { } private _start(): void { - if (!this.state.pipeline && !this.state.workflowFromRun) { - this.showErrorDialog('Run creation failed', 'Cannot start run without pipeline'); - logger.error('Cannot start run without pipeline'); + if (!this.state.pipelineVersion && !this.state.workflowFromRun) { + this.showErrorDialog('Run creation failed', 'Cannot start run without pipeline version'); + logger.error('Cannot start run without pipeline version'); return; } const references: ApiResourceReference[] = []; @@ -791,6 +967,15 @@ class NewRun extends Page<{}, NewRunState> { relationship: ApiRelationship.OWNER, }); } + if (this.state.pipelineVersion) { + references.push({ + key: { + id: this.state.pipelineVersion!.id, + type: ApiResourceType.PIPELINEVERSION, + }, + relationship: ApiRelationship.CREATOR, + }); + } let newRun: ApiRun | ApiJob = { description: this.state.description, @@ -800,7 +985,6 @@ class NewRun extends Page<{}, NewRunState> { p.value = (p.value || '').trim(); return p; }), - pipeline_id: this.state.pipeline ? this.state.pipeline.id : undefined, workflow_manifest: this.state.useWorkflowFromRun ? JSON.stringify(this.state.workflowFromRun) : undefined, @@ -863,10 +1047,10 @@ class NewRun extends Page<{}, NewRunState> { private _validate(): void { // Validate state - const { pipeline, workflowFromRun, maxConcurrentRuns, runName, trigger } = this.state; + const { pipelineVersion, workflowFromRun, maxConcurrentRuns, runName, trigger } = this.state; try { - if (!pipeline && !workflowFromRun) { - throw new Error('A pipeline must be selected'); + if (!pipelineVersion && !workflowFromRun) { + throw new Error('A pipeline version must be selected'); } if (!runName) { throw new Error('Run name is required'); diff --git a/frontend/src/pages/PipelineDetails.test.tsx b/frontend/src/pages/PipelineDetails.test.tsx index 029f261c03b..855ce625998 100644 --- a/frontend/src/pages/PipelineDetails.test.tsx +++ b/frontend/src/pages/PipelineDetails.test.tsx @@ -19,7 +19,7 @@ import * as StaticGraphParser from '../lib/StaticGraphParser'; import PipelineDetails, { css } from './PipelineDetails'; import TestUtils from '../TestUtils'; import { ApiExperiment } from '../apis/experiment'; -import { ApiPipeline } from '../apis/pipeline'; +import { ApiPipeline, ApiPipelineVersion } from '../apis/pipeline'; import { ApiRunDetail, ApiResourceType } from '../apis/run'; import { Apis } from '../lib/Apis'; import { PageProps } from './Page'; @@ -35,20 +35,32 @@ describe('PipelineDetails', () => { const updateToolbarSpy = jest.fn(); const historyPushSpy = jest.fn(); const getPipelineSpy = jest.spyOn(Apis.pipelineServiceApi, 'getPipeline'); + const getPipelineVersionSpy = jest.spyOn(Apis.pipelineServiceApi, 'getPipelineVersion'); + const listPipelineVersionsSpy = jest.spyOn(Apis.pipelineServiceApi, 'listPipelineVersions'); const getRunSpy = jest.spyOn(Apis.runServiceApi, 'getRun'); const getExperimentSpy = jest.spyOn(Apis.experimentServiceApi, 'getExperiment'); - const deletePipelineSpy = jest.spyOn(Apis.pipelineServiceApi, 'deletePipeline'); - const getTemplateSpy = jest.spyOn(Apis.pipelineServiceApi, 'getTemplate'); + const deletePipelineVersionSpy = jest.spyOn(Apis.pipelineServiceApi, 'deletePipelineVersion'); + const getPipelineVersionTemplateSpy = jest.spyOn( + Apis.pipelineServiceApi, + 'getPipelineVersionTemplate', + ); const createGraphSpy = jest.spyOn(StaticGraphParser, 'createGraph'); let tree: ShallowWrapper | ReactWrapper; let testPipeline: ApiPipeline = {}; + let testPipelineVersion: ApiPipelineVersion = {}; let testRun: ApiRunDetail = {}; function generateProps(fromRunSpec = false): PageProps { const match = { isExact: true, - params: fromRunSpec ? {} : { [RouteParams.pipelineId]: testPipeline.id }, + params: fromRunSpec + ? {} + : { + [RouteParams.pipelineId]: testPipeline.id, + [RouteParams.pipelineVersionId]: + (testPipeline.default_version && testPipeline.default_version!.id) || '', + }, path: '', url: '', }; @@ -77,6 +89,15 @@ describe('PipelineDetails', () => { id: 'test-pipeline-id', name: 'test pipeline', parameters: [{ name: 'param1', value: 'value1' }], + default_version: { + id: 'test-pipeline-version-id', + name: 'test-pipeline-version', + }, + }; + + testPipelineVersion = { + id: 'test-pipeline-version-id', + name: 'test-pipeline-version', }; testRun = { @@ -90,11 +111,18 @@ describe('PipelineDetails', () => { }; getPipelineSpy.mockImplementation(() => Promise.resolve(testPipeline)); + getPipelineVersionSpy.mockImplementation(() => Promise.resolve(testPipelineVersion)); + listPipelineVersionsSpy.mockImplementation(() => + Promise.resolve({ versions: [testPipelineVersion] }), + ); getRunSpy.mockImplementation(() => Promise.resolve(testRun)); getExperimentSpy.mockImplementation(() => Promise.resolve({ id: 'test-experiment-id', name: 'test experiment' } as ApiExperiment), ); - getTemplateSpy.mockImplementation(() => Promise.resolve({ template: 'test template' })); + // getTemplateSpy.mockImplementation(() => Promise.resolve({ template: 'test template' })); + getPipelineVersionTemplateSpy.mockImplementation(() => + Promise.resolve({ template: 'test template' }), + ); createGraphSpy.mockImplementation(() => new graphlib.Graph()); }); @@ -108,19 +136,19 @@ describe('PipelineDetails', () => { it('shows empty pipeline details with no graph', async () => { TestUtils.makeErrorResponseOnce(createGraphSpy, 'bad graph'); tree = shallow(); - await getTemplateSpy; + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); expect(tree).toMatchSnapshot(); }); it('shows pipeline name in page name, and breadcrumb to go back to pipelines', async () => { tree = shallow(); - await getTemplateSpy; + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); expect(updateToolbarSpy).toHaveBeenLastCalledWith( expect.objectContaining({ breadcrumbs: [{ displayName: 'Pipelines', href: RoutePage.PIPELINES }], - pageTitle: testPipeline.name, + pageTitle: testPipeline.name + ' (' + testPipelineVersion.name + ')', }), ); }); @@ -131,7 +159,7 @@ describe('PipelineDetails', () => { async () => { tree = shallow(); await getRunSpy; - await getTemplateSpy; + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); expect(updateToolbarSpy).toHaveBeenLastCalledWith( expect.objectContaining({ @@ -158,7 +186,7 @@ describe('PipelineDetails', () => { tree = shallow(); await getRunSpy; await getExperimentSpy; - await getTemplateSpy; + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); expect(updateToolbarSpy).toHaveBeenLastCalledWith( expect.objectContaining({ @@ -251,7 +279,7 @@ describe('PipelineDetails', () => { }); it('uses an empty string and does not show error when getTemplate response is empty', async () => { - getTemplateSpy.mockImplementationOnce(() => Promise.resolve({})); + getPipelineVersionTemplateSpy.mockImplementationOnce(() => Promise.resolve({})); tree = shallow(); await getPipelineSpy; @@ -280,7 +308,7 @@ describe('PipelineDetails', () => { }); it('shows load error banner when failing to get pipeline template', async () => { - TestUtils.makeErrorResponseOnce(getTemplateSpy, 'woops'); + TestUtils.makeErrorResponseOnce(getPipelineVersionTemplateSpy, 'woops'); tree = shallow(); await getPipelineSpy; await TestUtils.flushPromises(); @@ -297,7 +325,7 @@ describe('PipelineDetails', () => { it('shows no graph error banner when failing to parse graph', async () => { TestUtils.makeErrorResponseOnce(createGraphSpy, 'bad graph'); tree = shallow(); - await getTemplateSpy; + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); expect(updateBannerSpy).toHaveBeenCalledTimes(2); // Once to clear banner, once to show error expect(updateBannerSpy).toHaveBeenLastCalledWith( @@ -310,7 +338,7 @@ describe('PipelineDetails', () => { }); it('clears the error banner when refreshing the page', async () => { - TestUtils.makeErrorResponseOnce(getTemplateSpy, 'woops'); + TestUtils.makeErrorResponseOnce(getPipelineVersionTemplateSpy, 'woops'); tree = shallow(); await TestUtils.flushPromises(); @@ -329,14 +357,14 @@ describe('PipelineDetails', () => { it('shows empty pipeline details with empty graph', async () => { tree = shallow(); - await getTemplateSpy; + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); expect(tree).toMatchSnapshot(); }); it('sets summary shown state to false when clicking the Hide button', async () => { tree = mount(); - await getTemplateSpy; + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); tree.update(); expect(tree.state('summaryShown')).toBe(true); @@ -346,7 +374,7 @@ describe('PipelineDetails', () => { it('collapses summary card when summary shown state is false', async () => { tree = shallow(); - await getTemplateSpy; + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); tree.setState({ summaryShown: false }); expect(tree).toMatchSnapshot(); @@ -354,7 +382,7 @@ describe('PipelineDetails', () => { it('shows the summary card when clicking Show button', async () => { tree = mount(); - await getTemplateSpy; + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); tree.setState({ summaryShown: false }); tree.find(`.${css.footer} Button`).simulate('click'); @@ -363,7 +391,7 @@ describe('PipelineDetails', () => { it('has a new experiment button if it has a pipeline reference', async () => { tree = shallow(); - await getTemplateSpy; + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); const instance = tree.instance() as PipelineDetails; const newExperimentBtn = instance.getInitialToolbarState().actions[ButtonKeys.NEW_EXPERIMENT]; @@ -372,11 +400,14 @@ describe('PipelineDetails', () => { it("has 'create run' toolbar button if viewing an embedded pipeline", async () => { tree = shallow(); - await getTemplateSpy; + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); const instance = tree.instance() as PipelineDetails; - expect(Object.keys(instance.getInitialToolbarState().actions)).toHaveLength(1); - const newRunBtn = instance.getInitialToolbarState().actions[ButtonKeys.NEW_RUN_FROM_PIPELINE]; + /* create run and create pipeline version, so 2 */ + expect(Object.keys(instance.getInitialToolbarState().actions)).toHaveLength(2); + const newRunBtn = instance.getInitialToolbarState().actions[ + (ButtonKeys.NEW_RUN_FROM_PIPELINE_VERSION, ButtonKeys.NEW_PIPELINE_VERSION) + ]; expect(newRunBtn).toBeDefined(); }); @@ -384,7 +415,9 @@ describe('PipelineDetails', () => { tree = shallow(); await TestUtils.flushPromises(); const instance = tree.instance() as PipelineDetails; - const newRunBtn = instance.getInitialToolbarState().actions[ButtonKeys.NEW_RUN_FROM_PIPELINE]; + const newRunBtn = instance.getInitialToolbarState().actions[ + ButtonKeys.NEW_RUN_FROM_PIPELINE_VERSION + ]; newRunBtn!.action(); expect(historyPushSpy).toHaveBeenCalledTimes(1); expect(historyPushSpy).toHaveBeenLastCalledWith( @@ -394,11 +427,14 @@ describe('PipelineDetails', () => { it("has 'create run' toolbar button if not viewing an embedded pipeline", async () => { tree = shallow(); - await getTemplateSpy; + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); const instance = tree.instance() as PipelineDetails; - expect(Object.keys(instance.getInitialToolbarState().actions)).toHaveLength(3); - const newRunBtn = instance.getInitialToolbarState().actions[ButtonKeys.NEW_RUN_FROM_PIPELINE]; + /* create run, create pipeline version, create experiment and delete run, so 4 */ + expect(Object.keys(instance.getInitialToolbarState().actions)).toHaveLength(4); + const newRunBtn = instance.getInitialToolbarState().actions[ + ButtonKeys.NEW_RUN_FROM_PIPELINE_VERSION + ]; expect(newRunBtn).toBeDefined(); }); @@ -406,33 +442,39 @@ describe('PipelineDetails', () => { tree = shallow(); await TestUtils.flushPromises(); const instance = tree.instance() as PipelineDetails; - const newRunFromPipelineBtn = instance.getInitialToolbarState().actions[ - ButtonKeys.NEW_RUN_FROM_PIPELINE + const newRunFromPipelineVersionBtn = instance.getInitialToolbarState().actions[ + ButtonKeys.NEW_RUN_FROM_PIPELINE_VERSION ]; - newRunFromPipelineBtn.action(); + newRunFromPipelineVersionBtn.action(); expect(historyPushSpy).toHaveBeenCalledTimes(1); expect(historyPushSpy).toHaveBeenLastCalledWith( - RoutePage.NEW_RUN + `?${QUERY_PARAMS.pipelineId}=${testPipeline.id}`, + RoutePage.NEW_RUN + + `?${QUERY_PARAMS.pipelineId}=${testPipeline.id}&${ + QUERY_PARAMS.pipelineVersionId + }=${testPipeline.default_version!.id!}`, ); }); - it('clicking new run button when viewing half-loaded page navigates to the new run page with pipeline ID', async () => { + it('clicking new run button when viewing half-loaded page navigates to the new run page with pipeline ID and version ID', async () => { tree = shallow(); // Intentionally don't wait until all network requests finish. const instance = tree.instance() as PipelineDetails; - const newRunFromPipelineBtn = instance.getInitialToolbarState().actions[ - ButtonKeys.NEW_RUN_FROM_PIPELINE + const newRunFromPipelineVersionBtn = instance.getInitialToolbarState().actions[ + ButtonKeys.NEW_RUN_FROM_PIPELINE_VERSION ]; - newRunFromPipelineBtn.action(); + newRunFromPipelineVersionBtn.action(); expect(historyPushSpy).toHaveBeenCalledTimes(1); expect(historyPushSpy).toHaveBeenLastCalledWith( - RoutePage.NEW_RUN + `?${QUERY_PARAMS.pipelineId}=${testPipeline.id}`, + RoutePage.NEW_RUN + + `?${QUERY_PARAMS.pipelineId}=${testPipeline.id}&${ + QUERY_PARAMS.pipelineVersionId + }=${testPipeline.default_version!.id!}`, ); }); it('clicking new experiment button navigates to new experiment page', async () => { tree = shallow(); - await getTemplateSpy; + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); const instance = tree.instance() as PipelineDetails; const newExperimentBtn = instance.getInitialToolbarState().actions[ButtonKeys.NEW_EXPERIMENT]; @@ -457,7 +499,7 @@ describe('PipelineDetails', () => { it('has a delete button', async () => { tree = shallow(); - await getTemplateSpy; + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); const instance = tree.instance() as PipelineDetails; const deleteBtn = instance.getInitialToolbarState().actions[ButtonKeys.DELETE_RUN]; @@ -473,7 +515,7 @@ describe('PipelineDetails', () => { expect(updateDialogSpy).toHaveBeenCalledTimes(1); expect(updateDialogSpy).toHaveBeenLastCalledWith( expect.objectContaining({ - title: 'Delete this pipeline?', + title: 'Delete this pipeline version?', }), ); }); @@ -487,12 +529,12 @@ describe('PipelineDetails', () => { const call = updateDialogSpy.mock.calls[0][0]; const cancelBtn = call.buttons.find((b: any) => b.text === 'Cancel'); await cancelBtn.onClick(); - expect(deletePipelineSpy).not.toHaveBeenCalled(); + expect(deletePipelineVersionSpy).not.toHaveBeenCalled(); }); it('calls delete API when delete dialog is confirmed', async () => { tree = shallow(); - await getTemplateSpy; + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); const deleteBtn = (tree.instance() as PipelineDetails).getInitialToolbarState().actions[ ButtonKeys.DELETE_RUN @@ -501,8 +543,8 @@ describe('PipelineDetails', () => { const call = updateDialogSpy.mock.calls[0][0]; const confirmBtn = call.buttons.find((b: any) => b.text === 'Delete'); await confirmBtn.onClick(); - expect(deletePipelineSpy).toHaveBeenCalledTimes(1); - expect(deletePipelineSpy).toHaveBeenLastCalledWith(testPipeline.id); + expect(deletePipelineVersionSpy).toHaveBeenCalledTimes(1); + expect(deletePipelineVersionSpy).toHaveBeenLastCalledWith(testPipeline.default_version!.id!); }); it('calls delete API when delete dialog is confirmed and page is half-loaded', async () => { @@ -515,14 +557,14 @@ describe('PipelineDetails', () => { const call = updateDialogSpy.mock.calls[0][0]; const confirmBtn = call.buttons.find((b: any) => b.text === 'Delete'); await confirmBtn.onClick(); - expect(deletePipelineSpy).toHaveBeenCalledTimes(1); - expect(deletePipelineSpy).toHaveBeenLastCalledWith(testPipeline.id); + expect(deletePipelineVersionSpy).toHaveBeenCalledTimes(1); + expect(deletePipelineVersionSpy).toHaveBeenLastCalledWith(testPipeline.default_version!.id); }); it('shows error dialog if deletion fails', async () => { tree = shallow(); - TestUtils.makeErrorResponseOnce(deletePipelineSpy, 'woops'); - await getTemplateSpy; + TestUtils.makeErrorResponseOnce(deletePipelineVersionSpy, 'woops'); + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); const deleteBtn = (tree.instance() as PipelineDetails).getInitialToolbarState().actions[ ButtonKeys.DELETE_RUN @@ -534,15 +576,15 @@ describe('PipelineDetails', () => { expect(updateDialogSpy).toHaveBeenCalledTimes(2); // Delete dialog + error dialog expect(updateDialogSpy).toHaveBeenLastCalledWith( expect.objectContaining({ - content: 'Failed to delete pipeline: test-pipeline-id with error: "woops"', - title: 'Failed to delete pipeline', + content: 'Failed to delete pipeline version: test-pipeline-version-id with error: "woops"', + title: 'Failed to delete pipeline version', }), ); }); it('shows success snackbar if deletion succeeds', async () => { tree = shallow(); - await getTemplateSpy; + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); const deleteBtn = (tree.instance() as PipelineDetails).getInitialToolbarState().actions[ ButtonKeys.DELETE_RUN @@ -554,7 +596,7 @@ describe('PipelineDetails', () => { expect(updateSnackbarSpy).toHaveBeenCalledTimes(1); expect(updateSnackbarSpy).toHaveBeenLastCalledWith( expect.objectContaining({ - message: 'Delete succeeded for this pipeline', + message: 'Delete succeeded for this pipeline version', open: true, }), ); @@ -562,7 +604,7 @@ describe('PipelineDetails', () => { it('opens side panel on clicked node, shows message when node is not found in graph', async () => { tree = shallow(); - await getTemplateSpy; + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); tree.find('Graph').simulate('click', 'some-node-id'); expect(tree.state('selectedNodeId')).toBe('some-node-id'); @@ -583,7 +625,7 @@ describe('PipelineDetails', () => { createGraphSpy.mockImplementation(() => g); tree = shallow(); - await getTemplateSpy; + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); tree.find('Graph').simulate('click', 'node1'); expect(tree).toMatchSnapshot(); @@ -591,7 +633,7 @@ describe('PipelineDetails', () => { it('shows pipeline source code when config tab is clicked', async () => { tree = shallow(); - await getTemplateSpy; + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); tree.find('MD2Tabs').simulate('switch', 1); expect(tree.state('selectedTab')).toBe(1); @@ -600,11 +642,19 @@ describe('PipelineDetails', () => { it('closes side panel when close button is clicked', async () => { tree = shallow(); - await getTemplateSpy; + await getPipelineVersionTemplateSpy; await TestUtils.flushPromises(); tree.setState({ selectedNodeId: 'some-node-id' }); tree.find('SidePanel').simulate('close'); expect(tree.state('selectedNodeId')).toBe(''); expect(tree).toMatchSnapshot(); }); + + it('closes side panel when close button is clicked', async () => { + tree = shallow(); + await getPipelineVersionTemplateSpy; + await TestUtils.flushPromises(); + expect(tree.state('versions')).toHaveLength(1); + expect(tree).toMatchSnapshot(); + }); }); diff --git a/frontend/src/pages/PipelineDetails.tsx b/frontend/src/pages/PipelineDetails.tsx index 89b30f75917..6e20888da68 100644 --- a/frontend/src/pages/PipelineDetails.tsx +++ b/frontend/src/pages/PipelineDetails.tsx @@ -18,7 +18,7 @@ import * as JsYaml from 'js-yaml'; import * as React from 'react'; import * as StaticGraphParser from '../lib/StaticGraphParser'; import Button from '@material-ui/core/Button'; -import Buttons from '../lib/Buttons'; +import Buttons, { ButtonKeys } from '../lib/Buttons'; import Graph from '../components/Graph'; import InfoIcon from '@material-ui/icons/InfoOutlined'; import MD2Tabs from '../atoms/MD2Tabs'; @@ -27,7 +27,7 @@ import RunUtils from '../lib/RunUtils'; import SidePanel from '../components/SidePanel'; import StaticNodeDetails from '../components/StaticNodeDetails'; import { ApiExperiment } from '../apis/experiment'; -import { ApiPipeline, ApiGetTemplateResponse } from '../apis/pipeline'; +import { ApiPipeline, ApiGetTemplateResponse, ApiPipelineVersion } from '../apis/pipeline'; import { Apis } from '../lib/Apis'; import { Page } from './Page'; import { RoutePage, RouteParams, QUERY_PARAMS } from '../components/Router'; @@ -43,16 +43,22 @@ import 'brace/ext/language_tools'; import 'brace/mode/yaml'; import 'brace/theme/github'; import { Description } from '../components/Description'; +import Select from '@material-ui/core/Select'; +import FormControl from '@material-ui/core/FormControl'; +import InputLabel from '@material-ui/core/InputLabel'; +import MenuItem from '@material-ui/core/MenuItem'; interface PipelineDetailsState { - graph?: dagre.graphlib.Graph; + graph: dagre.graphlib.Graph | null; pipeline: ApiPipeline | null; selectedNodeId: string; selectedNodeInfo: JSX.Element | null; selectedTab: number; + selectedVersion?: ApiPipelineVersion; summaryShown: boolean; template?: Workflow; templateString?: string; + versions: ApiPipelineVersion[]; } const summaryCardWidth = 500; @@ -107,11 +113,13 @@ class PipelineDetails extends Page<{}, PipelineDetailsState> { super(props); this.state = { + graph: null, pipeline: null, selectedNodeId: '', selectedNodeInfo: null, selectedTab: 0, summaryShown: true, + versions: [], }; } @@ -119,13 +127,19 @@ class PipelineDetails extends Page<{}, PipelineDetailsState> { const buttons = new Buttons(this.props, this.refresh.bind(this)); const fromRunId = new URLParser(this.props).get(QUERY_PARAMS.fromRunId); const pipelineIdFromParams = this.props.match.params[RouteParams.pipelineId]; - buttons.newRunFromPipeline(() => { - return this.state.pipeline - ? this.state.pipeline.id! - : pipelineIdFromParams - ? pipelineIdFromParams - : ''; - }); + const pipelineVersionIdFromParams = this.props.match.params[RouteParams.pipelineVersionId]; + buttons + .newRunFromPipelineVersion( + () => { + return pipelineIdFromParams ? pipelineIdFromParams : ''; + }, + () => { + return pipelineVersionIdFromParams ? pipelineVersionIdFromParams : ''; + }, + ) + .newPipelineVersion('Upload pipeline version', () => + pipelineIdFromParams ? pipelineIdFromParams : '', + ); if (fromRunId) { return { @@ -139,7 +153,7 @@ class PipelineDetails extends Page<{}, PipelineDetailsState> { pageTitle: 'Pipeline details', }; } else { - // Add buttons for creating experiment and deleting pipeline + // Add buttons for creating experiment and deleting pipeline version buttons .newExperiment(() => this.state.pipeline @@ -149,13 +163,8 @@ class PipelineDetails extends Page<{}, PipelineDetailsState> { : '', ) .delete( - () => - this.state.pipeline - ? [this.state.pipeline.id!] - : pipelineIdFromParams - ? [pipelineIdFromParams] - : [], - 'pipeline', + () => (pipelineVersionIdFromParams ? [pipelineVersionIdFromParams] : []), + 'pipeline version', this._deleteCallback.bind(this), true /* useCurrentResource */, ); @@ -168,7 +177,15 @@ class PipelineDetails extends Page<{}, PipelineDetailsState> { } public render(): JSX.Element { - const { pipeline, selectedNodeId, selectedTab, summaryShown, templateString } = this.state; + const { + pipeline, + selectedNodeId, + selectedTab, + selectedVersion, + summaryShown, + templateString, + versions, + } = this.state; let selectedNodeInfo: StaticGraphParser.SelectedNodeInfo | null = null; if (this.state.graph && this.state.graph.node(selectedNodeId)) { @@ -213,6 +230,35 @@ class PipelineDetails extends Page<{}, PipelineDetailsState> {
ID
{pipeline.id || 'Unable to obtain Pipeline ID'}
+ {versions.length && ( + +
+ + Version + + +
+ +
+ )}
Uploaded on
{formatDateString(pipeline.created_at)}
Description
@@ -289,6 +335,24 @@ class PipelineDetails extends Page<{}, PipelineDetailsState> { ); } + public async handleVersionSelected(versionId: string): Promise { + if (this.state.pipeline) { + const selectedVersion = (this.state.versions || []).find(v => v.id === versionId); + const selectedVersionPipelineTemplate = await this._getTemplateString( + this.state.pipeline.id!, + versionId, + ); + this.props.history.replace({ + pathname: `/pipelines/details/${this.state.pipeline.id}/version/${versionId}`, + }); + this.setStateSafe({ + graph: await this._createGraph(selectedVersionPipelineTemplate), + selectedVersion, + templateString: selectedVersionPipelineTemplate, + }); + } + } + public async refresh(): Promise { return this.load(); } @@ -302,11 +366,13 @@ class PipelineDetails extends Page<{}, PipelineDetailsState> { const fromRunId = new URLParser(this.props).get(QUERY_PARAMS.fromRunId); let pipeline: ApiPipeline | null = null; + let version: ApiPipelineVersion | null = null; let templateString = ''; - let template: Workflow | undefined; let breadcrumbs: Array<{ displayName: string; href: string }> = []; const toolbarActions = this.props.toolbarProps.actions; let pageTitle = ''; + let selectedVersion: ApiPipelineVersion | undefined; + let versions: ApiPipelineVersion[] = []; // If fromRunId is specified, load the run and get the pipeline template from it if (fromRunId) { @@ -372,47 +438,104 @@ class PipelineDetails extends Page<{}, PipelineDetailsState> { } else { // if fromRunId is not specified, then we have a full pipeline const pipelineId = this.props.match.params[RouteParams.pipelineId]; - let templateResponse: ApiGetTemplateResponse; try { pipeline = await Apis.pipelineServiceApi.getPipeline(pipelineId); - pageTitle = pipeline.name!; } catch (err) { await this.showPageError('Cannot retrieve pipeline details.', err); logger.error('Cannot retrieve pipeline details.', err); return; } + const versionId = this.props.match.params[RouteParams.pipelineVersionId]; + try { - templateResponse = await Apis.pipelineServiceApi.getTemplate(pipelineId); - templateString = templateResponse.template || ''; + // TODO(rjbauer): it's possible we might not have a version, even default + if (versionId) { + version = await Apis.pipelineServiceApi.getPipelineVersion(versionId); + } } catch (err) { - await this.showPageError('Cannot retrieve pipeline template.', err); - logger.error('Cannot retrieve pipeline details.', err); + await this.showPageError('Cannot retrieve pipeline version.', err); + logger.error('Cannot retrieve pipeline version.', err); return; } + selectedVersion = versionId ? version! : pipeline.default_version; + + if (!selectedVersion) { + // An empty pipeline, which doesn't have any version. + pageTitle = pipeline.name!; + const actions = this.props.toolbarProps.actions; + actions[ButtonKeys.DELETE_RUN].disabled = true; + this.props.updateToolbar({ actions }); + } else { + // Fetch manifest for the selected version under this pipeline. + pageTitle = pipeline.name!.concat(' (', selectedVersion!.name!, ')'); + try { + // TODO(jingzhang36): pagination not proper here. so if many versions, + // the page size value should be? + versions = + (await Apis.pipelineServiceApi.listPipelineVersions( + 'PIPELINE', + pipelineId, + 50, + undefined, + 'created_at desc', + )).versions || []; + } catch (err) { + await this.showPageError('Cannot retrieve pipeline versions.', err); + logger.error('Cannot retrieve pipeline versions.', err); + return; + } + templateString = await this._getTemplateString(pipelineId, versionId); + } + breadcrumbs = [{ displayName: 'Pipelines', href: RoutePage.PIPELINES }]; } this.props.updateToolbar({ breadcrumbs, actions: toolbarActions, pageTitle }); - let g: dagre.graphlib.Graph | undefined; - try { - template = JsYaml.safeLoad(templateString); - g = StaticGraphParser.createGraph(template!); - } catch (err) { - await this.showPageError('Error: failed to generate Pipeline graph.', err); - } - this.setStateSafe({ - graph: g, + graph: await this._createGraph(templateString), pipeline, - template, + selectedVersion, templateString, + versions, }); } + private async _getTemplateString(pipelineId: string, versionId: string): Promise { + try { + let templateResponse: ApiGetTemplateResponse; + if (versionId) { + templateResponse = await Apis.pipelineServiceApi.getPipelineVersionTemplate(versionId); + } else { + templateResponse = await Apis.pipelineServiceApi.getTemplate(pipelineId); + } + return templateResponse.template || ''; + } catch (err) { + await this.showPageError('Cannot retrieve pipeline template.', err); + logger.error('Cannot retrieve pipeline details.', err); + } + return ''; + } + + private async _createGraph(templateString: string): Promise { + if (templateString) { + try { + const template = JsYaml.safeLoad(templateString); + return StaticGraphParser.createGraph(template!); + } catch (err) { + await this.showPageError('Error: failed to generate Pipeline graph.', err); + } + } + return null; + } + + private _createVersionUrl(): string { + return this.state.selectedVersion!.code_source_url!; + } + private _deleteCallback(_: string[], success: boolean): void { if (success) { const breadcrumbs = this.props.toolbarProps.breadcrumbs; diff --git a/frontend/src/pages/PipelineList.test.tsx b/frontend/src/pages/PipelineList.test.tsx index d5bf5d0fd5e..27235663726 100644 --- a/frontend/src/pages/PipelineList.test.tsx +++ b/frontend/src/pages/PipelineList.test.tsx @@ -17,26 +17,35 @@ import * as React from 'react'; import PipelineList from './PipelineList'; import TestUtils from '../TestUtils'; -import { ApiPipeline } from '../apis/pipeline'; import { Apis } from '../lib/Apis'; import { PageProps } from './Page'; import { RoutePage, RouteParams } from '../components/Router'; import { shallow, ReactWrapper, ShallowWrapper } from 'enzyme'; import { range } from 'lodash'; -import { ImportMethod } from '../components/UploadPipelineDialog'; import { ButtonKeys } from '../lib/Buttons'; describe('PipelineList', () => { let tree: ReactWrapper | ShallowWrapper; - const updateBannerSpy = jest.fn(); - const updateDialogSpy = jest.fn(); - const updateSnackbarSpy = jest.fn(); - const updateToolbarSpy = jest.fn(); - const listPipelinesSpy = jest.spyOn(Apis.pipelineServiceApi, 'listPipelines'); - const createPipelineSpy = jest.spyOn(Apis.pipelineServiceApi, 'createPipeline'); - const deletePipelineSpy = jest.spyOn(Apis.pipelineServiceApi, 'deletePipeline'); - const uploadPipelineSpy = jest.spyOn(Apis, 'uploadPipeline'); + let updateBannerSpy: jest.Mock<{}>; + let updateDialogSpy: jest.Mock<{}>; + let updateSnackbarSpy: jest.Mock<{}>; + let updateToolbarSpy: jest.Mock<{}>; + let listPipelinesSpy: jest.SpyInstance<{}>; + let listPipelineVersionsSpy: jest.SpyInstance<{}>; + let deletePipelineSpy: jest.SpyInstance<{}>; + let deletePipelineVersionSpy: jest.SpyInstance<{}>; + + function spyInit() { + updateBannerSpy = jest.fn(); + updateDialogSpy = jest.fn(); + updateSnackbarSpy = jest.fn(); + updateToolbarSpy = jest.fn(); + listPipelinesSpy = jest.spyOn(Apis.pipelineServiceApi, 'listPipelines'); + listPipelineVersionsSpy = jest.spyOn(Apis.pipelineServiceApi, 'listPipelineVersions'); + deletePipelineSpy = jest.spyOn(Apis.pipelineServiceApi, 'deletePipeline'); + deletePipelineVersionSpy = jest.spyOn(Apis.pipelineServiceApi, 'deletePipelineVersion'); + } function generateProps(): PageProps { return TestUtils.generatePageProps( @@ -52,10 +61,14 @@ describe('PipelineList', () => { } async function mountWithNPipelines(n: number): Promise { - listPipelinesSpy.mockImplementationOnce(() => ({ + listPipelinesSpy.mockImplementation(() => ({ pipelines: range(n).map(i => ({ id: 'test-pipeline-id' + i, name: 'test pipeline name' + i, + defaultVersion: { + id: 'test-pipeline-id' + i + '_default_version', + name: 'test-pipeline-id' + i + '_default_version_name', + }, })), })); tree = TestUtils.mountWithRouter(); @@ -66,6 +79,7 @@ describe('PipelineList', () => { } beforeEach(() => { + spyInit(); jest.clearAllMocks(); }); @@ -73,7 +87,7 @@ describe('PipelineList', () => { // unmount() should be called before resetAllMocks() in case any part of the unmount life cycle // depends on mocks/spies await tree.unmount(); - jest.resetAllMocks(); + jest.restoreAllMocks(); }); it('renders an empty list with empty state message', () => { @@ -84,13 +98,13 @@ describe('PipelineList', () => { it('renders a list of one pipeline', async () => { tree = shallow(); tree.setState({ - pipelines: [ + displayPipelines: [ { created_at: new Date(2018, 8, 22, 11, 5, 48), description: 'test pipeline description', name: 'pipeline1', parameters: [], - } as ApiPipeline, + }, ], }); await listPipelinesSpy; @@ -100,11 +114,11 @@ describe('PipelineList', () => { it('renders a list of one pipeline with no description or created date', async () => { tree = shallow(); tree.setState({ - pipelines: [ + displayPipelines: [ { name: 'pipeline1', parameters: [], - } as ApiPipeline, + }, ], }); await listPipelinesSpy; @@ -114,14 +128,14 @@ describe('PipelineList', () => { it('renders a list of one pipeline with error', async () => { tree = shallow(); tree.setState({ - pipelines: [ + displayPipelines: [ { created_at: new Date(2018, 8, 22, 11, 5, 48), description: 'test pipeline description', error: 'oops! could not load pipeline', name: 'pipeline1', parameters: [], - } as ApiPipeline, + }, ], }); await listPipelinesSpy; @@ -133,7 +147,9 @@ describe('PipelineList', () => { tree = TestUtils.mountWithRouter(); await listPipelinesSpy; expect(listPipelinesSpy).toHaveBeenLastCalledWith('', 10, 'created_at desc', ''); - expect(tree.state()).toHaveProperty('pipelines', [{ name: 'pipeline1' }]); + expect(tree.state()).toHaveProperty('displayPipelines', [ + { expandState: 0, name: 'pipeline1' }, + ]); }); it('has a Refresh button, clicking it refreshes the pipeline list', async () => { @@ -207,14 +223,17 @@ describe('PipelineList', () => { const link = tree.find('a[children="test pipeline name0"]'); expect(link).toHaveLength(1); expect(link.prop('href')).toBe( - RoutePage.PIPELINE_DETAILS.replace(':' + RouteParams.pipelineId + '?', 'test-pipeline-id0'), + RoutePage.PIPELINE_DETAILS_NO_VERSION.replace( + ':' + RouteParams.pipelineId + '?', + 'test-pipeline-id0', + ), ); }); it('always has upload pipeline button enabled', async () => { tree = await mountWithNPipelines(1); const calls = updateToolbarSpy.mock.calls[0]; - expect(calls[0].actions[ButtonKeys.UPLOAD_PIPELINE]).not.toHaveProperty('disabled'); + expect(calls[0].actions[ButtonKeys.NEW_PIPELINE_VERSION]).not.toHaveProperty('disabled'); }); it('enables delete button when one pipeline is selected', async () => { @@ -330,6 +349,7 @@ describe('PipelineList', () => { .at(0) .simulate('click'); expect(tree.state()).toHaveProperty('selectedIds', ['test-pipeline-id0']); + deletePipelineSpy.mockImplementation(() => Promise.resolve()); const deleteBtn = (tree.instance() as PipelineList).getInitialToolbarState().actions[ ButtonKeys.DELETE_RUN ]; @@ -351,6 +371,7 @@ describe('PipelineList', () => { .at(3) .simulate('click'); expect(tree.state()).toHaveProperty('selectedIds', ['test-pipeline-id0', 'test-pipeline-id3']); + deletePipelineSpy.mockImplementation(() => Promise.resolve()); const deleteBtn = (tree.instance() as PipelineList).getInitialToolbarState().actions[ ButtonKeys.DELETE_RUN ]; @@ -394,6 +415,7 @@ describe('PipelineList', () => { .find('.tableRow') .at(0) .simulate('click'); + deletePipelineSpy.mockImplementation(() => Promise.resolve()); const deleteBtn = (tree.instance() as PipelineList).getInitialToolbarState().actions[ ButtonKeys.DELETE_RUN ]; @@ -402,7 +424,7 @@ describe('PipelineList', () => { const confirmBtn = call.buttons.find((b: any) => b.text === 'Delete'); await confirmBtn.onClick(); expect(updateSnackbarSpy).toHaveBeenLastCalledWith({ - message: 'Delete succeeded for 1 pipeline', + message: 'Deletion succeeded for 1 pipeline', open: true, }); }); @@ -424,7 +446,7 @@ describe('PipelineList', () => { const lastCall = updateDialogSpy.mock.calls[1][0]; expect(lastCall).toMatchObject({ content: 'Failed to delete pipeline: test-pipeline-id0 with error: "woops, failed"', - title: 'Failed to delete 1 pipeline', + title: 'Failed to delete some pipelines and/or some pipeline versions', }); }); @@ -467,142 +489,76 @@ describe('PipelineList', () => { content: 'Failed to delete pipeline: test-pipeline-id0 with error: "woops, failed!"\n\n' + 'Failed to delete pipeline: test-pipeline-id1 with error: "woops, failed!"', - title: 'Failed to delete 2 pipelines', + title: 'Failed to delete some pipelines and/or some pipeline versions', }); // Should show snackbar for the one successful deletion expect(updateSnackbarSpy).toHaveBeenLastCalledWith({ - message: 'Delete succeeded for 2 pipelines', + message: 'Deletion succeeded for 2 pipelines', open: true, }); }); - it('shows upload dialog when upload button is clicked', async () => { - tree = await mountWithNPipelines(0); - const instance = tree.instance() as PipelineList; - const uploadBtn = instance.getInitialToolbarState().actions[ButtonKeys.UPLOAD_PIPELINE]; - expect(uploadBtn).toBeDefined(); - await uploadBtn!.action(); - expect(instance.state).toHaveProperty('uploadDialogOpen', true); - }); + it.only("delete a pipeline and some other pipeline's version together", async () => { + deletePipelineSpy.mockImplementation(() => Promise.resolve()); + deletePipelineVersionSpy.mockImplementation(() => Promise.resolve()); + listPipelineVersionsSpy.mockImplementation(() => ({ + versions: [ + { + id: 'test-pipeline-id1_default_version', + name: 'test-pipeline-id1_default_version_name', + }, + ], + })); - it('dismisses the upload dialog', async () => { - tree = shallow(); - tree.setState({ uploadDialogOpen: true }); - tree.find('UploadPipelineDialog').simulate('close', false); + tree = await mountWithNPipelines(2); + tree + .find('button[aria-label="Expand"]') + .at(1) + .simulate('click'); + await listPipelineVersionsSpy; tree.update(); - expect(tree.state()).toHaveProperty('uploadDialogOpen', false); - }); - - it('does not try to upload if the upload dialog dismissed', async () => { - tree = shallow(); - const handlerSpy = jest.spyOn(tree.instance() as any, '_uploadDialogClosed'); - tree.setState({ uploadDialogOpen: true }); - tree.find('UploadPipelineDialog').simulate('close', false); - expect(handlerSpy).toHaveBeenLastCalledWith(false); - expect(uploadPipelineSpy).not.toHaveBeenCalled(); - }); - it('does not try to upload if import method is local and no file is returned from upload dialog', async () => { - tree = shallow(); - const handlerSpy = jest.spyOn(tree.instance() as any, '_uploadDialogClosed'); - tree.setState({ uploadDialogOpen: true }); + // select pipeline of id 'test-pipeline-id0' tree - .find('UploadPipelineDialog') - .simulate('close', true, 'some name', null, '', ImportMethod.LOCAL); - expect(handlerSpy).toHaveBeenLastCalledWith(true, 'some name', null, '', ImportMethod.LOCAL); - expect(uploadPipelineSpy).not.toHaveBeenCalled(); - }); - - it('does not try to upload if import method is url and no url is returned from upload dialog', async () => { - tree = shallow(); - const handlerSpy = jest.spyOn(tree.instance() as any, '_uploadDialogClosed'); - tree.setState({ uploadDialogOpen: true }); + .find('.tableRow') + .at(0) + .simulate('click'); + // select pipeline version of id 'test-pipeline-id1_default_version' under pipeline 'test-pipeline-id1' tree - .find('UploadPipelineDialog') - .simulate('close', true, 'some name', null, '', ImportMethod.URL); - expect(handlerSpy).toHaveBeenLastCalledWith(true, 'some name', null, '', ImportMethod.URL); - expect(uploadPipelineSpy).not.toHaveBeenCalled(); - }); + .find('.tableRow') + .at(2) + .simulate('click'); - it('tries to upload if import method is local and a file is returned from upload dialog', async () => { - tree = shallow(); - tree.setState({ uploadDialogOpen: true }); - tree - .find('UploadPipelineDialog') - .simulate('close', true, 'some name', { body: 'something' }, '', ImportMethod.LOCAL); - tree.update(); - await createPipelineSpy; - await uploadPipelineSpy; - expect(uploadPipelineSpy).toHaveBeenLastCalledWith('some name', { body: 'something' }); - expect(createPipelineSpy).not.toHaveBeenCalled(); + expect(tree.state()).toHaveProperty('selectedIds', ['test-pipeline-id0']); + expect(tree.state()).toHaveProperty('selectedVersionIds', { + 'test-pipeline-id1': ['test-pipeline-id1_default_version'], + }); - // Check the dialog is closed - expect(tree.state()).toHaveProperty('uploadDialogOpen', false); - }); + const deleteBtn = (tree.instance() as PipelineList).getInitialToolbarState().actions[ + ButtonKeys.DELETE_RUN + ]; + await deleteBtn!.action(); + const call = updateDialogSpy.mock.calls[0][0]; + const confirmBtn = call.buttons.find((b: any) => b.text === 'Delete'); + await confirmBtn.onClick(); - it('shows error dialog and does not dismiss upload dialog when upload fails', async () => { - TestUtils.makeErrorResponseOnce(uploadPipelineSpy, 'woops, could not upload'); - tree = shallow(); - tree.setState({ uploadDialogOpen: true }); - tree - .find('UploadPipelineDialog') - .simulate('close', true, 'some name', { body: 'something' }, '', ImportMethod.LOCAL); - tree.update(); - await uploadPipelineSpy; - await TestUtils.flushPromises(); - expect(uploadPipelineSpy).toHaveBeenLastCalledWith('some name', { body: 'something' }); - expect(updateDialogSpy).toHaveBeenLastCalledWith( - expect.objectContaining({ - content: 'woops, could not upload', - title: 'Failed to upload pipeline', - }), - ); - // Check the dialog is not closed - expect(tree.state()).toHaveProperty('uploadDialogOpen', true); - }); + await deletePipelineSpy; + await deletePipelineVersionSpy; - it('tries to create a pipeline if import method is url and a url is returned from upload dialog', async () => { - tree = shallow(); - tree.setState({ uploadDialogOpen: true }); - tree - .find('UploadPipelineDialog') - .simulate('close', true, 'some name', null, 'https://some.url.com', ImportMethod.URL); - tree.update(); - await createPipelineSpy; - await uploadPipelineSpy; - expect(createPipelineSpy).toHaveBeenLastCalledWith({ - name: 'some name', - url: { pipeline_url: 'https://some.url.com' }, - }); - expect(uploadPipelineSpy).not.toHaveBeenCalled(); + expect(deletePipelineSpy).toHaveBeenCalledTimes(1); + expect(deletePipelineSpy).toHaveBeenCalledWith('test-pipeline-id0'); - // Check the dialog is closed - expect(tree.state()).toHaveProperty('uploadDialogOpen', false); - }); + expect(deletePipelineVersionSpy).toHaveBeenCalledTimes(1); + expect(deletePipelineVersionSpy).toHaveBeenCalledWith('test-pipeline-id1_default_version'); - it('shows error dialog and does not dismiss upload dialog when create fails', async () => { - TestUtils.makeErrorResponseOnce(createPipelineSpy, 'woops, could not create'); - tree = shallow(); - tree.setState({ uploadDialogOpen: true }); - tree - .find('UploadPipelineDialog') - .simulate('close', true, 'some name', null, 'https://some.url.com', ImportMethod.URL); - tree.update(); - await uploadPipelineSpy; - await TestUtils.flushPromises(); - expect(createPipelineSpy).toHaveBeenLastCalledWith({ - name: 'some name', - url: { pipeline_url: 'https://some.url.com' }, - }); - expect(updateDialogSpy).toHaveBeenLastCalledWith( - expect.objectContaining({ - content: 'woops, could not create', - title: 'Failed to upload pipeline', - }), - ); + expect(tree.state()).toHaveProperty('selectedIds', []); + expect(tree.state()).toHaveProperty('selectedVersionIds', { 'test-pipeline-id1': [] }); - // Check the dialog is not closed - expect(tree.state()).toHaveProperty('uploadDialogOpen', true); + // Should show snackbar for the one successful deletion + expect(updateSnackbarSpy).toHaveBeenLastCalledWith({ + message: 'Deletion succeeded for 1 pipeline and 1 pipeline version', + open: true, + }); }); }); diff --git a/frontend/src/pages/PipelineList.tsx b/frontend/src/pages/PipelineList.tsx index ed0c7d07322..1d08795e7a1 100644 --- a/frontend/src/pages/PipelineList.tsx +++ b/frontend/src/pages/PipelineList.tsx @@ -16,7 +16,13 @@ import * as React from 'react'; import Buttons, { ButtonKeys } from '../lib/Buttons'; -import CustomTable, { Column, Row, CustomRendererProps } from '../components/CustomTable'; +import CustomTable, { + Column, + Row, + CustomRendererProps, + ExpandState, +} from '../components/CustomTable'; +import PipelineVersionList from './PipelineVersionList'; import UploadPipelineDialog, { ImportMethod } from '../components/UploadPipelineDialog'; import { ApiPipeline, ApiListPipelinesResponse } from '../apis/pipeline'; import { Apis, PipelineSortKeys, ListRequest } from '../lib/Apis'; @@ -28,11 +34,20 @@ import { classes } from 'typestyle'; import { commonCss, padding } from '../Css'; import { formatDateString, errorToMessage } from '../lib/Utils'; import { Description } from '../components/Description'; +import produce from 'immer'; + +interface DisplayPipeline extends ApiPipeline { + expandState?: ExpandState; +} interface PipelineListState { - pipelines: ApiPipeline[]; + displayPipelines: DisplayPipeline[]; selectedIds: string[]; uploadDialogOpen: boolean; + + // selectedVersionIds is a map from string to string array. + // For each pipeline, there is a list of selected version ids. + selectedVersionIds: { [pipelineId: string]: string[] }; } const descriptionCustomRenderer: React.FC> = ( @@ -48,9 +63,11 @@ class PipelineList extends Page<{}, PipelineListState> { super(props); this.state = { - pipelines: [], + displayPipelines: [], selectedIds: [], uploadDialogOpen: false, + + selectedVersionIds: {}, }; } @@ -58,12 +75,12 @@ class PipelineList extends Page<{}, PipelineListState> { const buttons = new Buttons(this.props, this.refresh.bind(this)); return { actions: buttons - .upload(() => this.setStateSafe({ uploadDialogOpen: true })) + .newPipelineVersion('Upload pipeline') .refresh(this.refresh.bind(this)) - .delete( + .deletePipelinesAndPipelineVersions( () => this.state.selectedIds, - 'pipeline', - ids => this._selectionChanged(ids), + () => this.state.selectedVersionIds, + (pipelineId, ids) => this._selectionChanged(pipelineId, ids), false /* useCurrentResource */, ) .getToolbarActionMap(), @@ -84,8 +101,9 @@ class PipelineList extends Page<{}, PipelineListState> { { label: 'Uploaded on', sortKey: PipelineSortKeys.CREATED_AT, flex: 1 }, ]; - const rows: Row[] = this.state.pipelines.map(p => { + const rows: Row[] = this.state.displayPipelines.map(p => { return { + expandState: p.expandState, id: p.id!, otherFields: [p.name!, p.description!, formatDateString(p.created_at!)], }; @@ -98,9 +116,11 @@ class PipelineList extends Page<{}, PipelineListState> { columns={columns} rows={rows} initialSortColumn={PipelineSortKeys.CREATED_AT} - updateSelection={this._selectionChanged.bind(this)} + updateSelection={this._selectionChanged.bind(this, undefined)} selectedIds={this.state.selectedIds} reload={this._reload.bind(this)} + toggleExpansion={this._toggleRowExpand.bind(this)} + getExpandComponent={this._getExpandedPipelineComponent.bind(this)} filterLabel='Filter pipelines' emptyMessage='No pipelines found. Click "Upload pipeline" to start.' /> @@ -119,8 +139,36 @@ class PipelineList extends Page<{}, PipelineListState> { } } + private _toggleRowExpand(rowIndex: number): void { + const displayPipelines = produce(this.state.displayPipelines, draft => { + draft[rowIndex].expandState = + draft[rowIndex].expandState === ExpandState.COLLAPSED + ? ExpandState.EXPANDED + : ExpandState.COLLAPSED; + }); + + this.setState({ displayPipelines }); + } + + private _getExpandedPipelineComponent(rowIndex: number): JSX.Element { + const pipeline = this.state.displayPipelines[rowIndex]; + return ( + null} + {...this.props} + selectedIds={this.state.selectedVersionIds[pipeline.id!] || []} + noFilterBox={true} + onSelectionChange={this._selectionChanged.bind(this, pipeline.id)} + disableSorting={false} + disablePaging={false} + /> + ); + } + private async _reload(request: ListRequest): Promise { let response: ApiListPipelinesResponse | null = null; + let displayPipelines: DisplayPipeline[]; try { response = await Apis.pipelineServiceApi.listPipelines( request.pageToken, @@ -128,12 +176,14 @@ class PipelineList extends Page<{}, PipelineListState> { request.sortBy, request.filter, ); + displayPipelines = response.pipelines || []; + displayPipelines.forEach(exp => (exp.expandState = ExpandState.COLLAPSED)); this.clearBanner(); } catch (err) { await this.showPageError('Error: failed to retrieve list of pipelines.', err); } - this.setStateSafe({ pipelines: (response && response.pipelines) || [] }); + this.setStateSafe({ displayPipelines: (response && response.pipelines) || [] }); return response ? response.next_page_token || '' : ''; } @@ -145,18 +195,34 @@ class PipelineList extends Page<{}, PipelineListState> { e.stopPropagation()} className={commonCss.link} - to={RoutePage.PIPELINE_DETAILS.replace(':' + RouteParams.pipelineId, props.id)} + to={RoutePage.PIPELINE_DETAILS_NO_VERSION.replace(':' + RouteParams.pipelineId, props.id)} > {props.value} ); }; - private _selectionChanged(selectedIds: string[]): void { - const actions = this.props.toolbarProps.actions; - actions[ButtonKeys.DELETE_RUN].disabled = selectedIds.length < 1; - this.props.updateToolbar({ actions }); - this.setStateSafe({ selectedIds }); + // selection changes passed in via "selectedIds" can be + // (1) changes of selected pipeline ids, and will be stored in "this.state.selectedIds" or + // (2) changes of selected pipeline version ids, and will be stored in "selectedVersionIds" with key "pipelineId" + private _selectionChanged(pipelineId: string | undefined, selectedIds: string[]): void { + if (!!pipelineId) { + // Update selected pipeline version ids. + this.setStateSafe({ + selectedVersionIds: { ...this.state.selectedVersionIds, ...{ [pipelineId!]: selectedIds } }, + }); + const actions = this.props.toolbarProps.actions; + actions[ButtonKeys.DELETE_RUN].disabled = + this.state.selectedIds.length < 1 && selectedIds.length < 1; + this.props.updateToolbar({ actions }); + } else { + // Update selected pipeline ids. + this.setStateSafe({ selectedIds }); + const selectedVersionIdsCt = this._deepCountDictionary(this.state.selectedVersionIds); + const actions = this.props.toolbarProps.actions; + actions[ButtonKeys.DELETE_RUN].disabled = selectedIds.length < 1 && selectedVersionIdsCt < 1; + this.props.updateToolbar({ actions }); + } } private async _uploadDialogClosed( @@ -189,6 +255,10 @@ class PipelineList extends Page<{}, PipelineListState> { return false; } } + + private _deepCountDictionary(dict: { [pipelineId: string]: string[] }): number { + return Object.keys(dict).reduce((count, pipelineId) => count + dict[pipelineId].length, 0); + } } export default PipelineList; diff --git a/frontend/src/pages/PipelineVersionList.test.tsx b/frontend/src/pages/PipelineVersionList.test.tsx new file mode 100644 index 00000000000..d4de18a3b0b --- /dev/null +++ b/frontend/src/pages/PipelineVersionList.test.tsx @@ -0,0 +1,153 @@ +/* + * 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 PipelineVersionList, { PipelineVersionListProps } from './PipelineVersionList'; +import TestUtils from '../TestUtils'; +import { ApiPipelineVersion } from '../apis/pipeline'; +import { Apis, ListRequest } from '../lib/Apis'; +import { shallow, ReactWrapper, ShallowWrapper } from 'enzyme'; +import { range } from 'lodash'; + +class PipelineVersionListTest extends PipelineVersionList { + public _loadPipelineVersions(request: ListRequest): Promise { + return super._loadPipelineVersions(request); + } +} + +describe('PipelineVersionList', () => { + let tree: ReactWrapper | ShallowWrapper; + + const listPipelineVersionsSpy = jest.spyOn(Apis.pipelineServiceApi, 'listPipelineVersions'); + const onErrorSpy = jest.fn(); + + function generateProps(): PipelineVersionListProps { + return { + history: {} as any, + location: { search: '' } as any, + match: '' as any, + onError: onErrorSpy, + pipelineId: 'pipeline', + }; + } + + async function mountWithNPipelineVersions(n: number): Promise { + listPipelineVersionsSpy.mockImplementation((pipelineId: string) => ({ + versions: range(n).map(i => ({ + id: 'test-pipeline-version-id' + i, + name: 'test pipeline version name' + i, + })), + })); + tree = TestUtils.mountWithRouter(); + await listPipelineVersionsSpy; + await TestUtils.flushPromises(); + tree.update(); // Make sure the tree is updated before returning it + return tree; + } + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(async () => { + // unmount() should be called before resetAllMocks() in case any part of the unmount life cycle + // depends on mocks/spies + await tree.unmount(); + jest.resetAllMocks(); + }); + + it('renders an empty list with empty state message', () => { + tree = shallow(); + expect(tree).toMatchSnapshot(); + }); + + it('renders a list of one pipeline version', async () => { + tree = shallow(); + tree.setState({ + pipelineVersions: [ + { + created_at: new Date(2018, 8, 22, 11, 5, 48), + name: 'pipelineversion1', + } as ApiPipelineVersion, + ], + }); + await listPipelineVersionsSpy; + expect(tree).toMatchSnapshot(); + }); + + it('renders a list of one pipeline version without created date', async () => { + tree = shallow(); + tree.setState({ + pipelines: [ + { + name: 'pipelineversion1', + } as ApiPipelineVersion, + ], + }); + await listPipelineVersionsSpy; + expect(tree).toMatchSnapshot(); + }); + + it('renders a list of one pipeline version with error', async () => { + tree = shallow(); + tree.setState({ + pipelineVersions: [ + { + created_at: new Date(2018, 8, 22, 11, 5, 48), + error: 'oops! could not load pipeline', + name: 'pipeline1', + parameters: [], + } as ApiPipelineVersion, + ], + }); + await listPipelineVersionsSpy; + expect(tree).toMatchSnapshot(); + }); + + it('calls Apis to list pipeline versions, sorted by creation time in descending order', async () => { + tree = await mountWithNPipelineVersions(2); + await (tree.instance() as PipelineVersionListTest)._loadPipelineVersions({ + pageSize: 10, + pageToken: '', + sortBy: 'created_at', + } as ListRequest); + expect(listPipelineVersionsSpy).toHaveBeenLastCalledWith( + 'PIPELINE', + 'pipeline', + 10, + '', + 'created_at', + ); + expect(tree).toMatchSnapshot(); + }); + + it('calls Apis to list pipeline versions, sorted by pipeline version name in descending order', async () => { + tree = await mountWithNPipelineVersions(3); + await (tree.instance() as PipelineVersionListTest)._loadPipelineVersions({ + pageSize: 10, + pageToken: '', + sortBy: 'name', + } as ListRequest); + expect(listPipelineVersionsSpy).toHaveBeenLastCalledWith( + 'PIPELINE', + 'pipeline', + 10, + '', + 'name', + ); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/frontend/src/pages/PipelineVersionList.tsx b/frontend/src/pages/PipelineVersionList.tsx new file mode 100644 index 00000000000..8c8f31ce5f4 --- /dev/null +++ b/frontend/src/pages/PipelineVersionList.tsx @@ -0,0 +1,150 @@ +/* + * 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 CustomTable, { Column, CustomRendererProps, Row } from '../components/CustomTable'; +import * as React from 'react'; +import { Link, RouteComponentProps } from 'react-router-dom'; +import { ApiPipelineVersion, ApiListPipelineVersionsResponse } from '../apis/pipeline'; +import { Apis, ListRequest, PipelineVersionSortKeys } from '../lib/Apis'; +import { errorToMessage, formatDateString } from '../lib/Utils'; +import { RoutePage, RouteParams } from '../components/Router'; +import { commonCss } from '../Css'; + +export interface PipelineVersionListProps extends RouteComponentProps { + pipelineId?: string; + disablePaging?: boolean; + disableSelection?: boolean; + disableSorting?: boolean; + noFilterBox?: boolean; + onError: (message: string, error: Error) => void; + onSelectionChange?: (selectedIds: string[]) => void; + selectedIds?: string[]; +} + +interface PipelineVersionListState { + pipelineVersions: ApiPipelineVersion[]; +} + +class PipelineVersionList extends React.PureComponent< + PipelineVersionListProps, + PipelineVersionListState +> { + private _tableRef = React.createRef(); + + constructor(props: any) { + super(props); + + this.state = { + pipelineVersions: [], + }; + } + + public _nameCustomRenderer: React.FC> = ( + props: CustomRendererProps, + ) => { + if (this.props.pipelineId) { + return ( + e.stopPropagation()} + to={RoutePage.PIPELINE_DETAILS.replace( + ':' + RouteParams.pipelineId, + this.props.pipelineId, + ).replace(':' + RouteParams.pipelineVersionId, props.id)} + > + {props.value} + + ); + } else { + return ( + e.stopPropagation()} + to={RoutePage.PIPELINE_DETAILS.replace(':' + RouteParams.pipelineVersionId, props.id)} + > + {props.value} + + ); + } + }; + + public render(): JSX.Element { + const columns: Column[] = [ + { + customRenderer: this._nameCustomRenderer, + flex: 2, + label: 'Version name', + sortKey: PipelineVersionSortKeys.NAME, + }, + { label: 'Uploaded on', flex: 1, sortKey: PipelineVersionSortKeys.CREATED_AT }, + ]; + + const rows: Row[] = this.state.pipelineVersions.map(r => { + const row = { + id: r.id!, + otherFields: [r.name, formatDateString(r.created_at)] as any, + }; + return row; + }); + + return ( +
+ +
+ ); + } + + protected async _loadPipelineVersions(request: ListRequest): Promise { + let response: ApiListPipelineVersionsResponse | null = null; + + if (this.props.pipelineId) { + try { + response = await Apis.pipelineServiceApi.listPipelineVersions( + 'PIPELINE', + this.props.pipelineId, + request.pageSize, + request.pageToken, + request.sortBy, + ); + } catch (err) { + const error = new Error(await errorToMessage(err)); + this.props.onError('Error: failed to fetch runs.', error); + // No point in continuing if we couldn't retrieve any runs. + return ''; + } + + this.setState({ + pipelineVersions: response.versions || [], + }); + } + return response ? response.next_page_token || '' : ''; + } +} + +export default PipelineVersionList; diff --git a/frontend/src/pages/RunList.test.tsx b/frontend/src/pages/RunList.test.tsx index c43d5ece335..c1331fb16cf 100644 --- a/frontend/src/pages/RunList.test.tsx +++ b/frontend/src/pages/RunList.test.tsx @@ -462,16 +462,16 @@ describe('RunList', () => { it('renders pipeline name as link to its details page', () => { expect( - getMountedInstance()._pipelineCustomRenderer({ + getMountedInstance()._pipelineVersionCustomRenderer({ id: 'run-id', - value: { displayName: 'test pipeline', id: 'pipeline-id', usePlaceholder: false }, + value: { displayName: 'test pipeline', pipelineId: 'pipeline-id', usePlaceholder: false }, }), ).toMatchSnapshot(); }); it('handles no pipeline id given', () => { expect( - getMountedInstance()._pipelineCustomRenderer({ + getMountedInstance()._pipelineVersionCustomRenderer({ id: 'run-id', value: { displayName: 'test pipeline', usePlaceholder: false }, }), @@ -480,16 +480,16 @@ describe('RunList', () => { it('shows "View pipeline" button if pipeline is embedded in run', () => { expect( - getMountedInstance()._pipelineCustomRenderer({ + getMountedInstance()._pipelineVersionCustomRenderer({ id: 'run-id', - value: { displayName: 'test pipeline', id: 'pipeline-id', usePlaceholder: true }, + value: { displayName: 'test pipeline', pipelineId: 'pipeline-id', usePlaceholder: true }, }), ).toMatchSnapshot(); }); it('handles no pipeline name', () => { expect( - getMountedInstance()._pipelineCustomRenderer({ + getMountedInstance()._pipelineVersionCustomRenderer({ id: 'run-id', value: { /* no displayName */ usePlaceholder: true }, }), @@ -573,4 +573,18 @@ describe('RunList', () => { }), ).toMatchSnapshot(); }); + + it('renders pipeline version name as link to its details page', () => { + expect( + getMountedInstance()._pipelineVersionCustomRenderer({ + id: 'run-id', + value: { + displayName: 'test pipeline version', + pipelineId: 'pipeline-id', + usePlaceholder: false, + versionId: 'version-id', + }, + }), + ).toMatchSnapshot(); + }); }); diff --git a/frontend/src/pages/RunList.tsx b/frontend/src/pages/RunList.tsx index 095b8245c56..349fd8fde48 100644 --- a/frontend/src/pages/RunList.tsx +++ b/frontend/src/pages/RunList.tsx @@ -29,10 +29,11 @@ import { commonCss, color } from '../Css'; import { formatDateString, logger, errorToMessage, getRunDuration } from '../lib/Utils'; import { statusToIcon } from './Status'; -interface PipelineInfo { +interface PipelineVersionInfo { displayName?: string; - id?: string; + versionId?: string; runId?: string; + pipelineId?: string; usePlaceholder: boolean; } @@ -45,7 +46,7 @@ interface DisplayRun { experiment?: ExperimentInfo; recurringRun?: RecurringRunInfo; run: ApiRun; - pipeline?: PipelineInfo; + pipelineVersion?: PipelineVersionInfo; error?: string; } @@ -97,7 +98,7 @@ class RunList extends React.PureComponent { }, { customRenderer: this._statusCustomRenderer, flex: 0.5, label: 'Status' }, { label: 'Duration', flex: 0.5 }, - { customRenderer: this._pipelineCustomRenderer, label: 'Pipeline', flex: 1 }, + { customRenderer: this._pipelineVersionCustomRenderer, label: 'Pipeline Version', flex: 1 }, { customRenderer: this._recurringRunCustomRenderer, label: 'Recurring Run', flex: 0.5 }, { label: 'Start time', flex: 1, sortKey: RunSortKeys.CREATED_AT }, ]; @@ -148,7 +149,7 @@ class RunList extends React.PureComponent { r.run!.name, r.run.status || '-', getRunDuration(r.run), - r.pipeline, + r.pipelineVersion, r.recurringRun, formatDateString(r.run.created_at), ] as any, @@ -209,17 +210,26 @@ class RunList extends React.PureComponent { ); }; - public _pipelineCustomRenderer: React.FC> = ( - props: CustomRendererProps, + public _pipelineVersionCustomRenderer: React.FC> = ( + props: CustomRendererProps, ) => { // If the getPipeline call failed or a run has no pipeline, we display a placeholder. - if (!props.value || (!props.value.usePlaceholder && !props.value.id)) { + if (!props.value || (!props.value.usePlaceholder && !props.value.pipelineId)) { return
-
; } const search = new URLParser(this.props).build({ [QUERY_PARAMS.fromRunId]: props.id }); const url = props.value.usePlaceholder - ? RoutePage.PIPELINE_DETAILS.replace(':' + RouteParams.pipelineId + '?', '') + search - : RoutePage.PIPELINE_DETAILS.replace(':' + RouteParams.pipelineId, props.value.id || ''); + ? RoutePage.PIPELINE_DETAILS_NO_VERSION.replace(':' + RouteParams.pipelineId + '?', '') + + search + : !!props.value.versionId + ? RoutePage.PIPELINE_DETAILS.replace( + ':' + RouteParams.pipelineId, + props.value.pipelineId || '', + ).replace(':' + RouteParams.pipelineVersionId, props.value.versionId || '') + : RoutePage.PIPELINE_DETAILS_NO_VERSION.replace( + ':' + RouteParams.pipelineId, + props.value.pipelineId || '', + ); return ( e.stopPropagation()} to={url}> {props.value.usePlaceholder ? '[View pipeline]' : props.value.displayName} @@ -352,7 +362,7 @@ class RunList extends React.PureComponent { displayRuns.map(async displayRun => { this._setRecurringRun(displayRun); - await this._getAndSetPipelineNames(displayRun); + await this._getAndSetPipelineVersionNames(displayRun); if (!this.props.hideExperimentColumn) { await this._getAndSetExperimentNames(displayRun); @@ -395,9 +405,27 @@ class RunList extends React.PureComponent { * the DisplayRun. If the ApiRun has no Pipeline ID, then the corresponding DisplayRun will show * '-'. */ - private async _getAndSetPipelineNames(displayRun: DisplayRun): Promise { + private async _getAndSetPipelineVersionNames(displayRun: DisplayRun): Promise { + const pipelineVersionId = RunUtils.getPipelineVersionId(displayRun.run); const pipelineId = RunUtils.getPipelineId(displayRun.run); - if (pipelineId) { + if (pipelineVersionId) { + try { + const pipelineVersion = await Apis.pipelineServiceApi.getPipelineVersion(pipelineVersionId); + const pipelineVersionName = pipelineVersion.name || ''; + displayRun.pipelineVersion = { + displayName: pipelineVersionName, + pipelineId: RunUtils.getPipelineIdFromApiPipelineVersion(pipelineVersion), + usePlaceholder: false, + versionId: pipelineVersionId, + }; + } catch (err) { + displayRun.error = + 'Failed to get associated pipeline version: ' + (await errorToMessage(err)); + return; + } + } else if (pipelineId) { + // For backward compatibility. Runs created before version is introduced + // refer to pipeline id instead of pipeline version id. let pipelineName = RunUtils.getPipelineName(displayRun.run); if (!pipelineName) { try { @@ -408,13 +436,14 @@ class RunList extends React.PureComponent { return; } } - displayRun.pipeline = { + displayRun.pipelineVersion = { displayName: pipelineName, - id: pipelineId, + pipelineId, usePlaceholder: false, + versionId: undefined, }; } else if (!!RunUtils.getWorkflowManifest(displayRun.run)) { - displayRun.pipeline = { usePlaceholder: true }; + displayRun.pipelineVersion = { usePlaceholder: true }; } } diff --git a/frontend/src/pages/__snapshots__/NewPipelineVersion.test.tsx.snap b/frontend/src/pages/__snapshots__/NewPipelineVersion.test.tsx.snap new file mode 100644 index 00000000000..3d3248c49dc --- /dev/null +++ b/frontend/src/pages/__snapshots__/NewPipelineVersion.test.tsx.snap @@ -0,0 +1,213 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NewPipelineVersion creating new pipeline renders the new pipeline page 1`] = ` +
+
+
+ + } + id="createNewPipelineBtn" + label="Create a new pipeline" + onChange={[Function]} + /> + + } + id="createPipelineVersionUnderExistingPipelineBtn" + label="Create a new pipeline version under an existing pipeline" + onChange={[Function]} + /> +
+
+ Upload pipeline with the specified package. +
+ + +
+ URL must be publicly accessible. +
+ +
+ + } + id="localPackageBtn" + label="Upload a file" + onChange={[Function]} + /> + + + + Choose file + + , + "readOnly": true, + "style": Object { + "maxWidth": 2000, + "width": 455, + }, + } + } + disabled={true} + label="File" + onChange={[Function]} + required={true} + value="" + variant="outlined" + /> + +
+
+ + } + id="remotePackageBtn" + label="Import by url" + onChange={[Function]} + /> + +
+ +
+ + + Cancel + +
+ Must specify either package url or file +
+
+
+
+`; diff --git a/frontend/src/pages/__snapshots__/NewRun.test.tsx.snap b/frontend/src/pages/__snapshots__/NewRun.test.tsx.snap index 448a8308755..5eb64379578 100644 --- a/frontend/src/pages/__snapshots__/NewRun.test.tsx.snap +++ b/frontend/src/pages/__snapshots__/NewRun.test.tsx.snap @@ -15,7 +15,6 @@ exports[`NewRun arriving from pipeline details page indicates that a pipeline is
Using pipeline from previous page - + + + + + + + Cancel + + + Use this pipeline version + + + + + + Choose + + , + "readOnly": true, + } + } + disabled={true} + label="Pipeline Version" + required={true} + value="" + variant="outlined" + /> - Cancel @@ -727,67 +912,201 @@ exports[`NewRun changes the exit button's text if query params indicate this is - Use this experiment + Use this pipeline version - - -
- This run will be associated with the following experiment -
- - - Choose - - , - "readOnly": true, + "id": "experimentSelectorDialog", } } - disabled={true} - label="Experiment" - required={true} - value="some mock experiment name" - variant="outlined" - /> -
- Run Type + + + + + + 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
- A pipeline must be selected + A pipeline version must be selected
@@ -892,6 +1211,38 @@ exports[`NewRun changes title and form if the new run will recur, based on the r value="" variant="outlined" /> + + + Choose + + , + "readOnly": true, + } + } + disabled={true} + label="Pipeline Version" + required={true} + value="" + variant="outlined" + /> - Cancel @@ -1175,36 +1538,175 @@ exports[`NewRun changes title and form if the new run will recur, based on the r - Use this experiment + Use this pipeline version - - -
- This run will be associated with the following experiment -
- + + + + + + Cancel + + + Use this experiment + + + + + +
+ This run will be associated with the following experiment +
+ - A pipeline must be selected + A pipeline version must be selected @@ -1351,6 +1853,38 @@ exports[`NewRun changes title and form to default state if the new run is a one- value="" variant="outlined" /> + + + Choose + + , + "readOnly": true, + } + } + disabled={true} + label="Pipeline Version" + required={true} + value="" + variant="outlined" + /> - Cancel @@ -1626,51 +2172,186 @@ exports[`NewRun changes title and form to default state if the new run is a one- - Use this experiment + Use this pipeline version - - -
- This run will be associated with the following experiment -
- - + "id": "experimentSelectorDialog", + } + } + classes={ + Object { + "paper": "selectorDialog", + } + } + onClose={[Function]} + open={false} + > + + + + + + Cancel + + + Use this experiment + + + + + +
+ This run will be associated with the following experiment +
+ + Choose , @@ -1740,7 +2421,7 @@ exports[`NewRun changes title and form to default state if the new run is a one- } } > - A pipeline must be selected + A pipeline version must be selected @@ -1791,6 +2472,38 @@ exports[`NewRun fetches the associated pipeline if one is present in the query p value="original mock pipeline name" variant="outlined" /> + + + Choose + + , + "readOnly": true, + } + } + disabled={true} + label="Pipeline Version" + required={true} + value="original mock pipeline version name" + variant="outlined" + /> - Cancel @@ -2056,41 +2781,171 @@ exports[`NewRun fetches the associated pipeline if one is present in the query p - Use this experiment + Use this pipeline version - - -
- This run will be associated with the following experiment -
- - + + + + + + Cancel + + + Use this experiment + + + + + +
+ This run will be associated with the following experiment +
+ + + + + Choose + + , + "readOnly": true, + } + } + disabled={true} + label="Pipeline Version" + required={true} + value="" + variant="outlined" + /> - Cancel @@ -2494,55 +3393,189 @@ exports[`NewRun renders the new run page 1`] = ` - Use this experiment + Use this pipeline version - - -
- This run will be associated with the following experiment -
- - - Choose - - , - "readOnly": true, + "id": "experimentSelectorDialog", + } + } + classes={ + Object { + "paper": "selectorDialog", + } + } + onClose={[Function]} + open={false} + > + + + + + + Cancel + + + Use this experiment + + + + + +
+ This run will be associated with the following experiment +
+ + + Choose + + , + "readOnly": true, } } disabled={true} @@ -2608,7 +3641,7 @@ exports[`NewRun renders the new run page 1`] = ` } } > - A pipeline must be selected + A pipeline version must be selected @@ -2659,6 +3692,38 @@ exports[`NewRun starting a new recurring run includes additional trigger input f value="" variant="outlined" /> + + + Choose + + , + "readOnly": true, + } + } + disabled={true} + label="Pipeline Version" + required={true} + value="" + variant="outlined" + /> - Cancel @@ -2924,38 +4001,168 @@ exports[`NewRun starting a new recurring run includes additional trigger input f - Use this experiment + Use this pipeline version - - -
- This run will be associated with the following experiment -
- + + + + + + Cancel + + + Use this experiment + + + + + +
+ This run will be associated with the following experiment +
+ - A pipeline must be selected + A pipeline version must be selected @@ -3071,7 +4278,6 @@ exports[`NewRun starting a new run copies pipeline from run in the start API cal
Using pipeline from cloned run - - + + + + Cancel + + + Use this pipeline version + + + + + + + + + + + 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 +
+ + One-off + + +
+ + + Cancel + +
+
+
+
+`; + +exports[`NewRun starting a new run updates the parameters in state on handleParamChange 1`] = ` +
+
+
+ Run details +
+ + + Choose + + , + "readOnly": true, + } + } + disabled={true} + label="Pipeline" + required={true} + value="original mock pipeline name" + variant="outlined" + /> + + + Choose + + , + "readOnly": true, + } + } + disabled={true} + label="Pipeline Version" + required={true} + value="original mock pipeline version name" + variant="outlined" + /> + + + Cancel @@ -3379,154 +5072,17 @@ exports[`NewRun starting a new run copies pipeline from run in the start API cal - Use this experiment + Use this pipeline - - -
- This run will be associated with the following experiment -
- - - Choose - - , - "readOnly": true, - } - } - disabled={true} - label="Experiment" - required={true} - value="" - variant="outlined" - /> -
- Run Type -
- - One-off - - -
- - - Cancel - -
-
-
-
-`; - -exports[`NewRun starting a new run updates the parameters in state on handleParamChange 1`] = ` -
-
-
- Run details -
- - - Choose - - , - "readOnly": true, - } - } - disabled={true} - label="Pipeline" - required={true} - value="original mock pipeline name" - variant="outlined" - /> Cancel @@ -3675,10 +5231,10 @@ exports[`NewRun starting a new run updates the parameters in state on handlePara - Use this pipeline + Use this pipeline version @@ -3739,7 +5295,7 @@ exports[`NewRun starting a new run updates the parameters in state on handlePara location={ Object { "pathname": "/runs/new", - "search": "?pipelineId=original-run-pipeline-id", + "search": "?pipelineId=original-run-pipeline-id&pipelineVersionId=original-run-pipeline-version-id", } } match="" @@ -3979,27 +5535,205 @@ exports[`NewRun starting a new run updates the pipeline params as user selects d onClick={[Function]} style={ Object { - "margin": 0, - "padding": "3px 5px", - } - } - > - Choose - - , - "readOnly": true, - } - } - disabled={true} - label="Pipeline" - required={true} - value="" - variant="outlined" - /> + "margin": 0, + "padding": "3px 5px", + } + } + > + Choose + + , + "readOnly": true, + } + } + disabled={true} + label="Pipeline" + required={true} + value="" + variant="outlined" + /> + + + Choose + + , + "readOnly": true, + } + } + disabled={true} + label="Pipeline Version" + required={true} + value="" + variant="outlined" + /> + + + + + + + Cancel + + + Use this pipeline + + + Cancel @@ -4135,10 +5869,10 @@ exports[`NewRun starting a new run updates the pipeline params as user selects d - Use this pipeline + Use this pipeline version @@ -4383,7 +6117,7 @@ exports[`NewRun starting a new run updates the pipeline params as user selects d } } > - A pipeline must be selected + A pipeline version must be selected
@@ -4434,10 +6168,188 @@ exports[`NewRun starting a new run updates the pipeline params as user selects d value="original mock pipeline name" variant="outlined" /> + + + Choose + + , + "readOnly": true, + } + } + disabled={true} + label="Pipeline Version" + required={true} + value="original mock pipeline version name" + variant="outlined" + /> + + + + + + + Cancel + + + Use this pipeline + + + Cancel @@ -4573,10 +6485,10 @@ exports[`NewRun starting a new run updates the pipeline params as user selects d - Use this pipeline + Use this pipeline version @@ -4830,63 +6742,241 @@ exports[`NewRun starting a new run updates the pipeline params as user selects d Object { "color": "red", } - } - > - Run name is required - - - - -`; - -exports[`NewRun starting a new run updates the pipeline params as user selects different pipelines 3`] = ` -
-
-
- Run details -
- - - Choose - - , - "readOnly": true, - } - } - disabled={true} - label="Pipeline" - required={true} - value="original mock pipeline name" - variant="outlined" - /> + } + > + Run name is required +
+
+ + +`; + +exports[`NewRun starting a new run updates the pipeline params as user selects different pipelines 3`] = ` +
+
+
+ Run details +
+ + + Choose + + , + "readOnly": true, + } + } + disabled={true} + label="Pipeline" + required={true} + value="original mock pipeline name" + variant="outlined" + /> + + + Choose + + , + "readOnly": true, + } + } + disabled={true} + label="Pipeline Version" + required={true} + value="original mock pipeline version name" + variant="outlined" + /> + + + + + + + Cancel + + + Use this pipeline + + + Cancel @@ -5022,10 +7112,10 @@ exports[`NewRun starting a new run updates the pipeline params as user selects d - Use this pipeline + Use this pipeline version @@ -5321,6 +7411,38 @@ exports[`NewRun updates the run's state with the associated experiment if one is value="" variant="outlined" /> + + + Choose + + , + "readOnly": true, + } + } + disabled={true} + label="Pipeline Version" + required={true} + value="" + variant="outlined" + /> + + + + + + + Cancel + + + Use this pipeline version + + + - A pipeline must be selected + A pipeline version must be selected
diff --git a/frontend/src/pages/__snapshots__/PipelineDetails.test.tsx.snap b/frontend/src/pages/__snapshots__/PipelineDetails.test.tsx.snap index 4076c50dac8..7bf4c5f29bf 100644 --- a/frontend/src/pages/__snapshots__/PipelineDetails.test.tsx.snap +++ b/frontend/src/pages/__snapshots__/PipelineDetails.test.tsx.snap @@ -64,6 +64,216 @@ exports[`PipelineDetails closes side panel when close button is clicked 1`] = `
test-pipeline-id
+
+ + + Version + + + + test-pipeline-version + + + +
+ +
+ Uploaded on +
+
+ 9/5/2018, 4:03:02 AM +
+
+ Description +
+ + + + +
+
+ Unable to retrieve node info +
+
+
+
+
+ + + Static pipeline graph + +
+
+ + + + + +`; + +exports[`PipelineDetails closes side panel when close button is clicked 2`] = ` +
+
+ +
+
+
+ +
+
+ Summary +
+ + Hide + +
+
+ ID +
+
+ test-pipeline-id +
+
+ + + Version + + + + test-pipeline-version + + + +
+
@@ -301,6 +511,41 @@ exports[`PipelineDetails opens side panel on clicked node, shows message when no
test-pipeline-id
+
+ + + Version + + + + test-pipeline-version + + + +
+
@@ -441,6 +686,41 @@ exports[`PipelineDetails shows clicked node info in the side panel if it is in t
test-pipeline-id
+
+ + + Version + + + + test-pipeline-version + + + +
+
@@ -675,6 +955,41 @@ exports[`PipelineDetails shows empty pipeline details with empty graph 1`] = `
test-pipeline-id
+
+ + + Version + + + + test-pipeline-version + + + +
+
diff --git a/frontend/src/pages/__snapshots__/PipelineList.test.tsx.snap b/frontend/src/pages/__snapshots__/PipelineList.test.tsx.snap index c939609fd2e..2bcb04f4458 100644 --- a/frontend/src/pages/__snapshots__/PipelineList.test.tsx.snap +++ b/frontend/src/pages/__snapshots__/PipelineList.test.tsx.snap @@ -27,11 +27,13 @@ exports[`PipelineList renders a list of one pipeline 1`] = ` } emptyMessage="No pipelines found. Click \\"Upload pipeline\\" to start." filterLabel="Filter pipelines" + getExpandComponent={[Function]} initialSortColumn="created_at" reload={[Function]} rows={ Array [ Object { + "expandState": undefined, "id": undefined, "otherFields": Array [ "pipeline1", @@ -42,6 +44,7 @@ exports[`PipelineList renders a list of one pipeline 1`] = ` ] } selectedIds={Array []} + toggleExpansion={[Function]} updateSelection={[Function]} /> +
+ +
+
+ + + , + } + } + className="filterBox" + height={48} + id="tableFilterBox" + label="Filter" + maxWidth="100%" + onChange={[Function]} + value="" + variant="outlined" + > + + + , + } + } + className="filterBox" + id="tableFilterBox" + label="Filter" + onChange={[Function]} + required={false} + select={false} + spellCheck={false} + style={ + Object { + "height": 48, + "maxWidth": "100%", + "width": "100%", + } + } + value="" + variant="outlined" + > + + +
+ + + + + + + + + + + + + + + + + } + value="" + > + + + + } + value="" + > + + + + } + type="text" + value="" + > + + + + } + type="text" + value="" + > + + + + } + type="text" + value="" + > +
+ + +
+ + + +
+
+
+ + +
+ + + + + + + + + +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + } + classes={ + Object { + "checked": "MuiCheckbox-checked-69", + "colorPrimary": "MuiCheckbox-colorPrimary-72", + "colorSecondary": "MuiCheckbox-colorSecondary-73", + "disabled": "MuiCheckbox-disabled-70", + "indeterminate": "MuiCheckbox-indeterminate-71", + "root": "MuiCheckbox-root-68", + } + } + color="primary" + icon={} + indeterminate={false} + indeterminateIcon={} + onChange={[Function]} + > + } + className="" + classes={ + Object { + "checked": "MuiCheckbox-checked-69", + "disabled": "MuiCheckbox-disabled-70", + "root": "MuiCheckbox-root-68 MuiCheckbox-colorPrimary-72", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + onChange={[Function]} + type="checkbox" + > + } + className="" + classes={ + Object { + "checked": "MuiPrivateSwitchBase-checked-75 MuiCheckbox-checked-69", + "disabled": "MuiPrivateSwitchBase-disabled-76 MuiCheckbox-disabled-70", + "input": "MuiPrivateSwitchBase-input-77", + "root": "MuiPrivateSwitchBase-root-74 MuiCheckbox-root-68 MuiCheckbox-colorPrimary-72", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + onChange={[Function]} + type="checkbox" + > + } + className="" + classes={ + Object { + "checked": "MuiPrivateSwitchBase-checked-75 MuiCheckbox-checked-69", + "disabled": "MuiPrivateSwitchBase-disabled-76 MuiCheckbox-disabled-70", + "input": "MuiPrivateSwitchBase-input-77", + "root": "MuiPrivateSwitchBase-root-74 MuiCheckbox-root-68 MuiCheckbox-colorPrimary-72", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + onChange={[Function]} + type="checkbox" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + Version name + + + + + + + + + + + + + + + + + Version name + + + } + className="MuiTooltip-popper-87" + disablePortal={false} + id={null} + open={false} + placement="bottom" + transition={true} + /> + + +
+
+ + + + + + + + + Uploaded on + + + + + + + + + + + + + + + + + Uploaded on + + + } + className="MuiTooltip-popper-87" + disablePortal={false} + id={null} + open={false} + placement="bottom" + transition={true} + /> + + +
+
+
+
+
+
+ + } + classes={ + Object { + "checked": "MuiCheckbox-checked-69", + "colorPrimary": "MuiCheckbox-colorPrimary-72", + "colorSecondary": "MuiCheckbox-colorSecondary-73", + "disabled": "MuiCheckbox-disabled-70", + "indeterminate": "MuiCheckbox-indeterminate-71", + "root": "MuiCheckbox-root-68", + } + } + color="primary" + icon={} + indeterminate={false} + indeterminateIcon={} + > + } + className="" + classes={ + Object { + "checked": "MuiCheckbox-checked-69", + "disabled": "MuiCheckbox-disabled-70", + "root": "MuiCheckbox-root-68 MuiCheckbox-colorPrimary-72", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + type="checkbox" + > + } + className="" + classes={ + Object { + "checked": "MuiPrivateSwitchBase-checked-75 MuiCheckbox-checked-69", + "disabled": "MuiPrivateSwitchBase-disabled-76 MuiCheckbox-disabled-70", + "input": "MuiPrivateSwitchBase-input-77", + "root": "MuiPrivateSwitchBase-root-74 MuiCheckbox-root-68 MuiCheckbox-colorPrimary-72", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + type="checkbox" + > + } + className="" + classes={ + Object { + "checked": "MuiPrivateSwitchBase-checked-75 MuiCheckbox-checked-69", + "disabled": "MuiPrivateSwitchBase-disabled-76 MuiCheckbox-disabled-70", + "input": "MuiPrivateSwitchBase-input-77", + "root": "MuiPrivateSwitchBase-root-74 MuiCheckbox-root-68 MuiCheckbox-colorPrimary-72", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + type="checkbox" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ - +
+
+
+
+
+
+
+ + } + classes={ + Object { + "checked": "MuiCheckbox-checked-69", + "colorPrimary": "MuiCheckbox-colorPrimary-72", + "colorSecondary": "MuiCheckbox-colorSecondary-73", + "disabled": "MuiCheckbox-disabled-70", + "indeterminate": "MuiCheckbox-indeterminate-71", + "root": "MuiCheckbox-root-68", + } + } + color="primary" + icon={} + indeterminate={false} + indeterminateIcon={} + > + } + className="" + classes={ + Object { + "checked": "MuiCheckbox-checked-69", + "disabled": "MuiCheckbox-disabled-70", + "root": "MuiCheckbox-root-68 MuiCheckbox-colorPrimary-72", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + type="checkbox" + > + } + className="" + classes={ + Object { + "checked": "MuiPrivateSwitchBase-checked-75 MuiCheckbox-checked-69", + "disabled": "MuiPrivateSwitchBase-disabled-76 MuiCheckbox-disabled-70", + "input": "MuiPrivateSwitchBase-input-77", + "root": "MuiPrivateSwitchBase-root-74 MuiCheckbox-root-68 MuiCheckbox-colorPrimary-72", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + type="checkbox" + > + } + className="" + classes={ + Object { + "checked": "MuiPrivateSwitchBase-checked-75 MuiCheckbox-checked-69", + "disabled": "MuiPrivateSwitchBase-disabled-76 MuiCheckbox-disabled-70", + "input": "MuiPrivateSwitchBase-input-77", + "root": "MuiPrivateSwitchBase-root-74 MuiCheckbox-root-68 MuiCheckbox-colorPrimary-72", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + type="checkbox" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ - +
+
+
+
+
+
+ + Rows per page: + + + + +
+ + } + value={10} + > + + } + value={10} + > + + 10 + , + + 20 + , + + 50 + , + + 100 + , + ], + "classes": Object { + "disabled": "MuiSelect-disabled-105", + "filled": "MuiSelect-filled-102", + "icon": "MuiSelect-icon-106", + "outlined": "MuiSelect-outlined-103", + "root": "MuiSelect-root-100", + "select": "MuiSelect-select-101", + "selectMenu": "MuiSelect-selectMenu-104", + }, + "displayEmpty": false, + "multiple": false, + "onClose": undefined, + "onOpen": undefined, + "open": undefined, + "renderValue": undefined, + "type": undefined, + "variant": "standard", + } + } + onChange={[Function]} + value={10} + > + + 10 + , + + 20 + , + + 50 + , + + 100 + , + ], + "classes": Object { + "disabled": "MuiSelect-disabled-105", + "filled": "MuiSelect-filled-102", + "icon": "MuiSelect-icon-106", + "outlined": "MuiSelect-outlined-103", + "root": "MuiSelect-root-100", + "select": "MuiSelect-select-101", + "selectMenu": "MuiSelect-selectMenu-104", + }, + "displayEmpty": false, + "multiple": false, + "onClose": undefined, + "onOpen": undefined, + "open": undefined, + "renderValue": undefined, + "type": undefined, + "variant": "standard", + } + } + multiline={false} + onChange={[Function]} + type="text" + value={10} + > + + 10 + , + + 20 + , + + 50 + , + + 100 + , + ], + "classes": Object { + "disabled": "MuiSelect-disabled-105", + "filled": "MuiSelect-filled-102", + "icon": "MuiSelect-icon-106", + "outlined": "MuiSelect-outlined-103", + "root": "MuiSelect-root-100", + "select": "MuiSelect-select-101", + "selectMenu": "MuiSelect-selectMenu-104", + }, + "displayEmpty": false, + "multiple": false, + "onClose": undefined, + "onOpen": undefined, + "open": undefined, + "renderValue": undefined, + "type": undefined, + "variant": "standard", + } + } + multiline={false} + onChange={[Function]} + type="text" + value={10} + > + + 10 + , + + 20 + , + + 50 + , + + 100 + , + ], + "classes": Object { + "disabled": "MuiSelect-disabled-105", + "filled": "MuiSelect-filled-102", + "icon": "MuiSelect-icon-106", + "outlined": "MuiSelect-outlined-103", + "root": "MuiSelect-root-100", + "select": "MuiSelect-select-101", + "selectMenu": "MuiSelect-selectMenu-104", + }, + "displayEmpty": false, + "multiple": false, + "onClose": undefined, + "onOpen": undefined, + "open": undefined, + "renderValue": undefined, + "type": undefined, + "variant": "standard", + } + } + muiFormControl={ + Object { + "adornedStart": false, + "disabled": false, + "error": false, + "filled": true, + "focused": false, + "margin": "none", + "onBlur": [Function], + "onEmpty": [Function], + "onFilled": [Function], + "onFocus": [Function], + "required": false, + "variant": "standard", + } + } + multiline={false} + onChange={[Function]} + type="text" + value={10} + > +
+ +
+
+ 10 +
+ + + + + + + + + + + + 10 +
+ } + id="menu-" + onClose={[Function]} + open={false} + > + + 10 +
+ } + classes={ + Object { + "paper": "MuiMenu-paper-120", + } + } + disableAutoFocusItem={false} + id="menu-" + onClose={[Function]} + open={false} + theme={ + Object { + "breakpoints": Object { + "between": [Function], + "down": [Function], + "keys": Array [ + "xs", + "sm", + "md", + "lg", + "xl", + ], + "only": [Function], + "up": [Function], + "values": Object { + "lg": 1280, + "md": 960, + "sm": 600, + "xl": 1920, + "xs": 0, + }, + "width": [Function], + }, + "direction": "ltr", + "mixins": Object { + "gutters": [Function], + "toolbar": Object { + "@media (min-width:0px) and (orientation: landscape)": Object { + "minHeight": 48, + }, + "@media (min-width:600px)": Object { + "minHeight": 64, + }, + "minHeight": 56, + }, + }, + "overrides": Object {}, + "palette": Object { + "action": Object { + "active": "rgba(0, 0, 0, 0.54)", + "disabled": "rgba(0, 0, 0, 0.26)", + "disabledBackground": "rgba(0, 0, 0, 0.12)", + "hover": "rgba(0, 0, 0, 0.08)", + "hoverOpacity": 0.08, + "selected": "rgba(0, 0, 0, 0.14)", + }, + "augmentColor": [Function], + "background": Object { + "default": "#fafafa", + "paper": "#fff", + }, + "common": Object { + "black": "#000", + "white": "#fff", + }, + "contrastThreshold": 3, + "divider": "rgba(0, 0, 0, 0.12)", + "error": Object { + "contrastText": "#fff", + "dark": "#d32f2f", + "light": "#e57373", + "main": "#f44336", + }, + "getContrastText": [Function], + "grey": Object { + "100": "#f5f5f5", + "200": "#eeeeee", + "300": "#e0e0e0", + "400": "#bdbdbd", + "50": "#fafafa", + "500": "#9e9e9e", + "600": "#757575", + "700": "#616161", + "800": "#424242", + "900": "#212121", + "A100": "#d5d5d5", + "A200": "#aaaaaa", + "A400": "#303030", + "A700": "#616161", + }, + "primary": Object { + "contrastText": "#fff", + "dark": "#303f9f", + "light": "#7986cb", + "main": "#3f51b5", + }, + "secondary": Object { + "contrastText": "#fff", + "dark": "#c51162", + "light": "#ff4081", + "main": "#f50057", + }, + "text": Object { + "disabled": "rgba(0, 0, 0, 0.38)", + "hint": "rgba(0, 0, 0, 0.38)", + "primary": "rgba(0, 0, 0, 0.87)", + "secondary": "rgba(0, 0, 0, 0.54)", + }, + "tonalOffset": 0.2, + "type": "light", + }, + "props": Object {}, + "shadows": Array [ + "none", + "0px 1px 3px 0px rgba(0,0,0,0.2),0px 1px 1px 0px rgba(0,0,0,0.14),0px 2px 1px -1px rgba(0,0,0,0.12)", + "0px 1px 5px 0px rgba(0,0,0,0.2),0px 2px 2px 0px rgba(0,0,0,0.14),0px 3px 1px -2px rgba(0,0,0,0.12)", + "0px 1px 8px 0px rgba(0,0,0,0.2),0px 3px 4px 0px rgba(0,0,0,0.14),0px 3px 3px -2px rgba(0,0,0,0.12)", + "0px 2px 4px -1px rgba(0,0,0,0.2),0px 4px 5px 0px rgba(0,0,0,0.14),0px 1px 10px 0px rgba(0,0,0,0.12)", + "0px 3px 5px -1px rgba(0,0,0,0.2),0px 5px 8px 0px rgba(0,0,0,0.14),0px 1px 14px 0px rgba(0,0,0,0.12)", + "0px 3px 5px -1px rgba(0,0,0,0.2),0px 6px 10px 0px rgba(0,0,0,0.14),0px 1px 18px 0px rgba(0,0,0,0.12)", + "0px 4px 5px -2px rgba(0,0,0,0.2),0px 7px 10px 1px rgba(0,0,0,0.14),0px 2px 16px 1px rgba(0,0,0,0.12)", + "0px 5px 5px -3px rgba(0,0,0,0.2),0px 8px 10px 1px rgba(0,0,0,0.14),0px 3px 14px 2px rgba(0,0,0,0.12)", + "0px 5px 6px -3px rgba(0,0,0,0.2),0px 9px 12px 1px rgba(0,0,0,0.14),0px 3px 16px 2px rgba(0,0,0,0.12)", + "0px 6px 6px -3px rgba(0,0,0,0.2),0px 10px 14px 1px rgba(0,0,0,0.14),0px 4px 18px 3px rgba(0,0,0,0.12)", + "0px 6px 7px -4px rgba(0,0,0,0.2),0px 11px 15px 1px rgba(0,0,0,0.14),0px 4px 20px 3px rgba(0,0,0,0.12)", + "0px 7px 8px -4px rgba(0,0,0,0.2),0px 12px 17px 2px rgba(0,0,0,0.14),0px 5px 22px 4px rgba(0,0,0,0.12)", + "0px 7px 8px -4px rgba(0,0,0,0.2),0px 13px 19px 2px rgba(0,0,0,0.14),0px 5px 24px 4px rgba(0,0,0,0.12)", + "0px 7px 9px -4px rgba(0,0,0,0.2),0px 14px 21px 2px rgba(0,0,0,0.14),0px 5px 26px 4px rgba(0,0,0,0.12)", + "0px 8px 9px -5px rgba(0,0,0,0.2),0px 15px 22px 2px rgba(0,0,0,0.14),0px 6px 28px 5px rgba(0,0,0,0.12)", + "0px 8px 10px -5px rgba(0,0,0,0.2),0px 16px 24px 2px rgba(0,0,0,0.14),0px 6px 30px 5px rgba(0,0,0,0.12)", + "0px 8px 11px -5px rgba(0,0,0,0.2),0px 17px 26px 2px rgba(0,0,0,0.14),0px 6px 32px 5px rgba(0,0,0,0.12)", + "0px 9px 11px -5px rgba(0,0,0,0.2),0px 18px 28px 2px rgba(0,0,0,0.14),0px 7px 34px 6px rgba(0,0,0,0.12)", + "0px 9px 12px -6px rgba(0,0,0,0.2),0px 19px 29px 2px rgba(0,0,0,0.14),0px 7px 36px 6px rgba(0,0,0,0.12)", + "0px 10px 13px -6px rgba(0,0,0,0.2),0px 20px 31px 3px rgba(0,0,0,0.14),0px 8px 38px 7px rgba(0,0,0,0.12)", + "0px 10px 13px -6px rgba(0,0,0,0.2),0px 21px 33px 3px rgba(0,0,0,0.14),0px 8px 40px 7px rgba(0,0,0,0.12)", + "0px 10px 14px -6px rgba(0,0,0,0.2),0px 22px 35px 3px rgba(0,0,0,0.14),0px 8px 42px 7px rgba(0,0,0,0.12)", + "0px 11px 14px -7px rgba(0,0,0,0.2),0px 23px 36px 3px rgba(0,0,0,0.14),0px 9px 44px 8px rgba(0,0,0,0.12)", + "0px 11px 15px -7px rgba(0,0,0,0.2),0px 24px 38px 3px rgba(0,0,0,0.14),0px 9px 46px 8px rgba(0,0,0,0.12)", + ], + "shape": Object { + "borderRadius": 4, + }, + "spacing": Object { + "unit": 8, + }, + "transitions": Object { + "create": [Function], + "duration": Object { + "complex": 375, + "enteringScreen": 225, + "leavingScreen": 195, + "short": 250, + "shorter": 200, + "shortest": 150, + "standard": 300, + }, + "easing": Object { + "easeIn": "cubic-bezier(0.4, 0, 1, 1)", + "easeInOut": "cubic-bezier(0.4, 0, 0.2, 1)", + "easeOut": "cubic-bezier(0.0, 0, 0.2, 1)", + "sharp": "cubic-bezier(0.4, 0, 0.6, 1)", + }, + "getAutoHeightDuration": [Function], + }, + "typography": Object { + "body1": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "0.875rem", + "fontWeight": 400, + "lineHeight": "1.46429em", + }, + "body1Next": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "1rem", + "fontWeight": 400, + "letterSpacing": "0.00938em", + "lineHeight": 1.5, + }, + "body2": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "0.875rem", + "fontWeight": 500, + "lineHeight": "1.71429em", + }, + "body2Next": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "0.875rem", + "fontWeight": 400, + "letterSpacing": "0.01071em", + "lineHeight": 1.5, + }, + "button": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "0.875rem", + "fontWeight": 500, + "textTransform": "uppercase", + }, + "buttonNext": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "0.875rem", + "fontWeight": 500, + "letterSpacing": "0.02857em", + "lineHeight": 1.5, + "textTransform": "uppercase", + }, + "caption": Object { + "color": "rgba(0, 0, 0, 0.54)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "0.75rem", + "fontWeight": 400, + "lineHeight": "1.375em", + }, + "captionNext": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "0.75rem", + "fontWeight": 400, + "letterSpacing": "0.03333em", + "lineHeight": 1.66, + }, + "display1": Object { + "color": "rgba(0, 0, 0, 0.54)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "2.125rem", + "fontWeight": 400, + "lineHeight": "1.20588em", + }, + "display2": Object { + "color": "rgba(0, 0, 0, 0.54)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "2.8125rem", + "fontWeight": 400, + "lineHeight": "1.13333em", + "marginLeft": "-.02em", + }, + "display3": Object { + "color": "rgba(0, 0, 0, 0.54)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "3.5rem", + "fontWeight": 400, + "letterSpacing": "-.02em", + "lineHeight": "1.30357em", + "marginLeft": "-.02em", + }, + "display4": Object { + "color": "rgba(0, 0, 0, 0.54)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "7rem", + "fontWeight": 300, + "letterSpacing": "-.04em", + "lineHeight": "1.14286em", + "marginLeft": "-.04em", + }, + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": 14, + "fontWeightLight": 300, + "fontWeightMedium": 500, + "fontWeightRegular": 400, + "h1": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "6rem", + "fontWeight": 300, + "letterSpacing": "-0.01562em", + "lineHeight": 1, + }, + "h2": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "3.75rem", + "fontWeight": 300, + "letterSpacing": "-0.00833em", + "lineHeight": 1, + }, + "h3": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "3rem", + "fontWeight": 400, + "letterSpacing": "0em", + "lineHeight": 1.04, + }, + "h4": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "2.125rem", + "fontWeight": 400, + "letterSpacing": "0.00735em", + "lineHeight": 1.17, + }, + "h5": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "1.5rem", + "fontWeight": 400, + "letterSpacing": "0em", + "lineHeight": 1.33, + }, + "h6": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "1.25rem", + "fontWeight": 500, + "letterSpacing": "0.0075em", + "lineHeight": 1.6, + }, + "headline": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "1.5rem", + "fontWeight": 400, + "lineHeight": "1.35417em", + }, + "overline": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "0.75rem", + "fontWeight": 400, + "letterSpacing": "0.08333em", + "lineHeight": 2.66, + "textTransform": "uppercase", + }, + "pxToRem": [Function], + "round": [Function], + "subheading": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "1rem", + "fontWeight": 400, + "lineHeight": "1.5em", + }, + "subtitle1": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "1rem", + "fontWeight": 400, + "letterSpacing": "0.00938em", + "lineHeight": 1.75, + }, + "subtitle2": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "0.875rem", + "fontWeight": 500, + "letterSpacing": "0.00714em", + "lineHeight": 1.57, + }, + "title": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "1.3125rem", + "fontWeight": 500, + "lineHeight": "1.16667em", + }, + "useNextVariants": false, + }, + "zIndex": Object { + "appBar": 1100, + "drawer": 1200, + "mobileStepper": 1000, + "modal": 1300, + "snackbar": 1400, + "tooltip": 1500, + }, + } + } + transitionDuration="auto" + > + + 10 +
+ } + anchorOrigin={ + Object { + "horizontal": "left", + "vertical": "top", + } + } + getContentAnchorEl={[Function]} + id="menu-" + onClose={[Function]} + onEntering={[Function]} + open={false} + transformOrigin={ + Object { + "horizontal": "left", + "vertical": "top", + } + } + transitionDuration="auto" + > + + 10 +
+ } + anchorOrigin={ + Object { + "horizontal": "left", + "vertical": "top", + } + } + anchorReference="anchorEl" + classes={ + Object { + "paper": "MuiPopover-paper-121", + } + } + elevation={8} + getContentAnchorEl={[Function]} + id="menu-" + marginThreshold={16} + onClose={[Function]} + onEntering={[Function]} + open={false} + transformOrigin={ + Object { + "horizontal": "left", + "vertical": "top", + } + } + transitionDuration="auto" + > + } + id="menu-" + onClose={[Function]} + open={false} + > + } + disableAutoFocus={false} + disableBackdropClick={false} + disableEnforceFocus={false} + disableEscapeKeyDown={false} + disablePortal={false} + disableRestoreFocus={false} + hideBackdrop={false} + id="menu-" + keepMounted={false} + manager={ + ModalManager { + "data": Array [], + "handleContainerOverflow": true, + "hideSiblingNodes": true, + "modals": Array [], + } + } + onClose={[Function]} + open={false} + /> + + + + + +
+ +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +`; + +exports[`PipelineVersionList calls Apis to list pipeline versions, sorted by pipeline version name in descending order 1`] = ` + +
+ +
+
+ + + , + } + } + className="filterBox" + height={48} + id="tableFilterBox" + label="Filter" + maxWidth="100%" + onChange={[Function]} + value="" + variant="outlined" + > + + + , + } + } + className="filterBox" + id="tableFilterBox" + label="Filter" + onChange={[Function]} + required={false} + select={false} + spellCheck={false} + style={ + Object { + "height": 48, + "maxWidth": "100%", + "width": "100%", + } + } + value="" + variant="outlined" + > + + +
+ + + + + + + + + + + + + + + + + } + value="" + > + + + + } + value="" + > + + + + } + type="text" + value="" + > + + + + } + type="text" + value="" + > + + + + } + type="text" + value="" + > +
+ + +
+ + + +
+
+
+ + +
+ + + + + + + + + +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + } + classes={ + Object { + "checked": "MuiCheckbox-checked-209", + "colorPrimary": "MuiCheckbox-colorPrimary-212", + "colorSecondary": "MuiCheckbox-colorSecondary-213", + "disabled": "MuiCheckbox-disabled-210", + "indeterminate": "MuiCheckbox-indeterminate-211", + "root": "MuiCheckbox-root-208", + } + } + color="primary" + icon={} + indeterminate={false} + indeterminateIcon={} + onChange={[Function]} + > + } + className="" + classes={ + Object { + "checked": "MuiCheckbox-checked-209", + "disabled": "MuiCheckbox-disabled-210", + "root": "MuiCheckbox-root-208 MuiCheckbox-colorPrimary-212", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + onChange={[Function]} + type="checkbox" + > + } + className="" + classes={ + Object { + "checked": "MuiPrivateSwitchBase-checked-215 MuiCheckbox-checked-209", + "disabled": "MuiPrivateSwitchBase-disabled-216 MuiCheckbox-disabled-210", + "input": "MuiPrivateSwitchBase-input-217", + "root": "MuiPrivateSwitchBase-root-214 MuiCheckbox-root-208 MuiCheckbox-colorPrimary-212", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + onChange={[Function]} + type="checkbox" + > + } + className="" + classes={ + Object { + "checked": "MuiPrivateSwitchBase-checked-215 MuiCheckbox-checked-209", + "disabled": "MuiPrivateSwitchBase-disabled-216 MuiCheckbox-disabled-210", + "input": "MuiPrivateSwitchBase-input-217", + "root": "MuiPrivateSwitchBase-root-214 MuiCheckbox-root-208 MuiCheckbox-colorPrimary-212", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + onChange={[Function]} + type="checkbox" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + Version name + + + + + + + + + + + + + + + + + Version name + + + } + className="MuiTooltip-popper-227" + disablePortal={false} + id={null} + open={false} + placement="bottom" + transition={true} + /> + + +
+
+ + + + + + + + + Uploaded on + + + + + + + + + + + + + + + + + Uploaded on + + + } + className="MuiTooltip-popper-227" + disablePortal={false} + id={null} + open={false} + placement="bottom" + transition={true} + /> + + +
+
+
+
+
+
+ + } + classes={ + Object { + "checked": "MuiCheckbox-checked-209", + "colorPrimary": "MuiCheckbox-colorPrimary-212", + "colorSecondary": "MuiCheckbox-colorSecondary-213", + "disabled": "MuiCheckbox-disabled-210", + "indeterminate": "MuiCheckbox-indeterminate-211", + "root": "MuiCheckbox-root-208", + } + } + color="primary" + icon={} + indeterminate={false} + indeterminateIcon={} + > + } + className="" + classes={ + Object { + "checked": "MuiCheckbox-checked-209", + "disabled": "MuiCheckbox-disabled-210", + "root": "MuiCheckbox-root-208 MuiCheckbox-colorPrimary-212", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + type="checkbox" + > + } + className="" + classes={ + Object { + "checked": "MuiPrivateSwitchBase-checked-215 MuiCheckbox-checked-209", + "disabled": "MuiPrivateSwitchBase-disabled-216 MuiCheckbox-disabled-210", + "input": "MuiPrivateSwitchBase-input-217", + "root": "MuiPrivateSwitchBase-root-214 MuiCheckbox-root-208 MuiCheckbox-colorPrimary-212", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + type="checkbox" + > + } + className="" + classes={ + Object { + "checked": "MuiPrivateSwitchBase-checked-215 MuiCheckbox-checked-209", + "disabled": "MuiPrivateSwitchBase-disabled-216 MuiCheckbox-disabled-210", + "input": "MuiPrivateSwitchBase-input-217", + "root": "MuiPrivateSwitchBase-root-214 MuiCheckbox-root-208 MuiCheckbox-colorPrimary-212", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + type="checkbox" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ - +
+
+
+
+
+
+
+ + } + classes={ + Object { + "checked": "MuiCheckbox-checked-209", + "colorPrimary": "MuiCheckbox-colorPrimary-212", + "colorSecondary": "MuiCheckbox-colorSecondary-213", + "disabled": "MuiCheckbox-disabled-210", + "indeterminate": "MuiCheckbox-indeterminate-211", + "root": "MuiCheckbox-root-208", + } + } + color="primary" + icon={} + indeterminate={false} + indeterminateIcon={} + > + } + className="" + classes={ + Object { + "checked": "MuiCheckbox-checked-209", + "disabled": "MuiCheckbox-disabled-210", + "root": "MuiCheckbox-root-208 MuiCheckbox-colorPrimary-212", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + type="checkbox" + > + } + className="" + classes={ + Object { + "checked": "MuiPrivateSwitchBase-checked-215 MuiCheckbox-checked-209", + "disabled": "MuiPrivateSwitchBase-disabled-216 MuiCheckbox-disabled-210", + "input": "MuiPrivateSwitchBase-input-217", + "root": "MuiPrivateSwitchBase-root-214 MuiCheckbox-root-208 MuiCheckbox-colorPrimary-212", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + type="checkbox" + > + } + className="" + classes={ + Object { + "checked": "MuiPrivateSwitchBase-checked-215 MuiCheckbox-checked-209", + "disabled": "MuiPrivateSwitchBase-disabled-216 MuiCheckbox-disabled-210", + "input": "MuiPrivateSwitchBase-input-217", + "root": "MuiPrivateSwitchBase-root-214 MuiCheckbox-root-208 MuiCheckbox-colorPrimary-212", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + type="checkbox" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ - +
+
+
+
+
+
+
+ + } + classes={ + Object { + "checked": "MuiCheckbox-checked-209", + "colorPrimary": "MuiCheckbox-colorPrimary-212", + "colorSecondary": "MuiCheckbox-colorSecondary-213", + "disabled": "MuiCheckbox-disabled-210", + "indeterminate": "MuiCheckbox-indeterminate-211", + "root": "MuiCheckbox-root-208", + } + } + color="primary" + icon={} + indeterminate={false} + indeterminateIcon={} + > + } + className="" + classes={ + Object { + "checked": "MuiCheckbox-checked-209", + "disabled": "MuiCheckbox-disabled-210", + "root": "MuiCheckbox-root-208 MuiCheckbox-colorPrimary-212", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + type="checkbox" + > + } + className="" + classes={ + Object { + "checked": "MuiPrivateSwitchBase-checked-215 MuiCheckbox-checked-209", + "disabled": "MuiPrivateSwitchBase-disabled-216 MuiCheckbox-disabled-210", + "input": "MuiPrivateSwitchBase-input-217", + "root": "MuiPrivateSwitchBase-root-214 MuiCheckbox-root-208 MuiCheckbox-colorPrimary-212", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + type="checkbox" + > + } + className="" + classes={ + Object { + "checked": "MuiPrivateSwitchBase-checked-215 MuiCheckbox-checked-209", + "disabled": "MuiPrivateSwitchBase-disabled-216 MuiCheckbox-disabled-210", + "input": "MuiPrivateSwitchBase-input-217", + "root": "MuiPrivateSwitchBase-root-214 MuiCheckbox-root-208 MuiCheckbox-colorPrimary-212", + } + } + icon={} + inputProps={ + Object { + "data-indeterminate": false, + } + } + type="checkbox" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ - +
+
+
+
+
+
+ + Rows per page: + + + + +
+ + } + value={10} + > + + } + value={10} + > + + 10 + , + + 20 + , + + 50 + , + + 100 + , + ], + "classes": Object { + "disabled": "MuiSelect-disabled-245", + "filled": "MuiSelect-filled-242", + "icon": "MuiSelect-icon-246", + "outlined": "MuiSelect-outlined-243", + "root": "MuiSelect-root-240", + "select": "MuiSelect-select-241", + "selectMenu": "MuiSelect-selectMenu-244", + }, + "displayEmpty": false, + "multiple": false, + "onClose": undefined, + "onOpen": undefined, + "open": undefined, + "renderValue": undefined, + "type": undefined, + "variant": "standard", + } + } + onChange={[Function]} + value={10} + > + + 10 + , + + 20 + , + + 50 + , + + 100 + , + ], + "classes": Object { + "disabled": "MuiSelect-disabled-245", + "filled": "MuiSelect-filled-242", + "icon": "MuiSelect-icon-246", + "outlined": "MuiSelect-outlined-243", + "root": "MuiSelect-root-240", + "select": "MuiSelect-select-241", + "selectMenu": "MuiSelect-selectMenu-244", + }, + "displayEmpty": false, + "multiple": false, + "onClose": undefined, + "onOpen": undefined, + "open": undefined, + "renderValue": undefined, + "type": undefined, + "variant": "standard", + } + } + multiline={false} + onChange={[Function]} + type="text" + value={10} + > + + 10 + , + + 20 + , + + 50 + , + + 100 + , + ], + "classes": Object { + "disabled": "MuiSelect-disabled-245", + "filled": "MuiSelect-filled-242", + "icon": "MuiSelect-icon-246", + "outlined": "MuiSelect-outlined-243", + "root": "MuiSelect-root-240", + "select": "MuiSelect-select-241", + "selectMenu": "MuiSelect-selectMenu-244", + }, + "displayEmpty": false, + "multiple": false, + "onClose": undefined, + "onOpen": undefined, + "open": undefined, + "renderValue": undefined, + "type": undefined, + "variant": "standard", + } + } + multiline={false} + onChange={[Function]} + type="text" + value={10} + > + + 10 + , + + 20 + , + + 50 + , + + 100 + , + ], + "classes": Object { + "disabled": "MuiSelect-disabled-245", + "filled": "MuiSelect-filled-242", + "icon": "MuiSelect-icon-246", + "outlined": "MuiSelect-outlined-243", + "root": "MuiSelect-root-240", + "select": "MuiSelect-select-241", + "selectMenu": "MuiSelect-selectMenu-244", + }, + "displayEmpty": false, + "multiple": false, + "onClose": undefined, + "onOpen": undefined, + "open": undefined, + "renderValue": undefined, + "type": undefined, + "variant": "standard", + } + } + muiFormControl={ + Object { + "adornedStart": false, + "disabled": false, + "error": false, + "filled": true, + "focused": false, + "margin": "none", + "onBlur": [Function], + "onEmpty": [Function], + "onFilled": [Function], + "onFocus": [Function], + "required": false, + "variant": "standard", + } + } + multiline={false} + onChange={[Function]} + type="text" + value={10} + > +
+ +
+
+ 10 +
+ + + + + + + + + + + + 10 +
+ } + id="menu-" + onClose={[Function]} + open={false} + > + + 10 +
+ } + classes={ + Object { + "paper": "MuiMenu-paper-260", + } + } + disableAutoFocusItem={false} + id="menu-" + onClose={[Function]} + open={false} + theme={ + Object { + "breakpoints": Object { + "between": [Function], + "down": [Function], + "keys": Array [ + "xs", + "sm", + "md", + "lg", + "xl", + ], + "only": [Function], + "up": [Function], + "values": Object { + "lg": 1280, + "md": 960, + "sm": 600, + "xl": 1920, + "xs": 0, + }, + "width": [Function], + }, + "direction": "ltr", + "mixins": Object { + "gutters": [Function], + "toolbar": Object { + "@media (min-width:0px) and (orientation: landscape)": Object { + "minHeight": 48, + }, + "@media (min-width:600px)": Object { + "minHeight": 64, + }, + "minHeight": 56, + }, + }, + "overrides": Object {}, + "palette": Object { + "action": Object { + "active": "rgba(0, 0, 0, 0.54)", + "disabled": "rgba(0, 0, 0, 0.26)", + "disabledBackground": "rgba(0, 0, 0, 0.12)", + "hover": "rgba(0, 0, 0, 0.08)", + "hoverOpacity": 0.08, + "selected": "rgba(0, 0, 0, 0.14)", + }, + "augmentColor": [Function], + "background": Object { + "default": "#fafafa", + "paper": "#fff", + }, + "common": Object { + "black": "#000", + "white": "#fff", + }, + "contrastThreshold": 3, + "divider": "rgba(0, 0, 0, 0.12)", + "error": Object { + "contrastText": "#fff", + "dark": "#d32f2f", + "light": "#e57373", + "main": "#f44336", + }, + "getContrastText": [Function], + "grey": Object { + "100": "#f5f5f5", + "200": "#eeeeee", + "300": "#e0e0e0", + "400": "#bdbdbd", + "50": "#fafafa", + "500": "#9e9e9e", + "600": "#757575", + "700": "#616161", + "800": "#424242", + "900": "#212121", + "A100": "#d5d5d5", + "A200": "#aaaaaa", + "A400": "#303030", + "A700": "#616161", + }, + "primary": Object { + "contrastText": "#fff", + "dark": "#303f9f", + "light": "#7986cb", + "main": "#3f51b5", + }, + "secondary": Object { + "contrastText": "#fff", + "dark": "#c51162", + "light": "#ff4081", + "main": "#f50057", + }, + "text": Object { + "disabled": "rgba(0, 0, 0, 0.38)", + "hint": "rgba(0, 0, 0, 0.38)", + "primary": "rgba(0, 0, 0, 0.87)", + "secondary": "rgba(0, 0, 0, 0.54)", + }, + "tonalOffset": 0.2, + "type": "light", + }, + "props": Object {}, + "shadows": Array [ + "none", + "0px 1px 3px 0px rgba(0,0,0,0.2),0px 1px 1px 0px rgba(0,0,0,0.14),0px 2px 1px -1px rgba(0,0,0,0.12)", + "0px 1px 5px 0px rgba(0,0,0,0.2),0px 2px 2px 0px rgba(0,0,0,0.14),0px 3px 1px -2px rgba(0,0,0,0.12)", + "0px 1px 8px 0px rgba(0,0,0,0.2),0px 3px 4px 0px rgba(0,0,0,0.14),0px 3px 3px -2px rgba(0,0,0,0.12)", + "0px 2px 4px -1px rgba(0,0,0,0.2),0px 4px 5px 0px rgba(0,0,0,0.14),0px 1px 10px 0px rgba(0,0,0,0.12)", + "0px 3px 5px -1px rgba(0,0,0,0.2),0px 5px 8px 0px rgba(0,0,0,0.14),0px 1px 14px 0px rgba(0,0,0,0.12)", + "0px 3px 5px -1px rgba(0,0,0,0.2),0px 6px 10px 0px rgba(0,0,0,0.14),0px 1px 18px 0px rgba(0,0,0,0.12)", + "0px 4px 5px -2px rgba(0,0,0,0.2),0px 7px 10px 1px rgba(0,0,0,0.14),0px 2px 16px 1px rgba(0,0,0,0.12)", + "0px 5px 5px -3px rgba(0,0,0,0.2),0px 8px 10px 1px rgba(0,0,0,0.14),0px 3px 14px 2px rgba(0,0,0,0.12)", + "0px 5px 6px -3px rgba(0,0,0,0.2),0px 9px 12px 1px rgba(0,0,0,0.14),0px 3px 16px 2px rgba(0,0,0,0.12)", + "0px 6px 6px -3px rgba(0,0,0,0.2),0px 10px 14px 1px rgba(0,0,0,0.14),0px 4px 18px 3px rgba(0,0,0,0.12)", + "0px 6px 7px -4px rgba(0,0,0,0.2),0px 11px 15px 1px rgba(0,0,0,0.14),0px 4px 20px 3px rgba(0,0,0,0.12)", + "0px 7px 8px -4px rgba(0,0,0,0.2),0px 12px 17px 2px rgba(0,0,0,0.14),0px 5px 22px 4px rgba(0,0,0,0.12)", + "0px 7px 8px -4px rgba(0,0,0,0.2),0px 13px 19px 2px rgba(0,0,0,0.14),0px 5px 24px 4px rgba(0,0,0,0.12)", + "0px 7px 9px -4px rgba(0,0,0,0.2),0px 14px 21px 2px rgba(0,0,0,0.14),0px 5px 26px 4px rgba(0,0,0,0.12)", + "0px 8px 9px -5px rgba(0,0,0,0.2),0px 15px 22px 2px rgba(0,0,0,0.14),0px 6px 28px 5px rgba(0,0,0,0.12)", + "0px 8px 10px -5px rgba(0,0,0,0.2),0px 16px 24px 2px rgba(0,0,0,0.14),0px 6px 30px 5px rgba(0,0,0,0.12)", + "0px 8px 11px -5px rgba(0,0,0,0.2),0px 17px 26px 2px rgba(0,0,0,0.14),0px 6px 32px 5px rgba(0,0,0,0.12)", + "0px 9px 11px -5px rgba(0,0,0,0.2),0px 18px 28px 2px rgba(0,0,0,0.14),0px 7px 34px 6px rgba(0,0,0,0.12)", + "0px 9px 12px -6px rgba(0,0,0,0.2),0px 19px 29px 2px rgba(0,0,0,0.14),0px 7px 36px 6px rgba(0,0,0,0.12)", + "0px 10px 13px -6px rgba(0,0,0,0.2),0px 20px 31px 3px rgba(0,0,0,0.14),0px 8px 38px 7px rgba(0,0,0,0.12)", + "0px 10px 13px -6px rgba(0,0,0,0.2),0px 21px 33px 3px rgba(0,0,0,0.14),0px 8px 40px 7px rgba(0,0,0,0.12)", + "0px 10px 14px -6px rgba(0,0,0,0.2),0px 22px 35px 3px rgba(0,0,0,0.14),0px 8px 42px 7px rgba(0,0,0,0.12)", + "0px 11px 14px -7px rgba(0,0,0,0.2),0px 23px 36px 3px rgba(0,0,0,0.14),0px 9px 44px 8px rgba(0,0,0,0.12)", + "0px 11px 15px -7px rgba(0,0,0,0.2),0px 24px 38px 3px rgba(0,0,0,0.14),0px 9px 46px 8px rgba(0,0,0,0.12)", + ], + "shape": Object { + "borderRadius": 4, + }, + "spacing": Object { + "unit": 8, + }, + "transitions": Object { + "create": [Function], + "duration": Object { + "complex": 375, + "enteringScreen": 225, + "leavingScreen": 195, + "short": 250, + "shorter": 200, + "shortest": 150, + "standard": 300, + }, + "easing": Object { + "easeIn": "cubic-bezier(0.4, 0, 1, 1)", + "easeInOut": "cubic-bezier(0.4, 0, 0.2, 1)", + "easeOut": "cubic-bezier(0.0, 0, 0.2, 1)", + "sharp": "cubic-bezier(0.4, 0, 0.6, 1)", + }, + "getAutoHeightDuration": [Function], + }, + "typography": Object { + "body1": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "0.875rem", + "fontWeight": 400, + "lineHeight": "1.46429em", + }, + "body1Next": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "1rem", + "fontWeight": 400, + "letterSpacing": "0.00938em", + "lineHeight": 1.5, + }, + "body2": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "0.875rem", + "fontWeight": 500, + "lineHeight": "1.71429em", + }, + "body2Next": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "0.875rem", + "fontWeight": 400, + "letterSpacing": "0.01071em", + "lineHeight": 1.5, + }, + "button": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "0.875rem", + "fontWeight": 500, + "textTransform": "uppercase", + }, + "buttonNext": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "0.875rem", + "fontWeight": 500, + "letterSpacing": "0.02857em", + "lineHeight": 1.5, + "textTransform": "uppercase", + }, + "caption": Object { + "color": "rgba(0, 0, 0, 0.54)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "0.75rem", + "fontWeight": 400, + "lineHeight": "1.375em", + }, + "captionNext": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "0.75rem", + "fontWeight": 400, + "letterSpacing": "0.03333em", + "lineHeight": 1.66, + }, + "display1": Object { + "color": "rgba(0, 0, 0, 0.54)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "2.125rem", + "fontWeight": 400, + "lineHeight": "1.20588em", + }, + "display2": Object { + "color": "rgba(0, 0, 0, 0.54)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "2.8125rem", + "fontWeight": 400, + "lineHeight": "1.13333em", + "marginLeft": "-.02em", + }, + "display3": Object { + "color": "rgba(0, 0, 0, 0.54)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "3.5rem", + "fontWeight": 400, + "letterSpacing": "-.02em", + "lineHeight": "1.30357em", + "marginLeft": "-.02em", + }, + "display4": Object { + "color": "rgba(0, 0, 0, 0.54)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "7rem", + "fontWeight": 300, + "letterSpacing": "-.04em", + "lineHeight": "1.14286em", + "marginLeft": "-.04em", + }, + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": 14, + "fontWeightLight": 300, + "fontWeightMedium": 500, + "fontWeightRegular": 400, + "h1": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "6rem", + "fontWeight": 300, + "letterSpacing": "-0.01562em", + "lineHeight": 1, + }, + "h2": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "3.75rem", + "fontWeight": 300, + "letterSpacing": "-0.00833em", + "lineHeight": 1, + }, + "h3": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "3rem", + "fontWeight": 400, + "letterSpacing": "0em", + "lineHeight": 1.04, + }, + "h4": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "2.125rem", + "fontWeight": 400, + "letterSpacing": "0.00735em", + "lineHeight": 1.17, + }, + "h5": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "1.5rem", + "fontWeight": 400, + "letterSpacing": "0em", + "lineHeight": 1.33, + }, + "h6": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "1.25rem", + "fontWeight": 500, + "letterSpacing": "0.0075em", + "lineHeight": 1.6, + }, + "headline": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "1.5rem", + "fontWeight": 400, + "lineHeight": "1.35417em", + }, + "overline": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "0.75rem", + "fontWeight": 400, + "letterSpacing": "0.08333em", + "lineHeight": 2.66, + "textTransform": "uppercase", + }, + "pxToRem": [Function], + "round": [Function], + "subheading": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "1rem", + "fontWeight": 400, + "lineHeight": "1.5em", + }, + "subtitle1": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "1rem", + "fontWeight": 400, + "letterSpacing": "0.00938em", + "lineHeight": 1.75, + }, + "subtitle2": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "0.875rem", + "fontWeight": 500, + "letterSpacing": "0.00714em", + "lineHeight": 1.57, + }, + "title": Object { + "color": "rgba(0, 0, 0, 0.87)", + "fontFamily": "\\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif", + "fontSize": "1.3125rem", + "fontWeight": 500, + "lineHeight": "1.16667em", + }, + "useNextVariants": false, + }, + "zIndex": Object { + "appBar": 1100, + "drawer": 1200, + "mobileStepper": 1000, + "modal": 1300, + "snackbar": 1400, + "tooltip": 1500, + }, + } + } + transitionDuration="auto" + > + + 10 +
+ } + anchorOrigin={ + Object { + "horizontal": "left", + "vertical": "top", + } + } + getContentAnchorEl={[Function]} + id="menu-" + onClose={[Function]} + onEntering={[Function]} + open={false} + transformOrigin={ + Object { + "horizontal": "left", + "vertical": "top", + } + } + transitionDuration="auto" + > + + 10 +
+ } + anchorOrigin={ + Object { + "horizontal": "left", + "vertical": "top", + } + } + anchorReference="anchorEl" + classes={ + Object { + "paper": "MuiPopover-paper-261", + } + } + elevation={8} + getContentAnchorEl={[Function]} + id="menu-" + marginThreshold={16} + onClose={[Function]} + onEntering={[Function]} + open={false} + transformOrigin={ + Object { + "horizontal": "left", + "vertical": "top", + } + } + transitionDuration="auto" + > + } + id="menu-" + onClose={[Function]} + open={false} + > + } + disableAutoFocus={false} + disableBackdropClick={false} + disableEnforceFocus={false} + disableEscapeKeyDown={false} + disablePortal={false} + disableRestoreFocus={false} + hideBackdrop={false} + id="menu-" + keepMounted={false} + manager={ + ModalManager { + "data": Array [], + "handleContainerOverflow": true, + "hideSiblingNodes": true, + "modals": Array [], + } + } + onClose={[Function]} + open={false} + /> + + + + + +
+ +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +`; + +exports[`PipelineVersionList renders a list of one pipeline version 1`] = ` +
+ +
+`; + +exports[`PipelineVersionList renders a list of one pipeline version with error 1`] = ` +
+ +
+`; + +exports[`PipelineVersionList renders a list of one pipeline version without created date 1`] = ` +
+ +
+`; + +exports[`PipelineVersionList renders an empty list with empty state message 1`] = ` +
+ +
+`; diff --git a/frontend/src/pages/__snapshots__/RunList.test.tsx.snap b/frontend/src/pages/__snapshots__/RunList.test.tsx.snap index 18f79c94491..71958dc8bba 100644 --- a/frontend/src/pages/__snapshots__/RunList.test.tsx.snap +++ b/frontend/src/pages/__snapshots__/RunList.test.tsx.snap @@ -28,7 +28,7 @@ exports[`RunList adds metrics columns 1`] = ` Object { "customRenderer": [Function], "flex": 1, - "label": "Pipeline", + "label": "Pipeline Version", }, Object { "customRenderer": [Function], @@ -173,7 +173,7 @@ exports[`RunList displays error in run row if experiment could not be fetched 1` Object { "customRenderer": [Function], "flex": 1, - "label": "Pipeline", + "label": "Pipeline Version", }, Object { "customRenderer": [Function], @@ -240,7 +240,7 @@ exports[`RunList displays error in run row if it failed to parse (run list mask) Object { "customRenderer": [Function], "flex": 1, - "label": "Pipeline", + "label": "Pipeline Version", }, Object { "customRenderer": [Function], @@ -320,7 +320,7 @@ exports[`RunList displays error in run row if pipeline could not be fetched 1`] Object { "customRenderer": [Function], "flex": 1, - "label": "Pipeline", + "label": "Pipeline Version", }, Object { "customRenderer": [Function], @@ -399,7 +399,7 @@ exports[`RunList hides experiment name if instructed 1`] = ` Object { "customRenderer": [Function], "flex": 1, - "label": "Pipeline", + "label": "Pipeline Version", }, Object { "customRenderer": [Function], @@ -465,7 +465,7 @@ exports[`RunList in archived state renders the empty experience 1`] = ` Object { "customRenderer": [Function], "flex": 1, - "label": "Pipeline", + "label": "Pipeline Version", }, Object { "customRenderer": [Function], @@ -516,7 +516,7 @@ exports[`RunList loads multiple runs 1`] = ` Object { "customRenderer": [Function], "flex": 1, - "label": "Pipeline", + "label": "Pipeline Version", }, Object { "customRenderer": [Function], @@ -635,7 +635,7 @@ exports[`RunList loads one run 1`] = ` Object { "customRenderer": [Function], "flex": 1, - "label": "Pipeline", + "label": "Pipeline Version", }, Object { "customRenderer": [Function], @@ -712,7 +712,7 @@ exports[`RunList reloads the run when refresh is called 1`] = ` Object { "customRenderer": [Function], "flex": 1, - "label": "Pipeline", + "label": "Pipeline Version", }, Object { "customRenderer": [Function], @@ -4352,7 +4352,7 @@ exports[`RunList reloads the run when refresh is called 1`] = ` "width": "16.666666666666664%", } } - title="Pipeline" + title="Pipeline Version" > - Pipeline + Pipeline Version @@ -4905,7 +4905,7 @@ exports[`RunList reloads the run when refresh is called 1`] = ` tabindex="0" title="Cannot sort by this column" > - Pipeline + Pipeline Version