From c879a50e95f0656ddb8708c128740be4dd4f1ba4 Mon Sep 17 00:00:00 2001 From: mfrachet Date: Mon, 18 Oct 2021 15:52:46 +0200 Subject: [PATCH] Bulk delete --- .../admin/src/hooks/useBulkRemoveAsset.js | 37 +++ .../upload/admin/src/hooks/useRemoveAsset.js | 12 +- .../admin/src/pages/App/MediaLibrary.js | 37 ++- .../pages/App/components/BulkDeleteButton.js | 60 ++++ .../src/pages/App/components/ListView.js | 12 +- .../App/components/tests/ListView.test.js | 268 ++++++++++++------ .../admin/src/utils/removeAssetQuery.js | 11 + 7 files changed, 339 insertions(+), 98 deletions(-) create mode 100644 packages/core/upload/admin/src/hooks/useBulkRemoveAsset.js create mode 100644 packages/core/upload/admin/src/pages/App/components/BulkDeleteButton.js create mode 100644 packages/core/upload/admin/src/utils/removeAssetQuery.js diff --git a/packages/core/upload/admin/src/hooks/useBulkRemoveAsset.js b/packages/core/upload/admin/src/hooks/useBulkRemoveAsset.js new file mode 100644 index 0000000000..df4d8a2664 --- /dev/null +++ b/packages/core/upload/admin/src/hooks/useBulkRemoveAsset.js @@ -0,0 +1,37 @@ +import { useMutation, useQueryClient } from 'react-query'; +import { useNotification } from '@strapi/helper-plugin'; +import { removeAssetRequest } from '../utils/removeAssetQuery'; + +const bulkRemoveQuery = assetIds => { + const promises = assetIds.map(assetId => removeAssetRequest(assetId)); + + return Promise.all(promises); +}; + +export const useBulkRemoveAsset = () => { + const toggleNotification = useNotification(); + const queryClient = useQueryClient(); + + const mutation = useMutation(bulkRemoveQuery, { + onSuccess: () => { + // Coupled with the cache of useAssets + queryClient.refetchQueries(['assets'], { active: true }); + queryClient.refetchQueries(['asset-count'], { active: true }); + + toggleNotification({ + type: 'success', + message: { + id: 'modal.remove.success-label', + defaultMessage: 'The asset has been successfully removed.', + }, + }); + }, + onError: error => { + toggleNotification({ type: 'warning', message: error.message }); + }, + }); + + const removeAssets = assetIds => mutation.mutateAsync(assetIds); + + return { ...mutation, removeAssets }; +}; diff --git a/packages/core/upload/admin/src/hooks/useRemoveAsset.js b/packages/core/upload/admin/src/hooks/useRemoveAsset.js index ba03ccca22..12d28d9722 100644 --- a/packages/core/upload/admin/src/hooks/useRemoveAsset.js +++ b/packages/core/upload/admin/src/hooks/useRemoveAsset.js @@ -1,16 +1,6 @@ import { useMutation, useQueryClient } from 'react-query'; import { useNotification } from '@strapi/helper-plugin'; -import { axiosInstance } from '../utils'; - -const removeAssetRequest = assetId => { - const endpoint = `/upload/files/${assetId}`; - - return axiosInstance({ - method: 'delete', - url: endpoint, - headers: {}, - }); -}; +import { removeAssetRequest } from '../utils/removeAssetQuery'; export const useRemoveAsset = onSuccess => { const toggleNotification = useNotification(); diff --git a/packages/core/upload/admin/src/pages/App/MediaLibrary.js b/packages/core/upload/admin/src/pages/App/MediaLibrary.js index 44c0ff4521..e5097f7b9f 100644 --- a/packages/core/upload/admin/src/pages/App/MediaLibrary.js +++ b/packages/core/upload/admin/src/pages/App/MediaLibrary.js @@ -25,6 +25,7 @@ import { SortPicker } from './components/SortPicker'; import { PaginationFooter } from '../../components/PaginationFooter'; import { useMediaLibraryPermissions } from '../../hooks/useMediaLibraryPermissions'; import { useAssetCount } from '../../hooks/useAssetCount'; +import { BulkDeleteButton } from './components/BulkDeleteButton'; const BoxWithHeight = styled(Box)` height: ${32 / 16}rem; @@ -51,14 +52,35 @@ export const MediaLibrary = () => { const [showUploadAssetDialog, setShowUploadAssetDialog] = useState(false); const [assetToEdit, setAssetToEdit] = useState(undefined); + const [selected, setSelected] = useState([]); const toggleUploadAssetDialog = () => setShowUploadAssetDialog(prev => !prev); useFocusWhenNavigate(); const loading = isLoadingPermissions || isLoading || isLoadingCount; - const assets = data?.results; + const selectAllAssets = () => { + if (selected.length > 0) { + setSelected([]); + } else { + setSelected((assets || []).map(({ id }) => id)); + } + }; + + const selectAsset = asset => { + const index = selected.indexOf(asset.id); + + if (index > -1) { + setSelected(prevSelected => [ + ...prevSelected.slice(0, index), + ...prevSelected.slice(index + 1), + ]); + } else { + setSelected(prevSelected => [...prevSelected, asset.id]); + } + }; + return (
@@ -107,6 +129,8 @@ export const MediaLibrary = () => { id: getTrad('bulk.select.label'), defaultMessage: 'Select all assets', })} + value={selected.length === assets?.length} + onChange={selectAllAssets} /> @@ -124,6 +148,10 @@ export const MediaLibrary = () => { /> + {selected.length > 0 && ( + setSelected([])} /> + )} + {loading && } {error && } {!canRead && } @@ -160,7 +188,12 @@ export const MediaLibrary = () => { )} {canRead && assets && assets.length > 0 && ( <> - + {data?.pagination && } )} diff --git a/packages/core/upload/admin/src/pages/App/components/BulkDeleteButton.js b/packages/core/upload/admin/src/pages/App/components/BulkDeleteButton.js new file mode 100644 index 0000000000..d36cda1389 --- /dev/null +++ b/packages/core/upload/admin/src/pages/App/components/BulkDeleteButton.js @@ -0,0 +1,60 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; +import { Subtitle } from '@strapi/parts/Text'; +import { Button } from '@strapi/parts/Button'; +import { Stack } from '@strapi/parts/Stack'; +import DeleteIcon from '@strapi/icons/DeleteIcon'; +import { ConfirmDialog } from '@strapi/helper-plugin'; +import { useBulkRemoveAsset } from '../../../hooks/useBulkRemoveAsset'; +import getTrad from '../../../utils/getTrad'; + +export const BulkDeleteButton = ({ assetIds, onSuccess }) => { + const { formatMessage } = useIntl(); + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + + const { isLoading, removeAssets } = useBulkRemoveAsset(); + + const handleConfirmRemove = async () => { + await removeAssets(assetIds); + onSuccess(); + }; + + return ( + <> + + + {formatMessage( + { + id: getTrad('list.assets.selected.plural'), + defaultMessage: '1 asset selected', + }, + { + number: assetIds.length, + } + )} + + + + + setShowConfirmDialog(false)} + onConfirm={handleConfirmRemove} + /> + + ); +}; + +BulkDeleteButton.propTypes = { + assetIds: PropTypes.arrayOf(PropTypes.number).isRequired, + onSuccess: PropTypes.func.isRequired, +}; diff --git a/packages/core/upload/admin/src/pages/App/components/ListView.js b/packages/core/upload/admin/src/pages/App/components/ListView.js index a4b195cbd6..ff4aab0352 100644 --- a/packages/core/upload/admin/src/pages/App/components/ListView.js +++ b/packages/core/upload/admin/src/pages/App/components/ListView.js @@ -8,11 +8,13 @@ import { VideoAssetCard } from '../../../components/AssetCard/VideoAssetCard'; import { DocAssetCard } from '../../../components/AssetCard/DocAssetCard'; import { AssetType } from '../../../constants'; -export const ListView = ({ assets, onEditAsset }) => { +export const ListView = ({ assets, onEditAsset, onSelectAsset, selectedAssets }) => { return ( {assets.map(asset => { + const isSelected = (selectedAssets || []).indexOf(asset.id) > -1; + if (asset.mime.includes(AssetType.Video)) { return ( { url={prefixFileUrlWithBackendUrl(asset.url)} mime={asset.mime} onEdit={() => onEditAsset(asset)} + onSelect={() => onSelectAsset(asset)} + selected={isSelected} /> ); } @@ -38,6 +42,8 @@ export const ListView = ({ assets, onEditAsset }) => { width={asset.width} thumbnail={prefixFileUrlWithBackendUrl(asset?.formats?.thumbnail?.url || asset.url)} onEdit={() => onEditAsset(asset)} + onSelect={() => onSelectAsset(asset)} + selected={isSelected} /> ); } @@ -49,6 +55,8 @@ export const ListView = ({ assets, onEditAsset }) => { name={asset.name} extension={getFileExtension(asset.ext)} onEdit={() => onEditAsset(asset)} + onSelect={() => onSelectAsset(asset)} + selected={isSelected} /> ); })} @@ -60,4 +68,6 @@ export const ListView = ({ assets, onEditAsset }) => { ListView.propTypes = { assets: PropTypes.arrayOf(PropTypes.shape({})).isRequired, onEditAsset: PropTypes.func.isRequired, + onSelectAsset: PropTypes.func.isRequired, + selectedAssets: PropTypes.arrayOf(PropTypes.number).isRequired, }; diff --git a/packages/core/upload/admin/src/pages/App/components/tests/ListView.test.js b/packages/core/upload/admin/src/pages/App/components/tests/ListView.test.js index a35850617b..79e8aa558a 100644 --- a/packages/core/upload/admin/src/pages/App/components/tests/ListView.test.js +++ b/packages/core/upload/admin/src/pages/App/components/tests/ListView.test.js @@ -130,7 +130,7 @@ describe('MediaLibrary / ListView', () => { ); expect(container).toMatchInlineSnapshot(` - .c30 { + .c32 { border: 0; -webkit-clip: rect(0 0 0 0); clip: rect(0 0 0 0); @@ -148,7 +148,7 @@ describe('MediaLibrary / ListView', () => { grid-gap: 16px; } - .c5 { + .c7 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -162,21 +162,21 @@ describe('MediaLibrary / ListView', () => { outline: none; } - .c5 svg { + .c7 svg { height: 12px; width: 12px; } - .c5 svg > g, - .c5 svg path { + .c7 svg > g, + .c7 svg path { fill: #ffffff; } - .c5[aria-disabled='true'] { + .c7[aria-disabled='true'] { pointer-events: none; } - .c5:after { + .c7:after { -webkit-transition-property: all; transition-property: all; -webkit-transition-duration: 0.2s; @@ -191,11 +191,11 @@ describe('MediaLibrary / ListView', () => { border: 2px solid transparent; } - .c5:focus-visible { + .c7:focus-visible { outline: none; } - .c5:focus-visible:after { + .c7:focus-visible:after { border-radius: 8px; content: ''; position: absolute; @@ -206,7 +206,7 @@ describe('MediaLibrary / ListView', () => { border: 2px solid #4945ff; } - .c6 { + .c8 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -223,26 +223,26 @@ describe('MediaLibrary / ListView', () => { width: 2rem; } - .c6 svg > g, - .c6 svg path { + .c8 svg > g, + .c8 svg path { fill: #8e8ea9; } - .c6:hover svg > g, - .c6:hover svg path { + .c8:hover svg > g, + .c8:hover svg path { fill: #666687; } - .c6:active svg > g, - .c6:active svg path { + .c8:active svg > g, + .c8:active svg path { fill: #a5a5ba; } - .c6[aria-disabled='true'] { + .c8[aria-disabled='true'] { background-color: #eaeaef; } - .c6[aria-disabled='true'] svg path { + .c8[aria-disabled='true'] svg path { fill: #666687; } @@ -252,21 +252,21 @@ describe('MediaLibrary / ListView', () => { box-shadow: 0px 1px 4px rgba(33,33,52,0.1); } - .c9 { + .c11 { padding-top: 8px; padding-right: 12px; padding-bottom: 8px; padding-left: 12px; } - .c16 { + .c18 { background: #f6f6f9; color: #666687; padding: 4px; border-radius: 4px; } - .c24 { + .c26 { background: #32324d; color: #ffffff; padding: 4px; @@ -274,6 +274,12 @@ describe('MediaLibrary / ListView', () => { } .c4 { + position: absolute; + top: 12px; + left: 12px; + } + + .c6 { position: absolute; top: 12px; right: 12px; @@ -297,7 +303,7 @@ describe('MediaLibrary / ListView', () => { align-items: center; } - .c10 { + .c12 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -311,7 +317,7 @@ describe('MediaLibrary / ListView', () => { align-items: flex-start; } - .c22 { + .c24 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -325,14 +331,14 @@ describe('MediaLibrary / ListView', () => { align-items: center; } - .c8 { + .c10 { margin: 0; padding: 0; max-height: 100%; max-width: 100%; } - .c7 { + .c9 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -346,62 +352,129 @@ describe('MediaLibrary / ListView', () => { background: repeating-conic-gradient(#f6f6f9 0% 25%,transparent 0% 50%) 50% / 20px 20px; } - .c12 { + .c14 { font-weight: 500; font-size: 0.75rem; line-height: 1.33; color: #32324d; } - .c13 { + .c15 { font-weight: 400; font-size: 0.75rem; line-height: 1.33; color: #666687; } - .c19 { + .c21 { font-weight: 400; font-size: 0.875rem; line-height: 1.43; color: #32324d; } - .c26 { + .c28 { font-weight: 400; font-size: 0.75rem; line-height: 1.33; color: #ffffff; } - .c20 { + .c22 { font-weight: 600; line-height: 1.14; } - .c21 { + .c23 { font-weight: 600; font-size: 0.6875rem; line-height: 1.45; text-transform: uppercase; } - .c17 { + .c19 { display: inline-block; } - .c15 { + .c17 { margin-left: auto; -webkit-flex-shrink: 0; -ms-flex-negative: 0; flex-shrink: 0; } - .c18 { + .c20 { margin-left: 4px; } - .c11 { + .c5 { + margin: 0; + height: 18px; + min-width: 18px; + border-radius: 4px; + border: 1px solid #c0c0cf; + -webkit-appearance: none; + background-color: #ffffff; + } + + .c5:checked { + background-color: #4945ff; + border: 1px solid #4945ff; + } + + .c5:checked:after { + content: ''; + display: block; + position: relative; + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAiIGhlaWdodD0iOCIgdmlld0JveD0iMCAwIDEwIDgiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGgKICAgIGQ9Ik04LjU1MzIzIDAuMzk2OTczQzguNjMxMzUgMC4zMTYzNTUgOC43NjA1MSAwLjMxNTgxMSA4LjgzOTMxIDAuMzk1NzY4TDkuODYyNTYgMS40MzQwN0M5LjkzODkzIDEuNTExNTcgOS45MzkzNSAxLjYzNTkgOS44NjM0OSAxLjcxMzlMNC4wNjQwMSA3LjY3NzI0QzMuOTg1OSA3Ljc1NzU1IDMuODU3MDcgNy43NTgwNSAzLjc3ODM0IDcuNjc4MzRMMC4xMzg2NiAzLjk5MzMzQzAuMDYxNzc5OCAzLjkxNTQ5IDAuMDYxNzEwMiAzLjc5MDMyIDAuMTM4NTA0IDMuNzEyNEwxLjE2MjEzIDIuNjczNzJDMS4yNDAzOCAyLjU5NDMyIDEuMzY4NDMgMi41OTQyMiAxLjQ0NjggMi42NzM0OEwzLjkyMTc0IDUuMTc2NDdMOC41NTMyMyAwLjM5Njk3M1oiCiAgICBmaWxsPSJ3aGl0ZSIKICAvPgo8L3N2Zz4=) no-repeat no-repeat center center; + width: 10px; + height: 10px; + left: 50%; + top: 50%; + -webkit-transform: translateX(-50%) translateY(-50%); + -ms-transform: translateX(-50%) translateY(-50%); + transform: translateX(-50%) translateY(-50%); + } + + .c5:checked:disabled:after { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAiIGhlaWdodD0iOCIgdmlld0JveD0iMCAwIDEwIDgiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGgKICAgIGQ9Ik04LjU1MzIzIDAuMzk2OTczQzguNjMxMzUgMC4zMTYzNTUgOC43NjA1MSAwLjMxNTgxMSA4LjgzOTMxIDAuMzk1NzY4TDkuODYyNTYgMS40MzQwN0M5LjkzODkzIDEuNTExNTcgOS45MzkzNSAxLjYzNTkgOS44NjM0OSAxLjcxMzlMNC4wNjQwMSA3LjY3NzI0QzMuOTg1OSA3Ljc1NzU1IDMuODU3MDcgNy43NTgwNSAzLjc3ODM0IDcuNjc4MzRMMC4xMzg2NiAzLjk5MzMzQzAuMDYxNzc5OCAzLjkxNTQ5IDAuMDYxNzEwMiAzLjc5MDMyIDAuMTM4NTA0IDMuNzEyNEwxLjE2MjEzIDIuNjczNzJDMS4yNDAzOCAyLjU5NDMyIDEuMzY4NDMgMi41OTQyMiAxLjQ0NjggMi42NzM0OEwzLjkyMTc0IDUuMTc2NDdMOC41NTMyMyAwLjM5Njk3M1oiCiAgICBmaWxsPSIjOEU4RUE5IgogIC8+Cjwvc3ZnPg==) no-repeat no-repeat center center; + } + + .c5:disabled { + background-color: #dcdce4; + border: 1px solid #c0c0cf; + } + + .c5:indeterminate { + background-color: #4945ff; + border: 1px solid #4945ff; + } + + .c5:indeterminate:after { + content: ''; + display: block; + position: relative; + color: white; + height: 2px; + width: 10px; + background-color: #ffffff; + left: 50%; + top: 50%; + -webkit-transform: translateX(-50%) translateY(-50%); + -ms-transform: translateX(-50%) translateY(-50%); + transform: translateX(-50%) translateY(-50%); + } + + .c5:indeterminate:disabled { + background-color: #dcdce4; + border: 1px solid #c0c0cf; + } + + .c5:indeterminate:disabled:after { + background-color: #8e8ea9; + } + + .c13 { word-break: break-all; } @@ -410,31 +483,31 @@ describe('MediaLibrary / ListView', () => { border-bottom: 1px solid #eaeaef; } - .c25 { + .c27 { position: absolute; bottom: 4px; right: 4px; } - .c14 { + .c16 { text-transform: uppercase; } - .c23 canvas { + .c25 canvas { display: block; max-width: 100%; max-height: 10.25rem; } - .c27 { - text-transform: uppercase; - } - .c29 { text-transform: uppercase; } - .c28 svg { + .c31 { + text-transform: uppercase; + } + + .c30 svg { font-size: 3rem; } @@ -455,12 +528,21 @@ describe('MediaLibrary / ListView', () => { >
+ +
+

strapi-cover_1fabc982ce.png

png @@ -518,13 +600,13 @@ describe('MediaLibrary / ListView', () => {
Image @@ -543,12 +625,21 @@ describe('MediaLibrary / ListView', () => { >
+ +
+

mov_bbb.mp4

mp4
Video @@ -650,12 +741,21 @@ describe('MediaLibrary / ListView', () => { >
+ +
+
{

CARTE MARIAGE AVS - Printemps.pdf

pdf
Doc @@ -746,7 +846,7 @@ describe('MediaLibrary / ListView', () => {

{ + const endpoint = `/upload/files/${assetId}`; + + return axiosInstance({ + method: 'delete', + url: endpoint, + headers: {}, + }); +};