Implement friendlier whitelist editing UI

This commit is contained in:
hensm
2018-10-23 00:56:11 +01:00
parent adfbd5b2ef
commit 5d340864c4
2 changed files with 290 additions and 57 deletions

View File

@@ -41,3 +41,25 @@
*:invalid {
box-shadow: 0 0 1.5px 1px red;
}
.editable-list {
justify-content: end;
}
.editable-list__item {
align-items: center;
display: flex;
height: 2em;
}
.editable-list__title {
flex: 1;
}
.editable-list__edit-field {
width: -moz-available;
margin-inline-end: 1em;
}
.editable-list__raw-view {
max-height: 300px;
resize: vertical;
width: 100%;
}

View File

@@ -7,20 +7,268 @@ import ReactDOM from "react-dom";
const _ = browser.i18n.getMessage;
const MATCH_PATTERN_REGEX = /^(?:(\*|https?|file|ftp):\/\/((?:\*\.|[^\/\*])+)(\/.*)|<all_urls>)$/;
const MATCH_PATTERN_REGEX = /^(?:(?:(\*|https?|ftp):\/\/((?:\*\.|[^\/\*])+)|(file):\/\/\/?(?:\*\.|[^\/\*])+)(\/.*)|<all_urls>)$/;
class EditableListItem extends React.Component {
constructor (props) {
super(props);
this.state = {
editing: this.props.editing || false
, editValue: ""
};
this.handleRemove = this.handleRemove.bind(this);
this.handleEditBegin = this.handleEditBegin.bind(this);
this.handleEditEnd = this.handleEditEnd.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
this.handleInputKeyPress = this.handleInputKeyPress.bind(this);
}
handleRemove () {
this.props.onRemove(this.props.text);
}
handleEditBegin () {
this.setState({
editing: true
, editValue: this.props.text
});
}
handleEditEnd (ev) {
if (this.props.editing
&& !this.props.itemPattern.test(this.state.editValue)) {
ev.target.setCustomValidity(this.props.itemPatternError);
}
if (!ev.target.validity.valid) {
return;
}
this.props.onEdit(this.props.text, this.state.editValue);
this.setState({
editing: false
, editValue: ""
});
}
handleInputChange (ev) {
this.setState({
editValue: ev.target.value
});
if (!this.props.itemPattern.test(ev.target.value)) {
ev.target.setCustomValidity(this.props.itemPatternError);
} else {
ev.target.setCustomValidity("");
}
}
handleInputKeyPress (ev) {
if (ev.key === "Enter") {
this.handleEditEnd({ target: ev.target });
}
}
render () {
return (
<li className="editable-list__item">
<div className="editable-list__title"
onDoubleClick={this.handleEditBegin}>
{ this.state.editing
? <input className="editable-list__edit-field"
type="text"
autoFocus
value={this.state.editValue}
onBlur={this.handleEditEnd}
onChange={this.handleInputChange}
onKeyPress={this.handleInputKeyPress}/>
: this.props.text }
</div>
<button onClick={this.handleEditBegin}>
Edit
</button>
<button onClick={this.handleRemove}>
Remove
</button>
</li>
);
}
}
class EditableList extends React.Component {
constructor (props) {
super(props);
this.state = {
items: new Set(this.props.data)
, addingNewItem: false
, rawView: false
, rawViewValue: ""
};
this.handleItemRemove = this.handleItemRemove.bind(this);
this.handleItemEdit = this.handleItemEdit.bind(this);
this.handleSwitchView = this.handleSwitchView.bind(this);
this.handleSaveRaw = this.handleSaveRaw.bind(this);
this.handleRawViewTextAreaChange = this.handleRawViewTextAreaChange.bind(this);
this.handleAddItem = this.handleAddItem.bind(this);
this.handleNewItemRemove = this.handleNewItemRemove.bind(this);
this.handleNewItemEdit = this.handleNewItemEdit.bind(this);
}
handleItemRemove (item) {
this.setState(currentState => {
const newItems = new Set(currentState.items);
newItems.delete(item);
return {
items: newItems
};
}, () => {
this.props.onListChange(Array.from(this.state.items));
});
}
handleItemEdit (item, newValue) {
this.setState(currentState => ({
items: new Set([...currentState.items]
.map(item_ => item_ === item ? newValue : item_))
}), () => {
this.props.onListChange(Array.from(this.state.items));
});
}
handleSwitchView () {
this.setState(currentState => {
if (currentState.rawView) {
return {
rawView: false
, rawViewValue: ""
};
}
return {
rawView: true
, rawViewValue: [...currentState.items.values()].join("\n")
};
});
}
handleSaveRaw () {
this.setState(currentState => {
const newItems = currentState.rawViewValue.split("\n")
.filter(item => item !== "");
if ("itemPattern" in this.props) {
for (const item of newItems) {
if (!this.props.itemPattern.test(item)) {
this.rawViewTextArea.setCustomValidity(
`${this.props.itemPatternError}: ${item}`);
return;
}
}
}
return {
items: new Set(newItems)
};
}, () => {
this.props.onListChange(Array.from(this.state.items));
});
}
handleRawViewTextAreaChange (ev) {
if (this.rawViewTextArea.scrollHeight > this.rawViewTextArea.clientHeight) {
this.rawViewTextArea.style.height = `${this.rawViewTextArea.scrollHeight}px`;
}
this.setState({
rawViewValue: ev.target.value
});
}
handleAddItem () {
this.setState({
addingNewItem: true
});
}
handleNewItemRemove () {
this.setState({
addingNewItem: false
});
}
handleNewItemEdit (item, newItem) {
this.setState(currentState => ({
items: [ ...currentState.items, newItem ]
, addingNewItem: false
}), () => {
this.props.onListChange(Array.from(this.state.items));
});
}
render () {
const items = Array.from(this.state.items.values());
return (
<div className="editable-list">
<button className="editable-list__view-button"
onClick={this.handleSwitchView}>
{ this.state.rawView ? "Basic" : "Raw" } View
</button>
{ this.state.rawView &&
<button className="editable-list__save-raw-button"
onClick={this.handleSaveRaw}>
Save Raw
</button> }
<hr />
{
this.state.rawView
? ( <textarea className="editable-list__raw-view"
rows={items.length}
value={this.state.rawViewValue}
onChange={this.handleRawViewTextAreaChange}
ref={el => { this.rawViewTextArea = el }}>
</textarea> )
: ( <ul className="editable-list__items">
{ items.map((item, i) =>
<EditableListItem text={item}
itemPattern={this.props.itemPattern}
itemPatternError={this.props.itemPatternError}
onRemove={this.handleItemRemove}
onEdit={this.handleItemEdit}
key={i} /> )}
{ this.state.addingNewItem &&
<EditableListItem text={""}
itemPattern={this.props.itemPattern}
itemPatternError={this.props.itemPatternError}
onRemove={this.handleNewItemRemove}
onEdit={this.handleNewItemEdit}
editing={true} /> }
<button className="editable-list__add-button"
onClick={this.handleAddItem}>
Add Item
</button>
</ul> )
}
</div>
);
}
}
class OptionsApp extends Component {
constructor (props) {
super(props);
this.state = {
isFormValid: true
...props.options
, isFormValid: true
};
this.handleFormSubmit = this.handleFormSubmit.bind(this);
this.handleFormChange = this.handleFormChange.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
this.handleUAWhitelistChange = this.handleUAWhitelistChange.bind(this);
this.handleFormSubmit = this.handleFormSubmit.bind(this);
this.handleFormChange = this.handleFormChange.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
this.handleListChange = this.handleListChange.bind(this);
}
/**
@@ -29,35 +277,14 @@ class OptionsApp extends Component {
setStorage () {
return browser.storage.sync.set({
options: {
option_localMediaEnabled : this.state.option_localMediaEnabled
option_localMediaEnabled : this.state.option_localMediaEnabled
, option_localMediaServerPort : this.state.option_localMediaServerPort
, option_uaWhitelistEnabled : this.state.option_uaWhitelistEnabled
, option_uaWhitelist : this.state.option_uaWhitelist
, option_uaWhitelistEnabled : this.state.option_uaWhitelistEnabled
, option_uaWhitelist : this.state.option_uaWhitelist
}
});
}
/**
* Get current options state from storage and set initial
*/
async componentDidMount () {
const { options } = await browser.storage.sync.get("options");
if (options) {
this.setState({
...options
, isFormValid: this.form.checkValidity()
});
} else {
try {
await this.setStorage();
} catch (err) {
// TODO
}
}
}
async handleFormSubmit (ev) {
ev.preventDefault();
@@ -108,24 +335,9 @@ class OptionsApp extends Component {
});
}
handleUAWhitelistChange (ev) {
// Split patterns by newline
const matchPatterns = ev.target.value.split("\n");
// Validate each pattern against a regexp
for (const pattern of matchPatterns) {
if (!MATCH_PATTERN_REGEX.test(pattern)) {
// Set as invalid
ev.target.setCustomValidity(`Match pattern invalid: ${pattern}`);
break;
}
// Set as valid
ev.target.setCustomValidity("");
}
handleListChange (data) {
this.setState({
[ ev.target.name ]: matchPatterns
option_uaWhitelist: data
});
}
@@ -188,13 +400,10 @@ class OptionsApp extends Component {
<div className="option-label">
{ _("options_option_uaWhitelist") }
</div>
<textarea name="option_uaWhitelist"
value={ this.state.option_uaWhitelist
&& this.state.option_uaWhitelist.join("\n") }
required
rows="8"
onChange={ this.handleUAWhitelistChange }>
</textarea>
<EditableList data={ this.state.option_uaWhitelist }
onListChange={ this.handleListChange }
itemPattern={MATCH_PATTERN_REGEX}
itemPatternError="Invalid match pattern"/>
</label>
</fieldset>
@@ -210,6 +419,8 @@ class OptionsApp extends Component {
}
ReactDOM.render(
<OptionsApp />
, document.querySelector("#root"));
browser.storage.sync.get("options").then(({options}) => {
ReactDOM.render(
<OptionsApp options={options} />
, document.querySelector("#root"));
});