diff --git a/.eslintignore b/.eslintignore index 25a743b72..52079d084 100644 --- a/.eslintignore +++ b/.eslintignore @@ -6,4 +6,4 @@ **/public/** **/public-prod/** **/blueprints/** -web/static/** \ No newline at end of file +web/static/** diff --git a/.storybook/config.js b/.storybook/config.js index f9260ac8d..b21eb1dca 100644 --- a/.storybook/config.js +++ b/.storybook/config.js @@ -13,4 +13,5 @@ configure(() => { // Add regular story definitions (i.e. those using storiesOf() directly below) require('../components/x-increment/storybook/index.jsx'); + require('../components/x-follow-button/storybook/index.jsx'); }, module); diff --git a/.storybook/register-components.js b/.storybook/register-components.js index 06ad9288b..071883d9f 100644 --- a/.storybook/register-components.js +++ b/.storybook/register-components.js @@ -4,7 +4,7 @@ const components = [ require('../components/x-teaser/storybook'), require('../components/x-styling-demo/stories'), require('../components/x-gift-article/stories'), - require('../components/x-podcast-launchers/stories'), + require('../components/x-podcast-launchers/stories') ]; module.exports = components; diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js index b419c439b..70520a4c8 100644 --- a/.storybook/webpack.config.js +++ b/.storybook/webpack.config.js @@ -2,6 +2,7 @@ // See https://storybook.js.org/configurations/custom-webpack-config/ for more info. const path = require('path'); +const glob = require('glob'); const fs = require('fs'); const xBabelConfig = require('../packages/x-babel-config'); const xEngine = require('../packages/x-engine/src/webpack'); @@ -58,6 +59,29 @@ module.exports = ({ config }) => { return name.includes('babel-preset-minify') === false; }); + config.module.rules.push({ + test: /\.(scss|sass)$/, + use: [ + { + loader: require.resolve('style-loader') + }, + { + loader: require.resolve('css-loader'), + options: { + url: false, + import: false, + modules: true + } + }, + { + loader: require.resolve('sass-loader'), + options: { + includePaths: glob.sync('./components/*/bower_components', { absolute: true }), + } + } + ] + }); + // HACK: Ensure we only bundle one instance of React config.resolve.alias.react = require.resolve('react'); diff --git a/__mocks__/styleMock.js b/__mocks__/styleMock.js new file mode 100644 index 000000000..f053ebf79 --- /dev/null +++ b/__mocks__/styleMock.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/components/x-follow-button/.bowerrc b/components/x-follow-button/.bowerrc new file mode 100644 index 000000000..59e9a5925 --- /dev/null +++ b/components/x-follow-button/.bowerrc @@ -0,0 +1,8 @@ +{ + "registry": { + "search": [ + "https://origami-bower-registry.ft.com", + "https://registry.bower.io" + ] + } +} diff --git a/components/x-follow-button/__tests__/x-follow-button.test.jsx b/components/x-follow-button/__tests__/x-follow-button.test.jsx new file mode 100644 index 000000000..ae149fc84 --- /dev/null +++ b/components/x-follow-button/__tests__/x-follow-button.test.jsx @@ -0,0 +1,174 @@ +const { h } = require('@financial-times/x-engine'); +const { mount } = require('@financial-times/x-test-utils/enzyme'); + +import { FollowButton } from '../src/FollowButton'; + +describe('x-follow-button', () => { + describe('concept name', () => { + it('when conceptNameAsButtonText prop is true, and the topic name is provided, the button is named by this name', () => { + const subject = mount( + + ); + expect(subject.find('button').text()).toEqual('dummy concept name'); + }); + + it('when conceptNameAsButtonText prop is false, the button has a default name', () => { + const subject = mount( + + ); + expect(subject.find('button').text()).toEqual('Add to myFT'); + }); + + it('when conceptNameAsButtonText prop is true, and the topic name is not provided, the button has a default name', () => { + const subject = mount(); + expect(subject.find('button').text()).toEqual('Add to myFT'); + }); + + it('when conceptNameAsButtonText prop is not provided, the button has a default name', () => { + const subject = mount(); + expect(subject.find('button').text()).toEqual('Add to myFT'); + }); + }); + describe('conceptId prop', () => { + it('assigns conceptId prop to data-concept-id attribute of the button', async () => { + const subject = mount(); + expect(subject.find('button').prop('data-concept-id')).toEqual('dummy-id'); + }); + + it('assigns conceptId prop to data-concept-id attribute of the form', async () => { + const subject = mount(); + expect(subject.find('form').prop('data-concept-id')).toEqual('dummy-id'); + }); + }); + + describe('form action', () => { + it('assigns follow-plus-digest-email put action if followPlusDigestEmail is true', async () => { + const subject = mount(); + expect(subject.find('form').prop('action')).toEqual( + '/__myft/api/core/follow-plus-digest-email/dummy-id?method=put' + ); + }); + + it('assigns followed/concept delete action if isFollowed is true', async () => { + const subject = mount(); + expect(subject.find('form').prop('action')).toEqual( + '/__myft/api/core/followed/concept/dummy-id?method=delete' + ); + }); + + it('assigns followed/concept put action if isFollowed and followPlusDigestEmail are not passed', async () => { + const subject = mount(); + expect(subject.find('form').prop('action')).toEqual( + '/__myft/api/core/followed/concept/dummy-id?method=put' + ); + }); + }); + + describe('isFollowed', () => { + describe('when true', () => { + it('button text is "Added"', () => { + const subject = mount(); + expect(subject.find('button').text()).toEqual('Added'); + }); + + it('button aria-pressed is "true"', () => { + const subject = mount(); + expect(subject.find('button').prop('aria-pressed')).toEqual('true'); + }); + + it('button title is "Remove ConceptName from myFT"', () => { + const subject = mount(); + expect(subject.find('button').prop('title')).toEqual('Remove ConceptName from myFT'); + }); + + it('button aria-label is "Remove conceptName from myFT"', () => { + const subject = mount(); + expect(subject.find('button').prop('aria-label')).toEqual('Remove ConceptName from myFT'); + }); + }); + + describe('when false', () => { + it('button text is "Add to myFT"', () => { + const subject = mount(); + expect(subject.find('button').text()).toEqual('Add to myFT'); + }); + + it('button aria-pressed is "false"', () => { + const subject = mount(); + expect(subject.find('button').prop('aria-pressed')).toEqual('false'); + }); + + it('button title is "Add ConceptName to myFT"', () => { + const subject = mount(); + expect(subject.find('button').prop('title')).toEqual('Add ConceptName to myFT'); + }); + + it('button aria-label is "Add ConceptName to myFT"', () => { + const subject = mount(); + expect(subject.find('button').prop('aria-label')).toEqual('Add ConceptName to myFT'); + }); + }); + }); + + describe('followPlusDigestEmail', () => { + describe('when true', () => { + it('form has data-myft-ui-variant property which is true', () => { + const subject = mount(); + expect(subject.find('form').prop('data-myft-ui-variant')).toEqual(true); + }); + + it('button has data-trackable-context-messaging property which is add-to-myft-plus-digest-button', () => { + const subject = mount(); + expect(subject.find('button').prop('data-trackable-context-messaging')).toEqual( + 'add-to-myft-plus-digest-button' + ); + }); + }); + + describe('when false', () => { + it('form has data-myft-ui-variant property which is true', () => { + const subject = mount(); + expect(subject.find('form').prop('data-myft-ui-variant')).toEqual(undefined); + }); + + it('button has data-trackable-context-messaging property which is add-to-myft-plus-digest-button', () => { + const subject = mount(); + expect(subject.find('button').prop('data-trackable-context-messaging')).toEqual(null); + }); + }); + }); + + describe('form properties', () => { + it('method = GET', () => { + const subject = mount(); + expect(subject.find('form').prop('method')).toEqual('GET'); + }); + }); + + describe('button properties', () => { + it('data-trackable="follow"', () => { + const subject = mount(); + expect(subject.find('button').prop('data-trackable')).toEqual('follow'); + }); + + it('type="submit"', () => { + const subject = mount(); + expect(subject.find('button').prop('type')).toEqual('submit'); + }); + }); + + describe('csrf token', () => { + it('if passed creates an invisible input field', () => { + const subject = mount(); + expect(subject.find('input').prop('value')).toEqual('dummyToken'); + expect(subject.find('input').prop('type')).toEqual('hidden'); + expect(subject.find('input').prop('name')).toEqual('token'); + expect(subject.find('input').prop('data-myft-csrf-token')).toEqual(true); + }); + + it('if not passed an invisible input field is not created', () => { + const subject = mount(); + expect(subject.find('input')).toEqual({}); + }); + }); +}); diff --git a/components/x-follow-button/bower.json b/components/x-follow-button/bower.json new file mode 100644 index 000000000..48cc79ee1 --- /dev/null +++ b/components/x-follow-button/bower.json @@ -0,0 +1,10 @@ +{ + "name": "x-follow-button", + "private": true, + "main": "dist/FollowButton.es5.js", + "dependencies": { + "o-colors": "^5.0.3", + "o-icons": "^6.0.0", + "o-typography": "^6.1.0" + } +} diff --git a/components/x-follow-button/package.json b/components/x-follow-button/package.json new file mode 100644 index 000000000..a873b6f42 --- /dev/null +++ b/components/x-follow-button/package.json @@ -0,0 +1,31 @@ +{ + "name": "@financial-times/x-follow-button", + "version": "1.0.0", + "description": "", + "main": "dist/FollowButton.cjs.js", + "style": "dist/FollowButton.css", + "browser": "dist/FollowButton.es5.js", + "module": "dist/FollowButton.esm.js", + "scripts": { + "prepare": "bower install && npm run build", + "build": "node rollup.js", + "start": "node rollup.js --watch" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@financial-times/x-rollup": "file:../../packages/x-rollup", + "@financial-times/x-test-utils": "file:../../packages/x-test-utils", + "rollup": "^0.57.1", + "bower": "^1.7.9", + "node-sass": "^4.9.2" + }, + "peerDependencies": { + "@financial-times/x-engine": "file:../../packages/x-engine" + }, + "dependencies": { + "@financial-times/x-engine": "file:../../packages/x-engine", + "classnames": "^2.2.6" + } +} diff --git a/components/x-follow-button/readme.md b/components/x-follow-button/readme.md new file mode 100644 index 000000000..e1ce947ff --- /dev/null +++ b/components/x-follow-button/readme.md @@ -0,0 +1,43 @@ +# x-follow-button + +This module provides a template for myFT follow topic button, and is intended to replace the legacy handlebars component in [n-myft-ui](https://github.com/Financial-Times/n-myft-ui/tree/master/components/follow-button). + +## Installation + +```bash +npm install --save @financial-times/x-follow-button +``` + +## Props + +(Some of the properties don't influence the way button looks or acts, but can be used for e.g. client-side Javascript in the apps). + +Feature | Type | Required | Default value | Description +----------------------------|---------|----------|----------------|--------------- +`conceptId` | String | yes | none | UUID of the concept +`conceptName` | String | yes | none | Name of the concept +`conceptNameAsButtonText` | Boolean | no | `false` | If true will use the concept name as the button text, otherwise will default to "Add to MyFT" or "Remove from MyFT" (depending on isFollowed prop). +`isFollowed` | Boolean | no | `false` | Whether the concept is followed or not. +`csrfToken` | String | no | none | CSRF token (will be included in a hidden form field). +`variant` | String | no | `standard` | One of `standard`, `inverse`, `opinion` or `monochrome`. Other values will be ignored. +`followPlusDigestEmail` | Boolean | no | `false` | Whether following the topic should also subscribe to the digest. + +## Client side behaviour + +For users with JavaScript enabled, the default form submit action is prevented, and a custom event (named 'x-follow-button') will be dispatched on the form element. + +This custom event will contain the following in its `detail` object: + +Property | Value +-------------------|----------------- +`action` | `add` or `remove` +`actorType` | `user` +`relationshipName` | `followed` +`subjectType` | `concept` +`subjectId` | the value of the `conceptId` prop +`token` | the value of the `csrfToken` prop + +It is up to the consumer of this component to listen for the `x-follow-button` event, and use this data, along with the user's ID, and carry out the appropriate action. + +For example, if using `next-myft-client` to carry out the follow/unfollow action, n-myft-ui provides a x-button-interaction component for this: +https://github.com/Financial-Times/n-myft-ui/blob/master/components/x-button-integration/index.js diff --git a/components/x-follow-button/rollup.js b/components/x-follow-button/rollup.js new file mode 100644 index 000000000..075823328 --- /dev/null +++ b/components/x-follow-button/rollup.js @@ -0,0 +1,4 @@ +const xRollup = require('@financial-times/x-rollup'); +const pkg = require('./package.json'); + +xRollup({ input: './src/FollowButton.jsx', pkg }); diff --git a/components/x-follow-button/src/FollowButton.jsx b/components/x-follow-button/src/FollowButton.jsx new file mode 100644 index 000000000..807e28a72 --- /dev/null +++ b/components/x-follow-button/src/FollowButton.jsx @@ -0,0 +1,81 @@ +import { h } from '@financial-times/x-engine'; +import classNames from 'classnames'; +import styles from './styles/main.scss'; + +export const FollowButton = (props) => { + const { + conceptNameAsButtonText = false, + conceptId, + conceptName, + isFollowed, + csrfToken, + followPlusDigestEmail, + onSubmit, + variant + } = props; + const VARIANTS = ['standard', 'inverse', 'opinion', 'monochrome']; + + const getFormAction = () => { + if (followPlusDigestEmail) { + return `/__myft/api/core/follow-plus-digest-email/${conceptId}?method=put`; + } else if (isFollowed) { + return `/__myft/api/core/followed/concept/${conceptId}?method=delete`; + } else { + return `/__myft/api/core/followed/concept/${conceptId}?method=put`; + } + }; + + const getButtonText = () => { + if (conceptNameAsButtonText && conceptName) { + return conceptName; + } + + return isFollowed ? 'Added' : 'Add to myFT'; + }; + + const getAccessibleText = () => + isFollowed ? `Remove ${conceptName} from myFT` : `Add ${conceptName} to myFT`; + + return ( +
{ + event.preventDefault(); + const detail = { + action: isFollowed ? 'remove' : 'add', + actorType: 'user', + actorId: null, // myft client sets to user id from session + relationshipName: 'followed', + subjectType: 'concept', + subjectId: conceptId, + token: csrfToken + }; + + if (typeof onSubmit === 'function') { + onSubmit(detail); + } + + event.target.dispatchEvent(new CustomEvent('x-follow-button', { bubbles: true, detail })); + }} + {...(followPlusDigestEmail ? { 'data-myft-ui-variant': true } : null)}> + {csrfToken && } +