diff --git a/src/components/Entities/EntityDetails.tsx b/src/components/Entities/EntityDetails.tsx index ce3639cf8..5a4b2bc66 100644 --- a/src/components/Entities/EntityDetails.tsx +++ b/src/components/Entities/EntityDetails.tsx @@ -4,7 +4,7 @@ import { contentMarginGridUnits } from 'common/layout'; import { WaitForData } from 'components/common'; import { EntityDescription } from 'components/Entities/EntityDescription'; import { useProject } from 'components/hooks'; -import { LaunchWorkflowForm } from 'components/Launch/LaunchWorkflowForm/LaunchWorkflowForm'; +import { LaunchForm } from 'components/Launch/LaunchForm/LaunchForm'; import { ResourceIdentifier } from 'models'; import * as React from 'react'; import { entitySections } from './constants'; @@ -76,7 +76,6 @@ export const EntityDetails: React.FC = ({ id }) => { ) : null} - {/* TODO: LaunchWorkflowForm needs to be made generic */} {!!sections.launch ? ( = ({ id }) => { fullWidth={true} open={showLaunchForm} > - + ) : null} diff --git a/src/components/Executions/ExecutionDetails/RelaunchExecutionForm.tsx b/src/components/Executions/ExecutionDetails/RelaunchExecutionForm.tsx index 17d86b807..f2d2096b1 100644 --- a/src/components/Executions/ExecutionDetails/RelaunchExecutionForm.tsx +++ b/src/components/Executions/ExecutionDetails/RelaunchExecutionForm.tsx @@ -1,8 +1,8 @@ import { WaitForData } from 'components/common'; import { useWorkflow } from 'components/hooks'; -import { LaunchWorkflowForm } from 'components/Launch/LaunchWorkflowForm/LaunchWorkflowForm'; -import { useExecutionLaunchConfiguration } from 'components/Launch/LaunchWorkflowForm/useExecutionLaunchConfiguration'; -import { getWorkflowInputs } from 'components/Launch/LaunchWorkflowForm/utils'; +import { LaunchForm } from 'components/Launch/LaunchForm/LaunchForm'; +import { useExecutionLaunchConfiguration } from 'components/Launch/LaunchForm/useExecutionLaunchConfiguration'; +import { getWorkflowInputs } from 'components/Launch/LaunchForm/utils'; import { Execution, Workflow } from 'models'; import * as React from 'react'; @@ -22,7 +22,7 @@ const RenderForm: React.FC - = props => { + const [inputValueCache] = React.useState(createInputValueCache()); + + // TODO: Use LaunchTaskForm when it has been implemented. + return ( + + {isWorkflowPropsObject(props) ? ( + + ) : null} + + ); +}; diff --git a/src/components/Launch/LaunchForm/LaunchFormActions.tsx b/src/components/Launch/LaunchForm/LaunchFormActions.tsx new file mode 100644 index 000000000..c09c8ef9d --- /dev/null +++ b/src/components/Launch/LaunchForm/LaunchFormActions.tsx @@ -0,0 +1,89 @@ +import { Button, DialogActions, FormHelperText } from '@material-ui/core'; +import { ButtonCircularProgress } from 'components'; +import * as React from 'react'; +import { history } from 'routes/history'; +import { Routes } from 'routes/routes'; +import { formStrings } from './constants'; +import { LaunchState } from './launchMachine'; +import { useStyles } from './styles'; +import { BaseInterpretedLaunchState, BaseLaunchService } from './types'; + +export interface LaunchFormActionsProps { + state: BaseInterpretedLaunchState; + service: BaseLaunchService; + onClose(): void; +} +/** Renders the Submit/Cancel buttons for a LaunchForm */ +export const LaunchFormActions: React.FC = ({ + state, + service, + onClose +}) => { + const styles = useStyles(); + const submissionInFlight = state.matches(LaunchState.SUBMITTING); + const canSubmit = [ + LaunchState.ENTER_INPUTS, + LaunchState.VALIDATING_INPUTS, + LaunchState.INVALID_INPUTS, + LaunchState.SUBMIT_FAILED + ].some(state.matches); + + const submit: React.FormEventHandler = event => { + event.preventDefault(); + service.send({ type: 'SUBMIT' }); + }; + + const onCancel = () => { + service.send({ type: 'CANCEL' }); + onClose(); + }; + + React.useEffect(() => { + const subscription = service.subscribe(newState => { + // On transition to final success state, read the resulting execution + // id and navigate to the Execution Details page. + // if (state.matches({ submit: 'succeeded' })) { + if (newState.matches(LaunchState.SUBMIT_SUCCEEDED)) { + history.push( + Routes.ExecutionDetails.makeUrl( + newState.context.resultExecutionId + ) + ); + } + }); + + return subscription.unsubscribe; + }, [service]); + + return ( +
+ {state.matches(LaunchState.SUBMIT_FAILED) ? ( + + {state.context.error.message} + + ) : null} + + + + +
+ ); +}; diff --git a/src/components/Launch/LaunchForm/LaunchFormHeader.tsx b/src/components/Launch/LaunchForm/LaunchFormHeader.tsx new file mode 100644 index 000000000..38aa12a96 --- /dev/null +++ b/src/components/Launch/LaunchForm/LaunchFormHeader.tsx @@ -0,0 +1,17 @@ +import { DialogTitle, Typography } from '@material-ui/core'; +import * as React from 'react'; +import { formStrings } from './constants'; +import { useStyles } from './styles'; + +/** Shared header component for the Launch form */ +export const LaunchFormHeader: React.FC<{ title?: string }> = ({ + title = '' +}) => { + const styles = useStyles(); + return ( + +
{formStrings.title}
+ {title} +
+ ); +}; diff --git a/src/components/Launch/LaunchForm/LaunchFormInputs.tsx b/src/components/Launch/LaunchForm/LaunchFormInputs.tsx new file mode 100644 index 000000000..bf7cbcdc0 --- /dev/null +++ b/src/components/Launch/LaunchForm/LaunchFormInputs.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { BlobInput } from './BlobInput'; +import { CollectionInput } from './CollectionInput'; +import { formStrings } from './constants'; +import { LaunchState } from './launchMachine'; +import { SimpleInput } from './SimpleInput'; +import { useStyles } from './styles'; +import { + BaseInterpretedLaunchState, + InputProps, + InputType, + LaunchFormInputsRef +} from './types'; +import { UnsupportedInput } from './UnsupportedInput'; +import { UnsupportedRequiredInputsError } from './UnsupportedRequiredInputsError'; +import { useFormInputsState } from './useFormInputsState'; + +function getComponentForInput(input: InputProps, showErrors: boolean) { + const props = { ...input, error: showErrors ? input.error : undefined }; + switch (input.typeDefinition.type) { + case InputType.Blob: + return ; + case InputType.Collection: + return ; + case InputType.Map: + case InputType.Schema: + case InputType.Unknown: + case InputType.None: + return ; + default: + return ; + } +} + +export interface LaunchFormInputsProps { + state: BaseInterpretedLaunchState; +} + +export const LaunchFormInputsImpl: React.RefForwardingComponent< + LaunchFormInputsRef, + LaunchFormInputsProps +> = ({ state }, ref) => { + const { + parsedInputs, + unsupportedRequiredInputs, + showErrors + } = state.context; + const { getValues, inputs, validate } = useFormInputsState(parsedInputs); + const styles = useStyles(); + React.useImperativeHandle(ref, () => ({ + getValues, + validate + })); + + const showInputs = [ + LaunchState.UNSUPPORTED_INPUTS, + LaunchState.ENTER_INPUTS, + LaunchState.VALIDATING_INPUTS, + LaunchState.INVALID_INPUTS, + LaunchState.SUBMIT_VALIDATING, + LaunchState.SUBMITTING, + LaunchState.SUBMIT_FAILED, + LaunchState.SUBMIT_SUCCEEDED + ].some(state.matches); + + return showInputs ? ( +
+ {state.matches(LaunchState.UNSUPPORTED_INPUTS) ? ( + + ) : ( + <> + {inputs.map(input => ( +
+ {getComponentForInput(input, showErrors)} +
+ ))} + + )} +
+ ) : null; +}; + +/** Renders an array of `ParsedInput` values using the appropriate + * components. A `ref` to this component is used to access the current + * form values and trigger manual validation if needed. + */ +export const LaunchFormInputs = React.forwardRef(LaunchFormInputsImpl); diff --git a/src/components/Launch/LaunchForm/LaunchWorkflowForm.tsx b/src/components/Launch/LaunchForm/LaunchWorkflowForm.tsx new file mode 100644 index 000000000..3c1baa76b --- /dev/null +++ b/src/components/Launch/LaunchForm/LaunchWorkflowForm.tsx @@ -0,0 +1,105 @@ +import { DialogContent } from '@material-ui/core'; +import { getCacheKey } from 'components/Cache/utils'; +import * as React from 'react'; +import { formStrings } from './constants'; +import { LaunchFormActions } from './LaunchFormActions'; +import { LaunchFormHeader } from './LaunchFormHeader'; +import { LaunchFormInputs } from './LaunchFormInputs'; +import { LaunchState } from './launchMachine'; +import { SearchableSelector } from './SearchableSelector'; +import { useStyles } from './styles'; +import { + BaseInterpretedLaunchState, + BaseLaunchService, + LaunchWorkflowFormProps +} from './types'; +import { useLaunchWorkflowFormState } from './useLaunchWorkflowFormState'; + +/** Renders the form for initiating a Launch request based on a Workflow */ +export const LaunchWorkflowForm: React.FC = props => { + const { + formInputsRef, + state, + service, + workflowSourceSelectorState + } = useLaunchWorkflowFormState(props); + const styles = useStyles(); + const baseState = state as BaseInterpretedLaunchState; + const baseService = service as BaseLaunchService; + + // Any time the inputs change (even if it's just re-ordering), we must + // change the form key so that the inputs component will re-mount. + const formKey = React.useMemo(() => { + return getCacheKey(state.context.parsedInputs); + }, [state.context.parsedInputs]); + + const { + fetchSearchResults, + launchPlanSelectorOptions, + onSelectLaunchPlan, + onSelectWorkflowVersion, + selectedLaunchPlan, + selectedWorkflow, + workflowSelectorOptions + } = workflowSourceSelectorState; + + const showWorkflowSelector = ![ + LaunchState.LOADING_WORKFLOW_VERSIONS, + LaunchState.FAILED_LOADING_WORKFLOW_VERSIONS + ].some(state.matches); + const showLaunchPlanSelector = + state.context.workflowVersion && + ![ + LaunchState.LOADING_LAUNCH_PLANS, + LaunchState.FAILED_LOADING_LAUNCH_PLANS + ].some(state.matches); + + // TODO: We removed all loading indicators here. Decide if we want skeletons + // instead. + return ( + <> + + + {showWorkflowSelector ? ( +
+ +
+ ) : null} + {showLaunchPlanSelector ? ( +
+ +
+ ) : null} + +
+ + + ); +}; diff --git a/src/components/Launch/LaunchWorkflowForm/SearchableSelector.tsx b/src/components/Launch/LaunchForm/SearchableSelector.tsx similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/SearchableSelector.tsx rename to src/components/Launch/LaunchForm/SearchableSelector.tsx diff --git a/src/components/Launch/LaunchWorkflowForm/SimpleInput.tsx b/src/components/Launch/LaunchForm/SimpleInput.tsx similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/SimpleInput.tsx rename to src/components/Launch/LaunchForm/SimpleInput.tsx diff --git a/src/components/Launch/LaunchWorkflowForm/UnsupportedInput.tsx b/src/components/Launch/LaunchForm/UnsupportedInput.tsx similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/UnsupportedInput.tsx rename to src/components/Launch/LaunchForm/UnsupportedInput.tsx diff --git a/src/components/Launch/LaunchWorkflowForm/UnsupportedRequiredInputsError.tsx b/src/components/Launch/LaunchForm/UnsupportedRequiredInputsError.tsx similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/UnsupportedRequiredInputsError.tsx rename to src/components/Launch/LaunchForm/UnsupportedRequiredInputsError.tsx diff --git a/src/components/Launch/LaunchWorkflowForm/__mocks__/mockInputs.ts b/src/components/Launch/LaunchForm/__mocks__/mockInputs.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/__mocks__/mockInputs.ts rename to src/components/Launch/LaunchForm/__mocks__/mockInputs.ts diff --git a/src/components/Launch/LaunchWorkflowForm/__mocks__/utils.ts b/src/components/Launch/LaunchForm/__mocks__/utils.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/__mocks__/utils.ts rename to src/components/Launch/LaunchForm/__mocks__/utils.ts diff --git a/src/components/Launch/LaunchWorkflowForm/__stories__/LaunchWorkflowForm.stories.tsx b/src/components/Launch/LaunchForm/__stories__/LaunchForm.stories.tsx similarity index 97% rename from src/components/Launch/LaunchWorkflowForm/__stories__/LaunchWorkflowForm.stories.tsx rename to src/components/Launch/LaunchForm/__stories__/LaunchForm.stories.tsx index d7497d34b..0dd54b95e 100644 --- a/src/components/Launch/LaunchWorkflowForm/__stories__/LaunchWorkflowForm.stories.tsx +++ b/src/components/Launch/LaunchForm/__stories__/LaunchForm.stories.tsx @@ -30,7 +30,7 @@ import { simpleVariableDefaults, SimpleVariableKey } from '../__mocks__/mockInputs'; -import { LaunchWorkflowForm } from '../LaunchWorkflowForm'; +import { LaunchForm } from '../LaunchForm'; import { binaryInputName, errorInputName } from '../test/constants'; import { useExecutionLaunchConfiguration } from '../useExecutionLaunchConfiguration'; import { getWorkflowInputs } from '../utils'; @@ -124,7 +124,7 @@ const LaunchFormWithExecution: React.FC console.log('Close'); return ( - { execution={args.execution} /> ) : ( - @@ -158,7 +158,7 @@ const renderForm = (args: RenderFormArgs) => { ); }; -const stories = storiesOf('Launch/LaunchWorkflowForm', module); +const stories = storiesOf('Launch/LaunchForm/Workflow', module); stories.add('Simple', () => renderForm({ mocks: generateMocks(mockSimpleVariables) }) diff --git a/src/components/Launch/LaunchWorkflowForm/__stories__/WorkflowSelector.stories.tsx b/src/components/Launch/LaunchForm/__stories__/WorkflowSelector.stories.tsx similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/__stories__/WorkflowSelector.stories.tsx rename to src/components/Launch/LaunchForm/__stories__/WorkflowSelector.stories.tsx diff --git a/src/components/Launch/LaunchWorkflowForm/constants.ts b/src/components/Launch/LaunchForm/constants.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/constants.ts rename to src/components/Launch/LaunchForm/constants.ts diff --git a/src/components/Launch/LaunchWorkflowForm/getInputs.ts b/src/components/Launch/LaunchForm/getInputs.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/getInputs.ts rename to src/components/Launch/LaunchForm/getInputs.ts diff --git a/src/components/Launch/LaunchWorkflowForm/inputHelpers/blob.ts b/src/components/Launch/LaunchForm/inputHelpers/blob.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/inputHelpers/blob.ts rename to src/components/Launch/LaunchForm/inputHelpers/blob.ts diff --git a/src/components/Launch/LaunchWorkflowForm/inputHelpers/boolean.ts b/src/components/Launch/LaunchForm/inputHelpers/boolean.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/inputHelpers/boolean.ts rename to src/components/Launch/LaunchForm/inputHelpers/boolean.ts diff --git a/src/components/Launch/LaunchWorkflowForm/inputHelpers/collection.ts b/src/components/Launch/LaunchForm/inputHelpers/collection.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/inputHelpers/collection.ts rename to src/components/Launch/LaunchForm/inputHelpers/collection.ts diff --git a/src/components/Launch/LaunchWorkflowForm/inputHelpers/constants.ts b/src/components/Launch/LaunchForm/inputHelpers/constants.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/inputHelpers/constants.ts rename to src/components/Launch/LaunchForm/inputHelpers/constants.ts diff --git a/src/components/Launch/LaunchWorkflowForm/inputHelpers/datetime.ts b/src/components/Launch/LaunchForm/inputHelpers/datetime.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/inputHelpers/datetime.ts rename to src/components/Launch/LaunchForm/inputHelpers/datetime.ts diff --git a/src/components/Launch/LaunchWorkflowForm/inputHelpers/duration.ts b/src/components/Launch/LaunchForm/inputHelpers/duration.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/inputHelpers/duration.ts rename to src/components/Launch/LaunchForm/inputHelpers/duration.ts diff --git a/src/components/Launch/LaunchWorkflowForm/inputHelpers/float.ts b/src/components/Launch/LaunchForm/inputHelpers/float.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/inputHelpers/float.ts rename to src/components/Launch/LaunchForm/inputHelpers/float.ts diff --git a/src/components/Launch/LaunchWorkflowForm/inputHelpers/getHelperForInput.ts b/src/components/Launch/LaunchForm/inputHelpers/getHelperForInput.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/inputHelpers/getHelperForInput.ts rename to src/components/Launch/LaunchForm/inputHelpers/getHelperForInput.ts diff --git a/src/components/Launch/LaunchWorkflowForm/inputHelpers/inputHelpers.ts b/src/components/Launch/LaunchForm/inputHelpers/inputHelpers.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/inputHelpers/inputHelpers.ts rename to src/components/Launch/LaunchForm/inputHelpers/inputHelpers.ts diff --git a/src/components/Launch/LaunchWorkflowForm/inputHelpers/integer.ts b/src/components/Launch/LaunchForm/inputHelpers/integer.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/inputHelpers/integer.ts rename to src/components/Launch/LaunchForm/inputHelpers/integer.ts diff --git a/src/components/Launch/LaunchWorkflowForm/inputHelpers/none.ts b/src/components/Launch/LaunchForm/inputHelpers/none.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/inputHelpers/none.ts rename to src/components/Launch/LaunchForm/inputHelpers/none.ts diff --git a/src/components/Launch/LaunchWorkflowForm/inputHelpers/parseJson.ts b/src/components/Launch/LaunchForm/inputHelpers/parseJson.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/inputHelpers/parseJson.ts rename to src/components/Launch/LaunchForm/inputHelpers/parseJson.ts diff --git a/src/components/Launch/LaunchWorkflowForm/inputHelpers/string.ts b/src/components/Launch/LaunchForm/inputHelpers/string.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/inputHelpers/string.ts rename to src/components/Launch/LaunchForm/inputHelpers/string.ts diff --git a/src/components/Launch/LaunchWorkflowForm/inputHelpers/test/inputHelpers.test.ts b/src/components/Launch/LaunchForm/inputHelpers/test/inputHelpers.test.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/inputHelpers/test/inputHelpers.test.ts rename to src/components/Launch/LaunchForm/inputHelpers/test/inputHelpers.test.ts diff --git a/src/components/Launch/LaunchWorkflowForm/inputHelpers/test/testCases.ts b/src/components/Launch/LaunchForm/inputHelpers/test/testCases.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/inputHelpers/test/testCases.ts rename to src/components/Launch/LaunchForm/inputHelpers/test/testCases.ts diff --git a/src/components/Launch/LaunchWorkflowForm/inputHelpers/test/utils.test.ts b/src/components/Launch/LaunchForm/inputHelpers/test/utils.test.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/inputHelpers/test/utils.test.ts rename to src/components/Launch/LaunchForm/inputHelpers/test/utils.test.ts diff --git a/src/components/Launch/LaunchWorkflowForm/inputHelpers/types.ts b/src/components/Launch/LaunchForm/inputHelpers/types.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/inputHelpers/types.ts rename to src/components/Launch/LaunchForm/inputHelpers/types.ts diff --git a/src/components/Launch/LaunchWorkflowForm/inputHelpers/utils.ts b/src/components/Launch/LaunchForm/inputHelpers/utils.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/inputHelpers/utils.ts rename to src/components/Launch/LaunchForm/inputHelpers/utils.ts diff --git a/src/components/Launch/LaunchWorkflowForm/inputValueCache.ts b/src/components/Launch/LaunchForm/inputValueCache.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/inputValueCache.ts rename to src/components/Launch/LaunchForm/inputValueCache.ts diff --git a/src/components/Launch/LaunchWorkflowForm/launchMachine.ts b/src/components/Launch/LaunchForm/launchMachine.ts similarity index 60% rename from src/components/Launch/LaunchWorkflowForm/launchMachine.ts rename to src/components/Launch/LaunchForm/launchMachine.ts index cd78e1eb9..4ef623961 100644 --- a/src/components/Launch/LaunchWorkflowForm/launchMachine.ts +++ b/src/components/Launch/LaunchForm/launchMachine.ts @@ -6,7 +6,14 @@ import { WorkflowExecutionIdentifier, WorkflowId } from 'models'; -import { assign, DoneInvokeEvent, Machine, MachineConfig } from 'xstate'; +import { + assign, + DoneInvokeEvent, + Machine, + MachineConfig, + MachineOptions, + StatesConfig +} from 'xstate'; import { LiteralValueMap, ParsedInput } from './types'; export type SelectWorkflowVersionEvent = { @@ -33,37 +40,49 @@ export type InputsParsedEvent = DoneInvokeEvent<{ }>; export type ErrorEvent = DoneInvokeEvent; -export type LaunchEvent = +export type BaseLaunchEvent = | { type: 'CANCEL' } | { type: 'SUBMIT' } | { type: 'RETRY' } - | SelectWorkflowVersionEvent - | WorkflowVersionOptionsLoadedEvent - | LaunchPlanOptionsLoadedEvent - | TaskVersionOptionsLoadedEvent | InputsParsedEvent | SelectTaskVersionEvent | SelectLaunchPlanEvent | ExecutionCreatedEvent | ErrorEvent; -export interface LaunchContext { +export type TaskLaunchEvent = + | BaseLaunchEvent + | TaskVersionOptionsLoadedEvent + | SelectTaskVersionEvent; + +export type WorkflowLaunchEvent = + | BaseLaunchEvent + | SelectWorkflowVersionEvent + | WorkflowVersionOptionsLoadedEvent + | LaunchPlanOptionsLoadedEvent; + +export interface BaseLaunchContext { defaultInputValues?: LiteralValueMap; - launchPlan?: LaunchPlan; - launchPlanOptions?: LaunchPlan[]; parsedInputs: ParsedInput[]; resultExecutionId?: WorkflowExecutionIdentifier; - sourceType: 'workflow' | 'task'; - sourceWorkflowId?: NamedEntityIdentifier; - sourceTaskId?: NamedEntityIdentifier; + sourceId?: NamedEntityIdentifier; error?: Error; + showErrors: boolean; + unsupportedRequiredInputs: ParsedInput[]; +} + +export interface WorkflowLaunchContext extends BaseLaunchContext { + launchPlan?: LaunchPlan; + launchPlanOptions?: LaunchPlan[]; preferredLaunchPlanId?: Identifier; preferredWorkflowId?: Identifier; workflowVersion?: WorkflowId; workflowVersionOptions?: Workflow[]; +} + +export interface TaskLaunchContext extends BaseLaunchContext { taskVersion?: Identifier; taskVersionOptions?: Identifier[]; - unsupportedRequiredInputs: ParsedInput[]; } export enum LaunchState { @@ -90,19 +109,9 @@ export enum LaunchState { SUBMIT_SUCCEEDED = 'SUBMIT_SUCCEEDED' } -export interface LaunchSchema { +interface BaseLaunchSchema { states: { [LaunchState.CANCELLED]: {}; - [LaunchState.SELECT_SOURCE]: {}; - [LaunchState.LOADING_WORKFLOW_VERSIONS]: {}; - [LaunchState.FAILED_LOADING_WORKFLOW_VERSIONS]: {}; - [LaunchState.SELECT_WORKFLOW_VERSION]: {}; - [LaunchState.LOADING_LAUNCH_PLANS]: {}; - [LaunchState.FAILED_LOADING_LAUNCH_PLANS]: {}; - [LaunchState.SELECT_LAUNCH_PLAN]: {}; - [LaunchState.LOADING_TASK_VERSIONS]: {}; - [LaunchState.FAILED_LOADING_TASK_VERSIONS]: {}; - [LaunchState.SELECT_TASK_VERSION]: {}; [LaunchState.LOADING_INPUTS]: {}; [LaunchState.FAILED_LOADING_INPUTS]: {}; [LaunchState.UNSUPPORTED_INPUTS]: {}; @@ -116,32 +125,35 @@ export interface LaunchSchema { }; } +interface TaskLaunchSchema extends BaseLaunchSchema { + states: BaseLaunchSchema['states'] & { + [LaunchState.LOADING_TASK_VERSIONS]: {}; + [LaunchState.FAILED_LOADING_TASK_VERSIONS]: {}; + [LaunchState.SELECT_TASK_VERSION]: {}; + }; +} +interface WorkflowLaunchSchema extends BaseLaunchSchema { + states: BaseLaunchSchema['states'] & { + [LaunchState.LOADING_WORKFLOW_VERSIONS]: {}; + [LaunchState.FAILED_LOADING_WORKFLOW_VERSIONS]: {}; + [LaunchState.SELECT_WORKFLOW_VERSION]: {}; + [LaunchState.LOADING_LAUNCH_PLANS]: {}; + [LaunchState.FAILED_LOADING_LAUNCH_PLANS]: {}; + [LaunchState.SELECT_LAUNCH_PLAN]: {}; + }; +} + /** Typestates to narrow down the `context` values based on the result of * a `state.matches` check. */ -export type LaunchTypestate = +export type BaseLaunchTypestate = | { value: LaunchState; - context: LaunchContext; - } - | { - value: LaunchState.SELECT_WORKFLOW_VERSION; - context: LaunchContext & { - sourceWorkflowId: WorkflowId; - workflowVersionOptions: Workflow[]; - }; - } - | { - value: LaunchState.SELECT_LAUNCH_PLAN; - context: LaunchContext & { - launchPlanOptions: LaunchPlan[]; - sourceWorkflowId: WorkflowId; - workflowVersionOptions: Workflow[]; - }; + context: BaseLaunchContext; } | { value: LaunchState.UNSUPPORTED_INPUTS; - context: LaunchContext & { + context: BaseLaunchContext & { parsedInputs: []; unsupportedRequiredInputs: []; }; @@ -154,19 +166,19 @@ export type LaunchTypestate = | LaunchState.SUBMIT_VALIDATING | LaunchState.SUBMITTING | LaunchState.SUBMIT_SUCCEEDED; - context: LaunchContext & { + context: BaseLaunchContext & { parsedInputs: []; }; } | { value: LaunchState.SUBMIT_SUCCEEDED; - context: LaunchContext & { + context: BaseLaunchContext & { resultExecutionId: WorkflowExecutionIdentifier; }; } | { value: LaunchState.SUBMIT_FAILED; - context: LaunchContext & { + context: BaseLaunchContext & { parsedInputs: ParsedInput[]; error: Error; }; @@ -177,72 +189,201 @@ export type LaunchTypestate = | LaunchState.FAILED_LOADING_LAUNCH_PLANS | LaunchState.FAILED_LOADING_TASK_VERSIONS | LaunchState.FAILED_LOADING_WORKFLOW_VERSIONS; - context: LaunchContext & { + context: BaseLaunchContext & { error: Error; }; }; -/** A state machine config representing the flow a user takes through the Launch form. - * The high-level steps are: - * 1. Choose a source type. This is usually done automatically by specifying the - * source type in context when interpreting the machine. - * 2. Select the relevant parameters for the source (version/launch plan for a Workflow source) - * 3. Enter inputs - * 4. Submit - * 5. Optionally correct any validation errors and re-submit. - */ -export const launchMachineConfig: MachineConfig< - LaunchContext, - LaunchSchema, - LaunchEvent +export type WorkflowLaunchTypestate = + | BaseLaunchTypestate + | { + value: LaunchState.SELECT_WORKFLOW_VERSION; + context: WorkflowLaunchContext & { + sourceId: WorkflowId; + workflowVersionOptions: Workflow[]; + }; + } + | { + value: LaunchState.SELECT_LAUNCH_PLAN; + context: WorkflowLaunchContext & { + launchPlanOptions: LaunchPlan[]; + sourceId: WorkflowId; + workflowVersionOptions: Workflow[]; + }; + }; + +// TODO: +export type TaskLaunchTypestate = BaseLaunchTypestate; + +const defaultBaseContext: BaseLaunchContext = { + parsedInputs: [], + showErrors: false, + unsupportedRequiredInputs: [] +}; + +const defaultHandlers = { + CANCEL: LaunchState.CANCELLED +}; + +const baseStateConfig: StatesConfig< + BaseLaunchContext, + BaseLaunchSchema, + BaseLaunchEvent > = { - id: 'launch', - initial: LaunchState.SELECT_SOURCE, - context: { - parsedInputs: [], - // Defaults to workflow, can be overidden when interpreting the machine - sourceType: 'workflow', - unsupportedRequiredInputs: [] + [LaunchState.CANCELLED]: { + type: 'final' + }, + [LaunchState.LOADING_INPUTS]: { + entry: ['hideErrors'], + invoke: { + src: 'loadInputs', + onDone: { + target: LaunchState.ENTER_INPUTS, + actions: ['setInputs'] + }, + onError: { + target: LaunchState.FAILED_LOADING_INPUTS, + actions: ['setError'] + } + } + }, + [LaunchState.FAILED_LOADING_INPUTS]: { + on: { + RETRY: LaunchState.LOADING_INPUTS + } + }, + [LaunchState.UNSUPPORTED_INPUTS]: { + // events handled at top level + }, + [LaunchState.ENTER_INPUTS]: { + always: { + target: LaunchState.UNSUPPORTED_INPUTS, + cond: ({ unsupportedRequiredInputs }) => + unsupportedRequiredInputs.length > 0 + }, + on: { + SUBMIT: LaunchState.SUBMIT_VALIDATING, + VALIDATE: LaunchState.VALIDATING_INPUTS + } + }, + [LaunchState.VALIDATING_INPUTS]: { + invoke: { + src: 'validate', + onDone: LaunchState.ENTER_INPUTS, + onError: LaunchState.INVALID_INPUTS + } + }, + [LaunchState.INVALID_INPUTS]: { + on: { + VALIDATE: LaunchState.VALIDATING_INPUTS + } + }, + [LaunchState.SUBMIT_VALIDATING]: { + entry: ['showErrors'], + invoke: { + src: 'validate', + onDone: { + target: LaunchState.SUBMITTING + }, + onError: { + target: LaunchState.INVALID_INPUTS, + actions: ['setError'] + } + } + }, + [LaunchState.SUBMITTING]: { + invoke: { + src: 'submit', + onDone: { + target: LaunchState.SUBMIT_SUCCEEDED, + actions: ['setExecutionId'] + }, + onError: { + target: LaunchState.SUBMIT_FAILED, + actions: ['setError'] + } + } + }, + [LaunchState.SUBMIT_FAILED]: { + on: { + SUBMIT: LaunchState.SUBMITTING + } }, + [LaunchState.SUBMIT_SUCCEEDED]: { + type: 'final' + } +}; + +export const taskLaunchMachineConfig: MachineConfig< + TaskLaunchContext, + TaskLaunchSchema, + TaskLaunchEvent +> = { + id: 'launchTask', + context: { ...defaultBaseContext }, + initial: LaunchState.LOADING_TASK_VERSIONS, on: { - CANCEL: LaunchState.CANCELLED, + ...defaultHandlers, + SELECT_TASK_VERSION: { + target: LaunchState.LOADING_INPUTS, + actions: ['setTaskVersion'] + } + }, + states: { + ...(baseStateConfig as StatesConfig< + TaskLaunchContext, + TaskLaunchSchema, + TaskLaunchEvent + >), + [LaunchState.LOADING_TASK_VERSIONS]: { + invoke: { + src: 'loadTaskVersions', + onDone: { + target: LaunchState.SELECT_TASK_VERSION, + actions: ['setTaskVersionOptions'] + }, + onError: { + target: LaunchState.FAILED_LOADING_TASK_VERSIONS, + actions: ['setError'] + } + } + }, + [LaunchState.FAILED_LOADING_TASK_VERSIONS]: { + on: { + RETRY: LaunchState.LOADING_TASK_VERSIONS + } + }, + [LaunchState.SELECT_TASK_VERSION]: { + // events handled at top level + } + } +}; + +export const workflowLaunchMachineConfig: MachineConfig< + WorkflowLaunchContext, + WorkflowLaunchSchema, + WorkflowLaunchEvent +> = { + id: 'launchWorkflow', + context: { ...defaultBaseContext }, + initial: LaunchState.LOADING_WORKFLOW_VERSIONS, + on: { + ...defaultHandlers, SELECT_WORKFLOW_VERSION: { - cond: { type: 'isWorkflowSource' }, target: LaunchState.LOADING_LAUNCH_PLANS, actions: ['setWorkflowVersion'] }, SELECT_LAUNCH_PLAN: { - cond: { type: 'isWorkflowSource' }, target: LaunchState.LOADING_INPUTS, actions: ['setLaunchPlan'] - }, - SELECT_TASK_VERSION: { - cond: { type: 'isTaskSource' }, - target: LaunchState.LOADING_INPUTS, - actions: ['setTaskVersion'] } }, states: { - [LaunchState.CANCELLED]: { - type: 'final' - }, - [LaunchState.SELECT_SOURCE]: { - // Automatically transition to the proper sub-state based - // on what type of source was specified when interpreting - // the machine. - always: [ - { - target: LaunchState.LOADING_WORKFLOW_VERSIONS, - cond: ({ sourceType, sourceWorkflowId }) => - sourceType === 'workflow' && !!sourceWorkflowId - }, - { - target: LaunchState.LOADING_TASK_VERSIONS, - cond: ({ sourceType, sourceTaskId }) => - sourceType === 'task' && !!sourceTaskId - } - ] - }, + ...(baseStateConfig as StatesConfig< + WorkflowLaunchContext, + WorkflowLaunchSchema, + WorkflowLaunchEvent + >), [LaunchState.LOADING_WORKFLOW_VERSIONS]: { invoke: { src: 'loadWorkflowVersions', @@ -284,165 +425,83 @@ export const launchMachineConfig: MachineConfig< }, [LaunchState.SELECT_LAUNCH_PLAN]: { // events handled at top level - }, - [LaunchState.LOADING_TASK_VERSIONS]: { - invoke: { - src: 'loadTaskVersions', - onDone: { - target: LaunchState.SELECT_TASK_VERSION, - actions: ['setTaskVersionOptions'] - }, - onError: { - target: LaunchState.FAILED_LOADING_TASK_VERSIONS, - actions: ['setError'] - } - } - }, - [LaunchState.FAILED_LOADING_TASK_VERSIONS]: { - on: { - RETRY: LaunchState.LOADING_TASK_VERSIONS - } - }, - [LaunchState.SELECT_TASK_VERSION]: { - // events handled at top level - }, - [LaunchState.LOADING_INPUTS]: { - invoke: { - src: 'loadInputs', - onDone: { - target: LaunchState.ENTER_INPUTS, - actions: ['setInputs'] - }, - onError: { - target: LaunchState.FAILED_LOADING_INPUTS, - actions: ['setError'] - } - } - }, - [LaunchState.FAILED_LOADING_INPUTS]: { - on: { - RETRY: LaunchState.LOADING_INPUTS - } - }, - [LaunchState.UNSUPPORTED_INPUTS]: { - // events handled at top level - }, - [LaunchState.ENTER_INPUTS]: { - always: { - target: LaunchState.UNSUPPORTED_INPUTS, - cond: ({ unsupportedRequiredInputs }) => - unsupportedRequiredInputs.length > 0 - }, - on: { - SUBMIT: LaunchState.SUBMIT_VALIDATING, - VALIDATE: LaunchState.VALIDATING_INPUTS - } - }, - [LaunchState.VALIDATING_INPUTS]: { - invoke: { - src: 'validate', - onDone: LaunchState.ENTER_INPUTS, - onError: LaunchState.INVALID_INPUTS - } - }, - [LaunchState.INVALID_INPUTS]: { - on: { - VALIDATE: LaunchState.VALIDATING_INPUTS - } - }, - [LaunchState.SUBMIT_VALIDATING]: { - invoke: { - src: 'validate', - onDone: { - target: LaunchState.SUBMITTING - }, - onError: { - target: LaunchState.INVALID_INPUTS, - actions: ['setError'] - } - } - }, - [LaunchState.SUBMITTING]: { - invoke: { - src: 'submit', - onDone: { - target: LaunchState.SUBMIT_SUCCEEDED, - actions: ['setExecutionId'] - }, - onError: { - target: LaunchState.SUBMIT_FAILED, - actions: ['setError'] - } - } - }, - [LaunchState.SUBMIT_FAILED]: { - on: { - SUBMIT: LaunchState.SUBMITTING - } - }, - [LaunchState.SUBMIT_SUCCEEDED]: { - type: 'final' } } }; +type BaseMachineOptions = MachineOptions; + +const baseActions: BaseMachineOptions['actions'] = { + hideErrors: assign(_ => ({ showErrors: false })), + setExecutionId: assign((_, event) => ({ + resultExecutionId: (event as ExecutionCreatedEvent).data + })), + setInputs: assign((_, event) => { + const { + parsedInputs, + unsupportedRequiredInputs + } = (event as InputsParsedEvent).data; + return { + parsedInputs, + unsupportedRequiredInputs + }; + }), + setError: assign((_, event) => ({ + error: (event as ErrorEvent).data + })), + showErrors: assign(_ => ({ showErrors: true })) +}; + +const baseServices: BaseMachineOptions['services'] = { + loadInputs: () => + Promise.reject('No `loadInputs` service has been provided'), + submit: () => Promise.reject('No `submit` service has been provided'), + validate: () => Promise.reject('No `validate` service has been provided') +}; + +export const taskLaunchMachine = Machine(taskLaunchMachineConfig, { + actions: { + ...baseActions, + setTaskVersion: assign((_, event) => ({ + taskVersion: (event as SelectTaskVersionEvent).taskId + })), + setTaskVersionOptions: assign((_, event) => ({ + taskVersionOptions: (event as TaskVersionOptionsLoadedEvent).data + })) + }, + services: { + ...baseServices, + loadTaskVersions: () => + Promise.reject('No `loadTaskVersions` service has been provided') + } +}); + /** A full machine for representing the Launch flow, combining the state definitions * with actions/guards/services needed to support them. */ -export const launchMachine = Machine(launchMachineConfig, { +export const workflowLaunchMachine = Machine(workflowLaunchMachineConfig, { actions: { + ...baseActions, setWorkflowVersion: assign((_, event) => ({ workflowVersion: (event as SelectWorkflowVersionEvent).workflowId })), setWorkflowVersionOptions: assign((_, event) => ({ workflowVersionOptions: (event as DoneInvokeEvent).data })), - setTaskVersion: assign((_, event) => ({ - taskVersion: (event as SelectTaskVersionEvent).taskId - })), - setTaskVersionOptions: assign((_, event) => ({ - taskVersionOptions: (event as TaskVersionOptionsLoadedEvent).data - })), setLaunchPlanOptions: assign((_, event) => ({ launchPlanOptions: (event as LaunchPlanOptionsLoadedEvent).data })), setLaunchPlan: assign((_, event) => ({ launchPlan: (event as SelectLaunchPlanEvent).launchPlan - })), - setExecutionId: assign((_, event) => ({ - resultExecutionId: (event as ExecutionCreatedEvent).data - })), - setInputs: assign((_, event) => { - const { - parsedInputs, - unsupportedRequiredInputs - } = (event as InputsParsedEvent).data; - return { - parsedInputs, - unsupportedRequiredInputs - }; - }), - setError: assign((_, event) => ({ - error: (event as ErrorEvent).data })) }, - guards: { - isTaskSource: ({ sourceType }) => sourceType === 'task', - isWorkflowSource: ({ sourceType }) => sourceType === 'workflow' - }, services: { + ...baseServices, loadWorkflowVersions: () => Promise.reject( 'No `loadWorkflowVersions` service has been provided' ), loadLaunchPlans: () => - Promise.reject('No `loadLaunchPlans` service has been provided'), - loadTaskVersions: () => - Promise.reject('No `loadTaskVersions` service has been provided'), - loadInputs: () => - Promise.reject('No `loadInputs` service has been provided'), - submit: () => Promise.reject('No `submit` service has been provided'), - validate: () => - Promise.reject('No `validate` service has been provided') + Promise.reject('No `loadLaunchPlans` service has been provided') } }); diff --git a/src/components/Launch/LaunchWorkflowForm/styles.ts b/src/components/Launch/LaunchForm/styles.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/styles.ts rename to src/components/Launch/LaunchForm/styles.ts diff --git a/src/components/Launch/LaunchWorkflowForm/test/LaunchWorkflowForm.test.tsx b/src/components/Launch/LaunchForm/test/LaunchForm.test.tsx similarity index 97% rename from src/components/Launch/LaunchWorkflowForm/test/LaunchWorkflowForm.test.tsx rename to src/components/Launch/LaunchForm/test/LaunchForm.test.tsx index ebee69435..29d3d1d07 100644 --- a/src/components/Launch/LaunchWorkflowForm/test/LaunchWorkflowForm.test.tsx +++ b/src/components/Launch/LaunchForm/test/LaunchForm.test.tsx @@ -41,8 +41,11 @@ import { formStrings, requiredInputSuffix } from '../constants'; -import { LaunchWorkflowForm } from '../LaunchWorkflowForm'; -import { InitialLaunchParameters, LaunchWorkflowFormProps } from '../types'; +import { LaunchForm } from '../LaunchForm'; +import { + InitialWorkflowLaunchParameters, + LaunchWorkflowFormProps +} from '../types'; import { createInputCacheKey, getInputDefintionForLiteralType } from '../utils'; import { binaryInputName, @@ -165,7 +168,7 @@ describe('LaunchWorkflowForm', () => { listWorkflows: mockListWorkflows })} > - { describe('When using initial parameters', () => { it('should prefer the provided workflow version', async () => { - const initialParameters: InitialLaunchParameters = { + const initialParameters: InitialWorkflowLaunchParameters = { workflow: mockWorkflowVersions[2].id }; const { getByLabelText } = renderForm({ initialParameters }); @@ -511,7 +514,7 @@ describe('LaunchWorkflowForm', () => { }); it('should only include one instance of the preferred version in the selector', async () => { - const initialParameters: InitialLaunchParameters = { + const initialParameters: InitialWorkflowLaunchParameters = { workflow: mockWorkflowVersions[2].id }; const { getByTitle } = renderForm({ initialParameters }); @@ -548,7 +551,7 @@ describe('LaunchWorkflowForm', () => { } ); const baseId = mockWorkflowVersions[2].id; - const initialParameters: InitialLaunchParameters = { + const initialParameters: InitialWorkflowLaunchParameters = { workflow: { ...baseId, version: 'nonexistentValue' } }; const { getByLabelText } = renderForm({ initialParameters }); @@ -559,7 +562,7 @@ describe('LaunchWorkflowForm', () => { }); it('should prefer the provided launch plan', async () => { - const initialParameters: InitialLaunchParameters = { + const initialParameters: InitialWorkflowLaunchParameters = { launchPlan: mockLaunchPlans[1].id }; const { getByLabelText } = renderForm({ initialParameters }); @@ -570,7 +573,7 @@ describe('LaunchWorkflowForm', () => { }); it('should only include one instance of the preferred launch plan in the selector', async () => { - const initialParameters: InitialLaunchParameters = { + const initialParameters: InitialWorkflowLaunchParameters = { launchPlan: mockLaunchPlans[1].id }; const { getByTitle } = renderForm({ initialParameters }); @@ -606,7 +609,7 @@ describe('LaunchWorkflowForm', () => { ); const launchPlanId = { ...mockLaunchPlans[1].id }; launchPlanId.name = 'InvalidLauchPlan'; - const initialParameters: InitialLaunchParameters = { + const initialParameters: InitialWorkflowLaunchParameters = { launchPlan: launchPlanId }; const { getByLabelText } = renderForm({ initialParameters }); @@ -673,7 +676,7 @@ describe('LaunchWorkflowForm', () => { it('loads preferred workflow version when it does not exist in the list of suggestions', async () => { const missingWorkflow = mockWorkflowVersions[0]; missingWorkflow.id.version = 'missingVersionString'; - const initialParameters: InitialLaunchParameters = { + const initialParameters: InitialWorkflowLaunchParameters = { workflow: missingWorkflow.id }; const { getByLabelText } = renderForm({ initialParameters }); @@ -686,7 +689,7 @@ describe('LaunchWorkflowForm', () => { it('loads the preferred launch plan when it does not exist in the list of suggestions', async () => { const missingLaunchPlan = mockLaunchPlans[0]; missingLaunchPlan.id.name = 'missingLaunchPlanName'; - const initialParameters: InitialLaunchParameters = { + const initialParameters: InitialWorkflowLaunchParameters = { launchPlan: missingLaunchPlan.id }; const { getByLabelText } = renderForm({ initialParameters }); @@ -720,7 +723,7 @@ describe('LaunchWorkflowForm', () => { }); it('should correctly render workflow version search results', async () => { - const initialParameters: InitialLaunchParameters = { + const initialParameters: InitialWorkflowLaunchParameters = { workflow: mockWorkflowVersions[2].id }; const inputString = mockWorkflowVersions[1].id.version.substring( diff --git a/src/components/Launch/LaunchWorkflowForm/test/constants.ts b/src/components/Launch/LaunchForm/test/constants.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/test/constants.ts rename to src/components/Launch/LaunchForm/test/constants.ts diff --git a/src/components/Launch/LaunchWorkflowForm/test/getInputs.test.ts b/src/components/Launch/LaunchForm/test/getInputs.test.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/test/getInputs.test.ts rename to src/components/Launch/LaunchForm/test/getInputs.test.ts diff --git a/src/components/Launch/LaunchWorkflowForm/test/utils.ts b/src/components/Launch/LaunchForm/test/utils.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/test/utils.ts rename to src/components/Launch/LaunchForm/test/utils.ts diff --git a/src/components/Launch/LaunchWorkflowForm/types.ts b/src/components/Launch/LaunchForm/types.ts similarity index 54% rename from src/components/Launch/LaunchWorkflowForm/types.ts rename to src/components/Launch/LaunchForm/types.ts index b4c5ecf19..0d58e80de 100644 --- a/src/components/Launch/LaunchWorkflowForm/types.ts +++ b/src/components/Launch/LaunchForm/types.ts @@ -6,26 +6,77 @@ import { NamedEntityIdentifier, WorkflowId } from 'models'; -import { State } from 'xstate'; -import { LaunchContext, LaunchEvent, LaunchTypestate } from './launchMachine'; +import { Interpreter, State } from 'xstate'; +import { + BaseLaunchContext, + BaseLaunchEvent, + BaseLaunchTypestate, + TaskLaunchContext, + TaskLaunchEvent, + TaskLaunchTypestate, + WorkflowLaunchContext, + WorkflowLaunchEvent, + WorkflowLaunchTypestate +} from './launchMachine'; import { SearchableSelectorOption } from './SearchableSelector'; export type InputValueMap = Map; export type LiteralValueMap = Map; -export interface InitialLaunchParameters { +export type BaseInterpretedLaunchState = State< + BaseLaunchContext, + BaseLaunchEvent, + any, + BaseLaunchTypestate +>; + +export type BaseLaunchService = Interpreter< + BaseLaunchContext, + any, + BaseLaunchEvent, + BaseLaunchTypestate +>; + +export interface BaseLaunchFormProps { + onClose(): void; +} + +export interface BaseInitialLaunchParameters { + values?: LiteralValueMap; +} + +export interface WorkflowInitialLaunchParameters + extends BaseInitialLaunchParameters { launchPlan?: Identifier; workflow?: WorkflowId; - values?: LiteralValueMap; +} +export interface LaunchWorkflowFormProps extends BaseLaunchFormProps { + workflowId: NamedEntityIdentifier; + initialParameters?: InitialWorkflowLaunchParameters; } +export interface TaskInitialLaunchParameters + extends BaseInitialLaunchParameters { + taskId?: NamedEntityIdentifier; +} +export interface LaunchTaskFormProps extends BaseLaunchFormProps { + taskId: NamedEntityIdentifier; + initialParameters?: InitialWorkflowLaunchParameters; +} + +export type LaunchFormProps = LaunchWorkflowFormProps | LaunchTaskFormProps; + +export interface InitialWorkflowLaunchParameters { + launchPlan?: Identifier; + workflow?: WorkflowId; + values?: LiteralValueMap; +} export interface LaunchWorkflowFormProps { workflowId: NamedEntityIdentifier; - initialParameters?: InitialLaunchParameters; - onClose(): void; + initialParameters?: InitialWorkflowLaunchParameters; } -export interface LaunchWorkflowFormInputsRef { +export interface LaunchFormInputsRef { getValues(): Record; validate(): boolean; } @@ -45,15 +96,33 @@ export interface WorkflowSourceSelectorState { } export interface LaunchWorkflowFormState { - /** Used to key inputs component so it is re-mounted the list of inputs */ - formKey?: string; - formInputsRef: React.RefObject; - inputValueCache: InputValueMap; - showErrors: boolean; - state: State; + formInputsRef: React.RefObject; + state: State< + WorkflowLaunchContext, + WorkflowLaunchEvent, + any, + WorkflowLaunchTypestate + >; + service: Interpreter< + WorkflowLaunchContext, + any, + WorkflowLaunchEvent, + WorkflowLaunchTypestate + >; workflowSourceSelectorState: WorkflowSourceSelectorState; - onCancel(): void; - onSubmit(): void; +} + +export interface LaunchTaskFormState { + formInputsRef: React.RefObject; + state: State; + service: Interpreter< + TaskLaunchContext, + any, + TaskLaunchEvent, + TaskLaunchTypestate + >; + // TODO: + // taskSourceSelectorState: any; } export enum InputType { diff --git a/src/components/Launch/LaunchWorkflowForm/useExecutionLaunchConfiguration.ts b/src/components/Launch/LaunchForm/useExecutionLaunchConfiguration.ts similarity index 85% rename from src/components/Launch/LaunchWorkflowForm/useExecutionLaunchConfiguration.ts rename to src/components/Launch/LaunchForm/useExecutionLaunchConfiguration.ts index 6ebf81d62..374fdf2c3 100644 --- a/src/components/Launch/LaunchWorkflowForm/useExecutionLaunchConfiguration.ts +++ b/src/components/Launch/LaunchForm/useExecutionLaunchConfiguration.ts @@ -3,7 +3,7 @@ import { useAPIContext } from 'components/data/apiContext'; import { fetchWorkflowExecutionInputs } from 'components/Executions/useWorkflowExecution'; import { FetchableData, useFetchableData } from 'components/hooks'; import { Execution, Variable } from 'models'; -import { InitialLaunchParameters, LiteralValueMap } from './types'; +import { InitialWorkflowLaunchParameters, LiteralValueMap } from './types'; import { createInputCacheKey, getInputDefintionForLiteralType } from './utils'; export interface UseExecutionLaunchConfigurationArgs { @@ -11,21 +11,22 @@ export interface UseExecutionLaunchConfigurationArgs { workflowInputs: Record; } -/** Returns a fetchable that will result in a `InitialLaunchParameters` object based on a provided `Execution` and its associated workflow inputs. */ +/** Returns a fetchable that will result in a `InitialWorkflowLaunchParameters` + * object based on a provided `Execution` and its associated workflow inputs. */ export function useExecutionLaunchConfiguration({ execution, workflowInputs }: UseExecutionLaunchConfigurationArgs): FetchableData< - InitialLaunchParameters + InitialWorkflowLaunchParameters > { const apiContext = useAPIContext(); return useFetchableData< - InitialLaunchParameters, + InitialWorkflowLaunchParameters, UseExecutionLaunchConfigurationArgs >( { debugName: 'ExecutionLaunchConfiguration', - defaultValue: {} as InitialLaunchParameters, + defaultValue: {} as InitialWorkflowLaunchParameters, doFetch: async ({ execution, workflowInputs }) => { const { closure: { workflowId }, diff --git a/src/components/Launch/LaunchWorkflowForm/useFormInputsState.ts b/src/components/Launch/LaunchForm/useFormInputsState.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/useFormInputsState.ts rename to src/components/Launch/LaunchForm/useFormInputsState.ts diff --git a/src/components/Launch/LaunchWorkflowForm/useLaunchWorkflowFormState.ts b/src/components/Launch/LaunchForm/useLaunchWorkflowFormState.ts similarity index 79% rename from src/components/Launch/LaunchWorkflowForm/useLaunchWorkflowFormState.ts rename to src/components/Launch/LaunchForm/useLaunchWorkflowFormState.ts index 3e592fb0a..f4f0019de 100644 --- a/src/components/Launch/LaunchWorkflowForm/useLaunchWorkflowFormState.ts +++ b/src/components/Launch/LaunchForm/useLaunchWorkflowFormState.ts @@ -1,5 +1,4 @@ import { useMachine } from '@xstate/react'; -import { getCacheKey } from 'components/Cache'; import { defaultStateMachineConfig } from 'components/common/constants'; import { APIContextValue, useAPIContext } from 'components/data/apiContext'; import { isEqual, partial, uniqBy } from 'lodash'; @@ -12,20 +11,17 @@ import { WorkflowId, workflowSortFields } from 'models'; -import { RefObject, useEffect, useMemo, useRef, useState } from 'react'; -import { history } from 'routes/history'; -import { Routes } from 'routes/routes'; +import { RefObject, useEffect, useMemo, useRef } from 'react'; import { getInputs } from './getInputs'; -import { createInputValueCache } from './inputValueCache'; import { - LaunchContext, - LaunchEvent, - launchMachine, LaunchState, - LaunchTypestate + WorkflowLaunchContext, + WorkflowLaunchEvent, + workflowLaunchMachine, + WorkflowLaunchTypestate } from './launchMachine'; import { - LaunchWorkflowFormInputsRef, + LaunchFormInputsRef, LaunchWorkflowFormProps, LaunchWorkflowFormState, ParsedInput @@ -35,7 +31,7 @@ import { getUnsupportedRequiredInputs } from './utils'; async function loadLaunchPlans( { listLaunchPlans }: APIContextValue, - { preferredLaunchPlanId, workflowVersion }: LaunchContext + { preferredLaunchPlanId, workflowVersion }: WorkflowLaunchContext ) { if (workflowVersion == null) { return Promise.reject('No workflowVersion specified'); @@ -91,7 +87,7 @@ async function loadLaunchPlans( async function loadWorkflowVersions( { listWorkflows }: APIContextValue, - { preferredWorkflowId, sourceWorkflowId: sourceWorkflowName }: LaunchContext + { preferredWorkflowId, sourceId: sourceWorkflowName }: WorkflowLaunchContext ) { if (!sourceWorkflowName) { throw new Error('Cannot load workflows, missing workflowName'); @@ -138,7 +134,7 @@ async function loadWorkflowVersions( async function loadInputs( { getWorkflow }: APIContextValue, - { defaultInputValues, workflowVersion, launchPlan }: LaunchContext + { defaultInputValues, workflowVersion, launchPlan }: WorkflowLaunchContext ) { if (!workflowVersion) { throw new Error('Failed to load inputs: missing workflowVersion'); @@ -159,9 +155,10 @@ async function loadInputs( }; } +// TODO: Can be shared between both types of launch form. async function validate( - formInputsRef: RefObject, - {}: LaunchContext + formInputsRef: RefObject, + {}: WorkflowLaunchContext ) { if (formInputsRef.current === null) { throw new Error('Unexpected empty form inputs ref'); @@ -169,15 +166,15 @@ async function validate( if (!formInputsRef.current.validate()) { throw new Error( - 'Some inputs have errors. Please correct them before submitting' + 'Some inputs have errors. Please correct them before submitting.' ); } } async function submit( { createWorkflowExecution }: APIContextValue, - formInputsRef: RefObject, - { launchPlan, workflowVersion }: LaunchContext + formInputsRef: RefObject, + { launchPlan, workflowVersion }: WorkflowLaunchContext ) { if (!launchPlan) { throw new Error('Attempting to launch with no LaunchPlan'); @@ -208,7 +205,7 @@ async function submit( function getServices( apiContext: APIContextValue, - formInputsRef: RefObject + formInputsRef: RefObject ) { return { loadWorkflowVersions: partial(loadWorkflowVersions, apiContext), @@ -224,8 +221,7 @@ function getServices( */ export function useLaunchWorkflowFormState({ initialParameters = {}, - onClose, - workflowId: sourceWorkflowId + workflowId: sourceId }: LaunchWorkflowFormProps): LaunchWorkflowFormState { // These values will be used to auto-select items from the workflow // version/launch plan drop downs. @@ -236,9 +232,7 @@ export function useLaunchWorkflowFormState({ } = initialParameters; const apiContext = useAPIContext(); - const [inputValueCache] = useState(createInputValueCache()); - const formInputsRef = useRef(null); - const [showErrors, setShowErrors] = useState(false); + const formInputsRef = useRef(null); const services = useMemo(() => getServices(apiContext, formInputsRef), [ apiContext, @@ -246,18 +240,17 @@ export function useLaunchWorkflowFormState({ ]); const [state, sendEvent, service] = useMachine< - LaunchContext, - LaunchEvent, - LaunchTypestate - >(launchMachine, { + WorkflowLaunchContext, + WorkflowLaunchEvent, + WorkflowLaunchTypestate + >(workflowLaunchMachine, { ...defaultStateMachineConfig, services, context: { defaultInputValues, preferredLaunchPlanId, preferredWorkflowId, - sourceWorkflowId, - sourceType: 'workflow' + sourceId } }); @@ -265,22 +258,9 @@ export function useLaunchWorkflowFormState({ launchPlanOptions = [], launchPlan, workflowVersionOptions = [], - parsedInputs, workflowVersion } = state.context; - // Any time the inputs change (even if it's just re-ordering), we must - // change the form key so that the inputs component will re-mount. - const formKey = useMemo(() => { - if (!workflowVersion || !launchPlan) { - return ''; - } - return getCacheKey(parsedInputs); - }, [parsedInputs]); - - // Only show errors after first submission for a set of inputs. - useEffect(() => setShowErrors(false), [formKey]); - const selectWorkflowVersion = (newWorkflow: WorkflowId) => { if (newWorkflow === workflowVersion) { return; @@ -304,37 +284,15 @@ export function useLaunchWorkflowFormState({ const workflowSourceSelectorState = useWorkflowSourceSelectorState({ launchPlan, launchPlanOptions, - sourceWorkflowId, + sourceId, selectLaunchPlan, selectWorkflowVersion, workflowVersion, workflowVersionOptions }); - const onSubmit = () => { - // Show errors after the first submission - setShowErrors(true); - sendEvent({ type: 'SUBMIT' }); - }; - const onCancel = () => { - sendEvent({ type: 'CANCEL' }); - onClose(); - }; - useEffect(() => { const subscription = service.subscribe(newState => { - // On transition to final success state, read the resulting execution - // id and navigate to the Execution Details page. - // if (state.matches({ submit: 'succeeded' })) { - if (newState.matches(LaunchState.SUBMIT_SUCCEEDED)) { - history.push( - Routes.ExecutionDetails.makeUrl( - newState.context.resultExecutionId - ) - ); - } - - // if (state.matches({ workflow: 'select' })) { if (newState.matches(LaunchState.SELECT_WORKFLOW_VERSION)) { const { workflowVersionOptions, @@ -357,12 +315,11 @@ export function useLaunchWorkflowFormState({ } } - // if (state.matches({ launchPlan: 'select' })) { if (newState.matches(LaunchState.SELECT_LAUNCH_PLAN)) { const { launchPlan, launchPlanOptions, - sourceWorkflowId + sourceId } = newState.context; if (!launchPlanOptions.length) { return; @@ -392,7 +349,7 @@ export function useLaunchWorkflowFormState({ } } else { const defaultLaunchPlan = launchPlanOptions.find( - ({ id: { name } }) => name === sourceWorkflowId.name + ({ id: { name } }) => name === sourceId.name ); if (defaultLaunchPlan) { launchPlanToSelect = defaultLaunchPlan; @@ -411,12 +368,8 @@ export function useLaunchWorkflowFormState({ return { formInputsRef, - formKey, - inputValueCache, - onCancel, - onSubmit, - showErrors, state, + service, workflowSourceSelectorState }; } diff --git a/src/components/Launch/LaunchWorkflowForm/useWorkflowSourceSelectorState.ts b/src/components/Launch/LaunchForm/useWorkflowSourceSelectorState.ts similarity index 95% rename from src/components/Launch/LaunchWorkflowForm/useWorkflowSourceSelectorState.ts rename to src/components/Launch/LaunchForm/useWorkflowSourceSelectorState.ts index 8f2284c8f..9c6881388 100644 --- a/src/components/Launch/LaunchWorkflowForm/useWorkflowSourceSelectorState.ts +++ b/src/components/Launch/LaunchForm/useWorkflowSourceSelectorState.ts @@ -64,7 +64,7 @@ interface UseWorkflowSourceSelectorStateArgs { /** List of options to show for the launch plan selector. */ launchPlanOptions: LaunchPlan[]; /** The parent workflow for which we are selecting a version. */ - sourceWorkflowId: NamedEntityIdentifier; + sourceId: NamedEntityIdentifier; /** The currently selected Workflow version. */ workflowVersion?: WorkflowId; /** The list of options to show for the Workflow selector. */ @@ -81,7 +81,7 @@ interface UseWorkflowSourceSelectorStateArgs { export function useWorkflowSourceSelectorState({ launchPlan, launchPlanOptions, - sourceWorkflowId, + sourceId, workflowVersion, workflowVersionOptions, selectLaunchPlan, @@ -132,16 +132,13 @@ export function useWorkflowSourceSelectorState({ ); const fetchSearchResults = useMemo(() => { - const doFetch = generateFetchSearchResults( - apiContext, - sourceWorkflowId - ); + const doFetch = generateFetchSearchResults(apiContext, sourceId); return async (query: string) => { const results = await doFetch(query); setWorkflowVersionSearchOptions(results); return results; }; - }, [apiContext, sourceWorkflowId, setWorkflowVersionSearchOptions]); + }, [apiContext, sourceId, setWorkflowVersionSearchOptions]); return { fetchSearchResults, diff --git a/src/components/Launch/LaunchWorkflowForm/utils.ts b/src/components/Launch/LaunchForm/utils.ts similarity index 100% rename from src/components/Launch/LaunchWorkflowForm/utils.ts rename to src/components/Launch/LaunchForm/utils.ts diff --git a/src/components/Launch/LaunchWorkflowForm/LaunchWorkflowForm.tsx b/src/components/Launch/LaunchWorkflowForm/LaunchWorkflowForm.tsx deleted file mode 100644 index d1368161e..000000000 --- a/src/components/Launch/LaunchWorkflowForm/LaunchWorkflowForm.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { - Button, - DialogActions, - DialogContent, - DialogTitle, - FormHelperText, - Typography -} from '@material-ui/core'; -import { ButtonCircularProgress } from 'components/common/ButtonCircularProgress'; -import * as React from 'react'; -import { formStrings } from './constants'; -import { InputValueCacheContext } from './inputValueCache'; -import { LaunchState } from './launchMachine'; -import { LaunchWorkflowFormInputs } from './LaunchWorkflowFormInputs'; -import { SearchableSelector } from './SearchableSelector'; -import { useStyles } from './styles'; -import { LaunchWorkflowFormProps } from './types'; -import { UnsupportedRequiredInputsError } from './UnsupportedRequiredInputsError'; -import { useLaunchWorkflowFormState } from './useLaunchWorkflowFormState'; - -/** Renders the form for initiating a Launch request based on a Workflow */ -export const LaunchWorkflowForm: React.FC = props => { - const { - formKey, - formInputsRef, - showErrors, - inputValueCache, - onCancel, - onSubmit, - state, - workflowSourceSelectorState - } = useLaunchWorkflowFormState(props); - const styles = useStyles(); - - const { - fetchSearchResults, - launchPlanSelectorOptions, - onSelectLaunchPlan, - onSelectWorkflowVersion, - selectedLaunchPlan, - selectedWorkflow, - workflowSelectorOptions - } = workflowSourceSelectorState; - - const submit: React.FormEventHandler = event => { - event.preventDefault(); - onSubmit(); - }; - - const submissionInFlight = state.matches(LaunchState.SUBMITTING); - const canSubmit = [ - LaunchState.ENTER_INPUTS, - LaunchState.VALIDATING_INPUTS, - LaunchState.INVALID_INPUTS, - LaunchState.SUBMIT_FAILED - ].some(state.matches); - const showWorkflowSelector = ![ - LaunchState.LOADING_WORKFLOW_VERSIONS, - LaunchState.FAILED_LOADING_WORKFLOW_VERSIONS - ].some(state.matches); - const showLaunchPlanSelector = - state.context.workflowVersion && - ![ - LaunchState.LOADING_LAUNCH_PLANS, - LaunchState.FAILED_LOADING_LAUNCH_PLANS - ].some(state.matches); - const showInputs = [ - LaunchState.UNSUPPORTED_INPUTS, - LaunchState.ENTER_INPUTS, - LaunchState.VALIDATING_INPUTS, - LaunchState.INVALID_INPUTS, - LaunchState.SUBMIT_VALIDATING, - LaunchState.SUBMITTING, - LaunchState.SUBMIT_FAILED, - LaunchState.SUBMIT_SUCCEEDED - ].some(state.matches); - - // TODO: We removed all loading indicators here. Decide if we want skeletons - // instead. - return ( - - -
{formStrings.title}
- - {state.context.sourceWorkflowId?.name} - -
- - {showWorkflowSelector ? ( -
- -
- ) : null} - {showLaunchPlanSelector ? ( -
- -
- ) : null} - {showInputs ? ( -
- {state.matches(LaunchState.UNSUPPORTED_INPUTS) ? ( - - ) : ( - - )} -
- ) : null} -
-
- {state.matches(LaunchState.SUBMIT_FAILED) ? ( - - {state.context.error.message} - - ) : null} - - - - -
-
- ); -}; diff --git a/src/components/Launch/LaunchWorkflowForm/LaunchWorkflowFormInputs.tsx b/src/components/Launch/LaunchWorkflowForm/LaunchWorkflowFormInputs.tsx deleted file mode 100644 index ad83ed0ab..000000000 --- a/src/components/Launch/LaunchWorkflowForm/LaunchWorkflowFormInputs.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import * as React from 'react'; -import { BlobInput } from './BlobInput'; -import { CollectionInput } from './CollectionInput'; -import { SimpleInput } from './SimpleInput'; -import { useStyles } from './styles'; -import { - InputProps, - InputType, - LaunchWorkflowFormInputsRef, - ParsedInput -} from './types'; -import { UnsupportedInput } from './UnsupportedInput'; -import { useFormInputsState } from './useFormInputsState'; - -function getComponentForInput(input: InputProps, showErrors: boolean) { - const props = { ...input, error: showErrors ? input.error : undefined }; - switch (input.typeDefinition.type) { - case InputType.Blob: - return ; - case InputType.Collection: - return ; - case InputType.Map: - case InputType.Schema: - case InputType.Unknown: - case InputType.None: - return ; - default: - return ; - } -} - -export interface LaunchWorkflowFormInputsProps { - inputs: ParsedInput[]; - showErrors?: boolean; -} - -export const LaunchWorkflowFormInputsImpl: React.RefForwardingComponent< - LaunchWorkflowFormInputsRef, - LaunchWorkflowFormInputsProps -> = ({ inputs: parsedInputs, showErrors = true }, ref) => { - const { getValues, inputs, validate } = useFormInputsState(parsedInputs); - const styles = useStyles(); - React.useImperativeHandle(ref, () => ({ - getValues, - validate - })); - - return ( - <> - {inputs.map(input => ( -
- {getComponentForInput(input, showErrors)} -
- ))} - - ); -}; - -/** Renders an array of `ParsedInput` values using the appropriate - * components. A `ref` to this component is used to access the current - * form values and trigger manual validation if needed. - */ -export const LaunchWorkflowFormInputs = React.forwardRef( - LaunchWorkflowFormInputsImpl -); diff --git a/src/components/Task/SimpleTaskInterface.tsx b/src/components/Task/SimpleTaskInterface.tsx index d610ef03a..65adcda27 100644 --- a/src/components/Task/SimpleTaskInterface.tsx +++ b/src/components/Task/SimpleTaskInterface.tsx @@ -6,7 +6,7 @@ import { useCommonStyles } from 'components/common/styles'; import { formatType, getInputDefintionForLiteralType -} from 'components/Launch/LaunchWorkflowForm/utils'; +} from 'components/Launch/LaunchForm/utils'; import { Task, Variable } from 'models'; import * as React from 'react';