@@ -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;
|
||||
}
|
||||
}
|
||||
Executable
+146
@@ -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;
|
||||
Reference in New Issue
Block a user