diff --git a/__tests__/snapshots.test.js b/__tests__/snapshots.test.js index c802a987c..e32e029a7 100644 --- a/__tests__/snapshots.test.js +++ b/__tests__/snapshots.test.js @@ -2,6 +2,7 @@ const renderer = require('react-test-renderer'); const fs = require('fs'); const path = require('path'); const glob = require('glob'); +const { h } = require('../packages/x-engine'); const {packages} = require('../monorepo.json'); @@ -16,16 +17,16 @@ for(const pkg of packageDirs) { const storiesDir = path.resolve(pkgDir, 'stories'); if(fs.existsSync(storiesDir)) { - const { package: pkg, stories, component } = require(storiesDir); - const { presets = {default: {}} } = require(pkgDir); + const { package: pkg, stories, component: Component } = require(storiesDir); + const { presets = { default: {} } } = require(pkgDir); const name = path.basename(pkg.name); describe(pkg.name, () => { for (const { title, data } of stories) { - for (const [ preset, options ] of Object.entries(presets)) { + for (const [preset, options] of Object.entries(presets)) { it(`renders a ${preset} ${title} ${name}`, () => { const props = { ...data, ...options }; - const tree = renderer.create(component(props)).toJSON(); + const tree = renderer.create(h(Component, props)).toJSON(); expect(tree).toMatchSnapshot(); }); } diff --git a/components/x-topic-search/__tests__/x-topic-search.test.jsx b/components/x-topic-search/__tests__/x-topic-search.test.jsx index c77dc3a30..04fe1b4da 100644 --- a/components/x-topic-search/__tests__/x-topic-search.test.jsx +++ b/components/x-topic-search/__tests__/x-topic-search.test.jsx @@ -3,21 +3,20 @@ const { h } = require('@financial-times/x-engine'); const { mount } = require('@financial-times/x-test-utils/enzyme'); const { TopicSearch } = require('../'); -const searchTerm = 'Dog'; -const searchTermNoResult = 'Blobfish'; -const searchTermAllFollowed = 'Cat'; const minSearchLength = 3; const maxSuggestions = 3; const apiUrl = 'api-url'; -const alreadyFollowedTopics = [ - { uuid: 'Cat-House-id', name: 'Cat House' }, - { uuid: 'Cat-Food-id', name: 'Cat Food' }, - { uuid: 'Cat-Toys-id', name: 'Cat Toys' } -]; -const buildSearchUrl = term => `${apiUrl}?count=${maxSuggestions}&partial=${term}`; +const FOLLOWED_TOPIC_ID1 = 'Cat-House-id'; +const FOLLOWED_TOPIC_ID2 = 'Cat-Food-id'; +const UNFOLLOWED_TOPIC_ID1 = 'Cat-Toys-id'; describe('x-topic-search', () => { - const waitForApiResponse = () => new Promise(resolve => { setTimeout(resolve, 500); }); + const buildSearchUrl = term => `${apiUrl}?count=${maxSuggestions}&partial=${term}`; + const enterSearchTerm = searchTerm => { + target.find('input').simulate('input', { target: { value: searchTerm }}); + + return new Promise(resolve => { setTimeout(resolve, 400); }); + }; let target; beforeEach(() => { @@ -25,7 +24,7 @@ describe('x-topic-search', () => { minSearchLength, maxSuggestions, apiUrl, - followedTopicIds: alreadyFollowedTopics.map(topic => topic.uuid), + followedTopicIds: [FOLLOWED_TOPIC_ID1, FOLLOWED_TOPIC_ID2], }; target = mount(); }); @@ -45,67 +44,66 @@ describe('x-topic-search', () => { }); describe('given inputted text is shorter than minSearchLength', () => { - it('should not render result', () => { - const wordLessThanMin = searchTerm.slice(0, minSearchLength - 1); - const apiUrlWithResults = buildSearchUrl(wordLessThanMin); + const apiUrlWithResults = buildSearchUrl('a'); - fetchMock.get(apiUrlWithResults, []); + fetchMock.get(apiUrlWithResults, []); - target.find('input').simulate('input', { target: { value: wordLessThanMin }}); + beforeEach(() => { + return enterSearchTerm('a'); + }); - return waitForApiResponse().then(() => { - expect(fetchMock.called(apiUrlWithResults)).toBe(false); - expect(target.render().children('div')).toHaveLength(1); - }); + it('does not make a request to the api or render any result', () => { + expect(fetchMock.called(apiUrlWithResults)).toBe(false); + expect(target.render().children('div')).toHaveLength(1); }); }); describe('given searchTerm which has some topic suggestions to follow', () => { - const apiUrlWithResults = buildSearchUrl(searchTerm); - const topicSuggestions = [ - { id: 'Dog-House-id', prefLabel: 'Dog House', url: 'Dog-House-url' }, - { id: 'Dog-Food-id', prefLabel: 'Dog Food', url: 'Dog-Food-url' }, - { id: 'Dog-Toys-id', prefLabel: 'Dog Toys', url: 'Dog-Toys-url' } + const apiUrlWithResults = buildSearchUrl('Cat'); + const results = [ + { id: FOLLOWED_TOPIC_ID1, prefLabel: 'Cat House', url: 'Cat-House-url' }, + { id: FOLLOWED_TOPIC_ID2, prefLabel: 'Cat Food', url: 'Cat-Food-url' }, + { id: UNFOLLOWED_TOPIC_ID1, prefLabel: 'Cat Toys', url: 'Cat-Toys-url' } ]; - fetchMock.get(apiUrlWithResults, topicSuggestions); + fetchMock.get(apiUrlWithResults, results); beforeEach(() => { - target.find('input').simulate('input', { target: { value: searchTerm } }); - - return waitForApiResponse(); + return enterSearchTerm('Cat'); }); - it('should render topics list with follow button', () => { + it('requests the topic suggestions with count set to maxSuggestions', () => { expect(fetchMock.called(apiUrlWithResults)).toBe(true); + }); + + it('renders no more than the max number of suggestions', () => { expect(target.render().children('div')).toHaveLength(2); + expect(target.render().find('li')).toHaveLength(maxSuggestions); + }); + it('renders links and follow buttons for each suggestion', () => { const suggestionsList = target.render().find('li'); - expect(suggestionsList).toHaveLength(maxSuggestions); - - topicSuggestions.forEach((topic, index) => { + results.forEach((topic, index) => { const suggestion = suggestionsList.eq(index); expect(suggestion.find('a').text()).toEqual(topic.prefLabel); expect(suggestion.find('a').attr('href')).toEqual(topic.url); - expect(suggestion.find('button')).toHaveLength(1); + expect(suggestion.find('button').text()).toEqual(topic.id === UNFOLLOWED_TOPIC_ID1 ? 'Add to myFT' : 'Added'); }); - }); + }) }); describe('given searchTerm which has no topic suggestions to follow', () => { - const apiUrlNoResults = buildSearchUrl(searchTermNoResult); + const apiUrlNoResults = buildSearchUrl('Dog'); fetchMock.get(apiUrlNoResults, []); beforeEach(() => { - target.find('input').simulate('input', { target: { value: searchTermNoResult } }); - - return waitForApiResponse(); + return enterSearchTerm('Dog'); }); - it('should render no topic message', () => { + it('requests from the api and renders the no matching topics message', () => { expect(fetchMock.called(apiUrlNoResults)).toBe(true); const resultContainer = target.render().children('div').eq(1); @@ -114,33 +112,4 @@ describe('x-topic-search', () => { expect(resultContainer.find('h2').text()).toMatch('No topics matching'); }); }); - - describe('given searchTerm which all the topics has been followed', () => { - const apiUrlAllFollowed = buildSearchUrl(searchTermAllFollowed); - - fetchMock.get(apiUrlAllFollowed, alreadyFollowedTopics.map(topic => ({ - id: topic.uuid, - prefLabel: topic.name, - url: topic.name.replace(' ', '-') - }))); - - beforeEach(() => { - target.find('input').simulate('input', { target: { value: searchTermAllFollowed } }); - - return waitForApiResponse(); - }); - - it('should render already followed message with name of the topics', () => { - expect(fetchMock.called(apiUrlAllFollowed)).toBe(true); - - const resultContainer = target.render().children('div').eq(1); - - expect(resultContainer).toHaveLength(1); - expect(resultContainer.text()) - .toMatch( - `You already follow ${alreadyFollowedTopics[0].name}, ${alreadyFollowedTopics[1].name} and ${alreadyFollowedTopics[2].name}` - ); - }); - }); - }); diff --git a/components/x-topic-search/package.json b/components/x-topic-search/package.json index fd441f3b8..22c52604e 100644 --- a/components/x-topic-search/package.json +++ b/components/x-topic-search/package.json @@ -19,7 +19,6 @@ "dependencies": { "@financial-times/x-engine": "file:../../packages/x-engine", "@financial-times/x-follow-button": "0.0.11", - "@financial-times/x-interaction": "^1.0.0-beta.6", "classnames": "^2.2.6", "debounce-promise": "^3.1.0" }, diff --git a/components/x-topic-search/src/ResultContainer.jsx b/components/x-topic-search/src/ResultContainer.jsx deleted file mode 100644 index d696567aa..000000000 --- a/components/x-topic-search/src/ResultContainer.jsx +++ /dev/null @@ -1,57 +0,0 @@ -import { h } from '@financial-times/x-engine'; -import styles from './TopicSearch.scss'; -import classNames from 'classnames'; - -import SuggestionList from './SuggestionList'; -import NoSuggestions from './NoSuggestions'; - -// transform like this => topic1, topic2 and topic3 -const arrayToSentence = followedSuggestions => { - const topicsLength = followedSuggestions.length; - - if (topicsLength === 1) { - return { followedSuggestions[0].prefLabel }; - } else { - return followedSuggestions - .map((topic, index) => { - if (index === topicsLength - 1) { - // the last topic - return and { topic.prefLabel } - } else if (index === topicsLength - 2) { - // one before the last topic - return { topic.prefLabel } ; - } else { - return { topic.prefLabel }, ; - } - }) - } - -}; - - -export default ({ result, searchTerm, csrfToken, followedTopicIds }) => { - - const hasFollowedSuggestions = result.followedSuggestions.length > 0; - const hasUnfollowedSuggestions = result.unfollowedSuggestions.length > 0; - - return ( -
- - { hasUnfollowedSuggestions && - } - - { !hasUnfollowedSuggestions && hasFollowedSuggestions && -
- You already follow { arrayToSentence(result.followedSuggestions) } -
} - - { !hasUnfollowedSuggestions && !hasFollowedSuggestions && - } - -
- ); -}; diff --git a/components/x-topic-search/src/TopicSearch.jsx b/components/x-topic-search/src/TopicSearch.jsx index 4a5e93a9c..0675f849d 100644 --- a/components/x-topic-search/src/TopicSearch.jsx +++ b/components/x-topic-search/src/TopicSearch.jsx @@ -1,95 +1,124 @@ -import { h } from '@financial-times/x-engine'; -import { withActions } from '@financial-times/x-interaction'; +import { h, Component } from '@financial-times/x-engine'; import styles from './TopicSearch.scss'; import classNames from 'classnames'; import getSuggestions from './lib/get-suggestions.js'; import debounce from 'debounce-promise'; +import SuggestionList from './SuggestionList'; +import NoSuggestions from './NoSuggestions'; + +class TopicSearch extends Component { + constructor(props) { + super(props); + + this.minSearchLength = props.minSearchLength || 2; + this.maxSuggestions = props.maxSuggestions || 5; + this.apiUrl = props.apiUrl; + this.getSuggestions = debounce(getSuggestions, 150); + this.outsideEvents = ['focusout', 'focusin', 'click']; + this.handleInputChange = this.handleInputChange.bind(this); + this.handleInputClickOrFocus = this.handleInputClickOrFocus.bind(this); + this.handleInteractionOutside = this.handleInteractionOutside.bind(this); + + this.state = { + followedTopicIds: props.followedTopicIds || [], + searchTerm: '', + showResult: false + }; + } -import ResultContainer from './ResultContainer'; + componentDidMount() { + this.outsideEvents.forEach(action => { + document.body.addEventListener(action, this.handleInteractionOutside); + }); + } -const debounceGetSuggestions = debounce(getSuggestions, 150); + componentWillUnmount() { + this.outsideEvents.forEach(action => { + document.body.removeEventListener(action, this.handleInteractionOutside); + }); + } -let resultExists = false; + handleInputChange(event) { + const searchTerm = event.target.value.trim(); -const topicSearchActions = withActions(({ minSearchLength = 2, maxSuggestions = 5, apiUrl, followedTopicIds = [] }) => ({ - async checkInput(event) { - const searchTerm = event.target.value && event.target.value.trim(); + this.setState({ searchTerm }); - if (searchTerm.length >= minSearchLength) { - return debounceGetSuggestions(searchTerm, maxSuggestions, apiUrl, followedTopicIds) - .then(result => { - resultExists = true; - return { showResult: true, result, searchTerm }; + if (searchTerm.length >= this.minSearchLength) { + this.getSuggestions(searchTerm, this.maxSuggestions, this.apiUrl) + .then(({ suggestions }) => { + this.setState({ + suggestions, + showResult: true + }); }) .catch(() => { - resultExists = false; - return { showResult: false }; + this.setState({ + showResult: false + }); }); } else { - resultExists = false; - return Promise.resolve({ showResult: false }); + this.setState({ + showResult: false + }); } - }, + } - topicFollowed (subjectId) { - if (!followedTopicIds.includes(subjectId)) { - followedTopicIds.push(subjectId); + handleInteractionOutside(event) { + if (!this.rootEl.contains(event.target)) { + this.setState({ + showResult: false + }); } + } - return { followedTopicIds }; - }, - - topicUnfollowed (subjectId) { - const targetIdIndex = followedTopicIds.indexOf(subjectId); - - if (targetIdIndex > -1) { - followedTopicIds.splice(targetIdIndex, 1); + handleInputClickOrFocus() { + if (this.state.searchTerm.length >= this.minSearchLength) { + this.setState({ + showResult: true + }); } - - return { followedTopicIds }; - }, - - selectInput (event) { - event.target.select(); - return { showResult: resultExists }; - }, - - hideResult() { - return { showResult: false }; } -})); - -const TopicSearch = topicSearchActions(({ searchTerm, showResult, result, actions, isLoading, csrfToken, followedTopicIds }) => ( -
-

- Search for topics, authors, companies, or other areas of interest -

- -
- - -
- - { showResult && !isLoading && - } - -
-)); + render() { + const { csrfToken, followedTopicIds } = this.props; + const { searchTerm, showResult, suggestions } = this.state; + + return ( +
this.rootEl = el}> +

+ Search for topics, authors, companies, or other areas of interest +

+ + +
+ + +
+ + {showResult && searchTerm.length >= this.minSearchLength && +
+ {suggestions.length > 0 ? + : + } +
} +
+ ); + } +} export { TopicSearch }; diff --git a/components/x-topic-search/src/TopicSearch.scss b/components/x-topic-search/src/TopicSearch.scss index bca94f69d..759982612 100644 --- a/components/x-topic-search/src/TopicSearch.scss +++ b/components/x-topic-search/src/TopicSearch.scss @@ -117,11 +117,3 @@ margin-bottom: 0; list-style-type: disc; } - -.all-followed { - @include oTypographySans($scale: 1); - color: oColorsGetPaletteColor('black-70'); - text-align: left; - padding: 0; - margin: 0; -} diff --git a/components/x-topic-search/src/lib/get-suggestions.js b/components/x-topic-search/src/lib/get-suggestions.js index 4e1ea8e02..d7c85db08 100644 --- a/components/x-topic-search/src/lib/get-suggestions.js +++ b/components/x-topic-search/src/lib/get-suggestions.js @@ -1,17 +1,10 @@ const addQueryParamToUrl = (name, value, url, append = true) => { const queryParam = `${name}=${value}`; + return append === true ? `${url}&${queryParam}` : `${url}?${queryParam}`; }; -const separateFollowedAndUnfollowed = (suggestions = [], followedTopicIds) => { - const followedSuggestions = suggestions.filter(suggestion => followedTopicIds.includes(suggestion.id)); - const unfollowedSuggestions = suggestions.filter(suggestion => !followedTopicIds.includes(suggestion.id)); - - return { followedSuggestions, unfollowedSuggestions }; -} - -export default (searchTerm, maxSuggestions, apiUrl, followedTopicIds) => { - +export default (searchTerm, maxSuggestions, apiUrl) => { const dataSrc = addQueryParamToUrl('count', maxSuggestions, apiUrl, false); const url = addQueryParamToUrl('partial', searchTerm.replace(' ', '+'), dataSrc); @@ -20,13 +13,8 @@ export default (searchTerm, maxSuggestions, apiUrl, followedTopicIds) => { if (!response.ok) { throw new Error(response.statusText); } + return response.json(); }) - .then(suggestions => { - return separateFollowedAndUnfollowed(suggestions, followedTopicIds) - }) - .catch(() => { - throw new Error(); - }); - + .then(suggestions => ({ suggestions })); }; diff --git a/components/x-topic-search/stories/index.js b/components/x-topic-search/stories/index.js index 419ff844a..601676cb3 100644 --- a/components/x-topic-search/stories/index.js +++ b/components/x-topic-search/stories/index.js @@ -15,3 +15,6 @@ exports.dependencies = { exports.stories = [ require('./topic-search') ]; + +exports.knobs = require('./knobs'); + diff --git a/components/x-topic-search/stories/knobs.js b/components/x-topic-search/stories/knobs.js new file mode 100644 index 000000000..7809db53e --- /dev/null +++ b/components/x-topic-search/stories/knobs.js @@ -0,0 +1,19 @@ +module.exports = (data, { select }) => { + return { + followedTopicIds() { + return select( + 'Followed Topics', + { + None: [], + 'World Elephant Water Polo': ['f95d1e16-2307-4feb-b3ff-6f224798aa49'], + 'Brexit, Brexit Briefing, Brexit Unspun Podcast': [ + '19b95057-4614-45fb-9306-4d54049354db', + '464cc2f2-395e-4c36-bb29-01727fc95558', + 'c4e899ed-157e-4446-86f0-5a65803dc07a' + ] + }, + [] + ); + } + }; +}; diff --git a/components/x-topic-search/stories/topic-search.js b/components/x-topic-search/stories/topic-search.js index 76f3536de..387b4f604 100644 --- a/components/x-topic-search/stories/topic-search.js +++ b/components/x-topic-search/stories/topic-search.js @@ -1,6 +1,6 @@ exports.title = 'Topic Search Bar'; -exports.data = { +const data = { minSearchLength: 2, maxSuggestions: 10, apiUrl: '//tag-facets-api.ft.com/annotations', @@ -10,6 +10,10 @@ exports.data = { csrfToken: 'csrfToken' }; +exports.data = data; + +exports.knobs = Object.keys(data); + // This reference is only required for hot module loading in development // exports.m = module;