Replace listings tag component (#16855)

* wip

* feat:  Use MultiSelectAutocomplete for Tags

* feat:  Add additional tags for listings

* refactor: ♻️ Use preact/hooks and refactor propTypes

* fix: 🐛 Fix addiional tags support depending on the selected category

- Add slug to returned array in select_options_for_categories helper
- Differentiate the fields categoryId and categorySlug

* fix: 🐛 Adjust propTypes

* test: 🚨 Change test for select_options_for_categories helper

* refactor: ♻️ Apply requested changes

* refactor: ♻️ Remove border prop - default is true

* test: 🚨 Add test suite for ListingTagsField component

* refactor: ♻️ Apply requested changes

* refactor: ♻️ Remove unnecessary forEach + Improve code legibility

* docs: 📚 Remove topTags from hook documentation

* refactor: ♻️ Use the alias for crayons folder + Use loclaCompare in sort

* test: 🚨 Clean up mock response

* fix: 🐛 Reorder styles

* style: 🎨 Add comment to TagAutocompleteOption/Selection components
This commit is contained in:
Miguel Nieto A 2022-03-30 10:51:40 -05:00 committed by GitHub
parent 8e6981aac5
commit 148f242892
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 413 additions and 697 deletions

View file

@ -122,3 +122,72 @@
}
}
}
/***********************************************
** TagAutocompleteOption/Selection styles ***
***********************************************/
.c-autocomplete--multi {
&__top-tags-heading {
padding: var(--su-3);
font-size: var(--fs-l);
border-bottom: 1px solid var(--base-20);
}
&__tag-selection {
.c-btn {
color: var(--base-80);
background-color: var(--tag-bg);
}
.c-btn:hover:enabled,
.c-btn:focus-visible:enabled {
color: var(--base-80);
background-color: var(--tag-bg-hover);
}
}
&__tag-prefix {
color: var(--tag-prefix);
}
&__tag-option {
background: var(--card-bg);
color: var(--card-color);
padding: var(--su-3);
border-radius: var(--radius);
}
&__tag-option-title {
font-weight: var(--fw-medium);
}
&__tag-option-name {
text-overflow: ellipsis;
}
&__tag-option:hover {
// We try to avoid !important, but since the --tag-prefix color needs to be set inline, !important is required to temporarily change it
--tag-prefix: var(--accent-brand-darker) !important;
background: var(--body-bg);
.c-autocomplete--multi__tag-option-title {
color: var(--accent-brand-darker);
}
}
[aria-selected='true'] {
.c-autocomplete--multi__tag-option {
--tag-prefix: var(--accent-brand-darker) !important;
background: var(--body-bg);
.c-autocomplete--multi__tag-option-title {
color: var(--accent-brand-darker);
}
}
}
&__tag-option-image {
height: 1rem;
margin-left: var(--su-1);
}
}

View file

@ -237,72 +237,6 @@
}
}
.crayons-article-form {
&__top-tags-heading {
padding: var(--su-3);
font-size: var(--fs-l);
border-bottom: 1px solid var(--base-20);
}
&__tag-selection {
.c-btn {
color: var(--base-80);
background-color: var(--tag-bg);
}
.c-btn:hover:enabled,
.c-btn:focus-visible:enabled {
color: var(--base-80);
background-color: var(--tag-bg-hover);
}
}
&__tag-prefix {
color: var(--tag-prefix);
}
&__tag-option {
background: var(--card-bg);
color: var(--card-color);
padding: var(--su-3);
border-radius: var(--radius);
}
&__tag-option-title {
font-weight: var(--fw-medium);
}
&__tag-option-name {
text-overflow: ellipsis;
}
&__tag-option:hover {
// We try to avoid !important, but since the --tag-prefix color needs to be set inline, !important is required to temporarily change it
--tag-prefix: var(--accent-brand-darker) !important;
background: var(--body-bg);
.crayons-article-form__tag-option-title {
color: var(--accent-brand-darker);
}
}
[aria-selected='true'] {
.crayons-article-form__tag-option {
--tag-prefix: var(--accent-brand-darker) !important;
background: var(--body-bg);
.crayons-article-form__tag-option-title {
color: var(--accent-brand-darker);
}
}
}
&__tag-option-image {
height: 1rem;
margin-left: var(--su-1);
}
}
.js-focus-visible
.crayons-article-form__tag-selection
.c-btn.focus-visible:focus {

View file

@ -1,7 +1,7 @@
module ListingHelper
def select_options_for_categories
ListingCategory.select(:id, :name, :cost).map do |cl|
[I18n.t("helpers.listing_helper.option", cl_name: cl.name, count: cl.cost), cl.id]
ListingCategory.select(:id, :slug, :name, :cost).map do |cl|
[I18n.t("helpers.listing_helper.option", cl_name: cl.name, count: cl.cost), cl.slug, cl.id]
end
end

View file

@ -1,10 +1,10 @@
import { h } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import PropTypes from 'prop-types';
import { TagAutocompleteOption } from './TagAutocompleteOption';
import { TagAutocompleteSelection } from './TagAutocompleteSelection';
import { useTagsField } from '../../hooks/useTagsField';
import { TagAutocompleteOption } from '@crayons/MultiSelectAutocomplete/TagAutocompleteOption';
import { TagAutocompleteSelection } from '@crayons/MultiSelectAutocomplete/TagAutocompleteSelection';
import { MultiSelectAutocomplete } from '@crayons';
import { fetchSearch } from '@utilities/search';
/**
* TagsField for the article form. Allows users to search and select up to 4 tags.
@ -14,9 +14,11 @@ import { fetchSearch } from '@utilities/search';
* @param {Function} switchHelpContext Callback to switch the help context when the field is focused
*/
export const TagsField = ({ onInput, defaultValue, switchHelpContext }) => {
const [defaultSelections, setDefaultSelections] = useState([]);
const [defaultsLoaded, setDefaultsLoaded] = useState(false);
const [topTags, setTopTags] = useState([]);
const { defaultSelections, fetchSuggestions, syncSelections } = useTagsField({
defaultValue,
onInput,
});
useEffect(() => {
fetch('/tags/suggest')
@ -24,49 +26,13 @@ export const TagsField = ({ onInput, defaultValue, switchHelpContext }) => {
.then((results) => setTopTags(results));
}, []);
useEffect(() => {
// Previously selected tags are passed as a plain comma separated string
// Fetching further tag data allows us to display a richer UI
// This fetch only happens once on first component load
if (defaultValue && defaultValue !== '' && !defaultsLoaded) {
const tagNames = defaultValue.split(', ');
const tagRequests = tagNames.map((tagName) =>
fetchSearch('tags', { name: tagName }).then(({ result = [] }) => {
const [potentialMatch = {}] = result;
return potentialMatch.name === tagName
? potentialMatch
: { name: tagName };
}),
);
Promise.all(tagRequests).then((data) => {
setDefaultSelections(data);
});
}
setDefaultsLoaded(true);
}, [defaultValue, defaultsLoaded]);
// Converts the array of selected items into a plain string to be saved in the article form
const syncSelections = (selections = []) => {
const selectionsString = selections
.map((selection) => selection.name)
.join(', ');
onInput(selectionsString);
};
const fetchSuggestions = (searchTerm) =>
fetchSearch('tags', { name: searchTerm }).then(
(response) => response.result,
);
return (
<MultiSelectAutocomplete
defaultValue={defaultSelections}
fetchSuggestions={fetchSuggestions}
staticSuggestions={topTags}
staticSuggestionsHeading={
<h2 className="crayons-article-form__top-tags-heading">Top tags</h2>
<h2 className="c-autocomplete--multi__top-tags-heading">Top tags</h2>
}
labelText="Add up to 4 tags"
showLabel={false}

View file

@ -1,5 +1,5 @@
import { h } from 'preact';
import { TagAutocompleteOption } from '../TagAutocompleteOption';
import { TagAutocompleteOption } from '@crayons/MultiSelectAutocomplete/TagAutocompleteOption';
export default {
component: TagAutocompleteOption,

View file

@ -19,17 +19,17 @@ export const TagAutocompleteOption = ({
return (
<div
className="crayons-article-form__tag-option"
className="c-autocomplete--multi__tag-option"
style={{ '--tag-prefix': backgroundColor }}
>
<div className="crayons-article-form__tag-option-title flex items-center">
<div className="c-autocomplete--multi__tag-option-title flex items-center">
<span className="crayons-tag__prefix"># </span>
<span className="crayons-article-form__tag-option-name overflow-hidden">
<span className="c-autocomplete--multi__tag-option-name overflow-hidden">
{name}
</span>
{badgeUrl ? (
<img
className="crayons-article-form__tag-option-image"
className="c-autocomplete--multi__tag-option-image"
src={badgeUrl}
alt=""
/>

View file

@ -28,7 +28,7 @@ export const TagAutocompleteSelection = ({
<div
role="group"
aria-label={name}
className="crayons-article-form__tag-selection flex mr-1 mb-1 w-max"
className="c-autocomplete--multi__tag-selection flex mr-1 mb-1 w-max"
>
<Button
style={baseColorStyles}
@ -36,7 +36,7 @@ export const TagAutocompleteSelection = ({
aria-label={`Edit ${name}`}
onClick={onEdit}
>
<span className="crayons-article-form__tag-prefix"># </span>
<span className="c-autocomplete--multi__tag-prefix"># </span>
{name}
</Button>
<Button

View file

@ -0,0 +1,67 @@
import { useEffect, useState } from 'preact/hooks';
import { fetchSearch } from '@utilities/search';
/**
* Custom hook to manage the logic for the tags-fields based on the `MultiSelectAutocomplete` component
*
* @param {string} defaultValue The default value for the tags field, needs to be a comma separated string
* @param {Function} onInput The function to call when the input changes
* @returns {Object}
* An object containing `defaultSelections` list, `fetchSuggestions` function, and `syncSelections` function
*/
export const useTagsField = ({ defaultValue, onInput }) => {
const [defaultSelections, setDefaultSelections] = useState([]);
const [defaultsLoaded, setDefaultsLoaded] = useState(false);
useEffect(() => {
// Previously selected tags are passed as a plain comma separated string
// Fetching further tag data allows us to display a richer UI
// This fetch only happens once on first component load
if (defaultValue && defaultValue !== '' && !defaultsLoaded) {
const tagNames = defaultValue.split(', ');
const tagRequests = tagNames.map((tagName) =>
fetchSearch('tags', { name: tagName }).then(({ result = [] }) => {
const [potentialMatch = {}] = result;
return potentialMatch.name === tagName
? potentialMatch
: { name: tagName };
}),
);
Promise.all(tagRequests).then((data) => {
setDefaultSelections(data);
});
}
setDefaultsLoaded(true);
}, [defaultValue, defaultsLoaded]);
/**
* Converts the array of selected items into a plain string,
* and ensures the `onInput` callback is triggered with the new tags list
* @param {Array} selections
*/
const syncSelections = (selections = []) => {
const selectionsString = selections
.map((selection) => selection.name)
.join(', ');
onInput(selectionsString);
};
/**
* Fetches tags for a given search term
*
* @param {string} searchTerm The text to search for
* @returns {Promise} Promise which resolves to the tag search results
*/
const fetchSuggestions = (searchTerm) =>
fetchSearch('tags', { name: searchTerm }).then(
(response) => response.result,
);
return {
defaultSelections,
fetchSuggestions,
syncSelections,
};
};

View file

@ -0,0 +1,118 @@
import fetch from 'jest-fetch-mock';
import { h } from 'preact';
import { render, waitFor } from '@testing-library/preact';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { ListingTagsField } from '../components/ListingTagsField';
import '@testing-library/jest-dom';
fetch.enableMocks();
let renderResult;
describe('<ListingTagsField />', () => {
beforeAll(() => {
const environment = document.createElement('meta');
environment.setAttribute('name', 'environment');
document.body.appendChild(environment);
fetch.resetMocks();
window.fetch = fetch;
window.getCsrfToken = async () => 'this-is-a-csrf-token';
fetch.mockResponse((_req) =>
Promise.resolve(JSON.stringify({ result: [] })),
);
});
beforeEach(() => {
renderResult = render(
<ListingTagsField
defaultValue="tag1, tag2"
categorySlug="jobs"
name="listing[tag_list]"
onInput={(_) => {}}
/>,
);
});
afterAll(() => {
fetch.resetMocks();
});
it('should have no a11y violations', async () => {
const { container } = renderResult;
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should show additional tags suggestions depending on the selected category', async () => {
const { getByLabelText, getByText, getByRole } = renderResult;
getByLabelText('Tags').focus();
await waitFor(() => expect(getByText('Top tags')).toBeInTheDocument());
// Additional tags for jobs category are expected to be shown in the dropdown
// when the user focuses on the tags input.
const tagsForJobsCategory = [
'remote',
'remoteoptional',
'lgbtbenefits',
'greencard',
'senior',
'junior',
'intermediate',
'401k',
'fulltime',
'contract',
'temp',
];
tagsForJobsCategory.forEach((tag) => {
expect(getByRole('option', { name: `# ${tag}` })).toBeInTheDocument();
});
});
it('should show the default tags sent via props', async () => {
const { getByRole } = renderResult;
await waitFor(() => {
expect(getByRole('group', { name: 'tag1' })).toBeInTheDocument();
expect(getByRole('group', { name: 'tag2' })).toBeInTheDocument();
});
});
it('should show new selected tags', async () => {
const { getByLabelText, getByText, getByRole } = renderResult;
// New selected tags are expected to be shown
const input = getByLabelText('Tags');
input.focus();
await waitFor(() => expect(getByText('Top tags')).toBeInTheDocument());
userEvent.click(getByRole('option', { name: '# remote' }));
// It should now be added to the list of selected items
await waitFor(() =>
expect(getByRole('group', { name: 'remote' })).toBeInTheDocument(),
);
});
it('should suggest tags based on the input value', async () => {
const { getByLabelText, getByText, getByRole } = renderResult;
const input = getByLabelText('Tags');
input.focus();
await waitFor(() => expect(getByText('Top tags')).toBeInTheDocument());
userEvent.type(input, 're');
// Should suggest tags that start with 're'
// - remote
// - remoteoptional
await waitFor(() => {
expect(getByRole('option', { name: '# remote' })).toBeInTheDocument();
expect(
getByRole('option', { name: '# remoteoptional' }),
).toBeInTheDocument();
});
});
});

View file

@ -3,18 +3,18 @@ import { h, Component } from 'preact';
export class Categories extends Component {
options = () => {
const { categoriesForSelect, category } = this.props;
return categoriesForSelect.map(([text, value]) => {
// array example: ["Education/Courses (1 Credit)", "education"]
if (category === value) {
const { categoriesForSelect, categoryId } = this.props;
return categoriesForSelect.map(([text, slug, id]) => {
// Array example: ["Conference CFP (1 Credit)", "cfp", "1"]
if (categoryId === id) {
return (
<option key={value} value={value} selected>
<option key={id} value={id} data-slug={slug} selected>
{text}
</option>
);
}
return (
<option key={value} value={value}>
<option key={id} value={id} data-slug={slug}>
{text}
</option>
);
@ -65,13 +65,13 @@ export class Categories extends Component {
}
Categories.propTypes = {
categoriesForSelect: PropTypes.arrayOf(PropTypes.string).isRequired,
categoriesForSelect: PropTypes.arrayOf(PropTypes.array).isRequired,
categoriesForDetails: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string,
rules: PropTypes.string,
}),
).isRequired,
category: PropTypes.string.isRequired,
categoryId: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
};

View file

@ -0,0 +1,112 @@
import { h } from 'preact';
import { useEffect, useMemo, useState } from 'preact/hooks';
import PropTypes from 'prop-types';
import { useTagsField } from '../../hooks/useTagsField';
import { TagAutocompleteOption } from '@crayons/MultiSelectAutocomplete/TagAutocompleteOption';
import { TagAutocompleteSelection } from '@crayons/MultiSelectAutocomplete/TagAutocompleteSelection';
import { MultiSelectAutocomplete } from '@crayons';
export const ListingTagsField = ({
defaultValue,
onInput,
categorySlug,
name,
onFocus,
}) => {
const listingState = useMemo(
() => ({
additionalTags: {
jobs: [
'remote',
'remoteoptional',
'lgbtbenefits',
'greencard',
'senior',
'junior',
'intermediate',
'401k',
'fulltime',
'contract',
'temp',
],
forhire: [
'remote',
'remoteoptional',
'lgbtbenefits',
'greencard',
'senior',
'junior',
'intermediate',
'401k',
'fulltime',
'contract',
'temp',
],
forsale: ['laptop', 'desktopcomputer', 'new', 'used'],
events: ['conference', 'meetup'],
collabs: ['paid', 'temp'],
},
}),
[],
);
const { defaultSelections, fetchSuggestions, syncSelections } = useTagsField({
defaultValue,
onInput,
});
const [suggestedTags, setSuggestedTags] = useState([]);
useEffect(() => {
// Push in this way: { name: 'remote' }
const categorySuggestedTags = (
listingState.additionalTags[categorySlug] || []
).map((name) => ({ name }));
setSuggestedTags(categorySuggestedTags);
}, [listingState, categorySlug]);
const fetchSuggestionsWithAdditionalTags = async (searchTerm) => {
const fetchedSuggestions = await fetchSuggestions(searchTerm);
const suggestedNames = fetchedSuggestions.map((t) => t.name);
// Search in the suggestedTags array
const additionalItems = suggestedTags.filter(
(t) => t.name.startsWith(searchTerm) && !suggestedNames.includes(t.name),
);
// Join fetched and additional items
const suggestionsResult = [...fetchedSuggestions, ...additionalItems];
// Order suggestionsResult by name
suggestionsResult.sort((a, b) => a.name.localeCompare(b.name));
return suggestionsResult;
};
return (
<div className="listingform__tagswrapper crayons-field">
<MultiSelectAutocomplete
defaultValue={defaultSelections}
fetchSuggestions={fetchSuggestionsWithAdditionalTags}
staticSuggestions={suggestedTags}
staticSuggestionsHeading={
<h2 className="c-autocomplete--multi__top-tags-heading">Top tags</h2>
}
labelText="Tags"
placeholder="Add up to 8 tags..."
maxSelections={8}
SuggestionTemplate={TagAutocompleteOption}
SelectionTemplate={TagAutocompleteSelection}
onSelectionsChanged={syncSelections}
inputId="tag-input"
onFocus={onFocus}
/>
{/* Hidden input to store the selected tags and be sent via form data */}
{name && <input type="hidden" name={name} value={defaultValue} />}
</div>
);
};
ListingTagsField.propTypes = {
defaultValue: PropTypes.string.isRequired,
categorySlug: PropTypes.string,
name: PropTypes.string,
onInput: PropTypes.func.isRequired,
onFocus: PropTypes.func,
};

View file

@ -1,7 +1,7 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import linkState from 'linkstate';
import { Tags, DEFAULT_TAG_FORMAT } from '../shared/components/tags';
import { ListingTagsField } from '../listings/components/ListingTagsField';
import { OrganizationPicker } from '../organization/OrganizationPicker';
import { Title } from './components/Title';
import { BodyMarkdown } from './components/BodyMarkdown';
@ -28,7 +28,9 @@ export class ListingForm extends Component {
this.state = {
id: this.listing.id || null,
title: this.listing.title || '',
category: this.listing.category || '',
categoryId: this.listing.listing_category_id || '',
categorySlug:
this.listing.category || this.categoriesForSelect[0][1] || '',
tagList: this.listing.cached_tag_list || '',
bodyMarkdown: this.listing.body_markdown || '',
categoriesForSelect: this.categoriesForSelect,
@ -50,7 +52,8 @@ export class ListingForm extends Component {
title,
bodyMarkdown,
tagList,
category,
categoryId,
categorySlug,
categoriesForDetails,
categoriesForSelect,
organizations,
@ -90,20 +93,19 @@ export class ListingForm extends Component {
<Categories
categoriesForSelect={categoriesForSelect}
categoriesForDetails={categoriesForDetails}
onChange={linkState(this, 'category')}
category={category}
onChange={(e) => {
const categoryId = e.target.value;
const categorySlug = e.target.selectedOptions[0].dataset.slug;
this.setState({ categoryId, categorySlug });
}}
categoryId={categoryId}
/>
<div className="relative">
<Tags
<ListingTagsField
defaultValue={tagList}
category={category}
categorySlug={categorySlug}
name="listing[tag_list]"
onInput={linkState(this, 'tagList')}
classPrefix="listingform"
fieldClassName="crayons-textfield"
maxTags={8}
autocomplete="off"
listing
pattern={DEFAULT_TAG_FORMAT}
/>
</div>
<ExpireDate
@ -122,7 +124,10 @@ export class ListingForm extends Component {
defaultValue={bodyMarkdown}
onChange={linkState(this, 'bodyMarkdown')}
/>
<Tags defaultValue={tagList} onInput={linkState(this, 'tagList')} />
<ListingTagsField
defaultValue={tagList}
onInput={linkState(this, 'tagList')}
/>
{selectOrg}
</div>
);

View file

@ -1,53 +0,0 @@
import fetch from 'jest-fetch-mock';
import { h } from 'preact';
import { render, fireEvent } from '@testing-library/preact';
import { axe } from 'jest-axe';
import { Tags } from '../tags';
fetch.enableMocks();
describe('<Tags />', () => {
beforeAll(() => {
const environment = document.createElement('meta');
environment.setAttribute('name', 'environment');
document.body.appendChild(environment);
fetch.resetMocks();
window.fetch = fetch;
});
it('should have no a11y violations', async () => {
const { container } = render(<Tags defaultValue="defaultValue" listing />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
describe('handleKeyDown', () => {
it('does not call preventDefault on used keyCode', () => {
// Only one call is being made to /tags/suggest when the comma key is pressed
// It didn't seem worth it to inspect the whole mock object to ensure the right URL etc.
fetch.mockResponseOnce('[]');
const { getByTestId } = render(
<Tags defaultValue="defaultValue" listing />,
);
Event.prototype.preventDefault = jest.fn();
const tests = [
{ key: 'a', code: '65' },
{ key: '1', code: '49' },
{ key: ',', code: '188' },
{ key: 'Enter', code: '13' },
];
const input = getByTestId('tag-input');
tests.forEach((eventPayload) => {
fireEvent.keyDown(input, eventPayload);
});
expect(Event.prototype.preventDefault).not.toHaveBeenCalled();
});
});
});

View file

@ -1,502 +0,0 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import { fetchSearch } from '../../utilities/search';
const KEYS = {
UP: 'ArrowUp',
DOWN: 'ArrowDown',
LEFT: 'ArrowLeft',
RIGHT: 'ArrowRight',
TAB: 'Tab',
RETURN: 'Enter',
COMMA: ',',
DELETE: 'Backspace',
};
const NAVIGATION_KEYS = [
KEYS.COMMA,
KEYS.DELETE,
KEYS.LEFT,
KEYS.RIGHT,
KEYS.TAB,
];
const LETTERS_NUMBERS = /[a-z0-9]/i;
export const DEFAULT_TAG_FORMAT = '[0-9A-Za-z, ]+';
/* TODO: Remove all instances of this.props.listing
and refactor this component to be more generic */
export class Tags extends Component {
constructor(props) {
super(props);
const { listing } = props;
const listingState = listing
? {
additionalTags: {
jobs: [
'remote',
'remoteoptional',
'lgbtbenefits',
'greencard',
'senior',
'junior',
'intermediate',
'401k',
'fulltime',
'contract',
'temp',
],
forhire: [
'remote',
'remoteoptional',
'lgbtbenefits',
'greencard',
'senior',
'junior',
'intermediate',
'401k',
'fulltime',
'contract',
'temp',
],
forsale: ['laptop', 'desktopcomputer', 'new', 'used'],
events: ['conference', 'meetup'],
collabs: ['paid', 'temp'],
},
}
: null;
this.state = {
selectedIndex: -1,
searchResults: [],
additionalTags: [],
cursorIdx: 0,
prevLen: 0,
showingRulesForTag: null,
showingTopTags: false,
...listingState,
};
}
componentDidUpdate() {
// stop cursor jumping if the user goes back to edit previous tags
const { cursorIdx, prevLen } = this.state;
if (
cursorIdx < this.textArea.value.length &&
this.textArea.value.length < prevLen + 1
) {
this.textArea.selectionEnd = cursorIdx;
this.textArea.selectionStart = this.textArea.selectionEnd;
}
}
get selected() {
const { defaultValue } = this.props;
return defaultValue
.split(',')
.map((item) => item !== undefined && item.trim())
.filter((item) => item.length > 0);
}
get isTopOfSearchResults() {
const { selectedIndex } = this.state;
return selectedIndex < 0;
}
get isBottomOfSearchResults() {
const { selectedIndex, searchResults } = this.state;
return selectedIndex >= searchResults.length - 1;
}
get isSearchResultSelected() {
const { selectedIndex } = this.state;
return selectedIndex > -1;
}
getCurrentTagAtSelectionIndex = (value, index) => {
let tagIndex = 0;
const tagByCharacterIndex = {};
value.split('').forEach((letter, letterIndex) => {
if (letter === ',') {
tagIndex += 1;
} else {
tagByCharacterIndex[letterIndex] = tagIndex;
}
});
const tag = value.split(',')[tagByCharacterIndex[index]];
if (tag === undefined) {
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;
const { searchResults } = this.state;
const { maxTags } = this.props;
if (component.selected.length === maxTags && e.key === KEYS.COMMA) {
e.preventDefault();
return;
}
if (
(e.key === KEYS.DOWN || e.key === KEYS.TAB) &&
!this.isBottomOfSearchResults &&
searchResults.length > 0
) {
e.preventDefault();
this.moveDownInSearchResults();
} else if (e.key === KEYS.UP && !this.isTopOfSearchResults) {
e.preventDefault();
this.moveUpInSearchResults();
} else if (e.key === KEYS.RETURN && this.isSearchResultSelected) {
e.preventDefault();
this.insertTag(
component.state.searchResults[component.state.selectedIndex].name,
);
setTimeout(() => {
document.getElementById('tag-input').focus();
}, 10);
} else if (e.key === KEYS.COMMA && !this.isSearchResultSelected) {
this.fetchTopTagSuggestions();
this.clearSelectedSearchResult();
} else if (e.key === KEYS.DELETE) {
if (
component.props.defaultValue[
component.props.defaultValue.length - 1
] === ','
) {
this.clearSelectedSearchResult();
}
} else if (
!LETTERS_NUMBERS.test(e.key) &&
!NAVIGATION_KEYS.includes(e.key)
) {
e.preventDefault();
}
};
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();
// the rules container (__tagoptionrow) is the real target of the event,
// by using currentTarget we let the event propagation work
// from the inner rules box as well (__tagrules)
this.insertTag(e.currentTarget.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 += ' ';
}
/* eslint-disable-next-line react/destructuring-assignment */
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);
};
handleFocusChange = () => {
const component = this;
setTimeout(() => {
if (document.activeElement.id === 'tag-input') {
return;
}
component.forceUpdate();
}, 250);
};
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.fetchTopTagSuggestions();
this.clearSelectedSearchResult();
}
fetchTopTagSuggestions() {
this.setState({ showingTopTags: true });
const { topTags = [] } = this.state;
if (topTags.length > 0) {
return Promise.resolve().then(() =>
this.setState({
searchResults: topTags.filter((t) => !this.selected.includes(t.name)),
}),
);
}
return window
.fetch('/tags/suggest')
.then((res) => res.json())
.then((tags) => {
this.setState({
topTags: tags,
searchResults: tags.filter((t) => !this.selected.includes(t.name)),
});
});
}
search(query) {
if (query === '') {
return this.fetchTopTagSuggestions();
}
this.setState({ showingTopTags: false });
const { listing } = this.props;
const dataHash = { name: query };
const responsePromise = fetchSearch('tags', dataHash);
return responsePromise.then((response) => {
if (listing === true) {
const { additionalTags } = this.state;
const { category } = this.props;
const additionalItems = (additionalTags[category] || []).filter((t) =>
t.includes(query),
);
const resultsArray = response.result;
additionalItems.forEach((t) => {
if (!resultsArray.includes(t)) {
resultsArray.push({ name: t });
}
});
}
// updates searchResults array according to what is being typed by user
// allows user to choose a tag when they've typed the partial or whole word
this.setState({
searchResults: response.result.filter(
(t) => t.name === query || !this.selected.includes(t.name),
),
});
});
}
moveUpInSearchResults() {
this.setState((prevState) => ({
selectedIndex: prevState.selectedIndex - 1,
}));
}
moveDownInSearchResults() {
this.setState((prevState) => ({
selectedIndex: prevState.selectedIndex + 1,
}));
}
clearSelectedSearchResult() {
this.setState({
selectedIndex: -1,
});
}
render() {
let searchResultsHTML = '';
const { searchResults, selectedIndex, showingRulesForTag, showingTopTags } =
this.state;
const {
classPrefix,
defaultValue,
maxTags,
listing,
fieldClassName,
onFocus,
pattern,
} = this.props;
const { activeElement } = document;
const searchResultsRows = searchResults.map((tag, index) => (
<div
key={`option-${tag.name}`}
tabIndex="-1"
role="button"
className={`${classPrefix}__tagoptionrow ${classPrefix}__tagoptionrow--${
selectedIndex === index ? 'active' : 'inactive'
}`}
onClick={this.handleTagClick}
onKeyDown={this.handleTagEnter}
data-content={tag.name}
>
<span className={`${classPrefix}__tagname`}>{tag.name}</span>
{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>
) : (
''
)}
{!showingTopTags && (
<div
className={`${classPrefix}__tagrules--${
showingRulesForTag === tag.name ? 'active' : 'inactive'
}`}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: tag.rules_html }}
/>
)}
</div>
));
if (
searchResults.length > 0 &&
(activeElement.id === 'tag-input' ||
activeElement.classList.contains(
'articleform__tagsoptionrulesbutton',
) ||
activeElement.classList.contains('articleform__tagoptionrow'))
) {
searchResultsHTML = (
<div className={`${classPrefix}__tagsoptions`}>
{showingTopTags ? (
<h2 className={`${classPrefix}__tagsoptionsheading`}>Top tags</h2>
) : null}
{searchResultsRows}
<div className={`${classPrefix}__tagsoptionsbottomrow`}>
Some tags have rules and guidelines determined by community
moderators
</div>
</div>
);
}
return (
<div className={`${classPrefix}__tagswrapper crayons-field`}>
{listing && (
<label htmlFor="Tags" class="crayons-field__label">
Tags
</label>
)}
<input
data-testid="tag-input"
aria-label="Post Tags"
id="tag-input"
type="text"
ref={(t) => {
this.textArea = t;
return this.textArea;
}}
className={`${`${fieldClassName} ${classPrefix}`}__tags`}
name="listing[tag_list]"
placeholder={`Add up to ${maxTags} tags...`}
autoComplete="off"
value={defaultValue}
onInput={this.handleInput}
onKeyDown={this.handleKeyDown}
onBlur={this.handleFocusChange}
onFocus={(e) => {
this.fetchTopTagSuggestions();
onFocus(e);
}}
pattern={pattern}
/>
{searchResultsHTML}
</div>
);
}
}
Tags.propTypes = {
defaultValue: PropTypes.string.isRequired,
onInput: PropTypes.func.isRequired,
maxTags: PropTypes.number.isRequired,
classPrefix: PropTypes.string.isRequired,
fieldClassName: PropTypes.string.isRequired,
listing: PropTypes.string,
category: PropTypes.string,
onFocus: PropTypes.func.isRequired,
pattern: PropTypes.string.isRequired,
};

View file

@ -3,7 +3,7 @@
<%= render partial: "form_errors", locals: { listing: listing } %>
<div id="listingform-data"
data-listing="<%= listing.to_json(only: %i[id title body_markdown category cached_tag_list]) %>"
data-listing="<%= listing.to_json(only: %i[id title body_markdown listing_category_id category cached_tag_list]) %>"
data-organizations="<%= @organizations.to_json(only: %i[id name]) %>"
data-categories-for-select="<%= select_options_for_categories.to_json %>"
data-categories-for-details="<%= categories_available.transform_values { |value_hash| value_hash.except(:cost) }.values.to_json %>">

View file

@ -8,8 +8,8 @@ RSpec.describe ListingHelper, type: :helper do
it "returns the correct options array" do
expect(helper.select_options_for_categories).to match_array(
[
["#{cat1.name} (1 Credit)", cat1.id],
["#{cat2.name} (5 Credits)", cat2.id],
["#{cat1.name} (1 Credit)", cat1.slug, cat1.id],
["#{cat2.name} (5 Credits)", cat2.slug, cat2.id],
],
)
end