diff --git a/README.rst b/README.rst index ab8217d5c..5490b135c 100644 --- a/README.rst +++ b/README.rst @@ -84,7 +84,7 @@ Then you can access the app at http://apps.local.openedx.io:2001/course-authorin Troubleshooting --------------- -If you see an "Invalid Host header" error, then you're probably using a different domain name for your devstack such as +* If you see an "Invalid Host header" error, then you're probably using a different domain name for your devstack such as ``local.edly.io`` or ``local.overhang.io`` (not the new recommended default, ``local.openedx.io``). In that case, run these commands to update your devstack's domain names: @@ -95,6 +95,11 @@ these commands to update your devstack's domain names: tutor dev launch -I --skip-build tutor dev stop authoring # We will run this MFE on the host +* If tutor-mfe is not starting the authoring MFE in development mode (eg. `tutor dev start authoring` fails), it may be due to + using a tutor version that expects the MFE name to be frontend-app-course-authoring (the previous name of this repo). To fix + this, you can rename the cloned repo directory to frontend-app-course-authoring. More information can be found in + [this forum post](https://discuss.openedx.org/t/repo-rename-frontend-app-course-authoring-frontend-app-authoring/13930/2) + Features ******** diff --git a/src/library-authoring/component-info/ComponentInfo.test.tsx b/src/library-authoring/component-info/ComponentInfo.test.tsx index f0f094fed..7cb44cfee 100644 --- a/src/library-authoring/component-info/ComponentInfo.test.tsx +++ b/src/library-authoring/component-info/ComponentInfo.test.tsx @@ -8,6 +8,7 @@ import { mockContentLibrary, mockLibraryBlockMetadata } from '../data/api.mocks' import { mockBroadcastChannel } from '../../generic/data/api.mock'; import { LibraryProvider, SidebarBodyComponentId } from '../common/context'; import ComponentInfo from './ComponentInfo'; +import { getXBlockPublishApiUrl } from '../data/api'; mockBroadcastChannel(); mockContentLibrary.applyMock(); @@ -67,4 +68,58 @@ describe(' Sidebar', () => { const editButton = await screen.findByRole('button', { name: /Edit component/ }); await waitFor(() => expect(editButton).not.toBeDisabled()); }); + + it('should show a disabled "Publish" button when the component is already published', async () => { + initializeMocks(); + render( + , + withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyPublishDisabled), + ); + const publishButton = await screen.findByRole('button', { name: /Publish component/ }); + expect(publishButton).toBeDisabled(); + }); + + it('should show a working "Publish" button when the component is not published', async () => { + initializeMocks(); + render( + , + withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyNeverPublished), + ); + const publishButton = await screen.findByRole('button', { name: /Publish component/ }); + await waitFor(() => expect(publishButton).not.toBeDisabled()); + }); + + it('should show toast message when the component is published successfully', async () => { + const { axiosMock, mockShowToast } = initializeMocks(); + const url = getXBlockPublishApiUrl(mockLibraryBlockMetadata.usageKeyNeverPublished); + axiosMock.onPost(url).reply(200); + render( + , + withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyNeverPublished), + ); + + const publishButton = await screen.findByRole('button', { name: /Publish component/i }); + publishButton.click(); + + await waitFor(() => { + expect(mockShowToast).toHaveBeenCalledWith('Component published successfully.'); + }); + }); + + it('should show toast message when the component fails to be published', async () => { + const { axiosMock, mockShowToast } = initializeMocks(); + const url = getXBlockPublishApiUrl(mockLibraryBlockMetadata.usageKeyNeverPublished); + axiosMock.onPost(url).reply(500); + render( + , + withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyNeverPublished), + ); + + const publishButton = await screen.findByRole('button', { name: /Publish component/i }); + publishButton.click(); + + await waitFor(() => { + expect(mockShowToast).toHaveBeenCalledWith('There was an error publishing the component.'); + }); + }); }); diff --git a/src/library-authoring/component-info/ComponentInfo.tsx b/src/library-authoring/component-info/ComponentInfo.tsx index 74268936c..80e773c95 100644 --- a/src/library-authoring/component-info/ComponentInfo.tsx +++ b/src/library-authoring/component-info/ComponentInfo.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, @@ -15,6 +15,8 @@ import ComponentManagement from './ComponentManagement'; import ComponentPreview from './ComponentPreview'; import messages from './messages'; import { getBlockType } from '../../generic/key-utils'; +import { useLibraryBlockMetadata, usePublishComponent } from '../data/apiHooks'; +import { ToastContext } from '../../generic/toast-context'; const ComponentInfo = () => { const intl = useIntl(); @@ -29,7 +31,7 @@ const ComponentInfo = () => { const jumpToCollections = sidebarComponentInfo?.additionalAction === SidebarAdditionalActions.JumpToAddCollections; // Show Manage tab if JumpToAddCollections action is set in sidebarComponentInfo - const [tab, setTab] = useState(jumpToCollections ? 'manage' : 'preview'); + const [tab, setTab] = React.useState(jumpToCollections ? 'manage' : 'preview'); useEffect(() => { if (jumpToCollections) { setTab('manage'); @@ -58,6 +60,20 @@ const ComponentInfo = () => { category: getBlockType(usageKey), }, '*'); }; + const publishComponent = usePublishComponent(usageKey); + const { data: componentMetadata } = useLibraryBlockMetadata(usageKey); + // Only can be published when the component has been modified after the last published date. + const canPublish = (new Date(componentMetadata?.modified ?? 0)) > (new Date(componentMetadata?.lastPublished ?? 0)); + const { showToast } = React.useContext(ToastContext); + + const publish = React.useCallback(() => { + publishComponent.mutateAsync() + .then(() => { + showToast(intl.formatMessage(messages.publishSuccessMsg)); + }).catch(() => { + showToast(intl.formatMessage(messages.publishErrorMsg)); + }); + }, [publishComponent, showToast, intl]); return ( @@ -70,7 +86,7 @@ const ComponentInfo = () => { > {intl.formatMessage(messages.editComponentButtonTitle)} - diff --git a/src/library-authoring/component-info/messages.ts b/src/library-authoring/component-info/messages.ts index 5be6ba134..88ad396b5 100644 --- a/src/library-authoring/component-info/messages.ts +++ b/src/library-authoring/component-info/messages.ts @@ -181,6 +181,16 @@ const messages = defineMessages({ defaultMessage: 'Failed to add component to course', description: 'Error message when adding component to course fails', }, + publishSuccessMsg: { + id: 'course-authoring.component-authoring.component.publish.success', + defaultMessage: 'Component published successfully.', + description: 'Message when the component is published successfully.', + }, + publishErrorMsg: { + id: 'course-authoring.component-authoring.component.publish.error', + defaultMessage: 'There was an error publishing the component.', + description: 'Message when there is an error when publishing the component.', + }, }); export default messages; diff --git a/src/library-authoring/components/ComponentEditorModal.tsx b/src/library-authoring/components/ComponentEditorModal.tsx index a2b6cc3fe..9dfcec163 100644 --- a/src/library-authoring/components/ComponentEditorModal.tsx +++ b/src/library-authoring/components/ComponentEditorModal.tsx @@ -1,9 +1,11 @@ import { getConfig } from '@edx/frontend-platform'; import React from 'react'; -import { useLibraryContext } from '../common/context'; -import { getBlockType } from '../../generic/key-utils'; +import { useQueryClient } from '@tanstack/react-query'; import EditorPage from '../../editors/EditorPage'; +import { getBlockType } from '../../generic/key-utils'; +import { useLibraryContext } from '../common/context'; +import { invalidateComponentData } from '../data/apiHooks'; /* eslint-disable import/prefer-default-export */ export function canEditComponent(usageKey: string): boolean { @@ -21,12 +23,18 @@ export function canEditComponent(usageKey: string): boolean { export const ComponentEditorModal: React.FC> = () => { const { componentBeingEdited, closeComponentEditor, libraryId } = useLibraryContext(); + const queryClient = useQueryClient(); if (componentBeingEdited === undefined) { return null; } const blockType = getBlockType(componentBeingEdited); + const onClose = () => { + closeComponentEditor(); + invalidateComponentData(queryClient, libraryId, componentBeingEdited); + }; + return ( > = () => { blockId={componentBeingEdited} studioEndpointUrl={getConfig().STUDIO_BASE_URL} lmsEndpointUrl={getConfig().LMS_BASE_URL} - onClose={closeComponentEditor} - returnFunction={() => { closeComponentEditor(); return () => {}; }} + onClose={onClose} + returnFunction={() => { onClose(); return () => {}; }} fullScreen={false} /> ); diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index 995ccb569..bfd4d81ef 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -315,6 +315,7 @@ export async function mockLibraryBlockMetadata(usageKey: string): Promise `${getApiBaseUrl()}/a * Get the URL for the xblock OLX API */ export const getXBlockOLXApiUrl = (usageKey: string) => `${getLibraryBlockMetadataUrl(usageKey)}olx/`; +/** + * Get the URL for the xblock Publish API + */ +export const getXBlockPublishApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/libraries/v2/blocks/${usageKey}/publish/`; /** * Get the URL for the xblock Assets List API */ @@ -198,12 +202,12 @@ export interface LibraryBlockMetadata { defKey: string | null; displayName: string; lastPublished: string | null; - publishedBy: string | null, - lastDraftCreated: string | null, + publishedBy: string | null; + lastDraftCreated: string | null; lastDraftCreatedBy: string | null, hasUnpublishedChanges: boolean; - created: string | null, - modified: string | null, + created: string | null; + modified: string | null; tagsCount: number; collections: CollectionMetadata[]; } @@ -421,6 +425,14 @@ export async function setXBlockOLX(usageKey: string, newOLX: string): Promise { @@ -373,6 +374,20 @@ export const useUpdateXBlockOLX = (usageKey: string) => { }); }; +/** + * Publish changes to a library component + */ +export const usePublishComponent = (usageKey: string) => { + const queryClient = useQueryClient(); + const contentLibraryId = getLibraryId(usageKey); + return useMutation({ + mutationFn: () => publishXBlock(usageKey), + onSettled: () => { + invalidateComponentData(queryClient, contentLibraryId, usageKey); + }, + }); +}; + /** Get the list of assets (static files) attached to a library component */ export const useXBlockAssets = (usageKey: string) => ( useQuery({