Skip to content

Commit a74b54f

Browse files
committed
feat: Add support for grouping at subsections
For smaller courses, there is a feature under the new provider for grouping topics at the subsection level so that when navigating the course, all topics under a subsection show up in the sidebar instead of just the current unit. This implements that functionality by checking if the discussion is loaded in-context, and if grouping at subsection is enabled, and if so, displaying the current subsection's topics.
1 parent 93387f1 commit a74b54f

File tree

9 files changed

+221
-75
lines changed

9 files changed

+221
-75
lines changed

src/data/selectors.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { createSelector } from '@reduxjs/toolkit';
44

5-
import { selectDiscussionProvider } from '../discussions/data/selectors';
5+
import { selectDiscussionProvider, selectGroupAtSubsection } from '../discussions/data/selectors';
66
import { DiscussionProvider } from './constants';
77

88
export const selectTopicContext = (topicId) => (state) => state.blocks.topics[topicId];
@@ -14,6 +14,18 @@ export const selectorForUnitSubsection = createSelector(
1414
blocks => key => blocks[blocks[key]?.parent],
1515
);
1616

17+
// If subsection grouping is enabled, and the current selection is a unit, then get the current subsection.
18+
export const selectCurrentCategoryGrouping = createSelector(
19+
selectDiscussionProvider,
20+
selectGroupAtSubsection,
21+
selectBlocks,
22+
(provider, groupAtSubsection, blocks) => blockId => (
23+
(provider !== 'openedx' || !groupAtSubsection || blocks[blockId]?.type !== 'vertical')
24+
? blockId
25+
: blocks[blockId].parent
26+
),
27+
);
28+
1729
export const selectChapters = (state) => state.blocks.chapters;
1830
export const selectTopicsUnderCategory = createSelector(
1931
selectDiscussionProvider,

src/discussions/data/hooks.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import { AppContext } from '@edx/frontend-platform/react';
99
import { breakpoints, useWindowSize } from '@edx/paragon';
1010

1111
import { Routes } from '../../data/constants';
12+
import { selectTopicsUnderCategory } from '../../data/selectors';
1213
import { fetchCourseBlocks } from '../../data/thunks';
14+
import { DiscussionContext } from '../common/context';
1315
import { clearRedirect } from '../posts/data';
1416
import { selectTopics } from '../topics/data/selectors';
1517
import { fetchCourseTopics } from '../topics/data/thunks';
@@ -177,3 +179,24 @@ export const useShowLearnersTab = () => {
177179
const allowedUsers = isAdmin || IsGroupTA || privileged || (userRoles.includes('Student') && userRoles.length > 1);
178180
return learnersTabEnabled && allowedUsers;
179181
};
182+
183+
/**
184+
* React hook that gets the current topic ID from the current topic or category.
185+
* The topicId in the DiscussionContext only return the direct topicId from the URL.
186+
* If the URL has the current block ID it cannot get the topicID from that. This hook
187+
* gets the topic ID from the URL if available, or from the current category otherwise.
188+
* It only returns an ID if a single ID is available, if navigating a subsection it
189+
* returns null.
190+
* @returns {null|string} A topic ID if a single one available in the current context.
191+
*/
192+
export const useCurrentDiscussionTopic = () => {
193+
const { topicId, category } = useContext(DiscussionContext);
194+
const topics = useSelector(selectTopicsUnderCategory)(category);
195+
if (topicId) {
196+
return topicId;
197+
}
198+
if (topics?.length === 1) {
199+
return topics[0];
200+
}
201+
return null;
202+
};

src/discussions/data/hooks.test.jsx

Lines changed: 121 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -9,60 +9,135 @@ import { getConfig, initializeMockApp } from '@edx/frontend-platform';
99
import { AppProvider } from '@edx/frontend-platform/react';
1010

1111
import { initializeStore } from '../../store';
12-
import { useContainerSizeForParent } from './hooks';
12+
import { DiscussionContext } from '../common/context';
13+
import { useContainerSizeForParent, useCurrentDiscussionTopic } from './hooks';
1314

1415
let store;
1516
initializeMockApp();
1617
describe('Hooks', () => {
17-
function ComponentWithHook() {
18-
const refContainer = useRef(null);
19-
useContainerSizeForParent(refContainer);
20-
return (
21-
<div>
22-
<div ref={refContainer} />
23-
</div>
24-
);
25-
}
18+
describe('useContainerSizeForParent', () => {
19+
function ComponentWithHook() {
20+
const refContainer = useRef(null);
21+
useContainerSizeForParent(refContainer);
22+
return (
23+
<div>
24+
<div ref={refContainer} />
25+
</div>
26+
);
27+
}
2628

27-
function renderComponent() {
28-
return render(
29-
<IntlProvider locale="en">
30-
<ResponsiveContext.Provider value={{ width: 1280 }}>
29+
function renderComponent() {
30+
return render(
31+
<IntlProvider locale="en">
32+
<ResponsiveContext.Provider value={{ width: 1280 }}>
33+
<AppProvider store={store}>
34+
<MemoryRouter initialEntries={['/']}>
35+
<ComponentWithHook />
36+
</MemoryRouter>
37+
</AppProvider>
38+
</ResponsiveContext.Provider>
39+
</IntlProvider>,
40+
);
41+
}
42+
43+
let parent;
44+
beforeEach(() => {
45+
store = initializeStore();
46+
parent = window.parent;
47+
});
48+
afterEach(() => {
49+
window.parent = parent;
50+
});
51+
test('useContainerSizeForParent enabled', async () => {
52+
delete window.parent;
53+
window.parent = { ...window, postMessage: jest.fn() };
54+
const { unmount } = renderComponent();
55+
// Once for LMS and one for learning MFE
56+
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalledTimes(2));
57+
// Test that size is reset on unmount
58+
unmount();
59+
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalledTimes(4));
60+
expect(window.parent.postMessage).toHaveBeenLastCalledWith(
61+
{ type: 'plugin.resize', payload: { height: null } },
62+
getConfig().LMS_BASE_URL,
63+
);
64+
});
65+
test('useContainerSizeForParent disabled', async () => {
66+
window.parent.postMessage = jest.fn();
67+
renderComponent();
68+
await waitFor(() => expect(window.parent.postMessage).not.toHaveBeenCalled());
69+
});
70+
});
71+
72+
describe('useCurrentDiscussionTopic', () => {
73+
function ComponentWithHook() {
74+
const topic = useCurrentDiscussionTopic();
75+
return (
76+
<div>
77+
{String(topic)}
78+
</div>
79+
);
80+
}
81+
82+
function renderComponent({ topicId, category }) {
83+
return render(
84+
<IntlProvider locale="en">
3185
<AppProvider store={store}>
32-
<MemoryRouter initialEntries={['/']}>
86+
<DiscussionContext.Provider
87+
value={{
88+
topicId,
89+
category,
90+
}}
91+
>
3392
<ComponentWithHook />
34-
</MemoryRouter>
93+
</DiscussionContext.Provider>
3594
</AppProvider>
36-
</ResponsiveContext.Provider>
37-
</IntlProvider>,
38-
);
39-
}
95+
</IntlProvider>,
96+
);
97+
}
4098

41-
let parent;
42-
beforeEach(() => {
43-
store = initializeStore();
44-
parent = window.parent;
45-
});
46-
afterEach(() => {
47-
window.parent = parent;
48-
});
49-
test('useContainerSizeForParent enabled', async () => {
50-
delete window.parent;
51-
window.parent = { ...window, postMessage: jest.fn() };
52-
const { unmount } = renderComponent();
53-
// Once for LMS and one for learning MFE
54-
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalledTimes(2));
55-
// Test that size is reset on unmount
56-
unmount();
57-
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalledTimes(4));
58-
expect(window.parent.postMessage).toHaveBeenLastCalledWith(
59-
{ type: 'plugin.resize', payload: { height: null } },
60-
getConfig().LMS_BASE_URL,
61-
);
62-
});
63-
test('useContainerSizeForParent disabled', async () => {
64-
window.parent.postMessage = jest.fn();
65-
renderComponent();
66-
await waitFor(() => expect(window.parent.postMessage).not.toHaveBeenCalled());
99+
beforeEach(() => {
100+
store = initializeStore({
101+
blocks: {
102+
blocks: {
103+
'some-unit-key': { topics: ['some-topic-0'], parent: 'some-sequence-key' },
104+
'some-sequence-key': { topics: ['some-topic-0'] },
105+
'another-sequence-key': { topics: ['some-topic-1', 'some-topic-2'] },
106+
'empty-key': { topics: [] },
107+
},
108+
},
109+
config: { provider: 'openedx' },
110+
});
111+
});
112+
113+
test('when topicId is in context', () => {
114+
const { queryByText } = renderComponent({ topicId: 'some-topic' });
115+
expect(queryByText('some-topic')).toBeInTheDocument();
116+
});
117+
118+
test('when the category is a unit', () => {
119+
const { queryByText } = renderComponent({ category: 'some-unit-key' });
120+
expect(queryByText('some-topic-0')).toBeInTheDocument();
121+
});
122+
123+
test('when the category is a sequence with one unit', () => {
124+
const { queryByText } = renderComponent({ category: 'some-sequence-key' });
125+
expect(queryByText('some-topic-0')).toBeInTheDocument();
126+
});
127+
128+
test('when the category is a sequence with multiple units', () => {
129+
const { queryByText } = renderComponent({ category: 'another-sequence-key' });
130+
expect(queryByText('null')).toBeInTheDocument();
131+
});
132+
133+
test('when the category is invalid', () => {
134+
const { queryByText } = renderComponent({ category: 'invalid-key' });
135+
expect(queryByText('null')).toBeInTheDocument();
136+
});
137+
138+
test('when the category has no topics', () => {
139+
const { queryByText } = renderComponent({ category: 'empty-key' });
140+
expect(queryByText('null')).toBeInTheDocument();
141+
});
67142
});
68143
});

src/discussions/data/selectors.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export const selectDivisionSettings = state => state.config.settings;
2222

2323
export const selectBlackoutDate = state => state.config.blackouts;
2424

25+
export const selectGroupAtSubsection = state => state.config.groupAtSubsection;
26+
2527
export const selectModerationSettings = state => ({
2628
postCloseReasons: state.config.postCloseReasons,
2729
editReasons: state.config.editReasons,

src/discussions/data/slices.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const configSlice = createSlice({
1111
allowAnonymous: false,
1212
allowAnonymousToPeers: false,
1313
userRoles: [],
14+
groupAtSubsection: false,
1415
hasModerationPrivileges: false,
1516
isGroupTa: false,
1617
isUserAdmin: false,

src/discussions/posts/PostsView.jsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
44
import { useDispatch, useSelector } from 'react-redux';
55

66
import SearchInfo from '../../components/SearchInfo';
7-
import { selectTopicsUnderCategory } from '../../data/selectors';
7+
import { selectCurrentCategoryGrouping, selectTopicsUnderCategory } from '../../data/selectors';
88
import { DiscussionContext } from '../common/context';
99
import {
1010
selectAllThreads,
@@ -29,7 +29,10 @@ TopicPostsList.propTypes = {
2929
};
3030

3131
function CategoryPostsList({ category }) {
32-
const topicIds = useSelector(selectTopicsUnderCategory)(category);
32+
const { inContext } = useContext(DiscussionContext);
33+
const groupedCategory = useSelector(selectCurrentCategoryGrouping)(category);
34+
// If grouping at subsection is enabled, only apply it when browsing discussions in context in the learning MFE.
35+
const topicIds = useSelector(selectTopicsUnderCategory)(inContext ? groupedCategory : category);
3336
const posts = useSelector(selectTopicThreads(topicIds));
3437
return <PostsList posts={posts} topics={topicIds} />;
3538
}

0 commit comments

Comments
 (0)