Skip to content

Commit d997095

Browse files
committed
feat: added soft delete functionality
1 parent 5744304 commit d997095

File tree

12 files changed

+226
-83
lines changed

12 files changed

+226
-83
lines changed

src/components/FilterBar.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ FilterBar.propTypes = {
241241
selectedFilters: PropTypes.shape({
242242
postType: ThreadType,
243243
status: PostsStatusFilter,
244+
contentStatus: PostsStatusFilter,
244245
orderBy: ThreadOrdering,
245246
cohort: PropTypes.string,
246247
}).isRequired,

src/discussions/learners/LearnerActionsDropdown.jsx

Lines changed: 86 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import React, {
22
useCallback, useRef, useState,
33
} from 'react';
4+
import ReactDOM from 'react-dom';
45
import PropTypes from 'prop-types';
56

67
import {
78
Button, Dropdown, Icon, IconButton, ModalPopup, useToggle,
89
} from '@openedx/paragon';
9-
import { MoreHoriz } from '@openedx/paragon/icons';
10+
import { ChevronRight, MoreHoriz } from '@openedx/paragon/icons';
1011

1112
import { useIntl } from '@edx/frontend-platform/i18n';
1213

13-
import { useLearnerActions } from './utils';
14+
import { useLearnerActionsMenu } from './utils';
1415

1516
const LearnerActionsDropdown = ({
1617
actionHandlers,
@@ -21,14 +22,16 @@ const LearnerActionsDropdown = ({
2122
const intl = useIntl();
2223
const [isOpen, open, close] = useToggle(false);
2324
const [target, setTarget] = useState(null);
24-
const actions = useLearnerActions(userHasBulkDeletePrivileges);
25+
const [activeSubmenu, setActiveSubmenu] = useState(null);
26+
const menuItems = useLearnerActionsMenu(intl, userHasBulkDeletePrivileges);
2527

2628
const handleActions = useCallback((action) => {
2729
const actionFunction = actionHandlers[action];
2830
if (actionFunction) {
2931
actionFunction();
32+
close();
3033
}
31-
}, [actionHandlers]);
34+
}, [actionHandlers, close]);
3235

3336
const onClickButton = useCallback((event) => {
3437
event.preventDefault();
@@ -39,6 +42,7 @@ const LearnerActionsDropdown = ({
3942
const onCloseModal = useCallback(() => {
4043
close();
4144
setTarget(null);
45+
setActiveSubmenu(null);
4246
}, [close]);
4347

4448
return (
@@ -53,41 +57,86 @@ const LearnerActionsDropdown = ({
5357
iconClassNames={dropDownIconSize ? 'dropdown-icon-dimensions' : ''}
5458
/>
5559
<div className="actions-dropdown">
56-
<ModalPopup
57-
onClose={onCloseModal}
58-
positionRef={target}
59-
isOpen={isOpen}
60-
placement="bottom-start"
61-
>
62-
<div
63-
className="bg-white shadow d-flex flex-column mt-1"
64-
data-testid="learner-actions-dropdown-modal-popup"
60+
{isOpen && ReactDOM.createPortal(
61+
<ModalPopup
62+
onClose={onCloseModal}
63+
positionRef={target}
64+
isOpen={isOpen}
65+
placement="bottom-start"
66+
style={{ zIndex: 9998 }}
6567
>
66-
{actions.map(action => (
67-
<React.Fragment key={action.id}>
68-
<Dropdown.Item
69-
as={Button}
70-
variant="tertiary"
71-
size="inline"
72-
onClick={() => {
73-
close();
74-
handleActions(action.action);
75-
}}
76-
className="d-flex justify-content-start actions-dropdown-item"
77-
data-testId={action.id}
68+
<div
69+
className="bg-white shadow d-flex flex-column mt-1"
70+
data-testid="learner-actions-dropdown-modal-popup"
71+
style={{ position: 'relative', zIndex: 9998 }}
72+
>
73+
{menuItems.map(item => (
74+
<div
75+
key={item.id}
76+
className="position-relative"
77+
onMouseEnter={() => setActiveSubmenu(item.id)}
78+
onMouseLeave={() => setActiveSubmenu(null)}
79+
style={{ zIndex: 2 }}
7880
>
79-
<Icon
80-
src={action.icon}
81-
className="icon-size-24"
82-
/>
83-
<span className="font-weight-normal ml-2">
84-
{action.label.defaultMessage}
85-
</span>
86-
</Dropdown.Item>
87-
</React.Fragment>
88-
))}
89-
</div>
90-
</ModalPopup>
81+
<Dropdown.Item
82+
as={Button}
83+
variant="tertiary"
84+
size="inline"
85+
className="d-flex justify-content-between align-items-center actions-dropdown-item"
86+
data-testid={item.id}
87+
>
88+
<div className="d-flex align-items-center">
89+
<Icon
90+
src={item.icon}
91+
className="icon-size-24"
92+
/>
93+
<span className="font-weight-normal ml-2">
94+
{item.label}
95+
</span>
96+
</div>
97+
<Icon
98+
src={ChevronRight}
99+
className="icon-size-16"
100+
/>
101+
</Dropdown.Item>
102+
{activeSubmenu === item.id && (
103+
<div
104+
className="bg-white learner-submenu-container"
105+
style={{
106+
position: 'absolute',
107+
left: '100%',
108+
top: 0,
109+
minWidth: 300,
110+
maxWidth: 360,
111+
zIndex: 9999,
112+
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
113+
border: '1px solid var(--pgn-color-light-400)',
114+
overflow: 'visible',
115+
}}
116+
>
117+
{item.submenu.map(subItem => (
118+
<Dropdown.Item
119+
key={subItem.id}
120+
as={Button}
121+
variant="tertiary"
122+
size="inline"
123+
onClick={() => handleActions(subItem.action)}
124+
className="d-flex justify-content-start actions-dropdown-item"
125+
data-testid={subItem.id}
126+
>
127+
<span className="font-weight-normal">
128+
{subItem.label}
129+
</span>
130+
</Dropdown.Item>
131+
))}
132+
</div>
133+
)}
134+
</div>
135+
))}
136+
</div>
137+
</ModalPopup>,
138+
document.body,
139+
)}
91140
</div>
92141
</>
93142
);

src/discussions/learners/LearnerPostsView.jsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ const LearnerPostsView = () => {
8686

8787
const handleDeletePosts = useCallback(async (courseOrOrg) => {
8888
await dispatchDelete(deleteUserPosts(courseId, username, courseOrOrg, true));
89+
dispatch(clearPostsPages());
90+
loadMorePosts();
8991
hideDeleteConfirmation();
9092
// Navigate back to learners list after deletion
9193
navigate({ ...discussionsPath(Routes.LEARNERS.PATH, { courseId })(location) });
@@ -101,10 +103,9 @@ const LearnerPostsView = () => {
101103

102104
const handleRestorePosts = useCallback(async (courseOrOrg) => {
103105
await dispatch(undeleteUserPosts(courseId, username, courseOrOrg, true));
104-
hideRestoreConfirmation();
105-
// Clear and reload the posts to reflect restored content
106106
dispatch(clearPostsPages());
107107
loadMorePosts();
108+
hideRestoreConfirmation();
108109
}, [courseId, username, hideRestoreConfirmation, dispatch, loadMorePosts]);
109110

110111
const actionHandlers = useMemo(() => ({

src/discussions/learners/data/slices.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ const learnersSlice = createSlice({
2020
sortedBy: LearnersOrdering.BY_LAST_ACTIVITY,
2121
postFilter: {
2222
postType: ThreadType.ALL,
23-
status: PostsStatusFilter.ACTIVE,
23+
status: PostsStatusFilter.ALL, // secondary status (Unread, etc.)
24+
contentStatus: PostsStatusFilter.ACTIVE, // main content status (Active/Deleted)
2425
orderBy: ThreadOrdering.BY_LAST_ACTIVITY,
2526
cohort: '',
2627
},
@@ -85,7 +86,10 @@ const learnersSlice = createSlice({
8586
{
8687
...state,
8788
pages: [],
88-
postFilter: payload,
89+
postFilter: {
90+
...state.postFilter,
91+
...payload,
92+
},
8993
}
9094
),
9195
deleteUserPostsRequest: (state) => (

src/discussions/learners/data/thunks.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,14 @@ export function fetchUserPosts(courseId, {
9494
author,
9595
countFlagged,
9696
};
97+
// Main content status: Active/Deleted
98+
if (filters.contentStatus === PostsStatusFilter.DELETED) {
99+
options.showDeleted = true;
100+
} else if (filters.contentStatus === PostsStatusFilter.ACTIVE) {
101+
options.showDeleted = false;
102+
}
103+
104+
// Secondary status filters (independent)
97105
if (filters.status === PostsStatusFilter.UNREAD) {
98106
options.status = 'unread';
99107
}
@@ -106,6 +114,7 @@ export function fetchUserPosts(courseId, {
106114
if (filters.status === PostsStatusFilter.UNRESPONDED) {
107115
options.status = 'unresponded';
108116
}
117+
109118
if (filters.postType !== ThreadType.ALL) {
110119
options.threadType = filters.postType;
111120
}
@@ -115,9 +124,6 @@ export function fetchUserPosts(courseId, {
115124
if (filters.cohort) {
116125
options.cohort = filters.cohort;
117126
}
118-
if (filters.status === PostsStatusFilter.DELETED) {
119-
options.showDeleted = true;
120-
}
121127
return async (dispatch) => {
122128
try {
123129
dispatch(fetchLearnerThreadsRequest({ courseId, author }));

src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx

Lines changed: 20 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,18 @@ import { useParams } from 'react-router-dom';
77
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
88

99
import FilterBar from '../../../components/FilterBar';
10-
import { PostsStatusFilter, ThreadType } from '../../../data/constants';
10+
// import { PostsStatusFilter, ThreadType } from '../../../data/constants';
1111
import selectCourseCohorts from '../../cohorts/data/selectors';
1212
import fetchCourseCohorts from '../../cohorts/data/thunks';
13-
import { selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff } from '../../data/selectors';
13+
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors';
1414
import { setPostFilter } from '../data/slices';
1515

1616
const LearnerPostFilterBar = () => {
1717
const dispatch = useDispatch();
1818
const { courseId } = useParams();
1919
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
2020
const userIsGroupTa = useSelector(selectUserIsGroupTa);
21-
const userIsStaff = useSelector(selectUserIsStaff);
21+
// const userIsStaff = useSelector(selectUserIsStaff);
2222
const cohorts = useSelector(selectCourseCohorts);
2323
const postFilter = useSelector(state => state.learners.postFilter);
2424

@@ -28,24 +28,23 @@ const LearnerPostFilterBar = () => {
2828
filters: ['type-all', 'type-discussions', 'type-questions'],
2929
},
3030
{
31-
name: 'status',
31+
name: 'status', // secondary status
3232
filters: ['status-any', 'status-unread', 'status-unanswered', 'status-unresponded'],
3333
},
3434
{
3535
name: 'orderBy',
3636
filters: ['sort-activity', 'sort-comments', 'sort-votes'],
3737
},
38+
{
39+
name: 'contentStatus', // main content status
40+
filters: ['status-active', 'status-deleted'],
41+
hasSeparator: true,
42+
},
3843
];
3944

40-
if (userHasModerationPrivileges || userIsGroupTa || userIsStaff) {
41-
// Add reported filter to the regular status filters
45+
if (userHasModerationPrivileges || userIsGroupTa) {
46+
// Add reported filter only for group TA and moderators
4247
filtersToShow[1].filters.splice(2, 0, 'status-reported');
43-
// Add Active/Deleted as a separate filter section at the bottom with a separator
44-
filtersToShow.push({
45-
name: 'status',
46-
filters: ['status-active', 'status-deleted'],
47-
hasSeparator: true, // Add visual separator before this section
48-
});
4948
}
5049

5150
const handleFilterChange = (event) => {
@@ -59,40 +58,27 @@ const LearnerPostFilterBar = () => {
5958
};
6059
if (name === 'postType') {
6160
if (postFilter.postType !== value) {
62-
dispatch(setPostFilter({
63-
...postFilter,
64-
postType: value,
65-
}));
61+
dispatch(setPostFilter({ postType: value }));
6662
filterContentEventProperties.threadTypeFilter = value;
6763
}
6864
} else if (name === 'status') {
6965
if (postFilter.status !== value) {
70-
const postType = (value === PostsStatusFilter.UNANSWERED && ThreadType.QUESTION)
71-
|| (value === PostsStatusFilter.UNRESPONDED && ThreadType.DISCUSSION)
72-
|| postFilter.postType;
73-
74-
dispatch(setPostFilter({
75-
...postFilter,
76-
postType,
77-
status: value,
78-
}));
79-
66+
dispatch(setPostFilter({ status: value }));
8067
filterContentEventProperties.statusFilter = value;
8168
}
69+
} else if (name === 'contentStatus') {
70+
if (postFilter.contentStatus !== value) {
71+
dispatch(setPostFilter({ contentStatus: value }));
72+
filterContentEventProperties.contentStatusFilter = value;
73+
}
8274
} else if (name === 'orderBy') {
8375
if (postFilter.orderBy !== value) {
84-
dispatch(setPostFilter({
85-
...postFilter,
86-
orderBy: value,
87-
}));
76+
dispatch(setPostFilter({ orderBy: value }));
8877
filterContentEventProperties.sortFilter = value;
8978
}
9079
} else if (name === 'cohort') {
9180
if (postFilter.cohort !== value) {
92-
dispatch(setPostFilter({
93-
...postFilter,
94-
cohort: value,
95-
}));
81+
dispatch(setPostFilter({ cohort: value }));
9682
filterContentEventProperties.cohortFilter = value;
9783
}
9884
}

src/discussions/learners/messages.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,26 @@ const messages = defineMessages({
6868
defaultMessage: 'Deleted activity',
6969
description: 'Tooltip text for deleted activity icon',
7070
},
71+
deleteActivity: {
72+
id: 'discussions.learner.actions.deleteActivity',
73+
defaultMessage: 'Delete activity',
74+
description: 'Main menu option for deleting user activity',
75+
},
76+
restoreActivity: {
77+
id: 'discussions.learner.actions.restoreActivity',
78+
defaultMessage: 'Restore activity',
79+
description: 'Main menu option for restoring user activity',
80+
},
81+
withinCourse: {
82+
id: 'discussions.learner.actions.withinCourse',
83+
defaultMessage: 'Within course',
84+
description: 'Submenu option for actions within the current course',
85+
},
86+
withinOrg: {
87+
id: 'discussions.learner.actions.withinOrg',
88+
defaultMessage: 'Within organization',
89+
description: 'Submenu option for actions within the organization',
90+
},
7191
deleteCoursePosts: {
7292
id: 'discussions.learner.actions.deleteCoursePosts',
7393
defaultMessage: 'Delete user posts within this course',

0 commit comments

Comments
 (0)