Skip to content

Commit 05dddce

Browse files
authored
feat: allow filtering library by publish status (#1570)
* Adds a filter widget for Publish status. * Adds "Unpublished changes" badge.
1 parent 31f39cb commit 05dddce

File tree

14 files changed

+267
-14
lines changed

14 files changed

+267
-14
lines changed

src/library-authoring/LibraryAuthoringPage.test.tsx

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -539,7 +539,7 @@ describe('<LibraryAuthoringPage />', () => {
539539
// Validate clear filters
540540
fireEvent.click(problemMenu);
541541

542-
const clearFitlersButton = screen.getByRole('button', { name: /clear filters/i });
542+
const clearFitlersButton = screen.getByText('Clear Filters');
543543
fireEvent.click(clearFitlersButton);
544544
await waitFor(() => {
545545
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
@@ -713,6 +713,72 @@ describe('<LibraryAuthoringPage />', () => {
713713
});
714714
});
715715

716+
it('filters by publish status', async () => {
717+
await renderLibraryPage();
718+
719+
// Open the publish status filter dropdown
720+
const filterButton = screen.getByRole('button', { name: /publish status/i });
721+
fireEvent.click(filterButton);
722+
723+
// Test each publish status filter option
724+
const publishedCheckbox = screen.getByRole('checkbox', { name: /^published \d+$/i });
725+
const modifiedCheckbox = screen.getByRole('checkbox', { name: /^modified since publish \d+$/i });
726+
const neverPublishedCheckbox = screen.getByRole('checkbox', { name: /^never published \d+$/i });
727+
728+
// Verify initial state - no clear filters button
729+
expect(screen.queryByRole('button', { name: /clear filters/i })).not.toBeInTheDocument();
730+
731+
// Test Published filter
732+
fireEvent.click(publishedCheckbox);
733+
734+
// Wait for both the API call and the UI update
735+
await waitFor(() => {
736+
// Check that the API was called with the correct filter
737+
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
738+
body: expect.stringContaining('"publish_status = published"'),
739+
method: 'POST',
740+
headers: expect.anything(),
741+
});
742+
});
743+
744+
// Wait for the clear filters button to appear
745+
await waitFor(() => {
746+
const clearFiltersButton = screen.getByText('Clear Filters');
747+
expect(clearFiltersButton).toBeInTheDocument();
748+
});
749+
750+
// Test Modified filter
751+
fireEvent.click(modifiedCheckbox);
752+
await waitFor(() => {
753+
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
754+
body: expect.stringContaining('"publish_status = modified"'),
755+
method: 'POST',
756+
headers: expect.anything(),
757+
});
758+
});
759+
760+
// Test Never Published filter
761+
fireEvent.click(neverPublishedCheckbox);
762+
await waitFor(() => {
763+
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
764+
body: expect.stringContaining('"publish_status = never"'),
765+
method: 'POST',
766+
headers: expect.anything(),
767+
});
768+
});
769+
770+
// Test clearing filters
771+
const clearFiltersButton = screen.getByText('Clear Filters');
772+
fireEvent.click(clearFiltersButton);
773+
await waitFor(() => {
774+
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
775+
body: expect.stringContaining('"filter":[[],'), // Empty filter array
776+
method: 'POST',
777+
headers: expect.anything(),
778+
});
779+
});
780+
});
781+
716782
it('Disables Type filter on Collections tab', async () => {
717783
await renderLibraryPage();
718784

src/library-authoring/LibraryAuthoringPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
ClearFiltersButton,
2828
FilterByBlockType,
2929
FilterByTags,
30+
FilterByPublished,
3031
SearchContextProvider,
3132
SearchKeywordsField,
3233
SearchSortWidget,
@@ -266,6 +267,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
266267
<SearchKeywordsField className="mr-3" />
267268
<FilterByTags />
268269
{!insideCollections && <FilterByBlockType />}
270+
<FilterByPublished />
269271
<ClearFiltersButton />
270272
<ActionRow.Spacer />
271273
<SearchSortWidget />

src/library-authoring/collections/LibraryCollectionPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import NotFoundAlert from '../../generic/NotFoundAlert';
2222
import {
2323
ClearFiltersButton,
2424
FilterByBlockType,
25+
FilterByPublished,
2526
FilterByTags,
2627
SearchContextProvider,
2728
SearchKeywordsField,
@@ -211,6 +212,7 @@ const LibraryCollectionPage = () => {
211212
<SearchKeywordsField className="mr-3" />
212213
<FilterByTags />
213214
<FilterByBlockType />
215+
<FilterByPublished />
214216
<ClearFiltersButton />
215217
<ActionRow.Spacer />
216218
<SearchSortWidget />

src/library-authoring/components/BaseComponentCard.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
import React, { useMemo } from 'react';
22
import {
3+
Badge,
34
Card,
45
Container,
56
Icon,
67
Stack,
78
} from '@openedx/paragon';
8-
9+
import { useIntl } from '@edx/frontend-platform/i18n';
10+
import messages from './messages';
911
import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils';
1012
import TagCount from '../../generic/tag-count';
1113
import { BlockTypeLabel, type ContentHitTags, Highlight } from '../../search-manager';
1214

1315
type BaseComponentCardProps = {
14-
componentType: string,
15-
displayName: string, description: string,
16-
numChildren?: number,
17-
tags: ContentHitTags,
18-
actions: React.ReactNode,
16+
componentType: string;
17+
displayName: string;
18+
description: string;
19+
numChildren?: number;
20+
tags: ContentHitTags;
21+
actions: React.ReactNode;
22+
hasUnpublishedChanges?: boolean;
1923
onSelect: () => void
2024
};
2125

@@ -27,6 +31,7 @@ const BaseComponentCard = ({
2731
tags,
2832
actions,
2933
onSelect,
34+
...props
3035
} : BaseComponentCardProps) => {
3136
const tagCount = useMemo(() => {
3237
if (!tags) {
@@ -37,6 +42,7 @@ const BaseComponentCard = ({
3742
}, [tags]);
3843

3944
const componentIcon = getItemIcon(componentType);
45+
const intl = useIntl();
4046

4147
return (
4248
<Container className="library-component-card">
@@ -75,7 +81,8 @@ const BaseComponentCard = ({
7581
<div className="text-truncate h3 mt-2">
7682
<Highlight text={displayName} />
7783
</div>
78-
<Highlight text={description} />
84+
<Highlight text={description} /><br />
85+
{props.hasUnpublishedChanges ? <Badge variant="warning">{intl.formatMessage(messages.unpublishedChanges)}</Badge> : null}
7986
</Card.Section>
8087
</Card.Body>
8188
</Card>

src/library-authoring/components/ComponentCard.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { LibraryProvider } from '../common/context/LibraryContext';
99
import { getClipboardUrl } from '../../generic/data/api';
1010
import { ContentHit } from '../../search-manager';
1111
import ComponentCard from './ComponentCard';
12+
import { PublishStatus } from '../../search-manager/data/api';
1213

1314
const contentHit: ContentHit = {
1415
id: '1',
@@ -35,6 +36,7 @@ const contentHit: ContentHit = {
3536
modified: 1722434322294,
3637
lastPublished: null,
3738
collections: {},
39+
publishStatus: PublishStatus.Published,
3840
};
3941

4042
const clipboardBroadcastChannelMock = {

src/library-authoring/components/ComponentCard.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import BaseComponentCard from './BaseComponentCard';
2929
import { canEditComponent } from './ComponentEditorModal';
3030
import messages from './messages';
3131
import ComponentDeleter from './ComponentDeleter';
32+
import { PublishStatus } from '../../search-manager/data/api';
3233

3334
type ComponentCardProps = {
3435
contentHit: ContentHit,
@@ -196,6 +197,7 @@ const ComponentCard = ({ contentHit }: ComponentCardProps) => {
196197
formatted,
197198
tags,
198199
usageKey,
200+
publishStatus,
199201
} = contentHit;
200202
const componentDescription: string = (
201203
showOnlyPublished ? formatted.published?.description : formatted.description
@@ -228,6 +230,7 @@ const ComponentCard = ({ contentHit }: ComponentCardProps) => {
228230
)}
229231
</ActionRow>
230232
)}
233+
hasUnpublishedChanges={publishStatus !== PublishStatus.Published}
231234
onSelect={openComponent}
232235
/>
233236
);

src/library-authoring/components/messages.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,10 @@ const messages = defineMessages({
151151
defaultMessage: 'Select',
152152
description: 'Button title for selecting multiple components',
153153
},
154+
unpublishedChanges: {
155+
id: 'course-authoring.library-authoring.component.unpublished-changes',
156+
defaultMessage: 'Unpublished changes',
157+
description: 'Badge text shown when a component has unpublished changes',
158+
},
154159
});
155-
156160
export default messages;
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import React from 'react';
2+
import {
3+
Badge,
4+
Form,
5+
Menu,
6+
MenuItem,
7+
} from '@openedx/paragon';
8+
import { FilterList } from '@openedx/paragon/icons';
9+
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
10+
import messages from './messages';
11+
import SearchFilterWidget from './SearchFilterWidget';
12+
import { useSearchContext } from './SearchManager';
13+
import { PublishStatus, SearchSortOption } from './data/api';
14+
15+
/**
16+
* A button with a dropdown that allows filtering the current search by publish status
17+
*/
18+
const FilterByPublished: React.FC<Record<never, never>> = () => {
19+
const [onlyPublished, setOnlyPublished] = React.useState(false);
20+
const intl = useIntl();
21+
const {
22+
publishStatus,
23+
publishStatusFilter,
24+
setPublishStatusFilter,
25+
searchSortOrder,
26+
} = useSearchContext();
27+
28+
const clearFilters = React.useCallback(() => {
29+
setPublishStatusFilter([]);
30+
}, []);
31+
32+
React.useEffect(() => {
33+
if (searchSortOrder === SearchSortOption.RECENTLY_PUBLISHED) {
34+
setPublishStatusFilter([PublishStatus.Published, PublishStatus.Modified]);
35+
setOnlyPublished(true);
36+
} else {
37+
setOnlyPublished(false);
38+
}
39+
}, [searchSortOrder]);
40+
41+
const toggleFilterMode = React.useCallback((mode: PublishStatus) => {
42+
setPublishStatusFilter(oldList => {
43+
if (oldList.includes(mode)) {
44+
return oldList.filter(m => m !== mode);
45+
}
46+
return [...oldList, mode];
47+
});
48+
}, [setPublishStatusFilter]);
49+
const modeToLabel = {
50+
published: intl.formatMessage(messages.publishStatusPublished),
51+
modified: intl.formatMessage(messages.publishStatusModified),
52+
never: intl.formatMessage(messages.publishStatusNeverPublished),
53+
};
54+
const appliedFilters = publishStatusFilter.map(mode => ({ label: modeToLabel[mode] }));
55+
56+
return (
57+
<SearchFilterWidget
58+
appliedFilters={appliedFilters}
59+
label={<FormattedMessage {...messages.publishStatusFilter} />}
60+
clearFilter={clearFilters}
61+
icon={FilterList}
62+
>
63+
<Form.Group className="mb-0">
64+
<Form.CheckboxSet
65+
name="publish-status-filter"
66+
value={publishStatusFilter}
67+
>
68+
<Menu className="block-type-refinement-menu" style={{ boxShadow: 'none' }}>
69+
<MenuItem
70+
as={Form.Checkbox}
71+
value={PublishStatus.Published}
72+
onChange={() => { toggleFilterMode(PublishStatus.Published); }}
73+
>
74+
<div>
75+
{intl.formatMessage(messages.publishStatusPublished)}
76+
<Badge variant="light" pill>{publishStatus[PublishStatus.Published] ?? 0}</Badge>
77+
</div>
78+
</MenuItem>
79+
<MenuItem
80+
as={Form.Checkbox}
81+
value={PublishStatus.Modified}
82+
onChange={() => { toggleFilterMode(PublishStatus.Modified); }}
83+
>
84+
<div>
85+
{intl.formatMessage(messages.publishStatusModified)}
86+
<Badge variant="light" pill>{publishStatus[PublishStatus.Modified] ?? 0}</Badge>
87+
</div>
88+
</MenuItem>
89+
<MenuItem
90+
as={Form.Checkbox}
91+
value={PublishStatus.NeverPublished}
92+
onChange={() => { toggleFilterMode(PublishStatus.NeverPublished); }}
93+
disabled={onlyPublished}
94+
>
95+
<div>
96+
{intl.formatMessage(messages.publishStatusNeverPublished)}
97+
<Badge variant="light" pill>{publishStatus[PublishStatus.NeverPublished] ?? 0}</Badge>
98+
</div>
99+
</MenuItem>
100+
</Menu>
101+
</Form.CheckboxSet>
102+
</Form.Group>
103+
</SearchFilterWidget>
104+
);
105+
};
106+
107+
export default FilterByPublished;

0 commit comments

Comments
 (0)