diff --git a/Resources/Private/JavaScript/asset-collections/src/components/AssetCollectionTree.tsx b/Resources/Private/JavaScript/asset-collections/src/components/AssetCollectionTree.tsx index 78836f4c5..8063a59e9 100644 --- a/Resources/Private/JavaScript/asset-collections/src/components/AssetCollectionTree.tsx +++ b/Resources/Private/JavaScript/asset-collections/src/components/AssetCollectionTree.tsx @@ -4,6 +4,7 @@ import { useRecoilState, useRecoilValue } from 'recoil'; import { Tree, SelectBox } from '@neos-project/react-ui-components'; import { useIntl } from '@media-ui/core'; +import dndTypes from '@media-ui/core/src/constants/dndTypes'; import { IconStack } from '@media-ui/core/src/components'; import useAssetCountQuery from '@media-ui/core/src/hooks/useAssetCountQuery'; import { useTagsQuery } from '@media-ui/feature-asset-tags'; @@ -21,6 +22,17 @@ import useAssetCollectionsQuery from '../hooks/useAssetCollectionsQuery'; import { UNASSIGNED_COLLECTION_ID } from '../hooks/useAssetCollectionQuery'; import classes from './AssetCollectionTree.module.css'; +import { useAssetCollectionDnd } from '../provider/AssetCollectionTreeDndProvider'; + +const DraggedNodeRenderer: React.FC<{ + node: { contextPath: AssetCollectionId }; + nodeDndType: string; + level: number; +}> = ({ node, level }) => { + return ( + + ); +}; const AssetCollectionTree = () => { const { translate } = useIntl(); @@ -30,6 +42,7 @@ const AssetCollectionTree = () => { const { assetCount: totalAssetCount } = useAssetCountQuery(true); const [assetCollectionTreeView, setAssetCollectionTreeViewState] = useRecoilState(assetCollectionTreeViewState); const favourites = useRecoilValue(assetCollectionFavouritesState); + const { currentlyDraggedNodes } = useAssetCollectionDnd(); const assetCollectionsIdWithoutParent = useMemo(() => { return assetCollections.filter((assetCollection) => !assetCollection.parent).map(({ id }) => id); @@ -76,6 +89,11 @@ const AssetCollectionTree = () => { + ({ contextPath }))} + /> {assetCollectionTreeView === 'favourites' ? ( favouriteAssetCollections.map((assetCollection) => ( = ({ const isFavourite = useRecoilValue(assetCollectionFavouriteState(assetCollectionId)); const isActive = useRecoilValue(assetCollectionActiveState(assetCollectionId)); + const { currentlyDraggedNodes, handeEndDrag, handleDrag, handleDrop, acceptsDraggedNode } = useAssetCollectionDnd(); + const handleClick = useCallback(() => { selectAssetCollectionAndTag({ assetCollectionId, tagId: null }); setCollapsed(false); }, [assetCollectionId, selectAssetCollectionAndTag, setCollapsed]); + // Drag & drop specifics + const accepts = useCallback( + (mode) => acceptsDraggedNode(assetCollectionId, mode), + [assetCollectionId, acceptsDraggedNode] + ); + const handleNodeDrag = useCallback(() => handleDrag(assetCollectionId), [assetCollectionId, handleDrag]); + const handleNodeEndDrag = useCallback(() => handeEndDrag(), [handeEndDrag]); + const handleNodeDrop = useCallback( + (position) => handleDrop(assetCollectionId, position), + [assetCollectionId, handleDrop] + ); + const childCollectionIds = useMemo(() => { return ( assetCollections @@ -66,6 +81,9 @@ const AssetCollectionTreeNode: React.FC = ({ /> ); + // TODO: Also check assetSource.readonly + const dragForbidden = !assetCollectionId || assetCollectionId === UNASSIGNED_COLLECTION_ID; + return ( = ({ isHiddenInIndex={assetCollection?.assetCount === 0} customIconComponent={CollectionIcon} nodeDndType={dndTypes.COLLECTION} + isDragging={currentlyDraggedNodes.includes(assetCollectionId)} + dragAndDropContext={{ + onDrag: handleNodeDrag, + onEndDrag: handleNodeEndDrag, + onDrop: handleNodeDrop, + accepts, + }} + dragForbidden={dragForbidden} level={level} onToggle={() => setCollapsed(!collapsed)} onClick={handleClick} diff --git a/Resources/Private/JavaScript/asset-collections/src/components/TagTreeNode.tsx b/Resources/Private/JavaScript/asset-collections/src/components/TagTreeNode.tsx index 86e24b8fd..000940829 100644 --- a/Resources/Private/JavaScript/asset-collections/src/components/TagTreeNode.tsx +++ b/Resources/Private/JavaScript/asset-collections/src/components/TagTreeNode.tsx @@ -50,6 +50,7 @@ const TagTreeNode: React.FC = ({ icon={icon} customIconComponent={customIconComponent} nodeDndType={dndTypes.TAG} + dragForbidden={true} level={level} onClick={() => selectAssetCollectionAndTag({ tagId, assetCollectionId })} hasChildren={false} diff --git a/Resources/Private/JavaScript/asset-collections/src/helpers/collectionPath.ts b/Resources/Private/JavaScript/asset-collections/src/helpers/collectionPath.ts index 66eb9f5cc..d83251743 100644 --- a/Resources/Private/JavaScript/asset-collections/src/helpers/collectionPath.ts +++ b/Resources/Private/JavaScript/asset-collections/src/helpers/collectionPath.ts @@ -1,13 +1,35 @@ export function collectionPath(collection: AssetCollection, collections: AssetCollection[]) { const path: { title: string; id: string }[] = []; + const idsInPath = []; // Build the absolute path from the given collection to the root let parentCollection = collection; while (parentCollection) { + if (idsInPath.includes(parentCollection.id)) { + throw new Error('Circular reference detected in collection path'); + } path.push({ title: parentCollection.title, id: parentCollection.id }); + idsInPath.push(parentCollection.id); parentCollection = parentCollection.parent ? collections.find(({ id }) => id === parentCollection.parent.id) : null; } return path.reverse(); } + +export function isChildOfCollection( + collection: AssetCollection, + parentId: AssetCollectionId, + collections: AssetCollection[] +): boolean { + let parentCollection = collection; + while (parentCollection) { + if (parentCollection.id === parentId) { + return true; + } + parentCollection = parentCollection.parent + ? collections.find(({ id }) => id === parentCollection.parent.id) + : null; + } + return false; +} diff --git a/Resources/Private/JavaScript/asset-collections/src/provider/AssetCollectionTreeDndProvider.tsx b/Resources/Private/JavaScript/asset-collections/src/provider/AssetCollectionTreeDndProvider.tsx new file mode 100644 index 000000000..fff9bdb6d --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/src/provider/AssetCollectionTreeDndProvider.tsx @@ -0,0 +1,122 @@ +import React, { useCallback, useState, createContext, useContext } from 'react'; +import { DndProvider } from 'react-dnd'; +import HTML5Backend from 'react-dnd-html5-backend'; + +import { useIntl, useNotify } from '@media-ui/core'; + +import { useSetAssetCollectionParent } from '../hooks/useSetAssetCollectionParent'; +import useAssetCollectionsQuery from '../hooks/useAssetCollectionsQuery'; +import { UNASSIGNED_COLLECTION_ID } from '../hooks/useAssetCollectionQuery'; +import { isChildOfCollection } from '../helpers/collectionPath'; + +interface AssetCollectionTreeDndProviderProps { + children: React.ReactElement; +} + +interface AssetCollectionTreeDndProviderValues { + currentlyDraggedNodes: string[]; + handleDrag: (assetCollectionId: string) => void; + handeEndDrag: () => void; + handleDrop: (targetAssetCollectionId: string, position: number) => void; + acceptsDraggedNode: (assetCollectionId: AssetCollectionId, mode: 'into' | 'after') => boolean; +} + +export const AssetCollectionDndContext = createContext(null); +export const useAssetCollectionDnd = (): AssetCollectionTreeDndProviderValues => useContext(AssetCollectionDndContext); + +export function AssetCollectionTreeDndProvider({ children }: AssetCollectionTreeDndProviderProps) { + const { translate } = useIntl(); + const Notify = useNotify(); + const { assetCollections } = useAssetCollectionsQuery(); + const [currentlyDraggedNodes, setCurrentlyDraggedNodes] = useState([]); + const { setAssetCollectionParent } = useSetAssetCollectionParent(); + + const handleDrag = useCallback( + (assetCollectionId: string) => { + setCurrentlyDraggedNodes([assetCollectionId]); + }, + [setCurrentlyDraggedNodes] + ); + + const handeEndDrag = useCallback(() => { + setCurrentlyDraggedNodes([]); + }, [setCurrentlyDraggedNodes]); + + const handleDrop = useCallback( + (targetAssetCollectionId: string, position: 'before' | 'into') => { + const targetAssetCollection = assetCollections.find(({ id }) => id === targetAssetCollectionId); + const draggedAssetCollections = currentlyDraggedNodes.map((draggedId) => + assetCollections.find(({ id }) => id === draggedId) + ); + + const targetAssetCollectionParent = targetAssetCollection.parent?.id + ? assetCollections.find(({ id }) => id === targetAssetCollection.parent?.id) + : null; + const targetParentCollection = position === 'into' ? targetAssetCollection : targetAssetCollectionParent; + + draggedAssetCollections.forEach((draggedAssetCollection: AssetCollection) => { + if (targetParentCollection?.id !== draggedAssetCollection.parent?.id) { + setAssetCollectionParent({ + assetCollection: draggedAssetCollection, + parent: targetParentCollection, + }) + .then(() => { + Notify.ok( + translate( + 'ParentCollectionSelectBox.setParent.success', + 'The parent collection has been set' + ) + ); + }) + .catch(({ message }) => { + Notify.error( + translate( + 'ParentCollectionSelectBox.setParent.error', + 'Error while setting the parent collection' + ), + message + ); + }); + } + }); + + setCurrentlyDraggedNodes([]); + }, + [Notify, assetCollections, currentlyDraggedNodes, setAssetCollectionParent, setCurrentlyDraggedNodes, translate] + ); + + const acceptsDraggedNode = useCallback( + (assetCollectionId: AssetCollectionId, mode: 'into' | 'after') => { + if (currentlyDraggedNodes.length === 0 || currentlyDraggedNodes.includes(assetCollectionId)) return false; + + // TODO: Also check current assetSource.readonly property + const canBeInsertedInto = assetCollectionId && assetCollectionId !== UNASSIGNED_COLLECTION_ID; + const canBeInsertedAlongside = assetCollectionId && assetCollectionId !== UNASSIGNED_COLLECTION_ID; + const canBeInserted = mode === 'into' ? canBeInsertedInto : canBeInsertedAlongside; + if (!canBeInserted) return false; + + const assetCollection = assetCollections.find(({ id }) => id === assetCollectionId); + const createsRecursion = currentlyDraggedNodes.some((draggedAssetCollectionId) => { + return isChildOfCollection(assetCollection, draggedAssetCollectionId, assetCollections); + }); + return !createsRecursion; + }, + [assetCollections, currentlyDraggedNodes] + ); + + return ( + + + {children} + + + ); +} diff --git a/Resources/Private/JavaScript/asset-collections/typings/AssetCollection.ts b/Resources/Private/JavaScript/asset-collections/typings/AssetCollection.ts index 62ec7bd50..72ef20661 100644 --- a/Resources/Private/JavaScript/asset-collections/typings/AssetCollection.ts +++ b/Resources/Private/JavaScript/asset-collections/typings/AssetCollection.ts @@ -1,8 +1,10 @@ type AssetCollectionType = 'AssetCollection'; +type AssetCollectionId = string; + interface AssetCollection extends GraphQlEntity { __typename: AssetCollectionType; - readonly id: string; + readonly id: AssetCollectionId; readonly title: string; parent: { readonly id: string; diff --git a/Resources/Private/JavaScript/media-details-screen/src/MediaDetailsScreen.tsx b/Resources/Private/JavaScript/media-details-screen/src/MediaDetailsScreen.tsx index 3844a932d..6a0ab9d7f 100755 --- a/Resources/Private/JavaScript/media-details-screen/src/MediaDetailsScreen.tsx +++ b/Resources/Private/JavaScript/media-details-screen/src/MediaDetailsScreen.tsx @@ -10,14 +10,14 @@ import { neos } from '@neos-project/neos-ui-decorators'; import { actions } from '@neos-project/neos-ui-redux-store'; // Media UI dependencies -// GraphQL type definitions -import { MediaUiProvider, typeDefs as TYPE_DEFS_CORE } from '@media-ui/core'; import MediaApplicationWrapper from '@media-ui/core/src/components/MediaApplicationWrapper'; +import { AssetCollectionTreeDndProvider } from '@media-ui/feature-asset-collections/src/provider/AssetCollectionTreeDndProvider'; +import { MediaUiProvider, typeDefs as TYPE_DEFS_CORE } from '@media-ui/core'; import { CacheFactory, createErrorHandler } from '@media-ui/media-module/src/core'; -import { Details } from './components'; import { typeDefs as TYPE_DEFS_ASSET_USAGE } from '@media-ui/feature-asset-usage'; -// GraphQL local resolvers +// Package local dependencies +import { Details } from './components'; import { MediaDetailsScreenApprovalAttainmentStrategyFactory } from './strategy'; import classes from './MediaDetailsScreen.module.css'; @@ -147,7 +147,9 @@ export class MediaDetailsScreen extends React.PureComponent -
+ +
+ diff --git a/Resources/Private/JavaScript/media-details-screen/src/components/Details.tsx b/Resources/Private/JavaScript/media-details-screen/src/components/Details.tsx index 968991756..0a2798598 100644 --- a/Resources/Private/JavaScript/media-details-screen/src/components/Details.tsx +++ b/Resources/Private/JavaScript/media-details-screen/src/components/Details.tsx @@ -5,8 +5,6 @@ import cx from 'classnames'; import { InteractionDialogRenderer, useMediaUi } from '@media-ui/core'; import { useAssetQuery } from '@media-ui/core/src/hooks'; import { AssetUsagesModal, assetUsageDetailsModalState } from '@media-ui/feature-asset-usage'; -import { ClipboardWatcher } from '@media-ui/feature-clipboard'; -import { ConcurrentChangeMonitor } from '@media-ui/feature-concurrent-editing'; import { SimilarAssetsModal, similarAssetsModalState } from '@media-ui/feature-similar-assets'; import { uploadDialogState } from '@media-ui/feature-asset-upload/src/state'; import { UploadDialog } from '@media-ui/feature-asset-upload/src/components'; diff --git a/Resources/Private/JavaScript/media-details-screen/src/components/PreviewActions.tsx b/Resources/Private/JavaScript/media-details-screen/src/components/PreviewActions.tsx index 0fadf158a..57bf3ae21 100644 --- a/Resources/Private/JavaScript/media-details-screen/src/components/PreviewActions.tsx +++ b/Resources/Private/JavaScript/media-details-screen/src/components/PreviewActions.tsx @@ -5,7 +5,7 @@ import { IconButton } from '@neos-project/react-ui-components'; import { useIntl } from '@media-ui/core'; import { clipboardItemState } from '@media-ui/feature-clipboard'; -import DownloadAssetButton from 'Resources/Private/JavaScript/media-module/src/components/Actions/DownloadAssetButton'; +import DownloadAssetButton from '@media-ui/media-module/src/components/Actions/DownloadAssetButton'; interface PreviewActionsProps { asset: Asset; diff --git a/Resources/Private/JavaScript/media-module/src/index.tsx b/Resources/Private/JavaScript/media-module/src/index.tsx index 71a2e80ac..1c60c245f 100644 --- a/Resources/Private/JavaScript/media-module/src/index.tsx +++ b/Resources/Private/JavaScript/media-module/src/index.tsx @@ -1,8 +1,6 @@ import React, { createRef } from 'react'; import { render } from 'react-dom'; import Modal from 'react-modal'; -import { DndProvider } from 'react-dnd'; -import HTML5Backend from 'react-dnd-html5-backend'; import { ApolloClient, ApolloLink } from '@apollo/client'; import { createUploadLink } from 'apollo-upload-client'; @@ -10,6 +8,7 @@ import { createUploadLink } from 'apollo-upload-client'; import { MediaUiProvider, typeDefs as TYPE_DEFS_CORE } from '@media-ui/core'; import MediaApplicationWrapper from '@media-ui/core/src/components/MediaApplicationWrapper'; import { typeDefs as TYPE_DEFS_ASSET_USAGE } from '@media-ui/feature-asset-usage'; +import { AssetCollectionTreeDndProvider } from '@media-ui/feature-asset-collections/src/provider/AssetCollectionTreeDndProvider'; // Internal dependencies import { CacheFactory, createErrorHandler } from './core'; @@ -72,9 +71,9 @@ window.onload = async (): Promise => { > - + - + , diff --git a/Resources/Private/JavaScript/media-module/src/lib/FontAwesome.ts b/Resources/Private/JavaScript/media-module/src/lib/FontAwesome.ts index ef5b856fd..cd80e04c5 100644 --- a/Resources/Private/JavaScript/media-module/src/lib/FontAwesome.ts +++ b/Resources/Private/JavaScript/media-module/src/lib/FontAwesome.ts @@ -129,6 +129,6 @@ export default function loadIconLibrary() { faWeightHanging, faFilter, faSearch, - faBroom, + faBroom ); } diff --git a/Resources/Private/JavaScript/media-selection-screen/src/MediaSelectionScreen.tsx b/Resources/Private/JavaScript/media-selection-screen/src/MediaSelectionScreen.tsx index da5261cd8..d096c0290 100755 --- a/Resources/Private/JavaScript/media-selection-screen/src/MediaSelectionScreen.tsx +++ b/Resources/Private/JavaScript/media-selection-screen/src/MediaSelectionScreen.tsx @@ -19,6 +19,7 @@ import MediaApplicationWrapper from '@media-ui/core/src/components/MediaApplicat import { CacheFactory, createErrorHandler } from '@media-ui/media-module/src/core'; import App from '@media-ui/media-module/src/components/App'; import { typeDefs as TYPE_DEFS_ASSET_USAGE } from '@media-ui/feature-asset-usage'; +import { AssetCollectionTreeDndProvider } from '@media-ui/feature-asset-collections/src/provider/AssetCollectionTreeDndProvider'; import classes from './MediaSelectionScreen.module.css'; @@ -161,7 +162,9 @@ class MediaSelectionScreen extends React.PureComponent - + + +