diff --git a/src/components/Executions/ExecutionDetails/RelaunchExecutionForm.tsx b/src/components/Executions/ExecutionDetails/RelaunchExecutionForm.tsx index ed9ce53ff..2ebd9a5e0 100644 --- a/src/components/Executions/ExecutionDetails/RelaunchExecutionForm.tsx +++ b/src/components/Executions/ExecutionDetails/RelaunchExecutionForm.tsx @@ -62,7 +62,7 @@ function useRelaunchTaskFormState({ execution }: RelaunchExecutionFormProps) { defaultValue: {} as TaskInitialLaunchParameters, doFetch: async execution => { const { - spec: { launchPlan: taskId } + spec: { authRole, launchPlan: taskId } } = execution; const task = await apiContext.getTask(taskId); const inputDefinitions = getTaskInputs(task); @@ -73,7 +73,7 @@ function useRelaunchTaskFormState({ execution }: RelaunchExecutionFormProps) { }, apiContext ); - return { values, taskId }; + return { authRole, values, taskId }; } }, execution diff --git a/src/components/Executions/ExecutionDetails/test/RelaunchExecutionForm.test.tsx b/src/components/Executions/ExecutionDetails/test/RelaunchExecutionForm.test.tsx index 32dcee7b3..ef26530f3 100644 --- a/src/components/Executions/ExecutionDetails/test/RelaunchExecutionForm.test.tsx +++ b/src/components/Executions/ExecutionDetails/test/RelaunchExecutionForm.test.tsx @@ -12,6 +12,7 @@ import { createInputCacheKey, getInputDefintionForLiteralType } from 'components/Launch/LaunchForm/utils'; +import { Admin } from 'flyteidl'; import { Execution, ExecutionData, @@ -189,8 +190,13 @@ describe('RelaunchExecutionForm', () => { describe('with single task execution', () => { let values: LiteralValueMap; + let authRole: Admin.IAuthRole; beforeEach(() => { + authRole = { + assumableIamRole: 'arn:aws:iam::12345678:role/defaultrole' + }; execution.spec.launchPlan.resourceType = ResourceType.TASK; + execution.spec.authRole = { ...authRole }; taskInputDefinitions = { taskSimpleString: mockSimpleVariables.simpleString, taskSimpleInteger: mockSimpleVariables.simpleInteger @@ -222,6 +228,17 @@ describe('RelaunchExecutionForm', () => { }); }); + it('passes authRole from original execution', async () => { + const { getByText } = renderForm(); + await waitFor(() => getByText(mockContentString)); + + checkLaunchFormProps({ + initialParameters: expect.objectContaining({ + authRole + }) + }); + }); + it('maps execution input values to workflow inputs', async () => { const { getByText } = renderForm(); await waitFor(() => getByText(mockContentString)); diff --git a/src/components/Launch/LaunchForm/CollectionInput.tsx b/src/components/Launch/LaunchForm/CollectionInput.tsx index f2732ea33..5eb66a2d8 100644 --- a/src/components/Launch/LaunchForm/CollectionInput.tsx +++ b/src/components/Launch/LaunchForm/CollectionInput.tsx @@ -1,15 +1,10 @@ import { TextField } from '@material-ui/core'; import * as React from 'react'; -import { InputChangeHandler, InputProps, InputType } from './types'; +import { makeStringChangeHandler } from './handlers'; +import { InputProps, InputType } from './types'; import { UnsupportedInput } from './UnsupportedInput'; import { getLaunchInputId } from './utils'; -function stringChangeHandler(onChange: InputChangeHandler) { - return ({ target: { value } }: React.ChangeEvent) => { - onChange(value); - }; -} - /** Handles rendering of the input component for a Collection of SimpleType values*/ export const CollectionInput: React.FC = props => { const { @@ -49,7 +44,7 @@ export const CollectionInput: React.FC = props => { fullWidth={true} label={label} multiline={true} - onChange={stringChangeHandler(onChange)} + onChange={makeStringChangeHandler(onChange)} rowsMax={8} value={value} variant="outlined" diff --git a/src/components/Launch/LaunchForm/LaunchFormInputs.tsx b/src/components/Launch/LaunchForm/LaunchFormInputs.tsx index ed9ee9636..6dd6fb344 100644 --- a/src/components/Launch/LaunchForm/LaunchFormInputs.tsx +++ b/src/components/Launch/LaunchForm/LaunchFormInputs.tsx @@ -1,8 +1,10 @@ +import { Typography } from '@material-ui/core'; import * as React from 'react'; import { BlobInput } from './BlobInput'; import { CollectionInput } from './CollectionInput'; -import { formStrings } from './constants'; +import { formStrings, inputsDescription } from './constants'; import { LaunchState } from './launchMachine'; +import { NoInputsNeeded } from './NoInputsNeeded'; import { SimpleInput } from './SimpleInput'; import { StructInput } from './StructInput'; import { useStyles } from './styles'; @@ -15,6 +17,7 @@ import { import { UnsupportedInput } from './UnsupportedInput'; import { UnsupportedRequiredInputsError } from './UnsupportedRequiredInputsError'; import { useFormInputsState } from './useFormInputsState'; +import { isEnterInputsState } from './utils'; function getComponentForInput(input: InputProps, showErrors: boolean) { const props = { ...input, error: showErrors ? input.error : undefined }; @@ -39,6 +42,29 @@ export interface LaunchFormInputsProps { variant: 'workflow' | 'task'; } +const RenderFormInputs: React.FC<{ + inputs: InputProps[]; + showErrors: boolean; + variant: LaunchFormInputsProps['variant']; +}> = ({ inputs, showErrors, variant }) => { + const styles = useStyles(); + return inputs.length === 0 ? ( + + ) : ( + <> +
+ {formStrings.inputs} + {inputsDescription} +
+ {inputs.map(input => ( +
+ {getComponentForInput(input, showErrors)} +
+ ))} + + ); +}; + export const LaunchFormInputsImpl: React.RefForwardingComponent< LaunchFormInputsRef, LaunchFormInputsProps @@ -49,24 +75,12 @@ export const LaunchFormInputsImpl: React.RefForwardingComponent< 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 ? ( + return isEnterInputsState(state) ? (
{state.matches(LaunchState.UNSUPPORTED_INPUTS) ? ( ) : ( - <> - {inputs.map(input => ( -
- {getComponentForInput(input, showErrors)} -
- ))} - + )}
) : null; diff --git a/src/components/Launch/LaunchForm/LaunchRoleInput.tsx b/src/components/Launch/LaunchForm/LaunchRoleInput.tsx new file mode 100644 index 000000000..98c10995b --- /dev/null +++ b/src/components/Launch/LaunchForm/LaunchRoleInput.tsx @@ -0,0 +1,231 @@ +import { + FormControl, + FormControlLabel, + FormLabel, + Radio, + RadioGroup, + TextField, + Typography +} from '@material-ui/core'; +import { log } from 'common/log'; +import { NewTargetLink } from 'components/common/NewTargetLink'; +import { useDebouncedValue } from 'components/hooks/useDebouncedValue'; +import { Admin } from 'flyteidl'; +import * as React from 'react'; +import { launchInputDebouncDelay, roleTypes } from './constants'; +import { makeStringChangeHandler } from './handlers'; +import { useInputValueCacheContext } from './inputValueCache'; +import { useStyles } from './styles'; +import { InputValueMap, LaunchRoleInputRef, RoleType } from './types'; + +const roleHeader = 'Role'; +const roleDocLinkUrl = + 'https://github.com/lyft/flyteidl/blob/3789005a1372221eba28fa20d8386e44b32388f5/protos/flyteidl/admin/common.proto#L241'; +const roleTypeLabel = 'type'; +const roleInputId = 'launch-auth-role'; +const defaultRoleTypeValue = roleTypes.iamRole; + +export interface LaunchRoleInputProps { + initialValue?: Admin.IAuthRole; + showErrors: boolean; +} + +interface LaunchRoleInputState { + error?: string; + roleType: RoleType; + roleString?: string; + getValue(): Admin.IAuthRole; + onChangeRoleString(newValue: string): void; + onChangeRoleType(newValue: string): void; + validate(): boolean; +} + +function getRoleTypeByValue(value: string): RoleType | undefined { + return Object.values(roleTypes).find( + ({ value: roleTypeValue }) => value === roleTypeValue + ); +} + +const roleTypeCacheKey = '__roleType'; +const roleStringCacheKey = '__roleString'; + +interface AuthRoleInitialValues { + roleType: RoleType; + roleString: string; +} + +function getInitialValues( + cache: InputValueMap, + initialValue?: Admin.IAuthRole +): AuthRoleInitialValues { + let roleType: RoleType | undefined; + let roleString: string | undefined; + + // Prefer cached value first, since that is user input + if (cache.has(roleTypeCacheKey)) { + const cachedValue = `${cache.get(roleTypeCacheKey)}`; + roleType = getRoleTypeByValue(cachedValue); + if (roleType === undefined) { + log.error(`Unexepected cached role type: ${cachedValue}`); + } + } + if (cache.has(roleStringCacheKey)) { + roleString = cache.get(roleStringCacheKey)?.toString(); + } + + // After trying cache, check for an initial value and populate either + // field from the initial value if no cached value was passed. + if (initialValue != null) { + const initialRoleType = Object.values(roleTypes).find( + rt => initialValue[rt.value] != null + ); + if (initialRoleType != null && roleType == null) { + roleType = initialRoleType; + } + if (initialRoleType != null && roleString == null) { + roleString = initialValue[initialRoleType.value]?.toString(); + } + } + + return { + roleType: roleType ?? defaultRoleTypeValue, + roleString: roleString ?? '' + }; +} + +export function useRoleInputState( + props: LaunchRoleInputProps +): LaunchRoleInputState { + const inputValueCache = useInputValueCacheContext(); + const initialValues = getInitialValues(inputValueCache, props.initialValue); + + const [error, setError] = React.useState(); + const [roleString, setRoleString] = React.useState( + initialValues.roleString + ); + + const [roleType, setRoleType] = React.useState( + initialValues.roleType + ); + + const validationValue = useDebouncedValue( + roleString, + launchInputDebouncDelay + ); + + const getValue = () => ({ [roleType.value]: roleString }); + const validate = () => { + if (roleString == null || roleString.length === 0) { + setError('Value is required'); + return false; + } + setError(undefined); + return true; + }; + + const onChangeRoleString = (value: string) => { + inputValueCache.set(roleStringCacheKey, value); + setRoleString(value); + }; + + const onChangeRoleType = (value: string) => { + const newRoleType = getRoleTypeByValue(value); + if (newRoleType === undefined) { + throw new Error(`Unexpected role type value: ${value}`); + } + inputValueCache.set(roleTypeCacheKey, value); + setRoleType(newRoleType); + }; + + React.useEffect(() => { + validate(); + }, [validationValue]); + + return { + error, + getValue, + onChangeRoleString, + onChangeRoleType, + roleType, + roleString, + validate + }; +} + +const RoleDescription = () => ( + <> + + Enter a + +  role  + + to assume for this execution. + + +); + +export const LaunchRoleInputImpl: React.RefForwardingComponent< + LaunchRoleInputRef, + LaunchRoleInputProps +> = (props, ref) => { + const styles = useStyles(); + const { + error, + getValue, + roleType, + roleString = '', + onChangeRoleString, + onChangeRoleType, + validate + } = useRoleInputState(props); + const hasError = props.showErrors && !!error; + const helperText = hasError ? error : roleType.helperText; + + React.useImperativeHandle(ref, () => ({ + getValue, + validate + })); + + return ( +
+
+ {roleHeader} + +
+ + {roleTypeLabel} + + {Object.values(roleTypes).map(({ label, value }) => ( + } + label={label} + /> + ))} + + +
+ +
+
+ ); +}; + +/** Renders controls for selecting an AuthRole type and inputting a value for it. */ +export const LaunchRoleInput = React.forwardRef(LaunchRoleInputImpl); diff --git a/src/components/Launch/LaunchForm/LaunchTaskForm.tsx b/src/components/Launch/LaunchForm/LaunchTaskForm.tsx index 12f52dd7d..7647584a4 100644 --- a/src/components/Launch/LaunchForm/LaunchTaskForm.tsx +++ b/src/components/Launch/LaunchForm/LaunchTaskForm.tsx @@ -6,6 +6,7 @@ import { LaunchFormActions } from './LaunchFormActions'; import { LaunchFormHeader } from './LaunchFormHeader'; import { LaunchFormInputs } from './LaunchFormInputs'; import { LaunchState } from './launchMachine'; +import { LaunchRoleInput } from './LaunchRoleInput'; import { SearchableSelector } from './SearchableSelector'; import { useStyles } from './styles'; import { @@ -14,11 +15,13 @@ import { LaunchTaskFormProps } from './types'; import { useLaunchTaskFormState } from './useLaunchTaskFormState'; +import { isEnterInputsState } from './utils'; /** Renders the form for initiating a Launch request based on a Task */ export const LaunchTaskForm: React.FC = props => { const { formInputsRef, + roleInputRef, state, service, taskSourceSelectorState @@ -66,6 +69,13 @@ export const LaunchTaskForm: React.FC = props => { /> ) : null} + {isEnterInputsState(baseState) ? ( + + ) : null} ({ + root: { + marginBottom: theme.spacing(1), + marginTop: theme.spacing(1) + } +})); + +export interface NoInputsProps { + variant: 'workflow' | 'task'; +} +/** An informational message to be shown if a Workflow or Task does not need any + * input values. + */ +export const NoInputsNeeded: React.FC = ({ variant }) => { + const commonStyles = useCommonStyles(); + return ( + + {variant === 'workflow' + ? workflowNoInputsString + : taskNoInputsString} + + ); +}; diff --git a/src/components/Launch/LaunchForm/SimpleInput.tsx b/src/components/Launch/LaunchForm/SimpleInput.tsx index 058f9fbf5..2248c323e 100644 --- a/src/components/Launch/LaunchForm/SimpleInput.tsx +++ b/src/components/Launch/LaunchForm/SimpleInput.tsx @@ -7,22 +7,11 @@ import { } from '@material-ui/core'; import * as React from 'react'; import { DatetimeInput } from './DatetimeInput'; -import { InputChangeHandler, InputProps, InputType } from './types'; +import { makeStringChangeHandler, makeSwitchChangeHandler } from './handlers'; +import { InputProps, InputType } from './types'; import { UnsupportedInput } from './UnsupportedInput'; import { getLaunchInputId } from './utils'; -function switchChangeHandler(onChange: InputChangeHandler) { - return ({ target: { checked } }: React.ChangeEvent) => { - onChange(checked); - }; -} - -function stringChangeHandler(onChange: InputChangeHandler) { - return ({ target: { value } }: React.ChangeEvent) => { - onChange(value); - }; -} - /** Handles rendering of the input component for any primitive-type input */ export const SimpleInput: React.FC = props => { const { @@ -44,7 +33,7 @@ export const SimpleInput: React.FC = props => { } @@ -67,7 +56,7 @@ export const SimpleInput: React.FC = props => { helperText={helperText} fullWidth={true} label={label} - onChange={stringChangeHandler(onChange)} + onChange={makeStringChangeHandler(onChange)} value={value} variant="outlined" /> diff --git a/src/components/Launch/LaunchForm/StructInput.tsx b/src/components/Launch/LaunchForm/StructInput.tsx index 0f8327554..7ec8ffe49 100644 --- a/src/components/Launch/LaunchForm/StructInput.tsx +++ b/src/components/Launch/LaunchForm/StructInput.tsx @@ -1,14 +1,9 @@ import { TextField } from '@material-ui/core'; import * as React from 'react'; -import { InputChangeHandler, InputProps } from './types'; +import { makeStringChangeHandler } from './handlers'; +import { InputProps } from './types'; import { getLaunchInputId } from './utils'; -function stringChangeHandler(onChange: InputChangeHandler) { - return ({ target: { value } }: React.ChangeEvent) => { - onChange(value); - }; -} - /** Handles rendering of the input component for a Struct */ export const StructInput: React.FC = props => { const { @@ -29,7 +24,7 @@ export const StructInput: React.FC = props => { fullWidth={true} label={label} multiline={true} - onChange={stringChangeHandler(onChange)} + onChange={makeStringChangeHandler(onChange)} rowsMax={8} value={value} variant="outlined" diff --git a/src/components/Launch/LaunchForm/constants.ts b/src/components/Launch/LaunchForm/constants.ts index 398a79761..70b8701cc 100644 --- a/src/components/Launch/LaunchForm/constants.ts +++ b/src/components/Launch/LaunchForm/constants.ts @@ -1,5 +1,5 @@ import { BlobDimensionality, SimpleType } from 'models'; -import { BlobValue, InputType } from './types'; +import { BlobValue, InputType, ParsedInput, RoleType } from './types'; export const launchPlansTableRowHeight = 40; export const launchPlansTableColumnWidths = { @@ -16,6 +16,7 @@ export const schedulesTableColumnsWidths = { export const formStrings = { cancel: 'Cancel', inputs: 'Inputs', + role: 'Role', submit: 'Launch', taskVersion: 'Task Version', title: 'Create New Execution', @@ -23,6 +24,22 @@ export const formStrings = { launchPlan: 'Launch Plan' }; +type RoleTypesKey = 'iamRole' | 'k8sServiceAccount'; +export const roleTypes: { [k in RoleTypesKey]: RoleType } = { + iamRole: { + helperText: 'example: arn:aws:iam::12345678:role/defaultrole', + inputLabel: 'role urn', + label: 'IAM Role', + value: 'assumableIamRole' + }, + k8sServiceAccount: { + helperText: 'example: default-service-account', + inputLabel: 'service account name', + label: 'Kubernetes Service Account', + value: 'kubernetesServiceAccount' + } +}; + /** Maps any valid InputType enum to a display string */ export const typeLabels: { [k in InputType]: string } = { [InputType.Binary]: 'binary', @@ -61,10 +78,19 @@ export const defaultBlobValue: BlobValue = { dimensionality: BlobDimensionality.SINGLE }; +export const launchInputDebouncDelay = 500; + export const requiredInputSuffix = '*'; export const cannotLaunchWorkflowString = 'Workflow cannot be launched'; export const cannotLaunchTaskString = 'Task cannot be launched'; +export const inputsDescription = + 'Enter input values below. Items marked with an asterisk(*) are required.'; +export const workflowNoInputsString = + 'This workflow does not accept any inputs.'; +export const taskNoInputsString = 'This task does not accept any inputs.'; export const workflowUnsupportedRequiredInputsString = `This Workflow version contains one or more required inputs which are not supported by Flyte Console and do not have default values specified in the Workflow definition or the selected Launch Plan.\n\nYou can launch this Workflow version with the Flyte CLI or by selecting a Launch Plan which provides values for the unsupported inputs.\n\nThe required inputs are :`; export const taskUnsupportedRequiredInputsString = `This Task version contains one or more required inputs which are not supported by Flyte Console.\n\nYou can launch this Task version with the Flyte CLI instead.\n\nThe required inputs are :`; export const blobUriHelperText = '(required) location of the data'; export const blobFormatHelperText = '(optional) csv, parquet, etc...'; +export const correctInputErrors = + 'Some inputs have errors. Please correct them before submitting.'; diff --git a/src/components/Launch/LaunchForm/handlers.ts b/src/components/Launch/LaunchForm/handlers.ts new file mode 100644 index 000000000..e6dbf2d43 --- /dev/null +++ b/src/components/Launch/LaunchForm/handlers.ts @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { InputChangeHandler } from './types'; + +export function makeSwitchChangeHandler(onChange: InputChangeHandler) { + return ({ target: { checked } }: React.ChangeEvent) => { + onChange(checked); + }; +} + +type StringChangeHandler = (value: string) => void; +export function makeStringChangeHandler( + onChange: InputChangeHandler | StringChangeHandler +) { + return ({ target: { value } }: React.ChangeEvent) => { + onChange(value); + }; +} diff --git a/src/components/Launch/LaunchForm/launchMachine.ts b/src/components/Launch/LaunchForm/launchMachine.ts index 23557c56b..a60b15a8b 100644 --- a/src/components/Launch/LaunchForm/launchMachine.ts +++ b/src/components/Launch/LaunchForm/launchMachine.ts @@ -1,3 +1,4 @@ +import { Admin } from 'flyteidl'; import { Identifier, LaunchPlan, @@ -83,6 +84,7 @@ export interface WorkflowLaunchContext extends BaseLaunchContext { } export interface TaskLaunchContext extends BaseLaunchContext { + defaultAuthRole?: Admin.IAuthRole; preferredTaskId?: Identifier; taskVersion?: Identifier; taskVersionOptions?: Task[]; diff --git a/src/components/Launch/LaunchForm/services.ts b/src/components/Launch/LaunchForm/services.ts index 248c80710..7f9d867ea 100644 --- a/src/components/Launch/LaunchForm/services.ts +++ b/src/components/Launch/LaunchForm/services.ts @@ -1,4 +1,5 @@ import { RefObject } from 'react'; +import { correctInputErrors } from './constants'; import { WorkflowLaunchContext } from './launchMachine'; import { LaunchFormInputsRef } from './types'; @@ -11,8 +12,6 @@ export async function validate( } if (!formInputsRef.current.validate()) { - throw new Error( - 'Some inputs have errors. Please correct them before submitting.' - ); + throw new Error(correctInputErrors); } } diff --git a/src/components/Launch/LaunchForm/styles.ts b/src/components/Launch/LaunchForm/styles.ts index 50780d960..b0d566d92 100644 --- a/src/components/Launch/LaunchForm/styles.ts +++ b/src/components/Launch/LaunchForm/styles.ts @@ -24,5 +24,9 @@ export const useStyles = makeStyles((theme: Theme) => ({ display: 'flex', flexDirection: 'column', width: '100%' + }, + sectionHeader: { + marginBottom: theme.spacing(1), + marginTop: theme.spacing(1) } })); diff --git a/src/components/Launch/LaunchForm/test/LaunchTaskForm.test.tsx b/src/components/Launch/LaunchForm/test/LaunchTaskForm.test.tsx index aec7362f9..c4b7aad8e 100644 --- a/src/components/Launch/LaunchForm/test/LaunchTaskForm.test.tsx +++ b/src/components/Launch/LaunchForm/test/LaunchTaskForm.test.tsx @@ -1,6 +1,5 @@ import { ThemeProvider } from '@material-ui/styles'; import { - act, fireEvent, getAllByRole, getByLabelText, @@ -36,7 +35,10 @@ import { import { cannotLaunchTaskString, formStrings, - requiredInputSuffix + inputsDescription, + requiredInputSuffix, + roleTypes, + taskNoInputsString } from '../constants'; import { LaunchForm } from '../LaunchForm'; import { LaunchFormProps, TaskInitialLaunchParameters } from '../types'; @@ -45,7 +47,9 @@ import { binaryInputName, booleanInputName, floatInputName, + iamRoleString, integerInputName, + k8sServiceAccountString, stringInputName } from './constants'; import { createMockObjects } from './utils'; @@ -139,7 +143,7 @@ describe('LaunchForm: Task', () => { return buttons[0]; }; - const fillInputs = (container: HTMLElement) => { + const fillInputs = async (container: HTMLElement) => { fireEvent.change( getByLabelText(container, stringInputName, { exact: false @@ -158,9 +162,44 @@ describe('LaunchForm: Task', () => { }), { target: { value: '1.5' } } ); + fireEvent.click(getByLabelText(container, roleTypes.iamRole.label)); + const roleInput = await waitFor(() => + getByLabelText(container, roleTypes.iamRole.inputLabel, { + exact: false + }) + ); + fireEvent.change(roleInput, { target: { value: iamRoleString } }); }; - describe('With Simple Inputs', () => { + describe('With No Inputs', () => { + beforeEach(() => { + variables = {}; + createMocks(); + }); + + it('should render info message', async () => { + const { container, getByText } = renderForm(); + const submitButton = await waitFor(() => + getSubmitButton(container) + ); + await waitFor(() => expect(submitButton).toBeEnabled()); + + expect(getByText(taskNoInputsString)).toBeInTheDocument(); + }); + + it('should not render inputs header/description', async () => { + const { container, queryByText } = renderForm(); + const submitButton = await waitFor(() => + getSubmitButton(container) + ); + await waitFor(() => expect(submitButton).toBeEnabled()); + + expect(queryByText(formStrings.inputs)).toBeNull(); + expect(queryByText(inputsDescription)).toBeNull(); + }); + }); + + describe('With Inputs', () => { beforeEach(() => { const { simpleString, @@ -255,7 +294,7 @@ describe('LaunchForm: Task', () => { exact: false }) ); - fillInputs(container); + await fillInputs(container); const submitButton = getSubmitButton(container); fireEvent.change(integerInput, { target: { value: 'abc' } }); fireEvent.click(submitButton); @@ -337,7 +376,7 @@ describe('LaunchForm: Task', () => { queryByText } = renderForm(); await waitFor(() => getByTitle(formStrings.inputs)); - fillInputs(container); + await fillInputs(container); fireEvent.click(getSubmitButton(container)); await waitFor(() => @@ -357,6 +396,206 @@ describe('LaunchForm: Task', () => { ); }); + describe('Auth Role', () => { + it('should require a value', async () => { + const { container, getByLabelText } = renderForm(); + const roleInput = await waitFor(() => + getByLabelText(roleTypes.iamRole.inputLabel, { + exact: false + }) + ); + + fireEvent.click(getSubmitButton(container)); + await waitFor(() => expect(roleInput).toBeInvalid()); + }); + + Object.entries(roleTypes).forEach( + ([key, { label, inputLabel, helperText, value }]) => { + describe(`for role type ${key}`, () => { + it('should show correct label and helper text', async () => { + const { getByLabelText, getByText } = renderForm(); + const roleRadioSelector = await waitFor(() => + getByLabelText(label) + ); + fireEvent.click(roleRadioSelector); + await waitFor(() => + getByLabelText(inputLabel, { exact: false }) + ); + expect(getByText(helperText)).toBeInTheDocument(); + }); + + it('should preserve role value when changing task version', async () => { + const { getByLabelText, getByTitle } = renderForm(); + + // We expect both the radio selection and text value to be preserved + const roleRadioSelector = await waitFor(() => + getByLabelText(label) + ); + fireEvent.click(roleRadioSelector); + + expect(roleRadioSelector).toBeChecked(); + + const roleInput = await waitFor(() => + getByLabelText(inputLabel, { + exact: false + }) + ); + fireEvent.change(roleInput, { + target: { value: 'roleInputStringValue' } + }); + + // Click the expander for the task version, select the second item + const taskVersionDiv = getByTitle( + formStrings.taskVersion + ); + const expander = getByRole( + taskVersionDiv, + 'button' + ); + fireEvent.click(expander); + const items = await waitFor(() => + getAllByRole(taskVersionDiv, 'menuitem') + ); + fireEvent.click(items[1]); + await waitFor(() => getByTitle(formStrings.inputs)); + + expect(getByLabelText(label)).toBeChecked(); + expect( + getByLabelText(inputLabel, { + exact: false + }) + ).toHaveValue('roleInputStringValue'); + }); + + it(`should use initial values when provided`, async () => { + const initialParameters: TaskInitialLaunchParameters = { + authRole: { + [value]: 'roleStringValue' + } + }; + const { getByLabelText } = renderForm({ + initialParameters + }); + await waitFor(() => + expect(getByLabelText(label)).toBeChecked() + ); + await waitFor(() => + expect( + getByLabelText(inputLabel, { exact: false }) + ).toHaveValue('roleStringValue') + ); + }); + + it(`should prefer cached values over initial values when changing task versions`, async () => { + const initialRoleTypeValue = Object.values( + roleTypes + ).find(rt => rt.value !== value)?.value; + // Set the role and string initial values to something different than what we will input + const initialParameters: TaskInitialLaunchParameters = { + authRole: { + [initialRoleTypeValue!]: 'initialRoleStringValue' + } + }; + const { getByLabelText, getByTitle } = renderForm({ + initialParameters + }); + + // We expect both the radio selection and text value to be preserved + const roleRadioSelector = await waitFor(() => + getByLabelText(label) + ); + fireEvent.click(roleRadioSelector); + + expect(roleRadioSelector).toBeChecked(); + + const roleInput = await waitFor(() => + getByLabelText(inputLabel, { + exact: false + }) + ); + fireEvent.change(roleInput, { + target: { value: 'roleInputStringValue' } + }); + + // Click the expander for the task version, select the second item + const taskVersionDiv = getByTitle( + formStrings.taskVersion + ); + const expander = getByRole( + taskVersionDiv, + 'button' + ); + fireEvent.click(expander); + const items = await waitFor(() => + getAllByRole(taskVersionDiv, 'menuitem') + ); + fireEvent.click(items[1]); + await waitFor(() => getByTitle(formStrings.inputs)); + + expect(getByLabelText(label)).toBeChecked(); + expect( + getByLabelText(inputLabel, { + exact: false + }) + ).toHaveValue('roleInputStringValue'); + }); + }); + } + ); + + it('should correctly construct an IAM role', async () => { + const { container, getByLabelText } = renderForm(); + const { label, inputLabel, value } = roleTypes.iamRole; + const radioSelector = await waitFor(() => + getByLabelText(label) + ); + await fillInputs(container); + fireEvent.click(radioSelector); + const input = await waitFor(() => + getByLabelText(inputLabel, { exact: false }) + ); + fireEvent.change(input, { target: { value: iamRoleString } }); + + fireEvent.click(getSubmitButton(container)); + await waitFor(() => + expect(mockCreateWorkflowExecution).toHaveBeenCalledWith( + expect.objectContaining({ + authRole: { [value]: iamRoleString } + }) + ) + ); + }); + + it('should correctly construct a k8s service account role', async () => { + const { container, getByLabelText } = renderForm(); + const { + label, + inputLabel, + value + } = roleTypes.k8sServiceAccount; + const radioSelector = await waitFor(() => + getByLabelText(label) + ); + await fillInputs(container); + fireEvent.click(radioSelector); + const input = await waitFor(() => + getByLabelText(inputLabel, { exact: false }) + ); + fireEvent.change(input, { + target: { value: k8sServiceAccountString } + }); + + fireEvent.click(getSubmitButton(container)); + await waitFor(() => + expect(mockCreateWorkflowExecution).toHaveBeenCalledWith( + expect.objectContaining({ + authRole: { [value]: k8sServiceAccountString } + }) + ) + ); + }); + }); + describe('Input Values', () => { it('Should send false for untouched toggles', async () => { let inputs: Core.ILiteralMap = {}; @@ -371,7 +610,7 @@ describe('LaunchForm: Task', () => { const { container, getByTitle } = renderForm(); await waitFor(() => getByTitle(formStrings.inputs)); - fillInputs(container); + await fillInputs(container); fireEvent.click(getSubmitButton(container)); await waitFor(() => diff --git a/src/components/Launch/LaunchForm/test/LaunchWorkflowForm.test.tsx b/src/components/Launch/LaunchForm/test/LaunchWorkflowForm.test.tsx index a9a6f5c77..cc3a84c53 100644 --- a/src/components/Launch/LaunchForm/test/LaunchWorkflowForm.test.tsx +++ b/src/components/Launch/LaunchForm/test/LaunchWorkflowForm.test.tsx @@ -39,7 +39,9 @@ import { import { cannotLaunchWorkflowString, formStrings, - requiredInputSuffix + inputsDescription, + requiredInputSuffix, + workflowNoInputsString } from '../constants'; import { LaunchForm } from '../LaunchForm'; import { LaunchFormProps, WorkflowInitialLaunchParameters } from '../types'; @@ -184,6 +186,34 @@ describe('LaunchForm: Workflow', () => { return buttons[0]; }; + describe('With No Inputs', () => { + beforeEach(() => { + variables = {}; + createMocks(); + }); + + it('should render info message', async () => { + const { container, getByText } = renderForm(); + const submitButton = await waitFor(() => + getSubmitButton(container) + ); + await waitFor(() => expect(submitButton).toBeEnabled()); + + expect(getByText(workflowNoInputsString)).toBeInTheDocument(); + }); + + it('should not render inputs header/description', async () => { + const { container, queryByText } = renderForm(); + const submitButton = await waitFor(() => + getSubmitButton(container) + ); + await waitFor(() => expect(submitButton).toBeEnabled()); + + expect(queryByText(formStrings.inputs)).toBeNull(); + expect(queryByText(inputsDescription)).toBeNull(); + }); + }); + describe('With Simple Inputs', () => { beforeEach(() => { variables = cloneDeep(mockSimpleVariables); diff --git a/src/components/Launch/LaunchForm/test/constants.ts b/src/components/Launch/LaunchForm/test/constants.ts index a4f8ab0ae..be2d0e9db 100644 --- a/src/components/Launch/LaunchForm/test/constants.ts +++ b/src/components/Launch/LaunchForm/test/constants.ts @@ -1,3 +1,5 @@ +import { roleTypes } from '../constants'; + export const booleanInputName = 'simpleBoolean'; export const stringInputName = 'simpleString'; export const stringNoLabelName = 'stringNoLabel'; @@ -7,3 +9,6 @@ export const datetimeInputName = 'simpleDatetime'; export const integerInputName = 'simpleInteger'; export const binaryInputName = 'simpleBinary'; export const errorInputName = 'simpleError'; + +export const iamRoleString = 'arn:aws:iam::12345678:role/defaultrole'; +export const k8sServiceAccountString = 'default-service-account'; diff --git a/src/components/Launch/LaunchForm/types.ts b/src/components/Launch/LaunchForm/types.ts index 35f8f2f23..ebae78309 100644 --- a/src/components/Launch/LaunchForm/types.ts +++ b/src/components/Launch/LaunchForm/types.ts @@ -1,4 +1,4 @@ -import { Core } from 'flyteidl'; +import { Admin, Core } from 'flyteidl'; import { BlobDimensionality, Identifier, @@ -64,6 +64,7 @@ export interface LaunchWorkflowFormProps extends BaseLaunchFormProps { export interface TaskInitialLaunchParameters extends BaseInitialLaunchParameters { taskId?: Identifier; + authRole?: Admin.IAuthRole; } export interface LaunchTaskFormProps extends BaseLaunchFormProps { taskId: NamedEntityIdentifier; @@ -81,6 +82,10 @@ export interface LaunchFormInputsRef { getValues(): Record; validate(): boolean; } +export interface LaunchRoleInputRef { + getValue(): Admin.IAuthRole; + validate(): boolean; +} export interface WorkflowSourceSelectorState { launchPlanSelectorOptions: SearchableSelectorOption[]; @@ -124,6 +129,7 @@ export interface LaunchWorkflowFormState { export interface LaunchTaskFormState { formInputsRef: React.RefObject; + roleInputRef: React.RefObject; state: State; service: Interpreter< TaskLaunchContext, @@ -192,3 +198,11 @@ export interface ParsedInput /** Provides an initial value for the input, which can be changed by the user. */ initialValue?: Core.ILiteral; } + +export type RoleTypeValue = keyof Admin.IAuthRole; +export interface RoleType { + helperText: string; + inputLabel: string; + label: string; + value: RoleTypeValue; +} diff --git a/src/components/Launch/LaunchForm/useFormInputsState.ts b/src/components/Launch/LaunchForm/useFormInputsState.ts index 414eb7200..79689e682 100644 --- a/src/components/Launch/LaunchForm/useFormInputsState.ts +++ b/src/components/Launch/LaunchForm/useFormInputsState.ts @@ -1,6 +1,7 @@ import { useDebouncedValue } from 'components/hooks/useDebouncedValue'; import { Core } from 'flyteidl'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; +import { launchInputDebouncDelay } from './constants'; import { defaultValueForInputType, literalToInputValue, @@ -10,8 +11,6 @@ import { useInputValueCacheContext } from './inputValueCache'; import { InputProps, InputValue, ParsedInput } from './types'; import { convertFormInputsToLiterals, createInputCacheKey } from './utils'; -const debounceDelay = 500; - interface FormInputState extends InputProps { validate(): boolean; } @@ -42,7 +41,7 @@ function useFormInputState(parsedInput: ParsedInput): FormInputState { }); const [error, setError] = useState(); - const validationValue = useDebouncedValue(value, debounceDelay); + const validationValue = useDebouncedValue(value, launchInputDebouncDelay); const validate = () => { try { diff --git a/src/components/Launch/LaunchForm/useLaunchTaskFormState.ts b/src/components/Launch/LaunchForm/useLaunchTaskFormState.ts index 2338dd6b6..a3a0db62d 100644 --- a/src/components/Launch/LaunchForm/useLaunchTaskFormState.ts +++ b/src/components/Launch/LaunchForm/useLaunchTaskFormState.ts @@ -11,6 +11,7 @@ import { WorkflowExecutionIdentifier } from 'models'; import { RefObject, useEffect, useMemo, useRef } from 'react'; +import { correctInputErrors } from './constants'; import { getInputsForTask } from './getInputs'; import { LaunchState, @@ -19,9 +20,10 @@ import { taskLaunchMachine, TaskLaunchTypestate } from './launchMachine'; -import { validate } from './services'; +import { validate as baseValidate } from './services'; import { LaunchFormInputsRef, + LaunchRoleInputRef, LaunchTaskFormProps, LaunchTaskFormState, ParsedInput @@ -93,9 +95,25 @@ async function loadInputs( }; } +async function validate( + formInputsRef: RefObject, + roleInputRef: RefObject, + context: any +) { + if (roleInputRef.current === null) { + throw new Error('Unexpected empty role input ref'); + } + + if (!roleInputRef.current.validate()) { + throw new Error(correctInputErrors); + } + return baseValidate(formInputsRef, context); +} + async function submit( { createWorkflowExecution }: APIContextValue, formInputsRef: RefObject, + roleInputRef: RefObject, { referenceExecutionId, taskVersion }: TaskLaunchContext ) { if (!taskVersion) { @@ -104,11 +122,17 @@ async function submit( if (formInputsRef.current === null) { throw new Error('Unexpected empty form inputs ref'); } + if (roleInputRef.current === null) { + throw new Error('Unexpected empty role input ref'); + } + + const authRole = roleInputRef.current.getValue(); const literals = formInputsRef.current.getValues(); const launchPlanId = taskVersion; const { domain, project } = taskVersion; const response = await createWorkflowExecution({ + authRole, domain, launchPlanId, project, @@ -125,13 +149,14 @@ async function submit( function getServices( apiContext: APIContextValue, - formInputsRef: RefObject + formInputsRef: RefObject, + roleInputRef: RefObject ) { return { loadTaskVersions: partial(loadTaskVersions, apiContext), loadInputs: partial(loadInputs, apiContext), - submit: partial(submit, apiContext, formInputsRef), - validate: partial(validate, formInputsRef) + submit: partial(submit, apiContext, formInputsRef, roleInputRef), + validate: partial(validate, formInputsRef, roleInputRef) }; } @@ -146,17 +171,19 @@ export function useLaunchTaskFormState({ // These values will be used to auto-select items from the task // version/launch plan drop downs. const { + authRole: defaultAuthRole, taskId: preferredTaskId, values: defaultInputValues } = initialParameters; const apiContext = useAPIContext(); const formInputsRef = useRef(null); + const roleInputRef = useRef(null); - const services = useMemo(() => getServices(apiContext, formInputsRef), [ - apiContext, - formInputsRef - ]); + const services = useMemo( + () => getServices(apiContext, formInputsRef, roleInputRef), + [apiContext, formInputsRef, roleInputRef] + ); const [state, sendEvent, service] = useMachine< TaskLaunchContext, @@ -166,6 +193,7 @@ export function useLaunchTaskFormState({ ...defaultStateMachineConfig, services, context: { + defaultAuthRole, defaultInputValues, preferredTaskId, referenceExecutionId, @@ -222,6 +250,7 @@ export function useLaunchTaskFormState({ return { formInputsRef, + roleInputRef, state, service, taskSourceSelectorState diff --git a/src/components/Launch/LaunchForm/utils.ts b/src/components/Launch/LaunchForm/utils.ts index 6f24fd675..3761402e8 100644 --- a/src/components/Launch/LaunchForm/utils.ts +++ b/src/components/Launch/LaunchForm/utils.ts @@ -13,8 +13,10 @@ import * as moment from 'moment'; import { simpleTypeToInputType, typeLabels } from './constants'; import { inputToLiteral } from './inputHelpers/inputHelpers'; import { typeIsSupported } from './inputHelpers/utils'; +import { LaunchState } from './launchMachine'; import { SearchableSelectorOption } from './SearchableSelector'; import { + BaseInterpretedLaunchState, BlobValue, InputProps, InputType, @@ -195,3 +197,17 @@ export function getUnsupportedRequiredInputs( export function isBlobValue(value: unknown): value is BlobValue { return isObject(value); } + +/** Determines if a given launch machine state is one in which a user can provide input values. */ +export function isEnterInputsState(state: BaseInterpretedLaunchState): boolean { + return [ + 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); +} diff --git a/src/models/Execution/api.ts b/src/models/Execution/api.ts index 0786ae03e..e8dda112b 100644 --- a/src/models/Execution/api.ts +++ b/src/models/Execution/api.ts @@ -85,6 +85,7 @@ export const getExecutionData = ( ); export interface CreateWorkflowExecutionArguments { + authRole?: Admin.IAuthRole; domain: string; inputs: Core.ILiteralMap; launchPlanId: Identifier; @@ -96,6 +97,7 @@ export interface CreateWorkflowExecutionArguments { */ export const createWorkflowExecution = ( { + authRole, domain, inputs, launchPlanId: launchPlan, @@ -113,6 +115,7 @@ export const createWorkflowExecution = ( project, domain, spec: { + authRole, inputs, launchPlan, metadata: { diff --git a/src/models/Execution/types.ts b/src/models/Execution/types.ts index 0ad9046f6..41a85f98f 100644 --- a/src/models/Execution/types.ts +++ b/src/models/Execution/types.ts @@ -47,6 +47,7 @@ export interface ExecutionMetadata extends Admin.IExecutionMetadata { } export interface ExecutionSpec extends Admin.IExecutionSpec { + authRole?: Admin.IAuthRole; inputs: LiteralMap; launchPlan: Identifier; metadata: ExecutionMetadata;