diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx
index 0c725fbf2d..d3a739ffc6 100644
--- a/src/library-authoring/LibraryAuthoringPage.test.tsx
+++ b/src/library-authoring/LibraryAuthoringPage.test.tsx
@@ -532,7 +532,7 @@ describe('', () => {
expect(submenu).toBeInTheDocument();
fireEvent.click(submenu);
- const clearFitlersButton = screen.getByRole('button', { name: /clear filters/i });
+ const clearFitlersButton = screen.getByText('Clear Filters');
fireEvent.click(clearFitlersButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
@@ -706,6 +706,72 @@ describe('', () => {
});
});
+ 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('Shows an error if libraries V2 is disabled', async () => {
const { axiosMock } = initializeMocks();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, {
diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx
index 01fc146b60..c3594519f4 100644
--- a/src/library-authoring/LibraryAuthoringPage.tsx
+++ b/src/library-authoring/LibraryAuthoringPage.tsx
@@ -31,6 +31,7 @@ import {
ClearFiltersButton,
FilterByBlockType,
FilterByTags,
+ FilterByPublished,
SearchContextProvider,
SearchKeywordsField,
SearchSortWidget,
@@ -254,6 +255,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
+
diff --git a/src/library-authoring/components/BaseComponentCard.tsx b/src/library-authoring/components/BaseComponentCard.tsx
index 3b5aa748c9..f7f8a754ab 100644
--- a/src/library-authoring/components/BaseComponentCard.tsx
+++ b/src/library-authoring/components/BaseComponentCard.tsx
@@ -1,22 +1,26 @@
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,
- openInfoSidebar: () => void
+ componentType: string;
+ displayName: string;
+ description: string;
+ numChildren?: number;
+ tags: ContentHitTags;
+ actions: React.ReactNode;
+ openInfoSidebar: () => void;
+ hasUnpublishedChanges?: boolean;
};
const BaseComponentCard = ({
@@ -27,6 +31,7 @@ const BaseComponentCard = ({
tags,
actions,
openInfoSidebar,
+ ...props
} : BaseComponentCardProps) => {
const tagCount = useMemo(() => {
if (!tags) {
@@ -37,6 +42,7 @@ const BaseComponentCard = ({
}, [tags]);
const componentIcon = getItemIcon(componentType);
+ const intl = useIntl();
return (
@@ -75,7 +81,8 @@ const BaseComponentCard = ({
-
+
+ {props.hasUnpublishedChanges ? {intl.formatMessage(messages.unpublishedChanges)} : null}
diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx
index 813255b97a..0cffa8f9c2 100644
--- a/src/library-authoring/components/ComponentCard.tsx
+++ b/src/library-authoring/components/ComponentCard.tsx
@@ -192,6 +192,8 @@ const ComponentCard = ({ contentHit }: ComponentCardProps) => {
formatted,
tags,
usageKey,
+ modified,
+ lastPublished,
} = contentHit;
const componentDescription: string = (
showOnlyPublished ? formatted.published?.description : formatted.description
@@ -216,6 +218,7 @@ const ComponentCard = ({ contentHit }: ComponentCardProps) => {
)}
openInfoSidebar={() => openComponentInfoSidebar(usageKey)}
+ hasUnpublishedChanges={modified >= (lastPublished ?? 0)}
/>
);
};
diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts
index 0e466736e6..4a40746838 100644
--- a/src/library-authoring/components/messages.ts
+++ b/src/library-authoring/components/messages.ts
@@ -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;
diff --git a/src/search-manager/ClearFiltersButton.tsx b/src/search-manager/ClearFiltersButton.tsx
index 0328d38616..f970bbebf8 100644
--- a/src/search-manager/ClearFiltersButton.tsx
+++ b/src/search-manager/ClearFiltersButton.tsx
@@ -17,14 +17,16 @@ const ClearFiltersButton = ({
size = 'sm',
}: ClearFiltersButtonProps) => {
const { canClearFilters, clearFilters } = useSearchContext();
- if (canClearFilters) {
- return (
-
- );
- }
- return null;
+ return (
+
+ );
};
export default ClearFiltersButton;
diff --git a/src/search-manager/FilterByPublished.tsx b/src/search-manager/FilterByPublished.tsx
new file mode 100644
index 0000000000..9fe4fefe9c
--- /dev/null
+++ b/src/search-manager/FilterByPublished.tsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import {
+ Badge,
+ Form,
+ Menu,
+ MenuItem,
+} from '@openedx/paragon';
+import { FilterList } from '@openedx/paragon/icons';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import messages from './messages';
+import SearchFilterWidget from './SearchFilterWidget';
+import { useSearchContext } from './SearchManager';
+import { PublishStatus } from './data/api';
+
+/**
+ * A button with a dropdown that allows filtering the current search by publish status
+ */
+const FilterByPublished: React.FC> = () => {
+ const intl = useIntl();
+ const {
+ publishStatus,
+ publishStatusFilter,
+ setPublishStatusFilter,
+ } = useSearchContext();
+
+ const clearFilters = React.useCallback(() => {
+ setPublishStatusFilter([]);
+ }, []);
+
+ const toggleFilterMode = React.useCallback((mode: PublishStatus) => {
+ setPublishStatusFilter(oldList => {
+ if (oldList.includes(mode)) {
+ return oldList.filter(m => m !== mode);
+ }
+ return [...oldList, mode];
+ });
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export default FilterByPublished;
diff --git a/src/search-manager/SearchManager.ts b/src/search-manager/SearchManager.ts
index 314c90020a..270f92af71 100644
--- a/src/search-manager/SearchManager.ts
+++ b/src/search-manager/SearchManager.ts
@@ -10,7 +10,11 @@ import { MeiliSearch, type Filter } from 'meilisearch';
import { union } from 'lodash';
import {
- CollectionHit, ContentHit, SearchSortOption, forceArray,
+ CollectionHit,
+ ContentHit,
+ SearchSortOption,
+ forceArray,
+ type PublishStatus,
} from './data/api';
import { useContentSearchConnection, useContentSearchResults } from './data/apiHooks';
@@ -23,10 +27,13 @@ export interface SearchContextData {
setBlockTypesFilter: React.Dispatch>;
problemTypesFilter: string[];
setProblemTypesFilter: React.Dispatch>;
+ publishStatusFilter: PublishStatus[];
+ setPublishStatusFilter: React.Dispatch>;
tagsFilter: string[];
setTagsFilter: React.Dispatch>;
blockTypes: Record;
problemTypes: Record;
+ publishStatus: Record;
extraFilter?: Filter;
canClearFilters: boolean;
clearFilters: () => void;
@@ -99,6 +106,7 @@ export const SearchContextProvider: React.FC<{
const [searchKeywords, setSearchKeywords] = React.useState('');
const [blockTypesFilter, setBlockTypesFilter] = React.useState([]);
const [problemTypesFilter, setProblemTypesFilter] = React.useState([]);
+ const [publishStatusFilter, setPublishStatusFilter] = React.useState([]);
const [tagsFilter, setTagsFilter] = React.useState([]);
const [usageKey, setUsageKey] = useStateWithUrlSearchParam(
'',
@@ -140,6 +148,7 @@ export const SearchContextProvider: React.FC<{
blockTypesFilter.length > 0
|| problemTypesFilter.length > 0
|| tagsFilter.length > 0
+ || publishStatusFilter.length > 0
|| !!usageKey
);
const isFiltered = canClearFilters || (searchKeywords !== '');
@@ -147,6 +156,7 @@ export const SearchContextProvider: React.FC<{
setBlockTypesFilter([]);
setTagsFilter([]);
setProblemTypesFilter([]);
+ setPublishStatusFilter([]);
if (usageKey !== '') {
setUsageKey('');
}
@@ -163,6 +173,7 @@ export const SearchContextProvider: React.FC<{
searchKeywords,
blockTypesFilter,
problemTypesFilter,
+ publishStatusFilter,
tagsFilter,
sort,
skipBlockTypeFetch,
@@ -178,6 +189,8 @@ export const SearchContextProvider: React.FC<{
setBlockTypesFilter,
problemTypesFilter,
setProblemTypesFilter,
+ publishStatusFilter,
+ setPublishStatusFilter,
tagsFilter,
setTagsFilter,
extraFilter,
diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts
index 0763000f55..f1a0f0a288 100644
--- a/src/search-manager/data/api.ts
+++ b/src/search-manager/data/api.ts
@@ -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.
*/
@@ -179,6 +185,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 +201,7 @@ export async function fetchSearchResults({
searchKeywords,
blockTypesFilter,
problemTypesFilter,
+ publishStatusFilter,
tagsFilter,
extraFilter,
sort,
@@ -205,6 +213,7 @@ export async function fetchSearchResults({
totalHits: number,
blockTypes: Record,
problemTypes: Record,
+ publishStatus: Record,
}> {
const queries: MultiSearchQuery[] = [];
@@ -215,6 +224,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 +246,7 @@ export async function fetchSearchResults({
...typeFilters,
...extraFilterFormatted,
...tagsFilterFormatted,
+ ...publishStatusFilterFormatted,
],
attributesToHighlight: ['display_name', 'description', 'published'],
highlightPreTag: HIGHLIGHT_PRE_TAG,
@@ -249,7 +261,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 +278,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,
};
}
diff --git a/src/search-manager/data/apiHooks.ts b/src/search-manager/data/apiHooks.ts
index 923749b20d..c2fe73bf7c 100644
--- a/src/search-manager/data/apiHooks.ts
+++ b/src/search-manager/data/apiHooks.ts
@@ -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,
diff --git a/src/search-manager/index.ts b/src/search-manager/index.ts
index e2d4188be1..495f8c822f 100644
--- a/src/search-manager/index.ts
+++ b/src/search-manager/index.ts
@@ -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';
diff --git a/src/search-manager/messages.ts b/src/search-manager/messages.ts
index aca799f93c..03bf205deb 100644
--- a/src/search-manager/messages.ts
+++ b/src/search-manager/messages.ts
@@ -221,6 +221,21 @@ 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',
+ },
});
export default messages;