Skip to content

Commit

Permalink
refactor: Make launch state and components generic (#100)
Browse files Browse the repository at this point in the history
* refactor: splitting launch machine into two separate types

* refactor: move shared state out to component

* refactor: use composition to create workflow form

* refactor: update usage of LaunchWorkflowForm -> LaunchForm

* chore: cleanup and fix tests
  • Loading branch information
schottra authored Oct 5, 2020
1 parent c5462f6 commit 2254ef6
Show file tree
Hide file tree
Showing 54 changed files with 760 additions and 586 deletions.
8 changes: 2 additions & 6 deletions src/components/Entities/EntityDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -76,18 +76,14 @@ export const EntityDetails: React.FC<EntityDetailsProps> = ({ id }) => {
<EntityExecutions id={id} />
</div>
) : null}
{/* TODO: LaunchWorkflowForm needs to be made generic */}
{!!sections.launch ? (
<Dialog
scroll="paper"
maxWidth="sm"
fullWidth={true}
open={showLaunchForm}
>
<LaunchWorkflowForm
onClose={onCancelLaunch}
workflowId={id}
/>
<LaunchForm onClose={onCancelLaunch} workflowId={id} />
</Dialog>
) : null}
</WaitForData>
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -22,7 +22,7 @@ const RenderForm: React.FC<RelaunchExecutionFormProps & {
});
return (
<WaitForData {...launchConfiguration}>
<LaunchWorkflowForm
<LaunchForm
initialParameters={launchConfiguration.value}
onClose={onClose}
workflowId={workflowId}
Expand Down
27 changes: 27 additions & 0 deletions src/components/Launch/LaunchForm/LaunchForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as React from 'react';
import {
createInputValueCache,
InputValueCacheContext
} from './inputValueCache';
import { LaunchWorkflowForm } from './LaunchWorkflowForm';
import { LaunchFormProps, LaunchWorkflowFormProps } from './types';

function isWorkflowPropsObject(
props: LaunchFormProps
): props is LaunchWorkflowFormProps {
return (props as LaunchWorkflowFormProps).workflowId !== undefined;
}

/** Renders the form for initiating a Launch request based on a Workflow or Task */
export const LaunchForm: React.FC<LaunchFormProps> = props => {
const [inputValueCache] = React.useState(createInputValueCache());

// TODO: Use LaunchTaskForm when it has been implemented.
return (
<InputValueCacheContext.Provider value={inputValueCache}>
{isWorkflowPropsObject(props) ? (
<LaunchWorkflowForm {...props} />
) : null}
</InputValueCacheContext.Provider>
);
};
89 changes: 89 additions & 0 deletions src/components/Launch/LaunchForm/LaunchFormActions.tsx
Original file line number Diff line number Diff line change
@@ -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<LaunchFormActionsProps> = ({
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 (
<div className={styles.footer}>
{state.matches(LaunchState.SUBMIT_FAILED) ? (
<FormHelperText error={true}>
{state.context.error.message}
</FormHelperText>
) : null}
<DialogActions>
<Button
color="primary"
disabled={submissionInFlight}
id="launch-workflow-cancel"
onClick={onCancel}
variant="outlined"
>
{formStrings.cancel}
</Button>
<Button
color="primary"
disabled={!canSubmit}
id="launch-workflow-submit"
onClick={submit}
type="submit"
variant="contained"
>
{formStrings.submit}
{submissionInFlight && <ButtonCircularProgress />}
</Button>
</DialogActions>
</div>
);
};
17 changes: 17 additions & 0 deletions src/components/Launch/LaunchForm/LaunchFormHeader.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DialogTitle disableTypography={true} className={styles.header}>
<div className={styles.inputLabel}>{formStrings.title}</div>
<Typography variant="h6">{title}</Typography>
</DialogTitle>
);
};
89 changes: 89 additions & 0 deletions src/components/Launch/LaunchForm/LaunchFormInputs.tsx
Original file line number Diff line number Diff line change
@@ -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 <BlobInput {...props} />;
case InputType.Collection:
return <CollectionInput {...props} />;
case InputType.Map:
case InputType.Schema:
case InputType.Unknown:
case InputType.None:
return <UnsupportedInput {...props} />;
default:
return <SimpleInput {...props} />;
}
}

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 ? (
<section title={formStrings.inputs}>
{state.matches(LaunchState.UNSUPPORTED_INPUTS) ? (
<UnsupportedRequiredInputsError
inputs={unsupportedRequiredInputs}
/>
) : (
<>
{inputs.map(input => (
<div key={input.label} className={styles.formControl}>
{getComponentForInput(input, showErrors)}
</div>
))}
</>
)}
</section>
) : 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);
105 changes: 105 additions & 0 deletions src/components/Launch/LaunchForm/LaunchWorkflowForm.tsx
Original file line number Diff line number Diff line change
@@ -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<LaunchWorkflowFormProps> = 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<string>(() => {
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 (
<>
<LaunchFormHeader title={state.context.sourceId?.name} />
<DialogContent dividers={true} className={styles.inputsSection}>
{showWorkflowSelector ? (
<section
title={formStrings.workflowVersion}
className={styles.formControl}
>
<SearchableSelector
id="launch-workflow-selector"
label={formStrings.workflowVersion}
onSelectionChanged={onSelectWorkflowVersion}
options={workflowSelectorOptions}
fetchSearchResults={fetchSearchResults}
selectedItem={selectedWorkflow}
/>
</section>
) : null}
{showLaunchPlanSelector ? (
<section
title={formStrings.launchPlan}
className={styles.formControl}
>
<SearchableSelector
id="launch-lp-selector"
label={formStrings.launchPlan}
onSelectionChanged={onSelectLaunchPlan}
options={launchPlanSelectorOptions}
selectedItem={selectedLaunchPlan}
/>
</section>
) : null}
<LaunchFormInputs
key={formKey}
ref={formInputsRef}
state={baseState}
/>
</DialogContent>
<LaunchFormActions
state={baseState}
service={baseService}
onClose={props.onClose}
/>
</>
);
};
Loading

0 comments on commit 2254ef6

Please sign in to comment.