Compare commits
22 Commits
release/te
...
sundas/rec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86f050eaf2 | ||
|
|
7db3f4a21a | ||
|
|
5f11390979 | ||
|
|
af3ef4f491 | ||
|
|
547f9cd185 | ||
|
|
1af495a84c | ||
|
|
d8e63602d3 | ||
|
|
79fb8ecd02 | ||
|
|
90486b7454 | ||
|
|
f67ead3c0b | ||
|
|
a6b14740ea | ||
|
|
258a9b51b3 | ||
|
|
1d162d3109 | ||
|
|
eaf1e37c11 | ||
|
|
8618e8cfe9 | ||
|
|
3b7239d72c | ||
|
|
7ebdf1be3e | ||
|
|
5d75e0361d | ||
|
|
edd3f73211 | ||
|
|
33375a51e0 | ||
|
|
ac471e2dd7 | ||
|
|
f04429f6f7 |
2
.env
2
.env
@@ -22,3 +22,5 @@ USER_INFO_COOKIE_NAME=''
|
||||
SUPPORT_URL=''
|
||||
LEARNER_FEEDBACK_URL=''
|
||||
STAFF_FEEDBACK_URL=''
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
|
||||
@@ -23,3 +23,5 @@ USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
LEARNER_FEEDBACK_URL=''
|
||||
STAFF_FEEDBACK_URL=''
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
|
||||
6895
package-lock.json
generated
6895
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@
|
||||
"@edx/frontend-component-header": "^6.2.0",
|
||||
"@edx/frontend-platform": "^8.3.3",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@openedx/paragon": "^22.16.0",
|
||||
"@openedx/paragon": "^23.4.5",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@tinymce/tinymce-react": "5.1.1",
|
||||
"babel-polyfill": "6.26.0",
|
||||
@@ -47,6 +47,7 @@
|
||||
"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",
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
@import "~@edx/brand/paragon/fonts.scss";
|
||||
@import "~@edx/brand/paragon/variables.scss";
|
||||
@import "~@openedx/paragon/scss/core/core.scss";
|
||||
@import "~@edx/brand/paragon/overrides.scss";
|
||||
|
||||
$fa-font-path: "~font-awesome/fonts";
|
||||
@import "~font-awesome/scss/font-awesome";
|
||||
|
||||
.course-tabs-navigation {
|
||||
border-bottom: solid 1px #eaeaea;
|
||||
|
||||
.nav a,
|
||||
.nav button {
|
||||
&:hover {
|
||||
background-color: $light-400;
|
||||
background-color: var(--pgn-color-light-400);
|
||||
}
|
||||
}
|
||||
|
||||
.nav a {
|
||||
&:not(.active):hover {
|
||||
background-color: $light-400;
|
||||
background-color: var(--pgn-color-light-400);
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
@@ -30,7 +22,7 @@ $fa-font-path: "~font-awesome/fonts";
|
||||
.nav-link {
|
||||
border-bottom: 4px solid transparent;
|
||||
border-top: 4px solid transparent;
|
||||
color: $gray-700;
|
||||
color: var(--pgn-color-gray-700);
|
||||
|
||||
// temporary until we can remove .btn class from dropdowns
|
||||
border-left: 0;
|
||||
@@ -40,9 +32,9 @@ $fa-font-path: "~font-awesome/fonts";
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.active {
|
||||
font-weight: $font-weight-normal;
|
||||
color: $primary-500;
|
||||
border-bottom-color: $primary-500;
|
||||
font-weight: var(--pgn-typography-font-weight-normal);
|
||||
color: var(--pgn-color-primary-500);
|
||||
border-bottom-color: var(--pgn-color-primary-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,10 +224,6 @@ 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();
|
||||
@@ -252,7 +248,7 @@ export const useTourConfiguration = () => {
|
||||
enabled: tour && Boolean(tour.enabled && tour.showTour && !enableInContextSidebar),
|
||||
onDismiss: () => handleOnDismiss(tour.id),
|
||||
onEnd: () => handleOnEnd(tour.id),
|
||||
checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)],
|
||||
checkpoints: tourCheckpoints(intl)[tour.tourName],
|
||||
}
|
||||
))
|
||||
), [tours, enableInContextSidebar]);
|
||||
|
||||
@@ -31,6 +31,10 @@ 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,
|
||||
|
||||
@@ -22,6 +22,10 @@ const configSlice = createSlice({
|
||||
dividedInlineDiscussions: [],
|
||||
dividedCourseWideDiscussions: [],
|
||||
},
|
||||
captchaSettings: {
|
||||
enabled: false,
|
||||
siteKey: '',
|
||||
},
|
||||
editReasons: [],
|
||||
postCloseReasons: [],
|
||||
enableInContext: false,
|
||||
|
||||
@@ -12,3 +12,7 @@ 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
|
||||
);
|
||||
|
||||
@@ -2,19 +2,26 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Avatar } from '@openedx/paragon';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
const LearnerAvatar = ({ username }) => (
|
||||
<div className="mr-3 mt-1">
|
||||
<Avatar
|
||||
size="sm"
|
||||
alt={username}
|
||||
style={{
|
||||
height: '2rem',
|
||||
width: '2rem',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
LearnerAvatar.propTypes = {
|
||||
username: PropTypes.string.isRequired,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import {
|
||||
act, fireEvent, render, screen, waitFor, within,
|
||||
} from '@testing-library/react';
|
||||
@@ -23,6 +25,12 @@ 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';
|
||||
@@ -51,6 +59,8 @@ 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,
|
||||
@@ -215,7 +225,13 @@ describe('ThreadView', () => {
|
||||
endorsed: false,
|
||||
})];
|
||||
});
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { isPostingEnabled: true });
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
|
||||
isPostingEnabled: true,
|
||||
captchaSettings: {
|
||||
enabled: true,
|
||||
siteKey: 'test-key',
|
||||
},
|
||||
});
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
@@ -292,6 +308,29 @@ 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));
|
||||
|
||||
@@ -302,7 +341,6 @@ 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());
|
||||
});
|
||||
|
||||
@@ -323,7 +361,6 @@ 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());
|
||||
});
|
||||
|
||||
@@ -581,6 +618,42 @@ 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 }))
|
||||
@@ -749,7 +822,7 @@ describe('ThreadView', () => {
|
||||
fireEvent.click(screen.queryAllByText('Add comment')[0]);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('tinymce-editor').value).toBe('');
|
||||
expect(screen.queryByTestId('tinymce-editor').value).toBe('Draft comment 123!');
|
||||
});
|
||||
|
||||
it('successfully added response in the draft.', async () => {
|
||||
@@ -793,7 +866,7 @@ describe('ThreadView', () => {
|
||||
fireEvent.click(screen.queryByText('Add response'));
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('tinymce-editor').value).toBe('');
|
||||
expect(screen.queryByTestId('tinymce-editor').value).toBe('Draft Response!');
|
||||
});
|
||||
|
||||
it('successfully maintain response for the specific post in the draft.', async () => {
|
||||
@@ -975,3 +1048,61 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,7 +92,7 @@ const CommentsView = ({ threadType }) => {
|
||||
variant="plain"
|
||||
block="true"
|
||||
className="card mb-4 px-0 border-0 py-10px mt-2 font-style font-weight-500
|
||||
line-height-24 text-primary-500"
|
||||
line-height-24 text-primary-500 bg-white"
|
||||
onClick={handleAddResponse}
|
||||
data-testid="add-response"
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
|
||||
@@ -17,6 +18,7 @@ import PostPreviewPanel from '../../../../components/PostPreviewPanel';
|
||||
import useDispatchWithState from '../../../../data/hooks';
|
||||
import DiscussionContext from '../../../common/context';
|
||||
import {
|
||||
selectCaptchaSettings,
|
||||
selectModerationSettings,
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
@@ -40,6 +42,7 @@ 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);
|
||||
@@ -49,6 +52,13 @@ 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)
|
||||
@@ -62,16 +72,31 @@ 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]);
|
||||
|
||||
@@ -92,7 +117,7 @@ const CommentEditor = ({
|
||||
};
|
||||
await dispatch(editComment(id, payload));
|
||||
} else {
|
||||
await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar));
|
||||
await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar, shouldRequireCaptcha ? values.recaptchaToken : ''));
|
||||
}
|
||||
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
|
||||
if (editorRef.current) {
|
||||
@@ -100,7 +125,7 @@ const CommentEditor = ({
|
||||
}
|
||||
handleCloseEditor(resetForm);
|
||||
deleteEditorContent();
|
||||
}, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor]);
|
||||
}, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor, shouldRequireCaptcha]);
|
||||
// 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}`;
|
||||
@@ -147,6 +172,7 @@ const CommentEditor = ({
|
||||
handleBlur,
|
||||
handleChange,
|
||||
resetForm,
|
||||
setFieldValue,
|
||||
}) => (
|
||||
<Form onSubmit={handleSubmit} className={formClasses} ref={formRef}>
|
||||
{canDisplayEditReason && (
|
||||
@@ -202,6 +228,32 @@ 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"
|
||||
|
||||
@@ -3,10 +3,12 @@ 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,
|
||||
@@ -23,6 +25,7 @@ const CommentHeader = ({
|
||||
lastEdit,
|
||||
closed,
|
||||
});
|
||||
const authorAvatar = useSelector(selectAuthorAvatar(author));
|
||||
|
||||
return (
|
||||
<div className={classNames('d-flex flex-row justify-content-between', {
|
||||
@@ -33,6 +36,7 @@ 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',
|
||||
|
||||
@@ -15,6 +15,7 @@ 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';
|
||||
@@ -38,6 +39,7 @@ const Reply = ({ responseId }) => {
|
||||
lastEdit,
|
||||
closed,
|
||||
});
|
||||
const authorAvatar = useSelector(selectAuthorAvatar(author));
|
||||
|
||||
const handleDeleteConfirmation = useCallback(() => {
|
||||
dispatch(removeComment(id));
|
||||
@@ -121,6 +123,7 @@ 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',
|
||||
|
||||
@@ -73,10 +73,16 @@ export const getCommentResponses = async (commentId, {
|
||||
* @param {boolean} enableInContextSidebar
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export const postComment = async (comment, threadId, parentId = null, enableInContextSidebar = false) => {
|
||||
export const postComment = async (
|
||||
comment,
|
||||
threadId,
|
||||
parentId,
|
||||
enableInContextSidebar,
|
||||
recaptchaToken,
|
||||
) => {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCommentsApiUrl(), snakeCaseObject({
|
||||
threadId, raw_body: comment, parentId, enableInContextSidebar,
|
||||
threadId, raw_body: comment, parentId, enableInContextSidebar, captchaToken: recaptchaToken,
|
||||
}));
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('Post comments view api tests', () => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
test('successfully get thread comments', async () => {
|
||||
it('successfully get thread comments', async () => {
|
||||
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
|
||||
await executeThunk(fetchThreadComments(threadId, { endorsed: 'discussion' }), store.dispatch, store.getState);
|
||||
|
||||
@@ -92,22 +92,25 @@ 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, null), store.dispatch, store.getState);
|
||||
await executeThunk(addComment(content, threadId, 'parent_id', true, 'recaptcha-token'), 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, null), store.dispatch, store.getState);
|
||||
await executeThunk(addComment(content, threadId, 'parent_id', false, 'recaptcha-token'), 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, null), store.dispatch, store.getState);
|
||||
await executeThunk(addComment(content, threadId, 'parent_id', false, 'recaptcha-token'), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('denied');
|
||||
});
|
||||
@@ -164,4 +167,76 @@ 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,15 +141,16 @@ export function editComment(commentId, comment) {
|
||||
};
|
||||
}
|
||||
|
||||
export function addComment(comment, threadId, parentId = null, enableInContextSidebar = false) {
|
||||
export function addComment(comment, threadId, parentId = null, enableInContextSidebar = false, recaptchaToken = '') {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(postCommentRequest({
|
||||
comment,
|
||||
threadId,
|
||||
parentId,
|
||||
recaptchaToken,
|
||||
}));
|
||||
const data = await postComment(comment, threadId, parentId, enableInContextSidebar);
|
||||
const data = await postComment(comment, threadId, parentId, enableInContextSidebar, recaptchaToken);
|
||||
dispatch(postCommentSuccess(camelCaseObject(data)));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
|
||||
@@ -221,6 +221,16 @@ 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;
|
||||
|
||||
@@ -84,6 +84,8 @@ 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<{}>}
|
||||
*/
|
||||
@@ -98,6 +100,8 @@ export const postThread = async (
|
||||
cohort,
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
notifyAllLearners,
|
||||
recaptchaToken,
|
||||
} = {},
|
||||
enableInContextSidebar = false,
|
||||
) => {
|
||||
@@ -112,6 +116,8 @@ export const postThread = async (
|
||||
anonymousToPeers,
|
||||
groupId: cohort,
|
||||
enableInContextSidebar,
|
||||
notifyAllLearners,
|
||||
captchaToken: recaptchaToken,
|
||||
});
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getThreadsApiUrl(), postData);
|
||||
|
||||
@@ -11,9 +11,9 @@ const usePostList = (ids) => {
|
||||
|
||||
const sortedIds = useMemo(() => {
|
||||
posts.forEach((post) => {
|
||||
if (post.pinned) {
|
||||
if (post && post.pinned) {
|
||||
pinnedPostsIds.push(post.id);
|
||||
} else {
|
||||
} else if (post) {
|
||||
unpinnedPostsIds.push(post.id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import camelCase from 'lodash/camelCase';
|
||||
|
||||
const selectThreads = state => state.threads.threadsById;
|
||||
|
||||
@@ -56,3 +57,7 @@ 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
|
||||
);
|
||||
|
||||
@@ -204,6 +204,8 @@ export function createNewThread({
|
||||
anonymousToPeers,
|
||||
cohort,
|
||||
enableInContextSidebar,
|
||||
notifyAllLearners,
|
||||
recaptchaToken,
|
||||
}) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
@@ -217,12 +219,16 @@ 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) {
|
||||
|
||||
@@ -55,7 +55,7 @@ const PostActionsBar = () => {
|
||||
<Button
|
||||
variant={enableInContextSidebar ? 'plain' : 'brand'}
|
||||
className={classNames(
|
||||
'my-0 font-style border-0 line-height-24',
|
||||
'my-0 font-style line-height-24',
|
||||
{ 'px-3 py-10px border-0': enableInContextSidebar },
|
||||
)}
|
||||
onClick={handleAddPost}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
@@ -27,8 +28,10 @@ import DiscussionContext from '../../common/context';
|
||||
import { useCurrentDiscussionTopic } from '../../data/hooks';
|
||||
import {
|
||||
selectAnonymousPostingConfig,
|
||||
selectCaptchaSettings,
|
||||
selectDivisionSettings,
|
||||
selectEnableInContext,
|
||||
selectIsNotifyAllLearnersEnabled,
|
||||
selectModerationSettings,
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
@@ -42,6 +45,7 @@ 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';
|
||||
@@ -59,6 +63,7 @@ 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);
|
||||
@@ -79,6 +84,8 @@ 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)
|
||||
@@ -89,6 +96,34 @@ 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.
|
||||
@@ -108,16 +143,22 @@ 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,
|
||||
@@ -149,7 +190,7 @@ const PostEditor = ({
|
||||
}));
|
||||
} else {
|
||||
const cohort = canSelectCohort(values.topic) ? selectedCohort(values.cohort) : undefined;
|
||||
// if not allowed to set cohort, always undefined, so no value is sent to backend
|
||||
// Include CAPTCHA token in the request for new posts
|
||||
await dispatchSubmit(createNewThread({
|
||||
courseId,
|
||||
topicId: values.topic,
|
||||
@@ -161,16 +202,18 @@ 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,
|
||||
enableInContextSidebar, hideEditor, postId, selectedCohort, topicId, shouldRequireCaptcha,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -216,10 +259,14 @@ 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) => (
|
||||
@@ -240,6 +287,7 @@ 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' }}>
|
||||
@@ -280,6 +328,7 @@ const PostEditor = ({
|
||||
aria-describedby="topicAreaInput"
|
||||
floatingLabel={intl.formatMessage(messages.topicArea)}
|
||||
disabled={enableInContextSidebar}
|
||||
data-testid="topic-select"
|
||||
>
|
||||
{nonCoursewareTopics.map(topic => (
|
||||
<option
|
||||
@@ -367,6 +416,7 @@ const PostEditor = ({
|
||||
aria-describedby="titleInput"
|
||||
floatingLabel={intl.formatMessage(messages.postTitle)}
|
||||
value={values.title}
|
||||
data-testid="post-title-input"
|
||||
/>
|
||||
<FormikErrorFeedback name="title" />
|
||||
</Form.Group>
|
||||
@@ -417,6 +467,22 @@ 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"
|
||||
@@ -447,6 +513,31 @@ 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"
|
||||
|
||||
@@ -19,9 +19,14 @@ 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__';
|
||||
@@ -31,11 +36,14 @@ 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(
|
||||
@@ -58,6 +66,114 @@ 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({
|
||||
@@ -107,6 +223,10 @@ describe('PostEditor', () => {
|
||||
allowAnonymous,
|
||||
allowAnonymousToPeers,
|
||||
moderationSettings: {},
|
||||
captchaSettings: {
|
||||
enabled: false,
|
||||
siteKey: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
@@ -116,7 +236,6 @@ 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);
|
||||
@@ -143,6 +262,46 @@ 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'];
|
||||
@@ -162,12 +321,64 @@ 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();
|
||||
@@ -319,6 +530,10 @@ describe('PostEditor', () => {
|
||||
dividedInlineDiscussions: dividedcw,
|
||||
dividedCourseWideDiscussions: dividedncw,
|
||||
},
|
||||
captchaSettings: {
|
||||
enabled: false,
|
||||
siteKey: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
|
||||
@@ -89,6 +89,10 @@ 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',
|
||||
@@ -171,6 +175,16 @@ 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;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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;
|
||||
@@ -4,18 +4,21 @@ 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';
|
||||
@@ -60,6 +63,7 @@ export const PostAvatar = React.memo(({
|
||||
width: avatarSize,
|
||||
}}
|
||||
alt={author}
|
||||
src={authorAvatars?.imageUrlSmall}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,11 +7,11 @@ import messages from './messages';
|
||||
*/
|
||||
export default function tourCheckpoints(intl) {
|
||||
return {
|
||||
EXAMPLE_TOUR: [
|
||||
notify_all_learners: [
|
||||
{
|
||||
title: intl.formatMessage(messages.exampleTourTitle),
|
||||
body: intl.formatMessage(messages.exampleTourBody),
|
||||
target: '#example-tour-target',
|
||||
title: intl.formatMessage(messages.notifyAllLearnersTourTitle),
|
||||
body: intl.formatMessage(messages.notifyAllLearnersTourBody),
|
||||
target: '#notify-learners',
|
||||
placement: 'bottom',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -16,15 +16,15 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Okay',
|
||||
description: 'Action to end current tour',
|
||||
},
|
||||
exampleTourTitle: {
|
||||
id: 'tour.example.title',
|
||||
defaultMessage: 'Example Tour',
|
||||
description: 'Title for example tour',
|
||||
notifyAllLearnersTourTitle: {
|
||||
id: 'tour.title.notifyAllLearners',
|
||||
defaultMessage: 'Let your learners know.',
|
||||
description: 'Title of the tour to notify all learners',
|
||||
},
|
||||
exampleTourBody: {
|
||||
id: 'tour.example.body',
|
||||
defaultMessage: 'This is an example tour',
|
||||
description: 'Body 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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
@import "~@edx/brand/paragon/fonts.scss";
|
||||
@import "~@edx/brand/paragon/variables.scss";
|
||||
@import "~@openedx/paragon/scss/core/core.scss";
|
||||
@import "~@edx/brand/paragon/overrides.scss";
|
||||
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
|
||||
|
||||
|
||||
@import "~@edx/frontend-component-footer/dist/footer";
|
||||
@import "~@edx/frontend-component-header/dist/index";
|
||||
|
||||
$fa-font-path: "~font-awesome/fonts";
|
||||
@import "~font-awesome/scss/font-awesome";
|
||||
|
||||
body,
|
||||
#main
|
||||
@@ -28,6 +24,10 @@ body,
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.btn-plain {
|
||||
background-color: var(--pgn-color-card-bg-base) !important;
|
||||
}
|
||||
|
||||
#post,
|
||||
#comment,
|
||||
#reply,
|
||||
@@ -41,23 +41,23 @@ body,
|
||||
}
|
||||
|
||||
.text-staff-color {
|
||||
color: $warning-700;
|
||||
color: var(--pgn-color-warning-700);
|
||||
}
|
||||
|
||||
.outline-staff-color {
|
||||
outline: $warning-700 solid 2px;
|
||||
outline: var(--pgn-color-warning-700) solid 2px;
|
||||
}
|
||||
|
||||
.text-TA-color {
|
||||
color: $success-700;
|
||||
color: var(--pgn-color-success-700);
|
||||
}
|
||||
|
||||
.outline-TA-color {
|
||||
outline: $success-700 solid 2px;
|
||||
outline: var(--pgn-color-success-700) solid 2px;
|
||||
}
|
||||
|
||||
.outline-anonymous {
|
||||
outline: $light-400 solid 2px;
|
||||
outline: var(--pgn-color-light-400) solid 2px;
|
||||
}
|
||||
|
||||
.font-size-8 {
|
||||
@@ -173,7 +173,7 @@ body,
|
||||
}
|
||||
|
||||
.learner > a:hover {
|
||||
background-color: $light-300;
|
||||
background-color: var(--pgn-color-light-300);
|
||||
}
|
||||
|
||||
.py-10px {
|
||||
@@ -252,12 +252,12 @@ header {
|
||||
}
|
||||
|
||||
.border-light-400-2 {
|
||||
border: 2px solid $light-400 !important;
|
||||
border: 2px solid var(--pgn-color-light-400) !important;
|
||||
border-width: 2px !important;
|
||||
}
|
||||
|
||||
.border-primary-500-2 {
|
||||
border: 2px solid $primary-500 !important;
|
||||
border: 2px solid var(--pgn-color-primary-500) !important;
|
||||
border-width: 2px !important;
|
||||
}
|
||||
|
||||
@@ -383,8 +383,8 @@ header {
|
||||
}
|
||||
|
||||
.btn-icon.btn-icon-primary:hover {
|
||||
background-color: $light-300 !important;
|
||||
color: $primary-500 !important
|
||||
background-color: var(--pgn-color-light-300) !important;
|
||||
color: var(--pgn-color-primary-500) !important
|
||||
}
|
||||
|
||||
|
||||
@@ -427,38 +427,38 @@ header {
|
||||
}
|
||||
|
||||
.hover-button:hover {
|
||||
background-color: $light-300 !important;
|
||||
background-color: var(--pgn-color-light-300) !important;
|
||||
height: 36px !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.btn-tertiary:hover {
|
||||
background-color: $light-300 !important;
|
||||
background-color: var(--pgn-color-light-300) !important;
|
||||
}
|
||||
|
||||
.nav-button-group {
|
||||
.nav-link {
|
||||
&:hover {
|
||||
background-color: $light-300 !important;
|
||||
background-color: var(--pgn-color-light-300) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link.active,
|
||||
.show>.nav-link {
|
||||
background-color: $primary-500 !important;
|
||||
background-color: var(--pgn-color-primary-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.course-tabs-navigation {
|
||||
.nav a {
|
||||
&:hover {
|
||||
background-color: $light-300 !important;;
|
||||
background-color: var(--pgn-color-light-300) !important;;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-tertiary:disabled {
|
||||
color: $gray-700 !important;
|
||||
color: var(--pgn-color-gray-700) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
@@ -535,14 +535,14 @@ code {
|
||||
.post-preview,
|
||||
.discussion-comments {
|
||||
blockquote {
|
||||
border-left: 2px solid $gray-200;
|
||||
border-left: 2px solid var(--pgn-color-gray-200);
|
||||
margin-left: 1.5rem;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.add-comment-btn {
|
||||
border: 1px solid $light-300 !important;
|
||||
border: 1px solid var(--pgn-color-light-300) !important;
|
||||
}
|
||||
|
||||
.icon-size-24 {
|
||||
@@ -588,7 +588,7 @@ code {
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px dashed $gray-200;
|
||||
border: 1px dashed var(--pgn-color-gray-200);
|
||||
padding: 0.4rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user