@financial-times/n-myft-ui 25.0.1 → 26.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.
- package/.circleci/config.yml +3 -0
- package/README.md +50 -0
- package/build-state/npm-shrinkwrap.json +14847 -15637
- package/components/collections/collections.jsx +68 -0
- package/components/collections/collections.test.js +83 -0
- package/components/concept-list/concept-list.jsx +69 -0
- package/components/concept-list/concept-list.test.js +116 -0
- package/components/csrf-token/input.jsx +1 -7
- package/components/csrf-token/{__tests__/input.test.js → input.test.js} +2 -2
- package/components/follow-button/follow-button.jsx +6 -4
- package/components/follow-button/{__tests__/follow-button.test.js → follow-button.test.js} +4 -4
- package/components/index.js +17 -0
- package/components/instant-alert/instant-alert.jsx +73 -0
- package/components/instant-alert/instant-alert.test.js +86 -0
- package/components/pin-button/pin-button.jsx +40 -0
- package/components/pin-button/pin-button.test.js +57 -0
- package/components/save-for-later/save-for-later.jsx +101 -0
- package/components/save-for-later/save-for-later.test.js +59 -0
- package/demos/app.js +2 -4
- package/demos/templates/demo.html +8 -9
- package/demos/templates/demo.jsx +93 -1
- package/dist/bundles/bundle.js +3232 -0
- package/jsx-migration.md +16 -0
- package/package.json +7 -3
- package/webpack.config.js +34 -0
- package/components/collections/collections.html +0 -77
- package/components/concept-list/concept-list.html +0 -28
- package/components/instant-alert/instant-alert.html +0 -47
- package/components/pin-button/pin-button.html +0 -20
- package/components/save-for-later/save-for-later.html +0 -67
@@ -0,0 +1,68 @@
|
|
1
|
+
import React from 'react';
|
2
|
+
import CsrfToken from '../csrf-token/input';
|
3
|
+
import FollowButton from '../follow-button/follow-button';
|
4
|
+
|
5
|
+
export default function Collections ({ title, liteStyle, flags, collectionName, trackable, concepts = [], csrfToken, cacheablePersonalisedUrl }) {
|
6
|
+
const getLiteStyleModifier = () => liteStyle ? 'lite' : 'regular';
|
7
|
+
let formProps = {
|
8
|
+
method: 'POST',
|
9
|
+
action: '#',
|
10
|
+
'data-myft-ui': 'follow',
|
11
|
+
'data-concept-id': concepts.map(concept => concept.id).join(',')
|
12
|
+
};
|
13
|
+
|
14
|
+
if (collectionName) {
|
15
|
+
formProps['data-myft-tracking'] = collectionName;
|
16
|
+
}
|
17
|
+
|
18
|
+
return (
|
19
|
+
<section
|
20
|
+
className={`collection collection--${getLiteStyleModifier()}`}
|
21
|
+
data-trackable={trackable ? trackable : 'collection'}>
|
22
|
+
<header className={`collection__header collection__header--${getLiteStyleModifier()}`}>
|
23
|
+
<h2 className={`collection__title collection__title--${getLiteStyleModifier()}`}>
|
24
|
+
{title}
|
25
|
+
</h2>
|
26
|
+
</header>
|
27
|
+
<ul className="collection__concepts">
|
28
|
+
{concepts && concepts.map((concept, index) =>
|
29
|
+
<li className="collection__concept" key={index}>
|
30
|
+
<FollowButton cacheablePersonalisedUrl={cacheablePersonalisedUrl} csrfToken={csrfToken} variant={liteStyle ? 'primary' : 'inverse'} buttonText={concept.name} flags={flags} collectionName={collectionName} />
|
31
|
+
</li>)
|
32
|
+
}
|
33
|
+
</ul>
|
34
|
+
|
35
|
+
<div className="collection__meta">
|
36
|
+
<form
|
37
|
+
{...formProps}
|
38
|
+
className="n-myft-ui n-myft-ui--follow n-ui-hide-core collection-follow-all">
|
39
|
+
<input
|
40
|
+
type="hidden"
|
41
|
+
name="directType"
|
42
|
+
value={concepts.map(concept => concept.directType).join(',')}
|
43
|
+
/>
|
44
|
+
<CsrfToken csrfToken={csrfToken} cacheablePersonalisedUrl={cacheablePersonalisedUrl} />
|
45
|
+
<input
|
46
|
+
type="hidden"
|
47
|
+
name="name"
|
48
|
+
value={concepts.map(concept => concept.name).join(',')}
|
49
|
+
/>
|
50
|
+
<button
|
51
|
+
type="submit"
|
52
|
+
aria-pressed="false"
|
53
|
+
className={`collection-follow-all__button collection-follow-all__button--${getLiteStyleModifier()}`}
|
54
|
+
data-trackable="follow all"
|
55
|
+
data-concept-id={concepts.map(concept => concept.id).join(',')}
|
56
|
+
aria-label={`Add all topics in the ${title} collection to my F T`}
|
57
|
+
data-alternate-label={`Remove all topics in the ${title} collection from my F T`}
|
58
|
+
data-alternate-text="Added"
|
59
|
+
title={`Add all topics in the ${title} collection to my F T`}>
|
60
|
+
Add all to myFT
|
61
|
+
</button>
|
62
|
+
</form>
|
63
|
+
</div>
|
64
|
+
</section>
|
65
|
+
|
66
|
+
)
|
67
|
+
|
68
|
+
}
|
@@ -0,0 +1,83 @@
|
|
1
|
+
import React from 'react';
|
2
|
+
import Collections from './collections';
|
3
|
+
import { render, screen } from '@testing-library/react';
|
4
|
+
import '@testing-library/jest-dom';
|
5
|
+
|
6
|
+
const fixtures = {
|
7
|
+
'title': 'European Union',
|
8
|
+
'concepts': [
|
9
|
+
{
|
10
|
+
'id': '00000000-0000-0000-0000-000000000000',
|
11
|
+
'prefLabel': 'EU immigration',
|
12
|
+
'directType': 'http://www.ft.com/ontology/Topic',
|
13
|
+
'url': 'https://www.ft.com/stream/00000000-0000-0000-0000-000000000000',
|
14
|
+
'name': 'EU immigration'
|
15
|
+
},
|
16
|
+
{
|
17
|
+
'id': '00000000-0000-0000-0000-000000000001',
|
18
|
+
'prefLabel': 'Europe Quantitative Easing',
|
19
|
+
'directType': 'http://www.ft.com/ontology/Topic',
|
20
|
+
'url': 'https://www.ft.com/stream/00000000-0000-0000-0000-000000000000',
|
21
|
+
'name': 'Europe Quantitative Easing'
|
22
|
+
},
|
23
|
+
{
|
24
|
+
'id': '00000000-0000-0000-0000-000000000002',
|
25
|
+
'prefLabel': 'EU financial regulation',
|
26
|
+
'directType': 'http://www.ft.com/ontology/Topic',
|
27
|
+
'url': 'https://www.ft.com/stream/00000000-0000-0000-0000-000000000000',
|
28
|
+
'name': 'EU financial regulation'
|
29
|
+
},
|
30
|
+
{
|
31
|
+
'id': '00000000-0000-0000-0000-000000000003',
|
32
|
+
'prefLabel': 'EU nothing',
|
33
|
+
'directType': 'http://www.ft.com/ontology/Topic',
|
34
|
+
'url': 'https://www.ft.com/stream/00000000-0000-0000-0000-000000000000',
|
35
|
+
'name': 'EU nothing'
|
36
|
+
},
|
37
|
+
{
|
38
|
+
'id': '00000000-0000-0000-0000-000000000004',
|
39
|
+
'prefLabel': 'EU trade',
|
40
|
+
'directType': 'http://www.ft.com/ontology/Topic',
|
41
|
+
'url': 'https://www.ft.com/stream/00000000-0000-0000-0000-000000000000',
|
42
|
+
'name': 'EU trade'
|
43
|
+
}
|
44
|
+
]
|
45
|
+
};
|
46
|
+
const joinedDirectTypes = 'http://www.ft.com/ontology/Topic,http://www.ft.com/ontology/Topic,http://www.ft.com/ontology/Topic,http://www.ft.com/ontology/Topic,http://www.ft.com/ontology/Topic';
|
47
|
+
|
48
|
+
const flags = {
|
49
|
+
myFtApi: true,
|
50
|
+
myFtApiWrite: true
|
51
|
+
};
|
52
|
+
|
53
|
+
describe('Concept List', () => {
|
54
|
+
|
55
|
+
test('It renders the title of the collection', async () => {
|
56
|
+
render(<Collections {...fixtures} flags={flags} />);
|
57
|
+
expect(await screen.findByText('European Union')).toBeTruthy();
|
58
|
+
});
|
59
|
+
|
60
|
+
test('It renders label for the concept button', async () => {
|
61
|
+
render(<Collections {...fixtures} flags={flags} />);
|
62
|
+
expect(await screen.findByText('EU immigration')).toBeTruthy();
|
63
|
+
expect(await screen.findByText('Europe Quantitative Easing')).toBeTruthy();
|
64
|
+
expect(await screen.findByText('EU financial regulation')).toBeTruthy();
|
65
|
+
expect(await screen.findByText('EU nothing')).toBeTruthy();
|
66
|
+
expect(await screen.findByText('EU trade')).toBeTruthy();
|
67
|
+
});
|
68
|
+
|
69
|
+
test('It renders form "Add all to my FT" from', () => {
|
70
|
+
const { container} = render(<Collections {...fixtures} flags={flags} />);
|
71
|
+
const formElement = container.querySelector('form[action="#"]');
|
72
|
+
expect(formElement).toBeTruthy();
|
73
|
+
expect(formElement.method).toEqual('post');
|
74
|
+
});
|
75
|
+
|
76
|
+
test('It renders directType input with value of types joined', () => {
|
77
|
+
const { container} = render(<Collections {...fixtures} flags={flags} />);
|
78
|
+
const directTypeElement = container.querySelector('input[name="directType"]');
|
79
|
+
expect(directTypeElement).toBeTruthy();
|
80
|
+
expect(directTypeElement.value).toEqual(joinedDirectTypes);
|
81
|
+
});
|
82
|
+
|
83
|
+
});
|
@@ -0,0 +1,69 @@
|
|
1
|
+
import React, { Fragment } from 'react';
|
2
|
+
import FollowButton from '../follow-button/follow-button';
|
3
|
+
|
4
|
+
export default function ConceptList ({ flags, concepts, contentType, conceptListTitle, trackable, csrfToken, cacheablePersonalisedUrl }) {
|
5
|
+
|
6
|
+
const {
|
7
|
+
myFtApi,
|
8
|
+
myFtApiWrite
|
9
|
+
} = flags;
|
10
|
+
|
11
|
+
const generateTrackableProps = (primary, secondary) => {
|
12
|
+
return {
|
13
|
+
'data-trackable': primary ? primary : secondary
|
14
|
+
}
|
15
|
+
}
|
16
|
+
|
17
|
+
const shouldDisplay = () => {
|
18
|
+
if(myFtApi && myFtApiWrite && Array.isArray(concepts) && concepts.length) {
|
19
|
+
return true
|
20
|
+
}
|
21
|
+
|
22
|
+
return false;
|
23
|
+
}
|
24
|
+
|
25
|
+
|
26
|
+
return (
|
27
|
+
|
28
|
+
<Fragment>
|
29
|
+
{shouldDisplay() &&
|
30
|
+
<div
|
31
|
+
className='concept-list'
|
32
|
+
{...generateTrackableProps(trackable, 'concept-list')}>
|
33
|
+
{
|
34
|
+
(contentType || conceptListTitle) &&
|
35
|
+
<h2 className='concept-list__title'>
|
36
|
+
{conceptListTitle ? conceptListTitle : `Follow the topics in this ${contentType}`}
|
37
|
+
</h2>
|
38
|
+
}
|
39
|
+
<ul className='concept-list__list'>
|
40
|
+
{concepts.map((concept, index) => {
|
41
|
+
const {
|
42
|
+
relativeUrl,
|
43
|
+
url,
|
44
|
+
conceptTrackable,
|
45
|
+
prefLabel,
|
46
|
+
id
|
47
|
+
} = concept;
|
48
|
+
return (
|
49
|
+
<li key={index} className='concept-list__list-item'>
|
50
|
+
{/* The relativeUrl and url point to the same resource. The url is the base path + the relative url.
|
51
|
+
Example: browser_path = https://ft.com, relativeUrl = /capital-markets then url = https://www.ft.com/capital-markets.
|
52
|
+
|
53
|
+
Note: we don't need to compute these urls in the business logic of these components as they're passed in as props.
|
54
|
+
|
55
|
+
This note is just an explanation for why relativeUrl has preference over url.*/}
|
56
|
+
<a
|
57
|
+
href={relativeUrl || url}
|
58
|
+
{...generateTrackableProps(conceptTrackable, 'concept')}
|
59
|
+
className='concept-list__concept'>
|
60
|
+
{prefLabel}
|
61
|
+
</a>
|
62
|
+
<FollowButton csrfToken={csrfToken} cacheablePersonalisedUrl={cacheablePersonalisedUrl} conceptId={id} name={prefLabel} flags={flags} />
|
63
|
+
</li>
|
64
|
+
)
|
65
|
+
})}
|
66
|
+
</ul>
|
67
|
+
</div>}
|
68
|
+
</Fragment>)
|
69
|
+
}
|
@@ -0,0 +1,116 @@
|
|
1
|
+
import React from 'react';
|
2
|
+
import ConceptList from './concept-list';
|
3
|
+
import { render, screen } from '@testing-library/react';
|
4
|
+
import '@testing-library/jest-dom';
|
5
|
+
|
6
|
+
const fixtures = [
|
7
|
+
{
|
8
|
+
'conceptListTitle': 'Follow european union things',
|
9
|
+
'concepts': [
|
10
|
+
{
|
11
|
+
'id': '00000000-0000-0000-0000-000000000161',
|
12
|
+
'prefLabel': 'EU immigration',
|
13
|
+
'directType': 'http://www.ft.com/ontology/Topic',
|
14
|
+
'url': 'https://www.ft.com/stream/00000000-0000-0000-0000-000000000161',
|
15
|
+
'name': 'EU immigration'
|
16
|
+
},
|
17
|
+
{
|
18
|
+
'id': '00000000-0000-0000-0000-000000000162',
|
19
|
+
'prefLabel': 'Europe Quantitative Easing',
|
20
|
+
'directType': 'http://www.ft.com/ontology/Topic',
|
21
|
+
'url': 'https://www.ft.com/stream/00000000-0000-0000-0000-000000000162',
|
22
|
+
'name': 'Europe Quantitative Easing'
|
23
|
+
},
|
24
|
+
{
|
25
|
+
'id': '00000000-0000-0000-0000-000000000163',
|
26
|
+
'prefLabel': 'EU financial regulation',
|
27
|
+
'directType': 'http://www.ft.com/ontology/Topic',
|
28
|
+
'url': 'https://www.ft.com/stream/00000000-0000-0000-0000-000000000163',
|
29
|
+
'name': 'EU financial regulation'
|
30
|
+
},
|
31
|
+
{
|
32
|
+
'id': '00000000-0000-0000-0000-000000000164',
|
33
|
+
'prefLabel': 'EU nothing',
|
34
|
+
'directType': 'http://www.ft.com/ontology/Topic',
|
35
|
+
'url': 'https://www.ft.com/stream/00000000-0000-0000-0000-000000000164',
|
36
|
+
'name': 'EU nothing'
|
37
|
+
},
|
38
|
+
{
|
39
|
+
'id': '00000000-0000-0000-0000-000000000165',
|
40
|
+
'prefLabel': 'EU trade',
|
41
|
+
'directType': 'http://www.ft.com/ontology/Topic',
|
42
|
+
'url': 'https://www.ft.com/stream/00000000-0000-0000-0000-000000000165',
|
43
|
+
'name': 'EU trade'
|
44
|
+
}
|
45
|
+
]
|
46
|
+
},
|
47
|
+
{
|
48
|
+
'contentType': 'search',
|
49
|
+
'concepts': [
|
50
|
+
{
|
51
|
+
'id': '00000000-0000-0000-0000-000000000166',
|
52
|
+
'prefLabel': 'Noodle',
|
53
|
+
'directType': 'http://www.ft.com/ontology/Topic',
|
54
|
+
'url': 'https://www.ft.com/stream/00000000-0000-0000-0000-000000000166',
|
55
|
+
'name': 'Noodle'
|
56
|
+
},
|
57
|
+
{
|
58
|
+
'id': '00000000-0000-0000-0000-000000000167',
|
59
|
+
'prefLabel': 'Green apples',
|
60
|
+
'directType': 'http://www.ft.com/ontology/Topic',
|
61
|
+
'url': 'https://www.ft.com/stream/00000000-0000-0000-0000-000000000167',
|
62
|
+
'name': 'Green apples'
|
63
|
+
},
|
64
|
+
{
|
65
|
+
'id': '00000000-0000-0000-0000-000000000168',
|
66
|
+
'prefLabel': 'Fox blood',
|
67
|
+
'directType': 'http://www.ft.com/ontology/Topic',
|
68
|
+
'url': 'https://www.ft.com/stream/00000000-0000-0000-0000-000000000168',
|
69
|
+
'name': 'Fox blood'
|
70
|
+
},
|
71
|
+
{
|
72
|
+
'id': '00000000-0000-0000-0000-000000000169',
|
73
|
+
'prefLabel': 'Dog party',
|
74
|
+
'directType': 'http://www.ft.com/ontology/Topic',
|
75
|
+
'url': 'https://www.ft.com/stream/00000000-0000-0000-0000-000000000169',
|
76
|
+
'name': 'Dog party'
|
77
|
+
},
|
78
|
+
{
|
79
|
+
'id': '00000000-0000-0000-0000-000000000170',
|
80
|
+
'prefLabel': 'Fifth thing',
|
81
|
+
'directType': 'http://www.ft.com/ontology/Topic',
|
82
|
+
'url': 'https://www.ft.com/stream/00000000-0000-0000-0000-000000000170',
|
83
|
+
'name': 'Fifth thing'
|
84
|
+
}
|
85
|
+
]
|
86
|
+
},
|
87
|
+
];
|
88
|
+
|
89
|
+
|
90
|
+
const flags = {
|
91
|
+
myFtApi: true,
|
92
|
+
myFtApiWrite: true
|
93
|
+
};
|
94
|
+
|
95
|
+
describe('Concept List', () => {
|
96
|
+
|
97
|
+
test('It renders conceptListTitle value as title when conceptListTitle is provided', async () => {
|
98
|
+
render(<ConceptList {...fixtures[0]} flags={flags} />);
|
99
|
+
expect(await screen.findByText('Follow european union things')).toBeTruthy();
|
100
|
+
});
|
101
|
+
|
102
|
+
test('It renders "Follow the topics in this {conceptType}" value as title when conceptType is provided', async () => {
|
103
|
+
render(<ConceptList {...fixtures[1]} flags={flags} />);
|
104
|
+
expect(await screen.findByText('Follow the topics in this search')).toBeTruthy();
|
105
|
+
});
|
106
|
+
|
107
|
+
test('It renders label for the concept button', async () => {
|
108
|
+
render(<ConceptList {...fixtures[0]} flags={flags} />);
|
109
|
+
expect(await screen.findByText('EU immigration')).toBeTruthy();
|
110
|
+
expect(await screen.findByText('Europe Quantitative Easing')).toBeTruthy();
|
111
|
+
expect(await screen.findByText('EU financial regulation')).toBeTruthy();
|
112
|
+
expect(await screen.findByText('EU nothing')).toBeTruthy();
|
113
|
+
expect(await screen.findByText('EU trade')).toBeTruthy();
|
114
|
+
});
|
115
|
+
|
116
|
+
});
|
@@ -5,18 +5,12 @@ export default function CsrfToken ({ cacheablePersonalisedUrl, csrfToken }) {
|
|
5
5
|
let inputProps = {};
|
6
6
|
|
7
7
|
if (cacheablePersonalisedUrl) {
|
8
|
-
inputProps = {
|
9
|
-
...inputProps,
|
10
|
-
'data-myft-csrf-token': csrfToken
|
11
|
-
};
|
12
|
-
}
|
13
|
-
|
14
|
-
if(csrfToken) {
|
15
8
|
inputProps.value = csrfToken;
|
16
9
|
}
|
17
10
|
|
18
11
|
return (
|
19
12
|
<input
|
13
|
+
data-myft-csrf-token
|
20
14
|
{...inputProps}
|
21
15
|
type="hidden"
|
22
16
|
name="token"
|
@@ -1,5 +1,5 @@
|
|
1
1
|
import React from 'react';
|
2
|
-
import CsrfToken from '
|
2
|
+
import CsrfToken from './input';
|
3
3
|
import { render } from '@testing-library/react';
|
4
4
|
import '@testing-library/jest-dom';
|
5
5
|
|
@@ -16,7 +16,7 @@ describe('Csrf Token Input', () => {
|
|
16
16
|
|
17
17
|
test('It renders csrf token attribute', async () => {
|
18
18
|
let { container } = render(<CsrfToken cacheablePersonalisedUrl={true} csrfToken={'test-token'} />);
|
19
|
-
expect(container.querySelector('[data-myft-csrf-token
|
19
|
+
expect(container.querySelector('[data-myft-csrf-token]')).toBeTruthy();
|
20
20
|
});
|
21
21
|
|
22
22
|
|
@@ -1,4 +1,4 @@
|
|
1
|
-
import React from 'react';
|
1
|
+
import React, {Fragment} from 'react';
|
2
2
|
import CsrfToken from '../csrf-token/input';
|
3
3
|
|
4
4
|
function generateFormProps (props) {
|
@@ -140,6 +140,8 @@ export default function FollowButton (props) {
|
|
140
140
|
extraClasses,
|
141
141
|
conceptId,
|
142
142
|
variant,
|
143
|
+
csrfToken,
|
144
|
+
cacheablePersonalisedUrl
|
143
145
|
} = props;
|
144
146
|
|
145
147
|
const formProps = generateFormProps(props);
|
@@ -148,14 +150,14 @@ export default function FollowButton (props) {
|
|
148
150
|
const getVariantClass = (variant) => variant ? `n-myft-follow-button--${variant}` : '';
|
149
151
|
|
150
152
|
return (
|
151
|
-
|
153
|
+
<Fragment>
|
152
154
|
{flags.myFtApiWrite && <form
|
153
155
|
className={`n-myft-ui n-myft-ui--follow ${extraClasses || ''}`}
|
154
156
|
method="GET"
|
155
157
|
data-myft-ui="follow"
|
156
158
|
data-concept-id={conceptId}
|
157
159
|
{...formProps}>
|
158
|
-
<CsrfToken cacheablePersonalisedUrl={
|
160
|
+
<CsrfToken cacheablePersonalisedUrl={cacheablePersonalisedUrl} csrfToken={csrfToken} />
|
159
161
|
<div
|
160
162
|
className="n-myft-ui__announcement o-normalise-visually-hidden"
|
161
163
|
aria-live="assertive"
|
@@ -168,7 +170,7 @@ export default function FollowButton (props) {
|
|
168
170
|
{getButtonText(props)}
|
169
171
|
</button>
|
170
172
|
</form>}
|
171
|
-
|
173
|
+
</Fragment>
|
172
174
|
);
|
173
175
|
|
174
176
|
}
|
@@ -1,5 +1,5 @@
|
|
1
1
|
import React from 'react';
|
2
|
-
import FollowButton from '
|
2
|
+
import FollowButton from './follow-button';
|
3
3
|
import { render, screen } from '@testing-library/react';
|
4
4
|
import '@testing-library/jest-dom';
|
5
5
|
|
@@ -16,7 +16,7 @@ describe('Follow button', () => {
|
|
16
16
|
|
17
17
|
test('It renders default button', async () => {
|
18
18
|
render(<FollowButton {...props} />);
|
19
|
-
expect(screen.findByText('Add to myFT')).toBeTruthy();
|
19
|
+
expect(await screen.findByText('Add to myFT')).toBeTruthy();
|
20
20
|
});
|
21
21
|
|
22
22
|
test('It renders a variant', async () => {
|
@@ -29,12 +29,12 @@ describe('Follow button', () => {
|
|
29
29
|
expect(container.querySelector(`form[action='/myft/add/${props.conceptId}']`)).toBeTruthy();
|
30
30
|
});
|
31
31
|
|
32
|
-
test('Button state changes when attributes change', () => {
|
32
|
+
test('Button state changes when attributes change', async () => {
|
33
33
|
render(<FollowButton {...props}
|
34
34
|
variant={'standard'}
|
35
35
|
setFollowButtonStateToSelected={true}
|
36
36
|
cacheablePersonalisedUrl={true} />);
|
37
|
-
expect(screen.findByText('Added')).toBeTruthy();
|
37
|
+
expect(await screen.findByText('Added')).toBeTruthy();
|
38
38
|
});
|
39
39
|
|
40
40
|
});
|
@@ -0,0 +1,17 @@
|
|
1
|
+
import CsrfToken from './csrf-token/input';
|
2
|
+
import FollowButton from './follow-button/follow-button';
|
3
|
+
import ConceptList from './concept-list/concept-list';
|
4
|
+
import Collections from './collections/collections';
|
5
|
+
import SaveForLater from './save-for-later/save-for-later';
|
6
|
+
import PinButton from './pin-button/pin-button';
|
7
|
+
import InstantAlert from './instant-alert/instant-alert';
|
8
|
+
|
9
|
+
export {
|
10
|
+
CsrfToken,
|
11
|
+
FollowButton,
|
12
|
+
ConceptList,
|
13
|
+
Collections,
|
14
|
+
SaveForLater,
|
15
|
+
PinButton,
|
16
|
+
InstantAlert
|
17
|
+
};
|
@@ -0,0 +1,73 @@
|
|
1
|
+
import React, { Fragment } from 'react';
|
2
|
+
import CsrfToken from '../csrf-token/input';
|
3
|
+
|
4
|
+
/**
|
5
|
+
*
|
6
|
+
* @param {Object} props
|
7
|
+
* @param {string} props.name
|
8
|
+
* @param {Object} props.flags
|
9
|
+
* @param {booelan} props.hideButtonText
|
10
|
+
* @param {string} props.conceptId
|
11
|
+
* @param {string} props.name
|
12
|
+
* @param {string} props.extraClasses
|
13
|
+
* @param {boolean} props.directType
|
14
|
+
* @param {string} props.cacheablePersonalisedUrl
|
15
|
+
* @param {string} props.hasInstantAlert
|
16
|
+
* @param {string} props.buttonText
|
17
|
+
* @param {string} props.alternateText
|
18
|
+
* @param {string} props.variant
|
19
|
+
* @param {string} props.size
|
20
|
+
*/
|
21
|
+
export default function InstantAlert (props) {
|
22
|
+
|
23
|
+
const {
|
24
|
+
hasInstantAlert,
|
25
|
+
cacheablePersonalisedUrl,
|
26
|
+
name,
|
27
|
+
alternateText,
|
28
|
+
buttonText,
|
29
|
+
conceptId,
|
30
|
+
variant,
|
31
|
+
size,
|
32
|
+
flags,
|
33
|
+
hideButtonText,
|
34
|
+
directType,
|
35
|
+
extraClasses
|
36
|
+
} = props;
|
37
|
+
|
38
|
+
const generateButtonProps = () => {
|
39
|
+
|
40
|
+
let buttonProps = {
|
41
|
+
'aria-pressed': `${Boolean(hasInstantAlert) && Boolean(cacheablePersonalisedUrl)}`,
|
42
|
+
'aria-label': `Get instant alerts for ${name}`,
|
43
|
+
'data-alternate-label': `Stop instant alerts for ${name}`,
|
44
|
+
'data-alternate-text': alternateText? alternateText: (buttonText ? buttonText : 'Instant alerts'),
|
45
|
+
'data-concept-id': conceptId, // duplicated here for tracking
|
46
|
+
'data-trackable': 'instant',
|
47
|
+
title: `Get instant alerts for ${name}`,
|
48
|
+
value: hasInstantAlert ? false : true,
|
49
|
+
type: 'submit',
|
50
|
+
name: '_rel.instant',
|
51
|
+
className: `n-myft-ui__button n-myft-ui__button--instant n-myft-ui__button--instant-light${variant ? ` n-myft-ui__button--${variant}` : ''}${size ? ` n-myft-ui__button--${size}` : ''}`
|
52
|
+
};
|
53
|
+
return buttonProps;
|
54
|
+
}
|
55
|
+
|
56
|
+
return (
|
57
|
+
<Fragment>
|
58
|
+
{flags.myFtApiWrite &&
|
59
|
+
<form className={`n-myft-ui n-myft-ui--instant${hideButtonText ? ' n-myft-ui--instant--hide-text' : ''}${extraClasses ? ` ${extraClasses}` : ''}`}
|
60
|
+
method="GET"
|
61
|
+
data-myft-ui="instant"
|
62
|
+
data-concept-id={conceptId}
|
63
|
+
action={`/myft/add/${conceptId}?instant=true`}
|
64
|
+
data-js-action={`/__myft/api/core/followed/concept/${conceptId}?method=put`}>
|
65
|
+
<CsrfToken />
|
66
|
+
<input type="hidden" value={name} name="name" />
|
67
|
+
<input type="hidden" value={directType || 'http://www.ft.com/ontology/concept/Concept'} name="directType" />
|
68
|
+
<button {...generateButtonProps()}>{buttonText ? buttonText : 'Instant alerts'}</button>
|
69
|
+
</form>}
|
70
|
+
</Fragment>
|
71
|
+
);
|
72
|
+
|
73
|
+
}
|
@@ -0,0 +1,86 @@
|
|
1
|
+
import React from 'react';
|
2
|
+
import InstantAlert from './instant-alert';
|
3
|
+
import { render, screen } from '@testing-library/react';
|
4
|
+
import '@testing-library/jest-dom';
|
5
|
+
|
6
|
+
const props = {
|
7
|
+
flags: {
|
8
|
+
myFtApi: true,
|
9
|
+
myFtApiWrite: true
|
10
|
+
},
|
11
|
+
conceptId: '0000-000000-00000-0000',
|
12
|
+
name: 'Instant Alert',
|
13
|
+
buttonText: 'Instant Alert'
|
14
|
+
};
|
15
|
+
|
16
|
+
describe('InstantAlert', () => {
|
17
|
+
|
18
|
+
test('It renders', async () => {
|
19
|
+
render(<InstantAlert {...props} />);
|
20
|
+
expect(await screen.findByText('Instant Alert')).toBeInTheDocument();
|
21
|
+
});
|
22
|
+
|
23
|
+
test('It renders form attributes', () => {
|
24
|
+
const { container } = render(<InstantAlert {...props} />);
|
25
|
+
expect(container.querySelector('form[method="GET"]')).toBeInTheDocument();
|
26
|
+
expect(container.querySelector(`form[action='/myft/add/${props.conceptId}?instant=true']`)).toBeInTheDocument();
|
27
|
+
expect(container.querySelector('form[data-myft-ui="instant"]')).toBeInTheDocument();
|
28
|
+
expect(container.querySelector(`form[data-concept-id="${props.conceptId}"]`)).toBeInTheDocument();
|
29
|
+
expect(container.querySelector(`form[data-js-action="/__myft/api/core/followed/concept/${props.conceptId}?method=put"]`)).toBeInTheDocument();
|
30
|
+
});
|
31
|
+
|
32
|
+
test('It renders extraClasses in form', () => {
|
33
|
+
const { container } = render(<InstantAlert {...props} extraClasses={'extra'} />);
|
34
|
+
expect(container.querySelector('form[class="n-myft-ui n-myft-ui--instant extra"]')).toBeInTheDocument();
|
35
|
+
});
|
36
|
+
|
37
|
+
test('It renders hide button class in form', () => {
|
38
|
+
const { container } = render(<InstantAlert {...props} hideButtonText={true} />);
|
39
|
+
expect(container.querySelector('form[class="n-myft-ui n-myft-ui--instant n-myft-ui--instant--hide-text"]')).toBeInTheDocument();
|
40
|
+
});
|
41
|
+
|
42
|
+
test('It renders csrftoken input', () => {
|
43
|
+
const { container } = render(<InstantAlert {...props} />);
|
44
|
+
expect(container.querySelector('input[data-myft-csrf-token]')).toBeInTheDocument();
|
45
|
+
});
|
46
|
+
|
47
|
+
test('It renders input name as value attribute', () => {
|
48
|
+
const { container } = render(<InstantAlert {...props} />);
|
49
|
+
expect(container.querySelector('input[value="Instant Alert"]')).toBeInTheDocument();
|
50
|
+
});
|
51
|
+
|
52
|
+
test('It renders input directType as value attribute', () => {
|
53
|
+
const { container } = render(<InstantAlert {...props} directType={'http://www.ft.com/ontology/test/Test'} />);
|
54
|
+
expect(container.querySelector('input[value="http://www.ft.com/ontology/test/Test"]')).toBeInTheDocument();
|
55
|
+
});
|
56
|
+
|
57
|
+
test('It renders button props', () => {
|
58
|
+
const { container } = render(<InstantAlert {...props} alternateText={'Sample alternate text'} variant={'blue'} size={'small'} />);
|
59
|
+
expect(container.querySelector(`button[aria-label="Get instant alerts for ${props.name}"]`)).toBeInTheDocument();
|
60
|
+
expect(container.querySelector('button[data-alternate-text="Sample alternate text"]')).toBeInTheDocument();
|
61
|
+
expect(container.querySelector(`button[data-concept-id="${props.conceptId}"]`)).toBeInTheDocument();
|
62
|
+
expect(container.querySelector('button[data-trackable="instant"]')).toBeInTheDocument();
|
63
|
+
expect(container.querySelector(`button[title="Get instant alerts for ${props.name}"]`)).toBeInTheDocument();
|
64
|
+
expect(container.querySelector('button[value="true"]')).toBeInTheDocument();
|
65
|
+
expect(container.querySelector('button[type="submit"]')).toBeInTheDocument();
|
66
|
+
expect(container.querySelector('button[name="_rel.instant"]')).toBeInTheDocument();
|
67
|
+
expect(container.getElementsByClassName('n-myft-ui__button--blue')).toHaveLength(1);
|
68
|
+
expect(container.getElementsByClassName('n-myft-ui__button--small')).toHaveLength(1);
|
69
|
+
});
|
70
|
+
|
71
|
+
test('It renders buttonText as data-alternate-text attribute when alternateText prop is not provided', () => {
|
72
|
+
const { container } = render(<InstantAlert {...props} buttonText={'Sample button text'} variant={'blue'} size={'small'} />);
|
73
|
+
expect(container.querySelector('button[data-alternate-text="Sample button text"]')).toBeInTheDocument();
|
74
|
+
});
|
75
|
+
|
76
|
+
test('It renders button aria-pressed=false attribute when hasInstantAlert=false or cacheablePersonalisedUrl props is not provided', () => {
|
77
|
+
render(<InstantAlert {...props} buttonText={'Sample button text'} hasInstantAlert={false} cacheablePersonalisedUrl={'https://ft.com'} />);
|
78
|
+
expect(screen.getByRole('button', {pressed: false})).toBeInTheDocument();
|
79
|
+
});
|
80
|
+
|
81
|
+
test('It renders button aria-pressed=true attribute when hasInstantAlert=true and cacheablePersonalisedUrl provided', () => {
|
82
|
+
render(<InstantAlert {...props} buttonText={'Sample button text'} hasInstantAlert={true} cacheablePersonalisedUrl={'https://ft.com'} />);
|
83
|
+
expect(screen.getByRole('button', {pressed: true})).toBeInTheDocument();
|
84
|
+
});
|
85
|
+
|
86
|
+
});
|
@@ -0,0 +1,40 @@
|
|
1
|
+
import React, { Fragment } from 'react';
|
2
|
+
import CsrfToken from '../csrf-token/input';
|
3
|
+
export default function PinButton ({ showPrioritiseButton, id, name, directType, prioritised, csrfToken, cacheablePersonalisedUrl }) {
|
4
|
+
|
5
|
+
const getAction = () => `/__myft/api/core/prioritised/concept/${id}?method=${prioritised ? 'delete' : 'put'}`;
|
6
|
+
|
7
|
+
if (!showPrioritiseButton) {
|
8
|
+
return null;
|
9
|
+
}
|
10
|
+
|
11
|
+
return (
|
12
|
+
<Fragment>
|
13
|
+
<span className="myft-pin-divider"></span>
|
14
|
+
<div className="myft-pin-button-wrapper">
|
15
|
+
<form method="post" action={getAction()} data-myft-prioritise>
|
16
|
+
<CsrfToken csrfToken={csrfToken} cacheablePersonalisedUrl={cacheablePersonalisedUrl} />
|
17
|
+
<input type="hidden" value={name} name="name" />
|
18
|
+
<input type="hidden" value={directType || 'http://www.ft.com/ontology/concept/Concept'} name="directType" />
|
19
|
+
<div
|
20
|
+
className="n-myft-ui__announcement o-normalise-visually-hidden"
|
21
|
+
aria-live="assertive"
|
22
|
+
data-pressed-text={`${name} pinned in myFT.`}
|
23
|
+
data-unpressed-text={`Unpinned ${name} from myFT.`}
|
24
|
+
></div>
|
25
|
+
<button id={`myft-pin-button__${id}`}
|
26
|
+
className="myft-pin-button"
|
27
|
+
data-prioritise-button
|
28
|
+
data-trackable="prioritised"
|
29
|
+
data-concept-id={id}
|
30
|
+
data-prioritised={prioritised ? true : false}
|
31
|
+
aria-label={`${prioritised ? 'Unpin' : 'Pin'} ${name} ${prioritised ? 'from' : 'in'} my F T`}
|
32
|
+
aria-pressed={prioritised ? true : false}
|
33
|
+
title={`${prioritised ? 'Unpin' : 'Pin'} ${name}`}>
|
34
|
+
</button>
|
35
|
+
</form>
|
36
|
+
</div>
|
37
|
+
</Fragment>
|
38
|
+
)
|
39
|
+
|
40
|
+
}
|