feat: allow filtering library by publish status (#1570)

* Adds a filter widget for Publish status.
* Adds "Unpublished changes" badge.
This commit is contained in:
Daniel Valenzuela
2025-02-05 15:09:27 -03:00
committed by GitHub
parent 31f39cb015
commit 05dddce920
14 changed files with 267 additions and 14 deletions

View File

@@ -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, {
@@ -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();

View File

@@ -27,6 +27,7 @@ import {
ClearFiltersButton,
FilterByBlockType,
FilterByTags,
FilterByPublished,
SearchContextProvider,
SearchKeywordsField,
SearchSortWidget,
@@ -266,6 +267,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
<SearchKeywordsField className="mr-3" />
<FilterByTags />
{!insideCollections && <FilterByBlockType />}
<FilterByPublished />
<ClearFiltersButton />
<ActionRow.Spacer />
<SearchSortWidget />

View File

@@ -22,6 +22,7 @@ import NotFoundAlert from '../../generic/NotFoundAlert';
import {
ClearFiltersButton,
FilterByBlockType,
FilterByPublished,
FilterByTags,
SearchContextProvider,
SearchKeywordsField,
@@ -211,6 +212,7 @@ const LibraryCollectionPage = () => {
<SearchKeywordsField className="mr-3" />
<FilterByTags />
<FilterByBlockType />
<FilterByPublished />
<ClearFiltersButton />
<ActionRow.Spacer />
<SearchSortWidget />

View File

@@ -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
};
@@ -27,6 +31,7 @@ const BaseComponentCard = ({
tags,
actions,
onSelect,
...props
} : BaseComponentCardProps) => {
const tagCount = useMemo(() => {
if (!tags) {
@@ -37,6 +42,7 @@ const BaseComponentCard = ({
}, [tags]);
const componentIcon = getItemIcon(componentType);
const intl = useIntl();
return (
<Container className="library-component-card">
@@ -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>

View File

@@ -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',
@@ -35,6 +36,7 @@ const contentHit: ContentHit = {
modified: 1722434322294,
lastPublished: null,
collections: {},
publishStatus: PublishStatus.Published,
};
const clipboardBroadcastChannelMock = {

View File

@@ -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,
@@ -196,6 +197,7 @@ const ComponentCard = ({ contentHit }: ComponentCardProps) => {
formatted,
tags,
usageKey,
publishStatus,
} = contentHit;
const componentDescription: string = (
showOnlyPublished ? formatted.published?.description : formatted.description
@@ -228,6 +230,7 @@ const ComponentCard = ({ contentHit }: ComponentCardProps) => {
)}
</ActionRow>
)}
hasUnpublishedChanges={publishStatus !== PublishStatus.Published}
onSelect={openComponent}
/>
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -9,7 +9,10 @@ import { MeiliSearch, type Filter } from 'meilisearch';
import { union } from 'lodash';
import {
CollectionHit, ContentHit, SearchSortOption, forceArray,
CollectionHit,
ContentHit,
SearchSortOption,
forceArray, PublishStatus,
} from './data/api';
import { TypesFilterData, useStateOrUrlSearchParam } from './hooks';
import { useContentSearchConnection, useContentSearchResults } from './data/apiHooks';
@@ -20,12 +23,15 @@ export interface SearchContextData {
indexName?: string;
searchKeywords: string;
setSearchKeywords: React.Dispatch<React.SetStateAction<string>>;
publishStatusFilter: PublishStatus[];
setPublishStatusFilter: React.Dispatch<React.SetStateAction<PublishStatus[]>>;
typesFilter: TypesFilterData;
setTypesFilter: React.Dispatch<React.SetStateAction<TypesFilterData>>;
tagsFilter: string[];
setTagsFilter: React.Dispatch<React.SetStateAction<string[]>>;
blockTypes: Record<string, number>;
problemTypes: Record<string, number>;
publishStatus: Record<string, number>;
extraFilter?: Filter;
canClearFilters: boolean;
clearFilters: () => void;
@@ -98,6 +104,14 @@ export const SearchContextProvider: React.FC<{
skipUrlUpdate,
);
const [publishStatusFilter, setPublishStatusFilter] = useStateOrUrlSearchParam<PublishStatus>(
[],
'published',
(value: string) => Object.values(PublishStatus).find((enumValue) => value === enumValue),
(value: PublishStatus) => value.toString(),
skipUrlUpdate,
);
// E.g ?usageKey=lb:OpenCraft:libA:problem:5714eb65-7c36-4eee-8ab9-a54ed5a95849
const sanitizeUsageKey = (value: string): string | undefined => {
try {
@@ -144,12 +158,14 @@ export const SearchContextProvider: React.FC<{
const canClearFilters = (
!typesFilter.isEmpty()
|| tagsFilter.length > 0
|| publishStatusFilter.length > 0
|| !!usageKey
);
const isFiltered = canClearFilters || (searchKeywords !== '');
const clearFilters = React.useCallback(() => {
setTypesFilter((types) => types.clear());
setTagsFilter([]);
setPublishStatusFilter([]);
if (usageKey !== '') {
setUsageKey('');
}
@@ -164,6 +180,7 @@ export const SearchContextProvider: React.FC<{
indexName,
extraFilter,
searchKeywords,
publishStatusFilter,
blockTypesFilter: [...typesFilter.blocks],
problemTypesFilter: [...typesFilter.problems],
tagsFilter,
@@ -177,6 +194,8 @@ export const SearchContextProvider: React.FC<{
indexName,
searchKeywords,
setSearchKeywords,
publishStatusFilter,
setPublishStatusFilter,
typesFilter,
setTypesFilter,
tagsFilter,

View File

@@ -25,6 +25,12 @@ export enum SearchSortOption {
RECENTLY_MODIFIED = 'modified:desc',
}
export enum PublishStatus {
Published = 'published',
Modified = 'modified',
NeverPublished = 'never',
}
/**
* Get the content search configuration from the CMS.
*/
@@ -133,6 +139,7 @@ export interface ContentHit extends BaseContentHit {
lastPublished: number | null;
collections: { displayName?: string[], key?: string[] };
published?: ContentPublishedData;
publishStatus: PublishStatus;
formatted: BaseContentHit['formatted'] & { published?: ContentPublishedData, };
}
@@ -179,6 +186,7 @@ interface FetchSearchParams {
searchKeywords: string,
blockTypesFilter?: string[],
problemTypesFilter?: string[],
publishStatusFilter?: PublishStatus[],
/** The full path of tags that each result MUST have, e.g. ["Difficulty > Hard", "Subject > Math"] */
tagsFilter?: string[],
extraFilter?: Filter,
@@ -194,6 +202,7 @@ export async function fetchSearchResults({
searchKeywords,
blockTypesFilter,
problemTypesFilter,
publishStatusFilter,
tagsFilter,
extraFilter,
sort,
@@ -205,6 +214,7 @@ export async function fetchSearchResults({
totalHits: number,
blockTypes: Record<string, number>,
problemTypes: Record<string, number>,
publishStatus: Record<string, number>,
}> {
const queries: MultiSearchQuery[] = [];
@@ -215,6 +225,8 @@ export async function fetchSearchResults({
const problemTypesFilterFormatted = problemTypesFilter?.length ? [problemTypesFilter.map(pt => `content.problem_types = ${pt}`)] : [];
const publishStatusFilterFormatted = publishStatusFilter?.length ? [publishStatusFilter.map(ps => `publish_status = ${ps}`)] : [];
const tagsFilterFormatted = formatTagsFilter(tagsFilter);
const limit = 20; // How many results to retrieve per page.
@@ -235,6 +247,7 @@ export async function fetchSearchResults({
...typeFilters,
...extraFilterFormatted,
...tagsFilterFormatted,
...publishStatusFilterFormatted,
],
attributesToHighlight: ['display_name', 'description', 'published'],
highlightPreTag: HIGHLIGHT_PRE_TAG,
@@ -249,7 +262,7 @@ export async function fetchSearchResults({
if (!skipBlockTypeFetch) {
queries.push({
indexUid: indexName,
facets: ['block_type', 'content.problem_types'],
facets: ['block_type', 'content.problem_types', 'publish_status'],
filter: [
...extraFilterFormatted,
// We exclude the block type filter here so we get all the other available options for it.
@@ -266,6 +279,7 @@ export async function fetchSearchResults({
totalHits: results[0].totalHits ?? results[0].estimatedTotalHits ?? hitLength,
blockTypes: results[1]?.facetDistribution?.block_type ?? {},
problemTypes: results[1]?.facetDistribution?.['content.problem_types'] ?? {},
publishStatus: results[1]?.facetDistribution?.publish_status ?? {},
nextOffset: hitLength === limit ? offset + limit : undefined,
};
}

View File

@@ -10,6 +10,7 @@ import {
fetchTagsThatMatchKeyword,
getContentSearchConfig,
fetchBlockTypes,
type PublishStatus,
} from './api';
/**
@@ -53,6 +54,7 @@ export const useContentSearchResults = ({
searchKeywords,
blockTypesFilter = [],
problemTypesFilter = [],
publishStatusFilter = [],
tagsFilter = [],
sort = [],
skipBlockTypeFetch = false,
@@ -69,6 +71,7 @@ export const useContentSearchResults = ({
blockTypesFilter?: string[];
/** Only search for these problem types (e.g. `["choiceresponse", "multiplechoiceresponse"]`) */
problemTypesFilter?: string[];
publishStatusFilter?: PublishStatus[];
/** Required tags (all must match), e.g. `["Difficulty > Hard", "Subject > Math"]` */
tagsFilter?: string[];
/** Sort search results using these options */
@@ -88,6 +91,7 @@ export const useContentSearchResults = ({
searchKeywords,
blockTypesFilter,
problemTypesFilter,
publishStatusFilter,
tagsFilter,
sort,
],
@@ -103,6 +107,7 @@ export const useContentSearchResults = ({
searchKeywords,
blockTypesFilter,
problemTypesFilter,
publishStatusFilter,
tagsFilter,
sort,
// For infinite pagination of results, we can retrieve additional pages if requested.
@@ -128,6 +133,7 @@ export const useContentSearchResults = ({
// The distribution of block type filter options
blockTypes: pages?.[0]?.blockTypes ?? {},
problemTypes: pages?.[0]?.problemTypes ?? {},
publishStatus: pages?.[0]?.publishStatus ?? {},
status: query.status,
isLoading: query.isLoading,
isError: query.isError,

View File

@@ -3,6 +3,7 @@ export { default as BlockTypeLabel } from './BlockTypeLabel';
export { default as ClearFiltersButton } from './ClearFiltersButton';
export { default as FilterByBlockType } from './FilterByBlockType';
export { default as FilterByTags } from './FilterByTags';
export { default as FilterByPublished } from './FilterByPublished';
export { default as Highlight } from './Highlight';
export { default as SearchKeywordsField } from './SearchKeywordsField';
export { default as SearchSortWidget } from './SearchSortWidget';

View File

@@ -221,6 +221,26 @@ const messages = defineMessages({
defaultMessage: 'Most Relevant',
description: 'Label for the content search sort drop-down which sorts keyword searches by relevance',
},
publishStatusPublished: {
id: 'course-authoring.search-manager.publishStatus.published',
defaultMessage: 'Published',
description: 'Label for published content in the publish status filter',
},
publishStatusModified: {
id: 'course-authoring.search-manager.publishStatus.modified',
defaultMessage: 'Modified since publish',
description: 'Label for content modified since last publish in the publish status filter',
},
publishStatusNeverPublished: {
id: 'course-authoring.search-manager.publishStatus.neverPublished',
defaultMessage: 'Never published',
description: 'Label for content that has never been published in the publish status filter',
},
publishStatusFilter: {
id: 'course-authoring.search-manager.publishStatus.filter',
defaultMessage: 'Publish Status',
description: 'Label for the filter that allows filtering content by publish status',
},
});
export default messages;

View File

@@ -371,7 +371,7 @@ describe('<SearchUI />', () => {
// Clear any search filters applied by the previous test.
// We need to do this because search filters are stored in the URL, and so they can leak between tests.
const { queryByRole } = rendered;
const clearFilters = await queryByRole('button', { name: /clear filters/i });
const clearFilters = queryByRole('button', { name: /clear filters/i });
if (clearFilters) {
fireEvent.click(clearFilters);
}
@@ -425,7 +425,7 @@ describe('<SearchUI />', () => {
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
// Because we're mocking the results, there's no actual changes to the mock results,
// but we can verify that the filter was sent in the request
expect(fetchMock).toBeDone((_url, req) => {
expect(fetchMock).toHaveLastFetched((_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries?.[0]?.filter;
return JSON.stringify(requestedFilter) === JSON.stringify([
@@ -460,7 +460,7 @@ describe('<SearchUI />', () => {
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
// Because we're mocking the results, there's no actual changes to the mock results,
// but we can verify that the filter was sent in the request
expect(fetchMock).toBeDone((_url, req) => {
expect(fetchMock).toHaveLastFetched((_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries?.[0]?.filter;
return JSON.stringify(requestedFilter) === JSON.stringify([