Skip to content

Commit

Permalink
feat: publish single library component (#1407)
Browse files Browse the repository at this point in the history
  • Loading branch information
DanielVZ96 authored Oct 22, 2024
1 parent 57e7baf commit 966e1c3
Show file tree
Hide file tree
Showing 8 changed files with 140 additions and 12 deletions.
7 changes: 6 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
********
Expand Down
55 changes: 55 additions & 0 deletions src/library-authoring/component-info/ComponentInfo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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.');
});
});
});
22 changes: 19 additions & 3 deletions src/library-authoring/component-info/ComponentInfo.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button,
Expand All @@ -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();
Expand All @@ -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');
Expand Down Expand Up @@ -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>
Expand All @@ -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} />
Expand Down
10 changes: 10 additions & 0 deletions src/library-authoring/component-info/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
16 changes: 12 additions & 4 deletions src/library-authoring/components/ComponentEditorModal.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -21,21 +23,27 @@ 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}
blockType={blockType}
blockId={componentBeingEdited}
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
lmsEndpointUrl={getConfig().LMS_BASE_URL}
onClose={closeComponentEditor}
returnFunction={() => { closeComponentEditor(); return () => {}; }}
onClose={onClose}
returnFunction={() => { onClose(); return () => {}; }}
fullScreen={false}
/>
);
Expand Down
7 changes: 7 additions & 0 deletions src/library-authoring/data/api.mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}"`);
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 16 additions & 4 deletions src/library-authoring/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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[];
}
Expand Down Expand Up @@ -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.
*/
Expand Down
15 changes: 15 additions & 0 deletions src/library-authoring/data/apiHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
getXBlockAssets,
updateComponentCollections,
removeComponentsFromCollection,
publishXBlock,
} from './api';

export const libraryQueryPredicate = (query: Query, libraryId: string): boolean => {
Expand Down Expand Up @@ -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({
Expand Down

0 comments on commit 966e1c3

Please sign in to comment.