Make settings as a clean architecture

jh-changes
Shin'ya Ueoka 6 years ago
parent e76ca380f7
commit bacf83a320
  1. 11
      src/content/actions/index.ts
  2. 7
      src/content/actions/operation.ts
  3. 28
      src/content/actions/setting.ts
  4. 17
      src/content/client/SettingClient.ts
  5. 30
      src/content/components/common/index.ts
  6. 39
      src/content/components/common/keymapper.ts
  7. 8
      src/content/components/common/mark.ts
  8. 8
      src/content/components/top-content/follow-controller.ts
  9. 4
      src/content/reducers/index.ts
  10. 40
      src/content/reducers/setting.ts
  11. 22
      src/content/repositories/SettingRepository.ts
  12. 24
      src/content/usecases/SettingUseCase.ts
  13. 43
      test/content/actions/setting.test.ts
  14. 31
      test/content/reducers/setting.test.ts
  15. 30
      test/content/repositories/SettingRepository.test.ts
  16. 71
      test/content/usecases/SettingUseCaase.test.ts

@ -1,13 +1,9 @@
import Redux from 'redux'; import Redux from 'redux';
import Settings from '../../shared/Settings';
import * as keyUtils from '../../shared/utils/keys'; import * as keyUtils from '../../shared/utils/keys';
// Find // Find
export const FIND_SET_KEYWORD = 'find.set.keyword'; export const FIND_SET_KEYWORD = 'find.set.keyword';
// Settings
export const SETTING_SET = 'setting.set';
// User input // User input
export const INPUT_KEY_PRESS = 'input.key.press'; export const INPUT_KEY_PRESS = 'input.key.press';
export const INPUT_CLEAR_KEYS = 'input.clear.keys'; export const INPUT_CLEAR_KEYS = 'input.clear.keys';
@ -37,11 +33,6 @@ export interface FindSetKeywordAction extends Redux.Action {
found: boolean; found: boolean;
} }
export interface SettingSetAction extends Redux.Action {
type: typeof SETTING_SET;
settings: Settings,
}
export interface InputKeyPressAction extends Redux.Action { export interface InputKeyPressAction extends Redux.Action {
type: typeof INPUT_KEY_PRESS; type: typeof INPUT_KEY_PRESS;
key: keyUtils.Key; key: keyUtils.Key;
@ -94,7 +85,6 @@ export interface NoopAction extends Redux.Action {
} }
export type FindAction = FindSetKeywordAction | NoopAction; export type FindAction = FindSetKeywordAction | NoopAction;
export type SettingAction = SettingSetAction;
export type InputAction = InputKeyPressAction | InputClearKeysAction; export type InputAction = InputKeyPressAction | InputClearKeysAction;
export type FollowAction = export type FollowAction =
FollowControllerEnableAction | FollowControllerDisableAction | FollowControllerEnableAction | FollowControllerDisableAction |
@ -105,7 +95,6 @@ export type MarkAction =
export type Action = export type Action =
FindAction | FindAction |
SettingAction |
InputAction | InputAction |
FollowAction | FollowAction |
MarkAction | MarkAction |

@ -9,14 +9,16 @@ import * as consoleFrames from '../console-frames';
import * as markActions from './mark'; import * as markActions from './mark';
import AddonEnabledUseCase from '../usecases/AddonEnabledUseCase'; import AddonEnabledUseCase from '../usecases/AddonEnabledUseCase';
import { SettingRepositoryImpl } from '../repositories/SettingRepository';
let addonEnabledUseCase = new AddonEnabledUseCase(); let addonEnabledUseCase = new AddonEnabledUseCase();
let settingRepository = new SettingRepositoryImpl();
// eslint-disable-next-line complexity, max-lines-per-function // eslint-disable-next-line complexity, max-lines-per-function
const exec = async( const exec = async(
operation: operations.Operation, operation: operations.Operation,
settings: any,
): Promise<actions.Action> => { ): Promise<actions.Action> => {
let settings = settingRepository.get();
let smoothscroll = settings.properties.smoothscroll; let smoothscroll = settings.properties.smoothscroll;
switch (operation.type) { switch (operation.type) {
case operations.ADDON_ENABLE: case operations.ADDON_ENABLE:
@ -97,7 +99,8 @@ const exec = async(
break; break;
case operations.URLS_PASTE: case operations.URLS_PASTE:
urls.paste( urls.paste(
window, operation.newTab ? operation.newTab : false, settings.search window, operation.newTab ? operation.newTab : false,
settings.search,
); );
break; break;
default: default:

@ -1,28 +0,0 @@
import * as actions from './index';
import * as operations from '../../shared/operations';
import * as messages from '../../shared/messages';
import Settings, { Keymaps } from '../../shared/Settings';
const reservedKeymaps: Keymaps = {
'<Esc>': { type: operations.CANCEL },
'<C-[>': { type: operations.CANCEL },
};
const set = (settings: Settings): actions.SettingAction => {
return {
type: actions.SETTING_SET,
settings: {
...settings,
keymaps: { ...settings.keymaps, ...reservedKeymaps },
}
};
};
const load = async(): Promise<actions.SettingAction> => {
let settings = await browser.runtime.sendMessage({
type: messages.SETTINGS_QUERY,
});
return set(settings);
};
export { set, load };

@ -0,0 +1,17 @@
import Settings from '../../shared/Settings';
import * as messages from '../../shared/messages';
export default interface SettingClient {
load(): Promise<Settings>;
// eslint-disable-next-line semi
}
export class SettingClientImpl {
async load(): Promise<Settings> {
let settings = await browser.runtime.sendMessage({
type: messages.SETTINGS_QUERY,
});
return settings as Settings;
}
}

@ -2,22 +2,18 @@ import InputComponent from './input';
import FollowComponent from './follow'; import FollowComponent from './follow';
import MarkComponent from './mark'; import MarkComponent from './mark';
import KeymapperComponent from './keymapper'; import KeymapperComponent from './keymapper';
import * as settingActions from '../../actions/setting';
import * as messages from '../../../shared/messages'; import * as messages from '../../../shared/messages';
import MessageListener from '../../MessageListener'; import MessageListener from '../../MessageListener';
import * as blacklists from '../../../shared/blacklists'; import * as blacklists from '../../../shared/blacklists';
import * as keys from '../../../shared/utils/keys'; import * as keys from '../../../shared/utils/keys';
import * as actions from '../../actions';
import AddonEnabledUseCase from '../../usecases/AddonEnabledUseCase'; import AddonEnabledUseCase from '../../usecases/AddonEnabledUseCase';
import SettingUseCase from '../../usecases/SettingUseCase';
let addonEnabledUseCase = new AddonEnabledUseCase(); let addonEnabledUseCase = new AddonEnabledUseCase();
let settingUseCase = new SettingUseCase();
export default class Common { export default class Common {
private win: Window;
private store: any;
constructor(win: Window, store: any) { constructor(win: Window, store: any) {
const input = new InputComponent(win.document.body); const input = new InputComponent(win.document.body);
const follow = new FollowComponent(win); const follow = new FollowComponent(win);
@ -28,9 +24,6 @@ export default class Common {
input.onKey((key: keys.Key) => mark.key(key)); input.onKey((key: keys.Key) => mark.key(key));
input.onKey((key: keys.Key) => keymapper.key(key)); input.onKey((key: keys.Key) => keymapper.key(key));
this.win = win;
this.store = store;
this.reloadSettings(); this.reloadSettings();
new MessageListener().onBackgroundMessage(this.onMessage.bind(this)); new MessageListener().onBackgroundMessage(this.onMessage.bind(this));
@ -41,23 +34,22 @@ export default class Common {
case messages.SETTINGS_CHANGED: case messages.SETTINGS_CHANGED:
return this.reloadSettings(); return this.reloadSettings();
case messages.ADDON_TOGGLE_ENABLED: case messages.ADDON_TOGGLE_ENABLED:
addonEnabledUseCase.toggle(); return addonEnabledUseCase.toggle();
} }
return undefined;
} }
reloadSettings() { async reloadSettings() {
try { try {
this.store.dispatch(settingActions.load()) let current = await settingUseCase.reload();
.then((action: actions.SettingAction) => { let disabled = blacklists.includes(
let enabled = !blacklists.includes( current.blacklist, window.location.href,
action.settings.blacklist, this.win.location.href
); );
if (enabled) { if (disabled) {
addonEnabledUseCase.enable();
} else {
addonEnabledUseCase.disable(); addonEnabledUseCase.disable();
} else {
addonEnabledUseCase.enable();
} }
});
} catch (e) { } catch (e) {
// Sometime sendMessage fails when background script is not ready. // Sometime sendMessage fails when background script is not ready.
console.warn(e); console.warn(e);

@ -4,8 +4,18 @@ import * as operations from '../../../shared/operations';
import * as keyUtils from '../../../shared/utils/keys'; import * as keyUtils from '../../../shared/utils/keys';
import AddonEnabledUseCase from '../../usecases/AddonEnabledUseCase'; import AddonEnabledUseCase from '../../usecases/AddonEnabledUseCase';
import { SettingRepositoryImpl } from '../../repositories/SettingRepository';
import { Keymaps } from '../../../shared/Settings';
type KeymapEntityMap = Map<keyUtils.Key[], operations.Operation>;
let addonEnabledUseCase = new AddonEnabledUseCase(); let addonEnabledUseCase = new AddonEnabledUseCase();
let settingRepository = new SettingRepositoryImpl();
const reservedKeymaps: Keymaps = {
'<Esc>': { type: operations.CANCEL },
'<C-[>': { type: operations.CANCEL },
};
const mapStartsWith = ( const mapStartsWith = (
mapping: keyUtils.Key[], mapping: keyUtils.Key[],
@ -29,18 +39,11 @@ export default class KeymapperComponent {
this.store = store; this.store = store;
} }
// eslint-disable-next-line max-statements
key(key: keyUtils.Key): boolean { key(key: keyUtils.Key): boolean {
this.store.dispatch(inputActions.keyPress(key)); this.store.dispatch(inputActions.keyPress(key));
let state = this.store.getState(); let input = this.store.getState().input;
let input = state.input; let keymaps = this.keymapEntityMap();
let keymaps = new Map<keyUtils.Key[], operations.Operation>(
state.setting.keymaps.map(
(e: {key: keyUtils.Key[], op: operations.Operation}) => [e.key, e.op],
)
);
let matched = Array.from(keymaps.keys()).filter( let matched = Array.from(keymaps.keys()).filter(
(mapping: keyUtils.Key[]) => { (mapping: keyUtils.Key[]) => {
return mapStartsWith(mapping, input.keys); return mapStartsWith(mapping, input.keys);
@ -62,11 +65,23 @@ export default class KeymapperComponent {
return true; return true;
} }
let operation = keymaps.get(matched[0]) as operations.Operation; let operation = keymaps.get(matched[0]) as operations.Operation;
let act = operationActions.exec( let act = operationActions.exec(operation);
operation, state.setting,
);
this.store.dispatch(act); this.store.dispatch(act);
this.store.dispatch(inputActions.clearKeys()); this.store.dispatch(inputActions.clearKeys());
return true; return true;
} }
private keymapEntityMap(): KeymapEntityMap {
let keymaps = {
...settingRepository.get().keymaps,
...reservedKeymaps,
};
let entries = Object.entries(keymaps).map((entry) => {
return [
keyUtils.fromMapKeys(entry[0]),
entry[1],
];
}) as [keyUtils.Key[], operations.Operation][];
return new Map<keyUtils.Key[], operations.Operation>(entries);
}
} }

@ -4,6 +4,10 @@ import * as consoleFrames from '../..//console-frames';
import * as keyUtils from '../../../shared/utils/keys'; import * as keyUtils from '../../../shared/utils/keys';
import Mark from '../../Mark'; import Mark from '../../Mark';
import { SettingRepositoryImpl } from '../../repositories/SettingRepository';
let settingRepository = new SettingRepositoryImpl();
const cancelKey = (key: keyUtils.Key): boolean => { const cancelKey = (key: keyUtils.Key): boolean => {
return key.key === 'Esc' || key.key === '[' && Boolean(key.ctrlKey); return key.key === 'Esc' || key.key === '[' && Boolean(key.ctrlKey);
}; };
@ -21,8 +25,8 @@ export default class MarkComponent {
// eslint-disable-next-line max-statements // eslint-disable-next-line max-statements
key(key: keyUtils.Key) { key(key: keyUtils.Key) {
let { mark: markState, setting } = this.store.getState(); let smoothscroll = settingRepository.get().properties.smoothscroll;
let smoothscroll = setting.properties.smoothscroll; let { mark: markState } = this.store.getState();
if (!markState.setMode && !markState.jumpMode) { if (!markState.setMode && !markState.jumpMode) {
return false; return false;

@ -3,6 +3,10 @@ import * as messages from '../../../shared/messages';
import MessageListener, { WebMessageSender } from '../../MessageListener'; import MessageListener, { WebMessageSender } from '../../MessageListener';
import HintKeyProducer from '../../hint-key-producer'; import HintKeyProducer from '../../hint-key-producer';
import { SettingRepositoryImpl } from '../../repositories/SettingRepository';
let settingRepository = new SettingRepositoryImpl();
const broadcastMessage = (win: Window, message: messages.Message): void => { const broadcastMessage = (win: Window, message: messages.Message): void => {
let json = JSON.stringify(message); let json = JSON.stringify(message);
let frames = [win.self].concat(Array.from(win.frames as any)); let frames = [win.self].concat(Array.from(win.frames as any));
@ -160,7 +164,7 @@ export default class FollowController {
}); });
} }
hintchars() { private hintchars() {
return this.store.getState().setting.properties.hintchars; return settingRepository.get().properties.hintchars;
} }
} }

@ -1,6 +1,5 @@
import { combineReducers } from 'redux'; import { combineReducers } from 'redux';
import find, { State as FindState } from './find'; import find, { State as FindState } from './find';
import setting, { State as SettingState } from './setting';
import input, { State as InputState } from './input'; import input, { State as InputState } from './input';
import followController, { State as FollowControllerState } import followController, { State as FollowControllerState }
from './follow-controller'; from './follow-controller';
@ -8,12 +7,11 @@ import mark, { State as MarkState } from './mark';
export interface State { export interface State {
find: FindState; find: FindState;
setting: SettingState;
input: InputState; input: InputState;
followController: FollowControllerState; followController: FollowControllerState;
mark: MarkState; mark: MarkState;
} }
export default combineReducers({ export default combineReducers({
find, setting, input, followController, mark, find, input, followController, mark,
}); });

@ -1,40 +0,0 @@
import * as actions from '../actions';
import * as keyUtils from '../../shared/utils/keys';
import * as operations from '../../shared/operations';
import { Search, Properties, DefaultSetting } from '../../shared/Settings';
export interface State {
keymaps: { key: keyUtils.Key[], op: operations.Operation }[];
search: Search;
properties: Properties;
}
// defaultState does not refer due to the state is load from
// background on load.
const defaultState: State = {
keymaps: [],
search: DefaultSetting.search,
properties: DefaultSetting.properties,
};
export default function reducer(
state: State = defaultState,
action: actions.SettingAction,
): State {
switch (action.type) {
case actions.SETTING_SET:
return {
keymaps: Object.entries(action.settings.keymaps).map((entry) => {
return {
key: keyUtils.fromMapKeys(entry[0]),
op: entry[1],
};
}),
properties: action.settings.properties,
search: action.settings.search,
};
default:
return state;
}
}

@ -0,0 +1,22 @@
import Settings, { DefaultSetting } from '../../shared/Settings';
let current: Settings = DefaultSetting;
export default interface SettingRepository {
set(setting: Settings): void;
get(): Settings;
// eslint-disable-next-line semi
}
export class SettingRepositoryImpl implements SettingRepository {
set(setting: Settings): void {
current = setting;
}
get(): Settings {
return current;
}
}

@ -0,0 +1,24 @@
import SettingRepository, { SettingRepositoryImpl }
from '../repositories/SettingRepository';
import SettingClient, { SettingClientImpl } from '../client/SettingClient';
import Settings from '../../shared/Settings';
export default class SettingUseCase {
private repository: SettingRepository;
private client: SettingClient;
constructor({
repository = new SettingRepositoryImpl(),
client = new SettingClientImpl(),
} = {}) {
this.repository = repository;
this.client = client;
}
async reload(): Promise<Settings> {
let settings = await this.client.load();
this.repository.set(settings);
return settings;
}
}

@ -1,43 +0,0 @@
import * as actions from 'content/actions';
import * as settingActions from 'content/actions/setting';
describe("setting actions", () => {
describe("set", () => {
it('create SETTING_SET action', () => {
let action = settingActions.set({
keymaps: {
'dd': 'remove current tab',
'z<C-A>': 'increment',
},
search: {
default: "google",
engines: {
google: 'https://google.com/search?q={}',
}
},
properties: {
hintchars: 'abcd1234',
},
blacklist: [],
});
expect(action.type).to.equal(actions.SETTING_SET);
expect(action.settings.properties.hintchars).to.equal('abcd1234');
});
it('overrides cancel keys', () => {
let action = settingActions.set({
keymaps: {
"k": { "type": "scroll.vertically", "count": -1 },
"j": { "type": "scroll.vertically", "count": 1 },
}
});
let keymaps = action.settings.keymaps;
expect(action.settings.keymaps).to.deep.equals({
"k": { type: "scroll.vertically", count: -1 },
"j": { type: "scroll.vertically", count: 1 },
'<Esc>': { type: 'cancel' },
'<C-[>': { type: 'cancel' },
});
});
});
});

@ -1,31 +0,0 @@
import * as actions from 'content/actions';
import settingReducer from 'content/reducers/setting';
describe("content setting reducer", () => {
it('return the initial state', () => {
let state = settingReducer(undefined, {});
expect(state.keymaps).to.be.empty;
});
it('return next state for SETTING_SET', () => {
let newSettings = { red: 'apple', yellow: 'banana' };
let action = {
type: actions.SETTING_SET,
settings: {
keymaps: {
"zz": { type: "zoom.neutral" },
"<S-Esc>": { "type": "addon.toggle.enabled" }
},
"blacklist": []
}
}
let state = settingReducer(undefined, action);
expect(state.keymaps).to.have.deep.all.members([
{ key: [{ key: 'z', shiftKey: false, ctrlKey: false, altKey: false, metaKey: false },
{ key: 'z', shiftKey: false, ctrlKey: false, altKey: false, metaKey: false }],
op: { type: 'zoom.neutral' }},
{ key: [{ key: 'Esc', shiftKey: true, ctrlKey: false, altKey: false, metaKey: false }],
op: { type: 'addon.toggle.enabled' }},
]);
});
});

@ -0,0 +1,30 @@
import { SettingRepositoryImpl } from '../../../src/content/repositories/SettingRepository';
import { expect } from 'chai';
describe('SettingRepositoryImpl', () => {
it('updates and gets current value', () => {
let sut = new SettingRepositoryImpl();
let settings = {
keymaps: {},
search: {
default: 'google',
engines: {
google: 'https://google.com/?q={}',
}
},
properties: {
hintchars: 'abcd1234',
smoothscroll: false,
complete: 'sbh',
},
blacklist: [],
}
sut.set(settings);
let actual = sut.get();
expect(actual.properties.hintchars).to.equal('abcd1234');
});
});

@ -0,0 +1,71 @@
import SettingRepository from '../../../src/content/repositories/SettingRepository';
import SettingClient from '../../../src/content/client/SettingClient';
import SettingUseCase from '../../../src/content/usecases/SettingUseCase';
import Settings, { DefaultSetting } from '../../../src/shared/Settings';
import { expect } from 'chai';
class MockSettingRepository implements SettingRepository {
private current: Settings;
constructor() {
this.current = DefaultSetting;
}
set(settings: Settings): void {
this.current= settings;
}
get(): Settings {
return this.current;
}
}
class MockSettingClient implements SettingClient {
private data: Settings;
constructor(data: Settings) {
this.data = data;
}
load(): Promise<Settings> {
return Promise.resolve(this.data);
}
}
describe('AddonEnabledUseCase', () => {
let repository: MockSettingRepository;
let client: MockSettingClient;
let sut: SettingUseCase;
beforeEach(() => {
let testSettings = {
keymaps: {},
search: {
default: 'google',
engines: {
google: 'https://google.com/?q={}',
}
},
properties: {
hintchars: 'abcd1234',
smoothscroll: false,
complete: 'sbh',
},
blacklist: [],
};
repository = new MockSettingRepository();
client = new MockSettingClient(testSettings);
sut = new SettingUseCase({ repository, client });
});
describe('#reload', () => {
it('loads settings and store to repository', async() => {
let settings = await sut.reload();
expect(settings.properties.hintchars).to.equal('abcd1234');
let saved = repository.get();
expect(saved.properties.hintchars).to.equal('abcd1234');
});
});
});