diff --git a/.gitignore b/.gitignore
index f7d48591bda..c0ea7086227 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,4 +4,5 @@ npm-debug.log
dist
src/secrets.js
xunit.xml
-coverage
\ No newline at end of file
+coverage
+.sass-cache/
diff --git a/scss/components/card.scss b/scss/components/card.scss
new file mode 100644
index 00000000000..cf60518ec02
--- /dev/null
+++ b/scss/components/card.scss
@@ -0,0 +1,33 @@
+.card {
+ header {
+ background-color: $linode-lightish-gray;
+ padding: 2rem;
+ }
+
+ .react-tabs {
+ [role=tablist] {
+ background-color: $linode-lightish-gray;
+ padding: 0 2rem;
+ border-color: $linode-gray;
+ }
+
+ [role=tab] {
+ color: $linode-dark-gray;
+ border-radius: 0;
+ margin-right: 1.5rem;
+ padding: 3px 12px;
+
+ &[selected],
+ &:hover {
+ color: $linode-black;
+ border-color: $linode-gray;
+ background: white;
+ border-bottom: 0;
+ }
+ }
+
+ [role=tabpanel] {
+ padding: 2rem;
+ }
+ }
+}
diff --git a/scss/components/dropdown.scss b/scss/components/dropdown.scss
index db88247212f..adc4ed1fc4d 100644
--- a/scss/components/dropdown.scss
+++ b/scss/components/dropdown.scss
@@ -1,5 +1,6 @@
.li-dropdown {
- color: #999;
+ color: #555;
+ position: relative;
}
.li-dropdown-item.li-dropdown-first,
@@ -11,6 +12,7 @@
cursor: hand;
height: 29px;
line-height: 30px;
+ background: $linode-white;
}
.li-dropdown-activator {
@@ -25,7 +27,6 @@
}
.li-dropdown-body {
- position: relative;
display: inline-block;
.li-dropdown-target {
@@ -33,7 +34,6 @@
background: #fafafa;
border: $linode-border;
z-index: 9999;
- width: 101px;
}
&:hover,
@@ -42,8 +42,9 @@
.li-dropdown-target {
display: block;
position: absolute;
- top: 28px;
- left: -71px;
+ top: 22px;
+ left: 0;
+ right: 0;
}
}
}
diff --git a/scss/layout/linode-details.scss b/scss/layout/linode-details.scss
index 8c8df16ab82..26820700d36 100644
--- a/scss/layout/linode-details.scss
+++ b/scss/layout/linode-details.scss
@@ -13,30 +13,43 @@
header {
padding: 30px $side-margin 15px $side-margin;
+ line-height: 2.2rem;
h1 {
margin: 0;
+ font-size: 2.2rem;
}
.linode-status {
- margin-left: 15px;
- }
-
- a {
- padding-right: 30px;
+ margin-right: 1rem;
}
.li-dropdown-item.li-dropdown-first {
height: 30px;
}
- .li-dropdown-body {
- &:hover,
- &:active,
- &:focus {
- .li-dropdown-target {
- top: 29px;
- left: -71px;
+ .edit-icon {
+ margin-left: 0.75rem;
+ font-size: 16px;
+ position: relative;
+ top: -6px;
+ }
+
+ .edit-details {
+ font-size: 2.2rem;
+ display: inline-block;
+
+ input {
+ background: transparent;
+ margin: 0 0.5rem;
+ padding: 0;
+ width: 12rem;
+ border: none;
+ border-bottom: $linode-border;
+
+ &:focus {
+ outline: none;
+ border-bottom-color: $linode-dark-gray;
}
}
}
@@ -89,4 +102,4 @@
.linode-performance {
padding: 15px $side-margin;
}
-}
\ No newline at end of file
+}
diff --git a/scss/manager.scss b/scss/manager.scss
index c1535a84247..268f61a2ab5 100644
--- a/scss/manager.scss
+++ b/scss/manager.scss
@@ -5,11 +5,12 @@
$linode-blue: #4A90E2;
$linode-green: #3BB877;
$linode-red: #D0021B;
-$linode-gray: #bfbfbf;
+$linode-gray: #cccccc;
$linode-black: #4b4b4b;
$linode-dark-gray: #acacac;
$linode-white: #fefefe;
$linode-light-gray: #f9f9f9;
+$linode-lightish-gray: #f3f3f3;
$linode-border: 1px solid $linode-gray;
$sidebar-width: 200px;
@@ -19,6 +20,7 @@ $nav-height: 84px;
@import 'layout/linode-details';
@import 'layout/linodes';
@import 'components/dropdown';
+@import 'components/card';
* {
transition: background .35s, color .2s, border .1s;
@@ -275,6 +277,35 @@ nav {
}
}
+button {
+ font-size: 2rem;
+ margin: 0 0.5rem;
+
+ &.btn {
+ text-decoration: none;
+ font-size: 10pt;
+ border-radius: 0;
+ border: $linode-border;
+ background: $linode-lightish-gray;
+ }
+
+ &.btn-primary.good {
+ background: $linode-blue;
+ border: 0;
+ }
+
+ &.btn-primary bad {
+ background: $linode-red;
+ border: 0;
+ }
+
+ &.btn-primary bad-secondary {
+ border-color: $linode-red;
+ background: transparent;
+ color: $linode-red;
+ }
+}
+
.grid-list {
span {
padding-right: 5px;
diff --git a/src/components/Dropdown.js b/src/components/Dropdown.js
index 91366218be8..ada64d573f2 100644
--- a/src/components/Dropdown.js
+++ b/src/components/Dropdown.js
@@ -30,7 +30,7 @@ export default function Dropdown(props) {
Dropdown.propTypes = {
elements: PropTypes.arrayOf(PropTypes.shape({
- name: PropTypes.string.isRequired,
+ name: PropTypes.node.isRequired,
action: PropTypes.func,
})).isRequired,
};
diff --git a/src/linodes/actions/detail.js b/src/linodes/actions/detail.js
new file mode 100644
index 00000000000..83170eff7e0
--- /dev/null
+++ b/src/linodes/actions/detail.js
@@ -0,0 +1,42 @@
+import { fetch } from '~/fetch';
+import { UPDATE_LINODE } from '~/actions/api/linodes';
+
+export const CHANGE_DETAIL_TAB = '@@linodes@@detail/CHANGE_DETAIL_TAB';
+export const TOGGLE_EDIT_MODE = '@@linodes@@detail/TOGGLE_EDIT_MODE';
+export const SET_LINODE_LABEL = '@@linodes@@detail/SET_LINODE_LABEL';
+export const SET_LINODE_GROUP = '@@linodes@@detail/SET_LINODE_GROUP';
+export const TOGGLE_LOADING = '@@linodes@@detail/TOGGLE_LOADING';
+
+export function changeDetailTab(index) {
+ return { type: CHANGE_DETAIL_TAB, index };
+}
+
+export function toggleEditMode() {
+ return { type: TOGGLE_EDIT_MODE };
+}
+
+export function setLinodeLabel(label) {
+ return { type: SET_LINODE_LABEL, label };
+}
+
+export function setLinodeGroup(group) {
+ return { type: SET_LINODE_GROUP, group };
+}
+
+export function commitChanges(id) {
+ return async (dispatch, getState) => {
+ const state = getState();
+ const { label, group } = state.linodes.detail;
+ const { token } = state.authentication;
+ dispatch({ type: TOGGLE_LOADING });
+ // TODO: Error handling
+ const resp = await fetch(token, `/linodes/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify({ label, group }),
+ });
+ const json = await resp.json();
+ dispatch({ type: UPDATE_LINODE, linode: json });
+ dispatch({ type: TOGGLE_LOADING });
+ dispatch(toggleEditMode());
+ };
+}
diff --git a/src/linodes/layouts/LinodeDetailPage.js b/src/linodes/layouts/LinodeDetailPage.js
index 196da030628..47251fb4616 100644
--- a/src/linodes/layouts/LinodeDetailPage.js
+++ b/src/linodes/layouts/LinodeDetailPage.js
@@ -1,21 +1,28 @@
import React, { Component, PropTypes } from 'react';
-import { Link } from 'react-router';
+import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
import { connect } from 'react-redux';
+import Dropdown from '~/components/Dropdown';
+import { LinodeStates, LinodeStatesReadable } from '~/constants';
+import {
+ changeDetailTab,
+ toggleEditMode,
+ setLinodeLabel,
+ setLinodeGroup,
+ commitChanges,
+} from '../actions/detail';
import {
- updateLinode, powerOnLinode, powerOffLinode,
- rebootLinode,
+ updateLinode, powerOnLinode, powerOffLinode, rebootLinode,
} from '~/actions/api/linodes';
-import Dropdown from '~/components/Dropdown';
-import { LinodeStatesReadable } from '~/constants';
-class LinodeDetailPage extends Component {
+export class LinodeDetailPage extends Component {
constructor() {
super();
this.getLinode = this.getLinode.bind(this);
this.render = this.render.bind(this);
- this.powerOn = this.powerOn.bind(this);
- this.powerOff = this.powerOff.bind(this);
- this.reboot = this.reboot.bind(this);
+ this.renderHeader = this.renderHeader.bind(this);
+ this.renderEditUI = this.renderEditUI.bind(this);
+ this.renderLabel = this.renderLabel.bind(this);
+ this.handleLabelKeyUp = this.handleLabelKeyUp.bind(this);
}
componentDidMount() {
@@ -33,108 +40,150 @@ class LinodeDetailPage extends Component {
return linodes[linodeId];
}
- powerOn(linode) {
+ handleLabelKeyUp(e, linode) {
const { dispatch } = this.props;
- dispatch(powerOnLinode(linode.id));
+ if (e.keyCode === 13 /* Enter */) {
+ dispatch(commitChanges(linode.id));
+ }
}
- powerOff(linode) {
+ renderEditUI(linode) {
+ const { label, group, loading } = this.props.detail;
const { dispatch } = this.props;
- dispatch(powerOffLinode(linode.id));
+ return (
+
+ dispatch(setLinodeGroup(e.target.value))}
+ onKeyUp={e => this.handleLabelKeyUp(e, linode)}
+ />
+ /
+ dispatch(setLinodeLabel(e.target.value))}
+ onKeyUp={e => this.handleLabelKeyUp(e, linode)}
+ />
+
+
+
+ );
}
- reboot(linode) {
+ renderLabel(linode) {
const { dispatch } = this.props;
- dispatch(rebootLinode(linode.id));
- }
+ const label = linode.group ?
+ {linode.group} / {linode.label} :
+ {linode.label};
- render() {
- const linode = this.getLinode();
- if (!linode) return ;
+ return (
+
+ );
+ }
- const ipAddresses = linode.ip_addresses;
+ renderHeader(linode) {
+ const { dispatch } = this.props;
+ const { editing } = this.props.detail;
- // Convert ip groups into an array
- const arrayifyIps = (pubPriv, type) => {
- const ips = ipAddresses[pubPriv][type];
- if (Array.isArray(pubPriv)) {
- return ips;
- } else if (!!ips) {
- return [ips];
- }
- return [];
- };
+ const dropdownElements = [
+ {
+ name: Reboot,
+ _action: rebootLinode,
+ _condition: () => true,
+ },
+ {
+ name: Power Off,
+ _action: powerOffLinode,
+ _condition: () => linode.state === 'running',
+ },
+ {
+ name: Power On,
+ _action: powerOnLinode,
+ _condition: () => linode.state === 'offline',
+ },
+ ]
+ .filter(element => element._condition())
+ .map(element => ({ ...element, action: () => dispatch(element._action(linode.id)) }));
- const pubIpv4 = arrayifyIps('public', 'ipv4');
- const pubIpv6 = arrayifyIps('public', 'ipv6');
+ return (
+
+ {editing ? this.renderEditUI(linode) : this.renderLabel(linode)}
+ {LinodeStates.pending.indexOf(linode.state) !== -1 ? null :
+
+
+ }
+
+ {LinodeStatesReadable[linode.state]}
+
+
+ );
+ }
- const dropdownElements = [
- { name: 'Reboot', _action: this.reboot },
- { name: 'Power off', _action: this.powerOff },
- { name: 'Power on', _action: this.powerOn },
- ].map(element => ({ ...element, action: () => element._action(linode) }));
+ render() {
+ const linode = this.getLinode();
+ if (!linode) return ;
+ const { dispatch, detail } = this.props;
return (
-
- Linodes
- >
- {linode.label}
-
-
- {linode.label}
-
- {LinodeStatesReadable[linode.state]}
-
-
-
-
-
-
-
-
-
- -
-
- {pubIpv4[0]}
-
- -
-
- {pubIpv6[0]}
-
-
-
-
-
- -
-
- {linode.datacenter.label}
-
- -
-
- Last backup: 1 hour ago
-
- -
-
- Linode 1024
-
-
-
-
-
+ {this.renderHeader(linode)}
+
dispatch(changeDetailTab(ix))}
+ selectedIndex={detail.tab}
+ >
+
+ General
+ Networking
+ Resize
+ Repair
+ Backups
+ Settings
+
+
+ Summary
+
+
+ Networking Tab
+
+
+ Resize Tab
+
+
+ Repair Tab
+
+
+ Backups Tab
+
+
+ Settings Tab
+
+
);
@@ -147,10 +196,11 @@ LinodeDetailPage.propTypes = {
params: PropTypes.shape({
linodeId: PropTypes.string,
}),
+ detail: PropTypes.object,
};
function select(state) {
- return { linodes: state.api.linodes };
+ return { linodes: state.api.linodes, detail: state.linodes.detail };
}
export default connect(select)(LinodeDetailPage);
diff --git a/src/linodes/reducers/detail.js b/src/linodes/reducers/detail.js
new file mode 100644
index 00000000000..c03d33ffaf0
--- /dev/null
+++ b/src/linodes/reducers/detail.js
@@ -0,0 +1,32 @@
+import {
+ CHANGE_DETAIL_TAB,
+ TOGGLE_EDIT_MODE,
+ SET_LINODE_LABEL,
+ SET_LINODE_GROUP,
+ TOGGLE_LOADING,
+} from '../actions/detail';
+
+const defaultState = {
+ tab: 0,
+ editing: false,
+ label: '',
+ group: '',
+ loading: false,
+};
+
+export default function detail(state = defaultState, action) {
+ switch (action.type) {
+ case CHANGE_DETAIL_TAB:
+ return { ...state, tab: action.index };
+ case TOGGLE_EDIT_MODE:
+ return { ...state, editing: !state.editing };
+ case SET_LINODE_LABEL:
+ return { ...state, label: action.label };
+ case SET_LINODE_GROUP:
+ return { ...state, group: action.group };
+ case TOGGLE_LOADING:
+ return { ...state, loading: !state.loading };
+ default:
+ return state;
+ }
+}
diff --git a/src/linodes/reducers/index.js b/src/linodes/reducers/index.js
index 7418b746e49..1ead7e04d4c 100644
--- a/src/linodes/reducers/index.js
+++ b/src/linodes/reducers/index.js
@@ -2,6 +2,7 @@ import { combineReducers } from 'redux';
import _ from 'underscore';
import { getStorage, setStorage } from '~/storage';
import create from './create';
+import detail from './detail';
import {
CHANGE_VIEW,
@@ -46,6 +47,7 @@ export function index(_state = null, action) {
const rootReducer = combineReducers({
index,
create,
+ detail,
});
export default rootReducer;
diff --git a/test/data.js b/test/data.js
index 000ebe7cea4..d4891381dd6 100644
--- a/test/data.js
+++ b/test/data.js
@@ -1,4 +1,5 @@
export const testLinode = {
+ group: 'Test Group',
label: 'Test Linode',
ip_addresses: {
public: {
diff --git a/test/linodes/actions/detail.spec.js b/test/linodes/actions/detail.spec.js
new file mode 100644
index 00000000000..40808adddf0
--- /dev/null
+++ b/test/linodes/actions/detail.spec.js
@@ -0,0 +1,127 @@
+import sinon from 'sinon';
+import { expect } from 'chai';
+import * as actions from '~/linodes/actions/detail';
+import { UPDATE_LINODE } from '~/actions/api/linodes';
+import * as fetch from '~/fetch';
+
+describe('linodes/actions/detail', () => {
+ describe('changeDetailTab', () => {
+ it('should return a CHANGE_DETAIL_TAB action', () => {
+ expect(actions.changeDetailTab(1))
+ .to.deep.equal({
+ type: actions.CHANGE_DETAIL_TAB,
+ index: 1,
+ });
+ });
+ });
+
+ describe('toggleEditMode', () => {
+ it('should return a TOGGLE_EDIT_MODE action', () => {
+ expect(actions.toggleEditMode())
+ .to.deep.equal({
+ type: actions.TOGGLE_EDIT_MODE,
+ });
+ });
+ });
+
+ describe('setLinodeLabel', () => {
+ it('should return a SET_LINODE_LABEL action', () => {
+ expect(actions.setLinodeLabel('asdf'))
+ .to.deep.equal({
+ type: actions.SET_LINODE_LABEL,
+ label: 'asdf',
+ });
+ });
+ });
+
+ describe('setLinodeGroup', () => {
+ it('should return a SET_LINODE_GROUP action', () => {
+ expect(actions.setLinodeGroup('asdf'))
+ .to.deep.equal({
+ type: actions.SET_LINODE_GROUP,
+ group: 'asdf',
+ });
+ });
+ });
+
+ describe('commitChanges', () => {
+ const sandbox = sinon.sandbox.create();
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ const state = {
+ authentication: {
+ token: 'token',
+ },
+ linodes: {
+ detail: {
+ label: 'new label',
+ group: 'new group',
+ },
+ },
+ };
+ const getGetState = (_state = {}) => sandbox.stub().returns(_state);
+ const getDispatch = () => sandbox.spy();
+ const getFetchStub = (rsp) => sandbox.stub(fetch, 'fetch').returns({ json() { return rsp; } });
+
+ it('should return a function', () => {
+ const action = actions.commitChanges('linode_1234');
+ expect(action).to.be.a('function');
+ });
+
+ it('toggles the loading state twice', async () => {
+ const action = actions.commitChanges('linode_1234');
+ expect(action).to.be.a('function');
+ const dispatch = getDispatch();
+ const getState = getGetState(state);
+ getFetchStub();
+ await action(dispatch, getState);
+ expect(dispatch.withArgs({ type: actions.TOGGLE_LOADING }).calledTwice)
+ .to.equal(true);
+ });
+
+ it('performs the HTTP request', async () => {
+ const action = actions.commitChanges('linode_1234');
+ expect(action).to.be.a('function');
+ const dispatch = getDispatch();
+ const getState = getGetState(state);
+ const fetchStub = getFetchStub();
+ await action(dispatch, getState);
+ expect(fetchStub.calledOnce).to.equal(true);
+ expect(fetchStub.calledWith(
+ state.authentication.token,
+ '/linodes/linode_1234', {
+ method: 'PUT',
+ body: JSON.stringify({
+ label: 'new label',
+ group: 'new group',
+ }),
+ })).to.equal(true);
+ });
+
+ it('dispatches an UPDATE_LINODE action with the new linode details', async () => {
+ const action = actions.commitChanges('linode_1234');
+ expect(action).to.be.a('function');
+ const dispatch = getDispatch();
+ const getState = getGetState(state);
+ getFetchStub({ fake: 'linode' });
+ await action(dispatch, getState);
+ expect(dispatch.withArgs({
+ type: UPDATE_LINODE,
+ linode: { fake: 'linode' },
+ }).calledOnce).to.equal(true);
+ });
+
+ it('leaves edit mode', async () => {
+ const action = actions.commitChanges('linode_1234');
+ expect(action).to.be.a('function');
+ const dispatch = getDispatch();
+ const getState = getGetState(state);
+ getFetchStub();
+ await action(dispatch, getState);
+ expect(dispatch.withArgs({ type: actions.TOGGLE_EDIT_MODE }).calledOnce).to.equal(true);
+ });
+ });
+});
diff --git a/test/linodes/layouts/LinodeDetailPage.spec.js b/test/linodes/layouts/LinodeDetailPage.spec.js
new file mode 100644
index 00000000000..5c07cc845bf
--- /dev/null
+++ b/test/linodes/layouts/LinodeDetailPage.spec.js
@@ -0,0 +1,350 @@
+import React from 'react';
+import sinon from 'sinon';
+import { mount, shallow } from 'enzyme';
+import { expect } from 'chai';
+import * as fetch from '~/fetch';
+import { testLinode } from '~/../test/data';
+import { LinodeDetailPage } from '~/linodes/layouts/LinodeDetailPage';
+import { UPDATE_LINODE } from '~/actions/api/linodes';
+import { Tabs, Tab } from 'react-tabs';
+import * as actions from '~/linodes/actions/detail';
+import Dropdown from '~/components/Dropdown';
+
+describe('linodes/layouts/LinodeDetailPage', () => {
+ const sandbox = sinon.sandbox.create();
+
+ const dispatch = sandbox.spy();
+
+ afterEach(() => {
+ dispatch.reset();
+ sandbox.restore();
+ });
+
+ const linodes = {
+ pagesFetched: [0],
+ totalPages: 1,
+ linodes: {
+ [testLinode.id]: testLinode,
+ linode_1235: { ...testLinode, id: 'linode_1235', group: '' },
+ linode_1236: { ...testLinode, id: 'linode_1236', state: 'offline' },
+ linode_1237: { ...testLinode, id: 'linode_1236', state: 'booting' },
+ },
+ _singular: 'linode',
+ _plural: 'linodes',
+ };
+
+ const detail = {
+ tab: 0,
+ editing: false,
+ label: '',
+ group: '',
+ loading: false,
+ };
+
+ it('fetches a linode when mounted with an unknown linode', async () => {
+ mount(
+ );
+ expect(dispatch.calledOnce).to.equal(true);
+ const dispatched = dispatch.firstCall.args[0];
+ // Assert that dispatched is a function that fetches a linode
+ const fetchStub = sandbox.stub(fetch, 'fetch').returns({
+ json: () => {},
+ });
+ dispatch.reset();
+ await dispatched(dispatch, () => ({
+ authentication: { token: 'token' },
+ }));
+ expect(fetchStub.calledOnce).to.equal(true);
+ expect(fetchStub.firstCall.args[1]).to.equal('/linodes/linode_1234');
+ expect(dispatch.calledOnce).to.equal(true);
+ expect(dispatch.firstCall.args[0].type).to.equal(UPDATE_LINODE);
+ });
+
+ it('does not fetch when mounted with a known linode', async () => {
+ mount(
+ );
+ expect(dispatch.calledOnce).to.equal(false);
+ });
+
+ it('renders the linode label and group', async () => {
+ const page = mount(
+ );
+ expect(page.contains({testLinode.group} / {testLinode.label}))
+ .to.equal(true);
+ });
+
+ it('renders the linode label alone when ungrouped', async () => {
+ const page = mount(
+ );
+ expect(page.contains({testLinode.label}))
+ .to.equal(true);
+ });
+
+ it('renders detail tabs', () => {
+ const page = shallow(
+ );
+ const tabs = page.find(Tabs);
+ expect(tabs).to.exist;
+ const expectedTabs = [
+ 'General', 'Networking', 'Resize', 'Repair', 'Backups', 'Settings',
+ ];
+ expectedTabs.map(t => expect(tabs.contains({t})).to.equal(true));
+ });
+
+ it('dispatches a tab change action when tabs are clicked', () => {
+ const page = shallow(
+ );
+ const tabs = page.find(Tabs);
+ tabs.props().onSelect(2);
+ expect(dispatch.calledWith(actions.changeDetailTab(2))).to.equal(true);
+ });
+
+ it('renders a power management dropdown', () => {
+ const page = shallow(
+ );
+ const dropdown = page.find(Dropdown);
+ expect(dropdown).to.exist;
+ });
+
+ it('renders the appropriate items when linode is running', () => {
+ const page = shallow(
+ );
+ const dropdown = page.find(Dropdown).props();
+ const expected = ['Reboot', 'Power Off'];
+ for (let i = 0; i < expected.length; ++i) {
+ const elem = shallow(dropdown.elements[i].name);
+ expect(elem.text()).to.contain(expected[i]);
+ }
+ });
+
+ it('renders the appropriate items when linode is powered off', () => {
+ const page = shallow(
+ );
+ const dropdown = page.find(Dropdown).props();
+ const expected = ['Reboot', 'Power On'];
+ for (let i = 0; i < expected.length; ++i) {
+ const elem = shallow(dropdown.elements[i].name);
+ expect(elem.text()).to.contain(expected[i]);
+ }
+ });
+
+ it('does not render power management dropdown when linode is transitioning', () => {
+ const page = mount(
+ );
+ expect(page.contains(Dropdown)).to.equal(false);
+ });
+
+ it('renders the current state of the linode', () => {
+ const page = mount(
+ );
+ expect(page.contains(Running))
+ .to.equal(true);
+ });
+
+ describe('edit mode', () => {
+ it('renders an edit button', () => {
+ const page = shallow(
+ );
+ expect(page.find('.edit-icon')).to.exist;
+ });
+
+ it('toggles edit mode when edit is pressed', () => {
+ const page = shallow(
+ );
+ const icon = page.find('.edit-icon');
+ icon.simulate('click', { preventDefault: () => {} });
+ expect(dispatch.calledWith(actions.toggleEditMode())).to.equal(true);
+ });
+
+ it('copies the current group/label to the state', () => {
+ const page = shallow(
+ );
+ const icon = page.find('.edit-icon');
+ icon.simulate('click', { preventDefault: () => {} });
+ expect(dispatch.calledWith(actions.setLinodeLabel(testLinode.label)))
+ .to.equal(true);
+ expect(dispatch.calledWith(actions.setLinodeGroup(testLinode.group)))
+ .to.equal(true);
+ });
+
+ it('renders group/label text boxes in edit mode', () => {
+ const page = shallow(
+ );
+ const editor = page.find('.edit-details');
+ expect(editor).to.exist;
+ expect(editor.find('input[type="text"]').length)
+ .to.equal(2);
+ expect(editor.find('input[type="text"]').get(0).props.placeholder)
+ .to.equal('Group...');
+ expect(editor.find('input[type="text"]').get(1).props.placeholder)
+ .to.equal('Label...');
+ });
+
+ it('renders save and cancel buttons', () => {
+ const page = shallow(
+ );
+ const editor = page.find('.edit-details');
+ expect(editor).to.exist;
+ expect(editor.find('button').length).to.equal(2);
+ expect(editor.find('button.btn-primary').text()).to.equal('Save');
+ expect(editor.find('button.btn-default').text()).to.equal('Cancel');
+ });
+
+ it('disables save and cancel buttons when loading', () => {
+ const page = shallow(
+ );
+ const editor = page.find('.edit-details');
+ expect(editor).to.exist;
+ expect(editor.find('button').length).to.equal(2);
+ expect(editor.find('button.btn-primary').props())
+ .to.have.property('disabled').which.equals(true);
+ expect(editor.find('button.btn-default').props())
+ .to.have.property('disabled').which.equals(true);
+ });
+
+ it('leaves edit mode when cancel is pressed', () => {
+ const page = shallow(
+ );
+ const editor = page.find('.edit-details');
+ const cancel = editor.find('button.btn-default');
+ cancel.simulate('click');
+ expect(dispatch.calledOnce).to.equal(true);
+ expect(dispatch.calledWith(actions.toggleEditMode())).to.equal(true);
+ });
+
+ async function assertCommittedChanges() {
+ expect(dispatch.calledOnce).to.equal(true);
+ const dispatched = dispatch.firstCall.args[0];
+ // Assert that dispatched is a function that commits the changes
+ const fetchStub = sandbox.stub(fetch, 'fetch').returns({
+ json: () => {},
+ });
+ dispatch.reset();
+ const getState = () => ({
+ authentication: { token: 'token' },
+ linodes: { detail: { label: 'test', group: 'test' } },
+ });
+ await dispatched(dispatch, getState);
+ expect(fetchStub.calledOnce).to.equal(true);
+ expect(fetchStub.firstCall.args[1]).to.equal('/linodes/linode_1234');
+ }
+
+ it('commits changes to the API when save is pressed', async () => {
+ const page = shallow(
+ );
+ const editor = page.find('.edit-details');
+ const cancel = editor.find('button.btn-primary');
+ cancel.simulate('click');
+ await assertCommittedChanges();
+ });
+
+ it('commits changes to the API when the enter key is pressed', async () => {
+ const page = shallow(
+ );
+ const editor = page.find('.edit-details');
+ const text = editor.find('input[type="text"]').first();
+ text.simulate('keyUp', { keyCode: 13 /* Enter */ });
+ await assertCommittedChanges();
+ });
+ });
+});
diff --git a/test/linodes/reducers/detail.spec.js b/test/linodes/reducers/detail.spec.js
new file mode 100644
index 00000000000..986f1962e8f
--- /dev/null
+++ b/test/linodes/reducers/detail.spec.js
@@ -0,0 +1,78 @@
+import { expect } from 'chai';
+import deepFreeze from 'deep-freeze';
+import detail from '~/linodes/reducers/detail';
+import * as actions from '~/linodes/actions/detail';
+
+describe('linodes/detail reducer', () => {
+ it('should handle initial state', () => {
+ expect(
+ detail(undefined, {})
+ ).to.be.eql({
+ tab: 0,
+ editing: false,
+ label: '',
+ group: '',
+ loading: false,
+ });
+ });
+
+ it('should no-op on arbitrary actions', () => {
+ const state = { tab: 0 };
+ deepFreeze(state);
+
+ expect(detail(state, { type: 'foobar' }))
+ .to.deep.equal(state);
+ });
+
+ it('should handle CHANGE_DETAIL_TAB', () => {
+ const state = { tab: 0 };
+ deepFreeze(state);
+
+ expect(detail(state, actions.changeDetailTab(3)))
+ .to.have.property('tab').that.equals(3);
+ });
+
+ it('should handle TOGGLE_EDIT_MODE', () => {
+ const state = { editing: false };
+ deepFreeze(state);
+
+ expect(detail(state, actions.toggleEditMode()))
+ .to.have.property('editing').that.equals(true);
+
+ const stateEditing = { editing: true };
+ deepFreeze(stateEditing);
+
+ expect(detail(stateEditing, actions.toggleEditMode()))
+ .to.have.property('editing').that.equals(false);
+ });
+
+ it('should handle SET_LINODE_LABEL', () => {
+ const state = { label: '' };
+ deepFreeze(state);
+
+ expect(detail(state, actions.setLinodeLabel('asdf')))
+ .to.have.property('label').that.equals('asdf');
+ });
+
+ it('should handle SET_LINODE_GROUP', () => {
+ const state = { group: '' };
+ deepFreeze(state);
+
+ expect(detail(state, actions.setLinodeGroup('asdf')))
+ .to.have.property('group').that.equals('asdf');
+ });
+
+ it('should handle TOGGLE_LOADING', () => {
+ const state = { loading: false };
+ deepFreeze(state);
+
+ expect(detail(state, { type: actions.TOGGLE_LOADING }))
+ .to.have.property('loading').that.equals(true);
+
+ const stateLoading = { loading: true };
+ deepFreeze(state);
+
+ expect(detail(stateLoading, { type: actions.TOGGLE_LOADING }))
+ .to.have.property('loading').that.equals(false);
+ });
+});