diff --git a/cypress/e2e/item/home/home.cy.ts b/cypress/e2e/item/home/home.cy.ts index 95a6fad8b..07d75c703 100644 --- a/cypress/e2e/item/home/home.cy.ts +++ b/cypress/e2e/item/home/home.cy.ts @@ -170,7 +170,7 @@ describe('Home', () => { ); // visit child - const { id: childChildId } = SAMPLE_ITEMS.items[2]; + const { id: childChildId } = SAMPLE_ITEMS.items[3]; cy.goToItemInGrid(childChildId); // expect no children @@ -225,7 +225,7 @@ describe('Home', () => { }); // visit child - const { id: childChildId } = SAMPLE_ITEMS.items[2]; + const { id: childChildId } = SAMPLE_ITEMS.items[3]; cy.goToItemInList(childChildId); // expect no children diff --git a/cypress/e2e/item/move/gridMoveItem.cy.ts b/cypress/e2e/item/move/gridMoveItem.cy.ts index ca712849f..d8216818a 100644 --- a/cypress/e2e/item/move/gridMoveItem.cy.ts +++ b/cypress/e2e/item/move/gridMoveItem.cy.ts @@ -1,6 +1,5 @@ import { HOME_PATH, buildItemPath } from '../../../../src/config/paths'; import { - HOME_MODAL_ITEM_ID, ITEM_MENU_MOVE_BUTTON_CLASS, buildItemMenu, buildItemMenuButtonId, @@ -51,7 +50,7 @@ describe('Move Item in Grid', () => { // move const { id: movedItem } = SAMPLE_ITEMS.items[2]; - const { id: toItem, path: toItemPath } = SAMPLE_ITEMS.items[3]; + const { id: toItem, path: toItemPath } = SAMPLE_ITEMS.items[1]; moveItem({ id: movedItem, toItemPath }); cy.wait('@moveItems').then(({ request: { body, url } }) => { @@ -70,8 +69,7 @@ describe('Move Item in Grid', () => { // move const { id: movedItem } = SAMPLE_ITEMS.items[2]; - const toItem = HOME_MODAL_ITEM_ID; - moveItem({ id: movedItem, toItemPath: toItem }); + moveItem({ id: movedItem, toItemPath: 'selectionModalMyGraasp' }); cy.wait('@moveItems').then(({ request: { body, url } }) => { expect(body.parentId).to.equal(undefined); diff --git a/cypress/e2e/item/move/listMoveItem.cy.ts b/cypress/e2e/item/move/listMoveItem.cy.ts index e826e443a..46f48a4e1 100644 --- a/cypress/e2e/item/move/listMoveItem.cy.ts +++ b/cypress/e2e/item/move/listMoveItem.cy.ts @@ -1,14 +1,24 @@ import { HOME_PATH, buildItemPath } from '../../../../src/config/paths'; import { - HOME_MODAL_ITEM_ID, ITEM_MENU_MOVE_BUTTON_CLASS, buildItemMenu, buildItemMenuButtonId, + buildItemRowArrowId, + buildNavigationModalItemId, } from '../../../../src/config/selectors'; import { ITEM_LAYOUT_MODES } from '../../../../src/enums'; import { SAMPLE_ITEMS } from '../../../fixtures/items'; import { TABLE_ITEM_RENDER_TIME } from '../../../support/constants'; +const openMoveModal = ({ id: movedItemId }: { id: string }) => { + const menuSelector = `#${buildItemMenuButtonId(movedItemId)}`; + cy.wait(TABLE_ITEM_RENDER_TIME); + cy.get(menuSelector).click(); + cy.get( + `#${buildItemMenu(movedItemId)} .${ITEM_MENU_MOVE_BUTTON_CLASS}`, + ).click(); +}; + const moveItem = ({ id: movedItemId, toItemPath, @@ -18,12 +28,7 @@ const moveItem = ({ toItemPath: string; rootId?: string; }) => { - const menuSelector = `#${buildItemMenuButtonId(movedItemId)}`; - cy.wait(TABLE_ITEM_RENDER_TIME); - cy.get(menuSelector).click(); - cy.get( - `#${buildItemMenu(movedItemId)} .${ITEM_MENU_MOVE_BUTTON_CLASS}`, - ).click(); + openMoveModal({ id: movedItemId }); cy.handleTreeMenu(toItemPath, rootId); }; @@ -57,7 +62,7 @@ describe('Move Item in List', () => { // move const { id: movedItem } = SAMPLE_ITEMS.items[2]; - const { id: toItem, path: toItemPath } = SAMPLE_ITEMS.items[3]; + const { id: toItem, path: toItemPath } = SAMPLE_ITEMS.items[1]; moveItem({ id: movedItem, toItemPath }); cy.wait('@moveItems').then(({ request: { body, url } }) => { @@ -66,6 +71,39 @@ describe('Move Item in List', () => { }); }); + it('cannot move inside self children', () => { + cy.setUpApi(SAMPLE_ITEMS); + const { id } = SAMPLE_ITEMS.items[0]; + + // go to children item + cy.visit(buildItemPath(id)); + + cy.switchMode(ITEM_LAYOUT_MODES.LIST); + + const { id: movedItemId } = SAMPLE_ITEMS.items[2]; + const parentId = SAMPLE_ITEMS.items[0].id; + const childId = SAMPLE_ITEMS.items[6].id; + openMoveModal({ id: movedItemId }); + // parent is disabled + cy.get(`#${buildNavigationModalItemId(parentId)} button`).should( + 'be.disabled', + ); + cy.get(`#${buildNavigationModalItemId(parentId)}`).trigger('mouseover'); + cy.get(`#${buildItemRowArrowId(parentId)}`).click(); + + // self is disabled + cy.get(`#${buildNavigationModalItemId(movedItemId)} button`).should( + 'be.disabled', + ); + cy.get(`#${buildNavigationModalItemId(movedItemId)}`).trigger('mouseover'); + cy.get(`#${buildItemRowArrowId(movedItemId)}`).click(); + + // inner child is disabled + cy.get(`#${buildNavigationModalItemId(childId)} button`).should( + 'be.disabled', + ); + }); + it('move item to Home', () => { cy.setUpApi(SAMPLE_ITEMS); const { id } = SAMPLE_ITEMS.items[0]; @@ -77,8 +115,7 @@ describe('Move Item in List', () => { // move const { id: movedItem } = SAMPLE_ITEMS.items[2]; - const toItem = HOME_MODAL_ITEM_ID; - moveItem({ id: movedItem, toItemPath: toItem }); + moveItem({ id: movedItem, toItemPath: 'selectionModalMyGraasp' }); cy.wait('@moveItems').then(({ request: { body, url } }) => { expect(body.parentId).to.equal(undefined); diff --git a/cypress/e2e/item/move/listMoveMultiple.cy.ts b/cypress/e2e/item/move/listMoveMultiple.cy.ts index e19b164d6..89672cd7f 100644 --- a/cypress/e2e/item/move/listMoveMultiple.cy.ts +++ b/cypress/e2e/item/move/listMoveMultiple.cy.ts @@ -1,6 +1,5 @@ import { HOME_PATH, buildItemPath } from '../../../../src/config/paths'; import { - HOME_MODAL_ITEM_ID, ITEMS_TABLE_MOVE_SELECTED_ITEMS_ID, buildItemsTableRowIdAttribute, } from '../../../../src/config/selectors'; @@ -55,7 +54,7 @@ describe('Move Items in List', () => { // move const itemIds = [SAMPLE_ITEMS.items[2].id, SAMPLE_ITEMS.items[4].id]; - const { id: toItem, path: toItemPath } = SAMPLE_ITEMS.items[3]; + const { id: toItem, path: toItemPath } = SAMPLE_ITEMS.items[1]; moveItems({ itemIds, toItemPath }); cy.wait('@moveItems').then(({ request: { body, url } }) => { @@ -75,8 +74,7 @@ describe('Move Items in List', () => { // move const itemIds = [SAMPLE_ITEMS.items[2].id, SAMPLE_ITEMS.items[4].id]; - const toItem = HOME_MODAL_ITEM_ID; - moveItems({ itemIds, toItemPath: toItem }); + moveItems({ itemIds, toItemPath: 'selectionModalMyGraasp' }); cy.wait('@moveItems').then(({ request: { body, url } }) => { expect(body.parentId).to.equal(undefined); diff --git a/cypress/fixtures/items.ts b/cypress/fixtures/items.ts index 056d4604b..87050f623 100644 --- a/cypress/fixtures/items.ts +++ b/cypress/fixtures/items.ts @@ -104,6 +104,15 @@ const sampleItems: DiscriminatedItem[] = [ hasThumbnail: false, }, }, + { + ...DEFAULT_FOLDER_ITEM, + id: 'eef09f5a-5688-11eb-ae93-0242ac130003', + name: 'own_item_name7', + path: 'ecafbd2a_5688_11eb_ae93_0242ac130002.fdf09f5a_5688_11eb_ae93_0242ac130003.eef09f5a_5688_11eb_ae93_0242ac130003', + settings: { + hasThumbnail: false, + }, + }, ]; export const SAMPLE_ITEMS: ApiConfig = { items: [ @@ -200,6 +209,20 @@ export const SAMPLE_ITEMS: ApiConfig = { }, ], }, + { + ...sampleItems[6], + memberships: [ + { + item: sampleItems[6], + permission: PermissionLevel.Admin, + member: MEMBERS.ANNA, + creator: MEMBERS.ANNA, + createdAt: '2021-08-11T12:56:36.834Z', + updatedAt: '2021-08-11T12:56:36.834Z', + id: '2dd4caf9-538a-317a-86d3-99432b223c12', + }, + ], + }, ], // memberships: [], }; diff --git a/cypress/support/commands/item.ts b/cypress/support/commands/item.ts index a9c751d33..5ef6219a9 100644 --- a/cypress/support/commands/item.ts +++ b/cypress/support/commands/item.ts @@ -14,9 +14,9 @@ import { SHARE_ITEM_EMAIL_INPUT_ID, SHARE_ITEM_SHARE_BUTTON_ID, TREE_MODAL_CONFIRM_BUTTON_ID, - buildHomeModalItemID, buildItemFormAppOptionId, buildItemRowArrowId, + buildNavigationModalItemId, buildPermissionOptionId, buildTreeItemId, } from '../../../src/config/selectors'; @@ -58,7 +58,7 @@ Cypress.Commands.add( // click on the element if (idx === array.length - 1) { cy.wrap($tree) - .get(`#${buildHomeModalItemID(value)}`) + .get(`#${buildNavigationModalItemId(value)}`) .first() .click(); } @@ -68,7 +68,7 @@ Cypress.Commands.add( !$tree.find(`#${buildTreeItemId(array[idx + 1], treeRootId)}`).length ) { cy.wrap($tree) - .get(`#${buildHomeModalItemID(value)}`) + .get(`#${buildNavigationModalItemId(value)}`) .trigger('mouseover') .get(`#${buildItemRowArrowId(value)}`) .first() diff --git a/cypress/support/constants.ts b/cypress/support/constants.ts index 0918b8a78..6e8d5e1f6 100644 --- a/cypress/support/constants.ts +++ b/cypress/support/constants.ts @@ -20,7 +20,7 @@ export const REDIRECTION_TIME = 500; export const CAPTION_EDIT_PAUSE = 2000; export const ROW_HEIGHT = 48; -export const TABLE_ITEM_RENDER_TIME = 8000; +export const TABLE_ITEM_RENDER_TIME = 7000; export const TABLE_MEMBERSHIP_RENDER_TIME = 3000; export const FIXTURES_THUMBNAILS_FOLDER = './thumbnails'; export const CHATBOX_LOADING_TIME = 5000; diff --git a/src/components/common/MoveButton.tsx b/src/components/common/MoveButton.tsx index 31651d3d2..aed3493e2 100644 --- a/src/components/common/MoveButton.tsx +++ b/src/components/common/MoveButton.tsx @@ -8,18 +8,21 @@ import { MoveButton as GraaspMoveButton, } from '@graasp/ui'; -import { validate } from 'uuid'; - -import { mutations } from '@/config/queryClient'; +import { hooks, mutations } from '@/config/queryClient'; +import { applyEllipsisOnLength, getDirectParentId } from '@/utils/item'; import { useBuilderTranslation } from '../../config/i18n'; import { - HOME_MODAL_ITEM_ID, ITEM_MENU_MOVE_BUTTON_CLASS, ITEM_MOVE_BUTTON_CLASS, } from '../../config/selectors'; import { BUILDER } from '../../langs/constants'; -import TreeModal, { TreeModalProps } from '../main/MoveTreeModal'; +import { NavigationElement } from '../main/itemSelectionModal/Breadcrumbs'; +import ItemSelectionModal, { + ItemSelectionModalProps, +} from '../main/itemSelectionModal/ItemSelectionModal'; + +const TITLE_MAX_NAME_LENGTH = 15; type MoveButtonProps = { itemIds: string[]; @@ -42,6 +45,8 @@ const MoveButton = ({ const [open, setOpen] = useState(false); const [itemIds, setItemIds] = useState(defaultItemsIds || []); + const { data: items } = hooks.useItems(itemIds); + const openMoveModal = (newItemIds: string[]) => { setOpen(true); setItemIds(newItemIds); @@ -51,14 +56,11 @@ const MoveButton = ({ setOpen(false); }; - const onConfirm: TreeModalProps['onConfirm'] = (payload) => { + const onConfirm: ItemSelectionModalProps['onConfirm'] = (payload) => { // change item's root id to null const newPayload = { - ...payload, - to: - payload.to && payload.to !== HOME_MODAL_ITEM_ID && validate(payload.to) - ? payload.to - : undefined, + ids: itemIds, + to: payload, }; moveItems(newPayload); onClose(); @@ -73,6 +75,47 @@ const MoveButton = ({ onClick?.(); }; + const isDisabled = (item: NavigationElement, homeId: string) => { + if (items?.data) { + // cannot move inside self and below + const moveInSelf = Object.values(items.data).some((i) => + item.path.includes(i.path), + ); + + // cannot move in same direct parent + // todo: not opti because we only have the ids from the table + const directParentIds = Object.values(items.data).map((i) => + getDirectParentId(i.path), + ); + const moveInDirectParent = directParentIds.includes(item.id); + + // cannot move to home if was already on home + let moveToHome = false; + + if (items?.data) { + moveToHome = + item.id === homeId && + !getDirectParentId(Object.values(items.data)[0].path); + } + return moveInSelf || moveInDirectParent || moveToHome; + } + return false; + }; + + const title = items + ? translateBuilder(BUILDER.MOVE_ITEM_MODAL_TITLE, { + name: applyEllipsisOnLength( + Object.values(items.data)[0].name, + TITLE_MAX_NAME_LENGTH, + ), + // -1 because we show one name + count: itemIds.length - 1, + }) + : translateBuilder(BUILDER.MOVE_ITEM_MODAL_TITLE); + + const buttonText = (name?: string) => + translateBuilder(BUILDER.MOVE_BUTTON, { name, count: name ? 1 : 0 }); + return ( <> - {itemIds.length > 0 && open && ( - )} diff --git a/src/components/main/MoveTreeModal.tsx b/src/components/main/MoveTreeModal.tsx deleted file mode 100644 index 68b93eeee..000000000 --- a/src/components/main/MoveTreeModal.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import React, { useState } from 'react'; - -import HomeIcon from '@mui/icons-material/Home'; -import { Breadcrumbs, Button, Stack } from '@mui/material'; -import Dialog from '@mui/material/Dialog'; -import DialogActions from '@mui/material/DialogActions'; -import DialogContent from '@mui/material/DialogContent'; -import DialogTitle from '@mui/material/DialogTitle'; - -import { DiscriminatedItem } from '@graasp/sdk'; - -import i18n, { useBuilderTranslation } from '../../config/i18n'; -import { hooks } from '../../config/queryClient'; -import { - HOME_MODAL_ITEM_ID, - ROOT_MODAL_ID, - TREE_MODAL_CONFIRM_BUTTON_ID, -} from '../../config/selectors'; -import { BUILDER } from '../../langs/constants'; -import CancelButton from '../common/CancelButton'; -import RootTreeModal from './RootTreeModal'; - -const dialogId = 'items-tree-modal'; - -export type TreeModalProps = { - onConfirm: (args: { ids: string[]; to?: string }) => void; - onClose: (args: { id: string | null; open: boolean }) => void; - title: string; - itemIds?: string[]; - open?: boolean; -}; - -const TreeModal = ({ - title, - onClose, - onConfirm, - open = false, - itemIds = [], -}: TreeModalProps): JSX.Element => { - const { t: translateBuilder } = useBuilderTranslation(); - console.log(i18n.language); - const [selectedId, setSelectedId] = useState(''); - // serious of breadcrumbs - const [breadcrumbs, setBreadcrumbs] = useState< - (DiscriminatedItem | { name: string; id: string })[] - >([{ name: translateBuilder(BUILDER.ROOT), id: ROOT_MODAL_ID }]); - - const { data: parentItem } = hooks.useItem( - selectedId === HOME_MODAL_ITEM_ID ? '' : selectedId, - ); - const { data: itemToMove } = hooks.useItem(itemIds[0]); - - const { data: parents } = hooks.useParents({ - id: itemIds?.[0], - }); - - const handleClose = () => { - onClose({ id: null, open: false }); - }; - const onClickConfirm = () => { - onConfirm({ ids: itemIds, to: selectedId }); - handleClose(); - }; - - // no selected item, or selectedId is same original location - const isDisabled = - !selectedId || - selectedId === ROOT_MODAL_ID || - (!parents?.length && selectedId === HOME_MODAL_ITEM_ID) || - selectedId === (parents && parents[parents.length - 1]?.id); - - const text = - !isDisabled && - translateBuilder(BUILDER.TO_FOLDER, { - name: - selectedId === HOME_MODAL_ITEM_ID - ? translateBuilder(BUILDER.HOME_TITLE) - : parentItem?.name || '', - }); - - return ( - - - {translateBuilder(title, { - name: - i18n.language === 'en' - ? `${itemToMove?.name.slice(0, 10)}${ - (itemToMove?.name.length || 0) > 10 ? '...' : '' - }` - : itemToMove?.name, - count: itemIds.length - 1, - })} - - - {breadcrumbs.length > 1 && ( - - - {breadcrumbs.map((ele) => ( - - ))} - - - )} - - - - - - - - - ); -}; - -export default TreeModal; diff --git a/src/components/main/RootTreeModal.tsx b/src/components/main/RootTreeModal.tsx deleted file mode 100644 index 130f1cdf4..000000000 --- a/src/components/main/RootTreeModal.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react'; - -import { Box } from '@mui/material'; - -import { DiscriminatedItem, ItemType } from '@graasp/sdk'; -import { theme } from '@graasp/ui'; - -import { useBuilderTranslation } from '@/config/i18n'; -import { hooks } from '@/config/queryClient'; -import { HOME_MODAL_ITEM_ID, ROOT_MODAL_ID } from '@/config/selectors'; -import { BUILDER } from '@/langs/constants'; - -import MoveMenuRow from './RowMenu'; - -interface RootTreeModalProps { - setBreadcrumbs: Dispatch< - SetStateAction<(DiscriminatedItem | { name: string; id: string })[]> - >; - setSelectedId: Dispatch>; - selectedId: string; - selectedParent: DiscriminatedItem | null | { name: string; id: string }; - itemIds: string[]; - parentItem?: DiscriminatedItem; -} - -const RootTreeModal = ({ - setBreadcrumbs, - setSelectedId, - selectedId, - selectedParent, - itemIds, - parentItem, -}: RootTreeModalProps): JSX.Element => { - const { data: ownItems } = hooks.useOwnItems(); - const { data: sharedItems } = hooks.useSharedItems(); - const { t: translateBuilder } = useBuilderTranslation(); - - const [isHome, setIsHome] = useState(true); - const { data } = hooks.useChildren(selectedParent?.id || ''); - - const selectSubItems = (ele: DiscriminatedItem) => { - setBreadcrumbs((breadcrumb) => [...breadcrumb, ele]); - setSelectedId(ele.id); - }; - - useEffect(() => { - if (selectedParent?.id === ROOT_MODAL_ID) { - setIsHome(true); - } - }, [selectedParent]); - const rootMenuItem = { - name: translateBuilder(BUILDER.HOME_TITLE), - id: HOME_MODAL_ITEM_ID, - type: ItemType.FOLDER, - } as DiscriminatedItem; - - const items = useMemo(() => { - const results = - selectedParent?.id === HOME_MODAL_ITEM_ID - ? [...(ownItems || []), ...(sharedItems || [])] - : data; - - return results?.filter( - (ele: DiscriminatedItem) => ele.type === ItemType.FOLDER, - ); - }, [selectedParent, ownItems, sharedItems, data]); - - return ( -
- {/* Home or Root Which will be the start of the menu */} - {isHome && ( - { - setIsHome(false); - setBreadcrumbs((breadcrumb) => [...breadcrumb, rootMenuItem]); - setSelectedId(rootMenuItem.id); - }} - selectedId={selectedId} - setSelectedId={setSelectedId} - itemIds={[]} - /> - )} - {/* end of home */} - - {/* Items for selected Item, So if I choose folder1 this should be it children */} - {!isHome && - items?.map((ele) => ( - selectSubItems(ele)} - selectedId={selectedId} - setSelectedId={setSelectedId} - itemIds={itemIds} - /> - ))} - - {/* Default Parent So If I want to move an item who's in nested folder we will have it's parent as default */} - - {parentItem && selectedParent?.id === ROOT_MODAL_ID && ( - { - selectSubItems(parentItem); - setIsHome(false); - setSelectedId(parentItem.id); - }} - selectedId={selectedId} - setSelectedId={setSelectedId} - itemIds={itemIds} - /> - )} - - {!isHome && !items?.length && ( - - {translateBuilder(BUILDER.EMPTY_FOLDER_CHILDREN_FOR_THIS_ITEM)} - - )} -
- ); -}; - -export default RootTreeModal; diff --git a/src/components/main/RowMenu.tsx b/src/components/main/RowMenu.tsx deleted file mode 100644 index f25e20d06..000000000 --- a/src/components/main/RowMenu.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React, { Dispatch, SetStateAction, useState } from 'react'; - -import FolderIcon from '@mui/icons-material/Folder'; -import HomeIcon from '@mui/icons-material/Home'; -import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; -import { Box, Button, IconButton, Typography, styled } from '@mui/material'; - -import { DiscriminatedItem, ItemType } from '@graasp/sdk'; - -import { - HOME_MODAL_ITEM_ID, - buildHomeModalItemID, - buildItemRowArrowId, -} from '@/config/selectors'; - -interface MenuRowProps { - ele: DiscriminatedItem; - onNavigate: () => void; - selectedId: string; - setSelectedId: Dispatch>; - itemIds: string[]; -} - -const StyledButton = styled(Button)<{ isSelected: boolean }>( - ({ theme, isSelected }) => ({ - display: 'flex', - color: theme.palette.text.primary, - width: '100%', - justifyContent: 'space-between', - marginBottom: theme.spacing(1), - background: isSelected ? theme.palette.grey[200] : 'none', - textTransform: 'none', - }), -); - -const isTreeItemDisabled = ({ - itemPath, - itemIds, -}: { - itemPath: string; - itemIds: string[]; -}) => { - const itemPaths = itemIds.map((ele) => ele.replaceAll('-', '_')); - - return itemPaths.some((path) => itemPath.includes(path)); -}; -const MoveMenuRow = ({ - ele, - onNavigate, - setSelectedId, - selectedId, - itemIds, -}: MenuRowProps): JSX.Element | null => { - const [isHoverActive, setIsHoverActive] = useState(false); - - const handleHover = () => { - setIsHoverActive(true); - }; - const handleUnhover = () => { - setIsHoverActive(false); - }; - - if (ele.type !== ItemType.FOLDER) { - return null; - } - return ( - { - setSelectedId(ele?.id); - }} - id={buildHomeModalItemID(ele.id)} - isSelected={selectedId === ele.id} - disabled={isTreeItemDisabled({ - itemPath: ele.path, - itemIds, - })} - > - - {ele.id === HOME_MODAL_ITEM_ID ? : } - - {ele.name} - - - {(isHoverActive || selectedId === ele.id) && ( - - - - - - )} - - ); -}; - -export default MoveMenuRow; diff --git a/src/components/main/itemSelectionModal/AccessibleNavigationTree.tsx b/src/components/main/itemSelectionModal/AccessibleNavigationTree.tsx new file mode 100644 index 000000000..094441ec5 --- /dev/null +++ b/src/components/main/itemSelectionModal/AccessibleNavigationTree.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react'; + +import { Pagination, Stack } from '@mui/material'; + +import { hooks } from '@/config/queryClient'; + +import RowMenu, { RowMenuProps } from './RowMenu'; + +interface AccessibleNavigationTreeProps { + isDisabled?: RowMenuProps['isDisabled']; + onClick: RowMenuProps['onClick']; + onNavigate: RowMenuProps['onNavigate']; + selectedId?: string; +} + +const PAGE_SIZE = 10; + +const AccessibleNavigationTree = ({ + isDisabled, + onClick, + onNavigate, + selectedId, +}: AccessibleNavigationTreeProps): JSX.Element => { + // todo: to change with real recent items (most used) + const [page, setPage] = useState(1); + // todo: show only items with admin rights + const { data: accessibleItems } = hooks.useAccessibleItems({}, { page }); + + const nbPages = accessibleItems + ? Math.ceil(accessibleItems.totalCount / PAGE_SIZE) + : 0; + + return ( + + + {accessibleItems?.data?.map((ele) => ( + + ))} + + + {nbPages > 1 && ( + setPage(p)} + /> + )} + + + ); +}; + +export default AccessibleNavigationTree; diff --git a/src/components/main/itemSelectionModal/Breadcrumbs.tsx b/src/components/main/itemSelectionModal/Breadcrumbs.tsx new file mode 100644 index 000000000..b444fa22f --- /dev/null +++ b/src/components/main/itemSelectionModal/Breadcrumbs.tsx @@ -0,0 +1,53 @@ +import { Button, Breadcrumbs as MuiBreadcrumbs } from '@mui/material'; + +import { applyEllipsisOnLength } from '@/utils/item'; + +const ROW_MAX_NAME_LENGTH = 15; + +export type NavigationElement = { + id: string; + name: string; + // important to have to compute if an element should be disabled + path: string; + icon?: JSX.Element; +}; + +type Props = { + onSelect: (el: NavigationElement) => void; + elements: NavigationElement[]; +}; + +const Breadcrumbs = ({ onSelect, elements }: Props): JSX.Element | null => { + if (!elements) { + return null; + } + + return ( + + {elements.map((ele) => ( + + ))} + + ); +}; + +export default Breadcrumbs; diff --git a/src/components/main/itemSelectionModal/ChildrenNavigationTree.tsx b/src/components/main/itemSelectionModal/ChildrenNavigationTree.tsx new file mode 100644 index 000000000..3bcf3f652 --- /dev/null +++ b/src/components/main/itemSelectionModal/ChildrenNavigationTree.tsx @@ -0,0 +1,48 @@ +import { Box } from '@mui/material'; + +import { useBuilderTranslation } from '@/config/i18n'; +import { hooks } from '@/config/queryClient'; +import { BUILDER } from '@/langs/constants'; + +import { NavigationElement } from './Breadcrumbs'; +import RowMenu, { RowMenuProps } from './RowMenu'; + +interface ChildrenNavigationTreeProps { + isDisabled?: RowMenuProps['isDisabled']; + selectedId?: string; + selectedNavigationItem: NavigationElement; + onClick: RowMenuProps['onClick']; + onNavigate: RowMenuProps['onNavigate']; +} + +const ChildrenNavigationTree = ({ + onClick, + selectedId, + selectedNavigationItem, + onNavigate, + isDisabled, +}: ChildrenNavigationTreeProps): JSX.Element => { + const { t: translateBuilder } = useBuilderTranslation(); + const { data: children } = hooks.useChildren(selectedNavigationItem.id); + return ( + <> + {children?.map((ele) => ( + + ))} + {!children?.length && ( + + {translateBuilder(BUILDER.EMPTY_FOLDER_CHILDREN_FOR_THIS_ITEM)} + + )} + + ); +}; + +export default ChildrenNavigationTree; diff --git a/src/components/main/itemSelectionModal/ItemSelectionModal.tsx b/src/components/main/itemSelectionModal/ItemSelectionModal.tsx new file mode 100644 index 000000000..cc6864bf2 --- /dev/null +++ b/src/components/main/itemSelectionModal/ItemSelectionModal.tsx @@ -0,0 +1,203 @@ +import { useState } from 'react'; + +import HomeIcon from '@mui/icons-material/Home'; +import { Button, Stack } from '@mui/material'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; + +import { DiscriminatedItem } from '@graasp/sdk'; + +import { useBuilderTranslation } from '../../../config/i18n'; +import { hooks } from '../../../config/queryClient'; +import { + HOME_MODAL_ITEM_ID, + TREE_MODAL_CONFIRM_BUTTON_ID, +} from '../../../config/selectors'; +import { BUILDER } from '../../../langs/constants'; +import CancelButton from '../../common/CancelButton'; +import AccessibleNavigationTree from './AccessibleNavigationTree'; +import Breadcrumbs, { NavigationElement } from './Breadcrumbs'; +import ChildrenNavigationTree from './ChildrenNavigationTree'; +import RootNavigationTree from './RootNavigationTree'; + +const dialogId = 'items-tree-modal'; +const MY_GRAASP_BREADCRUMB_ID = 'selectionModalMyGraasp'; + +export type ItemSelectionModalProps = { + buttonText: (itemName?: string) => string; + /** disabled rows + * */ + isDisabled: (item: NavigationElement, homeId: string) => boolean; + // items can be undefined because "many" operations start empty + items?: DiscriminatedItem[]; + onClose: (args: { id: string | null; open: boolean }) => void; + onConfirm: (args: string | undefined) => void; + open?: boolean; + title: string; +}; + +const ItemSelectionModal = ({ + buttonText = () => 'Submit', + isDisabled, + items = [], + onClose, + onConfirm, + open = false, + title, +}: ItemSelectionModalProps): JSX.Element => { + const { t: translateBuilder } = useBuilderTranslation(); + + // special elements for breadcrumbs + // root displays specific paths + const ROOT_BREADCRUMB: NavigationElement = { + icon: , + name: '', + path: 'selectionModalRoot', + id: 'selectionModalRoot', + }; + // my graasp displays accessible items + const MY_GRAASP_BREADCRUMB: NavigationElement = { + name: translateBuilder(BUILDER.MY_ITEMS_TITLE), + id: MY_GRAASP_BREADCRUMB_ID, + path: MY_GRAASP_BREADCRUMB_ID, + }; + + const SPECIAL_BREADCRUMB_IDS = [ROOT_BREADCRUMB.id, MY_GRAASP_BREADCRUMB.id]; + + const [selectedItem, setSelectedItem] = useState(); + + // keep track of the navigation item that can be different from the selected item + const [selectedNavigationItem, setSelectedNavigationItem] = + useState(ROOT_BREADCRUMB); + + const { data: navigationParents } = hooks.useParents({ + id: selectedNavigationItem.id, + enabled: !SPECIAL_BREADCRUMB_IDS.includes(selectedNavigationItem.id), + }); + + const handleClose = () => { + onClose({ id: null, open: false }); + }; + + const onClickConfirm = () => { + onConfirm( + selectedItem?.id === MY_GRAASP_BREADCRUMB.id + ? undefined + : selectedItem?.id, + ); + handleClose(); + }; + + // row menu navigation + const onNavigate = (item: NavigationElement) => { + setSelectedNavigationItem(item); + setSelectedItem(item); + }; + + // does not show breadcrumbs on root + const renderBreadcrumbs = () => { + if (selectedNavigationItem.id === ROOT_BREADCRUMB.id) { + return null; + } + + // always show root + const elements: NavigationElement[] = [ROOT_BREADCRUMB]; + + // show graasp if not on root + if (selectedNavigationItem.id !== MY_GRAASP_BREADCRUMB.id) { + elements.push(MY_GRAASP_BREADCRUMB); + } + + // parents: needs a check on current selected value as parents might keep the previous data + if ( + !SPECIAL_BREADCRUMB_IDS.includes(selectedNavigationItem.id) && + navigationParents + ) { + elements.push(...navigationParents); + } + + // element itself + elements.push(selectedNavigationItem); + + return ; + }; + + return ( + + {title} + + + {renderBreadcrumbs()} + + {selectedNavigationItem.id === ROOT_BREADCRUMB.id && ( + isDisabled(item, MY_GRAASP_BREADCRUMB.id)} + onClick={setSelectedItem} + selectedId={selectedItem?.id} + onNavigate={onNavigate} + items={items} + rootMenuItems={[MY_GRAASP_BREADCRUMB]} + /> + )} + {selectedNavigationItem.id === MY_GRAASP_BREADCRUMB.id && ( + isDisabled(item, MY_GRAASP_BREADCRUMB.id)} + onClick={setSelectedItem} + onNavigate={onNavigate} + selectedId={selectedItem?.id} + /> + )} + {!SPECIAL_BREADCRUMB_IDS.includes(selectedNavigationItem.id) && ( + isDisabled(item, MY_GRAASP_BREADCRUMB.id)} + onClick={setSelectedItem} + onNavigate={onNavigate} + selectedId={selectedItem?.id} + selectedNavigationItem={selectedNavigationItem} + /> + )} + + + + + + + + ); +}; + +export default ItemSelectionModal; diff --git a/src/components/main/itemSelectionModal/RootNavigationTree.tsx b/src/components/main/itemSelectionModal/RootNavigationTree.tsx new file mode 100644 index 000000000..438afd766 --- /dev/null +++ b/src/components/main/itemSelectionModal/RootNavigationTree.tsx @@ -0,0 +1,93 @@ +import { Typography } from '@mui/material'; + +import { DiscriminatedItem, ItemType } from '@graasp/sdk'; + +import { useBuilderTranslation } from '@/config/i18n'; +import { hooks } from '@/config/queryClient'; +import { BUILDER } from '@/langs/constants'; + +import { NavigationElement } from './Breadcrumbs'; +import RowMenu, { RowMenuProps } from './RowMenu'; + +interface RootNavigationTreeProps { + isDisabled?: RowMenuProps['isDisabled']; + items: DiscriminatedItem[]; + onClick: RowMenuProps['onClick']; + onNavigate: RowMenuProps['onNavigate']; + rootMenuItems: NavigationElement[]; + selectedId?: string; +} + +const RootNavigationTree = ({ + isDisabled, + items, + onClick, + onNavigate, + rootMenuItems, + selectedId, +}: RootNavigationTreeProps): JSX.Element => { + const { t: translateBuilder } = useBuilderTranslation(); + + // todo: to change with real recent items (most used) + const { data: recentItems } = hooks.useAccessibleItems({}, { pageSize: 5 }); + const recentFolders = recentItems?.data?.filter( + ({ type }) => type === ItemType.FOLDER, + ); + + const { data: parents } = hooks.useParents({ + id: items[0].id, + path: items[0].path, + }); + + return ( + <> + + {translateBuilder(BUILDER.HOME_TITLE)} + + {rootMenuItems.map((mi) => ( + + ))} + {recentFolders && ( + <> + + {translateBuilder(BUILDER.ITEM_SELECTION_NAVIGATION_RECENT_ITEMS)} + + {recentFolders.map((item) => ( + + ))} + + )} + {/* show second parent to allow moving a level above */} + {parents && parents.length > 1 && ( + <> + + {translateBuilder(BUILDER.ITEM_SELECTION_NAVIGATION_PARENT)} + + + + )} + + ); +}; + +export default RootNavigationTree; diff --git a/src/components/main/itemSelectionModal/RowMenu.tsx b/src/components/main/itemSelectionModal/RowMenu.tsx new file mode 100644 index 000000000..911180328 --- /dev/null +++ b/src/components/main/itemSelectionModal/RowMenu.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; + +import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; +import { Button, IconButton, Stack, Typography, styled } from '@mui/material'; + +import { ItemType } from '@graasp/sdk'; +import { ItemIcon } from '@graasp/ui'; + +import { + buildItemRowArrowId, + buildNavigationModalItemId, +} from '@/config/selectors'; + +import { NavigationElement } from './Breadcrumbs'; + +export interface RowMenuProps { + item: NavigationElement; + onNavigate: (item: NavigationElement) => void; + selectedId?: string; + onClick: (item: NavigationElement) => void; + isDisabled?: (item: NavigationElement) => boolean; +} + +const StyledButton = styled(Button)<{ isSelected: boolean }>( + ({ theme, isSelected }) => ({ + color: theme.palette.text.primary, + width: '100%', + justifyContent: 'start', + background: isSelected ? theme.palette.grey[200] : 'none', + textTransform: 'none', + pl: 1, + }), +); + +const RowMenu = ({ + item, + onNavigate, + onClick, + selectedId, + isDisabled, +}: RowMenuProps): JSX.Element | null => { + const [isHoverActive, setIsHoverActive] = useState(false); + + const handleHover = () => { + setIsHoverActive(true); + }; + const handleUnhover = () => { + setIsHoverActive(false); + }; + + return ( + + + { + onClick(item); + }} + isSelected={selectedId === item.id} + disabled={isDisabled?.(item)} + startIcon={ + + } + > + + {item.name} + + + {(isHoverActive || selectedId === item.id) && ( + onNavigate(item)} + id={buildItemRowArrowId(item.id)} + size="small" + > + + + )} + + + ); +}; + +export default RowMenu; diff --git a/src/config/selectors.ts b/src/config/selectors.ts index b3a4b54dc..5d175ff68 100644 --- a/src/config/selectors.ts +++ b/src/config/selectors.ts @@ -22,7 +22,7 @@ export const ITEM_MENU_COPY_BUTTON_CLASS = 'itemMenuCopyButton'; export const ITEM_MENU_RECYCLE_BUTTON_CLASS = 'itemMenuRecycleButton'; export const buildItemMenu = (id: string): string => `itemMenu-${id}`; export const HOME_MODAL_ITEM_ID = 'treeModalHomeItem'; -export const buildHomeModalItemID = (id: string): string => +export const buildNavigationModalItemId = (id: string): string => `${HOME_MODAL_ITEM_ID}-${id}`; export const ROOT_MODAL_ID = 'rootModal'; diff --git a/src/langs/constants.ts b/src/langs/constants.ts index a6f763a61..16d5f94b6 100644 --- a/src/langs/constants.ts +++ b/src/langs/constants.ts @@ -245,6 +245,9 @@ export const BUILDER = { 'LIBRARY_SETTINGS_VALIDATION_VALIDATE_BUTTON', LINK_DEFAULT_NAME: 'LINK_DEFAULT_NAME', MOVE_ITEM_MODAL_TITLE: 'MOVE_ITEM_MODAL_TITLE', + ITEM_SELECTION_NAVIGATION_RECENT_ITEMS: + 'ITEM_SELECTION_NAVIGATION_RECENT_ITEMS', + ITEM_SELECTION_NAVIGATION_PARENT: 'ITEM_SELECTION_NAVIGATION_PARENT', MY_ITEMS_TITLE: 'MY_ITEMS_TITLE', NAVIGATION_FAVORITE_ITEMS_TITLE: 'NAVIGATION_FAVORITE_ITEMS_TITLE', NAVIGATION_MY_ITEMS_TITLE: 'NAVIGATION_MY_ITEMS_TITLE', diff --git a/src/langs/en.json b/src/langs/en.json index b7093c0a4..e609531b8 100644 --- a/src/langs/en.json +++ b/src/langs/en.json @@ -161,8 +161,11 @@ "LIBRARY_SETTINGS_VISIBILITY_INFORMATIONS": "This element must be public to be published", "LINK_DEFAULT_NAME": "My Link", "MOVE_BUTTON": "Move", + "MOVE_BUTTON_zero": "Move", + "MOVE_BUTTON_one": "Move to {{name}}", + "MOVE_ITEM_MODAL_TITLE": "Where do you want to move this item?", "MOVE_ITEM_MODAL_TITLE_zero": "Where do you want to move {{name}}?", - "MOVE_ITEM_MODAL_TITLE_one": "Where do you want to move {{name}} and one another?", + "MOVE_ITEM_MODAL_TITLE_one": "Where do you want to move {{name}} and one other?", "MOVE_ITEM_MODAL_TITLE_other": "Where do you want to move {{name}} and {{count}} others?", "MY_ITEMS_TITLE": "My Graasp", "NAVIGATION_FAVORITE_ITEMS_TITLE": "Favorite Items", @@ -296,5 +299,7 @@ "EMPTY_FOLDER_CHILDREN_FOR_THIS_ITEM": "This item does not contain any folder.", "TO_FOLDER": "to {{name}}", "ROOT": "Root", + "ITEM_SELECTION_NAVIGATION_RECENT_ITEMS": "Recent Items", + "ITEM_SELECTION_NAVIGATION_PARENT": "Move above", "You can also find the items of this page in ''My Graasp''. This page will be unavailable soon.": "You can also find the items of this page in ''My Graasp''. This page ''Shared Items'' will be unavailable soon." } diff --git a/src/langs/fr.json b/src/langs/fr.json index 96a5a7d59..7b25b0f66 100644 --- a/src/langs/fr.json +++ b/src/langs/fr.json @@ -161,7 +161,12 @@ "LIBRARY_SETTINGS_VISIBILITY_INFORMATIONS": "Cet élément doit être public pour pouvoir être publié", "LINK_DEFAULT_NAME": "Mon Lien", "MOVE_BUTTON": "Déplacer", - "MOVE_ITEM_MODAL_TITLE": "Où déplacer cet élément?", + "MOVE_BUTTON_zero": "Déplacer", + "MOVE_BUTTON_one": "Déplacer dans {{name}}", + "MOVE_ITEM_MODAL_TITLE_zero": "Où déplacer cet élément ?", + "MOVE_ITEM_MODAL_TITLE_one": "Où déplacer {{name}} ?", + "MOVE_ITEM_MODAL_TITLE_two": "Où déplacer {{name}} et un autre ?", + "MOVE_ITEM_MODAL_TITLE_other": "Où déplacer {{name}} et {{count}} autres éléments ?", "MY_ITEMS_TITLE": "Mon Graasp", "NAVIGATION_FAVORITE_ITEMS_TITLE": "Éléments Favoris", "NAVIGATION_MY_ITEMS_TITLE": "Mon Graasp", @@ -291,5 +296,7 @@ "SHORT_LINK_MAX_CHARS_ERROR": "Le lien rapide ne doit pas dépasser les {{data}} caractères.", "SHORT_LINK_INVALID_CHARS_ERROR": "Le lien rapide contient des caractères non valides ({{data}}).", "SHORT_LINK_UNKNOWN_ERROR": "Il y a une erreur inconnue.", - "You can also find the items of this page in ''My Graasp''. This page will be unavailable soon.": "Les éléments de cette page sont aussi disponibles dans ''Mon Graasp''. Cette page ''Eléments Partagés'' sera bientôt indisponible." + "You can also find the items of this page in ''My Graasp''. This page will be unavailable soon.": "Les éléments de cette page sont aussi disponibles dans ''Mon Graasp''. Cette page ''Eléments Partagés'' sera bientôt indisponible.", + "ITEM_SELECTION_NAVIGATION_RECENT_ITEMS": "Éléments Récents", + "ITEM_SELECTION_NAVIGATION_PARENT": "Déplacer au-dessus" } diff --git a/src/utils/item.ts b/src/utils/item.ts index f2b663892..fdeaddadf 100644 --- a/src/utils/item.ts +++ b/src/utils/item.ts @@ -164,3 +164,11 @@ export function useIsParentInstance({ return isParentMembership; } + +export const applyEllipsisOnLength = ( + longString: string, + maxLength: number, +): string => + `${longString.slice(0, maxLength)}${ + (longString.length || 0) > maxLength ? '…' : '' + }`;