Compare commits

...

13 Commits

Author SHA1 Message Date
ayeshoali
f2aa7e9e89 style: add border on focused post 2023-03-03 00:04:53 +05:00
ayesha waris
be1a2ccaab fix: fixed post coment actions menu accessibilty for keyboard (#456) 2023-03-01 21:46:23 +05:00
Sarina Canelake
ed0c73e051 Merge pull request #454 from openedx/repo_checks/ensure_workflows
Update standard workflow files.
2023-02-28 09:40:08 -05:00
Feanil Patel
1041b3e45f build: Updating a missing workflow file add-depr-ticket-to-depr-board.yml.
The .github/workflows/add-depr-ticket-to-depr-board.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-28 09:34:03 -05:00
Feanil Patel
493a0610ca build: Creating a missing workflow file add-remove-label-on-comment.yml.
The .github/workflows/add-remove-label-on-comment.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-28 09:34:03 -05:00
Feanil Patel
679e21c270 build: Creating a missing workflow file self-assign-issue.yml.
The .github/workflows/self-assign-issue.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-28 09:34:03 -05:00
sundasnoreen12
62eb9f5e02 test: Added test cases for noncourseware and courseware topic posts (#452)
* test: Added test cases for noncourseware and courseware topic posts

* refactor: optimized code for post view list

* refactor: updated selector tag

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
2023-02-24 12:39:46 +05:00
Muhammad Adeel Tajamul
dedbc25358 fix: incontext crashing (#453)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2023-02-23 19:05:39 +05:00
Mehak Nasir
0f2ad8b7b4 fix: conditionally skipped some API calls and deffered script loading to improve performance 2023-02-23 14:26:02 +05:00
Ahtisham Shahid
61581ff474 fix: resolved data retention issue in add a post form (#451)
* fix: resolved data retention issue in adding a post form

* test: added unit test for post editor
2023-02-22 22:58:23 +05:00
Ahtisham Shahid
3afce17a32 feat: added event tracking on load more response (#442)
* feat: added event tracking on load more response
2023-02-21 16:30:30 +05:00
Muhammad Adeel Tajamul
7e36e9f14c fix: post loading slow (#447)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2023-02-21 15:09:44 +05:00
sundasnoreen12
c662310b08 test: added test cases for v3 Topics and units list page (#440)
* test: added  test cases for v3 Topics and units list page

* refactor: v3 topics unit test cases

* refactor: v3 topics unit test cases

* refactor: v3 topics unit test cases

* refactor: removed my added commented line and also optimized the code to get stats for section

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
2023-02-21 12:45:08 +05:00
20 changed files with 594 additions and 25 deletions

View File

@@ -16,4 +16,4 @@ jobs:
secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}

View File

@@ -0,0 +1,20 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "label: " it tries to apply
# the label indicated in rest of comment.
# If the comment starts with "remove label: ", it tries
# to remove the indicated label.
# Note: Labels are allowed to have spaces and this script does
# not parse spaces (as often a space is legitimate), so the command
# "label: really long lots of words label" will apply the
# label "really long lots of words label"
name: Allows for the adding and removing of labels via comment
on:
issue_comment:
types: [created]
jobs:
add_remove_labels:
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master

12
.github/workflows/self-assign-issue.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "assign me" it assigns the author to the
# ticket (case insensitive)
name: Assign comment author to ticket if they say "assign me"
on:
issue_comment:
types: [created]
jobs:
self_assign_by_comment:
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master

View File

@@ -45,7 +45,7 @@
};
</script>
<script
async
defer
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"
id="MathJax-script"
></script>
@@ -54,7 +54,7 @@
<div id="root" class="small"></div>
<!-- begin usabilla live embed code -->
<script type="text/javascript">
<script defer type="text/javascript">
window.lightningjs ||
(function (n) {
var e = "lightningjs";

View File

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

View File

@@ -29,7 +29,13 @@ function PostPreviewPane({
className="float-right p-3"
iconClassNames="icon-size"
/>
<HTMLLoader htmlNode={htmlNode} cssClassName="text-primary" componentId="post-preview" testId="post-preview" />
<HTMLLoader
htmlNode={htmlNode}
cssClassName="text-primary"
componentId="post-preview"
testId="post-preview"
delay={500}
/>
</div>
)}
<div className="d-flex justify-content-end">

View File

@@ -124,7 +124,7 @@ export default function DiscussionsHome() {
</Switch>
)}
</div>
<DiscussionsProductTour />
{!enableInContextSidebar && <DiscussionsProductTour />}
</main>
{!enableInContextSidebar && <Footer />}
</DiscussionContext.Provider>

View File

@@ -0,0 +1,255 @@
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 { generatePath, 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 { PostActionsBar } from '../../components';
import { Routes } from '../../data/constants';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { DiscussionContext } from '../common/context';
import { getThreadsApiUrl } from '../posts/data/api';
import { fetchThreads } from '../posts/data/thunks';
import { getCourseTopicsApiUrl } from './data/api';
import { selectCoursewareTopics } from './data/selectors';
import { fetchCourseTopicsV3 } from './data/thunks';
import TopicPostsView from './TopicPostsView';
import TopicsView from './TopicsView';
import './data/__factories__';
import '../posts/data/__factories__/threads.factory';
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const threadsApiUrl = getThreadsApiUrl();
const topicsApiUrl = getCourseTopicsApiUrl();
let store;
let axiosMock;
let lastLocation;
let container;
async function renderComponent({ topicId, category } = { }) {
let path = `/${courseId}/topics`;
if (topicId) {
path = generatePath(Routes.POSTS.PATH, { courseId, topicId });
} else if (category) {
path = generatePath(Routes.TOPICS.CATEGORY, { courseId, category });
}
const wrapper = await render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider value={{
courseId,
topicId,
category,
page: 'topics',
}}
>
<MemoryRouter initialEntries={[path]}>
<Route exact path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}>
<TopicPostsView />
</Route>
<Route exact path={[Routes.TOPICS.ALL]}>
<PostActionsBar />
<TopicsView />
</Route>
<Route
render={({ location }) => {
lastLocation = location;
return null;
}}
/>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
}
describe('InContext Topic Posts View', () => {
let coursewareTopics;
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore({
config: {
enableInContext: true,
provider: 'openedx',
hasModerationPrivileges: true,
blackouts: [],
},
});
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
lastLocation = undefined;
});
async function setupTopicsMockResponse() {
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();
coursewareTopics = selectCoursewareTopics(state);
}
async function setupPostsMockResponse(topicId, numOfResponses = 3) {
axiosMock.onGet(threadsApiUrl)
.reply(() => {
const threadAttrs = { previewBody: 'thread preview body' };
return [200, Factory.build('threadsResult', {}, {
topicId,
threadAttrs,
count: numOfResponses,
})];
});
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
}
test.each([
{ parentId: 'noncourseware-topic-1', parentTitle: 'general-topic-1', topicType: 'NonCourseware' },
{ parentId: 'courseware-topic-1-v3-1', parentTitle: 'Introduction Introduction 1-1-1', topicType: 'Courseware' },
])('\'$topicType\' topic should have a required number of post lengths.', async ({ parentId, parentTitle }) => {
await setupTopicsMockResponse();
await setupPostsMockResponse(parentId, 3);
await act(async () => {
renderComponent({ topicId: parentId });
});
await waitFor(async () => {
const posts = await container.querySelectorAll('.discussion-post');
const backButton = screen.getByLabelText('Back to topics list');
const parentHeader = await screen.findByText(parentTitle);
expect(lastLocation.pathname.endsWith(`/topics/${parentId}`)).toBeTruthy();
expect(posts).toHaveLength(3);
expect(backButton).toBeInTheDocument();
expect(parentHeader).toBeInTheDocument();
});
});
it('A back button should redirect from list of posts to list of units.', async () => {
await setupTopicsMockResponse();
const subSection = coursewareTopics[0].children[0];
const unit = subSection.children[0];
await act(async () => {
setupPostsMockResponse(unit.id, 2);
renderComponent({ topicId: unit.id });
});
const backButton = await screen.getByLabelText('Back to topics list');
await act(async () => fireEvent.click(backButton));
await waitFor(async () => {
renderComponent({ category: subSection.id });
const subSectionList = await container.querySelector('.list-group');
const units = subSectionList.querySelectorAll('.discussion-topic');
const unitHeader = within(subSectionList).queryByText(unit.name);
expect(lastLocation.pathname.endsWith(`/category/${subSection.id}`)).toBeTruthy();
expect(unitHeader).toBeInTheDocument();
expect(units).toHaveLength(4);
});
});
it('A back button should redirect from units to the parent/selected subsection.', async () => {
await setupTopicsMockResponse();
const subSection = coursewareTopics[0].children[0];
renderComponent({ category: subSection.id });
const backButton = await screen.getByLabelText('Back to topics list');
await act(async () => fireEvent.click(backButton));
await waitFor(async () => {
renderComponent();
const sectionList = await container.querySelector('.list-group');
const subSections = sectionList.querySelectorAll('.discussion-topic-group');
const subSectionHeader = within(sectionList).queryByText(subSection.displayName);
expect(lastLocation.pathname.endsWith('/topics')).toBeTruthy();
expect(subSectionHeader).toBeInTheDocument();
expect(subSections).toHaveLength(3);
});
});
test.each([
{ searchText: 'hello world', output: 'Showing 0 results for', resultCount: 0 },
{ searchText: 'introduction', output: 'Showing 8 results for', resultCount: 8 },
])('It should have a search bar with a clear button and \'$output\' results found text.',
async ({ searchText, output, resultCount }) => {
await setupTopicsMockResponse();
await renderComponent();
const searchField = await within(container).getByPlaceholderText('Search topics');
const searchButton = await within(container).getByTestId('search-icon');
fireEvent.change(searchField, { target: { value: searchText } });
await waitFor(async () => expect(searchField).toHaveValue(searchText));
await act(async () => fireEvent.click(searchButton));
await waitFor(async () => {
const clearButton = await within(container).queryByText('Clear results');
const searchMessage = within(container).queryByText(`${output} "${searchText}"`);
const units = container.querySelectorAll('.discussion-topic');
expect(searchMessage).toBeInTheDocument();
expect(clearButton).toBeInTheDocument();
expect(units).toHaveLength(resultCount);
});
});
it('When click on the clear button it should move to main topics pages.', async () => {
await setupTopicsMockResponse();
await renderComponent();
const searchText = 'hello world';
const searchField = await within(container).getByPlaceholderText('Search topics');
const searchButton = await within(container).getByTestId('search-icon');
fireEvent.change(searchField, { target: { value: searchText } });
await waitFor(async () => expect(searchField).toHaveValue(searchText));
await act(async () => fireEvent.click(searchButton));
await waitFor(async () => {
const clearButton = await within(container).queryByText('Clear results');
await act(async () => fireEvent.click(clearButton));
await waitFor(async () => {
const coursewareTopicList = await container.querySelectorAll('.discussion-topic-group');
expect(coursewareTopicList).toHaveLength(3);
expect(within(container).queryByText('Clear results')).not.toBeInTheDocument();
});
});
});
});

View File

@@ -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(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider value={{ courseId, category }}>
<MemoryRouter initialEntries={[`/${courseId}/topics/`]}>
<Route path="/:courseId/topics/">
<TopicsView />
</Route>
<Route path="/:courseId/category/:category">
<TopicPostsView />
</Route>
<Route
render={({ location }) => {
lastLocation = location;
return null;
}}
/>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
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();
});
});
});
});

View File

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

View File

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

View File

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

View File

@@ -50,6 +50,7 @@ function TopicSearchBar({ intl }) {
<Icon
src={SearchIcon}
onClick={() => onSubmit(searchValue)}
data-testid="search-icon"
/>
</span>
</SearchField.Advanced>

View File

@@ -99,7 +99,7 @@ function PostCommentsView({ intl }) {
)
)}
<div
className="discussion-comments d-flex flex-column card border-0 post-card-margin post-card-padding"
className="discussion-comments d-flex flex-column card border-0 post-card-margin post-card-padding on-focus"
>
<Post post={thread} handleAddResponseButton={() => setAddingResponse(true)} />
{!thread.closed && (

View File

@@ -119,7 +119,7 @@ function Comment({
/>
)}
<EndorsedAlertBanner postType={postType} content={comment} />
<div className="d-flex flex-column post-card-comment px-4 pt-3.5 pb-10px" aria-level={5}>
<div className="d-flex flex-column post-card-comment px-4 pt-3.5 pb-10px" tabIndex="0">
<HoverCard
commentOrPost={comment}
actionHandlers={actionHandlers}

View File

@@ -2,6 +2,8 @@ import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { EndorsementStatus } from '../../../data/constants';
import { useDispatchWithState } from '../../../data/hooks';
import { selectThread } from '../../posts/data/selectors';
@@ -11,6 +13,16 @@ import {
} from './selectors';
import { fetchThreadComments } from './thunks';
function trackLoadMoreEvent(postId, params) {
sendTrackEvent(
'edx.forum.responses.loadMore',
{
postId,
params,
},
);
}
export function usePost(postId) {
const dispatch = useDispatch();
const thread = useSelector(selectThread(postId));
@@ -31,11 +43,15 @@ export function usePostComments(postId, endorsed = null) {
const hasMorePages = useSelector(selectThreadHasMorePages(postId, endorsed));
const currentPage = useSelector(selectThreadCurrentPage(postId, endorsed));
const handleLoadMoreResponses = async () => dispatch(fetchThreadComments(postId, {
endorsed,
page: currentPage + 1,
reverseOrder,
}));
const handleLoadMoreResponses = async () => {
const params = {
endorsed,
page: currentPage + 1,
reverseOrder,
};
await dispatch(fetchThreadComments(postId, params));
trackLoadMoreEvent(postId, params);
};
useEffect(() => {
dispatch(fetchThreadComments(postId, {

View File

@@ -106,7 +106,7 @@ function PostEditor({
const nonCoursewareIds = useSelector(enableInContext ? inContextCoursewareIds : selectNonCoursewareIds);
const coursewareTopics = useSelector(enableInContext ? inContextCourseware : selectCoursewareTopics);
const cohorts = useSelector(selectCourseCohorts);
const post = useSelector(selectThread(postId));
const post = useSelector(editExisting ? selectThread(postId) : () => ({}));
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const settings = useSelector(selectDivisionSettings);

View File

@@ -141,6 +141,16 @@ describe('PostEditor', () => {
}
},
);
test('selectThread is not called while creating a new post', async () => {
const mockSelectThread = jest.fn();
jest.mock('../data/selectors', () => ({
selectThread: mockSelectThread,
}));
await renderComponent();
expect(mockSelectThread)
.not
.toHaveBeenCalled();
});
});
describe('cohorting', () => {

View File

@@ -90,8 +90,9 @@ function Post({
return (
<div
className="d-flex flex-column w-100 mw-100 post-card-comment"
aria-level={5}
data-testid={`post-${post.id}`}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex="0"
>
<Confirmation
isOpen={isDeleting}

View File

@@ -433,7 +433,7 @@ header {
pointer-events: none;
}
.on-focus:focus-visible {
.on-focus:focus-within {
outline: 2px solid black;
}
@@ -442,6 +442,8 @@ header {
}
.post-card-comment {
outline: none;
&:not(:hover),
&:not(:focus) {
.hover-card {
@@ -450,7 +452,7 @@ header {
}
&:hover,
&:focus {
&:focus-within {
.hover-card {
display: flex;
}