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 ( + + + + + { toggleFilterMode(PublishStatus.Published); }} + > +
+ Published + {' '}{publishStatus[PublishStatus.Published] ?? 0} +
+
+ { toggleFilterMode(PublishStatus.Modified); }} + > +
+ Modified since publish + {' '}{publishStatus[PublishStatus.Modified] ?? 0} +
+
+ { toggleFilterMode(PublishStatus.NeverPublished); }} + > +
+ Never published + {' '}{publishStatus[PublishStatus.NeverPublished] ?? 0} +
+
+
+
+
+
+ ); +}; + +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';