mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2026-01-02 12:52:38 +00:00
Merge pull request #3735 from Gazook89/Functional-Tag-Editor
TagInput - Functional component for tag-like inputs
This commit is contained in:
@@ -6,7 +6,7 @@ const _ = require('lodash');
|
|||||||
const request = require('../../utils/request-middleware.js');
|
const request = require('../../utils/request-middleware.js');
|
||||||
const Nav = require('naturalcrit/nav/nav.jsx');
|
const Nav = require('naturalcrit/nav/nav.jsx');
|
||||||
const Combobox = require('client/components/combobox.jsx');
|
const Combobox = require('client/components/combobox.jsx');
|
||||||
const StringArrayEditor = require('../stringArrayEditor/stringArrayEditor.jsx');
|
const TagInput = require('../tagInput/tagInput.jsx');
|
||||||
|
|
||||||
|
|
||||||
const Themes = require('themes/themes.json');
|
const Themes = require('themes/themes.json');
|
||||||
@@ -341,10 +341,11 @@ const MetadataEditor = createClass({
|
|||||||
{this.renderThumbnail()}
|
{this.renderThumbnail()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StringArrayEditor label='tags' valuePatterns={[/^(?:(?:group|meta|system|type):)?[A-Za-z0-9][A-Za-z0-9 \/.\-]{0,40}$/]}
|
<TagInput label='tags' valuePatterns={[/^(?:(?:group|meta|system|type):)?[A-Za-z0-9][A-Za-z0-9 \/.\-]{0,40}$/]}
|
||||||
placeholder='add tag' unique={true}
|
placeholder='add tag' unique={true}
|
||||||
values={this.props.metadata.tags}
|
values={this.props.metadata.tags}
|
||||||
onChange={(e)=>this.handleFieldChange('tags', e)}/>
|
onChange={(e)=>this.handleFieldChange('tags', e)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className='field systems'>
|
<div className='field systems'>
|
||||||
<label>systems</label>
|
<label>systems</label>
|
||||||
@@ -363,12 +364,13 @@ const MetadataEditor = createClass({
|
|||||||
|
|
||||||
{this.renderAuthors()}
|
{this.renderAuthors()}
|
||||||
|
|
||||||
<StringArrayEditor label='invited authors' valuePatterns={[/.+/]}
|
<TagInput label='invited authors' valuePatterns={[/.+/]}
|
||||||
validators={[(v)=>!this.props.metadata.authors?.includes(v)]}
|
validators={[(v)=>!this.props.metadata.authors?.includes(v)]}
|
||||||
placeholder='invite author' unique={true}
|
placeholder='invite author' unique={true}
|
||||||
values={this.props.metadata.invitedAuthors}
|
values={this.props.metadata.invitedAuthors}
|
||||||
notes={['Invited author usernames are case sensitive.', 'After adding an invited author, send them the edit link. There, they can choose to accept or decline the invitation.']}
|
notes={['Invited author usernames are case sensitive.', 'After adding an invited author, send them the edit link. There, they can choose to accept or decline the invitation.']}
|
||||||
onChange={(e)=>this.handleFieldChange('invitedAuthors', e)}/>
|
onChange={(e)=>this.handleFieldChange('invitedAuthors', e)}
|
||||||
|
/>
|
||||||
|
|
||||||
<h2>Privacy</h2>
|
<h2>Privacy</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -272,7 +272,7 @@
|
|||||||
&:last-child { border-radius : 0 0.5em 0.5em 0; }
|
&:last-child { border-radius : 0 0.5em 0.5em 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.tag {
|
||||||
padding : 0.3em;
|
padding : 0.3em;
|
||||||
margin : 2px;
|
margin : 2px;
|
||||||
font-size : 0.9em;
|
font-size : 0.9em;
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
const React = require('react');
|
|
||||||
const createClass = require('create-react-class');
|
|
||||||
const _ = require('lodash');
|
|
||||||
|
|
||||||
const StringArrayEditor = createClass({
|
|
||||||
displayName : 'StringArrayEditor',
|
|
||||||
getDefaultProps : function() {
|
|
||||||
return {
|
|
||||||
label : '',
|
|
||||||
values : [],
|
|
||||||
valuePatterns : null,
|
|
||||||
validators : [],
|
|
||||||
placeholder : '',
|
|
||||||
notes : [],
|
|
||||||
unique : false,
|
|
||||||
cannotEdit : [],
|
|
||||||
onChange : ()=>{}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
getInitialState : function() {
|
|
||||||
return {
|
|
||||||
valueContext : !!this.props.values ? this.props.values.map((value)=>({
|
|
||||||
value,
|
|
||||||
editing : false
|
|
||||||
})) : [],
|
|
||||||
temporaryValue : '',
|
|
||||||
updateValue : ''
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
componentDidUpdate : function(prevProps) {
|
|
||||||
if(!_.eq(this.props.values, prevProps.values)) {
|
|
||||||
this.setState({
|
|
||||||
valueContext : this.props.values ? this.props.values.map((newValue)=>({
|
|
||||||
value : newValue,
|
|
||||||
editing : this.state.valueContext.find(({ value })=>value === newValue)?.editing || false
|
|
||||||
})) : []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
handleChange : function(value) {
|
|
||||||
this.props.onChange({
|
|
||||||
target : {
|
|
||||||
value
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
addValue : function(value){
|
|
||||||
this.handleChange(_.uniq([...this.props.values, value]));
|
|
||||||
this.setState({
|
|
||||||
temporaryValue : ''
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
removeValue : function(index){
|
|
||||||
this.handleChange(this.props.values.filter((_, i)=>i !== index));
|
|
||||||
},
|
|
||||||
|
|
||||||
updateValue : function(value, index){
|
|
||||||
const valueContext = this.state.valueContext;
|
|
||||||
valueContext[index].value = value;
|
|
||||||
valueContext[index].editing = false;
|
|
||||||
this.handleChange(valueContext.map((context)=>context.value));
|
|
||||||
this.setState({ valueContext, updateValue: '' });
|
|
||||||
},
|
|
||||||
|
|
||||||
editValue : function(index){
|
|
||||||
if(!!this.props.cannotEdit && this.props.cannotEdit.includes(this.props.values[index])) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const valueContext = this.state.valueContext.map((context, i)=>{
|
|
||||||
context.editing = index === i;
|
|
||||||
return context;
|
|
||||||
});
|
|
||||||
this.setState({ valueContext, updateValue: this.props.values[index] });
|
|
||||||
},
|
|
||||||
|
|
||||||
valueIsValid : function(value, index) {
|
|
||||||
const values = _.clone(this.props.values);
|
|
||||||
if(index !== undefined) {
|
|
||||||
values.splice(index, 1);
|
|
||||||
}
|
|
||||||
const matchesPatterns = !this.props.valuePatterns || this.props.valuePatterns.some((pattern)=>!!(value || '').match(pattern));
|
|
||||||
const uniqueIfSet = !this.props.unique || !values.includes(value);
|
|
||||||
const passesValidators = !this.props.validators || this.props.validators.every((validator)=>validator(value));
|
|
||||||
return matchesPatterns && uniqueIfSet && passesValidators;
|
|
||||||
},
|
|
||||||
|
|
||||||
handleValueInputKeyDown : function(event, index) {
|
|
||||||
if(event.key === 'Enter') {
|
|
||||||
if(this.valueIsValid(event.target.value, index)) {
|
|
||||||
if(index !== undefined) {
|
|
||||||
this.updateValue(event.target.value, index);
|
|
||||||
} else {
|
|
||||||
this.addValue(event.target.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if(event.key === 'Escape') {
|
|
||||||
this.closeEditInput(index);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
closeEditInput : function(index) {
|
|
||||||
const valueContext = this.state.valueContext;
|
|
||||||
valueContext[index].editing = false;
|
|
||||||
this.setState({ valueContext, updateValue: '' });
|
|
||||||
},
|
|
||||||
|
|
||||||
render : function() {
|
|
||||||
const valueElements = Object.values(this.state.valueContext).map((context, i)=>context.editing
|
|
||||||
? <React.Fragment key={i}>
|
|
||||||
<div className='input-group'>
|
|
||||||
<input type='text' className={`value ${this.valueIsValid(this.state.updateValue, i) ? '' : 'invalid'}`} autoFocus placeholder={this.props.placeholder}
|
|
||||||
value={this.state.updateValue}
|
|
||||||
onKeyDown={(e)=>this.handleValueInputKeyDown(e, i)}
|
|
||||||
onChange={(e)=>this.setState({ updateValue: e.target.value })}/>
|
|
||||||
{<div className='icon steel' onClick={(e)=>{ e.stopPropagation(); this.closeEditInput(i); }}><i className='fa fa-undo fa-fw'/></div>}
|
|
||||||
{this.valueIsValid(this.state.updateValue, i) ? <div className='icon steel' onClick={(e)=>{ e.stopPropagation(); this.updateValue(this.state.updateValue, i); }}><i className='fa fa-check fa-fw'/></div> : null}
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
: <div className='badge' key={i} onClick={()=>this.editValue(i)}>{context.value}
|
|
||||||
{!!this.props.cannotEdit && this.props.cannotEdit.includes(context.value) ? null : <div className='icon steel' onClick={(e)=>{ e.stopPropagation(); this.removeValue(i); }}><i className='fa fa-times fa-fw'/></div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return <div className='field'>
|
|
||||||
<label>{this.props.label}</label>
|
|
||||||
<div style={{ flex: '1 0' }} className='value'>
|
|
||||||
<div className='list'>
|
|
||||||
{valueElements}
|
|
||||||
<div className='input-group'>
|
|
||||||
<input type='text' className={`value ${this.valueIsValid(this.state.temporaryValue) ? '' : 'invalid'}`} placeholder={this.props.placeholder}
|
|
||||||
value={this.state.temporaryValue}
|
|
||||||
onKeyDown={(e)=>this.handleValueInputKeyDown(e)}
|
|
||||||
onChange={(e)=>this.setState({ temporaryValue: e.target.value })}/>
|
|
||||||
{this.valueIsValid(this.state.temporaryValue) ? <div className='icon steel' onClick={(e)=>{ e.stopPropagation(); this.addValue(this.state.temporaryValue); }}><i className='fa fa-check fa-fw'/></div> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{this.props.notes ? this.props.notes.map((n, index)=><p key={index}><small>{n}</small></p>) : null}
|
|
||||||
</div>
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = StringArrayEditor;
|
|
||||||
105
client/homebrew/editor/tagInput/tagInput.jsx
Normal file
105
client/homebrew/editor/tagInput/tagInput.jsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
require('./tagInput.less');
|
||||||
|
const React = require('react');
|
||||||
|
const { useState, useEffect } = React;
|
||||||
|
const _ = require('lodash');
|
||||||
|
|
||||||
|
const TagInput = ({ unique = true, values = [], ...props }) => {
|
||||||
|
const [tempInputText, setTempInputText] = useState('');
|
||||||
|
const [tagList, setTagList] = useState(values.map((value) => ({ value, editing: false })));
|
||||||
|
|
||||||
|
useEffect(()=>{
|
||||||
|
handleChange(tagList.map((context)=>context.value))
|
||||||
|
}, [tagList])
|
||||||
|
|
||||||
|
const handleChange = (value)=>{
|
||||||
|
props.onChange({
|
||||||
|
target : { value }
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputKeyDown = ({ evt, value, index, options = {} }) => {
|
||||||
|
if (_.includes(['Enter', ','], evt.key)) {
|
||||||
|
evt.preventDefault();
|
||||||
|
submitTag(evt.target.value, value, index);
|
||||||
|
if (options.clear) {
|
||||||
|
setTempInputText('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitTag = (newValue, originalValue, index) => {
|
||||||
|
setTagList((prevContext) => {
|
||||||
|
// remove existing tag
|
||||||
|
if(newValue === null){
|
||||||
|
return [...prevContext].filter((context, i)=>i !== index);
|
||||||
|
}
|
||||||
|
// add new tag
|
||||||
|
if(originalValue === null){
|
||||||
|
return [...prevContext, { value: newValue, editing: false }]
|
||||||
|
}
|
||||||
|
// update existing tag
|
||||||
|
return prevContext.map((context, i) => {
|
||||||
|
if (i === index) {
|
||||||
|
return { ...context, value: newValue, editing: false };
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const editTag = (index) => {
|
||||||
|
setTagList((prevContext) => {
|
||||||
|
return prevContext.map((context, i) => {
|
||||||
|
if (i === index) {
|
||||||
|
return { ...context, editing: true };
|
||||||
|
}
|
||||||
|
return { ...context, editing: false };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderReadTag = (context, index) => {
|
||||||
|
return (
|
||||||
|
<li key={index}
|
||||||
|
data-value={context.value}
|
||||||
|
className='tag'
|
||||||
|
onClick={() => editTag(index)}>
|
||||||
|
{context.value}
|
||||||
|
<button onClick={(evt)=>{evt.stopPropagation(); submitTag(null, context.value, index)}}><i className='fa fa-times fa-fw'/></button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderWriteTag = (context, index) => {
|
||||||
|
return (
|
||||||
|
<input type='text'
|
||||||
|
key={index}
|
||||||
|
defaultValue={context.value}
|
||||||
|
onKeyDown={(evt) => handleInputKeyDown({evt, value: context.value, index: index})}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='field'>
|
||||||
|
<label>{props.label}</label>
|
||||||
|
<div className='value'>
|
||||||
|
<ul className='list'>
|
||||||
|
{tagList.map((context, index) => { return context.editing ? renderWriteTag(context, index) : renderReadTag(context, index); })}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
className='value'
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
value={tempInputText}
|
||||||
|
onChange={(e) => setTempInputText(e.target.value)}
|
||||||
|
onKeyDown={(evt) => handleInputKeyDown({ evt, value: null, options: { clear: true } })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = TagInput;
|
||||||
0
client/homebrew/editor/tagInput/tagInput.less
Normal file
0
client/homebrew/editor/tagInput/tagInput.less
Normal file
Reference in New Issue
Block a user