Compare commits

..

2 Commits

Author SHA1 Message Date
obscherler
e8e5337b7b fix: Update frontend-platform to version 8.3.8 for fixes with theme URL loading. (#825)
8.3.7: Simplify the logic for Paragon `fallbackThemeUrl` to always rely on `window.location?.origin`;
8.3.8: Allow the creation of URLs with only `brandOverride` definition.
2025-11-06 09:50:20 -05:00
Brian Smith
88bf980546 feat!: add design tokens support (#777)
BREAKING CHANGE: Pre-design-tokens theming is no longer supported.

Co-authored-by: Diana Olarte <dcoa@live.com>
2025-07-30 12:43:11 -05:00
27 changed files with 56 additions and 763 deletions

34
package-lock.json generated
View File

@@ -12,7 +12,7 @@
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-platform": "^8.3.3",
"@edx/frontend-platform": "^8.3.8",
"@edx/openedx-atlas": "^0.6.0",
"@openedx/paragon": "^23.4.5",
"@reduxjs/toolkit": "1.9.7",
@@ -27,7 +27,6 @@
"raw-loader": "4.0.2",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-google-recaptcha": "^3.1.0",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "6.18.0",
@@ -2386,9 +2385,9 @@
}
},
"node_modules/@edx/frontend-platform": {
"version": "8.3.7",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-8.3.7.tgz",
"integrity": "sha512-ya5ObMvtJlfQmoeL36OtzjFBh0hzJgXN/R2ppyIJ+IbCtY2BCfv5NqvmKD7CplwnSGJTBugpv5hQHeGmi+v97w==",
"version": "8.3.8",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-8.3.8.tgz",
"integrity": "sha512-wr3HKzDcYGNuHcM7HuZ/mqBdqnY/A7eYa3JDVZEsceyjy0PJyVKHWFu3yneGpD1zufguQCvS0q53C8gJbVzIgw==",
"license": "AGPL-3.0",
"dependencies": {
"@cospired/i18n-iso-languages": "4.2.0",
@@ -2462,7 +2461,6 @@
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@@ -21631,18 +21629,6 @@
"node": ">=0.10.0"
}
},
"node_modules/react-async-script": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz",
"integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==",
"dependencies": {
"hoist-non-react-statics": "^3.3.0",
"prop-types": "^15.5.0"
},
"peerDependencies": {
"react": ">=16.4.1"
}
},
"node_modules/react-bootstrap": {
"version": "1.6.8",
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.6.8.tgz",
@@ -21900,18 +21886,6 @@
}
}
},
"node_modules/react-google-recaptcha": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz",
"integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==",
"dependencies": {
"prop-types": "^15.5.0",
"react-async-script": "^1.2.0"
},
"peerDependencies": {
"react": ">=16.4.1"
}
},
"node_modules/react-helmet": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz",

View File

@@ -32,7 +32,7 @@
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-platform": "^8.3.3",
"@edx/frontend-platform": "^8.3.8",
"@edx/openedx-atlas": "^0.6.0",
"@openedx/paragon": "^23.4.5",
"@reduxjs/toolkit": "1.9.7",
@@ -47,7 +47,6 @@
"raw-loader": "4.0.2",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-google-recaptcha": "^3.1.0",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "6.18.0",

View File

@@ -224,6 +224,10 @@ export const useUserPostingEnabled = () => {
return (isPostingEnabled || isPrivileged);
};
function camelToConstant(string) {
return string.replace(/[A-Z]/g, (match) => `_${match}`).toUpperCase();
}
export const useTourConfiguration = () => {
const intl = useIntl();
const dispatch = useDispatch();
@@ -248,7 +252,7 @@ export const useTourConfiguration = () => {
enabled: tour && Boolean(tour.enabled && tour.showTour && !enableInContextSidebar),
onDismiss: () => handleOnDismiss(tour.id),
onEnd: () => handleOnEnd(tour.id),
checkpoints: tourCheckpoints(intl)[tour.tourName],
checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)],
}
))
), [tours, enableInContextSidebar]);

View File

@@ -31,10 +31,6 @@ export const selectEnableInContext = state => state.config.enableInContext;
export const selectIsPostingEnabled = state => state.config.isPostingEnabled;
export const selectIsNotifyAllLearnersEnabled = state => state.config.isNotifyAllLearnersEnabled;
export const selectCaptchaSettings = state => state.config.captchaSettings;
export const selectModerationSettings = state => ({
postCloseReasons: state.config.postCloseReasons,
editReasons: state.config.editReasons,

View File

@@ -22,10 +22,6 @@ const configSlice = createSlice({
dividedInlineDiscussions: [],
dividedCourseWideDiscussions: [],
},
captchaSettings: {
enabled: false,
siteKey: '',
},
editReasons: [],
postCloseReasons: [],
enableInContext: false,

View File

@@ -12,7 +12,3 @@ export const selectUsernameSearch = () => state => state.learners.usernameSearch
export const selectLearnerSorting = () => state => state.learners.sortedBy;
export const selectLearnerNextPage = () => state => state.learners.nextPage;
export const selectLearnerAvatar = author => state => (
state.learners.learnerProfiles[author]?.profileImage?.imageUrlLarge
);

View File

@@ -2,26 +2,19 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Avatar } from '@openedx/paragon';
import { useSelector } from 'react-redux';
import { selectLearnerAvatar } from '../data/selectors';
const LearnerAvatar = ({ username }) => {
const learnerAvatar = useSelector(selectLearnerAvatar(username));
return (
<div className="mr-3 mt-1">
<Avatar
size="sm"
alt={username}
src={learnerAvatar}
style={{
height: '2rem',
width: '2rem',
}}
/>
</div>
);
};
const LearnerAvatar = ({ username }) => (
<div className="mr-3 mt-1">
<Avatar
size="sm"
alt={username}
style={{
height: '2rem',
width: '2rem',
}}
/>
</div>
);
LearnerAvatar.propTypes = {
username: PropTypes.string.isRequired,

View File

@@ -1,5 +1,3 @@
import React, { useRef } from 'react';
import {
act, fireEvent, render, screen, waitFor, within,
} from '@testing-library/react';
@@ -25,12 +23,6 @@ import fetchCourseConfig from '../data/thunks';
import DiscussionContent from '../discussions-home/DiscussionContent';
import { getThreadsApiUrl } from '../posts/data/api';
import { fetchThread, fetchThreads } from '../posts/data/thunks';
import MockReCAPTCHA, {
mockOnChange,
mockOnError,
mockOnExpired,
mockReset,
} from '../posts/post-editor/mocksData/react-google-recaptcha';
import fetchCourseTopics from '../topics/data/thunks';
import { getDiscussionTourUrl } from '../tours/data/api';
import selectTours from '../tours/data/selectors';
@@ -59,8 +51,6 @@ let testLocation;
let container;
let unmount;
jest.mock('react-google-recaptcha', () => MockReCAPTCHA);
async function mockAxiosReturnPagedComments(threadId, threadType = ThreadType.DISCUSSION, page = 1, count = 2) {
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult', { can_delete: true }, {
threadId,
@@ -225,13 +215,7 @@ describe('ThreadView', () => {
endorsed: false,
})];
});
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
isPostingEnabled: true,
captchaSettings: {
enabled: true,
siteKey: 'test-key',
},
});
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { isPostingEnabled: true });
window.HTMLElement.prototype.scrollIntoView = jest.fn();
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
@@ -308,29 +292,6 @@ describe('ThreadView', () => {
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
});
it('should allow posting a comment with CAPTCHA', async () => {
await waitFor(() => renderComponent(discussionPostId));
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
await act(async () => { fireEvent.click(within(hoverCard).getByRole('button', { name: /Add comment/i })); });
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'New comment with CAPTCHA' } }); });
await act(async () => { fireEvent.click(screen.getByText('Solve CAPTCHA')); });
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
await waitFor(() => {
expect(axiosMock.history.post).toHaveLength(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toMatchObject({
captcha_token: 'm',
enable_in_context_sidebar: false,
parent_id: 'comment-1',
raw_body: 'New comment with CAPTCHA',
thread_id: 'thread-1',
});
expect(mockOnChange).toHaveBeenCalled();
});
});
it('should allow posting a comment', async () => {
await waitFor(() => renderComponent(discussionPostId));
@@ -341,6 +302,7 @@ describe('ThreadView', () => {
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); });
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
await waitFor(async () => expect(await screen.findByTestId('comment-1')).toBeInTheDocument());
});
@@ -361,6 +323,7 @@ describe('ThreadView', () => {
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); });
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
await waitFor(async () => expect(await screen.findByTestId('reply-comment-2')).toBeInTheDocument());
});
@@ -618,42 +581,6 @@ describe('ThreadView', () => {
describe('for discussion thread', () => {
const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
it('renders the mocked ReCAPTCHA.', async () => {
await waitFor(() => renderComponent(discussionPostId));
await act(async () => {
fireEvent.click(screen.queryByText('Add comment'));
});
expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument();
});
it('successfully calls onTokenChange when Solve CAPTCHA button is clicked', async () => {
await waitFor(() => renderComponent(discussionPostId));
await act(async () => {
fireEvent.click(screen.queryByText('Add comment'));
});
const solveButton = screen.getByText('Solve CAPTCHA');
fireEvent.click(solveButton);
expect(mockOnChange).toHaveBeenCalled();
});
it('successfully calls onExpired handler when CAPTCHA expires', async () => {
await waitFor(() => renderComponent(discussionPostId));
await act(async () => {
fireEvent.click(screen.queryByText('Add comment'));
});
fireEvent.click(screen.getByText('Expire CAPTCHA'));
expect(mockOnExpired).toHaveBeenCalled();
});
it('successfully calls onError handler when CAPTCHA errors', async () => {
await waitFor(() => renderComponent(discussionPostId));
await act(async () => {
fireEvent.click(screen.queryByText('Add comment'));
});
fireEvent.click(screen.getByText('Error CAPTCHA'));
expect(mockOnError).toHaveBeenCalled();
});
it('shown post not found when post id does not belong to course', async () => {
await waitFor(() => renderComponent('unloaded-id'));
expect(await screen.findByText('Thread not found', { exact: true }))
@@ -822,7 +749,7 @@ describe('ThreadView', () => {
fireEvent.click(screen.queryAllByText('Add comment')[0]);
});
expect(screen.queryByTestId('tinymce-editor').value).toBe('Draft comment 123!');
expect(screen.queryByTestId('tinymce-editor').value).toBe('');
});
it('successfully added response in the draft.', async () => {
@@ -866,7 +793,7 @@ describe('ThreadView', () => {
fireEvent.click(screen.queryByText('Add response'));
});
expect(screen.queryByTestId('tinymce-editor').value).toBe('Draft Response!');
expect(screen.queryByTestId('tinymce-editor').value).toBe('');
});
it('successfully maintain response for the specific post in the draft.', async () => {
@@ -1048,61 +975,3 @@ describe('ThreadView', () => {
});
});
});
describe('MockReCAPTCHA', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('uses defaultProps when props are not provided', () => {
render(<MockReCAPTCHA />);
expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument();
fireEvent.click(screen.getByText('Solve CAPTCHA'));
fireEvent.click(screen.getByText('Expire CAPTCHA'));
fireEvent.click(screen.getByText('Error CAPTCHA'));
expect(mockOnChange).toHaveBeenCalled();
expect(mockOnExpired).toHaveBeenCalled();
expect(mockOnError).toHaveBeenCalled();
});
it('triggers all callbacks and exposes reset via ref', () => {
const onChange = jest.fn();
const onExpired = jest.fn();
const onError = jest.fn();
const Wrapper = () => {
const recaptchaRef = useRef(null);
return (
<div>
<MockReCAPTCHA
ref={recaptchaRef}
onChange={onChange}
onExpired={onExpired}
onError={onError}
/>
<button onClick={() => recaptchaRef.current.reset()} data-testid="reset-btn" type="button">Reset</button>
</div>
);
};
const { getByText, getByTestId } = render(<Wrapper />);
fireEvent.click(getByText('Solve CAPTCHA'));
fireEvent.click(getByText('Expire CAPTCHA'));
fireEvent.click(getByText('Error CAPTCHA'));
fireEvent.click(getByTestId('reset-btn'));
expect(mockOnChange).toHaveBeenCalled();
expect(mockOnExpired).toHaveBeenCalled();
expect(mockOnError).toHaveBeenCalled();
expect(onChange).toHaveBeenCalledWith('mock-token');
expect(onExpired).toHaveBeenCalled();
expect(onError).toHaveBeenCalled();
expect(mockReset).toHaveBeenCalled();
});
});

View File

@@ -5,7 +5,6 @@ import PropTypes from 'prop-types';
import { Button, Form, StatefulButton } from '@openedx/paragon';
import { Formik } from 'formik';
import ReCAPTCHA from 'react-google-recaptcha';
import { useSelector } from 'react-redux';
import * as Yup from 'yup';
@@ -18,7 +17,6 @@ import PostPreviewPanel from '../../../../components/PostPreviewPanel';
import useDispatchWithState from '../../../../data/hooks';
import DiscussionContext from '../../../common/context';
import {
selectCaptchaSettings,
selectModerationSettings,
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
@@ -42,7 +40,6 @@ const CommentEditor = ({
const intl = useIntl();
const editorRef = useRef(null);
const formRef = useRef(null);
const recaptchaRef = useRef(null);
const { authenticatedUser } = useContext(AppContext);
const { enableInContextSidebar } = useContext(DiscussionContext);
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
@@ -52,13 +49,6 @@ const CommentEditor = ({
const [submitting, dispatch] = useDispatchWithState();
const [editorContent, setEditorContent] = useState();
const { addDraftContent, getDraftContent, removeDraftContent } = useDraftContent();
const captchaSettings = useSelector(selectCaptchaSettings);
const shouldRequireCaptcha = !id && captchaSettings.enabled;
const captchaValidation = {
recaptchaToken: Yup.string().required(intl.formatMessage(messages.captchaVerificationLabel)),
};
const canDisplayEditReason = (edit
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
@@ -72,31 +62,16 @@ const CommentEditor = ({
const validationSchema = Yup.object().shape({
comment: Yup.string()
.required(),
...(shouldRequireCaptcha ? { recaptchaToken: Yup.string().required() } : { }),
...editReasonCodeValidation,
...(shouldRequireCaptcha ? captchaValidation : {}),
});
const initialValues = {
comment: editorContent,
editReasonCode: lastEdit?.reasonCode || (userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined),
recaptchaToken: '',
};
const handleCaptchaChange = useCallback((token, setFieldValue) => {
setFieldValue('recaptchaToken', token || '');
}, []);
const handleCaptchaExpired = useCallback((setFieldValue) => {
setFieldValue('recaptchaToken', '');
}, []);
const handleCloseEditor = useCallback((resetForm) => {
resetForm({ values: initialValues });
// Reset CAPTCHA when hiding editor
if (recaptchaRef.current) {
recaptchaRef.current.reset();
}
onCloseEditor();
}, [onCloseEditor, initialValues]);
@@ -117,7 +92,7 @@ const CommentEditor = ({
};
await dispatch(editComment(id, payload));
} else {
await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar, shouldRequireCaptcha ? values.recaptchaToken : ''));
await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar));
}
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
if (editorRef.current) {
@@ -125,7 +100,7 @@ const CommentEditor = ({
}
handleCloseEditor(resetForm);
deleteEditorContent();
}, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor, shouldRequireCaptcha]);
}, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor]);
// The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to
// the current comment id, or the current comment parent or the curren thread.
const editorId = `comment-editor-${id || parentId || threadId}`;
@@ -172,7 +147,6 @@ const CommentEditor = ({
handleBlur,
handleChange,
resetForm,
setFieldValue,
}) => (
<Form onSubmit={handleSubmit} className={formClasses} ref={formRef}>
{canDisplayEditReason && (
@@ -228,32 +202,6 @@ const CommentEditor = ({
</Form.Control.Feedback>
)}
<PostPreviewPanel htmlNode={values.comment} />
{/* CAPTCHA Section - Only show for new posts from non-staff users */}
{ shouldRequireCaptcha && captchaSettings.siteKey && (
<div className="mb-3">
<Form.Group
isInvalid={isFormikFieldInvalid('recaptchaToken', {
errors,
touched,
})}
>
<Form.Label className="h6">
{intl.formatMessage(messages.verifyHumanLabel)}
</Form.Label>
<div className="d-flex justify-content-start">
<ReCAPTCHA
ref={recaptchaRef}
sitekey={captchaSettings.siteKey}
onChange={(token) => handleCaptchaChange(token, setFieldValue)}
onExpired={() => handleCaptchaExpired(setFieldValue)}
onError={() => handleCaptchaExpired(setFieldValue)}
/>
</div>
<FormikErrorFeedback name="recaptchaToken" />
</Form.Group>
</div>
) }
<div className="d-flex py-2 justify-content-end">
<Button
variant="outline-primary"

View File

@@ -3,12 +3,10 @@ import PropTypes from 'prop-types';
import { Avatar } from '@openedx/paragon';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { AvatarOutlineAndLabelColors } from '../../../../data/constants';
import { AuthorLabel } from '../../../common';
import { useAlertBannerVisible } from '../../../data/hooks';
import { selectAuthorAvatar } from '../../../posts/data/selectors';
const CommentHeader = ({
author,
@@ -25,7 +23,6 @@ const CommentHeader = ({
lastEdit,
closed,
});
const authorAvatar = useSelector(selectAuthorAvatar(author));
return (
<div className={classNames('d-flex flex-row justify-content-between', {
@@ -36,7 +33,6 @@ const CommentHeader = ({
<Avatar
className={`border-0 ml-0.5 mr-2.5 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
alt={author}
src={authorAvatar?.imageUrlSmall}
style={{
width: '32px',
height: '32px',

View File

@@ -15,7 +15,6 @@ import {
import timeLocale from '../../../common/time-locale';
import { ContentTypes } from '../../../data/constants';
import { useAlertBannerVisible } from '../../../data/hooks';
import { selectAuthorAvatar } from '../../../posts/data/selectors';
import { selectCommentOrResponseById } from '../../data/selectors';
import { editComment, removeComment } from '../../data/thunks';
import messages from '../../messages';
@@ -39,7 +38,6 @@ const Reply = ({ responseId }) => {
lastEdit,
closed,
});
const authorAvatar = useSelector(selectAuthorAvatar(author));
const handleDeleteConfirmation = useCallback(() => {
dispatch(removeComment(id));
@@ -123,7 +121,6 @@ const Reply = ({ responseId }) => {
<Avatar
className={`ml-0.5 mt-0.5 border-0 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
alt={author}
src={authorAvatar?.imageUrlSmall}
style={{
width: '32px',
height: '32px',

View File

@@ -73,16 +73,10 @@ export const getCommentResponses = async (commentId, {
* @param {boolean} enableInContextSidebar
* @returns {Promise<{}>}
*/
export const postComment = async (
comment,
threadId,
parentId,
enableInContextSidebar,
recaptchaToken,
) => {
export const postComment = async (comment, threadId, parentId = null, enableInContextSidebar = false) => {
const { data } = await getAuthenticatedHttpClient()
.post(getCommentsApiUrl(), snakeCaseObject({
threadId, raw_body: comment, parentId, enableInContextSidebar, captchaToken: recaptchaToken,
threadId, raw_body: comment, parentId, enableInContextSidebar,
}));
return data;
};

View File

@@ -39,7 +39,7 @@ describe('Post comments view api tests', () => {
axiosMock.reset();
});
it('successfully get thread comments', async () => {
test('successfully get thread comments', async () => {
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
await executeThunk(fetchThreadComments(threadId, { endorsed: 'discussion' }), store.dispatch, store.getState);
@@ -92,25 +92,22 @@ describe('Post comments view api tests', () => {
thread_id: threadId,
raw_body: content,
rendered_body: content,
parent_id: 'parent_id',
enable_in_context_sidebar: true,
captcha_token: 'recaptcha-token',
}));
await executeThunk(addComment(content, threadId, 'parent_id', true, 'recaptcha-token'), store.dispatch, store.getState);
await executeThunk(addComment(content, threadId, null), store.dispatch, store.getState);
expect(store.getState().comments.postStatus).toEqual('successful');
});
it('failed to add comment', async () => {
axiosMock.onPost(commentsApiUrl).reply(404);
await executeThunk(addComment(content, threadId, 'parent_id', false, 'recaptcha-token'), store.dispatch, store.getState);
await executeThunk(addComment(content, threadId, null), store.dispatch, store.getState);
expect(store.getState().comments.postStatus).toEqual('failed');
});
it('denied to add comment', async () => {
axiosMock.onPost(commentsApiUrl).reply(403, {});
await executeThunk(addComment(content, threadId, 'parent_id', false, 'recaptcha-token'), store.dispatch, store.getState);
await executeThunk(addComment(content, threadId, null), store.dispatch, store.getState);
expect(store.getState().comments.postStatus).toEqual('denied');
});
@@ -167,76 +164,4 @@ describe('Post comments view api tests', () => {
expect(store.getState().comments.postStatus).toEqual('denied');
});
it('successfully added comment with default parentId', async () => {
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
await executeThunk(fetchThreadComments(threadId), store.dispatch, store.getState);
axiosMock.onPost(commentsApiUrl).reply(200, Factory.build('comment', {
thread_id: threadId,
raw_body: content,
rendered_body: content,
parent_id: null, // Explicitly expect null in response
}));
await executeThunk(addComment(content, threadId, null, false, ''), store.dispatch, store.getState);
expect(store.getState().comments.postStatus).toEqual('successful');
});
it('successfully added comment with explicit enableInContextSidebar false', async () => {
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
await executeThunk(fetchThreadComments(threadId), store.dispatch, store.getState);
axiosMock.onPost(commentsApiUrl).reply(200, Factory.build('comment', {
thread_id: threadId,
raw_body: content,
rendered_body: content,
}));
await executeThunk(addComment(content, threadId, null, false, 'recaptcha-token'), store.dispatch, store.getState);
expect(store.getState().comments.postStatus).toEqual('successful');
});
it('successfully added comment with empty recaptchaToken', async () => {
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
await executeThunk(fetchThreadComments(threadId), store.dispatch, store.getState);
axiosMock.onPost(commentsApiUrl).reply(200, Factory.build('comment', {
thread_id: threadId,
raw_body: content,
rendered_body: content,
}));
await executeThunk(addComment(content, threadId, null, true, ''), store.dispatch, store.getState);
expect(store.getState().comments.postStatus).toEqual('successful');
});
it('successfully added comment with undefined parentId', async () => {
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
await executeThunk(fetchThreadComments(threadId), store.dispatch, store.getState);
axiosMock.onPost(commentsApiUrl).reply(200, Factory.build('comment', {
thread_id: threadId,
raw_body: content,
rendered_body: content,
parent_id: null, // Expect null as the function should handle undefined as null
}));
await executeThunk(addComment(content, threadId, undefined, false, ''), store.dispatch, store.getState);
expect(store.getState().comments.postStatus).toEqual('successful');
});
it('successfully added comment with null recaptchaToken', async () => {
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
await executeThunk(fetchThreadComments(threadId), store.dispatch, store.getState);
axiosMock.onPost(commentsApiUrl).reply(200, Factory.build('comment', {
thread_id: threadId,
raw_body: content,
rendered_body: content,
}));
await executeThunk(addComment(content, threadId, null, false, null), store.dispatch, store.getState);
expect(store.getState().comments.postStatus).toEqual('successful');
});
});

View File

@@ -141,16 +141,15 @@ export function editComment(commentId, comment) {
};
}
export function addComment(comment, threadId, parentId = null, enableInContextSidebar = false, recaptchaToken = '') {
export function addComment(comment, threadId, parentId = null, enableInContextSidebar = false) {
return async (dispatch) => {
try {
dispatch(postCommentRequest({
comment,
threadId,
parentId,
recaptchaToken,
}));
const data = await postComment(comment, threadId, parentId, enableInContextSidebar, recaptchaToken);
const data = await postComment(comment, threadId, parentId, enableInContextSidebar);
dispatch(postCommentSuccess(camelCaseObject(data)));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {

View File

@@ -221,16 +221,6 @@ const messages = defineMessages({
}`,
description: 'sort message showing current sorting',
},
verifyHumanLabel: {
id: 'discussions.verify.human.label',
defaultMessage: 'Verify you are human',
description: 'Verify you are human description.',
},
captchaVerificationLabel: {
id: 'discussions.captcha.verification.label',
defaultMessage: 'Please complete the CAPTCHA verification',
description: 'Please complete the CAPTCHA to continue.',
},
});
export default messages;

View File

@@ -84,8 +84,6 @@ export const getThread = async (threadId, courseId) => {
* @param {boolean} following Follow the thread after creating
* @param {boolean} anonymous Should the thread be anonymous to all users
* @param {boolean} anonymousToPeers Should the thread be anonymous to peers
* @param notifyAllLearners
* @param recaptchaToken
* @param {boolean} enableInContextSidebar
* @returns {Promise<{}>}
*/
@@ -100,8 +98,6 @@ export const postThread = async (
cohort,
anonymous,
anonymousToPeers,
notifyAllLearners,
recaptchaToken,
} = {},
enableInContextSidebar = false,
) => {
@@ -116,8 +112,6 @@ export const postThread = async (
anonymousToPeers,
groupId: cohort,
enableInContextSidebar,
notifyAllLearners,
captchaToken: recaptchaToken,
});
const { data } = await getAuthenticatedHttpClient()
.post(getThreadsApiUrl(), postData);

View File

@@ -11,9 +11,9 @@ const usePostList = (ids) => {
const sortedIds = useMemo(() => {
posts.forEach((post) => {
if (post && post.pinned) {
if (post.pinned) {
pinnedPostsIds.push(post.id);
} else if (post) {
} else {
unpinnedPostsIds.push(post.id);
}
});

View File

@@ -1,5 +1,4 @@
import { createSelector } from '@reduxjs/toolkit';
import camelCase from 'lodash/camelCase';
const selectThreads = state => state.threads.threadsById;
@@ -57,7 +56,3 @@ export const selectThreadSorting = () => state => state.threads.sortedBy;
export const selectThreadFilters = () => state => state.threads.filters;
export const selectThreadNextPage = () => state => state.threads.nextPage;
export const selectAuthorAvatar = author => state => (
state.threads.avatars?.[camelCase(author)]?.profile.image
);

View File

@@ -204,8 +204,6 @@ export function createNewThread({
anonymousToPeers,
cohort,
enableInContextSidebar,
notifyAllLearners,
recaptchaToken,
}) {
return async (dispatch) => {
try {
@@ -219,16 +217,12 @@ export function createNewThread({
anonymous,
anonymousToPeers,
cohort,
notifyAllLearners,
recaptchaToken,
}));
const data = await postThread(courseId, topicId, type, title, content, {
cohort,
following,
anonymous,
anonymousToPeers,
notifyAllLearners,
recaptchaToken,
}, enableInContextSidebar);
dispatch(postThreadSuccess(camelCaseObject(data)));
} catch (error) {

View File

@@ -55,7 +55,7 @@ const PostActionsBar = () => {
<Button
variant={enableInContextSidebar ? 'plain' : 'brand'}
className={classNames(
'my-0 font-style line-height-24',
'my-0 font-style border-0 line-height-24',
{ 'px-3 py-10px border-0': enableInContextSidebar },
)}
onClick={handleAddPost}

View File

@@ -9,7 +9,6 @@ import {
import { Help, Post } from '@openedx/paragon/icons';
import { Formik } from 'formik';
import { isEmpty } from 'lodash';
import ReCAPTCHA from 'react-google-recaptcha';
import { useDispatch, useSelector } from 'react-redux';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import * as Yup from 'yup';
@@ -28,10 +27,8 @@ import DiscussionContext from '../../common/context';
import { useCurrentDiscussionTopic } from '../../data/hooks';
import {
selectAnonymousPostingConfig,
selectCaptchaSettings,
selectDivisionSettings,
selectEnableInContext,
selectIsNotifyAllLearnersEnabled,
selectModerationSettings,
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
@@ -45,7 +42,6 @@ import {
selectNonCoursewareTopics as inContextNonCourseware,
} from '../../in-context-topics/data/selectors';
import { selectCoursewareTopics, selectNonCoursewareIds, selectNonCoursewareTopics } from '../../topics/data/selectors';
import { updateUserDiscussionsTourByName } from '../../tours/data';
import {
discussionsPath, formikCompatibleHandler, isFormikFieldInvalid, useCommentsPagePath,
} from '../../utils';
@@ -63,7 +59,6 @@ const PostEditor = ({
const location = useLocation();
const dispatch = useDispatch();
const editorRef = useRef(null);
const recaptchaRef = useRef(null);
const { courseId, postId } = useParams();
const { authenticatedUser } = useContext(AppContext);
const { category, enableInContextSidebar } = useContext(DiscussionContext);
@@ -84,8 +79,6 @@ const PostEditor = ({
const userIsStaff = useSelector(selectUserIsStaff);
const archivedTopics = useSelector(selectArchivedTopics);
const postEditorId = `post-editor-${editExisting ? postId : 'new'}`;
const isNotifyAllLearnersEnabled = useSelector(selectIsNotifyAllLearnersEnabled);
const captchaSettings = useSelector(selectCaptchaSettings);
const canDisplayEditReason = (editExisting
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
@@ -96,34 +89,6 @@ const PostEditor = ({
editReasonCode: Yup.string().required(intl.formatMessage(messages.editReasonCodeError)),
};
const shouldRequireCaptcha = !postId && captchaSettings.enabled;
const captchaValidation = {
recaptchaToken: Yup.string().required(intl.formatMessage(messages.captchaVerificationLabel)),
};
const enableNotifyAllLearnersTour = useCallback((enabled) => {
const data = {
enabled,
tourName: 'notify_all_learners',
};
dispatch(updateUserDiscussionsTourByName(data));
}, []);
useEffect(() => {
enableNotifyAllLearnersTour(true);
return () => {
enableNotifyAllLearnersTour(false);
};
}, []);
const handleCaptchaChange = useCallback((token, setFieldValue) => {
setFieldValue('recaptchaToken', token || '');
}, []);
const handleCaptchaExpired = useCallback((setFieldValue) => {
setFieldValue('recaptchaToken', '');
}, []);
const canSelectCohort = useCallback((tId) => {
// If the user isn't privileged, they can't edit the cohort.
// If the topic is being edited the cohort can't be changed.
@@ -143,22 +108,16 @@ const PostEditor = ({
title: post?.title || '',
comment: post?.rawBody || '',
follow: isEmpty(post?.following) ? true : post?.following,
notifyAllLearners: false,
anonymous: allowAnonymous ? false : undefined,
anonymousToPeers: allowAnonymousToPeers ? false : undefined,
cohort: post?.cohort || 'default',
editReasonCode: post?.lastEdit?.reasonCode || (
userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined
),
recaptchaToken: '',
};
const hideEditor = useCallback((resetForm) => {
resetForm({ values: initialValues });
// Reset CAPTCHA when hiding editor
if (recaptchaRef.current) {
recaptchaRef.current.reset();
}
if (editExisting) {
const newLocation = discussionsPath(commentsPagePath, {
courseId,
@@ -190,7 +149,7 @@ const PostEditor = ({
}));
} else {
const cohort = canSelectCohort(values.topic) ? selectedCohort(values.cohort) : undefined;
// Include CAPTCHA token in the request for new posts
// if not allowed to set cohort, always undefined, so no value is sent to backend
await dispatchSubmit(createNewThread({
courseId,
topicId: values.topic,
@@ -202,18 +161,16 @@ const PostEditor = ({
anonymousToPeers: allowAnonymousToPeers ? values.anonymousToPeers : undefined,
cohort,
enableInContextSidebar,
notifyAllLearners: values.notifyAllLearners,
...(shouldRequireCaptcha ? { recaptchaToken: values.recaptchaToken } : {}),
}));
}
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
if (editorRef.current) {
editorRef.current.plugins.autosave.removeDraft();
}
hideEditor(resetForm);
}, [
allowAnonymous, allowAnonymousToPeers, canSelectCohort, editExisting,
enableInContextSidebar, hideEditor, postId, selectedCohort, topicId, shouldRequireCaptcha,
enableInContextSidebar, hideEditor, postId, selectedCohort, topicId,
]);
useEffect(() => {
@@ -259,14 +216,10 @@ const PostEditor = ({
anonymousToPeers: Yup.bool()
.default(false)
.nullable(),
notifyAllLearners: Yup.bool()
.default(false),
cohort: Yup.string()
.nullable()
.default(null),
...(shouldRequireCaptcha ? { recaptchaToken: Yup.string().required() } : { }),
...editReasonCodeValidation,
...(shouldRequireCaptcha ? captchaValidation : {}),
});
const handleInContextSelectLabel = (section, subsection) => (
@@ -287,7 +240,6 @@ const PostEditor = ({
handleBlur,
handleChange,
resetForm,
setFieldValue,
}) => (
<Form className="m-4 card p-4 post-form" onSubmit={handleSubmit}>
<h4 className="mb-4 font-style" style={{ lineHeight: '16px' }}>
@@ -328,7 +280,6 @@ const PostEditor = ({
aria-describedby="topicAreaInput"
floatingLabel={intl.formatMessage(messages.topicArea)}
disabled={enableInContextSidebar}
data-testid="topic-select"
>
{nonCoursewareTopics.map(topic => (
<option
@@ -416,7 +367,6 @@ const PostEditor = ({
aria-describedby="titleInput"
floatingLabel={intl.formatMessage(messages.postTitle)}
value={values.title}
data-testid="post-title-input"
/>
<FormikErrorFeedback name="title" />
</Form.Group>
@@ -467,22 +417,6 @@ const PostEditor = ({
<div className="d-flex flex-row mt-n4 w-75 text-primary font-style">
{!editExisting && (
<>
{isNotifyAllLearnersEnabled && (
<Form.Group>
<Form.Checkbox
name="notifyAllLearners"
id="notify-learners"
checked={values.notifyAllLearners}
onChange={handleChange}
onBlur={handleBlur}
className="mr-4.5"
>
<span>
{intl.formatMessage(messages.notifyAllLearners)}
</span>
</Form.Checkbox>
</Form.Group>
)}
<Form.Group>
<Form.Checkbox
name="follow"
@@ -513,31 +447,6 @@ const PostEditor = ({
</>
)}
</div>
{/* CAPTCHA Section - Only show for new posts for non-staff users */}
{shouldRequireCaptcha && captchaSettings.siteKey && (
<div className="mb-3">
<Form.Group
isInvalid={isFormikFieldInvalid('recaptchaToken', {
errors,
touched,
})}
>
<Form.Label className="h6">
{intl.formatMessage(messages.verifyHumanLabel)}
</Form.Label>
<div className="d-flex justify-content-start">
<ReCAPTCHA
ref={recaptchaRef}
sitekey={captchaSettings.siteKey}
onChange={(token) => handleCaptchaChange(token, setFieldValue)}
onExpired={() => handleCaptchaExpired(setFieldValue)}
onError={() => handleCaptchaExpired(setFieldValue)}
/>
</div>
<FormikErrorFeedback name="recaptchaToken" />
</Form.Group>
</div>
)}
<div className="d-flex justify-content-end">
<Button
variant="outline-primary"

View File

@@ -19,14 +19,9 @@ import { initializeStore } from '../../../store';
import executeThunk from '../../../test-utils';
import { getCohortsApiUrl } from '../../cohorts/data/api';
import DiscussionContext from '../../common/context';
import { getCourseConfigApiUrl } from '../../data/api';
import fetchCourseConfig from '../../data/thunks';
import fetchCourseTopics from '../../topics/data/thunks';
import { getThreadsApiUrl } from '../data/api';
import { fetchThread } from '../data/thunks';
import MockReCAPTCHA, {
mockOnChange, mockOnError, mockOnExpired,
} from './mocksData/react-google-recaptcha';
import PostEditor from './PostEditor';
import '../../cohorts/data/__factories__';
@@ -36,14 +31,11 @@ import '../data/__factories__';
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v1/course_topics/${courseId}`;
const courseConfigApiUrl = getCourseConfigApiUrl();
const threadsApiUrl = getThreadsApiUrl();
let store;
let axiosMock;
let container;
jest.mock('react-google-recaptcha', () => MockReCAPTCHA);
async function renderComponent(editExisting = false, location = `/${courseId}/posts/`) {
const paths = editExisting ? ROUTES.POSTS.EDIT_POST : [ROUTES.POSTS.NEW_POST];
const wrapper = await render(
@@ -66,114 +58,6 @@ async function renderComponent(editExisting = false, location = `/${courseId}/po
container = wrapper.container;
}
describe('PostEditor submit Form', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const cwtopics = Factory.buildList('category', 2);
Factory.reset('topic');
axiosMock.onGet(topicsApiUrl).reply(200, {
courseware_topics: cwtopics,
non_courseware_topics: Factory.buildList('topic', 3, {}, { topicPrefix: 'ncw-' }),
});
store = initializeStore({
config: {
provider: 'legacy',
allowAnonymous: true,
allowAnonymousToPeers: true,
hasModerationPrivileges: true,
settings: {
dividedInlineDiscussions: ['category-1-topic-2'],
dividedCourseWideDiscussions: ['ncw-topic-2'],
},
captchaSettings: {
enabled: true,
siteKey: 'test-key',
},
},
});
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
axiosMock.onGet(getCohortsApiUrl(courseId)).reply(200, Factory.buildList('cohort', 3));
});
test('successfully submits a new post with CAPTCHA', async () => {
const newThread = Factory.build('thread', { id: 'new-thread-1' });
axiosMock.onPost(threadsApiUrl).reply(200, newThread);
await renderComponent();
await act(async () => {
fireEvent.change(screen.getByTestId('topic-select'), {
target: { value: 'ncw-topic-1' },
});
const postTitle = await screen.findByTestId('post-title-input');
const tinymceEditor = await screen.findByTestId('tinymce-editor');
const solveButton = screen.getByText('Solve CAPTCHA');
fireEvent.change(postTitle, { target: { value: 'Test Post Title' } });
fireEvent.change(tinymceEditor, { target: { value: 'Test Post Content' } });
fireEvent.click(solveButton);
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
});
await waitFor(() => {
expect(axiosMock.history.post).toHaveLength(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toMatchObject({
course_id: 'course-v1:edX+DemoX+Demo_Course',
topic_id: 'ncw-topic-1',
type: 'discussion',
title: 'Test Post Title',
raw_body: 'Test Post Content',
following: true,
anonymous: false,
anonymous_to_peers: false,
enable_in_context_sidebar: false,
notify_all_learners: false,
captcha_token: 'mock-token',
});
});
});
test('fails to submit a new post with CAPTCHA if token is missing', async () => {
const newThread = Factory.build('thread', { id: 'new-thread-1' });
axiosMock.onPost(threadsApiUrl).reply(200, newThread);
await renderComponent();
await act(async () => {
fireEvent.change(screen.getByTestId('topic-select'), {
target: { value: 'ncw-topic-1' },
});
const postTitle = await screen.findByTestId('post-title-input');
const tinymceEditor = await screen.findByTestId('tinymce-editor');
fireEvent.change(postTitle, { target: { value: 'Test Post Title' } });
fireEvent.change(tinymceEditor, { target: { value: 'Test Post Content' } });
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
});
await waitFor(() => {
expect(screen.getByText('Please complete the CAPTCHA verification')).toBeInTheDocument();
});
});
});
describe('PostEditor', () => {
beforeEach(async () => {
initializeMockApp({
@@ -223,10 +107,6 @@ describe('PostEditor', () => {
allowAnonymous,
allowAnonymousToPeers,
moderationSettings: {},
captchaSettings: {
enabled: false,
siteKey: '',
},
},
});
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
@@ -236,6 +116,7 @@ describe('PostEditor', () => {
allowAnonymousToPeers ? '' : 'not'} allowed`, async () => {
await renderComponent();
expect(screen.queryByRole('heading')).toHaveTextContent('Add a post');
expect(screen.queryAllByRole('radio')).toHaveLength(2);
// 2 categories with 4 subcategories each
expect(screen.queryAllByText(/category-\d-topic \d/)).toHaveLength(8);
@@ -262,46 +143,6 @@ describe('PostEditor', () => {
});
});
describe.each([
{
isNotifyAllLearnersEnabled: true,
description: 'when "Notify All Learners" is enabled',
},
{
isNotifyAllLearnersEnabled: false,
description: 'when "Notify All Learners" is disabled',
},
])('$description', ({ isNotifyAllLearnersEnabled }) => {
beforeEach(async () => {
store = initializeStore({
config: {
provider: 'legacy',
is_notify_all_learners_enabled: isNotifyAllLearnersEnabled,
moderationSettings: {},
captchaSettings: {
enabled: false,
siteKey: '',
},
},
});
axiosMock
.onGet(`${courseConfigApiUrl}${courseId}/`)
.reply(200, { is_notify_all_learners_enabled: isNotifyAllLearnersEnabled });
await store.dispatch(fetchCourseConfig(courseId));
renderComponent();
});
test(`should ${isNotifyAllLearnersEnabled ? 'show' : 'not show'} the "Notify All Learners" option`, async () => {
if (isNotifyAllLearnersEnabled) {
await waitFor(() => expect(screen.queryByText('Notify All Learners')).toBeInTheDocument());
} else {
await waitFor(() => expect(screen.queryByText('Notify All Learners')).not.toBeInTheDocument());
}
});
});
describe('cohorting', () => {
const dividedncw = ['ncw-topic-2'];
const dividedcw = ['category-1-topic-2', 'category-2-topic-1', 'category-2-topic-2'];
@@ -321,64 +162,12 @@ describe('PostEditor', () => {
dividedCourseWideDiscussions: dividedncw,
...settings,
},
captchaSettings: {
enabled: false,
siteKey: '',
},
...config,
},
});
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
}
test('renders the mocked ReCAPTCHA.', async () => {
await setupData({
captchaSettings: {
enabled: true,
siteKey: 'test-key',
},
});
await renderComponent();
expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument();
});
test('successfully calls onTokenChange when Solve CAPTCHA button is clicked', async () => {
await setupData({
captchaSettings: {
enabled: true,
siteKey: 'test-key',
},
});
await renderComponent();
const solveButton = screen.getByText('Solve CAPTCHA');
fireEvent.click(solveButton);
expect(mockOnChange).toHaveBeenCalled();
});
test('successfully calls onExpired handler when CAPTCHA expires', async () => {
await setupData({
captchaSettings: {
enabled: true,
siteKey: 'test-key',
},
});
await renderComponent();
fireEvent.click(screen.getByText('Expire CAPTCHA'));
expect(mockOnExpired).toHaveBeenCalled();
});
test('successfully calls onError handler when CAPTCHA errors', async () => {
await setupData({
captchaSettings: {
enabled: true,
siteKey: 'test-key',
},
});
await renderComponent();
fireEvent.click(screen.getByText('Error CAPTCHA'));
expect(mockOnError).toHaveBeenCalled();
});
test('test privileged user', async () => {
await setupData();
await renderComponent();
@@ -530,10 +319,6 @@ describe('PostEditor', () => {
dividedInlineDiscussions: dividedcw,
dividedCourseWideDiscussions: dividedncw,
},
captchaSettings: {
enabled: false,
siteKey: '',
},
},
});
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);

View File

@@ -89,10 +89,6 @@ const messages = defineMessages({
id: 'discussions.post.editor.anonymousToPeersPost',
defaultMessage: 'Post anonymously to peers',
},
notifyAllLearners: {
id: 'discussions.post.editor.notifyAllLearners',
defaultMessage: 'Notify All Learners',
},
submit: {
id: 'discussions.editor.submit',
defaultMessage: 'Submit',
@@ -175,16 +171,6 @@ const messages = defineMessages({
defaultMessage: 'Archived',
description: 'Heading for displaying topics that are archived.',
},
captchaVerificationLabel: {
id: 'discussions.captcha.verification.label',
defaultMessage: 'Please complete the CAPTCHA verification',
description: 'Please complete the CAPTCHA to continue.',
},
verifyHumanLabel: {
id: 'discussions.verify.human.label',
defaultMessage: 'Verify you are human',
description: 'Verify you are human description.',
},
});
export default messages;

View File

@@ -1,42 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
// Define mock functions
export const mockOnChange = jest.fn();
export const mockOnExpired = jest.fn();
export const mockOnError = jest.fn();
export const mockReset = jest.fn();
const MockReCAPTCHA = React.forwardRef((props, ref) => {
const { onChange, onExpired, onError } = props;
React.useImperativeHandle(ref, () => ({
reset: mockReset,
}));
return (
<div data-testid="mocked-recaptcha" ref={ref}>
<button type="button" onClick={() => { mockOnChange(); onChange?.('mock-token'); }}>
Solve CAPTCHA
</button>
<button type="button" onClick={() => { mockOnExpired(); onExpired?.(); }}>
Expire CAPTCHA
</button>
<button type="button" onClick={() => { mockOnError(); onError?.(); }}>
Error CAPTCHA
</button>
</div>
);
});
MockReCAPTCHA.propTypes = {
onChange: PropTypes.func,
onExpired: PropTypes.func,
onError: PropTypes.func,
};
MockReCAPTCHA.defaultProps = {
onChange: () => {},
onExpired: () => {},
onError: () => {},
};
export default MockReCAPTCHA;

View File

@@ -4,21 +4,18 @@ import PropTypes from 'prop-types';
import { Avatar, Badge, Icon } from '@openedx/paragon';
import { Question } from '@openedx/paragon/icons';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants';
import { AuthorLabel } from '../../common';
import { useAlertBannerVisible } from '../../data/hooks';
import { selectAuthorAvatar } from '../data/selectors';
import messages from './messages';
export const PostAvatar = React.memo(({
author, postType, authorLabel, fromPostLink, read,
}) => {
const outlineColor = AvatarOutlineAndLabelColors[authorLabel];
const authorAvatars = useSelector(selectAuthorAvatar(author));
const avatarSize = useMemo(() => {
let size = '2rem';
@@ -63,7 +60,6 @@ export const PostAvatar = React.memo(({
width: avatarSize,
}}
alt={author}
src={authorAvatars?.imageUrlSmall}
/>
</div>
);

View File

@@ -7,11 +7,11 @@ import messages from './messages';
*/
export default function tourCheckpoints(intl) {
return {
notify_all_learners: [
EXAMPLE_TOUR: [
{
title: intl.formatMessage(messages.notifyAllLearnersTourTitle),
body: intl.formatMessage(messages.notifyAllLearnersTourBody),
target: '#notify-learners',
title: intl.formatMessage(messages.exampleTourTitle),
body: intl.formatMessage(messages.exampleTourBody),
target: '#example-tour-target',
placement: 'bottom',
},
],

View File

@@ -16,15 +16,15 @@ const messages = defineMessages({
defaultMessage: 'Okay',
description: 'Action to end current tour',
},
notifyAllLearnersTourTitle: {
id: 'tour.title.notifyAllLearners',
defaultMessage: 'Let your learners know.',
description: 'Title of the tour to notify all learners',
exampleTourTitle: {
id: 'tour.example.title',
defaultMessage: 'Example Tour',
description: 'Title for example tour',
},
notifyAllLearnersTourBody: {
id: 'tour.body.notifyAllLearners',
defaultMessage: 'Check this box to notify all learners.',
description: 'Body of the tour to notify all learners',
exampleTourBody: {
id: 'tour.example.body',
defaultMessage: 'This is an example tour',
description: 'Body for example tour',
},
});