aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.eslintrc4
-rw-r--r--karma.conf.js2
-rw-r--r--package-lock.json98
-rw-r--r--package.json9
-rw-r--r--src/settings/actions/setting.js19
-rw-r--r--src/settings/components/form/blacklist-form.jsx52
-rw-r--r--src/settings/components/form/blacklist-form.scss9
-rw-r--r--src/settings/components/form/keymaps-form.jsx99
-rw-r--r--src/settings/components/form/keymaps-form.scss9
-rw-r--r--src/settings/components/form/search-form.jsx78
-rw-r--r--src/settings/components/form/search-form.scss28
-rw-r--r--src/settings/components/index.jsx155
-rw-r--r--src/settings/components/site.scss19
-rw-r--r--src/settings/components/ui/add-button.jsx12
-rw-r--r--src/settings/components/ui/add-button.scss13
-rw-r--r--src/settings/components/ui/delete-button.jsx12
-rw-r--r--src/settings/components/ui/delete-button.scss13
-rw-r--r--src/settings/components/ui/input.jsx52
-rw-r--r--src/settings/components/ui/input.scss29
-rw-r--r--src/settings/index.jsx5
-rw-r--r--src/settings/reducers/setting.js2
-rw-r--r--src/shared/settings/default.js (renamed from src/shared/default-settings.js)67
-rw-r--r--src/shared/settings/values.js100
-rw-r--r--src/shared/store/provider.jsx13
-rw-r--r--test/settings/components/form/blacklist-form.test.jsx82
-rw-r--r--test/settings/components/form/keymaps-form.test.jsx53
-rw-r--r--test/settings/components/form/search-engine-form.test.jsx104
-rw-r--r--test/settings/components/ui/input.test.jsx83
-rw-r--r--test/shared/settings/values.test.js112
-rw-r--r--webpack.config.js2
30 files changed, 1189 insertions, 146 deletions
diff --git a/.eslintrc b/.eslintrc
index 949b5a5..0f230c7 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -36,6 +36,7 @@
"newline-after-var": "off",
"newline-before-return": "off",
"newline-per-chained-call": "off",
+ "no-alert": "off",
"no-bitwise": "off",
"no-console": ["error", { "allow": ["warn", "error"] }],
"no-empty-function": "off",
@@ -44,6 +45,8 @@
"no-plusplus": "off",
"no-ternary": "off",
"no-undefined": "off",
+ "no-undef-init": "off",
+ "no-unused-vars": ["error", { "varsIgnorePattern": "h" }],
"no-use-before-define": "off",
"no-warning-comments": "off",
"object-curly-newline": ["error", { "consistent": true }],
@@ -65,5 +68,6 @@
"react/jsx-indent": ["error", 2],
"react/prop-types": "off",
+ "react/react-in-jsx-scope": "off"
}
}
diff --git a/karma.conf.js b/karma.conf.js
index 859cee0..46a1774 100644
--- a/karma.conf.js
+++ b/karma.conf.js
@@ -7,11 +7,13 @@ module.exports = function (config) {
frameworks: ['mocha'],
files: [
'test/**/*.test.js',
+ 'test/**/*.test.jsx',
'test/**/*.html'
],
preprocessors: {
'test/**/*.test.js': [ 'webpack' ],
+ 'test/**/*.test.jsx': [ 'webpack' ],
'test/**/*.html': ['html2js']
},
diff --git a/package-lock.json b/package-lock.json
index ee8a41c..0a50bf0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -735,12 +735,6 @@
"babel-helper-is-void-0": "0.2.0"
}
},
- "babel-plugin-syntax-flow": {
- "version": "6.18.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-syntax-flow/-/babel-plugin-syntax-flow-6.18.0.tgz",
- "integrity": "sha1-TDqyCiryaqIM0lmVw5jE63AxDI0=",
- "dev": true
- },
"babel-plugin-syntax-jsx": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
@@ -981,16 +975,6 @@
"regexpu-core": "2.0.0"
}
},
- "babel-plugin-transform-flow-strip-types": {
- "version": "6.22.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-strip-types/-/babel-plugin-transform-flow-strip-types-6.22.0.tgz",
- "integrity": "sha1-hMtnKTXUNxT9wyvOhFaNh0Qc988=",
- "dev": true,
- "requires": {
- "babel-plugin-syntax-flow": "6.18.0",
- "babel-runtime": "6.25.0"
- }
- },
"babel-plugin-transform-inline-consecutive-adds": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-inline-consecutive-adds/-/babel-plugin-transform-inline-consecutive-adds-0.2.0.tgz",
@@ -1024,15 +1008,6 @@
"esutils": "2.0.2"
}
},
- "babel-plugin-transform-react-display-name": {
- "version": "6.25.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-display-name/-/babel-plugin-transform-react-display-name-6.25.0.tgz",
- "integrity": "sha1-Z+K/Hx6ck6sI25Z5LgU5K/LMKNE=",
- "dev": true,
- "requires": {
- "babel-runtime": "6.25.0"
- }
- },
"babel-plugin-transform-react-jsx": {
"version": "6.24.1",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx/-/babel-plugin-transform-react-jsx-6.24.1.tgz",
@@ -1044,26 +1019,6 @@
"babel-runtime": "6.25.0"
}
},
- "babel-plugin-transform-react-jsx-self": {
- "version": "6.22.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx-self/-/babel-plugin-transform-react-jsx-self-6.22.0.tgz",
- "integrity": "sha1-322AqdomEqEh5t3XVYvL7PBuY24=",
- "dev": true,
- "requires": {
- "babel-plugin-syntax-jsx": "6.18.0",
- "babel-runtime": "6.25.0"
- }
- },
- "babel-plugin-transform-react-jsx-source": {
- "version": "6.22.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx-source/-/babel-plugin-transform-react-jsx-source-6.22.0.tgz",
- "integrity": "sha1-ZqwSFT9c0tF7PBkmj0vwGX9E7NY=",
- "dev": true,
- "requires": {
- "babel-plugin-syntax-jsx": "6.18.0",
- "babel-runtime": "6.25.0"
- }
- },
"babel-plugin-transform-regenerator": {
"version": "6.24.1",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.24.1.tgz",
@@ -1165,15 +1120,6 @@
"babel-plugin-transform-regenerator": "6.24.1"
}
},
- "babel-preset-flow": {
- "version": "6.23.0",
- "resolved": "https://registry.npmjs.org/babel-preset-flow/-/babel-preset-flow-6.23.0.tgz",
- "integrity": "sha1-5xIYiHCFrpoktb5Baa/7WZgWxJ0=",
- "dev": true,
- "requires": {
- "babel-plugin-transform-flow-strip-types": "6.22.0"
- }
- },
"babel-preset-minify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/babel-preset-minify/-/babel-preset-minify-0.2.0.tgz",
@@ -1205,18 +1151,14 @@
"lodash.isplainobject": "4.0.6"
}
},
- "babel-preset-react": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-preset-react/-/babel-preset-react-6.24.1.tgz",
- "integrity": "sha1-umnfrqRfw+xjm2pOzqbhdwLJE4A=",
+ "babel-preset-preact": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/babel-preset-preact/-/babel-preset-preact-1.1.0.tgz",
+ "integrity": "sha1-NaxlWnOkm4Q4FjzgU4Fld+GYCGE=",
"dev": true,
"requires": {
"babel-plugin-syntax-jsx": "6.18.0",
- "babel-plugin-transform-react-display-name": "6.25.0",
- "babel-plugin-transform-react-jsx": "6.24.1",
- "babel-plugin-transform-react-jsx-self": "6.22.0",
- "babel-plugin-transform-react-jsx-source": "6.22.0",
- "babel-preset-flow": "6.23.0"
+ "babel-plugin-transform-react-jsx": "6.24.1"
}
},
"babel-register": {
@@ -6273,6 +6215,12 @@
"uniqs": "2.0.0"
}
},
+ "preact": {
+ "version": "8.2.6",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-8.2.6.tgz",
+ "integrity": "sha1-ACi0Ju+Y/Mp0Gjxhf/W4E7mpR8c=",
+ "dev": true
+ },
"prelude-ls": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
@@ -6491,30 +6439,6 @@
}
}
},
- "react": {
- "version": "16.0.0",
- "resolved": "https://registry.npmjs.org/react/-/react-16.0.0.tgz",
- "integrity": "sha1-zn348ZQbA28Cssyp29DLHw6FXi0=",
- "dev": true,
- "requires": {
- "fbjs": "0.8.16",
- "loose-envify": "1.3.1",
- "object-assign": "4.1.1",
- "prop-types": "15.6.0"
- }
- },
- "react-dom": {
- "version": "16.0.0",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.0.0.tgz",
- "integrity": "sha1-nMMHnD3NcNTG4BuEqrKn40wwP1g=",
- "dev": true,
- "requires": {
- "fbjs": "0.8.16",
- "loose-envify": "1.3.1",
- "object-assign": "4.1.1",
- "prop-types": "15.6.0"
- }
- },
"read-pkg": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz",
diff --git a/package.json b/package.json
index ad25ffa..5606b79 100644
--- a/package.json
+++ b/package.json
@@ -3,10 +3,9 @@
"description": "Vim vixen",
"scripts": {
"start": "webpack -w --debug --devtool inline-source-map",
- "lint": "eslint --ext .jsx,.js src",
"build": "NODE_ENV=production webpack --progress --display-error-details",
"package": "npm run build && ./package.sh",
- "lint": "eslint src",
+ "lint": "eslint --ext .jsx,.js src",
"test": "karma start"
},
"repository": {
@@ -24,9 +23,8 @@
"babel-eslint": "^7.2.3",
"babel-loader": "^7.1.1",
"babel-minify-webpack-plugin": "^0.2.0",
- "babel-plugin-transform-react-jsx": "^6.24.1",
"babel-preset-es2015": "^6.24.1",
- "babel-preset-react": "^6.24.1",
+ "babel-preset-preact": "^1.1.0",
"chai": "^4.1.1",
"css-loader": "^0.28.4",
"eslint": "^4.7.0",
@@ -41,8 +39,7 @@
"karma-webpack": "^2.0.4",
"mocha": "^3.5.0",
"node-sass": "^4.5.3",
- "react": "^16.0.0",
- "react-dom": "^16.0.0",
+ "preact": "^8.2.6",
"sass-loader": "^6.0.6",
"style-loader": "^0.18.2",
"webpack": "^3.5.3"
diff --git a/src/settings/actions/setting.js b/src/settings/actions/setting.js
index c1b27c8..1d01fda 100644
--- a/src/settings/actions/setting.js
+++ b/src/settings/actions/setting.js
@@ -1,13 +1,14 @@
import actions from 'settings/actions';
import messages from 'shared/messages';
-import DefaultSettings from 'shared/default-settings';
+import DefaultSettings from 'shared/settings/default';
+import * as settingsValues from 'shared/settings/values';
const load = () => {
return browser.storage.local.get('settings').then(({ settings }) => {
- if (settings) {
- return set(settings);
+ if (!settings) {
+ return set(DefaultSettings);
}
- return set(DefaultSettings);
+ return set(Object.assign({}, DefaultSettings, settings));
}, console.error);
};
@@ -24,11 +25,19 @@ const save = (settings) => {
};
const set = (settings) => {
+ let value = JSON.parse(DefaultSettings.json);
+ if (settings.source === 'json') {
+ value = settingsValues.valueFromJson(settings.json);
+ } else if (settings.source === 'form') {
+ value = settingsValues.valueFromForm(settings.form);
+ }
+
return {
type: actions.SETTING_SET_SETTINGS,
source: settings.source,
json: settings.json,
- value: JSON.parse(settings.json),
+ form: settings.form,
+ value,
};
};
diff --git a/src/settings/components/form/blacklist-form.jsx b/src/settings/components/form/blacklist-form.jsx
new file mode 100644
index 0000000..7ae9652
--- /dev/null
+++ b/src/settings/components/form/blacklist-form.jsx
@@ -0,0 +1,52 @@
+import './blacklist-form.scss';
+import AddButton from '../ui/add-button';
+import DeleteButton from '../ui/delete-button';
+import { h, Component } from 'preact';
+
+class BlacklistForm extends Component {
+
+ render() {
+ let value = this.props.value;
+ if (!value) {
+ value = [];
+ }
+
+ return <div className='form-blacklist-form'>
+ {
+ value.map((url, index) => {
+ return <div key={index} className='form-blacklist-form-row'>
+ <input data-index={index} type='text' name='url'
+ className='column-url' value={url}
+ onChange={this.bindValue.bind(this)} />
+ <DeleteButton data-index={index} name='delete'
+ onClick={this.bindValue.bind(this)} />
+ </div>;
+ })
+ }
+ <AddButton name='add' style='float:right'
+ onClick={this.bindValue.bind(this)} />
+ </div>;
+ }
+
+ bindValue(e) {
+ if (!this.props.onChange) {
+ return;
+ }
+
+ let name = e.target.name;
+ let index = e.target.getAttribute('data-index');
+ let next = this.props.value ? this.props.value.slice() : [];
+
+ if (name === 'url') {
+ next[index] = e.target.value;
+ } else if (name === 'add') {
+ next.push('');
+ } else if (name === 'delete') {
+ next.splice(index, 1);
+ }
+
+ this.props.onChange(next);
+ }
+}
+
+export default BlacklistForm;
diff --git a/src/settings/components/form/blacklist-form.scss b/src/settings/components/form/blacklist-form.scss
new file mode 100644
index 0000000..a230d0d
--- /dev/null
+++ b/src/settings/components/form/blacklist-form.scss
@@ -0,0 +1,9 @@
+.form-blacklist-form {
+ &-row {
+ display: flex;
+
+ .column-url {
+ flex: 1;
+ }
+ }
+}
diff --git a/src/settings/components/form/keymaps-form.jsx b/src/settings/components/form/keymaps-form.jsx
new file mode 100644
index 0000000..f64320c
--- /dev/null
+++ b/src/settings/components/form/keymaps-form.jsx
@@ -0,0 +1,99 @@
+import './keymaps-form.scss';
+import { h, Component } from 'preact';
+import Input from '../ui/input';
+
+const KeyMapFields = [
+ [
+ ['scroll.vertically?{"count":1}', 'Scroll down'],
+ ['scroll.vertically?{"count":-1}', 'Scroll up'],
+ ['scroll.horizonally?{"count":-1}', 'Scroll left'],
+ ['scroll.horizonally?{"count":1}', 'Scroll right'],
+ ['scroll.home', 'Scroll leftmost'],
+ ['scroll.end', 'Scroll last'],
+ ['scroll.pages?{"count":-0.5}', 'Scroll up by half of screen'],
+ ['scroll.pages?{"count":0.5}', 'Scroll up by half of screen'],
+ ['scroll.pages?{"count":-1}', 'Scroll up by a screen'],
+ ['scroll.pages?{"count":1}', 'Scroll up by a screen'],
+ ], [
+ ['tabs.close', 'Close a tab'],
+ ['tabs.reopen', 'Reopen closed tab'],
+ ['tabs.next?{"count":1}', 'Select next Tab'],
+ ['tabs.prev?{"count":1}', 'Select prev Tab'],
+ ['tabs.first', 'Select first tab'],
+ ['tabs.last', 'Select last tab'],
+ ['tabs.reload?{"cache":true}', 'Reload current tab'],
+ ['tabs.pin.toggle', 'Toggle pinned state'],
+ ['tabs.duplicate', 'Dupplicate a tab'],
+ ], [
+ ['follow.start?{"newTab":false}', 'Follow a link'],
+ ['follow.start?{"newTab":true}', 'Follow a link in new tab'],
+ ['navigate.history.prev', 'Go back in histories'],
+ ['navigate.history.next', 'Go forward in histories'],
+ ['navigate.link.next', 'Open next link'],
+ ['navigate.link.prev', 'Open previous link'],
+ ['navigate.parent', 'Go to parent directory'],
+ ['navigate.root', 'Go to root directory'],
+ ], [
+ ['find.start', 'Start find mode'],
+ ['find.next', 'Find next word'],
+ ['find.prev', 'Find previous word'],
+ ], [
+ ['command.show', 'Open console'],
+ ['command.show.open?{"alter":false}', 'Open URL'],
+ ['command.show.open?{"alter":true}', 'Alter URL'],
+ ['command.show.tabopen?{"alter":false}', 'Open URL in new Tab'],
+ ['command.show.tabopen?{"alter":true}', 'Alter URL in new Tab'],
+ ['command.show.winopen?{"alter":false}', 'Open URL in new window'],
+ ['command.show.winopen?{"alter":true}', 'Alter URL in new window'],
+ ['command.show.buffer', 'Open buffer command'],
+ ], [
+ ['addon.toggle.enabled', 'Enable or disable'],
+ ['urls.yank', 'Copy current URL'],
+ ['zoom.in', 'Zoom-in'],
+ ['zoom.out', 'Zoom-out'],
+ ['zoom.neutral', 'Reset zoom level'],
+ ]
+];
+
+class KeymapsForm extends Component {
+
+ render() {
+ let values = this.props.value;
+ if (!values) {
+ values = {};
+ }
+ return <div className='keymap-fields'>
+ {
+ KeyMapFields.map((group, index) => {
+ return <div key={index} className='form-keymaps-form'>
+ {
+ group.map((field) => {
+ let name = field[0];
+ let label = field[1];
+ let value = values[name];
+ return <Input
+ type='text' id={name} name={name} key={name}
+ label={label} value={value}
+ onChange={this.bindValue.bind(this)}
+ />;
+ })
+ }
+ </div>;
+ })
+ }
+ </div>;
+ }
+
+ bindValue(e) {
+ if (!this.props.onChange) {
+ return;
+ }
+
+ let next = Object.assign({}, this.props.value);
+ next[e.target.name] = e.target.value;
+
+ this.props.onChange(next);
+ }
+}
+
+export default KeymapsForm;
diff --git a/src/settings/components/form/keymaps-form.scss b/src/settings/components/form/keymaps-form.scss
new file mode 100644
index 0000000..3a83910
--- /dev/null
+++ b/src/settings/components/form/keymaps-form.scss
@@ -0,0 +1,9 @@
+.form-keymaps-form {
+ column-count: 3;
+ .keymap-fields-group {
+ margin-top: 24px;
+ }
+ .keymap-fields-group:first-of-type {
+ margin-top: 0;
+ }
+}
diff --git a/src/settings/components/form/search-form.jsx b/src/settings/components/form/search-form.jsx
new file mode 100644
index 0000000..e85761f
--- /dev/null
+++ b/src/settings/components/form/search-form.jsx
@@ -0,0 +1,78 @@
+import './search-form.scss';
+import { h, Component } from 'preact';
+import AddButton from '../ui/add-button';
+import DeleteButton from '../ui/delete-button';
+
+class SearchForm extends Component {
+
+ render() {
+ let value = this.props.value;
+ if (!value) {
+ value = { default: '', engines: []};
+ }
+ if (!value.engines) {
+ value.engines = [];
+ }
+
+ return <div className='form-search-form'>
+ <div className='form-search-form-header'>
+ <div className='column-name'>Name</div>
+ <div className='column-url'>URL</div>
+ <div className='column-option'>Default</div>
+ </div>
+ {
+ value.engines.map((engine, index) => {
+ return <div key={index} className='form-search-form-row'>
+ <input data-index={index} type='text' name='name'
+ className='column-name' value={engine[0]}
+ onChange={this.bindValue.bind(this)} />
+ <input data-index={index} type='text' name='url'
+ placeholder='http://example.com/?q={}'
+ className='column-url' value={engine[1]}
+ onChange={this.bindValue.bind(this)} />
+ <div className='column-option'>
+ <input data-index={index} type='radio' name='default'
+ checked={value.default === engine[0]}
+ onChange={this.bindValue.bind(this)} />
+ <DeleteButton data-index={index} name='delete'
+ onClick={this.bindValue.bind(this)} />
+ </div>
+ </div>;
+ })
+ }
+ <AddButton name='add' style='float:right'
+ onClick={this.bindValue.bind(this)} />
+ </div>;
+ }
+
+ bindValue(e) {
+ if (!this.props.onChange) {
+ return;
+ }
+
+ let value = this.props.value;
+ let name = e.target.name;
+ let index = e.target.getAttribute('data-index');
+ let next = Object.assign({}, {
+ default: value.default,
+ engines: value.engines ? value.engines.slice() : [],
+ });
+
+ if (name === 'name') {
+ next.engines[index][0] = e.target.value;
+ next.default = this.props.value.engines[index][0];
+ } else if (name === 'url') {
+ next.engines[index][1] = e.target.value;
+ } else if (name === 'default') {
+ next.default = this.props.value.engines[index][0];
+ } else if (name === 'add') {
+ next.engines.push(['', '']);
+ } else if (name === 'delete') {
+ next.engines.splice(index, 1);
+ }
+
+ this.props.onChange(next);
+ }
+}
+
+export default SearchForm;
diff --git a/src/settings/components/form/search-form.scss b/src/settings/components/form/search-form.scss
new file mode 100644
index 0000000..26b2f44
--- /dev/null
+++ b/src/settings/components/form/search-form.scss
@@ -0,0 +1,28 @@
+.form-search-form {
+ @mixin row-base {
+ display: flex;
+
+ .column-name {
+ flex: 1;
+ min-width: 0;
+ }
+ .column-url {
+ flex: 5;
+ min-width: 0;
+ }
+ .column-option {
+ text-align: right;
+ flex-basis: 5rem;
+ }
+ }
+
+ &-header {
+ @include row-base;
+
+ font-weight: bold;
+ }
+
+ &-row {
+ @include row-base;
+ }
+}
diff --git a/src/settings/components/index.jsx b/src/settings/components/index.jsx
index 4418942..3961982 100644
--- a/src/settings/components/index.jsx
+++ b/src/settings/components/index.jsx
@@ -1,16 +1,27 @@
import './site.scss';
-import React from 'react';
-import PropTypes from 'prop-types';
+import { h, Component } from 'preact';
+import Input from './ui/input';
+import SearchForm from './form/search-form';
+import KeymapsForm from './form/keymaps-form';
+import BlacklistForm from './form/blacklist-form';
import * as settingActions from 'settings/actions/setting';
import * as validator from 'shared/validators/setting';
+import * as settingsValues from 'shared/settings/values';
-class SettingsComponent extends React.Component {
+const DO_YOU_WANT_TO_CONTINUE =
+ 'Some settings in JSON can be lose on migrating. ' +
+ 'Do you want to continue ?';
+
+class SettingsComponent extends Component {
constructor(props, context) {
super(props, context);
this.state = {
settings: {
json: '',
+ },
+ errors: {
+ json: '',
}
};
this.context.store.subscribe(this.stateChanged.bind(this));
@@ -26,66 +37,140 @@ class SettingsComponent extends React.Component {
settings: {
source: settings.source,
json: settings.json,
+ form: settings.form,
}
});
}
+ renderFormFields() {
+ return <div>
+ <fieldset>
+ <legend>Keybindings</legend>
+ <KeymapsForm
+ value={this.state.settings.form.keymaps}
+ onChange={value => this.bindForm('keymaps', value)}
+ />
+ </fieldset>
+ <fieldset>
+ <legend>Search Engines</legend>
+ <SearchForm
+ value={this.state.settings.form.search}
+ onChange={value => this.bindForm('search', value)}
+ />
+ </fieldset>
+ <fieldset>
+ <legend>Blacklist</legend>
+ <BlacklistForm
+ value={this.state.settings.form.blacklist}
+ onChange={value => this.bindForm('blacklist', value)}
+ />
+ </fieldset>
+ </div>;
+ }
+
+ renderJsonFields() {
+ return <div>
+ <Input
+ type='textarea'
+ name='json'
+ label='Plane JSON'
+ spellCheck='false'
+ error={this.state.errors.json}
+ onChange={this.bindValue.bind(this)}
+ value={this.state.settings.json}
+ />
+ </div>;
+ }
+
render() {
+ let fields = null;
+ if (this.state.settings.source === 'form') {
+ fields = this.renderFormFields();
+ } else if (this.state.settings.source === 'json') {
+ fields = this.renderJsonFields();
+ }
return (
<div>
<h1>Configure Vim-Vixen</h1>
<form className='vimvixen-settings-form'>
+ <Input
+ type='radio'
+ id='setting-source-form'
+ name='source'
+ label='Use form'
+ checked={this.state.settings.source === 'form'}
+ value='form'
+ onChange={this.bindSource.bind(this)} />
- <p>Load settings from:</p>
- <input type='radio' id='setting-source-json'
+ <Input
+ type='radio'
name='source'
+ label='Use plain JSON'
+ checked={this.state.settings.source === 'json'}
value='json'
- onChange={this.bindAndSave.bind(this)}
- checked={this.state.settings.source === 'json'} />
- <label htmlFor='settings-source-json'>JSON</label>
-
- <textarea name='json' spellCheck='false'
- onInput={this.validate.bind(this)}
- onChange={this.bindValue.bind(this)}
- onBlur={this.bindAndSave.bind(this)}
- value={this.state.settings.json} />
+ onChange={this.bindSource.bind(this)} />
+
+ { fields }
</form>
</div>
);
}
- validate(e) {
- try {
- let settings = JSON.parse(e.target.value);
+ validate(target) {
+ if (target.name === 'json') {
+ let settings = JSON.parse(target.value);
validator.validate(settings);
- e.target.setCustomValidity('');
- } catch (err) {
- e.target.setCustomValidity(err.message);
}
}
- bindValue(e) {
- let nextSettings = Object.assign({}, this.state.settings);
- nextSettings[e.target.name] = e.target.value;
-
- this.setState({ settings: nextSettings });
+ bindForm(name, value) {
+ let next = Object.assign({}, this.state, {
+ settings: Object.assign({}, this.state.settings, {
+ form: Object.assign({}, this.state.settings.form)
+ })
+ });
+ next.settings.form[name] = value;
+ this.setState(next);
+ this.context.store.dispatch(settingActions.save(next.settings));
}
- bindAndSave(e) {
- this.bindValue(e);
+ bindValue(e) {
+ let next = Object.assign({}, this.state);
+ next.errors.json = '';
try {
- let json = this.state.settings.json;
- validator.validate(JSON.parse(json));
- this.context.store.dispatch(settingActions.save(this.state.settings));
+ this.validate(e.target);
} catch (err) {
- // error already shown
+ next.errors.json = err.message;
}
+ next.settings[e.target.name] = e.target.value;
+
+ this.setState(next);
+ this.context.store.dispatch(settingActions.save(next.settings));
}
-}
-SettingsComponent.contextTypes = {
- store: PropTypes.any,
-};
+ bindSource(e) {
+ let from = this.state.settings.source;
+ let to = e.target.value;
+
+ let next = Object.assign({}, this.state);
+ if (from === 'form' && to === 'json') {
+ next.settings.json =
+ settingsValues.jsonFromForm(this.state.settings.form);
+ } else if (from === 'json' && to === 'form') {
+ let b = window.confirm(DO_YOU_WANT_TO_CONTINUE);
+ if (!b) {
+ this.setState(this.state);
+ return;
+ }
+ next.settings.form =
+ settingsValues.formFromJson(this.state.settings.json);
+ }
+ next.settings.source = to;
+
+ this.setState(next);
+ this.context.store.dispatch(settingActions.save(next.settings));
+ }
+}
export default SettingsComponent;
diff --git a/src/settings/components/site.scss b/src/settings/components/site.scss
index fae9c39..c0c4f9e 100644
--- a/src/settings/components/site.scss
+++ b/src/settings/components/site.scss
@@ -1,8 +1,27 @@
.vimvixen-settings-form {
+ padding: 2px;
+
textarea[name=json] {
font-family: monospace;
width: 100%;
min-height: 64ex;
resize: vertical;
}
+
+ fieldset {
+ margin: 0;
+ padding: 0;
+ border: none;
+ margin-top: 1rem;
+
+ fieldset:first-of-type {
+ margin-top: 1rem;
+ }
+
+ legend {
+ font-size: 1.5rem;
+ padding: .5rem 0;
+ font-weight: bold;
+ }
+ }
}
diff --git a/src/settings/components/ui/add-button.jsx b/src/settings/components/ui/add-button.jsx
new file mode 100644
index 0000000..79292d8
--- /dev/null
+++ b/src/settings/components/ui/add-button.jsx
@@ -0,0 +1,12 @@
+import './add-button.scss';
+import { h, Component } from 'preact';
+
+class AddButton extends Component {
+ render() {
+ return <input
+ className='ui-add-button' type='button' value='&#x271a;'
+ {...this.props} />;
+ }
+}
+
+export default AddButton;
diff --git a/src/settings/components/ui/add-button.scss b/src/settings/components/ui/add-button.scss
new file mode 100644
index 0000000..beb5688
--- /dev/null
+++ b/src/settings/components/ui/add-button.scss
@@ -0,0 +1,13 @@
+.ui-add-button {
+ border: none;
+ padding: 4;
+ display: inline;
+ background: none;
+ font-weight: bold;
+ color: green;
+ cursor: pointer;
+
+ &:hover {
+ color: darkgreen;
+ }
+}
diff --git a/src/settings/components/ui/delete-button.jsx b/src/settings/components/ui/delete-button.jsx
new file mode 100644
index 0000000..8077a76
--- /dev/null
+++ b/src/settings/components/ui/delete-button.jsx
@@ -0,0 +1,12 @@
+import './delete-button.scss';
+import { h, Component } from 'preact';
+
+class DeleteButton extends Component {
+ render() {
+ return <input
+ className='ui-delete-button' type='button' value='&#x2716;'
+ {...this.props} />;
+ }
+}
+
+export default DeleteButton;
diff --git a/src/settings/components/ui/delete-button.scss b/src/settings/components/ui/delete-button.scss
new file mode 100644
index 0000000..5932a72
--- /dev/null
+++ b/src/settings/components/ui/delete-button.scss
@@ -0,0 +1,13 @@
+
+.ui-delete-button {
+ border: none;
+ padding: 4;
+ display: inline;
+ background: none;
+ color: red;
+ cursor: pointer;
+
+ &:hover {
+ color: darkred;
+ }
+}
diff --git a/src/settings/components/ui/input.jsx b/src/settings/components/ui/input.jsx
new file mode 100644
index 0000000..e99dbc7
--- /dev/null
+++ b/src/settings/components/ui/input.jsx
@@ -0,0 +1,52 @@
+import { h, Component } from 'preact';
+import './input.scss';
+
+class Input extends Component {
+
+ renderText(props) {
+ let inputClassName = props.error ? 'input-error' : '';
+ return <div className='settings-ui-input'>
+ <label htmlFor={props.id}>{ props.label }</label>
+ <input type='text' className={inputClassName} {...props} />
+ </div>;
+ }
+
+ renderRadio(props) {
+ let inputClassName = props.error ? 'input-error' : '';
+ return <div className='settings-ui-input'>
+ <label>
+ <input type='radio' className={inputClassName} {...props} />
+ { props.label }
+ </label>
+ </div>;
+ }
+
+ renderTextArea(props) {
+ let inputClassName = props.error ? 'input-error' : '';
+ return <div className='settings-ui-input'>
+ <label
+ htmlFor={props.id}
+ >{ props.label }</label>
+ <textarea className={inputClassName} {...props} />
+ <p className='settings-ui-input-error'>{ this.props.error }</p>
+ </div>;
+ }
+
+ render() {
+ let { type } = this.props;
+
+ switch (this.props.type) {
+ case 'text':
+ return this.renderText(this.props);
+ case 'radio':
+ return this.renderRadio(this.props);
+ case 'textarea':
+ return this.renderTextArea(this.props);
+ default:
+ console.warn(`Unsupported input type ${type}`);
+ }
+ return null;
+ }
+}
+
+export default Input;
diff --git a/src/settings/components/ui/input.scss b/src/settings/components/ui/input.scss
new file mode 100644
index 0000000..ad4daf8
--- /dev/null
+++ b/src/settings/components/ui/input.scss
@@ -0,0 +1,29 @@
+.settings-ui-input {
+ page-break-inside: avoid;
+
+ * {
+ page-break-inside: avoid;
+ }
+
+ label {
+ font-weight: bold;
+ min-width: 14rem;
+ display: inline-block;
+ }
+
+ input[type='text'] {
+ padding: 4px;
+ width: 8rem;
+ }
+
+ input.input-crror,
+ textarea.input-error {
+ box-shadow: 0 0 2px red;
+ }
+
+ &-error {
+ font-weight: bold;
+ color: red;
+ min-height: 1.5em;
+ }
+}
diff --git a/src/settings/index.jsx b/src/settings/index.jsx
index 7516fb7..eb251b4 100644
--- a/src/settings/index.jsx
+++ b/src/settings/index.jsx
@@ -1,5 +1,4 @@
-import React from 'react';
-import ReactDOM from 'react-dom';
+import { h, render } from 'preact';
import SettingsComponent from './components';
import reducer from 'settings/reducers/setting';
import Provider from 'shared/store/provider';
@@ -9,7 +8,7 @@ const store = createStore(reducer);
document.addEventListener('DOMContentLoaded', () => {
let wrapper = document.getElementById('vimvixen-settings');
- ReactDOM.render(
+ render(
<Provider store={store}>
<SettingsComponent />
</Provider>,
diff --git a/src/settings/reducers/setting.js b/src/settings/reducers/setting.js
index a61c09f..70c6183 100644
--- a/src/settings/reducers/setting.js
+++ b/src/settings/reducers/setting.js
@@ -3,6 +3,7 @@ import actions from 'settings/actions';
const defaultState = {
source: '',
json: '',
+ form: null,
value: {}
};
@@ -12,6 +13,7 @@ export default function reducer(state = defaultState, action = {}) {
return {
source: action.source,
json: action.json,
+ form: action.form,
value: action.value,
};
default:
diff --git a/src/shared/default-settings.js b/src/shared/settings/default.js
index 608890b..69238e3 100644
--- a/src/shared/default-settings.js
+++ b/src/shared/settings/default.js
@@ -62,5 +62,70 @@ export default {
"wikipedia": "https://en.wikipedia.org/w/index.php?search={}"
}
}
-}`
+}`,
+
+ 'form': {
+ 'keymaps': {
+ 'scroll.vertically?{"count":1}': 'j',
+ 'scroll.vertically?{"count":-1}': 'k',
+ 'scroll.horizonally?{"count":-1}': 'h',
+ 'scroll.horizonally?{"count":1}': 'l',
+ 'scroll.home': '0',
+ 'scroll.end': '$',
+ 'scroll.pages?{"count":-0.5}': '<C-U>',
+ 'scroll.pages?{"count":0.5}': '<C-D>',
+ 'scroll.pages?{"count":-1}': '<C-B>',
+ 'scroll.pages?{"count":1}': '<C-F>',
+
+ 'tabs.close': 'd',
+ 'tabs.reopen': 'u',
+ 'tabs.next?{"count":1}': 'J',
+ 'tabs.prev?{"count":1}': 'K',
+ 'tabs.first': 'g0',
+ 'tabs.last': 'g$',
+ 'tabs.reload?{"cache":true}': 'r',
+ 'tabs.pin.toggle': 'zp',
+ 'tabs.duplicate': 'zd',
+
+ 'follow.start?{"newTab":false}': 'f',
+ 'follow.start?{"newTab":true}': 'F',
+ 'navigate.history.prev': 'H',
+ 'navigate.history.next': 'L',
+ 'navigate.link.next': ']]',
+ 'navigate.link.prev': '[[',
+ 'navigate.parent': 'gu',
+ 'navigate.root': 'gU',
+
+ 'find.start': '/',
+ 'find.next': 'n',
+ 'find.prev': 'N',
+
+ 'command.show': ':',
+ 'command.show.open?{"alter":false}': 'o',
+ 'command.show.open?{"alter":true}': 'O',
+ 'command.show.tabopen?{"alter":false}': 't',
+ 'command.show.tabopen?{"alter":true}': 'T',
+ 'command.show.winopen?{"alter":false}': 'w',
+ 'command.show.winopen?{"alter":true}': 'W',
+ 'command.show.buffer': 'b',
+
+ 'addon.toggle.enabled': '<S-Esc>',
+ 'urls.yank': 'y',
+ 'zoom.in': 'zi',
+ 'zoom.out': 'zo',
+ 'zoom.neutral': 'zz',
+ },
+ 'search': {
+ 'default': 'google',
+ 'engines': [
+ ['google', 'https,//google.com/search?q={}'],
+ ['yahoo', 'https,//search.yahoo.com/search?p={}'],
+ ['bing', 'https,//www.bing.com/search?q={}'],
+ ['duckduckgo', 'https,//duckduckgo.com/?q={}'],
+ ['twitter', 'https,//twitter.com/search?q={}'],
+ ['wikipedia', 'https,//en.wikipedia.org/w/index.php?search={}'],
+ ]
+ },
+ 'blacklist': [],
+ }
};
diff --git a/src/shared/settings/values.js b/src/shared/settings/values.js
new file mode 100644
index 0000000..4482fbb
--- /dev/null
+++ b/src/shared/settings/values.js
@@ -0,0 +1,100 @@
+import DefaultSettings from './default';
+
+const operationFromFormName = (name) => {
+ let [type, argStr] = name.split('?');
+ let args = {};
+ if (argStr) {
+ args = JSON.parse(argStr);
+ }
+ return Object.assign({ type }, args);
+};
+
+const operationToFormName = (op) => {
+ let type = op.type;
+ let args = Object.assign({}, op);
+ delete args.type;
+
+ if (Object.keys(args).length === 0) {
+ return type;
+ }
+ return op.type + '?' + JSON.stringify(args);
+};
+
+const valueFromJson = (json) => {
+ return JSON.parse(json);
+};
+
+const valueFromForm = (form) => {
+ let keymaps = undefined;
+ if (form.keymaps) {
+ keymaps = {};
+ for (let name of Object.keys(form.keymaps)) {
+ let keys = form.keymaps[name];
+ keymaps[keys] = operationFromFormName(name);
+ }
+ }
+
+ let search = undefined;
+ if (form.search) {
+ search = { default: form.search.default };
+
+ if (form.search.engines) {
+ search.engines = {};
+ for (let [name, url] of form.search.engines) {
+ search.engines[name] = url;
+ }
+ }
+ }
+
+ let blacklist = form.blacklist;
+
+ return { keymaps, search, blacklist };
+};
+
+const jsonFromValue = (value) => {
+ return JSON.stringify(value, undefined, 2);
+};
+
+const formFromValue = (value) => {
+
+ let keymaps = undefined;
+ if (value.keymaps) {
+ let allowedOps = new Set(Object.keys(DefaultSettings.form.keymaps));
+
+ keymaps = {};
+ for (let keys of Object.keys(value.keymaps)) {
+ let op = operationToFormName(value.keymaps[keys]);
+ if (allowedOps.has(op)) {
+ keymaps[op] = keys;
+ }
+ }
+ }
+
+ let search = undefined;
+ if (value.search) {
+ search = { default: value.search.default };
+ if (value.search.engines) {
+ search.engines = Object.keys(value.search.engines).map((name) => {
+ return [name, value.search.engines[name]];
+ });
+ }
+ }
+
+ let blacklist = value.blacklist;
+
+ return { keymaps, search, blacklist };
+};
+
+const jsonFromForm = (form) => {
+ return jsonFromValue(valueFromForm(form));
+};
+
+const formFromJson = (json) => {
+ let value = valueFromJson(json);
+ return formFromValue(value);
+};
+
+export {
+ valueFromJson, valueFromForm, jsonFromValue, formFromValue,
+ jsonFromForm, formFromJson
+};
diff --git a/src/shared/store/provider.jsx b/src/shared/store/provider.jsx
index 743f656..fe925aa 100644
--- a/src/shared/store/provider.jsx
+++ b/src/shared/store/provider.jsx
@@ -1,18 +1,15 @@
-import React from 'react';
-import PropTypes from 'prop-types';
+import { h, Component } from 'preact';
-class Provider extends React.PureComponent {
+class Provider extends Component {
getChildContext() {
return { store: this.props.store };
}
render() {
- return React.Children.only(this.props.children);
+ return <div>
+ { this.props.children }
+ </div>;
}
}
-Provider.childContextTypes = {
- store: PropTypes.any,
-};
-
export default Provider;
diff --git a/test/settings/components/form/blacklist-form.test.jsx b/test/settings/components/form/blacklist-form.test.jsx
new file mode 100644
index 0000000..95f5cde
--- /dev/null
+++ b/test/settings/components/form/blacklist-form.test.jsx
@@ -0,0 +1,82 @@
+import { expect } from 'chai';
+import { h, render } from 'preact';
+import BlacklistForm from 'settings/components/form/blacklist-form'
+
+describe("settings/form/BlacklistForm", () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ describe('render', () => {
+ it('renders BlacklistForm', () => {
+ render(<BlacklistForm value={['*.slack.com', 'www.google.com/maps']} />, document.body);
+
+ let inputs = document.querySelectorAll('input[type=text]');
+ expect(inputs).to.have.lengthOf(2);
+ expect(inputs[0].value).to.equal('*.slack.com');
+ expect(inputs[1].value).to.equal('www.google.com/maps');
+ });
+
+ it('renders blank value', () => {
+ render(<BlacklistForm />, document.body);
+
+ let inputs = document.querySelectorAll('input[type=text]');
+ expect(inputs).to.be.empty;
+ });
+
+ it('renders blank value', () => {
+ render(<BlacklistForm />, document.body);
+
+ let inputs = document.querySelectorAll('input[type=text]');
+ expect(inputs).to.be.empty;
+ });
+ });
+
+ describe('onChange', () => {
+ it('invokes onChange event on edit', (done) => {
+ render(<BlacklistForm
+ value={['*.slack.com', 'www.google.com/maps*']}
+ onChange={value => {
+ expect(value).to.have.lengthOf(2)
+ .and.have.members(['gitter.im', 'www.google.com/maps*']);
+
+ done();
+ }}
+ />, document.body);
+
+ let input = document.querySelectorAll('input[type=text]')[0];
+ input.value = 'gitter.im';
+ input.dispatchEvent(new Event('change'))
+ });
+
+ it('invokes onChange event on delete', (done) => {
+ render(<BlacklistForm
+ value={['*.slack.com', 'www.google.com/maps*']}
+ onChange={value => {
+ expect(value).to.have.lengthOf(1)
+ .and.have.members(['www.google.com/maps*']);
+
+ done();
+ }}
+ />, document.body);
+
+ let button = document.querySelectorAll('input[type=button]')[0];
+ button.click();
+ });
+
+ it('invokes onChange event on add', (done) => {
+ render(<BlacklistForm
+ value={['*.slack.com']}
+ onChange={value => {
+ expect(value).to.have.lengthOf(2)
+ .and.have.members(['*.slack.com', '']);
+
+ done();
+ }}
+ />, document.body);
+
+ let button = document.querySelector('input[type=button].ui-add-button');
+ button.click();
+ });
+ });
+});
diff --git a/test/settings/components/form/keymaps-form.test.jsx b/test/settings/components/form/keymaps-form.test.jsx
new file mode 100644
index 0000000..e9f9359
--- /dev/null
+++ b/test/settings/components/form/keymaps-form.test.jsx
@@ -0,0 +1,53 @@
+import { expect } from 'chai';
+import { h, render } from 'preact';
+import KeymapsForm from 'settings/components/form/keymaps-form'
+
+describe("settings/form/KeymapsForm", () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ describe('render', () => {
+ it('renders KeymapsForm', () => {
+ render(<KeymapsForm value={{
+ 'scroll.vertically?{"count":1}': 'j',
+ 'scroll.vertically?{"count":-1}': 'k',
+ }} />, document.body);
+
+ let inputj = document.getElementById('scroll.vertically?{"count":1}');
+ let inputk = document.getElementById('scroll.vertically?{"count":-1}');
+
+ expect(inputj.value).to.equal('j');
+ expect(inputk.value).to.equal('k');
+ });
+
+ it('renders blank value', () => {
+ render(<KeymapsForm />, document.body);
+
+ let inputj = document.getElementById('scroll.vertically?{"count":1}');
+ let inputk = document.getElementById('scroll.vertically?{"count":-1}');
+
+ expect(inputj.value).to.be.empty;
+ expect(inputk.value).to.be.empty;
+ });
+ });
+
+ describe('onChange event', () => {
+ it('invokes onChange event on edit', (done) => {
+ render(<KeymapsForm
+ value={{
+ 'scroll.vertically?{"count":1}': 'j',
+ 'scroll.vertically?{"count":-1}': 'k',
+ }}
+ onChange={value => {
+ expect(value['scroll.vertically?{"count":1}']).to.equal('jjj');
+
+ done();
+ }} />, document.body);
+
+ let input = document.getElementById('scroll.vertically?{"count":1}');
+ input.value = 'jjj';
+ input.dispatchEvent(new Event('change'))
+ });
+ });
+});
diff --git a/test/settings/components/form/search-engine-form.test.jsx b/test/settings/components/form/search-engine-form.test.jsx
new file mode 100644
index 0000000..9600cae
--- /dev/null
+++ b/test/settings/components/form/search-engine-form.test.jsx
@@ -0,0 +1,104 @@
+import { expect } from 'chai';
+import { h, render } from 'preact';
+import SearchForm from 'settings/components/form/search-form'
+
+describe("settings/form/SearchForm", () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ describe('render', () => {
+ it('renders SearchForm', () => {
+ render(<SearchForm value={{
+ default: 'google',
+ engines: [['google', 'google.com'], ['yahoo', 'yahoo.com']],
+ }} />, document.body);
+
+ let names = document.querySelectorAll('input[name=name]');
+ expect(names).to.have.lengthOf(2);
+ expect(names[0].value).to.equal('google');
+ expect(names[1].value).to.equal('yahoo');
+
+ let urls = document.querySelectorAll('input[name=url]');
+ expect(urls).to.have.lengthOf(2);
+ expect(urls[0].value).to.equal('google.com');
+ expect(urls[1].value).to.equal('yahoo.com');
+ });
+
+ it('renders blank value', () => {
+ render(<SearchForm />, document.body);
+
+ let names = document.querySelectorAll('input[name=name]');
+ let urls = document.querySelectorAll('input[name=url]');
+ expect(names).to.have.lengthOf(0);
+ expect(urls).to.have.lengthOf(0);
+ });
+
+ it('renders blank engines', () => {
+ render(<SearchForm value={{ default: 'google' }} />, document.body);
+
+ let names = document.querySelectorAll('input[name=name]');
+ let urls = document.querySelectorAll('input[name=url]');
+ expect(names).to.have.lengthOf(0);
+ expect(urls).to.have.lengthOf(0);
+ });
+ });
+
+ describe('onChange event', () => {
+ it('invokes onChange event on edit', (done) => {
+ render(<SearchForm
+ value={{
+ default: 'google',
+ engines: [['google', 'google.com'], ['yahoo', 'yahoo.com']]
+ }}
+ onChange={value => {
+ expect(value.default).to.equal('louvre');
+ expect(value.engines).to.have.lengthOf(2)
+ .and.have.deep.members([['louvre', 'google.com'], ['yahoo', 'yahoo.com']])
+
+ done();
+ }} />, document.body);
+
+ let radio = document.querySelectorAll('input[type=radio]');
+ radio.checked = true;
+
+ let name = document.querySelector('input[name=name]');
+ name.value = 'louvre';
+ name.dispatchEvent(new Event('change'))
+ });
+
+ it('invokes onChange event on delete', (done) => {
+ render(<SearchForm value={{
+ default: 'yahoo',
+ engines: [['louvre', 'google.com'], ['yahoo', 'yahoo.com']]
+ }}
+ onChange={value => {
+ expect(value.default).to.equal('yahoo');
+ expect(value.engines).to.have.lengthOf(1)
+ .and.have.deep.members([['yahoo', 'yahoo.com']])
+
+ done();
+ }} />, document.body);
+
+ let button = document.querySelector('input[type=button]');
+ button.click();
+ });
+
+ it('invokes onChange event on add', (done) => {
+ render(<SearchForm value={{
+ default: 'yahoo',
+ engines: [['google', 'google.com']]
+ }}
+ onChange={value => {
+ expect(value.default).to.equal('yahoo');
+ expect(value.engines).to.have.lengthOf(2)
+ .and.have.deep.members([['google', 'google.com'], ['', '']])
+
+ done();
+ }} />, document.body);
+
+ let button = document.querySelector('input[type=button].ui-add-button');
+ button.click();
+ });
+ });
+});
diff --git a/test/settings/components/ui/input.test.jsx b/test/settings/components/ui/input.test.jsx
new file mode 100644
index 0000000..98f2cef
--- /dev/null
+++ b/test/settings/components/ui/input.test.jsx
@@ -0,0 +1,83 @@
+import { expect } from 'chai';
+import { h, render } from 'preact';
+import Input from 'settings/components/ui/input'
+
+describe("settings/ui/Input", () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ context("type=text", () => {
+ it('renders text input', () => {
+ render(<Input type='text' name='myname' label='myfield' value='myvalue'/>, document.body)
+
+ let label = document.querySelector('label');
+ let input = document.querySelector('input');
+ expect(label.textContent).to.contain('myfield');
+ expect(input.type).to.contain('text');
+ expect(input.name).to.contain('myname');
+ expect(input.value).to.contain('myvalue');
+ });
+
+ it('invoke onChange', (done) => {
+ render(<Input type='text' name='myname' label='myfield' value='myvalue' onChange={(e) => {
+ expect(e.target.value).to.equal('newvalue');
+ done();
+ }}/>, document.body);
+
+ let input = document.querySelector('input');
+ input.value = 'newvalue';
+ input.dispatchEvent(new Event('change'))
+ });
+ });
+
+ context("type=radio", () => {
+ it('renders radio button', () => {
+ render(<Input type='radio' name='myname' label='myfield' value='myvalue'/>, document.body)
+
+ let label = document.querySelector('label');
+ let input = document.querySelector('input');
+ expect(label.textContent).to.contain('myfield');
+ expect(input.type).to.contain('radio');
+ expect(input.name).to.contain('myname');
+ expect(input.value).to.contain('myvalue');
+ });
+
+ it('invoke onChange', (done) => {
+ render(<Input type='text' name='radio' label='myfield' value='myvalue' onChange={(e) => {
+ expect(e.target.checked).to.be.true;
+ done();
+ }}/>, document.body);
+
+ let input = document.querySelector('input');
+ input.checked = true;
+ input.dispatchEvent(new Event('change'))
+ });
+ });
+
+ context("type=textarea", () => {
+ it('renders textarea button', () => {
+ render(<Input type='textarea' name='myname' label='myfield' value='myvalue' error='myerror' />, document.body)
+
+ let label = document.querySelector('label');
+ let textarea = document.querySelector('textarea');
+ let error = document.querySelector('.settings-ui-input-error');
+ expect(label.textContent).to.contain('myfield');
+ expect(textarea.nodeName).to.contain('TEXTAREA');
+ expect(textarea.name).to.contain('myname');
+ expect(textarea.value).to.contain('myvalue');
+ expect(error.textContent).to.contain('myerror');
+ });
+
+ it('invoke onChange', (done) => {
+ render(<Input type='textarea' name='myname' label='myfield' value='myvalue' onChange={(e) => {
+ expect(e.target.value).to.equal('newvalue');
+ done();
+ }}/>, document.body);
+
+ let input = document.querySelector('textarea');
+ input.value = 'newvalue'
+ input.dispatchEvent(new Event('change'))
+ });
+ });
+});
diff --git a/test/shared/settings/values.test.js b/test/shared/settings/values.test.js
new file mode 100644
index 0000000..2c222b6
--- /dev/null
+++ b/test/shared/settings/values.test.js
@@ -0,0 +1,112 @@
+import { expect } from 'chai';
+import * as values from 'shared/settings/values';
+
+describe("settings values", () => {
+ describe('valueFromJson', () => {
+ it('return object from json string', () => {
+ let json = `{
+ "keymaps": { "0": {"type": "scroll.home"}},
+ "search": { "default": "google", "engines": { "google": "https://google.com/search?q={}" }},
+ "blacklist": [ "*.slack.com"]
+ }`;
+ let value = values.valueFromJson(json);
+
+ expect(value.keymaps).to.deep.equal({ 0: {type: "scroll.home"}});
+ expect(value.search).to.deep.equal({ default: "google", engines: { google: "https://google.com/search?q={}"} });
+ expect(value.blacklist).to.deep.equal(["*.slack.com"]);
+ });
+ });
+
+ describe('valueFromForm', () => {
+ it('returns value from form', () => {
+ let form = {
+ keymaps: {
+ 'scroll.vertically?{"count":1}': 'j',
+ 'scroll.home': '0',
+ },
+ search: {
+ default: 'google',
+ engines: [['google', 'https://google.com/search?q={}']],
+ },
+ blacklist: ['*.slack.com'],
+ };
+ let value = values.valueFromForm(form);
+
+ expect(value.keymaps).to.have.deep.property('j', { type: "scroll.vertically", count: 1 });
+ expect(value.keymaps).to.have.deep.property('0', { type: "scroll.home" });
+ expect(JSON.stringify(value.search)).to.deep.equal(JSON.stringify({ default: "google", engines: { google: "https://google.com/search?q={}"} }));
+ expect(value.search).to.deep.equal({ default: "google", engines: { google: "https://google.com/search?q={}"} });
+ expect(value.blacklist).to.deep.equal(["*.slack.com"]);
+ });
+
+ it('convert from empty form', () => {
+ let form = {};
+ let value = values.valueFromForm(form);
+ expect(value).to.not.have.key('keymaps');
+ expect(value).to.not.have.key('search');
+ expect(value).to.not.have.key('blacklist');
+ });
+
+ it('override keymaps', () => {
+ let form = {
+ keymaps: {
+ 'scroll.vertically?{"count":1}': 'j',
+ 'scroll.vertically?{"count":-1}': 'j',
+ }
+ };
+ let value = values.valueFromForm(form);
+
+ expect(value.keymaps).to.have.key('j');
+ });
+
+ it('override search engine', () => {
+ let form = {
+ search: {
+ default: 'google',
+ engines: [
+ ['google', 'https://google.com/search?q={}'],
+ ['google', 'https://google.co.jp/search?q={}'],
+ ]
+ }
+ };
+ let value = values.valueFromForm(form);
+
+ expect(value.search.engines).to.have.property('google', 'https://google.co.jp/search?q={}');
+ });
+ });
+
+ describe('jsonFromValue', () => {
+ });
+
+ describe('formFromValue', () => {
+ it('convert empty value to form', () => {
+ let value = {};
+ let form = values.formFromValue(value);
+
+ expect(value).to.not.have.key('keymaps');
+ expect(value).to.not.have.key('search');
+ expect(value).to.not.have.key('blacklist');
+ });
+
+ it('convert value to form', () => {
+ let value = {
+ keymaps: {
+ j: { type: 'scroll.vertically', count: 1 },
+ JJ: { type: 'scroll.vertically', count: 100 },
+ 0: { type: 'scroll.home' },
+ },
+ search: { default: 'google', engines: { google: 'https://google.com/search?q={}' }},
+ blacklist: [ '*.slack.com']
+ };
+ let form = values.formFromValue(value);
+
+ expect(form.keymaps).to.have.property('scroll.vertically?{"count":1}', 'j');
+ expect(form.keymaps).to.have.property('scroll.home', '0');
+ expect(Object.keys(form.keymaps)).to.have.lengthOf(2);
+ expect(form.search).to.have.property('default', 'google');
+ expect(form.search).to.have.deep.property('engines', [['google', 'https://google.com/search?q={}']]);
+ expect(form.blacklist).to.have.lengthOf(1);
+ expect(form.blacklist).to.include('*.slack.com');
+ });
+ });
+});
diff --git a/webpack.config.js b/webpack.config.js
index 16d437f..fc5ef5e 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -25,7 +25,7 @@ config = {
exclude: /node_modules/,
loader: 'babel-loader',
query: {
- presets: ['es2015', 'react']
+ presets: ['es2015', 'preact']
}
},
{