@financial-times/n-myft-ui 23.0.1 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.circleci/config.yml +13 -16
  2. package/.circleci/shared-helpers/helper-npm-install-peer-deps +6 -5
  3. package/.github/settings.yml +1 -1
  4. package/.scss-lint.yml +3 -3
  5. package/Makefile +1 -0
  6. package/README.md +11 -8
  7. package/build-state/npm-shrinkwrap.json +11383 -10733
  8. package/components/collections/collections.html +3 -11
  9. package/components/concept-list/concept-list.html +1 -4
  10. package/components/csrf-token/__tests__/input.test.js +23 -0
  11. package/components/csrf-token/input.jsx +26 -0
  12. package/components/follow-button/__tests__/follow-button.test.js +40 -0
  13. package/components/follow-button/follow-button.jsx +174 -0
  14. package/components/instant-alert/instant-alert.html +1 -1
  15. package/components/pin-button/pin-button.html +1 -1
  16. package/components/save-for-later/save-for-later.html +6 -9
  17. package/components/unread-articles-indicator/README.md +2 -62
  18. package/components/unread-articles-indicator/date-fns.js +5 -12
  19. package/components/unread-articles-indicator/index.js +1 -62
  20. package/components/unread-articles-indicator/storage.js +0 -44
  21. package/demos/app.js +30 -3
  22. package/demos/templates/demo.html +2 -4
  23. package/demos/templates/demo.jsx +33 -0
  24. package/demos/templates/digest-on-follow.html +1 -1
  25. package/jest.config.js +8 -0
  26. package/mixins/lozenge/_themes.scss +8 -4
  27. package/mixins/lozenge/main.scss +50 -4
  28. package/myft/main.scss +0 -1
  29. package/myft/ui/index.js +0 -2
  30. package/package.json +20 -6
  31. package/test/unread-articles-indicator/index.spec.js +13 -65
  32. package/test/unread-articles-indicator/storage.spec.js +0 -93
  33. package/components/csrf-token/input.html +0 -5
  34. package/components/follow-button/follow-button.html +0 -79
  35. package/components/header-tooltip/index.js +0 -37
  36. package/components/header-tooltip/main.scss +0 -12
  37. package/components/unread-articles-indicator/constants.js +0 -4
  38. package/components/unread-articles-indicator/count-unread-articles.js +0 -26
  39. package/components/unread-articles-indicator/main.scss +0 -59
  40. package/components/unread-articles-indicator/tracking.js +0 -15
  41. package/components/unread-articles-indicator/ui.js +0 -99
  42. package/components/unread-articles-indicator/update-count.js +0 -40
  43. package/test/unread-articles-indicator/count-unread-articles.spec.js +0 -72
  44. package/test/unread-articles-indicator/tracking.spec.js +0 -26
  45. package/test/unread-articles-indicator/ui.spec.js +0 -123
  46. package/test/unread-articles-indicator/update-count.spec.js +0 -156
@@ -1,59 +0,0 @@
1
- $circle-radius-desktop: 17px;
2
- $circle-radius-mobile-single-digit: 14px;
3
- $circle-radius-mobile: 8px;
4
-
5
- .myft__indicator-container {
6
- position: relative;
7
- }
8
-
9
- .myft__indicator {
10
- padding: 0;
11
- position: absolute;
12
- top: 18px;
13
- right: -3px;
14
-
15
- border: 1px solid oColorsByName("paper");
16
- background-color: oColorsByName("claret");
17
-
18
- font-family: MetricWeb, sans-serif;
19
- vertical-align: middle;
20
- overflow: hidden;
21
- text-align: center;
22
-
23
- font-size: 12px;
24
- font-weight: 500;
25
- line-height: $circle-radius-mobile;
26
- border-radius: $circle-radius-mobile;
27
- width: $circle-radius-mobile;
28
- height: $circle-radius-mobile;
29
-
30
- // On mobile hide text unless its only one digit
31
- color: transparent;
32
-
33
- &.myft__indicator--single-digit {
34
- top: 16px;
35
- right: -6px;
36
- color: oColorsByName("white");
37
- line-height: $circle-radius-mobile-single-digit;
38
- border-radius: $circle-radius-mobile-single-digit;
39
- width: $circle-radius-mobile-single-digit;
40
- height: $circle-radius-mobile-single-digit;
41
- }
42
- @include oGridRespondTo(M) {
43
- top: 16px;
44
- right: -8px;
45
- color: oColorsByName("white");
46
- line-height: $circle-radius-desktop;
47
- border-radius: $circle-radius-desktop;
48
- width: $circle-radius-desktop;
49
- height: $circle-radius-desktop;
50
- &.myft__indicator--single-digit {
51
- // shows single digit counts slightly larger for more legibility
52
- font-size: 13px;
53
- }
54
- }
55
- }
56
-
57
- .myft__indicator--hidden {
58
- display: none;
59
- }
@@ -1,15 +0,0 @@
1
- function dispatchEvent (detail) {
2
- const event = new CustomEvent('oTracking.event', {
3
- detail,
4
- bubbles: true
5
- });
6
-
7
- document.body.dispatchEvent(event);
8
- }
9
-
10
- export const onCountChange = (count, newArticlesSinceTime) => dispatchEvent({
11
- category: 'unread-articles-indicator',
12
- action: 'render',
13
- count,
14
- newArticlesSinceTime
15
- });
@@ -1,99 +0,0 @@
1
- class Indicator {
2
- constructor (container, {onClick} = {}) {
3
- this.count = undefined;
4
-
5
- this.container = container;
6
- this.container.classList.add('myft__indicator-container');
7
-
8
- this.el = document.createElement('span');
9
- this.el.classList.add('myft__indicator');
10
- this.el.classList.add('myft__indicator--hidden');
11
-
12
- container.appendChild(this.el);
13
-
14
- if (typeof onClick === 'function') {
15
- this.container.addEventListener('click', () => onClick());
16
- }
17
- }
18
-
19
- setCount (count) {
20
- if( count === this.count ) {
21
- return;
22
- }
23
- if( count < 1 ) {
24
- this.el.classList.add('myft__indicator--hidden');
25
- } else {
26
- this.el.classList.remove('myft__indicator--hidden');
27
- if( count < 10 ) {
28
- this.el.classList.add('myft__indicator--single-digit');
29
- } else {
30
- this.el.classList.remove('myft__indicator--single-digit');
31
- }
32
- this.el.innerText = count > 0 && count < 200 ? count : '';
33
- }
34
- this.count = count;
35
- }
36
- }
37
-
38
- class Favicon {
39
- constructor () {
40
- this.count = undefined;
41
- this.faviconLinks = Array.from(document.querySelectorAll('head link[rel=icon]'));
42
- this.showDot = false;
43
- }
44
-
45
- setCount (count) {
46
- if( count === this.count ) {
47
- return;
48
- }
49
- this.showDot = count > 0;
50
- const newImage = this.showDot ? 'brand-ft-logo-square-coloured-dot' : 'brand-ft-logo-square-coloured-no-dot';
51
- this.faviconLinks.forEach(link => {
52
- link.href = link.href.replace(/brand-ft-logo-square-coloured(-dot|-no-dot)?/, newImage);
53
- });
54
- this.count = count;
55
- }
56
- }
57
-
58
- class Title {
59
- constructor () {
60
- this.count = undefined;
61
- this.originalTitle = document.title;
62
- }
63
-
64
- setCount (count) {
65
- if( count === this.count ) {
66
- return;
67
- }
68
- document.title = count > 0 ? `(${count}) ${this.originalTitle}` : this.originalTitle;
69
- this.count = count;
70
- }
71
- }
72
-
73
- let indicators;
74
- let favicon;
75
- let title;
76
-
77
- export const createIndicators = (targets, options = {}) => {
78
- indicators = [...targets].map(target => new Indicator(target, options));
79
-
80
- if (options.flags && options.flags.myftUnreadFavicon) {
81
- favicon = new Favicon();
82
- title = new Title();
83
- }
84
- };
85
-
86
- export const setCount = count => {
87
- indicators.forEach(indicator => indicator.setCount(count));
88
- if (favicon) {
89
- favicon.setCount(count);
90
- }
91
- if (title) {
92
- title.setCount(count);
93
- }
94
- };
95
-
96
- export const getState = () => ({
97
- faviconHasDot: favicon ? favicon.showDot : false,
98
- numberInTitle: title ? title.count : 0
99
- });
@@ -1,40 +0,0 @@
1
- import {isAfter} from './date-fns';
2
- import * as storage from './storage';
3
- import countUnreadArticles from './count-unread-articles';
4
- import * as tracking from './tracking';
5
- import {UPDATE_INTERVAL, UPDATE_TIMEOUT} from './constants';
6
-
7
-
8
- function latest (a, b) {
9
- if (!a) return b;
10
- if (!b) return a;
11
- return isAfter(a, b) ? a : b;
12
- }
13
-
14
- export default async function updateCount (userId, now) {
15
- let doingUpdate;
16
- try {
17
- const lastUpdate = storage.getLastUpdate();
18
-
19
- const isFirstUpdate = !lastUpdate; // Always update if there has never been an update before.
20
- const noUpdateInProgress = !isFirstUpdate && (!lastUpdate.time || now.getTime() - lastUpdate.time.getTime() > UPDATE_INTERVAL);
21
- const updateInProgressHasTimedOut = !isFirstUpdate && (!lastUpdate.updateStarted || now.getTime() - lastUpdate.updateStarted.getTime() > UPDATE_TIMEOUT);
22
-
23
- if (isFirstUpdate || noUpdateInProgress && updateInProgressHasTimedOut) {
24
-
25
- storage.updateLastUpdate({updateStarted: now});
26
-
27
- const startTime = latest(storage.getFeedStartTime(), storage.getIndicatorDismissedTime());
28
-
29
- doingUpdate = true;
30
- const count = await countUnreadArticles(userId, startTime);
31
- if (!lastUpdate || count !== lastUpdate.count) {
32
- tracking.onCountChange(count, startTime);
33
- }
34
- storage.setLastUpdate({time: now, count, updateStarted: false});
35
- }
36
- } catch (error) {
37
- if (doingUpdate) storage.updateLastUpdate({updateStarted: false});
38
- throw error;
39
- }
40
- };
@@ -1,72 +0,0 @@
1
- /* global expect */
2
-
3
- import fetchMock from 'fetch-mock';
4
-
5
- const START_TIME = new Date('2018-06-05T06:48:26.635Z');
6
- const userId = '00000000-0000-0000-0000-000000000000';
7
-
8
- const timestamps = {
9
- BEFORE: '2018-06-04T06:48:26.635Z',
10
- AFTER: '2018-06-05T07:48:26.635Z'
11
- };
12
-
13
- const articleGenerator = () => {
14
- const counters = {};
15
- return (read, when) => {
16
- if (counters[read] === undefined) {
17
- counters[read] = {};
18
- }
19
- if (counters[read][when] === undefined) {
20
- counters[read][when] = 0;
21
- }
22
- const count = counters[read][when]++;
23
- return {
24
- id: `${read}_${when}_${count}`,
25
- contentTimeStamp: timestamps[when],
26
- userCompletion: read === 'READ' ? 1 : 0
27
- };
28
- };
29
- };
30
-
31
-
32
- const generateFeedArticle = articleGenerator();
33
- const mockPersonalisedFeedData = {
34
- results: [
35
- generateFeedArticle('READ', 'AFTER'),
36
- generateFeedArticle('UNREAD', 'BEFORE'),
37
- generateFeedArticle('READ', 'AFTER'),
38
- generateFeedArticle('UNREAD', 'BEFORE'),
39
- generateFeedArticle('UNREAD', 'BEFORE'),
40
- generateFeedArticle('UNREAD', 'AFTER'),
41
- generateFeedArticle('UNREAD', 'BEFORE'),
42
- generateFeedArticle('UNREAD', 'AFTER'),
43
- generateFeedArticle('UNREAD', 'AFTER'),
44
- generateFeedArticle('UNREAD', 'AFTER'),
45
- generateFeedArticle('UNREAD', 'AFTER'),
46
- generateFeedArticle('UNREAD', 'AFTER'),
47
- generateFeedArticle('UNREAD', 'AFTER'),
48
- generateFeedArticle('UNREAD', 'AFTER'),
49
- ]
50
- };
51
-
52
- describe('count-unread-articles', () => {
53
- let count;
54
-
55
- const countUnreadArticles = require('../../components/unread-articles-indicator/count-unread-articles');
56
-
57
- beforeEach(() => {
58
- count = null;
59
- fetchMock.get('begin:/__myft/api/onsite/feed/', mockPersonalisedFeedData);
60
-
61
- return countUnreadArticles(userId, START_TIME)
62
- .then(resolvedValue => {
63
- count = resolvedValue;
64
- });
65
- });
66
-
67
- afterEach(fetchMock.restore);
68
-
69
- it('should return only the unread articles after the start time', () => {
70
- expect(count).to.equal(8);
71
- });
72
- });
@@ -1,26 +0,0 @@
1
- /* global expect */
2
- import * as tracking from '../../components/unread-articles-indicator/tracking';
3
-
4
- describe('unread-articles-indicator tracking', () => {
5
- describe('onCountChange', () => {
6
- let dispatchedEvent;
7
- const COUNT = 3;
8
- const NEW_ARTICLES_SINCE_TIME = '2018-06-18T14:22:51.098Z';
9
-
10
- beforeEach(() => {
11
- dispatchedEvent = null;
12
- document.addEventListener('oTracking.event', event => dispatchedEvent = event);
13
- });
14
-
15
- it('should should dispatch the event', () => {
16
- tracking.onCountChange(COUNT, NEW_ARTICLES_SINCE_TIME);
17
-
18
- expect(dispatchedEvent.detail).to.deep.equal({
19
- category: 'unread-articles-indicator',
20
- action: 'render',
21
- count: COUNT,
22
- newArticlesSinceTime: NEW_ARTICLES_SINCE_TIME
23
- });
24
- });
25
- });
26
- });
@@ -1,123 +0,0 @@
1
- /* global expect */
2
- import sinon from 'sinon';
3
- import * as ui from '../../components/unread-articles-indicator/ui';
4
-
5
- describe('unread stories indicator - ui', () => {
6
-
7
- describe('createIndicators', () => {
8
- let containers;
9
- let options;
10
- const mockContainer = document.createElement('div');
11
-
12
- mockContainer.classList.add('o-header__top-link--myft');
13
-
14
- beforeEach(() => {
15
- containers = [0, 1].map(() => {
16
- const cont = mockContainer.cloneNode(false);
17
-
18
- document.body.appendChild(cont);
19
-
20
- return cont;
21
- });
22
- });
23
-
24
- afterEach(() => {
25
- containers.forEach(container => container.remove());
26
- });
27
-
28
- describe('createIndicators', () => {
29
- beforeEach(() => {
30
- ui.createIndicators(containers);
31
- });
32
-
33
- it('should set a class on each container', () => {
34
- containers.forEach(container => {
35
- expect(container.classList.contains('myft__indicator-container')).to.equal(true);
36
- });
37
- });
38
-
39
- it('should add a count element to each container', () => {
40
- containers.forEach(container => {
41
- const els = container.querySelectorAll('.myft__indicator');
42
-
43
- expect(els.length).to.equal(1);
44
- });
45
- });
46
- });
47
-
48
- describe('setCount', () => {
49
- beforeEach(() => {
50
- ui.createIndicators(containers);
51
- ui.setCount(3);
52
- });
53
-
54
- it('should show the count in the count element', () => {
55
- containers.forEach(container => {
56
- const el = container.querySelector('.myft__indicator');
57
-
58
- expect(el.innerText).to.equal('3');
59
- });
60
- });
61
-
62
- it('should hide the indicator when count is zero', () => {
63
- ui.setCount(0);
64
- containers.forEach(container => {
65
- const el = container.querySelector('.myft__indicator');
66
-
67
- expect(el.classList.contains('myft__indicator--hidden')).to.equal(true);
68
- });
69
- });
70
-
71
- it('should set a single digit class when count is less than 10', () => {
72
- ui.setCount(9);
73
- containers.forEach(container => {
74
- const el = container.querySelector('.myft__indicator');
75
-
76
- expect(el.classList.contains('myft__indicator--hidden')).to.equal(false);
77
- expect(el.classList.contains('myft__indicator--single-digit')).to.equal(true);
78
- });
79
- });
80
-
81
- it('should remove a single digit class when count is more than 10', () => {
82
- ui.setCount(10);
83
- containers.forEach(container => {
84
- const el = container.querySelector('.myft__indicator');
85
-
86
- expect(el.classList.contains('myft__indicator--hidden')).to.equal(false);
87
- expect(el.classList.contains('myft__indicator--single-digit')).to.equal(false);
88
- });
89
- });
90
-
91
- it('should not display number when count is more than 200', () => {
92
- ui.setCount(200);
93
- containers.forEach(container => {
94
- const el = container.querySelector('.myft__indicator');
95
-
96
- expect(el.classList.contains('myft__indicator--hidden')).to.equal(false);
97
- expect(el.classList.contains('myft__indicator--single-digit')).to.equal(false);
98
- expect(el.innerText).to.equal('');
99
- });
100
- });
101
- });
102
-
103
- describe('click handling', () => {
104
- beforeEach(() => {
105
- options = {
106
- onClick: sinon.stub()
107
- };
108
- ui.createIndicators(containers, options);
109
- ui.setCount(3);
110
- });
111
-
112
- describe('given an indicator is clicked', () => {
113
- beforeEach(() => {
114
- containers[0].click();
115
- });
116
-
117
- it('should set the indicator dismissed time', () => {
118
- expect(options.onClick.called).to.equal(true);
119
- });
120
- });
121
- });
122
- });
123
- });
@@ -1,156 +0,0 @@
1
- /* global expect */
2
- import sinon from 'sinon';
3
-
4
- const MOCK_COUNT = 1234;
5
- const MOCK_PREVIOUS_COUNT = 1230;
6
- const MOCK_NOW = new Date('2019-09-23T11:29:00Z');
7
- const MOCK_EARLIEST_CUTOFF_TIME = new Date('2019-09-22T00:00:00Z');
8
- const MOCK_OVERDUE_REFRESH_TIME = new Date('2019-09-23T11:23:59Z');
9
- const MOCK_UNDUE_REFRESH_TIME = new Date('2019-09-23T11:24:01Z');
10
- const USER_ID = '00000000-0000-0000-0000-000000000000';
11
-
12
- const mocks = {
13
- countUnreadArticles: sinon.stub().resolves(MOCK_COUNT),
14
- setCount: sinon.stub(),
15
- onCountChange: sinon.stub()
16
- };
17
-
18
- const mockStorage = {
19
- _lastUpdate: undefined,
20
- _setLastUpdate: sinon.spy( function (x) { this.default.lastUpdate = x; } ),
21
- getLastUpdate () { return this.default.lastUpdate; },
22
- setLastUpdate (x) { this._setLastUpdate(x); },
23
- updateLastUpdate (x) { this._setLastUpdate(x); },
24
- getFeedStartTime () { return MOCK_EARLIEST_CUTOFF_TIME; },
25
- getIndicatorDismissedTime () { return MOCK_EARLIEST_CUTOFF_TIME; }
26
- };
27
-
28
- function resetMocks () {
29
- mocks.countUnreadArticles.resetHistory();
30
- mocks.setCount.resetHistory();
31
- mockStorage._setLastUpdate.resetHistory();
32
- }
33
-
34
- describe('update function', () => {
35
-
36
- const injector = require('inject-loader!../../components/unread-articles-indicator/update-count');
37
- const updateCount = injector({
38
- './storage': mockStorage,
39
- './count-unread-articles': mocks.countUnreadArticles,
40
- './tracking': {onCountChange: mocks.onCountChange}
41
- });
42
-
43
- context('when there is no previous update', () => {
44
- before( function () {
45
- resetMocks();
46
- mockStorage.lastUpdate = undefined;
47
- return updateCount(USER_ID, MOCK_NOW);
48
- } );
49
-
50
- it('checks for new articles', () => {
51
- expect(mocks.countUnreadArticles).calledWith(USER_ID, MOCK_EARLIEST_CUTOFF_TIME);
52
- });
53
-
54
- it('marked an update as in progress', () => {
55
- expect(mockStorage._setLastUpdate.firstCall.args[0].updateStarted.toISOString()).equal(MOCK_NOW.toISOString());
56
- });
57
-
58
- it('updates the local storage', () => {
59
- expect(mockStorage.lastUpdate.count).equal(MOCK_COUNT);
60
- });
61
-
62
- });
63
-
64
-
65
- context('when an update is due', () => {
66
- before( function () {
67
- resetMocks();
68
- mockStorage.lastUpdate = {time: MOCK_OVERDUE_REFRESH_TIME};
69
- return updateCount(USER_ID, MOCK_NOW);
70
- } );
71
-
72
- it('checks for new articles', () => {
73
- expect(mocks.countUnreadArticles).calledWith(USER_ID, MOCK_EARLIEST_CUTOFF_TIME);
74
- });
75
-
76
- it('updates the local storage', () => {
77
- expect(mockStorage.lastUpdate.count).equal(MOCK_COUNT);
78
- });
79
-
80
- it('marked an update as in progress', () => {
81
- expect(mockStorage._setLastUpdate.firstCall.args[0].updateStarted.toISOString()).equal(MOCK_NOW.toISOString());
82
- });
83
-
84
- it('doesn\'t block further updates', () => {
85
- expect(mockStorage.lastUpdate.updateStarted).equal(false);
86
- });
87
- });
88
-
89
- context('when an update is not due', () => {
90
- const lastUpdate = {time: MOCK_UNDUE_REFRESH_TIME, count: MOCK_PREVIOUS_COUNT};
91
- before( function () {
92
- resetMocks();
93
- mockStorage.lastUpdate = lastUpdate;
94
- return updateCount(USER_ID, MOCK_NOW);
95
- } );
96
-
97
- it('does not check for new articles', () => {
98
- expect(mocks.countUnreadArticles.notCalled).equal(true,'Unexpected call to countUnreadArticles()');
99
- });
100
-
101
- it('did not mark an update as in progress', () => {
102
- expect(mockStorage._setLastUpdate.notCalled).equal(true,'Unexpected storage update');
103
- });
104
-
105
- it('doesn\'t update the local storage', () => {
106
- expect(mockStorage.lastUpdate).equal(lastUpdate);
107
- });
108
- });
109
-
110
- context('when an update fails', () => {
111
- let err;
112
- before( async () => {
113
- resetMocks();
114
- mocks.countUnreadArticles.rejects(new Error('boom'));
115
- mockStorage.lastUpdate = undefined;
116
- try {
117
- await updateCount(USER_ID, MOCK_NOW);
118
- } catch(e) {
119
- err = e;
120
- }
121
- } );
122
-
123
- after( () => {
124
- mocks.countUnreadArticles.resolves(MOCK_COUNT);
125
- });
126
-
127
- it('marks the update as in progress', () => {
128
- expect(mockStorage._setLastUpdate.firstCall.args[0].updateStarted.toISOString()).equal(MOCK_NOW.toISOString());
129
- });
130
-
131
- it('doesn\'t block further updates', () => {
132
- expect(mockStorage.lastUpdate.updateStarted).equal(false);
133
- });
134
-
135
- it('should throw an error', () => {
136
- expect(err.message).to.equal('boom');
137
- });
138
- });
139
-
140
- context('when an update is due but already happening', () => {
141
- before( function () {
142
- resetMocks();
143
- mockStorage.lastUpdate = {time: MOCK_OVERDUE_REFRESH_TIME, count: MOCK_PREVIOUS_COUNT, updateStarted: MOCK_OVERDUE_REFRESH_TIME};
144
- return updateCount(USER_ID, MOCK_NOW);
145
- } );
146
-
147
- it('does not check for new articles', () => {
148
- expect(mocks.countUnreadArticles.notCalled).equal(true,'Unexpected call to countUnreadArticles()');
149
- });
150
-
151
- it('does not touch local storage', () => {
152
- expect(mockStorage._setLastUpdate.notCalled).equal(true,'Unexpected storage update');
153
- });
154
-
155
- });
156
- });