feat: add component sidebar manage tab [FC-0062] (#1275)
This commit is contained in:
@@ -11,6 +11,7 @@ import { Link } from 'react-router-dom';
|
||||
import { getEditUrl } from '../components/utils';
|
||||
import { ComponentMenu } from '../components';
|
||||
import { ComponentDeveloperInfo } from './ComponentDeveloperInfo';
|
||||
import ComponentManagement from './ComponentManagement';
|
||||
import ComponentPreview from './ComponentPreview';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -46,7 +47,7 @@ const ComponentInfo = ({ usageKey }: ComponentInfoProps) => {
|
||||
<ComponentPreview usageKey={usageKey} />
|
||||
</Tab>
|
||||
<Tab eventKey="manage" title={intl.formatMessage(messages.manageTabTitle)}>
|
||||
Manage tab placeholder
|
||||
<ComponentManagement usageKey={usageKey} />
|
||||
</Tab>
|
||||
<Tab eventKey="details" title={intl.formatMessage(messages.detailsTabTitle)}>
|
||||
Details tab placeholder
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { setConfig, getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import {
|
||||
initializeMocks,
|
||||
render,
|
||||
screen,
|
||||
} from '../../testUtils';
|
||||
import { mockLibraryBlockMetadata } from '../data/api.mocks';
|
||||
import ComponentManagement from './ComponentManagement';
|
||||
|
||||
/*
|
||||
* FIXME: Summarize the reason here
|
||||
* https://stackoverflow.com/questions/47902335/innertext-is-undefined-in-jest-test
|
||||
*/
|
||||
const getInnerText = (element: Element) => element?.textContent
|
||||
?.split('\n')
|
||||
.filter((text) => text && !text.match(/^\s+$/))
|
||||
.map((text) => text.trim())
|
||||
.join(' ');
|
||||
|
||||
const matchInnerText = (nodeName: string, textToMatch: string) => (_: string, element: Element) => (
|
||||
element.nodeName === nodeName && getInnerText(element) === textToMatch
|
||||
);
|
||||
|
||||
describe('<ComponentManagement />', () => {
|
||||
it('should render draft status', async () => {
|
||||
initializeMocks();
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
|
||||
expect(await screen.findByText('Draft')).toBeInTheDocument();
|
||||
expect(await screen.findByText('(Never Published)')).toBeInTheDocument();
|
||||
expect(screen.getByText(matchInnerText('SPAN', 'Draft saved on June 20, 2024 at 13:54 UTC.'))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render published status', async () => {
|
||||
initializeMocks();
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyPublished} />);
|
||||
expect(await screen.findByText('Published')).toBeInTheDocument();
|
||||
expect(screen.getByText('Published')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(matchInnerText('SPAN', 'Last published on June 21, 2024 at 24:00 UTC by Luke.')),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the tagging info', async () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
|
||||
});
|
||||
initializeMocks();
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
|
||||
expect(await screen.findByText('Tags')).toBeInTheDocument();
|
||||
// TODO: replace with actual data when implement tag list
|
||||
expect(screen.queryByText('Tags placeholder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render draft status', async () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: 'false',
|
||||
});
|
||||
initializeMocks();
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
|
||||
expect(await screen.findByText('Draft')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Tags')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
57
src/library-authoring/component-info/ComponentManagement.tsx
Normal file
57
src/library-authoring/component-info/ComponentManagement.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Collapsible, Icon, Stack } from '@openedx/paragon';
|
||||
import { Tag } from '@openedx/paragon/icons';
|
||||
|
||||
import { useLibraryBlockMetadata } from '../data/apiHooks';
|
||||
import StatusWidget from '../generic/status-widget';
|
||||
import messages from './messages';
|
||||
|
||||
interface ComponentManagementProps {
|
||||
usageKey: string;
|
||||
}
|
||||
const ComponentManagement = ({ usageKey }: ComponentManagementProps) => {
|
||||
const intl = useIntl();
|
||||
const { data: componentMetadata } = useLibraryBlockMetadata(usageKey);
|
||||
|
||||
if (!componentMetadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={3}>
|
||||
<StatusWidget
|
||||
{...componentMetadata}
|
||||
/>
|
||||
{[true, 'true'].includes(getConfig().ENABLE_TAGGING_TAXONOMY_PAGES)
|
||||
&& (
|
||||
<Collapsible
|
||||
defaultOpen
|
||||
title={(
|
||||
<Stack gap={1} direction="horizontal">
|
||||
<Icon src={Tag} />
|
||||
{intl.formatMessage(messages.manageTabTagsTitle)}
|
||||
</Stack>
|
||||
)}
|
||||
className="border-0"
|
||||
>
|
||||
Tags placeholder
|
||||
</Collapsible>
|
||||
)}
|
||||
<Collapsible
|
||||
defaultOpen
|
||||
title={(
|
||||
<Stack gap={1} direction="horizontal">
|
||||
<Icon src={Tag} />
|
||||
{intl.formatMessage(messages.manageTabCollectionsTitle)}
|
||||
</Stack>
|
||||
)}
|
||||
className="border-0"
|
||||
>
|
||||
Collections placeholder
|
||||
</Collapsible>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComponentManagement;
|
||||
@@ -36,6 +36,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Manage',
|
||||
description: 'Title for manage tab',
|
||||
},
|
||||
manageTabTagsTitle: {
|
||||
id: 'course-authoring.library-authoring.component.manage-tab.tags-title',
|
||||
defaultMessage: 'Tags',
|
||||
description: 'Title for the Tags container in the management tab',
|
||||
},
|
||||
manageTabCollectionsTitle: {
|
||||
id: 'course-authoring.library-authoring.component.manage-tab.collections-title',
|
||||
defaultMessage: 'Collections',
|
||||
description: 'Title for the Collections container in the management tab',
|
||||
},
|
||||
detailsTabTitle: {
|
||||
id: 'course-authoring.library-authoring.component.details-tab.title',
|
||||
defaultMessage: 'Details',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
@@ -126,16 +126,26 @@ mockCreateLibraryBlock.newHtmlData = {
|
||||
blockType: 'html',
|
||||
displayName: 'New Text Component',
|
||||
hasUnpublishedChanges: true,
|
||||
lastPublished: null, // or e.g. '2024-08-30T16:37:42Z',
|
||||
publishedBy: null, // or e.g. 'test_author',
|
||||
lastDraftCreated: '2024-07-22T21:37:49Z',
|
||||
lastDraftCreatedBy: null,
|
||||
created: '2024-07-22T21:37:49Z',
|
||||
tagsCount: 0,
|
||||
} satisfies api.CreateBlockDataResponse;
|
||||
} satisfies api.LibraryBlockMetadata;
|
||||
mockCreateLibraryBlock.newProblemData = {
|
||||
id: 'lb:Axim:TEST:problem:prob1',
|
||||
defKey: 'prob1',
|
||||
blockType: 'problem',
|
||||
displayName: 'New Problem',
|
||||
hasUnpublishedChanges: true,
|
||||
lastPublished: null, // or e.g. '2024-08-30T16:37:42Z',
|
||||
publishedBy: null, // or e.g. 'test_author',
|
||||
lastDraftCreated: '2024-07-22T21:37:49Z',
|
||||
lastDraftCreatedBy: null,
|
||||
created: '2024-07-22T21:37:49Z',
|
||||
tagsCount: 0,
|
||||
} satisfies api.CreateBlockDataResponse;
|
||||
} satisfies api.LibraryBlockMetadata;
|
||||
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
||||
mockCreateLibraryBlock.applyMock = () => (
|
||||
jest.spyOn(api, 'createLibraryBlock').mockImplementation(mockCreateLibraryBlock)
|
||||
@@ -172,3 +182,49 @@ mockXBlockFields.dataNewHtml = {
|
||||
} satisfies api.XBlockFields;
|
||||
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
||||
mockXBlockFields.applyMock = () => jest.spyOn(api, 'getXBlockFields').mockImplementation(mockXBlockFields);
|
||||
|
||||
/**
|
||||
* Mock for `getLibraryBlockMetadata()`
|
||||
*
|
||||
* This mock returns different data/responses depending on the ID of the block
|
||||
* that you request. Use `mockLibraryBlockMetadata.applyMock()` to apply it to the whole
|
||||
* test suite.
|
||||
*/
|
||||
export async function mockLibraryBlockMetadata(usageKey: string): Promise<api.LibraryBlockMetadata> {
|
||||
const thisMock = mockLibraryBlockMetadata;
|
||||
switch (usageKey) {
|
||||
case thisMock.usageKeyNeverPublished: return thisMock.dataNeverPublished;
|
||||
case thisMock.usageKeyPublished: return thisMock.dataPublished;
|
||||
default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`);
|
||||
}
|
||||
}
|
||||
mockLibraryBlockMetadata.usageKeyNeverPublished = 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1';
|
||||
mockLibraryBlockMetadata.dataNeverPublished = {
|
||||
id: 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1',
|
||||
defKey: null,
|
||||
blockType: 'html',
|
||||
displayName: 'Introduction to Testing 1',
|
||||
lastPublished: null,
|
||||
publishedBy: null,
|
||||
lastDraftCreated: null,
|
||||
lastDraftCreatedBy: null,
|
||||
hasUnpublishedChanges: false,
|
||||
created: '2024-06-20T13:54:21Z',
|
||||
tagsCount: 0,
|
||||
} satisfies api.LibraryBlockMetadata;
|
||||
mockLibraryBlockMetadata.usageKeyPublished = 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2';
|
||||
mockLibraryBlockMetadata.dataPublished = {
|
||||
id: 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2',
|
||||
defKey: null,
|
||||
blockType: 'html',
|
||||
displayName: 'Introduction to Testing 2',
|
||||
lastPublished: '2024-06-21T00:00:00',
|
||||
publishedBy: 'Luke',
|
||||
lastDraftCreated: null,
|
||||
lastDraftCreatedBy: '2024-06-20T20:00:00Z',
|
||||
hasUnpublishedChanges: false,
|
||||
created: '2024-06-20T13:54:21Z',
|
||||
tagsCount: 0,
|
||||
} satisfies api.LibraryBlockMetadata;
|
||||
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
||||
mockLibraryBlockMetadata.applyMock = () => jest.spyOn(api, 'getLibraryBlockMetadata').mockImplementation(mockLibraryBlockMetadata);
|
||||
|
||||
@@ -18,6 +18,14 @@ export const getLibraryBlockTypesUrl = (libraryId: string) => `${getApiBaseUrl()
|
||||
*/
|
||||
export const getCreateLibraryBlockUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/blocks/`;
|
||||
|
||||
/**
|
||||
* Get the URL for library block metadata.
|
||||
*/
|
||||
export const getLibraryBlockMetadataUrl = (usageKey: string) => `${getApiBaseUrl()}/api/libraries/v2/blocks/${usageKey}/`;
|
||||
|
||||
/**
|
||||
* Get the URL for content library list API.
|
||||
*/
|
||||
export const getContentLibraryV2ListApiUrl = () => `${getApiBaseUrl()}/api/libraries/v2/`;
|
||||
|
||||
/**
|
||||
@@ -110,12 +118,17 @@ export interface CreateBlockDataRequest {
|
||||
definitionId: string;
|
||||
}
|
||||
|
||||
export interface CreateBlockDataResponse {
|
||||
export interface LibraryBlockMetadata {
|
||||
id: string;
|
||||
blockType: string;
|
||||
defKey: string | null;
|
||||
displayName: string;
|
||||
lastPublished: string | null;
|
||||
publishedBy: string | null,
|
||||
lastDraftCreated: string | null,
|
||||
lastDraftCreatedBy: string | null,
|
||||
hasUnpublishedChanges: boolean;
|
||||
created: string | null,
|
||||
tagsCount: number;
|
||||
}
|
||||
|
||||
@@ -166,7 +179,7 @@ export async function createLibraryBlock({
|
||||
libraryId,
|
||||
blockType,
|
||||
definitionId,
|
||||
}: CreateBlockDataRequest): Promise<CreateBlockDataResponse> {
|
||||
}: CreateBlockDataRequest): Promise<LibraryBlockMetadata> {
|
||||
const client = getAuthenticatedHttpClient();
|
||||
const { data } = await client.post(
|
||||
getCreateLibraryBlockUrl(libraryId),
|
||||
@@ -229,7 +242,7 @@ export async function revertLibraryChanges(libraryId: string) {
|
||||
export async function libraryPasteClipboard({
|
||||
libraryId,
|
||||
blockId,
|
||||
}: LibraryPasteClipboardRequest): Promise<CreateBlockDataResponse> {
|
||||
}: LibraryPasteClipboardRequest): Promise<LibraryBlockMetadata> {
|
||||
const client = getAuthenticatedHttpClient();
|
||||
const { data } = await client.post(
|
||||
getLibraryPasteClipboardUrl(libraryId),
|
||||
@@ -240,6 +253,14 @@ export async function libraryPasteClipboard({
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch library block metadata.
|
||||
*/
|
||||
export async function getLibraryBlockMetadata(usageKey: string): Promise<LibraryBlockMetadata> {
|
||||
const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockMetadataUrl(usageKey));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch xblock fields.
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
revertLibraryChanges,
|
||||
updateLibraryMetadata,
|
||||
libraryPasteClipboard,
|
||||
getLibraryBlockMetadata,
|
||||
getXBlockFields,
|
||||
updateXBlockFields,
|
||||
createCollection,
|
||||
@@ -72,6 +73,7 @@ export const xblockQueryKeys = {
|
||||
xblockFields: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'fields'],
|
||||
/** OLX (XML representation of the fields/content) */
|
||||
xblockOLX: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'OLX'],
|
||||
componentMetadata: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'componentMetadata'],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -197,6 +199,13 @@ export const useLibraryPasteClipboard = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const useLibraryBlockMetadata = (usageId: string) => (
|
||||
useQuery({
|
||||
queryKey: xblockQueryKeys.componentMetadata(usageId),
|
||||
queryFn: () => getLibraryBlockMetadata(usageId),
|
||||
})
|
||||
);
|
||||
|
||||
export const useXBlockFields = (usageKey: string) => (
|
||||
useQuery({
|
||||
queryKey: xblockQueryKeys.xblockFields(usageKey),
|
||||
|
||||
1
src/library-authoring/generic/index.scss
Normal file
1
src/library-authoring/generic/index.scss
Normal file
@@ -0,0 +1 @@
|
||||
@import "./status-widget/StatusWidget";
|
||||
@@ -1,4 +1,4 @@
|
||||
.library-publish-status {
|
||||
.status-widget {
|
||||
&.draft-status {
|
||||
background-color: #FDF3E9;
|
||||
border-top: 4px solid #F4B57B;
|
||||
@@ -9,3 +9,4 @@
|
||||
border-top: 4px solid $info-400;
|
||||
}
|
||||
}
|
||||
|
||||
207
src/library-authoring/generic/status-widget/index.tsx
Normal file
207
src/library-authoring/generic/status-widget/index.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
FormattedDate,
|
||||
FormattedMessage,
|
||||
FormattedTime,
|
||||
useIntl,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Button, Container, Stack } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const CustomFormattedDate = ({ date }: { date: string }) => (
|
||||
<b>
|
||||
<FormattedDate
|
||||
value={date}
|
||||
year="numeric"
|
||||
month="long"
|
||||
day="2-digit"
|
||||
/>
|
||||
</b>
|
||||
);
|
||||
|
||||
const CustomFormattedTime = ({ date }: { date: string }) => (
|
||||
<b>
|
||||
<FormattedTime
|
||||
value={date}
|
||||
hour12={false}
|
||||
/>
|
||||
</b>
|
||||
);
|
||||
|
||||
type DraftBodyMessageProps = {
|
||||
lastDraftCreatedBy: string | null;
|
||||
lastDraftCreated: string | null;
|
||||
created: string | null;
|
||||
};
|
||||
const DraftBodyMessage = ({ lastDraftCreatedBy, lastDraftCreated, created }: DraftBodyMessageProps) => {
|
||||
if (lastDraftCreatedBy && lastDraftCreated) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
{...messages.lastDraftMsg}
|
||||
values={{
|
||||
date: <CustomFormattedDate date={lastDraftCreated} />,
|
||||
time: <CustomFormattedTime date={lastDraftCreated} />,
|
||||
user: <b>{lastDraftCreatedBy}</b>,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (lastDraftCreated) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
{...messages.lastDraftMsgWithoutUser}
|
||||
values={{
|
||||
date: <CustomFormattedDate date={lastDraftCreated} />,
|
||||
time: <CustomFormattedTime date={lastDraftCreated} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (created) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
{...messages.lastDraftMsgWithoutUser}
|
||||
values={{
|
||||
date: <CustomFormattedDate date={created} />,
|
||||
time: <CustomFormattedTime date={created} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
type StatusWidgedProps = {
|
||||
lastPublished: string | null;
|
||||
hasUnpublishedChanges: boolean;
|
||||
hasUnpublishedDeletes?: boolean;
|
||||
lastDraftCreatedBy: string | null;
|
||||
lastDraftCreated: string | null;
|
||||
created: string | null;
|
||||
publishedBy: string | null;
|
||||
numBlocks?: number;
|
||||
onCommit?: () => void;
|
||||
onRevert?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* This component displays the status of an entity (published, draft, etc.) and allows the user to publish
|
||||
* or discard changes.
|
||||
*
|
||||
* This component doesn't handle fetching the data or any other side effects. It only displays the status
|
||||
* and provides the buttons to commit or revert changes.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { data: libraryData } = useContentLibrary(libraryId);
|
||||
* const onCommit = () => { commitChanges(libraryId); };
|
||||
* const onRevert = () => { revertChanges(libraryId); };
|
||||
*
|
||||
* return <StatusWidget {...libraryData} onCommit={onCommit} onRevert={onRevert} />;
|
||||
* ```
|
||||
*/
|
||||
const StatusWidget = ({
|
||||
lastPublished,
|
||||
hasUnpublishedChanges,
|
||||
hasUnpublishedDeletes,
|
||||
lastDraftCreatedBy,
|
||||
lastDraftCreated,
|
||||
created,
|
||||
publishedBy,
|
||||
numBlocks,
|
||||
onCommit,
|
||||
onRevert,
|
||||
}: StatusWidgedProps) => {
|
||||
const intl = useIntl();
|
||||
|
||||
let isNew: boolean | undefined;
|
||||
let isPublished: boolean;
|
||||
let statusMessage: string;
|
||||
let extraStatusMessage: string | undefined;
|
||||
let bodyMessage: React.ReactNode | undefined;
|
||||
|
||||
if (!lastPublished) {
|
||||
// Entity is never published (new)
|
||||
isNew = numBlocks != null && numBlocks === 0; // allow discarding if components are added
|
||||
isPublished = false;
|
||||
statusMessage = intl.formatMessage(messages.draftStatusLabel);
|
||||
extraStatusMessage = intl.formatMessage(messages.neverPublishedLabel);
|
||||
bodyMessage = (<DraftBodyMessage {...{ lastDraftCreatedBy, lastDraftCreated, created }} />);
|
||||
} else if (hasUnpublishedChanges || hasUnpublishedDeletes) {
|
||||
// Entity is on Draft state
|
||||
isPublished = false;
|
||||
statusMessage = intl.formatMessage(messages.draftStatusLabel);
|
||||
extraStatusMessage = intl.formatMessage(messages.unpublishedStatusLabel);
|
||||
bodyMessage = (<DraftBodyMessage {...{ lastDraftCreatedBy, lastDraftCreated, created }} />);
|
||||
} else {
|
||||
// Entity is published
|
||||
isPublished = true;
|
||||
statusMessage = intl.formatMessage(messages.publishedStatusLabel);
|
||||
if (publishedBy) {
|
||||
bodyMessage = (
|
||||
<FormattedMessage
|
||||
{...messages.lastPublishedMsg}
|
||||
values={{
|
||||
date: <CustomFormattedDate date={lastPublished} />,
|
||||
time: <CustomFormattedTime date={lastPublished} />,
|
||||
user: <b>{publishedBy}</b>,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
bodyMessage = (
|
||||
<FormattedMessage
|
||||
{...messages.lastPublishedMsgWithoutUser}
|
||||
values={{
|
||||
date: <CustomFormattedDate date={lastPublished} />,
|
||||
time: <CustomFormattedTime date={lastPublished} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Container className={classNames('status-widget', {
|
||||
'draft-status': !isPublished,
|
||||
'published-status': isPublished,
|
||||
})}
|
||||
>
|
||||
<span className="font-weight-bold">
|
||||
{statusMessage}
|
||||
</span>
|
||||
{ extraStatusMessage && (
|
||||
<span className="ml-1">
|
||||
{extraStatusMessage}
|
||||
</span>
|
||||
)}
|
||||
</Container>
|
||||
<Container className="mt-3">
|
||||
<Stack gap={3}>
|
||||
<span>
|
||||
{bodyMessage}
|
||||
</span>
|
||||
{onCommit && (
|
||||
<Button disabled={isPublished} onClick={onCommit}>
|
||||
{intl.formatMessage(messages.publishButtonLabel)}
|
||||
</Button>
|
||||
)}
|
||||
{onRevert && (
|
||||
<div className="d-flex justify-content-end">
|
||||
<Button disabled={isPublished || isNew} variant="link" onClick={onRevert}>
|
||||
{intl.formatMessage(messages.discardChangesButtonLabel)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusWidget;
|
||||
56
src/library-authoring/generic/status-widget/messages.ts
Normal file
56
src/library-authoring/generic/status-widget/messages.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
draftStatusLabel: {
|
||||
id: 'course-authoring.library-authoring.generic.status-widget.draft',
|
||||
defaultMessage: 'Draft',
|
||||
description: 'Label in library authoring sidebar when the entity is on draft status',
|
||||
},
|
||||
neverPublishedLabel: {
|
||||
id: 'course-authoring.library-authoring.generic.status-widget.never',
|
||||
defaultMessage: '(Never Published)',
|
||||
description: 'Label in library authoring sidebar when the entity is never published',
|
||||
},
|
||||
unpublishedStatusLabel: {
|
||||
id: 'course-authoring.library-authoring.generic.status-widget.unpublished',
|
||||
defaultMessage: '(Unpublished Changes)',
|
||||
description: 'Label in library authoring sidebar when the entity has unpublished changes',
|
||||
},
|
||||
publishedStatusLabel: {
|
||||
id: 'course-authoring.library-authoring.generic.status-widget.published',
|
||||
defaultMessage: 'Published',
|
||||
description: 'Label in library authoring sidebar when the entity is on published status',
|
||||
},
|
||||
lastPublishedMsg: {
|
||||
id: 'course-authoring.library-authoring.generic.status-widget.last-published',
|
||||
defaultMessage: 'Last published on {date} at {time} UTC by {user}.',
|
||||
description: 'Body message of the library authoring sidebar when the entity is published.',
|
||||
},
|
||||
lastPublishedMsgWithoutUser: {
|
||||
id: 'course-authoring.library-authoring.generic.status-widget.last-published-no-user',
|
||||
defaultMessage: 'Last published on {date} at {time} UTC.',
|
||||
description: 'Body message of the library authoring sidebar when the entity is published.',
|
||||
},
|
||||
lastDraftMsg: {
|
||||
id: 'course-authoring.library-authoring.generic.status-widget.last-draft',
|
||||
defaultMessage: 'Draft saved on {date} at {time} UTC by {user}.',
|
||||
description: 'Body message of the library authoring sidebar when the entity is on draft status.',
|
||||
},
|
||||
lastDraftMsgWithoutUser: {
|
||||
id: 'course-authoring.library-authoring.generic.status-widget.last-draft-no-user',
|
||||
defaultMessage: 'Draft saved on {date} at {time} UTC.',
|
||||
description: 'Body message of the library authoring sidebar when the entity is on draft status.',
|
||||
},
|
||||
publishButtonLabel: {
|
||||
id: 'course-authoring.library-authoring.generic.status-widget.publish-button',
|
||||
defaultMessage: 'Publish',
|
||||
description: 'Label of publish button for an entity.',
|
||||
},
|
||||
discardChangesButtonLabel: {
|
||||
id: 'course-authoring.library-authoring.generic.status-widget.discard-button',
|
||||
defaultMessage: 'Discard Changes',
|
||||
description: 'Label of discard changes button for an entity.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "library-authoring/component-info/ComponentPreview";
|
||||
@import "library-authoring/components/ComponentCard";
|
||||
@import "library-authoring/library-info/LibraryPublishStatus";
|
||||
@import "library-authoring/LibraryAuthoringPage";
|
||||
@import "./component-info/ComponentPreview";
|
||||
@import "./components/ComponentCard";
|
||||
@import "./generic";
|
||||
@import "./LibraryAuthoringPage";
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useCallback, useContext, useMemo } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Button, Container, Stack } from '@openedx/paragon';
|
||||
import { FormattedDate, FormattedTime, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import StatusWidget from '../generic/status-widget';
|
||||
import { useCommitLibraryChanges, useRevertLibraryChanges } from '../data/apiHooks';
|
||||
import { ContentLibrary } from '../data/api';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import messages from './messages';
|
||||
|
||||
type LibraryPublishStatusProps = {
|
||||
@@ -35,133 +35,12 @@ const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const {
|
||||
isPublished,
|
||||
isNew,
|
||||
statusMessage,
|
||||
extraStatusMessage,
|
||||
bodyMessage,
|
||||
} = useMemo(() => {
|
||||
let isPublishedResult: boolean;
|
||||
let isNewResult = false;
|
||||
let statusMessageResult : string;
|
||||
let extraStatusMessageResult : string | undefined;
|
||||
let bodyMessageResult : React.ReactNode | undefined;
|
||||
|
||||
const buildDate = ((date : string) => (
|
||||
<b>
|
||||
<FormattedDate
|
||||
value={date}
|
||||
year="numeric"
|
||||
month="long"
|
||||
day="2-digit"
|
||||
/>
|
||||
</b>
|
||||
));
|
||||
|
||||
const buildTime = ((date: string) => (
|
||||
<b>
|
||||
<FormattedTime
|
||||
value={date}
|
||||
hour12={false}
|
||||
/>
|
||||
</b>
|
||||
));
|
||||
|
||||
const buildDraftBodyMessage = (() => {
|
||||
if (library.lastDraftCreatedBy && library.lastDraftCreated) {
|
||||
return intl.formatMessage(messages.lastDraftMsg, {
|
||||
date: buildDate(library.lastDraftCreated),
|
||||
time: buildTime(library.lastDraftCreated),
|
||||
user: <b>{library.lastDraftCreatedBy}</b>,
|
||||
});
|
||||
}
|
||||
if (library.lastDraftCreated) {
|
||||
return intl.formatMessage(messages.lastDraftMsgWithoutUser, {
|
||||
date: buildDate(library.lastDraftCreated),
|
||||
time: buildTime(library.lastDraftCreated),
|
||||
});
|
||||
}
|
||||
if (library.created) {
|
||||
return intl.formatMessage(messages.lastDraftMsgWithoutUser, {
|
||||
date: buildDate(library.created),
|
||||
time: buildTime(library.created),
|
||||
});
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
if (!library.lastPublished) {
|
||||
// Library is never published (new)
|
||||
isNewResult = library.numBlocks === 0; // allow discarding if components are added
|
||||
isPublishedResult = false;
|
||||
statusMessageResult = intl.formatMessage(messages.draftStatusLabel);
|
||||
extraStatusMessageResult = intl.formatMessage(messages.neverPublishedLabel);
|
||||
bodyMessageResult = buildDraftBodyMessage();
|
||||
} else if (library.hasUnpublishedChanges || library.hasUnpublishedDeletes) {
|
||||
// Library is on Draft state
|
||||
isPublishedResult = false;
|
||||
statusMessageResult = intl.formatMessage(messages.draftStatusLabel);
|
||||
extraStatusMessageResult = intl.formatMessage(messages.unpublishedStatusLabel);
|
||||
bodyMessageResult = buildDraftBodyMessage();
|
||||
} else {
|
||||
// Library is published
|
||||
isPublishedResult = true;
|
||||
statusMessageResult = intl.formatMessage(messages.publishedStatusLabel);
|
||||
if (library.publishedBy) {
|
||||
bodyMessageResult = intl.formatMessage(messages.lastPublishedMsg, {
|
||||
date: buildDate(library.lastPublished),
|
||||
time: buildTime(library.lastPublished),
|
||||
user: <b>{library.publishedBy}</b>,
|
||||
});
|
||||
} else {
|
||||
bodyMessageResult = intl.formatMessage(messages.lastPublishedMsgWithoutUser, {
|
||||
date: buildDate(library.lastPublished),
|
||||
time: buildTime(library.lastPublished),
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
isPublished: isPublishedResult,
|
||||
isNew: isNewResult,
|
||||
statusMessage: statusMessageResult,
|
||||
extraStatusMessage: extraStatusMessageResult,
|
||||
bodyMessage: bodyMessageResult,
|
||||
};
|
||||
}, [library]);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Container className={classNames('library-publish-status', {
|
||||
'draft-status': !isPublished,
|
||||
'published-status': isPublished,
|
||||
})}
|
||||
>
|
||||
<span className="font-weight-bold">
|
||||
{statusMessage}
|
||||
</span>
|
||||
{ extraStatusMessage && (
|
||||
<span className="ml-1">
|
||||
{extraStatusMessage}
|
||||
</span>
|
||||
)}
|
||||
</Container>
|
||||
<Container className="mt-3">
|
||||
<Stack gap={3}>
|
||||
<span>
|
||||
{bodyMessage}
|
||||
</span>
|
||||
<Button disabled={isPublished} onClick={commit}>
|
||||
{intl.formatMessage(messages.publishButtonLabel)}
|
||||
</Button>
|
||||
<div className="d-flex justify-content-end">
|
||||
<Button disabled={isPublished || isNew} variant="link" onClick={revert}>
|
||||
{intl.formatMessage(messages.discardChangesButtonLabel)}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Stack>
|
||||
<StatusWidget
|
||||
{...library}
|
||||
onCommit={commit}
|
||||
onRevert={revert}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -26,56 +26,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Created',
|
||||
description: 'Created label used in Library History section.',
|
||||
},
|
||||
draftStatusLabel: {
|
||||
id: 'course-authoring.library-authoring.sidebar.info.publish-status.draft',
|
||||
defaultMessage: 'Draft',
|
||||
description: 'Label in library info sidebar when the library is on draft status',
|
||||
},
|
||||
neverPublishedLabel: {
|
||||
id: 'course-authoring.library-authoring.sidebar.info.publish-status.never',
|
||||
defaultMessage: '(Never Published)',
|
||||
description: 'Label in library info sidebar when the library is never published',
|
||||
},
|
||||
unpublishedStatusLabel: {
|
||||
id: 'course-authoring.library-authoring.sidebar.info.publish-status.unpublished',
|
||||
defaultMessage: '(Unpublished Changes)',
|
||||
description: 'Label in library info sidebar when the library has unpublished changes',
|
||||
},
|
||||
publishedStatusLabel: {
|
||||
id: 'course-authoring.library-authoring.sidebar.info.publish-status.published',
|
||||
defaultMessage: 'Published',
|
||||
description: 'Label in library info sidebar when the library is on published status',
|
||||
},
|
||||
publishButtonLabel: {
|
||||
id: 'course-authoring.library-authoring.sidebar.info.publish-status.publish-button',
|
||||
defaultMessage: 'Publish',
|
||||
description: 'Label of publish button for a library.',
|
||||
},
|
||||
discardChangesButtonLabel: {
|
||||
id: 'course-authoring.library-authoring.sidebar.info.publish-status.discard-button',
|
||||
defaultMessage: 'Discard Changes',
|
||||
description: 'Label of discard changes button for a library.',
|
||||
},
|
||||
lastPublishedMsg: {
|
||||
id: 'course-authoring.library-authoring.sidebar.info.publish-status.last-published',
|
||||
defaultMessage: 'Last published on {date} at {time} UTC by {user}.',
|
||||
description: 'Body meesage of the library info sidebar when library is published.',
|
||||
},
|
||||
lastPublishedMsgWithoutUser: {
|
||||
id: 'course-authoring.library-authoring.sidebar.info.publish-status.last-published-no-user',
|
||||
defaultMessage: 'Last published on {date} at {time} UTC.',
|
||||
description: 'Body meesage of the library info sidebar when library is published.',
|
||||
},
|
||||
lastDraftMsg: {
|
||||
id: 'course-authoring.library-authoring.sidebar.info.publish-status.last-draft',
|
||||
defaultMessage: 'Draft saved on {date} at {time} UTC by {user}.',
|
||||
description: 'Body meesage of the library info sidebar when library is on draft status.',
|
||||
},
|
||||
lastDraftMsgWithoutUser: {
|
||||
id: 'course-authoring.library-authoring.sidebar.info.publish-status.last-draft-no-user',
|
||||
defaultMessage: 'Draft saved on {date} at {time} UTC.',
|
||||
description: 'Body meesage of the library info sidebar when library is on draft status.',
|
||||
},
|
||||
publishSuccessMsg: {
|
||||
id: 'course-authoring.library-authoring.publish.success',
|
||||
defaultMessage: 'Library published successfully',
|
||||
|
||||
@@ -21,10 +21,10 @@ type LibrarySidebarProps = {
|
||||
* Sidebar container for library pages.
|
||||
*
|
||||
* It's designed to "squash" the page when open.
|
||||
* Uses `sidebarBodyComponent` of the `store` to
|
||||
* Uses `sidebarBodyComponent` of the `context` to
|
||||
* choose which component is rendered.
|
||||
* You can add more components in `bodyComponentMap`.
|
||||
* Use the slice actions to open and close this sidebar.
|
||||
* Use the returned actions to open and close this sidebar.
|
||||
*/
|
||||
const LibrarySidebar = ({ library }: LibrarySidebarProps) => {
|
||||
const intl = useIntl();
|
||||
|
||||
Reference in New Issue
Block a user