diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx
index 76cef54178..73f45f62b1 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,
@@ -249,6 +250,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
+
diff --git a/src/library-authoring/components/BaseComponentCard.tsx b/src/library-authoring/components/BaseComponentCard.tsx
index 3b5aa748c9..1c3d137ee5 100644
--- a/src/library-authoring/components/BaseComponentCard.tsx
+++ b/src/library-authoring/components/BaseComponentCard.tsx
@@ -1,5 +1,6 @@
import React, { useMemo } from 'react';
import {
+ Badge,
Card,
Container,
Icon,
@@ -11,12 +12,14 @@ 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 +30,7 @@ const BaseComponentCard = ({
tags,
actions,
openInfoSidebar,
+ ...props
} : BaseComponentCardProps) => {
const tagCount = useMemo(() => {
if (!tags) {
@@ -75,7 +79,8 @@ const BaseComponentCard = ({
-
+
+ {props.hasUnpublishedChanges ?
Unpublished changes : null}
diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx
index c21f15467e..5c7691c5ac 100644
--- a/src/library-authoring/components/ComponentCard.tsx
+++ b/src/library-authoring/components/ComponentCard.tsx
@@ -189,6 +189,8 @@ const ComponentCard = ({ contentHit }: ComponentCardProps) => {
formatted,
tags,
usageKey,
+ modified,
+ lastPublished,
} = contentHit;
const componentDescription: string = (
showOnlyPublished ? formatted.published?.description : formatted.description
@@ -213,6 +215,7 @@ const ComponentCard = ({ contentHit }: ComponentCardProps) => {
)}
openInfoSidebar={() => openComponentInfoSidebar(usageKey)}
+ hasUnpublishedChanges={modified >= (lastPublished ?? 0)}
/>
);
};
diff --git a/src/search-manager/FilterByPublished.tsx b/src/search-manager/FilterByPublished.tsx
new file mode 100644
index 0000000000..b0d7ee18ec
--- /dev/null
+++ b/src/search-manager/FilterByPublished.tsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import {
+ Badge,
+ Form,
+ Menu,
+ MenuItem,
+} from '@openedx/paragon';
+import { FilterList } from '@openedx/paragon/icons';
+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 {
+ 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..6fdf5ec744 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(
'',
@@ -163,6 +171,7 @@ export const SearchContextProvider: React.FC<{
searchKeywords,
blockTypesFilter,
problemTypesFilter,
+ publishStatusFilter,
tagsFilter,
sort,
skipBlockTypeFetch,
@@ -178,6 +187,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';