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
-
+
+
+