Make settings as a clean architecture
This commit is contained in:
parent
e76ca380f7
commit
bacf83a320
16 changed files with 223 additions and 196 deletions
|
@ -1,13 +1,9 @@
|
|||
import Redux from 'redux';
|
||||
import Settings from '../../shared/Settings';
|
||||
import * as keyUtils from '../../shared/utils/keys';
|
||||
|
||||
// Find
|
||||
export const FIND_SET_KEYWORD = 'find.set.keyword';
|
||||
|
||||
// Settings
|
||||
export const SETTING_SET = 'setting.set';
|
||||
|
||||
// User input
|
||||
export const INPUT_KEY_PRESS = 'input.key.press';
|
||||
export const INPUT_CLEAR_KEYS = 'input.clear.keys';
|
||||
|
@ -37,11 +33,6 @@ export interface FindSetKeywordAction extends Redux.Action {
|
|||
found: boolean;
|
||||
}
|
||||
|
||||
export interface SettingSetAction extends Redux.Action {
|
||||
type: typeof SETTING_SET;
|
||||
settings: Settings,
|
||||
}
|
||||
|
||||
export interface InputKeyPressAction extends Redux.Action {
|
||||
type: typeof INPUT_KEY_PRESS;
|
||||
key: keyUtils.Key;
|
||||
|
@ -94,7 +85,6 @@ export interface NoopAction extends Redux.Action {
|
|||
}
|
||||
|
||||
export type FindAction = FindSetKeywordAction | NoopAction;
|
||||
export type SettingAction = SettingSetAction;
|
||||
export type InputAction = InputKeyPressAction | InputClearKeysAction;
|
||||
export type FollowAction =
|
||||
FollowControllerEnableAction | FollowControllerDisableAction |
|
||||
|
@ -105,7 +95,6 @@ export type MarkAction =
|
|||
|
||||
export type Action =
|
||||
FindAction |
|
||||
SettingAction |
|
||||
InputAction |
|
||||
FollowAction |
|
||||
MarkAction |
|
||||
|
|
|
@ -9,14 +9,16 @@ import * as consoleFrames from '../console-frames';
|
|||
import * as markActions from './mark';
|
||||
|
||||
import AddonEnabledUseCase from '../usecases/AddonEnabledUseCase';
|
||||
import { SettingRepositoryImpl } from '../repositories/SettingRepository';
|
||||
|
||||
let addonEnabledUseCase = new AddonEnabledUseCase();
|
||||
let settingRepository = new SettingRepositoryImpl();
|
||||
|
||||
// eslint-disable-next-line complexity, max-lines-per-function
|
||||
const exec = async(
|
||||
operation: operations.Operation,
|
||||
settings: any,
|
||||
): Promise<actions.Action> => {
|
||||
let settings = settingRepository.get();
|
||||
let smoothscroll = settings.properties.smoothscroll;
|
||||
switch (operation.type) {
|
||||
case operations.ADDON_ENABLE:
|
||||
|
@ -97,7 +99,8 @@ const exec = async(
|
|||
break;
|
||||
case operations.URLS_PASTE:
|
||||
urls.paste(
|
||||
window, operation.newTab ? operation.newTab : false, settings.search
|
||||
window, operation.newTab ? operation.newTab : false,
|
||||
settings.search,
|
||||
);
|
||||
break;
|
||||
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 };
|
17
src/content/client/SettingClient.ts
Normal file
17
src/content/client/SettingClient.ts
Normal file
|
@ -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 MarkComponent from './mark';
|
||||
import KeymapperComponent from './keymapper';
|
||||
import * as settingActions from '../../actions/setting';
|
||||
import * as messages from '../../../shared/messages';
|
||||
import MessageListener from '../../MessageListener';
|
||||
import * as blacklists from '../../../shared/blacklists';
|
||||
import * as keys from '../../../shared/utils/keys';
|
||||
import * as actions from '../../actions';
|
||||
|
||||
import AddonEnabledUseCase from '../../usecases/AddonEnabledUseCase';
|
||||
import SettingUseCase from '../../usecases/SettingUseCase';
|
||||
|
||||
let addonEnabledUseCase = new AddonEnabledUseCase();
|
||||
let settingUseCase = new SettingUseCase();
|
||||
|
||||
export default class Common {
|
||||
private win: Window;
|
||||
|
||||
private store: any;
|
||||
|
||||
constructor(win: Window, store: any) {
|
||||
const input = new InputComponent(win.document.body);
|
||||
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) => keymapper.key(key));
|
||||
|
||||
this.win = win;
|
||||
this.store = store;
|
||||
|
||||
this.reloadSettings();
|
||||
|
||||
new MessageListener().onBackgroundMessage(this.onMessage.bind(this));
|
||||
|
@ -41,23 +34,22 @@ export default class Common {
|
|||
case messages.SETTINGS_CHANGED:
|
||||
return this.reloadSettings();
|
||||
case messages.ADDON_TOGGLE_ENABLED:
|
||||
addonEnabledUseCase.toggle();
|
||||
return addonEnabledUseCase.toggle();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
reloadSettings() {
|
||||
async reloadSettings() {
|
||||
try {
|
||||
this.store.dispatch(settingActions.load())
|
||||
.then((action: actions.SettingAction) => {
|
||||
let enabled = !blacklists.includes(
|
||||
action.settings.blacklist, this.win.location.href
|
||||
);
|
||||
if (enabled) {
|
||||
addonEnabledUseCase.enable();
|
||||
} else {
|
||||
addonEnabledUseCase.disable();
|
||||
}
|
||||
});
|
||||
let current = await settingUseCase.reload();
|
||||
let disabled = blacklists.includes(
|
||||
current.blacklist, window.location.href,
|
||||
);
|
||||
if (disabled) {
|
||||
addonEnabledUseCase.disable();
|
||||
} else {
|
||||
addonEnabledUseCase.enable();
|
||||
}
|
||||
} catch (e) {
|
||||
// Sometime sendMessage fails when background script is not ready.
|
||||
console.warn(e);
|
||||
|
|
|
@ -4,8 +4,18 @@ import * as operations from '../../../shared/operations';
|
|||
import * as keyUtils from '../../../shared/utils/keys';
|
||||
|
||||
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 settingRepository = new SettingRepositoryImpl();
|
||||
|
||||
const reservedKeymaps: Keymaps = {
|
||||
'<Esc>': { type: operations.CANCEL },
|
||||
'<C-[>': { type: operations.CANCEL },
|
||||
};
|
||||
|
||||
const mapStartsWith = (
|
||||
mapping: keyUtils.Key[],
|
||||
|
@ -29,18 +39,11 @@ export default class KeymapperComponent {
|
|||
this.store = store;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-statements
|
||||
key(key: keyUtils.Key): boolean {
|
||||
this.store.dispatch(inputActions.keyPress(key));
|
||||
|
||||
let state = this.store.getState();
|
||||
let input = state.input;
|
||||
let keymaps = new Map<keyUtils.Key[], operations.Operation>(
|
||||
state.setting.keymaps.map(
|
||||
(e: {key: keyUtils.Key[], op: operations.Operation}) => [e.key, e.op],
|
||||
)
|
||||
);
|
||||
|
||||
let input = this.store.getState().input;
|
||||
let keymaps = this.keymapEntityMap();
|
||||
let matched = Array.from(keymaps.keys()).filter(
|
||||
(mapping: keyUtils.Key[]) => {
|
||||
return mapStartsWith(mapping, input.keys);
|
||||
|
@ -62,11 +65,23 @@ export default class KeymapperComponent {
|
|||
return true;
|
||||
}
|
||||
let operation = keymaps.get(matched[0]) as operations.Operation;
|
||||
let act = operationActions.exec(
|
||||
operation, state.setting,
|
||||
);
|
||||
let act = operationActions.exec(operation);
|
||||
this.store.dispatch(act);
|
||||
this.store.dispatch(inputActions.clearKeys());
|
||||
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 Mark from '../../Mark';
|
||||
|
||||
import { SettingRepositoryImpl } from '../../repositories/SettingRepository';
|
||||
|
||||
let settingRepository = new SettingRepositoryImpl();
|
||||
|
||||
const cancelKey = (key: keyUtils.Key): boolean => {
|
||||
return key.key === 'Esc' || key.key === '[' && Boolean(key.ctrlKey);
|
||||
};
|
||||
|
@ -21,8 +25,8 @@ export default class MarkComponent {
|
|||
|
||||
// eslint-disable-next-line max-statements
|
||||
key(key: keyUtils.Key) {
|
||||
let { mark: markState, setting } = this.store.getState();
|
||||
let smoothscroll = setting.properties.smoothscroll;
|
||||
let smoothscroll = settingRepository.get().properties.smoothscroll;
|
||||
let { mark: markState } = this.store.getState();
|
||||
|
||||
if (!markState.setMode && !markState.jumpMode) {
|
||||
return false;
|
||||
|
|
|
@ -3,6 +3,10 @@ import * as messages from '../../../shared/messages';
|
|||
import MessageListener, { WebMessageSender } from '../../MessageListener';
|
||||
import HintKeyProducer from '../../hint-key-producer';
|
||||
|
||||
import { SettingRepositoryImpl } from '../../repositories/SettingRepository';
|
||||
|
||||
let settingRepository = new SettingRepositoryImpl();
|
||||
|
||||
const broadcastMessage = (win: Window, message: messages.Message): void => {
|
||||
let json = JSON.stringify(message);
|
||||
let frames = [win.self].concat(Array.from(win.frames as any));
|
||||
|
@ -160,7 +164,7 @@ export default class FollowController {
|
|||
});
|
||||
}
|
||||
|
||||
hintchars() {
|
||||
return this.store.getState().setting.properties.hintchars;
|
||||
private hintchars() {
|
||||
return settingRepository.get().properties.hintchars;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { combineReducers } from 'redux';
|
||||
import find, { State as FindState } from './find';
|
||||
import setting, { State as SettingState } from './setting';
|
||||
import input, { State as InputState } from './input';
|
||||
import followController, { State as FollowControllerState }
|
||||
from './follow-controller';
|
||||
|
@ -8,12 +7,11 @@ import mark, { State as MarkState } from './mark';
|
|||
|
||||
export interface State {
|
||||
find: FindState;
|
||||
setting: SettingState;
|
||||
input: InputState;
|
||||
followController: FollowControllerState;
|
||||
mark: MarkState;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
22
src/content/repositories/SettingRepository.ts
Normal file
22
src/content/repositories/SettingRepository.ts
Normal file
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
24
src/content/usecases/SettingUseCase.ts
Normal file
24
src/content/usecases/SettingUseCase.ts
Normal file
|
@ -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' }},
|
||||
]);
|
||||
});
|
||||
});
|
30
test/content/repositories/SettingRepository.test.ts
Normal file
30
test/content/repositories/SettingRepository.test.ts
Normal file
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
71
test/content/usecases/SettingUseCaase.test.ts
Normal file
71
test/content/usecases/SettingUseCaase.test.ts
Normal file
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
Reference in a new issue