Skip to content

Commit

Permalink
feat: allow filtering library by publish status (#1570)
Browse files Browse the repository at this point in the history
* Adds a filter widget for Publish status.
* Adds "Unpublished changes" badge.
  • Loading branch information
DanielVZ96 authored Feb 5, 2025
1 parent 31f39cb commit 05dddce
Show file tree
Hide file tree
Showing 14 changed files with 267 additions and 14 deletions.
68 changes: 67 additions & 1 deletion src/library-authoring/LibraryAuthoringPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ describe('<LibraryAuthoringPage />', () => {
// Validate clear filters
fireEvent.click(problemMenu);

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

it('filters by publish status', async () => {
await renderLibraryPage();

// Open the publish status filter dropdown
const filterButton = screen.getByRole('button', { name: /publish status/i });
fireEvent.click(filterButton);

// Test each publish status filter option
const publishedCheckbox = screen.getByRole('checkbox', { name: /^published \d+$/i });
const modifiedCheckbox = screen.getByRole('checkbox', { name: /^modified since publish \d+$/i });
const neverPublishedCheckbox = screen.getByRole('checkbox', { name: /^never published \d+$/i });

// Verify initial state - no clear filters button
expect(screen.queryByRole('button', { name: /clear filters/i })).not.toBeInTheDocument();

// Test Published filter
fireEvent.click(publishedCheckbox);

// Wait for both the API call and the UI update
await waitFor(() => {
// Check that the API was called with the correct filter
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.stringContaining('"publish_status = published"'),
method: 'POST',
headers: expect.anything(),
});
});

// Wait for the clear filters button to appear
await waitFor(() => {
const clearFiltersButton = screen.getByText('Clear Filters');
expect(clearFiltersButton).toBeInTheDocument();
});

// Test Modified filter
fireEvent.click(modifiedCheckbox);
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.stringContaining('"publish_status = modified"'),
method: 'POST',
headers: expect.anything(),
});
});

// Test Never Published filter
fireEvent.click(neverPublishedCheckbox);
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.stringContaining('"publish_status = never"'),
method: 'POST',
headers: expect.anything(),
});
});

// Test clearing filters
const clearFiltersButton = screen.getByText('Clear Filters');
fireEvent.click(clearFiltersButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.stringContaining('"filter":[[],'), // Empty filter array
method: 'POST',
headers: expect.anything(),
});
});
});

it('Disables Type filter on Collections tab', async () => {
await renderLibraryPage();

Expand Down
2 changes: 2 additions & 0 deletions src/library-authoring/LibraryAuthoringPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
ClearFiltersButton,
FilterByBlockType,
FilterByTags,
FilterByPublished,
SearchContextProvider,
SearchKeywordsField,
SearchSortWidget,
Expand Down Expand Up @@ -266,6 +267,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
<SearchKeywordsField className="mr-3" />
<FilterByTags />
{!insideCollections && <FilterByBlockType />}
<FilterByPublished />
<ClearFiltersButton />
<ActionRow.Spacer />
<SearchSortWidget />
Expand Down
2 changes: 2 additions & 0 deletions src/library-authoring/collections/LibraryCollectionPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import NotFoundAlert from '../../generic/NotFoundAlert';
import {
ClearFiltersButton,
FilterByBlockType,
FilterByPublished,
FilterByTags,
SearchContextProvider,
SearchKeywordsField,
Expand Down Expand Up @@ -211,6 +212,7 @@ const LibraryCollectionPage = () => {
<SearchKeywordsField className="mr-3" />
<FilterByTags />
<FilterByBlockType />
<FilterByPublished />
<ClearFiltersButton />
<ActionRow.Spacer />
<SearchSortWidget />
Expand Down
21 changes: 14 additions & 7 deletions src/library-authoring/components/BaseComponentCard.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
import React, { useMemo } from 'react';
import {
Badge,
Card,
Container,
Icon,
Stack,
} from '@openedx/paragon';

import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils';
import TagCount from '../../generic/tag-count';
import { BlockTypeLabel, type ContentHitTags, Highlight } from '../../search-manager';

type BaseComponentCardProps = {
componentType: string,
displayName: string, description: string,
numChildren?: number,
tags: ContentHitTags,
actions: React.ReactNode,
componentType: string;
displayName: string;
description: string;
numChildren?: number;
tags: ContentHitTags;
actions: React.ReactNode;
hasUnpublishedChanges?: boolean;
onSelect: () => void
};

Expand All @@ -27,6 +31,7 @@ const BaseComponentCard = ({
tags,
actions,
onSelect,
...props
} : BaseComponentCardProps) => {
const tagCount = useMemo(() => {
if (!tags) {
Expand All @@ -37,6 +42,7 @@ const BaseComponentCard = ({
}, [tags]);

const componentIcon = getItemIcon(componentType);
const intl = useIntl();

return (
<Container className="library-component-card">
Expand Down Expand Up @@ -75,7 +81,8 @@ const BaseComponentCard = ({
<div className="text-truncate h3 mt-2">
<Highlight text={displayName} />
</div>
<Highlight text={description} />
<Highlight text={description} /><br />
{props.hasUnpublishedChanges ? <Badge variant="warning">{intl.formatMessage(messages.unpublishedChanges)}</Badge> : null}
</Card.Section>
</Card.Body>
</Card>
Expand Down
2 changes: 2 additions & 0 deletions src/library-authoring/components/ComponentCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { LibraryProvider } from '../common/context/LibraryContext';
import { getClipboardUrl } from '../../generic/data/api';
import { ContentHit } from '../../search-manager';
import ComponentCard from './ComponentCard';
import { PublishStatus } from '../../search-manager/data/api';

const contentHit: ContentHit = {
id: '1',
Expand All @@ -35,6 +36,7 @@ const contentHit: ContentHit = {
modified: 1722434322294,
lastPublished: null,
collections: {},
publishStatus: PublishStatus.Published,
};

const clipboardBroadcastChannelMock = {
Expand Down
3 changes: 3 additions & 0 deletions src/library-authoring/components/ComponentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import BaseComponentCard from './BaseComponentCard';
import { canEditComponent } from './ComponentEditorModal';
import messages from './messages';
import ComponentDeleter from './ComponentDeleter';
import { PublishStatus } from '../../search-manager/data/api';

type ComponentCardProps = {
contentHit: ContentHit,
Expand Down Expand Up @@ -196,6 +197,7 @@ const ComponentCard = ({ contentHit }: ComponentCardProps) => {
formatted,
tags,
usageKey,
publishStatus,
} = contentHit;
const componentDescription: string = (
showOnlyPublished ? formatted.published?.description : formatted.description
Expand Down Expand Up @@ -228,6 +230,7 @@ const ComponentCard = ({ contentHit }: ComponentCardProps) => {
)}
</ActionRow>
)}
hasUnpublishedChanges={publishStatus !== PublishStatus.Published}
onSelect={openComponent}
/>
);
Expand Down
6 changes: 5 additions & 1 deletion src/library-authoring/components/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ const messages = defineMessages({
defaultMessage: 'Select',
description: 'Button title for selecting multiple components',
},
unpublishedChanges: {
id: 'course-authoring.library-authoring.component.unpublished-changes',
defaultMessage: 'Unpublished changes',
description: 'Badge text shown when a component has unpublished changes',
},
});

export default messages;
107 changes: 107 additions & 0 deletions src/search-manager/FilterByPublished.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import React from 'react';
import {
Badge,
Form,
Menu,
MenuItem,
} from '@openedx/paragon';
import { FilterList } from '@openedx/paragon/icons';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import SearchFilterWidget from './SearchFilterWidget';
import { useSearchContext } from './SearchManager';
import { PublishStatus, SearchSortOption } from './data/api';

/**
* A button with a dropdown that allows filtering the current search by publish status
*/
const FilterByPublished: React.FC<Record<never, never>> = () => {
const [onlyPublished, setOnlyPublished] = React.useState(false);
const intl = useIntl();
const {
publishStatus,
publishStatusFilter,
setPublishStatusFilter,
searchSortOrder,
} = useSearchContext();

const clearFilters = React.useCallback(() => {
setPublishStatusFilter([]);
}, []);

React.useEffect(() => {
if (searchSortOrder === SearchSortOption.RECENTLY_PUBLISHED) {
setPublishStatusFilter([PublishStatus.Published, PublishStatus.Modified]);
setOnlyPublished(true);
} else {
setOnlyPublished(false);
}
}, [searchSortOrder]);

const toggleFilterMode = React.useCallback((mode: PublishStatus) => {
setPublishStatusFilter(oldList => {
if (oldList.includes(mode)) {
return oldList.filter(m => m !== mode);
}
return [...oldList, mode];
});
}, [setPublishStatusFilter]);
const modeToLabel = {
published: intl.formatMessage(messages.publishStatusPublished),
modified: intl.formatMessage(messages.publishStatusModified),
never: intl.formatMessage(messages.publishStatusNeverPublished),
};
const appliedFilters = publishStatusFilter.map(mode => ({ label: modeToLabel[mode] }));

return (
<SearchFilterWidget
appliedFilters={appliedFilters}
label={<FormattedMessage {...messages.publishStatusFilter} />}
clearFilter={clearFilters}
icon={FilterList}
>
<Form.Group className="mb-0">
<Form.CheckboxSet
name="publish-status-filter"
value={publishStatusFilter}
>
<Menu className="block-type-refinement-menu" style={{ boxShadow: 'none' }}>
<MenuItem
as={Form.Checkbox}
value={PublishStatus.Published}
onChange={() => { toggleFilterMode(PublishStatus.Published); }}
>
<div>
{intl.formatMessage(messages.publishStatusPublished)}
<Badge variant="light" pill>{publishStatus[PublishStatus.Published] ?? 0}</Badge>
</div>
</MenuItem>
<MenuItem
as={Form.Checkbox}
value={PublishStatus.Modified}
onChange={() => { toggleFilterMode(PublishStatus.Modified); }}
>
<div>
{intl.formatMessage(messages.publishStatusModified)}
<Badge variant="light" pill>{publishStatus[PublishStatus.Modified] ?? 0}</Badge>
</div>
</MenuItem>
<MenuItem
as={Form.Checkbox}
value={PublishStatus.NeverPublished}
onChange={() => { toggleFilterMode(PublishStatus.NeverPublished); }}
disabled={onlyPublished}
>
<div>
{intl.formatMessage(messages.publishStatusNeverPublished)}
<Badge variant="light" pill>{publishStatus[PublishStatus.NeverPublished] ?? 0}</Badge>
</div>
</MenuItem>
</Menu>
</Form.CheckboxSet>
</Form.Group>
</SearchFilterWidget>
);
};

export default FilterByPublished;
Loading

0 comments on commit 05dddce

Please sign in to comment.