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
@@ -0,0 +1,392 @@
/*!
* 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 { 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, CardTypes, ListTypes } from '../../../constants/Enums';
import SelectCardTypeStep from '../SelectCardTypeStep';
import EditDueDateStep from '../EditDueDateStep';
import EditStopwatchStep from '../EditStopwatchStep';
import MoveCardStep from '../MoveCardStep';
import ConfirmationStep from '../../common/ConfirmationStep';
import BoardMembershipsStep from '../../board-memberships/BoardMembershipsStep';
import LabelsStep from '../../labels/LabelsStep';
import styles from './ActionsStep.module.scss';
const StepTypes = {
EDIT_TYPE: 'EDIT_TYPE',
USERS: 'USERS',
LABELS: 'LABELS',
EDIT_DUE_DATE: 'EDIT_DUE_DATE',
EDIT_STOPWATCH: 'EDIT_STOPWATCH',
MOVE: 'MOVE',
ARCHIVE: 'ARCHIVE',
DELETE: 'DELETE',
};
const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const selectPrevListById = useMemo(() => selectors.makeSelectListById(), []);
const selectUserIdsByCardId = useMemo(() => selectors.makeSelectUserIdsByCardId(), []);
const selectLabelIdsByCardId = useMemo(() => selectors.makeSelectLabelIdsByCardId(), []);
const board = useSelector(selectors.selectCurrentBoard);
const card = useSelector((state) => selectCardById(state, cardId));
const list = useSelector((state) => selectListById(state, card.listId));
// TODO: check availability?
const prevList = useSelector(
(state) => card.prevListId && selectPrevListById(state, card.prevListId),
);
const userIds = useSelector((state) => selectUserIdsByCardId(state, cardId));
const labelIds = useSelector((state) => selectLabelIdsByCardId(state, cardId));
const {
canEditType,
canEditName,
canEditDueDate,
canEditStopwatch,
canDuplicate,
canMove,
canRestore,
canArchive,
canDelete,
canUseMembers,
canUseLabels,
} = useSelector((state) => {
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
if (isListArchiveOrTrash(list)) {
return {
canEditType: false,
canEditName: false,
canEditDueDate: false,
canEditStopwatch: false,
canDuplicate: false,
canMove: false,
canRestore: isEditor,
canArchive: isEditor,
canDelete: isEditor,
canUseMembers: false,
canUseLabels: false,
};
}
return {
canEditType: isEditor,
canEditName: isEditor,
canEditDueDate: isEditor,
canEditStopwatch: isEditor,
canDuplicate: isEditor,
canMove: isEditor,
canRestore: null,
canArchive: isEditor,
canDelete: isEditor,
canUseMembers: isEditor,
canUseLabels: isEditor,
};
}, shallowEqual);
const dispatch = useDispatch();
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const handleTypeSelect = useCallback(
(type) => {
dispatch(
entryActions.updateCard(cardId, {
type,
}),
);
},
[cardId, dispatch],
);
const handleDuplicateClick = useCallback(() => {
dispatch(
entryActions.duplicateCard(cardId, {
name: `${card.name} (${t('common.copy', {
context: 'inline',
})})`,
}),
);
onClose();
}, [cardId, onClose, card.name, dispatch, t]);
const handleRestoreClick = useCallback(() => {
dispatch(entryActions.moveCard(cardId, card.prevListId, undefined, true));
}, [cardId, card.prevListId, dispatch]);
const handleArchiveConfirm = useCallback(() => {
dispatch(entryActions.moveCardToArchive(cardId));
}, [cardId, dispatch]);
const isInTrashList = list.type === ListTypes.TRASH;
const handleDeleteConfirm = useCallback(() => {
if (isInTrashList) {
dispatch(entryActions.deleteCard(cardId));
} else {
dispatch(entryActions.moveCardToTrash(cardId));
}
}, [cardId, isInTrashList, dispatch]);
const handleUserSelect = useCallback(
(userId) => {
dispatch(entryActions.addUserToCard(userId, cardId));
},
[cardId, dispatch],
);
const handleUserDeselect = useCallback(
(userId) => {
dispatch(entryActions.removeUserFromCard(userId, cardId));
},
[cardId, dispatch],
);
const handleLabelSelect = useCallback(
(labelId) => {
dispatch(entryActions.addLabelToCard(labelId, cardId));
},
[cardId, dispatch],
);
const handleLabelDeselect = useCallback(
(labelId) => {
dispatch(entryActions.removeLabelFromCard(labelId, cardId));
},
[cardId, dispatch],
);
const handleEditNameClick = useCallback(() => {
onNameEdit();
onClose();
}, [onNameEdit, onClose]);
const handleEditTypeClick = useCallback(() => {
openStep(StepTypes.EDIT_TYPE);
}, [openStep]);
const handleUsersClick = useCallback(() => {
openStep(StepTypes.USERS);
}, [openStep]);
const handleLabelsClick = useCallback(() => {
openStep(StepTypes.LABELS);
}, [openStep]);
const handleEditDueDateClick = useCallback(() => {
openStep(StepTypes.EDIT_DUE_DATE);
}, [openStep]);
const handleEditStopwatchClick = useCallback(() => {
openStep(StepTypes.EDIT_STOPWATCH);
}, [openStep]);
const handleMoveClick = useCallback(() => {
openStep(StepTypes.MOVE);
}, [openStep]);
const handleArchiveClick = useCallback(() => {
openStep(StepTypes.ARCHIVE);
}, [openStep]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [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.USERS:
return (
<BoardMembershipsStep
currentUserIds={userIds}
onUserSelect={handleUserSelect}
onUserDeselect={handleUserDeselect}
onBack={handleBack}
/>
);
case StepTypes.LABELS:
return (
<LabelsStep
currentIds={labelIds}
cardId={cardId}
onSelect={handleLabelSelect}
onDeselect={handleLabelDeselect}
onBack={handleBack}
/>
);
case StepTypes.EDIT_DUE_DATE:
return <EditDueDateStep cardId={cardId} onBack={handleBack} onClose={onClose} />;
case StepTypes.EDIT_STOPWATCH:
return <EditStopwatchStep cardId={cardId} onBack={handleBack} onClose={onClose} />;
case StepTypes.MOVE:
return <MoveCardStep id={cardId} onBack={handleBack} onClose={onClose} />;
case StepTypes.ARCHIVE:
return (
<ConfirmationStep
title="common.archiveCard"
content="common.areYouSureYouWantToArchiveThisCard"
buttonContent="action.archiveCard"
onConfirm={handleArchiveConfirm}
onBack={handleBack}
/>
);
case StepTypes.DELETE:
return (
<ConfirmationStep
title={isInTrashList ? 'common.deleteCardForever' : 'common.deleteCard'}
content={
isInTrashList
? 'common.areYouSureYouWantToDeleteThisCardForever'
: 'common.areYouSureYouWantToDeleteThisCard'
}
buttonContent={isInTrashList ? 'action.deleteCardForever' : 'action.deleteCard'}
onConfirm={handleDeleteConfirm}
onBack={handleBack}
/>
);
default:
}
}
return (
<>
<Popup.Header>
{t('common.cardActions', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
{canEditName && (
<Menu.Item className={styles.menuItem} onClick={handleEditNameClick}>
{t('action.editTitle', {
context: 'title',
})}
</Menu.Item>
)}
{!board.limitCardTypesToDefaultOne && canEditType && (
<Menu.Item className={styles.menuItem} onClick={handleEditTypeClick}>
{t('action.editType', {
context: 'title',
})}
</Menu.Item>
)}
{card.type === CardTypes.PROJECT && canUseMembers && (
<Menu.Item className={styles.menuItem} onClick={handleUsersClick}>
{t('common.members', {
context: 'title',
})}
</Menu.Item>
)}
{canUseLabels && (
<Menu.Item className={styles.menuItem} onClick={handleLabelsClick}>
{t('common.labels', {
context: 'title',
})}
</Menu.Item>
)}
{card.type === CardTypes.STORY && canUseMembers && (
<Menu.Item className={styles.menuItem} onClick={handleUsersClick}>
{t('common.members', {
context: 'title',
})}
</Menu.Item>
)}
{card.type === CardTypes.PROJECT && canEditDueDate && (
<Menu.Item className={styles.menuItem} onClick={handleEditDueDateClick}>
{t('action.editDueDate', {
context: 'title',
})}
</Menu.Item>
)}
{card.type === CardTypes.PROJECT && canEditStopwatch && (
<Menu.Item className={styles.menuItem} onClick={handleEditStopwatchClick}>
{t('action.editStopwatch', {
context: 'title',
})}
</Menu.Item>
)}
{canDuplicate && (
<Menu.Item className={styles.menuItem} onClick={handleDuplicateClick}>
{t('action.duplicateCard', {
context: 'title',
})}
</Menu.Item>
)}
{canMove && (
<Menu.Item className={styles.menuItem} onClick={handleMoveClick}>
{t('action.moveCard', {
context: 'title',
})}
</Menu.Item>
)}
{prevList && canRestore && (
<Menu.Item className={styles.menuItem} onClick={handleRestoreClick}>
{t('action.restoreToList', {
context: 'title',
list: prevList.name || t(`common.${prevList.type}`),
})}
</Menu.Item>
)}
{list.type !== ListTypes.ARCHIVE && canArchive && (
<Menu.Item className={styles.menuItem} onClick={handleArchiveClick}>
{t('action.archiveCard', {
context: 'title',
})}
</Menu.Item>
)}
{canDelete && (
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{isInTrashList
? t('action.deleteForever', {
context: 'title',
})
: t('action.deleteCard', {
context: 'title',
})}
</Menu.Item>
)}
</Menu>
</Popup.Content>
</>
);
});
ActionsStep.propTypes = {
cardId: PropTypes.string.isRequired,
onNameEdit: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default ActionsStep;
@@ -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;
}
}
+146
View File
@@ -0,0 +1,146 @@
/*!
* 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, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { Button, Icon } from 'semantic-ui-react';
import { push } from '../../../lib/redux-router';
import { usePopup } from '../../../lib/popup';
import selectors from '../../../selectors';
import { isListArchiveOrTrash } from '../../../utils/record-helpers';
import Paths from '../../../constants/Paths';
import { BoardMembershipRoles, CardTypes, ListTypes } from '../../../constants/Enums';
import ProjectContent from './ProjectContent';
import StoryContent from './StoryContent';
import InlineContent from './InlineContent';
import EditName from './EditName';
import ActionsStep from './ActionsStep';
import styles from './Card.module.scss';
const Card = React.memo(({ id, isInline }) => {
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
const selectIsCardWithIdRecent = useMemo(() => selectors.makeSelectIsCardWithIdRecent(), []);
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const card = useSelector((state) => selectCardById(state, id));
const isHighlightedAsRecent = useSelector((state) => {
const { turnOffRecentCardHighlighting } = selectors.selectCurrentUser(state);
if (turnOffRecentCardHighlighting) {
return false;
}
return selectIsCardWithIdRecent(state, id);
});
const { isDisabled, canUseActions } = useSelector((state) => {
const list = selectListById(state, card.listId);
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
if (isListArchiveOrTrash(list)) {
return {
isDisabled: false,
canUseActions: isEditor,
};
}
return {
isDisabled: list.type === ListTypes.CLOSED && !isEditor,
canUseActions: isEditor,
};
}, shallowEqual);
const dispatch = useDispatch();
const [isEditNameOpened, setIsEditNameOpened] = useState(false);
const handleClick = useCallback(() => {
if (document.activeElement) {
document.activeElement.blur();
}
dispatch(push(Paths.CARDS.replace(':id', id)));
}, [id, dispatch]);
const handleNameEdit = useCallback(() => {
setIsEditNameOpened(true);
}, []);
const handleEditNameClose = useCallback(() => {
setIsEditNameOpened(false);
}, []);
const ActionsPopup = usePopup(ActionsStep);
if (isEditNameOpened) {
return <EditName cardId={id} onClose={handleEditNameClose} />;
}
let Content;
if (isInline) {
Content = InlineContent;
} else {
switch (card.type) {
case CardTypes.PROJECT:
Content = ProjectContent;
break;
case CardTypes.STORY:
Content = StoryContent;
break;
default:
}
}
return (
<div
className={classNames(
styles.wrapper,
isDisabled && styles.wrapperDisabled,
isHighlightedAsRecent && styles.wrapperRecent,
'card',
)}
>
{card.isPersisted ? (
<>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<div className={styles.content} onClick={handleClick}>
<Content cardId={id} />
</div>
{canUseActions && (
<ActionsPopup cardId={id} onNameEdit={handleNameEdit}>
<Button className={styles.actionsButton}>
<Icon fitted name="pencil" size="small" />
</Button>
</ActionsPopup>
)}
</>
) : (
<span className={styles.content}>
<Content cardId={id} />
</span>
)}
</div>
);
});
Card.propTypes = {
id: PropTypes.string.isRequired,
isInline: PropTypes.bool,
};
Card.defaultProps = {
isInline: false,
};
export default Card;
@@ -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
*/
:global(#app) {
.actionsButton {
background: none;
box-shadow: none;
border-radius: 3px;
box-sizing: content-box;
color: #798d99;
display: inline-block;
margin: 0;
min-height: auto;
opacity: 0;
outline: none;
padding: 4px;
position: absolute;
right: 2px;
top: 2px;
transition: background 85ms ease;
width: 20px;
&:hover {
background: #ebeef0;
color: #516b7a;
}
}
.content {
cursor: pointer;
&:after {
clear: both;
content: "";
display: table;
}
}
.wrapper {
background: #fff;
border-radius: 3px;
box-shadow: 0 1px 0 #ccc;
position: relative;
&:hover {
background: #f5f6f7;
border-bottom-color: rgba(9, 30, 66, 0.25);
.actionsButton {
opacity: 1;
}
}
}
.wrapperDisabled {
filter: saturate(0.5);
opacity: 0.64;
}
.wrapperRecent:not(:hover) {
overflow: hidden;
&:after {
animation: slide 4s infinite 2s;
background: linear-gradient(to right, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.64) 50%, rgba(128, 186, 232, 0) 99%, rgba(125, 185, 232, 0) 100%);
content: '';
height: 100%;
position: absolute;
top: 0;
transform: translateX(100%);
width: 100%;
@keyframes slide {
0% {
transform: translateX(-100%);
}
50% {
transform: translateX(100%);
}
}
}
}
}
@@ -0,0 +1,123 @@
/*!
* 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 { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
import { useClickAwayListener } from '../../../lib/hooks';
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(({ cardId, onClose }) => {
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
const defaultValue = useSelector((state) => selectCardById(state, cardId).name);
const dispatch = useDispatch();
const [t] = useTranslation();
const [value, handleFieldChange] = useField(defaultValue);
const [fieldRef, handleFieldRef] = useNestedRef();
const [buttonRef, handleButtonRef] = useNestedRef();
const submit = useCallback(() => {
const cleanValue = value.trim();
if (!cleanValue) {
fieldRef.current.select();
return;
}
if (cleanValue !== defaultValue) {
dispatch(
entryActions.updateCard(cardId, {
name: cleanValue,
}),
);
}
onClose();
}, [cardId, onClose, defaultValue, dispatch, value, fieldRef]);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
const handleFieldKeyDown = useCallback(
(event) => {
switch (event.key) {
case 'Enter':
event.preventDefault();
submit();
break;
case 'Escape':
onClose();
break;
default:
}
},
[onClose, submit],
);
const handleClickAwayCancel = useCallback(() => {
fieldRef.current.focus();
}, [fieldRef]);
const clickAwayProps = useClickAwayListener(
[fieldRef, buttonRef],
onClose,
handleClickAwayCancel,
);
useEffect(() => {
focusEnd(fieldRef.current);
}, [fieldRef]);
return (
<Form onSubmit={handleSubmit}>
<div className={styles.fieldWrapper}>
<TextArea
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
ref={handleFieldRef}
as={TextareaAutosize}
value={value}
maxLength={1024}
minRows={3}
maxRows={8}
spellCheck={false}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
/>
</div>
<div>
<Button
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
positive
ref={handleButtonRef}
content={t('action.save')}
className={styles.submitButton}
/>
</div>
</Form>
);
});
EditName.propTypes = {
cardId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
};
export default EditName;
@@ -0,0 +1,31 @@
/*!
* 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;
margin-bottom: 4px;
outline: none;
overflow: hidden;
padding: 0;
resize: none;
width: 100%;
word-wrap: break-word;
}
.fieldWrapper {
background: #fff;
border-radius: 3px;
box-shadow: 0 1px 0 #ccc;
margin-bottom: 8px;
min-height: 20px;
padding: 6px 8px 2px;
}
.submitButton {
margin-bottom: 8px;
vertical-align: top;
}
}
@@ -0,0 +1,107 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { Icon } from 'semantic-ui-react';
import selectors from '../../../selectors';
import markdownToText from '../../../utils/markdown-to-text';
import { BoardViews } from '../../../constants/Enums';
import UserAvatar from '../../users/UserAvatar';
import LabelChip from '../../labels/LabelChip';
import styles from './InlineContent.module.scss';
const InlineContent = React.memo(({ cardId }) => {
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const selectLabelIdsByCardId = useMemo(() => selectors.makeSelectLabelIdsByCardId(), []);
const selectNotificationsTotalByCardId = useMemo(
() => selectors.makeSelectNotificationsTotalByCardId(),
[],
);
const card = useSelector((state) => selectCardById(state, cardId));
const labelIds = useSelector((state) => selectLabelIdsByCardId(state, cardId));
const notificationsTotal = useSelector((state) =>
selectNotificationsTotalByCardId(state, cardId),
);
const listName = useSelector((state) => {
const list = selectListById(state, card.listId);
if (!list.name) {
return null;
}
const { view } = selectors.selectCurrentBoard(state);
if (view === BoardViews.KANBAN) {
return null;
}
return list.name;
});
const descriptionText = useMemo(
() => card.description && markdownToText(card.description),
[card.description],
);
return (
<div className={styles.wrapper}>
<span className={styles.attachments}>
<UserAvatar withCreatorIndicator id={card.creatorUserId} />
</span>
{(notificationsTotal > 0 || listName) && (
<span className={styles.attachments}>
{notificationsTotal > 0 && (
<span
className={classNames(styles.attachment, styles.attachmentLeft, styles.notification)}
>
{notificationsTotal}
</span>
)}
{listName && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<span className={styles.attachmentContent}>
<Icon name="columns" />
{listName}
</span>
</span>
)}
</span>
)}
{labelIds.length > 0 && (
<span className={classNames(styles.attachments, styles.hidable)}>
{labelIds.map((labelId) => (
<span key={labelId} className={classNames(styles.attachment, styles.attachmentLeft)}>
<LabelChip id={labelId} size="tiny" />
</span>
))}
</span>
)}
<span className={classNames(styles.attachments, styles.name)}>
<div className={styles.hidable}>{card.name}</div>
</span>
{descriptionText && (
<span className={classNames(styles.attachments, styles.descriptionText, styles.hidable)}>
{descriptionText}
</span>
)}
</div>
);
});
InlineContent.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default InlineContent;
@@ -0,0 +1,70 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.attachment {
display: inline-block;
vertical-align: top;
}
.attachmentContent {
color: #6a808b;
font-size: 12px;
line-height: 20px;
max-width: 176px;
outline: none;
overflow: hidden;
padding: 0px 3px;
text-overflow: ellipsis;
transition: background 0.3s ease;
white-space: nowrap;
}
.attachmentLeft {
margin-right: 4px;
}
.attachments {
white-space: nowrap;
&:not(:last-child) {
margin-right: 20px;
}
}
.descriptionText {
flex: 1;
opacity: 0.5;
}
.hidable {
overflow: hidden;
text-overflow: ellipsis;
}
.name {
color: #17394d;
font-size: 14px;
max-width: 30%;
}
.notification {
background: #eb5a46;
color: #fff;
font-size: 12px;
line-height: 20px;
padding: 0px 6px;
border: none;
border-radius: 3px;
outline: none;
transition: background 0.3s ease;
}
.wrapper {
align-items: center;
display: flex;
padding: 6px 8px;
}
}
@@ -0,0 +1,240 @@
/*!
* 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 { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { Icon } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { startStopwatch, stopStopwatch } from '../../../utils/stopwatch';
import { isListArchiveOrTrash } from '../../../utils/record-helpers';
import { BoardMembershipRoles, BoardViews, ListTypes } from '../../../constants/Enums';
import TaskList from './TaskList';
import DueDateChip from '../DueDateChip';
import StopwatchChip from '../StopwatchChip';
import UserAvatar from '../../users/UserAvatar';
import LabelChip from '../../labels/LabelChip';
import CustomFieldValueChip from '../../custom-field-values/CustomFieldValueChip';
import styles from './ProjectContent.module.scss';
const ProjectContent = React.memo(({ cardId }) => {
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const selectUserIdsByCardId = useMemo(() => selectors.makeSelectUserIdsByCardId(), []);
const selectLabelIdsByCardId = useMemo(() => selectors.makeSelectLabelIdsByCardId(), []);
const selectShownOnFrontOfCardTaskListIdsByCardId = useMemo(
() => selectors.makeSelectShownOnFrontOfCardTaskListIdsByCardId(),
[],
);
const selectAttachmentsTotalByCardId = useMemo(
() => selectors.makeSelectAttachmentsTotalByCardId(),
[],
);
const selectShownOnFrontOfCardCustomFieldValueIdsByCardId = useMemo(
() => selectors.makeSelectShownOnFrontOfCardCustomFieldValueIdsByCardId(),
[],
);
const selectNotificationsTotalByCardId = useMemo(
() => selectors.makeSelectNotificationsTotalByCardId(),
[],
);
const selectAttachmentById = useMemo(() => selectors.makeSelectAttachmentById(), []);
const card = useSelector((state) => selectCardById(state, cardId));
const list = useSelector((state) => selectListById(state, card.listId));
const userIds = useSelector((state) => selectUserIdsByCardId(state, cardId));
const labelIds = useSelector((state) => selectLabelIdsByCardId(state, cardId));
const taskListIds = useSelector((state) =>
selectShownOnFrontOfCardTaskListIdsByCardId(state, cardId),
);
const attachmentsTotal = useSelector((state) => selectAttachmentsTotalByCardId(state, cardId));
const customFieldValueIds = useSelector((state) =>
selectShownOnFrontOfCardCustomFieldValueIdsByCardId(state, cardId),
);
const notificationsTotal = useSelector((state) =>
selectNotificationsTotalByCardId(state, cardId),
);
const coverUrl = useSelector((state) => {
const attachment = selectAttachmentById(state, card.coverAttachmentId);
return attachment && attachment.data.thumbnailUrls.outside360;
});
const { listName, withCreator } = useSelector((state) => {
const board = selectors.selectCurrentBoard(state);
return {
listName: list.name && (board.view === BoardViews.KANBAN ? null : list.name),
withCreator: board.alwaysDisplayCardCreator,
};
}, shallowEqual);
const canEditStopwatch = useSelector((state) => {
if (isListArchiveOrTrash(list)) {
return false;
}
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
});
const dispatch = useDispatch();
const handleToggleStopwatchClick = useCallback(
(event) => {
event.stopPropagation();
dispatch(
entryActions.updateCard(cardId, {
stopwatch: card.stopwatch.startedAt
? stopStopwatch(card.stopwatch)
: startStopwatch(card.stopwatch),
}),
);
},
[cardId, card.stopwatch, dispatch],
);
const hasInformation =
card.description ||
card.dueDate ||
card.stopwatch ||
attachmentsTotal > 0 ||
notificationsTotal > 0 ||
listName;
const isCompact =
(labelIds.length === 0 || customFieldValueIds.length === 0) &&
taskListIds.length === 0 &&
!hasInformation;
const usersNode =
userIds.length > 0 || withCreator ? (
<span className={classNames(styles.attachments, styles.attachmentsRight)}>
{withCreator && (
<>
<span className={classNames(styles.attachment, styles.attachmentRight)}>
<UserAvatar withCreatorIndicator id={card.creatorUserId} size="small" />
</span>
{userIds.length > 0 && <span className={styles.creatorDivider} />}
</>
)}
{userIds.map((userId) => (
<span key={userId} className={classNames(styles.attachment, styles.attachmentRight)}>
<UserAvatar id={userId} size="small" />
</span>
))}
</span>
) : null;
return (
<div className={styles.wrapper}>
<div className={styles.name}>{card.name}</div>
{coverUrl && (
<div className={styles.coverWrapper}>
<img src={coverUrl} alt="" className={styles.cover} />
</div>
)}
{labelIds.length > 0 && (
<span className={classNames(styles.labels, !isCompact && styles.labelsFull)}>
{labelIds.map((labelId) => (
<span key={labelId} className={classNames(styles.attachment, styles.attachmentLeft)}>
<LabelChip id={labelId} size="tiny" />
</span>
))}
</span>
)}
{customFieldValueIds.length > 0 && (
<span className={classNames(styles.labels, !isCompact && styles.labelsFull)}>
{customFieldValueIds.map((customFieldValueId) => (
<span
key={customFieldValueId}
className={classNames(styles.attachment, styles.attachmentLeft)}
>
<CustomFieldValueChip id={customFieldValueId} size="tiny" />
</span>
))}
</span>
)}
{isCompact && usersNode}
{taskListIds.map((taskListId) => (
<TaskList key={taskListId} id={taskListId} />
))}
{hasInformation && (
<span className={styles.attachments}>
{notificationsTotal > 0 && (
<span
className={classNames(styles.attachment, styles.attachmentLeft, styles.notification)}
>
{notificationsTotal}
</span>
)}
{card.dueDate && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<DueDateChip
value={card.dueDate}
size="tiny"
withStatus={list.type !== ListTypes.CLOSED && !isListArchiveOrTrash(list)}
/>
</span>
)}
{card.stopwatch && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<StopwatchChip
value={card.stopwatch}
as="span"
size="tiny"
onClick={canEditStopwatch ? handleToggleStopwatchClick : undefined}
/>
</span>
)}
{listName && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<span className={styles.attachmentContent}>
<Icon name="columns" />
{listName}
</span>
</span>
)}
{card.description && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<span className={styles.attachmentContent}>
<Icon name="align left" />
</span>
</span>
)}
{attachmentsTotal > 0 && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<span className={styles.attachmentContent}>
<Icon name="attach" />
{attachmentsTotal}
</span>
</span>
)}
</span>
)}
{!isCompact && usersNode}
</div>
);
});
ProjectContent.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default ProjectContent;
@@ -0,0 +1,112 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.attachment {
display: inline-block;
line-height: 0;
margin: 0 0 6px 0;
max-width: 100%;
vertical-align: top;
}
.attachmentContent {
color: #6a808b;
font-size: 12px;
line-height: 20px;
max-width: 176px;
outline: none;
overflow: hidden;
padding: 0px 3px;
text-overflow: ellipsis;
transition: background 0.3s ease;
white-space: nowrap;
}
.attachmentLeft {
margin-right: 4px;
}
.attachmentRight {
margin-left: 2px;
}
.attachments {
display: inline-block;
padding-bottom: 2px;
}
.attachmentsRight {
float: right;
line-height: 0;
margin-top: 6px;
}
.cover {
max-height: 340px;
object-fit: cover;
transform-origin: bottom;
transition: all .6s cubic-bezier(0.25, 0.1, 0.25, 1);
vertical-align: middle;
width: 100%;
}
.coverWrapper {
border-radius: 3px;
margin-bottom: 8px;
overflow: hidden;
}
.creatorDivider {
background: #dce0e4;
display: inline-block;
height: 24px;
margin: 2px 2px 0 4px;
width: 1px;
}
.labels {
max-width: 100%;
overflow: hidden;
}
.labelsFull {
display: block;
}
.name {
color: #17394d;
font-size: 14px;
line-height: 18px;
margin-bottom: 8px;
word-wrap: break-word;
}
.notification {
background: #eb5a46;
color: #fff;
font-size: 12px;
line-height: 20px;
padding: 0px 6px;
border: none;
border-radius: 3px;
display: inline-block;
outline: none;
text-align: left;
transition: background 0.3s ease;
vertical-align: top;
}
.wrapper {
padding: 6px 8px 0;
}
:global(.card):hover {
.cover {
filter: brightness(1.1);
transform: scale(1.05);
}
}
}
@@ -0,0 +1,151 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { Icon } from 'semantic-ui-react';
import selectors from '../../../selectors';
import markdownToText from '../../../utils/markdown-to-text';
import { BoardViews } from '../../../constants/Enums';
import LabelChip from '../../labels/LabelChip';
import CustomFieldValueChip from '../../custom-field-values/CustomFieldValueChip';
import styles from './StoryContent.module.scss';
const StoryContent = React.memo(({ cardId }) => {
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const selectLabelIdsByCardId = useMemo(() => selectors.makeSelectLabelIdsByCardId(), []);
const selectAttachmentsTotalByCardId = useMemo(
() => selectors.makeSelectAttachmentsTotalByCardId(),
[],
);
const selectShownOnFrontOfCardCustomFieldValueIdsByCardId = useMemo(
() => selectors.makeSelectShownOnFrontOfCardCustomFieldValueIdsByCardId(),
[],
);
const selectNotificationsTotalByCardId = useMemo(
() => selectors.makeSelectNotificationsTotalByCardId(),
[],
);
const selectAttachmentById = useMemo(() => selectors.makeSelectAttachmentById(), []);
const card = useSelector((state) => selectCardById(state, cardId));
const labelIds = useSelector((state) => selectLabelIdsByCardId(state, cardId));
const attachmentsTotal = useSelector((state) => selectAttachmentsTotalByCardId(state, cardId));
const customFieldValueIds = useSelector((state) =>
selectShownOnFrontOfCardCustomFieldValueIdsByCardId(state, cardId),
);
const notificationsTotal = useSelector((state) =>
selectNotificationsTotalByCardId(state, cardId),
);
const listName = useSelector((state) => {
const list = selectListById(state, card.listId);
if (!list.name) {
return null;
}
const { view } = selectors.selectCurrentBoard(state);
if (view === BoardViews.KANBAN) {
return null;
}
return list.name;
});
const coverUrl = useSelector((state) => {
const attachment = selectAttachmentById(state, card.coverAttachmentId);
return attachment && attachment.data.thumbnailUrls.outside360;
});
const descriptionText = useMemo(
() => card.description && markdownToText(card.description),
[card.description],
);
return (
<>
{coverUrl && (
<div className={styles.coverWrapper}>
<img src={coverUrl} alt="" className={styles.cover} />
</div>
)}
<div className={styles.wrapper}>
{labelIds.length > 0 && (
<span className={styles.labels}>
{labelIds.map((labelId) => (
<span key={labelId} className={classNames(styles.attachment, styles.attachmentLeft)}>
<LabelChip id={labelId} size="tiny" />
</span>
))}
</span>
)}
{customFieldValueIds.length > 0 && (
<span className={classNames(styles.labels)}>
{customFieldValueIds.map((customFieldValueId) => (
<span
key={customFieldValueId}
className={classNames(styles.attachment, styles.attachmentLeft)}
>
<CustomFieldValueChip id={customFieldValueId} size="tiny" />
</span>
))}
</span>
)}
<div className={styles.name}>{card.name}</div>
{card.description && <div className={styles.descriptionText}>{descriptionText}</div>}
{(attachmentsTotal > 0 || notificationsTotal > 0 || listName) && (
<span className={styles.attachments}>
{notificationsTotal > 0 && (
<span
className={classNames(
styles.attachment,
styles.attachmentLeft,
styles.notification,
)}
>
{notificationsTotal}
</span>
)}
{listName && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<span className={styles.attachmentContent}>
<Icon name="columns" />
{listName}
</span>
</span>
)}
{attachmentsTotal > 0 && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<span className={styles.attachmentContent}>
<Icon name="attach" />
{attachmentsTotal}
</span>
</span>
)}
</span>
)}
</div>
</>
);
});
StoryContent.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default StoryContent;
@@ -0,0 +1,102 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.attachment {
display: inline-block;
line-height: 0;
margin: 0 0 6px 0;
max-width: 100%;
vertical-align: top;
}
.attachmentContent {
color: #6a808b;
font-size: 12px;
line-height: 20px;
max-width: 176px;
outline: none;
overflow: hidden;
padding: 0px 3px;
text-overflow: ellipsis;
transition: background 0.3s ease;
white-space: nowrap;
}
.attachmentLeft {
margin-right: 4px;
}
.attachments {
display: inline-block;
padding-bottom: 2px;
}
.cover {
max-height: 340px;
object-fit: cover;
transition: all .6s cubic-bezier(0.25, 0.1, 0.25, 1);
vertical-align: middle;
width: 100%;
}
.coverWrapper {
border-radius: 3px 3px 0 0;
overflow: hidden;
}
.descriptionText {
color: #17394d;
font-size: 10px;
margin-bottom: 8px;
mask-image: linear-gradient(180deg, #000 60%, transparent);
-webkit-mask-image: linear-gradient(180deg, #000 60%, transparent);
max-height: 100px;
overflow: hidden;
}
.labels {
display: block;
margin-bottom: 4px;
max-width: 100%;
overflow: hidden;
}
.name {
color: #17394d;
font-size: 16px;
font-weight: bold;
line-height: 20px;
margin: 0 0 8px;
text-transform: uppercase;
word-wrap: break-word;
}
.notification {
background: #eb5a46;
color: #fff;
font-size: 12px;
line-height: 20px;
padding: 0px 6px;
border: none;
border-radius: 3px;
display: inline-block;
outline: none;
text-align: left;
transition: background 0.3s ease;
vertical-align: top;
}
.wrapper {
padding: 8px 8px 0;
}
:global(.card):hover {
.cover {
filter: brightness(1.1);
transform: scale(1.1) rotate(-2deg);
}
}
}
@@ -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
*/
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import selectors from '../../../../selectors';
import Linkify from '../../../common/Linkify';
import styles from './Task.module.scss';
const Task = React.memo(({ id }) => {
const selectTaskById = useMemo(() => selectors.makeSelectTaskById(), []);
const task = useSelector((state) => selectTaskById(state, id));
return (
<li className={classNames(styles.wrapper, task.isCompleted && styles.wrapperCompleted)}>
<Linkify linkStopPropagation>{task.name}</Linkify>
</li>
);
});
Task.propTypes = {
id: PropTypes.string.isRequired,
};
export default Task;
@@ -0,0 +1,26 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.wrapper {
display: block;
font-size: 12px;
line-height: 14px;
overflow-wrap: break-word;
padding-bottom: 6px;
padding-left: 14px;
&:before {
content: "–";
position: absolute;
left: 10px;
}
}
.wrapperCompleted {
color: #aaa;
text-decoration: line-through;
}
}
@@ -0,0 +1,79 @@
/*!
* 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 { Progress } from 'semantic-ui-react';
import { useToggle } from '../../../../lib/hooks';
import selectors from '../../../../selectors';
import Task from './Task';
import styles from './TaskList.module.scss';
const TaskList = React.memo(({ id }) => {
const selectTasksByTaskListId = useMemo(() => selectors.makeSelectTasksByTaskListId(), []);
const tasks = useSelector((state) => selectTasksByTaskListId(state, id));
const [isOpened, toggleOpened] = useToggle();
// TODO: move to selector?
const completedTasksTotal = useMemo(
() => tasks.reduce((result, task) => (task.isCompleted ? result + 1 : result), 0),
[tasks],
);
const handleToggleClick = useCallback(
(event) => {
event.stopPropagation();
toggleOpened();
},
[toggleOpened],
);
if (tasks.length === 0) {
return null;
}
return (
<>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<div className={styles.button} onClick={handleToggleClick}>
<span className={styles.progressWrapper}>
<Progress
autoSuccess
value={completedTasksTotal}
total={tasks.length}
color="blue"
size="tiny"
className={styles.progress}
/>
</span>
<span
className={classNames(styles.count, isOpened ? styles.countOpened : styles.countClosed)}
>
{completedTasksTotal}/{tasks.length}
</span>
</div>
{isOpened && (
<ul className={styles.tasks}>
{tasks.map((task) => (
<Task key={task.id} id={task.id} />
))}
</ul>
)}
</>
);
});
TaskList.propTypes = {
id: PropTypes.string.isRequired,
};
export default TaskList;
@@ -0,0 +1,65 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.button {
background: transparent;
border: none;
line-height: 0;
margin: 0 -8px;
outline: none;
padding: 0px 8px 8px;
width: calc(100% + 16px);
}
.count {
color: #888;
display: inline-block;
font-size: 12px;
line-height: 12px;
text-align: right;
vertical-align: top;
width: 50px;
&:after {
content: "";
opacity: 0.4;
}
&:hover {
opacity: 0.75;
}
}
.countOpened:after {
background: url("data:image/gif;base64,R0lGODlhCwALAJEAAAAAAP///xUVFf///yH5BAEAAAMALAAAAAALAAsAAAIPnI+py+0/hJzz0IruwjsVADs=") no-repeat center right;
margin-left: 2px;
padding: 6px 6px 0px;
}
.countClosed:after {
background: url("data:image/gif;base64,R0lGODlhCwALAJEAAAAAAP///xUVFf///yH5BAEAAAMALAAAAAALAAsAAAIRnC2nKLnT4or00Puy3rx7VQAAOw==") no-repeat center right;
margin-left: 2px;
padding: 0 6px 6px;
}
.progress {
margin: 0;
}
.progressWrapper {
display: inline-block;
padding: 3px 0;
vertical-align: top;
width: calc(100% - 50px);
}
.tasks {
color: #333;
list-style: none;
margin: -2px 0 0;
padding-left: 0;
}
}
@@ -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 TaskList from './TaskList';
export default TaskList;
@@ -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 Card from './Card';
export default Card;