diff --git a/src/index.scss b/src/index.scss index 912b40933f..595c45146e 100644 --- a/src/index.scss +++ b/src/index.scss @@ -18,6 +18,7 @@ @import "export-page/CourseExportPage"; @import "import-page/CourseImportPage"; @import "taxonomy"; +@import "library-authoring"; @import "files-and-videos"; @import "content-tags-drawer"; @import "course-outline/CourseOutline"; diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 3bf78b85c4..f8b7e3346b 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -80,11 +80,8 @@ const LibraryAuthoringPage = () => { }, [location]); useEffect(() => { - // Open Library Info sidebar by default - if (!isLoading && libraryData) { - openInfoSidebar(); - }; - }, [isLoading, libraryData]); + openInfoSidebar(); + }, []); if (isLoading) { return ; diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index fab13829b4..32a8d7f6b4 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -10,6 +10,11 @@ export const getContentLibraryApiUrl = (libraryId: string) => `${getApiBaseUrl() * Get the URL for create content in library. */ export const getCreateLibraryBlockUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/blocks/`; +/** + * Get the URL for commit/revert changes in library. + */ +export const getCommitLibraryChangesUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/commit/` + export interface ContentLibrary { id: string; @@ -21,6 +26,9 @@ export interface ContentLibrary { numBlocks: number; version: number; lastPublished: Date | null; + lastDraftCreated: Date | null; + publishedBy: string | null; + lastDraftCreatedBy: string | null; allowLti: boolean; allowPublicLearning: boolean; allowPublicRead: boolean; @@ -75,3 +83,25 @@ export async function createLibraryBlock({ return camelCaseObject(data); } + +/** + * Commit library changes. + */ +export async function commitLibraryChanges(libraryId: string): Promise { + const client = getAuthenticatedHttpClient(); + + const { data } = await client.post(getCommitLibraryChangesUrl(libraryId)); + + return camelCaseObject(data); +} + +/** + * Revert library changes. + */ +export async function revertLibraryChanges(libraryId: string): Promise { + const client = getAuthenticatedHttpClient(); + + const { data } = await client.delete(getCommitLibraryChangesUrl(libraryId)); + + return camelCaseObject(data); +} diff --git a/src/library-authoring/data/apiHook.ts b/src/library-authoring/data/apiHook.ts index bafd52e10d..0b2e24ea23 100644 --- a/src/library-authoring/data/apiHook.ts +++ b/src/library-authoring/data/apiHook.ts @@ -3,7 +3,12 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { MeiliSearch } from 'meilisearch'; import { useContentSearchConnection, useContentSearchResults } from '../../search-modal'; -import { createLibraryBlock, getContentLibrary } from './api'; +import { + createLibraryBlock, + getContentLibrary, + commitLibraryChanges, + revertLibraryChanges +} from './api'; export const libraryQueryKeys = { /** @@ -21,7 +26,7 @@ export const libraryQueryKeys = { */ export const useContentLibrary = (libraryId?: string) => ( useQuery({ - queryKey: ['contentLibrary', libraryId], + queryKey: libraryQueryKeys.contentLibrary(libraryId), queryFn: () => getContentLibrary(libraryId), }) ); @@ -71,3 +76,23 @@ export const useLibraryComponentCount = (libraryId: string, searchKeywords: stri collectionCount, }; }; + +export const useCommitLibraryChanges = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: commitLibraryChanges, + onSettled: (_data, _error, libraryId) => { + queryClient.invalidateQueries({ queryKey: libraryQueryKeys.contentLibrary(libraryId) }); + }, + }); +}; + +export const useRevertLibraryChanges = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: revertLibraryChanges, + onSettled: (_data, _error, libraryId) => { + queryClient.invalidateQueries({ queryKey: libraryQueryKeys.contentLibrary(libraryId) }); + }, + }); +}; diff --git a/src/library-authoring/index.scss b/src/library-authoring/index.scss new file mode 100644 index 0000000000..7cf7a9b786 --- /dev/null +++ b/src/library-authoring/index.scss @@ -0,0 +1 @@ +@import "library-authoring/library-info/LibraryPublishStatus" diff --git a/src/library-authoring/library-info/LibraryInfo.tsx b/src/library-authoring/library-info/LibraryInfo.tsx index 31f76e59cc..c0e8bac04e 100644 --- a/src/library-authoring/library-info/LibraryInfo.tsx +++ b/src/library-authoring/library-info/LibraryInfo.tsx @@ -1,30 +1,28 @@ +import React from "react"; import { Stack } from "@openedx/paragon"; import { useIntl } from '@edx/frontend-platform/i18n'; -import React from "react"; import messages from "./messages"; import { convertToStringFromDateAndFormat } from "../../utils"; import { COMMA_SEPARATED_DATE_FORMAT } from "../../constants"; +import LibraryPublishStatus from "./LibraryPublishStatus"; +import { ContentLibrary } from "../data/api"; type LibraryInfoProps = { - orgName: string, - createdAt: Date, - updatedAt: Date, + library: ContentLibrary, }; -const LibraryInfo = ({ orgName, createdAt, updatedAt } : LibraryInfoProps) => { +const LibraryInfo = ({ library } : LibraryInfoProps) => { const intl = useIntl(); return ( -
- Published section -
+ {intl.formatMessage(messages.organizationSectionTitle)} - {orgName} + {library.org} @@ -36,7 +34,7 @@ const LibraryInfo = ({ orgName, createdAt, updatedAt } : LibraryInfoProps) => { {intl.formatMessage(messages.lastModifiedLabel)} - {convertToStringFromDateAndFormat(updatedAt, COMMA_SEPARATED_DATE_FORMAT)} + {convertToStringFromDateAndFormat(library.updated, COMMA_SEPARATED_DATE_FORMAT)} @@ -44,7 +42,7 @@ const LibraryInfo = ({ orgName, createdAt, updatedAt } : LibraryInfoProps) => { {intl.formatMessage(messages.createdLabel)} - {convertToStringFromDateAndFormat(createdAt, COMMA_SEPARATED_DATE_FORMAT)} + {convertToStringFromDateAndFormat(library.created, COMMA_SEPARATED_DATE_FORMAT)}
diff --git a/src/library-authoring/library-info/LibraryInfoHeader.tsx b/src/library-authoring/library-info/LibraryInfoHeader.tsx index f8ecc0a9ef..b25e742143 100644 --- a/src/library-authoring/library-info/LibraryInfoHeader.tsx +++ b/src/library-authoring/library-info/LibraryInfoHeader.tsx @@ -3,21 +3,21 @@ import { Icon, IconButton, Stack } from "@openedx/paragon"; import { Edit } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; import messages from "./messages"; +import { ContentLibrary } from "../data/api"; type LibraryInfoHeaderProps = { - displayName: string, - canEditLibrary: boolean, + library: ContentLibrary, }; -const LibraryInfoHeader = ({ displayName, canEditLibrary} : LibraryInfoHeaderProps) => { +const LibraryInfoHeader = ({ library } : LibraryInfoHeaderProps) => { const intl = useIntl(); return ( - {displayName} + {library.title} - {canEditLibrary && ( + {library.canEditLibrary && ( { + const intl = useIntl(); + const commitLibraryChanges = useCommitLibraryChanges(); + const revertLibraryChanges = useRevertLibraryChanges(); + const { showToast } = useContext(ToastContext); + + const commit = useCallback(() => { + commitLibraryChanges.mutateAsync(library.id) + .then(() => { + showToast(intl.formatMessage(messages.publishSuccessMsg)); + }).catch(() => { + showToast(intl.formatMessage(messages.publishErrorMsg)); + }); + }, []); + + const revert = useCallback(() => { + revertLibraryChanges.mutateAsync(library.id) + .then(() => { + showToast(intl.formatMessage(messages.revertSuccessMsg)); + }).catch(() => { + showToast(intl.formatMessage(messages.revertErrorMsg)); + }); + }, []); + + const { + isPublished, + statusMessage, + extraStatusMessage, + bodyMessage, + } = useMemo(() => { + let isPublished : boolean; + let statusMessage : string; + let extraStatusMessage : string | undefined = undefined; + let bodyMessage : string | undefined = undefined; + const buildDraftBodyMessage = (() => { + if (library.lastDraftCreatedBy) { + return intl.formatMessage(messages.lastDraftMsg, { + date: {convertToStringFromDateAndFormat(library.lastDraftCreated, COMMA_SEPARATED_DATE_FORMAT)}, + time: {convertToStringFromDateAndFormat(library.lastDraftCreated, TIME_FORMAT)}, + user: {library.lastDraftCreatedBy}, + }); + } else { + return intl.formatMessage(messages.lastDraftMsgWithoutUser, { + date: {convertToStringFromDateAndFormat(library.lastDraftCreated, COMMA_SEPARATED_DATE_FORMAT)}, + time: {convertToStringFromDateAndFormat(library.lastDraftCreated, TIME_FORMAT)}, + }); + } + }); + + if (!library.lastPublished) { + // Library is never published (new) + isPublished = false; + statusMessage = intl.formatMessage(messages.draftStatusLabel); + extraStatusMessage = intl.formatMessage(messages.neverPublishedLabel); + bodyMessage = buildDraftBodyMessage(); + } else if (library.hasUnpublishedChanges || library.hasUnpublishedDeletes) { + // Library is on Draft state + isPublished = false; + statusMessage = intl.formatMessage(messages.draftStatusLabel); + extraStatusMessage = intl.formatMessage(messages.unpublishedStatusLabel); + bodyMessage = buildDraftBodyMessage(); + } else { + // Library is published + isPublished = true; + statusMessage = intl.formatMessage(messages.publishedStatusLabel); + if (library.publishedBy) { + bodyMessage = intl.formatMessage(messages.lastPublishedMsg, { + date: {convertToStringFromDateAndFormat(library.lastPublished, COMMA_SEPARATED_DATE_FORMAT)}, + time: {convertToStringFromDateAndFormat(library.lastPublished, TIME_FORMAT)}, + user: {library.publishedBy}, + }) + } else { + bodyMessage = intl.formatMessage(messages.lastPublishedMsgWithoutUser, { + date: {convertToStringFromDateAndFormat(library.lastPublished, COMMA_SEPARATED_DATE_FORMAT)}, + time: {convertToStringFromDateAndFormat(library.lastPublished, TIME_FORMAT)}, + }) + } + } + return { + isPublished, + statusMessage, + extraStatusMessage, + bodyMessage, + } + }, [library]) + + return ( + + + + {statusMessage} + + { extraStatusMessage && ( + + {extraStatusMessage} + + )} + + + + + {bodyMessage} + + +
+ +
+
+
+
+ ); +}; + +export default LibraryPublishStatus; diff --git a/src/library-authoring/library-info/messages.ts b/src/library-authoring/library-info/messages.ts index d5854ff77d..2ec7b8db59 100644 --- a/src/library-authoring/library-info/messages.ts +++ b/src/library-authoring/library-info/messages.ts @@ -9,23 +9,93 @@ const messages = defineMessages({ organizationSectionTitle: { id: 'course-authoring.library-authoring.sidebar.info.organization.title', defaultMessage: 'Organization', - description: 'Title for Organization section in Library info sidebar.' + description: 'Title for Organization section in Library info sidebar.', }, libraryHistorySectionTitle: { id: 'course-authoring.library-authoring.sidebar.info.history.title', defaultMessage: 'Library History', - description: 'Title for Library History section in Library info sidebar.' + description: 'Title for Library History section in Library info sidebar.', }, lastModifiedLabel: { id: 'course-authoring.library-authoring.sidebar.info.history.last-modified', defaultMessage: 'Last Modified', - description: 'Last Modified label used in Library History section.' + description: 'Last Modified label used in Library History section.', }, createdLabel: { id: 'course-authoring.library-authoring.sidebar.info.history.created', defaultMessage: 'Created', - description: 'Created label used in Library History section.' - }, + 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', + description: 'Message when the library is published successfully.', + }, + publishErrorMsg: { + id: 'course-authoring.library-authoring.publish.error', + defaultMessage: 'There was an error publishing the library.', + description: 'Message when there is an error when publishing the library.', + }, + revertSuccessMsg: { + id: 'course-authoring.library-authoring.revert.success', + defaultMessage: 'Library changes reverted successfully', + description: 'Message when the library changes are reverted successfully.', + }, + revertErrorMsg: { + id: 'course-authoring.library-authoring.publish.error', + defaultMessage: 'There was an error reverting changes in the library.', + description: 'Message when there is an error when reverting changes in the library.', + }, }); export default messages; diff --git a/src/library-authoring/library-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx index 0ec0b487c6..48a3ba7efe 100644 --- a/src/library-authoring/library-sidebar/LibrarySidebar.tsx +++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx @@ -31,17 +31,13 @@ const LibrarySidebar = ({library}: LibrarySidebarProps) => { const bodyComponentMap = { 'add-content': , - 'info': , + 'info': , unknown: null, }; const headerComponentMap = { 'add-content': , - info: , + info: , unknown: null, };