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 ( +
+

{label}

+ { + e.preventDefault(); + dispatch(setLinodeLabel(linode.label)); + dispatch(setLinodeGroup(linode.group)); + dispatch(toggleEditMode()); + }} + > + + +
+ ); + } - 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 -
  • -
-
-
-
-

Performance

-
-
+ {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); + }); +});