feat: Show published components on content picker (#1420)

* feat: Show published components on content picker

---------

Co-authored-by: Braden MacDonald <braden@opencraft.com>
This commit is contained in:
Chris Chávez
2024-10-22 13:47:07 -05:00
committed by GitHub
parent 966e1c3d91
commit 21cbf80f23
17 changed files with 179 additions and 39 deletions

View File

@@ -148,6 +148,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
libraryData,
isLoadingLibraryData,
componentPickerMode,
showOnlyPublished,
sidebarComponentInfo,
openInfoSidebar,
} = useLibraryContext();
@@ -212,6 +213,11 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
/>
) : undefined;
const extraFilter = [`context_key = "${libraryId}"`];
if (showOnlyPublished) {
extraFilter.push('last_published IS NOT NULL');
}
return (
<div className="d-flex">
<div className="flex-grow-1">
@@ -230,7 +236,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
)}
<Container className="px-4 mt-4 mb-5 library-authoring-page">
<SearchContextProvider
extraFilter={`context_key = "${libraryId}"`}
extraFilter={extraFilter}
>
<SubHeader
title={<SubHeaderTitle title={libraryData.title} />}

View File

@@ -56,11 +56,21 @@ const RecentlyModified: React.FC<Record<never, never>> = () => {
};
const LibraryRecentlyModified: React.FC<Record<never, never>> = () => {
const { libraryId } = useLibraryContext();
const { libraryId, showOnlyPublished } = useLibraryContext();
const extraFilter = [`context_key = "${libraryId}"`];
if (showOnlyPublished) {
extraFilter.push('last_published IS NOT NULL');
}
return (
<SearchContextProvider
extraFilter={`context_key = "${libraryId}"`}
overrideSearchSortOrder={SearchSortOption.RECENTLY_MODIFIED}
extraFilter={extraFilter}
overrideSearchSortOrder={
showOnlyPublished
? SearchSortOption.RECENTLY_PUBLISHED
: SearchSortOption.RECENTLY_MODIFIED
}
>
<RecentlyModified />
</SearchContextProvider>

View File

@@ -34,6 +34,10 @@
"key": [
"my-first-collection"
]
},
"published": {
"display_name": "Introduction to Testing",
"description": "Testing"
}
},
{
@@ -64,6 +68,10 @@
"key": [
"my-first-collection"
]
},
"published": {
"display_name": "Second Text Component",
"description": "Second Testing"
}
},
{
@@ -97,6 +105,10 @@
"my-first-collection",
"my-second-collection"
]
},
"published": {
"display_name": "Third Text component",
"description": "Third Testing"
}
},
{
@@ -128,6 +140,10 @@
"key": [
"my-first-collection"
]
},
"published": {
"display_name": "Text 4",
"description": "Testing 4"
}
},
{
@@ -159,6 +175,10 @@
"key": [
"my-first-collection"
]
},
"published": {
"display_name": "Blank Problem",
"description": "Problem"
}
}
],

View File

@@ -26,7 +26,11 @@
"block_type": "html",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
"access_id": 15,
"published": {
"display_name": "Introduction to Testing",
"description": "Testing"
}
},
{
"id": "lbaximtesthtml73a22298-bcd9-4f4c-ae34-0bc2b0612480-46b4a7f2",
@@ -48,7 +52,11 @@
"block_type": "html",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
"access_id": 15,
"published": {
"display_name": "Second Text Component",
"description": "Second Testing"
}
},
{
"id": "lbaximtesthtmlbe5b5db9-26ba-4fac-86af-654538c70b5e-73dbaa95",
@@ -71,7 +79,11 @@
"block_type": "html",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
"access_id": 15,
"published": {
"display_name": "Third Text component",
"description": "Third Testing"
}
},
{
"id": "lbaximtesthtmle59e8c73-4056-4894-bca4-062781fb3f68-46a404b2",
@@ -94,7 +106,11 @@
"block_type": "html",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
"access_id": 15,
"published": {
"display_name": "Text 4",
"description": "Testing 4"
}
},
{
"id": "lbaximtestproblemf16116c9-516e-4bb9-b99e-103599f62417-f2798115",
@@ -117,7 +133,11 @@
"block_type": "problem",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
"access_id": 15,
"published": {
"display_name": "Blank Problem",
"description": "Problem"
}
},
{
"id": "lbaximtestproblem2ace6b9b-6620-413c-a66f-19c797527f34-3a7973b7",
@@ -143,7 +163,11 @@
"block_type": "problem",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
"access_id": 15,
"published": {
"display_name": "Multiple Choice Problem",
"description": "Problem"
}
},
{
"id": "lbaximtestproblem7d7e98ba-3ac9-4aa8-8946-159129b39a28-3a7973b7",
@@ -169,7 +193,11 @@
"block_type": "problem",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
"access_id": 15,
"published": {
"display_name": "Single Choice Problem",
"description": "Problem"
}
},
{
"id": "lbaximtestproblem4e1a72f9-ac93-42aa-a61c-ab5f9698c398-3a7973b7",
@@ -195,7 +223,11 @@
"block_type": "problem",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
"access_id": 15,
"published": {
"display_name": "Numerical Response Problem",
"description": "Problem"
}
},
{
"id": "lbaximtestproblemad483625-ade2-4712-88d8-c9743abbd291-3a7973b7",
@@ -221,7 +253,11 @@
"block_type": "problem",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
"access_id": 15,
"published": {
"display_name": "Option Response Problem",
"description": "Problem"
}
},
{
"id": "lbaximtestproblemb4c859cb-de70-421a-917b-e6e01ce44bd8-3a7973b7",
@@ -247,7 +283,11 @@
"block_type": "problem",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
"access_id": 15,
"published": {
"display_name": "String Response Problem",
"description": "Problem"
}
}
],
"query": "",

View File

@@ -3,11 +3,13 @@ export default [
id: '1',
usageKey: 'lb:org:lib:html:1',
displayName: 'Text Component 1',
description: 'This is a text: ID=1',
formatted: {
displayName: 'Text Component 1',
content: {
htmlContent: 'This is a text: ID=1',
},
description: 'This is a text: ID=1',
},
tags: {
level0: ['1', '2', '3'],
@@ -18,11 +20,13 @@ export default [
id: '2',
usageKey: 'lb:org:lib:html:2',
displayName: 'Text Component 2',
description: 'This is a text: ID=2',
formatted: {
displayName: 'Text Component 2',
content: {
htmlContent: 'This is a text: ID=2',
},
description: 'This is a text: ID=2',
},
tags: {
level0: ['1', '2', '3'],
@@ -60,11 +64,13 @@ export default [
id: '5',
usageKey: 'lb:org:lib:problem:5',
displayName: 'Problem',
description: 'This is a problem: ID=5',
formatted: {
displayName: 'Problem',
content: {
capaContent: 'This is a problem: ID=5',
},
description: 'This is a problem: ID=5',
},
blockType: 'problem',
},
@@ -72,11 +78,13 @@ export default [
id: '6',
usageKey: 'lb:org:lib:problem:6',
displayName: 'Problem',
description: 'This is a problem: ID=6',
formatted: {
displayName: 'Problem',
content: {
capaContent: 'This is a problem: ID=6',
},
description: 'This is a problem: ID=6',
},
blockType: 'problem',
},

View File

@@ -107,6 +107,7 @@ const LibraryCollectionPage = () => {
sidebarComponentInfo,
openCollectionInfoSidebar,
componentPickerMode,
showOnlyPublished,
setCollectionId,
} = useLibraryContext();
@@ -175,6 +176,11 @@ const LibraryCollectionPage = () => {
/>
);
const extraFilter = [`context_key = "${libraryId}"`, `collections.key = "${collectionId}"`];
if (showOnlyPublished) {
extraFilter.push('last_published IS NOT NULL');
}
return (
<div className="d-flex">
<div className="flex-grow-1">
@@ -189,7 +195,7 @@ const LibraryCollectionPage = () => {
)}
<Container size="xl" className="px-4 mt-4 mb-5 library-authoring-page">
<SearchContextProvider
extraFilter={[`context_key = "${libraryId}"`, `collections.key = "${collectionId}"`]}
extraFilter={extraFilter}
overrideQueries={{ collections: { limit: 0 } }}
>
<SubHeader

View File

@@ -37,6 +37,8 @@ export interface LibraryContextData {
setCollectionId: (collectionId?: string) => void;
// Whether we're in "component picker" mode
componentPickerMode: boolean;
// Only show published components
showOnlyPublished: boolean;
// Sidebar stuff - only one sidebar is active at any given time:
closeLibrarySidebar: () => void;
openAddContentSidebar: () => void;
@@ -79,6 +81,7 @@ interface LibraryProviderProps {
/** The component picker mode is a special mode where the user is selecting a component to add to a Unit (or another
* XBlock) */
componentPickerMode?: boolean;
showOnlyPublished?: boolean;
/** Only used for testing */
initialSidebarComponentInfo?: SidebarComponentInfo;
}
@@ -91,6 +94,7 @@ export const LibraryProvider = ({
libraryId,
collectionId: collectionIdProp,
componentPickerMode = false,
showOnlyPublished = false,
initialSidebarComponentInfo,
}: LibraryProviderProps) => {
const [collectionId, setCollectionId] = useState(collectionIdProp);
@@ -148,6 +152,7 @@ export const LibraryProvider = ({
readOnly,
isLoadingLibraryData,
componentPickerMode,
showOnlyPublished,
closeLibrarySidebar,
openAddContentSidebar,
openInfoSidebar,
@@ -172,6 +177,7 @@ export const LibraryProvider = ({
readOnly,
isLoadingLibraryData,
componentPickerMode,
showOnlyPublished,
closeLibrarySidebar,
openAddContentSidebar,
openInfoSidebar,

View File

@@ -8,7 +8,7 @@ import {
initializeMocks,
} from '../../testUtils';
import { mockContentLibrary } from '../data/api.mocks';
import { getXBlockFieldsApiUrl } from '../data/api';
import { getXBlockFieldsVersionApiUrl, getXBlockFieldsApiUrl } from '../data/api';
import { LibraryProvider, SidebarBodyComponentId } from '../common/context';
import ComponentInfoHeader from './ComponentInfoHeader';
@@ -45,7 +45,7 @@ describe('<ComponentInfoHeader />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
axiosMock.onGet(getXBlockFieldsApiUrl(usageKey)).reply(200, xBlockFields);
axiosMock.onGet(getXBlockFieldsVersionApiUrl(usageKey, 'draft')).reply(200, xBlockFields);
mockShowToast = mocks.mockShowToast;
});
@@ -97,7 +97,7 @@ describe('<ComponentInfoHeader />', () => {
});
it('should close edit library title on press Escape', async () => {
const url = getXBlockFieldsApiUrl(usageKey);
const url = getXBlockFieldsVersionApiUrl(usageKey, 'draft');
axiosMock.onPost(url).reply(200);
render();

View File

@@ -20,6 +20,7 @@ const ComponentInfoHeader = () => {
const {
sidebarComponentInfo,
readOnly,
showOnlyPublished,
} = useLibraryContext();
const usageKey = sidebarComponentInfo?.id;
@@ -29,7 +30,7 @@ const ComponentInfoHeader = () => {
}
const {
data: xblockFields,
} = useXBlockFields(usageKey);
} = useXBlockFields(usageKey, showOnlyPublished ? 'published' : 'draft');
const updateMutation = useUpdateXBlockFields(usageKey);
const { showToast } = useContext(ToastContext);

View File

@@ -15,6 +15,7 @@ interface ModalComponentPreviewProps {
const ModalComponentPreview = ({ isOpen, close, usageKey }: ModalComponentPreviewProps) => {
const intl = useIntl();
const { showOnlyPublished } = useLibraryContext();
return (
<StandardModal
@@ -24,7 +25,10 @@ const ModalComponentPreview = ({ isOpen, close, usageKey }: ModalComponentPrevie
isOverflowVisible={false}
className="component-preview-modal"
>
<LibraryBlock usageKey={usageKey} />
<LibraryBlock
usageKey={usageKey}
version={showOnlyPublished ? 'published' : undefined}
/>
</StandardModal>
);
};
@@ -33,7 +37,7 @@ const ComponentPreview = () => {
const intl = useIntl();
const [isModalOpen, openModal, closeModal] = useToggle();
const { sidebarComponentInfo } = useLibraryContext();
const { sidebarComponentInfo, showOnlyPublished } = useLibraryContext();
const usageKey = sidebarComponentInfo?.id;
// istanbul ignore if: this should never happen
@@ -58,7 +62,13 @@ const ComponentPreview = () => {
{
// key=modified below is used to auto-refresh the preview when changes are made, e.g. via OLX editor
componentMetadata
? <LibraryBlock usageKey={usageKey} key={componentMetadata.modified} />
? (
<LibraryBlock
usageKey={usageKey}
key={componentMetadata.modified}
version={showOnlyPublished ? 'published' : undefined}
/>
)
: null
}
</div>

View File

@@ -17,6 +17,15 @@ import {
import { ComponentPicker } from './ComponentPicker';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: '/evilguy',
search: {
variant: 'published',
},
}),
}));
mockContentLibrary.applyMock();
mockContentSearchConfig.applyMock();
mockGetCollectionMetadata.applyMock();

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Stepper } from '@openedx/paragon';
import { LibraryProvider, useLibraryContext } from '../common/context';
@@ -24,6 +25,11 @@ export const ComponentPicker = () => {
const [currentStep, setCurrentStep] = useState('select-library');
const [selectedLibrary, setSelectedLibrary] = useState('');
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const variant = queryParams.get('variant') || 'draft';
const handleLibrarySelection = (library: string) => {
setCurrentStep('pick-components');
setSelectedLibrary(library);
@@ -43,7 +49,7 @@ export const ComponentPicker = () => {
</Stepper.Step>
<Stepper.Step eventKey="pick-components" title="Pick some components">
<LibraryProvider libraryId={selectedLibrary} componentPickerMode>
<LibraryProvider libraryId={selectedLibrary} componentPickerMode showOnlyPublished={variant === 'published'}>
<InnerComponentPicker returnToLibrarySelection={returnToLibrarySelection} />
</LibraryProvider>
</Stepper.Step>

View File

@@ -19,11 +19,13 @@ const contentHit: ContentHit = {
org: 'org1',
breadcrumbs: [{ displayName: 'Demo Lib' }],
displayName: 'Text Display Name',
description: 'This is a text: ID=1',
formatted: {
displayName: 'Text Display Formated Name',
content: {
htmlContent: 'This is a text: ID=1',
},
description: 'This is a text: ID=1',
},
tags: {
level0: ['1', '2', '3'],

View File

@@ -106,6 +106,7 @@ const ComponentCard = ({ contentHit }: ComponentCardProps) => {
const {
openComponentInfoSidebar,
componentPickerMode,
showOnlyPublished,
} = useLibraryContext();
const {
@@ -114,12 +115,12 @@ const ComponentCard = ({ contentHit }: ComponentCardProps) => {
tags,
usageKey,
} = contentHit;
const description: string = (/* eslint-disable */
blockType === 'html' ? formatted?.content?.htmlContent :
blockType === 'problem' ? formatted?.content?.capaContent :
undefined
) ?? '';/* eslint-enable */
const displayName = formatted?.displayName ?? '';
const componentDescription: string = (
showOnlyPublished ? formatted.published?.description : formatted.description
) ?? '';
const displayName: string = (
showOnlyPublished ? formatted.published?.displayName : formatted.displayName
) ?? '';
const handleAddComponentToCourse = () => {
window.parent.postMessage({
@@ -133,7 +134,7 @@ const ComponentCard = ({ contentHit }: ComponentCardProps) => {
<BaseComponentCard
componentType={blockType}
displayName={displayName}
description={description}
description={componentDescription}
tags={tags}
actions={(
<ActionRow>

View File

@@ -52,6 +52,8 @@ export const getLibraryPasteClipboardUrl = (libraryId: string) => `${getApiBaseU
* Get the URL for the xblock fields/metadata API.
*/
export const getXBlockFieldsApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/xblock/v2/xblocks/${usageKey}/fields/`;
export const getXBlockFieldsVersionApiUrl = (usageKey: string, version: string) => `${getApiBaseUrl()}/api/xblock/v2/xblocks/${usageKey}@${version}/fields/`;
/**
* Get the URL for the xblock OLX API
*/
@@ -383,8 +385,8 @@ export async function getLibraryBlockMetadata(usageKey: string): Promise<Library
/**
* Fetch xblock fields.
*/
export async function getXBlockFields(usageKey: string): Promise<XBlockFields> {
const { data } = await getAuthenticatedHttpClient().get(getXBlockFieldsApiUrl(usageKey));
export async function getXBlockFields(usageKey: string, version: string = 'draft'): Promise<XBlockFields> {
const { data } = await getAuthenticatedHttpClient().get(getXBlockFieldsVersionApiUrl(usageKey, version));
return camelCaseObject(data);
}

View File

@@ -89,7 +89,7 @@ export const xblockQueryKeys = {
*/
xblock: (usageKey?: string) => [...xblockQueryKeys.all, usageKey],
/** Fields (i.e. the content, display name, etc.) of an XBlock */
xblockFields: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'fields'],
xblockFields: (usageKey: string, version: string = 'draft') => [...xblockQueryKeys.xblock(usageKey), 'fields', version],
/** OLX (XML representation of the fields/content) */
xblockOLX: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'OLX'],
/** assets (static files) */
@@ -290,10 +290,10 @@ export const useLibraryBlockMetadata = (usageId: string) => (
})
);
export const useXBlockFields = (usageKey: string) => (
export const useXBlockFields = (usageKey: string, version: string = 'draft') => (
useQuery({
queryKey: xblockQueryKeys.xblockFields(usageKey),
queryFn: () => getXBlockFields(usageKey),
queryKey: xblockQueryKeys.xblockFields(usageKey, version),
queryFn: () => getXBlockFields(usageKey, version),
enabled: !!usageKey,
})
);

View File

@@ -127,9 +127,21 @@ export interface ContentHit extends BaseContentHit {
* - After that is the name and usage key of any parent Section/Subsection/Unit/etc.
*/
breadcrumbs: [{ displayName: string }, ...Array<{ displayName: string, usageKey: string }>];
description?: string;
content?: ContentDetails;
lastPublished: number | null;
collections: { displayName?: string[], key?: string[] },
collections: { displayName?: string[], key?: string[] };
published?: ContentPublishedData;
formatted: BaseContentHit['formatted'] & { published?: ContentPublishedData, };
}
/**
* Information about the published data of single Xblock returned in search results
* Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py
*/
export interface ContentPublishedData {
description?: string,
displayName?: string,
}
/**
@@ -152,6 +164,7 @@ export function formatSearchHit(hit: Record<string, any>): ContentHit | Collecti
displayName: _formatted?.display_name,
content: _formatted?.content ?? {},
description: _formatted?.description,
published: _formatted?.published,
};
return camelCaseObject(newHit);
}
@@ -247,10 +260,10 @@ export async function fetchSearchResults({
...extraFilterFormatted,
...tagsFilterFormatted,
],
attributesToHighlight: ['display_name', 'content'],
attributesToHighlight: ['display_name', 'description', 'published'],
highlightPreTag: HIGHLIGHT_PRE_TAG,
highlightPostTag: HIGHLIGHT_POST_TAG,
attributesToCrop: ['content'],
attributesToCrop: ['description', 'published'],
sort,
offset,
limit,