feat: Move infrequent card actions to more actions menu

This commit is contained in:
Maksim Eltyshev
2025-07-22 21:43:55 +02:00
parent df023439c1
commit 6dfb5ba024
29 changed files with 253 additions and 156 deletions

View File

@@ -0,0 +1,153 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Icon, Menu } from 'semantic-ui-react';
import { Popup } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useSteps } from '../../../hooks';
import { isListArchiveOrTrash } from '../../../utils/record-helpers';
import { BoardMembershipRoles } from '../../../constants/Enums';
import SelectCardTypeStep from '../SelectCardTypeStep';
import MoveCardStep from '../MoveCardStep';
import styles from './MoreActionsStep.module.scss';
const StepTypes = {
EDIT_TYPE: 'EDIT_TYPE',
MOVE: 'MOVE',
};
const MoreActionsStep = React.memo(({ onClose }) => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const card = useSelector(selectors.selectCurrentCard);
const board = useSelector(selectors.selectCurrentBoard);
const { canEditType, canDuplicate, canMove } = useSelector((state) => {
const list = selectListById(state, card.listId);
if (isListArchiveOrTrash(list)) {
return {
canEditType: false,
canDuplicate: false,
canMove: false,
};
}
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
return {
canEditType: isEditor,
canDuplicate: isEditor,
canMove: isEditor,
};
}, shallowEqual);
const dispatch = useDispatch();
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const handleTypeSelect = useCallback(
(type) => {
dispatch(
entryActions.updateCurrentCard({
type,
}),
);
},
[dispatch],
);
const handleDuplicateClick = useCallback(() => {
dispatch(
entryActions.duplicateCurrentCard({
name: `${card.name} (${t('common.copy', {
context: 'inline',
})})`,
}),
);
onClose();
}, [onClose, card.name, dispatch, t]);
const handleEditTypeClick = useCallback(() => {
openStep(StepTypes.EDIT_TYPE);
}, [openStep]);
const handleMoveClick = useCallback(() => {
openStep(StepTypes.MOVE);
}, [openStep]);
if (step) {
switch (step.type) {
case StepTypes.EDIT_TYPE:
return (
<SelectCardTypeStep
withButton
defaultValue={card.type}
title="common.editType"
buttonContent="action.save"
onSelect={handleTypeSelect}
onBack={handleBack}
onClose={onClose}
/>
);
case StepTypes.MOVE:
return <MoveCardStep id={card.id} onBack={handleBack} onClose={onClose} />;
default:
}
}
return (
<>
<Popup.Header>
{t('common.moreActions', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
{!board.limitCardTypesToDefaultOne && canEditType && (
<Menu.Item className={styles.menuItem} onClick={handleEditTypeClick}>
<Icon name="map outline" className={styles.menuItemIcon} />
{t('action.editType', {
context: 'title',
})}
</Menu.Item>
)}
{canDuplicate && (
<Menu.Item className={styles.menuItem} onClick={handleDuplicateClick}>
<Icon name="copy outline" className={styles.menuItemIcon} />
{t('action.duplicateCard', {
context: 'title',
})}
</Menu.Item>
)}
{canMove && (
<Menu.Item className={styles.menuItem} onClick={handleMoveClick}>
<Icon name="share square outline" className={styles.menuItemIcon} />
{t('action.moveCard', {
context: 'title',
})}
</Menu.Item>
)}
</Menu>
</Popup.Content>
</>
);
});
MoreActionsStep.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default MoreActionsStep;

View File

@@ -0,0 +1,21 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.menu {
margin: -7px -12px -5px;
width: calc(100% + 24px);
}
.menuItem {
margin: 0;
padding-left: 14px;
}
.menuItemIcon {
float: left;
margin: 0 0.5em 0 0;
}
}

View File

@@ -4,7 +4,6 @@
*/
import React, { useCallback, useContext, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
@@ -24,12 +23,11 @@ import TaskLists from './TaskLists';
import CustomFieldGroups from './CustomFieldGroups';
import Communication from './Communication';
import CreationDetailsStep from './CreationDetailsStep';
import MoreActionsStep from './MoreActionsStep';
import DueDateChip from '../DueDateChip';
import StopwatchChip from '../StopwatchChip';
import SelectCardTypeStep from '../SelectCardTypeStep';
import EditDueDateStep from '../EditDueDateStep';
import EditStopwatchStep from '../EditStopwatchStep';
import MoveCardStep from '../MoveCardStep';
import ExpandableMarkdown from '../../common/ExpandableMarkdown';
import EditMarkdown from '../../common/EditMarkdown';
import ConfirmationStep from '../../common/ConfirmationStep';
@@ -45,7 +43,7 @@ import AddCustomFieldGroupStep from '../../custom-field-groups/AddCustomFieldGro
import styles from './ProjectContent.module.scss';
const ProjectContent = React.memo(({ onClose }) => {
const ProjectContent = React.memo(() => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const selectPrevListById = useMemo(() => selectors.makeSelectListById(), []);
@@ -155,17 +153,6 @@ const ProjectContent = React.memo(({ onClose }) => {
[dispatch],
);
const handleTypeSelect = useCallback(
(type) => {
dispatch(
entryActions.updateCurrentCard({
type,
}),
);
},
[dispatch],
);
const handleNameUpdate = useCallback(
(name) => {
dispatch(
@@ -198,18 +185,6 @@ const ProjectContent = React.memo(({ onClose }) => {
);
}, [card.stopwatch, dispatch]);
const handleDuplicateClick = useCallback(() => {
dispatch(
entryActions.duplicateCurrentCard({
name: `${card.name} (${t('common.copy', {
context: 'inline',
})})`,
}),
);
onClose();
}, [onClose, card.name, dispatch, t]);
const handleRestoreClick = useCallback(() => {
dispatch(entryActions.moveCurrentCard(card.prevListId, undefined, true));
}, [card.prevListId, dispatch]);
@@ -304,13 +279,12 @@ const ProjectContent = React.memo(({ onClose }) => {
const BoardMembershipsPopup = usePopupInClosableContext(BoardMembershipsStep);
const LabelsPopup = usePopupInClosableContext(LabelsStep);
const ListsPopup = usePopupInClosableContext(ListsStep);
const SelectCardTypePopup = usePopupInClosableContext(SelectCardTypeStep);
const EditDueDatePopup = usePopupInClosableContext(EditDueDateStep);
const EditStopwatchPopup = usePopupInClosableContext(EditStopwatchStep);
const AddTaskListPopup = usePopupInClosableContext(AddTaskListStep);
const AddAttachmentPopup = usePopupInClosableContext(AddAttachmentStep);
const AddCustomFieldGroupPopup = usePopupInClosableContext(AddCustomFieldGroupStep);
const MoveCardPopup = usePopupInClosableContext(MoveCardStep);
const MoreActionsPopup = usePopupInClosableContext(MoreActionsStep);
const ConfirmationPopup = usePopupInClosableContext(ConfirmationStep);
return (
@@ -706,40 +680,6 @@ const ProjectContent = React.memo(({ onClose }) => {
)}
</Button>
)}
{!board.limitCardTypesToDefaultOne && canEditType && (
<SelectCardTypePopup
withButton
defaultValue={card.type}
title="common.editType"
buttonContent="action.save"
onSelect={handleTypeSelect}
>
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
<Icon name="map outline" className={styles.actionIcon} />
{t('action.editType', {
context: 'title',
})}
</Button>
</SelectCardTypePopup>
)}
{canDuplicate && (
<Button
fluid
className={classNames(styles.actionButton, styles.hidable)}
onClick={handleDuplicateClick}
>
<Icon name="copy outline" className={styles.actionIcon} />
{t('action.duplicate')}
</Button>
)}
{canMove && (
<MoveCardPopup id={card.id}>
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
<Icon name="share square outline" className={styles.actionIcon} />
{t('action.move')}
</Button>
</MoveCardPopup>
)}
{canRestore && (isInArchiveList || isInTrashList) && (
<Button
fluid
@@ -789,6 +729,16 @@ const ProjectContent = React.memo(({ onClose }) => {
</Button>
</ConfirmationPopup>
)}
{((!board.limitCardTypesToDefaultOne && canEditType) ||
canDuplicate ||
canMove) && (
<MoreActionsPopup>
<Button fluid className={classNames(styles.moreActionsButton, styles.hidable)}>
<Icon name="ellipsis horizontal" className={styles.moreActionsButtonIcon} />
{t('common.moreActions')}
</Button>
</MoreActionsPopup>
)}
</div>
)}
</div>
@@ -798,8 +748,4 @@ const ProjectContent = React.memo(({ onClose }) => {
);
});
ProjectContent.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default ProjectContent;

View File

@@ -260,6 +260,28 @@
position: relative;
}
.moreActionsButton {
background: transparent;
box-shadow: none;
color: #6b808c;
font-weight: normal;
margin-top: 8px;
padding: 6px 8px 6px 18px;
text-align: left;
text-decoration: underline;
transition: none;
&:hover {
background: rgba(9, 30, 66, 0.08);
color: #092d42;
}
}
.moreActionsButtonIcon {
margin-right: 8px;
text-decoration: none;
}
.sidebarPadding {
padding: 8px 16px 8px 8px;

View File

@@ -4,7 +4,6 @@
*/
import React, { useCallback, useContext, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
@@ -24,8 +23,7 @@ import NameField from '../NameField';
import CustomFieldGroups from '../CustomFieldGroups';
import Communication from '../Communication';
import CreationDetailsStep from '../CreationDetailsStep';
import SelectCardTypeStep from '../../SelectCardTypeStep';
import MoveCardStep from '../../MoveCardStep';
import MoreActionsStep from '../MoreActionsStep';
import Markdown from '../../../common/Markdown';
import EditMarkdown from '../../../common/EditMarkdown';
import ConfirmationStep from '../../../common/ConfirmationStep';
@@ -40,7 +38,7 @@ import AddCustomFieldGroupStep from '../../../custom-field-groups/AddCustomField
import styles from './StoryContent.module.scss';
const StoryContent = React.memo(({ onClose }) => {
const StoryContent = React.memo(() => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const selectPrevListById = useMemo(() => selectors.makeSelectListById(), []);
const selectAttachmentById = useMemo(() => selectors.makeSelectAttachmentById(), []);
@@ -150,17 +148,6 @@ const StoryContent = React.memo(({ onClose }) => {
[dispatch],
);
const handleTypeSelect = useCallback(
(type) => {
dispatch(
entryActions.updateCurrentCard({
type,
}),
);
},
[dispatch],
);
const handleNameUpdate = useCallback(
(name) => {
dispatch(
@@ -183,18 +170,6 @@ const StoryContent = React.memo(({ onClose }) => {
[dispatch],
);
const handleDuplicateClick = useCallback(() => {
dispatch(
entryActions.duplicateCurrentCard({
name: `${card.name} (${t('common.copy', {
context: 'inline',
})})`,
}),
);
onClose();
}, [onClose, card.name, dispatch, t]);
const handleRestoreClick = useCallback(() => {
dispatch(entryActions.moveCurrentCard(card.prevListId, undefined, true));
}, [card.prevListId, dispatch]);
@@ -300,10 +275,9 @@ const StoryContent = React.memo(({ onClose }) => {
const BoardMembershipsPopup = usePopupInClosableContext(BoardMembershipsStep);
const LabelsPopup = usePopupInClosableContext(LabelsStep);
const ListsPopup = usePopupInClosableContext(ListsStep);
const SelectCardTypePopup = usePopupInClosableContext(SelectCardTypeStep);
const AddAttachmentPopup = usePopupInClosableContext(AddAttachmentStep);
const AddCustomFieldGroupPopup = usePopupInClosableContext(AddCustomFieldGroupStep);
const MoveCardPopup = usePopupInClosableContext(MoveCardStep);
const MoreActionsPopup = usePopupInClosableContext(MoreActionsStep);
const ConfirmationPopup = usePopupInClosableContext(ConfirmationStep);
return (
@@ -614,40 +588,6 @@ const StoryContent = React.memo(({ onClose }) => {
)}
</Button>
)}
{!board.limitCardTypesToDefaultOne && canEditType && (
<SelectCardTypePopup
withButton
defaultValue={card.type}
title="common.editType"
buttonContent="action.save"
onSelect={handleTypeSelect}
>
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
<Icon name="map outline" className={styles.actionIcon} />
{t('action.editType', {
context: 'title',
})}
</Button>
</SelectCardTypePopup>
)}
{canDuplicate && (
<Button
fluid
className={classNames(styles.actionButton, styles.hidable)}
onClick={handleDuplicateClick}
>
<Icon name="copy outline" className={styles.actionIcon} />
{t('action.duplicate')}
</Button>
)}
{canMove && (
<MoveCardPopup id={card.id}>
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
<Icon name="share square outline" className={styles.actionIcon} />
{t('action.move')}
</Button>
</MoveCardPopup>
)}
{canRestore && (isInArchiveList || isInTrashList) && (
<Button
fluid
@@ -697,6 +637,16 @@ const StoryContent = React.memo(({ onClose }) => {
</Button>
</ConfirmationPopup>
)}
{((!board.limitCardTypesToDefaultOne && canEditType) ||
canDuplicate ||
canMove) && (
<MoreActionsPopup>
<Button fluid className={classNames(styles.moreActionsButton, styles.hidable)}>
<Icon name="ellipsis horizontal" className={styles.moreActionsButtonIcon} />
{t('common.moreActions')}
</Button>
</MoreActionsPopup>
)}
</div>
)}
</div>
@@ -706,8 +656,4 @@ const StoryContent = React.memo(({ onClose }) => {
);
});
StoryContent.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default StoryContent;

View File

@@ -286,6 +286,28 @@
margin-bottom: 20px;
}
.moreActionsButton {
background: transparent;
box-shadow: none;
color: #6b808c;
font-weight: normal;
margin-top: 8px;
padding: 6px 8px 6px 18px;
text-align: left;
text-decoration: underline;
transition: none;
&:hover {
background: rgba(9, 30, 66, 0.08);
color: #092d42;
}
}
.moreActionsButtonIcon {
margin-right: 8px;
text-decoration: none;
}
.sidebarPadding {
padding: 8px 16px 8px 8px;