feat(my-joy-beta): revise Metadata and KeyValue implementations

fixes #908
This commit is contained in:
Sérgio Ramos 2017-12-06 18:16:11 +00:00
parent c184066f26
commit 2538453d98
5 changed files with 436 additions and 324 deletions

View File

@ -1,4 +1,6 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { withTheme } from 'styled-components';
import { Row, Col } from 'react-styled-flexboxgrid';
import Value from 'react-redux-values';
import { Field } from 'redux-form';
@ -6,6 +8,7 @@ import styled from 'styled-components';
import remcalc from 'remcalc';
import titleCase from 'title-case';
import { Margin, Padding } from 'styled-components-spacing';
import Flex, { FlexItem } from 'styled-flex-component';
import Editor from 'joyent-ui-toolkit/dist/es/editor';
import {
@ -25,7 +28,9 @@ import {
FormMeta,
Button,
Textarea,
Divider
Editor,
Divider,
DeleteIcon
} from 'joyent-ui-toolkit';
const CollapsedKeyValue = styled.span`
@ -49,155 +54,180 @@ class ValueTextareaField extends PureComponent {
}
}
const KeyValue = ({
id,
label = '',
textarea,
create,
last,
first,
expanded,
removing,
pristine,
error,
submitting,
onRemove,
onToggleExpanded,
handleSubmit,
onClear
}) => {
const _error = error &&
!submitting && (
<Message error>
<MessageTitle>Ooops!</MessageTitle>
<MessageDescription>{error}</MessageDescription>
</Message>
);
const _meta = expanded ? (
<H4>{create ? `Create ${label}` : `Edit ${label}`}</H4>
) : (
<CollapsedKeyValue>
<Field
name="name"
type="text"
component={({ input }) => <b>{`${input.value}: `}</b>}
/>
<Field name="value" type="text" component={({ input }) => input.value} />
</CollapsedKeyValue>
);
const chevronToggle = create ? null : (
<CardHeaderBox onClick={onToggleExpanded} actionable={expanded}>
<ChevronIcon />
</CardHeaderBox>
);
const _valueField = textarea ? (
const TextareaKeyValue = ({ type, submitting }) => [
<Row key="key">
<Col xs={12}>
<FormGroup name="name" reduxForm fluid>
<FormLabel>{titleCase(type)} key</FormLabel>
<Input type="text" disabled={submitting} />
<FormMeta />
</FormGroup>
<Divider height={remcalc(12)} transparent />
</Col>
</Row>,
<Row key="value">
<Col xs={12}>
<FormGroup name="value" reduxForm fluid>
<FormLabel>{titleCase(type)} value</FormLabel>
<Field
name="name"
fluid
component={ValueTextareaField}
props={{ submitting }}
/>
) : (
<Input disabled={submitting} />
);
const _cancel = (
<Button
type="button"
key="cancel"
bold
onClick={
create
? pristine ? onToggleExpanded : onClear
: pristine ? onRemove : onClear
}
disabled={submitting}
loading={submitting && removing}
secondary
marginless
>
{create ? (pristine ? 'Cancel' : 'Clear') : pristine ? 'Remove' : 'Clear'}
</Button>
);
const _submit = (
<Button
type="submit"
key="submit"
bold
disabled={pristine || submitting}
loading={submitting && !removing}
marginless
>
{create ? 'Create' : 'Update'}
</Button>
);
return (
<form onSubmit={handleSubmit}>
<Divider
transparent
marginBottom={!first && expanded ? remcalc(13) : 0}
/>
<Card
collapsed={!expanded}
actionable={!expanded}
bottomless={!last && !expanded}
>
<CardHeader
secondary={false}
transparent={false}
onClick={onToggleExpanded}
actionable
>
<CardHeaderMeta>
<Padding left={1}>{_meta}</Padding>
</CardHeaderMeta>
{chevronToggle}
</CardHeader>
<CardOutlet>
<Padding all={1}>
<Row>
<Col xs={12}>{_error}</Col>
<FormMeta />
</FormGroup>
<Divider height={remcalc(12)} transparent />
</Col>
</Row>
<Row>
<Col xs={6}>
<FormGroup name="name" field={Field} fluid>
<FormLabel>Enter {titleCase(label)} key</FormLabel>
];
const InputKeyValue = ({ type, submitting }) => (
<Flex full justifyStart contentStretch>
<FlexItem basis="auto">
<FormGroup name="name" reduxForm fluid>
<FormLabel>{titleCase(type)} key</FormLabel>
<Input type="text" disabled={submitting} />
<FormMeta />
</FormGroup>
</Col>
<Col xs={6}>
<FormGroup name="value" field={Field} fluid>
<FormLabel>Enter {titleCase(label)} value</FormLabel>
{_valueField}
</FlexItem>
<FlexItem basis="auto">
<FormGroup name="value" reduxForm fluid>
<FormLabel>{titleCase(type)} value</FormLabel>
<Input disabled={submitting} />
<FormMeta />
</FormGroup>
</Col>
</Row>
</FlexItem>
</Flex>
);
const KeyValue = withTheme(
({
input = 'input',
type = 'metadata',
method = 'add',
error = null,
expanded = true,
submitting = false,
pristine = true,
removing = false,
handleSubmit,
onToggleExpanded = () => null,
onCancel = () => null,
onRemove = () => null,
theme
}) => {
const handleHeaderClick = method === 'edit' && onToggleExpanded;
return (
<form onSubmit={handleSubmit}>
<Card collapsed={!expanded} actionable={!expanded} shadow>
<CardHeader
secondary={false}
transparent={false}
actionable={Boolean(handleHeaderClick)}
onClick={handleHeaderClick}
>
<CardHeaderMeta>
{method === 'add' ? (
<H4>{`${titleCase(method)} ${type}`}</H4>
) : (
<CollapsedKeyValue>
<Field
name="name"
type="text"
component={({ input }) =>
expanded ? (
`${input.value}: `
) : (
<b>{`${input.value}: `}</b>
)
}
/>
<Field
name="value"
type="text"
component={({ input }) => input.value}
/>
</CollapsedKeyValue>
)}
</CardHeaderMeta>
</CardHeader>
<CardOutlet>
<Padding all={1}>
{error && !submitting ? (
<Row>
<Col xs={12}>
<Margin top={2}>
{_cancel}
{_submit}
</Margin>
<Message error>
<MessageTitle>Ooops!</MessageTitle>
<MessageDescription>{error}</MessageDescription>
</Message>
</Col>
</Row>
) : null}
{input === 'input' ? (
<InputKeyValue type={type} submitting={submitting} />
) : (
<TextareaKeyValue type={type} submitting={submitting} />
)}
<Row between="xs" middle="xs">
<Col xs={method === 'add' ? 12 : 7}>
<Button
type="button"
onClick={onCancel}
disabled={submitting}
secondary
marginless
>
<span>Cancel</span>
</Button>
<Button
type="submit"
disabled={pristine}
loading={submitting && !removing}
marginless
>
<span>{method === 'add' ? 'Create' : 'Save'}</span>
</Button>
</Col>
<Col xs={method === 'add' ? false : 5}>
<Button
type="button"
onClick={onRemove}
disabled={submitting}
loading={removing}
secondary
right
icon
error
marginless
>
<DeleteIcon
disabled={submitting}
fill={submitting ? undefined : theme.red}
/>
<span>Delete</span>
</Button>
</Col>
</Row>
</Padding>
</CardOutlet>
</Card>
<Divider transparent marginBottom={last || expanded ? remcalc(13) : 0} />
<Divider height={remcalc(13)} transparent />
</form>
);
}
);
KeyValue.propTypes = {
input: PropTypes.oneOf(['input', 'textarea']).isRequired,
type: PropTypes.string.isRequired,
method: PropTypes.oneOf(['add', 'edit']).isRequired,
removing: PropTypes.bool.isRequired,
expanded: PropTypes.bool.isRequired,
onToggleExpanded: PropTypes.func,
onCancel: PropTypes.func,
onRemove: PropTypes.func
};
export default ({ id, ...rest }) => (
<Value name={`${id}-removing`}>
{({ value: removing }) => (
<KeyValue {...rest} removing={removing} id={id} />
)}
</Value>
);
export default props => <KeyValue {...props} />;

View File

@ -0,0 +1,47 @@
import React from 'react';
import KeyValue from './key-value';
import {
Row,
Col,
FormGroup,
Input,
FormLabel,
Button
} from 'joyent-ui-toolkit';
export const MenuForm = ({ searchable, onAdd }) => (
<form>
<Row>
<Col xs={7} sm={5}>
<FormGroup name="filter" fluid reduxForm>
<FormLabel>Filter</FormLabel>
<Input disabled={!searchable} fluid />
</FormGroup>
</Col>
<Col xs={5} sm={7}>
<FormGroup right>
<FormLabel>&#8291;</FormLabel>
<Button
type="button"
disabled={!searchable}
onClick={onAdd}
small
icon
fluid
>
Add metadata
</Button>
</FormGroup>
</Col>
</Row>
</form>
);
export const AddForm = props => (
<KeyValue {...props} method="add" input="textarea" type="metadata" expanded />
);
export const EditForm = props => (
<KeyValue {...props} method="edit" input="textarea" type="metadata" />
);

View File

@ -30,6 +30,7 @@ import DisableInstanceFw from '@graphql/disable-instance-fw.gql';
import CreateSnapshot from '@graphql/create-snapshot.gql';
import StartSnapshot from '@graphql/start-from-snapshot.gql';
import Index from '@state/gen-index';
import parseError from '@state/parse-error';
import {
default as InstanceList,
@ -264,12 +265,6 @@ export default compose(
})
);
// parses the error to handle existance or not of graphQLErrors
const parseError = ({ graphQLErrors = [], message = '' }) =>
graphQLErrors.length
? graphQLErrors.map(({ message }) => message).join('\n')
: message;
// reverts submitting flag to false and propagates the error if it exists
const flipSubmitFalse = stopSubmit(TABLE_FORM_NAME, {
_error: err && parseError(err)

View File

@ -6,115 +6,121 @@ import { connect } from 'react-redux';
import { SubmissionError, reset, startSubmit, stopSubmit } from 'redux-form';
import ReduxForm from 'declarative-redux-form';
import find from 'lodash.find';
import sortBy from 'lodash.sortby';
import get from 'lodash.get';
import intercept from 'apr-intercept';
import remcalc from 'remcalc';
import {
ViewContainer,
Title,
StatusLoader,
Message,
MessageDescription,
MessageTitle,
Button
Button,
Divider,
H3
} from 'joyent-ui-toolkit';
import GetMetadata from '@graphql/list-metadata.gql';
import UpdateMetadata from '@graphql/update-metadata.gql';
import DeleteMetadata from '@graphql/delete-metadata.gql';
import { KeyValue } from '@components/instances';
import parseError from '@state/parse-error';
const METADATA_FORM_KEY = (name, field) => `instance-metadata-${name}-${field}`;
const CREATE_METADATA_FORM_KEY = name => `instance-create-metadata-${name}`;
import {
MenuForm as MetadataMenuForm,
AddForm as MetadataAddForm,
EditForm as MetadataEditForm
} from '@components/instances/metadata';
const MENU_FORM_NAME = 'instance-metadata-list-menu';
const ADD_FORM_NAME = 'instance-metadata-add-new';
const METADATA_FORM_KEY = field => `instance-metadata-${paramCase(field)}`;
const Metadata = ({
instance,
values = [],
metadata = [],
addOpen,
loading,
error,
handleRemove,
handleClear,
handleToggleAddOpen,
handleUpdateExpanded,
handleCancel,
handleCreate,
handleUpdate,
handleCreate
handleRemove
}) => {
const _loading = !(loading && !values.length) ? null : <StatusLoader />;
const _loading = !(loading && !metadata.length) ? null : <StatusLoader />;
const _add = addOpen ? (
<ReduxForm
form={ADD_FORM_NAME}
onSubmit={handleCreate}
onCancel={() => handleToggleAddOpen(false)}
>
{MetadataAddForm}
</ReduxForm>
) : null;
const _line = !_loading
? [
<Divider key="line" height={remcalc(1)} />,
<Divider key="after-line-space" height={remcalc(24)} transparent />
]
: null;
const _count = !_loading ? (
<H3 marginBottom={remcalc(24)} marginTop={addOpen && remcalc(24)}>
{metadata.length} key:value pair
</H3>
) : null;
// metadata items forms
const _metadata =
!_loading &&
values.map(({ form, initialValues }, i) => (
<Value name={`${form}-expanded`} key={form}>
{({ value: expanded, onValueChange }) => (
metadata.map(({ form, initialValues, expanded, removing }) => (
<ReduxForm
form={form}
key={form}
initialValues={initialValues}
onSubmit={newValues => handleUpdate(newValues, form)}
destroyOnUnmount
id={form}
onClear={() => handleClear(form)}
onToggleExpanded={() => onValueChange(!expanded)}
destroyOnUnmount={false}
onSubmit={handleUpdate}
onToggleExpanded={() => handleUpdateExpanded(form, !expanded)}
onCancel={() => handleCancel(form)}
onRemove={() => handleRemove(form)}
label="metadata"
last={values.length - 1 === i}
first={i === 0}
expanded={expanded}
textarea
removing={removing}
>
{KeyValue}
{MetadataEditForm}
</ReduxForm>
)}
</Value>
));
// create metadata form
const _addKey = instance && CREATE_METADATA_FORM_KEY(instance.name);
const _add = _metadata &&
_addKey && (
<Value name={`${_addKey}-expanded`}>
{({ value: expanded, onValueChange }) =>
!expanded ? (
<Button
type="button"
onClick={() => onValueChange(!expanded)}
secondary
>
Add metadata
</Button>
) : (
<ReduxForm
form={_addKey}
onSubmit={handleCreate}
id={_addKey}
onClear={() => handleClear(_addKey)}
onToggleExpanded={() => onValueChange(!expanded)}
onRemove={() => handleRemove(_addKey)}
expanded={expanded}
label="metadata"
create
textarea
>
{KeyValue}
</ReduxForm>
)}
</Value>
);
// fetching error
const _error =
error && !values.length && !_loading ? (
error && !_metadata.length && !_loading ? (
<Message error>
<MessageTitle>Ooops!</MessageTitle>
<MessageDescription>
An error occurred while loading your instance metadata
An error occurred while loading your metadata
</MessageDescription>
</Message>
) : null;
return (
<ViewContainer center={Boolean(_loading)} main>
{_loading}
<ViewContainer main>
<ReduxForm
form={MENU_FORM_NAME}
searchable={!_loading}
onAdd={() => handleToggleAddOpen(!addOpen)}
>
{MetadataMenuForm}
</ReduxForm>
<Divider height={remcalc(11)} transparent />
{_line}
{_error}
{_metadata}
{_loading}
{_add}
{_count}
{_metadata}
</ViewContainer>
);
};
@ -124,7 +130,7 @@ export default compose(
graphql(DeleteMetadata, { name: 'deleteMetadata' }),
graphql(GetMetadata, {
options: ({ match }) => ({
pollInterval: 1000,
// pollInterval: 1000,
variables: {
name: get(match, 'params.instance')
}
@ -133,23 +139,18 @@ export default compose(
const { name } = variables;
const instance = find(get(rest, 'machines', []), ['name', name]);
const metadata = get(instance, 'metadata', []);
const values = get(instance, 'metadata', []);
const values = sortBy(metadata, 'name').map(({ name, value }) => {
const field = paramCase(name);
const form = METADATA_FORM_KEY(name, field);
return {
form,
const metadata = values.map(({ name, value }) => ({
form: METADATA_FORM_KEY(name),
initialValues: {
name,
value
}
};
});
}));
return {
values,
metadata,
instance,
loading,
error,
@ -157,58 +158,76 @@ export default compose(
};
}
}),
connect(null, (dispatch, ownProps) => {
connect(
({ values }, { metadata, ownProps }) => ({
...ownProps,
addOpen: get(values, 'add-metadata-open', false),
metadata: metadata.map(({ form, ...metadata }) => ({
...metadata,
form,
expanded: get(values, `${form}-expanded`, false),
removing: get(values, `${form}-removing`, false)
}))
}),
(dispatch, ownProps) => {
const {
instance,
values,
refetch,
metadata,
updateMetadata,
deleteMetadata
deleteMetadata,
refetch
} = ownProps;
return {
// reset sets values to initialValues
handleClear: form => dispatch(reset(form)),
handleRemove: form =>
Promise.resolve(
// set removing=true (so that we can have a specific removing spinner)
// because remove button is not a submit button, we have to manually flip that flag
handleCancel: form =>
dispatch([
set({ name: `${form}-removing`, value: true }),
startSubmit(form)
])
)
.then(() =>
// call mutation. get key from values' initialValues
deleteMetadata({
set({ name: `${form}-expanded`, value: false }),
dispatch(reset(form))
]),
handleToggleAddOpen: value =>
dispatch(set({ name: `add-metadata-open`, value })),
handleUpdateExpanded: (form, expanded) =>
dispatch(set({ name: `${form}-expanded`, value: expanded })),
handleCreate: async ({ name, value }) => {
// call mutation
const [err] = await intercept(
updateMetadata({
variables: {
id: instance.id,
name: get(find(values, ['form', form]), 'initialValues.name')
metadata: [{ name, value }]
}
})
)
// fetch metadata again
.then(() => refetch())
// we only flip removing and submitting when there is an error.
// the reason for that is that metadata is updated asyncronously and
// it takes longer to have an efect than the mutation
.catch(error =>
);
if (err) {
// show mutation error
throw new SubmissionError({
_error: parseError(err)
});
}
dispatch([
set({ name: `${form}-removing`, value: false }),
stopSubmit(form, {
_error: error.graphQLErrors
.map(({ message }) => message)
.join('\n')
})
])
),
handleUpdate: ({ name, value }, form) =>
// call mutation. delete existing metadata, add new
// reset create new metadata form
reset(ADD_FORM_NAME),
stopSubmit(ADD_FORM_NAME),
// close add form
set({ name: `add-metadata-open`, value: false })
]);
// fetch metadata again (even though we are polling)
return refetch();
},
handleUpdate: async ({ name, value }, _, { form }) => {
// call mutations
const [err] = await intercept(
Promise.all([
deleteMetadata({
variables: {
id: instance.id,
name: get(find(values, ['form', form]), 'initialValues.name')
name: get(
find(metadata, ['form', form]),
'initialValues.name'
)
}
}),
updateMetadata({
@ -218,36 +237,57 @@ export default compose(
}
})
])
// fetch metadata again
.then(() => refetch())
// submit is flipped once the promise is resolved
.catch(error => {
);
if (err) {
// show mutation error
throw new SubmissionError({
_error: error.graphQLErrors
.map(({ message }) => message)
.join('\n')
_error: parseError(err)
});
}),
handleCreate: ({ name, value }) =>
}
dispatch([
// reset form
stopSubmit(form),
// close card
set({ name: `${form}-expanded`, value: false })
]);
// fetch metadata again (even though we are polling)
return refetch();
},
handleRemove: async form => {
dispatch([
set({ name: `${form}-removing`, value: true }),
startSubmit(form)
]);
// call mutation
updateMetadata({
const [err] = await intercept(
deleteMetadata({
variables: {
id: instance.id,
metadata: [{ name, value }]
name: get(find(metadata, ['form', form]), 'initialValues.name')
}
})
// fetch metadata again
.then(() => refetch())
// reset create new metadata form
.then(() => dispatch(reset(CREATE_METADATA_FORM_KEY(instance.name))))
// submit is flipped once the promise is resolved
.catch(error => {
);
if (err) {
// show mutation error
throw new SubmissionError({
_error: error.graphQLErrors
.map(({ message }) => message)
.join('\n')
_error: parseError(err)
});
})
}
dispatch([
stopSubmit(form),
set({ name: `${form}-removing`, value: false })
]);
// fetch metadata again (even though we are polling)
return refetch();
}
};
})
}
)
)(Metadata);

View File

@ -28,7 +28,7 @@ class FormGroup extends Component {
}
renderGroup(inputProps) {
const { className, style, children, ...rest } = this.props;
const { className, style, children, fluid = false, ...rest } = this.props;
const value = {
id: rndId(),
@ -37,7 +37,7 @@ class FormGroup extends Component {
};
return (
<Wrapper className={className} style={style} {...rest}>
<Wrapper className={className} style={style} fluid={fluid} {...rest}>
<Broadcast channel="input-group" value={value}>
<Noop>{children}</Noop>
</Broadcast>