mirror of
https://github.com/storybookjs/storybook.git
synced 2025-04-07 07:21:17 +08:00
Merge pull request #5289 from storybooks/5271-persist-state
Add a persistence API to context/state -- use for keyboard shortcuts
This commit is contained in:
commit
4508aa4ba1
@ -97,12 +97,7 @@ module.exports = {
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: [
|
||||
'**/__tests__/**',
|
||||
'**/*.test.js/**',
|
||||
'**/*.stories.js',
|
||||
'**/storyshots/**/stories/**',
|
||||
],
|
||||
files: ['**/__tests__/**', '**/*.test.js', '**/*.stories.js', '**/storyshots/**/stories/**'],
|
||||
rules: {
|
||||
'import/no-extraneous-dependencies': ignore,
|
||||
},
|
||||
|
@ -92,6 +92,7 @@
|
||||
"webpack-hot-middleware": "^2.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"flush-promises": "^1.0.2",
|
||||
"mock-fs": "^4.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
@ -3,8 +3,7 @@ import memoize from 'memoizerific';
|
||||
|
||||
import { Badge } from '@storybook/components';
|
||||
|
||||
import { get } from '../settings/persist';
|
||||
import { keyToSymbol, serializableKeyboardShortcuts } from '../settings/utils';
|
||||
import { keyToSymbol } from '../settings/utils';
|
||||
|
||||
import Nav from '../components/nav/nav';
|
||||
import { Consumer } from '../core/context';
|
||||
@ -103,7 +102,7 @@ export const mapper = (state, api) => {
|
||||
storiesHash,
|
||||
} = state;
|
||||
|
||||
const shortcutKeys = get('shortcutKeys') || serializableKeyboardShortcuts;
|
||||
const shortcutKeys = api.getShortcutKeys();
|
||||
return {
|
||||
title: name,
|
||||
url,
|
||||
|
@ -6,6 +6,7 @@ import Events from '@storybook/core-events';
|
||||
import initProviderApi from './init-provider-api';
|
||||
import initKeyHandler from './init-key-handler';
|
||||
|
||||
import Store from './store';
|
||||
import getInitialState from './initial-state';
|
||||
|
||||
import initAddons from './addons';
|
||||
@ -13,6 +14,7 @@ import initChannel from './channel';
|
||||
import initNotifications from './notifications';
|
||||
import initStories from './stories';
|
||||
import initLayout from './layout';
|
||||
import initShortcuts from './shortcuts';
|
||||
import initURL from './url';
|
||||
import initVersions from './versions';
|
||||
|
||||
@ -41,10 +43,15 @@ export class Provider extends Component {
|
||||
super(props);
|
||||
const { provider, location, path, viewMode, storyId, navigate } = props;
|
||||
|
||||
const store = {
|
||||
const store = new Store({
|
||||
getState: () => this.state,
|
||||
setState: (a, b) => this.setState(a, b),
|
||||
};
|
||||
});
|
||||
|
||||
// Initialize the state to be the initial (persisted) state of the store.
|
||||
// This gives the modules the chance to read the persisted state, apply their defaults
|
||||
// and override if necessary
|
||||
this.state = store.getInitialState();
|
||||
|
||||
const apiData = {
|
||||
navigate,
|
||||
@ -61,12 +68,13 @@ export class Provider extends Component {
|
||||
initAddons,
|
||||
initLayout,
|
||||
initNotifications,
|
||||
initShortcuts,
|
||||
initStories,
|
||||
initURL,
|
||||
initVersions,
|
||||
].map(initModule => initModule(apiData));
|
||||
|
||||
// Create our initial state by combining the initial state of all modules
|
||||
// Create our initial state by combining the initial state of all modules, then overlaying any saved state
|
||||
const state = getInitialState(...modules.map(m => m.state));
|
||||
|
||||
// Get our API by combining the APIs exported by each module
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { document } from 'global';
|
||||
import keyEvents, { features } from '../libs/key_events';
|
||||
import keyEventToFeature, { features } from '../libs/key_events';
|
||||
|
||||
export default ({ store, api }) => {
|
||||
const handle = eventType => {
|
||||
const handleFeature = feature => {
|
||||
const {
|
||||
layout: { isFullscreen, showNav, showPanel },
|
||||
} = store.getState();
|
||||
|
||||
switch (eventType) {
|
||||
switch (feature) {
|
||||
case features.ESCAPE: {
|
||||
if (isFullscreen) {
|
||||
api.toggleFullscreen();
|
||||
@ -153,9 +153,12 @@ export default ({ store, api }) => {
|
||||
};
|
||||
|
||||
return {
|
||||
handle,
|
||||
handle: handleFeature,
|
||||
bind: () => {
|
||||
document.addEventListener('keydown', e => handle(keyEvents(e, store), store));
|
||||
document.addEventListener('keydown', e => {
|
||||
const { shortcuts } = store.getState();
|
||||
handleFeature(keyEventToFeature(e, shortcuts));
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@ -1,15 +1,8 @@
|
||||
import mergeWith from 'lodash.mergewith';
|
||||
import isEqual from 'lodash.isequal';
|
||||
|
||||
import { logger } from '@storybook/client-logger';
|
||||
import { themes } from '@storybook/theming';
|
||||
|
||||
import { initShortcutKeys, serializedLocalStorage } from '../settings/utils';
|
||||
|
||||
const shortcuts = initShortcutKeys();
|
||||
import merge from '../libs/merge';
|
||||
|
||||
const initial = {
|
||||
shortcutKeys: serializedLocalStorage(shortcuts),
|
||||
ui: {
|
||||
name: 'STORYBOOK',
|
||||
url: 'https://github.com/storybooks/storybook',
|
||||
@ -28,24 +21,5 @@ const initial = {
|
||||
customQueryParams: {},
|
||||
};
|
||||
|
||||
const merge = (a, b) =>
|
||||
mergeWith({}, a, b, (objValue, srcValue) => {
|
||||
if (Array.isArray(srcValue) && Array.isArray(objValue)) {
|
||||
srcValue.forEach(s => {
|
||||
const existing = objValue.find(o => o === s || isEqual(o, s));
|
||||
if (!existing) {
|
||||
objValue.push(s);
|
||||
}
|
||||
});
|
||||
|
||||
return objValue;
|
||||
}
|
||||
if (Array.isArray(objValue)) {
|
||||
logger.log('the types mismatch, picking', objValue);
|
||||
return objValue;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Returns the initialState of the app
|
||||
export default (...additions) => additions.reduce((acc, item) => merge(acc, item), initial);
|
||||
|
57
lib/ui/src/core/shortcuts.js
Normal file
57
lib/ui/src/core/shortcuts.js
Normal file
@ -0,0 +1,57 @@
|
||||
import { navigator } from 'global';
|
||||
|
||||
export const isMacLike = () =>
|
||||
navigator && navigator.platform ? !!navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i) : false;
|
||||
export const controlOrMetaKey = () => (isMacLike() ? 'meta' : 'control');
|
||||
|
||||
export const defaultShortcuts = Object.freeze({
|
||||
fullScreen: ['F'],
|
||||
togglePanel: ['S'], // Panel visibiliy
|
||||
panelPosition: ['D'],
|
||||
navigation: ['A'],
|
||||
toolbar: ['T'],
|
||||
search: ['/'],
|
||||
focusNav: ['1'],
|
||||
focusIframe: ['2'],
|
||||
focusPanel: ['3'],
|
||||
prevComponent: ['alt', 'ArrowUp'],
|
||||
nextComponent: ['alt', 'ArrowDown'],
|
||||
prevStory: ['alt', 'ArrowLeft'],
|
||||
nextStory: ['alt', 'ArrowRight'],
|
||||
shortcutsPage: ['shift', ',', controlOrMetaKey()],
|
||||
aboutPage: [','],
|
||||
});
|
||||
|
||||
export default function initShortcuts({ store }) {
|
||||
const api = {
|
||||
getShortcutKeys() {
|
||||
return store.getState().shortcuts;
|
||||
},
|
||||
async setShortcuts(shortcuts) {
|
||||
await store.setState({ shortcuts }, { persistence: 'permanent' });
|
||||
return shortcuts;
|
||||
},
|
||||
async restoreAllDefaultShortcuts() {
|
||||
api.setShortcuts(defaultShortcuts);
|
||||
},
|
||||
async setShortcut(action, value) {
|
||||
const shortcuts = api.getShortcutKeys();
|
||||
return api.setShortcuts({ ...shortcuts, [action]: value });
|
||||
},
|
||||
async restoreDefaultShortcut(action) {
|
||||
const defaultShortcut = defaultShortcuts[action];
|
||||
return api.setShortcut(action, defaultShortcut);
|
||||
},
|
||||
};
|
||||
|
||||
const { shortcuts: persistedShortcuts = {} } = store.getState();
|
||||
const state = {
|
||||
// Any saved shortcuts that are still in our set of defaults
|
||||
shortcuts: Object.keys(defaultShortcuts).reduce(
|
||||
(acc, key) => ({ ...acc, [key]: persistedShortcuts[key] || defaultShortcuts[key] }),
|
||||
{}
|
||||
),
|
||||
};
|
||||
|
||||
return { api, state };
|
||||
}
|
79
lib/ui/src/core/shortcuts.test.js
Normal file
79
lib/ui/src/core/shortcuts.test.js
Normal file
@ -0,0 +1,79 @@
|
||||
import initShortcuts from './shortcuts';
|
||||
|
||||
function createMockStore() {
|
||||
let state = {};
|
||||
return {
|
||||
getState: jest.fn().mockImplementation(() => state),
|
||||
setState: jest.fn().mockImplementation(s => {
|
||||
state = { ...state, ...s };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('shortcuts api', () => {
|
||||
it('sets defaults', () => {
|
||||
const store = createMockStore();
|
||||
|
||||
const { api, state } = initShortcuts({ store });
|
||||
store.setState(state);
|
||||
|
||||
expect(api.getShortcutKeys().fullScreen).toEqual(['F']);
|
||||
});
|
||||
|
||||
it('sets defaults, augmenting anything that was persisted', () => {
|
||||
const store = createMockStore();
|
||||
store.setState({ shortcuts: { fullScreen: ['Z'] } });
|
||||
|
||||
const { api, state } = initShortcuts({ store });
|
||||
store.setState(state);
|
||||
|
||||
expect(api.getShortcutKeys().fullScreen).toEqual(['Z']);
|
||||
expect(api.getShortcutKeys().togglePanel).toEqual(['S']);
|
||||
});
|
||||
|
||||
it('sets defaults, ignoring anything persisted that is out of date', () => {
|
||||
const store = createMockStore();
|
||||
store.setState({ shortcuts: { randomKey: ['Z'] } });
|
||||
|
||||
const { api, state } = initShortcuts({ store });
|
||||
store.setState(state);
|
||||
|
||||
expect(api.getShortcutKeys().randomKey).not.toBeDefined();
|
||||
});
|
||||
|
||||
it('sets new values', async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
const { api, state } = initShortcuts({ store });
|
||||
store.setState(state);
|
||||
|
||||
await api.setShortcut('fullScreen', ['X']);
|
||||
expect(api.getShortcutKeys().fullScreen).toEqual(['X']);
|
||||
});
|
||||
|
||||
it('restores all defaults', async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
const { api, state } = initShortcuts({ store });
|
||||
store.setState(state);
|
||||
|
||||
await api.setShortcut('fullScreen', ['X']);
|
||||
await api.setShortcut('togglePanel', ['B']);
|
||||
await api.restoreAllDefaultShortcuts();
|
||||
expect(api.getShortcutKeys().fullScreen).toEqual(['F']);
|
||||
expect(api.getShortcutKeys().togglePanel).toEqual(['S']);
|
||||
});
|
||||
|
||||
it('restores single default', async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
const { api, state } = initShortcuts({ store });
|
||||
store.setState(state);
|
||||
|
||||
await api.setShortcut('fullScreen', ['X']);
|
||||
await api.setShortcut('togglePanel', ['B']);
|
||||
await api.restoreDefaultShortcut('fullScreen');
|
||||
expect(api.getShortcutKeys().fullScreen).toEqual(['F']);
|
||||
expect(api.getShortcutKeys().togglePanel).toEqual(['B']);
|
||||
});
|
||||
});
|
@ -1,33 +1,82 @@
|
||||
// simple store implementation
|
||||
const createStore = initialState => {
|
||||
let state = initialState;
|
||||
const listeners = [];
|
||||
// TODO -- make this TS?
|
||||
|
||||
function notify() {
|
||||
listeners.forEach(l => l());
|
||||
import { localStorage, sessionStorage } from 'global';
|
||||
|
||||
export const STORAGE_KEY = '@storybook/ui/store';
|
||||
|
||||
function get(storage) {
|
||||
const serialized = storage.getItem(STORAGE_KEY);
|
||||
return serialized ? JSON.parse(serialized) : {};
|
||||
}
|
||||
|
||||
function set(storage, value) {
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify(value));
|
||||
}
|
||||
|
||||
function update(storage, patch) {
|
||||
const previous = get(storage);
|
||||
// Apply the same behaviour as react here
|
||||
set(storage, { ...previous, ...patch });
|
||||
}
|
||||
|
||||
// Our store piggybacks off the internal React state of the Context Provider
|
||||
// It has been augmented to persist state to local/sessionStorage
|
||||
export default class Store {
|
||||
constructor({ setState, getState }) {
|
||||
this.upstreamSetState = setState;
|
||||
this.upstreamGetState = getState;
|
||||
}
|
||||
|
||||
function setState(patch) {
|
||||
if (typeof patch === 'function') {
|
||||
state = { ...state, ...patch(state) };
|
||||
// The assumption is that this will be called once, to initialize the React state
|
||||
// when the module is instanciated
|
||||
getInitialState() {
|
||||
// We don't only merge at the very top level (the same way as React setState)
|
||||
// when you set keys, so it makes sense to do the same in combining the two storage modes
|
||||
// Really, you shouldn't store the same key in both places
|
||||
return { ...get(localStorage), ...get(sessionStorage) };
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this.upstreamGetState();
|
||||
}
|
||||
|
||||
async setState(inputPatch, cbOrOptions, inputOptions) {
|
||||
let callback;
|
||||
let options;
|
||||
if (typeof cbOrOptions === 'function') {
|
||||
callback = cbOrOptions;
|
||||
options = inputOptions;
|
||||
} else {
|
||||
state = { ...state, ...patch };
|
||||
options = cbOrOptions;
|
||||
}
|
||||
notify();
|
||||
}
|
||||
const { persistence = 'none' } = options || {};
|
||||
|
||||
return {
|
||||
getState() {
|
||||
return state;
|
||||
},
|
||||
setState,
|
||||
subscribe(listener) {
|
||||
listeners.push(listener);
|
||||
return function unsubscribe(l) {
|
||||
listeners.splice(listeners.indexOf(l), 1);
|
||||
let patch;
|
||||
// What did the patch actually return
|
||||
let delta;
|
||||
if (typeof inputPatch === 'function') {
|
||||
// Pass the same function, but just set delta on the way
|
||||
patch = state => {
|
||||
delta = inputPatch(state);
|
||||
return delta;
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
} else {
|
||||
patch = inputPatch;
|
||||
delta = patch;
|
||||
}
|
||||
|
||||
export default createStore;
|
||||
const newState = await new Promise(resolve => {
|
||||
this.upstreamSetState(patch, resolve);
|
||||
});
|
||||
|
||||
if (persistence !== 'none') {
|
||||
const storage = persistence === 'session' ? sessionStorage : localStorage;
|
||||
await update(storage, delta);
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
callback(newState);
|
||||
}
|
||||
return newState;
|
||||
}
|
||||
}
|
||||
|
162
lib/ui/src/core/store.test.js
Normal file
162
lib/ui/src/core/store.test.js
Normal file
@ -0,0 +1,162 @@
|
||||
import { localStorage, sessionStorage } from 'global';
|
||||
import flushPromises from 'flush-promises';
|
||||
|
||||
import Store, { STORAGE_KEY } from './store';
|
||||
|
||||
jest.mock('global', () => ({
|
||||
sessionStorage: {
|
||||
setItem: jest.fn(),
|
||||
getItem: jest.fn(),
|
||||
},
|
||||
localStorage: {
|
||||
setItem: jest.fn(),
|
||||
getItem: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('store', () => {
|
||||
it('sensibly combines local+session storage for inital state', () => {
|
||||
sessionStorage.getItem.mockReturnValueOnce(
|
||||
JSON.stringify({ foo: 'bar', combined: { a: 'b' } })
|
||||
);
|
||||
localStorage.getItem.mockReturnValueOnce(
|
||||
JSON.stringify({ foo: 'baz', another: 'value', combined: { c: 'd' } })
|
||||
);
|
||||
|
||||
const store = new Store({});
|
||||
expect(store.getInitialState()).toEqual({
|
||||
foo: 'bar',
|
||||
another: 'value',
|
||||
// We don't combine subfields from the two sources.
|
||||
combined: { a: 'b' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes getState right through', () => {
|
||||
const getState = jest.fn();
|
||||
const store = new Store({ getState });
|
||||
|
||||
store.getState();
|
||||
|
||||
expect(getState).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('setState', () => {
|
||||
it('sets values in React only by default', async () => {
|
||||
const setState = jest.fn().mockImplementation((x, cb) => cb());
|
||||
const store = new Store({ setState });
|
||||
|
||||
await store.setState({ foo: 'bar' });
|
||||
|
||||
expect(setState).toHaveBeenCalledWith({ foo: 'bar' }, expect.any(Function));
|
||||
expect(sessionStorage.setItem).not.toHaveBeenCalled();
|
||||
expect(localStorage.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets values in React and sessionStorage if persistence === session', async () => {
|
||||
const setState = jest.fn().mockImplementation((x, cb) => cb());
|
||||
const store = new Store({ setState });
|
||||
|
||||
await store.setState({ foo: 'bar' }, { persistence: 'session' });
|
||||
|
||||
expect(setState).toHaveBeenCalledWith({ foo: 'bar' }, expect.any(Function));
|
||||
expect(sessionStorage.setItem).toHaveBeenCalledWith(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ foo: 'bar' })
|
||||
);
|
||||
expect(localStorage.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets values in React and sessionStorage if persistence === permanent', async () => {
|
||||
const setState = jest.fn().mockImplementation((x, cb) => cb());
|
||||
const store = new Store({ setState });
|
||||
|
||||
await store.setState({ foo: 'bar' }, { persistence: 'permanent' });
|
||||
|
||||
expect(setState).toHaveBeenCalledWith({ foo: 'bar' }, expect.any(Function));
|
||||
expect(sessionStorage.setItem).not.toHaveBeenCalled();
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ foo: 'bar' })
|
||||
);
|
||||
});
|
||||
|
||||
it('properly patches existing values', async () => {
|
||||
const setState = jest.fn().mockImplementation((x, cb) => cb());
|
||||
sessionStorage.getItem.mockReturnValueOnce(
|
||||
JSON.stringify({ foo: 'baz', another: 'value', combined: { a: 'b' } })
|
||||
);
|
||||
const store = new Store({ setState });
|
||||
|
||||
await store.setState({ foo: 'bar', combined: { c: 'd' } }, { persistence: 'session' });
|
||||
|
||||
expect(setState).toHaveBeenCalledWith(
|
||||
{ foo: 'bar', combined: { c: 'd' } },
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(sessionStorage.setItem).toHaveBeenCalledWith(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ foo: 'bar', another: 'value', combined: { c: 'd' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for react to setState', async () => {
|
||||
let cb;
|
||||
const setState = jest.fn().mockImplementation((x, inputCb) => {
|
||||
cb = inputCb;
|
||||
});
|
||||
const store = new Store({ setState });
|
||||
|
||||
// NOTE: not awaiting here
|
||||
let done = false;
|
||||
store.setState({ foo: 'bar' }).then(() => {
|
||||
done = true;
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
expect(setState).toHaveBeenCalledWith({ foo: 'bar' }, expect.any(Function));
|
||||
expect(done).toBe(false);
|
||||
|
||||
cb();
|
||||
await flushPromises();
|
||||
expect(done).toBe(true);
|
||||
});
|
||||
|
||||
it('returns react.setState result', async () => {
|
||||
const setState = jest.fn().mockImplementation((x, cb) => cb('RESULT'));
|
||||
const store = new Store({ setState });
|
||||
|
||||
const result = await store.setState({ foo: 'bar' });
|
||||
|
||||
expect(result).toBe('RESULT');
|
||||
});
|
||||
|
||||
it('allows a callback', async () =>
|
||||
new Promise(resolve => {
|
||||
const setState = jest.fn().mockImplementation((x, cb) => cb('RESULT'));
|
||||
const store = new Store({ setState });
|
||||
|
||||
store.setState({ foo: 'bar' }, result => {
|
||||
expect(result).toBe('RESULT');
|
||||
resolve();
|
||||
});
|
||||
}));
|
||||
|
||||
it('allows a patch function and persists its results', async () => {
|
||||
const setState = jest.fn().mockImplementation((x, cb) => {
|
||||
x('OLD_STATE');
|
||||
cb();
|
||||
});
|
||||
const store = new Store({ setState });
|
||||
|
||||
const patch = jest.fn().mockReturnValue({ foo: 'bar' });
|
||||
await store.setState(patch, { persistence: 'session' });
|
||||
|
||||
expect(patch).toHaveBeenCalledWith('OLD_STATE');
|
||||
expect(sessionStorage.setItem).toHaveBeenCalledWith(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ foo: 'bar' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,5 +1,4 @@
|
||||
import Events from '@storybook/core-events';
|
||||
import { localStorage } from 'global';
|
||||
import { parseKey } from '../settings/utils';
|
||||
|
||||
export const features = {
|
||||
@ -62,8 +61,7 @@ const addKeyToTempShortcutArr = (shortcutName, shortcutKeys = {}, parsedEvent) =
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export default function handle(e) {
|
||||
const shortcutKeys = JSON.parse(localStorage.getItem('shortcutKeys'));
|
||||
export default function keyEventToFeature(e, shortcutKeys) {
|
||||
if (e.key === 'Escape') {
|
||||
return features.ESCAPE;
|
||||
}
|
||||
@ -133,7 +131,7 @@ export default function handle(e) {
|
||||
// window.keydown handler to dispatch a key event to the preview channel
|
||||
export function handleKeyboardShortcuts(channel) {
|
||||
return event => {
|
||||
const parsedEvent = handle(event);
|
||||
const parsedEvent = keyEventToFeature(event);
|
||||
if (parsedEvent) {
|
||||
channel.emit(Events.APPLY_SHORTCUT, { event: parsedEvent });
|
||||
}
|
||||
|
23
lib/ui/src/libs/merge.js
Normal file
23
lib/ui/src/libs/merge.js
Normal file
@ -0,0 +1,23 @@
|
||||
import mergeWith from 'lodash.mergewith';
|
||||
import isEqual from 'lodash.isequal';
|
||||
|
||||
import { logger } from '@storybook/client-logger';
|
||||
|
||||
export default (a, b) =>
|
||||
mergeWith({}, a, b, (objValue, srcValue) => {
|
||||
if (Array.isArray(srcValue) && Array.isArray(objValue)) {
|
||||
srcValue.forEach(s => {
|
||||
const existing = objValue.find(o => o === s || isEqual(o, s));
|
||||
if (!existing) {
|
||||
objValue.push(s);
|
||||
}
|
||||
});
|
||||
|
||||
return objValue;
|
||||
}
|
||||
if (Array.isArray(objValue)) {
|
||||
logger.log('the types mismatch, picking', objValue);
|
||||
return objValue;
|
||||
}
|
||||
return undefined;
|
||||
});
|
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import AboutPage from './about_page';
|
||||
import ShortcutsPage from './shortcuts';
|
||||
import ShortcutsPage from './shortcuts_page';
|
||||
|
||||
const SettingsPages = () => [<AboutPage key="about" />, <ShortcutsPage key="shortcuts" />];
|
||||
|
||||
|
@ -1,18 +1,8 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Icons } from '@storybook/components';
|
||||
import { Route } from '@storybook/router';
|
||||
import { setAll, setItem } from './persist';
|
||||
import {
|
||||
defaultShortcutSets,
|
||||
serializableKeyboardShortcuts,
|
||||
initShortcutKeys,
|
||||
isShortcutTaken,
|
||||
keyToSymbol,
|
||||
labelsArr,
|
||||
mapToKeyEl,
|
||||
parseKey,
|
||||
serializedLocalStorage,
|
||||
} from './utils';
|
||||
import { isShortcutTaken, keyToSymbol, labelsArr, mapToKeyEl, parseKey } from './utils';
|
||||
import {
|
||||
A,
|
||||
Button,
|
||||
@ -36,21 +26,29 @@ import {
|
||||
Wrapper,
|
||||
} from './components';
|
||||
|
||||
class ShortcutsPage extends Component {
|
||||
function toShortcutState(shortcutKeys) {
|
||||
return Object.entries(shortcutKeys).reduce(
|
||||
(acc, [action, value]) => ({ ...acc, [action]: { value, error: false } }),
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
class ShortcutsScreen extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const shortcuts = initShortcutKeys();
|
||||
|
||||
this.state = {
|
||||
activeInputField: '',
|
||||
inputArr: [],
|
||||
shortcutKeys: serializedLocalStorage(shortcuts),
|
||||
successField: '',
|
||||
// The initial shortcutKeys that come from props are the defaults/what was saved
|
||||
// As the user interacts with the page, the state stores the temporary, unsaved shortcuts
|
||||
// This object also includes the error attached to each shortcut
|
||||
shortcutKeys: toShortcutState(props.shortcutKeys),
|
||||
};
|
||||
}
|
||||
|
||||
duplicateFound = () => {
|
||||
const { activeInputField, inputArr, shortcutKeys } = this.state;
|
||||
const { shortcutKeys, activeInputField, inputArr } = this.state;
|
||||
const match = Object.entries(shortcutKeys).filter(
|
||||
i => i[0] !== activeInputField && isShortcutTaken(inputArr, i[1].value)
|
||||
);
|
||||
@ -134,33 +132,34 @@ class ShortcutsPage extends Component {
|
||||
saveShortcut = () => {
|
||||
const { activeInputField, inputArr, shortcutKeys } = this.state;
|
||||
|
||||
const { setShortcut } = this.props;
|
||||
setShortcut(activeInputField, inputArr);
|
||||
this.setState({
|
||||
successField: activeInputField,
|
||||
shortcutKeys: { ...shortcutKeys, [activeInputField]: { value: inputArr, error: false } },
|
||||
});
|
||||
return setItem('shortcutKeys', activeInputField, inputArr);
|
||||
};
|
||||
|
||||
restoreDefaults = () => {
|
||||
this.setState({ shortcutKeys: defaultShortcutSets });
|
||||
return setAll('shortcutKeys', serializableKeyboardShortcuts);
|
||||
const { restoreAllDefaultShortcuts } = this.props;
|
||||
|
||||
const defaultShortcuts = restoreAllDefaultShortcuts();
|
||||
this.setState({ shortcutKeys: toShortcutState(defaultShortcuts) });
|
||||
};
|
||||
|
||||
restoreDefault = () => {
|
||||
const { activeInputField, shortcutKeys } = this.state;
|
||||
|
||||
const { restoreDefaultShortcut } = this.props;
|
||||
|
||||
const defaultShortcut = restoreDefaultShortcut(activeInputField);
|
||||
this.setState({
|
||||
inputArr: defaultShortcutSets[activeInputField].value,
|
||||
inputArr: defaultShortcut,
|
||||
shortcutKeys: {
|
||||
...shortcutKeys,
|
||||
[activeInputField]: defaultShortcutSets[activeInputField],
|
||||
...toShortcutState({ [activeInputField]: defaultShortcut }),
|
||||
},
|
||||
});
|
||||
return setItem(
|
||||
'shortcutKeys',
|
||||
activeInputField,
|
||||
serializableKeyboardShortcuts[activeInputField]
|
||||
);
|
||||
};
|
||||
|
||||
displaySuccessMessage = activeElement => {
|
||||
@ -256,4 +255,11 @@ class ShortcutsPage extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default ShortcutsPage;
|
||||
ShortcutsScreen.propTypes = {
|
||||
shortcutKeys: PropTypes.shape({}).isRequired, // Need TS for this
|
||||
setShortcut: PropTypes.func.isRequired,
|
||||
restoreDefaultShortcut: PropTypes.func.isRequired,
|
||||
restoreAllDefaultShortcuts: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ShortcutsScreen;
|
||||
|
@ -1,22 +1,39 @@
|
||||
import React from 'react';
|
||||
import { mount, shallow } from 'enzyme';
|
||||
import ShortcutsPage from './shortcuts';
|
||||
import ShortcutsScreen from './shortcuts';
|
||||
|
||||
describe('ShortcutsPage', () => {
|
||||
// A limited set of keys we use in this test file
|
||||
const shortcutKeys = {
|
||||
fullScreen: ['F'],
|
||||
togglePanel: ['S'],
|
||||
navigation: ['A'],
|
||||
toolbar: ['T'],
|
||||
search: ['/'],
|
||||
focusNav: ['1'],
|
||||
focusIframe: ['2'],
|
||||
};
|
||||
|
||||
const actions = {
|
||||
setShortcut: jest.fn(),
|
||||
restoreDefaultShortcut: jest.fn().mockImplementation(action => shortcutKeys[action]),
|
||||
restoreAllDefaultShortcuts: jest.fn().mockReturnValue(shortcutKeys),
|
||||
};
|
||||
|
||||
describe('ShortcutsScreen', () => {
|
||||
it('renders correctly', () => {
|
||||
const comp = shallow(<ShortcutsPage />);
|
||||
const comp = shallow(<ShortcutsScreen shortcutKeys={shortcutKeys} {...actions} />);
|
||||
expect(comp).toExist();
|
||||
});
|
||||
|
||||
it('handles a full mount', () => {
|
||||
const comp = mount(<ShortcutsPage />);
|
||||
const comp = mount(<ShortcutsScreen shortcutKeys={shortcutKeys} {...actions} />);
|
||||
expect(comp).toExist();
|
||||
comp.unmount();
|
||||
});
|
||||
|
||||
describe('duplicateFound', () => {
|
||||
it('returns true if there are duplicates', () => {
|
||||
const comp = shallow(<ShortcutsPage />);
|
||||
const comp = shallow(<ShortcutsScreen shortcutKeys={shortcutKeys} {...actions} />);
|
||||
comp.setState({ activeInputField: 'fullScreen', inputArr: ['S'] });
|
||||
const instance = comp.instance();
|
||||
|
||||
@ -24,7 +41,7 @@ describe('ShortcutsPage', () => {
|
||||
});
|
||||
|
||||
it('returns false if there are no duplicates', () => {
|
||||
const comp = shallow(<ShortcutsPage />);
|
||||
const comp = shallow(<ShortcutsScreen shortcutKeys={shortcutKeys} {...actions} />);
|
||||
comp.setState({ activeInputField: 'fullScreen', inputArr: ['R'] });
|
||||
const instance = comp.instance();
|
||||
|
||||
@ -34,7 +51,7 @@ describe('ShortcutsPage', () => {
|
||||
|
||||
describe('submitKeyHandler', () => {
|
||||
it('restores the default if the input array is empty', () => {
|
||||
const comp = shallow(<ShortcutsPage />);
|
||||
const comp = shallow(<ShortcutsScreen shortcutKeys={shortcutKeys} {...actions} />);
|
||||
comp.setState({
|
||||
activeInputField: 'togglePanel',
|
||||
inputArr: [],
|
||||
@ -47,7 +64,7 @@ describe('ShortcutsPage', () => {
|
||||
});
|
||||
|
||||
it('returns undefined if inputArray has keys in it', () => {
|
||||
const comp = shallow(<ShortcutsPage />);
|
||||
const comp = shallow(<ShortcutsScreen shortcutKeys={shortcutKeys} {...actions} />);
|
||||
comp.setState({
|
||||
activeInputField: 'togglePanel',
|
||||
inputArr: ['R', '1'],
|
||||
@ -63,7 +80,7 @@ describe('ShortcutsPage', () => {
|
||||
describe('handleBackspace', () => {
|
||||
it('cuts the last element from the inputArr', () => {
|
||||
const expected = ['A', 'B'];
|
||||
const comp = shallow(<ShortcutsPage />);
|
||||
const comp = shallow(<ShortcutsScreen shortcutKeys={shortcutKeys} {...actions} />);
|
||||
comp.setState({ activeInputField: 'navigation', inputArr: ['A', 'B', 'C'] });
|
||||
const instance = comp.instance();
|
||||
instance.handleBackspace();
|
||||
@ -75,7 +92,7 @@ describe('ShortcutsPage', () => {
|
||||
|
||||
describe('onKeyDown', () => {
|
||||
it('calls handleBackspace if Backspace key is pressed', () => {
|
||||
const comp = shallow(<ShortcutsPage />);
|
||||
const comp = shallow(<ShortcutsScreen shortcutKeys={shortcutKeys} {...actions} />);
|
||||
const instance = comp.instance();
|
||||
comp.setState({ activeInputField: 'focusIframe', inputArr: ['A', 'B', 'C'] });
|
||||
|
||||
@ -85,7 +102,7 @@ describe('ShortcutsPage', () => {
|
||||
});
|
||||
|
||||
it('calls submitKeyHandler if Tab key is pressed', () => {
|
||||
const comp = shallow(<ShortcutsPage />);
|
||||
const comp = shallow(<ShortcutsScreen shortcutKeys={shortcutKeys} {...actions} />);
|
||||
comp.setState({ activeInputField: 'focusIframe', inputArr: ['A', 'B', 'C'] });
|
||||
const instance = comp.instance();
|
||||
const spy = jest.spyOn(instance, 'submitKeyHandler');
|
||||
@ -94,7 +111,7 @@ describe('ShortcutsPage', () => {
|
||||
});
|
||||
|
||||
it('calls submitKeyHandler if Enter key is pressed', () => {
|
||||
const comp = shallow(<ShortcutsPage />);
|
||||
const comp = shallow(<ShortcutsScreen shortcutKeys={shortcutKeys} {...actions} />);
|
||||
comp.setState({ activeInputField: 'focusIframe', inputArr: ['A', 'B', 'C'] });
|
||||
const instance = comp.instance();
|
||||
const spy = jest.spyOn(instance, 'submitKeyHandler');
|
||||
@ -103,7 +120,7 @@ describe('ShortcutsPage', () => {
|
||||
});
|
||||
|
||||
it('sets state for regular key down events', () => {
|
||||
const comp = shallow(<ShortcutsPage />);
|
||||
const comp = shallow(<ShortcutsScreen shortcutKeys={shortcutKeys} {...actions} />);
|
||||
comp.setState({ activeInputField: 'search', inputArr: [] });
|
||||
const instance = comp.instance();
|
||||
instance.onKeyDown({ key: 'M' });
|
||||
@ -117,7 +134,7 @@ describe('ShortcutsPage', () => {
|
||||
|
||||
describe('onFocus', () => {
|
||||
it('calls setstate and clears the input on focus', () => {
|
||||
const comp = shallow(<ShortcutsPage />);
|
||||
const comp = shallow(<ShortcutsScreen shortcutKeys={shortcutKeys} {...actions} />);
|
||||
const instance = comp.instance();
|
||||
|
||||
instance.onFocus('toolbar')();
|
||||
@ -129,7 +146,7 @@ describe('ShortcutsPage', () => {
|
||||
|
||||
describe('onBlur', () => {
|
||||
it('if the input is empty, restores the respective default', () => {
|
||||
const comp = shallow(<ShortcutsPage />);
|
||||
const comp = shallow(<ShortcutsScreen shortcutKeys={shortcutKeys} {...actions} />);
|
||||
const instance = comp.instance();
|
||||
comp.setState({
|
||||
activeInputField: 'focusNav',
|
||||
@ -144,7 +161,7 @@ describe('ShortcutsPage', () => {
|
||||
});
|
||||
|
||||
it('if a duplicate is found, it sets error to true', () => {
|
||||
const comp = shallow(<ShortcutsPage />);
|
||||
const comp = shallow(<ShortcutsScreen shortcutKeys={shortcutKeys} {...actions} />);
|
||||
const instance = comp.instance();
|
||||
comp.setState({
|
||||
activeInputField: 'fullScreen',
|
||||
@ -156,7 +173,7 @@ describe('ShortcutsPage', () => {
|
||||
});
|
||||
|
||||
it('it saves the shortcut if it is valid', () => {
|
||||
const comp = shallow(<ShortcutsPage />);
|
||||
const comp = shallow(<ShortcutsScreen shortcutKeys={shortcutKeys} {...actions} />);
|
||||
const instance = comp.instance();
|
||||
comp.setState({ activeInputField: 'focusNav', inputArr: ['V', 'P'] });
|
||||
|
||||
@ -169,7 +186,7 @@ describe('ShortcutsPage', () => {
|
||||
|
||||
describe('saveShortcut', () => {
|
||||
it('if the input is empty, restores the respective default', () => {
|
||||
const comp = shallow(<ShortcutsPage />);
|
||||
const comp = shallow(<ShortcutsScreen shortcutKeys={shortcutKeys} {...actions} />);
|
||||
const instance = comp.instance();
|
||||
comp.setState({
|
||||
activeInputField: 'focusNav',
|
||||
@ -184,7 +201,7 @@ describe('ShortcutsPage', () => {
|
||||
|
||||
describe('restoreDefaults', () => {
|
||||
it('if the input is empty, restores the respective default', () => {
|
||||
const comp = shallow(<ShortcutsPage />);
|
||||
const comp = shallow(<ShortcutsScreen shortcutKeys={shortcutKeys} {...actions} />);
|
||||
|
||||
comp.setState({
|
||||
activeInputField: 'focusNav',
|
||||
|
20
lib/ui/src/settings/shortcuts_page.js
Normal file
20
lib/ui/src/settings/shortcuts_page.js
Normal file
@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Route } from '@storybook/router';
|
||||
|
||||
import { Consumer } from '../core/context';
|
||||
import ShortcutsScreen from './shortcuts';
|
||||
|
||||
export default () => (
|
||||
<Route path="shortcuts">
|
||||
<Consumer>
|
||||
{({
|
||||
api: { getShortcutKeys, setShortcut, restoreDefaultShortcut, restoreAllDefaultShortcuts },
|
||||
}) => (
|
||||
<ShortcutsScreen
|
||||
shortcutKeys={getShortcutKeys()}
|
||||
{...{ setShortcut, restoreDefaultShortcut, restoreAllDefaultShortcuts }}
|
||||
/>
|
||||
)}
|
||||
</Consumer>
|
||||
</Route>
|
||||
);
|
Loading…
x
Reference in New Issue
Block a user