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;