Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: support mapped tasks #494

Merged
merged 11 commits into from
Jul 1, 2022
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { BlobInput } from './BlobInput';
import { CollectionInput } from './CollectionInput';
import { formStrings, inputsDescription } from './constants';
import { LaunchState } from './launchMachine';
import { MapInput } from './MapInput';
import { NoInputsNeeded } from './NoInputsNeeded';
import { SimpleInput } from './SimpleInput';
import { StructInput } from './StructInput';
Expand All @@ -24,6 +25,7 @@ function getComponentForInput(input: InputProps, showErrors: boolean) {
case InputType.Struct:
return <StructInput {...props} />;
case InputType.Map:
return <MapInput {...props} />;
case InputType.Unknown:
case InputType.None:
return <UnsupportedInput {...props} />;
Expand Down
213 changes: 213 additions & 0 deletions packages/zapp/console/src/components/Launch/LaunchForm/MapInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { Button, 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 { requiredInputSuffix } from './constants';
import { InputProps, InputType, InputTypeDefinition, InputValue } from './types';
import { formatType, toMappedTypeValue } from './utils';

const useStyles = makeStyles((theme: Theme) => ({
formControl: {
width: '100%',
marginTop: theme.spacing(1),
},
controls: {
margin: theme.spacing(1),
width: '100%',
display: 'flex',
alignItems: 'flex-start',
flexDirection: 'row',
},
keyControl: {
marginRight: theme.spacing(1),
},
valueControl: {
flexGrow: 1,
},
addButton: {
display: 'flex',
justifyContent: 'flex-end',
marginTop: theme.spacing(1),
},
error: {
border: '1px solid #f44336',
},
}));

interface MapInputItemProps {
data: MapInputItem;
subtype?: InputTypeDefinition;
setKey: (key: string) => void;
setValue: (value: string) => void;
isValid: (value: string) => boolean;
onDeleteItem: () => void;
}

const MapSingleInputItem = (props: MapInputItemProps) => {
const classes = useStyles();
const { data, subtype, setKey, setValue, isValid, onDeleteItem } = props;
const [error, setError] = React.useState(false);

const isOneLineType = subtype?.type === InputType.String || subtype?.type === InputType.Integer;

return (
<div className={classes.controls}>
<TextField
label={`string${requiredInputSuffix}`}
onChange={({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setKey(value);
setError(!!value && !isValid(value));
}}
value={data.key}
error={error}
placeholder="key"
variant="outlined"
helperText={error ? 'This key already defined' : ''}
className={classes.keyControl}
/>
<TextField
label={subtype ? `${formatType(subtype)}${requiredInputSuffix}` : ''}
onChange={({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setValue(value);
}}
value={data.value}
variant="outlined"
className={classes.valueControl}
multiline={!isOneLineType}
type={subtype?.type === InputType.Integer ? 'number' : 'text'}
/>
<IconButton onClick={onDeleteItem}>
<RemoveIcon />
</IconButton>
</div>
);
};

type MapInputItem = {
id: number | null;
key: string;
value: string;
};

const getNewMapItem = (id, key = '', value = ''): MapInputItem => {
return { id, key, value };
};

function parseMappedTypeValue(value?: InputValue): MapInputItem[] {
const fallback = [getNewMapItem(0)];
if (!value) {
return fallback;
}
try {
const mapObj = JSON.parse(value.toString());
if (typeof mapObj === 'object') {
return Object.keys(mapObj).map((key, index) => getNewMapItem(index, key, mapObj[key]));
}
} catch (e) {
// do nothing
}

return fallback;
}

export const MapInput = (props: InputProps) => {
const {
value,
label,
onChange,
typeDefinition: { subtype },
} = props;
const classes = useStyles();

const [data, setData] = React.useState<MapInputItem[]>(parseMappedTypeValue(value));

const onAddItem = () => {
setData((data) => [...data, getNewMapItem(data.length)]);
};

const updateUpperStream = () => {
const newPairs = data
.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);
};

const onSetKey = (id: number | null, key: string) => {
if (id === null) return;
setData((data) => {
data[id].key = key;
return [...data];
});
updateUpperStream();
};

const onSetValue = (id: number | null, value: string) => {
if (id === null) return;
setData((data) => {
data[id].value = value;
return [...data];
});
updateUpperStream();
};

const onDeleteItem = (id: number | null) => {
if (id === null) return;
setData((data) => {
const dataIndex = data.findIndex((item) => item.id === id);
if (dataIndex >= 0 && dataIndex < data.length) {
data[dataIndex].id = null;
}
return [...data];
});
updateUpperStream();
};

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 (
<Card variant="outlined">
<CardContent>
<Typography variant="body1" component="label">
{label}
</Typography>
{data
.filter((item) => item.id !== null)
.map((item) => {
return (
<MapSingleInputItem
key={item.id}
data={item}
subtype={subtype}
setKey={(key) => onSetKey(item.id, key)}
setValue={(value) => onSetValue(item.id, value)}
isValid={(value) => isValid(item.id, value)}
onDeleteItem={() => onDeleteItem(item.id)}
/>
);
})}
<div className={classes.addButton}>
<Button onClick={onAddItem}>+ ADD ITEM</Button>
</div>
</CardContent>
</Card>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,31 @@ export function nestedCollectionInputTypeDefinition(
},
};
}

export function mapInputTypeDefinition(typeDefinition: InputTypeDefinition): InputTypeDefinition {
return {
literalType: {
mapValueType: typeDefinition.literalType,
},
type: InputType.Map,
subtype: typeDefinition,
};
}

export function nestedMapInputTypeDefinition(
typeDefinition: InputTypeDefinition,
): InputTypeDefinition {
return {
literalType: {
mapValueType: {
mapValueType: typeDefinition.literalType,
},
},
type: InputType.Map,
subtype: {
literalType: { mapValueType: typeDefinition.literalType },
type: InputType.Map,
subtype: typeDefinition,
},
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { storiesOf } from '@storybook/react';
import * as React from 'react';
import { MapInput } from '../MapInput';
import { InputValue } from '../types';
import { inputTypes } from '../inputHelpers/test/testCases';

const stories = storiesOf('Launch/MapInput', module);

stories.addDecorator((story) => {
return <div style={{ width: 600, height: '95vh' }}>{story()}</div>;
});

stories.add('Base', () => {
return (
<MapInput
description="Something"
name="Variable A"
label="Variable_A"
required={false}
typeDefinition={inputTypes.map}
onChange={(newValue: InputValue) => {
console.log('onChange', newValue);
}}
/>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { datetimeHelper } from './datetime';
import { durationHelper } from './duration';
import { floatHelper } from './float';
import { integerHelper } from './integer';
import { mapHelper } from './map';
import { noneHelper } from './none';
import { schemaHelper } from './schema';
import { stringHelper } from './string';
Expand All @@ -26,7 +27,7 @@ const inputHelpers: Record<InputType, InputHelper> = {
[InputType.Error]: unsupportedHelper,
[InputType.Float]: floatHelper,
[InputType.Integer]: integerHelper,
[InputType.Map]: unsupportedHelper,
[InputType.Map]: mapHelper,
[InputType.None]: noneHelper,
[InputType.Schema]: schemaHelper,
[InputType.String]: stringHelper,
Expand Down
Loading