Compare commits

...

41 Commits

Author SHA1 Message Date
sundasnoreen12
86f050eaf2 fix: fixed captcha issue for response 2025-07-17 19:13:59 +05:00
sundasnoreen12
7db3f4a21a fix: removed unused catch 2025-07-17 18:50:50 +05:00
sundasnoreen12
5f11390979 test: added test case for default values for captcha 2025-07-17 18:37:11 +05:00
Awais Ansari
af3ef4f491 test: added test cases for react-google-recaptcha 2025-07-17 18:18:14 +05:00
sundasnoreen12
547f9cd185 test: test edge cases for api 2025-07-17 18:14:42 +05:00
sundasnoreen12
1af495a84c test: added submit post test cases 2025-07-17 16:57:02 +05:00
sundasnoreen12
d8e63602d3 test: should allow posting a comment with CAPTCHA 2025-07-17 14:16:56 +05:00
sundasnoreen12
79fb8ecd02 test: added test cases for recaptcha 2025-07-17 14:09:56 +05:00
sundasnoreen12
90486b7454 fix: fixed translation issue 2025-07-16 21:11:58 +05:00
sundasnoreen12
f67ead3c0b fix: removed comment and added check for empty sitekey 2025-07-16 18:40:33 +05:00
sundasnoreen12
a6b14740ea test: fixed test cases 2025-07-16 18:30:15 +05:00
sundasnoreen12
258a9b51b3 fix: removed learner check 2025-07-16 18:30:04 +05:00
sundasnoreen12
1d162d3109 feat: added captcha for comment and response 2025-07-16 18:30:04 +05:00
Ahtisham Shahid
eaf1e37c11 feat: added captcha to discussion post creation 2025-07-16 18:30:04 +05:00
sundasnoreen12
8618e8cfe9 fix: fixed active state border issue for add a new post button (#784) 2025-07-15 14:05:42 +05:00
sundasnoreen12
3b7239d72c feat: added product tour to notify all learners (#783)
* feat: added product tour to notify all learners

* fix: removed unused function
2025-07-03 17:10:09 +05:00
ayesha waris
7ebdf1be3e feat: Add notify all learners discussion post checkbox (#779)
* feat: Add notify all learners discussion post checkbox

* refactor: refactored post editor

* test: added test cases

* fix: fixed lint errors

---------

Co-authored-by: Hassan Raza <h.raza@arbisoft.com>
Co-authored-by: Ayesha Waris <ayesha.waris@192.168.10.6>
2025-06-27 19:42:39 +05:00
Eemaan Amir
5d75e0361d fix: fixed camel case issue so profile image shows for all users (#781) 2025-06-24 13:54:34 +05:00
Eemaan Amir
edd3f73211 feat: made user profile image visible (#778)
* feat: made user profile image visible

* refactor: updated selector name

* refactor: updated selector name
2025-06-20 12:18:53 +05:00
Brian Smith
33375a51e0 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-06-18 15:45:26 -04:00
renovate[bot]
ac471e2dd7 fix(deps): update dependency @edx/frontend-platform to v8.3.7 (#768)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-19 09:44:05 -04:00
Zameel Hassan
f04429f6f7 fix: add null check for post objects in usePostList hook (#752)
Adds defensive null checks when accessing post properties in the posts
forEach loop to prevent potential errors in the MFE discussion sidebar.
This addresses the issue reported in #751.
2025-05-12 18:47:17 +05:00
Brian Smith
bad12462f5 feat: import FooterSlot from component package instead of slot package (#765) 2025-04-24 11:50:49 -04:00
renovate[bot]
ec915f622b fix(deps): update dependency @edx/frontend-component-header to v6.4.0 (#766)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-23 20:15:13 +00:00
Régis Behmo
60da5eafc4 chore: remove husky 🪓🐶 (#761) 2025-04-09 14:53:26 -04:00
Hunia Fatima
05cf174335 feat: upgrade react to v18 (#759) 2025-04-09 10:11:28 -04:00
Brian Smith
ff72dab001 chore(deps): update @openedx dependencies to versions that support React 18 (#758) 2025-03-27 16:16:13 -04:00
Sarina Canelake
c38887ec2b docs: Update edx.rtd links to docs.openedx.org 2025-03-25 14:42:44 -03:00
Feanil Patel
58aa512f47 Merge pull request #749 from salman2013/salman/update-catalog-info-file
Update catalog-info file for release data
2025-01-17 11:13:22 -05:00
salman2013
62a5c11f52 chore: Update catalog-info file for release data and remove openedx.yaml file 2025-01-15 16:54:09 +05:00
Ihor Romaniuk
3ef8515891 fix: block overflow when editing comment (#706) 2024-12-10 12:19:40 +05:00
Farhaan Bukhsh
3cc39d83c4 fix: Adds a fix to remove "Add a post" button when discussion is restricted (#742)
"Add a post" button  was visible even though the banner says that posting is
restricted. This change helps in removing the button when posting is restricted.

Signed-off-by: Farhaan Bukhsh <farhaan@opencraft.com>
2024-11-21 18:11:30 +05:30
Brian Smith
af6cd1853c revert: revert: "test: Remove support for Node 18 (#736)" (#740) (#744)
This reverts commit 472bbe2d96.
2024-11-01 09:38:17 -04:00
Brian Smith
79a2fa404b feat(deps): update header to 5.6.0 (#741) 2024-10-22 19:19:10 -04:00
Brian Smith
472bbe2d96 Revert "test: Remove support for Node 18 (#736)" (#740)
This reverts commit dc5f097736. Node 18 removal PRs should be merged after Sumac is cut.
2024-10-22 13:55:44 -04:00
Bilal Qamar
dc5f097736 test: Remove support for Node 18 (#736) 2024-09-10 14:38:24 +05:00
Bilal Qamar
5e8c8254b4 build: Upgrade to Node 20 (#734)
* feat: updated node to v20

* refactor: updated package-lock along with ci & lockfile version workflows

* refactor: updated lockfile version workflow

* refactor: updated package-lock
2024-09-03 12:21:05 -04:00
Bilal Qamar
0d6692cf8c test: Add Node 20 to CI matrix (#735) 2024-08-22 14:37:56 -04:00
sundasnoreen12
3391e966f3 feat: added help section for post documentation (#733)
* feat: added help section for post documentation

* refactor: refactor code
2024-08-08 18:13:08 +05:00
Bilal Qamar
4297a96102 feat: updated frontend-build & frontend-platform major versions (#626)
* chore: bumped jest to v29

* refactor: updated frontend-build

* refactor: updated package-lock

* feat: updated build and platform major versions, along with edx packages

* refactor: updated package-lock

* refactor: updated package-lock
2024-08-02 16:32:34 +05:00
sundasnoreen12
db883ca7cd feat: added draft functionality for comment and responses (#727)
* feat: added draft functionality for comment and responses

* fix: fixed comment update issue:

* test: added draft test case

* test: added mock conditions for tinymce

* refactor: refactor code

* test: added test cases

* refactor: refactor hook file

* refactor: fixed review issues

* refactor: memoize function

* refactor: refactor code

* test: added update comment test case

* refactor: refactor remove hook method

* test: fixed test cases issue
2024-07-24 17:24:23 +05:00
57 changed files with 20126 additions and 16173 deletions

2
.env
View File

@@ -22,3 +22,5 @@ USER_INFO_COOKIE_NAME=''
SUPPORT_URL=''
LEARNER_FEEDBACK_URL=''
STAFF_FEEDBACK_URL=''
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -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={}

View File

@@ -9,17 +9,16 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Nodejs
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VER }}
node-version-file: '.nvmrc'
- name: Install dependencies
run: npm ci
- name: Validate package-lock.json changes

View File

@@ -10,4 +10,4 @@ on:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfile-check.yml@master
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ node_modules
npm-debug.log
coverage
module.config.js
env.config.*
dist/
src/i18n/transifex_input.json

2
.nvmrc
View File

@@ -1 +1 @@
18
20

View File

@@ -76,7 +76,7 @@ How to Contribute
Details about how to become a contributor to the Open edX project may be found in the wiki at `How to contribute`_
.. _How to contribute: https://edx.readthedocs.io/projects/edx-developer-guide/en/latest/process/index.html
.. _How to contribute: https://docs.openedx.org/en/latest/developers/references/developer_guide/process/index.html
PR description template should be automatically applied if you are sending PR from github interface; otherwise you
can find it it at `PULL_REQUEST_TEMPLATE.md <https://github.com/openedx/frontend-app-discussions/blob/master/.github/pull_request_template.md>`_
@@ -125,4 +125,4 @@ Please see `edx/frontend-platform's i18n module <https://edx.github.io/frontend-
Reporting Security Issues
=========================
Please do not report security issues in public. Please email security@openedx.org.
Please do not report security issues in public. Please email security@openedx.org.

View File

@@ -12,6 +12,7 @@ metadata:
icon: "Web"
annotations:
openedx.org/arch-interest-groups: ""
openedx.org/release: "master"
spec:
owner: group:edx-infinity
type: 'website'

View File

@@ -1,11 +0,0 @@
# This file describes this Open edX repo, as described in OEP-2:
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
nick: tmpa
oeps: {}
owner: edx/arch-team
openedx-release:
# The openedx-release key is described in OEP-10:
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
ref: master

34806
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,13 +16,9 @@
"lint:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"dev": "PUBLIC_PATH=/discussions/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
"test": "fedx-scripts jest --coverage --passWithNoTests"
},
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/openedx/frontend-app-discussions#readme",
@@ -34,13 +30,13 @@
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-header": "^5.0.2",
"@edx/frontend-platform": "^7.1.0",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-platform": "^8.3.3",
"@edx/openedx-atlas": "^0.6.0",
"@openedx/frontend-slot-footer": "^1.0.2",
"@openedx/paragon": "^22.1.1",
"@openedx/paragon": "^23.4.5",
"@reduxjs/toolkit": "1.9.7",
"@tinymce/tinymce-react": "3.13.1",
"@tinymce/tinymce-react": "5.1.1",
"babel-polyfill": "6.26.0",
"classnames": "2.5.1",
"core-js": "3.21.1",
@@ -49,8 +45,9 @@
"lodash.snakecase": "4.1.1",
"prop-types": "15.8.1",
"raw-loader": "4.0.2",
"react": "17.0.2",
"react-dom": "17.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",
@@ -64,17 +61,16 @@
"devDependencies": {
"@edx/browserslist-config": "1.2.0",
"@edx/reactifex": "1.1.0",
"@openedx/frontend-build": "^13.0.28",
"@openedx/frontend-build": "^14.3.3",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
"@testing-library/react": "14.3.1",
"@testing-library/user-event": "13.5.0",
"axios": "^0.28.0",
"axios-mock-adapter": "1.22.0",
"babel-plugin-react-intl": "8.2.25",
"eslint-plugin-simple-import-sort": "7.0.0",
"glob": "7.2.0",
"husky": "7.0.4",
"jest": "27.5.1",
"jest": "29.7.0",
"rosie": "2.1.1"
}
}

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,74 @@
import React, { useState } from 'react';
import {
Hyperlink, Icon, IconButton, IconButtonWithTooltip,
} from '@openedx/paragon';
import { Close, HelpOutline } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from '../discussions/posts/post-editor/messages';
const PostHelpPanel = () => {
const intl = useIntl();
const [showHelpPane, setShowHelpPane] = useState(false);
return (
<>
<div className="d-flex justify-content-end">
<IconButtonWithTooltip
onClick={() => setShowHelpPane(true)}
alt={intl.formatMessage(messages.showHelpIcon)}
tooltipContent={<div>{intl.formatMessage(messages.discussionHelpTooltip)}</div>}
src={HelpOutline}
iconAs={Icon}
size="inline"
className="float-right p-3 help-icon"
iconClassNames="help-icon-size"
data-testid="help-button"
invertColors
isActive
/>
</div>
{showHelpPane && (
<div
className="w-100 p-2 bg-light-200 rounded box-shadow-down-1 post-preview overflow-auto my-3"
style={{ minHeight: '200px', wordBreak: 'break-word' }}
>
<IconButton
onClick={() => setShowHelpPane(false)}
alt={intl.formatMessage(messages.actionsAlt)}
src={Close}
iconAs={Icon}
size="inline"
className="float-right p-3"
iconClassNames="icon-size"
data-testid="hide-help-button"
/>
<div className="pt-2 px-3">
<h4 className="font-weight-bold">{intl.formatMessage(messages.discussionHelpHeader)}</h4>
<p className="pt-2">{intl.formatMessage(messages.discussionHelpDescription)}</p>
<Hyperlink
target="_blank"
className="w-100"
destination="https://support.edx.org/hc/en-us/sections/115004169687-Participating-in-Course-Discussions"
showLaunchIcon={false}
>
{intl.formatMessage(messages.discussionHelpCourseParticipation)}
</Hyperlink>
<Hyperlink
target="_blank"
className="w-100"
destination="https://support.edx.org/hc/en-us/articles/360000035267-Entering-math-expressions-in-course-discussions"
showLaunchIcon={false}
>
{intl.formatMessage(messages.discussionHelpMathExpressions)}
</Hyperlink>
</div>
</div>
)}
</>
);
};
export default React.memo(PostHelpPanel);

View File

@@ -94,6 +94,7 @@ const ActionsDropdown = ({
handleActions(action.action);
}}
className="d-flex justify-content-start actions-dropdown-item"
data-testId={action.id}
>
<Icon
src={action.icon}

View File

@@ -106,8 +106,8 @@ describe('HoverCard', () => {
});
test('it should have hover card on post', async () => {
await waitFor(() => renderComponent(discussionPostId));
const post = screen.getByTestId('post-thread-1');
renderComponent(discussionPostId);
const post = await screen.findByTestId('post-thread-1');
expect(within(post).getByTestId('hover-card-thread-1')).toBeInTheDocument();
});

View File

@@ -33,14 +33,11 @@ import { ContentSelectors } from './constants';
import {
selectAreThreadsFiltered,
selectEnableInContext,
selectIsCourseAdmin,
selectIsCourseStaff,
selectIsPostingEnabled,
selectIsUserLearner,
selectPostThreadCount,
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
selectUserIsStaff,
} from './selectors';
import fetchCourseConfig from './thunks';
@@ -220,20 +217,13 @@ export const useCurrentDiscussionTopic = () => {
export const useUserPostingEnabled = () => {
const isPostingEnabled = useSelector(selectIsPostingEnabled);
const isUserAdmin = useSelector(selectUserIsStaff);
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const isUserGroupTA = useSelector(selectUserIsGroupTa);
const isCourseAdmin = useSelector(selectIsCourseAdmin);
const isCourseStaff = useSelector(selectIsCourseStaff);
const isPrivileged = isUserAdmin || userHasModerationPrivileges || isUserGroupTA || isCourseAdmin || isCourseStaff;
const isPrivileged = userHasModerationPrivileges || isUserGroupTA;
return (isPostingEnabled || isPrivileged);
};
function camelToConstant(string) {
return string.replace(/[A-Z]/g, (match) => `_${match}`).toUpperCase();
}
export const useTourConfiguration = () => {
const intl = useIntl();
const dispatch = useDispatch();
@@ -258,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]);

View File

@@ -19,11 +19,11 @@ const courseConfigApiUrl = getCourseConfigApiUrl();
let store;
let axiosMock;
const generateApiResponse = (isPostingEnabled, isCourseAdmin = false) => ({
const generateApiResponse = (isPostingEnabled, hasModerationPrivileges = false) => ({
isPostingEnabled,
hasModerationPrivileges: false,
hasModerationPrivileges,
isGroupTa: false,
isCourseAdmin,
isCourseAdmin: false,
isCourseStaff: false,
isUserAdmin: false,
});
@@ -160,7 +160,7 @@ describe('Hooks', () => {
expect(queryByText('false')).toBeInTheDocument();
});
test('when posting is not disabled and Role is not Learner return true', async () => {
test('when posting is disabled and Role is not Learner return true', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
.reply(200, generateApiResponse(false, true));
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);

View File

@@ -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,

View File

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

View File

@@ -1,6 +1,5 @@
import { render, screen } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import { Context as ResponsiveContext } from 'react-responsive';
import { MemoryRouter } from 'react-router-dom';
@@ -85,7 +84,7 @@ describe('DiscussionSidebar', () => {
},
})]);
renderComponent();
await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy());
await screen.findAllByText('Thread by other users');
expect(screen.queryByText('Thread by abc123')).not.toBeInTheDocument();
});
@@ -100,7 +99,7 @@ describe('DiscussionSidebar', () => {
},
})]);
renderComponent();
await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy());
await screen.findAllByText('Thread by other users');
expect(screen.queryByText('Thread by abc123')).not.toBeInTheDocument();
expect(container.querySelectorAll('.discussion-post')).toHaveLength(postCount);
});

View File

@@ -27,7 +27,7 @@ import { selectPostEditorVisible } from '../posts/data/selectors';
import { isCourseStatusValid } from '../utils';
import useFeedbackWrapper from './FeedbackWrapper';
const FooterSlot = lazy(() => import('@openedx/frontend-slot-footer'));
const FooterSlot = lazy(() => import('@edx/frontend-component-footer').then(module => ({ default: module.FooterSlot })));
const PostActionsBar = lazy(() => import('../posts/post-actions-bar/PostActionsBar'));
const CourseTabsNavigation = lazy(() => import('../../components/NavigationBar/CourseTabsNavigation'));
const LegacyBreadcrumbMenu = lazy(() => import('../navigation/breadcrumb-menu/LegacyBreadcrumbMenu'));

View File

@@ -90,8 +90,7 @@ describe('DiscussionsHome', () => {
test('full view should hide close button', async () => {
renderComponent(`/${courseId}/topics`);
expect(screen.queryByText(navigationBarMessages.allTopics.defaultMessage))
.toBeInTheDocument();
await screen.findByText(navigationBarMessages.allTopics.defaultMessage);
expect(screen.queryByRole('button', { name: 'Close' }))
.not
.toBeInTheDocument();
@@ -144,9 +143,7 @@ describe('DiscussionsHome', () => {
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
await renderComponent(`/${courseId}/${searchByEndPoint}`);
waitFor(() => {
expect(screen.queryByText('Add a post')).toBeInTheDocument();
});
await screen.findByText('Add a post');
});
it.each([
@@ -170,9 +167,7 @@ describe('DiscussionsHome', () => {
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
await renderComponent(`/${courseId}/${searchByEndPoint}`);
waitFor(() => {
expect(screen.queryByText(result)).toBeInTheDocument();
});
await screen.findByText(result);
});
it.each([
@@ -199,9 +194,7 @@ describe('DiscussionsHome', () => {
await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState);
await renderComponent(`/${courseId}/${searchByEndPoint}`);
waitFor(() => {
expect(screen.queryByText('No topic selected')).toBeInTheDocument();
});
await screen.findByText('No topic selected');
},
);
@@ -210,9 +203,7 @@ describe('DiscussionsHome', () => {
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
await renderComponent(`/${courseId}/learners`);
waitFor(() => {
expect(screen.queryByText('Nothing here yet')).toBeInTheDocument();
});
await screen.findByText('Nothing here yet');
});
it('should display post editor form when click on add a post button for posts', async () => {
@@ -235,10 +226,10 @@ describe('DiscussionsHome', () => {
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
await renderComponent(`/${courseId}/topics`);
await waitFor(() => expect(screen.queryByText('Nothing here yet')).toBeInTheDocument());
await screen.findByText('Nothing here yet');
await act(async () => {
fireEvent.click(screen.queryByText('Add a post'));
fireEvent.click((await screen.findAllByText('Add a post'))[0]);
});
await waitFor(() => expect(container.querySelector('.post-form')).toBeInTheDocument());
@@ -247,27 +238,27 @@ describe('DiscussionsHome', () => {
it('should display Add a post button for legacy topics view', async () => {
await renderComponent(`/${courseId}/topics/topic-1`);
await waitFor(() => expect(screen.queryByText('Add a post')).toBeInTheDocument());
await screen.findByText('Add a post');
});
it('should display No post selected for legacy topics view', async () => {
await setUpV1TopicsMockResponse();
await renderComponent(`/${courseId}/topics/category-1-topic-1`);
await waitFor(() => expect(screen.queryByText('No post selected')).toBeInTheDocument());
await screen.findByText('No post selected');
});
it('should display No topic selected for legacy topics view', async () => {
await setUpV1TopicsMockResponse();
await renderComponent(`/${courseId}/topics`);
await waitFor(() => expect(screen.queryByText('No topic selected')).toBeInTheDocument());
await screen.findByText('No topic selected');
});
it('should display navigation tabs', async () => {
renderComponent(`/${courseId}/topics`);
await waitFor(() => expect(screen.queryByText('Discussion')).toBeInTheDocument());
await screen.findByText('Discussion');
});
it('should display content unavailable message when the user is not enrolled in the course.', async () => {
@@ -276,7 +267,7 @@ describe('DiscussionsHome', () => {
renderComponent();
await waitFor(() => expect(screen.queryByText('Content unavailable')).toBeInTheDocument());
await screen.findByText('Content unavailable');
});
it('should redirect to dashboard when the user clicks on the Enroll button.', async () => {

View File

@@ -66,15 +66,13 @@ describe('EmptyTopics', () => {
test('"no topic selected" text shown when viewing topics page', async () => {
renderComponent(`/${courseId}/topics/`);
expect(screen.queryByText(messages.emptyTitle.defaultMessage))
.toBeInTheDocument();
await screen.findByText(messages.emptyTitle.defaultMessage);
});
test('"no post selected" text shown when viewing a specific topic', async () => {
await setupMockResponse();
renderComponent(`/${courseId}/topics/ncwtopic-3/`);
expect(screen.queryByText(messages.noPostSelected.defaultMessage))
.toBeInTheDocument();
await screen.findByText(messages.noPostSelected.defaultMessage);
});
});

View File

@@ -283,9 +283,9 @@ describe('InContext Topic Posts View', () => {
await setupTopicsMockResponse(0, 0, 0);
await renderComponent({ topicId: 'test-topic', category: 'test-category' });
await waitFor(() => expect(within(container).queryByText('Nothing here yet')).toBeInTheDocument());
expect(within(container).queryByText('No topic exists')).toBeInTheDocument();
expect(within(container).queryByText('Unnamed Topic')).toBeInTheDocument();
await within(container).findByText('Nothing here yet');
await within(container).findByText('No topic exists');
await within(container).findByText('Unnamed Topic');
},
);

View File

@@ -2,6 +2,7 @@ import {
fireEvent, render, screen, waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
@@ -165,9 +166,10 @@ describe('InContext Topics View', () => {
it('The subsection should have a title name, be clickable, and have the stats', async () => {
await setupMockResponse();
renderComponent();
await screen.findByTestId('redux-provider');
const subsectionObject = coursewareTopics[0].children[0];
const subSection = await container.querySelector(`[data-subsection-id=${subsectionObject.id}]`);
const subSectionTitle = await within(subSection).queryByText(subsectionObject.displayName);
const subSectionTitle = await within(subSection).findByText(subsectionObject.displayName);
const statsList = await subSection.querySelectorAll('.icon-size');
expect(subSectionTitle).toBeInTheDocument();
@@ -177,11 +179,12 @@ describe('InContext Topics View', () => {
it('Subsection names should be clickable and redirected to the units lists', async () => {
await setupMockResponse();
renderComponent();
await screen.findByTestId('redux-provider');
const subsectionObject = coursewareTopics[0].children[0];
const subSection = await container.querySelector(`[data-subsection-id=${subsectionObject.id}]`);
await act(async () => { fireEvent.click(subSection); });
await userEvent.click(subSection);
await waitFor(async () => {
const backButton = await screen.getByLabelText('Back to topics list');
const topicsList = await screen.getByRole('list');
@@ -198,9 +201,11 @@ describe('InContext Topics View', () => {
it('The number of units should be matched with the actual unit length.', async () => {
await setupMockResponse();
renderComponent();
await screen.findByTestId('redux-provider');
const subSection = await container.querySelector(`[data-subsection-id=${coursewareTopics[0].children[0].id}]`);
await act(async () => { fireEvent.click(subSection); });
await userEvent.click(subSection);
await waitFor(async () => {
const units = await container.querySelectorAll('.discussion-topic');
@@ -211,12 +216,14 @@ describe('InContext Topics View', () => {
it('A unit should have a title and should be clickable', async () => {
await setupMockResponse();
renderComponent();
await screen.findByTestId('redux-provider');
const subSectionObject = coursewareTopics[0].children[0];
const unitObject = subSectionObject.children[0];
const subSection = await container.querySelector(`[data-subsection-id=${subSectionObject.id}]`);
await act(async () => { fireEvent.click(subSection); });
await userEvent.click(subSection);
await waitFor(async () => {
const unitElement = await screen.findByText(unitObject.name);
const unitContainer = await container.querySelector(`[data-topic-id=${unitObject.id}]`);

View File

@@ -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
);

View File

@@ -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,

View File

@@ -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 }))
@@ -671,6 +744,151 @@ describe('ThreadView', () => {
expect(screen.queryByTestId('reply-comment-3')).not.toBeInTheDocument();
});
it('successfully added comment in the draft.', async () => {
await waitFor(() => renderComponent(discussionPostId));
await act(async () => {
fireEvent.click(screen.queryByText('Add comment'));
});
await waitFor(() => {
fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Draft comment!' } });
});
await act(async () => {
fireEvent.click(screen.queryByText('Cancel'));
});
await act(async () => {
fireEvent.click(screen.queryByText('Add comment'));
});
expect(screen.queryByText('Draft comment!')).toBeInTheDocument();
});
it('successfully updated comment in the draft.', async () => {
await waitFor(() => renderComponent(discussionPostId));
const comment = screen.queryByTestId('reply-comment-2');
const actionBtn = comment.querySelector('button[aria-label="Actions menu"]');
await act(async () => {
fireEvent.click(actionBtn);
});
await act(async () => {
fireEvent.click(screen.queryByTestId('edit'));
});
await waitFor(() => {
fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Draft comment!' } });
});
await act(async () => {
fireEvent.click(screen.queryByText('Cancel'));
});
await act(async () => {
fireEvent.click(actionBtn);
});
await act(async () => {
fireEvent.click(screen.queryByTestId('edit'));
});
await act(async () => {
fireEvent.click(screen.queryByText('Submit'));
});
await waitFor(() => expect(screen.queryByText('Draft comment!')).toBeInTheDocument());
});
it('successfully removed comment from the draft.', async () => {
await waitFor(() => renderComponent(discussionPostId));
await act(async () => {
fireEvent.click(screen.queryByText('Add comment'));
});
await waitFor(() => {
fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Draft comment 123!' } });
});
await act(async () => {
fireEvent.click(screen.queryByText('Submit'));
});
await act(async () => {
fireEvent.click(screen.queryAllByText('Add comment')[0]);
});
expect(screen.queryByTestId('tinymce-editor').value).toBe('Draft comment 123!');
});
it('successfully added response in the draft.', async () => {
await waitFor(() => renderComponent(discussionPostId));
await act(async () => {
fireEvent.click(screen.queryByText('Add response'));
});
await waitFor(() => {
fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Draft Response!' } });
});
await act(async () => {
fireEvent.click(screen.queryByText('Cancel'));
});
await act(async () => {
fireEvent.click(screen.queryByText('Add response'));
});
expect(screen.queryByText('Draft Response!')).toBeInTheDocument();
});
it('successfully removed response from the draft.', async () => {
await waitFor(() => renderComponent(discussionPostId));
await act(async () => {
fireEvent.click(screen.queryByText('Add response'));
});
await waitFor(() => {
fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Draft Response!' } });
});
await act(async () => {
fireEvent.click(screen.queryByText('Submit'));
});
await act(async () => {
fireEvent.click(screen.queryByText('Add response'));
});
expect(screen.queryByTestId('tinymce-editor').value).toBe('Draft Response!');
});
it('successfully maintain response for the specific post in the draft.', async () => {
await waitFor(() => renderComponent(discussionPostId));
await act(async () => {
fireEvent.click(screen.queryByText('Add response'));
});
await waitFor(() => {
fireEvent.change(screen.queryByTestId('tinymce-editor'), { target: { value: 'Hello, world!' } });
});
await waitFor(() => renderComponent('thread-2'));
await act(async () => {
fireEvent.click(screen.queryAllByText('Add response')[0]);
});
expect(screen.queryByText('Hello, world!')).toBeInTheDocument();
});
it('pressing load more button will load next page of replies', async () => {
await waitFor(() => renderComponent(discussionPostId));
@@ -830,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();
});
});

View File

@@ -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"
>

View File

@@ -1,10 +1,11 @@
import React, {
useCallback, useContext, useEffect, useRef,
useCallback, useContext, useEffect, useRef, useState,
} from 'react';
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,12 +18,15 @@ import PostPreviewPanel from '../../../../components/PostPreviewPanel';
import useDispatchWithState from '../../../../data/hooks';
import DiscussionContext from '../../../common/context';
import {
selectCaptchaSettings,
selectModerationSettings,
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
selectUserIsStaff,
} from '../../../data/selectors';
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../../utils';
import { extractContent, formikCompatibleHandler, isFormikFieldInvalid } from '../../../utils';
import { useDraftContent } from '../../data/hooks';
import { setDraftComments, setDraftResponses } from '../../data/slices';
import { addComment, editComment } from '../../data/thunks';
import messages from '../../messages';
@@ -38,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);
@@ -45,6 +50,15 @@ const CommentEditor = ({
const userIsStaff = useSelector(selectUserIsStaff);
const { editReasons } = useSelector(selectModerationSettings);
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)
@@ -58,19 +72,43 @@ const CommentEditor = ({
const validationSchema = Yup.object().shape({
comment: Yup.string()
.required(),
...(shouldRequireCaptcha ? { recaptchaToken: Yup.string().required() } : { }),
...editReasonCodeValidation,
...(shouldRequireCaptcha ? captchaValidation : {}),
});
const initialValues = {
comment: rawBody,
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]);
const deleteEditorContent = useCallback(async () => {
const { updatedResponses, updatedComments } = removeDraftContent(parentId, id, threadId);
if (parentId) {
await dispatch(setDraftComments(updatedComments));
} else {
await dispatch(setDraftResponses(updatedResponses));
}
}, [parentId, id, threadId, setDraftComments, setDraftResponses]);
const saveUpdatedComment = useCallback(async (values, { resetForm }) => {
if (id) {
const payload = {
@@ -79,14 +117,15 @@ 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) {
editorRef.current.plugins.autosave.removeDraft();
}
handleCloseEditor(resetForm);
}, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor]);
deleteEditorContent();
}, [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}`;
@@ -97,11 +136,33 @@ const CommentEditor = ({
}
}, [formRef]);
useEffect(() => {
const draftHtml = getDraftContent(parentId, threadId, id) || rawBody;
setEditorContent(draftHtml);
}, [parentId, threadId, id]);
const saveDraftContent = async (content) => {
const draftDataContent = extractContent(content);
const { updatedResponses, updatedComments } = addDraftContent(
draftDataContent,
parentId,
id,
threadId,
);
if (parentId) {
await dispatch(setDraftComments(updatedComments));
} else {
await dispatch(setDraftResponses(updatedResponses));
}
};
return (
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={saveUpdatedComment}
enableReinitialize
>
{({
values,
@@ -111,6 +172,7 @@ const CommentEditor = ({
handleBlur,
handleChange,
resetForm,
setFieldValue,
}) => (
<Form onSubmit={handleSubmit} className={formClasses} ref={formRef}>
{canDisplayEditReason && (
@@ -151,7 +213,10 @@ const CommentEditor = ({
id={editorId}
value={values.comment}
onEditorChange={formikCompatibleHandler(handleChange, 'comment')}
onBlur={formikCompatibleHandler(handleBlur, 'comment')}
onBlur={(content) => {
formikCompatibleHandler(handleChange, 'comment');
saveDraftContent(content);
}}
/>
{isFormikFieldInvalid('comment', {
errors,
@@ -163,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"

View File

@@ -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',

View File

@@ -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',
@@ -129,7 +132,7 @@ const Reply = ({ responseId }) => {
</div>
<div
className="bg-light-300 pl-4 pt-2.5 pr-2.5 pb-10px flex-fill"
style={{ borderRadius: '0rem 0.375rem 0.375rem' }}
style={{ borderRadius: '0rem 0.375rem 0.375rem', maxWidth: 'calc(100% - 50px)' }}
>
<div className="d-flex flex-row justify-content-between">
<AuthorLabel

View File

@@ -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;
};

View File

@@ -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');
});
});

View File

@@ -3,6 +3,7 @@ import {
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { v4 as uuidv4 } from 'uuid';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
@@ -13,7 +14,8 @@ import { selectThread } from '../../posts/data/selectors';
import { markThreadAsRead } from '../../posts/data/thunks';
import { filterPosts } from '../../utils';
import {
selectCommentSortOrder, selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages,
selectCommentSortOrder, selectDraftComments, selectDraftResponses,
selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages,
} from './selectors';
import { fetchThreadComments } from './thunks';
@@ -102,3 +104,73 @@ export function useCommentsCount(postId) {
return commentsLength;
}
export const useDraftContent = () => {
const comments = useSelector(selectDraftComments);
const responses = useSelector(selectDraftResponses);
const getObjectByParentId = (data, parentId, isComment, id) => Object.values(data)
.find(draft => (isComment ? draft.parentId === parentId && (id ? draft.id === id : draft.isNewContent === true)
: draft.threadId === parentId && (id ? draft.id === id : draft.isNewContent === true)));
const updateDraftData = (draftData, newDraftObject) => ({
...draftData,
[newDraftObject.id]: newDraftObject,
});
const addDraftContent = (content, parentId, id, threadId) => {
const data = parentId ? comments : responses;
const draftParentId = parentId || threadId;
const isComment = !!parentId;
const existingObj = getObjectByParentId(data, draftParentId, isComment, id);
const newObject = existingObj
? { ...existingObj, content }
: {
threadId,
content,
parentId,
id: id || uuidv4(),
isNewContent: !id,
};
const updatedComments = parentId ? updateDraftData(comments, newObject) : comments;
const updatedResponses = !parentId ? updateDraftData(responses, newObject) : responses;
return { updatedComments, updatedResponses };
};
const getDraftContent = (parentId, threadId, id) => {
if (id) {
return parentId ? comments?.[id]?.content : responses?.[id]?.content;
}
const data = parentId ? comments : responses;
const draftParentId = parentId || threadId;
const isComment = !!parentId;
return getObjectByParentId(data, draftParentId, isComment, id)?.content;
};
const removeItem = (draftData, objId) => {
const { [objId]: _, ...newDraftData } = draftData;
return newDraftData;
};
const updateContent = (items, itemId, parentId, isComment) => {
const itemObj = itemId ? items[itemId] : getObjectByParentId(items, parentId, isComment, itemId);
return itemObj ? removeItem(items, itemObj.id) : items;
};
const removeDraftContent = (parentId, id, threadId) => {
const updatedResponses = !parentId ? updateContent(responses, id, threadId, false) : responses;
const updatedComments = parentId ? updateContent(comments, id, parentId, true) : comments;
return { updatedResponses, updatedComments };
};
return {
addDraftContent,
getDraftContent,
removeDraftContent,
};
};

View File

@@ -47,3 +47,7 @@ export const selectCommentCurrentPage = commentId => (
export const selectCommentsStatus = state => state.comments.status;
export const selectCommentSortOrder = state => state.comments.sortOrder;
export const selectDraftComments = state => state.comments.draftComments;
export const selectDraftResponses = state => state.comments.draftResponses;

View File

@@ -22,6 +22,8 @@ const commentsSlice = createSlice({
pagination: {},
responsesPagination: {},
sortOrder: true,
draftResponses: {},
draftComments: {},
},
reducers: {
fetchCommentsRequest: (state) => (
@@ -257,6 +259,18 @@ const commentsSlice = createSlice({
sortOrder: payload,
}
),
setDraftComments: (state, { payload }) => (
{
...state,
draftComments: payload,
}
),
setDraftResponses: (state, { payload }) => (
{
...state,
draftResponses: payload,
}
),
},
});
@@ -282,6 +296,8 @@ export const {
deleteCommentRequest,
deleteCommentSuccess,
setCommentSortOrder,
setDraftComments,
setDraftResponses,
} = commentsSlice.actions;
export const commentsReducer = commentsSlice.reducer;

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);
}
});

View File

@@ -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
);

View File

@@ -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) {

View File

@@ -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}

View File

@@ -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';
@@ -18,6 +19,7 @@ import { AppContext } from '@edx/frontend-platform/react';
import { TinyMCEEditor } from '../../../components';
import FormikErrorFeedback from '../../../components/FormikErrorFeedback';
import PostHelpPanel from '../../../components/PostHelpPanel';
import PostPreviewPanel from '../../../components/PostPreviewPanel';
import useDispatchWithState from '../../../data/hooks';
import selectCourseCohorts from '../../cohorts/data/selectors';
@@ -26,8 +28,10 @@ import DiscussionContext from '../../common/context';
import { useCurrentDiscussionTopic } from '../../data/hooks';
import {
selectAnonymousPostingConfig,
selectCaptchaSettings,
selectDivisionSettings,
selectEnableInContext,
selectIsNotifyAllLearnersEnabled,
selectModerationSettings,
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
@@ -41,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';
@@ -58,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);
@@ -78,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)
@@ -88,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.
@@ -107,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,
@@ -148,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,
@@ -160,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(() => {
@@ -215,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) => (
@@ -239,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' }}>
@@ -279,6 +328,7 @@ const PostEditor = ({
aria-describedby="topicAreaInput"
floatingLabel={intl.formatMessage(messages.topicArea)}
disabled={enableInContextSidebar}
data-testid="topic-select"
>
{nonCoursewareTopics.map(topic => (
<option
@@ -366,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>
@@ -409,12 +460,29 @@ const PostEditor = ({
onEditorChange={formikCompatibleHandler(handleChange, 'comment')}
onBlur={formikCompatibleHandler(handleBlur, 'comment')}
/>
<PostHelpPanel />
<FormikErrorFeedback name="comment" />
</div>
<PostPreviewPanel htmlNode={values.comment} isPost editExisting={editExisting} />
<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"
@@ -445,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"

View File

@@ -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);
@@ -368,5 +583,34 @@ describe('PostEditor', () => {
expect(container.querySelector('[data-testid="hide-preview-button"]')).not.toBeInTheDocument();
});
});
it('should show Help Panel', async () => {
await renderComponent(true, `/${courseId}/posts/${threadId}/edit`);
await act(async () => {
fireEvent.click(container.querySelector('[data-testid="help-button"]'));
});
await waitFor(() => {
expect(container.querySelector('[data-testid="hide-help-button"]')).toBeInTheDocument();
});
});
it('should hide Help Panel', async () => {
await renderComponent(true, `/${courseId}/posts/${threadId}/edit`);
await act(async () => {
fireEvent.click(container.querySelector('[data-testid="help-button"]'));
});
await act(async () => {
fireEvent.click(container.querySelector('[data-testid="hide-help-button"]'));
});
await waitFor(() => {
expect(container.querySelector('[data-testid="help-button"]')).toBeInTheDocument();
expect(container.querySelector('[data-testid="hide-help-button"]')).not.toBeInTheDocument();
});
});
});
});

View File

@@ -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',
@@ -116,6 +120,36 @@ const messages = defineMessages({
defaultMessage: 'Show preview',
description: 'show preview button text to allow user to see their post content.',
},
showHelpIcon: {
id: 'discussions.editor.posts.showHelp.icon',
defaultMessage: 'Show Help',
description: 'show help icon to allow user to see important documentation.',
},
discussionHelpHeader: {
id: 'discussions.editor.posts.discussionHelpHeader',
defaultMessage: 'Discussions help',
description: 'header text for post help section.',
},
discussionHelpDescription: {
id: 'discussions.editor.posts.discussionHelpDescription',
defaultMessage: 'Course discussions give you the opportunity to start conversations, ask questions, and interact with other learners. See the links below to learn more:',
description: 'description message for post help section.',
},
discussionHelpCourseParticipation: {
id: 'discussions.editor.posts.discussionHelpCourseParticipation',
defaultMessage: 'Participating in course discussions',
description: 'Documentation link title for participating in course discussions.',
},
discussionHelpMathExpressions: {
id: 'discussions.editor.posts.discussionHelpMathExpressions',
defaultMessage: 'Entering math expressions in course discussions',
description: 'Documentation link title for entering math expressions in course discussions.',
},
discussionHelpTooltip: {
id: 'discussions.editor.posts.discussionHelpTooltip',
defaultMessage: 'Learn more about MathJax & LaTeX',
description: 'Tooltip help message for documentation help.',
},
actionsAlt: {
id: 'discussions.actions.label',
defaultMessage: 'Actions menu',
@@ -141,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;

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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',
},
],

View File

@@ -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',
},
});

View File

@@ -316,3 +316,10 @@ export function getAuthorLabel(intl, authorLabel) {
}
export const isCourseStatusValid = (courseStatus) => [DENIED, LOADED].includes(courseStatus);
export const extractContent = (content) => {
if (typeof content === 'object') {
return content.target.getContent();
}
return content;
};

View File

@@ -1,8 +1,10 @@
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import React from 'react';
import ReactDOM from 'react-dom';
import React, { StrictMode } from 'react';
// eslint-disable-next-line import/no-unresolved
import { createRoot } from 'react-dom/client';
import {
APP_INIT_ERROR, APP_READY, initialize, mergeConfig,
@@ -17,18 +19,20 @@ import store from './store';
import './index.scss';
const rootNode = createRoot(document.getElementById('root'));
subscribe(APP_READY, () => {
ReactDOM.render(
<AppProvider store={store}>
<Head />
<DiscussionsHome />
</AppProvider>,
document.getElementById('root'),
rootNode.render(
<StrictMode>
<AppProvider store={store}>
<Head />
<DiscussionsHome />
</AppProvider>
</StrictMode>,
);
});
subscribe(APP_INIT_ERROR, (error) => {
ReactDOM.render(<ErrorPage message={error.message} />, document.getElementById('root'));
rootNode.render(<ErrorPage message={error.message} />);
});
initialize({

View File

@@ -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,11 +588,20 @@ code {
}
th, td {
border: 1px dashed $gray-200;
border: 1px dashed var(--pgn-color-gray-200);
padding: 0.4rem;
white-space: nowrap;
}
.help-icon {
margin: -47px -3px 0px 0px;
}
.help-icon-size {
height: 16px !important;
width: 16px !important;
}
@media only screen and (max-width: 367px) {
.discussion-comments h5,
@@ -603,7 +612,8 @@ th, td {
.pgn__modal,
.pgn__form-label,
.dropdown-menu,
.tox-tbtn {
.tox-tbtn,
.tooltip {
font-size: 10px !important;
}
@@ -640,7 +650,8 @@ th, td {
.pgn__form-label,
.pgn__modal,
.dropdown-menu,
.tox-tbtn {
.tox-tbtn,
.tooltip {
font-size: 12px !important;
}
@@ -659,7 +670,8 @@ th, td {
@media only screen and (min-width: 769px) {
body,
#main {
#main,
.tooltip {
font-size: 14px;
}
}

View File

@@ -1,12 +1,13 @@
# Footer Slot
### Slot ID: `footer_slot`
### Slot ID: `org.openedx.frontend.layout.footer.v1`
## Description
### Slot ID Aliases
* `footer_slot`
This slot is used to replace/modify/hide the footer.
The implementation of the `FooterSlot` component lives in [the `frontend-slot-footer` repository](https://github.com/openedx/frontend-slot-footer/).
The implementation of the `FooterSlot` component lives in [the `frontend-component-footer` repository](https://github.com/openedx/frontend-component-footer/).
## Example
@@ -23,7 +24,7 @@ import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-frame
const config = {
pluginSlots: {
footer_slot: {
'org.openedx.frontend.layout.footer.v1': {
plugins: [
{
// Hide the default footer

View File

@@ -1,3 +1,3 @@
# `frontend-app-discussions` Plugin Slots
* [`footer_slot`](./FooterSlot/)
* [`org.openedx.frontend.layout.footer.v1`](./FooterSlot/)

View File

@@ -19,25 +19,35 @@ Object.defineProperty(window, 'matchMedia', {
})),
});
global.MathJax = {
typeset: jest.fn(callback => {
if (callback) { callback(); }
}),
startup: {
defaultPageReady: jest.fn(() => Promise.resolve()),
},
};
// Provides a mock editor component that functions like tinyMCE without the overhead
const MockEditor = ({
onBlur,
onEditorChange,
value,
}) => (
<textarea
data-testid="tinymce-editor"
value={value}
onChange={(event) => {
onEditorChange(event.currentTarget.value);
}}
onBlur={event => {
onBlur(event.currentTarget.value);
}}
onBlur={onBlur}
/>
);
MockEditor.propTypes = {
onBlur: PropTypes.func.isRequired,
onEditorChange: PropTypes.func.isRequired,
value: PropTypes.string.isRequired,
};
jest.mock('@tinymce/tinymce-react', () => {
const originalModule = jest.requireActual('@tinymce/tinymce-react');