feat(my-joy-beta): instance tags

fixes #905
This commit is contained in:
Sara Vieira 2017-12-06 15:35:22 +00:00 committed by Sérgio Ramos
parent bba8c99ee6
commit 8c604df1d2
14 changed files with 469 additions and 239 deletions

3
.gitignore vendored
View File

@ -118,9 +118,6 @@ Session.vim
# temporary
.netrwhist
*~
# auto-generated tag files
tags
### Windows ###
# Windows image file caches

View File

@ -8,6 +8,9 @@
},
{
"path": "packages/my-joy-beta"
},
{
"path": "."
}
],
"settings": {}

View File

@ -6,11 +6,14 @@
"repository": "github:yldio/joyent-portal",
"main": "build/",
"scripts": {
"dev": "REACT_APP_GQL_PORT=4000 PORT=3069 REACT_APP_GQL_PROTOCOL=http joyent-react-scripts start",
"dev":
"REACT_APP_GQL_PORT=4000 PORT=3069 REACT_APP_GQL_PROTOCOL=http joyent-react-scripts start",
"start": "PORT=3069 joyent-react-scripts start",
"build": "NODE_ENV=production joyent-react-scripts build",
"lint-ci": "eslint . --ext .js --ext .md && echo 0 `# stylelint './src/**/*.js'`",
"lint": "eslint . --fix --ext .js --ext .md && echo 0 `# stylelint './src/**/*.js'`",
"lint-ci":
"eslint . --ext .js --ext .md && echo 0 `# stylelint './src/**/*.js'`",
"lint":
"eslint . --fix --ext .js --ext .md && echo 0 `# stylelint './src/**/*.js'`",
"test": "NODE_ENV=test joyent-react-scripts test --env=jsdom",
"test-ci": "redrun test",
"prepublish": "echo 0"
@ -43,6 +46,7 @@
"redux-form": "^7.1.2",
"remcalc": "^1.0.9",
"styled-components": "^2.2.3",
"styled-flex-component": "^1.1.0",
"title-case": "^2.1.1"
},
"devDependencies": {

View File

@ -0,0 +1,19 @@
import React from 'react';
import renderer from 'react-test-renderer';
import Store from '@mocks/store';
import 'jest-styled-components';
import Tags from '../tags';
it('renders <Tags /> without throwing', () => {
const tree = renderer
.create(
<Store>
<Tags />
</Store>
)
.toJSON();
expect(tree).toMatchSnapshot();
});

View File

@ -206,75 +206,65 @@ export default withTheme(
<CardOutlet big>
<Meta {...instance} />
<Flex>
<Flex>
<Button
secondary
bold
icon
loading={starting}
disabled={instance.state === 'RUNNING'}
onClick={() => onAction('start')}
>
<Padding right={3} style={{ height: 18 }}>
<StartIcon disabled={instance.state === 'RUNNING'} />
</Padding>
<span>Start</span>
</Button>
<Button
secondary
bold
icon
loading={stopping}
disabled={instance.state === 'STOPPED'}
onClick={() => onAction('stop')}
>
<Padding right={3} style={{ height: 18 }}>
<StopIcon disabled={instance.state === 'STOPPED'} />
</Padding>
<span>Stop</span>
</Button>
<Button
secondary
bold
icon
loading={rebooting}
disabled={instance.state === 'PROVISIONING'}
onClick={() => onAction('reboot')}
>
<Padding right={3} style={{ height: 18 }}>
<ResetIcon disabled={instance.state === 'PROVISIONING'} />
</Padding>
<span>Restart</span>
</Button>
</Flex>
<FlexEnd>
<Button
error
bold
icon
loading={deleteing}
disabled={instance.state === 'PROVISIONING'}
onClick={() => onAction('delete')}
>
<Padding right={3} style={{ height: 18 }}>
<DeleteIcon
fill={theme.red}
disabled={instance.state === 'PROVISIONING'}
/>
</Padding>
<span>Delete</span>
</Button>
</FlexEnd>
<Button
secondary
bold
icon
loading={starting}
disabled={instance.state === 'RUNNING'}
onClick={() => onAction('start')}
>
<StartIcon disabled={instance.state === 'RUNNING'} />
<Padding left={1}>Start</Padding>
</Button>
<Button
secondary
bold
icon
loading={stopping}
disabled={instance.state === 'STOPPED'}
onClick={() => onAction('stop')}
>
<StopIcon disabled={instance.state === 'STOPPED'} />
<Padding left={1}>Stop</Padding>
</Button>
<Button
secondary
bold
icon
loading={rebooting}
disabled={instance.state === 'PROVISIONING'}
onClick={() => onAction('reboot')}
>
<ResetIcon disabled={instance.state === 'PROVISIONING'} />
<Padding left={1}>Restart</Padding>
</Button>
</Flex>
<Margin bottom={5} top={4}>
<Divider height={remcalc(1)} />
</Margin>
<CopiableField text={instance.id.split('-')[0]} label="Short ID" />
<CopiableField text={instance.id} label="ID" />
<CopiableField text={instance.compute_node} label="CN UUID" />
{instance.image && (
<CopiableField text={instance.image.id} label="Image UUID" />
)}
<FlexEnd>
<Button
error
bold
icon
loading={deleteing}
disabled={instance.state === 'PROVISIONING'}
onClick={() => onAction('delete')}
>
<DeleteIcon fill={theme.red} disabled={instance.state === 'PROVISIONING'} />
<Padding left={1}>Delete</Padding>
</Button>
</FlexEnd>
</Flex>
<Margin bottom={5} top={4}>
<Divider height={remcalc(1)} />
</Margin>
<CopiableField text={instance.id.split('-')[0]} label="Short ID" />
<CopiableField text={instance.id} label="ID" />
<CopiableField text={instance.compute_node} label="CN UUID" />
{instance.image && (
<CopiableField text={instance.image.id} label="Image UUID" />
)}
<CopiableField text={`$ ssh root@${instance.primary_ip}`} label="Login" />
{instance.ips.map((ip, i) => (
<CopiableField
text={`$ ssh root@${instance.primary_ip}`}
label="Login"

View File

@ -5,10 +5,12 @@ import { Field } from 'redux-form';
import styled from 'styled-components';
import remcalc from 'remcalc';
import titleCase from 'title-case';
import { Margin, Padding } from 'styled-components-spacing';
import {
Message,
MessageDescription,
H4,
MessageTitle,
Card,
CardHeader,
@ -17,14 +19,13 @@ import {
CardOutlet,
ChevronIcon,
FormGroup,
Label,
FormLabel,
Input,
FormMeta,
Button,
Textarea,
Editor,
Divider,
P
Divider
} from 'joyent-ui-toolkit';
const CollapsedKeyValue = styled.span`
@ -74,7 +75,7 @@ const KeyValue = ({
);
const _meta = expanded ? (
<P>{create ? `Create ${label}` : `Edit ${label}`}</P>
<H4>{create ? `Create ${label}` : `Edit ${label}`}</H4>
) : (
<CollapsedKeyValue>
<Field
@ -93,7 +94,12 @@ const KeyValue = ({
);
const _valueField = textarea ? (
<Field name="name" component={ValueTextareaField} props={{ submitting }} />
<Field
name="name"
fluid
component={ValueTextareaField}
props={{ submitting }}
/>
) : (
<Input disabled={submitting} />
);
@ -102,6 +108,7 @@ const KeyValue = ({
<Button
type="button"
key="cancel"
bold
onClick={
create
? pristine ? onToggleExpanded : onClear
@ -120,9 +127,9 @@ const KeyValue = ({
<Button
type="submit"
key="submit"
bold
disabled={pristine || submitting}
loading={submitting && !removing}
secondary
marginless
>
{create ? 'Create' : 'Update'}
@ -146,36 +153,40 @@ const KeyValue = ({
onClick={onToggleExpanded}
actionable
>
<CardHeaderMeta>{_meta}</CardHeaderMeta>
<CardHeaderMeta>
<Padding left={1}>{_meta}</Padding>
</CardHeaderMeta>
{chevronToggle}
</CardHeader>
<CardOutlet>
<Row>
<Col xs={12}>{_error}</Col>
</Row>
<Row>
<Col xs={12}>
<FormGroup name="name" reduxForm>
<Label>{titleCase(label)} key</Label>
<Input type="text" disabled={submitting} />
<FormMeta />
</FormGroup>
</Col>
</Row>
<Row>
<Col xs={12}>
<FormGroup name="value" reduxForm>
<Label>{titleCase(label)} value</Label>
{_valueField}
</FormGroup>
</Col>
</Row>
<Row>
<Col xs={12}>
{_cancel}
{_submit}
</Col>
</Row>
<Padding all={1}>
<Row>
<Col xs={12}>{_error}</Col>
</Row>
<Row>
<Col xs={6}>
<FormGroup name="name" reduxForm fluid>
<FormLabel>Enter {titleCase(label)} key</FormLabel>
<Input type="text" disabled={submitting} />
<FormMeta />
</FormGroup>
</Col>
<Col xs={6}>
<FormGroup name="value" reduxForm 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} />

View File

@ -0,0 +1,178 @@
import React, { Component } from 'react';
import styled from 'styled-components';
import { Row, Col } from 'react-styled-flexboxgrid';
import { Margin } from 'styled-components-spacing';
import Value from 'react-redux-values';
import ReduxForm from 'declarative-redux-form';
import { KeyValue } from '@components/instances';
import { withTheme } from 'styled-components';
import remcalc from 'remcalc';
import {
H3,
H4,
Divider,
FormGroup,
FormLabel,
Input,
Button,
StatusLoader,
CloseIcon,
TagItem,
TagList,
TagItemContainer
} from 'joyent-ui-toolkit';
const FlexEnd = styled(Col)`
display: flex;
justify-content: flex-end;
`;
const CloseIconActionable = styled(CloseIcon)`
cursor: pointer;
`;
const Tags = ({
handleRemove,
addKey,
handleCreate,
handleClear,
theme,
toggleEdit,
removeTag,
filterTags,
state,
edit,
tags
}) => {
const _filterTags = (
<Col md={4} xs={12}>
<FormGroup fluid>
<FormLabel>Filter</FormLabel>
<Input fluid type="text" onChange={filterTags} />
</FormGroup>
</Col>
);
const _addTag = (
<Value name={`${addKey}-expanded`}>
{({ value: expanded, onValueChange }) =>
!expanded ? (
<FlexEnd mdOffset={4} md={4} xs={12}>
<Button secondary onClick={toggleEdit}>
Edit
</Button>
<Button
type="button"
onClick={() => onValueChange(!expanded)}
disabled={edit}
>
Add tag
</Button>
</FlexEnd>
) : (
[
<FlexEnd mdOffset={4} md={4} xs={12}>
<Button disabled secondary onClick={toggleEdit}>
Edit
</Button>
<Button
type="button"
onClick={() => onValueChange(!expanded)}
disabled
>
Add tag
</Button>
</FlexEnd>,
<Col xs={12}>
<ReduxForm
form={addKey}
onSubmit={handleCreate}
id={addKey}
onClear={() => handleClear(addKey)}
onToggleExpanded={() => onValueChange(!expanded)}
onRemove={() => handleRemove(addKey)}
expanded={expanded}
label="tag"
create
>
{KeyValue}
</ReduxForm>
</Col>
]
)}
</Value>
);
const _title = (
<Margin bottom={3} key="tag-title">
<H3>
{tags.length} {tags.length === 1 ? 'Tag' : 'Tags'}
</H3>
</Margin>
);
const _noTags =
!tags ||
(tags.length === 0 && <H4 key="no-tags">No tags have been added yet</H4>);
const _list = tags.length > 0 && (
<TagList key="tag-list">
{tags
.sort(
(a, b) =>
a.initialValues.name.toLowerCase() <
b.initialValues.name.toLowerCase()
? -1
: 1
)
.map(tag => (
<Margin
right={1}
bottom={1}
key={`${tag.initialValues.name}-${tag.initialValues.value}`}
>
<TagItem>
{state[
`${tag.initialValues.name}-${tag.initialValues.value}-deleting`
] ? (
<StatusLoader small />
) : (
<TagItemContainer>
{tag.initialValues.name}: {tag.initialValues.value}
{edit && (
<Margin left={2}>
<CloseIconActionable
onClick={() =>
removeTag(
tag.initialValues.name,
tag.initialValues.value
)}
fill={theme.grey}
height={remcalc(9)}
/>
</Margin>
)}
</TagItemContainer>
)}
</TagItem>
</Margin>
))}
</TagList>
);
return [
<Row bottom="md" key="tag-row">
{_filterTags}
{_addTag}
</Row>,
<Margin key="tag-divider" bottom={4} top={2}>
<Divider height="1px" />
</Margin>,
_title,
_list,
_noTags
];
};
export default withTheme(Tags);

View File

@ -1,124 +1,126 @@
import React from 'react';
import React, { Component } from 'react';
import paramCase from 'param-case';
import { compose, graphql } from 'react-apollo';
import Value, { set } from 'react-redux-values';
import { SubmissionError, reset, startSubmit, stopSubmit } from 'redux-form';
import ReduxForm from 'declarative-redux-form';
import { set } from 'react-redux-values';
import { SubmissionError, reset, stopSubmit } from 'redux-form';
import { connect } from 'react-redux';
import find from 'lodash.find';
import get from 'lodash.get';
import {
ViewContainer,
Title,
StatusLoader,
Message,
MessageDescription,
MessageTitle,
Button
MessageTitle
} from 'joyent-ui-toolkit';
import TagsComponent from '@components/instances/tags';
import GetTags from '@graphql/list-tags.gql';
import UpdateTags from '@graphql/update-tags.gql';
import DeleteTag from '@graphql/delete-tag.gql';
import { KeyValue } from '@components/instances';
const TAG_FORM_KEY = (name, field) => `instance-tag-${name}-${field}`;
const CREATE_TAG_FORM_KEY = name => `instance-create-tag-${name}`;
const Tags = ({
instance,
values = [],
loading,
error,
handleRemove,
handleClear,
handleUpdate,
handleCreate
}) => {
const _title = <Title>Tags</Title>;
const _loading = !(loading && !values.length) ? null : <StatusLoader />;
class Tags extends Component {
constructor(props) {
super(props);
const { values: tags } = props;
this.state = {
tags,
edit: false
};
}
// tags items forms
const _tags =
!_loading &&
values.map(({ form, initialValues }, i) => (
<Value name={`${form}-expanded`} key={form}>
{({ value: expanded, onValueChange }) => (
<ReduxForm
form={form}
initialValues={initialValues}
onSubmit={newValues => handleUpdate(newValues, form)}
destroyOnUnmount
id={form}
onClear={() => handleClear(form)}
onToggleExpanded={() => onValueChange(!expanded)}
onRemove={() => handleRemove(form)}
label="tag"
last={values.length - 1 === i}
first={i === 0}
expanded={expanded}
>
{KeyValue}
</ReduxForm>
)}
</Value>
));
componentWillReceiveProps = ({ values: tags }) => {
this.setState({
tags
});
};
// create tags form
const _addKey = instance && CREATE_TAG_FORM_KEY(instance.name);
const _add = _tags &&
_addKey && (
<Value name={`${_addKey}-expanded`}>
{({ value: expanded, onValueChange }) =>
!expanded ? (
<Button
type="button"
onClick={() => onValueChange(!expanded)}
secondary
>
Add tag
</Button>
) : (
<ReduxForm
form={_addKey}
onSubmit={handleCreate}
id={_addKey}
onClear={() => handleClear(_addKey)}
onToggleExpanded={() => onValueChange(!expanded)}
onRemove={() => handleRemove(_addKey)}
expanded={expanded}
label="tag"
create
>
{KeyValue}
</ReduxForm>
)
}
</Value>
filterTags = e => {
const value = e.target.value;
const { values: tags } = this.props;
this.setState({
tags: tags.filter(
tag =>
tag.initialValues.value.includes(value) ||
tag.initialValues.name.includes(value)
)
});
};
removeTag = (name, value) => {
const { handleRemove } = this.props;
handleRemove(name);
this.setState({
[`${name}-${value}-deleting`]: true
});
};
toggleEdit = () => {
const { edit } = this.state;
this.setState({
edit: !edit
});
};
render = () => {
const {
instance,
values = [],
loading,
error,
handleRemove,
handleClear,
handleCreate
} = this.props;
const _loading = !(loading && !values.length) ? null : <StatusLoader />;
const _addKey = instance && CREATE_TAG_FORM_KEY(instance.name);
const { edit, tags } = this.state;
// tags items forms
const _tags = !_loading && (
<TagsComponent
toggleEdit={this.toggleEdit}
removeTag={this.removeTag}
filterTags={this.filterTags}
state={this.state}
edit={edit}
handleCreate={handleCreate}
handleClear={handleClear}
addKey={_addKey}
tags={tags}
handleRemove={handleRemove}
/>
);
// fetching error
const _error =
error && !values.length && !_loading ? (
<Message error>
<MessageTitle>Ooops!</MessageTitle>
<MessageDescription>
An error occurred while loading your instance tags
</MessageDescription>
</Message>
) : null;
// fetching error
const _error =
error && !values.length && !_loading ? (
<Message error>
<MessageTitle>Ooops!</MessageTitle>
<MessageDescription>
An error occurred while loading your instance tags
</MessageDescription>
</Message>
) : null;
return (
<ViewContainer center={Boolean(_loading)} main>
{_title}
{_loading}
{_error}
{_tags}
{_add}
</ViewContainer>
);
};
return (
<ViewContainer center={Boolean(_loading)} main>
{_loading}
{_error}
{_tags}
</ViewContainer>
);
};
}
export default compose(
graphql(UpdateTags, { name: 'updateTags' }),
@ -159,26 +161,23 @@ export default compose(
}
}),
connect(null, (dispatch, ownProps) => {
const { instance, values, refetch, updateTags, deleteTag } = ownProps;
const { instance, refetch, updateTags, deleteTag } = ownProps;
return {
// reset sets values to initialValues
handleClear: form => dispatch(reset(form)),
handleRemove: form =>
handleRemove: name =>
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
dispatch([
set({ name: `${form}-removing`, value: true }),
startSubmit(form)
])
dispatch([set({ name: `${name}-removing`, value: true })])
)
.then(() =>
// call mutation
deleteTag({
variables: {
id: instance.id,
name: get(find(values, ['form', form]), 'initialValues.name')
name
}
})
)
@ -189,40 +188,14 @@ export default compose(
// it takes longer to have an efect than the mutation
.catch(error =>
dispatch([
set({ name: `${form}-removing`, value: false }),
stopSubmit(form, {
set({ name: `${name}-removing`, value: false }),
stopSubmit(name, {
_error: error.graphQLErrors
.map(({ message }) => message)
.join('\n')
})
])
),
handleUpdate: ({ name, value }, form) =>
// delete old tag and add a new one
Promise.all([
deleteTag({
variables: {
id: instance.id,
name: get(find(values, ['form', form]), 'initialValues.name')
}
}),
updateTags({
variables: {
id: instance.id,
tags: [{ name, value }]
}
})
])
// fetch tags again
.then(() => refetch())
// submit is flipped once the promise is resolved
.catch(error => {
throw new SubmissionError({
_error: error.graphQLErrors
.map(({ message }) => message)
.join('\n')
});
}),
handleCreate: ({ name, value }) =>
// call mutation
updateTags({

View File

@ -88,6 +88,8 @@ export {
Item as SectionListItem
} from './section-list';
export { TagItem, TagList, TagItemContainer } from './tags';
export {
Actions as ActionsIcon,
Affinity as AffinityIcon,

View File

@ -0,0 +1,7 @@
import styled from 'styled-components';
export default styled.div`
display: flex;
align-items: center;
flex-grow: 1;
`;

View File

@ -0,0 +1,3 @@
export { default as TagItem } from './item';
export { default as TagList } from './list';
export { default as TagItemContainer } from './container';

View File

@ -0,0 +1,13 @@
import styled from 'styled-components';
import remcalc from 'remcalc';
export default styled.li`
border: ${remcalc(1)} solid ${props => props.theme.grey};
box-sizing: border-box;
border-radius: ${remcalc(2)};
font-size: ${remcalc(13)};
padding: ${remcalc(6)} ${remcalc(12)};
display: flex;
align-items: center;
flex-grow: 1;
`;

View File

@ -0,0 +1,9 @@
import styled from 'styled-components';
export default styled.ul`
margin: 0;
padding: 0;
display: flex;
list-style: none;
flex-wrap: wrap;
`;

View File

@ -10163,6 +10163,20 @@ styled-components-spacing@^2.1.3:
react-create-component-from-tag-prop "^1.2.1"
styled-components-breakpoint "^1.0.0-preview.3"
styled-components@2.2.4:
version "2.2.4"
resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-2.2.4.tgz#dd87fd3dafd359e7a0d570aec1bd07d691c0b5a2"
dependencies:
buffer "^5.0.3"
css-to-react-native "^2.0.3"
fbjs "^0.8.9"
hoist-non-react-statics "^1.2.0"
is-function "^1.0.1"
is-plain-object "^2.0.1"
prop-types "^15.5.4"
stylis "^3.4.0"
supports-color "^3.2.3"
styled-components@^2.2.3:
version "2.2.4"
resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-2.2.4.tgz#dd87fd3dafd359e7a0d570aec1bd07d691c0b5a2"
@ -10177,7 +10191,14 @@ styled-components@^2.2.3:
stylis "^3.4.0"
supports-color "^3.2.3"
styled-is@^1.1.0:
styled-flex-component@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/styled-flex-component/-/styled-flex-component-1.1.0.tgz#f897bffecb7650045443481941bfb42ef16d2404"
dependencies:
styled-components "2.2.4"
styled-is "1.1.0"
styled-is@1.1.0, styled-is@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/styled-is/-/styled-is-1.1.0.tgz#0cf8d32098fe6559eb0ec889790cc6c84f1f497f"