From 3d8d248599130c2d12800dfac6bb1d6bf4c1104f Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 24 Oct 2024 06:46:27 -0700 Subject: [PATCH] feat: arbitrary asset upload/deletion for Library Components [FC-0062] (#1430) Allow users to upload and delete assets associated with Content Library components via the sidebar panel, under the "Advanced Details" section of the "Details" tab. This is intended as a debug tool and power-user feature, similar to the OLX editor provided there. It's also serving as our interim image-upload solution, because it was easier to implement than the full modal that integrates with TinyMCE. --------- Co-authored-by: XnpioChV --- .../ComponentAdvancedAssets.tsx | 100 ++++++++++++++++++ .../ComponentAdvancedInfo.test.tsx | 56 +++++++++- .../component-info/ComponentAdvancedInfo.tsx | 15 +-- .../component-info/messages.ts | 10 ++ src/library-authoring/data/api.ts | 8 ++ src/library-authoring/data/apiHooks.ts | 23 ++++ 6 files changed, 199 insertions(+), 13 deletions(-) create mode 100644 src/library-authoring/component-info/ComponentAdvancedAssets.tsx diff --git a/src/library-authoring/component-info/ComponentAdvancedAssets.tsx b/src/library-authoring/component-info/ComponentAdvancedAssets.tsx new file mode 100644 index 000000000..970de6f25 --- /dev/null +++ b/src/library-authoring/component-info/ComponentAdvancedAssets.tsx @@ -0,0 +1,100 @@ +/* eslint-disable no-nested-ternary */ +/* eslint-disable import/prefer-default-export */ +import React from 'react'; +import { + Button, + Dropzone, +} from '@openedx/paragon'; +import { Delete } from '@openedx/paragon/icons'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { FormattedMessage, FormattedNumber, useIntl } from '@edx/frontend-platform/i18n'; + +import { LoadingSpinner } from '../../generic/Loading'; +import DeleteModal from '../../generic/delete-modal/DeleteModal'; +import { useLibraryContext } from '../common/context'; +import { getXBlockAssetsApiUrl } from '../data/api'; +import { useDeleteXBlockAsset, useInvalidateXBlockAssets, useXBlockAssets } from '../data/apiHooks'; +import messages from './messages'; + +export const ComponentAdvancedAssets: React.FC> = () => { + const intl = useIntl(); + const { readOnly, sidebarComponentInfo } = useLibraryContext(); + + const usageKey = sidebarComponentInfo?.id; + // istanbul ignore if: this should never happen in production + if (!usageKey) { + throw new Error('sidebarComponentUsageKey is required to render ComponentAdvancedAssets'); + } + + // For listing assets: + const { data: assets, isLoading: areAssetsLoading } = useXBlockAssets(usageKey); + const refreshAssets = useInvalidateXBlockAssets(usageKey); + + // For uploading assets: + const handleProcessUpload = React.useCallback(async ({ + fileData, requestConfig, handleError, + }: { fileData: FormData, requestConfig: any, handleError: any }) => { + const uploadData = new FormData(); + const file = fileData.get('file') as File; + uploadData.set('content', file); // Paragon calls this 'file' but our API needs it called 'content' + // TODO: We may wish to warn the user (and prompt to confirm?) if they are + // about to overwite an existing file by uploading a file with the same + // name as an existing file. That is a workflow we want to support, but only + // if it's intentional. + // Note: we follow the convention that files meant to be seen/downloaded by + // learners should be prefixed with 'static/' + const uploadUrl = `${getXBlockAssetsApiUrl(usageKey)}static/${encodeURI(file.name)}`; + const client = getAuthenticatedHttpClient(); + try { + await client.put(uploadUrl, uploadData, requestConfig); + } catch (error) { + handleError(error); + return; + } + refreshAssets(); + }, [usageKey]); + + // For deleting assets: + const deleter = useDeleteXBlockAsset(usageKey); + const [filePathToDelete, setConfirmDeleteAsset] = React.useState(''); + const deleteFile = React.useCallback(() => { + deleter.mutateAsync(filePathToDelete); // Don't wait for this before clearing the modal on the next line + setConfirmDeleteAsset(''); + }, [filePathToDelete, usageKey]); + + return ( + <> +
    + { areAssetsLoading ?
  • : null } + { assets?.map(a => ( +
  • + {a.path}{' '} + () + +
  • + )) } +
+ { assets !== undefined && !readOnly // Wait until assets have loaded before displaying add button: + ? ( + {}} + /> + ) + : null } + + { setConfirmDeleteAsset(''); }} + variant="warning" + title={intl.formatMessage(messages.advancedDetailsAssetsDeleteFileTitle)} + description={`Are you sure you want to delete ${filePathToDelete}?`} + onDeleteSubmit={deleteFile} + btnState="default" + /> + + ); +}; diff --git a/src/library-authoring/component-info/ComponentAdvancedInfo.test.tsx b/src/library-authoring/component-info/ComponentAdvancedInfo.test.tsx index 773a2faaf..238be40d3 100644 --- a/src/library-authoring/component-info/ComponentAdvancedInfo.test.tsx +++ b/src/library-authoring/component-info/ComponentAdvancedInfo.test.tsx @@ -14,6 +14,7 @@ import { } from '../data/api.mocks'; import { LibraryProvider, SidebarBodyComponentId } from '../common/context'; import { ComponentAdvancedInfo } from './ComponentAdvancedInfo'; +import { getXBlockAssetsApiUrl } from '../data/api'; mockContentLibrary.applyMock(); mockLibraryBlockMetadata.applyMock(); @@ -68,6 +69,58 @@ describe('', () => { expect(await screen.findByText(/\(12M\)/)).toBeInTheDocument(); // size of the above file expect(await screen.findByText(/static\/data\.csv/)).toBeInTheDocument(); expect(await screen.findByText(/\(8K\)/)).toBeInTheDocument(); // size of the above file + expect(await screen.findByText(/Drag and drop your file here or click to upload/)).toBeInTheDocument(); + }); + + it('should delete static assets of the block', async () => { + const { axiosMock } = initializeMocks(); + + render(); + + const url = `${getXBlockAssetsApiUrl(mockLibraryBlockMetadata.usageKeyPublished)}${encodeURIComponent('static/image1.png')}`; + axiosMock.onDelete(url).reply(200); + + const expandButton = await screen.findByRole('button', { name: /Advanced details/ }); + fireEvent.click(expandButton); + + expect(await screen.findByText(/static\/image1\.png/)).toBeInTheDocument(); + + // Click on delete button + const deleteButtons = await screen.findAllByTitle('Delete this file'); + expect(deleteButtons.length).toEqual(2); + fireEvent.click(deleteButtons[0]); + + // Show the pop up and click on delete + expect(await screen.findByText(/Are you sure you want to delete static\/image1\.png/)).toBeInTheDocument(); + const deleteButton = await screen.findByRole('button', { name: /delete/i }); + fireEvent.click(deleteButton); + + await waitFor(() => expect(axiosMock.history.delete[0].url).toEqual(url)); + }); + + it('should add asset in Dropzone', async () => { + const { axiosMock } = initializeMocks(); + render(); + + const url = `${getXBlockAssetsApiUrl(mockLibraryBlockMetadata.usageKeyPublished)}static/image3.png`; + axiosMock.onPut(url).reply(200); + + const expandButton = await screen.findByRole('button', { name: /Advanced details/ }); + fireEvent.click(expandButton); + + const dropzone = await screen.findByText(/Drag and drop your file here or click to upload/); + expect(dropzone).toBeInTheDocument(); + + const file = new File(['file'], 'image3.png', { + type: 'image/png', + }); + Object.defineProperty(dropzone, 'files', { + value: [file], + }); + + fireEvent.drop(dropzone); + + await waitFor(() => expect(axiosMock.history.put[0].url).toEqual(url)); }); it('should display the OLX source of the block (when expanded)', async () => { @@ -80,11 +133,12 @@ describe('', () => { await waitFor(() => expect(screen.getByText(olxPart)).toBeInTheDocument()); }); - it('does not display "Edit OLX" button when the library is read-only', async () => { + it('does not display "Edit OLX" button and assets dropzone when the library is read-only', async () => { render(mockXBlockOLX.usageKeyHtml, mockContentLibrary.libraryIdReadOnly); const expandButton = await screen.findByRole('button', { name: /Advanced details/ }); fireEvent.click(expandButton); expect(screen.queryByRole('button', { name: /Edit OLX/ })).not.toBeInTheDocument(); + expect(screen.queryByText(/Drag and drop your file here or click to upload/)).not.toBeInTheDocument(); }); it('can edit the OLX', async () => { diff --git a/src/library-authoring/component-info/ComponentAdvancedInfo.tsx b/src/library-authoring/component-info/ComponentAdvancedInfo.tsx index 4039fb44e..96936747d 100644 --- a/src/library-authoring/component-info/ComponentAdvancedInfo.tsx +++ b/src/library-authoring/component-info/ComponentAdvancedInfo.tsx @@ -8,17 +8,17 @@ import { OverlayTrigger, Tooltip, } from '@openedx/paragon'; -import { FormattedMessage, FormattedNumber, useIntl } from '@edx/frontend-platform/i18n'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { LoadingSpinner } from '../../generic/Loading'; import { CodeEditor, EditorAccessor } from '../../generic/CodeEditor'; import { useLibraryContext } from '../common/context'; import { useUpdateXBlockOLX, - useXBlockAssets, useXBlockOLX, } from '../data/apiHooks'; import messages from './messages'; +import { ComponentAdvancedAssets } from './ComponentAdvancedAssets'; const ComponentAdvancedInfoInner: React.FC> = () => { const intl = useIntl(); @@ -31,7 +31,6 @@ const ComponentAdvancedInfoInner: React.FC> = () => { } const { data: olx, isLoading: isOLXLoading } = useXBlockOLX(usageKey); - const { data: assets, isLoading: areAssetsLoading } = useXBlockAssets(usageKey); const editorRef = React.useRef(undefined); const [isEditingOLX, setEditingOLX] = React.useState(false); const olxUpdater = useUpdateXBlockOLX(usageKey); @@ -101,15 +100,7 @@ const ComponentAdvancedInfoInner: React.FC> = () => { ); })()}

-
    - { areAssetsLoading ?
  • : null } - { assets?.map(a => ( -
  • - {a.path}{' '} - () -
  • - )) } -
+ ); }; diff --git a/src/library-authoring/component-info/messages.ts b/src/library-authoring/component-info/messages.ts index 1a77b3c0f..1c02d867f 100644 --- a/src/library-authoring/component-info/messages.ts +++ b/src/library-authoring/component-info/messages.ts @@ -11,6 +11,16 @@ const messages = defineMessages({ defaultMessage: 'Assets (Files)', description: 'Heading for files attached to the component', }, + advancedDetailsAssetsDeleteFileTitle: { + id: 'course-authoring.library-authoring.component.advanced.assets.delete-file-title', + defaultMessage: 'Delete File', + description: 'Title for confirmation dialog when deleting a file', + }, + advancedDetailsAssetsDeleteButton: { + id: 'course-authoring.library-authoring.component.advanced.assets.delete-btn', + defaultMessage: 'Delete this file', + description: 'screen reader description of the delete button for each static asset file', + }, advancedDetailsOLX: { id: 'course-authoring.library-authoring.component.advanced.olx', defaultMessage: 'OLX Source', diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index c47f4bdc5..06f8c50f0 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -444,6 +444,14 @@ export async function getXBlockAssets(usageKey: string): Promise<{ path: string; return data.files; } +/** + * Delete a single asset file + */ +// istanbul ignore next +export async function deleteXBlockAsset(usageKey: string, path: string): Promise { + await getAuthenticatedHttpClient().delete(getXBlockAssetsApiUrl(usageKey) + encodeURIComponent(path)); +} + /** * Get the collection metadata. */ diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index baba2bcfc..159fc8e6d 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -6,6 +6,7 @@ import { type Query, type QueryClient, } from '@tanstack/react-query'; +import { useCallback } from 'react'; import { getLibraryId } from '../../generic/key-utils'; import { @@ -42,6 +43,7 @@ import { updateComponentCollections, removeComponentsFromCollection, publishXBlock, + deleteXBlockAsset, } from './api'; export const libraryQueryPredicate = (query: Query, libraryId: string): boolean => { @@ -397,6 +399,27 @@ export const useXBlockAssets = (usageKey: string) => ( }) ); +/** Refresh the list of assets (static files) attached to a library component */ +export const useInvalidateXBlockAssets = (usageKey: string) => { + const client = useQueryClient(); + return useCallback(() => { + client.invalidateQueries({ queryKey: xblockQueryKeys.xblockAssets(usageKey) }); + }, [usageKey]); +}; + +/** + * Use this mutation to delete an asset file from a library + */ +export const useDeleteXBlockAsset = (usageKey: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (path: string) => deleteXBlockAsset(usageKey, path), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: xblockQueryKeys.xblockAssets(usageKey) }); + }, + }); +}; + /** * Get the metadata for a collection in a library */