From bdfb949d031993af786dfb5c8b2e63c3639113a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 14 Aug 2024 12:41:13 -0300 Subject: [PATCH] feat: add library component sidebar --- src/library-authoring/common/context.tsx | 31 ++++- .../component-info/ComponentInfo.tsx | 51 ++++++++ .../component-info/ComponentInfoHeader.tsx | 109 ++++++++++++++++++ .../component-info/index.tsx | 2 + .../component-info/messages.ts | 50 ++++++++ .../components/ComponentCard.tsx | 14 ++- src/library-authoring/components/index.ts | 2 +- src/library-authoring/data/api.ts | 35 ++++++ src/library-authoring/data/apiHooks.ts | 59 +++++++++- .../library-sidebar/LibrarySidebar.tsx | 21 +++- src/utils.js | 16 +++ 11 files changed, 377 insertions(+), 13 deletions(-) create mode 100644 src/library-authoring/component-info/ComponentInfo.tsx create mode 100644 src/library-authoring/component-info/ComponentInfoHeader.tsx create mode 100644 src/library-authoring/component-info/index.tsx create mode 100644 src/library-authoring/component-info/messages.ts diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx index 548b39aa35..bf7f98e982 100644 --- a/src/library-authoring/common/context.tsx +++ b/src/library-authoring/common/context.tsx @@ -4,6 +4,7 @@ import React from 'react'; export enum SidebarBodyComponentId { AddContent = 'add-content', Info = 'info', + ComponentInfo = 'component-info', } export interface LibraryContextData { @@ -11,6 +12,8 @@ export interface LibraryContextData { closeLibrarySidebar: () => void; openAddContentSidebar: () => void; openInfoSidebar: () => void; + openComponentInfoSidebar: (usageKey: string) => void; + currentComponentUsageKey?: string; } export const LibraryContext = React.createContext({ @@ -18,6 +21,7 @@ export const LibraryContext = React.createContext({ closeLibrarySidebar: () => {}, openAddContentSidebar: () => {}, openInfoSidebar: () => {}, + openComponentInfoSidebar: (_usageKey: string) => {}, // eslint-disable-line @typescript-eslint/no-unused-vars } as LibraryContextData); /** @@ -25,21 +29,42 @@ export const LibraryContext = React.createContext({ */ export const LibraryProvider = (props: { children?: React.ReactNode }) => { const [sidebarBodyComponent, setSidebarBodyComponent] = React.useState(null); + const [currentComponentUsageKey, setCurrentComponentUsageKey] = React.useState(); - const closeLibrarySidebar = React.useCallback(() => setSidebarBodyComponent(null), []); - const openAddContentSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.AddContent), []); - const openInfoSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.Info), []); + const closeLibrarySidebar = React.useCallback(() => { + setSidebarBodyComponent(null); + setCurrentComponentUsageKey(undefined); + }, []); + const openAddContentSidebar = React.useCallback(() => { + setCurrentComponentUsageKey(undefined); + setSidebarBodyComponent(SidebarBodyComponentId.AddContent); + }, []); + const openInfoSidebar = React.useCallback(() => { + setCurrentComponentUsageKey(undefined); + setSidebarBodyComponent(SidebarBodyComponentId.Info); + }, []); + const openComponentInfoSidebar = React.useCallback( + (usageKey: string) => { + setCurrentComponentUsageKey(usageKey); + setSidebarBodyComponent(SidebarBodyComponentId.ComponentInfo); + }, + [], + ); const context = React.useMemo(() => ({ sidebarBodyComponent, closeLibrarySidebar, openAddContentSidebar, openInfoSidebar, + openComponentInfoSidebar, + currentComponentUsageKey, }), [ sidebarBodyComponent, closeLibrarySidebar, openAddContentSidebar, openInfoSidebar, + openComponentInfoSidebar, + currentComponentUsageKey, ]); return ( diff --git a/src/library-authoring/component-info/ComponentInfo.tsx b/src/library-authoring/component-info/ComponentInfo.tsx new file mode 100644 index 0000000000..62bcc0387d --- /dev/null +++ b/src/library-authoring/component-info/ComponentInfo.tsx @@ -0,0 +1,51 @@ +/* eslint-disable react/require-default-props */ +import React from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Button, + Tab, + Tabs, + Stack, +} from '@openedx/paragon'; + +import { ComponentMenu } from '../components'; +import messages from './messages'; + +interface ComponentInfoProps { + usageKey: string; +} + +const ComponentInfo = ({ usageKey } : ComponentInfoProps) => { + const intl = useIntl(); + + return ( + + + + + + + + + Preview tab placeholder + + + Manage tab placeholder + + + Details tab placeholder + + + + ); +}; + +export default ComponentInfo; diff --git a/src/library-authoring/component-info/ComponentInfoHeader.tsx b/src/library-authoring/component-info/ComponentInfoHeader.tsx new file mode 100644 index 0000000000..ed88082637 --- /dev/null +++ b/src/library-authoring/component-info/ComponentInfoHeader.tsx @@ -0,0 +1,109 @@ +/* eslint-disable react/require-default-props */ +import React, { useState, useContext, useCallback } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Icon, + IconButton, + Stack, + Form, +} from '@openedx/paragon'; +import { Edit } from '@openedx/paragon/icons'; + +import { LoadingSpinner } from '../../generic/Loading'; +import AlertError from '../../generic/alert-error'; +import { ToastContext } from '../../generic/toast-context'; +import { useUpdateXBlockFields, useXBlockFields } from '../data/apiHooks'; +import messages from './messages'; + +interface ComponentInfoHeaderProps { + usageKey: string; +} + +const ComponentInfoHeader = ({ usageKey }: ComponentInfoHeaderProps) => { + const intl = useIntl(); + const [inputIsActive, setIsActive] = useState(false); + + const { + data: xblockFields, + isError, + error, + isLoading, + } = useXBlockFields(usageKey); + + const updateMutation = useUpdateXBlockFields(usageKey); + const { showToast } = useContext(ToastContext); + + const handleSaveDisplayName = useCallback( + (event) => { + const newDisplayName = event.target.value; + if (newDisplayName && newDisplayName !== xblockFields?.displayName) { + updateMutation.mutateAsync({ + metadata: { + display_name: newDisplayName, + }, + }).then(() => { + showToast(intl.formatMessage(messages.updateComponentSuccessMsg)); + }).catch(() => { + showToast(intl.formatMessage(messages.updateComponentErrorMsg)); + }); + } + setIsActive(false); + }, + [xblockFields, showToast, intl], + ); + + const handleClick = () => { + setIsActive(true); + }; + + const hanldeOnKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + handleSaveDisplayName(event); + } else if (event.key === 'Escape') { + setIsActive(false); + } + }; + + if (isError) { + return ; + } + + if (isLoading) { + return ; + } + + return ( + + { inputIsActive + ? ( + + ) + : ( + <> + + {xblockFields.displayName} + + {true && ( // Add condition to check if user has permission to edit + + )} + + )} + + ); +}; + +export default ComponentInfoHeader; diff --git a/src/library-authoring/component-info/index.tsx b/src/library-authoring/component-info/index.tsx new file mode 100644 index 0000000000..27bb2275f3 --- /dev/null +++ b/src/library-authoring/component-info/index.tsx @@ -0,0 +1,2 @@ +export { default as ComponentInfo } from './ComponentInfo'; +export { default as ComponentInfoHeader } from './ComponentInfoHeader'; diff --git a/src/library-authoring/component-info/messages.ts b/src/library-authoring/component-info/messages.ts new file mode 100644 index 0000000000..d8b0046fcf --- /dev/null +++ b/src/library-authoring/component-info/messages.ts @@ -0,0 +1,50 @@ +import { defineMessages as _defineMessages } from '@edx/frontend-platform/i18n'; +import type { defineMessages as defineMessagesType } from 'react-intl'; + +// frontend-platform currently doesn't provide types... do it ourselves. +const defineMessages = _defineMessages as typeof defineMessagesType; + +const messages = defineMessages({ + editNameButtonAlt: { + id: 'course-authoring.library-authoring.component.edit-name.alt', + defaultMessage: 'Edit componet name', + description: 'Alt text for edit component name icon button', + }, + updateComponentSuccessMsg: { + id: 'course-authoring.library-authoring.component.update.success', + defaultMessage: 'Component updated successfully.', + description: 'Message when the component is updated successfully', + }, + updateComponentErrorMsg: { + id: 'course-authoring.library-authoring.component.update.error', + defaultMessage: 'There was an error updating the component.', + description: 'Message when there is an error when updating the component', + }, + editComponentButtonTitle: { + id: 'course-authoring.library-authoring.component.edit.title', + defaultMessage: 'Edit component', + description: 'Title for edit component button', + }, + publishComponentButtonTitle: { + id: 'course-authoring.library-authoring.component.publish.title', + defaultMessage: 'Publish component', + description: 'Title for publish component button', + }, + previewTabTitle: { + id: 'course-authoring.library-authoring.component.preview-tab.title', + defaultMessage: 'Preview', + description: 'Title for preview tab', + }, + manageTabTitle: { + id: 'course-authoring.library-authoring.component.manage-tab.title', + defaultMessage: 'Manage', + description: 'Title for manage tab', + }, + detailsTabTitle: { + id: 'course-authoring.library-authoring.component.details-tab.title', + defaultMessage: 'Details', + description: 'Title for details tab', + }, +}); + +export default messages; diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx index a24df20de6..2a9c2440f4 100644 --- a/src/library-authoring/components/ComponentCard.tsx +++ b/src/library-authoring/components/ComponentCard.tsx @@ -16,6 +16,7 @@ import { updateClipboard } from '../../generic/data/api'; import TagCount from '../../generic/tag-count'; import { ToastContext } from '../../generic/toast-context'; import { type ContentHit, Highlight } from '../../search-manager'; +import { LibraryContext } from '../common/context'; import messages from './messages'; import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants'; @@ -24,7 +25,7 @@ type ComponentCardProps = { blockTypeDisplayName: string, }; -const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => { +export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { const intl = useIntl(); const { showToast } = useContext(ToastContext); const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL)); @@ -64,6 +65,10 @@ const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => { }; const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps) => { + const { + openComponentInfoSidebar, + } = useContext(LibraryContext); + const { blockType, formatted, @@ -84,7 +89,10 @@ const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps return ( - + openComponentInfoSidebar(usageKey)} + > - + )} /> diff --git a/src/library-authoring/components/index.ts b/src/library-authoring/components/index.ts index 63c42720e0..3a928498c7 100644 --- a/src/library-authoring/components/index.ts +++ b/src/library-authoring/components/index.ts @@ -1,2 +1,2 @@ -// eslint-disable-next-line import/prefer-default-export export { default as LibraryComponents } from './LibraryComponents'; +export { ComponentMenu } from './ComponentCard'; diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 4b933833c0..d9725719c2 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -24,6 +24,10 @@ export const getCommitLibraryChangesUrl = (libraryId: string) => `${getApiBaseUr * Get the URL for paste clipboard content into library. */ export const getLibraryPasteClipboardUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/paste_clipboard/`; +/** + * Get the URL for the xblock metadata API. + */ +export const getXBlockFieldsApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/xblock/v2/xblocks/${usageKey}/fields/`; export interface ContentLibrary { id: string; @@ -64,6 +68,12 @@ export interface LibrariesV2Response { results: ContentLibrary[], } +export interface XBlockFields { + displayName: string; + metadata: Record; + data: string; +} + /* Additional custom parameters for the API request. */ export interface GetLibrariesV2CustomParams { /* (optional) Library type, default `complex` */ @@ -110,6 +120,13 @@ export interface LibraryPasteClipboardRequest { blockId: string; } +export interface UpdateXBlockFieldsRequest { + data?: unknown; + metadata?: { + display_name?: string; + }; +} + /** * Fetch block types of a library */ @@ -211,3 +228,21 @@ export async function libraryPasteClipboard({ ); return data; } + +/** + * Fetch xblock fields. + */ +export async function getXBlockFields(usageKey: string): Promise { + const { data } = await getAuthenticatedHttpClient().get(getXBlockFieldsApiUrl(usageKey)); + return camelCaseObject(data); +} + +/** + * Update xblock fields. + */ +export async function updateXBlockFields(usageKey:string, xblockData: UpdateXBlockFieldsRequest): Promise { + const client = getAuthenticatedHttpClient(); + const { data } = await client.post(getXBlockFieldsApiUrl(usageKey), xblockData); + + return camelCaseObject(data); +} diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 1e9a92bf84..35da5d5a13 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -1,7 +1,13 @@ +import { camelCaseObject } from '@edx/frontend-platform'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { getContextKeyFromUsageKey } from '../../utils'; + import { type GetLibrariesV2CustomParams, + type ContentLibrary, + type XBlockFields, + type UpdateXBlockFieldsRequest, getContentLibrary, getLibraryBlockTypes, createLibraryBlock, @@ -9,8 +15,9 @@ import { commitLibraryChanges, revertLibraryChanges, updateLibraryMetadata, - ContentLibrary, libraryPasteClipboard, + getXBlockFields, + updateXBlockFields, } from './api'; export const libraryAuthoringQueryKeys = { @@ -30,6 +37,13 @@ export const libraryAuthoringQueryKeys = { 'content', 'libraryBlockTypes', ], + xblockFields: (usageKey: string) => [ + ...libraryAuthoringQueryKeys.all, + ...libraryAuthoringQueryKeys.contentLibrary(getContextKeyFromUsageKey(usageKey)), + 'content', + 'xblockFields', + usageKey, + ], }; /** @@ -136,3 +150,46 @@ export const useLibraryPasteClipboard = () => { }, }); }; + +export const useXBlockFields = (usageKey: string) => ( + useQuery({ + queryKey: libraryAuthoringQueryKeys.xblockFields(usageKey), + queryFn: () => getXBlockFields(usageKey), + enabled: !!usageKey, + }) +); + +export const useUpdateXBlockFields = (usageKey: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: UpdateXBlockFieldsRequest) => updateXBlockFields(usageKey, data), + onMutate: async (data) => { + const queryKey = libraryAuthoringQueryKeys.xblockFields(usageKey); + const previousBlockData = queryClient.getQueriesData(queryKey)[0][1] as XBlockFields; + const formatedData = camelCaseObject(data); + + const newBlockData = { + ...previousBlockData, + ...(formatedData.metadata?.displayName && { displayName: formatedData.metadata.displayName }), + metadata: { + ...previousBlockData.metadata, + ...formatedData.metadata, + }, + }; + + queryClient.setQueryData(queryKey, newBlockData); + + return { previousBlockData, newBlockData }; + }, + onError: (_err, _data, context) => { + queryClient.setQueryData( + libraryAuthoringQueryKeys.xblockFields(usageKey), + context?.previousBlockData, + ); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.xblockFields(usageKey) }); + queryClient.invalidateQueries({ queryKey: ['content_search'] }); + }, + }); +}; diff --git a/src/library-authoring/library-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx index 314de8792a..7beb2e4176 100644 --- a/src/library-authoring/library-sidebar/LibrarySidebar.tsx +++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx @@ -10,6 +10,7 @@ import messages from '../messages'; import { AddContentContainer, AddContentHeader } from '../add-content'; import { LibraryContext, SidebarBodyComponentId } from '../common/context'; import { LibraryInfo, LibraryInfoHeader } from '../library-info'; +import { ComponentInfo, ComponentInfoHeader } from '../component-info'; import { ContentLibrary } from '../data/api'; type LibrarySidebarProps = { @@ -27,22 +28,32 @@ type LibrarySidebarProps = { */ const LibrarySidebar = ({ library }: LibrarySidebarProps) => { const intl = useIntl(); - const { sidebarBodyComponent, closeLibrarySidebar } = useContext(LibraryContext); + const { + sidebarBodyComponent, + closeLibrarySidebar, + currentComponentUsageKey, + } = useContext(LibraryContext); const bodyComponentMap = { [SidebarBodyComponentId.AddContent]: , [SidebarBodyComponentId.Info]: , + [SidebarBodyComponentId.ComponentInfo]: ( + currentComponentUsageKey && + ), unknown: null, }; const headerComponentMap = { - 'add-content': , - info: , + [SidebarBodyComponentId.AddContent]: , + [SidebarBodyComponentId.Info]: , + [SidebarBodyComponentId.ComponentInfo]: ( + currentComponentUsageKey && + ), unknown: null, }; - const buildBody = () : React.ReactNode | null => bodyComponentMap[sidebarBodyComponent || 'unknown']; - const buildHeader = (): React.ReactNode | null => headerComponentMap[sidebarBodyComponent || 'unknown']; + const buildBody = () : React.ReactNode => bodyComponentMap[sidebarBodyComponent || 'unknown']; + const buildHeader = (): React.ReactNode => headerComponentMap[sidebarBodyComponent || 'unknown']; return ( diff --git a/src/utils.js b/src/utils.js index 2abb63e5be..f18224df72 100644 --- a/src/utils.js +++ b/src/utils.js @@ -325,3 +325,19 @@ export const constructLibraryAuthoringURL = (libraryAuthoringMfeUrl, path) => { return constructedUrl; }; + +/** + * Returns the context key from a usage key + * @param {string} usageKey - the usage key + * @returns {string} - the context key + */ +export const getContextKeyFromUsageKey = (usageKey) => { + const [contentType, org, slug] = usageKey.split(':'); + + let contextType = contentType; + + if (contentType === 'lb') { + contextType = 'lib'; + } + return `${contextType}:${org}:${slug}`; +};