diff options
33 files changed, 1264 insertions, 155 deletions
@@ -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" } } @@ -1,12 +1,12 @@ ## Checklist for testing Vim Vixen -### Operations +### Keybindings in JSON settings Test operations with default key maps. #### Scrolling -- [ ] <kbd>k</kbd> or <kbd>Ctrl</kbd>+<kbd>Y</kbd>, <kbd>j</kbd> or <kbd>Ctrl</kbd>+<kbd>E</kbd>: scroll up and down +- [ ] <kbd>k</kbd>, <kbd>j</kbd>: scroll up and down - [ ] <kbd>h</kbd>, <kbd>l</kbd>: scroll left and right - [ ] <kbd>Ctrl</kbd>+<kbd>U</kbd>, <kbd>Ctrl</kbd>+<kbd>D</kbd>: scroll up and down by half of screen - [ ] <kbd>Ctrl</kbd>+<kbd>B</kbd>, <kbd>Ctrl</kbd>+<kbd>F</kbd>: scroll up and down by a screen @@ -48,6 +48,55 @@ The behaviors of the console are tested in [Console section](#consoles). - [ ] <kbd>y</kbd>: yank current URL and show a message - [ ] Toggle enabled/disabled of plugin bu <kbd>Shift</kbd>+<kbd>Esc</kbd> +### Keybindings in form settings + +Test operations with default key maps. + +#### Scrolling + +- [ ] <kbd>k</kbd>, <kbd>j</kbd>: scroll up and down +- [ ] <kbd>h</kbd>, <kbd>l</kbd>: scroll left and right +- [ ] <kbd>Ctrl</kbd>+<kbd>U</kbd>, <kbd>Ctrl</kbd>+<kbd>D</kbd>: scroll up and down by half of screen +- [ ] <kbd>Ctrl</kbd>+<kbd>B</kbd>, <kbd>Ctrl</kbd>+<kbd>F</kbd>: scroll up and down by a screen +- [ ] <kbd>0</kbd>, <kbd>$</kbd>: scroll to leftmost and rightmost +- [ ] <kbd>g</kbd><kbd>g</kbd>, <kbd>G</kbd>: scroll to top and bottom + +#### Console + +The behaviors of the console are tested in [Console section](#consoles). + +- [ ] <kbd>:</kbd>: open empty console +- [ ] <kbd>o</kbd>, <kbd>t</kbd>, <kbd>w</kbd>: open a console with `open`, `tabopen`, `winopen` +- [ ] <kbd>O</kbd>, <kbd>T</kbd>, <kbd>W</kbd>: open a console with `open`, `tabopen`, `winopen` and current URL +- [ ] <kbd>b</kbd>: open a consolw with `buffer` + +#### Tabs + +- [ ] <kbd>d</kbd>: delete current tab +- [ ] <kbd>u</kbd>: reopen close tab +- [ ] <kbd>K</kbd>, <kbd>J</kbd>: select prev and next tab +- [ ] <kbd>g0</kbd>, <kbd>g$</kbd>: select first and last tab +- [ ] <kbd>r</kbd>: reload current tab +- [ ] <kbd>R</kbd>: reload current tab without cache +- [ ] <kbd>zd</kbd>: duplicate current tab +- [ ] <kbd>zp</kbd>: toggle pin/unpin state on current tab + +#### Navigation + +- [ ] <kbd>H</kbd>, <kbd>L</kbd>: go back and forward in histories +- [ ] <kbd>[</kbd><kbd>[</kbd>, <kbd>]</kbd><kbd>]</kbd>: Open next/prev link in `<link>` tags. +- [ ] <kbd>[</kbd><kbd>[</kbd>, <kbd>]</kbd><kbd>]</kbd>: find prev and next links and open it +- [ ] <kbd>g</kbd><kbd>u</kbd>: go to parent directory +- [ ] <kbd>g</kbd><kbd>U</kbd>: go to root directory + +#### Misc + +- [ ] <kbd>z</kbd><kbd>i</kbd>, <kbd>z</kbd><kbd>o</kbd>: zoom-in and zoom-out +- [ ] <kbd>z</kbd><kbd>z</kbd>: set zoom level as default +- [ ] <kbd>y</kbd>: yank current URL and show a message +- [ ] Toggle enabled/disabled of plugin bu <kbd>Shift</kbd>+<kbd>Esc</kbd> + + ### Following links - [ ] <kbd>f</kbd>: start following links @@ -83,7 +132,7 @@ The behaviors of the console are tested in [Console section](#consoles). - [ ] `buffer`,`buffer<SP>`: do nothing - [ ] `buffer <title>`, `buffer <url>`: select tab which has an title matched with - [ ] `buffer 1`: select leftmost tab -- [ ] `buffer 0`, `buffer 99`: shows an error +- [ ] `buffer 0`, `buffer <a number more than count of tabs>`: shows an error - [ ] select tabs rotationally when more than two tabs are matched ### Completions @@ -110,20 +159,22 @@ The behaviors of the console are tested in [Console section](#consoles). ### Settings -#### Validations +#### JSON Settings + +##### Validations - [ ] show error on invalid json - [ ] show error when top-level keys has keys other than `keymaps`, `search`, and `blacklist` -##### `"keymaps"` section +###### `"keymaps"` section - [ ] show error on unknown operation name in `"keymaps"` -##### `"search"` section +###### `"search"` section - validations in `"search"` section are not tested in this release -#### `"blacklist"` section +##### `"blacklist"` section - [ ] `github.com/a` blocks `github.com/a`, and not blocks `github.com/aa` - [ ] `github.com/a*` blocks both `github.com/a` and `github.com/aa` @@ -131,13 +182,44 @@ The behaviors of the console are tested in [Console section](#consoles). - [ ] `github.com` blocks both `github.com/` and `github.com/a` - [ ] `*.github.com` blocks `gist.github.com/`, and not `github.com` -#### Updating +##### Updating - [ ] changes are updated on textarea blure when no errors - [ ] changes are not updated on textarea blure when errors occurs - [ ] keymap settings are applied to open tabs without reload - [ ] search settings are applied to open tabs without reload +#### Form Settings + +<!-- validation on form settings does not implement in 0.7 --> + +##### Search Engines + +- [ ] able to change default +- [ ] able to remove item +- [ ] able to add item + +##### `"blacklist"` section + +- [ ] able to add item +- [ ] able to remove item +- [ ] `github.com/a` blocks `github.com/a`, and not blocks `github.com/aa` +- [ ] `github.com/a*` blocks both `github.com/a` and `github.com/aa` +- [ ] `github.com/` blocks `github.com/`, and not blocks `github.com/a` +- [ ] `github.com` blocks both `github.com/` and `github.com/a` +- [ ] `*.github.com` blocks `gist.github.com/`, and not `github.com` + +##### Updating + +- [ ] keymap settings are applied to open tabs without reload +- [ ] search settings are applied to open tabs without reload + +### Settings source + +- [ ] show confirmation dialog on switched from json to form +- [ ] state is saved on source changed +- [ ] on switching form -> json -> form, first and last form setting is equivalent to first one + ### For certain sites - [ ] scoll on Hacker News @@ -1,5 +1,8 @@ # Vim Vixen +[](https://gitter.im/vim-vixen/vim-vixen) +[](https://travis-ci.org/ueokande/vim-vixen) + Vim Vixen is a Firefox add-on which allows you to navigate with keyboard on the browser. Firefox started to support WebExtensions API and will stop supporting add-ons using legacy APIs from version 57. For this reason, many legacy add-ons do not work on Firefox 57. 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/manifest.json b/manifest.json index 532eaac..665c10f 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "Vim Vixen", "description": "Vim Vixen", - "version": "0.6", + "version": "0.7", "icons": { "48": "resources/icon_48x48.png", "96": "resources/icon_96x96.png" 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..f3b6abe --- /dev/null +++ b/src/settings/components/form/keymaps-form.jsx @@ -0,0 +1,106 @@ +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 to leftmost'], + ['scroll.end', 'Scroll to rightmost'], + ['scroll.top', 'Scroll to top'], + ['scroll.bottom', 'Scroll to bottom'], + ['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":false}', 'Reload current tab'], + ['tabs.reload?{"cache":true}', 'Reload with no caches'], + ['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'], + ] +]; + +const AllowdOps = [].concat(...KeyMapFields.map(group => group.map(e => e[0]))); + +class KeymapsForm extends Component { + + render() { + let values = this.props.value; + if (!values) { + values = {}; + } + return <div className='form-keymaps-form'> + { + KeyMapFields.map((group, index) => { + return <div key={index} className='form-keymaps-form-field-group'> + { + 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); + } +} + +KeymapsForm.AllowdOps = AllowdOps; + +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..1a4e5cd --- /dev/null +++ b/src/settings/components/form/keymaps-form.scss @@ -0,0 +1,11 @@ +.form-keymaps-form { + column-count: 3; + + &-field-group { + margin-top: 24px; + } + + &-field-group:first-of-type { + margin-top: 24px; + } +} 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..73520ca 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,179 @@ 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(''); + } + } + + validateValue(e) { + let next = Object.assign({}, this.state); + + next.errors.json = ''; + try { + this.validate(e.target); } catch (err) { - e.target.setCustomValidity(err.message); + next.errors.json = err.message; } + next.settings[e.target.name] = e.target.value; + } + + 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)); } bindValue(e) { - let nextSettings = Object.assign({}, this.state.settings); - nextSettings[e.target.name] = e.target.value; + let next = Object.assign({}, this.state); + let error = false; - this.setState({ settings: nextSettings }); - } + next.errors.json = ''; + try { + this.validate(e.target); + } catch (err) { + next.errors.json = err.message; + error = true; + } + next.settings[e.target.name] = e.target.value; - bindAndSave(e) { - this.bindValue(e); + this.setState(this.state); + if (!error) { + this.context.store.dispatch(settingActions.save(next.settings)); + } + } + migrateToForm() { + let b = window.confirm(DO_YOU_WANT_TO_CONTINUE); + if (!b) { + this.setState(this.state); + return; + } try { - let json = this.state.settings.json; - validator.validate(JSON.parse(json)); - this.context.store.dispatch(settingActions.save(this.state.settings)); + validator.validate(JSON.parse(this.state.settings.json)); } catch (err) { - // error already shown + this.setState(this.state); + return; } + + let form = settingsValues.formFromJson( + this.state.settings.json, KeymapsForm.AllowdOps); + let next = Object.assign({}, this.state); + next.settings.form = form; + next.settings.source = 'form'; + next.errors.json = ''; + + this.setState(next); + this.context.store.dispatch(settingActions.save(next.settings)); + } + + migrateToJson() { + let json = settingsValues.jsonFromForm(this.state.settings.form); + let next = Object.assign({}, this.state); + next.settings.json = json; + next.settings.source = 'json'; + next.errors.json = ''; + + 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; + + if (from === 'form' && to === 'json') { + this.migrateToJson(); + } else if (from === 'json' && to === 'form') { + this.migrateToForm(); + } + } +} 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='✚' + {...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='✖' + {...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..d187565 100644 --- a/src/shared/default-settings.js +++ b/src/shared/settings/default.js @@ -15,8 +15,6 @@ export default { "j": { "type": "scroll.vertically", "count": 1 }, "h": { "type": "scroll.horizonally", "count": -1 }, "l": { "type": "scroll.horizonally", "count": 1 }, - "<C-Y>": { "type": "scroll.vertically", "count": -1 }, - "<C-E>": { "type": "scroll.vertically", "count": 1 }, "<C-U>": { "type": "scroll.pages", "count": -0.5 }, "<C-D>": { "type": "scroll.pages", "count": 0.5 }, "<C-B>": { "type": "scroll.pages", "count": -1 }, @@ -62,5 +60,5 @@ export default { "wikipedia": "https://en.wikipedia.org/w/index.php?search={}" } } -}` +}`, }; diff --git a/src/shared/settings/values.js b/src/shared/settings/values.js new file mode 100644 index 0000000..4e55fa0 --- /dev/null +++ b/src/shared/settings/values.js @@ -0,0 +1,98 @@ +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, allowedOps) => { + let keymaps = undefined; + + if (value.keymaps) { + let allowedSet = new Set(allowedOps); + + keymaps = {}; + for (let keys of Object.keys(value.keymaps)) { + let op = operationToFormName(value.keymaps[keys]); + if (allowedSet.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, allowedOps) => { + let value = valueFromJson(json); + return formFromValue(value, allowedOps); +}; + +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..2632cd7 --- /dev/null +++ b/test/shared/settings/values.test.js @@ -0,0 +1,114 @@ +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 allowed = ['scroll.vertically?{"count":1}', 'scroll.home' ]; + let form = values.formFromValue(value, allowed); + + expect(form.keymaps).to.have.property('scroll.vertically?{"count":1}', 'j'); + expect(form.keymaps).to.not.have.property('scroll.vertically?{"count":100}'); + 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'] } }, { |