From 61352f06c30ba19dcd59fea9edac5d0181e9ab92 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 1 Jul 2024 12:43:55 +0200 Subject: [PATCH 01/14] feat: LibraryInfo created and added to LibrarySidebar --- .../LibraryAuthoringPage.tsx | 18 +++++++++-- .../add-content/AddContentHeader.tsx | 11 +++++++ src/library-authoring/add-content/index.ts | 1 + src/library-authoring/add-content/messages.ts | 5 +++ src/library-authoring/common/context.tsx | 12 ++++++- .../library-info/LibraryInfo.tsx | 26 ++++++++++++++++ .../library-info/LibraryInfoHeader.tsx | 31 +++++++++++++++++++ src/library-authoring/library-info/index.ts | 3 ++ .../library-info/messages.ts | 16 ++++++++++ .../library-sidebar/LibrarySidebar.tsx | 22 ++++++++++--- 10 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 src/library-authoring/add-content/AddContentHeader.tsx create mode 100644 src/library-authoring/library-info/LibraryInfo.tsx create mode 100644 src/library-authoring/library-info/LibraryInfoHeader.tsx create mode 100644 src/library-authoring/library-info/index.ts create mode 100644 src/library-authoring/library-info/messages.ts diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 8d5e2f7313..c1fd9b76bb 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -37,6 +37,8 @@ enum TabList { const SubHeaderTitle = ({ title }: { title: string }) => { const intl = useIntl(); + const { openInfoSidebar } = useContext(LibraryContext); + return ( <> {title} @@ -45,6 +47,7 @@ const SubHeaderTitle = ({ title }: { title: string }) => { iconAs={Icon} alt={intl.formatMessage(messages.headingInfoAlt)} className="mr-2" + onClick={openInfoSidebar} /> ); @@ -62,7 +65,18 @@ const LibraryAuthoringPage = () => { const currentPath = location.pathname.split('/').pop(); const activeKey = (currentPath && currentPath in TabList) ? TabList[currentPath] : TabList.home; - const { sidebarBodyComponent, openAddContentSidebar } = useContext(LibraryContext); + const { + sidebarBodyComponent, + openAddContentSidebar, + openInfoSidebar, + } = useContext(LibraryContext); + + useEffect(() => { + // Open Library Info sidebar by default + if (!isLoading && libraryData) { + openInfoSidebar(); + }; + }, [isLoading, libraryData]); if (isLoading) { return ; @@ -142,7 +156,7 @@ const LibraryAuthoringPage = () => { { sidebarBodyComponent !== null && ( - + )} diff --git a/src/library-authoring/add-content/AddContentHeader.tsx b/src/library-authoring/add-content/AddContentHeader.tsx new file mode 100644 index 0000000000..7b5e537b19 --- /dev/null +++ b/src/library-authoring/add-content/AddContentHeader.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import messages from "./messages"; + +const AddContentHeader = () => ( + + + +); + +export default AddContentHeader; diff --git a/src/library-authoring/add-content/index.ts b/src/library-authoring/add-content/index.ts index 876828e16f..e1495c3bd9 100644 --- a/src/library-authoring/add-content/index.ts +++ b/src/library-authoring/add-content/index.ts @@ -1,2 +1,3 @@ // eslint-disable-next-line import/prefer-default-export export { default as AddContentContainer } from './AddContentContainer'; +export { default as AddContentHeader } from './AddContentHeader'; diff --git a/src/library-authoring/add-content/messages.ts b/src/library-authoring/add-content/messages.ts index 1d13635e5c..6024e144c1 100644 --- a/src/library-authoring/add-content/messages.ts +++ b/src/library-authoring/add-content/messages.ts @@ -50,6 +50,11 @@ const messages = defineMessages({ defaultMessage: 'There was an error creating the content.', description: 'Message when creation of content in library is on error', }, + addContentTitle: { + id: 'course-authoring.library-authoring.sidebar.title.add-content', + defaultMessage: 'Add Content', + description: 'Title of add content in library container.', + }, }); export default messages; diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx index 241ed67d20..735708bfce 100644 --- a/src/library-authoring/common/context.tsx +++ b/src/library-authoring/common/context.tsx @@ -3,18 +3,21 @@ import React from 'react'; enum SidebarBodyComponentId { AddContent = 'add-content', + Info = 'info', } export interface LibraryContextData { sidebarBodyComponent: SidebarBodyComponentId | null; closeLibrarySidebar: () => void; openAddContentSidebar: () => void; + openInfoSidebar: () => void; } export const LibraryContext = React.createContext({ sidebarBodyComponent: null, closeLibrarySidebar: () => {}, openAddContentSidebar: () => {}, + openInfoSidebar: () => {}, } as LibraryContextData); /** @@ -25,12 +28,19 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => { const closeLibrarySidebar = React.useCallback(() => setSidebarBodyComponent(null), []); const openAddContentSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.AddContent), []); + const openInfoSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.Info), []); const context = React.useMemo(() => ({ sidebarBodyComponent, closeLibrarySidebar, openAddContentSidebar, - }), [sidebarBodyComponent, closeLibrarySidebar, openAddContentSidebar]); + openInfoSidebar, + }), [ + sidebarBodyComponent, + closeLibrarySidebar, + openAddContentSidebar, + openInfoSidebar, + ]); return ( diff --git a/src/library-authoring/library-info/LibraryInfo.tsx b/src/library-authoring/library-info/LibraryInfo.tsx new file mode 100644 index 0000000000..0553a10553 --- /dev/null +++ b/src/library-authoring/library-info/LibraryInfo.tsx @@ -0,0 +1,26 @@ +import { Stack } from "@openedx/paragon"; +import { useIntl } from '@edx/frontend-platform/i18n'; +import React from "react"; +import messages from "./messages"; + +const LibraryInfo = () => { + const intl = useIntl(); + + return ( + +
+ Published section +
+
+ + {intl.formatMessage(messages.organizationSectionTitle)} + +
+
+ Library History Section +
+
+ ); +}; + +export default LibraryInfo; diff --git a/src/library-authoring/library-info/LibraryInfoHeader.tsx b/src/library-authoring/library-info/LibraryInfoHeader.tsx new file mode 100644 index 0000000000..f8ecc0a9ef --- /dev/null +++ b/src/library-authoring/library-info/LibraryInfoHeader.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { Icon, IconButton, Stack } from "@openedx/paragon"; +import { Edit } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import messages from "./messages"; + +type LibraryInfoHeaderProps = { + displayName: string, + canEditLibrary: boolean, +}; + +const LibraryInfoHeader = ({ displayName, canEditLibrary} : LibraryInfoHeaderProps) => { + const intl = useIntl(); + + return ( + + + {displayName} + + {canEditLibrary && ( + + )} + + ); +}; + +export default LibraryInfoHeader; diff --git a/src/library-authoring/library-info/index.ts b/src/library-authoring/library-info/index.ts new file mode 100644 index 0000000000..cfe118fdaa --- /dev/null +++ b/src/library-authoring/library-info/index.ts @@ -0,0 +1,3 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as LibraryInfo } from './LibraryInfo'; +export { default as LibraryInfoHeader } from './LibraryInfoHeader'; diff --git a/src/library-authoring/library-info/messages.ts b/src/library-authoring/library-info/messages.ts new file mode 100644 index 0000000000..f1f0400beb --- /dev/null +++ b/src/library-authoring/library-info/messages.ts @@ -0,0 +1,16 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + editNameButtonAlt: { + id: 'course-authoring.library-authoring.sidebar.info.edit-name.alt', + defaultMessage: 'Edit library name', + description: 'Alt text for edit library name icon button', + }, + organizationSectionTitle: { + id: 'course-authoring.library-authoring.sidebar.info.organization.title', + defaultMessage: 'Organization', + description: 'Title for Organization section in Library info sidebar.' + }, +}); + +export default messages; diff --git a/src/library-authoring/library-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx index 734379f518..135b1f6a3a 100644 --- a/src/library-authoring/library-sidebar/LibrarySidebar.tsx +++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx @@ -7,8 +7,14 @@ import { import { Close } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; import messages from '../messages'; -import { AddContentContainer } from '../add-content'; +import { AddContentContainer, AddContentHeader } from '../add-content'; import { LibraryContext } from '../common/context'; +import { LibraryInfo, LibraryInfoHeader } from '../library-info'; +import { ContentLibrary } from '../data/api'; + +type LibrarySidebarProps = { + library: ContentLibrary, +} /** * Sidebar container for library pages. @@ -19,23 +25,29 @@ import { LibraryContext } from '../common/context'; * You can add more components in `bodyComponentMap`. * Use the slice actions to open and close this sidebar. */ -const LibrarySidebar = () => { +const LibrarySidebar = ({library}: LibrarySidebarProps) => { const intl = useIntl(); const { sidebarBodyComponent, closeLibrarySidebar } = useContext(LibraryContext); const bodyComponentMap = { 'add-content': , + 'info': , + unknown: null, + }; + + const headerComponentMap = { + 'add-content': , + info: , unknown: null, }; const buildBody = () : React.ReactNode | null => bodyComponentMap[sidebarBodyComponent || 'unknown']; + const buildHeader = (): React.ReactNode | null => headerComponentMap[sidebarBodyComponent || 'unknown']; return (
- - {intl.formatMessage(messages.addContentTitle)} - + {buildHeader()} Date: Wed, 3 Jul 2024 18:35:53 +0200 Subject: [PATCH 02/14] feat: Library history section of info sidebar added --- src/library-authoring/data/api.ts | 2 + .../library-info/LibraryInfo.tsx | 43 ++++++++++++++++--- .../library-info/messages.ts | 15 +++++++ .../library-sidebar/LibrarySidebar.tsx | 8 +++- src/utils.js | 15 +++++++ 5 files changed, 74 insertions(+), 9 deletions(-) diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index a0129b5c16..2280c6305a 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -34,6 +34,8 @@ export interface ContentLibrary { hasUnpublishedDeletes: boolean; canEditLibrary: boolean; license: string; + created: Date; + updated: Date; } export interface LibraryBlockType { diff --git a/src/library-authoring/library-info/LibraryInfo.tsx b/src/library-authoring/library-info/LibraryInfo.tsx index 0553a10553..31f76e59cc 100644 --- a/src/library-authoring/library-info/LibraryInfo.tsx +++ b/src/library-authoring/library-info/LibraryInfo.tsx @@ -2,23 +2,52 @@ 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"; -const LibraryInfo = () => { +type LibraryInfoProps = { + orgName: string, + createdAt: Date, + updatedAt: Date, +}; + +const LibraryInfo = ({ orgName, createdAt, updatedAt } : LibraryInfoProps) => { const intl = useIntl(); return ( - +
Published section
-
+ {intl.formatMessage(messages.organizationSectionTitle)} -
-
- Library History Section -
+ + {orgName} + +
+ + + {intl.formatMessage(messages.libraryHistorySectionTitle)} + + + + {intl.formatMessage(messages.lastModifiedLabel)} + + + {convertToStringFromDateAndFormat(updatedAt, COMMA_SEPARATED_DATE_FORMAT)} + + + + + {intl.formatMessage(messages.createdLabel)} + + + {convertToStringFromDateAndFormat(createdAt, COMMA_SEPARATED_DATE_FORMAT)} + + +
); }; diff --git a/src/library-authoring/library-info/messages.ts b/src/library-authoring/library-info/messages.ts index f1f0400beb..d5854ff77d 100644 --- a/src/library-authoring/library-info/messages.ts +++ b/src/library-authoring/library-info/messages.ts @@ -11,6 +11,21 @@ const messages = defineMessages({ defaultMessage: 'Organization', 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.' + }, + lastModifiedLabel: { + id: 'course-authoring.library-authoring.sidebar.info.history.last-modified', + defaultMessage: 'Last Modified', + 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.' + }, }); export default messages; diff --git a/src/library-authoring/library-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx index 135b1f6a3a..0ec0b487c6 100644 --- a/src/library-authoring/library-sidebar/LibrarySidebar.tsx +++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx @@ -31,7 +31,11 @@ const LibrarySidebar = ({library}: LibrarySidebarProps) => { const bodyComponentMap = { 'add-content': , - 'info': , + 'info': , unknown: null, }; @@ -45,7 +49,7 @@ const LibrarySidebar = ({library}: LibrarySidebarProps) => { const buildHeader = (): React.ReactNode | null => headerComponentMap[sidebarBodyComponent || 'unknown']; return ( -
+
{buildHeader()} { return moment(date).format(DATE_TIME_FORMAT); }; +export const convertToStringFromDateAndFormat = (date, format) => { + /** + * Convert local time to UTC string from react-datepicker in a format + * Note: react-datepicker has a bug where it only interacts with local time + * @param {Date} date - date in local time + * @param {string} format - format of the date + * @return {string} date converted in string in the desired format + */ + if (!date) { + return ''; + } + + return moment(date).format(format); +}; + export const isValidDate = (date) => { const formattedValue = convertToStringFromDate(date).split('T')[0]; From 1012ef935d453723cafb662a9646e479122ab810 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Tue, 9 Jul 2024 15:09:37 +0200 Subject: [PATCH 03/14] feat: LibraryPublishStatus component added - Connection with API to publish an revert changes - LibraryPublishStatus component created with draft and publish status - Component create with the feature to publish and revert changes --- src/index.scss | 1 + .../LibraryAuthoringPage.tsx | 9 +- src/library-authoring/data/api.ts | 30 ++++ src/library-authoring/data/apiHook.ts | 98 +++++++++++++ src/library-authoring/index.scss | 1 + .../library-info/LibraryInfo.tsx | 20 ++- .../library-info/LibraryInfoHeader.tsx | 10 +- .../library-info/LibraryPublishStatus.scss | 13 ++ .../library-info/LibraryPublishStatus.tsx | 136 ++++++++++++++++++ .../library-info/messages.ts | 80 ++++++++++- .../library-sidebar/LibrarySidebar.tsx | 8 +- 11 files changed, 373 insertions(+), 33 deletions(-) create mode 100644 src/library-authoring/data/apiHook.ts create mode 100644 src/library-authoring/library-info/LibraryPublishStatus.scss create mode 100644 src/library-authoring/library-info/LibraryPublishStatus.tsx diff --git a/src/index.scss b/src/index.scss index 381ca17082..717e4a7215 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 c1fd9b76bb..42aa3e25e5 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import React, { useContext, useEffect } from 'react'; import { StudioFooter } from '@edx/frontend-component-footer'; import { useIntl } from '@edx/frontend-platform/i18n'; import { @@ -72,11 +72,8 @@ const LibraryAuthoringPage = () => { } = useContext(LibraryContext); 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 2280c6305a..3288a6a632 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -16,6 +16,11 @@ export const getLibraryBlockTypesUrl = (libraryId: string) => `${getApiBaseUrl() */ export const getCreateLibraryBlockUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/blocks/`; export const getContentLibraryV2ListApiUrl = () => `${getApiBaseUrl()}/api/libraries/v2/`; +/** + * 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; @@ -27,6 +32,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; @@ -141,3 +149,25 @@ export async function getContentLibraryV2List(customParams: GetLibrariesV2Custom .get(getContentLibraryV2ListApiUrl(), { params: customParamsFormated }); 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 new file mode 100644 index 0000000000..0b2e24ea23 --- /dev/null +++ b/src/library-authoring/data/apiHook.ts @@ -0,0 +1,98 @@ +import React from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { MeiliSearch } from 'meilisearch'; + +import { useContentSearchConnection, useContentSearchResults } from '../../search-modal'; +import { + createLibraryBlock, + getContentLibrary, + commitLibraryChanges, + revertLibraryChanges +} from './api'; + +export const libraryQueryKeys = { + /** + * Used in all query keys. + * You can use these key to invalidate all queries. + */ + all: ['contentLibrary'], + contentLibrary: (libraryId) => [ + libraryQueryKeys.all, libraryId, + ], +}; + +/** + * Hook to fetch a content library by its ID. + */ +export const useContentLibrary = (libraryId?: string) => ( + useQuery({ + queryKey: libraryQueryKeys.contentLibrary(libraryId), + queryFn: () => getContentLibrary(libraryId), + }) +); + +/** + * Use this mutation to create a block in a library + */ +export const useCreateLibraryBlock = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createLibraryBlock, + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: libraryQueryKeys.contentLibrary(variables.libraryId) }); + queryClient.invalidateQueries({ queryKey: ['content_search'] }); + }, + }); +}; + +/** + * Hook to fetch the count of components and collections in a library. + */ +export const useLibraryComponentCount = (libraryId: string, searchKeywords: string) => { + // Meilisearch code to get Collection and Component counts + const { data: connectionDetails } = useContentSearchConnection(); + + const indexName = connectionDetails?.indexName; + const client = React.useMemo(() => { + if (connectionDetails?.apiKey === undefined || connectionDetails?.url === undefined) { + return undefined; + } + return new MeiliSearch({ host: connectionDetails.url, apiKey: connectionDetails.apiKey }); + }, [connectionDetails?.apiKey, connectionDetails?.url]); + + const libFilter = `context_key = "${libraryId}"`; + + const { totalHits: componentCount } = useContentSearchResults({ + client, + indexName, + searchKeywords, + extraFilter: [libFilter], // ToDo: Add filter for components when collection is implemented + }); + + const collectionCount = 0; // ToDo: Implement collections count + + return { + componentCount, + 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 index 87c22f838e..e82ba16ab0 100644 --- a/src/library-authoring/index.scss +++ b/src/library-authoring/index.scss @@ -1 +1,2 @@ @import "library-authoring/components/ComponentCard"; +@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, }; From 5bedf75f9cfd472d3768f323a9b6ab14903b1d27 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Wed, 24 Jul 2024 10:13:45 -0500 Subject: [PATCH 04/14] chore: Fix rebase issues and nits --- src/index.scss | 1 - .../LibraryAuthoringPage.test.tsx | 5 + src/library-authoring/data/api.ts | 4 +- src/library-authoring/data/apiHook.ts | 98 ------------------- src/library-authoring/data/apiHooks.ts | 23 +++++ .../library-info/LibraryPublishStatus.scss | 1 - .../library-info/LibraryPublishStatus.tsx | 2 +- 7 files changed, 31 insertions(+), 103 deletions(-) delete mode 100644 src/library-authoring/data/apiHook.ts diff --git a/src/index.scss b/src/index.scss index 717e4a7215..595c45146e 100644 --- a/src/index.scss +++ b/src/index.scss @@ -30,7 +30,6 @@ @import "search-modal/SearchModal"; @import "certificates/scss/Certificates"; @import "group-configurations/GroupConfigurations"; -@import "library-authoring"; // To apply the glow effect to the selected Section/Subsection, in the Course Outline div.row:has(> div > div.highlight) { diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 0e90e222f6..cad29ffa9b 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -61,6 +61,9 @@ const libraryData: ContentLibrary = { numBlocks: 2, version: 0, lastPublished: null, + lastDraftCreated: null, + publishedBy: 'staff', + lastDraftCreatedBy: null, allowLti: false, allowPublicLearning: false, allowPublicRead: false, @@ -68,6 +71,8 @@ const libraryData: ContentLibrary = { hasUnpublishedDeletes: false, canEditLibrary: true, license: '', + created: null, + updated: null, }; const RootWrapper = () => ( diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 3288a6a632..b1406b14f4 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -42,8 +42,8 @@ export interface ContentLibrary { hasUnpublishedDeletes: boolean; canEditLibrary: boolean; license: string; - created: Date; - updated: Date; + created: Date | null; + updated: Date | null; } export interface LibraryBlockType { diff --git a/src/library-authoring/data/apiHook.ts b/src/library-authoring/data/apiHook.ts deleted file mode 100644 index 0b2e24ea23..0000000000 --- a/src/library-authoring/data/apiHook.ts +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { MeiliSearch } from 'meilisearch'; - -import { useContentSearchConnection, useContentSearchResults } from '../../search-modal'; -import { - createLibraryBlock, - getContentLibrary, - commitLibraryChanges, - revertLibraryChanges -} from './api'; - -export const libraryQueryKeys = { - /** - * Used in all query keys. - * You can use these key to invalidate all queries. - */ - all: ['contentLibrary'], - contentLibrary: (libraryId) => [ - libraryQueryKeys.all, libraryId, - ], -}; - -/** - * Hook to fetch a content library by its ID. - */ -export const useContentLibrary = (libraryId?: string) => ( - useQuery({ - queryKey: libraryQueryKeys.contentLibrary(libraryId), - queryFn: () => getContentLibrary(libraryId), - }) -); - -/** - * Use this mutation to create a block in a library - */ -export const useCreateLibraryBlock = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: createLibraryBlock, - onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: libraryQueryKeys.contentLibrary(variables.libraryId) }); - queryClient.invalidateQueries({ queryKey: ['content_search'] }); - }, - }); -}; - -/** - * Hook to fetch the count of components and collections in a library. - */ -export const useLibraryComponentCount = (libraryId: string, searchKeywords: string) => { - // Meilisearch code to get Collection and Component counts - const { data: connectionDetails } = useContentSearchConnection(); - - const indexName = connectionDetails?.indexName; - const client = React.useMemo(() => { - if (connectionDetails?.apiKey === undefined || connectionDetails?.url === undefined) { - return undefined; - } - return new MeiliSearch({ host: connectionDetails.url, apiKey: connectionDetails.apiKey }); - }, [connectionDetails?.apiKey, connectionDetails?.url]); - - const libFilter = `context_key = "${libraryId}"`; - - const { totalHits: componentCount } = useContentSearchResults({ - client, - indexName, - searchKeywords, - extraFilter: [libFilter], // ToDo: Add filter for components when collection is implemented - }); - - const collectionCount = 0; // ToDo: Implement collections count - - return { - componentCount, - 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/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index fe87357efa..e7eb4d7c5c 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -9,6 +9,8 @@ import { getLibraryBlockTypes, createLibraryBlock, getContentLibraryV2List, + commitLibraryChanges, + revertLibraryChanges, } from './api'; export const libraryAuthoringQueryKeys = { @@ -130,3 +132,24 @@ export const useContentLibraryV2List = (customParams: GetLibrariesV2CustomParams keepPreviousData: true, }) ); + + +export const useCommitLibraryChanges = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: commitLibraryChanges, + onSettled: (_data, _error, libraryId) => { + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) }); + }, + }); +}; + +export const useRevertLibraryChanges = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: revertLibraryChanges, + onSettled: (_data, _error, libraryId) => { + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) }); + }, + }); +}; \ No newline at end of file diff --git a/src/library-authoring/library-info/LibraryPublishStatus.scss b/src/library-authoring/library-info/LibraryPublishStatus.scss index d505ccddbf..7f94889290 100644 --- a/src/library-authoring/library-info/LibraryPublishStatus.scss +++ b/src/library-authoring/library-info/LibraryPublishStatus.scss @@ -10,4 +10,3 @@ border-top: 4px solid $info-400; } } - diff --git a/src/library-authoring/library-info/LibraryPublishStatus.tsx b/src/library-authoring/library-info/LibraryPublishStatus.tsx index c11c61de57..84997017b3 100644 --- a/src/library-authoring/library-info/LibraryPublishStatus.tsx +++ b/src/library-authoring/library-info/LibraryPublishStatus.tsx @@ -4,7 +4,7 @@ import { ContentLibrary } from "../data/api"; import { useIntl } from '@edx/frontend-platform/i18n'; import messages from "./messages"; import classNames from 'classnames'; -import { useCommitLibraryChanges, useRevertLibraryChanges } from "../data/apiHook"; +import { useCommitLibraryChanges, useRevertLibraryChanges } from "../data/apiHooks"; import { ToastContext } from "../../generic/toast-context"; import { convertToStringFromDateAndFormat } from "../../utils"; import { COMMA_SEPARATED_DATE_FORMAT, TIME_FORMAT } from "../../constants"; From 8418d834374c3855d48a28e99b0e8d13e15716c2 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Wed, 24 Jul 2024 20:56:53 -0500 Subject: [PATCH 05/14] feat: Update library title --- .../LibraryAuthoringPage.tsx | 2 +- .../add-content/AddContentHeader.tsx | 4 +- src/library-authoring/common/context.tsx | 2 +- src/library-authoring/data/api.ts | 60 +++++---- src/library-authoring/data/apiHooks.ts | 14 ++- .../library-info/LibraryInfo.tsx | 20 +-- .../library-info/LibraryInfoHeader.tsx | 80 +++++++++--- .../library-info/LibraryPublishStatus.scss | 1 - .../library-info/LibraryPublishStatus.tsx | 114 +++++++++--------- .../library-info/messages.ts | 10 ++ .../library-sidebar/LibrarySidebar.tsx | 10 +- 11 files changed, 200 insertions(+), 117 deletions(-) diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 42aa3e25e5..8b5f099362 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -153,7 +153,7 @@ const LibraryAuthoringPage = () => { { sidebarBodyComponent !== null && ( - + )} diff --git a/src/library-authoring/add-content/AddContentHeader.tsx b/src/library-authoring/add-content/AddContentHeader.tsx index 7b5e537b19..a73a41a039 100644 --- a/src/library-authoring/add-content/AddContentHeader.tsx +++ b/src/library-authoring/add-content/AddContentHeader.tsx @@ -1,6 +1,6 @@ -import React from "react"; +import React from 'react'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import messages from "./messages"; +import messages from './messages'; const AddContentHeader = () => ( diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx index 735708bfce..548b39aa35 100644 --- a/src/library-authoring/common/context.tsx +++ b/src/library-authoring/common/context.tsx @@ -1,7 +1,7 @@ /* eslint-disable react/require-default-props */ import React from 'react'; -enum SidebarBodyComponentId { +export enum SidebarBodyComponentId { AddContent = 'add-content', Info = 'info', } diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index b1406b14f4..abf38b7630 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -19,8 +19,7 @@ export const getContentLibraryV2ListApiUrl = () => `${getApiBaseUrl()}/api/libra /** * Get the URL for commit/revert changes in library. */ -export const getCommitLibraryChangesUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/commit/` - +export const getCommitLibraryChangesUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/commit/`; export interface ContentLibrary { id: string; @@ -51,17 +50,6 @@ export interface LibraryBlockType { displayName: string; } -/** - * Fetch block types of a library - */ -export async function getLibraryBlockTypes(libraryId?: string): Promise { - if (!libraryId) { - throw new Error('libraryId is required'); - } - - const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockTypesUrl(libraryId)); - return camelCaseObject(data); -} export interface LibrariesV2Response { next: string | null, previous: string | null, @@ -103,6 +91,28 @@ export interface CreateBlockDataResponse { tagsCount: number; } +export interface UpdateBlockDataRequest { + id: string; + title?: string | null; + description?: string | null; + allow_public_learning?: boolean | null; + allow_public_read?: boolean | null; + type?: string | null; + license?: string | null; +} + +/** + * Fetch block types of a library + */ +export async function getLibraryBlockTypes(libraryId?: string): Promise { + if (!libraryId) { + throw new Error('libraryId is required'); + } + + const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockTypesUrl(libraryId)); + return camelCaseObject(data); +} + /** * Fetch a content library by its ID. */ @@ -131,6 +141,16 @@ export async function createLibraryBlock({ return data; } +/** + * Update library metadata. + */ +export async function updateLibraryMetadata(libraryData: UpdateBlockDataRequest): Promise { + const client = getAuthenticatedHttpClient(); + const { data } = await client.patch(getContentLibraryApiUrl(libraryData.id), libraryData); + + return camelCaseObject(data); +} + /** * Get a list of content libraries. */ @@ -153,21 +173,15 @@ export async function getContentLibraryV2List(customParams: GetLibrariesV2Custom /** * Commit library changes. */ -export async function commitLibraryChanges(libraryId: string): Promise { +export async function commitLibraryChanges(libraryId: string) { const client = getAuthenticatedHttpClient(); - - const { data } = await client.post(getCommitLibraryChangesUrl(libraryId)); - - return camelCaseObject(data); + await client.post(getCommitLibraryChangesUrl(libraryId)); } /** * Revert library changes. */ -export async function revertLibraryChanges(libraryId: string): Promise { +export async function revertLibraryChanges(libraryId: string) { const client = getAuthenticatedHttpClient(); - - const { data } = await client.delete(getCommitLibraryChangesUrl(libraryId)); - - return camelCaseObject(data); + await client.delete(getCommitLibraryChangesUrl(libraryId)); } diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index e7eb4d7c5c..d767e9ff12 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -11,6 +11,7 @@ import { getContentLibraryV2List, commitLibraryChanges, revertLibraryChanges, + updateLibraryMetadata, } from './api'; export const libraryAuthoringQueryKeys = { @@ -90,6 +91,16 @@ export const useCreateLibraryBlock = () => { }); }; +export const useUpdateLibraryMetadata = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: updateLibraryMetadata, + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(variables.id) }); + }, + }); +}; + /** * Hook to fetch the count of components and collections in a library. */ @@ -133,7 +144,6 @@ export const useContentLibraryV2List = (customParams: GetLibrariesV2CustomParams }) ); - export const useCommitLibraryChanges = () => { const queryClient = useQueryClient(); return useMutation({ @@ -152,4 +162,4 @@ export const useRevertLibraryChanges = () => { queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) }); }, }); -}; \ No newline at end of file +}; diff --git a/src/library-authoring/library-info/LibraryInfo.tsx b/src/library-authoring/library-info/LibraryInfo.tsx index c0e8bac04e..df58247ac9 100644 --- a/src/library-authoring/library-info/LibraryInfo.tsx +++ b/src/library-authoring/library-info/LibraryInfo.tsx @@ -1,11 +1,11 @@ -import React from "react"; -import { Stack } from "@openedx/paragon"; +import React from 'react'; +import { Stack } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; -import messages from "./messages"; -import { convertToStringFromDateAndFormat } from "../../utils"; -import { COMMA_SEPARATED_DATE_FORMAT } from "../../constants"; -import LibraryPublishStatus from "./LibraryPublishStatus"; -import { ContentLibrary } from "../data/api"; +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 = { library: ContentLibrary, @@ -15,9 +15,9 @@ const LibraryInfo = ({ library } : LibraryInfoProps) => { const intl = useIntl(); return ( - - - + + + {intl.formatMessage(messages.organizationSectionTitle)} diff --git a/src/library-authoring/library-info/LibraryInfoHeader.tsx b/src/library-authoring/library-info/LibraryInfoHeader.tsx index b25e742143..e184ba0c56 100644 --- a/src/library-authoring/library-info/LibraryInfoHeader.tsx +++ b/src/library-authoring/library-info/LibraryInfoHeader.tsx @@ -1,9 +1,16 @@ -import React from "react"; -import { Icon, IconButton, Stack } from "@openedx/paragon"; +import React, { useState, useContext } from 'react'; +import { + Icon, + IconButton, + Stack, + Form, +} 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"; +import messages from './messages'; +import { ContentLibrary } from '../data/api'; +import { useUpdateLibraryMetadata } from '../data/apiHooks'; +import { ToastContext } from '../../generic/toast-context'; type LibraryInfoHeaderProps = { library: ContentLibrary, @@ -11,19 +18,62 @@ type LibraryInfoHeaderProps = { const LibraryInfoHeader = ({ library } : LibraryInfoHeaderProps) => { const intl = useIntl(); + const [inputIsActive, setIsActive] = useState(false); + const updateMutation = useUpdateLibraryMetadata(); + const { showToast } = useContext(ToastContext); + + const handleSaveTitle = (event) => { + const newTitle = event.target.value; + if (newTitle && newTitle !== library.title) { + updateMutation.mutateAsync({ + id: library.id, + title: newTitle, + }).then(() => { + showToast(intl.formatMessage(messages.updateLibrarySuccessMsg)); + }).catch(() => { + showToast(intl.formatMessage(messages.updateLibraryErrorMsg)); + }); + } + setIsActive(false); + }; + const handleClick = () => { + setIsActive(true); + }; return ( - - - {library.title} - - {library.canEditLibrary && ( - - )} + + { inputIsActive + ? ( + { + if (event.key === 'Enter') { handleSaveTitle(event); } + }} + /> + ) + : ( + <> + + {library.title} + + {library.canEditLibrary && ( + + )} + + ) + } + ); }; diff --git a/src/library-authoring/library-info/LibraryPublishStatus.scss b/src/library-authoring/library-info/LibraryPublishStatus.scss index 7f94889290..9b920eea90 100644 --- a/src/library-authoring/library-info/LibraryPublishStatus.scss +++ b/src/library-authoring/library-info/LibraryPublishStatus.scss @@ -1,5 +1,4 @@ .library-publish-status { - &.draft-status { background-color: #FDF3E9; border-top: 4px solid #F4B57B; diff --git a/src/library-authoring/library-info/LibraryPublishStatus.tsx b/src/library-authoring/library-info/LibraryPublishStatus.tsx index 84997017b3..7fa69c4013 100644 --- a/src/library-authoring/library-info/LibraryPublishStatus.tsx +++ b/src/library-authoring/library-info/LibraryPublishStatus.tsx @@ -1,17 +1,17 @@ -import React, { useCallback, useContext, useMemo } from "react"; -import { Button, Container, Stack } from "@openedx/paragon"; -import { ContentLibrary } from "../data/api"; -import { useIntl } from '@edx/frontend-platform/i18n'; -import messages from "./messages"; +import React, { useCallback, useContext, useMemo } from 'react'; import classNames from 'classnames'; -import { useCommitLibraryChanges, useRevertLibraryChanges } from "../data/apiHooks"; -import { ToastContext } from "../../generic/toast-context"; -import { convertToStringFromDateAndFormat } from "../../utils"; -import { COMMA_SEPARATED_DATE_FORMAT, TIME_FORMAT } from "../../constants"; +import { Button, Container, Stack } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useCommitLibraryChanges, useRevertLibraryChanges } from '../data/apiHooks'; +import { ContentLibrary } from '../data/api'; +import { ToastContext } from '../../generic/toast-context'; +import { convertToStringFromDateAndFormat } from '../../utils'; +import { COMMA_SEPARATED_DATE_FORMAT, TIME_FORMAT } from '../../constants'; +import messages from './messages'; type LibraryPublishStatusProps = { library: ContentLibrary, -} +}; const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => { const intl = useIntl(); @@ -21,20 +21,20 @@ const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => { const commit = useCallback(() => { commitLibraryChanges.mutateAsync(library.id) - .then(() => { - showToast(intl.formatMessage(messages.publishSuccessMsg)); - }).catch(() => { - showToast(intl.formatMessage(messages.publishErrorMsg)); - }); + .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)); - }); + .then(() => { + showToast(intl.formatMessage(messages.revertSuccessMsg)); + }).catch(() => { + showToast(intl.formatMessage(messages.revertErrorMsg)); + }); }, []); const { @@ -43,10 +43,10 @@ const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => { extraStatusMessage, bodyMessage, } = useMemo(() => { - let isPublished : boolean; - let statusMessage : string; - let extraStatusMessage : string | undefined = undefined; - let bodyMessage : string | undefined = undefined; + let isPublishedResult: boolean; + let statusMessageResult : string; + let extraStatusMessageResult : string | undefined; + let bodyMessageResult : string | undefined; const buildDraftBodyMessage = (() => { if (library.lastDraftCreatedBy) { return intl.formatMessage(messages.lastDraftMsg, { @@ -54,57 +54,57 @@ const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => { 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)}, - }); } + 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(); + 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 - isPublished = false; - statusMessage = intl.formatMessage(messages.draftStatusLabel); - extraStatusMessage = intl.formatMessage(messages.unpublishedStatusLabel); - bodyMessage = buildDraftBodyMessage(); + isPublishedResult = false; + statusMessageResult = intl.formatMessage(messages.draftStatusLabel); + extraStatusMessageResult = intl.formatMessage(messages.unpublishedStatusLabel); + bodyMessageResult = buildDraftBodyMessage(); } else { // Library is published - isPublished = true; - statusMessage = intl.formatMessage(messages.publishedStatusLabel); + isPublishedResult = true; + statusMessageResult = intl.formatMessage(messages.publishedStatusLabel); if (library.publishedBy) { - bodyMessage = intl.formatMessage(messages.lastPublishedMsg, { + bodyMessageResult = 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, { + bodyMessageResult = intl.formatMessage(messages.lastPublishedMsgWithoutUser, { date: {convertToStringFromDateAndFormat(library.lastPublished, COMMA_SEPARATED_DATE_FORMAT)}, time: {convertToStringFromDateAndFormat(library.lastPublished, TIME_FORMAT)}, - }) + }); } } return { - isPublished, - statusMessage, - extraStatusMessage, - bodyMessage, - } - }, [library]) - + isPublished: isPublishedResult, + statusMessage: statusMessageResult, + extraStatusMessage: extraStatusMessageResult, + bodyMessage: bodyMessageResult, + }; + }, [library]); + return ( - + {statusMessage} @@ -122,10 +122,10 @@ const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => { -
- +
diff --git a/src/library-authoring/library-info/messages.ts b/src/library-authoring/library-info/messages.ts index 2ec7b8db59..6b6b6dbac6 100644 --- a/src/library-authoring/library-info/messages.ts +++ b/src/library-authoring/library-info/messages.ts @@ -96,6 +96,16 @@ const messages = defineMessages({ defaultMessage: 'There was an error reverting changes in the library.', description: 'Message when there is an error when reverting changes in the library.', }, + updateLibrarySuccessMsg: { + id: 'course-authoring.library-authoring.library.update.success', + defaultMessage: 'Library updated successfully', + description: 'Message when the library is updated successfully', + }, + updateLibraryErrorMsg: { + id: 'course-authoring.library-authoring.library.update.error', + defaultMessage: 'There was an error updating the library', + description: 'Message when there is an error when updating the library', + }, }); export default messages; diff --git a/src/library-authoring/library-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx index 48a3ba7efe..ccbb7b09ce 100644 --- a/src/library-authoring/library-sidebar/LibrarySidebar.tsx +++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx @@ -8,13 +8,13 @@ import { Close } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; import messages from '../messages'; import { AddContentContainer, AddContentHeader } from '../add-content'; -import { LibraryContext } from '../common/context'; +import { LibraryContext, SidebarBodyComponentId } from '../common/context'; import { LibraryInfo, LibraryInfoHeader } from '../library-info'; import { ContentLibrary } from '../data/api'; type LibrarySidebarProps = { library: ContentLibrary, -} +}; /** * Sidebar container for library pages. @@ -25,13 +25,13 @@ type LibrarySidebarProps = { * You can add more components in `bodyComponentMap`. * Use the slice actions to open and close this sidebar. */ -const LibrarySidebar = ({library}: LibrarySidebarProps) => { +const LibrarySidebar = ({ library }: LibrarySidebarProps) => { const intl = useIntl(); const { sidebarBodyComponent, closeLibrarySidebar } = useContext(LibraryContext); const bodyComponentMap = { - 'add-content': , - 'info': , + [SidebarBodyComponentId.AddContent]: , + [SidebarBodyComponentId.Info]: , unknown: null, }; From 1d7bed20bf83f2194935397ed74de92f4ec331c3 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 26 Jul 2024 12:15:39 -0500 Subject: [PATCH 06/14] refactor: Styles and library info button --- .../LibraryAuthoringPage.tsx | 52 ++++++++++--------- .../library-info/LibraryInfo.tsx | 8 +-- .../library-info/LibraryInfoHeader.tsx | 4 +- .../library-info/LibraryPublishStatus.tsx | 5 +- .../library-sidebar/LibrarySidebar.tsx | 8 +-- src/library-authoring/messages.ts | 5 ++ 6 files changed, 45 insertions(+), 37 deletions(-) diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 8b5f099362..060fef15e6 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -4,8 +4,6 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, Container, - Icon, - IconButton, SearchField, Tab, Tabs, @@ -35,20 +33,34 @@ enum TabList { collections = 'collections', } -const SubHeaderTitle = ({ title }: { title: string }) => { +interface HeaderActionsProps { + canEditLibrary: boolean; +} + +const HeaderActions = ({ canEditLibrary }: HeaderActionsProps) => { const intl = useIntl(); - const { openInfoSidebar } = useContext(LibraryContext); + const { + openAddContentSidebar, + openInfoSidebar, + } = useContext(LibraryContext); return ( <> - {title} - + + ); }; @@ -67,7 +79,6 @@ const LibraryAuthoringPage = () => { const activeKey = (currentPath && currentPath in TabList) ? TabList[currentPath] : TabList.home; const { sidebarBodyComponent, - openAddContentSidebar, openInfoSidebar, } = useContext(LibraryContext); @@ -100,18 +111,9 @@ const LibraryAuthoringPage = () => { /> } + title={libraryData.title} subtitle={intl.formatMessage(messages.headingSubtitle)} - headerActions={[ - , - ]} + headerActions={} /> { { sidebarBodyComponent !== null && ( - + )} diff --git a/src/library-authoring/library-info/LibraryInfo.tsx b/src/library-authoring/library-info/LibraryInfo.tsx index df58247ac9..e963be4adb 100644 --- a/src/library-authoring/library-info/LibraryInfo.tsx +++ b/src/library-authoring/library-info/LibraryInfo.tsx @@ -17,7 +17,7 @@ const LibraryInfo = ({ library } : LibraryInfoProps) => { return ( - + {intl.formatMessage(messages.organizationSectionTitle)} @@ -25,11 +25,11 @@ const LibraryInfo = ({ library } : LibraryInfoProps) => { {library.org}
- + {intl.formatMessage(messages.libraryHistorySectionTitle)} - + {intl.formatMessage(messages.lastModifiedLabel)} @@ -37,7 +37,7 @@ const LibraryInfo = ({ library } : LibraryInfoProps) => { {convertToStringFromDateAndFormat(library.updated, COMMA_SEPARATED_DATE_FORMAT)} - + {intl.formatMessage(messages.createdLabel)} diff --git a/src/library-authoring/library-info/LibraryInfoHeader.tsx b/src/library-authoring/library-info/LibraryInfoHeader.tsx index e184ba0c56..bfe1f9bac4 100644 --- a/src/library-authoring/library-info/LibraryInfoHeader.tsx +++ b/src/library-authoring/library-info/LibraryInfoHeader.tsx @@ -71,9 +71,7 @@ const LibraryInfoHeader = ({ library } : LibraryInfoHeaderProps) => { /> )} - ) - } - + )} ); }; diff --git a/src/library-authoring/library-info/LibraryPublishStatus.tsx b/src/library-authoring/library-info/LibraryPublishStatus.tsx index 7fa69c4013..607d3c114a 100644 --- a/src/library-authoring/library-info/LibraryPublishStatus.tsx +++ b/src/library-authoring/library-info/LibraryPublishStatus.tsx @@ -47,6 +47,7 @@ const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => { let statusMessageResult : string; let extraStatusMessageResult : string | undefined; let bodyMessageResult : string | undefined; + const buildDraftBodyMessage = (() => { if (library.lastDraftCreatedBy) { return intl.formatMessage(messages.lastDraftMsg, { @@ -114,8 +115,8 @@ const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => { )} - - + + {bodyMessage} diff --git a/src/library-authoring/library-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx index ccbb7b09ce..314de8792a 100644 --- a/src/library-authoring/library-sidebar/LibrarySidebar.tsx +++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx @@ -45,7 +45,7 @@ const LibrarySidebar = ({ library }: LibrarySidebarProps) => { const buildHeader = (): React.ReactNode | null => headerComponentMap[sidebarBodyComponent || 'unknown']; return ( -
+ {buildHeader()} { variant="black" /> - {buildBody()} -
+
+ {buildBody()} +
+
); }; diff --git a/src/library-authoring/messages.ts b/src/library-authoring/messages.ts index 88116c620b..57f0c46570 100644 --- a/src/library-authoring/messages.ts +++ b/src/library-authoring/messages.ts @@ -105,6 +105,11 @@ const messages = defineMessages({ defaultMessage: 'Close', description: 'Alt text of close button', }, + libraryInfoButton: { + id: 'course-authoring.library-authoring.buttons.library-info.text', + defaultMessage: 'Library Info', + description: 'Text of button to open "Library Info sidebar"', + }, }); export default messages; From a90b0c3f2e025dcb543fab656919aa2f334dcd14 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 26 Jul 2024 14:15:47 -0500 Subject: [PATCH 07/14] fix: Tests in library authoring --- .../LibraryAuthoringPage.test.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index ae6dcd36a1..a31459a094 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -159,14 +159,14 @@ describe('', () => { axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); const { - getByRole, getByText, queryByText, findByText, + getByRole, getByText, queryByText, findByText, findAllByText, } = render(); // Ensure the search endpoint is called await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); - expect(getByText('Content library')).toBeInTheDocument(); - expect(getByText(libraryData.title)).toBeInTheDocument(); + expect(await findByText('Content library')).toBeInTheDocument(); + expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument(); expect(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); @@ -202,10 +202,10 @@ describe('', () => { axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); - const { findByText, getByText } = render(); + const { findByText, getByText, findAllByText } = render(); expect(await findByText('Content library')).toBeInTheDocument(); - expect(await findByText(libraryData.title)).toBeInTheDocument(); + expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument(); // Ensure the search endpoint is called await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); @@ -228,10 +228,15 @@ describe('', () => { axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); - const { findByText, getByRole, getByText } = render(); + const { + findByText, + getByRole, + getByText, + findAllByText, + } = render(); expect(await findByText('Content library')).toBeInTheDocument(); - expect(await findByText(libraryData.title)).toBeInTheDocument(); + expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument(); // Ensure the search endpoint is called await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); From 6a66c27f1b9a1ced68f45a4abec28af5603319a3 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Wed, 31 Jul 2024 11:24:36 -0500 Subject: [PATCH 08/14] test: Added for LibraryInfo and LibraryInfoheader --- .../LibraryAuthoringPage.test.tsx | 48 ++++- .../library-info/LibraryInfo.test.tsx | 179 ++++++++++++++++++ .../library-info/LibraryInfoHeader.test.tsx | 142 ++++++++++++++ 3 files changed, 365 insertions(+), 4 deletions(-) create mode 100644 src/library-authoring/library-info/LibraryInfo.test.tsx create mode 100644 src/library-authoring/library-info/LibraryInfoHeader.test.tsx diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index a31459a094..3f8837120f 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -18,6 +18,7 @@ import mockResult from '../search-modal/__mocks__/search-result.json'; import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json'; import { getContentLibraryApiUrl, type ContentLibrary } from './data/api'; import { LibraryLayout } from '.'; +import { convertToDateFromString } from '../utils'; let store; const mockUseParams = jest.fn(); @@ -60,9 +61,9 @@ const libraryData: ContentLibrary = { numBlocks: 2, version: 0, lastPublished: null, - lastDraftCreated: null, + lastDraftCreated: convertToDateFromString('2024-07-22') as Date, publishedBy: 'staff', - lastDraftCreatedBy: null, + lastDraftCreatedBy: 'staff', allowLti: false, allowPublicLearning: false, allowPublicRead: false, @@ -70,8 +71,8 @@ const libraryData: ContentLibrary = { hasUnpublishedDeletes: false, canEditLibrary: true, license: '', - created: null, - updated: null, + created: convertToDateFromString('2024-06-26') as Date, + updated: convertToDateFromString('2024-07-20') as Date, }; const RootWrapper = () => ( @@ -276,4 +277,43 @@ describe('', () => { expect(screen.queryByText(/add content/i)).not.toBeInTheDocument(); }); + + it('should open Library Info by default', async () => { + mockUseParams.mockReturnValue({ libraryId: libraryData.id }); + axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); + + render(); + + expect(await screen.findByText('Content library')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument(); + expect((await screen.findAllByText(libraryData.title))[1]).toBeInTheDocument(); + + expect(screen.getByText('Draft')).toBeInTheDocument(); + expect(screen.getByText('(Never Published)')).toBeInTheDocument(); + expect(screen.getByText('July 22, 2024')).toBeInTheDocument(); + expect(screen.getByText('staff')).toBeInTheDocument(); + expect(screen.getByText(libraryData.org)).toBeInTheDocument(); + expect(screen.getByText('July 20, 2024')).toBeInTheDocument(); + expect(screen.getByText('June 26, 2024')).toBeInTheDocument(); + }); + + it('should close and open Library Info', async () => { + mockUseParams.mockReturnValue({ libraryId: libraryData.id }); + axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); + + render(); + + expect(await screen.findByText('Content library')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument(); + expect((await screen.findAllByText(libraryData.title))[1]).toBeInTheDocument(); + + const closeButton = screen.getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + + const libraryInfoButton = screen.getByRole('button', { name: /library info/i }); + fireEvent.click(libraryInfoButton); + + expect(screen.getByText('Draft')).toBeInTheDocument(); + expect(screen.getByText('(Never Published)')).toBeInTheDocument(); + }); }); diff --git a/src/library-authoring/library-info/LibraryInfo.test.tsx b/src/library-authoring/library-info/LibraryInfo.test.tsx new file mode 100644 index 0000000000..fba9644d9b --- /dev/null +++ b/src/library-authoring/library-info/LibraryInfo.test.tsx @@ -0,0 +1,179 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { initializeMockApp } from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { + render, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import LibraryInfo from './LibraryInfo'; +import { ToastProvider } from '../../generic/toast-context'; +import { ContentLibrary, getCommitLibraryChangesUrl } from '../data/api'; +import initializeStore from '../../store'; +import { convertToDateFromString } from '../../utils'; + +let store; +let axiosMock; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const libraryData: ContentLibrary = { + id: 'lib:org1:lib1', + type: 'complex', + org: 'org1', + slug: 'lib1', + title: 'lib1', + description: 'lib1', + numBlocks: 2, + version: 0, + lastPublished: null, + lastDraftCreated: convertToDateFromString('2024-07-22') as Date, + publishedBy: 'staff', + lastDraftCreatedBy: 'staff', + allowLti: false, + allowPublicLearning: false, + allowPublicRead: false, + hasUnpublishedChanges: true, + hasUnpublishedDeletes: false, + canEditLibrary: true, + license: '', + created: convertToDateFromString('2024-06-26') as Date, + updated: convertToDateFromString('2024-07-20') as Date, +}; + +interface WrapperProps { + data: ContentLibrary, +} + +const RootWrapper = ({ data } : WrapperProps) => ( + + + + + + + + + +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + afterEach(() => { + jest.clearAllMocks(); + axiosMock.restore(); + }); + + it('should render Library info sidebar', () => { + render(); + + expect(screen.getByText('Draft')).toBeInTheDocument(); + expect(screen.getByText('(Never Published)')).toBeInTheDocument(); + expect(screen.getByText('July 22, 2024')).toBeInTheDocument(); + expect(screen.getByText('staff')).toBeInTheDocument(); + expect(screen.getByText(libraryData.org)).toBeInTheDocument(); + expect(screen.getByText('July 20, 2024')).toBeInTheDocument(); + expect(screen.getByText('June 26, 2024')).toBeInTheDocument(); + }); + + it('should render draft library info sidebar', () => { + const data = { + ...libraryData, + lastPublished: convertToDateFromString('2024-07-26') as Date, + }; + + render(); + + expect(screen.getByText('Draft')).toBeInTheDocument(); + expect(screen.queryByText('(Never Published)')).not.toBeInTheDocument(); + expect(screen.getByText('July 22, 2024')).toBeInTheDocument(); + expect(screen.getByText('staff')).toBeInTheDocument(); + }); + + it('should render published library info sidebar', () => { + const data = { + ...libraryData, + lastPublished: convertToDateFromString('2024-07-26') as Date, + hasUnpublishedChanges: false, + }; + + render(); + expect(screen.getByText('Published')).toBeInTheDocument(); + expect(screen.getByText('July 26, 2024')).toBeInTheDocument(); + expect(screen.getByText('staff')).toBeInTheDocument(); + }); + + it('should publish library', async () => { + const url = getCommitLibraryChangesUrl(libraryData.id); + axiosMock.onPost(url).reply(200); + render(); + + const publishButton = screen.getByRole('button', { name: /publish/i }); + fireEvent.click(publishButton); + + expect(await screen.findByText('Library published successfully')).toBeInTheDocument(); + + await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url)); + }); + + it('should show error on publish library', async () => { + const url = getCommitLibraryChangesUrl(libraryData.id); + axiosMock.onPost(url).reply(500); + render(); + + const publishButton = screen.getByRole('button', { name: /publish/i }); + fireEvent.click(publishButton); + + expect(await screen.findByText('There was an error publishing the library.')).toBeInTheDocument(); + + await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url)); + }); + + it('should discard changes', async () => { + const url = getCommitLibraryChangesUrl(libraryData.id); + axiosMock.onDelete(url).reply(200); + + render(); + const discardButton = screen.getByRole('button', { name: /discard changes/i }); + fireEvent.click(discardButton); + + expect(await screen.findByText('Library changes reverted successfully')).toBeInTheDocument(); + + await waitFor(() => expect(axiosMock.history.delete[0].url).toEqual(url)); + }); + + it('should show error on discard changes', async () => { + const url = getCommitLibraryChangesUrl(libraryData.id); + axiosMock.onDelete(url).reply(500); + + render(); + const discardButton = screen.getByRole('button', { name: /discard changes/i }); + fireEvent.click(discardButton); + + expect(await screen.findByText('There was an error reverting changes in the library.')).toBeInTheDocument(); + + await waitFor(() => expect(axiosMock.history.delete[0].url).toEqual(url)); + }); +}); diff --git a/src/library-authoring/library-info/LibraryInfoHeader.test.tsx b/src/library-authoring/library-info/LibraryInfoHeader.test.tsx new file mode 100644 index 0000000000..98f03a7d94 --- /dev/null +++ b/src/library-authoring/library-info/LibraryInfoHeader.test.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import MockAdapter from 'axios-mock-adapter'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + render, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import { convertToDateFromString } from '../../utils'; +import { ContentLibrary, getContentLibraryApiUrl } from '../data/api'; +import initializeStore from '../../store'; +import { ToastProvider } from '../../generic/toast-context'; +import LibraryInfoHeader from './LibraryInfoHeader'; + +let store; +let axiosMock; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const libraryData: ContentLibrary = { + id: 'lib:org1:lib1', + type: 'complex', + org: 'org1', + slug: 'lib1', + title: 'lib1', + description: 'lib1', + numBlocks: 2, + version: 0, + lastPublished: null, + lastDraftCreated: convertToDateFromString('2024-07-22') as Date, + publishedBy: 'staff', + lastDraftCreatedBy: 'staff', + allowLti: false, + allowPublicLearning: false, + allowPublicRead: false, + hasUnpublishedChanges: true, + hasUnpublishedDeletes: false, + canEditLibrary: true, + license: '', + created: convertToDateFromString('2024-06-26') as Date, + updated: convertToDateFromString('2024-07-20') as Date, +}; + +interface WrapperProps { + data: ContentLibrary, +} + +const RootWrapper = ({ data } : WrapperProps) => ( + + + + + + + + + +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + afterEach(() => { + jest.clearAllMocks(); + axiosMock.restore(); + }); + + it('should render Library info Header', () => { + render(); + + expect(screen.getByText(libraryData.title)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /edit library name/i })).toBeInTheDocument(); + }); + + it('should not render edit title button without permission', () => { + const data = { + ...libraryData, + canEditLibrary: false, + }; + + render(); + + expect(screen.queryByRole('button', { name: /edit library name/i })).not.toBeInTheDocument(); + }); + + it('should edit library title', async () => { + const url = getContentLibraryApiUrl(libraryData.id); + axiosMock.onPatch(url).reply(200); + render(); + + const editTitleButton = screen.getByRole('button', { name: /edit library name/i }); + fireEvent.click(editTitleButton); + + const textBox = screen.getByRole('textbox', { name: /title input/i }); + + fireEvent.change(textBox, { target: { value: 'New Library Title' } }); + fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 }); + + expect(await screen.findByText('Library updated successfully')).toBeInTheDocument(); + + await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(url)); + }); + + it('should show error on edit library tittle', async () => { + const url = getContentLibraryApiUrl(libraryData.id); + axiosMock.onPatch(url).reply(500); + render(); + + const editTitleButton = screen.getByRole('button', { name: /edit library name/i }); + fireEvent.click(editTitleButton); + + const textBox = screen.getByRole('textbox', { name: /title input/i }); + + fireEvent.change(textBox, { target: { value: 'New Library Title' } }); + fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 }); + + expect(await screen.findByText('There was an error updating the library')).toBeInTheDocument(); + + await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(url)); + }); +}); From a4562d45894e01d7892359f57f3d46c3d6d73b0a Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 1 Aug 2024 13:25:30 -0500 Subject: [PATCH 09/14] style: Nits on style and types in library authoring code --- .../LibraryAuthoringPage.test.tsx | 3 +++ src/library-authoring/LibraryAuthoringPage.tsx | 4 ++-- src/library-authoring/add-content/index.ts | 1 - src/library-authoring/data/api.ts | 16 ++++++++-------- .../library-info/LibraryInfoHeader.tsx | 13 ++++++++++--- src/library-authoring/library-info/index.ts | 1 - 6 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 40b682e071..9e4cd796c1 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -340,6 +340,9 @@ describe('', () => { const closeButton = screen.getByRole('button', { name: /close/i }); fireEvent.click(closeButton); + expect(screen.queryByText('Draft')).not.toBeInTheDocument(); + expect(screen.queryByText('(Never Published)')).not.toBeInTheDocument(); + const libraryInfoButton = screen.getByRole('button', { name: /library info/i }); fireEvent.click(libraryInfoButton); diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 909068a618..41230593ed 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -56,14 +56,14 @@ const HeaderActions = ({ canEditLibrary }: HeaderActionsProps) => { -
- -
+ { /* + * TODO, the discard changes breaks the library. + * Discomment this when discard changes is fixed. +
+ +
+ */ }
From 9cee29ebbdc369cc858b9d9d9839f2a066d178d1 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 1 Aug 2024 14:51:55 -0500 Subject: [PATCH 12/14] style: Nit on the code --- .../library-info/LibraryPublishStatus.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/library-authoring/library-info/LibraryPublishStatus.tsx b/src/library-authoring/library-info/LibraryPublishStatus.tsx index 58937c8b98..e497042657 100644 --- a/src/library-authoring/library-info/LibraryPublishStatus.tsx +++ b/src/library-authoring/library-info/LibraryPublishStatus.tsx @@ -2,7 +2,7 @@ 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 { useCommitLibraryChanges, useRevertLibraryChanges } from '../data/apiHooks'; +import { useCommitLibraryChanges } from '../data/apiHooks'; import { ContentLibrary } from '../data/api'; import { ToastContext } from '../../generic/toast-context'; import messages from './messages'; @@ -14,7 +14,6 @@ type LibraryPublishStatusProps = { const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => { const intl = useIntl(); const commitLibraryChanges = useCommitLibraryChanges(); - const revertLibraryChanges = useRevertLibraryChanges(); const { showToast } = useContext(ToastContext); const commit = useCallback(() => { @@ -26,6 +25,9 @@ const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => { }); }, []); + /** + * TODO, the discard changes breaks the library. + * Discomment this when discard changes is fixed. const revert = useCallback(() => { revertLibraryChanges.mutateAsync(library.id) .then(() => { @@ -34,6 +36,7 @@ const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => { showToast(intl.formatMessage(messages.revertErrorMsg)); }); }, []); + */ const { isPublished, From 2ad5b1f22cf8f8cec7622710b5e3e04c5e594d8d Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 2 Aug 2024 13:21:38 -0500 Subject: [PATCH 13/14] test: LibraryInfo, LibraryInfoHeader, api and apiHooks --- src/library-authoring/data/api.test.ts | 29 ++++++++++++- src/library-authoring/data/apiHooks.test.tsx | 24 ++++++++++- .../library-info/LibraryInfo.test.tsx | 41 +++++++++++++++++++ .../library-info/LibraryInfoHeader.test.tsx | 17 ++++++++ 4 files changed, 108 insertions(+), 3 deletions(-) diff --git a/src/library-authoring/data/api.test.ts b/src/library-authoring/data/api.test.ts index 66736ad249..557488900d 100644 --- a/src/library-authoring/data/api.test.ts +++ b/src/library-authoring/data/api.test.ts @@ -1,7 +1,13 @@ import MockAdapter from 'axios-mock-adapter'; import { initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { createLibraryBlock, getCreateLibraryBlockUrl } from './api'; +import { + commitLibraryChanges, + createLibraryBlock, + getCommitLibraryChangesUrl, + getCreateLibraryBlockUrl, + revertLibraryChanges, +} from './api'; let axiosMock; @@ -21,6 +27,7 @@ describe('library api calls', () => { afterEach(() => { jest.clearAllMocks(); + axiosMock.restore(); }); it('should create library block', async () => { @@ -35,4 +42,24 @@ describe('library api calls', () => { expect(axiosMock.history.post[0].url).toEqual(url); }); + + it('should commit library changes', async () => { + const libraryId = 'lib:org:1'; + const url = getCommitLibraryChangesUrl(libraryId); + axiosMock.onPost(url).reply(200); + + await commitLibraryChanges(libraryId); + + expect(axiosMock.history.post[0].url).toEqual(url); + }); + + it('should revert library changes', async () => { + const libraryId = 'lib:org:1'; + const url = getCommitLibraryChangesUrl(libraryId); + axiosMock.onDelete(url).reply(200); + + await revertLibraryChanges(libraryId); + + expect(axiosMock.history.delete[0].url).toEqual(url); + }); }); diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx index 6798423767..686e114018 100644 --- a/src/library-authoring/data/apiHooks.test.tsx +++ b/src/library-authoring/data/apiHooks.test.tsx @@ -5,8 +5,8 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { renderHook } from '@testing-library/react-hooks'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import MockAdapter from 'axios-mock-adapter'; -import { getCreateLibraryBlockUrl } from './api'; -import { useCreateLibraryBlock } from './apiHooks'; +import { getCommitLibraryChangesUrl, getCreateLibraryBlockUrl } from './api'; +import { useCommitLibraryChanges, useCreateLibraryBlock, useRevertLibraryChanges } from './apiHooks'; let axiosMock; @@ -50,4 +50,24 @@ describe('library api hooks', () => { expect(axiosMock.history.post[0].url).toEqual(url); }); + + it('should commit library changes', async () => { + const libraryId = 'lib:org:1'; + const url = getCommitLibraryChangesUrl(libraryId); + axiosMock.onPost(url).reply(200); + const { result } = renderHook(() => useCommitLibraryChanges(), { wrapper }); + await result.current.mutateAsync(libraryId); + + expect(axiosMock.history.post[0].url).toEqual(url); + }); + + it('should revert library changes', async () => { + const libraryId = 'lib:org:1'; + const url = getCommitLibraryChangesUrl(libraryId); + axiosMock.onDelete(url).reply(200); + const { result } = renderHook(() => useRevertLibraryChanges(), { wrapper }); + await result.current.mutateAsync(libraryId); + + expect(axiosMock.history.delete[0].url).toEqual(url); + }); }); diff --git a/src/library-authoring/library-info/LibraryInfo.test.tsx b/src/library-authoring/library-info/LibraryInfo.test.tsx index d5976d6c97..5ace15eb94 100644 --- a/src/library-authoring/library-info/LibraryInfo.test.tsx +++ b/src/library-authoring/library-info/LibraryInfo.test.tsx @@ -97,6 +97,20 @@ describe('', () => { expect(screen.getByText('June 26, 2024')).toBeInTheDocument(); }); + it('should render Library info in draft state without user', () => { + const data = { + ...libraryData, + lastDraftCreatedBy: null, + }; + + render(); + + expect(screen.getByText('Draft')).toBeInTheDocument(); + expect(screen.getByText('(Never Published)')).toBeInTheDocument(); + expect(screen.getByText('July 22, 2024')).toBeInTheDocument(); + expect(screen.queryByText('staff')).not.toBeInTheDocument(); + }); + it('should render Library creation date if last draft created date is null', () => { const data = { ...libraryData, @@ -111,6 +125,19 @@ describe('', () => { expect(screen.getAllByText('June 26, 2024')[1]).toBeInTheDocument(); }); + it('should render library info in draft state without date', () => { + const data = { + ...libraryData, + lastDraftCreated: null, + created: null, + }; + + render(); + + expect(screen.getByText('Draft')).toBeInTheDocument(); + expect(screen.getByText('(Never Published)')).toBeInTheDocument(); + }); + it('should render draft library info sidebar', () => { const data = { ...libraryData, @@ -138,6 +165,20 @@ describe('', () => { expect(screen.getByText('staff')).toBeInTheDocument(); }); + it('should render published library info without user', () => { + const data = { + ...libraryData, + lastPublished: '2024-07-26', + hasUnpublishedChanges: false, + publishedBy: null, + }; + + render(); + expect(screen.getByText('Published')).toBeInTheDocument(); + expect(screen.getByText('July 26, 2024')).toBeInTheDocument(); + expect(screen.queryByText('staff')).not.toBeInTheDocument(); + }); + it('should publish library', async () => { const url = getCommitLibraryChangesUrl(libraryData.id); axiosMock.onPost(url).reply(200); diff --git a/src/library-authoring/library-info/LibraryInfoHeader.test.tsx b/src/library-authoring/library-info/LibraryInfoHeader.test.tsx index ac7221e279..cf77f62617 100644 --- a/src/library-authoring/library-info/LibraryInfoHeader.test.tsx +++ b/src/library-authoring/library-info/LibraryInfoHeader.test.tsx @@ -116,11 +116,28 @@ describe('', () => { fireEvent.change(textBox, { target: { value: 'New Library Title' } }); fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 }); + expect(textBox).not.toBeInTheDocument(); expect(await screen.findByText('Library updated successfully')).toBeInTheDocument(); await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(url)); }); + it('should close edit library title on press Escape', async () => { + const url = getContentLibraryApiUrl(libraryData.id); + axiosMock.onPatch(url).reply(200); + render(); + + const editTitleButton = screen.getByRole('button', { name: /edit library name/i }); + fireEvent.click(editTitleButton); + + const textBox = screen.getByRole('textbox', { name: /title input/i }); + fireEvent.keyDown(textBox, { key: 'Escape', code: 'Escape', charCode: 27 }); + + expect(textBox).not.toBeInTheDocument(); + + await waitFor(() => expect(axiosMock.history.patch.length).toEqual(0)); + }); + it('should show error on edit library tittle', async () => { const url = getContentLibraryApiUrl(libraryData.id); axiosMock.onPatch(url).reply(500); From 0490452c14085681be0c64501191a759565bf776 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 12 Aug 2024 22:58:24 -0500 Subject: [PATCH 14/14] style: Lint on the code --- src/library-authoring/LibraryAuthoringPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 4f870f49ae..c2eb969292 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -69,7 +69,7 @@ const HeaderActions = ({ canEditLibrary }: HeaderActionsProps) => {