From 846809ec86b0b779de7cf9843301c29dd38ce4dc Mon Sep 17 00:00:00 2001 From: Preston Goforth Date: Tue, 12 Mar 2019 14:51:42 -0400 Subject: [PATCH] addon-cssresources - `STORY_RENDERED` causes picked CSS to reset - Track the current story id so addon-cssresources doesn't reset itself every time the story is re-rendered - Merge existing list item picked state with parameters that share an `id` --- .../src/css-resource-panel.test.js | 304 ++++++++++++++++++ .../cssresources/src/css-resource-panel.tsx | 28 +- 2 files changed, 328 insertions(+), 4 deletions(-) create mode 100644 addons/cssresources/src/css-resource-panel.test.js diff --git a/addons/cssresources/src/css-resource-panel.test.js b/addons/cssresources/src/css-resource-panel.test.js new file mode 100644 index 000000000000..2d3353e7a1ef --- /dev/null +++ b/addons/cssresources/src/css-resource-panel.test.js @@ -0,0 +1,304 @@ +import React from 'react'; +import { configure, mount, shallow } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import { SyntaxHighlighter } from '@storybook/components'; +import { STORY_RENDERED } from '@storybook/core-events'; +import { EVENTS, PARAM_KEY } from './constants'; +import { CssResourcePanel } from './css-resource-panel.tsx'; + +configure({ + adapter: new Adapter(), +}); + +const defaultParameters = [ + { + id: 'fake-css-id-1', + code: 'fake-css-code-1', + picked: true, + }, + { + id: 'fake-css-id-2', + code: 'fake-css-code-2', + picked: false, + }, +]; +const newFakeParameters = [ + { + id: 'new-fake-css-id-1', + code: 'new-fake-css-code-1', + picked: false, + }, + { + id: 'new-fake-css-id-2', + code: 'new-fake-css-code-2', + picked: true, + }, +]; +const defaultProps = { + active: true, + api: { + emit: jest.fn(), + on: jest.fn(), + off: jest.fn(), + getParameters: jest.fn(() => defaultParameters), + }, +}; + +const shallowNode = (props = defaultProps) => shallow(); +const mountNode = (props = defaultProps) => mount(); + +describe('CSSResourcePanel', () => { + describe('constructor', () => { + const node = mountNode(); + + it('should initialize state', () => { + expect(node).toHaveState({ list: [], currentStoryId: '' }); + }); + + it('should not render anything', () => { + expect(node).toBeEmptyRender(); + }); + }); + + describe('componentDidMount', () => { + let spy; + + afterEach(() => { + spy.mockClear(); + }); + + it('should execute when component is mounted', () => { + spy = jest.spyOn(CssResourcePanel.prototype, 'componentDidMount'); + shallowNode(); + expect(spy).toHaveBeenCalled(); + }); + + it('should add STORY_RENDERED listener to the api', () => { + const apiAdd = jest.fn(); + const node = shallowNode({ + ...defaultProps, + api: { + ...defaultProps.api, + on: apiAdd, + }, + }); + const { onStoryChange } = node.instance(); + expect(apiAdd).toHaveBeenCalledWith(STORY_RENDERED, onStoryChange); + }); + }); + + describe('componentWillUnmount', () => { + let spy; + + afterEach(() => { + if (spy) { + spy.mockClear(); + } + }); + + it('should execute when component is unmounted', () => { + spy = jest.spyOn(CssResourcePanel.prototype, 'componentWillUnmount'); + shallowNode().unmount(); + expect(spy).toHaveBeenCalled(); + }); + + it('should remove STORY_RENDERED listener from the api', () => { + const apiRemove = jest.fn(); + const node = shallowNode({ + ...defaultProps, + api: { + ...defaultProps.api, + off: apiRemove, + }, + }); + const { onStoryChange } = node.instance(); + node.unmount(); + expect(apiRemove).toHaveBeenCalledWith(STORY_RENDERED, onStoryChange); + }); + }); + + describe('onStoryChange', () => { + it('should populate list with the default items', () => { + const node = shallowNode(); + expect(node.state('list')).toMatchObject([]); + node.instance().onStoryChange('fake-story-id'); + expect(node.state('list')).toMatchObject(defaultParameters); + }); + + it('should pull default items from getParameters', () => { + const apiGetParameters = jest.fn(() => newFakeParameters); + const node = shallowNode({ + ...defaultProps, + api: { + ...defaultProps.api, + getParameters: apiGetParameters, + }, + }); + expect(node.state('list')).toMatchObject([]); + node.instance().onStoryChange('fake-story-id'); + expect(apiGetParameters).toHaveBeenCalledWith('fake-story-id', PARAM_KEY); + expect(node.state('list')).toMatchObject(newFakeParameters); + }); + + it('should maintain picked attribute for matching ids', () => { + const node = shallowNode(); + const invertedItem = { + ...defaultParameters[0], + picked: !defaultParameters[0].picked, + }; + node.setState({ list: [invertedItem] }); + node.instance().onStoryChange('fake-story-id'); + expect(node.state('list')).toMatchObject([ + { + ...defaultParameters[0], + picked: !defaultParameters[0].picked, + }, + ...defaultParameters.slice(1), + ]); + }); + + it("should not overwrite list when story id hasn't changed", () => { + const fakeList = []; + const apiGetParameters = jest.fn(() => newFakeParameters); + const node = shallowNode({ + ...defaultProps, + api: { + ...defaultProps.api, + getParameters: apiGetParameters, + }, + }); + node.setState({ list: fakeList, currentStoryId: 'fake-story-id' }); + expect(node.state('list')).toEqual(fakeList); + node.instance().onStoryChange('fake-story-id'); + expect(node.state('list')).toEqual(fakeList); + }); + + it('should not overwrite list when default list is undefined', () => { + const fakeList = []; + const apiGetParameters = jest.fn(() => undefined); + const node = shallowNode({ + ...defaultProps, + api: { + ...defaultProps.api, + getParameters: apiGetParameters, + }, + }); + node.setState({ list: fakeList }); + expect(node.state('list')).toEqual(fakeList); + node.instance().onStoryChange('fake-story-id'); + expect(node.state('list')).toEqual(fakeList); + }); + }); + + describe('onChange', () => { + let spy; + + afterEach(() => { + if (spy) { + spy.mockClear(); + } + }); + + const changedIndex = 1; + const fakeEvent = { + target: { + id: defaultParameters[changedIndex].id, + checked: true, + }, + }; + const newFakeList = defaultParameters.map((param, index) => + index === changedIndex + ? { + ...param, + picked: true, + } + : param + ); + + it('should update the list with new picked items', () => { + const node = shallowNode(); + node.instance().onStoryChange('fake-story-id'); + node.instance().onChange(fakeEvent); + expect(node.state('list')).toMatchObject(newFakeList); + }); + + it('should call emit method with updated list', () => { + spy = jest.spyOn(CssResourcePanel.prototype, 'emit'); + const node = shallowNode(); + node.instance().onStoryChange('fake-story-id'); + node.instance().onChange(fakeEvent); + expect(spy).toHaveBeenCalledWith(newFakeList); + }); + }); + + describe('emit', () => { + it('should call emit from the api with EVENTS.SET', () => { + const apiEmit = jest.fn(); + const node = shallowNode({ + ...defaultProps, + api: { + ...defaultProps.api, + emit: apiEmit, + }, + }); + node.instance().emit(newFakeParameters); + expect(apiEmit).toHaveBeenCalledWith(EVENTS.SET, newFakeParameters); + }); + }); + + describe('render', () => { + it('should not render anything when not active', () => { + const node = shallowNode({ + ...defaultProps, + active: false, + }); + node.instance().onStoryChange('fake-story-id'); + expect(node).toBeEmptyRender(); + }); + + describe('each list item', () => { + const node = shallowNode(); + node.instance().onStoryChange('fake-story-id'); + + defaultParameters.forEach(param => { + it(`should render list item with id '${param.id}'`, () => { + expect(node.find(`#${param.id}`).length).toEqual(1); + }); + + it(`should render list item with id '${param.id}' as ${ + param.picked ? 'checked' : 'unchecked' + }`, () => { + expect( + node + .find(`#${param.id}`) + .first() + .prop('checked') + ).toBe(param.picked); + }); + }); + }); + + it('should not render code for items without a code', () => { + const apiGetParameters = jest.fn(() => [ + { + id: 'local-fake-id-1', + picked: true, + }, + { + id: 'local-fake-id-2', + code: 'local-fake-code-2', + picked: false, + }, + ]); + const node = shallowNode({ + ...defaultProps, + api: { + ...defaultProps.api, + getParameters: apiGetParameters, + }, + }); + node.instance().onStoryChange('fake-story-id'); + expect(node.find(SyntaxHighlighter).length).toEqual(1); + }); + }); +}); diff --git a/addons/cssresources/src/css-resource-panel.tsx b/addons/cssresources/src/css-resource-panel.tsx index 874b36204558..f1ba6c8618ff 100644 --- a/addons/cssresources/src/css-resource-panel.tsx +++ b/addons/cssresources/src/css-resource-panel.tsx @@ -18,14 +18,20 @@ interface CssResourcePanelProps { } interface CssResourcePanelState { + currentStoryId: string; list: CssResource[]; } +interface CssResourceLookup { + [key: string]: CssResource; +} + export class CssResourcePanel extends Component { constructor(props: CssResourcePanelProps) { super(props); this.state = { + currentStoryId: '', list: [], }; } @@ -41,12 +47,26 @@ export class CssResourcePanel extends Component { + const { list: currentList, currentStoryId } = this.state; const { api } = this.props; const list = api.getParameters(id, PARAM_KEY) as CssResource[]; - if (list) { - const picked = list.filter(res => res.picked); - this.setState({ list }, () => this.emit(picked)); + if (list && currentStoryId !== id) { + const existingIds = currentList.reduce((lookup: CssResourceLookup, res) => { + lookup[res.id] = res; + return lookup; + }, {}) as CssResourceLookup; + const mergedList = list.map(res => { + const existingItem = existingIds[res.id]; + return existingItem + ? { + ...res, + picked: existingItem.picked, + } + : res; + }); + const picked = mergedList.filter(res => res.picked); + this.setState({ list: mergedList, currentStoryId: id }, () => this.emit(picked)); } }; @@ -65,7 +85,7 @@ export class CssResourcePanel extends Component