Skip to content

Commit

Permalink
Merge pull request #218 from Financial-Times/x-topic-search
Browse files Browse the repository at this point in the history
  • Loading branch information
apaleslimghost authored Jan 31, 2022
2 parents 05e03f5 + 8c7f58c commit 076490d
Show file tree
Hide file tree
Showing 15 changed files with 646 additions and 2 deletions.
2 changes: 1 addition & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@
**/public-prod/**
**/blueprints/**
web/static/**
/e2e/**
/e2e/**
8 changes: 8 additions & 0 deletions components/x-topic-search/.bowerrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"registry": {
"search": [
"https://origami-bower-registry.ft.com",
"https://registry.bower.io"
]
}
}
3 changes: 3 additions & 0 deletions components/x-topic-search/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
src/
stories/
rollup.js
128 changes: 128 additions & 0 deletions components/x-topic-search/__tests__/x-topic-search.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
const fetchMock = require('fetch-mock')
const { h } = require('@financial-times/x-engine')
const { mount } = require('@financial-times/x-test-utils/enzyme')
const { TopicSearch } = require('../')

const minSearchLength = 3
const maxSuggestions = 3
const apiUrl = 'api-url'
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 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(() => {
const props = {
minSearchLength,
maxSuggestions,
apiUrl,
followedTopicIds: [FOLLOWED_TOPIC_ID1, FOLLOWED_TOPIC_ID2]
}
target = mount(<TopicSearch {...props} />)
})

afterEach(() => {
fetchMock.reset()
})

describe('initial rendering', () => {
it('should render with input box', () => {
expect(target.find('input').exists()).toBe(true)
})

it('should not display result container', () => {
expect(target.render().children('div')).toHaveLength(1)
})
})

describe('when input receives focus', () => {
it('selects the text in the input', () => {
const selectMock = jest.fn()
const inputBox = target.find('input')

inputBox.simulate('blur')
inputBox.simulate('focus', { target: { select: selectMock } })

expect(selectMock).toHaveBeenCalledTimes(1)
})
})

describe('given inputted text is shorter than minSearchLength', () => {
const apiUrlWithResults = buildSearchUrl('a')

beforeEach(() => {
fetchMock.get(apiUrlWithResults, [])
return enterSearchTerm('a')
})

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('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' }
]

beforeEach(() => {
fetchMock.get(apiUrlWithResults, results)
return enterSearchTerm('Cat')
})

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')

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').text()).toEqual(
topic.id === UNFOLLOWED_TOPIC_ID1 ? 'Add to myFT' : 'Added'
)
})
})
})

describe('given searchTerm which has no topic suggestions to follow', () => {
const apiUrlNoResults = buildSearchUrl('Dog')

beforeEach(() => {
fetchMock.get(apiUrlNoResults, [])
return enterSearchTerm('Dog')
})

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)

expect(resultContainer).toHaveLength(1)
expect(resultContainer.find('h2').text()).toMatch('No topics matching')
})
})
})
11 changes: 11 additions & 0 deletions components/x-topic-search/bower.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "x-topic-search",
"main": "dist/TopicSearch.es5.js",
"private": true,
"dependencies": {
"o-icons": "^6.3.0",
"o-typography": "^6.4.6",
"o-editorial-typography": "^1.2.1",
"o-colors": "^5.4.1"
}
}
42 changes: 42 additions & 0 deletions components/x-topic-search/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"name": "@financial-times/x-topic-search",
"version": "0.0.0",
"description": "",
"main": "dist/TopicSearch.cjs.js",
"module": "dist/TopicSearch.esm.js",
"browser": "dist/TopicSearch.es5.js",
"style": "dist/TopicSearch.css",
"scripts": {
"prepare": "bower install && npm run build",
"build": "node rollup.js",
"start": "node rollup.js --watch"
},
"keywords": [
"x-dash"
],
"author": "",
"license": "ISC",
"dependencies": {
"@financial-times/x-engine": "file:../../packages/x-engine",
"@financial-times/x-follow-button": "file:../x-follow-button",
"classnames": "^2.2.6",
"debounce-promise": "^3.1.0"
},
"devDependencies": {
"@financial-times/x-rollup": "file:../../packages/x-rollup",
"@financial-times/x-test-utils": "file:../../packages/x-test-utils",
"bower": "^1.7.9",
"node-sass": "^4.9.2"
},
"repository": {
"type": "git",
"url": "https://github.com/Financial-Times/x-dash.git"
},
"homepage": "https://github.com/Financial-Times/x-dash/tree/master/components/x-topic-search",
"engines": {
"node": ">= 6.0.0"
},
"publishConfig": {
"access": "public"
}
}
54 changes: 54 additions & 0 deletions components/x-topic-search/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# x-topic-search

This module allows a user to search for topics by name, and follow them. If an already-followed topic is returned in the search results, then those topics are indicated as such.

The search results are fetched from the api whose url is passed as a property.
[next-myft-page](https://github.com/Financial-Times/next-myft-page/blob/master/client/components/topic-search/TopicSearchContainer.jsx#L9)
uses [next-tag-facets-api](https://github.com/Financial-Times/next-tag-facets-api).


## Installation

This module is compatible with Node 6+ and is distributed on npm.

```bash
npm install --save @financial-times/x-topic-search
```

The [`x-engine`][engine] module is used to inject your chosen runtime into the component. Please read the `x-engine` documentation first if you are consuming `x-` components for the first time in your application.

[engine]: https://github.com/Financial-Times/x-dash/tree/master/packages/x-engine


## Usage

The components provided by this module are all functions that expect a map of [properties](#properties). They can be used with vanilla JavaScript or JSX (If you are not familiar check out [WTF is JSX][jsx-wtf] first). For example if you were writing your application using React you could use the component like this:

```jsx
import React from 'react';
import { TopicSearch } from '@financial-times/x-topic-search';

// A == B == C
const a = TopicSearch(props);
const b = <TopicSearch {...props} />;
const c = React.createElement(TopicSearch, props);
```

All `x-` components are designed to be compatible with a variety of runtimes, not just React. Check out the [`x-engine`][engine] documentation for a list of recommended libraries and frameworks.

[jsx-wtf]: https://jasonformat.com/wtf-is-jsx/


The consumer of this component needs to update `followedTopicIds` every time when users follow or unfollow topics.


### Properties

Property | Type | Required | Note
---------------------|----------|----------|------------------
`minSearchLength` | Number | No | Minimum chars to start search. Default is 2
`maxSuggestions` | Number | No | Maximum number to display suggestions. Default is 5
`apiUrl` | String | Yes | The url to use when making requests to get topics
`followedTopicIds` | Array | Yes | Array of followed topic `id`s.
`csrfToken` | String | Yes | Value included in a hidden form field for x-follow-button
`renderFollowButton` | Function | No | Optional render prop for the follow button
4 changes: 4 additions & 0 deletions components/x-topic-search/rollup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const xRollup = require('@financial-times/x-rollup');
const pkg = require('./package.json');

xRollup({ input: './src/TopicSearch.jsx', pkg });
22 changes: 22 additions & 0 deletions components/x-topic-search/src/NoSuggestions.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { h } from '@financial-times/x-engine';
import styles from './TopicSearch.scss';
import classNames from 'classnames';

export default ({ searchTerm }) => (
<div className={classNames(styles["no-suggestions"])} aria-live="polite">

<h2 className={classNames(styles["no-suggestions__title"])}>
No topics matching <b>{searchTerm}</b>
</h2>

<p>Suggestions:</p>

<ul className={classNames(styles["no-suggestions__message"])}>
<li>Make sure that all words are spelled correctly.</li>
<li>Try different keywords.</li>
<li>Try more general keywords.</li>
<li>Try fewer keywords.</li>
</ul>

</div>
);
40 changes: 40 additions & 0 deletions components/x-topic-search/src/SuggestionList.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { h } from '@financial-times/x-engine';
import { FollowButton } from '@financial-times/x-follow-button';
import styles from './TopicSearch.scss';
import classNames from 'classnames';

const defaultFollowButtonRender = (concept, csrfToken, followedTopicIds) => (
<FollowButton
conceptId={concept.id}
conceptName={concept.prefLabel}
csrfToken={csrfToken}
isFollowed={followedTopicIds.includes(concept.id)}
/>
);

export default ({ suggestions, renderFollowButton, searchTerm, csrfToken, followedTopicIds = [] }) => {
renderFollowButton = typeof renderFollowButton === 'function' ? renderFollowButton : defaultFollowButtonRender;

return (
<ul className={classNames(styles["suggestions"])} aria-live="polite">

{suggestions.map(suggestion => (
<li className={classNames(styles["suggestion"])}
key={suggestion.id}
data-trackable="myft-topic"
data-concept-id={suggestion.id}
data-trackable-meta={'{"search-term":"' + searchTerm + '"}'}>

<a data-trackable="topic-link"
className={classNames(styles["suggestion__name"])}
href={suggestion.url || `/stream/${suggestion.id}`}>
{suggestion.prefLabel}
</a>

{renderFollowButton(suggestion, csrfToken, followedTopicIds)}
</li>
))}

</ul>
);
};
Loading

0 comments on commit 076490d

Please sign in to comment.