309 lines
11 KiB
React
Executable File
309 lines
11 KiB
React
Executable File
/*!
|
|
* Copyright (c) 2024 PLANKA Software GmbH
|
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
|
*/
|
|
|
|
import upperFirst from 'lodash/upperFirst';
|
|
import camelCase from 'lodash/camelCase';
|
|
import React, { useCallback, useContext, useMemo, useRef, useState } from 'react';
|
|
import PropTypes from 'prop-types';
|
|
import classNames from 'classnames';
|
|
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Draggable, Droppable } from 'react-beautiful-dnd';
|
|
import { Button, Icon } from 'semantic-ui-react';
|
|
import { useDidUpdate, useToggle, useTransitioning } from '../../../lib/hooks';
|
|
import { Tooltip } from '../../../lib/custom-ui';
|
|
import { usePopup } from '../../../lib/popup';
|
|
|
|
import selectors from '../../../selectors';
|
|
import entryActions from '../../../entry-actions';
|
|
import { BoardShortcutsContext } from '../../../contexts';
|
|
import DroppableTypes from '../../../constants/DroppableTypes';
|
|
import { BoardMembershipRoles, ListTypes } from '../../../constants/Enums';
|
|
import { ListTypeIcons } from '../../../constants/Icons';
|
|
import EditName from './EditName';
|
|
import ActionsStep from './ActionsStep';
|
|
import DraggableCard from '../../cards/DraggableCard';
|
|
import AddCard from '../../cards/AddCard';
|
|
import ArchiveCardsStep from '../../cards/ArchiveCardsStep';
|
|
import PlusMathIcon from '../../../assets/images/plus-math-icon.svg?react';
|
|
|
|
import styles from './List.module.scss';
|
|
import globalStyles from '../../../styles.module.scss';
|
|
|
|
const AddCardPositions = {
|
|
TOP: 'top',
|
|
BOTTOM: 'bottom',
|
|
};
|
|
|
|
const INDEX_BY_ADD_CARD_POSITION = {
|
|
[AddCardPositions.TOP]: 0,
|
|
};
|
|
|
|
const List = React.memo(({ id, index }) => {
|
|
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
|
|
|
|
const selectFilteredCardIdsByListId = useMemo(
|
|
() => selectors.makeSelectFilteredCardIdsByListId(),
|
|
[],
|
|
);
|
|
|
|
const clipboard = useSelector(selectors.selectClipboard);
|
|
const isFavoritesActive = useSelector(selectors.selectIsFavoritesActiveForCurrentUser);
|
|
|
|
const list = useSelector((state) => selectListById(state, id));
|
|
const cardIds = useSelector((state) => selectFilteredCardIdsByListId(state, id));
|
|
|
|
const { canEdit, canArchiveCards, canAddCard, canPasteCard, canDropCard } = useSelector(
|
|
(state) => {
|
|
const isEditModeEnabled = selectors.selectIsEditModeEnabled(state); // TODO: move out?
|
|
|
|
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
|
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
|
|
|
return {
|
|
canEdit: isEditModeEnabled && isEditor,
|
|
canArchiveCards: list.type === ListTypes.CLOSED && isEditor,
|
|
canAddCard: isEditor,
|
|
canPasteCard: isEditor,
|
|
canDropCard: isEditor,
|
|
};
|
|
},
|
|
shallowEqual,
|
|
);
|
|
|
|
const dispatch = useDispatch();
|
|
const [t] = useTranslation();
|
|
const [isEditNameOpened, setIsEditNameOpened] = useState(false);
|
|
const [addCardPosition, setAddCardPosition] = useState(null);
|
|
const [scrollBottomState, scrollBottom] = useToggle();
|
|
const [handleListMouseEnter, handleListMouseLeave] = useContext(BoardShortcutsContext);
|
|
|
|
const wrapperRef = useRef(null);
|
|
const cardsWrapperRef = useRef(null);
|
|
|
|
const handleCardCreate = useCallback(
|
|
(data, autoOpen) => {
|
|
dispatch(
|
|
entryActions.createCard(id, data, INDEX_BY_ADD_CARD_POSITION[addCardPosition], autoOpen),
|
|
);
|
|
},
|
|
[id, dispatch, addCardPosition],
|
|
);
|
|
|
|
const handlePasteCardClick = useCallback(() => {
|
|
dispatch(entryActions.pasteCard(id));
|
|
scrollBottom();
|
|
}, [id, dispatch, scrollBottom]);
|
|
|
|
const handleMouseEnter = useCallback(() => {
|
|
handleListMouseEnter(id, () => {
|
|
scrollBottom();
|
|
});
|
|
}, [id, scrollBottom, handleListMouseEnter]);
|
|
|
|
const handleHeaderClick = useCallback(() => {
|
|
if (list.isPersisted && canEdit) {
|
|
setIsEditNameOpened(true);
|
|
}
|
|
}, [list.isPersisted, canEdit]);
|
|
|
|
const handleAddCardClick = useCallback(() => {
|
|
setAddCardPosition(AddCardPositions.BOTTOM);
|
|
}, []);
|
|
|
|
const handleAddCardClose = useCallback(() => {
|
|
setAddCardPosition(null);
|
|
}, []);
|
|
|
|
const handleCardAdd = useCallback(() => {
|
|
setAddCardPosition(AddCardPositions.TOP);
|
|
}, []);
|
|
|
|
const handleNameEdit = useCallback(() => {
|
|
setIsEditNameOpened(true);
|
|
}, []);
|
|
|
|
const handleEditNameClose = useCallback(() => {
|
|
setIsEditNameOpened(false);
|
|
}, []);
|
|
|
|
const handleWrapperTransitionEnd = useTransitioning(
|
|
wrapperRef,
|
|
styles.outerWrapperTransitioning,
|
|
[isFavoritesActive],
|
|
);
|
|
|
|
useDidUpdate(() => {
|
|
if (!addCardPosition) {
|
|
return;
|
|
}
|
|
|
|
cardsWrapperRef.current.scrollTop =
|
|
addCardPosition === AddCardPositions.TOP ? 0 : cardsWrapperRef.current.scrollHeight;
|
|
}, [cardIds, addCardPosition]);
|
|
|
|
useDidUpdate(() => {
|
|
cardsWrapperRef.current.scrollTop = cardsWrapperRef.current.scrollHeight;
|
|
}, [scrollBottomState]);
|
|
|
|
const ActionsPopup = usePopup(ActionsStep);
|
|
const ArchiveCardsPopup = usePopup(ArchiveCardsStep);
|
|
|
|
const addCardNode = canAddCard && (
|
|
<AddCard
|
|
isOpened={!!addCardPosition}
|
|
className={styles.addCard}
|
|
onCreate={handleCardCreate}
|
|
onClose={handleAddCardClose}
|
|
/>
|
|
);
|
|
|
|
const cardsNode = (
|
|
<Droppable
|
|
droppableId={`list:${id}`}
|
|
type={DroppableTypes.CARD}
|
|
isDropDisabled={!list.isPersisted || !canDropCard}
|
|
>
|
|
{({ innerRef, droppableProps, placeholder }) => (
|
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
|
<div {...droppableProps} ref={innerRef}>
|
|
<div className={styles.cards}>
|
|
{addCardPosition === AddCardPositions.TOP && addCardNode}
|
|
{cardIds.map((cardId, cardIndex) => (
|
|
<DraggableCard key={cardId} id={cardId} index={cardIndex} className={styles.card} />
|
|
))}
|
|
{placeholder}
|
|
{addCardPosition === AddCardPositions.BOTTOM && addCardNode}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Droppable>
|
|
);
|
|
|
|
return (
|
|
<Draggable
|
|
draggableId={`list:${id}`}
|
|
index={index}
|
|
isDragDisabled={!list.isPersisted || !canEdit || isEditNameOpened}
|
|
>
|
|
{({ innerRef, draggableProps, dragHandleProps }) => (
|
|
<div
|
|
{...draggableProps} // eslint-disable-line react/jsx-props-no-spreading
|
|
data-drag-scroller
|
|
ref={innerRef}
|
|
className={styles.innerWrapper}
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={handleListMouseLeave}
|
|
>
|
|
<div
|
|
ref={wrapperRef}
|
|
className={classNames(
|
|
styles.outerWrapper,
|
|
isFavoritesActive && styles.outerWrapperWithFavorites,
|
|
list.color && globalStyles[`background${upperFirst(camelCase(list.color))}Soft`],
|
|
)}
|
|
onTransitionEnd={handleWrapperTransitionEnd}
|
|
>
|
|
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
|
|
jsx-a11y/no-static-element-interactions */}
|
|
<div
|
|
{...dragHandleProps} // eslint-disable-line react/jsx-props-no-spreading
|
|
className={classNames(styles.header, canEdit && styles.headerEditable)}
|
|
onClick={handleHeaderClick}
|
|
>
|
|
{isEditNameOpened ? (
|
|
<EditName listId={id} onClose={handleEditNameClose} />
|
|
) : (
|
|
<div className={styles.headerName}>
|
|
{list.color && (
|
|
<Icon
|
|
name="circle"
|
|
className={classNames(
|
|
styles.headerNameColor,
|
|
globalStyles[`color${upperFirst(camelCase(list.color))}`],
|
|
)}
|
|
/>
|
|
)}
|
|
{list.name}
|
|
</div>
|
|
)}
|
|
{list.type !== ListTypes.ACTIVE && (
|
|
<Icon
|
|
name={ListTypeIcons[list.type]}
|
|
className={classNames(
|
|
styles.headerIcon,
|
|
list.isPersisted && (canEdit || canArchiveCards) && styles.headerIconHidable,
|
|
)}
|
|
/>
|
|
)}
|
|
{list.isPersisted &&
|
|
(canEdit ? (
|
|
<ActionsPopup listId={id} onNameEdit={handleNameEdit} onCardAdd={handleCardAdd}>
|
|
<Tooltip content={t('common.openListActions')}>
|
|
<Button className={styles.headerButton}>
|
|
<Icon fitted name="pencil" size="small" />
|
|
</Button>
|
|
</Tooltip>
|
|
</ActionsPopup>
|
|
) : (
|
|
canArchiveCards && (
|
|
<ArchiveCardsPopup listId={id}>
|
|
<Tooltip content={t('action.archiveCards')}>
|
|
<Button className={styles.headerButton}>
|
|
<Icon fitted name="archive" size="small" />
|
|
</Button>
|
|
</Tooltip>
|
|
</ArchiveCardsPopup>
|
|
)
|
|
))}
|
|
</div>
|
|
<div ref={cardsWrapperRef} className={styles.cardsInnerWrapper}>
|
|
<div className={styles.cardsOuterWrapper}>{cardsNode}</div>
|
|
</div>
|
|
{!addCardPosition && canAddCard && (
|
|
<div className={styles.addCardButtonWrapper}>
|
|
<button
|
|
type="button"
|
|
disabled={!list.isPersisted}
|
|
className={classNames(
|
|
styles.addCardButton,
|
|
list.color &&
|
|
globalStyles[`background${upperFirst(camelCase(list.color))}Soft`],
|
|
)}
|
|
onClick={handleAddCardClick}
|
|
>
|
|
<PlusMathIcon className={styles.addCardButtonIcon} />
|
|
<span className={styles.addCardButtonText}>
|
|
{cardIds.length > 0 ? t('action.addAnotherCard') : t('action.addCard')}
|
|
</span>
|
|
</button>
|
|
{clipboard && canPasteCard && (
|
|
<Tooltip content={t('action.paste')}>
|
|
<button
|
|
type="button"
|
|
disabled={!list.isPersisted}
|
|
className={classNames(styles.addCardButton, styles.paste)}
|
|
onClick={handlePasteCardClick}
|
|
>
|
|
<Icon name="paste" />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Draggable>
|
|
);
|
|
});
|
|
|
|
List.propTypes = {
|
|
id: PropTypes.string.isRequired,
|
|
index: PropTypes.number.isRequired,
|
|
};
|
|
|
|
export default List;
|