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 React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { withTheme } from 'styled-components';
import { Row, Col } from 'react-styled-flexboxgrid'; import { Row, Col } from 'react-styled-flexboxgrid';
import Value from 'react-redux-values'; import Value from 'react-redux-values';
import { Field } from 'redux-form'; import { Field } from 'redux-form';
@ -6,6 +8,7 @@ import styled from 'styled-components';
import remcalc from 'remcalc'; import remcalc from 'remcalc';
import titleCase from 'title-case'; import titleCase from 'title-case';
import { Margin, Padding } from 'styled-components-spacing'; import { Margin, Padding } from 'styled-components-spacing';
import Flex, { FlexItem } from 'styled-flex-component';
import Editor from 'joyent-ui-toolkit/dist/es/editor'; import Editor from 'joyent-ui-toolkit/dist/es/editor';
import { import {
@ -25,7 +28,9 @@ import {
FormMeta, FormMeta,
Button, Button,
Textarea, Textarea,
Divider Editor,
Divider,
DeleteIcon
} from 'joyent-ui-toolkit'; } from 'joyent-ui-toolkit';
const CollapsedKeyValue = styled.span` const CollapsedKeyValue = styled.span`
@ -49,155 +54,180 @@ class ValueTextareaField extends PureComponent {
} }
} }
const KeyValue = ({ const TextareaKeyValue = ({ type, submitting }) => [
id, <Row key="key">
label = '', <Col xs={12}>
textarea, <FormGroup name="name" reduxForm fluid>
create, <FormLabel>{titleCase(type)} key</FormLabel>
last, <Input type="text" disabled={submitting} />
first, <FormMeta />
expanded, </FormGroup>
removing, <Divider height={remcalc(12)} transparent />
pristine, </Col>
error, </Row>,
submitting, <Row key="value">
onRemove, <Col xs={12}>
onToggleExpanded, <FormGroup name="value" reduxForm fluid>
handleSubmit, <FormLabel>{titleCase(type)} value</FormLabel>
onClear <Field
}) => { name="name"
const _error = error && fluid
!submitting && ( component={ValueTextareaField}
<Message error> props={{ submitting }}
<MessageTitle>Ooops!</MessageTitle> />
<MessageDescription>{error}</MessageDescription> <FormMeta />
</Message> </FormGroup>
<Divider height={remcalc(12)} transparent />
</Col>
</Row>
];
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>
</FlexItem>
<FlexItem basis="auto">
<FormGroup name="value" reduxForm fluid>
<FormLabel>{titleCase(type)} value</FormLabel>
<Input disabled={submitting} />
<FormMeta />
</FormGroup>
</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}>
<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 height={remcalc(13)} transparent />
</form>
); );
}
);
const _meta = expanded ? ( KeyValue.propTypes = {
<H4>{create ? `Create ${label}` : `Edit ${label}`}</H4> input: PropTypes.oneOf(['input', 'textarea']).isRequired,
) : ( type: PropTypes.string.isRequired,
<CollapsedKeyValue> method: PropTypes.oneOf(['add', 'edit']).isRequired,
<Field removing: PropTypes.bool.isRequired,
name="name" expanded: PropTypes.bool.isRequired,
type="text" onToggleExpanded: PropTypes.func,
component={({ input }) => <b>{`${input.value}: `}</b>} onCancel: PropTypes.func,
/> onRemove: PropTypes.func
<Field name="value" type="text" component={({ input }) => input.value} />
</CollapsedKeyValue>
);
const chevronToggle = create ? null : (
<CardHeaderBox onClick={onToggleExpanded} actionable={expanded}>
<ChevronIcon />
</CardHeaderBox>
);
const _valueField = textarea ? (
<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>
</Row>
<Row>
<Col xs={6}>
<FormGroup name="name" field={Field} fluid>
<FormLabel>Enter {titleCase(label)} 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}
</FormGroup>
</Col>
</Row>
<Row>
<Col xs={12}>
<Margin top={2}>
{_cancel}
{_submit}
</Margin>
</Col>
</Row>
</Padding>
</CardOutlet>
</Card>
<Divider transparent marginBottom={last || expanded ? remcalc(13) : 0} />
</form>
);
}; };
export default ({ id, ...rest }) => ( export default props => <KeyValue {...props} />;
<Value name={`${id}-removing`}>
{({ value: removing }) => (
<KeyValue {...rest} removing={removing} id={id} />
)}
</Value>
);

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 CreateSnapshot from '@graphql/create-snapshot.gql';
import StartSnapshot from '@graphql/start-from-snapshot.gql'; import StartSnapshot from '@graphql/start-from-snapshot.gql';
import Index from '@state/gen-index'; import Index from '@state/gen-index';
import parseError from '@state/parse-error';
import { import {
default as InstanceList, 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 // reverts submitting flag to false and propagates the error if it exists
const flipSubmitFalse = stopSubmit(TABLE_FORM_NAME, { const flipSubmitFalse = stopSubmit(TABLE_FORM_NAME, {
_error: err && parseError(err) _error: err && parseError(err)

View File

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

View File

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