Skip to content

Commit

Permalink
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
@@ -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>
);
};
131 changes: 131 additions & 0 deletions components/x-topic-search/src/TopicSearch.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
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.handleInputClick = this.handleInputClick.bind(this);
this.handleInputFocus = this.handleInputFocus.bind(this);
this.handleInteractionOutside = this.handleInteractionOutside.bind(this);

this.state = {
followedTopicIds: props.followedTopicIds || [],
searchTerm: '',
showResult: false
};
}

componentDidMount() {
this.outsideEvents.forEach(action => {
document.body.addEventListener(action, this.handleInteractionOutside);
});
}

componentWillUnmount() {
this.outsideEvents.forEach(action => {
document.body.removeEventListener(action, this.handleInteractionOutside);
});
}

handleInputChange(event) {
const searchTerm = event.target.value.trim();

this.setState({ searchTerm });

if (searchTerm.length >= this.minSearchLength) {
this.getSuggestions(searchTerm, this.maxSuggestions, this.apiUrl)
.then(({ suggestions }) => {
this.setState({
suggestions,
showResult: true
});
})
.catch(() => {
this.setState({
showResult: false
});
});
} else {
this.setState({
showResult: false
});
}
}

handleInteractionOutside(event) {
if (!this.rootEl.contains(event.target)) {
this.setState({
showResult: false
});
}
}

handleInputClick() {
if (this.state.searchTerm.length >= this.minSearchLength) {
this.setState({
showResult: true
});
}
}

handleInputFocus(event) {
event.target.select();
this.handleInputClick();
}

render() {
const { csrfToken, followedTopicIds, renderFollowButton } = this.props;
const { searchTerm, showResult, suggestions } = this.state;

return (
<div className={classNames(styles['container'])} ref={el => this.rootEl = el}>
<h2 className="o-normalise-visually-hidden">
Search for topics, authors, companies, or other areas of interest
</h2>

<label className="o-normalise-visually-hidden" htmlFor="topic-search-input">Search and add topics</label>
<div className={classNames(styles["input-wrapper"])}>
<i className={classNames(styles["search-icon"])}/>
<input
type="search"
id="topic-search-input"
placeholder="Search and add topics"
className={classNames(styles["input"])}
data-trackable="topic-search"
autoComplete="off"
onInput={this.handleInputChange}
onClick={this.handleInputClick}
onFocus={this.handleInputFocus}
/>
</div>

{showResult && searchTerm.length >= this.minSearchLength &&
<div className={classNames(styles['result-container'])}>
{suggestions.length > 0 ?
<SuggestionList
csrfToken={csrfToken}
followedTopicIds={followedTopicIds}
searchTerm={searchTerm}
suggestions={suggestions}
renderFollowButton={renderFollowButton}
/> :
<NoSuggestions searchTerm={searchTerm}/>}
</div>}
</div>
);
}
}

export { TopicSearch };
128 changes: 128 additions & 0 deletions components/x-topic-search/src/TopicSearch.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
$system-code:'github:Financial-Times/x-dash' !default;

@import 'o-icons/main';
@import 'o-colors/main';
@import 'o-typography/main';
@import 'o-editorial-typography/main';

:global {
@import "~@financial-times/x-follow-button/dist/FollowButton";
}

.container {
position: relative;
text-align: center;
background-color: oColorsByName('claret-70');
color: oColorsByName('white');
width: 100%;
}

.input-wrapper {
position: relative;
}

.search-icon {
@include oIconsContent($icon-name: 'search', $color: oColorsByName('white'), $size: 32);
position: absolute;
top: 4px;
left: -7px;
}

.input {
@include oTypographySans($scale: 0);
-webkit-appearance: none;
width: 100%;
min-height: 40px;
margin: 0;
border: none;
border-bottom: 2px solid oColorsByName('white');
padding: 0 9px 0 24px;
max-width: none;
color: oColorsByName('white');
background: transparent;

&::placeholder {
color: oColorsByName('white');
}

&::-webkit-search-cancel-button {
@include oIconsContent($icon-name: 'cross', $color: oColorsByName('white'), $size: 26);
-webkit-appearance: none;
}

&::-webkit-search-decoration,
&::-webkit-search-results-button,
&::-webkit-search-results-decoration {
display: none;
}
}

.result-container {
position: absolute;
background: oColorsByName('white');
top: 48px;
padding: 10px;
z-index: 1;
width: calc(100% - 20px);
}

.suggestions {
list-style: none;
padding: 0;
text-align: left;
margin: 0;
}

.suggestion {
display: flex;
justify-content: space-between;
align-items: center;
clear: right;
padding: 5px 0;
border-bottom: 1px solid oColorsByName('black-5');

&:last-child {
border-bottom: 0;
padding-bottom: 0;
}

&:first-child {
padding-top: 0;
}

}

.suggestion__name {
@include oTypographySans($scale: -2, $weight: 'semibold');
@include oEditorialTypographyTag($type: 'topic');
padding: 5px 0;
max-width: 50%;
word-wrap: break-word;
overflow-wrap: break-word;
}

.no-suggestions {
@include oTypographySans($scale: 1);
color: oColorsByName('black-70');
text-align: left;
padding: 0 0 5px 0;
margin: 0;
p {
margin: 0;
}
}

.no-suggestions__title {
@include oTypographySans($scale: 3);
font-weight: normal;
overflow-wrap: break-word;
margin: 0;
padding: 0 0 20px;
}

.no-suggestions__message {
margin-top: 12px;
margin-bottom: 0;
padding-left: 20px;
list-style-type: disc;
}
20 changes: 20 additions & 0 deletions components/x-topic-search/src/lib/get-suggestions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const addQueryParamToUrl = (name, value, url, append = true) => {
const queryParam = `${name}=${value}`;

return append === true ? `${url}&${queryParam}` : `${url}?${queryParam}`;
};

export default (searchTerm, maxSuggestions, apiUrl) => {
const dataSrc = addQueryParamToUrl('count', maxSuggestions, apiUrl, false);
const url = addQueryParamToUrl('partial', searchTerm.replace(' ', '+'), dataSrc);

return fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(response.statusText);
}

return response.json();
})
.then(suggestions => ({ suggestions }));
};
52 changes: 52 additions & 0 deletions components/x-topic-search/storybook/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React from 'react'
import { TopicSearch } from '../src/TopicSearch'
import BuildService from '../../../.storybook/build-service'

// Set up basic document styling using the Origami build service
const dependencies = {
'o-normalise': '^2.0.0',
'o-typography': '^6.0.0',
'o-colors': '^5.0.0',
'o-icons': '^6.0.0'
}

export default {
title: 'x-topic-search'
}

export const _TopicSearchBar = (args) => {
return (
<div className="story-container">
<BuildService dependencies={dependencies} />
<TopicSearch {...args} />
</div>
)
}

_TopicSearchBar.args = {
minSearchLength: 2,
maxSuggestions: 10,
apiUrl: '//tag-facets-api.ft.com/annotations',
followedTopicIds: ['f95d1e16-2307-4feb-b3ff-6f224798aa49'],
csrfToken: 'csrfToken'
}

_TopicSearchBar.argTypes = {
minSearchLength: { name: 'Minimum search start length' },
maxSuggestions: { name: 'Maximum sugggestions to show' },
apiUrl: { name: 'URL of the API to use' },
followedTopicIds: {
type: 'select',
name: 'Followed Topics',
options: {
None: [],
'World Elephant Water Polo': ['f95d1e16-2307-4feb-b3ff-6f224798aa49'],
'Brexit, Britain after Brexit, Brexit Unspun Podcast': [
'19b95057-4614-45fb-9306-4d54049354db',
'464cc2f2-395e-4c36-bb29-01727fc95558',
'c4e899ed-157e-4446-86f0-5a65803dc07a'
]
}
},
csrfToken: { name: 'CSRF Token' }
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -53,6 +53,7 @@
"husky": "^4.0.0",
"jest": "^24.8.0",
"lint-staged": "^10.0.0",
"node-fetch": "^2.3.0",
"node-sass": "^4.12.0",
"prettier": "^2.0.2",
"react": "^16.8.6",
@@ -69,7 +70,7 @@
"engine": {
"browser": "react",
"server": "react"
}
}
},
"husky": {
"hooks": {

0 comments on commit 076490d

Please sign in to comment.