@financial-times/qanda-ui 0.0.1-beta.1
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/.prettierrc +3 -0
- package/.toolkitrc.yml +34 -0
- package/.vscode/settings.json +4 -0
- package/CODEOWNERS +3 -0
- package/README.md +161 -0
- package/babel.config.json +10 -0
- package/dist/index.html +4 -0
- package/dist/index.js +2 -0
- package/dist/index.js.LICENSE.txt +15 -0
- package/dist/mockServiceWorker.js +307 -0
- package/jest.setup.ts +15 -0
- package/package.json +115 -0
- package/server.js +17 -0
- package/src/client/styles.scss +61 -0
- package/src/components/Author.tsx +45 -0
- package/src/components/CountdownTimer.tsx +73 -0
- package/src/components/Network.tsx +19 -0
- package/src/components/Qanda.tsx +51 -0
- package/src/components/QandaBlock.tsx +63 -0
- package/src/components/QandaContainer.tsx +60 -0
- package/src/components/QandaOComments.tsx +60 -0
- package/src/components/QandaProvider.tsx +13 -0
- package/src/components/QuestionForm.tsx +122 -0
- package/src/components/Status.tsx +20 -0
- package/src/components/Stream.tsx +49 -0
- package/src/components/UITestBar.tsx +76 -0
- package/src/components/UpdatesButton.tsx +45 -0
- package/src/components/UserInfo.tsx +57 -0
- package/src/index.tsx +58 -0
- package/src/services/comments-api.ts +217 -0
- package/src/services/custom-sse.ts +101 -0
- package/src/services/mocks/api-endpoints.ts +36 -0
- package/src/services/mocks/mock-comments.json +307 -0
- package/src/services/mocks/msw-browser.ts +4 -0
- package/src/services/mocks/msw-server.ts +4 -0
- package/src/store/index.ts +41 -0
- package/src/store/network.ts +23 -0
- package/src/store/qa.ts +22 -0
- package/src/store/questionSlice.ts +37 -0
- package/src/store/stream.ts +21 -0
- package/src/store/updateComments.ts +60 -0
- package/src/store/user.ts +55 -0
- package/src/types/async-operation.d.ts +4 -0
- package/src/types/comment.d.ts +19 -0
- package/src/types/globals.d.ts +1 -0
- package/src/types/modules.d.ts +1 -0
- package/src/types/o-types.d.ts +2 -0
- package/src/types/qanda.d.ts +7 -0
- package/src/types/sse-events.d.ts +6 -0
- package/src/types/x-dash.d.ts +6 -0
- package/src/utils/auth.ts +9 -0
- package/src/utils/mocks/update.ts +168 -0
- package/src/utils/qandas.ts +21 -0
- package/tsconfig.json +26 -0
- package/webpack.common.js +60 -0
- package/webpack.dev.js +26 -0
- package/webpack.prod.js +6 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Provider } from 'react-redux';
|
|
2
|
+
import { store } from '../store';
|
|
3
|
+
import QandaContainer from './QandaContainer';
|
|
4
|
+
import { QandaContext } from './QandaProvider';
|
|
5
|
+
import '../client/styles.scss';
|
|
6
|
+
|
|
7
|
+
import type { QandaProps } from '../types/qanda';
|
|
8
|
+
|
|
9
|
+
function Qanda({
|
|
10
|
+
storyId,
|
|
11
|
+
useStaging,
|
|
12
|
+
title,
|
|
13
|
+
commentsAPIHost,
|
|
14
|
+
commentsAPIVersion,
|
|
15
|
+
}: QandaProps) {
|
|
16
|
+
// check we have a valid ft.com url and remove any trailing slashes
|
|
17
|
+
const allowedHostRegx = /^[\w\-\.]+\.ft\.com$/i;
|
|
18
|
+
const allowedVersionRegx = /^v\d+$/i;
|
|
19
|
+
const validateApiUrl = (url: string, version: string): string | undefined => {
|
|
20
|
+
const baseURL = new URL(url);
|
|
21
|
+
if (
|
|
22
|
+
!allowedHostRegx.test(baseURL.hostname) ||
|
|
23
|
+
!allowedVersionRegx.test(version)
|
|
24
|
+
) {
|
|
25
|
+
console.error('Invalid comments API details provided');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
return `https://${baseURL.hostname}/qa/${version}`;
|
|
29
|
+
};
|
|
30
|
+
const cleanCommentsAPIUrl = validateApiUrl(
|
|
31
|
+
commentsAPIHost,
|
|
32
|
+
commentsAPIVersion,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
if (!storyId || !title || !cleanCommentsAPIUrl) {
|
|
36
|
+
console.error('Missing required props');
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<Provider store={store}>
|
|
42
|
+
<QandaContext.Provider
|
|
43
|
+
value={{ storyId, useStaging, commentsAPIUrl: cleanCommentsAPIUrl }}
|
|
44
|
+
>
|
|
45
|
+
<QandaContainer title={title} />
|
|
46
|
+
</QandaContext.Provider>
|
|
47
|
+
</Provider>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default Qanda;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { RichText, Byline } from '@financial-times/cp-content-pipeline-ui';
|
|
2
|
+
// TODO: this component needs exporting from cp-content-pipeline-ui
|
|
3
|
+
import Timestamp from '@financial-times/cp-content-pipeline-ui/lib/components/LiveBlogPost/Timestamp';
|
|
4
|
+
import type { Comment } from '../types/comment';
|
|
5
|
+
import Author from './Author';
|
|
6
|
+
|
|
7
|
+
type UpdatedDateProps = {
|
|
8
|
+
answers: Comment[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function UpdatedDate({ answers }: UpdatedDateProps) {
|
|
12
|
+
const sortedAnswers = Array.from(answers).sort(
|
|
13
|
+
(a, b) =>
|
|
14
|
+
new Date(b.publishedDate).getTime() - new Date(a.publishedDate).getTime(),
|
|
15
|
+
);
|
|
16
|
+
return (
|
|
17
|
+
<span>
|
|
18
|
+
<Timestamp publishedTimestamp={sortedAnswers[0].publishedDate} />
|
|
19
|
+
</span>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function QandaBlock({ id, body, children, byline }: Comment) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="qanda-block">
|
|
26
|
+
<div
|
|
27
|
+
className="qanda-block__question"
|
|
28
|
+
id={id}
|
|
29
|
+
key={id}
|
|
30
|
+
data-testid={`qanda-block__question-${id}`}
|
|
31
|
+
>
|
|
32
|
+
Question
|
|
33
|
+
{children && <UpdatedDate answers={children} />}
|
|
34
|
+
<Byline structuredContent={byline} showEditedBy={false} />
|
|
35
|
+
<RichText structuredContent={body.structured} />
|
|
36
|
+
</div>
|
|
37
|
+
{children?.map(
|
|
38
|
+
(child) =>
|
|
39
|
+
child.body && (
|
|
40
|
+
<div
|
|
41
|
+
className="qanda-block__answer"
|
|
42
|
+
key={child.id}
|
|
43
|
+
data-testid={`qanda-block__answer-${child.id}`}
|
|
44
|
+
>
|
|
45
|
+
Answer
|
|
46
|
+
<Byline structuredContent={child.byline} showEditedBy={false} />
|
|
47
|
+
{child.author && (
|
|
48
|
+
<Author
|
|
49
|
+
role={child.author.role}
|
|
50
|
+
headshot={child.author.headshot}
|
|
51
|
+
prefLabel={child.author.prefLabel}
|
|
52
|
+
streamPage={child.author.streamPage}
|
|
53
|
+
/>
|
|
54
|
+
)}
|
|
55
|
+
<RichText structuredContent={child.body.structured} />
|
|
56
|
+
</div>
|
|
57
|
+
),
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default QandaBlock;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useEffect, useContext } from 'react';
|
|
2
|
+
import { useDispatch } from 'react-redux';
|
|
3
|
+
import { getUserProfile } from '../store/user';
|
|
4
|
+
import { AppDispatch } from '../store';
|
|
5
|
+
import Status from './Status';
|
|
6
|
+
import UserInfo from './UserInfo';
|
|
7
|
+
import QandaOComments from './QandaOComments';
|
|
8
|
+
import Stream from './Stream';
|
|
9
|
+
import Network from './Network';
|
|
10
|
+
import UpdatesButton from './UpdatesButton';
|
|
11
|
+
import UITest from './UITestBar';
|
|
12
|
+
import CountdownTimer from './CountdownTimer';
|
|
13
|
+
import {
|
|
14
|
+
useGetQandAUpdatesQuery,
|
|
15
|
+
nextCommentsApi,
|
|
16
|
+
} from '../services/comments-api';
|
|
17
|
+
|
|
18
|
+
import { QandaContext } from './QandaProvider';
|
|
19
|
+
|
|
20
|
+
function QandaContainer({ title }: { title: string }) {
|
|
21
|
+
const { storyId, useStaging, commentsAPIUrl } = useContext(QandaContext);
|
|
22
|
+
const dispatch = useDispatch<AppDispatch>();
|
|
23
|
+
|
|
24
|
+
const { data, error, isLoading } = useGetQandAUpdatesQuery({
|
|
25
|
+
storyId,
|
|
26
|
+
useStaging,
|
|
27
|
+
commentsAPIUrl,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
dispatch(getUserProfile({ useStaging }));
|
|
32
|
+
return () => {
|
|
33
|
+
// ensure we reset the api state when the component is unmounted
|
|
34
|
+
dispatch(nextCommentsApi.util.resetApiState());
|
|
35
|
+
};
|
|
36
|
+
}, [dispatch, storyId]);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="qanda-container">
|
|
40
|
+
{isLoading && <p>Loading updates</p>}
|
|
41
|
+
{error && (
|
|
42
|
+
<p>
|
|
43
|
+
Sorry, we could not connect you to get automatic updates. Error:
|
|
44
|
+
{error}
|
|
45
|
+
</p>
|
|
46
|
+
)}
|
|
47
|
+
{data && <p>Updates: {JSON.stringify(data)}</p>}
|
|
48
|
+
<Status />
|
|
49
|
+
<Network />
|
|
50
|
+
<UITest />
|
|
51
|
+
<CountdownTimer />
|
|
52
|
+
<UpdatesButton />
|
|
53
|
+
<Stream />
|
|
54
|
+
<UserInfo />
|
|
55
|
+
<QandaOComments title={title} />
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default QandaContainer;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import OComments from '@financial-times/o-comments';
|
|
2
|
+
import { useEffect, useContext } from 'react';
|
|
3
|
+
import { useSelector } from 'react-redux';
|
|
4
|
+
import { RootState } from '../store';
|
|
5
|
+
import { QandaContext } from './QandaProvider';
|
|
6
|
+
import { useGetUserRolesQuery } from '../services/comments-api';
|
|
7
|
+
|
|
8
|
+
type QandaOCommentsProps = {
|
|
9
|
+
title: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function QandaOComments({ title }: QandaOCommentsProps) {
|
|
13
|
+
const allowedRoles = ['ADMIN', 'MODERATOR'];
|
|
14
|
+
const { storyId, useStaging, commentsAPIUrl } = useContext(QandaContext);
|
|
15
|
+
|
|
16
|
+
const profile = useSelector((state: RootState) => state.user.profile);
|
|
17
|
+
if (!profile) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const {
|
|
22
|
+
data: userRole,
|
|
23
|
+
error,
|
|
24
|
+
isLoading,
|
|
25
|
+
} = useGetUserRolesQuery(
|
|
26
|
+
{
|
|
27
|
+
storyId,
|
|
28
|
+
useStaging,
|
|
29
|
+
commentsAPIUrl,
|
|
30
|
+
token: profile.token,
|
|
31
|
+
},
|
|
32
|
+
{ skip: !profile.token },
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!userRole) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const { isExpert, isStaffExpert, role } = userRole;
|
|
40
|
+
const showCoral =
|
|
41
|
+
isExpert || isStaffExpert || (role && allowedRoles.includes(role));
|
|
42
|
+
if (showCoral) {
|
|
43
|
+
OComments.init();
|
|
44
|
+
}
|
|
45
|
+
}, [userRole]);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
className="o-comments"
|
|
50
|
+
id="o-comments-stream"
|
|
51
|
+
data-o-component="o-comments"
|
|
52
|
+
data-o-comments-article-id={storyId}
|
|
53
|
+
data-o-comments-article-url={`https://www.ft.com/content/${storyId}`} // do not use window.location in case we have a vanity url
|
|
54
|
+
data-o-comments-use-staging-environment={useStaging}
|
|
55
|
+
data-o-comments-title={title}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default QandaOComments;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createContext } from 'react';
|
|
2
|
+
|
|
3
|
+
type QandaContextType = {
|
|
4
|
+
storyId: string;
|
|
5
|
+
useStaging: boolean;
|
|
6
|
+
commentsAPIUrl: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const QandaContext = createContext<QandaContextType>({
|
|
10
|
+
storyId: '',
|
|
11
|
+
useStaging: true,
|
|
12
|
+
commentsAPIUrl: '',
|
|
13
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useDispatch, useSelector } from 'react-redux';
|
|
2
|
+
import type { RootState } from '../store';
|
|
3
|
+
import { useState, useContext, FormEvent } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
openForm,
|
|
6
|
+
closeForm,
|
|
7
|
+
updateQuestion,
|
|
8
|
+
closeSuccess,
|
|
9
|
+
} from '../store/questionSlice';
|
|
10
|
+
import type { NetworkStatus } from '../store/network';
|
|
11
|
+
import { QandaContext } from './QandaProvider';
|
|
12
|
+
import { usePostQuestionMutation } from '../services/comments-api';
|
|
13
|
+
|
|
14
|
+
export default function QuestionForm() {
|
|
15
|
+
const dispatch = useDispatch();
|
|
16
|
+
const { storyId, useStaging, commentsAPIUrl } = useContext(QandaContext);
|
|
17
|
+
const [postError, setPostError] = useState<string | null>(null);
|
|
18
|
+
const { isFormOpen, question } = useSelector(
|
|
19
|
+
(state: RootState) => state.question,
|
|
20
|
+
);
|
|
21
|
+
const profile = useSelector((state: RootState) => state.user.profile);
|
|
22
|
+
const networkStatus = useSelector((state: RootState) => state.network.status);
|
|
23
|
+
|
|
24
|
+
const [postQuestion, { isLoading, isSuccess, error, reset }] =
|
|
25
|
+
usePostQuestionMutation();
|
|
26
|
+
|
|
27
|
+
const handleSubmit = async (e: FormEvent) => {
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
|
|
30
|
+
if (!profile?.token) {
|
|
31
|
+
//TODO: how to handle users that are not logged in? design awaited
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const response = await postQuestion({
|
|
37
|
+
token: profile?.token,
|
|
38
|
+
storyId,
|
|
39
|
+
useStaging,
|
|
40
|
+
commentsAPIUrl,
|
|
41
|
+
question: question,
|
|
42
|
+
}).unwrap();
|
|
43
|
+
} catch (e) {
|
|
44
|
+
dispatch(openForm());
|
|
45
|
+
setPostError('Failed to submit question');
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
if (isSuccess) {
|
|
50
|
+
return (
|
|
51
|
+
<div>
|
|
52
|
+
<p>Question submitted successfully!</p>
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
onClick={() => {
|
|
56
|
+
dispatch(closeSuccess());
|
|
57
|
+
reset();
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
Close
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!isFormOpen) {
|
|
67
|
+
return (
|
|
68
|
+
<>
|
|
69
|
+
{networkStatus === 'offline' && (
|
|
70
|
+
<p>You cannot submit question while offline</p>
|
|
71
|
+
)}
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
onClick={() => dispatch(openForm())}
|
|
75
|
+
disabled={networkStatus === ('offline' as NetworkStatus)}
|
|
76
|
+
>
|
|
77
|
+
Ask question
|
|
78
|
+
</button>
|
|
79
|
+
</>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<form onSubmit={handleSubmit}>
|
|
85
|
+
{error && (
|
|
86
|
+
<p role="alert" id="question-error-message">
|
|
87
|
+
{error}
|
|
88
|
+
</p>
|
|
89
|
+
)}
|
|
90
|
+
<textarea
|
|
91
|
+
value={question}
|
|
92
|
+
onChange={(e) => dispatch(updateQuestion(e.currentTarget.value))}
|
|
93
|
+
aria-label="Your question"
|
|
94
|
+
placeholder="Type your question"
|
|
95
|
+
maxLength={2000}
|
|
96
|
+
aria-invalid={postError ? 'true' : undefined}
|
|
97
|
+
aria-describedby={postError ? 'question-error-message' : undefined}
|
|
98
|
+
/>
|
|
99
|
+
<div>
|
|
100
|
+
{!profile?.token && <p>You must be logged in to submit a question</p>}
|
|
101
|
+
<button
|
|
102
|
+
type="submit"
|
|
103
|
+
disabled={question.length < 20 || isLoading}
|
|
104
|
+
aria-label={
|
|
105
|
+
question.length < 20
|
|
106
|
+
? 'Submit button (disabled until question is at least 20 characters)'
|
|
107
|
+
: 'Submit question'
|
|
108
|
+
}
|
|
109
|
+
>
|
|
110
|
+
Submit
|
|
111
|
+
</button>
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
onClick={() => dispatch(closeForm())}
|
|
115
|
+
aria-label="Close form"
|
|
116
|
+
>
|
|
117
|
+
Close
|
|
118
|
+
</button>
|
|
119
|
+
</div>
|
|
120
|
+
</form>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useSelector } from 'react-redux';
|
|
3
|
+
import { RootState } from '../store';
|
|
4
|
+
import QuestionForm from './QuestionForm';
|
|
5
|
+
|
|
6
|
+
function Status() {
|
|
7
|
+
const status = useSelector((state: RootState) => state.qa.status);
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div>
|
|
11
|
+
<p>
|
|
12
|
+
The Q&A is
|
|
13
|
+
<b>{status === 'live' ? 'Live' : 'Close'}</b>
|
|
14
|
+
</p>
|
|
15
|
+
{status === 'live' ? <QuestionForm /> : <p>Questions not accepted</p>}
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default Status;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useEffect, useContext } from 'react';
|
|
2
|
+
import { useSelector } from 'react-redux';
|
|
3
|
+
import { RootState } from '../store';
|
|
4
|
+
import QandaBlock from './QandaBlock';
|
|
5
|
+
import { QandaContext } from './QandaProvider';
|
|
6
|
+
import { useGetQandAStreamQuery } from '../services/comments-api';
|
|
7
|
+
function Stream() {
|
|
8
|
+
const latestAnsweredQuestionId = useSelector(
|
|
9
|
+
(state: RootState) => state.stream.latestAnsweredQuestionId,
|
|
10
|
+
);
|
|
11
|
+
const { storyId, useStaging, commentsAPIUrl } = useContext(QandaContext);
|
|
12
|
+
|
|
13
|
+
const { data, error, isLoading } = useGetQandAStreamQuery({
|
|
14
|
+
storyId,
|
|
15
|
+
useStaging,
|
|
16
|
+
commentsAPIUrl,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const element = document.getElementById(latestAnsweredQuestionId);
|
|
21
|
+
element?.scrollIntoView({ behavior: 'smooth' });
|
|
22
|
+
}, [latestAnsweredQuestionId]);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="qanda-block" data-testid="qanda-block">
|
|
26
|
+
<h2>Q&As:</h2>
|
|
27
|
+
{isLoading && <p>Loading Stream for you, be patient...</p>}
|
|
28
|
+
{error && <p>Failed to load Q&A stream</p>}
|
|
29
|
+
{data &&
|
|
30
|
+
data.qandas &&
|
|
31
|
+
data.qandas.map((qandaBlock: any) => (
|
|
32
|
+
<div key={qandaBlock.id}>
|
|
33
|
+
<QandaBlock
|
|
34
|
+
id={qandaBlock.id}
|
|
35
|
+
body={qandaBlock.body}
|
|
36
|
+
type="comment"
|
|
37
|
+
publishedDate={qandaBlock.publishedDate}
|
|
38
|
+
author={qandaBlock.author}
|
|
39
|
+
byline={qandaBlock.byline}
|
|
40
|
+
>
|
|
41
|
+
{qandaBlock.children}
|
|
42
|
+
</QandaBlock>
|
|
43
|
+
</div>
|
|
44
|
+
))}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default Stream;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useDispatch, useSelector } from 'react-redux';
|
|
2
|
+
import { useEffect } from 'preact/hooks';
|
|
3
|
+
import { AppDispatch, RootState } from '../store';
|
|
4
|
+
import { eventSource } from '../services/comments-api';
|
|
5
|
+
import newAnserEventData from '../utils/mocks/update';
|
|
6
|
+
import type { NewAnswerEvent } from '../types/sse-events';
|
|
7
|
+
|
|
8
|
+
interface ExtendedEventSource extends EventSource {
|
|
9
|
+
sendMessage?: (message: NewAnswerEvent) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function UITest() {
|
|
13
|
+
const dispatch = useDispatch<AppDispatch>();
|
|
14
|
+
const networkStatus = useSelector((state: RootState) => state.network.status);
|
|
15
|
+
const qaStatus = useSelector((state: RootState) => state.qa.status);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const handleNewUpdate = (event: Event) => {
|
|
19
|
+
const customEvent = event as CustomEvent<NewAnswerEvent>;
|
|
20
|
+
|
|
21
|
+
const customEventSource = eventSource as ExtendedEventSource;
|
|
22
|
+
if (customEventSource?.sendMessage) {
|
|
23
|
+
customEventSource.sendMessage(customEvent.detail);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
window.addEventListener('newUpdate', handleNewUpdate);
|
|
28
|
+
|
|
29
|
+
return () => {
|
|
30
|
+
window.removeEventListener('newUpdate', handleNewUpdate);
|
|
31
|
+
};
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="ui-test-bar">
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
onClick={() => {
|
|
39
|
+
dispatch({
|
|
40
|
+
type: 'network/networkChangeStatusTo',
|
|
41
|
+
payload: {
|
|
42
|
+
status: networkStatus === 'offline' ? 'online' : 'offline',
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
Change Network status to{' '}
|
|
48
|
+
{networkStatus === 'offline' ? 'online' : 'offline'}
|
|
49
|
+
</button>
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
onClick={() => {
|
|
53
|
+
dispatch({
|
|
54
|
+
type: `qa/${qaStatus === 'live' ? 'close' : 'open'}`,
|
|
55
|
+
payload: null,
|
|
56
|
+
});
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
Change the status to {qaStatus === 'live' ? 'Close' : 'Live'}
|
|
60
|
+
</button>
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
onClick={() => {
|
|
64
|
+
const event = new CustomEvent('newUpdate', {
|
|
65
|
+
detail: newAnserEventData(),
|
|
66
|
+
});
|
|
67
|
+
window.dispatchEvent(event);
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
Send a real-time update
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export default UITest;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useContext } from 'react';
|
|
2
|
+
import { useDispatch } from 'react-redux';
|
|
3
|
+
import { AppDispatch } from '../store';
|
|
4
|
+
import { useUpdatedComments } from '../services/comments-api';
|
|
5
|
+
import { updateComments } from '../store/updateComments';
|
|
6
|
+
import { QandaContext } from './QandaProvider';
|
|
7
|
+
|
|
8
|
+
import type { Comment } from '../types/comment';
|
|
9
|
+
|
|
10
|
+
function UpdatesButton() {
|
|
11
|
+
const { storyId, useStaging, commentsAPIUrl } = useContext(QandaContext);
|
|
12
|
+
const dispatch = useDispatch<AppDispatch>();
|
|
13
|
+
const updatedComments: Comment[] = useUpdatedComments({
|
|
14
|
+
storyId,
|
|
15
|
+
useStaging,
|
|
16
|
+
commentsAPIUrl,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const handleShowUpdates = () => {
|
|
20
|
+
updateComments(
|
|
21
|
+
dispatch,
|
|
22
|
+
updatedComments,
|
|
23
|
+
storyId,
|
|
24
|
+
useStaging,
|
|
25
|
+
commentsAPIUrl,
|
|
26
|
+
);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
{updatedComments && updatedComments.length && (
|
|
32
|
+
<button
|
|
33
|
+
data-testid="qanda-button"
|
|
34
|
+
className="update-button"
|
|
35
|
+
onClick={() => handleShowUpdates()}
|
|
36
|
+
type="button"
|
|
37
|
+
>
|
|
38
|
+
{`There are ${updatedComments.length} updates available`}
|
|
39
|
+
</button>
|
|
40
|
+
)}
|
|
41
|
+
</>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default UpdatesButton;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useContext } from 'react';
|
|
2
|
+
import { useSelector } from 'react-redux';
|
|
3
|
+
import { RootState } from '../store';
|
|
4
|
+
import { useGetUserRolesQuery } from '../services/comments-api';
|
|
5
|
+
import { QandaContext } from './QandaProvider';
|
|
6
|
+
|
|
7
|
+
function UserInfo() {
|
|
8
|
+
const profile = useSelector((state: RootState) => state.user.profile);
|
|
9
|
+
if (!profile) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { storyId, useStaging, commentsAPIUrl } = useContext(QandaContext);
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
data: userRole,
|
|
17
|
+
error,
|
|
18
|
+
isLoading,
|
|
19
|
+
} = useGetUserRolesQuery(
|
|
20
|
+
{
|
|
21
|
+
storyId,
|
|
22
|
+
useStaging,
|
|
23
|
+
commentsAPIUrl,
|
|
24
|
+
token: profile.token,
|
|
25
|
+
},
|
|
26
|
+
{ skip: !profile.token },
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div>
|
|
31
|
+
{isLoading && <p>Loading your profile...</p>}
|
|
32
|
+
{error && (
|
|
33
|
+
<p>
|
|
34
|
+
<b>{error}</b>
|
|
35
|
+
</p>
|
|
36
|
+
)}
|
|
37
|
+
{userRole && JSON.stringify(userRole)}
|
|
38
|
+
{userRole?.isExpert && (
|
|
39
|
+
<p>
|
|
40
|
+
<b>It seems you are a bit of an expert:</b>{' '}
|
|
41
|
+
{userRole.isStaffExpert ? 'FT Reporter' : 'FT Expert'}
|
|
42
|
+
</p>
|
|
43
|
+
)}
|
|
44
|
+
{profile?.token ? (
|
|
45
|
+
<p>
|
|
46
|
+
<b>{`Your display name is ${profile.displayName}`}</b>
|
|
47
|
+
</p>
|
|
48
|
+
) : (
|
|
49
|
+
<p>
|
|
50
|
+
<b>You are not logged in</b>
|
|
51
|
+
</p>
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default UserInfo;
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { render, h } from 'preact';
|
|
2
|
+
import Qanda from './components/Qanda';
|
|
3
|
+
import type { QandaProps } from './types/qanda';
|
|
4
|
+
|
|
5
|
+
export class QandaUI {
|
|
6
|
+
containerElement: HTMLElement;
|
|
7
|
+
storyId: string;
|
|
8
|
+
useStaging: boolean;
|
|
9
|
+
title: string;
|
|
10
|
+
commentsAPIHost: string;
|
|
11
|
+
commentsAPIVersion: string;
|
|
12
|
+
|
|
13
|
+
constructor(containerElement: HTMLElement, options?: QandaProps) {
|
|
14
|
+
this.containerElement = containerElement;
|
|
15
|
+
if (!options && !containerElement) {
|
|
16
|
+
throw new Error('Required properties are missing');
|
|
17
|
+
}
|
|
18
|
+
const storyId = options?.storyId ?? containerElement.dataset.qandaStoryid;
|
|
19
|
+
const useStaging =
|
|
20
|
+
options?.useStaging ??
|
|
21
|
+
containerElement.dataset.qandaUsestaging === 'true' ??
|
|
22
|
+
false;
|
|
23
|
+
const title = options?.title ?? containerElement.dataset.qandaTitle;
|
|
24
|
+
const commentsAPIHost =
|
|
25
|
+
options?.commentsAPIHost ?? containerElement.dataset.qandaCommentsapihost;
|
|
26
|
+
const commentsAPIVersion =
|
|
27
|
+
options?.commentsAPIVersion ??
|
|
28
|
+
containerElement.dataset.qandaCommentsapiversion;
|
|
29
|
+
|
|
30
|
+
if (!storyId || !title || !commentsAPIHost || !commentsAPIVersion) {
|
|
31
|
+
throw new Error('Required properties are missing');
|
|
32
|
+
}
|
|
33
|
+
//TODO: test this doesn't break all the other bits of js, needs to be throw because of typescript, can't just bail out normally
|
|
34
|
+
this.storyId = storyId;
|
|
35
|
+
this.useStaging = useStaging;
|
|
36
|
+
this.title = title;
|
|
37
|
+
this.commentsAPIHost = commentsAPIHost;
|
|
38
|
+
this.commentsAPIVersion = commentsAPIVersion;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
init() {
|
|
42
|
+
render(
|
|
43
|
+
<Qanda
|
|
44
|
+
storyId={this.storyId}
|
|
45
|
+
useStaging={this.useStaging}
|
|
46
|
+
title={this.title}
|
|
47
|
+
commentsAPIHost={this.commentsAPIHost}
|
|
48
|
+
commentsAPIVersion={this.commentsAPIVersion}
|
|
49
|
+
/>,
|
|
50
|
+
|
|
51
|
+
this.containerElement,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
destroy() {
|
|
56
|
+
render(null, this.containerElement);
|
|
57
|
+
}
|
|
58
|
+
}
|