diff --git a/.circleci/config.yml b/.circleci/config.yml
index b9be963cc..c38b41685 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -5,10 +5,10 @@ references:
#
# Workspace
#
- container_config_node8: &container_config_node8
+ container_config_node: &container_config_node
working_directory: ~/project/build
docker:
- - image: circleci/node:8.15
+ - image: circleci/node:12-browsers
workspace_root: &workspace_root
~/project
@@ -22,37 +22,17 @@ references:
#
cache_keys_root: &cache_keys_root
keys:
- - cache-root-v1-{{ .Branch }}-{{ checksum "./package.json" }}
-
- cache_keys_docs: &cache_keys_docs
- keys:
- - cache-docs-v1-{{ .Branch }}-{{ checksum "./tools/x-docs/package.json" }}
-
- cache_keys_storybook: &cache_keys_storybook
- keys:
- - cache-storybook-v1-{{ .Branch }}-{{ checksum "./tools/x-storybook/package.json" }}
+ - cache-root-v4-{{ .Branch }}-{{ checksum "./package.json" }}
#
# Cache creation
#
create_cache_root: &create_cache_root
save_cache:
- key: cache-root-v1-{{ .Branch }}-{{ checksum "./package.json" }}
+ key: cache-root-v4-{{ .Branch }}-{{ checksum "./package.json" }}
paths:
- ./node_modules/
- create_cache_docs: &create_cache_docs
- save_cache:
- key: cache-docs-v1-{{ .Branch }}-{{ checksum "./tools/x-docs/package.json" }}
- paths:
- - ./tools/x-docs/node_modules/
-
- create_cache_storybook: &create_cache_storybook
- save_cache:
- key: cache-storybook-v1-{{ .Branch }}-{{ checksum "./tools/x-storybook/package.json" }}
- paths:
- - ./tools/x-storybook/node_modules/
-
#
# Cache restoration
#
@@ -60,23 +40,19 @@ references:
restore_cache:
<<: *cache_keys_root
- restore_cache_docs: &restore_cache_docs
- restore_cache:
- <<: *cache_keys_docs
-
- restore_cache_storybook: &restore_cache_storybook
- restore_cache:
- <<: *cache_keys_storybook
-
#
# Filters
#
- filters_branch_build: &filters_branch_build
+
+ filters_only_renovate_nori: &filters_only_renovate_nori
branches:
- ignore:
- - gh-pages
+ only: /(^renovate-.*|^nori\/.*)/
+
+ filters_ignore_tags_renovate_nori_build: &filters_ignore_tags_renovate_nori_build
tags:
ignore: /.*/
+ branches:
+ ignore: /(^renovate-.*|^nori\/.*|^gh-pages)/
filters_release_build: &filters_release_build
tags:
@@ -92,23 +68,21 @@ references:
branches:
ignore: /.*/
- filters_master_branch: &filters_master_branch
+ filters_only_main: &filters_only_main
branches:
only:
- - master
+ - main
jobs:
build:
- <<: *container_config_node8
+ <<: *container_config_node
steps:
- checkout
- run:
name: Checkout next-ci-shared-helpers
command: git clone --depth 1 git@github.com:Financial-Times/next-ci-shared-helpers.git .circleci/shared-helpers
- *restore_cache_root
- - *restore_cache_docs
- - *restore_cache_storybook
- run:
name: Install project dependencies
command: make install
@@ -116,23 +90,32 @@ jobs:
name: Run the project build task
command: make build
- *create_cache_root
- - *create_cache_docs
- - *create_cache_storybook
- persist_to_workspace:
root: *workspace_root
paths:
- build
test:
- <<: *container_config_node8
+ <<: *container_config_node
steps:
- *attach_workspace
- run:
name: Run tests
command: make test
+ - run:
+ name: Run storybook
+ command: npm run start-storybook:ci
+
+ e2e-test:
+ <<: *container_config_node
+ steps:
+ - *attach_workspace
+ - run:
+ name: Run end to end test
+ command: npm run e2e
publish:
- <<: *container_config_node8
+ <<: *container_config_node
steps:
- *attach_workspace
- run:
@@ -146,35 +129,36 @@ jobs:
command: npx athloi publish -- --access=public
prerelease:
- <<: *container_config_node8
+ <<: *container_config_node
steps:
- *attach_workspace
- run:
name: shared-helper / npm-store-auth-token
command: .circleci/shared-helpers/helper-npm-store-auth-token
- run:
- name: Bump version number
+ name: Extract tag name and version number
command: |
# https://circleci.com/docs/2.0/env-vars/#using-bash_env-to-set-environment-variables
- export PRERELEASE_SEMVER='v0\.[0-9]{1,2}\.[0-9]{1,2}(-[a-z]+\.[0-9])?'
+ export PRERELEASE_SEMVER='v0\.[0-9]{1,2}\.[0-9]{1,2}(-[a-z]+\.[0-9]+)?'
export TARGET_VERSION=$(echo $CIRCLE_TAG | grep -o -E $PRERELEASE_SEMVER);
export TARGET_MODULE=$(echo $CIRCLE_TAG | sed -E "s/-${PRERELEASE_SEMVER}//g");
+ echo "export TARGET_VERSION=$TARGET_VERSION" >> $BASH_ENV;
+ echo "export TARGET_MODULE=$TARGET_MODULE" >> $BASH_ENV;
echo "Creating prerelease ${TARGET_VERSION} for ${TARGET_MODULE}";
- npx athloi -F ${TARGET_MODULE} version ${TARGET_VERSION};
+ - run:
+ name: Bump version number
+ command: npx athloi -F ${TARGET_MODULE} version ${TARGET_VERSION};
- run:
name: NPM publish
command: npx athloi -F ${TARGET_MODULE} publish -- --access=public --tag=pre-release
deploy:
- <<: *container_config_node8
+ <<: *container_config_node
steps:
- *attach_workspace
- - add_ssh_keys:
- fingerprints:
- - "2b:98:17:21:34:bf:5d:3b:15:a5:82:77:90:11:03:e9"
- run:
- name: Publish GitHub Pages
- command: ./private/scripts/gh-pages
+ name: Deploy Storybook
+ command: npm run deploy-storybook:ci
workflows:
@@ -184,18 +168,32 @@ workflows:
jobs:
- build:
filters:
- <<: *filters_branch_build
+ <<: *filters_ignore_tags_renovate_nori_build
- test:
- filters:
- <<: *filters_branch_build
+ requires:
+ - build
+ - e2e-test:
requires:
- build
- deploy:
filters:
- <<: *filters_master_branch
+ <<: *filters_only_main
requires:
- test
+ renovate-nori-build-test:
+ jobs:
+ - waiting-for-approval:
+ type: approval
+ filters:
+ <<: *filters_only_renovate_nori
+ - build:
+ requires:
+ - waiting-for-approval
+ - test:
+ requires:
+ - build
+
build-test-publish-deploy:
jobs:
- build:
diff --git a/.eslintignore b/.eslintignore
index c29184fe1..0fe84d9ab 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1,3 +1,4 @@
+**/*.ts
**/coverage/**
**/node_modules/**
**/bower_components/**
@@ -6,4 +7,5 @@
**/public/**
**/public-prod/**
**/blueprints/**
-tools/x-docs/static/**
\ No newline at end of file
+web/static/**
+/e2e/**
\ No newline at end of file
diff --git a/.eslintrc.js b/.eslintrc.js
index e3967a3f0..5d5b914a8 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -1,12 +1,11 @@
module.exports = {
+ parser: '@typescript-eslint/parser',
env: {
node: true,
browser: true,
es6: true
},
- plugins: [
- 'jsx-a11y'
- ],
+ plugins: ['jsx-a11y'],
extends: [
'eslint:recommended',
// https://github.com/jest-community/eslint-plugin-jest
@@ -14,7 +13,8 @@ module.exports = {
// https://github.com/yannickcr/eslint-plugin-react
'plugin:react/recommended',
// https://github.com/evcohen/eslint-plugin-jsx-a11y
- 'plugin:jsx-a11y/recommended'
+ 'plugin:jsx-a11y/recommended',
+ 'prettier'
],
parserOptions: {
ecmaFeatures: {
@@ -35,12 +35,15 @@ module.exports = {
// We don't use display names for SFCs
'react/display-name': 'off',
// This rule is intended to catch < or > but it's too eager
- 'react/no-unescaped-entities': 'off'
+ 'react/no-unescaped-entities': 'off',
+ // this rule is deprecated and replaced with label-has-associated-control
+ 'jsx-a11y/label-has-for': 'off',
+ 'jsx-a11y/label-has-associated-control': 'error'
},
overrides: [
{
// Components in x-dash interact with x-engine rather than React
- files: [ 'components/**/*.jsx' ],
+ files: ['components/*/src/**/*.jsx', 'components/*/__tests__/**/*.jsx'],
settings: {
react: {
pragma: 'h',
@@ -52,4 +55,4 @@ module.exports = {
}
}
]
-};
+}
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index a48dd077b..9e20308da 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,12 +1,22 @@
-If this is your first `x-dash` pull request please familiarise yourself with the contribution guide before submitting.
-
-**TL;DR**
-
- - Discuss features first
- - Update the documentation
- - No hacks, experiments or temporary workarounds
- - Reviewers are empowered to say no
- - Reference other issues
- - Update affected stories and snapshots
- - Follow the code style
- - Decide on a version (major, minor, or patch)
+If this is your first `x-dash` pull request please familiarise yourself with the [contribution guide](https://github.com/Financial-Times/x-dash/blob/HEAD/contribution.md) before submitting.
+
+## If you're creating a component:
+
+- Add the `Component` label to this Pull Request
+- If this will be a long-lived PR, consider using smaller PRs targeting this branch for individual features, so your team can review them without involving x-dash maintainers
+ - If you're using this workflow, create a Label and a Project for your component and ensure all small PRs are attached to them. Add the Project to the [Components board](https://github.com/Financial-Times/x-dash/projects/4)
+ - put a link to this Pull Request in the Project description
+ - set the Project to `Automated kanban with reviews`, but remove the `To Do` column
+ - If you're not using this workflow, add this Pull Request to the [Components board](https://github.com/Financial-Times/x-dash/projects/4).
+
+##
+
+- Discuss features first
+- Update the documentation
+- **Must** be tested in FT.com and Apps before merge
+- No hacks, experiments or temporary workarounds
+- Reviewers are empowered to say no
+- Reference other issues
+- Update affected stories and snapshots
+- Follow the code style
+- Decide on a version (major, minor, or patch)
diff --git a/.github/settings.yml b/.github/settings.yml
new file mode 100644
index 000000000..8c831e50d
--- /dev/null
+++ b/.github/settings.yml
@@ -0,0 +1,16 @@
+_extends: github-apps-config-next
+branches:
+ - name: main
+ protection:
+ required_pull_request_reviews:
+ required_approving_review_count: 1
+ dismiss_stale_reviews: true
+ require_code_owner_reviews: false
+ required_status_checks:
+ strict: true
+ contexts:
+ - 'ci/circleci: test'
+ enforce_admins: true
+ restrictions:
+ users: []
+ teams: []
diff --git a/.gitignore b/.gitignore
index aed1a305d..78ec717b3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,5 @@ bower_components
npm-debug.log
.DS_Store
dist
+.idea
+coverage
\ No newline at end of file
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 000000000..5e454b3c5
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,9 @@
+{
+ "printWidth": 110,
+ "semi": false,
+ "singleQuote": true,
+ "bracketSpacing": true,
+ "arrowParens": "always",
+ "jsxBracketSameLine": true,
+ "trailingComma": "none"
+}
diff --git a/.prettierrc.json b/.prettierrc.json
deleted file mode 100644
index c2f098c4c..000000000
--- a/.prettierrc.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "printWidth": 100,
- "semi": true,
- "singleQuote": true,
- "bracketSpacing": true,
- "arrowParens": "always",
- "jsxBracketSameLine": true
-}
diff --git a/.snyk b/.snyk
new file mode 100644
index 000000000..861428bd4
--- /dev/null
+++ b/.snyk
@@ -0,0 +1,4 @@
+# Snyk (https://snyk.io) policy file, which patches or ignores known vulnerabilities.
+version: v1.13.5
+ignore: {}
+patch: {}
diff --git a/.storybook/.browserslistrc b/.storybook/.browserslistrc
new file mode 100644
index 000000000..a4eb887f0
--- /dev/null
+++ b/.storybook/.browserslistrc
@@ -0,0 +1,4 @@
+last 2 Chrome versions
+last 2 FF versions
+last 2 Edge versions
+Safari >= 12
diff --git a/tools/x-storybook/.storybook/build-service.js b/.storybook/build-service.js
similarity index 55%
rename from tools/x-storybook/.storybook/build-service.js
rename to .storybook/build-service.js
index bbeb441a9..4c8267456 100644
--- a/tools/x-storybook/.storybook/build-service.js
+++ b/.storybook/build-service.js
@@ -1,23 +1,25 @@
-import React from 'react';
-import { Helmet } from 'react-helmet';
+import React from 'react'
+import { Helmet } from 'react-helmet'
function buildServiceUrl(deps, type) {
- const modules = Object.keys(deps).map((i) => `${i}@${deps[i]}`).join(',');
- return `https://www.ft.com/__origami/service/build/v2/bundles/${type}?modules=${modules}`;
+ const modules = Object.keys(deps)
+ .map((i) => `${i}@${deps[i]}`)
+ .join(',')
+ return `https://www.ft.com/__origami/service/build/v2/bundles/${type}?modules=${modules}`
}
class BuildService extends React.Component {
constructor(props) {
- super(props);
- this.initialised = [];
+ super(props)
+ this.initialised = []
}
componentDidUpdate() {
if (window.hasOwnProperty('Origami')) {
for (const component in Origami) {
if (typeof Origami[component].init === 'function') {
- const instance = Origami[component].init();
- this.initialised.concat(instance);
+ const instance = Origami[component].init()
+ this.initialised.concat(instance)
}
}
}
@@ -26,22 +28,22 @@ class BuildService extends React.Component {
componentWillUnmount() {
this.initialised.forEach((instance) => {
if (typeof instance.destroy === 'function') {
- instance.destroy();
+ instance.destroy()
}
- });
+ })
}
render() {
- const js = buildServiceUrl(this.props.dependencies, 'js');
- const css = buildServiceUrl(this.props.dependencies, 'css');
+ const js = buildServiceUrl(this.props.dependencies, 'js')
+ const css = buildServiceUrl(this.props.dependencies, 'css')
return (
- );
+ )
}
}
-export default BuildService;
+export default BuildService
diff --git a/.storybook/main.js b/.storybook/main.js
new file mode 100644
index 000000000..df6cdb76b
--- /dev/null
+++ b/.storybook/main.js
@@ -0,0 +1,4 @@
+module.exports = {
+ stories: ['../components/**/storybook/index.jsx'],
+ addons: ['@storybook/addon-essentials']
+}
diff --git a/tools/x-storybook/.storybook/preview-head.html b/.storybook/preview-head.html
similarity index 100%
rename from tools/x-storybook/.storybook/preview-head.html
rename to .storybook/preview-head.html
diff --git a/.storybook/static/.gitignore b/.storybook/static/.gitignore
new file mode 100644
index 000000000..c96a04f00
--- /dev/null
+++ b/.storybook/static/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
\ No newline at end of file
diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js
new file mode 100644
index 000000000..7a3bd3f72
--- /dev/null
+++ b/.storybook/webpack.config.js
@@ -0,0 +1,92 @@
+// This configuration extends the existing Storybook Webpack config.
+// 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')
+const CopyPlugin = require('copy-webpack-plugin')
+const WritePlugin = require('write-file-webpack-plugin')
+
+const excludePaths = [/node_modules/, /dist/]
+
+const cssCopy = fs.readdirSync(path.resolve('components')).reduce((mains, component) => {
+ const componentPkg = path.resolve('components', component, 'package.json')
+
+ if (fs.existsSync(componentPkg)) {
+ const pkg = require(componentPkg)
+
+ if (pkg.style) {
+ const styleResolved = path.resolve('components', component, pkg.style)
+
+ return mains.concat({
+ from: styleResolved,
+ to: path.resolve(__dirname, 'static/components', path.basename(pkg.name), pkg.style)
+ })
+ }
+ }
+
+ return mains
+}, [])
+
+module.exports = ({ config }) => {
+ // HACK: extend existing JS rule to ensure all dependencies are correctly ignored
+ // from Babel transpilation.
+ // https://github.com/storybooks/storybook/issues/3346#issuecomment-459439438
+ const jsRule = config.module.rules.find((rule) => rule.test.test('.jsx'))
+ jsRule.exclude = excludePaths
+
+ // HACK: Instruct Babel to check module type before injecting Core JS polyfills
+ // https://github.com/i-like-robots/broken-webpack-bundle-test-case
+ const babelConfig = jsRule.use.find(({ loader }) => loader === 'babel-loader') || {
+ options: { presets: [] }
+ }
+ babelConfig.options.sourceType = 'unambiguous'
+
+ // Override the Babel configuration for all x- components with our own
+ babelConfig.options.overrides = [
+ {
+ test: /\/components\/x-[^\/]+\/src\//,
+ ...xBabelConfig()
+ }
+ ]
+
+ // HACK: there is a bug in babel-plugin-minify-simplify which cannot
+ // handle how Babel transpiles restful destructing assignment so remove it.
+ // e.g. const { foo, ...qux } = { foo: 0, bar: 1, baz: 2 }
+ babelConfig.options.presets = babelConfig.options.presets.filter((preset) => {
+ const name = Array.isArray(preset) ? preset[0] : preset
+ 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')
+
+ config.plugins.push(xEngine(), new CopyPlugin(cssCopy), new WritePlugin())
+
+ return config
+}
diff --git a/CODEOWNERS b/CODEOWNERS
new file mode 100644
index 000000000..5b3748a6b
--- /dev/null
+++ b/CODEOWNERS
@@ -0,0 +1,12 @@
+# See https://help.github.com/articles/about-codeowners/ for more information about this file.
+
+* @financial-times/platforms
+
+# component ownership
+
+components/x-privacy-manager @Financial-Times/ads
+components/x-teaser @Financial-Times/content-discovery
+components/x-teaser-timeline @Financial-Times/content-discovery
+components/x-live-blog-post @Financial-Times/content-innovation
+components/x-live-blog-wrapper @Financial-Times/content-innovation
+components/x-gift-article @Financial-Times/accounts
diff --git a/__mocks__/styleMock.js b/__mocks__/styleMock.js
new file mode 100644
index 000000000..4ba52ba2c
--- /dev/null
+++ b/__mocks__/styleMock.js
@@ -0,0 +1 @@
+module.exports = {}
diff --git a/__tests__/snapshots.test.js b/__tests__/snapshots.test.js
deleted file mode 100644
index 518ef658e..000000000
--- a/__tests__/snapshots.test.js
+++ /dev/null
@@ -1,35 +0,0 @@
-const renderer = require('react-test-renderer');
-const fs = require('fs');
-const path = require('path');
-const glob = require('glob');
-
-const { workspaces } = require('../package.json');
-
-const packagesGlob = workspaces.length > 1
- ? `{${workspaces.join(',')}}`
- : workspaces[0];
-
-const packageDirs = glob.sync(packagesGlob);
-
-for(const pkg of packageDirs) {
- const pkgDir = path.resolve(pkg);
- const storiesDir = path.resolve(pkgDir, 'stories');
-
- if(fs.existsSync(storiesDir)) {
- const { package: pkg, stories, 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)) {
- it(`renders a ${preset} ${title} ${name}`, () => {
- const props = { ...data, ...options };
- const tree = renderer.create(component(props)).toJSON();
- expect(tree).toMatchSnapshot();
- });
- }
- }
- });
- }
-}
diff --git a/app.json b/app.json
new file mode 100644
index 000000000..e28d05fb9
--- /dev/null
+++ b/app.json
@@ -0,0 +1,18 @@
+{
+ "env": {
+ "NPM_CONFIG_PRODUCTION": {
+ "description": "don't prune devDependencies",
+ "value": "false"
+ }
+ },
+ "formation": {
+ "web": {
+ "quantity": 1,
+ "size": "Standard-1X"
+ }
+ },
+ "buildpacks": [
+ {"url": "heroku/nodejs"},
+ {"url": "https://github.com/heroku/heroku-buildpack-static.git"}
+ ]
+}
diff --git a/changelog.md b/changelog.md
deleted file mode 100644
index 29a8b9726..000000000
--- a/changelog.md
+++ /dev/null
@@ -1,49 +0,0 @@
-# Changelog
-
-## v1
-
-### v1.0.0-beta.9
-
-- Refactors x-teaser to make the teaser standfirst a link (#268)
-
-### v1.0.0-beta.8
-
-- Updates x-teaser for cross-compatibility with o-teaser v2 and v3 (#256)
-
-### v1.0.0-beta.7
-
-- Added new x-node-jsx package to enable direct usage of .jsx files (#217)
-
-### v1.0.0-beta.6
-
-- Refactors wrapping logic to enable re-wrapping a component with x-interaction (#214)
-
-### v1.0.0-beta.5
-
-- Adds audio to ContentTypes type in TypeScript declaration file in x-teaser (#209)
-
-### v1.0.0-beta.4
-
-- Add support for an isPodcast identifier to x-teaser (#211)
-
-### v1.0.0-beta.3
-
-- Removes whitespace output around text output in x-teaser (#146)
-- Refactors headshot prop to accept a string value in x-teaser (#153)
-- Refactors theme indicator and theme rule to enable explicit overrides in x-teaser (#154)
-- Adds parentTheme prop to x-teaser (#154)
-
-
-### v1.0.0-beta.2
-
-- Adds support for fragments to x-engine (#134)
-- Removes obsolete syndication props from x-teaser (#137)
-- Adds support for configurable image lazy loading to x-teaser (#135)
-- Refactors rendering of title prop to avoid stringification in x-teaser (#139)
-- Updates presets to enable relative time by default in x-teaser (#142)
-- Adds XXL image size prop to x-teaser (#143)
-- Refactors teaser rulesets to avoid minification issues in x-teaser (#144)
-
-### v1.0.0-beta.1
-
-- Initial public release of x-engine, x-interaction, x-handlebars, and x-teaser
diff --git a/components/x-article-save-button/stories/default.js b/components/x-article-save-button/stories/default.js
deleted file mode 100644
index 66d6159c5..000000000
--- a/components/x-article-save-button/stories/default.js
+++ /dev/null
@@ -1,17 +0,0 @@
-exports.title = 'ArticleSaveButton';
-
-const data = {
- contentId: '0000-0000-0000-0000',
- contentTitle: 'UK crime agency steps up assault on Russian dirty money',
- csrfToken: 'dummy-token',
- saved: false,
- trackableId: 'trackable-id'
-};
-
-exports.data = data;
-
-exports.knobs = Object.keys(data);
-
-// This reference is only required for hot module loading in development
-//
-exports.m = module;
diff --git a/components/x-article-save-button/stories/index.js b/components/x-article-save-button/stories/index.js
deleted file mode 100644
index 495147ecb..000000000
--- a/components/x-article-save-button/stories/index.js
+++ /dev/null
@@ -1,14 +0,0 @@
-const { ArticleSaveButton } = require('../');
-
-exports.component = ArticleSaveButton;
-exports.package = require('../package.json');
-exports.knobs = require('./knobs');
-exports.dependencies = {
- "o-colors": "^4.7.2",
- "o-icons": "^5.7.1",
- 'o-normalise': '^1.6.0',
- 'o-typography': '^5.5.0'
-};
-exports.stories = [
- require('./default')
-];
diff --git a/components/x-article-save-button/stories/knobs.js b/components/x-article-save-button/stories/knobs.js
deleted file mode 100644
index c20330588..000000000
--- a/components/x-article-save-button/stories/knobs.js
+++ /dev/null
@@ -1,15 +0,0 @@
-module.exports = (data, { text, boolean }) => {
-
- const Groups = {
- Form: 'Form',
- Content: 'Content'
- };
-
- return {
- csrfToken: text('CSRF token', data.csrfToken, Groups.Form),
- contentId: text('Content ID', data.contentId, Groups.Content),
- contentTitle: text('Content title', data.contentTitle, Groups.Content),
- trackableId: text('Trackable ID', data.trackableId),
- saved: boolean('Saved', data.saved, Groups.Content),
- };
-};
diff --git a/components/x-article-save-button/storybook/index.jsx b/components/x-article-save-button/storybook/index.jsx
new file mode 100644
index 000000000..f16ec51fd
--- /dev/null
+++ b/components/x-article-save-button/storybook/index.jsx
@@ -0,0 +1,37 @@
+import React from 'react'
+import { ArticleSaveButton } from '../src/ArticleSaveButton'
+import BuildService from '../../../.storybook/build-service'
+
+// Set up basic document styling using the Origami build service
+const dependencies = {
+ 'o-fonts': '^3.0.0'
+}
+
+export default {
+ title: 'x-article-save-button'
+}
+
+export const _ArticleSaveButton = (args) => {
+ return (
+
+
+
+
+ )
+}
+
+_ArticleSaveButton.args = {
+ saved: false,
+ csrfToken: 'dummy-token',
+ trackableId: 'trackable-id',
+ contentId: '0000-0000-0000-0000',
+ contentTitle: 'UK crime agency steps up assault on Russian dirty money'
+}
+
+_ArticleSaveButton.argTypes = {
+ csrfToken: { name: 'CSRF token' },
+ contentId: { name: 'Content ID' },
+ contentTitle: { name: 'Content title' },
+ trackableId: { name: 'Trackable ID' },
+ saved: { name: 'Saved' }
+}
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..b4b0337e6
--- /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..78abb478d
--- /dev/null
+++ b/components/x-follow-button/package.json
@@ -0,0 +1,30 @@
+{
+ "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",
+ "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..838f36ecf
--- /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/HEAD/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/HEAD/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..9347ae8b4
--- /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..9c5d5ffb2
--- /dev/null
+++ b/components/x-follow-button/src/FollowButton.jsx
@@ -0,0 +1,79 @@
+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 (
+
+ )
+}
diff --git a/components/x-follow-button/src/styles/components/FollowButton.scss b/components/x-follow-button/src/styles/components/FollowButton.scss
new file mode 100644
index 000000000..3c98e9db9
--- /dev/null
+++ b/components/x-follow-button/src/styles/components/FollowButton.scss
@@ -0,0 +1,11 @@
+@import '../mixins/lozenge/main';
+
+.button {
+ @include myftLozenge($with-toggle-icon: true);
+}
+
+@each $theme in map-keys($myft-lozenge-themes) {
+ .button--#{$theme} {
+ @include myftLozengeTheme($theme, $with-toggle-icon: true);
+ }
+}
diff --git a/components/x-follow-button/src/styles/main.scss b/components/x-follow-button/src/styles/main.scss
new file mode 100644
index 000000000..529d088fa
--- /dev/null
+++ b/components/x-follow-button/src/styles/main.scss
@@ -0,0 +1,10 @@
+// TODO: update me to not need a system code
+$system-code:'github:Financial-Times/x-dash' !default;
+
+@import 'o-icons/main';
+@import 'o-colors/main';
+@import 'o-typography/main';
+
+@import './mixins/lozenge/main.scss';
+
+@import './components/FollowButton.scss';
diff --git a/components/x-follow-button/src/styles/mixins/lozenge/_themes.scss b/components/x-follow-button/src/styles/mixins/lozenge/_themes.scss
new file mode 100644
index 000000000..72af64738
--- /dev/null
+++ b/components/x-follow-button/src/styles/mixins/lozenge/_themes.scss
@@ -0,0 +1,46 @@
+$theme-map: null;
+
+$myft-lozenge-themes: (
+ standard: (
+ background: oColorsByName('claret'),
+ text: oColorsByName('white'),
+ highlight: oColorsByName('claret-50'),
+ pressed-highlight: rgba(oColorsByName('black'), 0.05),
+ disabled: rgba(oColorsByName('black'), 0.5),
+ focus-outline: oColorsByUsecase('focus', 'outline', $fallback: null)
+ ),
+ inverse: (
+ background: oColorsByName('white'),
+ text: oColorsByName('claret'),
+ highlight: rgba(white, 0.8),
+ pressed-highlight: rgba(white, 0.2),
+ disabled: rgba(oColorsByName('white'), 0.5),
+ focus-outline: oColorsByName('white')
+ ),
+ opinion: (
+ background: oColorsByName('oxford-40'),
+ text: oColorsByName('white'),
+ highlight: oColorsByName('oxford-30'),
+ pressed-highlight: rgba(oColorsByName('oxford-40'), 0.2),
+ disabled: rgba(oColorsByName('black'), 0.5),
+ focus-outline: oColorsByUsecase('focus', 'outline', $fallback: null)
+ ),
+ monochrome: (
+ background: oColorsByName('white'),
+ text: oColorsByName('black'),
+ highlight: oColorsByName('white-80'),
+ pressed-highlight: rgba(oColorsByName('white'), 0.2),
+ disabled: rgba(oColorsByName('white'), 0.5),
+ focus-outline: oColorsByName('white')
+ )
+);
+
+@function getThemeColor($key) {
+ @return map-get($theme-map, $key);
+}
+
+@mixin withTheme($theme) {
+ $theme-map: map-get($myft-lozenge-themes, $theme) !global;
+
+ @content;
+}
diff --git a/components/x-follow-button/src/styles/mixins/lozenge/_toggle-icon.scss b/components/x-follow-button/src/styles/mixins/lozenge/_toggle-icon.scss
new file mode 100644
index 000000000..302298c94
--- /dev/null
+++ b/components/x-follow-button/src/styles/mixins/lozenge/_toggle-icon.scss
@@ -0,0 +1,43 @@
+@mixin getIcon($name, $color) {
+ @include oIconsContent($icon-name: $name, $size: 10, $color: $color, $iconset-version: 1);
+ content: '';
+}
+
+@mixin plusIcon($color) {
+ @include getIcon('plus', $color);
+ background-size: 25px;
+ margin: 0 6px -1px 0;
+}
+
+@mixin tickIcon($color) {
+ @include getIcon('tick', $color);
+ background-size: 21px;
+}
+
+@mixin myftToggleIcon($theme: standard) {
+ @include withTheme($theme) {
+ &::before {
+ @include plusIcon(getThemeColor(background));
+ }
+
+ &[aria-pressed="true"] {
+ &::before {
+ @include tickIcon(getThemeColor(text));
+ }
+ }
+
+ &[disabled],
+ &[disabled]:hover {
+ background: transparent;
+
+ &::before {
+ @include plusIcon(getThemeColor(disabled));
+ opacity: 0.5;
+ }
+
+ &[aria-pressed="true"]::before {
+ @include tickIcon(getThemeColor(disabled));
+ }
+ }
+ }
+}
diff --git a/components/x-follow-button/src/styles/mixins/lozenge/main.scss b/components/x-follow-button/src/styles/mixins/lozenge/main.scss
new file mode 100644
index 000000000..6ef420cc0
--- /dev/null
+++ b/components/x-follow-button/src/styles/mixins/lozenge/main.scss
@@ -0,0 +1,109 @@
+@import './themes';
+@import './toggle-icon';
+
+@mixin focusOutlineColor($focus-color) {
+ // Apply :focus styles as a fallback
+ // These styles will be applied to all browsers that don't use the polyfill, this includes browsers which support the feature natively.
+ :global(body:not(.js-focus-visible)) &,
+ :global(html:not(.js-focus-visible)) & {
+ // Standardise focus styles.
+ &:focus {
+ outline: 2px solid $focus-color;
+ }
+ }
+
+ // When the focus-visible polyfill is applied `.js-focus-visible` is added to the html dom node
+ // (the body node in v4 of the 3rd party polyfill)
+
+ // stylelint-disable-next-line selector-no-qualifying-type
+ :global(body.js-focus-visible) &, // stylelint-disable-next-line selector-no-qualifying-type
+ :global(html.js-focus-visible) & {
+ // Standardise focus styles.
+ // stylelint-disable-next-line selector-no-qualifying-type
+ &:global(.focus-visible) {
+ outline: 2px solid $focus-color;
+ }
+ // Disable browser default focus style.
+ // stylelint-disable-next-line selector-no-qualifying-type
+ &:focus:not(:global(.focus-visible)) {
+ outline: 0;
+ }
+ }
+
+ // These styles will be ignored by browsers which do not recognise the :focus-visible selector (as per the third bullet point in https://www.w3.org/TR/selectors-3/#Conformance)
+ // If a browser supports :focus-visible we unset the :focus styles that were applied above
+ // (within the html:not(.js-focus-visible) block).
+ &:focus-visible,
+ :global(body:not(.js-focus-visible)) &:focus,
+ :global(html:not(.js-focus-visible)) &:focus {
+ outline: unset;
+ }
+
+ // Styles given :focus-visible support. Extra selectors needed to match
+ // previous `:focus` selector specificity.
+ :global(body:not(.js-focus-visible)) &:focus-visible,
+ :global(html:not(.js-focus-visible)) &:focus-visible,
+ &:focus-visible {
+ outline: 2px solid $focus-color;
+ }
+}
+
+@mixin myftLozengeTheme($theme: standard, $with-toggle-icon: false) {
+ @if $with-toggle-icon != false {
+ @include myftToggleIcon($theme);
+ }
+
+ @include withTheme($theme) {
+ background-color: transparent;
+ border: 1px solid getThemeColor(background);
+ color: getThemeColor(background);
+
+ @include focusOutlineColor(getThemeColor(focus-outline))
+
+ &:hover,
+ &:focus {
+ background-color: getThemeColor(pressed-highlight);
+ border-color: getThemeColor(background);
+ color: getThemeColor(background);
+ }
+
+ &[aria-pressed="true"] {
+ background-color: getThemeColor(background);
+ border: 1px solid getThemeColor(background);
+ color: getThemeColor(text);
+
+ &:hover,
+ &:focus {
+ background-color: getThemeColor(highlight);
+ border-color: getThemeColor(highlight);
+ color: getThemeColor(text);
+ }
+ }
+
+ &[disabled]:hover,
+ &[disabled] {
+ background: transparent;
+ border: 1px solid getThemeColor(disabled);
+ color: getThemeColor(disabled);
+ }
+ }
+}
+
+@mixin myftLozenge($theme: standard, $with-toggle-icon: false) {
+ @include myftLozengeTheme($theme, $with-toggle-icon);
+ @include oTypographySans($scale: -1, $weight: 'semibold');
+
+ border-radius: 100px; // Number that will be larger than any possible height, so that works for all possible button sizes
+ box-sizing: content-box;
+ display: block;
+ font-size: 14px;
+ margin: 6px 4px 6px 2px;
+ max-width: 200px;
+ outline-offset: 2px;
+ overflow: hidden;
+ padding: 5px 12px;
+ text-align: left;
+ text-overflow: ellipsis;
+ transition: border-color, background-color 0.5s ease;
+ white-space: nowrap;
+}
diff --git a/components/x-follow-button/storybook/index.jsx b/components/x-follow-button/storybook/index.jsx
new file mode 100644
index 000000000..d9947c135
--- /dev/null
+++ b/components/x-follow-button/storybook/index.jsx
@@ -0,0 +1,21 @@
+import React from 'react'
+import { FollowButton } from '../src/FollowButton'
+
+export default {
+ title: 'x-follow-button'
+}
+
+export const _FollowButton = (args) => {
+ return
+}
+
+_FollowButton.args = {
+ conceptNameAsButtonText: false,
+ isFollowed: false,
+ conceptName: 'UK politics & policy',
+ followPlusDigestEmail: true,
+ variant: 'standard'
+}
+_FollowButton.argTypes = {
+ variant: { control: { type: 'select', options: ['standard', 'inverse', 'opinion', 'monochrome'] } }
+}
diff --git a/components/x-gift-article/.bowerrc b/components/x-gift-article/.bowerrc
new file mode 100644
index 000000000..39039a4a1
--- /dev/null
+++ b/components/x-gift-article/.bowerrc
@@ -0,0 +1,8 @@
+{
+ "registry": {
+ "search": [
+ "https://origami-bower-registry.ft.com",
+ "https://registry.bower.io"
+ ]
+ }
+}
diff --git a/components/x-gift-article/.gitignore b/components/x-gift-article/.gitignore
new file mode 100644
index 000000000..67af58370
--- /dev/null
+++ b/components/x-gift-article/.gitignore
@@ -0,0 +1,2 @@
+dist
+package-lock.json
diff --git a/components/x-gift-article/bower.json b/components/x-gift-article/bower.json
new file mode 100644
index 000000000..18dc603c2
--- /dev/null
+++ b/components/x-gift-article/bower.json
@@ -0,0 +1,14 @@
+{
+ "name": "@financial-times/x-gift-article",
+ "description": "",
+ "main": "dist/GiftArticle.es5.js",
+ "private": true,
+ "dependencies": {
+ "o-buttons": "^6.0.0",
+ "o-forms": "^8.0.0",
+ "o-loading": "^4.0.0",
+ "o-message": "^4.0.0",
+ "o-typography": "6.0.0",
+ "o-normalise": "^2.0.0"
+ }
+}
diff --git a/components/x-gift-article/package.json b/components/x-gift-article/package.json
new file mode 100644
index 000000000..8121dd068
--- /dev/null
+++ b/components/x-gift-article/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "@financial-times/x-gift-article",
+ "version": "0.0.0",
+ "description": "This module provides gift article form",
+ "main": "dist/GiftArticle.cjs.js",
+ "browser": "dist/GiftArticle.es5.js",
+ "module": "dist/GiftArticle.esm.js",
+ "style": "dist/GiftArticle.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-interaction": "file:../x-interaction",
+ "classnames": "^2.2.6"
+ },
+ "devDependencies": {
+ "@financial-times/x-rollup": "file:../../packages/x-rollup",
+ "node-sass": "^4.9.2",
+ "bower": "^1.8.8"
+ }
+}
diff --git a/components/x-gift-article/readme.md b/components/x-gift-article/readme.md
new file mode 100644
index 000000000..df16886fc
--- /dev/null
+++ b/components/x-gift-article/readme.md
@@ -0,0 +1,82 @@
+# x-gift-article
+
+This module provides a gift article form.
+
+## Installation
+
+This module is supported on Node 12 and is distributed on npm.
+
+```bash
+npm install --save @financial-times/x-gift-article
+```
+
+## Styling
+
+To get correct styling, Your app should have:
+[o-fonts](https://registry.origami.ft.com/components/o-fonts)
+
+## Usage
+
+Component provided by this module expects a map of [gift article 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 { GiftArticle } from '@financial-times/x-gift-article';
+
+// A == B == C
+const a = GiftArticle(props);
+const b = ;
+const c = React.createElement(GiftArticle, props);
+```
+
+Your app should trigger the `activate` action to activate the gift article form when your app actually displays the form. For example, if your app is client-side rendered, you can use `actionsRef` to trigger this action:
+
+```jsx
+import { h, Component } from '@financial-times/x-engine';
+import { GiftArticle } from '@financial-times/x-gift-article';
+
+class Container extends Component {
+ showGiftArticle() {
+ if(this.giftArticleActions) {
+ this.setState({ showGiftArticle: true });
+
+ // trigger the action
+ this.giftArticleActions.activate();
+ }
+ }
+
+ render() {
+ return
+
+
+
+ this.giftArticleActions = actions} />
+
+
+ }
+}
+```
+
+For more information about triggering actions, see the [x-interaction documentation][interaction].
+
+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/
+[interaction]: /components/x-interaction#triggering-actions-externally
+[engine]: https://github.com/Financial-Times/x-dash/tree/HEAD/packages/x-engine
+
+### Properties
+
+Property | Type | Required | Note
+--------------------------|---------|----------|----
+`isFreeArticle` | Boolean | yes | Only non gift form is displayed when this value is `true`.
+`article` | Object | yes | Must contain `id`, `title` and `url` properties
+`showMobileShareLinks` | Boolean | no | For ft.com on mobile sharing.
+`nativeShare` | Boolean | no | This is a property for App to display Native Sharing.
+`apiProtocol` | String | no | The protocol to use when making requests to the gift article and URL shortening services. Ignored if `apiDomain` is not set.
+`apiDomain` | String | no | The domain to use when making requests to the gift article and URL shortening services.
+
+###
+`isArticleSharingUxUpdates` boolean has been added as part of ACC-749 to enable AB testing of the impact of minor UX improvements to x-gift-article. Once AB testing is done, and decision to keep / remove has been made, the changes made in https://github.com/Financial-Times/x-dash/pull/579 need to be ditched or baked in as default.
diff --git a/components/x-gift-article/rollup.js b/components/x-gift-article/rollup.js
new file mode 100644
index 000000000..8973a9a58
--- /dev/null
+++ b/components/x-gift-article/rollup.js
@@ -0,0 +1,4 @@
+const xRollup = require('@financial-times/x-rollup')
+const pkg = require('./package.json')
+
+xRollup({ input: './src/GiftArticle.jsx', pkg })
diff --git a/components/x-gift-article/src/Buttons.jsx b/components/x-gift-article/src/Buttons.jsx
new file mode 100644
index 000000000..77d028e39
--- /dev/null
+++ b/components/x-gift-article/src/Buttons.jsx
@@ -0,0 +1,65 @@
+import { h } from '@financial-times/x-engine'
+import { ShareType } from './lib/constants'
+import styles from './GiftArticle.scss'
+
+const ButtonsClassName = styles.buttons
+
+const ButtonClassNames = styles['buttonBaseStyle']
+
+const ButtonWithGapClassNames = [ButtonClassNames, 'js-copy-link', styles['button--with-gap']].join(' ')
+
+export default ({
+ shareType,
+ isGiftUrlCreated,
+ mailtoUrl,
+ showCopyButton,
+ nativeShare,
+ actions,
+ giftCredits
+}) => {
+ if (isGiftUrlCreated || shareType === ShareType.nonGift) {
+ if (nativeShare) {
+ return (
+
@@ -13,8 +13,104 @@ Check out the [getting started] guide to begin hacking on x-dash.
[Google Slides]: https://docs.google.com/presentation/d/1Z8mGsv4JU2TafNPIHw2RcejoNp7AN_v4LfCCGC7qrgw/edit?usp=sharing
[getting started]: https://financial-times.github.io/x-dash/docs/get-started/installation
-### How is that not Origami?
+## How is that not Origami?
Origami components are designed to work across the whole of FT and our sub-brands, making as few assumptions as possible about the tech stack that will be consuming them. Origami components don't contain templating, only styles and behaviour. It's up to each individual app to produce markup for components.
x-dash aims to complement Origami by providing easily reusable and composable templates, flexibly enough to work across Next and Apps apps.
+
+## Installing x-dash
+
+### Requirements
+
+To get started with x-dash, you'll need to make sure you have the following software tools installed.
+
+1. [Git](https://git-scm.com/)
+2. [Make](https://www.gnu.org/software/make/)
+3. [Node.js](https://nodejs.org/en/) (version 12)
+4. [npm](http://npmjs.com/)
+
+Please note that x-dash has only been tested in Mac and Linux environments. If you are on a Mac you may find it easiest to install the [Command Line Tools](https://developer.apple.com/download/more/) package which includes Git and Make.
+
+#### Recommended
+
+To aid the development of interactive components with Storybook it is recommended to install the React Developer Tools for your browser. These addons aid debugging by displaying the rendered component tree and provide access to view and edit their properties:
+
+- [React Developer Tools for Chrome](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi)
+- [React Developer Tools for Firefox](https://addons.mozilla.org/en-GB/firefox/addon/react-devtools/)
+
+
+### Project setup
+
+1. Clone the x-dash Git repository and change to the new directory that has been created:
+
+ ```bash
+ git clone git@github.com:financial-times/x-dash
+ cd x-dash
+ ```
+
+2. Install all of the project dependencies (this may take a few minutes if you are running this for the first time):
+
+ ```bash
+ make install
+ ```
+
+3. Build the current set of x-dash components and start Storybook to view:
+
+ ```bash
+ make build
+ npm run start-storybook
+ ```
+
+## Working with x-dash
+
+The project repository is a monorepo which means all of the tools, packages and components are kept in one place and can be worked upon concurrently.
+
+
+### Repository structure
+
+The repository groups related code together in directories. UI components are stored in the `components` directory, documentation files in the `docs` directory, additional public packages in the `packages` directory and tools to aid working with x-dash are in the `tools` directory.
+
+```
+├ components/
+│ └ x-component/
+│ ├ readme.md
+│ └ package.json
+├ packages/
+│ └ x-package/
+│ ├ readme.md
+│ └ package.json
+├ tools/
+│ └ x-docs/
+│ └ package.json
+├ readme.md
+└ package.json
+```
+
+### Using Storybook
+
+[Storybook] is a development environment and showcase for UI components. It makes working on and sharing UI components easier by providing a richly configurable environment.
+
+After installing x-dash you can start Storybook by running the following command from the repository root:
+
+```sh
+npm run start-storybook
+```
+
+This command will start a server running on [local.ft.com:9001] and generate an application presenting all of the components configured to use it. Changes to these components can be updated in real-time speeding up the development process.
+
+Data properties passed to these component may also be configured in-situ and these changes will be reflected in the URL making it possible to share specific configurations.
+
+[Storybook]: https://storybook.js.org/
+[local.ft.com:9001]: http://local.ft.com:9001/
+
+
+## Coding standards
+
+The best way to ensure you stick to the x-dash code style is to make your work consistent with the code around it. We also provide a [Prettier] configuration to automatically format files and run [ESLint] before any tests. See the [contribution guide] for more information.
+
+[Prettier]: https://prettier.io/
+[ESLint]: https://eslint.org/
+[contribution guide]: https://github.com/Financial-Times/x-dash/blob/HEAD/contribution.md
+
+For more in-depth information visit the [Wiki](https://github.com/Financial-Times/x-dash/wiki)
\ No newline at end of file
diff --git a/release-guidelines.md b/release-guidelines.md
deleted file mode 100644
index d4e99c79a..000000000
--- a/release-guidelines.md
+++ /dev/null
@@ -1,34 +0,0 @@
-# Release Guidelines
-
-## Experimental features
-
-Only stable, well tested components and packages may be present in the master or development branches. _Any publishable code in the master or development branches must have been tested in both The App and FT.com_. This is so we do not release unproven components with a stable version number.
-
-To develop your component create a new feature branch including your module name, for example if you are building a new tabs component you would create a branch named `feature-x-tabs`. Your component will stay in this branch until it is ready to be merged into the next major or minor release so you are encouraged to merge from or rebase onto the latest development or master branch regularly. You are welcome to raise pull requests against your feature branch if you need to.
-
-Because experimental modules will not be included in any stable releases we allow them to be published separately using a pre-1.0.0 version number. You are free to make as many prereleases as you need. To create a prerelease of your experimental module you must create a tag in the format `module-name-v0.x.x`, for example to release the tabs component you would create tag named `x-tabs-v0.0.1` for the latest commit in the `feature-x-tabs` branch.
-
-You are encouraged to use an identifier to namespace your prereleases, e.g. `x-tags-v0.0.1-beta.1`, as this will also prevent Renovate from automatically creating a PR for updating applications using an earlier version of your component (this would be undesirable if your new component version contained breaking changes which cannot be expressed with semver).
-
-When your new module is considered stable raise a pull request against the current development branch. Your module will be released as part of the next major or minor version.
-
-
-## Releasing/Versioning
-
-All of our projects are versioned using [Semantic Versioning], you should familiarise yourself with this. The following guide will outline how to tag and release a new version of all projects, it assumes that all the code you wish to release is now on the `master` or main branch.
-
- 1. **Review the commits since the last release**. You can find the last release in the git log, or by using the compare feature on GitHub. Make sure you've pulled all of the latest changes.
-
- 2. **Decide on a version**. Work out whether this release is major, minor, or patch level. Major releases are generally planned out; if a breaking change has snuck into `master` without prior-planning it may be worth removing it or attempting to make it backwards-compatible.
-
- 3. **Write the changelog**. This project has a `changelog.md` file in the root. You should create a new section at the top with the new version number and the date, then outline all of the changes as a list. Follow the style of the rest of the document.
-
- 4. **Update any package files**. Add the new version to package files. This could include `package.json` or `bower.json` as examples. A quick way to check if you've got them all is by running: `git grep "current-version-number"`
-
- 5. **Commit your changes**. Commit the changes to changelong, README, and package files. The commit message should be "Version x.x.x" (exact casing, and with no "v" preceeding the version number). This is the _only_ time you're allowed to commit directly to `master`.
-
- 6. **Add a release**. Create a release using the GitHub UI (note there should be a "v" preceeding the version number). This will automatically kick off a new build and publish each package.
-
- 7. **Celebrate**. :tada::beer::cake::cocktail:
-
-[semantic versioning]: http://semver.org/
diff --git a/renovate.json b/renovate.json
new file mode 100644
index 000000000..2bde8702b
--- /dev/null
+++ b/renovate.json
@@ -0,0 +1,5 @@
+{
+ "extends": [
+ "github>financial-times/renovate-config-next-beta"
+ ]
+}
diff --git a/static.json b/static.json
new file mode 100644
index 000000000..9e4c3e264
--- /dev/null
+++ b/static.json
@@ -0,0 +1,3 @@
+{
+ "root": "dist/storybook"
+}
diff --git a/tools/x-docs/.gitignore b/tools/x-docs/.gitignore
deleted file mode 100644
index a1610c548..000000000
--- a/tools/x-docs/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-.cache
-/public
diff --git a/tools/x-docs/gatsby-config.js b/tools/x-docs/gatsby-config.js
deleted file mode 100644
index 36b851000..000000000
--- a/tools/x-docs/gatsby-config.js
+++ /dev/null
@@ -1,57 +0,0 @@
-module.exports = {
- // The GitHub Pages deployment will be in this sub-folder
- pathPrefix: '/x-dash',
- polyfill: false,
- siteMetadata: {
- title: 'x-dash',
- siteUrl: 'https://financial-times.github.io/x-dash/',
- description: 'Shared front-end for FT.com and The App.'
- },
- plugins: [
- {
- resolve: 'gatsby-source-filesystem',
- options: {
- name: 'docs',
- path: './src/data'
- },
- },
- {
- resolve: 'gatsby-source-filesystem',
- options: {
- name: 'docs',
- path: '../../docs'
- },
- },
- {
- resolve: 'gatsby-source-filesystem',
- options: {
- name: 'components',
- path: '../../components',
- ignore: ['**/bower_components']
- },
- },
- {
- resolve: 'gatsby-source-filesystem',
- options: {
- name: 'packages',
- path: '../../packages',
- ignore: ['**/bower_components']
- },
- },
- // Handles markdown files (creates "MarkdownRemark" nodes)
- {
- resolve: 'gatsby-transformer-remark',
- options: {
- plugins: [
- 'gatsby-remark-prismjs',
- 'gatsby-remark-autolink-headers',
- 'gatsby-remark-external-links'
- ]
- }
- },
- // Handles package manifest files (creates "NpmPackage" nodes)
- 'gatsby-transformer-npm-package',
- // Handles YAML files (creates "YourFileNameYaml" nodes)
- 'gatsby-transformer-yaml'
- ]
-};
diff --git a/tools/x-docs/gatsby-node.js b/tools/x-docs/gatsby-node.js
deleted file mode 100644
index 93f3c2286..000000000
--- a/tools/x-docs/gatsby-node.js
+++ /dev/null
@@ -1,14 +0,0 @@
-const decorateNodes = require('./src/lib/decorate-nodes');
-const createNpmPackagePages = require('./src/lib/create-npm-package-pages');
-const createDocumentationPages = require('./src/lib/create-documentation-pages');
-
-exports.onCreateNode = ({ node, actions, getNode }) => {
- decorateNodes(node, actions, getNode);
-};
-
-exports.createPages = async ({ actions, graphql }) => {
- return Promise.all([
- createNpmPackagePages(actions, graphql),
- createDocumentationPages(actions, graphql)
- ]);
-};
diff --git a/tools/x-docs/package.json b/tools/x-docs/package.json
deleted file mode 100644
index f072a35b4..000000000
--- a/tools/x-docs/package.json
+++ /dev/null
@@ -1,39 +0,0 @@
-{
- "name": "@financial-times/x-docs",
- "private": true,
- "version": "0.0.0",
- "description": "",
- "scripts": {
- "build": "gatsby build --prefix-paths --verbose",
- "start": "gatsby develop -H local.ft.com",
- "test": "echo \"Error: no test specified\" && exit 1"
- },
- "license": "ISC",
- "devDependencies": {
- "@financial-times/x-logo": "file:../../packages/x-logo",
- "@financial-times/x-storybook": "file:../x-storybook",
- "case": "^1.5.5",
- "gatsby": "^2.0.11",
- "gatsby-remark-autolink-headers": "^2.0.6",
- "gatsby-remark-external-links": "0.0.4",
- "gatsby-remark-prismjs": "^3.0.1",
- "gatsby-source-filesystem": "^2.0.2",
- "gatsby-transformer-remark": "^2.1.6",
- "gatsby-transformer-yaml": "^2.1.1",
- "graphql-type-json": "^0.2.1",
- "prismjs": "^1.15.0",
- "react": "^16.4.1",
- "react-dom": "^16.4.1",
- "react-helmet": "^5.2.0"
- },
- "browserslist": [
- "> 1%",
- "not ie 11"
- ],
- "x-dash": {
- "engine": {
- "server": "react",
- "browser": "react"
- }
- }
-}
diff --git a/tools/x-docs/plugins/gatsby-transformer-npm-package/extend-node-type.js b/tools/x-docs/plugins/gatsby-transformer-npm-package/extend-node-type.js
deleted file mode 100644
index f521d1b26..000000000
--- a/tools/x-docs/plugins/gatsby-transformer-npm-package/extend-node-type.js
+++ /dev/null
@@ -1,13 +0,0 @@
-const GraphQlJson = require('graphql-type-json');
-
-// This allows us to fetch the entire manifest without specifying every field \0/
-module.exports = ({ type }) => {
- if (type.name === 'NpmPackage') {
- return {
- manifest: {
- type: GraphQlJson,
- resolve: (node) => node.manifest
- }
- };
- }
-};
diff --git a/tools/x-docs/plugins/gatsby-transformer-npm-package/gatsby-node.js b/tools/x-docs/plugins/gatsby-transformer-npm-package/gatsby-node.js
deleted file mode 100644
index 8a140cc2a..000000000
--- a/tools/x-docs/plugins/gatsby-transformer-npm-package/gatsby-node.js
+++ /dev/null
@@ -1,3 +0,0 @@
-// This plugin will create new nodes for any package manifests found by the filesystem plugin
-exports.setFieldsOnGraphQLNodeType = require('./extend-node-type');
-exports.onCreateNode = require('./on-create-node');
diff --git a/tools/x-docs/plugins/gatsby-transformer-npm-package/on-create-node.js b/tools/x-docs/plugins/gatsby-transformer-npm-package/on-create-node.js
deleted file mode 100644
index 682ece288..000000000
--- a/tools/x-docs/plugins/gatsby-transformer-npm-package/on-create-node.js
+++ /dev/null
@@ -1,53 +0,0 @@
-const crypto = require('crypto');
-
-const hash = (string) => crypto.createHash('md5').update(string).digest('hex');
-
-module.exports = ({ node, actions }) => {
- const { createNode, createParentChildLink } = actions;
-
- if (node.internal.type === 'File' && node.base === 'package.json') {
- const json = require(node.absolutePath);
-
- // Assemble node information
- const npmPackageNode = {
- id: `${node.id} >>> NpmPackage`,
- children: [],
- parent: node.id,
- internal: {
- type: 'NpmPackage'
- },
- manifest: json,
- name: json.name,
- private: Boolean(json.private),
- // Mimic remark transformer
- fileAbsolutePath: node.absolutePath
- };
-
- // Append unique node hash
- npmPackageNode.internal.contentDigest = hash(JSON.stringify(npmPackageNode));
-
- createNode(npmPackageNode);
- createParentChildLink({ parent: node, child: npmPackageNode });
- }
-
- if (node.internal.type === 'File' && node.absolutePath.endsWith('stories/index.js')) {
- const contents = require(node.absolutePath);
-
- const storiesNode = {
- id: `${node.id} >>> Stories`,
- children: [],
- parent: node.id,
- internal: {
- type: 'Stories'
- },
- stories: contents.stories.map((story) => story.title),
- fileAbsolutePath: node.absolutePath
- };
-
- // Append unique node hash
- storiesNode.internal.contentDigest = hash(JSON.stringify(storiesNode));
-
- createNode(storiesNode);
- createParentChildLink({ parent: node, child: storiesNode });
- }
-};
diff --git a/tools/x-docs/plugins/gatsby-transformer-npm-package/package.json b/tools/x-docs/plugins/gatsby-transformer-npm-package/package.json
deleted file mode 100644
index 25bb6271f..000000000
--- a/tools/x-docs/plugins/gatsby-transformer-npm-package/package.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "name": "gatsby-transformer-npm-package"
-}
diff --git a/tools/x-docs/src/components/footer/index.jsx b/tools/x-docs/src/components/footer/index.jsx
deleted file mode 100644
index ec551bb5e..000000000
--- a/tools/x-docs/src/components/footer/index.jsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import React from 'react';
-
-const linkProps = {
- rel: 'noopener noreferrer',
- target: '_blank'
-};
-
-export default () => (
-
-);
diff --git a/tools/x-docs/src/components/header/index.jsx b/tools/x-docs/src/components/header/index.jsx
deleted file mode 100644
index 80cbb503f..000000000
--- a/tools/x-docs/src/components/header/index.jsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import React from 'react';
-import { Link, withPrefix } from 'gatsby';
-
-export default ({ showLogo }) => (
-
-