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 <xnpiochv@gmail.com>
This commit is contained in:
Braden MacDonald
2024-10-24 06:46:27 -07:00
committed by GitHub
parent e1ce3eb484
commit 3d8d248599
6 changed files with 199 additions and 13 deletions

View File

@@ -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<Record<never, never>> = () => {
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<string>('');
const deleteFile = React.useCallback(() => {
deleter.mutateAsync(filePathToDelete); // Don't wait for this before clearing the modal on the next line
setConfirmDeleteAsset('');
}, [filePathToDelete, usageKey]);
return (
<>
<ul>
{ areAssetsLoading ? <li><LoadingSpinner /></li> : null }
{ assets?.map(a => (
<li key={a.path}>
<a href={a.url}>{a.path}</a>{' '}
(<FormattedNumber value={a.size} notation="compact" unit="byte" unitDisplay="narrow" />)
<Button variant="link" size="sm" iconBefore={Delete} onClick={() => { setConfirmDeleteAsset(a.path); }} title={intl.formatMessage(messages.advancedDetailsAssetsDeleteButton)}>
<span className="sr-only"><FormattedMessage {...messages.advancedDetailsAssetsDeleteButton} /></span>
</Button>
</li>
)) }
</ul>
{ assets !== undefined && !readOnly // Wait until assets have loaded before displaying add button:
? (
<Dropzone
style={{ height: '200px' }}
onProcessUpload={handleProcessUpload}
onUploadProgress={() => {}}
/>
)
: null }
<DeleteModal
isOpen={filePathToDelete !== ''}
close={() => { setConfirmDeleteAsset(''); }}
variant="warning"
title={intl.formatMessage(messages.advancedDetailsAssetsDeleteFileTitle)}
description={`Are you sure you want to delete ${filePathToDelete}?`}
onDeleteSubmit={deleteFile}
btnState="default"
/>
</>
);
};

View File

@@ -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('<ComponentAdvancedInfo />', () => {
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('<ComponentAdvancedInfo />', () => {
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 () => {

View File

@@ -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<Record<never, never>> = () => {
const intl = useIntl();
@@ -31,7 +31,6 @@ const ComponentAdvancedInfoInner: React.FC<Record<never, never>> = () => {
}
const { data: olx, isLoading: isOLXLoading } = useXBlockOLX(usageKey);
const { data: assets, isLoading: areAssetsLoading } = useXBlockAssets(usageKey);
const editorRef = React.useRef<EditorAccessor | undefined>(undefined);
const [isEditingOLX, setEditingOLX] = React.useState(false);
const olxUpdater = useUpdateXBlockOLX(usageKey);
@@ -101,15 +100,7 @@ const ComponentAdvancedInfoInner: React.FC<Record<never, never>> = () => {
);
})()}
<h3 className="h5"><FormattedMessage {...messages.advancedDetailsAssets} /></h3>
<ul>
{ areAssetsLoading ? <li><LoadingSpinner /></li> : null }
{ assets?.map(a => (
<li key={a.path}>
<a href={a.url}>{a.path}</a>{' '}
(<FormattedNumber value={a.size} notation="compact" unit="byte" unitDisplay="narrow" />)
</li>
)) }
</ul>
<ComponentAdvancedAssets />
</>
);
};

View File

@@ -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',

View File

@@ -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<void> {
await getAuthenticatedHttpClient().delete(getXBlockAssetsApiUrl(usageKey) + encodeURIComponent(path));
}
/**
* Get the collection metadata.
*/

View File

@@ -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
*/