diff --git a/src/components/Launch/LaunchWorkflowForm/LaunchWorkflowForm.tsx b/src/components/Launch/LaunchWorkflowForm/LaunchWorkflowForm.tsx index 1e67b824e..f84467919 100644 --- a/src/components/Launch/LaunchWorkflowForm/LaunchWorkflowForm.tsx +++ b/src/components/Launch/LaunchWorkflowForm/LaunchWorkflowForm.tsx @@ -1,6 +1,8 @@ import { Button, DialogActions, + DialogContent, + DialogTitle, FormHelperText, Typography } from '@material-ui/core'; @@ -9,7 +11,12 @@ import { WaitForData } from 'components/common'; import { ButtonCircularProgress } from 'components/common/ButtonCircularProgress'; import { APIContextValue, useAPIContext } from 'components/data/apiContext'; import { smallFontSize } from 'components/Theme'; -import { FilterOperationName, WorkflowId } from 'models'; +import { + FilterOperationName, + NamedEntityIdentifier, + SortDirection, + workflowSortFields +} from 'models'; import * as React from 'react'; import { SearchableSelector } from './SearchableSelector'; import { SimpleInput } from './SimpleInput'; @@ -20,18 +27,17 @@ import { workflowsToSearchableSelectorOptions } from './utils'; const useStyles = makeStyles((theme: Theme) => ({ footer: { - borderTop: `1px solid ${theme.palette.divider}`, padding: theme.spacing(2) }, formControl: { padding: `${theme.spacing(1.5)}px 0` }, header: { - borderBottom: `1px solid ${theme.palette.divider}`, padding: theme.spacing(2), width: '100%' }, inputsSection: { + minHeight: theme.spacing(75), padding: theme.spacing(2) }, inputLabel: { @@ -60,7 +66,7 @@ function getComponentForInput(input: InputProps) { function generateFetchSearchResults( { listWorkflows }: APIContextValue, - workflowId: WorkflowId + workflowId: NamedEntityIdentifier ) { return async (query: string) => { const { entities: workflows } = await listWorkflows(workflowId, { @@ -70,13 +76,13 @@ function generateFetchSearchResults( operation: FilterOperationName.CONTAINS, value: query } - ] + ], + sort: { + key: workflowSortFields.createdAt, + direction: SortDirection.DESCENDING + } }); - const options = workflowsToSearchableSelectorOptions(workflows); - if (options.length > 0) { - options[0].description = 'latest'; - } - return options; + return workflowsToSearchableSelectorOptions(workflows); }; } @@ -97,12 +103,12 @@ export const LaunchWorkflowForm: React.FC = props => { }; return ( -
-
+ <> +
Launch Workflow
{state.workflowName} -
-
+ + = props => { ) : null} -
+
{!!submissionState.lastError && ( @@ -178,6 +184,6 @@ export const LaunchWorkflowForm: React.FC = props => {
-
+ ); }; diff --git a/src/components/Launch/LaunchWorkflowForm/SearchableSelector.tsx b/src/components/Launch/LaunchWorkflowForm/SearchableSelector.tsx index 8e96d28cd..e3a280913 100644 --- a/src/components/Launch/LaunchWorkflowForm/SearchableSelector.tsx +++ b/src/components/Launch/LaunchWorkflowForm/SearchableSelector.tsx @@ -40,7 +40,7 @@ const useStyles = makeStyles((theme: Theme) => ({ marginTop: theme.spacing(0.5), position: 'absolute', right: 0, - zIndex: 2 + zIndex: theme.zIndex.tooltip }, selectedItem: { fontWeight: 'bold' diff --git a/src/components/Launch/LaunchWorkflowForm/__stories__/LaunchWorkflowForm.stories.tsx b/src/components/Launch/LaunchWorkflowForm/__stories__/LaunchWorkflowForm.stories.tsx index d74f62280..a44ff00ec 100644 --- a/src/components/Launch/LaunchWorkflowForm/__stories__/LaunchWorkflowForm.stories.tsx +++ b/src/components/Launch/LaunchWorkflowForm/__stories__/LaunchWorkflowForm.stories.tsx @@ -41,6 +41,8 @@ const mockApi = mockAPIContextValue({ listLaunchPlans: () => resolveAfter(500, { entities: [mockLaunchPlan] }) }); +const onClose = () => console.log('Close'); + const stories = storiesOf('Launch/LaunchWorkflowForm', module); stories.addDecorator(story => ( @@ -48,4 +50,6 @@ stories.addDecorator(story => ( )); -stories.add('Basic', () => ); +stories.add('Basic', () => ( + +)); diff --git a/src/components/Launch/LaunchWorkflowForm/types.ts b/src/components/Launch/LaunchWorkflowForm/types.ts index 6d9e76228..7e2689828 100644 --- a/src/components/Launch/LaunchWorkflowForm/types.ts +++ b/src/components/Launch/LaunchWorkflowForm/types.ts @@ -1,9 +1,15 @@ import { FetchableData, MultiFetchableState } from 'components/hooks'; -import { LaunchPlan, WorkflowExecutionIdentifier, WorkflowId } from 'models'; +import { + LaunchPlan, + NamedEntityIdentifier, + WorkflowExecutionIdentifier, + WorkflowId +} from 'models'; import { SearchableSelectorOption } from './SearchableSelector'; export interface LaunchWorkflowFormProps { - workflowId: WorkflowId; + workflowId: NamedEntityIdentifier; + onClose(): void; } export interface LaunchWorkflowFormState { diff --git a/src/components/Launch/LaunchWorkflowForm/useLaunchWorkflowFormState.ts b/src/components/Launch/LaunchWorkflowForm/useLaunchWorkflowFormState.ts index d1d374777..660a6894e 100644 --- a/src/components/Launch/LaunchWorkflowForm/useLaunchWorkflowFormState.ts +++ b/src/components/Launch/LaunchWorkflowForm/useLaunchWorkflowFormState.ts @@ -9,11 +9,14 @@ import { FilterOperationName, LaunchPlan, LiteralType, + SortDirection, Workflow, WorkflowExecutionIdentifier, - WorkflowId + WorkflowId, + workflowSortFields } from 'models'; import { useEffect, useMemo, useState } from 'react'; +import { history, Routes } from 'routes'; import { simpleTypeToInputType } from './constants'; import { SearchableSelectorOption } from './SearchableSelector'; import { @@ -139,10 +142,17 @@ function useFormInputsState(parsedInputs: ParsedInput[]): FormInputsState { }; } -function useWorkflowSelectorOptions(workflows: Workflow[]) { - return useMemo(() => workflowsToSearchableSelectorOptions(workflows), [ - workflows - ]); +export function useWorkflowSelectorOptions(workflows: Workflow[]) { + return useMemo( + () => { + const options = workflowsToSearchableSelectorOptions(workflows); + if (options.length > 0) { + options[0].description = 'latest'; + } + return options; + }, + [workflows] + ); } function useLaunchPlanSelectorOptions(launchPlans: LaunchPlan[]) { @@ -193,9 +203,17 @@ function useLaunchPlansForWorkflow(workflowId: WorkflowId | null = null) { * definitions, current input values, and errors. */ export function useLaunchWorkflowFormState({ + onClose, workflowId }: LaunchWorkflowFormProps): LaunchWorkflowFormState { - const workflows = useWorkflows(workflowId, { limit: 10 }); + const { createWorkflowExecution } = useAPIContext(); + const workflows = useWorkflows(workflowId, { + limit: 10, + sort: { + key: workflowSortFields.createdAt, + direction: SortDirection.DESCENDING + } + }); const workflowSelectorOptions = useWorkflowSelectorOptions(workflows.value); const [selectedWorkflow, setWorkflow] = useState< SearchableSelectorOption @@ -213,6 +231,9 @@ export function useLaunchWorkflowFormState({ const [selectedLaunchPlan, setLaunchPlan] = useState< SearchableSelectorOption >(); + const launchPlanData = selectedLaunchPlan + ? selectedLaunchPlan.data + : undefined; const workflowOptionsLoadingState = waitForAllFetchables([workflows]); const launchPlanOptionsLoadingState = waitForAllFetchables([launchPlans]); @@ -230,12 +251,24 @@ export function useLaunchWorkflowFormState({ setWorkflow(newWorkflow); }; - const launchWorkflow = () => { - const literalMap = convertFormInputsToLiteralMap(inputs); - console.log('launch', literalMap); - return new Promise((resolve, reject) => { - setTimeout(() => reject('Launching is not implemented'), 1500); + const launchWorkflow = async () => { + if (!launchPlanData) { + throw new Error('Attempting to launch with no LaunchPlan'); + } + const launchPlanId = launchPlanData.id; + const { domain, project } = workflowId; + const response = await createWorkflowExecution({ + domain, + launchPlanId, + project, + inputs: convertFormInputsToLiteralMap(inputs) }); + const newExecutionId = response.id as WorkflowExecutionIdentifier; + if (!newExecutionId) { + throw new Error('API Response did not include new execution id'); + } + history.push(Routes.ExecutionDetails.makeUrl(newExecutionId)); + return newExecutionId; }; const submissionState = useFetchableData({ @@ -246,19 +279,27 @@ export function useLaunchWorkflowFormState({ }); const onSubmit = submissionState.fetch; - const onCancel = () => { - console.log('cancel'); - }; + const onCancel = onClose; useEffect( () => { const parsedInputs = - selectedLaunchPlan && workflow.hasLoaded - ? getInputs(workflow.value, selectedLaunchPlan.data) + launchPlanData && workflow.hasLoaded + ? getInputs(workflow.value, launchPlanData) : []; setParsedInputs(parsedInputs); }, - [workflow.hasLoaded, workflow.value, selectedLaunchPlan] + [workflow.hasLoaded, workflow.value, launchPlanData] + ); + + // Once workflows have loaded, attempt to select the first option + useEffect( + () => { + if (workflowSelectorOptions.length > 0 && !selectedWorkflow) { + setWorkflow(workflowSelectorOptions[0]); + } + }, + [workflows.value] ); // Once launch plans have been loaded, attempt to select the default diff --git a/src/components/Workflow/WorkflowDetails/WorkflowDetails.tsx b/src/components/Workflow/WorkflowDetails/WorkflowDetails.tsx index 695755277..1552ae58e 100644 --- a/src/components/Workflow/WorkflowDetails/WorkflowDetails.tsx +++ b/src/components/Workflow/WorkflowDetails/WorkflowDetails.tsx @@ -1,7 +1,9 @@ +import { Dialog } from '@material-ui/core'; import { makeStyles, Theme } from '@material-ui/core/styles'; import { contentMarginGridUnits } from 'common/layout'; import { WaitForData, withRouteParams } from 'components/common'; import { useProject } from 'components/hooks'; +import { LaunchWorkflowForm } from 'components/Launch/LaunchWorkflowForm/LaunchWorkflowForm'; import * as React from 'react'; import { WorkflowDescription } from './WorkflowDescription'; import { WorkflowDetailsHeader } from './WorkflowDetailsHeader'; @@ -46,6 +48,10 @@ export const WorkflowDetailsContainer: React.FC = ({ }) => { const project = useProject(projectId); const styles = useStyles(); + const [showLaunchForm, setShowLaunchForm] = React.useState(false); + const onLaunch = () => setShowLaunchForm(true); + const onCancelLaunch = () => setShowLaunchForm(false); + const workflowId = { project: projectId, domain: domainId, @@ -58,6 +64,7 @@ export const WorkflowDetailsContainer: React.FC = ({ project={project.value} domainId={domainId} workflowName={workflowName} + onClickLaunch={onLaunch} />
@@ -70,6 +77,17 @@ export const WorkflowDetailsContainer: React.FC = ({
+ + + ); diff --git a/src/components/Workflow/WorkflowDetails/WorkflowDetailsHeader.tsx b/src/components/Workflow/WorkflowDetails/WorkflowDetailsHeader.tsx index 295ed1001..58a0de973 100644 --- a/src/components/Workflow/WorkflowDetails/WorkflowDetailsHeader.tsx +++ b/src/components/Workflow/WorkflowDetails/WorkflowDetailsHeader.tsx @@ -1,3 +1,4 @@ +import { Button } from '@material-ui/core'; import { makeStyles, Theme } from '@material-ui/core/styles'; import ArrowBack from '@material-ui/icons/ArrowBack'; import * as classnames from 'classnames'; @@ -9,10 +10,12 @@ import { Link } from 'react-router-dom'; import { Routes } from 'routes'; const useStyles = makeStyles((theme: Theme) => ({ + actionsContainer: {}, headerContainer: { alignItems: 'center', display: 'flex', height: theme.spacing(5), + justifyContent: 'space-between', marginTop: theme.spacing(2), width: '100%' }, @@ -36,11 +39,13 @@ interface WorkflowDetailsHeaderProps { domainId: string; project: Project; workflowName: string; + onClickLaunch(): void; } /** Renders the workflow name and actions shown on the workflow details page */ export const WorkflowDetailsHeader: React.FC = ({ domainId, + onClickLaunch, project, workflowName }) => { @@ -67,6 +72,16 @@ export const WorkflowDetailsHeader: React.FC = ({ {headerText}
+
+ +
); }; diff --git a/src/models/Execution/api.ts b/src/models/Execution/api.ts index 9a96ed515..c38127734 100644 --- a/src/models/Execution/api.ts +++ b/src/models/Execution/api.ts @@ -1,4 +1,4 @@ -import { Admin } from 'flyteidl'; +import { Admin, Core } from 'flyteidl'; import { defaultPaginationConfig, getAdminEntity, @@ -7,11 +7,12 @@ import { } from 'models/AdminEntity'; import { endpointPrefixes, + Identifier, IdentifierScope, makeIdentifierPath, NameIdentifierScope } from 'models/Common'; - +import { defaultExecutionPrincipal } from './constants'; import { Execution, ExecutionData, @@ -21,7 +22,6 @@ import { TaskExecutionIdentifier, WorkflowExecutionIdentifier } from './types'; - import { executionListTransformer, makeExecutionPath, @@ -76,6 +76,45 @@ export const getExecutionData = ( config ); +interface CreateWorkflowExecutionArguments { + domain: string; + inputs: Core.ILiteralMap; + launchPlanId: Identifier; + project: string; +} +/** Submits a request to create a new `WorkflowExecution` using the provided + * LaunchPlan and input values. + */ +export const createWorkflowExecution = ( + { + domain, + inputs, + launchPlanId: launchPlan, + project + }: CreateWorkflowExecutionArguments, + config?: RequestConfig +) => + postAdminEntity< + Admin.IExecutionCreateRequest, + Admin.ExecutionCreateResponse + >( + { + data: { + project, + domain, + spec: { + inputs, + launchPlan, + metadata: { principal: defaultExecutionPrincipal } + } + }, + path: endpointPrefixes.execution, + requestMessageType: Admin.ExecutionCreateRequest, + responseMessageType: Admin.ExecutionCreateResponse + }, + config + ); + /** Submits a request to terminate a WorkflowExecution by id */ export const terminateWorkflowExecution = ( id: WorkflowExecutionIdentifier, diff --git a/src/models/Execution/constants.ts b/src/models/Execution/constants.ts index 51f610f46..1cfabb733 100644 --- a/src/models/Execution/constants.ts +++ b/src/models/Execution/constants.ts @@ -29,3 +29,5 @@ export const executionSortFields = { createdAt: 'created_at', startedAt: 'started_at' }; + +export const defaultExecutionPrincipal = 'flyteconsole';