Refactor: component Tags.jsx (#4448)
* Refactor: component Tags.jsx * Revamp Changes lint fix. * Fixes method binding. * Destructure Fix. * Lint fix * Button Lint Fix. * Event Key Hash Change.
This commit is contained in:
parent
fca98d952d
commit
5028e1a670
1 changed files with 204 additions and 172 deletions
|
|
@ -28,7 +28,6 @@ const LETTERS = /[a-z]/i;
|
|||
class Tags extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
selectedIndex: -1,
|
||||
searchResults: [],
|
||||
|
|
@ -48,7 +47,8 @@ class Tags extends Component {
|
|||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.listing === true) {
|
||||
const { listing } = this.props;
|
||||
if (listing === true) {
|
||||
this.setState({
|
||||
additionalTags: {
|
||||
jobs: [
|
||||
|
|
@ -87,148 +87,44 @@ class Tags extends Component {
|
|||
|
||||
componentDidUpdate() {
|
||||
// stop cursor jumping if the user goes back to edit previous tags
|
||||
const { cursorIdx, prevLen } = this.state;
|
||||
if (
|
||||
this.state.cursorIdx < this.textArea.value.length &&
|
||||
this.textArea.value.length < this.state.prevLen + 1
|
||||
cursorIdx < this.textArea.value.length &&
|
||||
this.textArea.value.length < prevLen + 1
|
||||
) {
|
||||
this.textArea.selectionEnd = this.state.cursorIdx;
|
||||
this.textArea.selectionEnd = cursorIdx;
|
||||
this.textArea.selectionStart = this.textArea.selectionEnd;
|
||||
}
|
||||
}
|
||||
|
||||
get selected() {
|
||||
return this.props.defaultValue
|
||||
const { defaultValue } = this.props;
|
||||
return defaultValue
|
||||
.split(',')
|
||||
.map(item => item !== undefined && item.trim())
|
||||
.filter(item => item.length > 0);
|
||||
}
|
||||
|
||||
get isTopOfSearchResults() {
|
||||
return this.state.selectedIndex <= 0;
|
||||
const { selectedIndex } = this.state;
|
||||
return selectedIndex <= 0;
|
||||
}
|
||||
|
||||
get isBottomOfSearchResults() {
|
||||
return this.state.selectedIndex >= this.state.searchResults.length - 1;
|
||||
const { selectedIndex, searchResults } = this.state;
|
||||
return selectedIndex >= searchResults.length - 1;
|
||||
}
|
||||
|
||||
get isSearchResultSelected() {
|
||||
return this.state.selectedIndex > -1;
|
||||
const { selectedIndex } = this.state;
|
||||
return selectedIndex > -1;
|
||||
}
|
||||
|
||||
render() {
|
||||
let searchResultsHTML = '';
|
||||
const searchResultsRows = this.state.searchResults.map((tag, index) => (
|
||||
<div
|
||||
tabIndex="-1"
|
||||
className={`${this.props.classPrefix}__tagoptionrow ${
|
||||
this.props.classPrefix
|
||||
}__tagoptionrow--${
|
||||
this.state.selectedIndex === index ? 'active' : 'inactive'
|
||||
}`}
|
||||
onClick={this.handleTagClick}
|
||||
data-content={tag.name}
|
||||
>
|
||||
{tag.name}
|
||||
{(tag.rules_html && tag.rules_html.length > 0) ? <button
|
||||
type='button'
|
||||
className={`${this.props.classPrefix}__tagsoptionrulesbutton`}
|
||||
onClick={this.handleRulesClick}
|
||||
data-content={tag.name}
|
||||
>
|
||||
{this.state.showingRulesForTag === tag.name ? 'Hide Rules' : 'View Rules'}
|
||||
</button> : ''}
|
||||
<div className={`${this.props.classPrefix}__tagrules--${this.state.showingRulesForTag === tag.name ? 'active' : 'inactive'}`} dangerouslySetInnerHTML={{ __html: tag.rules_html }} />
|
||||
</div>
|
||||
));
|
||||
if (
|
||||
this.state.searchResults.length > 0 &&
|
||||
(document.activeElement.id === 'tag-input' || document.activeElement.className === 'articleform__tagsoptionrulesbutton')
|
||||
) {
|
||||
searchResultsHTML = (
|
||||
<div className={`${this.props.classPrefix}__tagsoptions`}>
|
||||
{searchResultsRows}
|
||||
<div className={`${this.props.classPrefix}__tagsoptionsbottomrow`}>Some tags have rules and guidelines determined by community moderators</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${this.props.classPrefix}__tagswrapper`}>
|
||||
{this.props.listing && <label htmlFor="Tags">Tags</label>}
|
||||
<input
|
||||
id="tag-input"
|
||||
type="text"
|
||||
ref={t => (this.textArea = t)}
|
||||
className={`${this.props.classPrefix}__tags`}
|
||||
placeholder={`${this.props.maxTags} tags max, comma separated, no spaces or special characters`}
|
||||
autoComplete='off'
|
||||
value={this.props.defaultValue}
|
||||
onInput={this.handleInput}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onBlur={this.handleFocusChange}
|
||||
onFocus={this.handleFocusChange}
|
||||
/>
|
||||
{searchResultsHTML}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
handleRulesClick = e => {
|
||||
e.preventDefault();
|
||||
if (this.state.showingRulesForTag === e.target.dataset.content) {
|
||||
this.setState({showingRulesForTag: null});
|
||||
} else {
|
||||
this.setState({showingRulesForTag: e.target.dataset.content});
|
||||
}
|
||||
}
|
||||
|
||||
handleTagClick = e => {
|
||||
if (e.target.className === 'articleform__tagsoptionrulesbutton') {
|
||||
return;
|
||||
}
|
||||
const input = document.getElementById('tag-input');
|
||||
input.focus();
|
||||
this.insertTag(e.target.dataset.content);
|
||||
};
|
||||
|
||||
handleInput = e => {
|
||||
let { value } = e.target;
|
||||
// If we start typing immediately after a comma, add a space
|
||||
// before what we typed.
|
||||
// e.g. If value = "javascript," and we type a "p",
|
||||
// the result should be "javascript, p".
|
||||
if (e.inputType === 'insertText') {
|
||||
const isTypingAfterComma =
|
||||
e.target.value[e.target.selectionStart - 2] === ',';
|
||||
if (isTypingAfterComma) {
|
||||
value = this.insertSpace(value, e.target.selectionStart - 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (e.data === ',') {
|
||||
value += ' ';
|
||||
}
|
||||
|
||||
this.props.onInput(value);
|
||||
|
||||
const query = this.getCurrentTagAtSelectionIndex(
|
||||
e.target.value,
|
||||
e.target.selectionStart - 1,
|
||||
);
|
||||
|
||||
this.setState({
|
||||
selectedIndex: 0,
|
||||
cursorIdx: e.target.selectionStart,
|
||||
prevLen: this.textArea.value.length,
|
||||
});
|
||||
return this.search(query);
|
||||
};
|
||||
|
||||
getCurrentTagAtSelectionIndex(value, index) {
|
||||
getCurrentTagAtSelectionIndex = (value, index) => {
|
||||
let tagIndex = 0;
|
||||
const tagByCharacterIndex = {};
|
||||
|
||||
value.split('').map((letter, letterIndex) => {
|
||||
value.split('').forEach((letter, letterIndex) => {
|
||||
if (letter === ',') {
|
||||
tagIndex += 1;
|
||||
} else {
|
||||
|
|
@ -242,15 +138,37 @@ class Tags extends Component {
|
|||
return '';
|
||||
}
|
||||
return tag.trim();
|
||||
}
|
||||
};
|
||||
|
||||
// Given an index of the String value, finds the range between commas.
|
||||
// This is useful when we want to insert a new tag anywhere in the
|
||||
// comma separated list of tags.
|
||||
getRangeBetweenCommas = (value, index) => {
|
||||
let start = 0;
|
||||
let end = value.length;
|
||||
|
||||
const toPreviousComma = value
|
||||
.slice(0, index)
|
||||
.split('')
|
||||
.reverse()
|
||||
.indexOf(',');
|
||||
const toNextComma = value.slice(index).indexOf(',');
|
||||
|
||||
if (toPreviousComma !== -1) {
|
||||
start = index - toPreviousComma + 1;
|
||||
}
|
||||
|
||||
if (toNextComma !== -1) {
|
||||
end = index + toNextComma;
|
||||
}
|
||||
|
||||
return [start, end];
|
||||
};
|
||||
|
||||
handleKeyDown = e => {
|
||||
const component = this;
|
||||
|
||||
if (
|
||||
component.selected.length === this.props.maxTags &&
|
||||
e.key === KEYS.COMMA
|
||||
) {
|
||||
const { maxTags } = this.props;
|
||||
if (component.selected.length === maxTags && e.key === KEYS.COMMA) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
|
@ -285,60 +203,65 @@ class Tags extends Component {
|
|||
) {
|
||||
this.clearSelectedSearchResult();
|
||||
}
|
||||
} else if (
|
||||
!LETTERS.test(e.key) &&
|
||||
!NAVIGATION_KEYS.includes(e.key)) {
|
||||
} else if (!LETTERS.test(e.key) && !NAVIGATION_KEYS.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
insertTag(tag) {
|
||||
handleRulesClick = e => {
|
||||
e.preventDefault();
|
||||
const { showingRulesForTag } = this.state;
|
||||
if (showingRulesForTag === e.target.dataset.content) {
|
||||
this.setState({ showingRulesForTag: null });
|
||||
} else {
|
||||
this.setState({ showingRulesForTag: e.target.dataset.content });
|
||||
}
|
||||
};
|
||||
|
||||
handleTagClick = e => {
|
||||
if (e.target.className === 'articleform__tagsoptionrulesbutton') {
|
||||
return;
|
||||
}
|
||||
const input = document.getElementById('tag-input');
|
||||
input.focus();
|
||||
this.insertTag(e.target.dataset.content);
|
||||
};
|
||||
|
||||
const range = this.getRangeBetweenCommas(input.value, input.selectionStart);
|
||||
const insertingAtEnd = range[1] === input.value.length;
|
||||
const maxTagsWillBeReached = this.selected.length === this.props.maxTags;
|
||||
if (insertingAtEnd && !maxTagsWillBeReached) {
|
||||
tag += ', ';
|
||||
handleInput = e => {
|
||||
let { value } = e.target;
|
||||
// If we start typing immediately after a comma, add a space
|
||||
// before what we typed.
|
||||
// e.g. If value = "javascript," and we type a "p",
|
||||
// the result should be "javascript, p".
|
||||
if (e.inputType === 'insertText') {
|
||||
const isTypingAfterComma =
|
||||
e.target.value[e.target.selectionStart - 2] === ',';
|
||||
if (isTypingAfterComma) {
|
||||
value = this.insertSpace(value, e.target.selectionStart - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new tag between commas if there are any.
|
||||
const newInput =
|
||||
input.value.slice(0, range[0]) +
|
||||
tag +
|
||||
input.value.slice(range[1], input.value.length);
|
||||
|
||||
this.props.onInput(newInput);
|
||||
this.resetSearchResults();
|
||||
this.clearSelectedSearchResult();
|
||||
}
|
||||
|
||||
// Given an index of the String value, finds the range between commas.
|
||||
// This is useful when we want to insert a new tag anywhere in the
|
||||
// comma separated list of tags.
|
||||
getRangeBetweenCommas(value, index) {
|
||||
let start = 0;
|
||||
let end = value.length;
|
||||
|
||||
const toPreviousComma = value
|
||||
.slice(0, index)
|
||||
.split('')
|
||||
.reverse()
|
||||
.indexOf(',');
|
||||
const toNextComma = value.slice(index).indexOf(',');
|
||||
|
||||
if (toPreviousComma !== -1) {
|
||||
start = index - toPreviousComma + 1;
|
||||
if (e.data === ',') {
|
||||
value += ' ';
|
||||
}
|
||||
|
||||
if (toNextComma !== -1) {
|
||||
end = index + toNextComma;
|
||||
}
|
||||
/* eslint-disable-next-line react/destructuring-assignment */
|
||||
this.props.onInput(value);
|
||||
|
||||
return [start, end];
|
||||
}
|
||||
const query = this.getCurrentTagAtSelectionIndex(
|
||||
e.target.value,
|
||||
e.target.selectionStart - 1,
|
||||
);
|
||||
|
||||
handleFocusChange = e => {
|
||||
this.setState({
|
||||
selectedIndex: 0,
|
||||
cursorIdx: e.target.selectionStart,
|
||||
prevLen: this.textArea.value.length,
|
||||
});
|
||||
return this.search(query);
|
||||
};
|
||||
|
||||
handleFocusChange = () => {
|
||||
const component = this;
|
||||
setTimeout(() => {
|
||||
if (document.activeElement.id === 'tag-input') {
|
||||
|
|
@ -348,21 +271,48 @@ class Tags extends Component {
|
|||
}, 250);
|
||||
};
|
||||
|
||||
insertSpace(value, position) {
|
||||
insertSpace = (value, position) => {
|
||||
return `${value.slice(0, position)} ${value.slice(position, value.length)}`;
|
||||
};
|
||||
|
||||
handleTagEnter = e => {
|
||||
if (e.key === KEYS.RETURN) {
|
||||
this.handleTagClick();
|
||||
}
|
||||
};
|
||||
|
||||
insertTag(tag) {
|
||||
const input = document.getElementById('tag-input');
|
||||
const { maxTags } = this.props;
|
||||
const range = this.getRangeBetweenCommas(input.value, input.selectionStart);
|
||||
const insertingAtEnd = range[1] === input.value.length;
|
||||
const maxTagsWillBeReached = this.selected.length === maxTags;
|
||||
let tagValue = tag;
|
||||
if (insertingAtEnd && !maxTagsWillBeReached) {
|
||||
tagValue = `${tagValue}, `;
|
||||
}
|
||||
|
||||
// Insert new tag between commas if there are any.
|
||||
const newInput =
|
||||
input.value.slice(0, range[0]) +
|
||||
tagValue +
|
||||
input.value.slice(range[1], input.value.length);
|
||||
/* eslint-disable-next-line react/destructuring-assignment */
|
||||
this.props.onInput(newInput);
|
||||
this.resetSearchResults();
|
||||
this.clearSelectedSearchResult();
|
||||
}
|
||||
|
||||
search(query) {
|
||||
if (query === '') {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
'search query'
|
||||
this.resetSearchResults();
|
||||
resolve();
|
||||
}, 5);
|
||||
});
|
||||
}
|
||||
|
||||
const { listing } = this.props;
|
||||
return this.index
|
||||
.search(query, {
|
||||
hitsPerPage: 8,
|
||||
|
|
@ -370,7 +320,7 @@ class Tags extends Component {
|
|||
filters: 'supported:true',
|
||||
})
|
||||
.then(content => {
|
||||
if (this.props.listing === true) {
|
||||
if (listing === true) {
|
||||
const { additionalTags } = this.state;
|
||||
const { category } = this.props;
|
||||
const additionalItems = (additionalTags[category] || []).filter(
|
||||
|
|
@ -414,10 +364,92 @@ class Tags extends Component {
|
|||
selectedIndex: -1,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
let searchResultsHTML = '';
|
||||
const { searchResults, selectedIndex, showingRulesForTag } = this.state;
|
||||
const { classPrefix, defaultValue, maxTags, listing } = this.props;
|
||||
const searchResultsRows = searchResults.map((tag, index) => (
|
||||
<div
|
||||
tabIndex="-1"
|
||||
role="button"
|
||||
className={`${classPrefix}__tagoptionrow ${classPrefix}__tagoptionrow--${
|
||||
selectedIndex === index ? 'active' : 'inactive'
|
||||
}`}
|
||||
onClick={this.handleTagClick}
|
||||
onKeyDown={this.handleTagEnter}
|
||||
data-content={tag.name}
|
||||
>
|
||||
{tag.name}
|
||||
{tag.rules_html && tag.rules_html.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${classPrefix}__tagsoptionrulesbutton`}
|
||||
onClick={this.handleRulesClick}
|
||||
data-content={tag.name}
|
||||
>
|
||||
{showingRulesForTag === tag.name ? 'Hide Rules' : 'View Rules'}
|
||||
</button>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<div
|
||||
className={`${classPrefix}__tagrules--${
|
||||
showingRulesForTag === tag.name ? 'active' : 'inactive'
|
||||
}`}
|
||||
dangerouslySetInnerHTML={{ __html: tag.rules_html }}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
if (
|
||||
searchResults.length > 0 &&
|
||||
(document.activeElement.id === 'tag-input' ||
|
||||
document.activeElement.className ===
|
||||
'articleform__tagsoptionrulesbutton')
|
||||
) {
|
||||
searchResultsHTML = (
|
||||
<div className={`${classPrefix}__tagsoptions`}>
|
||||
{searchResultsRows}
|
||||
<div className={`${classPrefix}__tagsoptionsbottomrow`}>
|
||||
Some tags have rules and guidelines determined by community
|
||||
moderators
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${classPrefix}__tagswrapper`}>
|
||||
{listing && <label htmlFor="Tags">Tags</label>}
|
||||
<input
|
||||
id="tag-input"
|
||||
type="text"
|
||||
ref={t => {
|
||||
this.textArea = t;
|
||||
return this.textArea;
|
||||
}}
|
||||
className={`${classPrefix}__tags`}
|
||||
placeholder={`${maxTags} tags max, comma separated, no spaces or special characters`}
|
||||
autoComplete="off"
|
||||
value={defaultValue}
|
||||
onInput={this.handleInput}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onBlur={this.handleFocusChange}
|
||||
onFocus={this.handleFocusChange}
|
||||
/>
|
||||
{searchResultsHTML}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Tags.propTypes = {
|
||||
defaultValue: PropTypes.string.isRequired,
|
||||
onInput: PropTypes.func.isRequired,
|
||||
maxTags: PropTypes.number.isRequired,
|
||||
classPrefix: PropTypes.string.isRequired,
|
||||
listing: PropTypes.string.isRequired,
|
||||
category: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default Tags;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue