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

Sign up to get free protection for your applications and to get access to all the features.
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
- });