diff --git a/src/components/Launch/LaunchForm/LaunchForm.tsx b/src/components/Launch/LaunchForm/LaunchForm.tsx index 524b3ee38..1f724c93e 100644 --- a/src/components/Launch/LaunchForm/LaunchForm.tsx +++ b/src/components/Launch/LaunchForm/LaunchForm.tsx @@ -3,6 +3,7 @@ import { createInputValueCache, InputValueCacheContext } from './inputValueCache'; +import { LaunchTaskForm } from './LaunchTaskForm'; import { LaunchWorkflowForm } from './LaunchWorkflowForm'; import { LaunchFormProps, LaunchWorkflowFormProps } from './types'; @@ -21,7 +22,9 @@ export const LaunchForm: React.FC = props => { {isWorkflowPropsObject(props) ? ( - ) : null} + ) : ( + + )} ); }; diff --git a/src/components/Launch/LaunchForm/LaunchFormInputs.tsx b/src/components/Launch/LaunchForm/LaunchFormInputs.tsx index bf7cbcdc0..01ae9adb5 100644 --- a/src/components/Launch/LaunchForm/LaunchFormInputs.tsx +++ b/src/components/Launch/LaunchForm/LaunchFormInputs.tsx @@ -34,12 +34,13 @@ function getComponentForInput(input: InputProps, showErrors: boolean) { export interface LaunchFormInputsProps { state: BaseInterpretedLaunchState; + variant: 'workflow' | 'task'; } export const LaunchFormInputsImpl: React.RefForwardingComponent< LaunchFormInputsRef, LaunchFormInputsProps -> = ({ state }, ref) => { +> = ({ state, variant }, ref) => { const { parsedInputs, unsupportedRequiredInputs, @@ -68,6 +69,7 @@ export const LaunchFormInputsImpl: React.RefForwardingComponent< {state.matches(LaunchState.UNSUPPORTED_INPUTS) ? ( ) : ( <> diff --git a/src/components/Launch/LaunchForm/LaunchTaskForm.tsx b/src/components/Launch/LaunchForm/LaunchTaskForm.tsx new file mode 100644 index 000000000..12f52dd7d --- /dev/null +++ b/src/components/Launch/LaunchForm/LaunchTaskForm.tsx @@ -0,0 +1,83 @@ +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, + LaunchTaskFormProps +} from './types'; +import { useLaunchTaskFormState } from './useLaunchTaskFormState'; + +/** Renders the form for initiating a Launch request based on a Task */ +export const LaunchTaskForm: React.FC = props => { + const { + formInputsRef, + state, + service, + taskSourceSelectorState + } = useLaunchTaskFormState(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, + onSelectTaskVersion, + selectedTask, + taskSelectorOptions + } = taskSourceSelectorState; + + const showTaskSelector = ![ + LaunchState.LOADING_TASK_VERSIONS, + LaunchState.FAILED_LOADING_TASK_VERSIONS + ].some(state.matches); + + // TODO: We removed all loading indicators here. Decide if we want skeletons + // instead. + return ( + <> + + + {showTaskSelector ? ( +
+ +
+ ) : null} + +
+ + + ); +}; diff --git a/src/components/Launch/LaunchForm/LaunchWorkflowForm.tsx b/src/components/Launch/LaunchForm/LaunchWorkflowForm.tsx index 3c1baa76b..fffe4d7ae 100644 --- a/src/components/Launch/LaunchForm/LaunchWorkflowForm.tsx +++ b/src/components/Launch/LaunchForm/LaunchWorkflowForm.tsx @@ -93,6 +93,7 @@ export const LaunchWorkflowForm: React.FC = props => { key={formKey} ref={formInputsRef} state={baseState} + variant="workflow" /> = ({ - inputs + inputs, + variant }) => { const styles = useStyles(); const commonStyles = useCommonStyles(); + const [titleString, errorString] = + variant === 'workflow' + ? [ + cannotLaunchWorkflowString, + workflowUnsupportedRequiredInputsString + ] + : [cannotLaunchTaskString, taskUnsupportedRequiredInputsString]; return (
-

{unsupportedRequiredInputsString}

+

{errorString}

    {inputs.map(input => (
  • ): TypedInterface { return { diff --git a/src/components/Launch/LaunchForm/__stories__/LaunchForm.stories.tsx b/src/components/Launch/LaunchForm/__stories__/LaunchForm.stories.tsx index 0dd54b95e..ad05ec08d 100644 --- a/src/components/Launch/LaunchForm/__stories__/LaunchForm.stories.tsx +++ b/src/components/Launch/LaunchForm/__stories__/LaunchForm.stories.tsx @@ -23,7 +23,7 @@ import { import { mockWorkflowExecutionResponse } from 'models/Execution/__mocks__/mockWorkflowExecutionsData'; import * as React from 'react'; import { - createMockWorkflowInputsInterface, + createMockInputsInterface, mockCollectionVariables, mockNestedCollectionVariables, mockSimpleVariables, @@ -44,7 +44,7 @@ const submitAction = action('createWorkflowExecution'); const generateMocks = (variables: Record) => { const mockWorkflow = createMockWorkflow('MyWorkflow'); mockWorkflow.closure = createMockWorkflowClosure(); - mockWorkflow.closure!.compiledWorkflow!.primary.template.interface = createMockWorkflowInputsInterface( + mockWorkflow.closure!.compiledWorkflow!.primary.template.interface = createMockInputsInterface( variables ); @@ -95,7 +95,7 @@ const generateMocks = (variables: Record) => { id }; workflow.closure = createMockWorkflowClosure(); - workflow.closure!.compiledWorkflow!.primary.template.interface = createMockWorkflowInputsInterface( + workflow.closure!.compiledWorkflow!.primary.template.interface = createMockInputsInterface( variables ); diff --git a/src/components/Launch/LaunchForm/constants.ts b/src/components/Launch/LaunchForm/constants.ts index c873f7ab2..31f18c1e4 100644 --- a/src/components/Launch/LaunchForm/constants.ts +++ b/src/components/Launch/LaunchForm/constants.ts @@ -17,7 +17,8 @@ export const formStrings = { cancel: 'Cancel', inputs: 'Inputs', submit: 'Launch', - title: 'Launch Workflow', + taskVersion: 'Task Version', + title: 'Create New Execution', workflowVersion: 'Workflow Version', launchPlan: 'Launch Plan' }; @@ -62,6 +63,8 @@ export const defaultBlobValue: BlobValue = { export const requiredInputSuffix = '*'; export const cannotLaunchWorkflowString = 'Workflow cannot be launched'; -export const unsupportedRequiredInputsString = `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 cannotLaunchTaskString = 'Task cannot be launched'; +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...'; diff --git a/src/components/Launch/LaunchForm/getInputs.ts b/src/components/Launch/LaunchForm/getInputs.ts index 63dd7c754..16b233265 100644 --- a/src/components/Launch/LaunchForm/getInputs.ts +++ b/src/components/Launch/LaunchForm/getInputs.ts @@ -1,11 +1,12 @@ import { sortedObjectEntries } from 'common/utils'; -import { LaunchPlan, Workflow } from 'models'; +import { LaunchPlan, Task, Workflow } from 'models'; import { requiredInputSuffix } from './constants'; import { LiteralValueMap, ParsedInput } from './types'; import { createInputCacheKey, formatLabelWithType, getInputDefintionForLiteralType, + getTaskInputs, getWorkflowInputs } from './utils'; @@ -13,7 +14,7 @@ import { // to depend on the existence of a value const emptyDescription = ' '; -export function getInputs( +export function getInputsForWorkflow( workflow: Workflow, launchPlan: LaunchPlan, initialValues: LiteralValueMap = new Map() @@ -57,3 +58,35 @@ export function getInputs( }; }); } + +export function getInputsForTask( + task: Task, + initialValues: LiteralValueMap = new Map() +): ParsedInput[] { + if (!task) { + return []; + } + + const taskInputs = getTaskInputs(task); + return sortedObjectEntries(taskInputs).map(value => { + const [name, { description = emptyDescription, type }] = value; + const typeDefinition = getInputDefintionForLiteralType(type); + const typeLabel = formatLabelWithType(name, typeDefinition); + const label = `${typeLabel}${requiredInputSuffix}`; + const inputKey = createInputCacheKey(name, typeDefinition); + const initialValue = initialValues.has(inputKey) + ? initialValues.get(inputKey) + : undefined; + + return { + description, + initialValue, + label, + name, + typeDefinition, + // Task inputs are always required, as there is no default provided + // by a parent workflow. + required: true + }; + }); +} diff --git a/src/components/Launch/LaunchForm/launchMachine.ts b/src/components/Launch/LaunchForm/launchMachine.ts index 4ef623961..e8a91b080 100644 --- a/src/components/Launch/LaunchForm/launchMachine.ts +++ b/src/components/Launch/LaunchForm/launchMachine.ts @@ -2,6 +2,7 @@ import { Identifier, LaunchPlan, NamedEntityIdentifier, + Task, Workflow, WorkflowExecutionIdentifier, WorkflowId @@ -30,7 +31,7 @@ export type SelectLaunchPlanEvent = { }; export type WorkflowVersionOptionsLoadedEvent = DoneInvokeEvent; export type LaunchPlanOptionsLoadedEvent = DoneInvokeEvent; -export type TaskVersionOptionsLoadedEvent = DoneInvokeEvent; +export type TaskVersionOptionsLoadedEvent = DoneInvokeEvent; export type ExecutionCreatedEvent = DoneInvokeEvent< WorkflowExecutionIdentifier >; @@ -81,8 +82,9 @@ export interface WorkflowLaunchContext extends BaseLaunchContext { } export interface TaskLaunchContext extends BaseLaunchContext { + preferredTaskId?: Identifier; taskVersion?: Identifier; - taskVersionOptions?: Identifier[]; + taskVersionOptions?: Task[]; } export enum LaunchState { @@ -199,7 +201,7 @@ export type WorkflowLaunchTypestate = | { value: LaunchState.SELECT_WORKFLOW_VERSION; context: WorkflowLaunchContext & { - sourceId: WorkflowId; + sourceId: NamedEntityIdentifier; workflowVersionOptions: Workflow[]; }; } @@ -207,13 +209,20 @@ export type WorkflowLaunchTypestate = value: LaunchState.SELECT_LAUNCH_PLAN; context: WorkflowLaunchContext & { launchPlanOptions: LaunchPlan[]; - sourceId: WorkflowId; + sourceId: NamedEntityIdentifier; workflowVersionOptions: Workflow[]; }; }; -// TODO: -export type TaskLaunchTypestate = BaseLaunchTypestate; +export type TaskLaunchTypestate = + | BaseLaunchTypestate + | { + value: LaunchState.SELECT_TASK_VERSION; + context: TaskLaunchContext & { + sourceId: NamedEntityIdentifier; + taskVersionOptions: Task[]; + }; + }; const defaultBaseContext: BaseLaunchContext = { parsedInputs: [], diff --git a/src/components/Launch/LaunchForm/services.ts b/src/components/Launch/LaunchForm/services.ts new file mode 100644 index 000000000..248c80710 --- /dev/null +++ b/src/components/Launch/LaunchForm/services.ts @@ -0,0 +1,18 @@ +import { RefObject } from 'react'; +import { WorkflowLaunchContext } from './launchMachine'; +import { LaunchFormInputsRef } from './types'; + +export async function validate( + formInputsRef: RefObject, + {}: WorkflowLaunchContext +) { + if (formInputsRef.current === null) { + throw new Error('Unexpected empty form inputs ref'); + } + + if (!formInputsRef.current.validate()) { + throw new Error( + 'Some inputs have errors. Please correct them before submitting.' + ); + } +} diff --git a/src/components/Launch/LaunchForm/test/LaunchTaskForm.test.tsx b/src/components/Launch/LaunchForm/test/LaunchTaskForm.test.tsx new file mode 100644 index 000000000..f88ad7d25 --- /dev/null +++ b/src/components/Launch/LaunchForm/test/LaunchTaskForm.test.tsx @@ -0,0 +1,593 @@ +import { ThemeProvider } from '@material-ui/styles'; +import { + act, + fireEvent, + getAllByRole, + getByLabelText, + getByRole, + queryAllByRole, + render, + waitFor +} from '@testing-library/react'; +import { mockAPIContextValue } from 'components/data/__mocks__/apiContext'; +import { APIContext } from 'components/data/apiContext'; +import { muiTheme } from 'components/Theme'; +import { Core } from 'flyteidl'; +import { cloneDeep, get } from 'lodash'; +import { + createWorkflowExecution, + CreateWorkflowExecutionArguments, + getTask, + Identifier, + listTasks, + NamedEntityIdentifier, + RequestConfig, + Task, + Variable +} from 'models'; +import { createMockTaskClosure } from 'models/__mocks__/taskData'; +import * as React from 'react'; +import { delayedPromise, pendingPromise } from 'test/utils'; +import { + createMockInputsInterface, + mockSimpleVariables, + simpleVariableDefaults +} from '../__mocks__/mockInputs'; +import { + cannotLaunchTaskString, + formStrings, + requiredInputSuffix +} from '../constants'; +import { LaunchForm } from '../LaunchForm'; +import { LaunchFormProps, TaskInitialLaunchParameters } from '../types'; +import { createInputCacheKey, getInputDefintionForLiteralType } from '../utils'; +import { + binaryInputName, + booleanInputName, + floatInputName, + integerInputName, + stringInputName +} from './constants'; +import { createMockObjects } from './utils'; + +describe('LaunchForm: Task', () => { + let onClose: jest.Mock; + let mockTask: Task; + let mockTaskVersions: Task[]; + let taskId: NamedEntityIdentifier; + let variables: Record; + + let mockListTasks: jest.Mock>; + let mockGetTask: jest.Mock>; + let mockCreateWorkflowExecution: jest.Mock>; + + beforeEach(() => { + onClose = jest.fn(); + }); + + const createMockTaskWithInputs = (id: Identifier) => { + const task: Task = { + id, + closure: createMockTaskClosure() + }; + task.closure!.compiledTask!.template.interface = createMockInputsInterface( + variables + ); + return task; + }; + + const createMocks = () => { + const mockObjects = createMockObjects(variables); + mockTask = mockObjects.mockTask; + + mockTaskVersions = mockObjects.mockTaskVersions; + + taskId = mockTask.id; + mockCreateWorkflowExecution = jest.fn(); + // Return our mock inputs for any version requested + mockGetTask = jest + .fn() + .mockImplementation(id => + Promise.resolve(createMockTaskWithInputs(id)) + ); + + // For workflow/task list endpoints: If the scope has a filter, the calling + // code is searching for a specific item. So we'll return a single-item + // list containing it. + mockListTasks = jest + .fn() + .mockImplementation( + (scope: Partial, { filter }: RequestConfig) => { + if (filter && filter[0].key === 'version') { + const task = { ...mockTaskVersions[0] }; + task.id = { + ...scope, + version: filter[0].value + } as Identifier; + return Promise.resolve({ + entities: [task] + }); + } + return Promise.resolve({ entities: mockTaskVersions }); + } + ); + }; + + const renderForm = (props?: Partial) => { + return render( + + + + + + ); + }; + + const getSubmitButton = (container: HTMLElement) => { + const buttons = queryAllByRole(container, 'button').filter( + el => el.getAttribute('type') === 'submit' + ); + expect(buttons.length).toBe(1); + return buttons[0]; + }; + + const fillInputs = (container: HTMLElement) => { + fireEvent.change( + getByLabelText(container, stringInputName, { + exact: false + }), + { target: { value: 'abc' } } + ); + fireEvent.change( + getByLabelText(container, integerInputName, { + exact: false + }), + { target: { value: '10' } } + ); + fireEvent.change( + getByLabelText(container, floatInputName, { + exact: false + }), + { target: { value: '1.5' } } + ); + }; + + describe('With Simple Inputs', () => { + beforeEach(() => { + const { + simpleString, + simpleInteger, + simpleFloat, + simpleBoolean + } = cloneDeep(mockSimpleVariables); + // Only taking supported variable types since they are all required. + variables = { + simpleString, + simpleInteger, + simpleFloat, + simpleBoolean + }; + createMocks(); + }); + + it('should not show task selector until options have loaded', async () => { + mockListTasks.mockReturnValue(pendingPromise()); + const { getByText, queryByText } = renderForm(); + await waitFor(() => getByText(formStrings.title)); + expect( + queryByText(formStrings.taskVersion) + ).not.toBeInTheDocument(); + }); + + it('should select the most recent task version by default', async () => { + const { getByLabelText } = renderForm(); + const versionEl = await waitFor(() => + getByLabelText(formStrings.taskVersion) + ); + expect(versionEl).toHaveValue(mockTaskVersions[0].id.version); + }); + + it('should disable submit button until inputs have loaded', async () => { + let identifier: Identifier = {} as Identifier; + const { promise, resolve } = delayedPromise(); + mockGetTask.mockImplementation(id => { + identifier = id; + return promise; + }); + const { container } = renderForm(); + + const submitButton = await waitFor(() => + getSubmitButton(container) + ); + + expect(submitButton).toBeDisabled(); + resolve(createMockTaskWithInputs(identifier)); + + await waitFor(() => expect(submitButton).not.toBeDisabled()); + }); + + it('should not show validation errors until first submit', async () => { + const { container, getByLabelText } = renderForm(); + const integerInput = await waitFor(() => + getByLabelText(integerInputName, { + exact: false + }) + ); + fireEvent.change(integerInput, { target: { value: 'abc' } }); + + await waitFor(() => expect(integerInput).not.toBeInvalid()); + + fireEvent.click(getSubmitButton(container)); + await waitFor(() => expect(integerInput).toBeInvalid()); + }); + + it('should update validation errors while typing', async () => { + const { container, getByLabelText } = renderForm(); + await waitFor(() => {}); + + const integerInput = await waitFor(() => + getByLabelText(integerInputName, { + exact: false + }) + ); + fireEvent.change(integerInput, { target: { value: 'abc' } }); + fireEvent.click(getSubmitButton(container)); + await waitFor(() => expect(integerInput).toBeInvalid()); + + fireEvent.change(integerInput, { target: { value: '123' } }); + await waitFor(() => expect(integerInput).toBeValid()); + }); + + it('should update inputs when selecting a new task version', async () => { + const { queryByLabelText, getByTitle } = renderForm(); + const taskVersionDiv = await waitFor(() => + getByTitle(formStrings.taskVersion) + ); + + // Delete the string input so that its corresponding input will + // disappear after the new launch plan is loaded. + delete variables[stringInputName]; + + // Click the expander for the task version, select the second item + 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( + queryByLabelText(stringInputName, { + // Don't use exact match because the label will be decorated with type info + exact: false + }) + ).toBeNull(); + }); + + it('should preserve input values when changing task version', async () => { + const { getByLabelText, getByTitle } = renderForm(); + + const integerInput = await waitFor(() => + getByLabelText(integerInputName, { + exact: false + }) + ); + fireEvent.change(integerInput, { target: { value: '10' } }); + + // 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(integerInputName, { + exact: false + }) + ).toHaveValue('10'); + }); + + it('should reset form error when inputs change', async () => { + const errorString = 'Something went wrong'; + mockCreateWorkflowExecution.mockRejectedValue( + new Error(errorString) + ); + + const { + container, + getByText, + getByTitle, + queryByText + } = renderForm(); + await waitFor(() => getByTitle(formStrings.inputs)); + fillInputs(container); + + fireEvent.click(getSubmitButton(container)); + await waitFor(() => + expect(getByText(errorString)).toBeInTheDocument() + ); + + // Click the expander for the launch plan, 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(() => + expect(queryByText(errorString)).not.toBeInTheDocument() + ); + }); + + describe('Input Values', () => { + it('Should send false for untouched toggles', async () => { + let inputs: Core.ILiteralMap = {}; + mockCreateWorkflowExecution.mockImplementation( + ({ + inputs: passedInputs + }: CreateWorkflowExecutionArguments) => { + inputs = passedInputs; + return pendingPromise(); + } + ); + + const { container, getByTitle } = renderForm(); + await waitFor(() => getByTitle(formStrings.inputs)); + fillInputs(container); + + fireEvent.click(getSubmitButton(container)); + await waitFor(() => + expect(mockCreateWorkflowExecution).toHaveBeenCalled() + ); + + expect(inputs.literals).toBeDefined(); + const value = get( + inputs.literals, + `${booleanInputName}.scalar.primitive.boolean` + ); + expect(value).toBe(false); + }); + + it('should decorate all inputs with required labels', async () => { + const { getByTitle, queryAllByText } = renderForm(); + await waitFor(() => getByTitle(formStrings.inputs)); + Object.keys(variables).forEach(name => { + const elements = queryAllByText(name, { + exact: false + }); + expect(elements.length).toBeGreaterThan(0); + expect(elements[0].textContent).toContain('*'); + }); + }); + }); + + describe('When using initial parameters', () => { + it('should prefer the provided task version', async () => { + const initialParameters: TaskInitialLaunchParameters = { + taskId: mockTaskVersions[2].id + }; + const { getByLabelText } = renderForm({ initialParameters }); + await waitFor(() => + expect(getByLabelText(formStrings.taskVersion)).toHaveValue( + mockTaskVersions[2].id.version + ) + ); + }); + + it('should only include one instance of the preferred version in the selector', async () => { + const initialParameters: TaskInitialLaunchParameters = { + taskId: mockTaskVersions[2].id + }; + const { getByTitle } = renderForm({ initialParameters }); + + // Click the expander for the workflow, select the second item + const versionDiv = await waitFor(() => + getByTitle(formStrings.taskVersion) + ); + const expander = getByRole(versionDiv, 'button'); + fireEvent.click(expander); + const items = await waitFor(() => + getAllByRole(versionDiv, 'menuitem') + ); + + const expectedVersion = mockTaskVersions[2].id.version; + expect( + items.filter( + item => + item.textContent && + item.textContent.includes(expectedVersion) + ) + ).toHaveLength(1); + }); + + it('should fall back to the first item in the list if preferred version is not found', async () => { + mockListTasks.mockImplementation( + (scope: Partial) => { + // If we get a request for a specific item, + // simulate not found + if (scope.version) { + return Promise.resolve({ entities: [] }); + } + return Promise.resolve({ + entities: mockTaskVersions + }); + } + ); + const baseId = mockTaskVersions[2].id; + const initialParameters: TaskInitialLaunchParameters = { + taskId: { ...baseId, version: 'nonexistentValue' } + }; + const { getByLabelText } = renderForm({ initialParameters }); + await waitFor(() => + expect(getByLabelText(formStrings.taskVersion)).toHaveValue( + mockTaskVersions[0].id.version + ) + ); + }); + + it('should prepopulate inputs with provided initial values', async () => { + const stringValue = 'initialStringValue'; + const initialStringValue: Core.ILiteral = { + scalar: { primitive: { stringValue } } + }; + const values = new Map(); + const stringCacheKey = createInputCacheKey( + stringInputName, + getInputDefintionForLiteralType( + variables[stringInputName].type + ) + ); + values.set(stringCacheKey, initialStringValue); + const { getByLabelText } = renderForm({ + initialParameters: { values } + }); + await waitFor(() => + expect( + getByLabelText(stringInputName, { exact: false }) + ).toHaveValue(stringValue) + ); + }); + + it('loads preferred task version when it does not exist in the list of suggestions', async () => { + const missingTask = mockTaskVersions[0]; + missingTask.id.version = 'missingVersionString'; + const initialParameters: TaskInitialLaunchParameters = { + taskId: missingTask.id + }; + const { getByLabelText } = renderForm({ initialParameters }); + await waitFor(() => + expect(getByLabelText(formStrings.taskVersion)).toHaveValue( + missingTask.id.version + ) + ); + }); + + it('should select contents of task version input on focus', async () => { + const { getByLabelText } = renderForm(); + + // Focus the workflow version input + const workflowInput = await waitFor(() => + getByLabelText(formStrings.taskVersion) + ); + fireEvent.focus(workflowInput); + + const expectedValue = mockTaskVersions[0].id.version; + + // The value should remain, but selection should be the entire string + await waitFor(() => + expect(workflowInput).toHaveValue(expectedValue) + ); + expect((workflowInput as HTMLInputElement).selectionEnd).toBe( + expectedValue.length + ); + }); + + it('should correctly render task version search results', async () => { + const initialParameters: TaskInitialLaunchParameters = { + taskId: mockTaskVersions[2].id + }; + const inputString = mockTaskVersions[1].id.version.substring( + 0, + 4 + ); + const { getByLabelText } = renderForm({ initialParameters }); + + const versionInput = await waitFor(() => + getByLabelText(formStrings.taskVersion) + ); + mockListTasks.mockClear(); + + fireEvent.change(versionInput, { + target: { value: inputString } + }); + + const { project, domain, name } = mockTaskVersions[2].id; + await waitFor(() => + expect(mockListTasks).toHaveBeenCalledWith( + { project, domain, name }, + expect.anything() + ) + ); + }); + }); + + describe('With Unsupported Required Inputs', () => { + beforeEach(() => { + // Binary is currently unsupported, and all values are required. + // So adding a binary variable will generate our test case. + variables[binaryInputName] = cloneDeep( + mockSimpleVariables[binaryInputName] + ); + }); + + it('should render error message', async () => { + const { getByText } = renderForm(); + const errorElement = await waitFor(() => + getByText(cannotLaunchTaskString) + ); + expect(errorElement).toBeInTheDocument(); + }); + + it('should show unsupported inputs', async () => { + const { getByText } = renderForm(); + const inputElement = await waitFor(() => + getByText(binaryInputName, { exact: false }) + ); + expect(inputElement).toBeInTheDocument(); + }); + + it('should print input labels without decoration', async () => { + const { getByText } = renderForm(); + const inputElement = await waitFor(() => + getByText(binaryInputName, { exact: false }) + ); + expect(inputElement.textContent).not.toContain( + requiredInputSuffix + ); + }); + + it('should disable submission', async () => { + const { getByRole } = renderForm(); + + const submitButton = await waitFor(() => + getByRole('button', { name: formStrings.submit }) + ); + + expect(submitButton).toBeDisabled(); + }); + + it('should not show error if initial value is provided', async () => { + const values = new Map(); + const cacheKey = createInputCacheKey( + binaryInputName, + getInputDefintionForLiteralType( + variables[binaryInputName].type + ) + ); + values.set(cacheKey, simpleVariableDefaults.simpleBinary); + const { getByLabelText, queryByText } = renderForm({ + initialParameters: { values } + }); + + await waitFor(() => + getByLabelText(binaryInputName, { exact: false }) + ); + expect(queryByText(cannotLaunchTaskString)).toBeNull(); + }); + }); + }); +}); diff --git a/src/components/Launch/LaunchForm/test/LaunchForm.test.tsx b/src/components/Launch/LaunchForm/test/LaunchWorkflowForm.test.tsx similarity index 96% rename from src/components/Launch/LaunchForm/test/LaunchForm.test.tsx rename to src/components/Launch/LaunchForm/test/LaunchWorkflowForm.test.tsx index 29d3d1d07..7318a585d 100644 --- a/src/components/Launch/LaunchForm/test/LaunchForm.test.tsx +++ b/src/components/Launch/LaunchForm/test/LaunchWorkflowForm.test.tsx @@ -32,7 +32,7 @@ import { createMockWorkflowClosure } from 'models/__mocks__/workflowData'; import * as React from 'react'; import { delayedPromise, pendingPromise } from 'test/utils'; import { - createMockWorkflowInputsInterface, + createMockInputsInterface, mockSimpleVariables, simpleVariableDefaults } from '../__mocks__/mockInputs'; @@ -42,10 +42,7 @@ import { requiredInputSuffix } from '../constants'; import { LaunchForm } from '../LaunchForm'; -import { - InitialWorkflowLaunchParameters, - LaunchWorkflowFormProps -} from '../types'; +import { LaunchFormProps, WorkflowInitialLaunchParameters } from '../types'; import { createInputCacheKey, getInputDefintionForLiteralType } from '../utils'; import { binaryInputName, @@ -56,7 +53,7 @@ import { } from './constants'; import { createMockObjects } from './utils'; -describe('LaunchWorkflowForm', () => { +describe('LaunchForm: Workflow', () => { let onClose: jest.Mock; let mockLaunchPlans: LaunchPlan[]; let mockSingleLaunchPlan: LaunchPlan; @@ -87,7 +84,7 @@ describe('LaunchWorkflowForm', () => { id }; workflow.closure = createMockWorkflowClosure(); - workflow.closure!.compiledWorkflow!.primary.template.interface = createMockWorkflowInputsInterface( + workflow.closure!.compiledWorkflow!.primary.template.interface = createMockInputsInterface( variables ); return workflow; @@ -135,13 +132,14 @@ describe('LaunchWorkflowForm', () => { return Promise.resolve({ entities: mockLaunchPlans }); } ); + + // For workflow/task list endpoints: If the scope has a filter, the calling + // code is searching for a specific item. So we'll return a single-item + // list containing it. mockListWorkflows = jest .fn() .mockImplementation( (scope: Partial, { filter }: RequestConfig) => { - // If the scope has a filter, the calling - // code is searching for a specific item. So we'll - // return a single-item list containing it. if (filter && filter[0].key === 'version') { const workflow = { ...mockWorkflowVersions[0] }; workflow.id = { @@ -157,7 +155,7 @@ describe('LaunchWorkflowForm', () => { ); }; - const renderForm = (props?: Partial) => { + const renderForm = (props?: Partial) => { return render( { describe('When using initial parameters', () => { it('should prefer the provided workflow version', async () => { - const initialParameters: InitialWorkflowLaunchParameters = { + const initialParameters: WorkflowInitialLaunchParameters = { workflow: mockWorkflowVersions[2].id }; const { getByLabelText } = renderForm({ initialParameters }); @@ -514,7 +512,7 @@ describe('LaunchWorkflowForm', () => { }); it('should only include one instance of the preferred version in the selector', async () => { - const initialParameters: InitialWorkflowLaunchParameters = { + const initialParameters: WorkflowInitialLaunchParameters = { workflow: mockWorkflowVersions[2].id }; const { getByTitle } = renderForm({ initialParameters }); @@ -551,7 +549,7 @@ describe('LaunchWorkflowForm', () => { } ); const baseId = mockWorkflowVersions[2].id; - const initialParameters: InitialWorkflowLaunchParameters = { + const initialParameters: WorkflowInitialLaunchParameters = { workflow: { ...baseId, version: 'nonexistentValue' } }; const { getByLabelText } = renderForm({ initialParameters }); @@ -562,7 +560,7 @@ describe('LaunchWorkflowForm', () => { }); it('should prefer the provided launch plan', async () => { - const initialParameters: InitialWorkflowLaunchParameters = { + const initialParameters: WorkflowInitialLaunchParameters = { launchPlan: mockLaunchPlans[1].id }; const { getByLabelText } = renderForm({ initialParameters }); @@ -573,7 +571,7 @@ describe('LaunchWorkflowForm', () => { }); it('should only include one instance of the preferred launch plan in the selector', async () => { - const initialParameters: InitialWorkflowLaunchParameters = { + const initialParameters: WorkflowInitialLaunchParameters = { launchPlan: mockLaunchPlans[1].id }; const { getByTitle } = renderForm({ initialParameters }); @@ -609,7 +607,7 @@ describe('LaunchWorkflowForm', () => { ); const launchPlanId = { ...mockLaunchPlans[1].id }; launchPlanId.name = 'InvalidLauchPlan'; - const initialParameters: InitialWorkflowLaunchParameters = { + const initialParameters: WorkflowInitialLaunchParameters = { launchPlan: launchPlanId }; const { getByLabelText } = renderForm({ initialParameters }); @@ -676,7 +674,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: InitialWorkflowLaunchParameters = { + const initialParameters: WorkflowInitialLaunchParameters = { workflow: missingWorkflow.id }; const { getByLabelText } = renderForm({ initialParameters }); @@ -689,7 +687,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: InitialWorkflowLaunchParameters = { + const initialParameters: WorkflowInitialLaunchParameters = { launchPlan: missingLaunchPlan.id }; const { getByLabelText } = renderForm({ initialParameters }); @@ -723,7 +721,7 @@ describe('LaunchWorkflowForm', () => { }); it('should correctly render workflow version search results', async () => { - const initialParameters: InitialWorkflowLaunchParameters = { + const initialParameters: WorkflowInitialLaunchParameters = { workflow: mockWorkflowVersions[2].id }; const inputString = mockWorkflowVersions[1].id.version.substring( @@ -756,8 +754,6 @@ describe('LaunchWorkflowForm', () => { describe('With Unsupported Required Inputs', () => { beforeEach(() => { - // variables = mockSimpleVariables; - // createMocks(); // Binary is currently unsupported, setting the binary input to // required and removing the default value will trigger our use case const parameters = mockLaunchPlans[0].closure!.expectedInputs diff --git a/src/components/Launch/LaunchForm/test/constants.ts b/src/components/Launch/LaunchForm/test/constants.ts index 1c7df2799..a4f8ab0ae 100644 --- a/src/components/Launch/LaunchForm/test/constants.ts +++ b/src/components/Launch/LaunchForm/test/constants.ts @@ -1,6 +1,9 @@ export const booleanInputName = 'simpleBoolean'; export const stringInputName = 'simpleString'; export const stringNoLabelName = 'stringNoLabel'; +export const floatInputName = 'simpleFloat'; +export const durationInputName = 'simpleDuration'; +export const datetimeInputName = 'simpleDatetime'; export const integerInputName = 'simpleInteger'; export const binaryInputName = 'simpleBinary'; export const errorInputName = 'simpleError'; diff --git a/src/components/Launch/LaunchForm/test/getInputs.test.ts b/src/components/Launch/LaunchForm/test/getInputs.test.ts index e9b4fb896..231ff55b4 100644 --- a/src/components/Launch/LaunchForm/test/getInputs.test.ts +++ b/src/components/Launch/LaunchForm/test/getInputs.test.ts @@ -1,5 +1,5 @@ import { mockSimpleVariables } from '../__mocks__/mockInputs'; -import { getInputs } from '../getInputs'; +import { getInputsForWorkflow } from '../getInputs'; import { stringInputName } from './constants'; import { createMockObjects } from './utils'; @@ -15,9 +15,13 @@ describe('getInputs', () => { const parameters = launchPlan.closure!.expectedInputs.parameters; parameters[stringInputName].default = null; - expect(() => getInputs(mockWorkflow, launchPlan)).not.toThrowError(); + expect(() => + getInputsForWorkflow(mockWorkflow, launchPlan) + ).not.toThrowError(); delete parameters[stringInputName].default; - expect(() => getInputs(mockWorkflow, launchPlan)).not.toThrowError(); + expect(() => + getInputsForWorkflow(mockWorkflow, launchPlan) + ).not.toThrowError(); }); }); diff --git a/src/components/Launch/LaunchForm/test/utils.ts b/src/components/Launch/LaunchForm/test/utils.ts index e641c417f..4ba1353a7 100644 --- a/src/components/Launch/LaunchForm/test/utils.ts +++ b/src/components/Launch/LaunchForm/test/utils.ts @@ -1,6 +1,10 @@ import { mapValues } from 'lodash'; import { Variable } from 'models'; import { createMockLaunchPlan } from 'models/__mocks__/launchPlanData'; +import { + createMockTask, + createMockTaskVersions +} from 'models/__mocks__/taskData'; import { createMockWorkflow, createMockWorkflowVersions @@ -8,12 +12,15 @@ import { export function createMockObjects(variables: Record) { const mockWorkflow = createMockWorkflow('MyWorkflow'); + const mockTask = createMockTask('MyTask'); const mockWorkflowVersions = createMockWorkflowVersions( mockWorkflow.id.name, 10 ); + const mockTaskVersions = createMockTaskVersions(mockTask.id.name, 10); + const mockLaunchPlans = [mockWorkflow.id.name, 'OtherLaunchPlan'].map( name => { const parameterMap = { @@ -27,5 +34,11 @@ export function createMockObjects(variables: Record) { return launchPlan; } ); - return { mockWorkflow, mockLaunchPlans, mockWorkflowVersions }; + return { + mockWorkflow, + mockLaunchPlans, + mockTask, + mockTaskVersions, + mockWorkflowVersions + }; } diff --git a/src/components/Launch/LaunchForm/types.ts b/src/components/Launch/LaunchForm/types.ts index 0d58e80de..f372a3767 100644 --- a/src/components/Launch/LaunchForm/types.ts +++ b/src/components/Launch/LaunchForm/types.ts @@ -4,6 +4,8 @@ import { Identifier, LaunchPlan, NamedEntityIdentifier, + Task, + Workflow, WorkflowId } from 'models'; import { Interpreter, State } from 'xstate'; @@ -22,6 +24,7 @@ import { SearchableSelectorOption } from './SearchableSelector'; export type InputValueMap = Map; export type LiteralValueMap = Map; +export type SearchableVersion = Workflow | Task; export type BaseInterpretedLaunchState = State< BaseLaunchContext, @@ -52,28 +55,28 @@ export interface WorkflowInitialLaunchParameters } export interface LaunchWorkflowFormProps extends BaseLaunchFormProps { workflowId: NamedEntityIdentifier; - initialParameters?: InitialWorkflowLaunchParameters; + initialParameters?: WorkflowInitialLaunchParameters; } export interface TaskInitialLaunchParameters extends BaseInitialLaunchParameters { - taskId?: NamedEntityIdentifier; + taskId?: Identifier; } export interface LaunchTaskFormProps extends BaseLaunchFormProps { taskId: NamedEntityIdentifier; - initialParameters?: InitialWorkflowLaunchParameters; + initialParameters?: TaskInitialLaunchParameters; } export type LaunchFormProps = LaunchWorkflowFormProps | LaunchTaskFormProps; -export interface InitialWorkflowLaunchParameters { +export interface WorkflowInitialLaunchParameters { launchPlan?: Identifier; workflow?: WorkflowId; values?: LiteralValueMap; } export interface LaunchWorkflowFormProps { workflowId: NamedEntityIdentifier; - initialParameters?: InitialWorkflowLaunchParameters; + initialParameters?: WorkflowInitialLaunchParameters; } export interface LaunchFormInputsRef { @@ -95,6 +98,15 @@ export interface WorkflowSourceSelectorState { onSelectLaunchPlan(selected: SearchableSelectorOption): void; } +export interface TaskSourceSelectorState { + selectedTask?: SearchableSelectorOption; + taskSelectorOptions: SearchableSelectorOption[]; + fetchSearchResults( + query: string + ): Promise[]>; + onSelectTaskVersion(selected: SearchableSelectorOption): void; +} + export interface LaunchWorkflowFormState { formInputsRef: React.RefObject; state: State< @@ -121,8 +133,7 @@ export interface LaunchTaskFormState { TaskLaunchEvent, TaskLaunchTypestate >; - // TODO: - // taskSourceSelectorState: any; + taskSourceSelectorState: TaskSourceSelectorState; } export enum InputType { diff --git a/src/components/Launch/LaunchForm/useExecutionLaunchConfiguration.ts b/src/components/Launch/LaunchForm/useExecutionLaunchConfiguration.ts index 374fdf2c3..df59c7249 100644 --- a/src/components/Launch/LaunchForm/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 { InitialWorkflowLaunchParameters, LiteralValueMap } from './types'; +import { LiteralValueMap, WorkflowInitialLaunchParameters } from './types'; import { createInputCacheKey, getInputDefintionForLiteralType } from './utils'; export interface UseExecutionLaunchConfigurationArgs { @@ -17,16 +17,16 @@ export function useExecutionLaunchConfiguration({ execution, workflowInputs }: UseExecutionLaunchConfigurationArgs): FetchableData< - InitialWorkflowLaunchParameters + WorkflowInitialLaunchParameters > { const apiContext = useAPIContext(); return useFetchableData< - InitialWorkflowLaunchParameters, + WorkflowInitialLaunchParameters, UseExecutionLaunchConfigurationArgs >( { debugName: 'ExecutionLaunchConfiguration', - defaultValue: {} as InitialWorkflowLaunchParameters, + defaultValue: {} as WorkflowInitialLaunchParameters, doFetch: async ({ execution, workflowInputs }) => { const { closure: { workflowId }, diff --git a/src/components/Launch/LaunchForm/useLaunchTaskFormState.ts b/src/components/Launch/LaunchForm/useLaunchTaskFormState.ts new file mode 100644 index 000000000..42842b654 --- /dev/null +++ b/src/components/Launch/LaunchForm/useLaunchTaskFormState.ts @@ -0,0 +1,226 @@ +import { useMachine } from '@xstate/react'; +import { defaultStateMachineConfig } from 'components/common/constants'; +import { APIContextValue, useAPIContext } from 'components/data/apiContext'; +import { isEqual, partial, uniqBy } from 'lodash'; +import { + FilterOperationName, + Identifier, + SortDirection, + Task, + taskSortFields, + WorkflowExecutionIdentifier +} from 'models'; +import { RefObject, useEffect, useMemo, useRef } from 'react'; +import { getInputsForTask } from './getInputs'; +import { + LaunchState, + TaskLaunchContext, + TaskLaunchEvent, + taskLaunchMachine, + TaskLaunchTypestate +} from './launchMachine'; +import { validate } from './services'; +import { + LaunchFormInputsRef, + LaunchTaskFormProps, + LaunchTaskFormState, + ParsedInput +} from './types'; +import { useTaskSourceSelectorState } from './useTaskSourceSelectorState'; +import { getUnsupportedRequiredInputs } from './utils'; + +async function loadTaskVersions( + { listTasks }: APIContextValue, + { preferredTaskId, sourceId }: TaskLaunchContext +) { + if (!sourceId) { + throw new Error('Cannot load tasks, missing sourceId'); + } + const { project, domain, name } = sourceId; + const tasksPromise = listTasks( + { project, domain, name }, + { + limit: 10, + sort: { + key: taskSortFields.createdAt, + direction: SortDirection.DESCENDING + } + } + ); + + let preferredTaskPromise = Promise.resolve({ + entities: [] as Task[] + }); + if (preferredTaskId) { + const { version, ...scope } = preferredTaskId; + preferredTaskPromise = listTasks(scope, { + limit: 1, + filter: [ + { + key: 'version', + operation: FilterOperationName.EQ, + value: version + } + ] + }); + } + + const [tasksResult, preferredTaskResult] = await Promise.all([ + tasksPromise, + preferredTaskPromise + ]); + const merged = [...tasksResult.entities, ...preferredTaskResult.entities]; + return uniqBy(merged, ({ id: { version } }) => version); +} + +async function loadInputs( + { getTask }: APIContextValue, + { defaultInputValues, taskVersion }: TaskLaunchContext +) { + if (!taskVersion) { + throw new Error('Failed to load inputs: missing taskVersion'); + } + + const task = await getTask(taskVersion); + const parsedInputs: ParsedInput[] = getInputsForTask( + task, + defaultInputValues + ); + + return { + parsedInputs, + unsupportedRequiredInputs: getUnsupportedRequiredInputs(parsedInputs) + }; +} + +async function submit( + { createWorkflowExecution }: APIContextValue, + formInputsRef: RefObject, + { taskVersion }: TaskLaunchContext +) { + if (!taskVersion) { + throw new Error('Attempting to launch with no Task version'); + } + if (formInputsRef.current === null) { + throw new Error('Unexpected empty form inputs ref'); + } + const literals = formInputsRef.current.getValues(); + const launchPlanId = taskVersion; + const { domain, project } = taskVersion; + + const response = await createWorkflowExecution({ + domain, + launchPlanId, + project, + inputs: { literals } + }); + const newExecutionId = response.id as WorkflowExecutionIdentifier; + if (!newExecutionId) { + throw new Error('API Response did not include new execution id'); + } + + return newExecutionId; +} + +function getServices( + apiContext: APIContextValue, + formInputsRef: RefObject +) { + return { + loadTaskVersions: partial(loadTaskVersions, apiContext), + loadInputs: partial(loadInputs, apiContext), + submit: partial(submit, apiContext, formInputsRef), + validate: partial(validate, formInputsRef) + }; +} + +/** Contains all of the form state for a LaunchTaskForm, including input + * definitions, current input values, and errors. + */ +export function useLaunchTaskFormState({ + initialParameters = {}, + taskId: sourceId +}: LaunchTaskFormProps): LaunchTaskFormState { + // These values will be used to auto-select items from the task + // version/launch plan drop downs. + const { + taskId: preferredTaskId, + values: defaultInputValues + } = initialParameters; + + const apiContext = useAPIContext(); + const formInputsRef = useRef(null); + + const services = useMemo(() => getServices(apiContext, formInputsRef), [ + apiContext, + formInputsRef + ]); + + const [state, sendEvent, service] = useMachine< + TaskLaunchContext, + TaskLaunchEvent, + TaskLaunchTypestate + >(taskLaunchMachine, { + ...defaultStateMachineConfig, + services, + context: { + defaultInputValues, + preferredTaskId, + sourceId + } + }); + + const { taskVersionOptions = [], taskVersion } = state.context; + + const selectTaskVersion = (newTask: Identifier) => { + if (newTask === taskVersion) { + return; + } + sendEvent({ + type: 'SELECT_TASK_VERSION', + taskId: newTask + }); + }; + + const taskSourceSelectorState = useTaskSourceSelectorState({ + sourceId, + selectTaskVersion, + taskVersion, + taskVersionOptions + }); + + useEffect(() => { + const subscription = service.subscribe(newState => { + if (newState.matches(LaunchState.SELECT_TASK_VERSION)) { + const { + taskVersionOptions, + preferredTaskId + } = newState.context; + if (taskVersionOptions.length > 0) { + let taskToSelect = taskVersionOptions[0]; + if (preferredTaskId) { + const preferred = taskVersionOptions.find(({ id }) => + isEqual(id, preferredTaskId) + ); + if (preferred) { + taskToSelect = preferred; + } + } + sendEvent({ + type: 'SELECT_TASK_VERSION', + taskId: taskToSelect.id + }); + } + } + }); + + return subscription.unsubscribe; + }, [service, sendEvent]); + + return { + formInputsRef, + state, + service, + taskSourceSelectorState + }; +} diff --git a/src/components/Launch/LaunchForm/useLaunchWorkflowFormState.ts b/src/components/Launch/LaunchForm/useLaunchWorkflowFormState.ts index f4f0019de..03bfa9b7e 100644 --- a/src/components/Launch/LaunchForm/useLaunchWorkflowFormState.ts +++ b/src/components/Launch/LaunchForm/useLaunchWorkflowFormState.ts @@ -12,7 +12,7 @@ import { workflowSortFields } from 'models'; import { RefObject, useEffect, useMemo, useRef } from 'react'; -import { getInputs } from './getInputs'; +import { getInputsForWorkflow } from './getInputs'; import { LaunchState, WorkflowLaunchContext, @@ -20,6 +20,7 @@ import { workflowLaunchMachine, WorkflowLaunchTypestate } from './launchMachine'; +import { validate } from './services'; import { LaunchFormInputsRef, LaunchWorkflowFormProps, @@ -143,7 +144,7 @@ async function loadInputs( throw new Error('Failed to load inputs: missing launchPlan'); } const workflow = await getWorkflow(workflowVersion); - const parsedInputs: ParsedInput[] = getInputs( + const parsedInputs: ParsedInput[] = getInputsForWorkflow( workflow, launchPlan, defaultInputValues @@ -155,22 +156,6 @@ async function loadInputs( }; } -// TODO: Can be shared between both types of launch form. -async function validate( - formInputsRef: RefObject, - {}: WorkflowLaunchContext -) { - if (formInputsRef.current === null) { - throw new Error('Unexpected empty form inputs ref'); - } - - if (!formInputsRef.current.validate()) { - throw new Error( - 'Some inputs have errors. Please correct them before submitting.' - ); - } -} - async function submit( { createWorkflowExecution }: APIContextValue, formInputsRef: RefObject, diff --git a/src/components/Launch/LaunchForm/useTaskSourceSelectorState.ts b/src/components/Launch/LaunchForm/useTaskSourceSelectorState.ts new file mode 100644 index 000000000..bc8ca04bc --- /dev/null +++ b/src/components/Launch/LaunchForm/useTaskSourceSelectorState.ts @@ -0,0 +1,100 @@ +import { APIContextValue, useAPIContext } from 'components/data/apiContext'; +import { + FilterOperationName, + Identifier, + NamedEntityIdentifier, + SortDirection, + Task, + taskSortFields +} from 'models'; +import { useMemo, useState } from 'react'; +import { SearchableSelectorOption } from './SearchableSelector'; +import { TaskSourceSelectorState } from './types'; +import { useVersionSelectorOptions } from './useVersionSelectorOptions'; +import { versionsToSearchableSelectorOptions } from './utils'; + +function generateFetchSearchResults( + { listTasks }: APIContextValue, + taskId: NamedEntityIdentifier +) { + return async (query: string) => { + const { project, domain, name } = taskId; + const { entities: tasks } = await listTasks( + { project, domain, name }, + { + filter: [ + { + key: 'version', + operation: FilterOperationName.CONTAINS, + value: query + } + ], + sort: { + key: taskSortFields.createdAt, + direction: SortDirection.DESCENDING + } + } + ); + return versionsToSearchableSelectorOptions(tasks); + }; +} + +interface UseTaskSourceSelectorStateArgs { + /** The parent task for which we are selecting a version. */ + sourceId: NamedEntityIdentifier; + /** The currently selected Task version. */ + taskVersion?: Identifier; + /** The list of options to show for the Task selector. */ + taskVersionOptions: Task[]; + /** Callback fired when a task has been selected. */ + selectTaskVersion(task: Identifier): void; +} + +/** Generates state for the version selector rendered when using a task + * as a source in the Launch form. + */ +export function useTaskSourceSelectorState({ + sourceId, + taskVersion, + taskVersionOptions, + selectTaskVersion +}: UseTaskSourceSelectorStateArgs): TaskSourceSelectorState { + const apiContext = useAPIContext(); + const taskSelectorOptions = useVersionSelectorOptions(taskVersionOptions); + const [taskVersionSearchOptions, setTaskVersionSearchOptions] = useState< + SearchableSelectorOption[] + >([]); + + const selectedTask = useMemo(() => { + if (!taskVersion) { + return undefined; + } + // Search both the default and search results to match our selected task + // with the correct SearchableSelector item. + return [...taskSelectorOptions, ...taskVersionSearchOptions].find( + option => option.id === taskVersion.version + ); + }, [taskVersion, taskVersionOptions]); + + const onSelectTaskVersion = useMemo( + () => ({ data }: SearchableSelectorOption) => + selectTaskVersion(data), + [selectTaskVersion] + ); + + const fetchSearchResults = useMemo(() => { + const doFetch = generateFetchSearchResults(apiContext, sourceId); + return async (query: string) => { + const results = await doFetch(query); + setTaskVersionSearchOptions(results); + return results; + }; + }, [apiContext, sourceId, setTaskVersionSearchOptions]); + + return { + fetchSearchResults, + onSelectTaskVersion, + selectedTask, + taskSelectorOptions + }; +} diff --git a/src/components/Launch/LaunchForm/useVersionSelectorOptions.ts b/src/components/Launch/LaunchForm/useVersionSelectorOptions.ts new file mode 100644 index 000000000..f7ee0f00a --- /dev/null +++ b/src/components/Launch/LaunchForm/useVersionSelectorOptions.ts @@ -0,0 +1,13 @@ +import { useMemo } from 'react'; +import { SearchableVersion } from './types'; +import { versionsToSearchableSelectorOptions } from './utils'; + +export function useVersionSelectorOptions(versions: SearchableVersion[]) { + return useMemo(() => { + const options = versionsToSearchableSelectorOptions(versions); + if (options.length > 0) { + options[0].description = 'latest'; + } + return options; + }, [versions]); +} diff --git a/src/components/Launch/LaunchForm/useWorkflowSourceSelectorState.ts b/src/components/Launch/LaunchForm/useWorkflowSourceSelectorState.ts index 9c6881388..e607599be 100644 --- a/src/components/Launch/LaunchForm/useWorkflowSourceSelectorState.ts +++ b/src/components/Launch/LaunchForm/useWorkflowSourceSelectorState.ts @@ -11,21 +11,12 @@ import { import { useMemo, useState } from 'react'; import { SearchableSelectorOption } from './SearchableSelector'; import { WorkflowSourceSelectorState } from './types'; +import { useVersionSelectorOptions } from './useVersionSelectorOptions'; import { launchPlansToSearchableSelectorOptions, - workflowsToSearchableSelectorOptions + versionsToSearchableSelectorOptions } from './utils'; -export function useWorkflowSelectorOptions(workflows: Workflow[]) { - return useMemo(() => { - const options = workflowsToSearchableSelectorOptions(workflows); - if (options.length > 0) { - options[0].description = 'latest'; - } - return options; - }, [workflows]); -} - function useLaunchPlanSelectorOptions(launchPlans: LaunchPlan[]) { return useMemo(() => launchPlansToSearchableSelectorOptions(launchPlans), [ launchPlans @@ -54,7 +45,7 @@ function generateFetchSearchResults( } } ); - return workflowsToSearchableSelectorOptions(workflows); + return versionsToSearchableSelectorOptions(workflows); }; } @@ -88,7 +79,7 @@ export function useWorkflowSourceSelectorState({ selectWorkflowVersion }: UseWorkflowSourceSelectorStateArgs): WorkflowSourceSelectorState { const apiContext = useAPIContext(); - const workflowSelectorOptions = useWorkflowSelectorOptions( + const workflowSelectorOptions = useVersionSelectorOptions( workflowVersionOptions ); const [ diff --git a/src/components/Launch/LaunchForm/utils.ts b/src/components/Launch/LaunchForm/utils.ts index 2434b0210..ba1111dbd 100644 --- a/src/components/Launch/LaunchForm/utils.ts +++ b/src/components/Launch/LaunchForm/utils.ts @@ -2,11 +2,12 @@ import { timestampToDate } from 'common/utils'; import { Core } from 'flyteidl'; import { isObject } from 'lodash'; import { + Identifier, LaunchPlan, LiteralType, + Task, Variable, - Workflow, - WorkflowId + Workflow } from 'models'; import * as moment from 'moment'; import { simpleTypeToInputType, typeLabels } from './constants'; @@ -18,7 +19,8 @@ import { InputProps, InputType, InputTypeDefinition, - ParsedInput + ParsedInput, + SearchableVersion } from './types'; /** Creates a unique cache key for an input based on its name and type. @@ -56,6 +58,25 @@ export function getWorkflowInputs( return inputs.variables; } +export function getTaskInputs(task: Task): Record { + if (!task.closure) { + return {}; + } + const { compiledTask } = task.closure; + if (!compiledTask) { + return {}; + } + const { interface: ioInterface } = compiledTask.template; + if (!ioInterface) { + return {}; + } + const { inputs } = ioInterface; + if (!inputs) { + return {}; + } + return inputs.variables; +} + /** Returns a formatted string based on an InputTypeDefinition. * ex. `string`, `string[]`, `map` */ @@ -77,17 +98,17 @@ export function formatLabelWithType(label: string, type: InputTypeDefinition) { return `${label}${typeString ? ` (${typeString})` : ''}`; } -/** Formats a list of `Workflow` records for use in a `SearchableSelector` */ -export function workflowsToSearchableSelectorOptions( - workflows: Workflow[] -): SearchableSelectorOption[] { - return workflows.map>((wf, index) => ({ - data: wf.id, - id: wf.id.version, - name: wf.id.version, +/** Formats a list of records for use in a `SearchableSelector` */ +export function versionsToSearchableSelectorOptions( + items: SearchableVersion[] +): SearchableSelectorOption[] { + return items.map>((item, index) => ({ + data: item.id, + id: item.id.version, + name: item.id.version, description: - wf.closure && wf.closure.createdAt - ? moment(timestampToDate(wf.closure.createdAt)).format( + item.closure && item.closure.createdAt + ? moment(timestampToDate(item.closure.createdAt)).format( 'DD MMM YYYY' ) : '' diff --git a/src/models/__mocks__/simpleTaskClosure.json b/src/models/__mocks__/simpleTaskClosure.json new file mode 100644 index 000000000..479ea94ab --- /dev/null +++ b/src/models/__mocks__/simpleTaskClosure.json @@ -0,0 +1,69 @@ +{ + "compiledTask": { + "template": { + "id": { + "resource_type": "TASK", + "project": "myflyteproject", + "domain": "development", + "name": "work-find-odd-numbers", + "version": "ABC123" + }, + "type": "python-task", + "metadata": { + "runtime": { + "type": "FlyteSDK", + "version": "0.0.1a0", + "flavor": "python" + }, + "timeout": "0s", + "retries": {}, + "discovery_version": "1" + }, + "interface": { + "inputs": { + "variables": { + "list_of_nums": { + "type": { + "collection_type": { + "simple": "INTEGER" + } + } + } + } + }, + "outputs": { + "variables": { + "are_num_odd": { + "type": { + "collection_type": { + "simple": "BOOLEAN" + } + } + } + } + } + }, + "container": { + "image": "myflyteproject:DEF123", + "command": ["pyflyte-execute"], + "args": [ + "--task-module", + "work", + "--task-name", + "find_odd_numbers", + "--inputs", + "{{.input}}", + "--output-prefix", + "{{.outputPrefix}}" + ], + "resources": {}, + "env": [ + { + "key": "FLYTE_CONFIGURATION_PATH", + "value": "/myflyteproject/flytekit.config" + } + ] + } + } + } +} diff --git a/src/models/__mocks__/taskData.ts b/src/models/__mocks__/taskData.ts new file mode 100644 index 000000000..7c9f9a345 --- /dev/null +++ b/src/models/__mocks__/taskData.ts @@ -0,0 +1,43 @@ +import { getCacheKey } from 'components/Cache'; +import { Admin } from 'flyteidl'; +import { cloneDeep } from 'lodash'; +import { Identifier } from '../Common'; +import { Task, TaskClosure } from '../Task'; +import * as simpleClosure from './simpleTaskClosure.json'; + +const decodedClosure = Admin.TaskClosure.create( + (simpleClosure as unknown) as Admin.ITaskClosure +) as TaskClosure; + +const taskId: (name: string, version: string) => Identifier = ( + name, + version +) => ({ + name, + version, + project: 'flyte', + domain: 'development' +}); + +export const createMockTask: (name: string, version?: string) => Task = ( + name: string, + version: string = 'abcdefg' +) => ({ + id: taskId(name, version), + closure: createMockTaskClosure() +}); + +export const createMockTaskClosure: () => TaskClosure = () => + cloneDeep(decodedClosure); + +export const createMockTasks: Fn = () => [ + createMockTask('task1'), + createMockTask('task2'), + createMockTask('task3') +]; + +export const createMockTaskVersions = (name: string, length: number) => { + return Array.from({ length }, (_, idx) => { + return createMockTask(name, getCacheKey({ idx })); + }); +};