Compare commits

...

20 Commits

Author SHA1 Message Date
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
ayesha waris
422fbf6173 fix: fixed author liking its own post (#720) 2024-06-26 17:13:52 +05:00
Ahtisham Shahid
e862ee6fb1 fix: post editor breaking for moderator (#717)
fix: updated unit tests

fix: updated unit tests
2024-06-21 12:29:27 +05:00
Adolfo R. Brandes
eeae6d45ce build: Update codecov and use token
Update codecov to the latest version and start using the org-wide token for uploads.

See https://github.com/openedx/wg-frontend/issues/179
2024-06-17 12:02:50 -03:00
ayesha waris
71b88bcea3 fix: fixed sidebar inconsistent font size (#716) 2024-06-11 18:25:33 +05:00
Ahtisham Shahid
c808069fe1 Revert "feat: updated course config api version (#702)" (#715)
This reverts commit 8d86e6dcc0.
2024-06-11 15:25:41 +05:00
ayesha waris
b9543c6d9c fix: fixed font-size of load more buttons and add a post heading (#708)
* fix: fixed font-size of load more buttons and add a post heading

* fix: fixed font-size of time stamps

* fix: fixed width of actions dropdown modal
2024-05-28 18:13:03 +05:00
ayesha waris
a545d0b9f6 fix: responsiveness of MFE discussions (#697)
* fix: fixed sidebar xl screen width

* fix: fixed failing test runs

* refactor: removed unused css classes
2024-05-27 16:48:53 +05:00
Ahtisham Shahid
8d86e6dcc0 feat: updated course config api version (#702) 2024-05-24 05:38:30 -04:00
sundasnoreen12
37781566f5 fix: fix redirection to new tab issue (#704) 2024-05-22 12:59:45 +05:00
Brian Smith
50948acfeb feat: import FooterSlot from frontend-slot-footer package 2024-05-17 09:39:00 -03:00
Awais Ansari
4de1011780 Revert "feat: use frontend-plugin-framework to provide a FooterSlot" (#700)
This reverts commit d7474782b4.
2024-05-13 18:16:56 +05:00
Brian Smith
d7474782b4 feat: use frontend-plugin-framework to provide a FooterSlot 2024-05-10 11:21:26 -03:00
37 changed files with 7883 additions and 6079 deletions

View File

@@ -9,17 +9,19 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20]
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: ${{ matrix.node }}
- name: Install dependencies
run: npm ci
- name: Validate package-lock.json changes
@@ -33,4 +35,7 @@ jobs:
- name: i18n_extract
run: npm run i18n_extract
- name: Coverage
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true

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

2
.nvmrc
View File

@@ -1 +1 @@
18
20

View File

@@ -52,6 +52,12 @@ Cloning and Startup
The dev server is running at `http://localhost:2002 <http://localhost:2002>`_.
Plugins
=======
This MFE can be customized using `Frontend Plugin Framework <https://github.com/openedx/frontend-plugin-framework>`_.
The parts of this MFE that can be customized in that manner are documented `here </src/plugin-slots>`_.
Getting Help
============
Please tag **@openedx/edx-infinity ** on any PRs or issues. Thanks.

13186
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,10 +34,10 @@
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^13.0.2",
"@edx/frontend-component-header": "^5.0.2",
"@edx/frontend-platform": "^7.1.0",
"@edx/frontend-component-header": "^5.6.0",
"@edx/frontend-platform": "8.0.0",
"@edx/openedx-atlas": "^0.6.0",
"@openedx/frontend-slot-footer": "^1.0.2",
"@openedx/paragon": "^22.1.1",
"@reduxjs/toolkit": "1.9.7",
"@tinymce/tinymce-react": "3.13.1",
@@ -63,8 +63,8 @@
},
"devDependencies": {
"@edx/browserslist-config": "1.2.0",
"@openedx/frontend-build": "^13.0.28",
"@edx/reactifex": "1.1.0",
"@openedx/frontend-build": "14.0.3",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
"@testing-library/user-event": "13.5.0",
@@ -74,7 +74,7 @@
"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

@@ -9,7 +9,7 @@ import { useDebounce } from '../discussions/data/hooks';
const defaultSanitizeOptions = {
USE_PROFILES: { html: true },
ADD_ATTR: ['columnalign'],
ADD_ATTR: ['columnalign', 'target'],
};
const HTMLLoader = ({

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

@@ -79,7 +79,7 @@ const ActionsDropdown = ({
placement="bottom-end"
>
<div
className="bg-white shadow d-flex flex-column"
className="bg-white shadow d-flex flex-column mt-1"
data-testid="actions-dropdown-modal-popup"
>
{actions.map(action => (
@@ -94,12 +94,13 @@ const ActionsDropdown = ({
handleActions(action.action);
}}
className="d-flex justify-content-start actions-dropdown-item"
data-testId={action.id}
>
<Icon
src={action.icon}
className="icon-size-24"
/>
<span className="font-weight-normal font-xl ml-2">
<span className="font-weight-normal ml-2">
{intl.formatMessage(action.label)}
</span>
</Dropdown.Item>

View File

@@ -38,7 +38,7 @@ const AuthorLabel = ({
const authorName = useMemo(() => (
<span
className={classNames('mr-1.5 font-size-14 font-style font-weight-500 author-name', {
className={classNames('mr-1.5 font-style font-weight-500 author-name', {
'text-gray-700': isRetiredUser,
'text-primary-500': !authorLabelMessage && !isRetiredUser,
})}
@@ -71,7 +71,7 @@ const AuthorLabel = ({
/>
{authorLabelMessage && (
<span
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
className={classNames('mr-1.5 font-style font-weight-500', {
'text-primary-500': showTextPrimary,
'text-gray-700': isRetiredUser,
})}
@@ -85,7 +85,7 @@ const AuthorLabel = ({
{postCreatedAt && (
<span
title={postCreatedAt}
className={classNames('align-content-center', {
className={classNames('align-content-center post-summary-timestamp', {
'text-white': alert,
'text-gray-500': !alert,
})}

View File

@@ -12,7 +12,7 @@ import classNames from 'classnames';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ThreadType } from '../../data/constants';
import { useUserPostingEnabled } from '../data/hooks';
import { useHasLikePermission, useUserPostingEnabled } from '../data/hooks';
import PostCommentsContext from '../post-comments/postCommentsContext';
import ActionsDropdown from './ActionsDropdown';
import DiscussionContext from './context';
@@ -33,6 +33,7 @@ const HoverCard = ({
const { enableInContextSidebar } = useContext(DiscussionContext);
const { isClosed } = useContext(PostCommentsContext);
const isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
const userHasLikePermission = useHasLikePermission(contentType, id);
return (
<div
@@ -45,7 +46,7 @@ const HoverCard = ({
<Button
variant="tertiary"
className={classNames(
'px-2.5 py-2 border-0 font-style text-gray-700 font-size-12',
'px-2.5 py-2 border-0 font-style text-gray-700',
{ 'w-100': enableInContextSidebar },
)}
onClick={() => handleResponseCommentButton()}
@@ -86,6 +87,7 @@ const HoverCard = ({
iconAs={Icon}
size="sm"
alt="Like"
disabled={!userHasLikePermission}
iconClassNames="like-icon-dimensions"
onClick={(e) => {
e.preventDefault();

View File

@@ -35,7 +35,7 @@ const ContentUnavailable = ({ subTitleMessage }) => {
{intl.formatMessage(messages.contentUnavailableTitle)}
</h3>
<p className="pb-2 text-gray-500 text-center">{intl.formatMessage(subTitleMessage)}</p>
<Button onClick={redirectToDashboard} variant="outline-dark" className="font-size-14 py-2 px-2.5">
<Button onClick={redirectToDashboard} variant="outline-dark" className="py-2 px-2.5">
{intl.formatMessage(messages.contentUnavailableAction)}
</Button>
</div>

View File

@@ -5,8 +5,10 @@ ensureConfig([
'LMS_BASE_URL',
], 'Posts API service');
export const getCourseConfigApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/`;
export const getCourseConfigApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v2/courses/`;
export const getCourseSettingsApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/`;
export const getDiscussionsConfigUrl = (courseId) => `${getCourseConfigApiUrl()}${courseId}/`;
export const getDiscussionsSettingsUrl = (courseId) => `${getCourseSettingsApiUrl()}${courseId}/settings`;
/**
* Get discussions course config
* @param {string} courseId
@@ -21,7 +23,7 @@ export async function getDiscussionsConfig(courseId) {
* @param {string} courseId
*/
export async function getDiscussionsSettings(courseId) {
const url = `${getDiscussionsConfigUrl(courseId)}settings`;
const url = `${getDiscussionsSettingsUrl(courseId)}`;
const { data } = await getAuthenticatedHttpClient().get(url);
return data;
}

View File

@@ -16,10 +16,11 @@ import { AppContext } from '@edx/frontend-platform/react';
import selectCourseTabs from '../../components/NavigationBar/data/selectors';
import { LOADED } from '../../components/NavigationBar/data/slice';
import fetchTab from '../../components/NavigationBar/data/thunks';
import { RequestStatus, Routes } from '../../data/constants';
import { ContentActions, RequestStatus, Routes } from '../../data/constants';
import { selectTopicsUnderCategory } from '../../data/selectors';
import fetchCourseBlocks from '../../data/thunks';
import DiscussionContext from '../common/context';
import PostCommentsContext from '../post-comments/postCommentsContext';
import { clearRedirect } from '../posts/data';
import { threadsLoadingStatus } from '../posts/data/selectors';
import { selectTopics } from '../topics/data/selectors';
@@ -27,7 +28,8 @@ import tourCheckpoints from '../tours/constants';
import selectTours from '../tours/data/selectors';
import { updateTourShowStatus } from '../tours/data/thunks';
import messages from '../tours/messages';
import { discussionsPath } from '../utils';
import { checkPermissions, discussionsPath } from '../utils';
import { ContentSelectors } from './constants';
import {
selectAreThreadsFiltered,
selectEnableInContext,
@@ -284,3 +286,10 @@ export const useDebounce = (value, delay) => {
);
return debouncedValue;
};
export const useHasLikePermission = (contentType, id) => {
const { postType } = useContext(PostCommentsContext);
const content = { ...useSelector(ContentSelectors[contentType](id)), postType };
return checkPermissions(content, ContentActions.VOTE);
};

View File

@@ -27,7 +27,7 @@ import { selectPostEditorVisible } from '../posts/data/selectors';
import { isCourseStatusValid } from '../utils';
import useFeedbackWrapper from './FeedbackWrapper';
const Footer = lazy(() => import('@edx/frontend-component-footer'));
const FooterSlot = lazy(() => import('@openedx/frontend-slot-footer'));
const PostActionsBar = lazy(() => import('../posts/post-actions-bar/PostActionsBar'));
const CourseTabsNavigation = lazy(() => import('../../components/NavigationBar/CourseTabsNavigation'));
const LegacyBreadcrumbMenu = lazy(() => import('../navigation/breadcrumb-menu/LegacyBreadcrumbMenu'));
@@ -82,7 +82,7 @@ const DiscussionsHome = () => {
<Suspense fallback={(<Spinner />)}>
<DiscussionContext.Provider value={discussionContextValue}>
{!enableInContextSidebar && (<Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />)}
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
<main className="container-fluid d-flex flex-column p-0 w-100 font-size" id="main" tabIndex="-1">
{!enableInContextSidebar && <CourseTabsNavigation />}
{(isEnrolled || !isUserLearner) && (
<div
@@ -175,7 +175,7 @@ const DiscussionsHome = () => {
)}
{!enableInContextSidebar && isEnrolled && (<DiscussionsProductTour />)}
</main>
{!enableInContextSidebar && <Footer />}
{!enableInContextSidebar && <FooterSlot />}
</DiscussionContext.Provider>
</Suspense>
);

View File

@@ -37,7 +37,7 @@ const LearnerCard = ({ learner }) => {
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
<div className="d-flex align-items-center flex-fill">
<div
className="text-truncate font-weight-500 font-size-14 text-primary-500 font-style"
className="text-truncate font-weight-500 text-primary-500 font-style"
>
{username}
</div>

View File

@@ -18,7 +18,7 @@ import executeThunk from '../../test-utils';
import { getCohortsApiUrl } from '../cohorts/data/api';
import fetchCourseCohorts from '../cohorts/data/thunks';
import DiscussionContext from '../common/context';
import { getCourseConfigApiUrl } from '../data/api';
import { getCourseConfigApiUrl, getCourseSettingsApiUrl } from '../data/api';
import fetchCourseConfig from '../data/thunks';
import DiscussionContent from '../discussions-home/DiscussionContent';
import { getThreadsApiUrl } from '../posts/data/api';
@@ -37,6 +37,7 @@ import '../topics/data/__factories__';
import '../cohorts/data/__factories__';
const courseConfigApiUrl = getCourseConfigApiUrl();
const courseSettingsApiUrl = getCourseSettingsApiUrl();
const commentsApiUrl = getCommentsApiUrl();
const threadsApiUrl = getThreadsApiUrl();
const discussionPostId = 'thread-1';
@@ -105,7 +106,7 @@ async function setupCourseConfig() {
{ code: 'reason-2', label: 'reason 2' },
],
});
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
axiosMock.onGet(`${courseSettingsApiUrl}${courseId}/settings`).reply(200, {});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
}
@@ -670,6 +671,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('');
});
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('');
});
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));

View File

@@ -73,7 +73,7 @@ const CommentsView = ({ threadType }) => {
onClick={handleLoadMoreResponses}
variant="link"
block="true"
className="px-4 mt-3 border-0 line-height-24 py-0 mb-2 font-style font-weight-500 font-size-14"
className="px-4 mt-3 border-0 line-height-24 py-0 mb-2 font-style font-weight-500"
data-testid="load-more-comments"
>
{intl.formatMessage(messages.loadMoreResponses)}
@@ -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 font-size-14 text-primary-500"
line-height-24 text-primary-500"
onClick={handleAddResponse}
data-testid="add-response"
>

View File

@@ -104,6 +104,10 @@ const Comment = ({
hideReportConfirmation();
}, [abuseFlagged, id, hideReportConfirmation]);
const handleCommentLike = useCallback(async () => {
await dispatch(editComment(id, { voted: !voted }));
}, [id, voted]);
const actionHandlers = useMemo(() => ({
[ContentActions.EDIT_CONTENT]: handleEditContent,
[ContentActions.ENDORSE]: handleCommentEndorse,
@@ -124,10 +128,6 @@ const Comment = ({
}
}, [isUserPrivilegedInPostingRestriction]);
const handleCommentLike = useCallback(async () => {
await dispatch(editComment(id, { voted: !voted }));
}, [id, voted]);
const handleCloseEditor = useCallback(() => {
setEditing(false);
}, []);
@@ -249,7 +249,7 @@ const Comment = ({
onClick={handleLoadMoreComments}
variant="link"
block="true"
className="font-size-14 line-height-24 font-style pt-10px border-0 font-weight-500 pb-0"
className="line-height-24 font-style pt-10px border-0 font-weight-500 pb-0"
data-testid="load-more-comments-responses"
>
{intl.formatMessage(messages.loadMoreComments)}
@@ -267,7 +267,7 @@ const Comment = ({
) : (
!isClosed && isUserPrivilegedInPostingRestriction && (inlineReplies.length >= 5) && (
<Button
className="d-flex flex-grow mt-2 font-size-14 font-style font-weight-500 text-primary-500 add-comment-btn rounded-0"
className="d-flex flex-grow mt-2 font-style font-weight-500 text-primary-500 add-comment-btn rounded-0"
variant="plain"
style={{ height: '36px' }}
onClick={handleAddCommentReply}

View File

@@ -1,5 +1,5 @@
import React, {
useCallback, useContext, useEffect, useRef,
useCallback, useContext, useEffect, useRef, useState,
} from 'react';
import PropTypes from 'prop-types';
@@ -22,7 +22,9 @@ import {
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';
@@ -45,6 +47,8 @@ const CommentEditor = ({
const userIsStaff = useSelector(selectUserIsStaff);
const { editReasons } = useSelector(selectModerationSettings);
const [submitting, dispatch] = useDispatchWithState();
const [editorContent, setEditorContent] = useState();
const { addDraftContent, getDraftContent, removeDraftContent } = useDraftContent();
const canDisplayEditReason = (edit
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
@@ -62,7 +66,7 @@ const CommentEditor = ({
});
const initialValues = {
comment: rawBody,
comment: editorContent,
editReasonCode: lastEdit?.reasonCode || (userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined),
};
@@ -71,6 +75,15 @@ const CommentEditor = ({
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 = {
@@ -86,6 +99,7 @@ const CommentEditor = ({
editorRef.current.plugins.autosave.removeDraft();
}
handleCloseEditor(resetForm);
deleteEditorContent();
}, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor]);
// The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to
// the current comment id, or the current comment parent or the curren thread.
@@ -97,11 +111,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,
@@ -151,7 +187,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,

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

@@ -18,6 +18,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';
@@ -241,7 +242,7 @@ const PostEditor = ({
resetForm,
}) => (
<Form className="m-4 card p-4 post-form" onSubmit={handleSubmit}>
<h4 className="mb-4 font-style font-size-16" style={{ lineHeight: '16px' }}>
<h4 className="mb-4 font-style" style={{ lineHeight: '16px' }}>
{editExisting
? intl.formatMessage(messages.editPostHeading)
: intl.formatMessage(messages.addPostHeading)}
@@ -409,6 +410,7 @@ const PostEditor = ({
onEditorChange={formikCompatibleHandler(handleChange, 'comment')}
onBlur={formikCompatibleHandler(handleBlur, 'comment')}
/>
<PostHelpPanel />
<FormikErrorFeedback name="comment" />
</div>
<PostPreviewPanel htmlNode={values.comment} isPost editExisting={editExisting} />
@@ -423,7 +425,7 @@ const PostEditor = ({
onBlur={handleBlur}
className="mr-4.5"
>
<span className="font-size-14">
<span>
{intl.formatMessage(messages.followPost)}
</span>
</Form.Checkbox>
@@ -436,7 +438,7 @@ const PostEditor = ({
onChange={handleChange}
onBlur={handleBlur}
>
<span className="font-size-14">
<span>
{intl.formatMessage(messages.anonymousToPeersPost)}
</span>
</Form.Checkbox>

View File

@@ -368,5 +368,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

@@ -116,6 +116,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',

View File

@@ -44,7 +44,7 @@ export const ActionItem = React.memo(({
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={value === selected ? '0' : '-1'}
>
<Icon src={Check} className={classNames('text-success mr-2', { invisible: value !== selected })} />
<Icon src={Check} className={classNames('text-success dropdown-icon-dimensions', { invisible: value !== selected })} />
<Form.Radio id={id} className="sr-only sr-only-focusable" value={value} tabIndex="0">
{label}
</Form.Radio>

View File

@@ -85,6 +85,10 @@ const Post = ({ handleAddResponseButton }) => {
updateExistingThread(postId, { pinned: !pinned }),
), [postId, pinned]);
const handlePostLike = useCallback(() => {
dispatch(updateExistingThread(postId, { voted: !voted }));
}, [postId, voted]);
const handlePostReport = useCallback(() => {
if (abuseFlagged) {
dispatch(updateExistingThread(postId, { flagged: !abuseFlagged }));
@@ -109,10 +113,6 @@ const Post = ({ handleAddResponseButton }) => {
hideClosePostModal();
}, [postId, hideClosePostModal]);
const handlePostLike = useCallback(() => {
dispatch(updateExistingThread(postId, { voted: !voted }));
}, [postId, voted]);
const handlePostFollow = useCallback(() => {
dispatch(updateExistingThread(postId, { following: !following }));
}, [postId, following]);
@@ -188,10 +188,7 @@ const Post = ({ handleAddResponseButton }) => {
</div>
{(topicContext || topic) && (
<div
className={classNames(
'mt-14px font-style font-size-12',
{ 'w-100': enableInContextSidebar, 'mb-1': !displayPostFooter },
)}
className={classNames('mt-14px font-style', { 'w-100': enableInContextSidebar, 'mb-1': !displayPostFooter })}
style={{ lineHeight: '20px' }}
>
<span className="text-gray-500" style={{ lineHeight: '20px' }}>
@@ -206,13 +203,9 @@ const Post = ({ handleAddResponseButton }) => {
)}
>
{(topicContext && !topic) ? (
<>
<span className="w-auto">{topicContext.chapterName}</span>
<span className="mx-1">/</span>
<span className="w-auto">{topicContext.verticalName}</span>
<span className="mx-1">/</span>
<span className="w-auto">{topicContext.unitName}</span>
</>
<span>
{topicContext.chapterName} / {topicContext.verticalName} / {topicContext.unitName}
</span>
) : (
getTopicInfo(topic)
)}

View File

@@ -89,13 +89,13 @@ const PostLink = ({
<div className="d-flex align-items-center pb-0 mb-0 flex-fill">
<div className="text-truncate mr-1">
<span className={classNames(
'font-weight-500 font-size-14 text-primary-500 font-style align-bottom mr-1',
'font-weight-500 text-primary-500 font-style align-bottom mr-1',
{ 'font-weight-bolder': !read },
)}
>
{title}
</span>
<span className="text-gray-700 font-weight-normal font-size-14 font-style align-bottom">
<span className="text-gray-700 font-weight-normal font-style align-bottom">
{isPostPreviewAvailable(previewBody) ? previewBody : intl.formatMessage(messages.postWithoutPreview)}
</span>
</div>

View File

@@ -68,7 +68,7 @@ const PostSummaryFooter = ({
</OverlayTrigger>
{preview && commentCount > 1 && (
<div className="d-flex align-items-center ml-4.5 text-gray-700 font-style font-size-12">
<div className="d-flex align-items-center ml-4.5 text-gray-700 font-style">
<OverlayTrigger
overlay={(
<Tooltip id={`follow-${postId}-tooltip`}>

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

@@ -9,6 +9,25 @@
$fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome";
body,
#main
{
font-size: 14px ;
}
.btn,
.form-control,
.dropdown-menu,
.pgn__modal,
.pgn__form-control-floating-label-content,
.pgn__form-label {
font-size: 14px !important;
}
.btn-md {
font-size: 16px !important;
}
#post,
#comment,
#reply,
@@ -41,18 +60,6 @@ $fa-font-path: "~font-awesome/fonts";
outline: $light-400 solid 2px;
}
.font-size-16 {
font-size: 16px !important;
}
.font-size-14 {
font-size: 14px !important;
}
.font-size-12 {
font-size: 12px !important;
}
.font-size-8 {
font-size: 8px !important;
}
@@ -233,11 +240,11 @@ header {
}
.sidebar-tablet-width {
max-width: 20rem !important;
max-width: 21rem !important;
}
.sidebar-XL-width {
min-width: 35rem !important;
min-width: 29rem !important;
}
.filter-menu:focus-visible {
@@ -259,6 +266,9 @@ header {
width: 18px !important;
height: 18px !important;
}
h4 {
font-size: 16px !important;
}
}
.pointer-cursor-hover :hover {
@@ -390,6 +400,7 @@ header {
.badge {
padding: 1px 5px !important;
font-size: 12px !important;
}
.pgn__checkpoint {
@@ -542,7 +553,7 @@ code {
.actions-dropdown-item {
padding: 12px 16px;
height: 48px !important;
width: 304px !important;
width: 195px !important
}
.font-xl {
@@ -581,3 +592,102 @@ th, td {
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,
.btn,
.alert-message-content,
.pgn__form-control-floating-label-content,
.form-control,
.pgn__modal,
.pgn__form-label,
.dropdown-menu,
.tox-tbtn,
.tooltip {
font-size: 10px !important;
}
.badge,
.post-summary-timestamp,
.hover-card button {
font-size: 8px !important;
}
.header-action-bar button {
height: 42px;
text-wrap: nowrap;
;
}
.nav-item a {
padding-left: 14px !important;
padding-right: 14px !important;
}
.post-form h4,
.discussion-posts .btn-md {
font-size: 12px !important;
}
}
@media only screen and (min-width: 367px) and (max-width: 768px) {
.discussion-comments h5,
.btn,
.alert-message-content,
.pgn__form-control-floating-label-content,
.form-control,
.pgn__form-label,
.pgn__modal,
.dropdown-menu,
.tox-tbtn,
.tooltip {
font-size: 12px !important;
}
.badge,
.post-summary-timestamp,
.hover-card.btn-tertiary {
font-size: 10px !important;
}
.post-form h4,
.discussion-posts .btn-md {
font-size: 14px !important;
}
}
@media only screen and (min-width: 769px) {
body,
#main,
.tooltip {
font-size: 14px;
}
}
@media only screen and (max-width: 768px) {
body,
#main {
font-size: 12px;
}
}
@media only screen and (max-width: 366px) {
body,
#main {
font-size: 10px;
}
}

View File

@@ -0,0 +1,50 @@
# Footer Slot
### Slot ID: `footer_slot`
## Description
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/).
## Example
The following `env.config.jsx` will replace the default footer.
![Screenshot of Default Footer](./images/default_footer.png)
with a simple custom footer
![Screenshot of Custom Footer](./images/custom_footer.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
footer_slot: {
plugins: [
{
// Hide the default footer
op: PLUGIN_OPERATIONS.Hide,
widgetId: 'default_contents',
},
{
// Insert a custom footer
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_footer',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🦶</h1>
),
},
},
]
}
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -0,0 +1,3 @@
# `frontend-app-discussions` Plugin Slots
* [`footer_slot`](./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');