diff --git a/packages/console/src/components/Entities/test/EntityVersionDetails.test.tsx b/packages/console/src/components/Entities/test/EntityVersionDetails.test.tsx index cdf609cfc..6a7368d9e 100644 --- a/packages/console/src/components/Entities/test/EntityVersionDetails.test.tsx +++ b/packages/console/src/components/Entities/test/EntityVersionDetails.test.tsx @@ -21,7 +21,7 @@ describe('EntityVersionDetails', () => { const renderDetails = (id: ResourceIdentifier) => { return render( - + { expect(queryByText('0s')).not.toBeInTheDocument(); }); - it('should render details with task updated info without duration', () => { - const { queryByText } = render(); + it('should render details with task updated info without duration', async () => { + const { queryByText } = await render( + , + ); expect(queryByText('started')).not.toBeInTheDocument(); expect(queryByText('last updated')).toBeInTheDocument(); diff --git a/packages/console/src/components/Launch/LaunchForm/CollectionInput.tsx b/packages/console/src/components/Launch/LaunchForm/CollectionInput.tsx deleted file mode 100644 index 34303f574..000000000 --- a/packages/console/src/components/Launch/LaunchForm/CollectionInput.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { TextField } from '@material-ui/core'; -import * as React from 'react'; -import { log } from 'common/log'; -import { makeStringChangeHandler } from './handlers'; -import { InputProps, InputType } from './types'; -import { UnsupportedInput } from './UnsupportedInput'; -import { getLaunchInputId } from './utils'; - -/** Handles rendering of the input component for a Collection of SimpleType values */ -export const CollectionInput: React.FC = props => { - const { - error, - label, - name, - onChange, - typeDefinition: { subtype }, - value = '', - } = props; - if (!subtype) { - log.warn( - 'Unexpected missing subtype for collection input', - props.typeDefinition, - ); - return ; - } - const hasError = !!error; - const helperText = hasError ? error : props.helperText; - switch (subtype.type) { - case InputType.Blob: - case InputType.Boolean: - case InputType.Collection: - case InputType.Datetime: - case InputType.Duration: - case InputType.Error: - case InputType.Float: - case InputType.Integer: - case InputType.Map: - case InputType.String: - case InputType.Struct: - return ( - - ); - default: - return ; - } -}; diff --git a/packages/console/src/components/Launch/LaunchForm/BlobInput.tsx b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/BlobInput.tsx similarity index 51% rename from packages/console/src/components/Launch/LaunchForm/BlobInput.tsx rename to packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/BlobInput.tsx index c96794f73..ba7d6ee71 100644 --- a/packages/console/src/components/Launch/LaunchForm/BlobInput.tsx +++ b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/BlobInput.tsx @@ -1,18 +1,25 @@ +import React, { FC, useEffect, useMemo, useState } from 'react'; import { FormControl, - FormHelperText, InputLabel, MenuItem, Select, TextField, - Typography, } from '@material-ui/core'; import { makeStyles, Theme } from '@material-ui/core/styles'; import { BlobDimensionality } from 'models/Common/types'; -import * as React from 'react'; -import t from './strings'; -import { InputProps } from './types'; -import { getLaunchInputId, isBlobValue } from './utils'; +import { Core } from '@flyteorg/flyteidl-types'; +import t from '../strings'; +import { + BlobValue, + InputProps, + InputTypeDefinition, + InputValue, +} from '../types'; +import { getLaunchInputId, isBlobValue } from '../utils'; +import { StyledCard } from './StyledCard'; +import { getHelperForInput } from '../inputHelpers/getHelperForInput'; +import { InputHelper } from '../inputHelpers/types'; const useStyles = makeStyles((theme: Theme) => ({ dimensionalityInput: { @@ -23,7 +30,6 @@ const useStyles = makeStyles((theme: Theme) => ({ flex: '1 1 auto', }, inputContainer: { - borderLeft: `1px solid ${theme.palette.divider}`, marginTop: theme.spacing(1), paddingLeft: theme.spacing(1), }, @@ -34,70 +40,75 @@ const useStyles = makeStyles((theme: Theme) => ({ }, })); +const tryGetBlobValue = ( + typeDefinition: InputTypeDefinition, + helper: InputHelper, + value?: InputValue, +) => { + if (isBlobValue(value)) { + return value; + } + + let finalValue; + try { + finalValue = helper.fromLiteral( + value as Core.ILiteral, + typeDefinition, + ) as BlobValue; + } catch { + finalValue = helper.typeDefinitionToDefaultValue( + typeDefinition, + ) as BlobValue; + } + + return finalValue; +}; + /** A micro form for entering the values related to a Blob Literal */ -export const BlobInput: React.FC = props => { +export const BlobInput: FC = props => { const styles = useStyles(); const { error, - label, name, onChange, value: propValue, typeDefinition, + label, } = props; - const dimensionality = typeDefinition?.literalType?.blob?.dimensionality; - const blobValue = isBlobValue(propValue) - ? propValue - : { - uri: '', - dimensionality: dimensionality ?? BlobDimensionality.SINGLE, - }; - const hasError = !!error; - const helperText = hasError ? error : props.helperText; - const handleUriChange = ({ - target: { value: uri }, - }: React.ChangeEvent) => { - onChange({ - ...blobValue, - uri, - }); - }; + const helper = useMemo( + () => getHelperForInput(typeDefinition.type), + [typeDefinition], + ); - const handleFormatChange = ({ - target: { value: format }, - }: React.ChangeEvent) => { - onChange({ - ...blobValue, - format, - }); - }; + const [blobValue, setBlobValue] = useState( + tryGetBlobValue(typeDefinition, helper, propValue), + ); - const handleDimensionalityChange = ({ - target: { value }, - }: React.ChangeEvent<{ value: unknown }>) => { - onChange({ - ...blobValue, - dimensionality: value as BlobDimensionality, - }); + const handleChange = (input: Partial) => { + const value = { ...blobValue, ...input } as BlobValue; + setBlobValue(value); }; + useEffect(() => { + if (!blobValue) { + return; + } + onChange(blobValue); + }, [blobValue]); + const selectId = getLaunchInputId(`${name}-select`); return ( -
- - {label} - - {helperText} +
handleChange({ uri: e.target.value })} + value={blobValue?.uri} variant="outlined" />
@@ -106,8 +117,8 @@ export const BlobInput: React.FC = props => { id={getLaunchInputId(`${name}-format`)} helperText={t('blobFormatHelperText')} label="format" - onChange={handleFormatChange} - value={blobValue.format} + onChange={e => handleChange({ format: e.target.value })} + value={blobValue?.format} variant="outlined" /> @@ -115,8 +126,12 @@ export const BlobInput: React.FC = props => { + {literalType && + literalType.enumType?.values.map(item => ( + {item} + ))} + + + ); +}; diff --git a/packages/console/src/components/Launch/LaunchForm/LaunchFormAdvancedInputs.tsx b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/LaunchFormAdvancedInputs.tsx similarity index 75% rename from packages/console/src/components/Launch/LaunchForm/LaunchFormAdvancedInputs.tsx rename to packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/LaunchFormAdvancedInputs.tsx index 5fa632e89..6d3d4c0dc 100644 --- a/packages/console/src/components/Launch/LaunchForm/LaunchFormAdvancedInputs.tsx +++ b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/LaunchFormAdvancedInputs.tsx @@ -1,6 +1,17 @@ -import * as React from 'react'; +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useState, +} from 'react'; import { Admin } from '@flyteorg/flyteidl-types'; -import { createTheme, MuiThemeProvider } from '@material-ui/core/styles'; +import { + createGenerateClassName, + createTheme, + MuiThemeProvider, + StylesProvider, +} from '@material-ui/core/styles'; import Accordion from '@material-ui/core/Accordion'; import AccordionSummary from '@material-ui/core/AccordionSummary'; import AccordionDetails from '@material-ui/core/AccordionDetails'; @@ -14,13 +25,13 @@ import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import Form from '@rjsf/material-ui'; import validator from '@rjsf/validator-ajv8'; import { State } from 'xstate'; -import { LaunchAdvancedOptionsRef } from './types'; +import { LaunchAdvancedOptionsRef } from '../types'; import { WorkflowLaunchContext, WorkflowLaunchEvent, WorkflowLaunchTypestate, -} from './launchMachine'; -import { useStyles } from './styles'; +} from '../launchMachine'; +import { useStyles } from '../styles'; const muiTheme = createTheme({ props: { @@ -62,7 +73,7 @@ const isValueValid = (value: any) => { return value !== undefined && value !== null; }; -export const LaunchFormAdvancedInputs = React.forwardRef< +export const LaunchFormAdvancedInputs = forwardRef< LaunchAdvancedOptionsRef, LaunchAdvancedOptionsProps >( @@ -75,13 +86,13 @@ export const LaunchFormAdvancedInputs = React.forwardRef< ref, ) => { const styles = useStyles(); - const [labelsParamData, setLabelsParamData] = React.useState({}); - const [annotationsParamData, setAnnotationsParamData] = React.useState({}); - const [disableAll, setDisableAll] = React.useState(false); - const [maxParallelism, setMaxParallelism] = React.useState(''); - const [rawOutputDataConfig, setRawOutputDataConfig] = React.useState(''); + const [labelsParamData, setLabelsParamData] = useState({}); + const [annotationsParamData, setAnnotationsParamData] = useState({}); + const [disableAll, setDisableAll] = useState(false); + const [maxParallelism, setMaxParallelism] = useState(''); + const [rawOutputDataConfig, setRawOutputDataConfig] = useState(''); - React.useEffect(() => { + useEffect(() => { if (isValueValid(other.disableAll)) { setDisableAll(other.disableAll!); } @@ -113,7 +124,7 @@ export const LaunchFormAdvancedInputs = React.forwardRef< launchPlan?.spec, ]); - React.useImperativeHandle( + useImperativeHandle( ref, () => ({ getValues: () => { @@ -144,26 +155,23 @@ export const LaunchFormAdvancedInputs = React.forwardRef< ], ); - const handleDisableAllChange = React.useCallback(() => { + const handleDisableAllChange = useCallback(() => { setDisableAll(prevState => !prevState); }, []); - const handleMaxParallelismChange = React.useCallback( - ({ target: { value } }) => { - setMaxParallelism(value); - }, - [], - ); + const handleMaxParallelismChange = useCallback(({ target: { value } }) => { + setMaxParallelism(value); + }, []); - const handleLabelsChange = React.useCallback(({ formData }) => { + const handleLabelsChange = useCallback(({ formData }) => { setLabelsParamData(formData); }, []); - const handleAnnotationsParamData = React.useCallback(({ formData }) => { + const handleAnnotationsParamData = useCallback(({ formData }) => { setAnnotationsParamData(formData); }, []); - const handleRawOutputDataConfigChange = React.useCallback( + const handleRawOutputDataConfigChange = useCallback( ({ target: { value } }) => { setRawOutputDataConfig(value); }, @@ -185,23 +193,29 @@ export const LaunchFormAdvancedInputs = React.forwardRef< - - - -
-
- - - - + + + + +
+
+ + + + + diff --git a/packages/console/src/components/Launch/LaunchForm/LaunchInterruptibleInput.tsx b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/LaunchInterruptibleInput.tsx similarity index 84% rename from packages/console/src/components/Launch/LaunchForm/LaunchInterruptibleInput.tsx rename to packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/LaunchInterruptibleInput.tsx index 971cba406..c89c3e3d8 100644 --- a/packages/console/src/components/Launch/LaunchForm/LaunchInterruptibleInput.tsx +++ b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/LaunchInterruptibleInput.tsx @@ -1,11 +1,18 @@ +import React, { + ForwardRefRenderFunction, + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useState, +} from 'react'; import { makeStyles, Theme, Typography } from '@material-ui/core'; import FormControlLabel from '@material-ui/core/FormControlLabel'; import Checkbox from '@material-ui/core/Checkbox'; -import * as React from 'react'; import { Protobuf } from '@flyteorg/flyteidl-types'; -import { useStyles } from './styles'; -import { LaunchInterruptibleInputRef } from './types'; -import t from './strings'; +import { useStyles } from '../styles'; +import { LaunchInterruptibleInputRef } from '../types'; +import t from '../strings'; export const useInterruptibleStyles = makeStyles((theme: Theme) => ({ labelIndeterminate: { @@ -21,16 +28,16 @@ interface LaunchInterruptibleInputProps { initialValue?: Protobuf.IBoolValue | null; } -export const LaunchInterruptibleInputImpl: React.ForwardRefRenderFunction< +export const LaunchInterruptibleInputImpl: ForwardRefRenderFunction< LaunchInterruptibleInputRef, LaunchInterruptibleInputProps > = (props, ref) => { // interruptible stores the override to enable/disable the setting for an execution - const [interruptible, setInterruptible] = React.useState(false); + const [interruptible, setInterruptible] = useState(false); // indeterminate tracks whether the interruptible flag is unspecified/indeterminate (true) or an override has been selected (false) - const [indeterminate, setIndeterminate] = React.useState(true); + const [indeterminate, setIndeterminate] = useState(true); - React.useEffect(() => { + useEffect(() => { if ( isValueValid(props.initialValue) && isValueValid(props.initialValue!.value) @@ -43,7 +50,7 @@ export const LaunchInterruptibleInputImpl: React.ForwardRefRenderFunction< } }, [props.initialValue?.value]); - const handleInputChange = React.useCallback(() => { + const handleInputChange = useCallback(() => { if (indeterminate) { setInterruptible(() => true); setIndeterminate(() => false); @@ -56,7 +63,7 @@ export const LaunchInterruptibleInputImpl: React.ForwardRefRenderFunction< } }, [interruptible, indeterminate]); - React.useImperativeHandle( + useImperativeHandle( ref, () => ({ getValue: () => { @@ -114,6 +121,6 @@ export const LaunchInterruptibleInputImpl: React.ForwardRefRenderFunction< ); }; -export const LaunchInterruptibleInput = React.forwardRef( +export const LaunchInterruptibleInput = forwardRef( LaunchInterruptibleInputImpl, ); diff --git a/packages/console/src/components/Launch/LaunchForm/LaunchOverwriteCacheInput.tsx b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/LaunchOverwriteCacheInput.tsx similarity index 75% rename from packages/console/src/components/Launch/LaunchForm/LaunchOverwriteCacheInput.tsx rename to packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/LaunchOverwriteCacheInput.tsx index f1b4571e6..b74014eda 100644 --- a/packages/console/src/components/Launch/LaunchForm/LaunchOverwriteCacheInput.tsx +++ b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/LaunchOverwriteCacheInput.tsx @@ -1,10 +1,17 @@ -import * as React from 'react'; +import React, { + ForwardRefRenderFunction, + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useState, +} from 'react'; import { Typography } from '@material-ui/core'; import FormControlLabel from '@material-ui/core/FormControlLabel'; import Checkbox from '@material-ui/core/Checkbox'; -import { LaunchOverwriteCacheInputRef } from './types'; -import { useStyles } from './styles'; -import t from './strings'; +import { LaunchOverwriteCacheInputRef } from '../types'; +import { useStyles } from '../styles'; +import t from '../strings'; const isValueValid = (value: any) => { return value !== undefined && value !== null; @@ -14,24 +21,24 @@ interface LaunchOverwriteCacheInputProps { initialValue?: boolean | null; } -export const LaunchOverwriteCacheInputImpl: React.ForwardRefRenderFunction< +export const LaunchOverwriteCacheInputImpl: ForwardRefRenderFunction< LaunchOverwriteCacheInputRef, LaunchOverwriteCacheInputProps > = (props, ref) => { // overwriteCache stores the override to enable/disable the setting for an execution - const [overwriteCache, setOverwriteCache] = React.useState(false); + const [overwriteCache, setOverwriteCache] = useState(false); - React.useEffect(() => { + useEffect(() => { if (isValueValid(props.initialValue)) { setOverwriteCache(() => props.initialValue!); } }, [props.initialValue]); - const handleInputChange = React.useCallback(() => { + const handleInputChange = useCallback(() => { setOverwriteCache(prevState => !prevState); }, [overwriteCache]); - React.useImperativeHandle( + useImperativeHandle( ref, () => ({ getValue: () => { @@ -67,6 +74,6 @@ export const LaunchOverwriteCacheInputImpl: React.ForwardRefRenderFunction< ); }; -export const LaunchOverwriteCacheInput = React.forwardRef( +export const LaunchOverwriteCacheInput = forwardRef( LaunchOverwriteCacheInputImpl, ); diff --git a/packages/console/src/components/Launch/LaunchForm/NoneInput.tsx b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/NoneInput.tsx similarity index 67% rename from packages/console/src/components/Launch/LaunchForm/NoneInput.tsx rename to packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/NoneInput.tsx index 4a7da92d4..f7e364e8c 100644 --- a/packages/console/src/components/Launch/LaunchForm/NoneInput.tsx +++ b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/NoneInput.tsx @@ -1,11 +1,11 @@ import { TextField } from '@material-ui/core'; -import * as React from 'react'; -import t from './strings'; -import { InputProps } from './types'; -import { getLaunchInputId } from './utils'; +import React, { FC } from 'react'; +import t from '../strings'; +import { InputProps } from '../types'; +import { getLaunchInputId } from '../utils'; /** Shared renderer for any launch input type we can't accept via the UI */ -export const NoneInput: React.FC = props => { +export const NoneInput: FC = props => { const { description, label, name } = props; return ( ({ container: { flexGrow: 1, position: 'relative', + display: 'inline-block', + marginBottom: theme.spacing(1), }, menuItem: { display: 'flex', @@ -73,7 +75,7 @@ interface SearchableSelectorState { showList: boolean; inputValue: string; onBlur(): void; - onChange(event: React.ChangeEvent): void; + onChange(event: ChangeEvent): void; onFocus(): void; selectItem(item: SearchableSelectorOption): void; setIsExpanded(expanded: boolean): void; @@ -93,15 +95,15 @@ function useSearchableSelectorState({ onSelectionChanged, }: SearchableSelectorProps): SearchableSelectorState { const fetchResults = fetchSearchResults || generateDefaultFetch(options); - const [hasReceivedInput, setHasReceivedInput] = React.useState(false); - const [rawSearchValue, setSearchValue] = React.useState(''); + const [hasReceivedInput, setHasReceivedInput] = useState(false); + const [rawSearchValue, setSearchValue] = useState(''); const debouncedSearchValue = useDebouncedValue( rawSearchValue, searchDebounceTimeMs, ); - const [isExpanded, setIsExpanded] = React.useState(false); - const [focused, setFocused] = React.useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const [focused, setFocused] = useState(false); const minimumQueryMet = hasReceivedInput && debouncedSearchValue.length > minimumQuerySize; @@ -137,9 +139,7 @@ function useSearchableSelectorState({ setFocused(true); }; - const onChange = ({ - target: { value }, - }: React.ChangeEvent) => { + const onChange = ({ target: { value } }: ChangeEvent) => { setHasReceivedInput(true); setSearchValue(value); }; @@ -169,17 +169,17 @@ function useSearchableSelectorState({ }; } -const preventBubble = (event: React.MouseEvent) => { +const preventBubble = (event: MouseEvent) => { event.preventDefault(); }; -const NoResultsContent: React.FC = () => ( +const NoResultsContent: FC = () => ( No results found. ); -const LoadingContent: React.FC = () => ( +const LoadingContent: FC = () => (
@@ -235,7 +235,7 @@ export const SearchableSelector = ( const state = useSearchableSelectorState(props); const { inputValue, isExpanded, onBlur, onChange, setIsExpanded, showList } = state; - const inputRef = React.useRef(); + const inputRef = useRef(); const blurInput = () => { if (inputRef.current) { @@ -269,7 +269,7 @@ export const SearchableSelector = ( = props => { + const { + typeDefinition: { type }, + hasCollectionParent, + } = props; + + if (hasCollectionParent) { + return ; + } + + switch (type) { + case InputType.Boolean: + return ; + case InputType.Datetime: + return ; + case InputType.Enum: + return ; + case InputType.Schema: + case InputType.String: + case InputType.Integer: + case InputType.Float: + case InputType.Duration: + return ; + default: + return ; + } +}; diff --git a/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/StructInput.tsx b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/StructInput.tsx new file mode 100644 index 000000000..9ce0a8210 --- /dev/null +++ b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/StructInput.tsx @@ -0,0 +1,163 @@ +import React, { FC, useCallback, useMemo, useState } from 'react'; +import Form from '@rjsf/material-ui'; +import { + MuiThemeProvider, + StylesProvider, + createGenerateClassName, + createTheme, +} from '@material-ui/core/styles'; +import validator from '@rjsf/validator-ajv8'; +import { InputProps } from '../types'; +import { + protobufValueToPrimitive, + PrimitiveType, +} from '../inputHelpers/struct'; +import { StyledCard } from './StyledCard'; +import { TextInput } from './TextInput'; + +const muiTheme = createTheme({ + props: { + MuiTextField: { + variant: 'outlined', + }, + }, + overrides: { + MuiButton: { + label: { + color: 'gray', + }, + }, + MuiInputLabel: { + root: { + fontSize: '13.5px', + }, + }, + MuiInputBase: { + root: { + fontSize: '14px', + }, + }, + }, + typography: { + h5: { + fontSize: '16px', + fontWeight: 400, + }, + }, +}); + +const formatJson = data => { + const keys = Object.keys(data); + + if (keys.includes('title')) { + const { title, type, format } = data; + data['title'] = `${title} (${format ?? type})`; + if (!keys.includes('additionalProperties')) return data; + } + + keys.forEach(key => { + const item = data[`${key}`]; + if (typeof item === 'object') { + data = { ...data, [key]: formatJson(item ?? {}) }; + } + }); + + return data; +}; + +/** Handles rendering of the input component for a Struct */ +export const StructInput: FC = props => { + const { + error, + label, + onChange, + typeDefinition: { literalType }, + value = '', + hasCollectionParent, + } = props; + + const { jsonFormRenderable, parsedJson } = useMemo(() => { + let jsonFormRenderable = false; + let parsedJson: PrimitiveType = {}; + + if (literalType?.metadata?.fields?.definitions?.structValue?.fields) { + const keys = Object.keys( + literalType?.metadata?.fields?.definitions?.structValue?.fields, + ); + + if (keys.length > 1) { + // If there are multiple keys, we can't render a form because of not supporting nested structs so render a text field + jsonFormRenderable = false; + } else if (keys[0]) { + parsedJson = protobufValueToPrimitive( + literalType.metadata.fields.definitions.structValue.fields[ + `${keys[0]}` + ], + ); + + if (parsedJson) { + parsedJson = formatJson(parsedJson); + jsonFormRenderable = true; + } + } + } + + return { + jsonFormRenderable: jsonFormRenderable && !hasCollectionParent, + parsedJson, + }; + }, [literalType, hasCollectionParent]); + + const [paramData, setParamData] = useState( + jsonFormRenderable && value ? JSON.parse(value as string) : {}, + ); + + const onFormChange = useCallback(({ formData }) => { + onChange(JSON.stringify(formData)); + setParamData(formData); + }, []); + + return jsonFormRenderable ? ( + + + +
+
+
+
+
+
+ ) : ( + { + // prev[key] = (parsedJson as any).properties[key].default; + // return prev; + // }, + // {}, + // ), + // ) + // } + textInputProps={{ + fullWidth: true, + multiline: true, + maxRows: 8, + variant: 'outlined', + }} + /> + ); +}; diff --git a/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/StyledCard.tsx b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/StyledCard.tsx new file mode 100644 index 000000000..88cf6eeb4 --- /dev/null +++ b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/StyledCard.tsx @@ -0,0 +1,56 @@ +import { + Card, + CardContent, + FormHelperText, + Typography, + styled, +} from '@material-ui/core'; +import React, { FC } from 'react'; + +export const StyledCardContainer = styled(Card)(({ theme }) => ({ + position: 'relative', + overflow: 'visible', + border: `1px solid ${theme.palette.grey[300]}`, + boxShadow: 'none', + + '&.error': { + border: '1px solid red', + '& .inlineTitle': { + color: 'red', + }, + }, + + '& .inlineTitle': { + position: 'absolute', + top: '-8px', + left: '10px', + color: 'gray', + background: 'white', + fontSize: '10.5px', + padding: '0 4px', + }, +})); + +export interface StyledCardProps { + error?: string; + label: string; +} +export const StyledCard: FC = ({ error, label, children }) => { + return label ? ( + + + + {label} + + + {children} + + {error} + + ) : ( +
+ {children} + {error} +
+ ); +}; diff --git a/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/TextInput.tsx b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/TextInput.tsx new file mode 100644 index 000000000..1b19b97ff --- /dev/null +++ b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/TextInput.tsx @@ -0,0 +1,28 @@ +import React, { FC } from 'react'; +import { TextField, TextFieldProps } from '@material-ui/core'; +import { makeStringChangeHandler } from '../handlers'; +import { InputProps } from '../types'; +import { getLaunchInputId } from '../utils'; + +/** Handles rendering of the input component for any primitive-type input */ +export const TextInput: FC< + InputProps & { textInputProps?: Partial } +> = props => { + const { error, label, name, onChange, value = '', textInputProps } = props; + + const id = getLaunchInputId(name); + return ( + + ); +}; diff --git a/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/UnionInput.tsx b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/UnionInput.tsx new file mode 100644 index 000000000..486721954 --- /dev/null +++ b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/UnionInput.tsx @@ -0,0 +1,163 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + InputProps, + InputType, + InputTypeDefinition, + UnionValue, + InputValue, +} from '../types'; +import { formatType } from '../utils'; + +import { getHelperForInput } from '../inputHelpers/getHelperForInput'; +import { + SearchableSelector, + SearchableSelectorOption, +} from './SearchableSelector'; +import t from '../../../common/strings'; +import { getComponentForInput } from './getComponentForInput'; +import { StyledCard } from './StyledCard'; + +const generateInputTypeToValueMap = ( + listOfSubTypes: InputTypeDefinition[] | undefined, + initialInputValue: UnionValue | undefined, + initialType: InputTypeDefinition, +): Record | {} => { + if (!listOfSubTypes?.length) { + return {}; + } + + const final = listOfSubTypes.reduce(function (map, subType) { + if (initialInputValue && subType.type === initialType.type) { + map[subType.type] = initialInputValue; + } else { + map[subType.type] = { value: undefined, typeDefinition: subType }; + } + return map; + }, {}); + return final; +}; + +const generateSearchableSelectorOption = ( + inputTypeDefinition: InputTypeDefinition, +): SearchableSelectorOption => { + return { + id: inputTypeDefinition.type, + data: inputTypeDefinition.type, + name: formatType(inputTypeDefinition), + } as SearchableSelectorOption; +}; + +const generateListOfSearchableSelectorOptions = ( + listOfInputTypeDefinition: InputTypeDefinition[], +): SearchableSelectorOption[] => { + return listOfInputTypeDefinition.map(inputTypeDefinition => + generateSearchableSelectorOption(inputTypeDefinition), + ); +}; + +export const UnionInput = (props: InputProps) => { + const { + initialValue, + label, + onChange, + typeDefinition, + error, + hasCollectionParent, + } = props; + + const { listOfSubTypes, type } = typeDefinition; + + if (!listOfSubTypes?.length) { + return <>; + } + + const helper = getHelperForInput(type); + + const initialInputValue = + initialValue && + (helper.fromLiteral(initialValue, typeDefinition) as UnionValue); + + const initialInputTypeDefinition = + initialInputValue?.typeDefinition ?? listOfSubTypes[0]; + + if (!initialInputTypeDefinition) { + return <>; + } + + const [inputTypeToValueMap, setInputTypeToValueMap] = useState< + Record | {} + >( + generateInputTypeToValueMap( + listOfSubTypes, + initialInputValue, + initialInputTypeDefinition, + ), + ); + + const [selectedInputType, setSelectedInputType] = useState( + initialInputTypeDefinition.type, + ); + + const inputTypeToInputTypeDefinition = listOfSubTypes?.reduce?.( + (previous, current) => ({ ...previous, [current.type]: current }), + {}, + ); + + const selectedInputTypeDefinition = inputTypeToInputTypeDefinition[ + selectedInputType + ] as InputTypeDefinition; + + // change the selected union input value when change the selected union input type + useEffect(() => { + if (inputTypeToValueMap[selectedInputType]) { + handleSubTypeOnChange(inputTypeToValueMap[selectedInputType].value); + } + }, [selectedInputTypeDefinition]); + + const handleSubTypeOnChange = (input: InputValue) => { + setInputTypeToValueMap({ + ...inputTypeToValueMap, + [selectedInputType]: { + value: input, + typeDefinition: selectedInputTypeDefinition, + } as UnionValue, + }); + }; + + const childComponentValue = useMemo(() => { + return inputTypeToValueMap[selectedInputType]; + }, [inputTypeToValueMap, selectedInputType]); + + useEffect(() => { + onChange(childComponentValue); + }, [childComponentValue]); + + const inputComponent = getComponentForInput( + { + ...props, + name: `${formatType(selectedInputTypeDefinition)}`, + label: '', + typeDefinition: selectedInputTypeDefinition, + onChange: handleSubTypeOnChange, + value: childComponentValue.value, + } as InputProps, + !hasCollectionParent, + ); + + return ( + + ) => { + setSelectedInputType(value.data); + }} + /> + +
{inputComponent}
+
+ ); +}; diff --git a/packages/console/src/components/Launch/LaunchForm/UnsupportedInput.tsx b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/UnsupportedInput.tsx similarity index 71% rename from packages/console/src/components/Launch/LaunchForm/UnsupportedInput.tsx rename to packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/UnsupportedInput.tsx index de5c298ed..0c583d1c9 100644 --- a/packages/console/src/components/Launch/LaunchForm/UnsupportedInput.tsx +++ b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/UnsupportedInput.tsx @@ -1,10 +1,10 @@ +import React, { FC } from 'react'; import { TextField } from '@material-ui/core'; -import * as React from 'react'; -import { InputProps } from './types'; -import { getLaunchInputId } from './utils'; +import { InputProps } from '../types'; +import { getLaunchInputId } from '../utils'; /** Shared renderer for any launch input type we can't accept via the UI */ -export const UnsupportedInput: React.FC = props => { +export const UnsupportedInput: FC = props => { const { description, label, name } = props; return ( { + const helper = getHelperForInput(input.typeDefinition.type); + try { + helper.validate({ ...input, value: newValue }); + } catch (e) { + // no-op + } + input.onChange(newValue); + }; + + const props = { + ...input, + error: showErrors ? input.error : undefined, + onChange, + }; + + switch (input.typeDefinition.type) { + case InputType.Union: + return ; + case InputType.Blob: + return ; + case InputType.Collection: + return ; + case InputType.Struct: + return ; + case InputType.Map: + return ; + case InputType.Unknown: + return ; + case InputType.None: + return ; + default: + // handles Boolean, Datetime, Schema, String, Integer, Float, Duration, Enum + return ; + } +} diff --git a/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/index.ts b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/index.ts new file mode 100644 index 000000000..38da5af63 --- /dev/null +++ b/packages/console/src/components/Launch/LaunchForm/LaunchFormComponents/index.ts @@ -0,0 +1,12 @@ +export * from './BooleanInput'; +export * from './BlobInput'; +export * from './SimpleInput'; +export * from './CollectionInput'; +export * from './DatetimeInput'; +export * from './EnumInput'; +export * from './LaunchFormAdvancedInputs'; +export * from './NoneInput'; +export * from './StructInput'; +export * from './UnionInput'; +export * from './UnsupportedInput'; +export * from './getComponentForInput'; diff --git a/packages/console/src/components/Launch/LaunchForm/LaunchFormInputs.tsx b/packages/console/src/components/Launch/LaunchForm/LaunchFormInputs.tsx index f0d3bc1ac..632197f6b 100644 --- a/packages/console/src/components/Launch/LaunchForm/LaunchFormInputs.tsx +++ b/packages/console/src/components/Launch/LaunchForm/LaunchFormInputs.tsx @@ -1,71 +1,18 @@ +import React, { useEffect, useMemo } from 'react'; import { Typography } from '@material-ui/core'; -import * as React from 'react'; -import { BlobInput } from './BlobInput'; -import { CollectionInput } from './CollectionInput'; import t from './strings'; import { LaunchState } from './launchMachine'; -import { MapInput } from './MapInput'; import { NoInputsNeeded } from './NoInputsNeeded'; -import { SimpleInput } from './SimpleInput'; -import { StructInput } from './StructInput'; -import { UnionInput } from './UnionInput'; import { useStyles } from './styles'; import { BaseInterpretedLaunchState, InputProps, - InputType, - InputValue, LaunchFormInputsRef, } from './types'; -import { UnsupportedInput } from './UnsupportedInput'; import { UnsupportedRequiredInputsError } from './UnsupportedRequiredInputsError'; import { useFormInputsState } from './useFormInputsState'; import { isEnterInputsState } from './utils'; -import { getHelperForInput } from './inputHelpers/getHelperForInput'; -import { NoneInput } from './NoneInput'; - -export function getComponentForInput( - input: InputProps, - showErrors: boolean, - setIsError: (boolean) => void, -) { - const onChange = (newValue: InputValue) => { - const helper = getHelperForInput(input.typeDefinition.type); - try { - helper.validate({ ...input, value: newValue }); - setIsError(false); - } catch (e) { - setIsError(true); - } - input.onChange(newValue); - }; - - const props = { - ...input, - error: showErrors ? input.error : undefined, - setIsError, - onChange, - }; - - switch (input.typeDefinition.type) { - case InputType.Union: - return ; - case InputType.Blob: - return ; - case InputType.Collection: - return ; - case InputType.Struct: - return ; - case InputType.Map: - return ; - case InputType.Unknown: - return ; - case InputType.None: - return ; - default: - return ; - } -} +import { getComponentForInput } from './LaunchFormComponents'; export interface LaunchFormInputsProps { state: BaseInterpretedLaunchState; @@ -78,8 +25,27 @@ const RenderFormInputs: React.FC<{ showErrors: boolean; variant: LaunchFormInputsProps['variant']; setIsError: (boolean) => void; -}> = ({ inputs, showErrors, variant, setIsError }) => { +}> = ({ inputs, variant, setIsError }) => { const styles = useStyles(); + + useEffect(() => { + /** + * Invalidate the form if: + * * value is required and the input is invalid + * * value is supplied and the input is invalid + */ + const hasError = inputs.some(i => (i.required || i.value) && !!i.error); + setIsError(hasError); + }, [inputs]); + + const inputsFormElements = useMemo(() => { + return inputs.map(input => ( +
+ {getComponentForInput(input, true)} +
+ )); + }, [inputs]); + return inputs.length === 0 ? ( ) : ( @@ -88,11 +54,7 @@ const RenderFormInputs: React.FC<{ {t('inputs')} {t('inputsDescription')} - {inputs.map(input => ( -
- {getComponentForInput(input, showErrors, setIsError)} -
- ))} + {inputsFormElements} ); }; diff --git a/packages/console/src/components/Launch/LaunchForm/LaunchTaskForm.tsx b/packages/console/src/components/Launch/LaunchForm/LaunchTaskForm.tsx index a027b851b..df2322d32 100644 --- a/packages/console/src/components/Launch/LaunchForm/LaunchTaskForm.tsx +++ b/packages/console/src/components/Launch/LaunchForm/LaunchTaskForm.tsx @@ -7,9 +7,9 @@ import { LaunchFormHeader } from './LaunchFormHeader'; import { LaunchFormInputs } from './LaunchFormInputs'; import { LaunchState } from './launchMachine'; import { LaunchRoleInput } from './LaunchRoleInput'; -import { LaunchInterruptibleInput } from './LaunchInterruptibleInput'; -import { LaunchOverwriteCacheInput } from './LaunchOverwriteCacheInput'; -import { SearchableSelector } from './SearchableSelector'; +import { LaunchInterruptibleInput } from './LaunchFormComponents/LaunchInterruptibleInput'; +import { LaunchOverwriteCacheInput } from './LaunchFormComponents/LaunchOverwriteCacheInput'; +import { SearchableSelector } from './LaunchFormComponents/SearchableSelector'; import { useStyles } from './styles'; import { BaseInterpretedLaunchState, diff --git a/packages/console/src/components/Launch/LaunchForm/LaunchWorkflowForm.tsx b/packages/console/src/components/Launch/LaunchForm/LaunchWorkflowForm.tsx index 7e6d9f663..9f0dcc588 100644 --- a/packages/console/src/components/Launch/LaunchForm/LaunchWorkflowForm.tsx +++ b/packages/console/src/components/Launch/LaunchForm/LaunchWorkflowForm.tsx @@ -11,7 +11,7 @@ import { LaunchFormActions } from './LaunchFormActions'; import { LaunchFormHeader } from './LaunchFormHeader'; import { LaunchFormInputs } from './LaunchFormInputs'; import { LaunchState } from './launchMachine'; -import { SearchableSelector } from './SearchableSelector'; +import { SearchableSelector } from './LaunchFormComponents/SearchableSelector'; import { useStyles } from './styles'; import { BaseInterpretedLaunchState, @@ -21,9 +21,9 @@ import { import { useLaunchWorkflowFormState } from './useLaunchWorkflowFormState'; import { isEnterInputsState } from './utils'; import { LaunchRoleInput } from './LaunchRoleInput'; -import { LaunchFormAdvancedInputs } from './LaunchFormAdvancedInputs'; -import { LaunchInterruptibleInput } from './LaunchInterruptibleInput'; -import { LaunchOverwriteCacheInput } from './LaunchOverwriteCacheInput'; +import { LaunchInterruptibleInput } from './LaunchFormComponents/LaunchInterruptibleInput'; +import { LaunchOverwriteCacheInput } from './LaunchFormComponents/LaunchOverwriteCacheInput'; +import { LaunchFormAdvancedInputs } from './LaunchFormComponents'; /** Renders the form for initiating a Launch request based on a Workflow */ export const LaunchWorkflowForm: React.FC = props => { diff --git a/packages/console/src/components/Launch/LaunchForm/MapInput.tsx b/packages/console/src/components/Launch/LaunchForm/MapInput.tsx index 7b2156564..78e745181 100644 --- a/packages/console/src/components/Launch/LaunchForm/MapInput.tsx +++ b/packages/console/src/components/Launch/LaunchForm/MapInput.tsx @@ -1,15 +1,12 @@ +import React, { useEffect } from 'react'; import { Button, FormHelperText, IconButton, TextField, - Typography, } from '@material-ui/core'; import { makeStyles, Theme } from '@material-ui/core/styles'; -import * as React from 'react'; import RemoveIcon from '@material-ui/icons/Remove'; -import Card from '@material-ui/core/Card'; -import CardContent from '@material-ui/core/CardContent'; import t from './strings'; import { InputProps, @@ -19,6 +16,7 @@ import { } from './types'; import { formatType, toMappedTypeValue } from './utils'; import { getHelperForInput } from './inputHelpers/getHelperForInput'; +import { StyledCard } from './LaunchFormComponents/StyledCard'; const useStyles = makeStyles((theme: Theme) => ({ formControl: { @@ -48,80 +46,74 @@ const useStyles = makeStyles((theme: Theme) => ({ }, })); +const arrayToMappedType = (array: MapInputItem[]): string => { + const newPairs = array.map(item => { + return { + key: item.key, + value: item.value, + }; + }); + + return toMappedTypeValue(newPairs); +}; + interface MapInputItemProps { data: MapInputItem; - subtype?: InputTypeDefinition; + typeDefinition: InputTypeDefinition; setKey: (key: string) => void; setValue: (value: string) => void; - isValid: (value: string) => boolean; + validate: (value: MapInputItem) => string | undefined; onDeleteItem: () => void; } const MapSingleInputItem = (props: MapInputItemProps) => { const classes = useStyles(); - const { data, subtype, setKey, setValue, isValid, onDeleteItem } = props; - const [error, setError] = React.useState(false); - const [focused, setFocused] = React.useState(false); - const [touched, setTouched] = React.useState(false); + const { data, typeDefinition, setKey, setValue, onDeleteItem, validate } = + props; + // const [tupleError, setTupleError] = React.useState(); + const tupleError = validate(data); + const { subtype } = typeDefinition; const isOneLineType = subtype?.type === InputType.String || subtype?.type === InputType.Integer; - let invalidValueError = null; - if (subtype && !focused && touched) { - const helper = getHelperForInput(subtype.type); - try { - helper.validate({ - name: data.key, - value: data.value, - required: true, - typeDefinition: subtype, - }); - } catch (e) { - invalidValueError = e?.message; - } - } - return ( -
- ) => { - setKey(value); - setError(!!value && !isValid(value)); - }} - value={data.key} - error={error} - placeholder="key" - variant="outlined" - helperText={error ? 'This key already defined' : ''} - className={classes.keyControl} - /> - ) => { - setTouched(true); - setValue(value); - }} - value={data.value} - variant="outlined" - className={classes.valueControl} - multiline={!isOneLineType} - type={subtype?.type === InputType.Integer ? 'number' : 'text'} - error={!!invalidValueError} - helperText={invalidValueError ? invalidValueError : ''} - onFocus={() => setFocused(true)} - onBlur={() => setFocused(false)} - /> - - - +
+
+ ) => { + setKey(value); + }} + value={data.key} + error={!!tupleError} + placeholder="key" + variant="outlined" + className={classes.keyControl} + /> + ) => { + setValue(value); + }} + value={data.value} + variant="outlined" + className={classes.valueControl} + multiline={!isOneLineType} + type={subtype?.type === InputType.Integer ? 'number' : 'text'} + error={!!tupleError} + /> + + + +
+ {tupleError}
); }; @@ -156,117 +148,81 @@ function parseMappedTypeValue(value?: InputValue): MapInputItem[] { } export const MapInput = (props: InputProps) => { - const { - value, - label, - onChange, - error, - typeDefinition: { subtype }, - setIsError, - } = props; + const { value, label, onChange, error, typeDefinition } = props; const classes = useStyles(); - const [data, setData] = React.useState( parseMappedTypeValue(value), ); const onAddItem = () => { - setIsError?.(true); setData(data => [...data, getNewMapItem(data.length)]); }; - const updateUpperStream = (newData: MapInputItem[]) => { - let newError = false; - newData.forEach(item => { - if (item.id === null || !item.key?.length || !item.value?.length) - newError = true; - else { - if ( - data.findIndex(({ key, id }) => id !== item.id && key === item.key) >= - 0 - ) - newError = true; - } - }); - const newPairs = newData - .filter(item => { - // we filter out delted values and items with errors or empty keys/values - return item.id !== null && !!item.key && !!item.value; - }) - .map(item => { - return { - key: item.key, - value: item.value, - }; - }); - const newValue = toMappedTypeValue(newPairs); - onChange(newValue); - if (newError) setIsError?.(newError); - }; + useEffect(() => { + try { + const newValue = arrayToMappedType(data); + onChange(newValue); + } catch (error) { + // noop + } + }, [data]); - const onSetKey = (id: number | null, key: string) => { - if (id === null) return; + const onSetKey = (index: number, key: string) => { const newData = [...data]; - newData[id].key = key; + newData[index].key = key; setData([...newData]); - updateUpperStream([...newData]); }; - const onSetValue = (id: number | null, value: string) => { - if (id === null) return; + const onSetValue = (index: number, value: string) => { const newData = [...data]; - newData[id].value = value; + newData[index].value = value; setData([...newData]); - updateUpperStream([...newData]); }; - const onDeleteItem = (id: number | null) => { - if (id === null) return; + const onDeleteItem = index => { const newData = [...data]; - const dataIndex = newData.findIndex(item => item.id === id); - if (dataIndex >= 0 && dataIndex < newData.length) { - newData[dataIndex].id = null; + if (index >= 0 && index < newData.length) { + newData.splice(index, 1); } setData([...newData]); - updateUpperStream([...newData]); - }; - - const isValid = (id: number | null, value: string) => { - if (id === null) return true; - // findIndex returns -1 if value is not found, which means we can use that key - return ( - data - .filter(item => item.id !== null && item.id !== id) - .findIndex(item => item.key === value) === -1 - ); }; return ( - - - - {label} - - {data - .filter(item => item.id !== null) - .map(item => { - return ( - onSetKey(item.id, key)} - setValue={value => onSetValue(item.id, value)} - isValid={value => isValid(item.id, value)} - onDeleteItem={() => onDeleteItem(item.id)} - /> - ); - })} - {error && {error}} -
- -
-
-
+ + {data + .filter(item => item.id !== null) + .map((item, index) => { + return ( + onSetKey(index, key)} + setValue={value => onSetValue(index, value)} + validate={value => { + const instances = data.filter(i => i.key === value.key); + if (instances.length > 1) { + return 'Duplicate key'; + } + + const helper = getHelperForInput(typeDefinition.type); + try { + helper.validate({ + typeDefinition, + value: arrayToMappedType([value]), + required: true, + } as any); + } catch (e) { + return e.message; + } + }} + onDeleteItem={() => onDeleteItem(index)} + /> + ); + })} +
+ +
+
); }; diff --git a/packages/console/src/components/Launch/LaunchForm/SimpleInput.tsx b/packages/console/src/components/Launch/LaunchForm/SimpleInput.tsx deleted file mode 100644 index 0182496ac..000000000 --- a/packages/console/src/components/Launch/LaunchForm/SimpleInput.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { - FormControl, - FormControlLabel, - FormHelperText, - MenuItem, - Select, - Switch, - TextField, -} from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; -import * as React from 'react'; -import { DatetimeInput } from './DatetimeInput'; -import { makeStringChangeHandler, makeSwitchChangeHandler } from './handlers'; -import { InputProps, InputType } from './types'; -import { UnsupportedInput } from './UnsupportedInput'; -import { getLaunchInputId } from './utils'; - -const useStyles = makeStyles(() => ({ - formControl: { - minWidth: '100%', - }, -})); - -/** Handles rendering of the input component for any primitive-type input */ -export const SimpleInput: React.FC = props => { - const { - error, - label, - name, - onChange, - typeDefinition: { type, literalType }, - value = '', - } = props; - const hasError = !!error; - const helperText = hasError ? error : props.helperText; - const classes = useStyles(); - - const handleEnumChange = (event: React.ChangeEvent<{ value: unknown }>) => { - onChange(event.target.value as string); - }; - - switch (type) { - case InputType.Boolean: - return ( - - - } - label={label} - /> - {helperText} - - ); - case InputType.Datetime: - return ; - case InputType.Schema: - case InputType.String: - case InputType.Integer: - case InputType.Float: - case InputType.Duration: - return ( - - ); - case InputType.Enum: - return ( - - - {label} - - ); - default: - return ; - } -}; diff --git a/packages/console/src/components/Launch/LaunchForm/StructInput.tsx b/packages/console/src/components/Launch/LaunchForm/StructInput.tsx deleted file mode 100644 index c2dfe6434..000000000 --- a/packages/console/src/components/Launch/LaunchForm/StructInput.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import * as React from 'react'; -import { TextField, Card, CardContent, CardHeader } from '@material-ui/core'; -import { useState } from 'react'; -import Form from '@rjsf/material-ui'; -import { MuiThemeProvider, createTheme } from '@material-ui/core/styles'; -import validator from '@rjsf/validator-ajv8'; -import { makeStringChangeHandler } from './handlers'; -import { InputProps } from './types'; -import { getLaunchInputId } from './utils'; -import { protobufValueToPrimitive, PrimitiveType } from './inputHelpers/struct'; - -const muiTheme = createTheme({ - props: { - MuiTextField: { - variant: 'outlined', - }, - }, - overrides: { - MuiButton: { - label: { - color: 'gray', - }, - }, - }, -}); - -muiTheme.typography.h5 = { - fontSize: '16px', - fontWeight: 400, -}; - -const formatJson = data => { - const keys = Object.keys(data); - - if (keys.includes('title')) { - const { title, type, format } = data; - data['title'] = `${title} (${format ?? type})`; - if (!keys.includes('additionalProperties')) return data; - } - - keys.forEach(key => { - const item = data[`${key}`]; - if (typeof item === 'object') { - data = { ...data, [key]: formatJson(item ?? {}) }; - } - }); - - return data; -}; - -/** Handles rendering of the input component for a Struct */ -export const StructInput: React.FC = props => { - const { - error, - label, - name, - onChange, - typeDefinition: { literalType }, - value = '', - } = props; - const hasError = !!error; - const helperText = hasError ? error : props.helperText; - - let jsonFormRenderable = false; - let parsedJson: PrimitiveType = {}; - - if (literalType?.metadata?.fields?.definitions?.structValue?.fields) { - const keys = Object.keys( - literalType?.metadata?.fields?.definitions?.structValue?.fields, - ); - - if (keys.length > 1) { - // If there are multiple keys, we can't render a form because of not supporting nested structs so render a text field - jsonFormRenderable = false; - } else if (keys[0]) { - parsedJson = protobufValueToPrimitive( - literalType.metadata.fields.definitions.structValue.fields[ - `${keys[0]}` - ], - ); - - if (parsedJson) { - parsedJson = formatJson(parsedJson); - jsonFormRenderable = true; - } - } - } - - const [paramData, setParamData] = useState( - jsonFormRenderable && value ? JSON.parse(value as string) : {}, - ); - - const onFormChange = React.useCallback(({ formData }) => { - onChange(JSON.stringify(formData)); - setParamData(formData); - }, []); - - return jsonFormRenderable ? ( - - - - -
-
-
-
-
-
- ) : ( - - ); -}; diff --git a/packages/console/src/components/Launch/LaunchForm/UnionInput.tsx b/packages/console/src/components/Launch/LaunchForm/UnionInput.tsx deleted file mode 100644 index 03dc4d635..000000000 --- a/packages/console/src/components/Launch/LaunchForm/UnionInput.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { Typography } from '@material-ui/core'; -import { makeStyles, Theme } from '@material-ui/core/styles'; -import * as React from 'react'; -import Card from '@material-ui/core/Card'; -import CardContent from '@material-ui/core/CardContent'; -import { - InputProps, - InputType, - InputTypeDefinition, - UnionValue, - InputValue, -} from './types'; -import { formatType } from './utils'; -import { getComponentForInput } from './LaunchFormInputs'; -import { getHelperForInput } from './inputHelpers/getHelperForInput'; -import { - SearchableSelector, - SearchableSelectorOption, -} from './SearchableSelector'; -import t from '../../common/strings'; - -const useStyles = makeStyles((theme: Theme) => ({ - inlineTitle: { - display: 'flex', - gap: theme.spacing(1), - alignItems: 'center', - paddingBottom: theme.spacing(3), - }, -})); - -const generateInputTypeToValueMap = ( - listOfSubTypes: InputTypeDefinition[] | undefined, - initialInputValue: UnionValue | undefined, - initialType: InputTypeDefinition, -): Record | {} => { - if (!listOfSubTypes?.length) { - return {}; - } - - return listOfSubTypes.reduce(function (map, subType) { - if (initialInputValue && subType === initialType) { - map[subType.type] = initialInputValue; - } else { - map[subType.type] = { value: '', typeDefinition: subType }; - } - return map; - }, {}); -}; - -const generateSearchableSelectorOption = ( - inputTypeDefinition: InputTypeDefinition, -): SearchableSelectorOption => { - return { - id: inputTypeDefinition.type, - data: inputTypeDefinition.type, - name: formatType(inputTypeDefinition), - } as SearchableSelectorOption; -}; - -const generateListOfSearchableSelectorOptions = ( - listOfInputTypeDefinition: InputTypeDefinition[], -): SearchableSelectorOption[] => { - return listOfInputTypeDefinition.map(inputTypeDefinition => - generateSearchableSelectorOption(inputTypeDefinition), - ); -}; - -export const UnionInput = (props: InputProps) => { - const { - initialValue, - required, - label, - onChange, - typeDefinition, - error, - description, - setIsError, - } = props; - - const classes = useStyles(); - - const listOfSubTypes = typeDefinition?.listOfSubTypes; - - if (!listOfSubTypes?.length) { - return <>; - } - - const inputTypeToInputTypeDefinition = listOfSubTypes.reduce( - (previous, current) => ({ ...previous, [current.type]: current }), - {}, - ); - - const initialInputValue = - initialValue && - (getHelperForInput(typeDefinition.type).fromLiteral( - initialValue, - typeDefinition, - ) as UnionValue); - - const initialInputTypeDefinition = - initialInputValue?.typeDefinition ?? listOfSubTypes[0]; - - if (!initialInputTypeDefinition) { - return <>; - } - - const [inputTypeToValueMap, setInputTypeToValueMap] = React.useState< - Record | {} - >( - generateInputTypeToValueMap( - listOfSubTypes, - initialInputValue, - initialInputTypeDefinition, - ), - ); - - const [selectedInputType, setSelectedInputType] = React.useState( - initialInputTypeDefinition.type, - ); - - const selectedInputTypeDefintion = inputTypeToInputTypeDefinition[ - selectedInputType - ] as InputTypeDefinition; - - // change the selected union input value when change the selected union input type - React.useEffect(() => { - if (inputTypeToValueMap[selectedInputType]) { - handleSubTypeOnChange(inputTypeToValueMap[selectedInputType].value); - } - }, [selectedInputTypeDefintion]); - - const handleTypeOnSelectionChanged = ( - value: SearchableSelectorOption, - ) => { - setSelectedInputType(value.data); - }; - - const handleSubTypeOnChange = (input: InputValue) => { - onChange({ - value: input, - typeDefinition: selectedInputTypeDefintion, - } as UnionValue); - setInputTypeToValueMap({ - ...inputTypeToValueMap, - [selectedInputType]: { - value: input, - typeDefinition: selectedInputTypeDefintion, - } as UnionValue, - }); - }; - - return ( - - -
- - {label} - - - -
- -
- {getComponentForInput( - { - description: description, - name: `${formatType(selectedInputTypeDefintion)}`, - label: '', - required: required, - typeDefinition: selectedInputTypeDefintion, - onChange: handleSubTypeOnChange, - value: inputTypeToValueMap[selectedInputType]?.value, - error: error, - } as InputProps, - true, - setIsError, - )} -
-
-
- ); -}; diff --git a/packages/console/src/components/Launch/LaunchForm/__stories__/WorkflowSelector.stories.tsx b/packages/console/src/components/Launch/LaunchForm/__stories__/WorkflowSelector.stories.tsx index 1764be893..c01464997 100644 --- a/packages/console/src/components/Launch/LaunchForm/__stories__/WorkflowSelector.stories.tsx +++ b/packages/console/src/components/Launch/LaunchForm/__stories__/WorkflowSelector.stories.tsx @@ -10,7 +10,7 @@ import * as React from 'react'; import { SearchableSelector, SearchableSelectorOption, -} from '../SearchableSelector'; +} from '../LaunchFormComponents/SearchableSelector'; const mockWorkflow = createMockWorkflow('MyWorkflow'); const mockWorkflowVersions = createMockWorkflowVersions( diff --git a/packages/console/src/components/Launch/LaunchForm/getInputs.ts b/packages/console/src/components/Launch/LaunchForm/getInputs.ts index d0c73f81e..5014cd040 100644 --- a/packages/console/src/components/Launch/LaunchForm/getInputs.ts +++ b/packages/console/src/components/Launch/LaunchForm/getInputs.ts @@ -44,6 +44,7 @@ export function getInputsForWorkflow( const inputKey = createInputCacheKey(name, typeDefinition); const defaultVaue = parameter.default != null ? parameter.default : undefined; + // TODO: fill default value if initial value is not set const initialValue = initialValues.has(inputKey) ? initialValues.get(inputKey) : defaultVaue; diff --git a/packages/console/src/components/Launch/LaunchForm/handlers.ts b/packages/console/src/components/Launch/LaunchForm/handlers.ts index 951d14068..ff78ec88f 100644 --- a/packages/console/src/components/Launch/LaunchForm/handlers.ts +++ b/packages/console/src/components/Launch/LaunchForm/handlers.ts @@ -8,7 +8,7 @@ export function makeSwitchChangeHandler(onChange: InputChangeHandler) { } type StringChangeHandler = ( - value: string, + value: string | undefined, roleType: AuthRoleTypes | null, ) => void; export function makeStringChangeHandler( diff --git a/packages/console/src/components/Launch/LaunchForm/inputHelpers/blob.ts b/packages/console/src/components/Launch/LaunchForm/inputHelpers/blob.ts index e0f98f35e..5a14f2bc9 100644 --- a/packages/console/src/components/Launch/LaunchForm/inputHelpers/blob.ts +++ b/packages/console/src/components/Launch/LaunchForm/inputHelpers/blob.ts @@ -1,12 +1,12 @@ import { Core } from '@flyteorg/flyteidl-types'; import { isObject } from 'lodash'; import { BlobDimensionality } from 'models/Common/types'; -import { BlobValue, InputValue } from '../types'; -import { literalNone } from './constants'; +import { BlobValue } from '../types'; import { ConverterInput, InputHelper, InputValidatorParams } from './types'; import { isKeyOfBlobDimensionality } from './utils'; +import { literalNone } from './constants'; -function fromLiteral(literal: Core.ILiteral): InputValue { +function fromLiteral(literal: Core.ILiteral): BlobValue { if (!literal.scalar || !literal.scalar.blob) { throw new Error('Literal blob missing scalar.blob property'); } @@ -37,17 +37,15 @@ function getDimensionality(value: string | number) { } function toLiteral({ value }: ConverterInput): Core.ILiteral { - if (!isObject(value)) { + if (!value) { return literalNone(); } + const { dimensionality: rawDimensionality, format: rawFormat, uri, } = value as BlobValue; - if (!uri) { - return literalNone(); - } const dimensionality = getDimensionality(rawDimensionality); @@ -61,27 +59,25 @@ function toLiteral({ value }: ConverterInput): Core.ILiteral { } function validate({ value, required }: InputValidatorParams) { - if (typeof value !== 'object') { - throw new Error('Value must be an object'); + if (!isObject(value)) { + throw new Error('Invalid blob value'); } const blobValue = value as BlobValue; - if (required && (typeof blobValue.uri == null || !blobValue.uri.length)) { - throw new Error('uri is required'); - } - if (blobValue != null && typeof blobValue.uri !== 'string') { - throw new Error('uri must be a string'); + if (required && (!blobValue?.uri || typeof blobValue?.uri !== 'string')) { + throw new Error('Blob uri is required'); } + if (blobValue.dimensionality == null) { - throw new Error('dimensionality is required'); + throw new Error('Blob dimensionality is required'); } if (!(getDimensionality(blobValue.dimensionality) in BlobDimensionality)) { throw new Error( - `unknown dimensionality value: ${blobValue.dimensionality}`, + `Unknown blob dimensionality value: ${blobValue.dimensionality}`, ); } if (blobValue.format != null && typeof blobValue.format !== 'string') { - throw new Error('format must be a string'); + throw new Error('Blob format must be a string'); } } @@ -89,4 +85,13 @@ export const blobHelper: InputHelper = { fromLiteral, toLiteral, validate, + typeDefinitionToDefaultValue: (typeDefinition): BlobValue => { + return { + uri: undefined, + dimensionality: + typeDefinition?.literalType?.blob?.dimensionality ?? + BlobDimensionality.SINGLE, + format: (typeDefinition?.literalType?.blob as any)?.format, + } as any as BlobValue; + }, }; diff --git a/packages/console/src/components/Launch/LaunchForm/inputHelpers/boolean.ts b/packages/console/src/components/Launch/LaunchForm/inputHelpers/boolean.ts index c7282a666..e8cdda9df 100644 --- a/packages/console/src/components/Launch/LaunchForm/inputHelpers/boolean.ts +++ b/packages/console/src/components/Launch/LaunchForm/inputHelpers/boolean.ts @@ -56,4 +56,7 @@ export const booleanHelper: InputHelper = { toLiteral, validate, defaultValue: false, + typeDefinitionToDefaultValue: typeDefinition => { + return false; + }, }; diff --git a/packages/console/src/components/Launch/LaunchForm/inputHelpers/collection.ts b/packages/console/src/components/Launch/LaunchForm/inputHelpers/collection.ts index d7f7b8ca4..91a14ad0c 100644 --- a/packages/console/src/components/Launch/LaunchForm/inputHelpers/collection.ts +++ b/packages/console/src/components/Launch/LaunchForm/inputHelpers/collection.ts @@ -1,14 +1,15 @@ import { Core } from '@flyteorg/flyteidl-types'; -import { InputTypeDefinition, InputValue } from '../types'; +import { InputType, InputTypeDefinition } from '../types'; import { literalNone } from './constants'; import { getHelperForInput } from './getHelperForInput'; import { parseJSON } from './parseJson'; import { ConverterInput, InputHelper, InputValidatorParams } from './types'; -import { collectionChildToString } from './utils'; +import { formatType } from '../utils'; +import { formatParameterValues } from './utils'; const missingSubTypeError = 'Unexpected missing subtype for collection'; -function parseCollection(list: string) { +export function parseCollection(list: string) { const parsed = parseJSON(list); if (!Array.isArray(parsed)) { throw new Error('Value did not parse to an array'); @@ -19,7 +20,7 @@ function parseCollection(list: string) { function fromLiteral( literal: Core.ILiteral, { subtype }: InputTypeDefinition, -): InputValue { +): string { if (!subtype) { throw new Error(missingSubTypeError); } @@ -28,22 +29,23 @@ function fromLiteral( } if (!literal.collection.literals) { throw new Error( - 'Collection literal missing `colleciton.literals` property', + 'Collection literal missing `collection.literals` property', ); } const subTypeHelper = getHelperForInput(subtype.type); - const values = literal.collection.literals.reduce( - (out, literal) => { - const value = subTypeHelper.fromLiteral(literal, subtype); - if (value !== undefined) { - out.push(collectionChildToString(subtype.type, value)); - } - return out; - }, - [], - ); - return `[${values.join(',')}]`; + const values = literal.collection.literals.map(literal => { + let temp = subTypeHelper.fromLiteral(literal, subtype); + try { + // JSON.parse corrupts large numbers, so we must use lossless json parsing + temp = parseJSON(temp as string); + } catch (e) { + // no-op + } + return temp; + }); + + return formatParameterValues(subtype.type, values); } function toLiteral({ @@ -77,18 +79,42 @@ function toLiteral({ }; } -function validate({ value }: InputValidatorParams) { +function validate({ + value, + typeDefinition, + required, + ...props +}: InputValidatorParams) { + const typeString = formatType(typeDefinition); if (typeof value !== 'string') { - throw new Error('Value must be a string'); + throw `Failed to parse to expected format: ${typeString}.`; } try { const parsed = parseCollection(value); if (!Array.isArray(parsed)) { - throw new Error(`Value parsed to type: ${typeof parsed}`); + throw new Error( + `Value parsed to type: ${typeof parsed}. Expected format: ${typeString}`, + ); } + // validate sub values + const collectionLiteral = toLiteral({ value, typeDefinition }); + const subtype = typeDefinition!.subtype; + const subTypeHelper = getHelperForInput(subtype?.type!); + collectionLiteral.collection!.literals!.map(subLiteral => { + const value = subTypeHelper.fromLiteral(subLiteral, subtype!); + subTypeHelper.validate({ + value, + typeDefinition: subtype, + required, + ...props, + } as any); + }); } catch (e) { - throw new Error(`Failed to parse array: ${e}`); + const typeString = formatType(typeDefinition); + throw new Error( + `Failed to parse to expected format: ${typeString}. ${e.message}`, + ); } } @@ -96,4 +122,23 @@ export const collectionHelper: InputHelper = { fromLiteral, toLiteral, validate, + typeDefinitionToDefaultValue: typeDefinition => { + const { subtype } = typeDefinition; + const subtypeHelper = getHelperForInput(subtype?.type!); + const subDefaultValue = subtypeHelper.typeDefinitionToDefaultValue( + subtype!, + ); + const subLiteral = subtypeHelper.toLiteral({ + value: subDefaultValue, + typeDefinition: subtype!, + }); + return fromLiteral( + { + collection: { + literals: [subLiteral], + }, + }, + { subtype: subtype! } as any, + ); + }, }; diff --git a/packages/console/src/components/Launch/LaunchForm/inputHelpers/datetime.ts b/packages/console/src/components/Launch/LaunchForm/inputHelpers/datetime.ts index f0ffead4f..c6ad98b83 100644 --- a/packages/console/src/components/Launch/LaunchForm/inputHelpers/datetime.ts +++ b/packages/console/src/components/Launch/LaunchForm/inputHelpers/datetime.ts @@ -38,4 +38,7 @@ export const datetimeHelper: InputHelper = { fromLiteral, toLiteral, validate, + typeDefinitionToDefaultValue: typeDefinition => { + return {}; + }, }; diff --git a/packages/console/src/components/Launch/LaunchForm/inputHelpers/duration.ts b/packages/console/src/components/Launch/LaunchForm/inputHelpers/duration.ts index c51cf23ea..7e6a26254 100644 --- a/packages/console/src/components/Launch/LaunchForm/inputHelpers/duration.ts +++ b/packages/console/src/components/Launch/LaunchForm/inputHelpers/duration.ts @@ -33,4 +33,7 @@ export const durationHelper: InputHelper = { fromLiteral, toLiteral, validate, + typeDefinitionToDefaultValue: typeDefinition => { + return ''; + }, }; diff --git a/packages/console/src/components/Launch/LaunchForm/inputHelpers/float.ts b/packages/console/src/components/Launch/LaunchForm/inputHelpers/float.ts index 08bcb0a96..4ce1caca6 100644 --- a/packages/console/src/components/Launch/LaunchForm/inputHelpers/float.ts +++ b/packages/console/src/components/Launch/LaunchForm/inputHelpers/float.ts @@ -39,4 +39,7 @@ export const floatHelper: InputHelper = { fromLiteral, toLiteral, validate, + typeDefinitionToDefaultValue: typeDefinition => { + return {}; + }, }; diff --git a/packages/console/src/components/Launch/LaunchForm/inputHelpers/integer.ts b/packages/console/src/components/Launch/LaunchForm/inputHelpers/integer.ts index ddd24be2e..11812a030 100644 --- a/packages/console/src/components/Launch/LaunchForm/inputHelpers/integer.ts +++ b/packages/console/src/components/Launch/LaunchForm/inputHelpers/integer.ts @@ -45,4 +45,7 @@ export const integerHelper: InputHelper = { fromLiteral, toLiteral, validate, + typeDefinitionToDefaultValue: typeDefinition => { + return ''; + }, }; diff --git a/packages/console/src/components/Launch/LaunchForm/inputHelpers/map.ts b/packages/console/src/components/Launch/LaunchForm/inputHelpers/map.ts index aa9af2f5e..d20685321 100644 --- a/packages/console/src/components/Launch/LaunchForm/inputHelpers/map.ts +++ b/packages/console/src/components/Launch/LaunchForm/inputHelpers/map.ts @@ -39,7 +39,12 @@ function fromLiteral( Object.entries(literal.map.literals).forEach(([key, childLiteral]) => { const helper = getHelperForInput(subtype.type); - result[key] = helper.fromLiteral(childLiteral, subtype); + const literalValue = helper.fromLiteral(childLiteral, subtype); + try { + result[key] = parseJSON(literalValue as any); + } catch { + result[key] = literalValue; + } }); return stringifyValue(result); @@ -94,13 +99,13 @@ function validate({ throw new Error(t('valueNotParse')); } const obj = parseJSON(value); - if ( - !Object.keys(obj).length || - Object.keys(obj).some(key => !key.trim().length) - ) { + if (!Object.keys(obj).length) { throw new Error(t('valueKeyRequired')); } Object.keys(obj).forEach(key => { + if (!key || typeof key !== 'string') { + throw new Error(t('valueKeyInvalid')); + } const helper = getHelperForInput(subtype.type); const subValue = obj[key]; @@ -121,4 +126,7 @@ export const mapHelper: InputHelper = { fromLiteral, toLiteral, validate, + typeDefinitionToDefaultValue: typeDefinition => { + return ''; + }, }; diff --git a/packages/console/src/components/Launch/LaunchForm/inputHelpers/none.ts b/packages/console/src/components/Launch/LaunchForm/inputHelpers/none.ts index 2b0171908..1bba3e3cf 100644 --- a/packages/console/src/components/Launch/LaunchForm/inputHelpers/none.ts +++ b/packages/console/src/components/Launch/LaunchForm/inputHelpers/none.ts @@ -12,10 +12,17 @@ function toLiteral({ value }: ConverterInput): Core.ILiteral { }; } -function validate({ value }: InputValidatorParams) {} +function validate({ value }: InputValidatorParams) { + if (typeof value !== 'object' && Object.keys(value).length) { + throw new Error('Value must be an empty object'); + } +} export const noneHelper: InputHelper = { fromLiteral, toLiteral, validate, + typeDefinitionToDefaultValue: typeDefinition => { + return fromLiteral({} as any); + }, }; diff --git a/packages/console/src/components/Launch/LaunchForm/inputHelpers/schema.ts b/packages/console/src/components/Launch/LaunchForm/inputHelpers/schema.ts index 5d2be15a0..b264e2704 100644 --- a/packages/console/src/components/Launch/LaunchForm/inputHelpers/schema.ts +++ b/packages/console/src/components/Launch/LaunchForm/inputHelpers/schema.ts @@ -27,4 +27,7 @@ export const schemaHelper: InputHelper = { fromLiteral, toLiteral, validate, + typeDefinitionToDefaultValue: typeDefinition => { + return {}; + }, }; diff --git a/packages/console/src/components/Launch/LaunchForm/inputHelpers/string.ts b/packages/console/src/components/Launch/LaunchForm/inputHelpers/string.ts index 82e7d6cc6..aed0c1191 100644 --- a/packages/console/src/components/Launch/LaunchForm/inputHelpers/string.ts +++ b/packages/console/src/components/Launch/LaunchForm/inputHelpers/string.ts @@ -12,19 +12,24 @@ function fromLiteral(literal: Core.ILiteral): InputValue { } function toLiteral({ value }: ConverterInput): Core.ILiteral { - const stringValue = typeof value === 'string' ? value : value.toString(); + const stringValue = + typeof value === 'string' + ? value + : // TODO: this is a hack to support the case where the value is a number + // Should we throw an error instead? + value?.toString?.(); return { scalar: { primitive: { stringValue } } }; } -function validate({ value }: InputValidatorParams) { +function validate({ value, required }: InputValidatorParams) { if (typeof value !== 'string') { throw new Error('Value is not a string'); } - if (value && value[0] === ' ') { - throw new Error('Value should not have a leading space'); + if (required && !value) { + throw new Error('Value should not be empty'); } - if (value && value[value.length - 1] === ' ') { - throw new Error('Value should not have a trailing space'); + if (value?.length !== value?.trim?.()?.length) { + throw new Error('Value should not have leading or trailing spaces'); } } @@ -32,4 +37,7 @@ export const stringHelper: InputHelper = { fromLiteral, toLiteral, validate, + typeDefinitionToDefaultValue: typeDefinition => { + return { scalar: { primitive: { stringValue: '' } } }; + }, }; diff --git a/packages/console/src/components/Launch/LaunchForm/inputHelpers/struct.ts b/packages/console/src/components/Launch/LaunchForm/inputHelpers/struct.ts index 56d78d219..04e688f57 100644 --- a/packages/console/src/components/Launch/LaunchForm/inputHelpers/struct.ts +++ b/packages/console/src/components/Launch/LaunchForm/inputHelpers/struct.ts @@ -1,9 +1,9 @@ import { stringifyValue } from 'common/utils'; import { Core, Protobuf } from '@flyteorg/flyteidl-types'; -import { InputValue } from '../types'; +import { InputType, InputValue } from '../types'; import { structPath } from './constants'; import { ConverterInput, InputHelper, InputValidatorParams } from './types'; -import { extractLiteralWithCheck } from './utils'; +import { extractLiteralWithCheck, formatParameterValues } from './utils'; export type PrimitiveType = string | number | boolean | null | object; @@ -103,7 +103,10 @@ function fromLiteral(literal: Core.ILiteral): InputValue { structPath, ); - return stringifyValue(protobufStructToObject(structValue)); + return formatParameterValues( + InputType.Struct, + protobufStructToObject(structValue), + ); } function toLiteral({ value }: ConverterInput): Core.ILiteral { @@ -127,6 +130,7 @@ function toLiteral({ value }: ConverterInput): Core.ILiteral { return { scalar: { generic: objectToProtobufStruct(parsedObject) } }; } +// TODO: proper validation based on struct params from typedefs function validate({ value }: InputValidatorParams) { if (typeof value !== 'string') { throw new Error('Value is not a string'); @@ -143,4 +147,7 @@ export const structHelper: InputHelper = { fromLiteral, toLiteral, validate, + typeDefinitionToDefaultValue: typeDefinition => { + return {}; + }, }; diff --git a/packages/console/src/components/Launch/LaunchForm/inputHelpers/test/inputHelpers.test.ts b/packages/console/src/components/Launch/LaunchForm/inputHelpers/test/inputHelpers.test.ts index 8b188feda..21cf91798 100644 --- a/packages/console/src/components/Launch/LaunchForm/inputHelpers/test/inputHelpers.test.ts +++ b/packages/console/src/components/Launch/LaunchForm/inputHelpers/test/inputHelpers.test.ts @@ -17,7 +17,6 @@ import { literalToInputValue, validateInput, } from '../inputHelpers'; -import { collectionChildToString } from '../utils'; import { inputTypes, literalTestCases, @@ -26,6 +25,7 @@ import { unsupportedTypes, validityTestCases, } from './testCases'; +import { formatParameterValues } from '../utils'; const baseInputProps: InputProps = { description: 'test', @@ -34,14 +34,14 @@ const baseInputProps: InputProps = { onChange: () => {}, required: false, typeDefinition: inputTypes.unknown, - setIsError: () => {}, }; function makeSimpleInput( typeDefinition: InputTypeDefinition, value: any, + required = false, ): InputProps { - return { ...baseInputProps, value, typeDefinition }; + return { ...baseInputProps, value, typeDefinition, required }; } function makeMapInput( @@ -93,8 +93,17 @@ describe('literalToInputValue', () => { literalToInputTestCases.map(([typeDefinition, input, output]) => it(`should correctly convert ${typeDefinition.type}: ${stringifyValue( input, - )}`, () => - expect(literalToInputValue(typeDefinition, input)).toEqual(output)), + )}`, () => { + const result = literalToInputValue(typeDefinition, input); + let expectedString = output; + if ( + typeDefinition.type === InputType.Integer || + typeDefinition.type === InputType.Struct + ) { + expectedString = formatParameterValues(typeDefinition.type, output); + } + expect(expectedString).toEqual(result); + }), ); supportedPrimitives.map(typeDefinition => @@ -159,11 +168,11 @@ describe('literalToInputValue', () => { literals: [input, input], }, }; - const stringifiedValue = collectionChildToString( - typeDefinition.type, + + const expectedString = formatParameterValues(typeDefinition.type, [ output, - ); - const expectedString = `[${stringifiedValue},${stringifiedValue}]`; + output, + ]); const result = literalToInputValue( collectionInputTypeDefinition(typeDefinition), collection, @@ -190,7 +199,7 @@ describe('literalToInputValue', () => { collectionInputTypeDefinition(typeDefinition), collection, ), - ).toEqual('[{},{}]'); + ).toEqual('[{}, {}]'); }); }); @@ -211,10 +220,11 @@ describe('inputToLiteral', () => { literalTestCases.map(([typeDefinition, input, output]) => { it(`should correctly convert ${typeDefinition.type}: ${stringifyValue( input, - )} (${typeof input})`, () => - expect(inputToLiteral(makeSimpleInput(typeDefinition, input))).toEqual( - output, - )); + )} (${typeof input})`, () => { + const expected = inputToLiteral(makeSimpleInput(typeDefinition, input)); + + expect(expected).toEqual(output); + }); }); }); @@ -223,9 +233,8 @@ describe('inputToLiteral', () => { let singleMapValue: any; let nestedMapValue: any; if (typeDefinition.type === InputType.Struct) { - const objValue = JSON.parse(input); - singleMapValue = stringifyValue({ a: objValue }); - nestedMapValue = stringifyValue({ a: { b: objValue } }); + singleMapValue = stringifyValue({ a: input }); + nestedMapValue = stringifyValue({ a: { b: input } }); } else if (['boolean', 'number'].includes(typeof input)) { singleMapValue = `{"a":${input}}`; nestedMapValue = `{"a":{"b":${input}}}`; @@ -251,7 +260,8 @@ describe('inputToLiteral', () => { const result = inputToLiteral( makeMapInput(typeDefinition, singleMapValue), ); - expect(result.map!.literals!.a).toEqual(output); + const expected = result.map!.literals!.a; + expect(expected).toEqual(output); }); it(`should correctly convert nested map of type ${ @@ -260,7 +270,8 @@ describe('inputToLiteral', () => { const result = inputToLiteral( makeNestedMapInput(typeDefinition, nestedMapValue), ); - expect(result.map!.literals!.a.map!.literals!.b).toEqual(output); + const expected = result.map!.literals!.a.map!.literals!.b; + expect(expected).toEqual(output); }); }); }); @@ -270,9 +281,8 @@ describe('inputToLiteral', () => { let singleCollectionValue: any; let nestedCollectionValue: any; if (typeDefinition.type === InputType.Struct) { - const objValue = JSON.parse(input); - singleCollectionValue = stringifyValue([objValue]); - nestedCollectionValue = stringifyValue([[objValue]]); + singleCollectionValue = stringifyValue([input]); + nestedCollectionValue = stringifyValue([[input]]); } else if (['boolean', 'number'].includes(typeof input)) { singleCollectionValue = `[${input}]`; nestedCollectionValue = `[[${input}]]`; @@ -382,8 +392,8 @@ function generateValidityTests( invalid.map(value => it(`should treat ${stringifyValue( value, - )} (${typeof value}) as invalid`, () => { - const input = makeSimpleInput(typeDefinition, value); + )} (${typeof value}) as invalid when required`, () => { + const input = makeSimpleInput(typeDefinition, value, true); expect(() => validateInput(input)).toThrowError(); }), ); diff --git a/packages/console/src/components/Launch/LaunchForm/inputHelpers/test/structTestCases.ts b/packages/console/src/components/Launch/LaunchForm/inputHelpers/test/structTestCases.ts index 69c311bcb..325387e3b 100644 --- a/packages/console/src/components/Launch/LaunchForm/inputHelpers/test/structTestCases.ts +++ b/packages/console/src/components/Launch/LaunchForm/inputHelpers/test/structTestCases.ts @@ -23,17 +23,14 @@ const structValues: { [k in keyof typeof values]: Protobuf.IValue } = { booleanFalseField: { boolValue: false }, }; -type StructTestCase = [string, Core.ILiteral]; +type StructTestCase = [any, Core.ILiteral]; export const structTestCases: StructTestCase[] = [ - ['{}', structLiteral({ fields: {} })], + [{}, structLiteral({ fields: {} })], // simple case with no lists or nested structs - [ - stringifyValue({ ...values }), - structLiteral({ fields: { ...structValues } }), - ], + [{ ...values }, structLiteral({ fields: { ...structValues } })], // Nested struct value [ - stringifyValue({ nestedStruct: { ...values } }), + { nestedStruct: { ...values } }, structLiteral({ fields: { nestedStruct: { structValue: { fields: { ...structValues } } }, @@ -42,7 +39,7 @@ export const structTestCases: StructTestCase[] = [ ], // List [ - stringifyValue({ listField: Object.values(values) }), + { listField: Object.values(values) }, structLiteral({ fields: { listField: { @@ -53,7 +50,7 @@ export const structTestCases: StructTestCase[] = [ ], // Nested struct with list [ - stringifyValue({ nestedStruct: { listField: Object.values(values) } }), + { nestedStruct: { listField: Object.values(values) } }, structLiteral({ fields: { nestedStruct: { @@ -72,7 +69,7 @@ export const structTestCases: StructTestCase[] = [ ], // List with nested struct [ - stringifyValue({ listField: [{ ...values }] }), + { listField: [{ ...values }] }, structLiteral({ fields: { listField: { @@ -85,7 +82,7 @@ export const structTestCases: StructTestCase[] = [ ], // List with nested list [ - stringifyValue({ listField: [Object.values(values)] }), + { listField: [Object.values(values)] }, structLiteral({ fields: { listField: { diff --git a/packages/console/src/components/Launch/LaunchForm/inputHelpers/test/testCases.ts b/packages/console/src/components/Launch/LaunchForm/inputHelpers/test/testCases.ts index 2f91dd210..e2295b6e2 100644 --- a/packages/console/src/components/Launch/LaunchForm/inputHelpers/test/testCases.ts +++ b/packages/console/src/components/Launch/LaunchForm/inputHelpers/test/testCases.ts @@ -231,6 +231,9 @@ export const validityTestCases = { /** Test cases for converting a *valid* input value to its corresponding ILiteral * representation. */ export const literalTestCases: InputToLiteralTestParams[] = [ + /** + * BOOLEAN TEST CASES + */ [inputTypes.boolean, true, primitiveLiteral({ boolean: true })], [inputTypes.boolean, 'true', primitiveLiteral({ boolean: true })], [inputTypes.boolean, 't', primitiveLiteral({ boolean: true })], @@ -241,6 +244,9 @@ export const literalTestCases: InputToLiteralTestParams[] = [ [inputTypes.boolean, 'f', primitiveLiteral({ boolean: false })], [inputTypes.boolean, '0', primitiveLiteral({ boolean: false })], [inputTypes.boolean, 0, primitiveLiteral({ boolean: false })], + /** + * DATETIME TEST CASES + */ [ inputTypes.datetime, new Date(validDateString), @@ -255,6 +261,9 @@ export const literalTestCases: InputToLiteralTestParams[] = [ datetime: dateToTimestamp(new Date(validDateString)), }), ], + /** + * DURATION TEST CASES + */ [ inputTypes.duration, 0, @@ -265,6 +274,9 @@ export const literalTestCases: InputToLiteralTestParams[] = [ 10000, primitiveLiteral({ duration: millisecondsToDuration(10000) }), ], + /** + * FLOAT TEST CASES + */ [inputTypes.float, 0, primitiveLiteral({ floatValue: 0 })], [inputTypes.float, '0', primitiveLiteral({ floatValue: 0 })], [inputTypes.float, -1.5, primitiveLiteral({ floatValue: -1.5 })], @@ -273,6 +285,9 @@ export const literalTestCases: InputToLiteralTestParams[] = [ [inputTypes.float, '1.5', primitiveLiteral({ floatValue: 1.5 })], [inputTypes.float, 1.25e10, primitiveLiteral({ floatValue: 1.25e10 })], [inputTypes.float, '1.25e10', primitiveLiteral({ floatValue: 1.25e10 })], + /** + * INTEGER TEST CASES + */ [inputTypes.integer, 0, primitiveLiteral({ integer: Long.fromNumber(0) })], [ inputTypes.integer, @@ -318,6 +333,9 @@ export const literalTestCases: InputToLiteralTestParams[] = [ Long.MIN_VALUE, primitiveLiteral({ integer: Long.MIN_VALUE }), ], + /** + * SCHEMA TEST CASES + */ [ inputTypes.schema, '', @@ -339,8 +357,14 @@ export const literalTestCases: InputToLiteralTestParams[] = [ }, }, ], + /** + * STRING TEST CASES + */ [inputTypes.string, '', primitiveLiteral({ stringValue: '' })], [inputTypes.string, 'abcdefg', primitiveLiteral({ stringValue: 'abcdefg' })], + /** + * BLOB TEST CASES + */ // Standard Blob [ inputTypes.blobSingle, @@ -440,17 +464,23 @@ export const literalTestCases: InputToLiteralTestParams[] = [ uri: 's3://somePath', }), ], - // Blob missing URI (results in None) + // Blob missing URI [ inputTypes.blobMulti, { format: 'csv', dimensionality: 'MULTIPART', }, - literalNone(), + blobLiteral({ + dimensionality: BlobDimensionality.MULTIPART, + format: 'csv', + }), ], // Blob which is not an object (results in None) [inputTypes.blobMulti, undefined, literalNone()], + /** + * STRUCT TEST CASES + */ ...structTestCases.map( ([stringValue, literalValue]) => [ inputTypes.struct, @@ -497,22 +527,18 @@ export const literalToInputTestCases: LiteralToInputTestParams[] = [ [inputTypes.float, primitiveLiteral({ floatValue: 1.5 }), 1.5], [inputTypes.float, primitiveLiteral({ floatValue: 1.25e10 }), 1.25e10], // Integers will be returned as strings because they may overflow numbers - [inputTypes.integer, primitiveLiteral({ integer: Long.fromNumber(0) }), '0'], - [inputTypes.integer, primitiveLiteral({ integer: Long.fromNumber(1) }), '1'], - [ - inputTypes.integer, - primitiveLiteral({ integer: Long.fromNumber(-1) }), - '-1', - ], + [inputTypes.integer, primitiveLiteral({ integer: Long.fromNumber(0) }), 0], + [inputTypes.integer, primitiveLiteral({ integer: Long.fromNumber(1) }), 1], + [inputTypes.integer, primitiveLiteral({ integer: Long.fromNumber(-1) }), -1], [ inputTypes.integer, primitiveLiteral({ integer: Long.MAX_VALUE }), - Long.MAX_VALUE.toString(), + Long.MAX_VALUE, ], [ inputTypes.integer, primitiveLiteral({ integer: Long.MIN_VALUE }), - Long.MIN_VALUE.toString(), + Long.MIN_VALUE, ], [inputTypes.schema, { scalar: { schema: { uri: '' } } }, ''], [ @@ -589,11 +615,9 @@ export const literalToInputTestCases: LiteralToInputTestParams[] = [ uri: 's3://somePath', }, ], - ...structTestCases.map( - ([stringValue, literalValue]) => [ - inputTypes.struct, - literalValue, - stringValue, - ], - ), + ...structTestCases.map(([value, literalValue]) => [ + inputTypes.struct, + literalValue, + value, + ]), ]; diff --git a/packages/console/src/components/Launch/LaunchForm/inputHelpers/types.ts b/packages/console/src/components/Launch/LaunchForm/inputHelpers/types.ts index 7362ca2d7..118f784ea 100644 --- a/packages/console/src/components/Launch/LaunchForm/inputHelpers/types.ts +++ b/packages/console/src/components/Launch/LaunchForm/inputHelpers/types.ts @@ -27,4 +27,7 @@ export interface InputHelper { fromLiteral: LiteralToInputConterterFn; /** Will throw in the case of a failed validation */ validate: (params: InputValidatorParams) => void; + typeDefinitionToDefaultValue: ( + typeDefinition: InputTypeDefinition, + ) => InputValue; } diff --git a/packages/console/src/components/Launch/LaunchForm/inputHelpers/union.ts b/packages/console/src/components/Launch/LaunchForm/inputHelpers/union.ts index 2a7648e61..db4c48a64 100644 --- a/packages/console/src/components/Launch/LaunchForm/inputHelpers/union.ts +++ b/packages/console/src/components/Launch/LaunchForm/inputHelpers/union.ts @@ -4,6 +4,7 @@ import { InputTypeDefinition, InputValue, UnionValue } from '../types'; import { getHelperForInput } from './getHelperForInput'; import { ConverterInput, InputHelper, InputValidatorParams } from './types'; import t from '../../../common/strings'; +import { getInputDefintionForLiteralType } from '../utils'; function fromLiteral( literal: Core.ILiteral, @@ -14,27 +15,39 @@ function fromLiteral( throw new Error(t('missingUnionListOfSubType')); } + const localLiteral = literal?.scalar?.union || (literal as any); + const inputDef = + (localLiteral?.type && + getInputDefintionForLiteralType(localLiteral.type as any)) || + localLiteral?.typeDefinition; + // Unpack nested variant of union data value - const literalValue = literal?.scalar?.union?.value - ? literal.scalar.union.value - : literal; + const literalValue = localLiteral?.value || literal; - // loop though the subtypes to find the correct match literal typex` - for (let i = 0; i < listOfSubTypes.length; i++) { + if (inputDef) { + const helper = getHelperForInput(inputDef.type); try { - const value = getHelperForInput(listOfSubTypes[i].type).fromLiteral( - literalValue, - listOfSubTypes[i], - ); - return { value, typeDefinition: listOfSubTypes[i] } as UnionValue; - } catch (error) { - // do nothing here. it's expected to have error from fromLiteral - // because we loop through all the type to decode the input value - // the error should be something like this - // new Error(`Failed to extract literal value with path ${path}`); + const value = helper.fromLiteral(literalValue, inputDef); + return { value, typeDefinition: inputDef } as UnionValue; + } catch { + // no-op, continue executing } } - throw new Error(t('noMatchingResults')); + + // else try to guess the type + const values = listOfSubTypes.map(subtype => { + const helper = getHelperForInput(subtype.type); + try { + const value = helper.fromLiteral(literalValue, subtype); + + return { value, typeDefinition: subtype } as UnionValue; + } catch { + // no-op + } + return { value: undefined, typeDefinition: subtype }; + }); + + return values?.filter(v => v.value)?.[0] || values?.[0]; } function toLiteral({ @@ -51,16 +64,21 @@ function toLiteral({ const { value: unionValue, typeDefinition } = value as UnionValue; - return getHelperForInput(typeDefinition.type).toLiteral({ + const literal = getHelperForInput(typeDefinition.type).toLiteral({ value: unionValue, typeDefinition: typeDefinition, } as ConverterInput); + return { + scalar: { + union: { + value: literal, + type: typeDefinition.literalType, + }, + }, + }; } -function validate({ - value, - typeDefinition: { listOfSubTypes }, -}: InputValidatorParams) { +function validate({ value, ...props }: InputValidatorParams) { if (!value) { throw new Error(t('valueRequired')); } @@ -68,14 +86,32 @@ function validate({ throw new Error(t('valueMustBeObject')); } - const { typeDefinition } = value as UnionValue; - getHelperForInput(typeDefinition.type).validate( - value as InputValidatorParams, - ); + try { + const { typeDefinition: subTypeDefinition } = value as UnionValue; + getHelperForInput(subTypeDefinition.type).validate({ + required: props.required, + ...(value as any), + }); + } catch (error) { + throw new Error('Invalid value'); + } } export const unionHelper: InputHelper = { fromLiteral, toLiteral, validate, + typeDefinitionToDefaultValue: typeDefinition => { + const { listOfSubTypes } = typeDefinition; + const selectedSubType = listOfSubTypes?.[0]; + const subtypeHelper = getHelperForInput(selectedSubType?.type!); + return { + scalar: { + union: { + value: subtypeHelper.typeDefinitionToDefaultValue(selectedSubType!), + type: typeDefinition.literalType, + }, + }, + }; + }, }; diff --git a/packages/console/src/components/Launch/LaunchForm/inputHelpers/utils.ts b/packages/console/src/components/Launch/LaunchForm/inputHelpers/utils.ts index 5b38f488e..05101cfd1 100644 --- a/packages/console/src/components/Launch/LaunchForm/inputHelpers/utils.ts +++ b/packages/console/src/components/Launch/LaunchForm/inputHelpers/utils.ts @@ -20,7 +20,7 @@ export function extractLiteralWithCheck( /** Converts a value within a collection to the appropriate string * representation. Some values require additional quotes. */ -export function collectionChildToString(type: InputType, value: any) { +export function collectionChildToStringOld(type: InputType, value: any) { if (value === undefined) { return ''; } @@ -29,6 +29,21 @@ export function collectionChildToString(type: InputType, value: any) { : stringifyValue(value); } +export function formatParameterValues(type: InputType, value: any) { + if (value === undefined) { + return ''; + } + + return type === InputType.Integer + ? `${value}` + : JSON.stringify(value, null, type === InputType.Struct ? 2 : 0) + .split(',') + .join(', '); + // return type === (InputType.Integer || InputType.Struct) + // ? `${value}` + // : stringifyValue(value); +} + /** Determines if a given input type, including all levels of nested types, is * supported for use in the Launch form. */ diff --git a/packages/console/src/components/Launch/LaunchForm/test/LaunchTaskForm.test.tsx b/packages/console/src/components/Launch/LaunchForm/test/LaunchTaskForm.test.tsx index ad977802b..40edf5e48 100644 --- a/packages/console/src/components/Launch/LaunchForm/test/LaunchTaskForm.test.tsx +++ b/packages/console/src/components/Launch/LaunchForm/test/LaunchTaskForm.test.tsx @@ -1,5 +1,6 @@ import { ThemeProvider } from '@material-ui/styles'; import { + act, fireEvent, getAllByRole, getByLabelText, @@ -48,6 +49,15 @@ import { } from './constants'; import { createMockObjects } from './utils'; +jest.mock( + 'components/Executions/ExecutionDetails/Timeline/ExecutionTimelineContainer', + () => ({ + ExecutionTimelineContainer: jest.fn(() => ( +
+ )), + }), +); + describe('LaunchForm: Task', () => { let onClose: jest.Mock; let mockTask: Task; @@ -63,6 +73,12 @@ describe('LaunchForm: Task', () => { beforeEach(() => { onClose = jest.fn(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); }); const createMockTaskWithInputs = (id: Identifier) => { @@ -132,7 +148,7 @@ describe('LaunchForm: Task', () => { const renderForm = (props?: Partial) => { return render( - + { describe('With Inputs', () => { beforeEach(() => { const { simpleString, simpleInteger, simpleFloat, simpleBoolean } = - cloneDeep(mockSimpleVariables); + mockSimpleVariables; // Only taking supported variable types since they are all required. - variables = { + variables = cloneDeep({ simpleString, simpleInteger, simpleFloat, simpleBoolean, - }; + }); createMocks(); }); + afterEach(() => { + variables = {}; + }); it('should not show task selector until options have loaded', async () => { mockListTasks.mockReturnValue(pendingPromise()); @@ -239,13 +258,22 @@ describe('LaunchForm: Task', () => { identifier = id; return promise; }); - const { container } = renderForm(); + const { container, getByLabelText } = renderForm(); const submitButton = await waitFor(() => getSubmitButton(container)); expect(submitButton).toBeDisabled(); resolve(createMockTaskWithInputs(identifier)); + // wait for inputs to load + await waitFor(() => + getByLabelText(integerInputName, { + exact: false, + }), + ); + // fill inputs because they are required + await fillInputs(container); + await waitFor(() => expect(submitButton).not.toBeDisabled()); }); @@ -258,12 +286,17 @@ describe('LaunchForm: Task', () => { exact: false, }), ); - const submitButton = getSubmitButton(container); + + // fill inputs because they are required + await fillInputs(container); + await fireEvent.change(integerInput, { target: { value: 'abc' } }); - await fireEvent.click(getSubmitButton(container)); + + const submitButton = getSubmitButton(container); await waitFor(() => expect(submitButton).toBeDisabled()); - await fireEvent.change(integerInput, { target: { value: '123' } }); + await fireEvent.change(integerInput, { target: { value: 123 } }); + await waitFor(() => expect(submitButton).toBeEnabled()); }); @@ -345,11 +378,25 @@ describe('LaunchForm: Task', () => { const errorString = 'Something went wrong'; mockCreateWorkflowExecution.mockRejectedValue(new Error(errorString)); - const { container, getByText, getByTitle, queryByText } = renderForm(); - await waitFor(() => getByTitle(t('inputs'))); + const { container, getByText, getByTitle, queryByText, getByLabelText } = + renderForm(); + await waitFor(() => + getByLabelText(integerInputName, { + exact: false, + }), + ); + await fillInputs(container); - await fireEvent.click(getSubmitButton(container)); + const submitButton = await waitFor(() => getSubmitButton(container)); + await waitFor(() => expect(submitButton).toBeEnabled()); + + await fireEvent.click(submitButton); + + await waitFor(() => + expect(mockCreateWorkflowExecution).toHaveBeenCalled(), + ); + await waitFor(() => expect(getByText(errorString)).toBeInTheDocument()); // Click the expander for the launch plan, select the second item @@ -634,6 +681,9 @@ describe('LaunchForm: Task', () => { }); describe('Interruptible', () => { + beforeEach(() => { + createMocks(); + }); it('should render checkbox', async () => { const { getByLabelText } = renderForm(); const inputElement = await waitFor(() => @@ -710,7 +760,9 @@ describe('LaunchForm: Task', () => { expect(inputElement).toHaveAttribute('data-indeterminate', 'true'); await fillInputs(container); - await fireEvent.click(getSubmitButton(container)); + const submitButton = await waitFor(() => getSubmitButton(container)); + await waitFor(() => expect(submitButton).toBeEnabled()); + fireEvent.click(submitButton); await waitFor(() => expect(mockCreateWorkflowExecution).toHaveBeenCalledWith( @@ -735,7 +787,9 @@ describe('LaunchForm: Task', () => { expect(inputElement).toHaveAttribute('data-indeterminate', 'false'); await fillInputs(container); - await fireEvent.click(getSubmitButton(container)); + const submitButton = await waitFor(() => getSubmitButton(container)); + await waitFor(() => expect(submitButton).toBeEnabled()); + fireEvent.click(submitButton); await waitFor(() => expect(mockCreateWorkflowExecution).toHaveBeenCalledWith( @@ -760,7 +814,9 @@ describe('LaunchForm: Task', () => { expect(inputElement).toHaveAttribute('data-indeterminate', 'false'); await fillInputs(container); - await fireEvent.click(getSubmitButton(container)); + const submitButton = await waitFor(() => getSubmitButton(container)); + await waitFor(() => expect(submitButton).toBeEnabled()); + await fireEvent.click(submitButton); await waitFor(() => expect(mockCreateWorkflowExecution).toHaveBeenCalledWith( @@ -773,6 +829,9 @@ describe('LaunchForm: Task', () => { }); describe('overwrite cache', () => { + beforeEach(() => { + createMocks(); + }); it('should render checkbox', async () => { const { getByLabelText } = renderForm(); const inputElement = await waitFor(() => @@ -809,7 +868,9 @@ describe('LaunchForm: Task', () => { expect(inputElement).not.toBeChecked(); await fillInputs(container); - fireEvent.click(getSubmitButton(container)); + const submitButton = await waitFor(() => getSubmitButton(container)); + await waitFor(() => expect(submitButton).toBeEnabled()); + fireEvent.click(submitButton); await waitFor(() => expect(mockCreateWorkflowExecution).toHaveBeenCalledWith( @@ -825,6 +886,7 @@ describe('LaunchForm: Task', () => { overwriteCache: true, }; const { container, getByLabelText } = renderForm({ initialParameters }); + await waitFor(() => {}); const inputElement = await waitFor(() => getByLabelText(t('overwriteCache'), { exact: false }), @@ -833,7 +895,9 @@ describe('LaunchForm: Task', () => { expect(inputElement).toBeChecked(); await fillInputs(container); - fireEvent.click(getSubmitButton(container)); + const submitButton = await waitFor(() => getSubmitButton(container)); + await waitFor(() => expect(submitButton).toBeEnabled()); + fireEvent.click(submitButton); await waitFor(() => expect(mockCreateWorkflowExecution).toHaveBeenCalledWith( @@ -857,7 +921,9 @@ describe('LaunchForm: Task', () => { expect(inputElement).not.toBeChecked(); await fillInputs(container); - fireEvent.click(getSubmitButton(container)); + const submitButton = await waitFor(() => getSubmitButton(container)); + await waitFor(() => expect(submitButton).toBeEnabled()); + fireEvent.click(submitButton); await waitFor(() => expect(mockCreateWorkflowExecution).toHaveBeenCalledWith( @@ -871,6 +937,19 @@ describe('LaunchForm: Task', () => { }); describe('overwrite cache', () => { + 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 render checkbox', async () => { const { getByLabelText } = renderForm(); const inputElement = await waitFor(() => @@ -907,7 +986,10 @@ describe('LaunchForm: Task', () => { expect(inputElement).not.toBeChecked(); await fillInputs(container); - fireEvent.click(getSubmitButton(container)); + + const submitButton = await waitFor(() => getSubmitButton(container)); + await waitFor(() => expect(submitButton).toBeEnabled()); + fireEvent.click(submitButton); await waitFor(() => expect(mockCreateWorkflowExecution).toHaveBeenCalledWith( @@ -931,7 +1013,10 @@ describe('LaunchForm: Task', () => { expect(inputElement).toBeChecked(); await fillInputs(container); - fireEvent.click(getSubmitButton(container)); + + const submitButton = await waitFor(() => getSubmitButton(container)); + await waitFor(() => expect(submitButton).toBeEnabled()); + fireEvent.click(submitButton); await waitFor(() => expect(mockCreateWorkflowExecution).toHaveBeenCalledWith( @@ -946,16 +1031,22 @@ describe('LaunchForm: Task', () => { const initialParameters: TaskInitialLaunchParameters = { overwriteCache: false, }; - const { container, getByLabelText } = renderForm({ initialParameters }); + const { container, getByLabelText } = renderForm({ + initialParameters, + }); + await waitFor(() => {}); const inputElement = await waitFor(() => getByLabelText(t('overwriteCache'), { exact: false }), ); expect(inputElement).toBeInTheDocument(); expect(inputElement).not.toBeChecked(); - await fillInputs(container); - fireEvent.click(getSubmitButton(container)); + + const submitButton = await waitFor(() => getSubmitButton(container)); + await waitFor(() => expect(submitButton).toBeEnabled()); + + await fireEvent.click(submitButton); await waitFor(() => expect(mockCreateWorkflowExecution).toHaveBeenCalledWith( diff --git a/packages/console/src/components/Launch/LaunchForm/test/LaunchWorkflowForm.test.tsx b/packages/console/src/components/Launch/LaunchForm/test/LaunchWorkflowForm.test.tsx index ef4d2ff98..8cd9e1a67 100644 --- a/packages/console/src/components/Launch/LaunchForm/test/LaunchWorkflowForm.test.tsx +++ b/packages/console/src/components/Launch/LaunchForm/test/LaunchWorkflowForm.test.tsx @@ -171,7 +171,7 @@ describe('LaunchForm: Workflow', () => { const renderForm = (props?: Partial) => { return render( - + { const integerInput = getByLabelText(integerInputName, { exact: false, }); - const submitButton = getSubmitButton(container); + let submitButton = getSubmitButton(container); + await fireEvent.change(integerInput, { target: { value: 'abc' } }); + await act(() => { + jest.runAllTimers(); + }); + await waitFor(() => expect(submitButton).toBeDisabled()); - await fireEvent.change(integerInput, { target: { value: '123' } }); + await fireEvent.change(integerInput, { target: { value: 123 } }); await act(() => { jest.runAllTimers(); }); + + submitButton = getSubmitButton(container); await waitFor(() => expect(submitButton).toBeEnabled()); }); @@ -326,11 +333,13 @@ describe('LaunchForm: Workflow', () => { }), ); const submitButton = getSubmitButton(container); + await fireEvent.change(integerInput, { target: { value: 'abc' } }); await waitFor(() => expect(submitButton).toBeDisabled()); await fireEvent.change(integerInput, { target: { value: '123' } }); await waitFor(() => expect(submitButton).toBeEnabled()); + await fireEvent.click(submitButton); await waitFor(() => expect(mockCreateWorkflowExecution).toHaveBeenCalled(), @@ -464,7 +473,8 @@ describe('LaunchForm: Workflow', () => { const { container } = renderForm(); await waitFor(() => {}); - await fireEvent.click(getSubmitButton(container)); + const submitButton = getSubmitButton(container); + await fireEvent.click(submitButton); await waitFor(() => {}); expect(mockCreateWorkflowExecution).toHaveBeenCalled(); diff --git a/packages/console/src/components/Launch/LaunchForm/test/ResumeSignalForm.test.tsx b/packages/console/src/components/Launch/LaunchForm/test/ResumeSignalForm.test.tsx index 91b8f9354..1ff1e13d4 100644 --- a/packages/console/src/components/Launch/LaunchForm/test/ResumeSignalForm.test.tsx +++ b/packages/console/src/components/Launch/LaunchForm/test/ResumeSignalForm.test.tsx @@ -97,7 +97,7 @@ describe('ResumeSignalForm', () => { mockCompiledNode, ]); return render( - + ; export type LiteralValueMap = Map; @@ -247,7 +247,8 @@ export interface InputProps { typeDefinition: InputTypeDefinition; value?: InputValue; onChange: InputChangeHandler; - setIsError: (boolean) => void; + // used to signal to the input that it is part of a collection + hasCollectionParent?: boolean; } export interface ParsedInput diff --git a/packages/console/src/components/Launch/LaunchForm/useFormInputsState.ts b/packages/console/src/components/Launch/LaunchForm/useFormInputsState.ts index 004d0d4a3..93479b710 100644 --- a/packages/console/src/components/Launch/LaunchForm/useFormInputsState.ts +++ b/packages/console/src/components/Launch/LaunchForm/useFormInputsState.ts @@ -77,7 +77,6 @@ function useFormInputState(parsedInput: ParsedInput): FormInputState { validate, value, helperText: parsedInput.description, - setIsError: () => {}, }; } diff --git a/packages/console/src/components/Launch/LaunchForm/useTaskSourceSelectorState.ts b/packages/console/src/components/Launch/LaunchForm/useTaskSourceSelectorState.ts index 274fb3dfd..099047304 100644 --- a/packages/console/src/components/Launch/LaunchForm/useTaskSourceSelectorState.ts +++ b/packages/console/src/components/Launch/LaunchForm/useTaskSourceSelectorState.ts @@ -4,7 +4,7 @@ import { Identifier, NamedEntityIdentifier } from 'models/Common/types'; import { taskSortFields } from 'models/Task/constants'; import { Task } from 'models/Task/types'; import { useMemo, useState } from 'react'; -import { SearchableSelectorOption } from './SearchableSelector'; +import { SearchableSelectorOption } from './LaunchFormComponents/SearchableSelector'; import { TaskSourceSelectorState } from './types'; import { useVersionSelectorOptions } from './useVersionSelectorOptions'; import { versionsToSearchableSelectorOptions } from './utils'; diff --git a/packages/console/src/components/Launch/LaunchForm/useWorkflowSourceSelectorState.ts b/packages/console/src/components/Launch/LaunchForm/useWorkflowSourceSelectorState.ts index ea2dfe193..07228205f 100644 --- a/packages/console/src/components/Launch/LaunchForm/useWorkflowSourceSelectorState.ts +++ b/packages/console/src/components/Launch/LaunchForm/useWorkflowSourceSelectorState.ts @@ -5,7 +5,7 @@ import { LaunchPlan } from 'models/Launch/types'; import { workflowSortFields } from 'models/Workflow/constants'; import { Workflow, WorkflowId } from 'models/Workflow/types'; import { useMemo, useState } from 'react'; -import { SearchableSelectorOption } from './SearchableSelector'; +import { SearchableSelectorOption } from './LaunchFormComponents/SearchableSelector'; import { WorkflowSourceSelectorState } from './types'; import { useVersionSelectorOptions } from './useVersionSelectorOptions'; import { diff --git a/packages/console/src/components/Launch/LaunchForm/utils.ts b/packages/console/src/components/Launch/LaunchForm/utils.ts index 6e106215e..eeccd3772 100644 --- a/packages/console/src/components/Launch/LaunchForm/utils.ts +++ b/packages/console/src/components/Launch/LaunchForm/utils.ts @@ -11,7 +11,7 @@ import { simpleTypeToInputType, typeLabels } from './constants'; import { inputToLiteral } from './inputHelpers/inputHelpers'; import { typeIsSupported } from './inputHelpers/utils'; import { LaunchState } from './launchMachine'; -import { SearchableSelectorOption } from './SearchableSelector'; +import { SearchableSelectorOption } from './LaunchFormComponents/SearchableSelector'; import { BaseInterpretedLaunchState, BlobValue, @@ -211,8 +211,15 @@ export function getUnsupportedRequiredInputs( ); } -export function isBlobValue(value: unknown): value is BlobValue { - return isObject(value); +export function isStringValue(value: unknown): value is string { + return typeof value === 'string'; +} + +export function isBlobValue(value: any): value is BlobValue { + return ( + isObject(value) && + ('uri' in value || 'format' in value || 'dimensionality' in value) + ); } /** Determines if a given launch machine state is one in which a user can provide input values. */ diff --git a/packages/console/src/components/common/MapTaskExecutionsList/test/TaskNameList.test.tsx b/packages/console/src/components/common/MapTaskExecutionsList/test/TaskNameList.test.tsx index 2eec6e06c..010858b54 100644 --- a/packages/console/src/components/common/MapTaskExecutionsList/test/TaskNameList.test.tsx +++ b/packages/console/src/components/common/MapTaskExecutionsList/test/TaskNameList.test.tsx @@ -21,7 +21,7 @@ const taskLogsWithoutUri = [ describe('TaskNameList', () => { it('should render log names in color if they have URI', async () => { const { queryAllByTestId } = render( - +