Skip to content

Commit 8711640

Browse files
committed
feat: added soft delete functionality
1 parent c711d5d commit 8711640

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+2006
-117
lines changed

src/assets/undelete.svg

Lines changed: 3 additions & 0 deletions
Loading

src/components/FilterBar.jsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,16 @@ const FilterBar = ({
7575
label: intl.formatMessage(messages.filterUnresponded),
7676
value: PostsStatusFilter.UNRESPONDED,
7777
},
78+
{
79+
id: 'status-active',
80+
label: intl.formatMessage(messages.filterActive),
81+
value: PostsStatusFilter.ACTIVE,
82+
},
83+
{
84+
id: 'status-deleted',
85+
label: intl.formatMessage(messages.filterDeleted),
86+
value: PostsStatusFilter.DELETED,
87+
},
7888
{
7989
id: 'sort-activity',
8090
label: intl.formatMessage(messages.lastActivityAt),
@@ -124,7 +134,7 @@ const FilterBar = ({
124134
<Collapsible.Body className="collapsible-body px-4 pb-3 pt-0">
125135
<Form>
126136
<div className="d-flex flex-row py-2 justify-content-between">
127-
{filters.map((value) => (
137+
{filters.filter(f => !f.hasSeparator).map((value) => (
128138
<Form.RadioSet
129139
key={value.name}
130140
name={value.name}
@@ -150,6 +160,38 @@ const FilterBar = ({
150160
</Form.RadioSet>
151161
))}
152162
</div>
163+
{filters.some(f => f.hasSeparator) && (
164+
<>
165+
<div className="border-bottom my-2" />
166+
<div className="d-flex flex-row py-2 justify-content-between">
167+
{filters.filter(f => f.hasSeparator).map((value) => (
168+
<Form.RadioSet
169+
key={value.name}
170+
name={value.name}
171+
className="d-flex flex-column list-group list-group-flush"
172+
value={selectedFilters[value.name]}
173+
onChange={handleFilterToggle}
174+
>
175+
{value.filters.map(filterName => {
176+
const element = allFilters.find(obj => obj.id === filterName);
177+
if (element) {
178+
return (
179+
<ActionItem
180+
key={element.id}
181+
id={element.id}
182+
label={element.label}
183+
value={element.value}
184+
selected={selectedFilters[value.name]}
185+
/>
186+
);
187+
}
188+
return false;
189+
})}
190+
</Form.RadioSet>
191+
))}
192+
</div>
193+
</>
194+
)}
153195
{showCohortsFilter && (
154196
<>
155197
<div className="border-bottom my-2" />

src/data/constants.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export const ContentActions = {
5151
COPY_LINK: 'copy_link',
5252
REPORT: 'abuse_flagged',
5353
DELETE: 'delete',
54+
SOFT_DELETE: 'soft_delete',
55+
RESTORE: 'restore',
5456
FOLLOWING: 'following',
5557
CHANGE_GROUP: 'group_id',
5658
MARK_READ: 'read',
@@ -60,6 +62,8 @@ export const ContentActions = {
6062
VOTE: 'voted',
6163
DELETE_COURSE_POSTS: 'delete-course-posts',
6264
DELETE_ORG_POSTS: 'delete-org-posts',
65+
RESTORE_COURSE_POSTS: 'restore-course-posts',
66+
RESTORE_ORG_POSTS: 'restore-org-posts',
6367
};
6468

6569
/**
@@ -109,6 +113,8 @@ export const PostsStatusFilter = {
109113
REPORTED: 'statusReported',
110114
UNANSWERED: 'statusUnanswered',
111115
UNRESPONDED: 'statusUnresponded',
116+
ACTIVE: 'statusActive',
117+
DELETED: 'statusDeleted',
112118
};
113119

114120
/**
@@ -132,6 +138,7 @@ export const LearnersOrdering = {
132138
BY_FLAG: 'flagged',
133139
BY_LAST_ACTIVITY: 'activity',
134140
BY_RECENCY: 'recency',
141+
BY_DELETED: 'deleted',
135142
};
136143

137144
/**

src/discussions/common/ActionsDropdown.jsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,14 @@ const ActionsDropdown = ({
7878
size="inline"
7979
onClick={() => {
8080
close();
81-
handleActions(action.action);
81+
if (!action.disabled) {
82+
handleActions(action.action);
83+
}
8284
}}
8385
className="d-flex justify-content-start actions-dropdown-item"
8486
data-testId={action.id}
87+
disabled={action.disabled}
88+
style={action.disabled ? { opacity: 0.3, cursor: 'not-allowed' } : {}}
8589
>
8690
<Icon
8791
src={action.icon}

src/discussions/common/HoverCard.jsx

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const HoverCard = ({
2929
voted,
3030
following,
3131
endorseIcons,
32+
isDeleted,
3233
}) => {
3334
const intl = useIntl();
3435
const { enableInContextSidebar } = useContext(DiscussionContext);
@@ -50,9 +51,9 @@ const HoverCard = ({
5051
'px-2.5 py-2 border-0 font-style text-gray-700',
5152
{ 'w-100': enableInContextSidebar },
5253
)}
53-
onClick={() => handleResponseCommentButton()}
54-
disabled={isClosed}
55-
style={{ lineHeight: '20px' }}
54+
onClick={() => !isDeleted && handleResponseCommentButton()}
55+
disabled={isClosed || isDeleted}
56+
style={{ lineHeight: '20px', ...(isDeleted ? { opacity: 0.3, cursor: 'not-allowed' } : {}) }}
5657
>
5758
{addResponseCommentButtonMessage}
5859
</Button>
@@ -72,12 +73,16 @@ const HoverCard = ({
7273
src={endorseIcons.icon}
7374
iconAs={Icon}
7475
onClick={() => {
75-
const actionFunction = actionHandlers[endorseIcons.action];
76-
actionFunction();
76+
if (!isDeleted) {
77+
const actionFunction = actionHandlers[endorseIcons.action];
78+
actionFunction();
79+
}
7780
}}
7881
className={['endorse', 'unendorse'].includes(endorseIcons.id) ? 'text-dark-500' : 'text-success-500'}
7982
size="sm"
8083
alt="Endorse"
84+
disabled={isDeleted}
85+
style={isDeleted ? { opacity: 0.3, cursor: 'not-allowed' } : {}}
8186
/>
8287
</OverlayTrigger>
8388
</div>
@@ -95,11 +100,14 @@ const HoverCard = ({
95100
iconAs={Icon}
96101
size="sm"
97102
alt="Like"
98-
disabled={!userHasLikePermission}
103+
disabled={!userHasLikePermission || isDeleted}
99104
iconClassNames="like-icon-dimensions"
105+
style={isDeleted ? { opacity: 0.3, cursor: 'not-allowed' } : {}}
100106
onClick={(e) => {
101107
e.preventDefault();
102-
onLike();
108+
if (!isDeleted) {
109+
onLike();
110+
}
103111
}}
104112
/>
105113
</OverlayTrigger>
@@ -119,9 +127,13 @@ const HoverCard = ({
119127
size="sm"
120128
alt="Follow"
121129
iconClassNames="follow-icon-dimensions"
130+
disabled={isDeleted}
131+
style={isDeleted ? { opacity: 0.3, cursor: 'not-allowed' } : {}}
122132
onClick={(e) => {
123133
e.preventDefault();
124-
onFollow();
134+
if (!isDeleted) {
135+
onFollow();
136+
}
125137
}}
126138
/>
127139
</OverlayTrigger>
@@ -165,12 +177,14 @@ HoverCard.propTypes = {
165177
)),
166178
onFollow: PropTypes.func,
167179
following: PropTypes.bool,
180+
isDeleted: PropTypes.bool,
168181
};
169182

170183
HoverCard.defaultProps = {
171184
onFollow: () => null,
172185
endorseIcons: null,
173186
following: undefined,
187+
isDeleted: false,
174188
};
175189

176190
export default React.memo(HoverCard);
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import React, { useState } from 'react';
2+
import PropTypes from 'prop-types';
3+
4+
import { Badge, Button, Spinner } from '@openedx/paragon';
5+
import { DeleteOutline, RestoreOutline } from '@openedx/paragon/icons';
6+
import classNames from 'classnames';
7+
8+
import { useIntl } from '@edx/frontend-platform/i18n';
9+
10+
import messages from '../messages';
11+
12+
const FilterBar = ({
13+
isDeletedView,
14+
setIsDeletedView,
15+
selectedThreadIds = [],
16+
onBulkAction,
17+
isLoading = false,
18+
}) => {
19+
const intl = useIntl();
20+
const [pendingAction, setPendingAction] = useState(null);
21+
22+
const handleBulkSoftDelete = async () => {
23+
if (selectedThreadIds.length === 0) { return; }
24+
25+
setPendingAction('soft-delete');
26+
try {
27+
await onBulkAction('soft-delete', selectedThreadIds);
28+
} finally {
29+
setPendingAction(null);
30+
}
31+
};
32+
33+
const handleBulkRestore = async () => {
34+
if (selectedThreadIds.length === 0) { return; }
35+
36+
setPendingAction('restore');
37+
try {
38+
await onBulkAction('restore', selectedThreadIds);
39+
} finally {
40+
setPendingAction(null);
41+
}
42+
};
43+
44+
const hasSelectedThreads = selectedThreadIds.length > 0;
45+
46+
return (
47+
<div className="d-flex align-items-center justify-content-between mb-3">
48+
{/* Filter Toggle Buttons */}
49+
<div
50+
className="d-flex gap-3"
51+
data-testid="filter-bar"
52+
>
53+
<Button
54+
variant={isDeletedView ? 'outline-primary' : 'primary'}
55+
size="sm"
56+
onClick={() => setIsDeletedView(false)}
57+
data-testid="active-threads-button"
58+
disabled={isLoading}
59+
style={{
60+
padding: '6px 12px',
61+
borderRadius: '4px',
62+
fontSize: '14px',
63+
fontWeight: '500',
64+
}}
65+
>
66+
{intl.formatMessage(messages.activeThreads)}
67+
</Button>
68+
<Button
69+
variant={isDeletedView ? 'primary' : 'outline-primary'}
70+
size="sm"
71+
onClick={() => setIsDeletedView(true)}
72+
data-testid="deleted-threads-button"
73+
disabled={isLoading}
74+
style={{
75+
padding: '6px 12px',
76+
borderRadius: '4px',
77+
fontSize: '14px',
78+
fontWeight: '500',
79+
}}
80+
>
81+
{intl.formatMessage(messages.deletedThreads)}
82+
</Button>
83+
84+
{isLoading && (
85+
<Spinner
86+
animation="border"
87+
size="sm"
88+
className="ms-2"
89+
screenReaderText={intl.formatMessage(messages.loadingThreads)}
90+
/>
91+
)}
92+
</div>
93+
94+
{/* Bulk Actions */}
95+
{hasSelectedThreads && onBulkAction && (
96+
<div className="d-flex align-items-center gap-3">
97+
<Badge variant="secondary">
98+
{intl.formatMessage(messages.selectedCount, { count: selectedThreadIds.length })}
99+
</Badge>
100+
101+
{!isDeletedView && (
102+
<Button
103+
variant="outline-danger"
104+
size="sm"
105+
onClick={handleBulkSoftDelete}
106+
disabled={pendingAction === 'soft-delete'}
107+
iconBefore={DeleteOutline}
108+
className={classNames({
109+
'opacity-50': pendingAction === 'soft-delete',
110+
})}
111+
data-testid="bulk-delete-button"
112+
>
113+
{pendingAction === 'soft-delete' ? (
114+
<>
115+
<Spinner size="sm" className="me-1" />
116+
{intl.formatMessage(messages.deleting)}
117+
</>
118+
) : (
119+
intl.formatMessage(messages.deleteSelected)
120+
)}
121+
</Button>
122+
)}
123+
124+
{isDeletedView && (
125+
<Button
126+
variant="outline-success"
127+
size="sm"
128+
onClick={handleBulkRestore}
129+
disabled={pendingAction === 'restore'}
130+
iconBefore={RestoreOutline}
131+
className={classNames({
132+
'opacity-50': pendingAction === 'restore',
133+
})}
134+
data-testid="bulk-restore-button"
135+
>
136+
{pendingAction === 'restore' ? (
137+
<>
138+
<Spinner size="sm" className="me-1" />
139+
{intl.formatMessage(messages.restoring)}
140+
</>
141+
) : (
142+
intl.formatMessage(messages.restoreSelected)
143+
)}
144+
</Button>
145+
)}
146+
</div>
147+
)}
148+
</div>
149+
);
150+
};
151+
152+
FilterBar.propTypes = {
153+
isDeletedView: PropTypes.bool.isRequired,
154+
setIsDeletedView: PropTypes.func.isRequired,
155+
selectedThreadIds: PropTypes.arrayOf(PropTypes.string),
156+
onBulkAction: PropTypes.func,
157+
isLoading: PropTypes.bool,
158+
};
159+
160+
export default FilterBar;

0 commit comments

Comments
 (0)