feat: publish single library component (#1407)
This commit is contained in:
committed by
GitHub
parent
57e7baf59e
commit
966e1c3d91
@@ -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
|
||||
********
|
||||
|
||||
@@ -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('<ComponentInfo> 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(
|
||||
<ComponentInfo />,
|
||||
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(
|
||||
<ComponentInfo />,
|
||||
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(
|
||||
<ComponentInfo />,
|
||||
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(
|
||||
<ComponentInfo />,
|
||||
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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<Stack>
|
||||
@@ -70,7 +86,7 @@ const ComponentInfo = () => {
|
||||
>
|
||||
{intl.formatMessage(messages.editComponentButtonTitle)}
|
||||
</Button>
|
||||
<Button disabled variant="outline-primary" className="m-1 text-nowrap flex-grow-1">
|
||||
<Button disabled={publishComponent.isLoading || !canPublish} onClick={publish} variant="outline-primary" className="m-1 text-nowrap flex-grow-1">
|
||||
{intl.formatMessage(messages.publishComponentButtonTitle)}
|
||||
</Button>
|
||||
<ComponentMenu usageKey={usageKey} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Record<never, never>> = () => {
|
||||
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 (
|
||||
<EditorPage
|
||||
courseId={libraryId}
|
||||
@@ -34,8 +42,8 @@ export const ComponentEditorModal: React.FC<Record<never, never>> = () => {
|
||||
blockId={componentBeingEdited}
|
||||
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
|
||||
lmsEndpointUrl={getConfig().LMS_BASE_URL}
|
||||
onClose={closeComponentEditor}
|
||||
returnFunction={() => { closeComponentEditor(); return () => {}; }}
|
||||
onClose={onClose}
|
||||
returnFunction={() => { onClose(); return () => {}; }}
|
||||
fullScreen={false}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -315,6 +315,7 @@ export async function mockLibraryBlockMetadata(usageKey: string): Promise<api.Li
|
||||
case thisMock.usageKeyNeverPublished: return thisMock.dataNeverPublished;
|
||||
case thisMock.usageKeyPublished: return thisMock.dataPublished;
|
||||
case thisMock.usageKeyWithCollections: return thisMock.dataWithCollections;
|
||||
case thisMock.usageKeyPublishDisabled: return thisMock.dataPublishDisabled;
|
||||
case thisMock.usageKeyThirdPartyXBlock: return thisMock.dataThirdPartyXBlock;
|
||||
case thisMock.usageKeyForTags: return thisMock.dataPublished;
|
||||
default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`);
|
||||
@@ -354,6 +355,12 @@ mockLibraryBlockMetadata.dataPublished = {
|
||||
tagsCount: 0,
|
||||
collections: [],
|
||||
} satisfies api.LibraryBlockMetadata;
|
||||
mockLibraryBlockMetadata.usageKeyPublishDisabled = 'lb:Axim:TEST2-disabled:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2';
|
||||
mockLibraryBlockMetadata.dataPublishDisabled = {
|
||||
...mockLibraryBlockMetadata.dataPublished,
|
||||
id: mockLibraryBlockMetadata.usageKeyPublishDisabled,
|
||||
modified: '2024-06-11T13:54:21Z',
|
||||
} satisfies api.LibraryBlockMetadata;
|
||||
mockLibraryBlockMetadata.usageKeyThirdPartyXBlock = mockXBlockFields.usageKeyThirdParty;
|
||||
mockLibraryBlockMetadata.dataThirdPartyXBlock = {
|
||||
...mockLibraryBlockMetadata.dataPublished,
|
||||
|
||||
@@ -56,6 +56,10 @@ export const getXBlockFieldsApiUrl = (usageKey: string) => `${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<st
|
||||
return data.olx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the given XBlock.
|
||||
*/
|
||||
export async function publishXBlock(usageKey: string) {
|
||||
const client = getAuthenticatedHttpClient();
|
||||
await client.post(getXBlockPublishApiUrl(usageKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the asset (static file) list for the given XBlock.
|
||||
*/
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
getXBlockAssets,
|
||||
updateComponentCollections,
|
||||
removeComponentsFromCollection,
|
||||
publishXBlock,
|
||||
} from './api';
|
||||
|
||||
export const libraryQueryPredicate = (query: Query, libraryId: string): boolean => {
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user