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:
parent
8e6981aac5
commit
148f242892
16 changed files with 413 additions and 697 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { h } from 'preact';
|
||||
import { TagAutocompleteOption } from '../TagAutocompleteOption';
|
||||
import { TagAutocompleteOption } from '@crayons/MultiSelectAutocomplete/TagAutocompleteOption';
|
||||
|
||||
export default {
|
||||
component: TagAutocompleteOption,
|
||||
|
|
|
|||
|
|
@ -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=""
|
||||
/>
|
||||
|
|
@ -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
|
||||
67
app/javascript/hooks/useTagsField.js
Normal file
67
app/javascript/hooks/useTagsField.js
Normal 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,
|
||||
};
|
||||
};
|
||||
118
app/javascript/listings/__tests__/ListingTagsField.test.jsx
Normal file
118
app/javascript/listings/__tests__/ListingTagsField.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
112
app/javascript/listings/components/ListingTagsField.jsx
Normal file
112
app/javascript/listings/components/ListingTagsField.jsx
Normal 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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 %>">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue