1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-27 09:09:46 +02:00

feat: Version 2

Closes #627, closes #1047
This commit is contained in:
Maksim Eltyshev 2025-05-10 02:09:06 +02:00
parent ad7fb51cfa
commit 2ee1166747
1557 changed files with 76832 additions and 47042 deletions

View file

@ -0,0 +1,180 @@
/*!
* 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 { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { 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 { ListTypes } from '../../../constants/Enums';
import EditColorStep from './EditColorStep';
import SortStep from './SortStep';
import SelectListTypeStep from '../SelectListTypeStep';
import ConfirmationStep from '../../common/ConfirmationStep';
import ArchiveCardsStep from '../../cards/ArchiveCardsStep';
import styles from './ActionsStep.module.scss';
const StepTypes = {
EDIT_TYPE: 'EDIT_TYPE',
EDIT_COLOR: 'EDIT_COLOR',
SORT: 'SORT',
ARCHIVE_CARDS: 'ARCHIVE_CARDS',
DELETE: 'DELETE',
};
const ActionsStep = React.memo(({ listId, onNameEdit, onCardAdd, onClose }) => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const list = useSelector((state) => selectListById(state, listId));
const dispatch = useDispatch();
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const handleTypeSelect = useCallback(
(type) => {
dispatch(
entryActions.updateList(listId, {
type,
}),
);
},
[listId, dispatch],
);
const handleDeleteConfirm = useCallback(() => {
dispatch(entryActions.deleteList(listId));
}, [listId, dispatch]);
const handleEditNameClick = useCallback(() => {
onNameEdit();
onClose();
}, [onNameEdit, onClose]);
const handleAddCardClick = useCallback(() => {
onCardAdd();
onClose();
}, [onCardAdd, onClose]);
const handleEditTypeClick = useCallback(() => {
openStep(StepTypes.EDIT_TYPE);
}, [openStep]);
const handleEditColorClick = useCallback(() => {
openStep(StepTypes.EDIT_COLOR);
}, [openStep]);
const handleSortClick = useCallback(() => {
openStep(StepTypes.SORT);
}, [openStep]);
const handleArchiveCardsClick = useCallback(() => {
openStep(StepTypes.ARCHIVE_CARDS);
}, [openStep]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
if (step) {
switch (step.type) {
case StepTypes.EDIT_TYPE:
return (
<SelectListTypeStep
withButton
defaultValue={list.type}
title="common.editType"
buttonContent="action.save"
onSelect={handleTypeSelect}
onBack={handleBack}
onClose={onClose}
/>
);
case StepTypes.EDIT_COLOR:
return <EditColorStep listId={listId} onBack={handleBack} onClose={onClose} />;
case StepTypes.SORT:
return <SortStep listId={listId} onBack={handleBack} onClose={onClose} />;
case StepTypes.ARCHIVE_CARDS:
return <ArchiveCardsStep listId={listId} onBack={handleBack} onClose={onClose} />;
case StepTypes.DELETE:
return (
<ConfirmationStep
title="common.deleteList"
content="common.areYouSureYouWantToDeleteThisList"
buttonContent="action.deleteList"
onConfirm={handleDeleteConfirm}
onBack={handleBack}
/>
);
default:
}
}
return (
<>
<Popup.Header>
{t('common.listActions', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
<Menu.Item className={styles.menuItem} onClick={handleEditNameClick}>
{t('action.editTitle', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditTypeClick}>
{t('action.editType', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditColorClick}>
{t('action.editColor', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleAddCardClick}>
{t('action.addCard', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleSortClick}>
{t('action.sortList', {
context: 'title',
})}
</Menu.Item>
{list.type === ListTypes.CLOSED && (
<Menu.Item className={styles.menuItem} onClick={handleArchiveCardsClick}>
{t('action.archiveCards', {
context: 'title',
})}
</Menu.Item>
)}
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{t('action.deleteList', {
context: 'title',
})}
</Menu.Item>
</Menu>
</Popup.Content>
</>
);
});
ActionsStep.propTypes = {
listId: PropTypes.string.isRequired,
onNameEdit: PropTypes.func.isRequired,
onCardAdd: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default ActionsStep;

View file

@ -0,0 +1,16 @@
/*!
* 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;
}
}

View file

@ -0,0 +1,94 @@
/*!
* 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, useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button } from 'semantic-ui-react';
import { Popup } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import LIST_COLORS from '../../../constants/ListColors';
import styles from './EditColorStep.module.scss';
import globalStyles from '../../../styles.module.scss';
const EditColorStep = React.memo(({ listId, onBack, onClose }) => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const defaultValue = useSelector((state) => selectListById(state, listId).color);
const dispatch = useDispatch();
const [t] = useTranslation();
const handleSelectClick = useCallback(
(_, { value: color }) => {
dispatch(
entryActions.updateList(listId, {
color,
}),
);
},
[listId, dispatch],
);
const handleClearClick = useCallback(() => {
dispatch(
entryActions.updateList(listId, {
color: null,
}),
);
onClose();
}, [listId, onClose, dispatch]);
return (
<>
<Popup.Header onBack={onBack}>
{t('common.editColor', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<div className={styles.colorButtons}>
{LIST_COLORS.map((color) => (
<Button
key={color}
type="button"
value={color}
className={classNames(
styles.colorButton,
color === defaultValue && styles.colorButtonActive,
globalStyles[`background${upperFirst(camelCase(color))}`],
)}
onClick={handleSelectClick}
/>
))}
</div>
{defaultValue && (
<Button
fluid
content={t('action.removeColor')}
className={styles.clearButton}
onClick={handleClearClick}
/>
)}
</Popup.Content>
</>
);
});
EditColorStep.propTypes = {
listId: PropTypes.string.isRequired,
onBack: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default EditColorStep;

View file

@ -0,0 +1,60 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.clearButton {
background: transparent;
box-shadow: none;
color: #6b808c;
font-weight: normal;
margin-top: 8px;
padding: 6px 11px;
text-align: left;
text-decoration: underline;
transition: background 0.3s ease;
&:hover {
background: #e9e9e9;
}
}
.colorButton {
float: left;
height: 40px;
margin: 4px;
padding: 0;
position: relative;
width: 49.6px;
&:hover {
opacity: 0.9;
}
}
.colorButtonActive:before {
bottom: 3px;
color: #ffffff;
content: "Г";
font-size: 18px;
line-height: 36px;
position: absolute;
right: 6px;
text-align: center;
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.2);
top: 0;
transform: rotate(-135deg);
width: 36px;
}
.colorButtons {
margin: -4px;
&:after {
clear: both;
content: "";
display: table;
}
}
}

View file

@ -0,0 +1,94 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import TextareaAutosize from 'react-textarea-autosize';
import { TextArea } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useField, useNestedRef } from '../../../hooks';
import { focusEnd } from '../../../utils/element-helpers';
import styles from './EditName.module.scss';
const EditName = React.memo(({ listId, onClose }) => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const defaultValue = useSelector((state) => selectListById(state, listId).name);
const dispatch = useDispatch();
const [value, handleFieldChange] = useField(defaultValue);
const [fieldRef, handleFieldRef] = useNestedRef();
const submit = useCallback(() => {
const cleanValue = value.trim();
if (cleanValue && cleanValue !== defaultValue) {
dispatch(
entryActions.updateList(listId, {
name: cleanValue,
}),
);
}
onClose();
}, [listId, defaultValue, dispatch, value, onClose]);
const handleFieldClick = useCallback((event) => {
event.stopPropagation();
}, []);
const handleFieldKeyDown = useCallback(
(event) => {
switch (event.key) {
case 'Enter':
event.preventDefault();
submit();
break;
case 'Escape':
submit();
break;
default:
}
},
[submit],
);
const handleFieldBlur = useCallback(() => {
submit();
}, [submit]);
useEffect(() => {
focusEnd(fieldRef.current);
}, [fieldRef]);
return (
<TextArea
ref={handleFieldRef}
as={TextareaAutosize}
value={value}
maxLength={128}
spellCheck={false}
className={styles.field}
onClick={handleFieldClick}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
/>
);
});
EditName.propTypes = {
listId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
};
export default EditName;

View file

@ -0,0 +1,28 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.field {
border: none;
border-color: #bbb;
border-radius: 3px;
color: #17394d;
display: block;
font-weight: bold;
line-height: 20px;
margin: 0;
outline: none;
overflow: hidden;
padding: 4px 8px;
resize: none;
width: 100%;
&:focus {
background: #fff;
border-color: #5ba4cf;
box-shadow: 0 0 0 1px #5ba4cf;
}
}
}

View file

@ -0,0 +1,241 @@
/*!
* 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, 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, useTransitioning } from '../../../lib/hooks';
import { usePopup } from '../../../lib/popup';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
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 List = React.memo(({ id, index }) => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const selectFilteredCardIdsByListId = useMemo(
() => selectors.makeSelectFilteredCardIdsByListId(),
[],
);
const isFavoritesActive = useSelector(selectors.selectIsFavoritesActiveForCurrentUser);
const list = useSelector((state) => selectListById(state, id));
const cardIds = useSelector((state) => selectFilteredCardIdsByListId(state, id));
const { canEdit, canArchiveCards, canAddCard, 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,
canDropCard: isEditor,
};
}, shallowEqual);
const dispatch = useDispatch();
const [t] = useTranslation();
const [isEditNameOpened, setIsEditNameOpened] = useState(false);
const [isAddCardOpened, setIsAddCardOpened] = useState(false);
const wrapperRef = useRef(null);
const cardsWrapperRef = useRef(null);
const handleCardCreate = useCallback(
(data, autoOpen) => {
dispatch(entryActions.createCard(id, data, autoOpen));
},
[id, dispatch],
);
const handleHeaderClick = useCallback(() => {
if (list.isPersisted && canEdit) {
setIsEditNameOpened(true);
}
}, [list.isPersisted, canEdit]);
const handleAddCardClick = useCallback(() => {
setIsAddCardOpened(true);
}, []);
const handleAddCardClose = useCallback(() => {
setIsAddCardOpened(false);
}, []);
const handleCardAdd = useCallback(() => {
setIsAddCardOpened(true);
}, []);
const handleNameEdit = useCallback(() => {
setIsEditNameOpened(true);
}, []);
const handleEditNameClose = useCallback(() => {
setIsEditNameOpened(false);
}, []);
const handleWrapperTransitionEnd = useTransitioning(
wrapperRef,
styles.outerWrapperTransitioning,
[isFavoritesActive],
);
useDidUpdate(() => {
if (isAddCardOpened) {
cardsWrapperRef.current.scrollTop = cardsWrapperRef.current.scrollHeight;
}
}, [cardIds, isAddCardOpened]);
const ActionsPopup = usePopup(ActionsStep);
const ArchiveCardsPopup = usePopup(ArchiveCardsStep);
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}>
{cardIds.map((cardId, cardIndex) => (
<DraggableCard key={cardId} id={cardId} index={cardIndex} className={styles.card} />
))}
{placeholder}
{canAddCard && (
<AddCard
isOpened={isAddCardOpened}
className={styles.addCard}
onCreate={handleCardCreate}
onClose={handleAddCardClose}
/>
)}
</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}
>
<div
ref={wrapperRef}
className={classNames(
styles.outerWrapper,
isFavoritesActive && styles.outerWrapperWithFavorites,
)}
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}>
<Button className={styles.headerButton}>
<Icon fitted name="pencil" size="small" />
</Button>
</ActionsPopup>
) : (
canArchiveCards && (
<ArchiveCardsPopup listId={id}>
<Button className={styles.headerButton}>
<Icon fitted name="archive" size="small" />
</Button>
</ArchiveCardsPopup>
)
))}
</div>
<div ref={cardsWrapperRef} className={styles.cardsInnerWrapper}>
<div className={styles.cardsOuterWrapper}>{cardsNode}</div>
</div>
{!isAddCardOpened && canAddCard && (
<button
type="button"
disabled={!list.isPersisted}
className={styles.addCardButton}
onClick={handleAddCardClick}
>
<PlusMathIcon className={styles.addCardButtonIcon} />
<span className={styles.addCardButtonText}>
{cardIds.length > 0 ? t('action.addAnotherCard') : t('action.addCard')}
</span>
</button>
)}
</div>
</div>
)}
</Draggable>
);
});
List.propTypes = {
id: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
};
export default List;

View file

@ -0,0 +1,172 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.addCard {
margin-bottom: 8px;
}
.addCardButton {
background: #dfe3e6;
border: none;
color: #6b808c;
cursor: pointer;
display: block;
fill: #6b808c;
flex: 0 0 auto;
font-weight: normal;
height: 36px;
outline: none;
padding: 8px;
text-align: left;
width: 100%;
&:hover {
background: #c3cbd0;
color: #17394d;
fill: #17394d;
}
}
.addCardButtonIcon {
height: 20px;
padding: 0.64px;
width: 20px;
}
.addCardButtonText {
display: inline-block;
font-size: 14px;
line-height: 20px;
margin-left: 2px;
vertical-align: top;
}
.card {
margin-bottom: 8px;
}
.cards {
min-height: 1px;
}
.cardsInnerWrapper {
overflow-x: hidden;
overflow-y: auto;
width: 290px;
@supports (-moz-appearance: none) {
scrollbar-color: rgba(0, 0, 0, 0.32) transparent;
scrollbar-width: thin;
}
&:hover {
width: 272px;
}
&::-webkit-scrollbar {
width: 5px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
}
}
.cardsOuterWrapper {
padding: 0 8px;
white-space: normal;
width: 272px;
}
.header {
outline: none;
padding: 6px 36px 6px 8px;
position: relative;
&:hover {
.headerButton {
opacity: 1;
}
.headerIconHidable {
opacity: 0;
}
}
}
.headerEditable {
cursor: pointer;
}
.headerButton {
background: none;
box-shadow: none;
color: #798d99;
line-height: 32px;
margin: 0;
opacity: 0;
padding: 0;
position: absolute;
right: 4px;
top: 4px;
width: 32px;
&:hover {
background: rgba(9, 30, 66, 0.13);
color: #516b7a;
}
}
.headerIcon {
color: #798d99;
position: absolute;
right: 8px;
top: 10px;
}
.headerName {
color: #17394d;
font-weight: bold;
line-height: 20px;
max-height: 256px;
outline: none;
overflow: hidden;
overflow-wrap: break-word;
padding: 4px 8px;
word-break: break-word;
}
.headerNameColor {
line-height: 1;
margin-right: 6px;
}
.innerWrapper {
margin-right: 8px;
width: 272px;
}
.outerWrapper {
background: #dfe3e6;
border-radius: 3px;
display: flex;
flex-direction: column;
max-height: calc(100vh - 198px);
overflow: hidden;
}
.outerWrapperTransitioning {
transition: max-height 0.2s ease;
}
.outerWrapperWithFavorites {
max-height: calc(100vh - 288px);
}
}

View file

@ -0,0 +1,86 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react';
import { Popup } from '../../../lib/custom-ui';
import entryActions from '../../../entry-actions';
import { ListSortFieldNames, SortOrders } from '../../../constants/Enums';
import styles from './ActionsStep.module.scss';
const Types = {
ALPHABETICALLY: 'alphabetically',
BY_DUE_DATE: 'byDueDate',
OLDEST_FIRST: 'oldestFirst',
NEWEST_FIRST: 'newestFirst',
};
const DATA_BY_TYPE = {
[Types.ALPHABETICALLY]: {
fieldName: ListSortFieldNames.NAME,
},
[Types.BY_DUE_DATE]: {
fieldName: ListSortFieldNames.DUE_DATE,
},
[Types.OLDEST_FIRST]: {
fieldName: ListSortFieldNames.CREATED_AT,
},
[Types.NEWEST_FIRST]: {
fieldName: ListSortFieldNames.CREATED_AT,
order: SortOrders.DESC,
},
};
const SortStep = React.memo(({ listId, onBack, onClose }) => {
const dispatch = useDispatch();
const [t] = useTranslation();
const handleSelectTypeClick = useCallback(
(_, { value: type }) => {
dispatch(entryActions.sortList(listId, DATA_BY_TYPE[type]));
onClose();
},
[listId, onClose, dispatch],
);
return (
<>
<Popup.Header onBack={onBack}>
{t('common.sortList', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
{[Types.ALPHABETICALLY, Types.BY_DUE_DATE, Types.OLDEST_FIRST, Types.NEWEST_FIRST].map(
(type) => (
<Menu.Item
key={type}
value={type}
className={styles.menuItem}
onClick={handleSelectTypeClick}
>
{t(`common.${type}`)}
</Menu.Item>
),
)}
</Menu>
</Popup.Content>
</>
);
});
SortStep.propTypes = {
listId: PropTypes.string.isRequired,
onBack: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default SortStep;

View file

@ -0,0 +1,16 @@
/*!
* 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;
}
}

View file

@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import List from './List';
export default List;

View file

@ -0,0 +1,55 @@
/*!
* 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 classNames from 'classnames';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Icon } from 'semantic-ui-react';
import selectors from '../../../selectors';
import { ListTypes } from '../../../constants/Enums';
import { ListTypeIcons } from '../../../constants/Icons';
import styles from './Item.module.scss';
const Item = React.memo(({ id, isActive, onSelect }) => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const list = useSelector((state) => selectListById(state, id));
const [t] = useTranslation();
const handleSelectClick = useCallback(() => {
if (!isActive && list.isPersisted) {
onSelect(id);
}
}, [id, isActive, onSelect, list.isPersisted]);
return (
<div className={styles.wrapper}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<span
className={classNames(styles.name, isActive && styles.nameActive)}
onClick={handleSelectClick}
>
{list.type !== ListTypes.ACTIVE && (
<Icon name={ListTypeIcons[list.type]} className={styles.nameIcon} />
)}
{list.name || t(`common.${list.type}`)}
</span>
</div>
);
});
Item.propTypes = {
id: PropTypes.string.isRequired,
isActive: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired,
};
export default Item;

View file

@ -0,0 +1,54 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.name {
background: rgba(9, 30, 66, 0.04);
border-radius: 3px;
color: #17394d;
cursor: pointer;
flex: 1 1 auto;
font-size: 14px;
overflow: hidden;
padding: 8px 32px 8px 10px;
position: relative;
text-overflow: ellipsis;
&:hover {
background: rgba(9, 30, 66, 0.08);
}
}
.nameActive {
opacity: 0.45;
&:before {
bottom: 1px;
content: "Г";
font-size: 18px;
font-weight: normal;
line-height: 36px;
position: absolute;
right: 2px;
text-align: center;
transform: rotate(-135deg);
width: 36px;
}
}
.nameIcon {
color: rgba(9, 30, 66, 0.24);
font-size: 12px;
margin: 0 8px 0 0;
width: 14px;
}
.wrapper {
display: flex;
margin-bottom: 4px;
max-width: 280px;
white-space: nowrap;
}
}

View file

@ -0,0 +1,80 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Input, Popup } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
import { useField, useNestedRef } from '../../../hooks';
import Item from './Item';
import styles from './ListsStep.module.scss';
const ListsStep = React.memo(({ currentId, onSelect }) => {
const lists = useSelector(selectors.selectAvailableListsForCurrentBoard);
const [t] = useTranslation();
const [search, handleSearchChange] = useField('');
const cleanSearch = useMemo(() => search.trim().toLowerCase(), [search]);
const filteredLists = useMemo(
() =>
lists.filter((list) =>
(list.name ? list.name.toLowerCase() : list.type).includes(cleanSearch),
),
[lists, cleanSearch],
);
const [searchFieldRef, handleSearchFieldRef] = useNestedRef('inputRef');
useEffect(() => {
searchFieldRef.current.focus({
preventScroll: true,
});
}, [searchFieldRef]);
return (
<>
<Popup.Header>
{t('common.lists', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Input
fluid
ref={handleSearchFieldRef}
value={search}
placeholder={t('common.searchLists')}
maxLength={128}
icon="search"
onChange={handleSearchChange}
/>
{filteredLists.length > 0 && (
<div className={styles.items}>
{filteredLists.map((list) => (
<Item
key={list.id}
id={list.id}
isActive={list.id === currentId}
onSelect={onSelect}
/>
))}
</div>
)}
</Popup.Content>
</>
);
});
ListsStep.propTypes = {
currentId: PropTypes.string.isRequired,
onSelect: PropTypes.func.isRequired,
};
export default ListsStep;

View file

@ -0,0 +1,32 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.items {
margin-top: 8px;
max-height: 60vh;
overflow-x: hidden;
overflow-y: auto;
@supports (-moz-appearance: none) {
scrollbar-color: rgba(0, 0, 0, 0.32) transparent;
scrollbar-width: thin;
}
&::-webkit-scrollbar {
width: 9px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-clip: padding-box;
border-left: 0.25em transparent solid;
border-radius: 3px;
}
}
}

View file

@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import ListsStep from './ListsStep';
export default ListsStep;

View file

@ -0,0 +1,99 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useState } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form, Icon, Menu } from 'semantic-ui-react';
import { Popup } from '../../../lib/custom-ui';
import { ListTypes } from '../../../constants/Enums';
import { ListTypeIcons } from '../../../constants/Icons';
import styles from './SelectListTypeStep.module.scss';
const DESCRIPTION_BY_TYPE = {
[ListTypes.ACTIVE]: 'common.cardsOnThisListAreReadyToBeWorkedOn',
[ListTypes.CLOSED]: 'common.cardsOnThisListAreCompleteAndReadyToBeArchived',
};
const SelectListTypeStep = React.memo(
({ defaultValue, title, withButton, buttonContent, onSelect, onBack, onClose }) => {
const [t] = useTranslation();
const [value, setValue] = useState(defaultValue);
const handleSelectClick = useCallback(
(_, { value: nextValue }) => {
if (withButton) {
setValue(nextValue);
} else {
if (nextValue !== defaultValue) {
onSelect(nextValue);
}
onClose();
}
},
[defaultValue, withButton, onSelect, onClose],
);
const handleSubmit = useCallback(() => {
if (value !== defaultValue) {
onSelect(value);
}
onClose();
}, [defaultValue, onSelect, onClose, value]);
return (
<>
<Popup.Header onBack={onBack}>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<Menu secondary vertical className={styles.menu}>
{[ListTypes.ACTIVE, ListTypes.CLOSED].map((type) => (
<Menu.Item
key={type}
value={type}
active={type === value}
className={styles.menuItem}
onClick={handleSelectClick}
>
<Icon name={ListTypeIcons[type]} className={styles.menuItemIcon} />
<div className={styles.menuItemTitle}>{t(`common.${type}`)}</div>
<p className={styles.menuItemDescription}>{t(DESCRIPTION_BY_TYPE[type])}</p>
</Menu.Item>
))}
</Menu>
{withButton && <Button positive content={t(buttonContent)} />}
</Form>
</Popup.Content>
</>
);
},
);
SelectListTypeStep.propTypes = {
defaultValue: PropTypes.string.isRequired,
title: PropTypes.string,
withButton: PropTypes.bool,
buttonContent: PropTypes.string,
onSelect: PropTypes.func.isRequired,
onBack: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
SelectListTypeStep.defaultProps = {
title: 'common.selectType',
withButton: false,
buttonContent: 'action.selectType',
onBack: undefined,
};
export default SelectListTypeStep;

View file

@ -0,0 +1,28 @@
/*!
* 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: 0 auto 8px;
width: 100%;
}
.menuItem:last-child {
margin-bottom: 0;
}
.menuItemDescription {
opacity: 0.5;
}
.menuItemIcon {
float: left;
margin: 0 0.35714286em 0 0;
}
.menuItemTitle {
margin-bottom: 8px;
}
}

View file

@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import SelectListTypeStep from './SelectListTypeStep';
export default SelectListTypeStep;