From b162e42914e5f8853bbb0d870e5aac8020849730 Mon Sep 17 00:00:00 2001 From: Jose Buitron Date: Wed, 13 Sep 2023 12:34:14 -0500 Subject: [PATCH] feat: Show chapter menu, move up, move down and delete --- src/common/components/ConfirmMenuItem.js | 56 ++++ src/localization/locales/en-US.json | 4 + src/storyMap/components/StoryMapForm.test.js | 212 +++++++++++- .../StoryMapForm/ChaptersSideBar.js | 316 ++++++++++++------ src/storyMap/components/StoryMapForm/index.js | 21 ++ 5 files changed, 502 insertions(+), 107 deletions(-) create mode 100644 src/common/components/ConfirmMenuItem.js diff --git a/src/common/components/ConfirmMenuItem.js b/src/common/components/ConfirmMenuItem.js new file mode 100644 index 000000000..3101198c6 --- /dev/null +++ b/src/common/components/ConfirmMenuItem.js @@ -0,0 +1,56 @@ +/* + * Copyright © 2021-2023 Technology Matters + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +import React, { useCallback, useEffect, useState } from 'react'; +import { MenuItem } from '@mui/material'; + +import ConfirmationDialog from 'common/components/ConfirmationDialog'; + +const ConfirmMenuItem = props => { + const [openConfirmation, setOpenConfirmation] = useState(false); + const { confirmTitle, confirmMessage, confirmButton, onConfirm, children } = + props; + + useEffect(() => { + setOpenConfirmation(false); + }, []); + + const onClick = useCallback(event => { + setOpenConfirmation(true); + event.stopPropagation(); + }, []); + + const onCancel = useCallback(event => { + setOpenConfirmation(false); + event.stopPropagation(); + }, []); + + return ( + <> + + {children} + + ); +}; + +export default ConfirmMenuItem; diff --git a/src/localization/locales/en-US.json b/src/localization/locales/en-US.json index 6d1a99c55..e0df0da5a 100644 --- a/src/localization/locales/en-US.json +++ b/src/localization/locales/en-US.json @@ -889,6 +889,10 @@ "form_chapter_alignment_left": "Left", "form_chapter_alignment_center": "Center", "form_chapter_alignment_right": "Right", + "form_chapter_open_menu": "Open menu", + "form_chapter_menu_label": "{{chapterLabel}} menu", + "form_chapter_move_up": "Move Chapter Up", + "form_chapter_move_down": "Move Chapter Down", "form_location_dialog_title": "Set map location for <1>{{title}}", "form_location_dialog_title_blank": "Set map location", "location_dialog_cancel_button": "Cancel", diff --git a/src/storyMap/components/StoryMapForm.test.js b/src/storyMap/components/StoryMapForm.test.js index ca6ce8cd7..7fbbb9e68 100644 --- a/src/storyMap/components/StoryMapForm.test.js +++ b/src/storyMap/components/StoryMapForm.test.js @@ -62,6 +62,11 @@ const BASE_CONFIG = { zoom: 5, }, }, + { + id: 'chapter-3', + title: 'Chapter 3', + description: 'Chapter 3 description', + }, ], }; @@ -395,13 +400,13 @@ test('StoryMapForm: Sidebar navigation', async () => { }); const title = within(sidebarList).getByRole('button', { - name: 'T Title', + name: 'Title', }); const chapter1 = within(sidebarList).getByRole('button', { - name: '1 Chapter 1', + name: 'Chapter 1', }); const chapter2 = within(sidebarList).getByRole('button', { - name: '2 Chapter 2', + name: 'Chapter 2', }); await waitFor(() => expect(scrollama).toHaveBeenCalled()); @@ -505,7 +510,7 @@ test('StoryMapForm: Adds new chapter', async () => { description: 'Chapter 2 description', }) ); - expect(saveCall[0].chapters[2]).toEqual( + expect(saveCall[0].chapters[3]).toEqual( expect.objectContaining({ alignment: 'left', title: 'New chapter', @@ -516,14 +521,14 @@ test('StoryMapForm: Adds new chapter', async () => { onChapterExit: [], }) ); - expect(saveCall[0].chapters[2].media).toEqual( + expect(saveCall[0].chapters[3].media).toEqual( expect.objectContaining({ filename: 'test.jpg', type: 'image/jpeg', }) ); - expect(saveCall[0].chapters[2].media.contentId).toEqual( + expect(saveCall[0].chapters[3].media.contentId).toEqual( Object.keys(saveCall[1])[0] ); }); @@ -674,3 +679,198 @@ test('StoryMapForm: Change chapter location', async () => { }) ); }); + +test('StoryMapForm: Move chapter down with menu', async () => { + const { onSaveDraft } = await setup(BASE_CONFIG); + + const chaptersSection = screen.getByRole('navigation', { + name: 'Chapters sidebar', + }); + + const chapter1 = within(chaptersSection).getByRole('button', { + name: 'Chapter 1', + }); + + const menuButton = within(chapter1).getByRole('button', { + name: 'Open menu', + }); + await act(async () => fireEvent.click(menuButton)); + + const menu = screen.getByRole('menu', { + name: 'Chapter 1 menu', + }); + + const moveDownButton = within(menu).getByRole('menuitem', { + name: 'Move Chapter Down', + }); + + await act(async () => fireEvent.click(moveDownButton)); + + await act(async () => + fireEvent.click(screen.getByRole('button', { name: 'Save draft' })) + ); + expect(onSaveDraft).toHaveBeenCalledTimes(1); + const saveCall = onSaveDraft.mock.calls[0]; + + expect(saveCall[0].chapters[0]).toEqual( + expect.objectContaining({ + id: 'chapter-2', + title: 'Chapter 2', + description: 'Chapter 2 description', + }) + ); + expect(saveCall[0].chapters[1]).toEqual( + expect.objectContaining({ + id: 'chapter-1', + title: 'Chapter 1', + description: 'Chapter 1 description', + }) + ); +}); + +test('StoryMapForm: Move chapter up with menu', async () => { + const { onSaveDraft } = await setup(BASE_CONFIG); + + const chaptersSection = screen.getByRole('navigation', { + name: 'Chapters sidebar', + }); + + const chapter2 = within(chaptersSection).getByRole('button', { + name: 'Chapter 2', + }); + + const menuButton = within(chapter2).getByRole('button', { + name: 'Open menu', + }); + await act(async () => fireEvent.click(menuButton)); + + const menu = screen.getByRole('menu', { + name: 'Chapter 2 menu', + }); + + const moveUpButton = within(menu).getByRole('menuitem', { + name: 'Move Chapter Up', + }); + + await act(async () => fireEvent.click(moveUpButton)); + + await act(async () => + fireEvent.click(screen.getByRole('button', { name: 'Save draft' })) + ); + expect(onSaveDraft).toHaveBeenCalledTimes(1); + const saveCall = onSaveDraft.mock.calls[0]; + + expect(saveCall[0].chapters[0]).toEqual( + expect.objectContaining({ + id: 'chapter-2', + title: 'Chapter 2', + description: 'Chapter 2 description', + }) + ); + expect(saveCall[0].chapters[1]).toEqual( + expect.objectContaining({ + id: 'chapter-1', + title: 'Chapter 1', + description: 'Chapter 1 description', + }) + ); +}); + +test('StoryMapForm: Show correct sort buttons if chapter is first', async () => { + await setup(BASE_CONFIG); + + const chaptersSection = screen.getByRole('navigation', { + name: 'Chapters sidebar', + }); + + const chapter1 = within(chaptersSection).getByRole('button', { + name: 'Chapter 1', + }); + const menuButton = within(chapter1).getByRole('button', { + name: 'Open menu', + }); + await act(async () => fireEvent.click(menuButton)); + const menu = screen.getByRole('menu', { + name: 'Chapter 1 menu', + }); + const moveUpButton = within(menu).queryByRole('menuitem', { + name: 'Move Chapter Up', + }); + + expect(moveUpButton).not.toBeInTheDocument(); +}); + +test('StoryMapForm: Show correct sort buttons if chapter is last', async () => { + await setup(BASE_CONFIG); + + const chaptersSection = screen.getByRole('navigation', { + name: 'Chapters sidebar', + }); + + const chapter3 = within(chaptersSection).getByRole('button', { + name: 'Chapter 3', + }); + const menuButton = within(chapter3).getByRole('button', { + name: 'Open menu', + }); + await act(async () => fireEvent.click(menuButton)); + const menu = screen.getByRole('menu', { + name: 'Chapter 3 menu', + }); + const moveDownButton = within(menu).queryByRole('menuitem', { + name: 'Move Chapter Down', + }); + + expect(moveDownButton).not.toBeInTheDocument(); +}); + +test('StoryMapForm: Delete chapter', async () => { + const { onSaveDraft } = await setup(BASE_CONFIG); + + const chaptersSection = screen.getByRole('navigation', { + name: 'Chapters sidebar', + }); + + const chapter1 = within(chaptersSection).getByRole('button', { + name: 'Chapter 1', + }); + const menuButton = within(chapter1).getByRole('button', { + name: 'Open menu', + }); + await act(async () => fireEvent.click(menuButton)); + const menu = screen.getByRole('menu', { + name: 'Chapter 1 menu', + }); + const deleteButton = within(menu).getByRole('menuitem', { + name: 'Delete Chapter', + }); + + await act(async () => fireEvent.click(deleteButton)); + + // Confirmation dialog + await act(async () => + fireEvent.click(screen.getByRole('button', { name: 'Delete Chapter' })) + ); + + await act(async () => + fireEvent.click(screen.getByRole('button', { name: 'Save draft' })) + ); + expect(onSaveDraft).toHaveBeenCalledTimes(1); + const saveCall = onSaveDraft.mock.calls[0]; + + expect(saveCall[0].chapters.length).toEqual(2); + expect(saveCall[0].chapters[0]).toEqual( + expect.objectContaining({ + id: 'chapter-2', + title: 'Chapter 2', + description: 'Chapter 2 description', + }) + ); + expect(saveCall[0].chapters[1]).toEqual( + expect.objectContaining({ + id: 'chapter-3', + title: 'Chapter 3', + description: 'Chapter 3 description', + }) + ); +}); diff --git a/src/storyMap/components/StoryMapForm/ChaptersSideBar.js b/src/storyMap/components/StoryMapForm/ChaptersSideBar.js index 21a32b660..9b0576d63 100644 --- a/src/storyMap/components/StoryMapForm/ChaptersSideBar.js +++ b/src/storyMap/components/StoryMapForm/ChaptersSideBar.js @@ -14,24 +14,222 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see https://www.gnu.org/licenses/. */ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import AddIcon from '@mui/icons-material/Add'; -import DeleteIcon from '@mui/icons-material/Delete'; -import { Button, Grid, List, ListItem, Paper, Typography } from '@mui/material'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import { + Button, + Divider, + Grid, + IconButton, + List, + ListItem, + Menu, + MenuItem, + Paper, + Stack, + Typography, +} from '@mui/material'; -import ConfirmButton from 'common/components/ConfirmButton'; +import ConfirmMenuItem from 'common/components/ConfirmMenuItem'; -const ChaptersSidebar = props => { +const SideBarItem = props => { const { t } = useTranslation(); - const { config, currentStepId, onAdd, onDelete, height } = props; - const { chapters } = config; + const { item, onDelete, onMoveChapter, chaptersLength } = props; + const [menuAnchorEl, setMenuAnchorEl] = useState(null); + const openMenu = useMemo(() => Boolean(menuAnchorEl), [menuAnchorEl]); const scrollTo = id => { const element = document.getElementById(id); element?.scrollIntoView({ block: 'start', behavior: 'smooth' }); }; + const handleOpenMenuClick = useCallback(event => { + setMenuAnchorEl(event.currentTarget); + event.stopPropagation(); + }, []); + const handleMenuClose = useCallback(() => { + setMenuAnchorEl(null); + }, []); + + const handleMoveUp = useCallback( + event => { + onMoveChapter(item.id, item.index - 1); + handleMenuClose(); + event.stopPropagation(); + }, + [handleMenuClose, item.id, item.index, onMoveChapter] + ); + + const handleMoveDown = useCallback( + event => { + onMoveChapter(item.id, item.index + 1); + handleMenuClose(); + event.stopPropagation(); + }, + [handleMenuClose, item.id, item.index, onMoveChapter] + ); + + const sortActions = useMemo(() => { + if (chaptersLength === 1) { + return []; + } + if (item.index === 0) { + return [ + { + label: t('storyMap.form_chapter_move_down'), + onClick: handleMoveDown, + }, + ]; + } + if (item.index > 0 && item.index < chaptersLength - 1) { + return [ + { + label: t('storyMap.form_chapter_move_up'), + onClick: handleMoveUp, + }, + { + label: t('storyMap.form_chapter_move_down'), + onClick: handleMoveDown, + }, + ]; + } + if (item.index === chaptersLength - 1) { + return [ + { + label: t('storyMap.form_chapter_move_up'), + onClick: handleMoveUp, + }, + ]; + } + }, [handleMoveDown, handleMoveUp, item.index, chaptersLength, t]); + + return ( + + + + ); +}; + +const ChaptersSidebar = props => { + const { t } = useTranslation(); + const { config, currentStepId, onAdd, onDelete, onMoveChapter, height } = + props; + const { chapters } = config; + const listItems = useMemo( () => [ { @@ -45,7 +243,9 @@ const ChaptersSidebar = props => { id: chapter.id, active: currentStepId === chapter.id, deletable: true, - index: index + 1, + sortable: true, + index: index, + indexLabel: index + 1, })), ], [chapters, currentStepId, t] @@ -61,99 +261,13 @@ const ChaptersSidebar = props => { > {listItems.map(item => ( - - - + ))}