From 842353be08c4d42679f24555c11f31839ec07768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20Chereche=C8=99?= Date: Wed, 28 Jun 2017 12:35:42 +0300 Subject: [PATCH] Add back Fixture Editor to new Component Playground (#380) * Fix component name #360 * Create DragHandle component #360 * Integrate DragHandle for resizing left nav of CP #360 * Fix overflow or left nav #360 * Cover iframe while dragging to have drag continuity #360 * Rename test #360 * Don't show StarryBg when Loader is visible #360 * Add selected state and toggling to fixture editor button #360 * Fix env default in CP web pack config #360 * Narrow down CSS trans to one attr #360 * Add fixture for open fixture editor in CP #360 * Implement horizontal pane for fixture editor #360 * Adjust fixture editor pane orientation based on window size #360 * Update Cypress selector #360 * Update the content orientation when content width changes due to left nav #360 * Nevermind debouncing #360 * Add FixtureEditor component #360 * Prevent editor key events from reaching other components #360 * Use lodash.merge to override fixtures in tests #360 * Keep focused state inside FixtureEditor #360 * Ensure `fixtureLoad` is sent before 1st `fixtureUpdate` from RemoteLoader #360 * Don't show fixture editor before Loader are ready #360 * Integrate FixtureEditor in CP #360 * Clarify FixtureEditor state #360 * Bring back Cypress fixture editor smoke #360 * Always check children for state #360 * Simplify FixtureList fuzzy matching #360 * Add snapshot tests for StarryBg #360 * Send new message to Loader when clicking on selected fixture #360 * Unmount loader before mounting new one after HMR #360 * Use toHaveLength test helper #360 cc @flaviusone --- cypress/integration/local-state_spec.js | 5 +- .../__mocks__/@skidding/react-codemirror.js | 3 + packages/__mocks__/localforage.js | 9 + .../package.json | 4 + .../__fixtures__/selected-editor.js | 22 ++ .../__tests__/fixture-editor/controls.jsx | 45 +++ .../__tests__/fixture-editor/editor.jsx | 121 ++++++ .../fixture-editor/pane-landscape.jsx | 166 +++++++++ .../fixture-editor/pane-portrait.jsx | 127 +++++++ .../__tests__/fixture-editor/pane-resize.jsx | 78 ++++ .../__tests__/fixture-selected-fullscreen.jsx | 2 +- .../__tests__/fixture-selected-missing.jsx | 2 +- .../__tests__/fixture-selected.jsx | 42 ++- .../__tests__/fixtures-loaded.jsx | 12 +- .../ComponentPlayground/__tests__/init.jsx | 4 +- .../__tests__/left-nav-drag.jsx | 143 +++++++ .../components/ComponentPlayground/index.jsx | 348 +++++++++++++----- .../components/ComponentPlayground/index.less | 82 ++++- .../src/components/DisplayScreen/index.jsx | 7 +- .../DragHandle/__fixtures__/horizontal.js | 5 + .../DragHandle/__fixtures__/vertical.js | 6 + .../__tests__/__snapshots__/render.jsx.snap | 13 + .../DragHandle/__tests__/render.jsx | 64 ++++ .../src/components/DragHandle/index.jsx | 93 +++++ .../src/components/DragHandle/index.less | 20 + .../FixtureEditor/__fixtures__/empty.js | 6 + .../FixtureEditor/__fixtures__/erred.js | 15 + .../FixtureEditor/__fixtures__/focused.js | 13 + .../FixtureEditor/__fixtures__/props.js | 10 + .../FixtureEditor/__tests__/error.js | 87 +++++ .../FixtureEditor/__tests__/focused.js | 59 +++ .../FixtureEditor/__tests__/index.js | 84 +++++ .../src/components/FixtureEditor/index.jsx | 99 +++++ .../src/components/FixtureEditor/index.less | 37 ++ .../FixtureList/__tests__/index.jsx | 13 +- .../src/components/FixtureList/index.jsx | 22 +- .../src/components/FixtureList/index.less | 5 +- .../__tests__/__snapshots__/render.js.snap | 26 ++ .../components/StarryBg/__tests__/render.js | 34 ++ .../src/utils/codemirror.css | 12 - .../src/utils/common.less | 2 +- .../webpack.config.js | 2 +- .../yarn.lock | 159 +++++++- .../src/__tests__/index.jsx | 2 +- .../src/__tests__/mount.js | 6 +- .../src/components/Loader/__tests__/index.jsx | 2 +- .../__tests__/fixture-select-es-modules.jsx | 2 +- .../RemoteLoader/__tests__/fixture-select.jsx | 2 +- .../RemoteLoader/__tests__/proxy-change.jsx | 2 +- .../src/components/RemoteLoader/index.jsx | 72 ++-- packages/react-cosmos-loader/src/index.js | 2 +- packages/react-cosmos-loader/src/mount.js | 19 +- .../src/__tests__/index.jsx | 2 +- .../src/__tests__/index.jsx | 2 +- .../react-cosmos-state-proxy/src/index.jsx | 2 +- .../src/__tests__/loader-entry.js | 13 +- .../react-cosmos-webpack/src/loader-entry.js | 7 +- .../src/__tests__/uri.js | 4 +- 58 files changed, 2014 insertions(+), 233 deletions(-) create mode 100644 packages/__mocks__/@skidding/react-codemirror.js create mode 100644 packages/__mocks__/localforage.js create mode 100644 packages/react-cosmos-component-playground/src/components/ComponentPlayground/__fixtures__/selected-editor.js create mode 100644 packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-editor/controls.jsx create mode 100644 packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-editor/editor.jsx create mode 100644 packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-editor/pane-landscape.jsx create mode 100644 packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-editor/pane-portrait.jsx create mode 100644 packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-editor/pane-resize.jsx create mode 100644 packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/left-nav-drag.jsx create mode 100644 packages/react-cosmos-component-playground/src/components/DragHandle/__fixtures__/horizontal.js create mode 100644 packages/react-cosmos-component-playground/src/components/DragHandle/__fixtures__/vertical.js create mode 100644 packages/react-cosmos-component-playground/src/components/DragHandle/__tests__/__snapshots__/render.jsx.snap create mode 100644 packages/react-cosmos-component-playground/src/components/DragHandle/__tests__/render.jsx create mode 100644 packages/react-cosmos-component-playground/src/components/DragHandle/index.jsx create mode 100644 packages/react-cosmos-component-playground/src/components/DragHandle/index.less create mode 100644 packages/react-cosmos-component-playground/src/components/FixtureEditor/__fixtures__/empty.js create mode 100644 packages/react-cosmos-component-playground/src/components/FixtureEditor/__fixtures__/erred.js create mode 100644 packages/react-cosmos-component-playground/src/components/FixtureEditor/__fixtures__/focused.js create mode 100644 packages/react-cosmos-component-playground/src/components/FixtureEditor/__fixtures__/props.js create mode 100644 packages/react-cosmos-component-playground/src/components/FixtureEditor/__tests__/error.js create mode 100644 packages/react-cosmos-component-playground/src/components/FixtureEditor/__tests__/focused.js create mode 100644 packages/react-cosmos-component-playground/src/components/FixtureEditor/__tests__/index.js create mode 100644 packages/react-cosmos-component-playground/src/components/FixtureEditor/index.jsx create mode 100644 packages/react-cosmos-component-playground/src/components/FixtureEditor/index.less create mode 100644 packages/react-cosmos-component-playground/src/components/StarryBg/__tests__/__snapshots__/render.js.snap create mode 100644 packages/react-cosmos-component-playground/src/components/StarryBg/__tests__/render.js delete mode 100644 packages/react-cosmos-component-playground/src/utils/codemirror.css diff --git a/cypress/integration/local-state_spec.js b/cypress/integration/local-state_spec.js index 2c1a3f6ef9..144e5714cc 100644 --- a/cypress/integration/local-state_spec.js +++ b/cypress/integration/local-state_spec.js @@ -30,7 +30,7 @@ describe('Local state example', () => { }); it('should show welcome message', () => { - cy.get(getSelector('index__loader')).should('contain', "You're all set"); + cy.get(getSelector('index__content')).should('contain', "You're all set"); }); }); @@ -75,8 +75,7 @@ describe('Local state example', () => { }); }); - // TODO: Enable back once FixtureEditor is put into new ComponentPlayground - context.skip('fixture editor', () => { + context('fixture editor', () => { // The first menu button is the fixture editor toggle const editorButtonSel = `${getSelector('index__button')}:eq(1)`; diff --git a/packages/__mocks__/@skidding/react-codemirror.js b/packages/__mocks__/@skidding/react-codemirror.js new file mode 100644 index 0000000000..9040058fd7 --- /dev/null +++ b/packages/__mocks__/@skidding/react-codemirror.js @@ -0,0 +1,3 @@ +import React from 'react'; + +module.exports = () => ; diff --git a/packages/__mocks__/localforage.js b/packages/__mocks__/localforage.js new file mode 100644 index 0000000000..b2120cf617 --- /dev/null +++ b/packages/__mocks__/localforage.js @@ -0,0 +1,9 @@ +let itemMocks = {}; + +module.exports = { + __setItemMocks: mocks => { + itemMocks = mocks; + }, + getItem: jest.fn(itemKey => Promise.resolve(itemMocks[itemKey])), + setItem: jest.fn(() => Promise.resolve()), +}; diff --git a/packages/react-cosmos-component-playground/package.json b/packages/react-cosmos-component-playground/package.json index eded1a686b..7c9ced5460 100644 --- a/packages/react-cosmos-component-playground/package.json +++ b/packages/react-cosmos-component-playground/package.json @@ -10,9 +10,13 @@ ], "main": "lib/index.js", "devDependencies": { + "@skidding/react-codemirror": "^1.0.1", "classnames": "^2.2.5", + "codemirror": "^5.25.2", "fuzzaldrin-plus": "^0.4.1", "html-webpack-plugin": "^2.28.0", + "localforage": "^1.5.0", + "lodash.merge": "^4.6.0", "lodash.omitby": "^4.6.0", "lodash.reduce": "^4.6.0", "prop-types": "^15.5.10", diff --git a/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__fixtures__/selected-editor.js b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__fixtures__/selected-editor.js new file mode 100644 index 0000000000..566a5b91a9 --- /dev/null +++ b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__fixtures__/selected-editor.js @@ -0,0 +1,22 @@ +export default { + props: { + loaderUri: '/mock/loader/index.html', + router: { + goTo: url => console.log('go to', url), + routeLink: e => { + e.preventDefault(); + console.log('link to', e.currentTarget.href); + }, + }, + component: 'ComponentA', + fixture: 'foo', + editor: true, + }, + state: { + waitingForLoader: false, + fixtures: { + ComponentA: ['foo', 'bar'], + ComponentB: ['baz', 'qux'], + }, + }, +}; diff --git a/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-editor/controls.jsx b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-editor/controls.jsx new file mode 100644 index 0000000000..28a677a013 --- /dev/null +++ b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-editor/controls.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { Loader } from 'react-cosmos-loader'; +import createStateProxy from 'react-cosmos-state-proxy'; +import selectedEditorFixture from '../../__fixtures__/selected-editor'; +import DragHandle from '../../../DragHandle'; +import ComponentPlayground from '../../'; + +// Vars populated in beforeEach blocks +let wrapper; + +describe('Fixture editor controls', () => { + // Fixture editor is already on so the button will untoggle it + const fixtureEditorUrl = '/?component=ComponentA&fixture=foo'; + + beforeEach(() => { + return new Promise(resolve => { + // Mount component in order for ref and lifecycle methods to be called + wrapper = mount( + + ); + }); + }); + + it('should set untoggle URL to fixture editor button', () => { + expect(wrapper.find(`.header a[href="${fixtureEditorUrl}"]`)).toHaveLength( + 1 + ); + }); + + it('should render selected fixture editor button', () => { + expect( + wrapper.find(`.header a[href="${fixtureEditorUrl}"].selectedButton`) + ).toHaveLength(1); + }); + + it('should render DragHandle in fixture editor pane', () => { + expect(wrapper.find('.fixtureEditorPane').find(DragHandle)).toHaveLength(1); + }); +}); diff --git a/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-editor/editor.jsx b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-editor/editor.jsx new file mode 100644 index 0000000000..678cfa49c4 --- /dev/null +++ b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-editor/editor.jsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { Loader } from 'react-cosmos-loader'; +import createStateProxy from 'react-cosmos-state-proxy'; +import selectedEditorFixture from '../../__fixtures__/selected-editor'; +import FixtureEditor from '../../../FixtureEditor'; +import ComponentPlayground from '../../'; + +// Vars populated in beforeEach blocks +let messageHandlers; +let wrapper; +let loaderContentWindow; + +const handleMessage = e => { + const { type } = e.data; + if (!messageHandlers[type]) { + throw new Error('Unexpected message event'); + } + messageHandlers[type](e.data); +}; + +const waitForPostMessage = type => + new Promise(resolve => { + messageHandlers[type] = resolve; + }); + +describe('Fixture editor', () => { + beforeEach(() => { + messageHandlers = {}; + window.addEventListener('message', handleMessage, false); + + const onFixtureLoad = waitForPostMessage('fixtureLoad'); + + return new Promise(resolve => { + // Mount component in order for ref and lifecycle methods to be called + wrapper = mount( + + ); + }).then(instance => { + loaderContentWindow = { + postMessage: jest.fn(), + }; + // iframe.contentWindow isn't available in jsdom + instance.loaderFrame = { + contentWindow: loaderContentWindow, + }; + + window.postMessage( + { + type: 'fixtureLoad', + fixtureBody: { + foo: 'bar', + }, + }, + '*' + ); + + return onFixtureLoad; + }); + }); + + afterEach(() => { + window.removeEventListener('message', handleMessage); + }); + + it('sends initial fixture body as value to FixtureEditor', () => { + expect(wrapper.find(FixtureEditor).prop('value')).toEqual({ + foo: 'bar', + }); + }); + + describe('on fixture update from Loader', () => { + beforeEach(() => { + const onFixtureUpdate = waitForPostMessage('fixtureUpdate'); + + window.postMessage( + { + type: 'fixtureUpdate', + fixtureBody: { + baz: 'qux', + }, + }, + '*' + ); + + return onFixtureUpdate; + }); + + it('sends updated fixture body as value to FixtureEditor', () => { + expect(wrapper.find(FixtureEditor).prop('value')).toEqual({ + foo: 'bar', + baz: 'qux', + }); + }); + }); + + describe('on fixture edit from editor', () => { + beforeEach(() => { + wrapper.find(FixtureEditor).prop('onChange')({ + foo: 'baz', + }); + }); + + it('sends edited fixture body to Loader', () => { + expect(loaderContentWindow.postMessage).toHaveBeenCalledWith( + { + type: 'fixtureEdit', + fixtureBody: { + foo: 'baz', + }, + }, + '*' + ); + }); + }); +}); diff --git a/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-editor/pane-landscape.jsx b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-editor/pane-landscape.jsx new file mode 100644 index 0000000000..5d291b176b --- /dev/null +++ b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-editor/pane-landscape.jsx @@ -0,0 +1,166 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { Loader } from 'react-cosmos-loader'; +import createStateProxy from 'react-cosmos-state-proxy'; +import selectedEditorFixture from '../../__fixtures__/selected-editor'; +import DragHandle from '../../../DragHandle'; +import ComponentPlayground, { FIXTURE_EDITOR_PANE_SIZE } from '../../'; +import localForage from 'localforage'; + +jest.mock('localforage'); + +// Vars populated in beforeEach blocks +let wrapper; +let instance; + +const mockContentNodeSize = () => { + // Fake node width/height + instance.contentNode = { + // Landscape + offsetWidth: 300, + offsetHeight: 200, + }; +}; + +describe('Landscape fixture editor pane', () => { + describe('default size', () => { + beforeEach(() => { + return new Promise(resolve => { + // Mount component in order for ref and lifecycle methods to be called + wrapper = mount( + { + instance = i; + resolve(); + }} + /> + ); + }).then(mockContentNodeSize); + }); + + it('should set landscape class to content', () => { + expect(wrapper.find('.content.contentLandscape')).toHaveLength(1); + }); + + it('should render fixture editor pane', () => { + expect(wrapper.find('.fixtureEditorPane')).toHaveLength(1); + }); + + it('should set default fixture editor pane width', () => { + expect(wrapper.find('.fixtureEditorPane').prop('style').width).toBe(250); + }); + + describe('on drag', () => { + let dragHandleElement; + + beforeEach(() => { + localForage.setItem.mockClear(); + + dragHandleElement = wrapper + .find('.fixtureEditorPane') + .find(DragHandle) + .getDOMNode(); + + // We can't use Enzyme's simulate to trigger native events + const downEvent = new MouseEvent('mousedown', { + clientX: 3, + }); + dragHandleElement.dispatchEvent(downEvent); + + const moveEvent = new MouseEvent('mousemove', { + clientX: 204, + }); + document.dispatchEvent(moveEvent); + + const upEvent = new MouseEvent('mouseup'); + document.dispatchEvent(upEvent); + }); + + it('should resize fixture editor pane', () => { + expect(wrapper.find('.fixtureEditorPane').prop('style').width).toBe( + 201 + ); + }); + + it('should update cache', () => { + expect(localForage.setItem).toHaveBeenCalledWith( + FIXTURE_EDITOR_PANE_SIZE, + 201 + ); + }); + }); + + describe('loader frame overlay', () => { + it('is visible while dragging', () => { + const dragHandleElement = wrapper + .find('.fixtureEditorPane') + .find(DragHandle) + .getDOMNode(); + + // We can't use Enzyme's simulate to trigger native events + const downEvent = new MouseEvent('mousedown', { + clientX: 0, + }); + dragHandleElement.dispatchEvent(downEvent); + + expect(wrapper.find('.loaderFrameOverlay').prop('style').display).toBe( + 'block' + ); + }); + + it('is not visible after dragging', () => { + const dragHandleElement = wrapper + .find('.fixtureEditorPane') + .find(DragHandle) + .getDOMNode(); + + // We can't use Enzyme's simulate to trigger native events + const downEvent = new MouseEvent('mousedown', { + clientX: 0, + }); + dragHandleElement.dispatchEvent(downEvent); + + const upEvent = new MouseEvent('mouseup'); + document.dispatchEvent(upEvent); + + expect(wrapper.find('.loaderFrameOverlay').prop('style').display).toBe( + 'none' + ); + }); + }); + }); + + describe('cached size', () => { + const cachedSize = 270; + + beforeEach(() => { + localForage.__setItemMocks({ + [FIXTURE_EDITOR_PANE_SIZE]: cachedSize, + }); + + return new Promise(resolve => { + // Mount component in order for ref and lifecycle methods to be called + wrapper = mount( + { + instance = i; + resolve(); + }} + /> + ); + }).then(mockContentNodeSize); + }); + + it('should set cached fixture editor pane width', () => { + expect(wrapper.find('.fixtureEditorPane').prop('style').width).toBe( + cachedSize + ); + }); + }); +}); diff --git a/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-editor/pane-portrait.jsx b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-editor/pane-portrait.jsx new file mode 100644 index 0000000000..4e3291ca85 --- /dev/null +++ b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-editor/pane-portrait.jsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { Loader } from 'react-cosmos-loader'; +import createStateProxy from 'react-cosmos-state-proxy'; +import selectedEditorFixture from '../../__fixtures__/selected-editor'; +import DragHandle from '../../../DragHandle'; +import ComponentPlayground, { FIXTURE_EDITOR_PANE_SIZE } from '../../'; +import localForage from 'localforage'; + +jest.mock('localforage'); + +// Vars populated in beforeEach blocks +let wrapper; +let instance; + +const mockContentNodeSize = () => { + // Fake node width/height + instance.contentNode = { + // Portrait + offsetWidth: 200, + offsetHeight: 300, + }; +}; + +describe('Portrait fixture editor pane', () => { + describe('default size', () => { + beforeEach(() => { + return new Promise(resolve => { + // Mount component in order for ref and lifecycle methods to be called + wrapper = mount( + { + instance = i; + resolve(); + }} + /> + ); + }).then(mockContentNodeSize); + }); + + it('should set landscape class to content', () => { + expect(wrapper.find('.content.contentPortrait')).toHaveLength(1); + }); + + it('should render fixture editor pane', () => { + expect(wrapper.find('.fixtureEditorPane')).toHaveLength(1); + }); + + it('should set default fixture editor pane height', () => { + expect(wrapper.find('.fixtureEditorPane').prop('style').height).toBe(250); + }); + + describe('on drag', () => { + let dragHandleElement; + + beforeEach(() => { + localForage.setItem.mockClear(); + + dragHandleElement = wrapper + .find('.fixtureEditorPane') + .find(DragHandle) + .getDOMNode(); + + // We can't use Enzyme's simulate to trigger native events + const downEvent = new MouseEvent('mousedown', { + clientY: 3, + }); + dragHandleElement.dispatchEvent(downEvent); + + const moveEvent = new MouseEvent('mousemove', { + clientY: 204, + }); + document.dispatchEvent(moveEvent); + + const upEvent = new MouseEvent('mouseup'); + document.dispatchEvent(upEvent); + }); + + it('should resize fixture editor pane', () => { + expect(wrapper.find('.fixtureEditorPane').prop('style').height).toBe( + 201 + ); + }); + + it('should update cache', () => { + expect(localForage.setItem).toHaveBeenCalledWith( + FIXTURE_EDITOR_PANE_SIZE, + 201 + ); + }); + }); + }); + + describe('cached size', () => { + const cachedSize = 270; + + beforeEach(() => { + localForage.__setItemMocks({ + [FIXTURE_EDITOR_PANE_SIZE]: cachedSize, + }); + + return new Promise(resolve => { + // Mount component in order for ref and lifecycle methods to be called + wrapper = mount( + { + instance = i; + resolve(); + }} + /> + ); + }).then(mockContentNodeSize); + }); + + it('should set cached fixture editor pane height', () => { + expect(wrapper.find('.fixtureEditorPane').prop('style').height).toBe( + cachedSize + ); + }); + }); +}); diff --git a/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-editor/pane-resize.jsx b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-editor/pane-resize.jsx new file mode 100644 index 0000000000..8060d08615 --- /dev/null +++ b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-editor/pane-resize.jsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { Loader } from 'react-cosmos-loader'; +import createStateProxy from 'react-cosmos-state-proxy'; +import selectedEditorFixture from '../../__fixtures__/selected-editor'; +import ComponentPlayground, { FIXTURE_EDITOR_PANE_SIZE } from '../../'; +import localForage from 'localforage'; + +jest.mock('localforage'); + +// Vars populated in beforeEach blocks +let wrapper; +let instance; + +const mockContentNodeSize = () => { + // Fake node width/height + instance.contentNode = { + // Landscape + offsetWidth: 300, + offsetHeight: 200, + }; +}; + +describe('Resize fixture editor pane', () => { + const cachedSize = 270; + + beforeEach(() => { + localForage.__setItemMocks({ + [FIXTURE_EDITOR_PANE_SIZE]: cachedSize, + }); + + return new Promise(resolve => { + // Mount component in order for ref and lifecycle methods to be called + wrapper = mount( + { + instance = i; + resolve(); + }} + /> + ); + }).then(mockContentNodeSize); + }); + + it('should set landscape class to content', () => { + expect(wrapper.find('.content.contentLandscape')).toHaveLength(1); + }); + + it('should set cached fixture editor pane width', () => { + expect(wrapper.find('.fixtureEditorPane').prop('style').width).toBe( + cachedSize + ); + }); + + describe('from landscape to portrait', () => { + beforeEach(() => { + instance.contentNode = { + // Portrait + offsetWidth: 200, + offsetHeight: 300, + }; + instance.onResize(); + }); + + it('should set portrait class to content', () => { + expect(wrapper.find('.content.contentPortrait')).toHaveLength(1); + }); + + it('should set cached fixture editor pane height', () => { + expect(wrapper.find('.fixtureEditorPane').prop('style').height).toBe( + cachedSize + ); + }); + }); +}); diff --git a/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-selected-fullscreen.jsx b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-selected-fullscreen.jsx index 1f7fec9d81..5e8706c301 100644 --- a/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-selected-fullscreen.jsx +++ b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-selected-fullscreen.jsx @@ -31,6 +31,6 @@ describe('CP with fixture already selected in full screen', () => { }); test('should render loader iframe', () => { - expect(wrapper.find('iframe').length).toBe(1); + expect(wrapper.find('iframe')).toHaveLength(1); }); }); diff --git a/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-selected-missing.jsx b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-selected-missing.jsx index 60f2bd2391..6773229b31 100644 --- a/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-selected-missing.jsx +++ b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-selected-missing.jsx @@ -78,7 +78,7 @@ describe('CP with missing fixture already selected', () => { }); test('renders MissingScreen', () => { - expect(wrapper.find(MissingScreen).length).toBe(1); + expect(wrapper.find(MissingScreen)).toHaveLength(1); }); test('sends component name to MissingScreen', () => { diff --git a/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-selected.jsx b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-selected.jsx index b7ca47efd5..5ca979238b 100644 --- a/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-selected.jsx +++ b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixture-selected.jsx @@ -3,6 +3,7 @@ import { mount } from 'enzyme'; import { Loader } from 'react-cosmos-loader'; import createStateProxy from 'react-cosmos-state-proxy'; import selectedFixture from '../__fixtures__/selected'; +import StarryBg from '../../StarryBg'; import FixtureList from '../../FixtureList'; import ComponentPlayground from '../'; @@ -73,7 +74,7 @@ describe('CP with fixture already selected', () => { window.removeEventListener('message', handleMessage); }); - test('sends fixture select message to loader', () => { + it('sends fixture select message to loader', () => { expect(loaderContentWindow.postMessage).toHaveBeenCalledWith( { type: 'fixtureSelect', @@ -91,40 +92,59 @@ describe('CP with fixture already selected', () => { props = wrapper.find(FixtureList).props(); }); - test('should send url params (component, fixture) to fixture list', () => { + it('should send url params (component, fixture) to fixture list', () => { expect(props.urlParams).toEqual({ component: 'ComponentA', fixture: 'foo', }); }); + + it('clicking on selected fixture sends new message to loader', () => { + props.onUrlChange(window.location.href); + expect(loaderContentWindow.postMessage).toHaveBeenCalledTimes(2); + expect(loaderContentWindow.postMessage).toHaveBeenLastCalledWith( + { + type: 'fixtureSelect', + component: 'ComponentA', + fixture: 'foo', + }, + '*' + ); + }); }); describe('main menu', () => { const fixtureEditorUrl = '/?component=ComponentA&fixture=foo&editor=true'; const fullScreenUrl = '/?component=ComponentA&fixture=foo&fullScreen=true'; - test('should render home button', () => { - expect(wrapper.find('a[href="/"].button').length).toBe(1); + it('should render home button', () => { + expect(wrapper.find('a[href="/"].button')).toHaveLength(1); }); - test('should not render selected home button', () => { - expect(wrapper.find('a[href="/"].selectedButton').length).toBe(0); + it('should not render selected home button', () => { + expect(wrapper.find('a[href="/"].selectedButton')).toHaveLength(0); }); - test('should render fixture editor button', () => { - expect(wrapper.find(`a[href="${fixtureEditorUrl}"].button`).length).toBe( + it('should render fixture editor button', () => { + expect(wrapper.find(`a[href="${fixtureEditorUrl}"].button`)).toHaveLength( 1 ); }); - test('should not render selected fixture editor button', () => { + it('should not render selected fixture editor button', () => { expect( wrapper.find(`a[href="${fixtureEditorUrl}"].selectedButton`).length ).toBe(0); }); - test('should render full screen button', () => { - expect(wrapper.find(`a[href="${fullScreenUrl}"].button`).length).toBe(1); + it('should render full screen button', () => { + expect(wrapper.find(`a[href="${fullScreenUrl}"].button`)).toHaveLength(1); + }); + }); + + describe('content', () => { + it('should not render StarryBg', () => { + expect(wrapper.find(StarryBg)).toHaveLength(0); }); }); }); diff --git a/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixtures-loaded.jsx b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixtures-loaded.jsx index 9a6be06ee3..883dc9a77e 100644 --- a/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixtures-loaded.jsx +++ b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/fixtures-loaded.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import merge from 'lodash.merge'; import { mount } from 'enzyme'; import { Loader } from 'react-cosmos-loader'; import createStateProxy from 'react-cosmos-state-proxy'; @@ -16,14 +17,11 @@ describe('CP fixtures loaded', () => { router = { goTo: jest.fn(), }; - const { props, state } = readyFixture; - const fixture = { + const fixture = merge({}, readyFixture, { props: { - ...props, router, }, - state, - }; + }); return new Promise(resolve => { // Mount component in order for ref and lifecycle methods to be called @@ -70,11 +68,11 @@ describe('CP fixtures loaded', () => { describe('main menu', () => { test('should render home button', () => { - expect(wrapper.find('a[href="/"].button').length).toBe(1); + expect(wrapper.find('a[href="/"].button')).toHaveLength(1); }); test('should render selected home button', () => { - expect(wrapper.find('a[href="/"].selectedButton').length).toBe(1); + expect(wrapper.find('a[href="/"].selectedButton')).toHaveLength(1); }); }); diff --git a/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/init.jsx b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/init.jsx index b33cbdcfcf..1a472c3a79 100644 --- a/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/init.jsx +++ b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/init.jsx @@ -20,11 +20,11 @@ describe('CP init', () => { }); test('should starry background', () => { - expect(wrapper.find(StarryBg).length).toBe(1); + expect(wrapper.find(StarryBg)).toHaveLength(1); }); test('should render loader iframe', () => { - expect(wrapper.find('iframe').length).toBe(1); + expect(wrapper.find('iframe')).toHaveLength(1); }); test('should render loader iframe with props.loaderUri', () => { diff --git a/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/left-nav-drag.jsx b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/left-nav-drag.jsx new file mode 100644 index 0000000000..e263e10529 --- /dev/null +++ b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/__tests__/left-nav-drag.jsx @@ -0,0 +1,143 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { Loader } from 'react-cosmos-loader'; +import createStateProxy from 'react-cosmos-state-proxy'; +import readyFixture from '../__fixtures__/ready'; +import DragHandle from '../../DragHandle'; +import ComponentPlayground, { LEFT_NAV_SIZE } from '../'; +import localForage from 'localforage'; + +jest.mock('localforage'); + +// Vars populated in beforeEach blocks +let wrapper; + +describe('CP left nav drag', () => { + describe('default size', () => { + beforeEach(() => { + return new Promise(resolve => { + // Mount component in order for ref and lifecycle methods to be called + wrapper = mount( + { + resolve(); + }} + /> + ); + }); + }); + + it('should set default left nav width', () => { + expect(wrapper.find('.leftNav').prop('style').width).toBe(250); + }); + }); + + describe('cached size', () => { + const cachedSize = 220; + + beforeEach(() => { + localForage.__setItemMocks({ + [LEFT_NAV_SIZE]: cachedSize, + }); + + return new Promise(resolve => { + // Mount component in order for ref and lifecycle methods to be called + wrapper = mount( + { + resolve(); + }} + /> + ); + }); + }); + + it('should set cached left nav width', () => { + expect(wrapper.find('.leftNav').prop('style').width).toBe(cachedSize); + }); + + it('should render DragHandle in left nav', () => { + expect(wrapper.find('.leftNav').find(DragHandle)).toHaveLength(1); + }); + }); + + describe('on drag', () => { + let dragHandleElement; + + beforeEach(() => { + localForage.setItem.mockClear(); + + dragHandleElement = wrapper + .find('.leftNav') + .find(DragHandle) + .getDOMNode(); + + // We can't use Enzyme's simulate to trigger native events + const downEvent = new MouseEvent('mousedown', { + clientX: 2, + }); + dragHandleElement.dispatchEvent(downEvent); + + const moveEvent = new MouseEvent('mousemove', { + clientX: 202, + }); + document.dispatchEvent(moveEvent); + + const upEvent = new MouseEvent('mouseup'); + document.dispatchEvent(upEvent); + }); + + it('should resize left nav', () => { + expect(wrapper.find('.leftNav').prop('style').width).toBe(200); + }); + + it('should update cache', () => { + expect(localForage.setItem).toHaveBeenCalledWith(LEFT_NAV_SIZE, 200); + }); + }); + + describe('loader frame overlay', () => { + it('is visible while dragging', () => { + const dragHandleElement = wrapper + .find('.leftNav') + .find(DragHandle) + .getDOMNode(); + + // We can't use Enzyme's simulate to trigger native events + const downEvent = new MouseEvent('mousedown', { + clientX: 0, + }); + dragHandleElement.dispatchEvent(downEvent); + + expect(wrapper.find('.loaderFrameOverlay').prop('style').display).toBe( + 'block' + ); + }); + + it('is not visible after dragging', () => { + const dragHandleElement = wrapper + .find('.leftNav') + .find(DragHandle) + .getDOMNode(); + + // We can't use Enzyme's simulate to trigger native events + const downEvent = new MouseEvent('mousedown', { + clientX: 0, + }); + dragHandleElement.dispatchEvent(downEvent); + + const upEvent = new MouseEvent('mouseup'); + document.dispatchEvent(upEvent); + + expect(wrapper.find('.loaderFrameOverlay').prop('style').display).toBe( + 'none' + ); + }); + }); +}); diff --git a/packages/react-cosmos-component-playground/src/components/ComponentPlayground/index.jsx b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/index.jsx index 0b2d67c289..1f6a053845 100644 --- a/packages/react-cosmos-component-playground/src/components/ComponentPlayground/index.jsx +++ b/packages/react-cosmos-component-playground/src/components/ComponentPlayground/index.jsx @@ -2,19 +2,31 @@ import React, { Component } from 'react'; import { string, bool, object } from 'prop-types'; import classNames from 'classnames'; import omitBy from 'lodash.omitby'; +import localForage from 'localforage'; import { uri } from 'react-querystring-router'; import { HomeIcon, FullScreenIcon, CodeIcon } from '../SvgIcon'; import StarryBg from '../StarryBg'; import FixtureList from '../FixtureList'; import WelcomeScreen from '../WelcomeScreen'; import MissingScreen from '../MissingScreen'; +import DragHandle from '../DragHandle'; +import FixtureEditor from '../FixtureEditor'; import styles from './index.less'; +export const LEFT_NAV_SIZE = '__cosmos__left-nav-size'; +export const FIXTURE_EDITOR_PANE_SIZE = '__cosmos__fixture-editor-pane-size'; + const fixtureExists = (fixtures, component, fixture) => fixtures[component] && fixtures[component].indexOf(fixture) !== -1; +const postMessageToFrame = (frame, data) => + frame.contentWindow.postMessage(data, '*'); + export default class ComponentPlayground extends Component { - static defaultProps = {}; + static defaultProps = { + editor: false, + fullScreen: false, + }; // Exclude params with default values static getCleanUrlParams = params => @@ -22,14 +34,39 @@ export default class ComponentPlayground extends Component { state = { waitingForLoader: true, + isDragging: false, + leftNavSize: 250, + fixtureEditorPaneSize: 250, + orientation: 'landscape', + fixtureBody: {}, }; componentDidMount() { window.addEventListener('message', this.onMessage, false); + window.addEventListener('resize', this.onResize, false); + + // Remember the resizable pane offsets between sessions + Promise.all([ + localForage.getItem(LEFT_NAV_SIZE), + localForage.getItem(FIXTURE_EDITOR_PANE_SIZE), + ]).then(([leftNavSize, fixtureEditorPaneSize]) => { + this.setState( + // Only override default values when cache values are present + omitBy( + { + leftNavSize, + fixtureEditorPaneSize, + }, + val => typeof val !== 'number' + ), + this.updateContentOrientation + ); + }); } componentWillUnmount() { window.removeEventListener('message', this.onMessage); + window.removeEventListener('resize', this.onResize); } componentWillReceiveProps({ component, fixture }) { @@ -40,14 +77,11 @@ export default class ComponentPlayground extends Component { component !== this.props.component || fixture !== this.props.fixture; if (fixtureChanged && fixtureExists(fixtures, component, fixture)) { - this.loaderFrame.contentWindow.postMessage( - { - type: 'fixtureSelect', - component, - fixture, - }, - '*' - ); + postMessageToFrame(this.loaderFrame, { + type: 'fixtureSelect', + component, + fixture, + }); } } } @@ -59,27 +93,37 @@ export default class ComponentPlayground extends Component { this.onLoaderReady(data); } else if (type === 'fixtureListUpdate') { this.onFixtureListUpdate(data); + } else if (type === 'fixtureLoad') { + this.onFixtureLoad(data); + } else if (type === 'fixtureUpdate') { + this.onFixtureUpdate(data); } }; + onResize = () => { + this.updateContentOrientation(); + }; + onLoaderReady({ fixtures }) { const { loaderFrame } = this; - this.setState({ - waitingForLoader: false, - fixtures, - }); + this.setState( + { + waitingForLoader: false, + fixtures, + }, + // We update the content orientation because the content width decreases + // when the left nav becomes visible + this.updateContentOrientation + ); const { component, fixture } = this.props; if (component && fixture && fixtureExists(fixtures, component, fixture)) { - loaderFrame.contentWindow.postMessage( - { - type: 'fixtureSelect', - component, - fixture, - }, - '*' - ); + postMessageToFrame(loaderFrame, { + type: 'fixtureSelect', + component, + fixture, + }); } } @@ -89,10 +133,90 @@ export default class ComponentPlayground extends Component { }); } + onFixtureLoad({ fixtureBody }) { + this.setState({ + fixtureBody, + }); + } + + onFixtureUpdate({ fixtureBody }) { + this.setState({ + // Fixture updates are partial + fixtureBody: { + ...this.state.fixtureBody, + ...fixtureBody, + }, + }); + } + onUrlChange = location => { - this.props.router.goTo(location); + if (location === window.location.href) { + const { component, fixture } = this.props; + postMessageToFrame(this.loaderFrame, { + type: 'fixtureSelect', + component, + fixture, + }); + } else { + this.props.router.goTo(location); + } + }; + + onLeftNavDrag = leftNavSize => { + this.setState( + { + leftNavSize, + }, + // We update the content orientation because the content width changes + // when the width of left nav changes + this.updateContentOrientation + ); + + localForage.setItem(LEFT_NAV_SIZE, leftNavSize); + }; + + onFixtureEditorPaneDrag = fixtureEditorPaneSize => { + this.setState({ + fixtureEditorPaneSize, + }); + + localForage.setItem(FIXTURE_EDITOR_PANE_SIZE, fixtureEditorPaneSize); + }; + + onDragStart = () => { + this.setState({ isDragging: true }); + }; + + onDragEnd = () => { + this.setState({ isDragging: false }); + }; + + onFixtureEditorChange = fixtureBody => { + this.setState({ + fixtureBody, + }); + + postMessageToFrame(this.loaderFrame, { + type: 'fixtureEdit', + fixtureBody, + }); + }; + + handleContentRef = node => { + this.contentNode = node; }; + handleIframeRef = node => { + this.loaderFrame = node; + }; + + updateContentOrientation() { + const { offsetHeight, offsetWidth } = this.contentNode; + this.setState({ + orientation: offsetHeight > offsetWidth ? 'portrait' : 'landscape', + }); + } + render() { return (
@@ -113,41 +237,38 @@ export default class ComponentPlayground extends Component { } renderContent() { - const { loaderUri, component, fixture } = this.props; - const { waitingForLoader, fixtures } = this.state; + const { component, fixture, editor } = this.props; + const { waitingForLoader, fixtures, orientation } = this.state; const isFixtureSelected = !waitingForLoader && Boolean(fixture); const isMissingFixtureSelected = isFixtureSelected && !fixtureExists(fixtures, component, fixture); + const isLoaderVisible = isFixtureSelected && !isMissingFixtureSelected; + const classes = classNames(styles.content, { + [styles.contentPortrait]: orientation === 'portrait', + [styles.contentLandscape]: orientation === 'landscape', + }); return ( -
- - {!waitingForLoader && - !isFixtureSelected && - } - {isMissingFixtureSelected && - } - -