From 44459e39c3526673ac2ac7065c5659e4af5ea7d8 Mon Sep 17 00:00:00 2001
From: Shin'ya Ueoka <ueokande@i-beam.org>
Date: Sat, 18 Nov 2017 22:02:44 +0900
Subject: use preact

---
 src/settings/components/index.jsx | 9 ++-------
 1 file changed, 2 insertions(+), 7 deletions(-)

(limited to 'src/settings/components/index.jsx')

diff --git a/src/settings/components/index.jsx b/src/settings/components/index.jsx
index 4418942..bb2045a 100644
--- a/src/settings/components/index.jsx
+++ b/src/settings/components/index.jsx
@@ -1,10 +1,9 @@
 import './site.scss';
-import React from 'react';
-import PropTypes from 'prop-types';
+import { h, Component } from 'preact';
 import * as settingActions from 'settings/actions/setting';
 import * as validator from 'shared/validators/setting';
 
-class SettingsComponent extends React.Component {
+class SettingsComponent extends Component {
   constructor(props, context) {
     super(props, context);
 
@@ -84,8 +83,4 @@ class SettingsComponent extends React.Component {
   }
 }
 
-SettingsComponent.contextTypes = {
-  store: PropTypes.any,
-};
-
 export default SettingsComponent;
-- 
cgit v1.2.3


From d33b37cdb9d2956f5f2d23ab4e71e35db137b16e Mon Sep 17 00:00:00 2001
From: Shin'ya Ueoka <ueokande@i-beam.org>
Date: Sun, 19 Nov 2017 08:23:51 +0900
Subject: Use Preact for settings and show validation

---
 src/settings/components/index.jsx     | 48 ++++++++++++++++++++-------------
 src/settings/components/site.scss     |  2 ++
 src/settings/components/ui/input.jsx  | 50 +++++++++++++++++++++++++++++++++++
 src/settings/components/ui/input.scss | 17 ++++++++++++
 4 files changed, 99 insertions(+), 18 deletions(-)
 create mode 100644 src/settings/components/ui/input.jsx
 create mode 100644 src/settings/components/ui/input.scss

(limited to 'src/settings/components/index.jsx')

diff --git a/src/settings/components/index.jsx b/src/settings/components/index.jsx
index bb2045a..98d8fb2 100644
--- a/src/settings/components/index.jsx
+++ b/src/settings/components/index.jsx
@@ -1,5 +1,6 @@
 import './site.scss';
 import { h, Component } from 'preact';
+import Input from './ui/input';
 import * as settingActions from 'settings/actions/setting';
 import * as validator from 'shared/validators/setting';
 
@@ -10,6 +11,9 @@ class SettingsComponent extends Component {
     this.state = {
       settings: {
         json: '',
+      },
+      errors: {
+        json: '',
       }
     };
     this.context.store.subscribe(this.stateChanged.bind(this));
@@ -35,39 +39,47 @@ class SettingsComponent extends Component {
         <h1>Configure Vim-Vixen</h1>
         <form className='vimvixen-settings-form'>
 
-          <p>Load settings from:</p>
-          <input type='radio' id='setting-source-json'
+          <Input
+            type='radio'
             name='source'
-            value='json'
-            onChange={this.bindAndSave.bind(this)}
-            checked={this.state.settings.source === 'json'} />
-          <label htmlFor='settings-source-json'>JSON</label>
+            label='Use plain JSON'
+            checked={this.state.settings.source === 'json'}
+            value='json' />
 
-          <textarea name='json' spellCheck='false'
-            onInput={this.validate.bind(this)}
+          <Input
+            type='textarea'
+            name='json'
+            label='Plane JSON'
+            spellCheck='false'
+            error={this.state.errors.json}
             onChange={this.bindValue.bind(this)}
             onBlur={this.bindAndSave.bind(this)}
-            value={this.state.settings.json} />
+            value={this.state.settings.json}
+          />
         </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;
+    let next = Object.assign({}, this.state);
+
+    next.errors.json = '';
+    try {
+      this.validate(e.target);
+    } catch (err) {
+      next.errors.json = err.message;
+    }
+    next.settings[e.target.name] = e.target.value;
 
-    this.setState({ settings: nextSettings });
+    this.setState(next);
   }
 
   bindAndSave(e) {
diff --git a/src/settings/components/site.scss b/src/settings/components/site.scss
index fae9c39..aac4319 100644
--- a/src/settings/components/site.scss
+++ b/src/settings/components/site.scss
@@ -1,4 +1,6 @@
 .vimvixen-settings-form {
+  padding: 2px;
+
   textarea[name=json] {
     font-family: monospace;
     width: 100%;
diff --git a/src/settings/components/ui/input.jsx b/src/settings/components/ui/input.jsx
new file mode 100644
index 0000000..9b6c229
--- /dev/null
+++ b/src/settings/components/ui/input.jsx
@@ -0,0 +1,50 @@
+import { h, Component } from 'preact';
+import './input.scss';
+
+class Input extends Component {
+
+  renderRadio(props) {
+    let inputClasses = 'form-field-input';
+    if (props.error) {
+      inputClasses += ' input-error';
+    }
+    return <div>
+      <label>
+        <input className={ inputClasses } type='radio' {...props} />
+        { props.label }
+      </label>
+    </div>;
+  }
+
+  renderTextArea(props) {
+    let inputClasses = 'form-field-input';
+    if (props.error) {
+      inputClasses += ' input-error';
+    }
+    return <div>
+      <label
+        className='form-field-label'
+        htmlFor={props.id}
+      >{ props.label }</label>
+      <textarea className={inputClasses} {...props} />
+
+      <p className='form-field-error'>{ this.props.error }</p>
+    </div>;
+  }
+
+  render() {
+    let { type } = this.props;
+
+    switch (this.props.type) {
+    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..6187138
--- /dev/null
+++ b/src/settings/components/ui/input.scss
@@ -0,0 +1,17 @@
+.form-field-label {
+  font-weight: bold
+}
+
+.form-field-error {
+  font-weight: bold;
+  color: red;
+  min-height: 1.5em;
+}
+
+.form-field-input {
+  padding: 4px;
+}
+
+.form-field-input.input-error {
+  box-shadow: 0 0 2px red;
+}
-- 
cgit v1.2.3


From 4e5ddc1d57ba4f42314eb49ae57a9b67950be596 Mon Sep 17 00:00:00 2001
From: Shin'ya Ueoka <ueokande@i-beam.org>
Date: Wed, 22 Nov 2017 22:38:34 +0900
Subject: Add keybindings form

---
 src/settings/actions/setting.js       |   7 +-
 src/settings/components/index.jsx     | 139 ++++++++++++++++++++++++++++++----
 src/settings/components/site.scss     |  27 +++++++
 src/settings/components/ui/input.jsx  |  35 +++++----
 src/settings/components/ui/input.scss |  37 +++++----
 src/settings/reducers/setting.js      |   2 +
 src/shared/default-settings.js        |  66 +++++++++++++++-
 7 files changed, 268 insertions(+), 45 deletions(-)

(limited to 'src/settings/components/index.jsx')

diff --git a/src/settings/actions/setting.js b/src/settings/actions/setting.js
index c1b27c8..fa158a3 100644
--- a/src/settings/actions/setting.js
+++ b/src/settings/actions/setting.js
@@ -4,10 +4,10 @@ import DefaultSettings from 'shared/default-settings';
 
 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);
 };
 
@@ -28,6 +28,7 @@ const set = (settings) => {
     type: actions.SETTING_SET_SETTINGS,
     source: settings.source,
     json: settings.json,
+    form: settings.form,
     value: JSON.parse(settings.json),
   };
 };
diff --git a/src/settings/components/index.jsx b/src/settings/components/index.jsx
index 98d8fb2..11411bd 100644
--- a/src/settings/components/index.jsx
+++ b/src/settings/components/index.jsx
@@ -4,6 +4,59 @@ import Input from './ui/input';
 import * as settingActions from 'settings/actions/setting';
 import * as validator from 'shared/validators/setting';
 
+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.histories.prev', 'Go back in histories'],
+    ['navigate.histories.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 SettingsComponent extends Component {
   constructor(props, context) {
     super(props, context);
@@ -29,33 +82,87 @@ class SettingsComponent extends Component {
       settings: {
         source: settings.source,
         json: settings.json,
+        form: settings.form,
       }
     });
   }
 
+  renderFormFields() {
+    let keymapFields = KeyMapFields.map((group, index) => {
+      return <div key={index} className='keymap-fields-group'>
+        {
+          group.map((field) => {
+            let name = field[0];
+            let label = field[1];
+            let value = this.state.settings.form.keymaps[name];
+            return <Input
+              type='text'
+              id={name}
+              name={name}
+              key={name}
+              label={label}
+              value={value}
+              onChange={this.bindFormKeymapsValue.bind(this)}
+            />;
+          })
+        }
+      </div>;
+    });
+
+    return <div>
+      <fieldset>
+        <legend>Keybindings</legend>
+        <div className='keymap-fields'>
+          { keymapFields }
+        </div>
+      </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)}
+        onBlur={this.save.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.bindValue.bind(this)} />
 
           <Input
             type='radio'
             name='source'
             label='Use plain JSON'
             checked={this.state.settings.source === 'json'}
-            value='json' />
+            value='json'
+            onChange={this.bindValue.bind(this)} />
 
-          <Input
-            type='textarea'
-            name='json'
-            label='Plane JSON'
-            spellCheck='false'
-            error={this.state.errors.json}
-            onChange={this.bindValue.bind(this)}
-            onBlur={this.bindAndSave.bind(this)}
-            value={this.state.settings.json}
-          />
+          { fields }
         </form>
       </div>
     );
@@ -82,9 +189,15 @@ class SettingsComponent extends Component {
     this.setState(next);
   }
 
-  bindAndSave(e) {
-    this.bindValue(e);
+  bindFormKeymapsValue(e) {
+    let next = Object.assign({}, this.state);
+
+    next.settings.form.keymaps[e.target.name] = e.target.value;
+
+    this.context.store.dispatch(settingActions.save(next.settings));
+  }
 
+  save() {
     try {
       let json = this.state.settings.json;
       validator.validate(JSON.parse(json));
diff --git a/src/settings/components/site.scss b/src/settings/components/site.scss
index aac4319..dcc52ce 100644
--- a/src/settings/components/site.scss
+++ b/src/settings/components/site.scss
@@ -7,4 +7,31 @@
     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;
+      line-height: 1.5rem;
+      font-weight: bold;
+    }
+  }
+
+  .keymap-fields{
+    column-count: 2;
+    .keymap-fields-group {
+      margin-top: 24px;
+    }
+    .keymap-fields-group:first-of-type {
+      margin-top: 0;
+    }
+  }
 }
diff --git a/src/settings/components/ui/input.jsx b/src/settings/components/ui/input.jsx
index 9b6c229..5138411 100644
--- a/src/settings/components/ui/input.jsx
+++ b/src/settings/components/ui/input.jsx
@@ -3,32 +3,35 @@ import './input.scss';
 
 class Input extends Component {
 
+  renderText(props) {
+    let inputClassName = props.error ? 'input-error' : '';
+    return <div className='settings-ui-input'>
+      <label
+        type='text'
+        htmlFor={props.id}
+      >{ props.label }</label>
+      <input type='text' className={inputClassName} {...props} />
+    </div>;
+  }
+
   renderRadio(props) {
-    let inputClasses = 'form-field-input';
-    if (props.error) {
-      inputClasses += ' input-error';
-    }
-    return <div>
+    let inputClassName = props.error ? 'input-error' : '';
+    return <div className='settings-ui-input'>
       <label>
-        <input className={ inputClasses } type='radio' {...props} />
+        <input type='radio' className={inputClassName} {...props} />
         { props.label }
       </label>
     </div>;
   }
 
   renderTextArea(props) {
-    let inputClasses = 'form-field-input';
-    if (props.error) {
-      inputClasses += ' input-error';
-    }
-    return <div>
+    let inputClassName = props.error ? 'input-error' : '';
+    return <div className='settings-ui-input'>
       <label
-        className='form-field-label'
         htmlFor={props.id}
       >{ props.label }</label>
-      <textarea className={inputClasses} {...props} />
-
-      <p className='form-field-error'>{ this.props.error }</p>
+      <textarea className={inputClassName} {...props} />
+      <p className='settings-ui-input-error'>{ this.props.error }</p>
     </div>;
   }
 
@@ -36,6 +39,8 @@ class Input extends Component {
     let { type } = this.props;
 
     switch (this.props.type) {
+    case 'text':
+      return this.renderText(this.props);
     case 'radio':
       return this.renderRadio(this.props);
     case 'textarea':
diff --git a/src/settings/components/ui/input.scss b/src/settings/components/ui/input.scss
index 6187138..92df712 100644
--- a/src/settings/components/ui/input.scss
+++ b/src/settings/components/ui/input.scss
@@ -1,17 +1,28 @@
-.form-field-label {
-  font-weight: bold
-}
+.settings-ui-input {
+  page-break-inside: avoid;
 
-.form-field-error {
-  font-weight: bold;
-  color: red;
-  min-height: 1.5em;
-}
+  * {
+    page-break-inside: avoid;
+  }
 
-.form-field-input {
-  padding: 4px;
-}
+  label {
+    font-weight: bold;
+    min-width: 14rem;
+    display: inline-block;
+  }
+
+  input {
+    padding: 4px;
+  }
+
+  input.input-crror,
+  textarea.input-error {
+    box-shadow: 0 0 2px red;
+  }
 
-.form-field-input.input-error {
-  box-shadow: 0 0 2px red;
+  &-error {
+    font-weight: bold;
+    color: red;
+    min-height: 1.5em;
+  }
 }
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/default-settings.js
index 608890b..c1402b7 100644
--- a/src/shared/default-settings.js
+++ b/src/shared/default-settings.js
@@ -62,5 +62,69 @@ 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.histories.prev': 'H',
+      'navigate.histories.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={}'
+      }
+    }
+  }
 };
-- 
cgit v1.2.3


From 7b44b652473b487e2d00a5b705a7f78ee66dec65 Mon Sep 17 00:00:00 2001
From: Shin'ya Ueoka <ueokande@i-beam.org>
Date: Thu, 23 Nov 2017 21:22:42 +0900
Subject: Add search form

---
 .../components/form/search-engine-form.jsx         | 69 ++++++++++++++++++++++
 .../components/form/search-engine-form.scss        | 38 ++++++++++++
 src/settings/components/index.jsx                  | 27 +++++----
 src/shared/default-settings.js                     | 16 ++---
 4 files changed, 131 insertions(+), 19 deletions(-)
 create mode 100644 src/settings/components/form/search-engine-form.jsx
 create mode 100644 src/settings/components/form/search-engine-form.scss

(limited to 'src/settings/components/index.jsx')

diff --git a/src/settings/components/form/search-engine-form.jsx b/src/settings/components/form/search-engine-form.jsx
new file mode 100644
index 0000000..77da304
--- /dev/null
+++ b/src/settings/components/form/search-engine-form.jsx
@@ -0,0 +1,69 @@
+import { h, Component } from 'preact';
+import './search-engine-form.scss';
+
+class SearchEngineForm extends Component {
+
+  render() {
+    let value = this.props.value;
+    if (!value) {
+      value = { default: '', engines: []};
+    }
+    let {
+      default: defaultEngine,
+      engines
+    } = value;
+
+    return <div className='form-search-engine-form'>
+      <div className='form-search-engine-form-header'>
+        <div className='column-name'>Name</div>
+        <div className='column-url'>URL</div>
+        <div className='column-option'>Default</div>
+      </div>
+      {
+        engines.map((engine, index) => {
+          return <div key={index} className='form-search-engine-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={defaultEngine === engine[0]}
+                onChange={this.bindValue.bind(this)} />
+              <input data-index={index} type='button' name='delete'
+                value='&#x2716;'
+                onClick={this.bindValue.bind(this)} />
+            </div>
+          </div>;
+        })
+      }
+    </div>;
+  }
+
+  bindValue(e) {
+    if (!this.props.onChange) {
+      return;
+    }
+
+    let name = e.target.name;
+    let index = e.target.getAttribute('data-index');
+    let next = Object.assign({}, this.props.value);
+
+    if (name === 'name') {
+      next.engines[index][0] = e.target.value;
+    } 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 === 'delete') {
+      next.engines.splice(index, 1);
+    }
+
+    this.props.onChange(next);
+  }
+}
+
+export default SearchEngineForm;
diff --git a/src/settings/components/form/search-engine-form.scss b/src/settings/components/form/search-engine-form.scss
new file mode 100644
index 0000000..1ca43b7
--- /dev/null
+++ b/src/settings/components/form/search-engine-form.scss
@@ -0,0 +1,38 @@
+.form-search-engine-form {
+  @mixin row-base {
+    display: flex;
+
+    .column-name {
+      flex: 1;
+    }
+    .column-url {
+      flex: 5;
+    }
+    .column-option {
+      text-align: right;
+      flex-basis: 5rem;
+    }
+  }
+
+  &-header {
+    @include row-base;
+
+    font-weight: bold;
+  }
+
+  &-row {
+    @include row-base;
+
+    .column-option input[type='button'] {
+      border: none;
+      padding: 4;
+      display: inline;
+      background: none;
+      color: red;
+
+      &:hover {
+        color: darkred;
+      }
+    }
+  }
+}
diff --git a/src/settings/components/index.jsx b/src/settings/components/index.jsx
index 11411bd..8fe7d4d 100644
--- a/src/settings/components/index.jsx
+++ b/src/settings/components/index.jsx
@@ -1,6 +1,7 @@
 import './site.scss';
 import { h, Component } from 'preact';
 import Input from './ui/input';
+import SearchEngineForm from './form/search-engine-form';
 import * as settingActions from 'settings/actions/setting';
 import * as validator from 'shared/validators/setting';
 
@@ -116,6 +117,13 @@ class SettingsComponent extends Component {
           { keymapFields }
         </div>
       </fieldset>
+      <fieldset>
+        <legend>Search Engines</legend>
+        <SearchEngineForm
+          value={this.state.settings.form.search}
+          onChange={this.bindSearchForm.bind(this)}
+        />
+      </fieldset>
     </div>;
   }
 
@@ -128,7 +136,6 @@ class SettingsComponent extends Component {
         spellCheck='false'
         error={this.state.errors.json}
         onChange={this.bindValue.bind(this)}
-        onBlur={this.save.bind(this)}
         value={this.state.settings.json}
       />
     </div>;
@@ -175,6 +182,13 @@ class SettingsComponent extends Component {
     }
   }
 
+  bindSearchForm(value) {
+    let next = Object.assign({}, this.state);
+    next.settings.form.search = value;
+    this.setState(next);
+    this.context.store.dispatch(settingActions.save(next.settings));
+  }
+
   bindValue(e) {
     let next = Object.assign({}, this.state);
 
@@ -187,6 +201,7 @@ class SettingsComponent extends Component {
     next.settings[e.target.name] = e.target.value;
 
     this.setState(next);
+    this.context.store.dispatch(settingActions.save(next.settings));
   }
 
   bindFormKeymapsValue(e) {
@@ -196,16 +211,6 @@ class SettingsComponent extends Component {
 
     this.context.store.dispatch(settingActions.save(next.settings));
   }
-
-  save() {
-    try {
-      let json = this.state.settings.json;
-      validator.validate(JSON.parse(json));
-      this.context.store.dispatch(settingActions.save(this.state.settings));
-    } catch (err) {
-      // error already shown
-    }
-  }
 }
 
 export default SettingsComponent;
diff --git a/src/shared/default-settings.js b/src/shared/default-settings.js
index c1402b7..6d2013b 100644
--- a/src/shared/default-settings.js
+++ b/src/shared/default-settings.js
@@ -117,14 +117,14 @@ export default {
     },
     '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={}'
-      }
+      '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={}'],
+      ]
     }
   }
 };
-- 
cgit v1.2.3


From 2641183a5b244a9640d8fb7e92ba95dc15dadc58 Mon Sep 17 00:00:00 2001
From: Shin'ya Ueoka <ueokande@i-beam.org>
Date: Sat, 25 Nov 2017 12:31:24 +0900
Subject: keymaps form

---
 src/settings/components/form/keymaps-form.jsx  | 95 +++++++++++++++++++++++++
 src/settings/components/form/keymaps-form.scss |  9 +++
 src/settings/components/index.jsx              | 97 ++++----------------------
 src/settings/components/site.scss              | 10 ---
 4 files changed, 116 insertions(+), 95 deletions(-)
 create mode 100644 src/settings/components/form/keymaps-form.jsx
 create mode 100644 src/settings/components/form/keymaps-form.scss

(limited to 'src/settings/components/index.jsx')

diff --git a/src/settings/components/form/keymaps-form.jsx b/src/settings/components/form/keymaps-form.jsx
new file mode 100644
index 0000000..a112979
--- /dev/null
+++ b/src/settings/components/form/keymaps-form.jsx
@@ -0,0 +1,95 @@
+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.histories.prev', 'Go back in histories'],
+    ['navigate.histories.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() {
+    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 = this.props.value[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..8b86c80
--- /dev/null
+++ b/src/settings/components/form/keymaps-form.scss
@@ -0,0 +1,9 @@
+.form-keymaps-form {
+  column-count: 2;
+  .keymap-fields-group {
+    margin-top: 24px;
+  }
+  .keymap-fields-group:first-of-type {
+    margin-top: 0;
+  }
+}
diff --git a/src/settings/components/index.jsx b/src/settings/components/index.jsx
index 8fe7d4d..e5b46ad 100644
--- a/src/settings/components/index.jsx
+++ b/src/settings/components/index.jsx
@@ -2,62 +2,10 @@ import './site.scss';
 import { h, Component } from 'preact';
 import Input from './ui/input';
 import SearchEngineForm from './form/search-engine-form';
+import KeymapsForm from './form/keymaps-form';
 import * as settingActions from 'settings/actions/setting';
 import * as validator from 'shared/validators/setting';
 
-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.histories.prev', 'Go back in histories'],
-    ['navigate.histories.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 SettingsComponent extends Component {
   constructor(props, context) {
     super(props, context);
@@ -89,33 +37,13 @@ class SettingsComponent extends Component {
   }
 
   renderFormFields() {
-    let keymapFields = KeyMapFields.map((group, index) => {
-      return <div key={index} className='keymap-fields-group'>
-        {
-          group.map((field) => {
-            let name = field[0];
-            let label = field[1];
-            let value = this.state.settings.form.keymaps[name];
-            return <Input
-              type='text'
-              id={name}
-              name={name}
-              key={name}
-              label={label}
-              value={value}
-              onChange={this.bindFormKeymapsValue.bind(this)}
-            />;
-          })
-        }
-      </div>;
-    });
-
     return <div>
       <fieldset>
         <legend>Keybindings</legend>
-        <div className='keymap-fields'>
-          { keymapFields }
-        </div>
+        <KeymapsForm
+          value={this.state.settings.form.keymaps}
+          onChange={this.bindKeymapsForm.bind(this)}
+        />
       </fieldset>
       <fieldset>
         <legend>Search Engines</legend>
@@ -189,6 +117,13 @@ class SettingsComponent extends Component {
     this.context.store.dispatch(settingActions.save(next.settings));
   }
 
+  bindKeymapsForm(value) {
+    let next = Object.assign({}, this.state);
+    next.settings.form.keymaps = value;
+    this.setState(next);
+    this.context.store.dispatch(settingActions.save(next.settings));
+  }
+
   bindValue(e) {
     let next = Object.assign({}, this.state);
 
@@ -203,14 +138,6 @@ class SettingsComponent extends Component {
     this.setState(next);
     this.context.store.dispatch(settingActions.save(next.settings));
   }
-
-  bindFormKeymapsValue(e) {
-    let next = Object.assign({}, this.state);
-
-    next.settings.form.keymaps[e.target.name] = e.target.value;
-
-    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 dcc52ce..c8dd422 100644
--- a/src/settings/components/site.scss
+++ b/src/settings/components/site.scss
@@ -24,14 +24,4 @@
       font-weight: bold;
     }
   }
-
-  .keymap-fields{
-    column-count: 2;
-    .keymap-fields-group {
-      margin-top: 24px;
-    }
-    .keymap-fields-group:first-of-type {
-      margin-top: 0;
-    }
-  }
 }
-- 
cgit v1.2.3


From df10208ed5dfbc823eb1979272903d74f02a898c Mon Sep 17 00:00:00 2001
From: Shin'ya Ueoka <ueokande@i-beam.org>
Date: Sat, 25 Nov 2017 14:59:03 +0900
Subject: blacklist form

---
 src/settings/components/form/blacklist-form.jsx  | 47 ++++++++++++++++++++++++
 src/settings/components/form/blacklist-form.scss |  9 +++++
 src/settings/components/index.jsx                | 29 +++++++++------
 src/shared/default-settings.js                   |  3 +-
 4 files changed, 75 insertions(+), 13 deletions(-)
 create mode 100644 src/settings/components/form/blacklist-form.jsx
 create mode 100644 src/settings/components/form/blacklist-form.scss

(limited to 'src/settings/components/index.jsx')

diff --git a/src/settings/components/form/blacklist-form.jsx b/src/settings/components/form/blacklist-form.jsx
new file mode 100644
index 0000000..83b54da
--- /dev/null
+++ b/src/settings/components/form/blacklist-form.jsx
@@ -0,0 +1,47 @@
+import './blacklist-form.scss';
+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>;
+        })
+      }
+    </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.slice();
+
+    if (name === 'url') {
+      next[index] = e.target.value;
+    } 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/index.jsx b/src/settings/components/index.jsx
index e5b46ad..fb25df6 100644
--- a/src/settings/components/index.jsx
+++ b/src/settings/components/index.jsx
@@ -3,6 +3,7 @@ import { h, Component } from 'preact';
 import Input from './ui/input';
 import SearchEngineForm from './form/search-engine-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';
 
@@ -42,14 +43,21 @@ class SettingsComponent extends Component {
         <legend>Keybindings</legend>
         <KeymapsForm
           value={this.state.settings.form.keymaps}
-          onChange={this.bindKeymapsForm.bind(this)}
+          onChange={value => this.bindForm('keymaps', value)}
         />
       </fieldset>
       <fieldset>
         <legend>Search Engines</legend>
         <SearchEngineForm
           value={this.state.settings.form.search}
-          onChange={this.bindSearchForm.bind(this)}
+          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>;
@@ -110,16 +118,13 @@ class SettingsComponent extends Component {
     }
   }
 
-  bindSearchForm(value) {
-    let next = Object.assign({}, this.state);
-    next.settings.form.search = value;
-    this.setState(next);
-    this.context.store.dispatch(settingActions.save(next.settings));
-  }
-
-  bindKeymapsForm(value) {
-    let next = Object.assign({}, this.state);
-    next.settings.form.keymaps = 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));
   }
diff --git a/src/shared/default-settings.js b/src/shared/default-settings.js
index 6d2013b..218390b 100644
--- a/src/shared/default-settings.js
+++ b/src/shared/default-settings.js
@@ -125,6 +125,7 @@ export default {
         ['twitter', 'https,//twitter.com/search?q={}'],
         ['wikipedia', 'https,//en.wikipedia.org/w/index.php?search={}'],
       ]
-    }
+    },
+    'blacklist': [],
   }
 };
-- 
cgit v1.2.3


From 256e7372d4a96079b993115eabde8b33d400c7c8 Mon Sep 17 00:00:00 2001
From: Shin'ya Ueoka <ueokande@i-beam.org>
Date: Sun, 26 Nov 2017 08:25:23 +0900
Subject: settings migration between json and form

---
 src/settings/components/form/blacklist-form.jsx |  2 +-
 src/settings/components/index.jsx               | 23 +++++++++++++++++++++--
 2 files changed, 22 insertions(+), 3 deletions(-)

(limited to 'src/settings/components/index.jsx')

diff --git a/src/settings/components/form/blacklist-form.jsx b/src/settings/components/form/blacklist-form.jsx
index 9cb547b..4333929 100644
--- a/src/settings/components/form/blacklist-form.jsx
+++ b/src/settings/components/form/blacklist-form.jsx
@@ -34,7 +34,7 @@ class BlacklistForm extends Component {
 
     let name = e.target.name;
     let index = e.target.getAttribute('data-index');
-    let next = this.props.value.slice();
+    let next = this.props.value ? this.props.value.slice() : [];
 
     if (name === 'url') {
       next[index] = e.target.value;
diff --git a/src/settings/components/index.jsx b/src/settings/components/index.jsx
index fb25df6..ae7cd31 100644
--- a/src/settings/components/index.jsx
+++ b/src/settings/components/index.jsx
@@ -6,6 +6,7 @@ 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 Component {
   constructor(props, context) {
@@ -95,7 +96,7 @@ class SettingsComponent extends Component {
             label='Use form'
             checked={this.state.settings.source === 'form'}
             value='form'
-            onChange={this.bindValue.bind(this)} />
+            onChange={this.bindSource.bind(this)} />
 
           <Input
             type='radio'
@@ -103,7 +104,7 @@ class SettingsComponent extends Component {
             label='Use plain JSON'
             checked={this.state.settings.source === 'json'}
             value='json'
-            onChange={this.bindValue.bind(this)} />
+            onChange={this.bindSource.bind(this)} />
 
           { fields }
         </form>
@@ -143,6 +144,24 @@ class SettingsComponent extends Component {
     this.setState(next);
     this.context.store.dispatch(settingActions.save(next.settings));
   }
+
+  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') {
+      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;
-- 
cgit v1.2.3


From d195890eb957e5b6e40f7c01ce34d07e7ccf22b5 Mon Sep 17 00:00:00 2001
From: Shin'ya Ueoka <ueokande@i-beam.org>
Date: Sun, 26 Nov 2017 11:37:25 +0900
Subject: rename serach engine

---
 .../components/form/search-engine-form.jsx         | 73 ----------------------
 .../components/form/search-engine-form.scss        | 26 --------
 src/settings/components/form/search-form.jsx       | 73 ++++++++++++++++++++++
 src/settings/components/form/search-form.scss      | 26 ++++++++
 src/settings/components/index.jsx                  |  4 +-
 5 files changed, 101 insertions(+), 101 deletions(-)
 delete mode 100644 src/settings/components/form/search-engine-form.jsx
 delete mode 100644 src/settings/components/form/search-engine-form.scss
 create mode 100644 src/settings/components/form/search-form.jsx
 create mode 100644 src/settings/components/form/search-form.scss

(limited to 'src/settings/components/index.jsx')

diff --git a/src/settings/components/form/search-engine-form.jsx b/src/settings/components/form/search-engine-form.jsx
deleted file mode 100644
index faa066f..0000000
--- a/src/settings/components/form/search-engine-form.jsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import './search-engine-form.scss';
-import { h, Component } from 'preact';
-import AddButton from '../ui/add-button';
-import DeleteButton from '../ui/delete-button';
-
-class SearchEngineForm extends Component {
-
-  render() {
-    let value = this.props.value;
-    if (!value) {
-      value = { default: '', engines: []};
-    }
-    let {
-      default: defaultEngine,
-      engines
-    } = value;
-
-    return <div className='form-search-engine-form'>
-      <div className='form-search-engine-form-header'>
-        <div className='column-name'>Name</div>
-        <div className='column-url'>URL</div>
-        <div className='column-option'>Default</div>
-      </div>
-      {
-        engines.map((engine, index) => {
-          return <div key={index} className='form-search-engine-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={defaultEngine === engine[0]}
-                onChange={this.bindValue.bind(this)} />
-              <DeleteButton data-index={index} name='delete'
-                onClick={this.bindValue.bind(this)} />
-            </div>
-          </div>;
-        })
-      }
-      <AddButton name='add' 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 = Object.assign({}, this.props.value);
-
-    if (name === 'name') {
-      next.engines[index][0] = e.target.value;
-    } 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 SearchEngineForm;
diff --git a/src/settings/components/form/search-engine-form.scss b/src/settings/components/form/search-engine-form.scss
deleted file mode 100644
index 0d33c23..0000000
--- a/src/settings/components/form/search-engine-form.scss
+++ /dev/null
@@ -1,26 +0,0 @@
-.form-search-engine-form {
-  @mixin row-base {
-    display: flex;
-
-    .column-name {
-      flex: 1;
-    }
-    .column-url {
-      flex: 5;
-    }
-    .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/form/search-form.jsx b/src/settings/components/form/search-form.jsx
new file mode 100644
index 0000000..9bafb8d
--- /dev/null
+++ b/src/settings/components/form/search-form.jsx
@@ -0,0 +1,73 @@
+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: []};
+    }
+    let {
+      default: defaultEngine,
+      engines
+    } = value;
+
+    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>
+      {
+        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={defaultEngine === engine[0]}
+                onChange={this.bindValue.bind(this)} />
+              <DeleteButton data-index={index} name='delete'
+                onClick={this.bindValue.bind(this)} />
+            </div>
+          </div>;
+        })
+      }
+      <AddButton name='add' 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 = Object.assign({}, this.props.value);
+
+    if (name === 'name') {
+      next.engines[index][0] = e.target.value;
+    } 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..8814ef5
--- /dev/null
+++ b/src/settings/components/form/search-form.scss
@@ -0,0 +1,26 @@
+.form-search-form {
+  @mixin row-base {
+    display: flex;
+
+    .column-name {
+      flex: 1;
+    }
+    .column-url {
+      flex: 5;
+    }
+    .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 ae7cd31..b7de259 100644
--- a/src/settings/components/index.jsx
+++ b/src/settings/components/index.jsx
@@ -1,7 +1,7 @@
 import './site.scss';
 import { h, Component } from 'preact';
 import Input from './ui/input';
-import SearchEngineForm from './form/search-engine-form';
+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';
@@ -49,7 +49,7 @@ class SettingsComponent extends Component {
       </fieldset>
       <fieldset>
         <legend>Search Engines</legend>
-        <SearchEngineForm
+        <SearchForm
           value={this.state.settings.form.search}
           onChange={value => this.bindForm('search', value)}
         />
-- 
cgit v1.2.3


From c23333110d846b4bf4a76422853820875b74e93a Mon Sep 17 00:00:00 2001
From: Shin'ya Ueoka <ueokande@i-beam.org>
Date: Sun, 26 Nov 2017 16:41:59 +0900
Subject: confirm before setting migration

---
 .eslintrc                         | 1 +
 src/settings/components/index.jsx | 9 +++++++++
 2 files changed, 10 insertions(+)

(limited to 'src/settings/components/index.jsx')

diff --git a/.eslintrc b/.eslintrc
index ab61dc7..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",
diff --git a/src/settings/components/index.jsx b/src/settings/components/index.jsx
index b7de259..3961982 100644
--- a/src/settings/components/index.jsx
+++ b/src/settings/components/index.jsx
@@ -8,6 +8,10 @@ import * as settingActions from 'settings/actions/setting';
 import * as validator from 'shared/validators/setting';
 import * as settingsValues from 'shared/settings/values';
 
+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);
@@ -154,6 +158,11 @@ class SettingsComponent extends Component {
       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);
     }
-- 
cgit v1.2.3


From e1060f9bb218202d13a4382584f220d47173194c Mon Sep 17 00:00:00 2001
From: Shin'ya Ueoka <ueokande@i-beam.org>
Date: Tue, 28 Nov 2017 20:45:22 +0900
Subject: remove default form settings

---
 src/settings/components/form/keymaps-form.jsx |  4 ++
 src/settings/components/index.jsx             | 15 +++++-
 src/shared/settings/default.js                | 68 ---------------------------
 src/shared/settings/values.js                 | 14 +++---
 test/shared/settings/values.test.js           |  4 +-
 5 files changed, 27 insertions(+), 78 deletions(-)

(limited to 'src/settings/components/index.jsx')

diff --git a/src/settings/components/form/keymaps-form.jsx b/src/settings/components/form/keymaps-form.jsx
index f99318f..f3b6abe 100644
--- a/src/settings/components/form/keymaps-form.jsx
+++ b/src/settings/components/form/keymaps-form.jsx
@@ -58,6 +58,8 @@ const KeyMapFields = [
   ]
 ];
 
+const AllowdOps = [].concat(...KeyMapFields.map(group => group.map(e => e[0])));
+
 class KeymapsForm extends Component {
 
   render() {
@@ -99,4 +101,6 @@ class KeymapsForm extends Component {
   }
 }
 
+KeymapsForm.AllowdOps = AllowdOps;
+
 export default KeymapsForm;
diff --git a/src/settings/components/index.jsx b/src/settings/components/index.jsx
index 3961982..38f7db8 100644
--- a/src/settings/components/index.jsx
+++ b/src/settings/components/index.jsx
@@ -123,6 +123,18 @@ class SettingsComponent extends Component {
     }
   }
 
+  validateValue(e) {
+    let next = Object.assign({}, this.state);
+
+    next.errors.json = '';
+    try {
+      this.validate(e.target);
+    } catch (err) {
+      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, {
@@ -164,7 +176,8 @@ class SettingsComponent extends Component {
         return;
       }
       next.settings.form =
-        settingsValues.formFromJson(this.state.settings.json);
+        settingsValues.formFromJson(
+          this.state.settings.json, KeymapsForm.AllowdOps);
     }
     next.settings.source = to;
 
diff --git a/src/shared/settings/default.js b/src/shared/settings/default.js
index 14ed548..d187565 100644
--- a/src/shared/settings/default.js
+++ b/src/shared/settings/default.js
@@ -61,72 +61,4 @@ export default {
     }
   }
 }`,
-
-  '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.top': 'gg',
-      'scroll.bottom': 'G',
-      '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":false}': 'r',
-      '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
index 4482fbb..4e55fa0 100644
--- a/src/shared/settings/values.js
+++ b/src/shared/settings/values.js
@@ -1,5 +1,3 @@
-import DefaultSettings from './default';
-
 const operationFromFormName = (name) => {
   let [type, argStr] = name.split('?');
   let args = {};
@@ -55,16 +53,16 @@ const jsonFromValue = (value) => {
   return JSON.stringify(value, undefined, 2);
 };
 
-const formFromValue = (value) => {
-
+const formFromValue = (value, allowedOps) => {
   let keymaps = undefined;
+
   if (value.keymaps) {
-    let allowedOps = new Set(Object.keys(DefaultSettings.form.keymaps));
+    let allowedSet = new Set(allowedOps);
 
     keymaps = {};
     for (let keys of Object.keys(value.keymaps)) {
       let op = operationToFormName(value.keymaps[keys]);
-      if (allowedOps.has(op)) {
+      if (allowedSet.has(op)) {
         keymaps[op] = keys;
       }
     }
@@ -89,9 +87,9 @@ const jsonFromForm = (form) => {
   return jsonFromValue(valueFromForm(form));
 };
 
-const formFromJson = (json) => {
+const formFromJson = (json, allowedOps) => {
   let value = valueFromJson(json);
-  return formFromValue(value);
+  return formFromValue(value, allowedOps);
 };
 
 export {
diff --git a/test/shared/settings/values.test.js b/test/shared/settings/values.test.js
index 2c222b6..2632cd7 100644
--- a/test/shared/settings/values.test.js
+++ b/test/shared/settings/values.test.js
@@ -98,9 +98,11 @@ describe("settings values", () => {
         search: { default: 'google', engines: { google: 'https://google.com/search?q={}' }},
         blacklist: [ '*.slack.com']
       };
-      let form = values.formFromValue(value);
+      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');
-- 
cgit v1.2.3


From a69f33f569f97791c712234971f943288df8778a Mon Sep 17 00:00:00 2001
From: Shin'ya Ueoka <ueokande@i-beam.org>
Date: Tue, 28 Nov 2017 21:26:01 +0900
Subject: cancel migrate on failure

---
 src/settings/components/index.jsx | 56 ++++++++++++++++++++++++++++-----------
 1 file changed, 41 insertions(+), 15 deletions(-)

(limited to 'src/settings/components/index.jsx')

diff --git a/src/settings/components/index.jsx b/src/settings/components/index.jsx
index 38f7db8..73520ca 100644
--- a/src/settings/components/index.jsx
+++ b/src/settings/components/index.jsx
@@ -148,15 +148,54 @@ class SettingsComponent extends Component {
 
   bindValue(e) {
     let next = Object.assign({}, this.state);
+    let error = false;
 
     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;
 
+    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 {
+      validator.validate(JSON.parse(this.state.settings.json));
+    } catch (err) {
+      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));
   }
@@ -165,24 +204,11 @@ class SettingsComponent extends Component {
     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);
+      this.migrateToJson();
     } 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, KeymapsForm.AllowdOps);
+      this.migrateToForm();
     }
-    next.settings.source = to;
-
-    this.setState(next);
-    this.context.store.dispatch(settingActions.save(next.settings));
   }
 }
 
-- 
cgit v1.2.3


From fbdec04786e28bad45021bef4a74e7077e34282f Mon Sep 17 00:00:00 2001
From: Shin'ya Ueoka <ueokande@i-beam.org>
Date: Thu, 4 Jan 2018 18:55:24 +0900
Subject: move settings validator

---
 src/settings/components/index.jsx      |  2 +-
 src/shared/settings/validator.js       | 61 +++++++++++++++++++++++++
 src/shared/validators/setting.js       | 61 -------------------------
 test/shared/settings/validator.test.js | 82 ++++++++++++++++++++++++++++++++++
 test/shared/validators/setting.test.js | 82 ----------------------------------
 5 files changed, 144 insertions(+), 144 deletions(-)
 create mode 100644 src/shared/settings/validator.js
 delete mode 100644 src/shared/validators/setting.js
 create mode 100644 test/shared/settings/validator.test.js
 delete mode 100644 test/shared/validators/setting.test.js

(limited to 'src/settings/components/index.jsx')

diff --git a/src/settings/components/index.jsx b/src/settings/components/index.jsx
index 73520ca..074c4c7 100644
--- a/src/settings/components/index.jsx
+++ b/src/settings/components/index.jsx
@@ -5,7 +5,7 @@ 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 validator from 'shared/settings/validator';
 import * as settingsValues from 'shared/settings/values';
 
 const DO_YOU_WANT_TO_CONTINUE =
diff --git a/src/shared/settings/validator.js b/src/shared/settings/validator.js
new file mode 100644
index 0000000..949ab29
--- /dev/null
+++ b/src/shared/settings/validator.js
@@ -0,0 +1,61 @@
+import operations from 'shared/operations';
+
+const VALID_TOP_KEYS = ['keymaps', 'search', 'blacklist'];
+const VALID_OPERATION_VALUES = Object.keys(operations).map((key) => {
+  return operations[key];
+});
+
+const validateInvalidTopKeys = (settings) => {
+  let invalidKey = Object.keys(settings).find((key) => {
+    return !VALID_TOP_KEYS.includes(key);
+  });
+  if (invalidKey) {
+    throw Error(`Unknown key: "${invalidKey}"`);
+  }
+};
+
+const validateKeymaps = (keymaps) => {
+  for (let key of Object.keys(keymaps)) {
+    let value = keymaps[key];
+    if (!VALID_OPERATION_VALUES.includes(value.type)) {
+      throw Error(`Unknown operation: "${value.type}"`);
+    }
+  }
+};
+
+const validateSearch = (search) => {
+  let engines = search.engines;
+  for (let key of Object.keys(engines)) {
+    if (/\s/.test(key)) {
+      throw new Error(
+        `While space in search engine name is not allowed: "${key}"`
+      );
+    }
+    let url = engines[key];
+    if (!url.match(/{}/)) {
+      throw new Error(`No {}-placeholders in URL of "${key}"`);
+    }
+    if (url.match(/{}/g).length > 1) {
+      throw new Error(`Multiple {}-placeholders in URL of "${key}"`);
+    }
+  }
+
+  if (!search.default) {
+    throw new Error(`Default engine is not set`);
+  }
+  if (!Object.keys(engines).includes(search.default)) {
+    throw new Error(`Default engine "${search.default}" not found`);
+  }
+};
+
+const validate = (settings) => {
+  validateInvalidTopKeys(settings);
+  if (settings.keymaps) {
+    validateKeymaps(settings.keymaps);
+  }
+  if (settings.search) {
+    validateSearch(settings.search);
+  }
+};
+
+export { validate };
diff --git a/src/shared/validators/setting.js b/src/shared/validators/setting.js
deleted file mode 100644
index 949ab29..0000000
--- a/src/shared/validators/setting.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import operations from 'shared/operations';
-
-const VALID_TOP_KEYS = ['keymaps', 'search', 'blacklist'];
-const VALID_OPERATION_VALUES = Object.keys(operations).map((key) => {
-  return operations[key];
-});
-
-const validateInvalidTopKeys = (settings) => {
-  let invalidKey = Object.keys(settings).find((key) => {
-    return !VALID_TOP_KEYS.includes(key);
-  });
-  if (invalidKey) {
-    throw Error(`Unknown key: "${invalidKey}"`);
-  }
-};
-
-const validateKeymaps = (keymaps) => {
-  for (let key of Object.keys(keymaps)) {
-    let value = keymaps[key];
-    if (!VALID_OPERATION_VALUES.includes(value.type)) {
-      throw Error(`Unknown operation: "${value.type}"`);
-    }
-  }
-};
-
-const validateSearch = (search) => {
-  let engines = search.engines;
-  for (let key of Object.keys(engines)) {
-    if (/\s/.test(key)) {
-      throw new Error(
-        `While space in search engine name is not allowed: "${key}"`
-      );
-    }
-    let url = engines[key];
-    if (!url.match(/{}/)) {
-      throw new Error(`No {}-placeholders in URL of "${key}"`);
-    }
-    if (url.match(/{}/g).length > 1) {
-      throw new Error(`Multiple {}-placeholders in URL of "${key}"`);
-    }
-  }
-
-  if (!search.default) {
-    throw new Error(`Default engine is not set`);
-  }
-  if (!Object.keys(engines).includes(search.default)) {
-    throw new Error(`Default engine "${search.default}" not found`);
-  }
-};
-
-const validate = (settings) => {
-  validateInvalidTopKeys(settings);
-  if (settings.keymaps) {
-    validateKeymaps(settings.keymaps);
-  }
-  if (settings.search) {
-    validateSearch(settings.search);
-  }
-};
-
-export { validate };
diff --git a/test/shared/settings/validator.test.js b/test/shared/settings/validator.test.js
new file mode 100644
index 0000000..61d976a
--- /dev/null
+++ b/test/shared/settings/validator.test.js
@@ -0,0 +1,82 @@
+import { expect } from "chai";
+import { validate } from 'shared/settings/validator';
+
+describe("setting validator", () => {
+  describe("unknown top keys", () => {
+    it('throws an error for unknown settings', () => {
+      let settings = { keymaps: {}, poison: 123 };
+      let fn = validate.bind(undefined, settings)
+      expect(fn).to.throw(Error, 'poison');
+    })
+  });
+
+  describe("keymaps settings", () => {
+    it('throws an error for unknown operation', () => {
+      let settings = {
+        keymaps: {
+          a: { 'type': 'scroll.home' },
+          b: { 'type': 'poison.dressing' },
+        }
+      };
+      let fn = validate.bind(undefined, settings)
+      expect(fn).to.throw(Error, 'poison.dressing');
+    });
+  });
+
+  describe("search settings", () => {
+    it('throws an error for invalid search engine name', () => {
+      let settings = {
+        search: {
+          default: 'google',
+          engines: {
+            'google': 'https://google.com/search?q={}',
+            'cherry pie': 'https://cherypie.com/search?q={}',
+          }
+        }
+      };
+      let fn = validate.bind(undefined, settings)
+      expect(fn).to.throw(Error, 'cherry pie');
+    });
+
+    it('throws an error for no {}-placeholder', () => {
+      let settings = {
+        search: {
+          default: 'google',
+          engines: {
+            'google': 'https://google.com/search?q={}',
+            'yahoo': 'https://search.yahoo.com/search',
+          }
+        }
+      };
+      let fn = validate.bind(undefined, settings)
+      expect(fn).to.throw(Error, 'yahoo');
+    });
+
+    it('throws an error for no default engines', () => {
+      let settings = {
+        search: {
+          engines: {
+            'google': 'https://google.com/search?q={}',
+            'yahoo': 'https://search.yahoo.com/search?q={}',
+          }
+        }
+      };
+      let fn = validate.bind(undefined, settings)
+      expect(fn).to.throw(Error, 'Default engine');
+    });
+
+    it('throws an error for invalid default engine', () => {
+      let settings = {
+        search: {
+          default: 'twitter',
+          engines: {
+            'google': 'https://google.com/search?q={}',
+            'yahoo': 'https://search.yahoo.com/search?q={}',
+          }
+        }
+      };
+      let fn = validate.bind(undefined, settings)
+      expect(fn).to.throw(Error, 'twitter');
+    });
+  });
+});
diff --git a/test/shared/validators/setting.test.js b/test/shared/validators/setting.test.js
deleted file mode 100644
index 15d6a10..0000000
--- a/test/shared/validators/setting.test.js
+++ /dev/null
@@ -1,82 +0,0 @@
-import { expect } from "chai";
-import { validate } from 'shared/validators/setting';
-
-describe("setting validator", () => {
-  describe("unknown top keys", () => {
-    it('throws an error for unknown settings', () => {
-      let settings = { keymaps: {}, poison: 123 };
-      let fn = validate.bind(undefined, settings)
-      expect(fn).to.throw(Error, 'poison');
-    })
-  });
-
-  describe("keymaps settings", () => {
-    it('throws an error for unknown operation', () => {
-      let settings = {
-        keymaps: {
-          a: { 'type': 'scroll.home' },
-          b: { 'type': 'poison.dressing' },
-        }
-      };
-      let fn = validate.bind(undefined, settings)
-      expect(fn).to.throw(Error, 'poison.dressing');
-    });
-  });
-
-  describe("search settings", () => {
-    it('throws an error for invalid search engine name', () => {
-      let settings = {
-        search: {
-          default: 'google',
-          engines: {
-            'google': 'https://google.com/search?q={}',
-            'cherry pie': 'https://cherypie.com/search?q={}',
-          }
-        }
-      };
-      let fn = validate.bind(undefined, settings)
-      expect(fn).to.throw(Error, 'cherry pie');
-    });
-
-    it('throws an error for no {}-placeholder', () => {
-      let settings = {
-        search: {
-          default: 'google',
-          engines: {
-            'google': 'https://google.com/search?q={}',
-            'yahoo': 'https://search.yahoo.com/search',
-          }
-        }
-      };
-      let fn = validate.bind(undefined, settings)
-      expect(fn).to.throw(Error, 'yahoo');
-    });
-
-    it('throws an error for no default engines', () => {
-      let settings = {
-        search: {
-          engines: {
-            'google': 'https://google.com/search?q={}',
-            'yahoo': 'https://search.yahoo.com/search?q={}',
-          }
-        }
-      };
-      let fn = validate.bind(undefined, settings)
-      expect(fn).to.throw(Error, 'Default engine');
-    });
-
-    it('throws an error for invalid default engine', () => {
-      let settings = {
-        search: {
-          default: 'twitter',
-          engines: {
-            'google': 'https://google.com/search?q={}',
-            'yahoo': 'https://search.yahoo.com/search?q={}',
-          }
-        }
-      };
-      let fn = validate.bind(undefined, settings)
-      expect(fn).to.throw(Error, 'twitter');
-    });
-  });
-});
-- 
cgit v1.2.3


From 86df54067f8a105264a6816e115fd65efa75fb5b Mon Sep 17 00:00:00 2001
From: Shin'ya Ueoka <ueokande@i-beam.org>
Date: Fri, 5 Jan 2018 22:54:36 +0900
Subject: Add PropertiesForm

---
 src/settings/components/form/properties-form.jsx   | 60 +++++++++++++++
 src/settings/components/form/properties-form.scss  | 12 +++
 src/settings/components/index.jsx                  | 10 +++
 .../components/form/properties-form.test.jsx       | 86 ++++++++++++++++++++++
 4 files changed, 168 insertions(+)
 create mode 100644 src/settings/components/form/properties-form.jsx
 create mode 100644 src/settings/components/form/properties-form.scss
 create mode 100644 test/settings/components/form/properties-form.test.jsx

(limited to 'src/settings/components/index.jsx')

diff --git a/src/settings/components/form/properties-form.jsx b/src/settings/components/form/properties-form.jsx
new file mode 100644
index 0000000..55c8512
--- /dev/null
+++ b/src/settings/components/form/properties-form.jsx
@@ -0,0 +1,60 @@
+import './properties-form.scss';
+import { h, Component } from 'preact';
+
+class PropertiesForm extends Component {
+
+  render() {
+    let types = this.props.types;
+    let value = this.props.value;
+    if (!value) {
+      value = {};
+    }
+
+    return <div className='form-properties-form'>
+      {
+        Object.keys(types).map((name) => {
+          let type = types[name];
+          let inputType = null;
+          if (type === 'string') {
+            inputType = 'text';
+          } else if (type === 'number') {
+            inputType = 'number';
+          } else if (type === 'boolean') {
+            inputType = 'checkbox';
+          }
+          return <div key={name} className='form-properties-form-row'>
+            <label>
+              <span className='column-name'>{name}</span>
+              <input type={inputType} name={name}
+                className='column-input'
+                value={value[name] ? value[name] : ''}
+                onChange={this.bindValue.bind(this)}
+                checked={value[name]}
+              />
+            </label>
+          </div>;
+        })
+      }
+    </div>;
+  }
+
+  bindValue(e) {
+    if (!this.props.onChange) {
+      return;
+    }
+
+    let name = e.target.name;
+    let next = Object.assign({}, this.props.value);
+    if (e.target.type.toLowerCase() === 'checkbox') {
+      next[name] = e.target.checked;
+    } else if (e.target.type.toLowerCase() === 'number') {
+      next[name] = Number(e.target.value);
+    } else {
+      next[name] = e.target.value;
+    }
+
+    this.props.onChange(next);
+  }
+}
+
+export default PropertiesForm;
diff --git a/src/settings/components/form/properties-form.scss b/src/settings/components/form/properties-form.scss
new file mode 100644
index 0000000..7c9e167
--- /dev/null
+++ b/src/settings/components/form/properties-form.scss
@@ -0,0 +1,12 @@
+.form-properties-form {
+  &-row {
+    .column-name {
+      display: inline-block;
+      min-width: 5rem;
+      font-weight: bold;
+    }
+    .column-input {
+      line-height: 2.2rem;
+    }
+  }
+}
diff --git a/src/settings/components/index.jsx b/src/settings/components/index.jsx
index 074c4c7..c41aa6b 100644
--- a/src/settings/components/index.jsx
+++ b/src/settings/components/index.jsx
@@ -4,6 +4,8 @@ import Input from './ui/input';
 import SearchForm from './form/search-form';
 import KeymapsForm from './form/keymaps-form';
 import BlacklistForm from './form/blacklist-form';
+import PropertiesForm from './form/properties-form';
+import PropertyTypes from 'shared/settings/property-types';
 import * as settingActions from 'settings/actions/setting';
 import * as validator from 'shared/settings/validator';
 import * as settingsValues from 'shared/settings/values';
@@ -65,6 +67,14 @@ class SettingsComponent extends Component {
           onChange={value => this.bindForm('blacklist', value)}
         />
       </fieldset>
+      <fieldset>
+        <legend>Properties</legend>
+        <PropertiesForm
+          types={PropertyTypes}
+          value={this.state.settings.form.properties}
+          onChange={value => this.bindForm('properties', value)}
+        />
+      </fieldset>
     </div>;
   }
 
diff --git a/test/settings/components/form/properties-form.test.jsx b/test/settings/components/form/properties-form.test.jsx
new file mode 100644
index 0000000..4807361
--- /dev/null
+++ b/test/settings/components/form/properties-form.test.jsx
@@ -0,0 +1,86 @@
+import { expect } from 'chai';
+import { h, render } from 'preact';
+import PropertiesForm from 'settings/components/form/properties-form'
+
+describe("settings/form/PropertiesForm", () => {
+  beforeEach(() => {
+    document.body.innerHTML = '';
+  });
+
+  describe('render', () => {
+    it('renders PropertiesForm', () => {
+      let types = {
+        mystr: 'string',
+        mynum: 'number',
+        mybool: 'boolean',
+        empty: 'string',
+      }
+      let value = {
+        mystr: 'abc',
+        mynum: 123,
+        mybool: true,
+      };
+      render(<PropertiesForm types={types} value={value} />, document.body);
+
+      let strInput = document.querySelector('input[name=mystr]');
+      let numInput = document.querySelector('input[name=mynum]');
+      let boolInput = document.querySelector('input[name=mybool]');
+      let emptyInput = document.querySelector('input[name=empty]');
+
+      expect(strInput.type).to.equals('text');
+      expect(strInput.value).to.equal('abc');
+      expect(numInput.type).to.equals('number');
+      expect(numInput.value).to.equal('123');
+      expect(boolInput.type).to.equals('checkbox');
+      expect(boolInput.checked).to.be.true;
+      expect(emptyInput.type).to.equals('text');
+      expect(emptyInput.value).to.be.empty;
+    });
+  });
+
+  describe('onChange', () => {
+    it('invokes onChange event on text changed', (done) => {
+      render(<PropertiesForm
+        types={{ 'myvalue': 'string' }}
+        value={{ 'myvalue': 'abc' }}
+        onChange={value => {
+          expect(value).to.have.property('myvalue', 'abcd');
+          done();
+        }}
+      />, document.body);
+
+      let input = document.querySelector('input[name=myvalue]');
+      input.value = 'abcd'
+      input.dispatchEvent(new Event('change'))
+    });
+
+    it('invokes onChange event on number changeed', (done) => {
+      render(<PropertiesForm
+        types={{ 'myvalue': 'number' }}
+        value={{ '': 123 }}
+        onChange={value => {
+          expect(value).to.have.property('myvalue', 1234);
+          done();
+        }}
+      />, document.body);
+
+      let input = document.querySelector('input[name=myvalue]');
+      input.value = '1234'
+      input.dispatchEvent(new Event('change'))
+    });
+
+    it('invokes onChange event on checkbox changed', (done) => {
+      render(<PropertiesForm
+        types={{ 'myvalue': 'boolean' }}
+        value={{ 'myvalue': false }}
+        onChange={value => {
+          expect(value).to.have.property('myvalue', true);
+          done();
+        }}
+      />, document.body);
+
+      let input = document.querySelector('input[name=myvalue]');
+      input.click();
+    });
+  });
+});
-- 
cgit v1.2.3


From fe48dce1c9b6f003c669cb19542063c8ac0c91ba Mon Sep 17 00:00:00 2001
From: Shin'ya Ueoka <ueokande@i-beam.org>
Date: Mon, 8 Jan 2018 16:25:09 +0900
Subject: default property values

---
 src/settings/components/index.jsx     |  4 ++--
 src/shared/settings/properties.js     | 15 +++++++++++++++
 src/shared/settings/property-types.js |  6 ------
 src/shared/settings/validator.js      |  5 ++---
 src/shared/settings/values.js         |  6 +++++-
 5 files changed, 24 insertions(+), 12 deletions(-)
 create mode 100644 src/shared/settings/properties.js
 delete mode 100644 src/shared/settings/property-types.js

(limited to 'src/settings/components/index.jsx')

diff --git a/src/settings/components/index.jsx b/src/settings/components/index.jsx
index c41aa6b..d7696a1 100644
--- a/src/settings/components/index.jsx
+++ b/src/settings/components/index.jsx
@@ -5,7 +5,7 @@ import SearchForm from './form/search-form';
 import KeymapsForm from './form/keymaps-form';
 import BlacklistForm from './form/blacklist-form';
 import PropertiesForm from './form/properties-form';
-import PropertyTypes from 'shared/settings/property-types';
+import * as properties from 'shared/settings/properties';
 import * as settingActions from 'settings/actions/setting';
 import * as validator from 'shared/settings/validator';
 import * as settingsValues from 'shared/settings/values';
@@ -70,7 +70,7 @@ class SettingsComponent extends Component {
       <fieldset>
         <legend>Properties</legend>
         <PropertiesForm
-          types={PropertyTypes}
+          types={properties.types}
           value={this.state.settings.form.properties}
           onChange={value => this.bindForm('properties', value)}
         />
diff --git a/src/shared/settings/properties.js b/src/shared/settings/properties.js
new file mode 100644
index 0000000..ff8039b
--- /dev/null
+++ b/src/shared/settings/properties.js
@@ -0,0 +1,15 @@
+const types = {
+  // TODO describe property types here
+  // mystr: 'string',
+  // mynum: 'number',
+  // mybool: 'boolean',
+};
+
+const defaults = {
+  // TODO describe property defaults values
+  // mystr: 'hello',
+  // mynum: 123,
+  // mybool: true,
+};
+
+export { types, defaults };
diff --git a/src/shared/settings/property-types.js b/src/shared/settings/property-types.js
deleted file mode 100644
index bcfa809..0000000
--- a/src/shared/settings/property-types.js
+++ /dev/null
@@ -1,6 +0,0 @@
-export default {
-  // TODO describe property types here
-  // mystr: 'string',
-  // mynum: 'number',
-  // mybool: 'boolean',
-};
diff --git a/src/shared/settings/validator.js b/src/shared/settings/validator.js
index 6fadac7..744f63d 100644
--- a/src/shared/settings/validator.js
+++ b/src/shared/settings/validator.js
@@ -1,5 +1,4 @@
 import operations from 'shared/operations';
-import propertyTypes from './property-types';
 
 const VALID_TOP_KEYS = ['keymaps', 'search', 'blacklist', 'properties'];
 const VALID_OPERATION_VALUES = Object.keys(operations).map((key) => {
@@ -51,10 +50,10 @@ const validateSearch = (search) => {
 
 const validateProperties = (properties) => {
   for (let name of Object.keys(properties)) {
-    if (!propertyTypes[name]) {
+    if (!properties.types[name]) {
       throw new Error(`Unknown property name: "${name}"`);
     }
-    if (typeof properties[name] !== propertyTypes[name]) {
+    if (typeof properties[name] !== properties.types[name]) {
       throw new Error(`Invalid type for property: "${name}"`);
     }
   }
diff --git a/src/shared/settings/values.js b/src/shared/settings/values.js
index 5027ba5..bd03be2 100644
--- a/src/shared/settings/values.js
+++ b/src/shared/settings/values.js
@@ -1,3 +1,5 @@
+import * as properties from './properties';
+
 const operationFromFormName = (name) => {
   let [type, argStr] = name.split('?');
   let args = {};
@@ -81,11 +83,13 @@ const formFromValue = (value, allowedOps) => {
     }
   }
 
+  let formProperties = Object.assign({}, properties.defaults, value.properties);
+
   return {
     keymaps,
     search,
     blacklist: value.blacklist,
-    properties: value.properties,
+    properties: formProperties,
   };
 };
 
-- 
cgit v1.2.3


From a0dd098fc63a22e666b18d00c365b23cdc3dfb35 Mon Sep 17 00:00:00 2001
From: LeBronse <warnock881@gmail.com>
Date: Tue, 6 Feb 2018 04:08:15 +0800
Subject: fixed grammar for warning prompt

---
 src/settings/components/index.jsx | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

(limited to 'src/settings/components/index.jsx')

diff --git a/src/settings/components/index.jsx b/src/settings/components/index.jsx
index d7696a1..e96fac3 100644
--- a/src/settings/components/index.jsx
+++ b/src/settings/components/index.jsx
@@ -11,8 +11,8 @@ import * as validator from 'shared/settings/validator';
 import * as settingsValues from 'shared/settings/values';
 
 const DO_YOU_WANT_TO_CONTINUE =
-  'Some settings in JSON can be lose on migrating.  ' +
-  'Do you want to continue ?';
+  'Some settings in JSON can be lost when migrating.  ' +
+  'Do you want to continue?';
 
 class SettingsComponent extends Component {
   constructor(props, context) {
-- 
cgit v1.2.3

