joyent-portal/consoles/my-joy-instances/src/containers/instances/tags.js

361 lines
9.8 KiB
JavaScript

import React from 'react';
import { Margin } from 'styled-components-spacing';
import paramCase from 'param-case';
import { compose, graphql } from 'react-apollo';
import { set } from 'react-redux-values';
import { SubmissionError, reset, stopSubmit, startSubmit } from 'redux-form';
import ReduxForm from 'declarative-redux-form';
import { connect } from 'react-redux';
import find from 'lodash.find';
import get from 'lodash.get';
import intercept from 'apr-intercept';
import remcalc from 'remcalc';
import Fuse from 'fuse.js';
import {
ViewContainer,
StatusLoader,
Divider,
H3,
TagList,
TagItem
} from 'joyent-ui-toolkit';
import { KeyValue } from 'joyent-ui-resource-widgets';
import ToolbarForm from '@components/instances/toolbar';
import GetTags from '@graphql/list-tags.gql';
import UpdateTags from '@graphql/update-tags.gql';
import DeleteTag from '@graphql/delete-tag.gql';
import parseError from '@state/parse-error';
import { addTag as validateTag } from '@state/validators';
import Confirm from '@state/confirm';
const MENU_FORM_NAME = 'instance-tags-list-menu';
const ADD_FORM_NAME = 'instance-tags-add-new';
const EDIT_FORM_KEY = field => `instance-tags-${paramCase(field)}`;
const TagsAddForm = props => (
<KeyValue {...props} method="add" input="input" type="tag" expanded />
);
const TagsEditForm = props => (
<KeyValue {...props} method="edit" input="input" type="tag" expanded />
);
const Tag = ({ name, value, onRemoveClick, onClick }) => (
<Margin right="1" bottom="1" key={`${name}-${value}`}>
<TagItem onClick={onClick} onRemoveClick={onRemoveClick}>
{name ? `${name}: ${value}` : value}
</TagItem>
</Margin>
);
export const Tags = ({
tags = [],
addOpen,
editing,
editable,
loading,
handleToggleAddOpen,
handleToggleEditing,
handleCancel,
handleEdit,
handleRemove,
handleCreate,
shouldAsyncValidate,
handleAsyncValidate
}) => {
const _loading = loading && !tags.length ? <StatusLoader /> : null;
const _add = addOpen ? (
<ReduxForm
form={ADD_FORM_NAME}
shouldAsyncValidate={shouldAsyncValidate}
asyncValidate={handleAsyncValidate}
onSubmit={handleCreate}
onCancel={() => handleToggleAddOpen(false)}
>
{TagsAddForm}
</ReduxForm>
) : null;
const _line =
!_loading && !addOpen
? [
<Divider key="line" height={remcalc(1)} />,
<Divider key="after-line-space" height={remcalc(24)} transparent />
]
: null;
const _count = _loading ? null : (
<Margin bottom="3" top="5">
<H3>
{tags.length} tag{tags.length === 1 ? '' : 's'}
</H3>
</Margin>
);
const _tags = _loading ? null : (
<TagList>
{tags.map(({ id, name, value }) => (
<Tag
key={id}
id={id}
name={name}
value={value}
onClick={editable && !editing && (() => handleToggleEditing(name))}
/>
))}
</TagList>
);
const _edit = editing ? (
<ReduxForm
form={editing.form}
initialValues={{ name: editing.name, value: editing.value }}
shouldAsyncValidate={shouldAsyncValidate}
asyncValidate={handleAsyncValidate}
onSubmit={handleEdit}
>
{props => (
<TagsEditForm
{...props}
/* yeah, we need this here too */
initialValues={{ name: editing.name, value: editing.value }}
onCancel={() => handleToggleEditing(false)}
onToggleExpanded={() => handleToggleEditing(false)}
onRemove={() => handleRemove(editing.form, editing)}
removing={editing.removing}
/>
)}
</ReduxForm>
) : null;
return (
<ViewContainer main>
<Margin bottom="3">
<ReduxForm form={MENU_FORM_NAME}>
{props => (
<ToolbarForm
{...props}
searchable={!_loading}
searchLabel="Filter tags"
searchPlaceholder="Search by name or value"
actionLabel="Add Tag"
actionable={!editing}
onActionClick={() => handleToggleAddOpen(!addOpen)}
/>
)}
</ReduxForm>
</Margin>
<Divider height={remcalc(11)} transparent />
{_line}
{_loading}
{_add}
{_edit}
{_count}
{_tags}
</ViewContainer>
);
};
export default compose(
graphql(UpdateTags, { name: 'updateTags' }),
graphql(DeleteTag, { name: 'deleteTag' }),
graphql(GetTags, {
options: ({ match }) => ({
ssr: false,
pollInterval: 1000,
variables: {
id: get(match, 'params.instance')
}
}),
props: ({ data: { loading, error, machine, refetch, ...rest } }) => {
const tags = get(machine, 'tags', []).filter(
({ name = '' }) => !/^triton\.cns\./i.test(name)
);
const index = new Fuse(tags, {
keys: ['name', 'value']
});
return {
tags,
instance: machine,
index,
loading,
error,
refetch
};
}
}),
connect(
({ values, form }, { tags, index, ...ownProps }) => {
// get search value
const filter = get(form, `${MENU_FORM_NAME}.values.filter`, false);
// if user is searching something, get items that match that query
// if user is searching something, get items that match that query
const filtered = filter ? index.search(filter) : tags;
const addOpen = get(values, 'add-tags-open', false);
const editingTagName = get(values, 'editing-tag', null);
const removingTagName = get(values, 'removing-tag', null);
const removingTag = editingTagName === removingTagName;
const editingTag =
editingTagName && find(filtered, ['name', editingTagName]);
return {
...ownProps,
tags: filtered,
addOpen,
// are existing tags editable?
editable: !addOpen && !editingTag,
// if a tag is being edited, which one?
editing: editingTag && {
...editingTag,
removing: Boolean(removingTag),
form: EDIT_FORM_KEY(editingTag.name)
}
};
},
(dispatch, ownProps) => {
return {
shouldAsyncValidate: ({ trigger }) => {
return trigger === 'submit';
},
handleAsyncValidate: validateTag,
handleToggleAddOpen: value => {
return dispatch(set({ name: `add-tags-open`, value }));
},
handleToggleEditing: value => {
return dispatch(set({ name: `editing-tag`, value }));
},
handleEdit: async ({ name, value }, _, { form, initialValues }) => {
const { instance, deleteTag, updateTags, refetch } = ownProps;
const replaceTag = async () => {
// we can't mutate in parallel because if the tag name is the
// same we can have a race condition and remove after updating
await updateTags({
variables: {
id: instance.id,
tags: [{ name, value }]
}
});
await deleteTag({
variables: {
id: instance.id,
name: initialValues.name
}
});
};
const updateValue = async () => {
await updateTags({
variables: {
id: instance.id,
tags: [{ name, value }]
}
});
};
const mutation =
initialValues.name === name ? updateValue : replaceTag;
const [err] = await intercept(mutation());
if (err) {
// show mutation error
throw new SubmissionError({
_error: parseError(err)
});
}
dispatch([
set({ name: `editing-tag`, value: false }),
reset(form),
startSubmit(form)
]);
// fetch tags again (even though we are polling)
return refetch();
},
handleRemove: async (form, { name }) => {
// eslint-disable-next-line no-alert
if (!await Confirm(`Do you want to remove "${name}"?`)) {
return;
}
const { instance, deleteTag, refetch } = ownProps;
dispatch([
set({ name: `removing-tag`, value: name }),
startSubmit(form)
]);
// call mutation
const [err] = await intercept(
deleteTag({
variables: {
id: instance.id,
name
}
})
);
if (err) {
// show mutation error
throw new SubmissionError({
_error: parseError(err)
});
}
dispatch([
set({ name: `editing-tag`, value: false }),
set({ name: `removing-tag`, value: false }),
reset(form),
startSubmit(form)
]);
// fetch tags again (even though we are polling)
return refetch();
},
handleCreate: async ({ name, value }) => {
const { updateTags, instance, refetch } = ownProps;
// call mutation
const [err] = await intercept(
updateTags({
variables: {
id: instance.id,
tags: [{ name, value }]
}
})
);
if (err) {
// show mutation error
throw new SubmissionError({
_error: parseError(err)
});
}
dispatch([
// reset create new tags form
reset(ADD_FORM_NAME),
stopSubmit(ADD_FORM_NAME),
// close add form
set({ name: `add-tags-open`, value: false })
]);
// fetch tags again (even though we are polling)
return refetch();
}
};
}
)
)(Tags);