Skip to content

Commit

Permalink
Merge pull request #230 from Flowpack/feature/190-dnd-collections
Browse files Browse the repository at this point in the history
FEATURE: Enable drag & drop for asset collections
  • Loading branch information
Sebobo authored Feb 12, 2024
2 parents c3880d9 + b08ae00 commit e882d1c
Show file tree
Hide file tree
Showing 12 changed files with 209 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 (
<AssetCollectionTreeNode assetCollectionId={node.contextPath} level={level} renderChildCollections={false} />
);
};

const AssetCollectionTree = () => {
const { translate } = useIntl();
Expand All @@ -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);
Expand Down Expand Up @@ -76,6 +89,11 @@ const AssetCollectionTree = () => {
</div>

<Tree className={classes.tree}>
<Tree.DragLayer
nodeDndType={dndTypes.COLLECTION}
ChildRenderer={DraggedNodeRenderer}
currentlyDraggedNodes={currentlyDraggedNodes.map((contextPath) => ({ contextPath }))}
/>
{assetCollectionTreeView === 'favourites' ? (
favouriteAssetCollections.map((assetCollection) => (
<AssetCollectionTreeNode
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import { assetCollectionFavouriteState } from '../state/assetCollectionFavourite
import { assetCollectionTreeCollapsedItemState } from '../state/assetCollectionTreeCollapsedState';
import { assetCollectionFocusedState } from '../state/assetCollectionFocusedState';
import { assetCollectionActiveState } from '../state/assetCollectionActiveState';
import { useAssetCollectionDnd } from '../provider/AssetCollectionTreeDndProvider';

export interface AssetCollectionTreeNodeProps extends TreeNodeProps {
assetCollectionId: string | null;
assetCollectionId?: AssetCollectionId;
renderChildCollections?: boolean;
children?: React.ReactNode;
}
Expand All @@ -37,11 +38,25 @@ const AssetCollectionTreeNode: React.FC<AssetCollectionTreeNodeProps> = ({
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
Expand All @@ -66,6 +81,9 @@ const AssetCollectionTreeNode: React.FC<AssetCollectionTreeNodeProps> = ({
/>
);

// TODO: Also check assetSource.readonly
const dragForbidden = !assetCollectionId || assetCollectionId === UNASSIGNED_COLLECTION_ID;

return (
<Tree.Node>
<Tree.Node.Header
Expand All @@ -81,6 +99,14 @@ const AssetCollectionTreeNode: React.FC<AssetCollectionTreeNodeProps> = ({
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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const TagTreeNode: React.FC<TagTreeNodeProps> = ({
icon={icon}
customIconComponent={customIconComponent}
nodeDndType={dndTypes.TAG}
dragForbidden={true}
level={level}
onClick={() => selectAssetCollectionAndTag({ tagId, assetCollectionId })}
hasChildren={false}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<string[]>([]);
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 (
<DndProvider backend={HTML5Backend}>
<AssetCollectionDndContext.Provider
value={{
currentlyDraggedNodes,
handleDrag,
handeEndDrag,
handleDrop,
acceptsDraggedNode,
}}
>
{children}
</AssetCollectionDndContext.Provider>
</DndProvider>
);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
type AssetCollectionType = 'AssetCollection';

type AssetCollectionId = string;

interface AssetCollection extends GraphQlEntity {

Check warning on line 5 in Resources/Private/JavaScript/asset-collections/typings/AssetCollection.ts

View workflow job for this annotation

GitHub Actions / lint

'AssetCollection' is defined but never used
__typename: AssetCollectionType;
readonly id: string;
readonly id: AssetCollectionId;
readonly title: string;
parent: {
readonly id: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -147,7 +147,9 @@ export class MediaDetailsScreen extends React.PureComponent<MediaDetailsScreenPr
containerRef={containerRef}
approvalAttainmentStrategyFactory={MediaDetailsScreenApprovalAttainmentStrategyFactory}
>
<Details buildLinkToMediaUi={buildLinkToMediaUi} />
<AssetCollectionTreeDndProvider>
<Details buildLinkToMediaUi={buildLinkToMediaUi} />
</AssetCollectionTreeDndProvider>
</MediaUiProvider>
</MediaApplicationWrapper>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 3 additions & 4 deletions Resources/Private/JavaScript/media-module/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
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';

// GraphQL type definitions
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';
Expand Down Expand Up @@ -72,9 +71,9 @@ window.onload = async (): Promise<void> => {
>
<ErrorBoundary>
<MediaUiProvider dummyImage={dummyImage} containerRef={containerRef}>
<DndProvider backend={HTML5Backend}>
<AssetCollectionTreeDndProvider>
<App />
</DndProvider>
</AssetCollectionTreeDndProvider>
</MediaUiProvider>
</ErrorBoundary>
</MediaApplicationWrapper>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,6 @@ export default function loadIconLibrary() {
faWeightHanging,
faFilter,
faSearch,
faBroom,
faBroom
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -161,7 +162,9 @@ class MediaSelectionScreen extends React.PureComponent<MediaSelectionScreenProps
isInNodeCreationDialog={isInNodeCreationDialog}
containerRef={containerRef}
>
<App />
<AssetCollectionTreeDndProvider>
<App />
</AssetCollectionTreeDndProvider>
</MediaUiProvider>
</MediaApplicationWrapper>
</div>
Expand Down

0 comments on commit e882d1c

Please sign in to comment.