diff --git a/public/index.html b/public/index.html index 7ef8a8a4..99e3b51e 100644 --- a/public/index.html +++ b/public/index.html @@ -13,41 +13,178 @@ window.MathJax = { tex: { inlineMath: [ - ['$', '$'], - ['\\\\(', '\\\\)'], - ['\\(', '\\)'], - ['[mathjaxinline]', '[/mathjaxinline]'], - ['\\begin{math}', '\\end{math}'], + ["$", "$"], + ["\\\\(", "\\\\)"], + ["\\(", "\\)"], + ["[mathjaxinline]", "[/mathjaxinline]"], + ["\\begin{math}", "\\end{math}"], ], displayMath: [ - ['[mathjax]', '[/mathjax]'], - ['$$', '$$'], - ['\\\\[', '\\\\]'], - ['\\[', '\\]'], - ['\\begin{displaymath}', '\\end{displaymath}'], - ['\\begin{equation}', '\\end{equation}'], + ["[mathjax]", "[/mathjax]"], + ["$$", "$$"], + ["\\\\[", "\\\\]"], + ["\\[", "\\]"], + ["\\begin{displaymath}", "\\end{displaymath}"], + ["\\begin{equation}", "\\end{equation}"], ], processEscapes: true, processEnvironments: true, autoload: { color: [], - colorv2: ['color'] + colorv2: ["color"], }, - packages: {'[+]': ['noerrors']} + packages: { "[+]": ["noerrors"] }, }, options: { - ignoreHtmlClass: 'tex2jax_ignore', - processHtmlClass: 'tex2jax_process' + ignoreHtmlClass: "tex2jax_ignore", + processHtmlClass: "tex2jax_process", }, loader: { - load: ['input/asciimath', '[tex]/noerrors'] - } + load: ["input/asciimath", "[tex]/noerrors"], + }, }; - - +
+ + + + diff --git a/src/components/HTMLLoader.jsx b/src/components/HTMLLoader.jsx index 5294f942..cac3a7f2 100644 --- a/src/components/HTMLLoader.jsx +++ b/src/components/HTMLLoader.jsx @@ -13,12 +13,12 @@ const defaultSanitizeOptions = { }; function HTMLLoader({ - htmlNode, componentId, cssClassName, testId, + htmlNode, componentId, cssClassName, testId, delay, }) { const sanitizedMath = DOMPurify.sanitize(htmlNode, { ...defaultSanitizeOptions }); const previewRef = useRef(); - const debouncedPostContent = useDebounce(htmlNode, 500); + const debouncedPostContent = useDebounce(htmlNode, delay); useEffect(() => { let promise = Promise.resolve(); // Used to hold chain of typesetting calls @@ -45,6 +45,7 @@ HTMLLoader.propTypes = { componentId: PropTypes.string, cssClassName: PropTypes.string, testId: PropTypes.string, + delay: PropTypes.number, }; HTMLLoader.defaultProps = { @@ -52,6 +53,7 @@ HTMLLoader.defaultProps = { componentId: null, cssClassName: '', testId: '', + delay: 0, }; export default HTMLLoader; diff --git a/src/components/PostPreviewPane.jsx b/src/components/PostPreviewPane.jsx index dba69d84..44d39233 100644 --- a/src/components/PostPreviewPane.jsx +++ b/src/components/PostPreviewPane.jsx @@ -29,7 +29,13 @@ function PostPreviewPane({ className="float-right p-3" iconClassNames="icon-size" /> - + )}
diff --git a/src/discussions/in-context-topics/TopicsView.test.jsx b/src/discussions/in-context-topics/TopicsView.test.jsx new file mode 100644 index 00000000..c00b16e4 --- /dev/null +++ b/src/discussions/in-context-topics/TopicsView.test.jsx @@ -0,0 +1,233 @@ +import { + fireEvent, render, screen, waitFor, + within, +} from '@testing-library/react'; +import MockAdapter from 'axios-mock-adapter'; +import { act } from 'react-dom/test-utils'; +import { IntlProvider } from 'react-intl'; +import { MemoryRouter, Route } from 'react-router'; +import { Factory } from 'rosie'; + +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { AppProvider } from '@edx/frontend-platform/react'; + +import { initializeStore } from '../../store'; +import { executeThunk } from '../../test-utils'; +import { DiscussionContext } from '../common/context'; +import { getCourseTopicsApiUrl } from './data/api'; +import { selectCoursewareTopics, selectNonCoursewareTopics } from './data/selectors'; +import { fetchCourseTopicsV3 } from './data/thunks'; +import TopicPostsView from './TopicPostsView'; +import TopicsView from './TopicsView'; + +import './data/__factories__'; + +const courseId = 'course-v1:edX+DemoX+Demo_Course'; +const category = 'section-topic-1'; + +const topicsApiUrl = `${getCourseTopicsApiUrl()}`; +let store; +let axiosMock; +let lastLocation; +let container; + +function renderComponent() { + const wrapper = render( + + + + + + + + + + + { + lastLocation = location; + return null; + }} + /> + + + + , + ); + container = wrapper.container; +} + +describe('InContext Topics View', () => { + let nonCoursewareTopics; + let coursewareTopics; + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore({ + config: { enableInContext: true, provider: 'openedx', hasModerationPrivileges: true }, + }); + Factory.resetAll(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + lastLocation = undefined; + }); + + async function setupMockResponse() { + axiosMock.onGet(`${topicsApiUrl}${courseId}`) + .reply(200, (Factory.buildList('topic', 1, null, { + topicPrefix: 'noncourseware-topic', + enabledInContext: true, + topicNamePrefix: 'general-topic', + usageKey: '', + courseware: false, + discussionCount: 1, + questionCount: 1, + }).concat(Factory.buildList('section', 2, null, { topicPrefix: 'courseware' }))) + .concat(Factory.buildList('archived-topics', 2, null))); + await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState); + + const state = store.getState(); + nonCoursewareTopics = selectNonCoursewareTopics(state); + coursewareTopics = selectCoursewareTopics(state); + } + + it('A non-courseware topic should be clickable and should have a title', async () => { + await setupMockResponse(); + renderComponent(); + + const nonCourseware = nonCoursewareTopics[0]; + const nonCoursewareTopic = await screen.findByText(nonCourseware.name); + + await act(async () => { + fireEvent.click(nonCoursewareTopic); + }); + await waitFor(() => { + expect(screen.queryByText(nonCourseware.name)).toBeInTheDocument(); + expect(lastLocation.pathname.endsWith(`/topics/${nonCourseware.id}`)).toBeTruthy(); + }); + }); + + it('A non-courseware topic should be on the top of the list', async () => { + await setupMockResponse(); + renderComponent(); + const topic = await container.querySelector('.discussion-topic'); + + expect(within(topic).queryByText('general-topic-1')).toBeInTheDocument(); + expect(topic.nextSibling).toBe(container.querySelector('.divider')); + }); + + it('A non-Courseware topic should have 3 stats and should be hoverable', async () => { + await setupMockResponse(); + renderComponent(); + + const topic = await container.querySelector('.discussion-topic'); + const statsList = await topic.querySelectorAll('.icon-size'); + + expect(statsList.length).toBe(3); + fireEvent.mouseOver(statsList[0]); + expect(screen.queryByText('1 Discussion')).toBeInTheDocument(); + }); + + it('Section groups should be listed in the middle of the topics list.', async () => { + await setupMockResponse(); + renderComponent(); + const topicsList = await screen.getByRole('list'); + const sectionGroups = await screen.getAllByTestId('section-group'); + + expect(topicsList.children[1]).toStrictEqual(topicsList.querySelector('.divider')); + expect(sectionGroups.length).toBe(2); + expect(topicsList.children[5]).toStrictEqual(topicsList.querySelector('.divider')); + }); + + it('A section group should have only a title and required subsections.', async () => { + await setupMockResponse(); + renderComponent(); + const sectionGroups = await screen.getAllByTestId('section-group'); + + coursewareTopics.forEach(async (topic, index) => { + const stats = await sectionGroups[index].querySelectorAll('.icon-size:not([data-testid="subsection-group"].icon-size)'); + const subsectionGroups = await within(sectionGroups[index]).getAllByTestId('subsection-group'); + + expect(within(sectionGroups[index]).queryByText(topic.displayName)).toBeInTheDocument(); + expect(stats).toHaveLength(0); + expect(subsectionGroups).toHaveLength(2); + }); + }); + + it('The subsection should have a title name, be clickable, and have the stats', async () => { + await setupMockResponse(); + renderComponent(); + 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 statsList = await subSection.querySelectorAll('.icon-size'); + + expect(subSectionTitle).toBeInTheDocument(); + expect(statsList).toHaveLength(2); + }); + + it('Subsection names should be clickable and redirected to the units lists', async () => { + await setupMockResponse(); + renderComponent(); + + const subsectionObject = coursewareTopics[0].children[0]; + const subSection = await container.querySelector(`[data-subsection-id=${subsectionObject.id}]`); + + await act(async () => fireEvent.click(subSection)); + await waitFor(async () => { + const backButton = await screen.getByLabelText('Back to topics list'); + const topicsList = await screen.getByRole('list'); + const subSectionHeading = await screen.findByText(subsectionObject.displayName); + const units = await topicsList.querySelectorAll('.discussion-topic'); + + expect(backButton).toBeInTheDocument(); + expect(subSectionHeading).toBeInTheDocument(); + expect(units).toHaveLength(4); + expect(lastLocation.pathname.endsWith(`/category/${subsectionObject.id}`)).toBeTruthy(); + }); + }); + + it('The number of units should be matched with the actual unit length.', async () => { + await setupMockResponse(); + renderComponent(); + const subSection = await container.querySelector(`[data-subsection-id=${coursewareTopics[0].children[0].id}]`); + + await act(async () => fireEvent.click(subSection)); + await waitFor(async () => { + const units = await container.querySelectorAll('.discussion-topic'); + + expect(units).toHaveLength(4); + }); + }); + + it('A unit should have a title and stats and should be clickable', async () => { + await setupMockResponse(); + renderComponent(); + 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 waitFor(async () => { + const unitElement = await screen.findByText(unitObject.name); + const unitContainer = await container.querySelector(`[data-topic-id=${unitObject.id}]`); + const statsList = await unitContainer.querySelectorAll('.icon-size'); + + expect(unitElement).toBeInTheDocument(); + expect(statsList).toHaveLength(3); + + await act(async () => fireEvent.click(unitContainer)); + await waitFor(async () => { + expect(lastLocation.pathname.endsWith(`/topics/${unitObject.id}`)).toBeTruthy(); + }); + }); + }); +}); diff --git a/src/discussions/in-context-topics/data/__factories__/inContextTopics.factory.js b/src/discussions/in-context-topics/data/__factories__/inContextTopics.factory.js index d36ea937..5851c8a8 100644 --- a/src/discussions/in-context-topics/data/__factories__/inContextTopics.factory.js +++ b/src/discussions/in-context-topics/data/__factories__/inContextTopics.factory.js @@ -8,7 +8,7 @@ Factory.define('topic') .sequence('name', ['topicNamePrefix'], (idx, topicNamePrefix) => `${topicNamePrefix}-${idx}`) .sequence('usage-key', ['usageKey'], (idx, usageKey) => usageKey) .sequence('courseware', ['courseware'], (idx, courseware) => courseware) - + .attr('activeFlags', null, true) .attr('thread_counts', ['discussionCount', 'questionCount'], (discCount, questCount) => { Factory.reset('thread-counts'); return Factory.build('thread-counts', null, { discussionCount: discCount, questionCount: questCount }); @@ -27,6 +27,11 @@ Factory.define('sub-section') .sequence('student_view_url', ['id', 'courseId'], (idx, id) => `${getApiBaseUrl}/xblock/block-v1:${id}`) .attr('type', null, 'sequential') + .attr('activeFlags', null, true) + .attr('thread_counts', ['discussionCount', 'questionCount'], (discCount, questCount) => { + Factory.reset('thread-counts'); + return Factory.build('thread-counts', null, { discussionCount: discCount, questionCount: questCount }); + }) .attr('children', ['id', 'display-name', 'courseId'], (id, name, courseId) => { Factory.reset('topic'); return Factory.buildList('topic', 2, null, { @@ -42,7 +47,7 @@ Factory.define('sub-section') Factory.define('section') .sequence('block_id', (idx) => `${idx}`) .option('topicPrefix', null, '') - .sequence('id', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}-topic-${idx}`) + .sequence('id', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}-topic-${idx}-v3`) .attr('courseware', null, true) .sequence('display-name', (idx) => `Introduction ${idx}`) .option('courseId', null, 'course-v1:edX+DemoX+Demo_Course') @@ -53,9 +58,15 @@ Factory.define('section') .sequence('student_view_url', ['id', 'courseId'], (idx, id, courseId) => `${getApiBaseUrl}/xblock/${courseId.replace('course-v1:', 'block-v1:')}+type@chapter+block@${id}`) .attr('type', null, 'chapter') - .attr('children', ['display-name'], (name) => { + .attr('children', ['id', 'display-name'], (id, name) => { Factory.reset('sub-section'); - return Factory.buildList('sub-section', 2, null, { sectionPrefix: `${name}-`, topicPrefix: 'section' }); + return Factory.buildList('sub-section', 2, null, { + sectionPrefix: `${name}-`, + topicPrefix: 'section', + id, + discussionCount: 1, + questionCount: 1, + }); }); Factory.define('thread-counts') diff --git a/src/discussions/in-context-topics/data/redux.test.js b/src/discussions/in-context-topics/data/redux.test.js index 4a1826f3..fd8c132c 100644 --- a/src/discussions/in-context-topics/data/redux.test.js +++ b/src/discussions/in-context-topics/data/redux.test.js @@ -101,7 +101,7 @@ describe('Redux in context topics tests', () => { // contain chapter at first level coursewareTopics.forEach((chapter, index) => { expect(chapter.courseware).toEqual(true); - expect(chapter.id).toEqual(`courseware-topic-${index + 1}`); + expect(chapter.id).toEqual(`courseware-topic-${index + 1}-v3`); expect(chapter.type).toEqual('chapter'); expect(chapter).toHaveProperty('blockId'); expect(chapter).toHaveProperty('lmsWebUrl'); @@ -120,7 +120,7 @@ describe('Redux in context topics tests', () => { // contain sub section at third level section.children.forEach((subSection, subSecIndex) => { expect(subSection.enabledInContext).toEqual(true); - expect(subSection.id).toEqual(`${section.id}-${subSecIndex + 1}`); + expect(subSection.id).toEqual(`courseware-topic-${index + 1}-v3-${subSecIndex + 1}`); expect(subSection).toHaveProperty('usageKey'); expect(subSection).not.toHaveProperty('blockId'); expect(subSection?.threadCounts?.discussion).toEqual(1); diff --git a/src/discussions/in-context-topics/data/selector.test.jsx b/src/discussions/in-context-topics/data/selector.test.jsx index d27fb940..a12ba615 100644 --- a/src/discussions/in-context-topics/data/selector.test.jsx +++ b/src/discussions/in-context-topics/data/selector.test.jsx @@ -88,7 +88,7 @@ describe('In Context Topics Selector test cases', () => { expect(coursewareTopics).not.toBeUndefined(); coursewareTopics.forEach((topic, index) => { - expect(topic?.id).toEqual(`courseware-topic-${index + 1}`); + expect(topic?.id).toEqual(`courseware-topic-${index + 1}-v3`); }); }); }); diff --git a/src/discussions/in-context-topics/topic/SectionBaseGroup.jsx b/src/discussions/in-context-topics/topic/SectionBaseGroup.jsx index 8e74c27f..a783c248 100644 --- a/src/discussions/in-context-topics/topic/SectionBaseGroup.jsx +++ b/src/discussions/in-context-topics/topic/SectionBaseGroup.jsx @@ -50,7 +50,7 @@ function SectionBaseGroup({ aria-current={isSelected(section.id) ? 'page' : undefined} tabIndex={(isSelected(subsection.id) || index === 0) ? 0 : -1} > -
+
diff --git a/src/discussions/post-comments/PostCommentsView.jsx b/src/discussions/post-comments/PostCommentsView.jsx index 3ba81833..07fa3b91 100644 --- a/src/discussions/post-comments/PostCommentsView.jsx +++ b/src/discussions/post-comments/PostCommentsView.jsx @@ -40,6 +40,7 @@ function PostCommentsView({ intl }) { const { courseId, learnerUsername, category, topicId, page, enableInContextSidebar, } = useContext(DiscussionContext); + const enableCommentsSort = false; useEffect(() => { if (!thread) { submitDispatch(fetchThread(postId, courseId, true)); } @@ -109,7 +110,7 @@ function PostCommentsView({ intl }) { /> )}
- {!!commentsCount && commentsStatus === RequestStatus.SUCCESSFUL && } + {!!commentsCount && commentsStatus === RequestStatus.SUCCESSFUL && enableCommentsSort && } {thread.type === ThreadType.DISCUSSION && ( { @@ -106,7 +104,7 @@ function renderComponent(postId) { , ); - container = wrapper.container; + return wrapper; } describe('ThreadView', () => { @@ -698,68 +696,4 @@ describe('ThreadView', () => { expect(screen.queryByRole('dialog', { name: /Delete/i, exact: false })).toBeInTheDocument(); }); }); - - describe('for comments sort', () => { - it('should show sort dropdown if there are endorse or unendorsed comments', async () => { - renderComponent(discussionPostId); - - const comment = await waitFor(() => screen.findByTestId('comment-comment-1')); - const sortWrapper = container.querySelector('.comments-sort'); - const sortDropDown = within(sortWrapper).getByRole('button', { name: /Oldest first/i }); - - expect(comment).toBeInTheDocument(); - expect(sortDropDown).toBeInTheDocument(); - }); - - it('should not show sort dropdown if there is no response', async () => { - const commentId = 'comment-1'; - renderComponent(discussionPostId); - - await waitFor(() => screen.findByTestId('comment-comment-1')); - axiosMock.onDelete(`${commentsApiUrl}${commentId}/`).reply(201); - await executeThunk(removeComment(commentId, discussionPostId), store.dispatch, store.getState); - - expect(await waitFor(() => screen.findByText('No responses', { exact: true }))).toBeInTheDocument(); - expect(container.querySelector('.comments-sort')).not.toBeInTheDocument(); - }); - - it('should have only two options', async () => { - renderComponent(discussionPostId); - - await waitFor(() => screen.findByTestId('comment-comment-1')); - await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Oldest first/i })); }); - const dropdown = await waitFor(() => screen.findByTestId('comment-sort-dropdown-modal-popup')); - - expect(dropdown).toBeInTheDocument(); - expect(await within(dropdown).getAllByRole('button')).toHaveLength(2); - }); - - it('should be selected Oldest first and auto focus', async () => { - renderComponent(discussionPostId); - - await waitFor(() => screen.findByTestId('comment-comment-1')); - await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Oldest first/i })); }); - const dropdown = await waitFor(() => screen.findByTestId('comment-sort-dropdown-modal-popup')); - - expect(dropdown).toBeInTheDocument(); - expect(within(dropdown).getByRole('button', { name: /Oldest first/i })).toBeInTheDocument(); - expect(within(dropdown).getByRole('button', { name: /Oldest first/i })).toHaveFocus(); - expect(within(dropdown).getByRole('button', { name: /Newest first/i })).not.toHaveFocus(); - }); - - test('successfully handles sort state update', async () => { - renderComponent(discussionPostId); - - expect(store.getState().comments.sortOrder).toBeFalsy(); - - await waitFor(() => screen.findByTestId('comment-comment-1')); - await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Oldest first/i })); }); - const dropdown = await waitFor(() => screen.findByTestId('comment-sort-dropdown-modal-popup')); - await act(async () => { - fireEvent.click(within(dropdown).getByRole('button', { name: /Newest first/i })); - }); - - expect(store.getState().comments.sortOrder).toBeTruthy(); - }); - }); });