@financial-times/qanda-ui 0.0.1-beta.2 → 0.0.1-beta.21
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/README.md +38 -6
- package/dist/cjs/components/Author/index.js +19 -0
- package/dist/cjs/components/Calendar/Apps.js +48 -0
- package/dist/cjs/components/Calendar/CalendarList.js +12 -0
- package/dist/cjs/components/Calendar/CalendarListItem.js +24 -0
- package/dist/cjs/components/Calendar/index.js +47 -0
- package/dist/cjs/components/CountdownTimer/index.js +91 -0
- package/dist/cjs/components/EmbedFormField/index.js +24 -0
- package/dist/cjs/components/ErrorMessage/humanReadableError.js +74 -0
- package/dist/cjs/components/ErrorMessage/index.js +63 -0
- package/dist/cjs/components/Expander/index.js +71 -0
- package/dist/cjs/components/ExpertDrawer/index.js +36 -0
- package/dist/cjs/components/FloatingActionBar/index.js +59 -0
- package/dist/cjs/components/Loader/index.js +7 -0
- package/dist/cjs/components/MessageBox/index.js +7 -0
- package/dist/cjs/components/Overlay/index.js +18 -0
- package/dist/cjs/components/Qanda.js +37 -0
- package/dist/cjs/components/QandaBlock/index.js +78 -0
- package/dist/cjs/components/QandaContainer.js +99 -0
- package/dist/cjs/components/QandaProvider.js +11 -0
- package/dist/cjs/components/QuestionForm/index.js +165 -0
- package/dist/cjs/components/QuestionForm/messagesByUserType.js +33 -0
- package/dist/cjs/components/Stream/index.js +37 -0
- package/dist/cjs/components/Survey/index.js +36 -0
- package/dist/cjs/hooks/useIsMobile.js +15 -0
- package/dist/cjs/index.js +53 -0
- package/dist/cjs/services/comments-api.js +152 -0
- package/dist/cjs/services/error-middleware.js +47 -0
- package/dist/cjs/services/reliability.js +79 -0
- package/dist/cjs/store/expertSlice.js +26 -0
- package/dist/cjs/store/index.js +30 -0
- package/dist/cjs/store/questionSlice.js +32 -0
- package/dist/cjs/store/stream.js +17 -0
- package/dist/cjs/store/updateComments.js +40 -0
- package/dist/cjs/store/user.js +51 -0
- package/dist/cjs/utils/auth.js +26 -0
- package/dist/cjs/utils/qandas.js +20 -0
- package/dist/cjs/utils/strings.js +16 -0
- package/dist/cjs/utils/tracking.js +11 -0
- package/dist/cjs/utils/validate-display-name.js +22 -0
- package/dist/esm/components/Author/index.js +14 -0
- package/dist/esm/components/Calendar/Apps.js +46 -0
- package/dist/esm/components/Calendar/CalendarList.js +7 -0
- package/dist/esm/components/Calendar/CalendarListItem.js +22 -0
- package/dist/esm/components/Calendar/index.js +42 -0
- package/dist/esm/components/CountdownTimer/index.js +88 -0
- package/dist/esm/components/EmbedFormField/index.js +22 -0
- package/dist/esm/components/ErrorMessage/humanReadableError.js +71 -0
- package/dist/esm/components/ErrorMessage/index.js +57 -0
- package/dist/esm/components/Expander/index.js +69 -0
- package/dist/esm/components/ExpertDrawer/index.js +31 -0
- package/dist/esm/components/FloatingActionBar/index.js +57 -0
- package/dist/esm/components/Loader/index.js +4 -0
- package/dist/esm/components/MessageBox/index.js +5 -0
- package/dist/esm/components/Overlay/index.js +13 -0
- package/dist/esm/components/Qanda.js +32 -0
- package/dist/esm/components/QandaBlock/index.js +73 -0
- package/dist/esm/components/QandaContainer.js +94 -0
- package/dist/esm/components/QandaProvider.js +8 -0
- package/dist/esm/components/QuestionForm/index.js +159 -0
- package/dist/esm/components/QuestionForm/messagesByUserType.js +28 -0
- package/dist/esm/components/Stream/index.js +32 -0
- package/dist/esm/components/Survey/index.js +34 -0
- package/dist/esm/hooks/useIsMobile.js +12 -0
- package/dist/esm/index.js +46 -0
- package/dist/esm/services/comments-api.js +147 -0
- package/dist/esm/services/error-middleware.js +43 -0
- package/dist/esm/services/reliability.js +75 -0
- package/dist/esm/store/expertSlice.js +22 -0
- package/dist/esm/store/index.js +24 -0
- package/dist/esm/store/questionSlice.js +28 -0
- package/dist/esm/store/stream.js +14 -0
- package/dist/esm/store/updateComments.js +36 -0
- package/dist/esm/store/user.js +45 -0
- package/dist/esm/utils/auth.js +24 -0
- package/dist/esm/utils/qandas.js +16 -0
- package/dist/esm/utils/strings.js +13 -0
- package/dist/esm/utils/tracking.js +7 -0
- package/dist/esm/utils/validate-display-name.js +19 -0
- package/dist/qanda.scss +802 -0
- package/dist/types/components/Author/index.d.ts +18 -0
- package/dist/types/components/Calendar/Apps.d.ts +14 -0
- package/dist/types/components/Calendar/CalendarList.d.ts +2 -0
- package/dist/types/components/Calendar/CalendarListItem.d.ts +8 -0
- package/dist/types/components/Calendar/index.d.ts +2 -0
- package/dist/types/components/CountdownTimer/index.d.ts +6 -0
- package/dist/types/components/EmbedFormField/index.d.ts +5 -0
- package/dist/types/components/ErrorMessage/humanReadableError.d.ts +29 -0
- package/dist/types/components/ErrorMessage/index.d.ts +8 -0
- package/dist/types/components/Expander/index.d.ts +8 -0
- package/dist/types/components/ExpertDrawer/index.d.ts +2 -0
- package/dist/types/components/FloatingActionBar/index.d.ts +4 -0
- package/dist/types/components/Loader/index.d.ts +14 -0
- package/dist/types/components/MessageBox/index.d.ts +10 -0
- package/dist/types/components/Overlay/index.d.ts +12 -0
- package/dist/types/components/Qanda.d.ts +3 -0
- package/dist/types/components/QandaBlock/index.d.ts +7 -0
- package/dist/types/components/QandaContainer.d.ts +6 -0
- package/dist/types/components/QandaProvider.d.ts +9 -0
- package/dist/types/components/QuestionForm/index.d.ts +1 -0
- package/dist/types/components/QuestionForm/messagesByUserType.d.ts +7 -0
- package/dist/types/components/Stream/index.d.ts +5 -0
- package/dist/types/components/Survey/index.d.ts +6 -0
- package/dist/types/hooks/useIsMobile.d.ts +1 -0
- package/dist/types/index.d.ts +17 -0
- package/dist/types/services/comments-api.d.ts +52 -0
- package/dist/types/services/error-middleware.d.ts +2 -0
- package/dist/types/services/reliability.d.ts +19 -0
- package/dist/types/store/expertSlice.d.ts +14 -0
- package/dist/types/store/index.d.ts +218 -0
- package/dist/types/store/questionSlice.d.ts +16 -0
- package/dist/types/store/stream.d.ts +7 -0
- package/dist/types/store/updateComments.d.ts +3 -0
- package/dist/types/store/user.d.ts +26 -0
- package/dist/types/utils/auth.d.ts +6 -0
- package/dist/types/utils/qandas.d.ts +2 -0
- package/dist/types/utils/strings.d.ts +1 -0
- package/dist/types/utils/tracking.d.ts +2 -0
- package/dist/types/utils/validate-display-name.d.ts +1 -0
- package/package.json +35 -29
- package/dist/index.html +0 -19
- package/dist/index.js +0 -2
- package/dist/index.js.LICENSE.txt +0 -15
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
|
2
|
+
// TODO: ensure that users don't miss out on updates in this window
|
|
3
|
+
// https://financialtimes.atlassian.net/browse/CI-2715
|
|
4
|
+
const retryDelay = 1000;
|
|
5
|
+
const updateTypes = {
|
|
6
|
+
QA_NEW_REPLY: 'updatedComments',
|
|
7
|
+
QA_UPDATE: 'updatedQA',
|
|
8
|
+
};
|
|
9
|
+
export let eventSource = null;
|
|
10
|
+
export const nextCommentsApi = createApi({
|
|
11
|
+
reducerPath: 'nextCommentsApi',
|
|
12
|
+
refetchOnReconnect: true, // refetches all subscribed queries on regaining a network connection
|
|
13
|
+
baseQuery: fetchBaseQuery({
|
|
14
|
+
timeout: 10000,
|
|
15
|
+
}),
|
|
16
|
+
endpoints: (builder) => ({
|
|
17
|
+
// QA STREAM
|
|
18
|
+
getQandAStream: builder.query({
|
|
19
|
+
query: ({ storyId, useStaging, commentsAPIUrl, status, }) =>
|
|
20
|
+
// The usage of status is to create different caches (RTK and Fastly) for different statuses
|
|
21
|
+
`${commentsAPIUrl}/story/${storyId}/stream?status=${status}${useStaging ? '&staging=1' : ''}`,
|
|
22
|
+
transformResponse: (response) => {
|
|
23
|
+
return {
|
|
24
|
+
qandas: response?.children || [],
|
|
25
|
+
type: response.type,
|
|
26
|
+
status: response?.status || 'pre-live',
|
|
27
|
+
startDateTime: response?.startDateTime || '',
|
|
28
|
+
endDateTime: response?.endDateTime || '',
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
// UPDATES LISTENER
|
|
33
|
+
getQandAUpdates: builder.query({
|
|
34
|
+
queryFn: async ({ storyId, useStaging, commentsAPIUrl, }) => {
|
|
35
|
+
return { data: {} };
|
|
36
|
+
},
|
|
37
|
+
async onCacheEntryAdded(arg, { updateCachedData, cacheDataLoaded, cacheEntryRemoved }) {
|
|
38
|
+
await cacheDataLoaded;
|
|
39
|
+
const connectToSSE = () => {
|
|
40
|
+
const sseEndpoint = `${arg.commentsAPIUrl}/story/${arg.storyId}/updates${arg.useStaging ? '?staging=1' : ''}`;
|
|
41
|
+
eventSource = new EventSource(sseEndpoint);
|
|
42
|
+
eventSource.onopen = () => {
|
|
43
|
+
console.log(`Connected to SSE: ${sseEndpoint}`);
|
|
44
|
+
};
|
|
45
|
+
eventSource.onmessage = (event) => {
|
|
46
|
+
try {
|
|
47
|
+
const data = JSON.parse(event.data);
|
|
48
|
+
console.log('Received SSE update:', data);
|
|
49
|
+
if (data && data.type) {
|
|
50
|
+
updateCachedData((draft) => {
|
|
51
|
+
const updatedData = updateTypes[data.type];
|
|
52
|
+
draft[updatedData] = draft[updatedData] || [];
|
|
53
|
+
draft[updatedData].push(data.payload);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
console.warn('Failed to parse SSE message:', event.data);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
eventSource.onerror = (event) => {
|
|
62
|
+
const es = event.target;
|
|
63
|
+
if (es.readyState === EventSource.CONNECTING) {
|
|
64
|
+
console.error('SSE connection lost. Attempting to reconnect...');
|
|
65
|
+
}
|
|
66
|
+
else if (es.readyState === EventSource.CLOSED) {
|
|
67
|
+
console.error(`SSE connection closed. Will try to reconnect in ${retryDelay / 1000} seconds...`);
|
|
68
|
+
eventSource?.close();
|
|
69
|
+
eventSource = null;
|
|
70
|
+
setTimeout(() => connectToSSE(), retryDelay);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
console.error('SSE error occurred.', event);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
try {
|
|
78
|
+
connectToSSE();
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
console.error('Failed to subscribe to SSE:', error);
|
|
82
|
+
console.error(`Reconnecting in ${retryDelay / 1000} seconds...`);
|
|
83
|
+
eventSource = null;
|
|
84
|
+
setTimeout(() => connectToSSE(), retryDelay);
|
|
85
|
+
}
|
|
86
|
+
// cleanup when there are no subscribers to the cache anymore
|
|
87
|
+
await cacheEntryRemoved;
|
|
88
|
+
if (eventSource !== null) {
|
|
89
|
+
eventSource.close();
|
|
90
|
+
eventSource = null;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
// USER ROLES
|
|
95
|
+
getUserRoles: builder.query({
|
|
96
|
+
query: ({ storyId, useStaging, commentsAPIUrl, token, }) => ({
|
|
97
|
+
url: `${commentsAPIUrl}/user/user-details/story/${storyId}${useStaging ? '?staging=1' : ''}`,
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: {
|
|
100
|
+
Authorization: `Bearer ${token}`,
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
transformResponse: (response, meta, arg) => {
|
|
104
|
+
const allowedRoles = ['ADMIN', 'MODERATOR'];
|
|
105
|
+
const { isExpert, isStaffExpert, role } = response;
|
|
106
|
+
const expertView = isExpert || isStaffExpert || !!(role && allowedRoles.includes(role));
|
|
107
|
+
return { expertView };
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
// POST A QUESTION
|
|
111
|
+
postQuestion: builder.mutation({
|
|
112
|
+
query: ({ storyId, useStaging, commentsAPIUrl, token, question, postAnonymously, }) => ({
|
|
113
|
+
url: `${commentsAPIUrl}/story/${storyId}/question${useStaging ? '?staging=1' : ''}`,
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: {
|
|
116
|
+
'Content-Type': 'application/json',
|
|
117
|
+
Authorization: `Bearer ${token}`,
|
|
118
|
+
},
|
|
119
|
+
body: JSON.stringify({ question, postAnonymously, token }),
|
|
120
|
+
}),
|
|
121
|
+
transformResponse: (response) => {
|
|
122
|
+
return response.data;
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
}),
|
|
126
|
+
});
|
|
127
|
+
export const { useGetQandAStreamQuery, useGetQandAUpdatesQuery, useGetUserRolesQuery, usePostQuestionMutation, } = nextCommentsApi;
|
|
128
|
+
// UPDATES QUERY CUSTOM HOOKS
|
|
129
|
+
// custom hook for getting just the updated comments from the updates, so we are more efficient
|
|
130
|
+
export const useUpdatedComments = ({ storyId, useStaging, commentsAPIUrl, }) => {
|
|
131
|
+
return nextCommentsApi.useGetQandAUpdatesQuery({ storyId, useStaging, commentsAPIUrl }, {
|
|
132
|
+
selectFromResult: ({ data }) => ({
|
|
133
|
+
updatedComments: data?.updatedComments || [],
|
|
134
|
+
}),
|
|
135
|
+
}).updatedComments;
|
|
136
|
+
};
|
|
137
|
+
// Custom hook to get the updates on the information about the Q&A
|
|
138
|
+
export const useUpdatedQA = ({ storyId, useStaging, commentsAPIUrl, }) => {
|
|
139
|
+
return nextCommentsApi.useGetQandAUpdatesQuery({ storyId, useStaging, commentsAPIUrl }, {
|
|
140
|
+
selectFromResult: ({ data }) => ({
|
|
141
|
+
updatedQA: (data?.updatedQA &&
|
|
142
|
+
data.updatedQA.length &&
|
|
143
|
+
data.updatedQA[data.updatedQA.length - 1]) ||
|
|
144
|
+
{},
|
|
145
|
+
}),
|
|
146
|
+
}).updatedQA;
|
|
147
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { isRejectedWithValue } from '@reduxjs/toolkit';
|
|
2
|
+
import { qandaReliability } from './reliability';
|
|
3
|
+
// Tracking successes for updates may generate a lot of events
|
|
4
|
+
// Disable it when not needed
|
|
5
|
+
const endpointToOperationMap = {
|
|
6
|
+
postQuestion: { operation: 'question-submission', trackSuccess: true },
|
|
7
|
+
getUserRoles: { operation: 'profile-load', trackSuccess: true },
|
|
8
|
+
getQandAStream: { operation: 'stream-load', trackSuccess: true },
|
|
9
|
+
getQandAUpdates: { operation: 'qa-updates', trackSuccess: true },
|
|
10
|
+
};
|
|
11
|
+
export const rtkQueryErrorLogger = () => (next) => (action) => {
|
|
12
|
+
// Log failures
|
|
13
|
+
if (isRejectedWithValue(action)) {
|
|
14
|
+
const rejected = action;
|
|
15
|
+
const isHttpError = typeof rejected.payload?.status === 'number';
|
|
16
|
+
const endpointName = rejected.meta?.arg?.endpointName ?? '';
|
|
17
|
+
const mapEntry = endpointToOperationMap[endpointName];
|
|
18
|
+
const operation = mapEntry?.operation ?? 'unmapped-operation';
|
|
19
|
+
const statusCodeInfo = isHttpError
|
|
20
|
+
? ` with status code ${rejected.payload?.status}`
|
|
21
|
+
: '';
|
|
22
|
+
const errorMessage = rejected.payload?.data?.error ||
|
|
23
|
+
rejected.payload?.error ||
|
|
24
|
+
'Unknown error';
|
|
25
|
+
const rtkNormalisedError = new Error(`Network error${statusCodeInfo}: ${errorMessage}`);
|
|
26
|
+
qandaReliability.failure(operation, rtkNormalisedError);
|
|
27
|
+
}
|
|
28
|
+
// Fullfilled actions
|
|
29
|
+
if (typeof action === 'object' &&
|
|
30
|
+
action !== null &&
|
|
31
|
+
'type' in action &&
|
|
32
|
+
typeof action.type === 'string' &&
|
|
33
|
+
action.type.endsWith('/fulfilled')) {
|
|
34
|
+
const meta = action.meta;
|
|
35
|
+
const endpointName = meta?.arg?.endpointName ?? '';
|
|
36
|
+
const mapEntry = endpointToOperationMap[endpointName];
|
|
37
|
+
// Send success event only if trackSucces is enabled for that particular endpoint
|
|
38
|
+
if (mapEntry?.trackSuccess) {
|
|
39
|
+
qandaReliability.success(mapEntry.operation);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return next(action);
|
|
43
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @param outcome - success or failure
|
|
3
|
+
* @param operation - initialisation or question-submission
|
|
4
|
+
* @returns void
|
|
5
|
+
*
|
|
6
|
+
* This class is used to dispatch events to the client-metrics-web library
|
|
7
|
+
* for tracking the success or failure of operations.
|
|
8
|
+
* The class checks if the client-metrics-web library is available, if not it will log warnings
|
|
9
|
+
*
|
|
10
|
+
* For failures, it will log errors for debugging purposes
|
|
11
|
+
*/
|
|
12
|
+
class Reliability {
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.testOnly = options.testOnly || false;
|
|
15
|
+
this.topLevelNamespace = options.topLevelNamespace;
|
|
16
|
+
this.articleId = '';
|
|
17
|
+
}
|
|
18
|
+
setArticleId(articleId) {
|
|
19
|
+
this.articleId = articleId;
|
|
20
|
+
}
|
|
21
|
+
dispatch(outcome, operation, error = null) {
|
|
22
|
+
if (!this.articleId) {
|
|
23
|
+
console.warn('(Qanda Reliability) Article ID not set. Please call setArticleId() before dispatching events.');
|
|
24
|
+
}
|
|
25
|
+
const eventNamespace = `${this.topLevelNamespace}.${operation}.${outcome}`;
|
|
26
|
+
if (this.testOnly) {
|
|
27
|
+
console.warn(`(Reliability) Event not sent: client-metrics-web not available %c${eventNamespace}`, 'font-weight: bold;');
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
window.dispatchEvent(new CustomEvent('ft.clientMetric', {
|
|
31
|
+
detail: {
|
|
32
|
+
namespace: eventNamespace,
|
|
33
|
+
operation,
|
|
34
|
+
articleId: this.articleId,
|
|
35
|
+
...(error && {
|
|
36
|
+
error: {
|
|
37
|
+
name: error?.name,
|
|
38
|
+
message: error?.message,
|
|
39
|
+
stack: error?.stack,
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
normaliseError(error) {
|
|
47
|
+
let normalisedError;
|
|
48
|
+
if (error instanceof Error) {
|
|
49
|
+
normalisedError = error;
|
|
50
|
+
}
|
|
51
|
+
else if (typeof error === 'string') {
|
|
52
|
+
normalisedError = new Error(error);
|
|
53
|
+
}
|
|
54
|
+
else if (typeof error === 'object' && error !== null) {
|
|
55
|
+
normalisedError = new Error(JSON.stringify(error));
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
normalisedError = new Error('Unknown error type: ' + JSON.stringify(error));
|
|
59
|
+
}
|
|
60
|
+
return normalisedError;
|
|
61
|
+
}
|
|
62
|
+
failure(operation, error) {
|
|
63
|
+
// Dispatch a metric for failure and log the error
|
|
64
|
+
this.dispatch('failure', operation, this.normaliseError(error));
|
|
65
|
+
}
|
|
66
|
+
success(operation) {
|
|
67
|
+
this.dispatch('success', operation);
|
|
68
|
+
}
|
|
69
|
+
// Use this method to test the error handling
|
|
70
|
+
throw(error = 'Throwing an error for testing purposes') {
|
|
71
|
+
throw new Error(error);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const qandaReliability = new Reliability({ topLevelNamespace: 'live-qanda' });
|
|
75
|
+
export { qandaReliability, Reliability };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createSlice, } from '@reduxjs/toolkit';
|
|
2
|
+
const initialState = {
|
|
3
|
+
isExpertFormOpen: false,
|
|
4
|
+
expertView: undefined,
|
|
5
|
+
};
|
|
6
|
+
export const expertSlice = createSlice({
|
|
7
|
+
name: 'expert',
|
|
8
|
+
initialState,
|
|
9
|
+
reducers: {
|
|
10
|
+
openExpertForm: (state) => {
|
|
11
|
+
state.isExpertFormOpen = true;
|
|
12
|
+
},
|
|
13
|
+
closeExpertForm: (state) => {
|
|
14
|
+
state.isExpertFormOpen = false;
|
|
15
|
+
},
|
|
16
|
+
setExpertView: (state, action) => {
|
|
17
|
+
state.expertView = action.payload;
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
export const { openExpertForm, closeExpertForm, setExpertView } = expertSlice.actions;
|
|
22
|
+
export default expertSlice.reducer;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { configureStore, combineReducers, } from '@reduxjs/toolkit';
|
|
2
|
+
import { setupListeners } from '@reduxjs/toolkit/query';
|
|
3
|
+
import questionReducer from './questionSlice';
|
|
4
|
+
import { stream } from './stream';
|
|
5
|
+
import { user } from './user';
|
|
6
|
+
import expertReducer from './expertSlice';
|
|
7
|
+
import { nextCommentsApi } from '../services/comments-api';
|
|
8
|
+
import { rtkQueryErrorLogger } from '../services/error-middleware';
|
|
9
|
+
export const rootReducer = combineReducers({
|
|
10
|
+
stream: stream.reducer,
|
|
11
|
+
question: questionReducer,
|
|
12
|
+
user: user.reducer,
|
|
13
|
+
expert: expertReducer,
|
|
14
|
+
nextCommentsApi: nextCommentsApi.reducer,
|
|
15
|
+
});
|
|
16
|
+
export const store = configureStore({
|
|
17
|
+
reducer: rootReducer,
|
|
18
|
+
middleware: (getDefaultMiddleware) => getDefaultMiddleware()
|
|
19
|
+
.concat(nextCommentsApi.middleware)
|
|
20
|
+
.concat(rtkQueryErrorLogger),
|
|
21
|
+
});
|
|
22
|
+
// optional, but required for refetchOnFocus/refetchOnReconnect behaviors
|
|
23
|
+
// see `setupListeners` docs - takes an optional callback as the 2nd arg for customization
|
|
24
|
+
setupListeners(store.dispatch);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createSlice, } from '@reduxjs/toolkit';
|
|
2
|
+
const initialState = {
|
|
3
|
+
isFormOpen: false,
|
|
4
|
+
question: '',
|
|
5
|
+
displayNameError: null,
|
|
6
|
+
};
|
|
7
|
+
export const questionSlice = createSlice({
|
|
8
|
+
name: 'question',
|
|
9
|
+
initialState,
|
|
10
|
+
reducers: {
|
|
11
|
+
openForm: (state) => {
|
|
12
|
+
state.isFormOpen = true;
|
|
13
|
+
},
|
|
14
|
+
closeForm: (state) => {
|
|
15
|
+
state.isFormOpen = false;
|
|
16
|
+
state.question = '';
|
|
17
|
+
state.displayNameError = null;
|
|
18
|
+
},
|
|
19
|
+
updateQuestion: (state, action) => {
|
|
20
|
+
state.question = action.payload;
|
|
21
|
+
},
|
|
22
|
+
updateDisplayNameError: (state, action) => {
|
|
23
|
+
state.displayNameError = action.payload;
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
export const { openForm, closeForm, updateQuestion, updateDisplayNameError } = questionSlice.actions;
|
|
28
|
+
export default questionSlice.reducer;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createSlice } from '@reduxjs/toolkit';
|
|
2
|
+
export const initialState = {
|
|
3
|
+
latestAnsweredQuestionId: '',
|
|
4
|
+
};
|
|
5
|
+
export const stream = createSlice({
|
|
6
|
+
name: 'stream',
|
|
7
|
+
initialState,
|
|
8
|
+
reducers: {
|
|
9
|
+
setLatestAnsweredQuestionId: (state, action) => {
|
|
10
|
+
state.latestAnsweredQuestionId = action.payload;
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
export const { setLatestAnsweredQuestionId } = stream.actions;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// PURPOSE: move the comments saved in updatedComments into the main comments array for the stream and then clear out the updatedComments array, and update the lastAnsweredQuestionId in the store
|
|
2
|
+
import { nextCommentsApi } from '../services/comments-api';
|
|
3
|
+
import { setLatestAnsweredQuestionId } from '../store/stream';
|
|
4
|
+
import { getLatestAnsweredQuestion } from '../utils/qandas';
|
|
5
|
+
export const updateComments = (dispatch, updatedComments, storyId, useStaging, commentsAPIUrl) => {
|
|
6
|
+
if (!updatedComments.length) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
const latestAnsweredQuestionId = getLatestAnsweredQuestion(updatedComments);
|
|
10
|
+
try {
|
|
11
|
+
// Update the cache with the new comments
|
|
12
|
+
dispatch(nextCommentsApi.util.updateQueryData('getQandAStream',
|
|
13
|
+
// Updates happen only for the live stream, so we pass the status as 'live' hardcoded
|
|
14
|
+
{ storyId, useStaging, commentsAPIUrl, status: 'live' }, (draft) => {
|
|
15
|
+
// NB - if the same comment is updated more than once, the most recent thing added to the array will be the one that is shown
|
|
16
|
+
for (let c = 0; c < updatedComments.length; c++) {
|
|
17
|
+
const commentId = updatedComments[c].id;
|
|
18
|
+
const commentIndex = draft.qandas.findIndex((d) => d.id === commentId);
|
|
19
|
+
if (commentIndex > -1) {
|
|
20
|
+
draft.qandas[commentIndex] = updatedComments[c];
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
draft.qandas.unshift(updatedComments[c]);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}));
|
|
27
|
+
// Clear out the updated comments
|
|
28
|
+
dispatch(nextCommentsApi.util.updateQueryData('getQandAUpdates', { storyId, useStaging, commentsAPIUrl }, (draft) => {
|
|
29
|
+
draft.updatedComments = [];
|
|
30
|
+
}));
|
|
31
|
+
dispatch(setLatestAnsweredQuestionId(latestAnsweredQuestionId));
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
console.log('error', error);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// this will get the user token and display name from oComments library and save it to the redux store
|
|
2
|
+
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
|
3
|
+
import auth from '../utils/auth';
|
|
4
|
+
const ACTION_ROOT = 'user';
|
|
5
|
+
const ACTION_GET_USER_PROFILE = `${ACTION_ROOT}/profile`;
|
|
6
|
+
const initialState = {
|
|
7
|
+
profile: {
|
|
8
|
+
token: '',
|
|
9
|
+
displayName: '',
|
|
10
|
+
isRegistered: false,
|
|
11
|
+
isTrialist: false,
|
|
12
|
+
isSubscribed: false,
|
|
13
|
+
},
|
|
14
|
+
loading: false,
|
|
15
|
+
error: '',
|
|
16
|
+
};
|
|
17
|
+
export const getUserProfile = createAsyncThunk(ACTION_GET_USER_PROFILE, async ({ useStaging, displayName = '', commentsAPIUrl }) => {
|
|
18
|
+
const userDetails = await auth({
|
|
19
|
+
useStaging,
|
|
20
|
+
displayName,
|
|
21
|
+
commentsAPIUrl,
|
|
22
|
+
});
|
|
23
|
+
return userDetails;
|
|
24
|
+
});
|
|
25
|
+
export const user = createSlice({
|
|
26
|
+
name: 'user',
|
|
27
|
+
initialState,
|
|
28
|
+
reducers: {}, // No local reducers in this case
|
|
29
|
+
extraReducers: (builder) => {
|
|
30
|
+
builder
|
|
31
|
+
.addCase(getUserProfile.pending, (state) => {
|
|
32
|
+
state.loading = true;
|
|
33
|
+
})
|
|
34
|
+
.addCase(getUserProfile.fulfilled, (state, action) => {
|
|
35
|
+
state.loading = false;
|
|
36
|
+
state.profile = action.payload;
|
|
37
|
+
state.error = '';
|
|
38
|
+
})
|
|
39
|
+
.addCase(getUserProfile.rejected, (state) => {
|
|
40
|
+
state.loading = false;
|
|
41
|
+
state.error =
|
|
42
|
+
'We could not retrieve your profile. Please try again later.';
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { auth } from '@financial-times/o-comments';
|
|
2
|
+
const PROD_AUTH_URL = 'https://comments-api.ft.com';
|
|
3
|
+
const TEST_AUTH_URL = 'https://comments-api-test.ft.com';
|
|
4
|
+
export default async ({ useStaging = true, displayName = '', commentsAPIUrl = PROD_AUTH_URL, }) => {
|
|
5
|
+
const userDetails = await auth.fetchJsonWebToken({
|
|
6
|
+
useStagingEnvironment: useStaging,
|
|
7
|
+
onlySubscribers: true,
|
|
8
|
+
displayName,
|
|
9
|
+
commentsAPIUrl: commentsAPIUrl === PROD_AUTH_URL ? PROD_AUTH_URL : TEST_AUTH_URL,
|
|
10
|
+
});
|
|
11
|
+
// if the JWT creation has failed, a `userHasValidSession` value will be returned
|
|
12
|
+
// our application assumes the values of a JWT are saved to state, so we set the
|
|
13
|
+
// default values
|
|
14
|
+
if (userDetails.userHasValidSession) {
|
|
15
|
+
return {
|
|
16
|
+
token: '',
|
|
17
|
+
displayName: '',
|
|
18
|
+
isSubscribed: userDetails.userHasValidSession,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
return userDetails;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Returns the question UUID with the latest answer
|
|
2
|
+
// eslint-disable-next-line import/prefer-default-export
|
|
3
|
+
export const getLatestAnsweredQuestion = (questions) => {
|
|
4
|
+
let questionId = '';
|
|
5
|
+
let latestTimestamp = 0;
|
|
6
|
+
questions.forEach((question) => {
|
|
7
|
+
question.children?.forEach((answer) => {
|
|
8
|
+
const answerTimestamp = new Date(answer.publishedDate).getTime();
|
|
9
|
+
if (answerTimestamp > latestTimestamp) {
|
|
10
|
+
questionId = question.id;
|
|
11
|
+
latestTimestamp = answerTimestamp;
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
return questionId;
|
|
16
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Remove special characters and replace spaces with underscores
|
|
3
|
+
* @param {string} str - The input string to be cleaned
|
|
4
|
+
* @returns {string} - The cleaned string with special characters removed and spaces replaced with underscores
|
|
5
|
+
*/
|
|
6
|
+
export function cleanString(str) {
|
|
7
|
+
return str
|
|
8
|
+
.normalize('NFD') // Decompose accented characters
|
|
9
|
+
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
|
|
10
|
+
.replace(/[^a-zA-Z0-9 ]/g, '') // Remove non-ASCII letters, numbers, and spaces
|
|
11
|
+
.trim() // Trim leading/trailing spaces
|
|
12
|
+
.replace(/\s+/g, '_'); // Replace spaces with underscores
|
|
13
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export default function validateDisplayName(displayName) {
|
|
2
|
+
/*
|
|
3
|
+
* Allowed Characters
|
|
4
|
+
* The below regex matches any character not in the allowed characters set
|
|
5
|
+
* All allowed characters are case insensitive
|
|
6
|
+
* Anything in the range A-Z or 0-9 is allowed
|
|
7
|
+
* Any of the below special characters are allowed
|
|
8
|
+
* !#$%'`()*+,-./:;=@[]^_{|}
|
|
9
|
+
* Spaces are allowed
|
|
10
|
+
*/
|
|
11
|
+
const matchInvalidCharacters = /[^0-9a-z!#$%'`()*+,\-.\/:;=@[\]\^_{}\|\s]/gi;
|
|
12
|
+
const matchingCharacters = displayName.match(matchInvalidCharacters);
|
|
13
|
+
const uniqueMatchingCharacters = matchingCharacters && matchingCharacters.length
|
|
14
|
+
? matchingCharacters.filter((character, position) => matchingCharacters.indexOf(character) === position)
|
|
15
|
+
: [];
|
|
16
|
+
return uniqueMatchingCharacters.length
|
|
17
|
+
? uniqueMatchingCharacters.join('')
|
|
18
|
+
: false;
|
|
19
|
+
}
|