@@ -0,0 +1,212 @@
|
||||
/*!
|
||||
* 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 } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { Button, Form, Icon, TextArea } from 'semantic-ui-react';
|
||||
import { useClickAwayListener, useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks';
|
||||
import { usePopup } from '../../../lib/popup';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import { useClosable, useForm, useNestedRef } from '../../../hooks';
|
||||
import { isModifierKeyPressed } from '../../../utils/event-helpers';
|
||||
import { CardTypeIcons } from '../../../constants/Icons';
|
||||
import SelectCardTypeStep from '../SelectCardTypeStep';
|
||||
|
||||
import styles from './AddCard.module.scss';
|
||||
|
||||
const DEFAULT_DATA = {
|
||||
name: '',
|
||||
};
|
||||
|
||||
const AddCard = React.memo(({ isOpened, className, onCreate, onClose }) => {
|
||||
const { defaultCardType: defaultType, limitCardTypesToDefaultOne: limitTypesToDefaultOne } =
|
||||
useSelector(selectors.selectCurrentBoard);
|
||||
|
||||
const [t] = useTranslation();
|
||||
const prevDefaultType = usePrevious(defaultType);
|
||||
|
||||
const [data, handleFieldChange, setData] = useForm(() => ({
|
||||
...DEFAULT_DATA,
|
||||
type: defaultType,
|
||||
}));
|
||||
|
||||
const [focusNameFieldState, focusNameField] = useToggle();
|
||||
const [isClosableActiveRef, activateClosable, deactivateClosable] = useClosable();
|
||||
|
||||
const [nameFieldRef, handleNameFieldRef] = useNestedRef();
|
||||
const [submitButtonRef, handleSubmitButtonRef] = useNestedRef();
|
||||
const [selectTypeButtonRef, handleSelectTypeButtonRef] = useNestedRef();
|
||||
|
||||
const submit = useCallback(
|
||||
(autoOpen) => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
name: data.name.trim(),
|
||||
};
|
||||
|
||||
if (!cleanData.name) {
|
||||
nameFieldRef.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
onCreate(cleanData, autoOpen);
|
||||
|
||||
setData({
|
||||
...DEFAULT_DATA,
|
||||
type: defaultType,
|
||||
});
|
||||
|
||||
if (autoOpen) {
|
||||
onClose();
|
||||
} else {
|
||||
focusNameField();
|
||||
}
|
||||
},
|
||||
[onCreate, onClose, defaultType, data, setData, focusNameField, nameFieldRef],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
submit();
|
||||
}, [submit]);
|
||||
|
||||
const handleTypeSelect = useCallback(
|
||||
(type) => {
|
||||
setData((prevData) => ({
|
||||
...prevData,
|
||||
type,
|
||||
}));
|
||||
},
|
||||
[setData],
|
||||
);
|
||||
|
||||
const handleFieldKeyDown = useCallback(
|
||||
(event) => {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
submit(isModifierKeyPressed(event));
|
||||
|
||||
break;
|
||||
case 'Escape':
|
||||
onClose();
|
||||
|
||||
break;
|
||||
default:
|
||||
}
|
||||
},
|
||||
[onClose, submit],
|
||||
);
|
||||
|
||||
const handleSelectTypeClose = useCallback(() => {
|
||||
deactivateClosable();
|
||||
nameFieldRef.current.focus();
|
||||
}, [deactivateClosable, nameFieldRef]);
|
||||
|
||||
const handleAwayClick = useCallback(() => {
|
||||
if (!isOpened || isClosableActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [isOpened, onClose, isClosableActiveRef]);
|
||||
|
||||
const handleClickAwayCancel = useCallback(() => {
|
||||
nameFieldRef.current.focus();
|
||||
}, [nameFieldRef]);
|
||||
|
||||
const clickAwayProps = useClickAwayListener(
|
||||
[nameFieldRef, submitButtonRef, selectTypeButtonRef],
|
||||
handleAwayClick,
|
||||
handleClickAwayCancel,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpened) {
|
||||
nameFieldRef.current.focus();
|
||||
}
|
||||
}, [isOpened, nameFieldRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpened && defaultType !== prevDefaultType) {
|
||||
setData((prevData) => ({
|
||||
...prevData,
|
||||
type: defaultType,
|
||||
}));
|
||||
}
|
||||
}, [isOpened, defaultType, prevDefaultType, setData]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
nameFieldRef.current.focus();
|
||||
}, [focusNameFieldState]);
|
||||
|
||||
const SelectCardTypePopup = usePopup(SelectCardTypeStep, {
|
||||
onOpen: activateClosable,
|
||||
onClose: handleSelectTypeClose,
|
||||
});
|
||||
|
||||
return (
|
||||
<Form
|
||||
className={classNames(className, !isOpened && styles.wrapperClosed)}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className={styles.fieldWrapper}>
|
||||
<TextArea
|
||||
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
ref={handleNameFieldRef}
|
||||
as={TextareaAutosize}
|
||||
name="name"
|
||||
value={data.name}
|
||||
placeholder={t('common.enterCardTitle')}
|
||||
maxLength={1024}
|
||||
minRows={3}
|
||||
spellCheck={false}
|
||||
className={styles.field}
|
||||
onKeyDown={handleFieldKeyDown}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.controls}>
|
||||
<Button
|
||||
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
positive
|
||||
ref={handleSubmitButtonRef}
|
||||
content={t('action.addCard')}
|
||||
className={styles.button}
|
||||
/>
|
||||
<SelectCardTypePopup defaultValue={data.type} onSelect={handleTypeSelect}>
|
||||
<Button
|
||||
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
ref={handleSelectTypeButtonRef}
|
||||
type="button"
|
||||
disabled={limitTypesToDefaultOne}
|
||||
className={classNames(styles.button, styles.selectTypeButton)}
|
||||
>
|
||||
<Icon name={CardTypeIcons[data.type]} className={styles.selectTypeButtonIcon} />
|
||||
{t(`common.${data.type}`)}
|
||||
</Button>
|
||||
</SelectCardTypePopup>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
AddCard.propTypes = {
|
||||
isOpened: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
onCreate: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
AddCard.defaultProps = {
|
||||
isOpened: true,
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export default AddCard;
|
||||
@@ -0,0 +1,60 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.field {
|
||||
border: none;
|
||||
margin-bottom: 4px;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
resize: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fieldWrapper {
|
||||
background: #fff;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 1px 0 #ccc;
|
||||
margin-bottom: 8px;
|
||||
min-height: 20px;
|
||||
padding: 6px 8px 2px;
|
||||
}
|
||||
|
||||
.selectTypeButton {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: #6b808c;
|
||||
font-weight: normal;
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-decoration: underline;
|
||||
text-overflow: ellipsis;
|
||||
transition: none;
|
||||
|
||||
&:hover {
|
||||
background: rgba(9, 30, 66, 0.08);
|
||||
color: #092d42;
|
||||
}
|
||||
}
|
||||
|
||||
.selectTypeButtonIcon {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wrapperClosed {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -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 AddCard from './AddCard';
|
||||
|
||||
export default AddCard;
|
||||
@@ -0,0 +1,42 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import entryActions from '../../entry-actions';
|
||||
import ConfirmationStep from '../common/ConfirmationStep';
|
||||
|
||||
const ArchiveCardsStep = React.memo(({ listId, onBack, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
dispatch(entryActions.moveListCardsToArchiveList(listId));
|
||||
onClose();
|
||||
}, [listId, onClose, dispatch]);
|
||||
|
||||
return (
|
||||
<ConfirmationStep
|
||||
title="common.archiveCards"
|
||||
content="common.areYouSureYouWantToArchiveCards"
|
||||
buttonContent="action.archiveCards"
|
||||
onConfirm={handleConfirm}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
ArchiveCardsStep.propTypes = {
|
||||
listId: PropTypes.string.isRequired,
|
||||
onBack: PropTypes.func,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
ArchiveCardsStep.defaultProps = {
|
||||
onBack: undefined,
|
||||
};
|
||||
|
||||
export default ArchiveCardsStep;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,155 @@
|
||||
/*!
|
||||
* 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 } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { closePopup } from '../../../../lib/popup';
|
||||
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import { useModal } from '../../../../hooks';
|
||||
import { isUrl } from '../../../../utils/validator';
|
||||
import { isActiveTextElement } from '../../../../utils/element-helpers';
|
||||
import { AttachmentTypes } from '../../../../constants/Enums';
|
||||
import AddTextFileModal from './AddTextFileModal';
|
||||
|
||||
import styles from './AddAttachmentZone.module.scss';
|
||||
|
||||
const AddAttachmentZone = React.memo(({ children }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
const [modal, openModal, handleModalClose] = useModal();
|
||||
|
||||
const submitFile = useCallback(
|
||||
(file) => {
|
||||
dispatch(
|
||||
entryActions.createAttachmentInCurrentCard({
|
||||
file,
|
||||
type: AttachmentTypes.FILE,
|
||||
name: file.name,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const submitLink = useCallback(
|
||||
(url) => {
|
||||
dispatch(
|
||||
entryActions.createAttachmentInCurrentCard({
|
||||
url,
|
||||
type: AttachmentTypes.LINK,
|
||||
name: url,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleDropAccepted = useCallback(
|
||||
(files) => {
|
||||
files.forEach((file) => {
|
||||
submitFile(file);
|
||||
});
|
||||
},
|
||||
[submitFile],
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
onDropAccepted: handleDropAccepted,
|
||||
});
|
||||
|
||||
const handleFileCreate = useCallback(
|
||||
(file) => {
|
||||
submitFile(file);
|
||||
},
|
||||
[submitFile],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (event) => {
|
||||
if (!event.clipboardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { files, items } = event.clipboardData;
|
||||
|
||||
if (files.length > 0) {
|
||||
[...files].forEach((file) => {
|
||||
submitFile(file);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (items[0].kind === 'string') {
|
||||
if (isActiveTextElement(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
items[0].getAsString((content) => {
|
||||
if (isUrl(content)) {
|
||||
submitLink(content);
|
||||
} else {
|
||||
closePopup();
|
||||
|
||||
openModal({
|
||||
content,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
[...items].forEach((item) => {
|
||||
if (item.kind !== 'file') {
|
||||
return;
|
||||
}
|
||||
|
||||
submitFile(item.getAsFile());
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('paste', handlePaste);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('paste', handlePaste);
|
||||
};
|
||||
}, [openModal, submitFile, submitLink]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<div {...getRootProps()}>
|
||||
{isDragActive && <div className={styles.dropzone}>{t('common.dropFileToUpload')}</div>}
|
||||
{children}
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<input {...getInputProps()} />
|
||||
</div>
|
||||
{modal && (
|
||||
<AddTextFileModal
|
||||
content={modal.content}
|
||||
onCreate={handleFileCreate}
|
||||
onClose={handleModalClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
AddAttachmentZone.propTypes = {
|
||||
children: PropTypes.element.isRequired,
|
||||
};
|
||||
|
||||
export default AddAttachmentZone;
|
||||
@@ -0,0 +1,20 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.dropzone {
|
||||
background: white;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
height: 100%;
|
||||
line-height: 30px;
|
||||
opacity: 0.7;
|
||||
padding: 200px 50px;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
z-index: 2001;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*!
|
||||
* 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 } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Header, Modal } from 'semantic-ui-react';
|
||||
import { Input } from '../../../../lib/custom-ui';
|
||||
|
||||
import { useForm, useNestedRef } from '../../../../hooks';
|
||||
|
||||
import styles from './AddTextFileModal.module.scss';
|
||||
|
||||
const AddTextFileModal = React.memo(({ content, onCreate, onClose }) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const [data, handleFieldChange] = useForm(() => ({
|
||||
name: '',
|
||||
}));
|
||||
|
||||
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
name: data.name.trim(),
|
||||
};
|
||||
|
||||
if (!cleanData.name) {
|
||||
nameFieldRef.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
const file = new File([content], `${cleanData.name}.txt`, {
|
||||
type: 'plain/text',
|
||||
});
|
||||
|
||||
onCreate(file);
|
||||
onClose();
|
||||
}, [content, onCreate, onClose, data, nameFieldRef]);
|
||||
|
||||
useEffect(() => {
|
||||
nameFieldRef.current.focus();
|
||||
}, [nameFieldRef]);
|
||||
|
||||
return (
|
||||
<Modal open basic closeIcon size="tiny" onClose={onClose}>
|
||||
<Modal.Content>
|
||||
<Header inverted size="huge">
|
||||
{t('common.createTextFile', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Header>
|
||||
<p>{t('common.enterFilename')}</p>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Input
|
||||
fluid
|
||||
inverted
|
||||
ref={handleNameFieldRef}
|
||||
name="name"
|
||||
value={data.name}
|
||||
maxLength={124}
|
||||
label=".txt"
|
||||
labelPosition="right"
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<Button
|
||||
inverted
|
||||
color="green"
|
||||
icon="checkmark"
|
||||
content={t('action.createFile')}
|
||||
floated="right"
|
||||
/>
|
||||
</Form>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
AddTextFileModal.propTypes = {
|
||||
content: PropTypes.string.isRequired,
|
||||
onCreate: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default AddTextFileModal;
|
||||
@@ -0,0 +1,10 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.field {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
@@ -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 AddAttachmentZone from './AddAttachmentZone';
|
||||
|
||||
export default AddAttachmentZone;
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*!
|
||||
* 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 classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { push } from '../../../lib/redux-router';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { useClosableModal } from '../../../hooks';
|
||||
import { isListArchiveOrTrash } from '../../../utils/record-helpers';
|
||||
import { isActiveTextElement } from '../../../utils/element-helpers';
|
||||
import Paths from '../../../constants/Paths';
|
||||
import { BoardMembershipRoles, CardTypes } from '../../../constants/Enums';
|
||||
import ProjectContent from './ProjectContent';
|
||||
import StoryContent from './StoryContent';
|
||||
import AddAttachmentZone from './AddAttachmentZone';
|
||||
|
||||
import styles from './CardModal.module.scss';
|
||||
|
||||
const DIRECTION_BY_KEY = {
|
||||
ArrowLeft: -1,
|
||||
ArrowRight: 1,
|
||||
};
|
||||
|
||||
const CardModal = React.memo(() => {
|
||||
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
|
||||
|
||||
const card = useSelector(selectors.selectCurrentCard);
|
||||
|
||||
const canEdit = useSelector((state) => {
|
||||
const list = selectListById(state, card.listId);
|
||||
|
||||
if (isListArchiveOrTrash(list)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
});
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
dispatch(push(Paths.BOARDS.replace(':id', card.boardId)));
|
||||
}, [card.boardId, dispatch]);
|
||||
|
||||
const [ClosableModal, isClosableActiveRef] = useClosableModal();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeydown = (event) => {
|
||||
if (isClosableActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActiveTextElement(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Object.keys(DIRECTION_BY_KEY).includes(event.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
dispatch(entryActions.goToAdjacentCard(DIRECTION_BY_KEY[event.key]));
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
}, [card, dispatch, isClosableActiveRef]);
|
||||
|
||||
let Content;
|
||||
switch (card.type) {
|
||||
case CardTypes.PROJECT:
|
||||
Content = ProjectContent;
|
||||
|
||||
break;
|
||||
case CardTypes.STORY:
|
||||
Content = StoryContent;
|
||||
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
return (
|
||||
<ClosableModal
|
||||
closeIcon
|
||||
centered={false}
|
||||
className={classNames(styles.wrapper, card.type === CardTypes.STORY && styles.wrapperStory)}
|
||||
onClose={handleClose}
|
||||
>
|
||||
{canEdit ? (
|
||||
<AddAttachmentZone>
|
||||
<Content onClose={handleClose} />
|
||||
</AddAttachmentZone>
|
||||
) : (
|
||||
<Content onClose={handleClose} />
|
||||
)}
|
||||
</ClosableModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default CardModal;
|
||||
@@ -0,0 +1,28 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
@mixin smallScreen {
|
||||
margin: 1rem auto;
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
margin: 2rem auto;
|
||||
width: 880px;
|
||||
|
||||
@media (width < 926px) {
|
||||
@include smallScreen;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapperStory {
|
||||
width: 980px;
|
||||
|
||||
@media (width < 1026px) {
|
||||
@include smallScreen;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Menu, Tab } from 'semantic-ui-react';
|
||||
|
||||
import Comments from '../../comments/Comments';
|
||||
import Activities from '../../activities/Activities';
|
||||
|
||||
import styles from './Communication.module.scss';
|
||||
|
||||
const Communication = React.memo(() => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const panes = [
|
||||
{
|
||||
menuItem: (
|
||||
<Menu.Item key="comments" className={styles.menuItem}>
|
||||
{t('common.comments', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
),
|
||||
render: () => <Comments />,
|
||||
},
|
||||
{
|
||||
menuItem: (
|
||||
<Menu.Item key="actions" className={styles.menuItem}>
|
||||
{t('common.actions', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
),
|
||||
render: () => <Activities />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Tab
|
||||
menu={{
|
||||
secondary: true,
|
||||
pointing: true,
|
||||
}}
|
||||
panes={panes}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default Communication;
|
||||
@@ -0,0 +1,10 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.menuItem {
|
||||
color: #17394d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import { StaticUserIds } from '../../../constants/StaticUsers';
|
||||
import TimeAgo from '../../common/TimeAgo';
|
||||
import UserAvatar from '../../users/UserAvatar';
|
||||
|
||||
import styles from './CreationDetailsStep.module.scss';
|
||||
|
||||
const CreationDetailsStep = React.memo(({ userId }) => {
|
||||
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
|
||||
|
||||
const card = useSelector(selectors.selectCurrentCard);
|
||||
const user = useSelector((state) => selectUserById(state, userId));
|
||||
|
||||
const [t] = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.userWrapper}>
|
||||
<span className={styles.user}>
|
||||
<UserAvatar id={userId} size="large" />
|
||||
</span>
|
||||
<span className={styles.content}>
|
||||
<div className={styles.name}>
|
||||
{user.id === StaticUserIds.DELETED
|
||||
? t(`common.${user.name}`, {
|
||||
context: 'title',
|
||||
})
|
||||
: user.name}
|
||||
</div>
|
||||
{user && user.username && <div className={styles.username}>@{user.username}</div>}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.information}>
|
||||
<Icon name="clock" className={styles.informationIcon} />
|
||||
<TimeAgo date={card.createdAt} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
CreationDetailsStep.propTypes = {
|
||||
userId: PropTypes.string,
|
||||
};
|
||||
|
||||
CreationDetailsStep.defaultProps = {
|
||||
userId: undefined,
|
||||
};
|
||||
|
||||
export default CreationDetailsStep;
|
||||
@@ -0,0 +1,47 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.content {
|
||||
display: inline-block;
|
||||
width: calc(100% - 44px);
|
||||
}
|
||||
|
||||
.information {
|
||||
color: #888888;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.informationIcon {
|
||||
margin-right: 6px;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.name {
|
||||
color: #212121;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
padding: 9px 28px 0 2px;
|
||||
}
|
||||
|
||||
.userWrapper {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.user {
|
||||
display: inline-block;
|
||||
padding-right: 8px;
|
||||
padding-top: 10px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.username {
|
||||
color: #888888;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
padding: 2px 0 2px 2px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
|
||||
import { closePopup } from '../../../../lib/popup';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import DroppableTypes from '../../../../constants/DroppableTypes';
|
||||
import Item from './Item';
|
||||
import DraggableItem from './DraggableItem';
|
||||
|
||||
import styles from './CustomFieldGroups.module.scss';
|
||||
import globalStyles from '../../../../styles.module.scss';
|
||||
|
||||
const CustomFieldGroups = React.memo(() => {
|
||||
const boardCustomFieldGroupIds = useSelector(selectors.selectCustomFieldGroupIdsForCurrentBoard);
|
||||
const cardCustomFieldGroupIds = useSelector(selectors.selectCustomFieldGroupIdsForCurrentCard);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleDragStart = useCallback(() => {
|
||||
document.body.classList.add(globalStyles.dragging);
|
||||
closePopup();
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
({ draggableId, source, destination }) => {
|
||||
document.body.classList.remove(globalStyles.dragging);
|
||||
|
||||
if (!destination || source.index === destination.index) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(entryActions.moveCustomFieldGroup(draggableId, destination.index));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{boardCustomFieldGroupIds.map((customFieldGroupId) => (
|
||||
<div key={customFieldGroupId} className={styles.item}>
|
||||
<Item id={customFieldGroupId} />
|
||||
</div>
|
||||
))}
|
||||
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="card" type={DroppableTypes.CUSTOM_FIELD_GROUP} direction="vertical">
|
||||
{({ innerRef, droppableProps, placeholder }) => (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<div {...droppableProps} ref={innerRef}>
|
||||
{cardCustomFieldGroupIds.map((customFieldGroupId, index) => (
|
||||
<DraggableItem
|
||||
key={customFieldGroupId}
|
||||
id={customFieldGroupId}
|
||||
index={index}
|
||||
className={styles.item}
|
||||
/>
|
||||
))}
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default CustomFieldGroups;
|
||||
@@ -0,0 +1,11 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.item {
|
||||
border-radius: 3px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*!
|
||||
* 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 ReactDOM from 'react-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import selectors from '../../../../selectors';
|
||||
import { isListArchiveOrTrash } from '../../../../utils/record-helpers';
|
||||
import { BoardMembershipRoles } from '../../../../constants/Enums';
|
||||
import Item from './Item';
|
||||
|
||||
import styles from './DraggableItem.module.scss';
|
||||
|
||||
const DraggableItem = React.memo(({ id, index, className, ...props }) => {
|
||||
const selectCustomFieldGroupById = useMemo(() => selectors.makeSelectCustomFieldGroupById(), []);
|
||||
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
|
||||
|
||||
const customFieldGroup = useSelector((state) => selectCustomFieldGroupById(state, id));
|
||||
|
||||
const canEdit = useSelector((state) => {
|
||||
if (customFieldGroup.boardId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { listId } = selectors.selectCurrentCard(state);
|
||||
const list = selectListById(state, listId);
|
||||
|
||||
if (isListArchiveOrTrash(list)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
});
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
draggableId={id}
|
||||
index={index}
|
||||
isDragDisabled={!customFieldGroup.isPersisted || !canEdit}
|
||||
>
|
||||
{({ innerRef, draggableProps, dragHandleProps }, { isDragging }) => {
|
||||
const contentNode = (
|
||||
<div
|
||||
{...draggableProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
ref={innerRef}
|
||||
className={classNames(className, isDragging && styles.dragging)}
|
||||
>
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<Item {...props} id={id} dragHandleProps={dragHandleProps} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return isDragging ? ReactDOM.createPortal(contentNode, document.body) : contentNode;
|
||||
}}
|
||||
</Draggable>
|
||||
);
|
||||
});
|
||||
|
||||
DraggableItem.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
DraggableItem.defaultProps = {
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export default DraggableItem;
|
||||
@@ -0,0 +1,10 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.dragging {
|
||||
background: rgba(247, 246, 247, 0.8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*!
|
||||
* 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 { Button, Icon } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
import { usePopupInClosableContext } from '../../../../hooks';
|
||||
import { isListArchiveOrTrash } from '../../../../utils/record-helpers';
|
||||
import { BoardMembershipRoles } from '../../../../constants/Enums';
|
||||
import CustomFieldGroup from '../../../custom-field-groups/CustomFieldGroup';
|
||||
import CustomFieldGroupStep from '../../../custom-field-groups/CustomFieldGroupStep';
|
||||
|
||||
import styles from './Item.module.scss';
|
||||
|
||||
const Item = React.memo(({ id, dragHandleProps }) => {
|
||||
const selectCustomFieldGroupById = useMemo(() => selectors.makeSelectCustomFieldGroupById(), []);
|
||||
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
|
||||
|
||||
const customFieldGroup = useSelector((state) => selectCustomFieldGroupById(state, id));
|
||||
|
||||
const canEdit = useSelector((state) => {
|
||||
if (customFieldGroup.boardId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { listId } = selectors.selectCurrentCard(state);
|
||||
const list = selectListById(state, listId);
|
||||
|
||||
if (isListArchiveOrTrash(list)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
});
|
||||
|
||||
const CustomFieldGroupPopup = usePopupInClosableContext(CustomFieldGroupStep);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.moduleWrapper}>
|
||||
<Icon name="sticky note outline" className={styles.moduleIcon} />
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<div {...dragHandleProps}>
|
||||
<div className={classNames(styles.moduleHeader, canEdit && styles.moduleHeaderEditable)}>
|
||||
{customFieldGroup.isPersisted && canEdit && (
|
||||
<CustomFieldGroupPopup id={customFieldGroup.id}>
|
||||
<Button className={styles.editButton}>
|
||||
<Icon fitted name="pencil" size="small" />
|
||||
</Button>
|
||||
</CustomFieldGroupPopup>
|
||||
)}
|
||||
<span className={styles.moduleHeaderTitle}>{customFieldGroup.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<CustomFieldGroup id={id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Item.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
dragHandleProps: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
};
|
||||
|
||||
Item.defaultProps = {
|
||||
dragHandleProps: undefined,
|
||||
};
|
||||
|
||||
export default Item;
|
||||
@@ -0,0 +1,71 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.editButton {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
line-height: 28px;
|
||||
margin: 0;
|
||||
min-height: auto;
|
||||
opacity: 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 4px;
|
||||
width: 28px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(9, 30, 66, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.moduleHeader {
|
||||
color: #17394d;
|
||||
display: flex;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 4px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.moduleHeaderEditable {
|
||||
padding-right: 32px;
|
||||
|
||||
&:hover {
|
||||
.editButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.moduleHeaderTitle {
|
||||
min-width: 0;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.moduleIcon {
|
||||
color: #17394d;
|
||||
font-size: 17px;
|
||||
height: 32px;
|
||||
left: -40px;
|
||||
line-height: 32px;
|
||||
margin-right: 0;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.moduleWrapper {
|
||||
margin: 0 0 0 40px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
border-radius: 3px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
@@ -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 CustomFieldGroups from './CustomFieldGroups';
|
||||
|
||||
export default CustomFieldGroups;
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import upperFirst from 'lodash/upperFirst';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { TextArea } from 'semantic-ui-react';
|
||||
import { useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks';
|
||||
|
||||
import { useEscapeInterceptor, useField, useNestedRef } from '../../../hooks';
|
||||
|
||||
import styles from './NameField.module.scss';
|
||||
|
||||
const Sizes = {
|
||||
MEDIUM: 'medium',
|
||||
LARGE: 'large',
|
||||
};
|
||||
|
||||
const NameField = React.memo(({ defaultValue, size, onUpdate }) => {
|
||||
const prevDefaultValue = usePrevious(defaultValue);
|
||||
const [value, handleChange, setValue] = useField(defaultValue);
|
||||
const [blurFieldState, blurField] = useToggle();
|
||||
|
||||
const [fiedRef, handleFieldRef] = useNestedRef();
|
||||
const isFocusedRef = useRef(false);
|
||||
|
||||
const handleEscape = useCallback(() => {
|
||||
setValue(defaultValue);
|
||||
blurField();
|
||||
}, [defaultValue, setValue, blurField]);
|
||||
|
||||
const [activateEscapeInterceptor, deactivateEscapeInterceptor] =
|
||||
useEscapeInterceptor(handleEscape);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
activateEscapeInterceptor();
|
||||
isFocusedRef.current = true;
|
||||
}, [activateEscapeInterceptor]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
fiedRef.current.blur();
|
||||
}
|
||||
},
|
||||
[fiedRef],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
deactivateEscapeInterceptor();
|
||||
isFocusedRef.current = false;
|
||||
|
||||
const cleanValue = value.trim();
|
||||
|
||||
if (cleanValue) {
|
||||
if (cleanValue !== defaultValue) {
|
||||
onUpdate(cleanValue);
|
||||
}
|
||||
} else {
|
||||
setValue(defaultValue);
|
||||
}
|
||||
}, [defaultValue, onUpdate, value, setValue, deactivateEscapeInterceptor]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
if (!isFocusedRef.current && defaultValue !== prevDefaultValue) {
|
||||
setValue(defaultValue);
|
||||
}
|
||||
}, [defaultValue, prevDefaultValue]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
fiedRef.current.blur();
|
||||
}, [blurFieldState]);
|
||||
|
||||
return (
|
||||
<TextArea
|
||||
ref={handleFieldRef}
|
||||
as={TextareaAutosize}
|
||||
value={value}
|
||||
maxLength={1024}
|
||||
spellCheck={false}
|
||||
className={classNames(styles.field, styles[`field${upperFirst(size)}`])}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
NameField.propTypes = {
|
||||
defaultValue: PropTypes.string.isRequired,
|
||||
size: PropTypes.oneOf(Object.values(Sizes)),
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
NameField.defaultProps = {
|
||||
size: Sizes.MEDIUM,
|
||||
};
|
||||
|
||||
export default NameField;
|
||||
@@ -0,0 +1,39 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.field {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
box-shadow: none;
|
||||
color: #17394d;
|
||||
font-weight: bold;
|
||||
margin: -5px;
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
resize: none;
|
||||
width: 100%;
|
||||
|
||||
&:focus {
|
||||
background: #fff;
|
||||
border-color: #5ba4cf;
|
||||
box-shadow: 0 0 2px 0 #5ba4cf;
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
|
||||
.fieldMedium {
|
||||
font-size: 20px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.fieldLarge {
|
||||
font-size: 28px;
|
||||
line-height: 34px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,809 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Grid, Icon } from 'semantic-ui-react';
|
||||
import { useDidUpdate } from '../../../lib/hooks';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { usePopupInClosableContext } from '../../../hooks';
|
||||
import { startStopwatch, stopStopwatch } from '../../../utils/stopwatch';
|
||||
import { isUsableMarkdownElement } from '../../../utils/element-helpers';
|
||||
import { BoardMembershipRoles, CardTypes, ListTypes } from '../../../constants/Enums';
|
||||
import { CardTypeIcons } from '../../../constants/Icons';
|
||||
import { ClosableContext } from '../../../contexts';
|
||||
import NameField from './NameField';
|
||||
import TaskLists from './TaskLists';
|
||||
import CustomFieldGroups from './CustomFieldGroups';
|
||||
import Communication from './Communication';
|
||||
import CreationDetailsStep from './CreationDetailsStep';
|
||||
import DueDateChip from '../DueDateChip';
|
||||
import StopwatchChip from '../StopwatchChip';
|
||||
import SelectCardTypeStep from '../SelectCardTypeStep';
|
||||
import EditDueDateStep from '../EditDueDateStep';
|
||||
import EditStopwatchStep from '../EditStopwatchStep';
|
||||
import MoveCardStep from '../MoveCardStep';
|
||||
import Markdown from '../../common/Markdown';
|
||||
import EditMarkdown from '../../common/EditMarkdown';
|
||||
import ConfirmationStep from '../../common/ConfirmationStep';
|
||||
import UserAvatar from '../../users/UserAvatar';
|
||||
import BoardMembershipsStep from '../../board-memberships/BoardMembershipsStep';
|
||||
import LabelChip from '../../labels/LabelChip';
|
||||
import LabelsStep from '../../labels/LabelsStep';
|
||||
import ListsStep from '../../lists/ListsStep';
|
||||
import AddTaskListStep from '../../task-lists/AddTaskListStep';
|
||||
import Attachments from '../../attachments/Attachments';
|
||||
import AddAttachmentStep from '../../attachments/AddAttachmentStep';
|
||||
import AddCustomFieldGroupStep from '../../custom-field-groups/AddCustomFieldGroupStep';
|
||||
|
||||
import styles from './ProjectContent.module.scss';
|
||||
|
||||
const ProjectContent = React.memo(({ onClose }) => {
|
||||
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
|
||||
const selectPrevListById = useMemo(() => selectors.makeSelectListById(), []);
|
||||
|
||||
const card = useSelector(selectors.selectCurrentCard);
|
||||
const board = useSelector(selectors.selectCurrentBoard);
|
||||
const userIds = useSelector(selectors.selectUserIdsForCurrentCard);
|
||||
const labelIds = useSelector(selectors.selectLabelIdsForCurrentCard);
|
||||
const attachmentIds = useSelector(selectors.selectAttachmentIdsForCurrentCard);
|
||||
|
||||
const isJoined = useSelector(selectors.selectIsCurrentUserInCurrentCard);
|
||||
|
||||
const list = useSelector((state) => selectListById(state, card.listId));
|
||||
|
||||
// TODO: check availability?
|
||||
const prevList = useSelector(
|
||||
(state) => card.prevListId && selectPrevListById(state, card.prevListId),
|
||||
);
|
||||
|
||||
const isInArchiveList = list.type === ListTypes.ARCHIVE;
|
||||
const isInTrashList = list.type === ListTypes.TRASH;
|
||||
|
||||
const {
|
||||
canEditType,
|
||||
canEditName,
|
||||
canEditDescription,
|
||||
canEditDueDate,
|
||||
canEditStopwatch,
|
||||
canSubscribe,
|
||||
canJoin,
|
||||
canDuplicate,
|
||||
canMove,
|
||||
canRestore,
|
||||
canArchive,
|
||||
canDelete,
|
||||
canUseLists,
|
||||
canUseMembers,
|
||||
canUseLabels,
|
||||
canAddTaskList,
|
||||
canAddAttachment,
|
||||
canAddCustomFieldGroup,
|
||||
} = useSelector((state) => {
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
|
||||
let isMember = false;
|
||||
let isEditor = false;
|
||||
|
||||
if (boardMembership) {
|
||||
isMember = true;
|
||||
isEditor = boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
}
|
||||
|
||||
if (isInArchiveList || isInTrashList) {
|
||||
return {
|
||||
canEditType: false,
|
||||
canEditName: false,
|
||||
canEditDescription: false,
|
||||
canEditDueDate: false,
|
||||
canEditStopwatch: false,
|
||||
canSubscribe: isMember,
|
||||
canJoin: false,
|
||||
canDuplicate: false,
|
||||
canMove: false,
|
||||
canRestore: isEditor,
|
||||
canArchive: isEditor,
|
||||
canDelete: isEditor,
|
||||
canUseLists: isEditor,
|
||||
canUseMembers: false,
|
||||
canUseLabels: false,
|
||||
canAddTaskList: false,
|
||||
canAddAttachment: false,
|
||||
canAddCustomFieldGroup: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
canEditType: isEditor,
|
||||
canEditName: isEditor,
|
||||
canEditDescription: isEditor,
|
||||
canEditDueDate: isEditor,
|
||||
canEditStopwatch: isEditor,
|
||||
canSubscribe: isMember,
|
||||
canJoin: isEditor,
|
||||
canDuplicate: isEditor,
|
||||
canMove: isEditor,
|
||||
canRestore: null,
|
||||
canArchive: isEditor,
|
||||
canDelete: isEditor,
|
||||
canUseLists: isEditor,
|
||||
canUseMembers: isEditor,
|
||||
canUseLabels: isEditor,
|
||||
canAddTaskList: isEditor,
|
||||
canAddAttachment: isEditor,
|
||||
canAddCustomFieldGroup: isEditor,
|
||||
};
|
||||
}, shallowEqual);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
const [descriptionDraft, setDescriptionDraft] = useState(null);
|
||||
const [isEditDescriptionOpened, setIsEditDescriptionOpened] = useState(false);
|
||||
const [, , setIsClosableActive] = useContext(ClosableContext);
|
||||
|
||||
const handleListSelect = useCallback(
|
||||
(listId) => {
|
||||
dispatch(entryActions.moveCurrentCard(listId));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleTypeSelect = useCallback(
|
||||
(type) => {
|
||||
dispatch(
|
||||
entryActions.updateCurrentCard({
|
||||
type,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleNameUpdate = useCallback(
|
||||
(name) => {
|
||||
dispatch(
|
||||
entryActions.updateCurrentCard({
|
||||
name,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleDescriptionUpdate = useCallback(
|
||||
(description) => {
|
||||
dispatch(
|
||||
entryActions.updateCurrentCard({
|
||||
description,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleToggleStopwatchClick = useCallback(() => {
|
||||
dispatch(
|
||||
entryActions.updateCurrentCard({
|
||||
stopwatch: card.stopwatch.startedAt
|
||||
? stopStopwatch(card.stopwatch)
|
||||
: startStopwatch(card.stopwatch),
|
||||
}),
|
||||
);
|
||||
}, [card.stopwatch, dispatch]);
|
||||
|
||||
const handleDuplicateClick = useCallback(() => {
|
||||
dispatch(
|
||||
entryActions.duplicateCurrentCard({
|
||||
name: `${card.name} (${t('common.copy', {
|
||||
context: 'inline',
|
||||
})})`,
|
||||
}),
|
||||
);
|
||||
|
||||
onClose();
|
||||
}, [onClose, card.name, dispatch, t]);
|
||||
|
||||
const handleRestoreClick = useCallback(() => {
|
||||
dispatch(entryActions.moveCurrentCard(card.prevListId, undefined, true));
|
||||
}, [card.prevListId, dispatch]);
|
||||
|
||||
const handleArchiveConfirm = useCallback(() => {
|
||||
dispatch(entryActions.moveCurrentCardToArchive());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleDeleteConfirm = useCallback(() => {
|
||||
if (isInTrashList) {
|
||||
dispatch(entryActions.deleteCurrentCard());
|
||||
} else {
|
||||
dispatch(entryActions.moveCurrentCardToTrash());
|
||||
}
|
||||
}, [isInTrashList, dispatch]);
|
||||
|
||||
const handleUserSelect = useCallback(
|
||||
(userId) => {
|
||||
dispatch(entryActions.addUserToCurrentCard(userId));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleUserDeselect = useCallback(
|
||||
(userId) => {
|
||||
dispatch(entryActions.removeUserFromCurrentCard(userId));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleLabelSelect = useCallback(
|
||||
(labelId) => {
|
||||
dispatch(entryActions.addLabelToCurrentCard(labelId));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleLabelDeselect = useCallback(
|
||||
(labelId) => {
|
||||
dispatch(entryActions.removeLabelFromCurrentCard(labelId));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleCustomFieldGroupCreate = useCallback(
|
||||
(data) => {
|
||||
dispatch(entryActions.createCustomFieldGroupInCurrentCard(data));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleToggleJointClick = useCallback(() => {
|
||||
if (isJoined) {
|
||||
dispatch(entryActions.removeCurrentUserFromCurrentCard());
|
||||
} else {
|
||||
dispatch(entryActions.addCurrentUserToCurrentCard());
|
||||
}
|
||||
}, [isJoined, dispatch]);
|
||||
|
||||
const handleToggleSubscriptionClick = useCallback(() => {
|
||||
dispatch(
|
||||
entryActions.updateCurrentCard({
|
||||
isSubscribed: !card.isSubscribed,
|
||||
}),
|
||||
);
|
||||
}, [card.isSubscribed, dispatch]);
|
||||
|
||||
const handleEditDescriptionClick = useCallback((event) => {
|
||||
if (window.getSelection().toString() || isUsableMarkdownElement(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEditDescriptionOpened(true);
|
||||
}, []);
|
||||
|
||||
const handleEditDescriptionClose = useCallback((nextDescriptionDraft) => {
|
||||
setDescriptionDraft(nextDescriptionDraft);
|
||||
setIsEditDescriptionOpened(false);
|
||||
}, []);
|
||||
|
||||
useDidUpdate(() => {
|
||||
if (!canEditDescription) {
|
||||
setIsEditDescriptionOpened(false);
|
||||
}
|
||||
}, [canEditDescription]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
setIsClosableActive(isEditDescriptionOpened);
|
||||
}, [isEditDescriptionOpened]);
|
||||
|
||||
const CreationDetailsPopup = usePopupInClosableContext(CreationDetailsStep);
|
||||
const BoardMembershipsPopup = usePopupInClosableContext(BoardMembershipsStep);
|
||||
const LabelsPopup = usePopupInClosableContext(LabelsStep);
|
||||
const ListsPopup = usePopupInClosableContext(ListsStep);
|
||||
const SelectCardTypePopup = usePopupInClosableContext(SelectCardTypeStep);
|
||||
const EditDueDatePopup = usePopupInClosableContext(EditDueDateStep);
|
||||
const EditStopwatchPopup = usePopupInClosableContext(EditStopwatchStep);
|
||||
const AddTaskListPopup = usePopupInClosableContext(AddTaskListStep);
|
||||
const AddAttachmentPopup = usePopupInClosableContext(AddAttachmentStep);
|
||||
const AddCustomFieldGroupPopup = usePopupInClosableContext(AddCustomFieldGroupStep);
|
||||
const MoveCardPopup = usePopupInClosableContext(MoveCardStep);
|
||||
const ConfirmationPopup = usePopupInClosableContext(ConfirmationStep);
|
||||
|
||||
return (
|
||||
<Grid className={styles.wrapper}>
|
||||
<Grid.Row className={styles.headerPadding}>
|
||||
<Grid.Column width={16} className={styles.headerPadding}>
|
||||
<div className={styles.headerWrapper}>
|
||||
<Icon name={CardTypeIcons[CardTypes.PROJECT]} className={styles.moduleIcon} />
|
||||
<div className={styles.headerTitleWrapper}>
|
||||
{canEditName ? (
|
||||
<NameField defaultValue={card.name} onUpdate={handleNameUpdate} />
|
||||
) : (
|
||||
<div className={styles.headerTitle}>{card.name}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
<Grid.Row className={styles.modalPadding}>
|
||||
<Grid.Column width={12} className={styles.contentPadding}>
|
||||
{(card.dueDate ||
|
||||
card.stopwatch ||
|
||||
board.alwaysDisplayCardCreator ||
|
||||
userIds.length > 0 ||
|
||||
labelIds.length > 0) && (
|
||||
<div className={styles.moduleWrapper}>
|
||||
{board.alwaysDisplayCardCreator && (
|
||||
<div className={styles.attachments}>
|
||||
<div className={styles.text}>
|
||||
{t('common.creator', {
|
||||
context: 'title',
|
||||
})}
|
||||
</div>
|
||||
<span className={styles.attachment}>
|
||||
<CreationDetailsPopup userId={card.creatorUserId}>
|
||||
<UserAvatar withCreatorIndicator id={card.creatorUserId} />
|
||||
</CreationDetailsPopup>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{userIds.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
<div className={styles.text}>
|
||||
{t('common.members', {
|
||||
context: 'title',
|
||||
})}
|
||||
</div>
|
||||
{userIds.map((userId) => (
|
||||
<span key={userId} className={styles.attachment}>
|
||||
{canUseMembers ? (
|
||||
<BoardMembershipsPopup
|
||||
currentUserIds={userIds}
|
||||
onUserSelect={handleUserSelect}
|
||||
onUserDeselect={handleUserDeselect}
|
||||
>
|
||||
<UserAvatar id={userId} />
|
||||
</BoardMembershipsPopup>
|
||||
) : (
|
||||
<UserAvatar id={userId} />
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{canUseMembers && (
|
||||
<BoardMembershipsPopup
|
||||
currentUserIds={userIds}
|
||||
onUserSelect={handleUserSelect}
|
||||
onUserDeselect={handleUserDeselect}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(styles.attachment, styles.dueDate)}
|
||||
>
|
||||
<Icon name="add" size="small" className={styles.addAttachment} />
|
||||
</button>
|
||||
</BoardMembershipsPopup>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{labelIds.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
<div className={styles.text}>
|
||||
{t('common.labels', {
|
||||
context: 'title',
|
||||
})}
|
||||
</div>
|
||||
{labelIds.map((labelId) => (
|
||||
<span key={labelId} className={styles.attachment}>
|
||||
{canUseLabels ? (
|
||||
<LabelsPopup
|
||||
currentIds={labelIds}
|
||||
cardId={card.id}
|
||||
onSelect={handleLabelSelect}
|
||||
onDeselect={handleLabelDeselect}
|
||||
>
|
||||
<LabelChip id={labelId} />
|
||||
</LabelsPopup>
|
||||
) : (
|
||||
<LabelChip id={labelId} />
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{canUseLabels && (
|
||||
<LabelsPopup
|
||||
currentIds={labelIds}
|
||||
cardId={card.id}
|
||||
onSelect={handleLabelSelect}
|
||||
onDeselect={handleLabelDeselect}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(styles.attachment, styles.dueDate)}
|
||||
>
|
||||
<Icon name="add" size="small" className={styles.addAttachment} />
|
||||
</button>
|
||||
</LabelsPopup>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{card.dueDate && (
|
||||
<div className={styles.attachments}>
|
||||
<div className={styles.text}>
|
||||
{t('common.dueDate', {
|
||||
context: 'title',
|
||||
})}
|
||||
</div>
|
||||
<span className={styles.attachment}>
|
||||
{canEditDueDate ? (
|
||||
<EditDueDatePopup cardId={card.id}>
|
||||
<DueDateChip
|
||||
withStatusIcon
|
||||
value={card.dueDate}
|
||||
withStatus={
|
||||
list.type !== ListTypes.CLOSED && !isInArchiveList && !isInTrashList
|
||||
}
|
||||
/>
|
||||
</EditDueDatePopup>
|
||||
) : (
|
||||
<DueDateChip
|
||||
withStatusIcon
|
||||
value={card.dueDate}
|
||||
withStatus={
|
||||
list.type !== ListTypes.CLOSED && !isInArchiveList && !isInTrashList
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{card.stopwatch && (
|
||||
<div className={styles.attachments}>
|
||||
<div className={styles.text}>
|
||||
{t('common.stopwatch', {
|
||||
context: 'title',
|
||||
})}
|
||||
</div>
|
||||
<span className={styles.attachment}>
|
||||
{canEditStopwatch ? (
|
||||
<EditStopwatchPopup cardId={card.id}>
|
||||
<StopwatchChip value={card.stopwatch} />
|
||||
</EditStopwatchPopup>
|
||||
) : (
|
||||
<StopwatchChip value={card.stopwatch} />
|
||||
)}
|
||||
</span>
|
||||
{canEditStopwatch && (
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(styles.attachment, styles.dueDate)}
|
||||
onClick={handleToggleStopwatchClick}
|
||||
>
|
||||
<Icon
|
||||
name={card.stopwatch.startedAt ? 'pause' : 'play'}
|
||||
size="small"
|
||||
className={styles.addAttachment}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(card.description || canEditDescription) && (
|
||||
<div className={classNames(styles.contentModule, styles.contentModuleDescription)}>
|
||||
<div className={styles.moduleWrapper}>
|
||||
<Icon name="align left" className={styles.moduleIcon} />
|
||||
<div className={styles.moduleHeader}>
|
||||
{t('common.description')}
|
||||
{canEditDescription && !isEditDescriptionOpened && descriptionDraft && (
|
||||
<span className={styles.draftChip}>{t('common.unsavedChanges')}</span>
|
||||
)}
|
||||
</div>
|
||||
{canEditDescription && (
|
||||
<>
|
||||
{isEditDescriptionOpened && (
|
||||
<EditMarkdown
|
||||
defaultValue={card.description}
|
||||
draftValue={descriptionDraft}
|
||||
placeholder="common.enterDescription"
|
||||
onUpdate={handleDescriptionUpdate}
|
||||
onClose={handleEditDescriptionClose}
|
||||
/>
|
||||
)}
|
||||
{!isEditDescriptionOpened &&
|
||||
(card.description ? (
|
||||
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
|
||||
jsx-a11y/no-static-element-interactions */
|
||||
<div className={styles.cursorPointer} onClick={handleEditDescriptionClick}>
|
||||
<Button className={styles.editButton}>
|
||||
<Icon fitted name="pencil" size="small" />
|
||||
</Button>
|
||||
<Markdown>{card.description}</Markdown>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.descriptionButton}
|
||||
onClick={handleEditDescriptionClick}
|
||||
>
|
||||
<span className={styles.descriptionButtonText}>
|
||||
{t('action.addMoreDetailedDescription')}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!canEditDescription && <Markdown>{card.description}</Markdown>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CustomFieldGroups />
|
||||
<TaskLists />
|
||||
{attachmentIds.length > 0 && (
|
||||
<div className={styles.contentModule}>
|
||||
<div className={styles.moduleWrapper}>
|
||||
<Icon name="attach" className={styles.moduleIcon} />
|
||||
<div className={styles.moduleHeader}>{t('common.attachments')}</div>
|
||||
<Attachments />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.contentModule}>
|
||||
<div className={styles.moduleWrapper}>
|
||||
<Icon name="list ul" className={styles.moduleIcon} />
|
||||
<Communication />
|
||||
</div>
|
||||
</div>
|
||||
</Grid.Column>
|
||||
<Grid.Column width={4} className={styles.sidebarPadding}>
|
||||
<div className={styles.sticky}>
|
||||
<div className={styles.actions}>
|
||||
<div className={classNames(styles.attachments, styles.attachmentsList)}>
|
||||
<div className={classNames(styles.text, styles.textList)}>{t('common.list')}</div>
|
||||
{canUseLists ? (
|
||||
<ListsPopup currentId={list.id} onSelect={handleListSelect}>
|
||||
<button type="button" className={styles.listButton}>
|
||||
<span className={classNames(styles.list, styles.listHoverable)}>
|
||||
<Icon name="columns" size="small" className={styles.listIcon} />
|
||||
<span className={styles.hidable}>
|
||||
{list.name || t(`common.${list.type}`)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</ListsPopup>
|
||||
) : (
|
||||
<span className={styles.list}>
|
||||
<Icon name="columns" size="small" className={styles.listIcon} />
|
||||
<span className={styles.hidable}>{list.name || t(`common.${list.type}`)}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(canEditDueDate ||
|
||||
canEditStopwatch ||
|
||||
canUseMembers ||
|
||||
canUseLabels ||
|
||||
canAddTaskList ||
|
||||
canAddAttachment ||
|
||||
canAddCustomFieldGroup) && (
|
||||
<div className={styles.actions}>
|
||||
<span className={styles.actionsTitle}>{t('action.addToCard')}</span>
|
||||
{canUseMembers && (
|
||||
<BoardMembershipsPopup
|
||||
currentUserIds={userIds}
|
||||
onUserSelect={handleUserSelect}
|
||||
onUserDeselect={handleUserDeselect}
|
||||
>
|
||||
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
|
||||
<Icon name="user outline" className={styles.actionIcon} />
|
||||
{t('common.members')}
|
||||
</Button>
|
||||
</BoardMembershipsPopup>
|
||||
)}
|
||||
{canUseLabels && (
|
||||
<LabelsPopup
|
||||
currentIds={labelIds}
|
||||
cardId={card.id}
|
||||
onSelect={handleLabelSelect}
|
||||
onDeselect={handleLabelDeselect}
|
||||
>
|
||||
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
|
||||
<Icon name="bookmark outline" className={styles.actionIcon} />
|
||||
{t('common.labels')}
|
||||
</Button>
|
||||
</LabelsPopup>
|
||||
)}
|
||||
{canEditDueDate && (
|
||||
<EditDueDatePopup cardId={card.id}>
|
||||
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
|
||||
<Icon name="calendar check outline" className={styles.actionIcon} />
|
||||
{t('common.dueDate', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Button>
|
||||
</EditDueDatePopup>
|
||||
)}
|
||||
{canEditStopwatch && (
|
||||
<EditStopwatchPopup cardId={card.id}>
|
||||
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
|
||||
<Icon name="clock outline" className={styles.actionIcon} />
|
||||
{t('common.stopwatch')}
|
||||
</Button>
|
||||
</EditStopwatchPopup>
|
||||
)}
|
||||
{canAddTaskList && (
|
||||
<AddTaskListPopup>
|
||||
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
|
||||
<Icon name="check square outline" className={styles.actionIcon} />
|
||||
{t('common.taskList', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Button>
|
||||
</AddTaskListPopup>
|
||||
)}
|
||||
{canAddAttachment && (
|
||||
<AddAttachmentPopup>
|
||||
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
|
||||
<Icon name="attach" className={styles.actionIcon} />
|
||||
{t('common.attachment')}
|
||||
</Button>
|
||||
</AddAttachmentPopup>
|
||||
)}
|
||||
{canAddCustomFieldGroup && (
|
||||
<AddCustomFieldGroupPopup onCreate={handleCustomFieldGroupCreate}>
|
||||
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
|
||||
<Icon name="sticky note outline" className={styles.actionIcon} />
|
||||
{t('common.customField', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Button>
|
||||
</AddCustomFieldGroupPopup>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{((!board.limitCardTypesToDefaultOne && canEditType) ||
|
||||
canSubscribe ||
|
||||
canJoin ||
|
||||
canDuplicate ||
|
||||
canMove ||
|
||||
(canRestore && (isInArchiveList || isInTrashList)) ||
|
||||
(canArchive && !isInArchiveList) ||
|
||||
canDelete) && (
|
||||
<div className={styles.actions}>
|
||||
<span className={styles.actionsTitle}>{t('common.actions')}</span>
|
||||
{canJoin && (
|
||||
<Button
|
||||
fluid
|
||||
className={classNames(styles.actionButton, styles.hidable)}
|
||||
onClick={handleToggleJointClick}
|
||||
>
|
||||
<Icon
|
||||
name={isJoined ? 'flag outline' : 'flag checkered'}
|
||||
className={styles.actionIcon}
|
||||
/>
|
||||
{isJoined ? t('action.leave') : t('action.join')}
|
||||
</Button>
|
||||
)}
|
||||
{canSubscribe && (
|
||||
<Button
|
||||
fluid
|
||||
disabled={board.isSubscribed}
|
||||
className={classNames(styles.actionButton, styles.hidable)}
|
||||
onClick={handleToggleSubscriptionClick}
|
||||
>
|
||||
{board.isSubscribed ? (
|
||||
<>
|
||||
<Icon name="bell slash outline" className={styles.actionIcon} />
|
||||
{t('common.boardSubscribed')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon
|
||||
name={card.isSubscribed ? 'bell slash outline' : 'bell outline'}
|
||||
className={styles.actionIcon}
|
||||
/>
|
||||
{card.isSubscribed ? t('action.unsubscribe') : t('action.subscribe')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!board.limitCardTypesToDefaultOne && canEditType && (
|
||||
<SelectCardTypePopup
|
||||
withButton
|
||||
defaultValue={card.type}
|
||||
title="common.editType"
|
||||
buttonContent="action.save"
|
||||
onSelect={handleTypeSelect}
|
||||
>
|
||||
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
|
||||
<Icon name="map outline" className={styles.actionIcon} />
|
||||
{t('action.editType', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Button>
|
||||
</SelectCardTypePopup>
|
||||
)}
|
||||
{canDuplicate && (
|
||||
<Button
|
||||
fluid
|
||||
className={classNames(styles.actionButton, styles.hidable)}
|
||||
onClick={handleDuplicateClick}
|
||||
>
|
||||
<Icon name="copy outline" className={styles.actionIcon} />
|
||||
{t('action.duplicate')}
|
||||
</Button>
|
||||
)}
|
||||
{canMove && (
|
||||
<MoveCardPopup id={card.id}>
|
||||
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
|
||||
<Icon name="share square outline" className={styles.actionIcon} />
|
||||
{t('action.move')}
|
||||
</Button>
|
||||
</MoveCardPopup>
|
||||
)}
|
||||
{canRestore && (isInArchiveList || isInTrashList) && (
|
||||
<Button
|
||||
fluid
|
||||
disabled={!prevList}
|
||||
className={classNames(styles.actionButton, styles.hidable)}
|
||||
onClick={handleRestoreClick}
|
||||
>
|
||||
<Icon name="undo alternate" className={styles.actionIcon} />
|
||||
{prevList
|
||||
? t('action.restoreToList', {
|
||||
list: prevList.name || t(`common.${prevList.type}`),
|
||||
})
|
||||
: t('common.selectListToRestoreThisCard')}
|
||||
</Button>
|
||||
)}
|
||||
{canArchive && !isInArchiveList && (
|
||||
<ConfirmationPopup
|
||||
title="common.archiveCard"
|
||||
content="common.areYouSureYouWantToArchiveThisCard"
|
||||
buttonContent="action.archiveCard"
|
||||
onConfirm={handleArchiveConfirm}
|
||||
>
|
||||
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
|
||||
<Icon name="folder open outline" className={styles.actionIcon} />
|
||||
{t('action.archive')}
|
||||
</Button>
|
||||
</ConfirmationPopup>
|
||||
)}
|
||||
{canDelete && (
|
||||
<ConfirmationPopup
|
||||
title={isInTrashList ? 'common.deleteCardForever' : 'common.deleteCard'}
|
||||
content={
|
||||
isInTrashList
|
||||
? 'common.areYouSureYouWantToDeleteThisCardForever'
|
||||
: 'common.areYouSureYouWantToDeleteThisCard'
|
||||
}
|
||||
buttonContent={isInTrashList ? 'action.deleteCardForever' : 'action.deleteCard'}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
>
|
||||
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
|
||||
<Icon name="trash alternate outline" className={styles.actionIcon} />
|
||||
{isInTrashList
|
||||
? t('action.deleteForever', {
|
||||
context: 'title',
|
||||
})
|
||||
: t('action.delete')}
|
||||
</Button>
|
||||
</ConfirmationPopup>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
);
|
||||
});
|
||||
|
||||
ProjectContent.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ProjectContent;
|
||||
@@ -0,0 +1,302 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.actionButton {
|
||||
background: #ebeef0;
|
||||
box-shadow: 0 1px 0 0 rgba(9, 30, 66, 0.13);
|
||||
color: #444;
|
||||
margin-top: 8px;
|
||||
padding: 6px 8px 6px 18px;
|
||||
text-align: left;
|
||||
transition: background 85ms ease;
|
||||
|
||||
&:hover {
|
||||
background: #dfe3e6;
|
||||
box-shadow: 0 1px 0 0 rgba(9, 30, 66, 0.25);
|
||||
color: #4c4c4c;
|
||||
}
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
color: #17394d;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-bottom: 24px;
|
||||
|
||||
@media only screen and (width < 768px) {
|
||||
flex: 1;
|
||||
|
||||
&:first-child {
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actionsTitle {
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
letter-spacing: 0.04em;
|
||||
margin-top: 16px;
|
||||
text-transform: uppercase;
|
||||
line-height: 20px;
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
|
||||
.addAttachment {
|
||||
margin: 0 -4.3px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.attachment {
|
||||
display: inline-block;
|
||||
margin: 0 4px 4px 0;
|
||||
max-width: 100%;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
display: inline-block;
|
||||
margin: 0 8px 8px 0;
|
||||
max-width: 100%;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.attachmentsList {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.contentModule {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.contentModuleDescription:hover {
|
||||
.editButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.contentPadding {
|
||||
padding: 8px 24px 0 16px;
|
||||
|
||||
@media only screen and (width < 768px) {
|
||||
padding-right: 16px;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cursorPointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dueDate {
|
||||
background: rgba(9, 30, 66, 0.04);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #6b808c;
|
||||
cursor: pointer;
|
||||
line-height: 20px;
|
||||
outline: none;
|
||||
padding: 6px 14px;
|
||||
text-align: left;
|
||||
text-decoration: underline;
|
||||
transition: background 0.3s ease;
|
||||
vertical-align: top;
|
||||
|
||||
&:hover {
|
||||
background: rgba(9, 30, 66, 0.08);
|
||||
color: #17394d;
|
||||
}
|
||||
}
|
||||
|
||||
.descriptionButton {
|
||||
background: rgba(9, 30, 66, 0.04);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
color: #6b808c;
|
||||
cursor: pointer;
|
||||
min-height: 54px;
|
||||
outline: none;
|
||||
padding: 8px 12px;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background: rgba(9, 30, 66, 0.08);
|
||||
color: #092d42;
|
||||
}
|
||||
}
|
||||
|
||||
.descriptionButtonText {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
.draftChip {
|
||||
background: #f1eee2;
|
||||
border-radius: 3px;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.editButton {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
line-height: 28px;
|
||||
margin: 0;
|
||||
min-height: auto;
|
||||
opacity: 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 4px;
|
||||
width: 28px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(9, 30, 66, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.headerPadding {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
color: #17394d;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
line-height: 24px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.headerTitleWrapper {
|
||||
margin: 4px 0;
|
||||
padding: 6px 0 0;
|
||||
}
|
||||
|
||||
.headerWrapper {
|
||||
margin: 12px 48px 12px 56px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hidable {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.list {
|
||||
align-items: center;
|
||||
background: rgba(9, 30, 66, 0.04);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #6b808c;
|
||||
display: flex;
|
||||
line-height: 20px;
|
||||
outline: none;
|
||||
padding: 6px 12px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.listButton {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.listHoverable:hover {
|
||||
background: rgba(9, 30, 66, 0.08);
|
||||
color: #17394d;
|
||||
}
|
||||
|
||||
.listIcon {
|
||||
margin: 0 8px 0 0;
|
||||
}
|
||||
|
||||
.modalPadding {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.moduleHeader {
|
||||
color: #17394d;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 4px;
|
||||
padding: 6px 0;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.moduleIcon {
|
||||
color: #17394d;
|
||||
font-size: 17px;
|
||||
height: 32px;
|
||||
left: -40px;
|
||||
line-height: 32px;
|
||||
margin-right: 0;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.moduleWrapper {
|
||||
margin: 0 0 0 40px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebarPadding {
|
||||
padding: 8px 16px 8px 8px;
|
||||
|
||||
@media only screen and (width < 768px) {
|
||||
padding-left: 16px;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
@media only screen and (width < 768px) {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #6b808c;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.3px;
|
||||
line-height: 20px;
|
||||
margin: 0 8px 4px 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.textList {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
background: #f5f6f7;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Gallery, Item as GalleryItem } from 'react-photoswipe-gallery';
|
||||
import { Button, Grid, Icon } from 'semantic-ui-react';
|
||||
import { useDidUpdate } from '../../../../lib/hooks';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import { usePopupInClosableContext } from '../../../../hooks';
|
||||
import { isUsableMarkdownElement } from '../../../../utils/element-helpers';
|
||||
import { BoardMembershipRoles, CardTypes, ListTypes } from '../../../../constants/Enums';
|
||||
import { CardTypeIcons } from '../../../../constants/Icons';
|
||||
import { ClosableContext } from '../../../../contexts';
|
||||
import Thumbnail from './Thumbnail';
|
||||
import NameField from '../NameField';
|
||||
import CustomFieldGroups from '../CustomFieldGroups';
|
||||
import Communication from '../Communication';
|
||||
import CreationDetailsStep from '../CreationDetailsStep';
|
||||
import SelectCardTypeStep from '../../SelectCardTypeStep';
|
||||
import MoveCardStep from '../../MoveCardStep';
|
||||
import Markdown from '../../../common/Markdown';
|
||||
import EditMarkdown from '../../../common/EditMarkdown';
|
||||
import ConfirmationStep from '../../../common/ConfirmationStep';
|
||||
import UserAvatar from '../../../users/UserAvatar';
|
||||
import BoardMembershipsStep from '../../../board-memberships/BoardMembershipsStep';
|
||||
import LabelChip from '../../../labels/LabelChip';
|
||||
import LabelsStep from '../../../labels/LabelsStep';
|
||||
import ListsStep from '../../../lists/ListsStep';
|
||||
import Attachments from '../../../attachments/Attachments';
|
||||
import AddAttachmentStep from '../../../attachments/AddAttachmentStep';
|
||||
import AddCustomFieldGroupStep from '../../../custom-field-groups/AddCustomFieldGroupStep';
|
||||
|
||||
import styles from './StoryContent.module.scss';
|
||||
|
||||
const StoryContent = React.memo(({ onClose }) => {
|
||||
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
|
||||
const selectPrevListById = useMemo(() => selectors.makeSelectListById(), []);
|
||||
const selectAttachmentById = useMemo(() => selectors.makeSelectAttachmentById(), []);
|
||||
|
||||
const card = useSelector(selectors.selectCurrentCard);
|
||||
const board = useSelector(selectors.selectCurrentBoard);
|
||||
const userIds = useSelector(selectors.selectUserIdsForCurrentCard);
|
||||
const labelIds = useSelector(selectors.selectLabelIdsForCurrentCard);
|
||||
const attachmentIds = useSelector(selectors.selectAttachmentIdsForCurrentCard);
|
||||
|
||||
const imageAttachmentIdsExceptCover = useSelector(
|
||||
selectors.selectImageAttachmentIdsExceptCoverForCurrentCard,
|
||||
);
|
||||
|
||||
const isJoined = useSelector(selectors.selectIsCurrentUserInCurrentCard);
|
||||
|
||||
const list = useSelector((state) => selectListById(state, card.listId));
|
||||
|
||||
// TODO: check availability?
|
||||
const prevList = useSelector(
|
||||
(state) => card.prevListId && selectPrevListById(state, card.prevListId),
|
||||
);
|
||||
|
||||
const coverAttachment = useSelector((state) =>
|
||||
selectAttachmentById(state, card.coverAttachmentId),
|
||||
);
|
||||
|
||||
const isInArchiveList = list.type === ListTypes.ARCHIVE;
|
||||
const isInTrashList = list.type === ListTypes.TRASH;
|
||||
|
||||
const {
|
||||
canEditType,
|
||||
canEditName,
|
||||
canEditDescription,
|
||||
canSubscribe,
|
||||
canJoin,
|
||||
canDuplicate,
|
||||
canMove,
|
||||
canRestore,
|
||||
canArchive,
|
||||
canDelete,
|
||||
canUseLists,
|
||||
canUseMembers,
|
||||
canUseLabels,
|
||||
canAddAttachment,
|
||||
canAddCustomFieldGroup,
|
||||
} = useSelector((state) => {
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
|
||||
let isMember = false;
|
||||
let isEditor = false;
|
||||
|
||||
if (boardMembership) {
|
||||
isMember = true;
|
||||
isEditor = boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
}
|
||||
|
||||
if (isInArchiveList || isInTrashList) {
|
||||
return {
|
||||
canEditType: false,
|
||||
canEditName: false,
|
||||
canEditDescription: false,
|
||||
canSubscribe: isMember,
|
||||
canJoin: false,
|
||||
canDuplicate: false,
|
||||
canMove: false,
|
||||
canRestore: isEditor,
|
||||
canArchive: isEditor,
|
||||
canDelete: isEditor,
|
||||
canUseLists: isEditor,
|
||||
canUseMembers: false,
|
||||
canUseLabels: false,
|
||||
canAddAttachment: false,
|
||||
canAddCustomFieldGroup: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
canEditType: isEditor,
|
||||
canEditName: isEditor,
|
||||
canEditDescription: isEditor,
|
||||
canSubscribe: isMember,
|
||||
canJoin: isEditor,
|
||||
canDuplicate: isEditor,
|
||||
canMove: isEditor,
|
||||
canRestore: null,
|
||||
canArchive: isEditor,
|
||||
canDelete: isEditor,
|
||||
canUseLists: isEditor,
|
||||
canUseMembers: isEditor,
|
||||
canUseLabels: isEditor,
|
||||
canAddAttachment: isEditor,
|
||||
canAddCustomFieldGroup: isEditor,
|
||||
};
|
||||
}, shallowEqual);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
const [descriptionDraft, setDescriptionDraft] = useState(null);
|
||||
const [isEditDescriptionOpened, setIsEditDescriptionOpened] = useState(false);
|
||||
const [activateClosable, deactivateClosable, setIsClosableActive] = useContext(ClosableContext);
|
||||
|
||||
const handleListSelect = useCallback(
|
||||
(listId) => {
|
||||
dispatch(entryActions.moveCurrentCard(listId));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleTypeSelect = useCallback(
|
||||
(type) => {
|
||||
dispatch(
|
||||
entryActions.updateCurrentCard({
|
||||
type,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleNameUpdate = useCallback(
|
||||
(name) => {
|
||||
dispatch(
|
||||
entryActions.updateCurrentCard({
|
||||
name,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleDescriptionUpdate = useCallback(
|
||||
(description) => {
|
||||
dispatch(
|
||||
entryActions.updateCurrentCard({
|
||||
description,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleDuplicateClick = useCallback(() => {
|
||||
dispatch(
|
||||
entryActions.duplicateCurrentCard({
|
||||
name: `${card.name} (${t('common.copy', {
|
||||
context: 'inline',
|
||||
})})`,
|
||||
}),
|
||||
);
|
||||
|
||||
onClose();
|
||||
}, [onClose, card.name, dispatch, t]);
|
||||
|
||||
const handleRestoreClick = useCallback(() => {
|
||||
dispatch(entryActions.moveCurrentCard(card.prevListId, undefined, true));
|
||||
}, [card.prevListId, dispatch]);
|
||||
|
||||
const handleArchiveConfirm = useCallback(() => {
|
||||
dispatch(entryActions.moveCurrentCardToArchive());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleDeleteConfirm = useCallback(() => {
|
||||
if (isInTrashList) {
|
||||
dispatch(entryActions.deleteCurrentCard());
|
||||
} else {
|
||||
dispatch(entryActions.moveCurrentCardToTrash());
|
||||
}
|
||||
}, [isInTrashList, dispatch]);
|
||||
|
||||
const handleUserSelect = useCallback(
|
||||
(userId) => {
|
||||
dispatch(entryActions.addUserToCurrentCard(userId));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleUserDeselect = useCallback(
|
||||
(userId) => {
|
||||
dispatch(entryActions.removeUserFromCurrentCard(userId));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleLabelSelect = useCallback(
|
||||
(labelId) => {
|
||||
dispatch(entryActions.addLabelToCurrentCard(labelId));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleLabelDeselect = useCallback(
|
||||
(labelId) => {
|
||||
dispatch(entryActions.removeLabelFromCurrentCard(labelId));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleCustomFieldGroupCreate = useCallback(
|
||||
(data) => {
|
||||
dispatch(entryActions.createCustomFieldGroupInCurrentCard(data));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleToggleJointClick = useCallback(() => {
|
||||
if (isJoined) {
|
||||
dispatch(entryActions.removeCurrentUserFromCurrentCard());
|
||||
} else {
|
||||
dispatch(entryActions.addCurrentUserToCurrentCard());
|
||||
}
|
||||
}, [isJoined, dispatch]);
|
||||
|
||||
const handleToggleSubscriptionClick = useCallback(() => {
|
||||
dispatch(
|
||||
entryActions.updateCurrentCard({
|
||||
isSubscribed: !card.isSubscribed,
|
||||
}),
|
||||
);
|
||||
}, [card.isSubscribed, dispatch]);
|
||||
|
||||
const handleEditDescriptionClick = useCallback((event) => {
|
||||
if (window.getSelection().toString() || isUsableMarkdownElement(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEditDescriptionOpened(true);
|
||||
}, []);
|
||||
|
||||
const handleEditDescriptionClose = useCallback((nextDescriptionDraft) => {
|
||||
setDescriptionDraft(nextDescriptionDraft);
|
||||
setIsEditDescriptionOpened(false);
|
||||
}, []);
|
||||
|
||||
const handleBeforeGalleryOpen = useCallback(
|
||||
(gallery) => {
|
||||
activateClosable();
|
||||
|
||||
gallery.on('destroy', () => {
|
||||
deactivateClosable();
|
||||
});
|
||||
},
|
||||
[activateClosable, deactivateClosable],
|
||||
);
|
||||
|
||||
useDidUpdate(() => {
|
||||
if (!canEditDescription) {
|
||||
setIsEditDescriptionOpened(false);
|
||||
}
|
||||
}, [canEditDescription]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
setIsClosableActive(isEditDescriptionOpened);
|
||||
}, [isEditDescriptionOpened]);
|
||||
|
||||
const CreationDetailsPopup = usePopupInClosableContext(CreationDetailsStep);
|
||||
const BoardMembershipsPopup = usePopupInClosableContext(BoardMembershipsStep);
|
||||
const LabelsPopup = usePopupInClosableContext(LabelsStep);
|
||||
const ListsPopup = usePopupInClosableContext(ListsStep);
|
||||
const SelectCardTypePopup = usePopupInClosableContext(SelectCardTypeStep);
|
||||
const AddAttachmentPopup = usePopupInClosableContext(AddAttachmentStep);
|
||||
const AddCustomFieldGroupPopup = usePopupInClosableContext(AddCustomFieldGroupStep);
|
||||
const MoveCardPopup = usePopupInClosableContext(MoveCardStep);
|
||||
const ConfirmationPopup = usePopupInClosableContext(ConfirmationStep);
|
||||
|
||||
return (
|
||||
<Grid className={styles.wrapper}>
|
||||
<Grid.Row className={styles.headerPadding}>
|
||||
<Grid.Column width={16} className={styles.headerPadding}>
|
||||
<div className={styles.headerWrapper}>
|
||||
<Icon
|
||||
name={CardTypeIcons[CardTypes.STORY]}
|
||||
className={classNames(styles.moduleIcon, styles.moduleIconTitle)}
|
||||
/>
|
||||
<div className={styles.headerTitleWrapper}>
|
||||
{canEditName ? (
|
||||
<NameField defaultValue={card.name} size="large" onUpdate={handleNameUpdate} />
|
||||
) : (
|
||||
<div className={styles.headerTitle}>{card.name}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
<Grid.Row className={styles.modalPadding}>
|
||||
<Grid.Column width={12} className={styles.contentPadding}>
|
||||
<Gallery
|
||||
withCaption
|
||||
withDownloadButton
|
||||
options={{
|
||||
wheelToZoom: true,
|
||||
showHideAnimationType: 'none',
|
||||
closeTitle: '',
|
||||
zoomTitle: '',
|
||||
arrowPrevTitle: '',
|
||||
arrowNextTitle: '',
|
||||
errorMsg: '',
|
||||
paddingFn: (viewportSize) => {
|
||||
const paddingX = viewportSize.x / 20;
|
||||
const paddingY = viewportSize.y / 20;
|
||||
|
||||
return {
|
||||
top: paddingX,
|
||||
bottom: paddingX,
|
||||
left: paddingY,
|
||||
right: paddingY,
|
||||
};
|
||||
},
|
||||
}}
|
||||
onBeforeOpen={handleBeforeGalleryOpen}
|
||||
>
|
||||
{(board.alwaysDisplayCardCreator || labelIds.length > 0 || coverAttachment) && (
|
||||
<div className={classNames(styles.moduleWrapper, styles.moduleWrapperAttachments)}>
|
||||
{coverAttachment && (
|
||||
<div className={styles.coverWrapper}>
|
||||
<GalleryItem
|
||||
{...coverAttachment.data.image} // eslint-disable-line react/jsx-props-no-spreading
|
||||
original={coverAttachment.data.url}
|
||||
caption={coverAttachment.name}
|
||||
>
|
||||
{({ ref, open }) => (
|
||||
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
|
||||
jsx-a11y/no-noninteractive-element-interactions */
|
||||
<img
|
||||
ref={ref}
|
||||
src={coverAttachment.data.thumbnailUrls.outside720}
|
||||
alt={coverAttachment.name}
|
||||
className={styles.cover}
|
||||
onClick={open}
|
||||
/>
|
||||
)}
|
||||
</GalleryItem>
|
||||
</div>
|
||||
)}
|
||||
{board.alwaysDisplayCardCreator && (
|
||||
<div className={styles.attachments}>
|
||||
<span className={styles.attachment}>
|
||||
<CreationDetailsPopup userId={card.creatorUserId}>
|
||||
<UserAvatar withCreatorIndicator id={card.creatorUserId} size="tiny" />
|
||||
</CreationDetailsPopup>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{labelIds.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
{labelIds.map((labelId) => (
|
||||
<span key={labelId} className={styles.attachment}>
|
||||
{canUseLabels ? (
|
||||
<LabelsPopup
|
||||
currentIds={labelIds}
|
||||
cardId={card.id}
|
||||
onSelect={handleLabelSelect}
|
||||
onDeselect={handleLabelDeselect}
|
||||
>
|
||||
<LabelChip id={labelId} size="small" />
|
||||
</LabelsPopup>
|
||||
) : (
|
||||
<LabelChip id={labelId} size="small" />
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{canUseLabels && (
|
||||
<LabelsPopup
|
||||
currentIds={labelIds}
|
||||
cardId={card.id}
|
||||
onSelect={handleLabelSelect}
|
||||
onDeselect={handleLabelDeselect}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(styles.attachment, styles.dueDate)}
|
||||
>
|
||||
<Icon name="add" size="small" className={styles.addAttachment} />
|
||||
</button>
|
||||
</LabelsPopup>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(card.description || canEditDescription) && (
|
||||
<div className={classNames(styles.contentModule, styles.contentModuleDescription)}>
|
||||
<div className={styles.moduleWrapper}>
|
||||
{canEditDescription &&
|
||||
(isEditDescriptionOpened ? (
|
||||
<EditMarkdown
|
||||
defaultValue={card.description}
|
||||
draftValue={descriptionDraft}
|
||||
placeholder="common.enterDescription"
|
||||
onUpdate={handleDescriptionUpdate}
|
||||
onClose={handleEditDescriptionClose}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{descriptionDraft && (
|
||||
<span className={styles.draftChip}>{t('common.unsavedChanges')}</span>
|
||||
)}
|
||||
{card.description ? (
|
||||
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
|
||||
jsx-a11y/no-static-element-interactions */
|
||||
<div
|
||||
className={classNames(styles.descriptionText, styles.cursorPointer)}
|
||||
onClick={handleEditDescriptionClick}
|
||||
>
|
||||
<Button className={styles.editButton}>
|
||||
<Icon fitted name="pencil" size="small" />
|
||||
</Button>
|
||||
<Markdown>{card.description}</Markdown>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.descriptionButton}
|
||||
onClick={handleEditDescriptionClick}
|
||||
>
|
||||
<span className={styles.descriptionButtonText}>
|
||||
{t('action.addMoreDetailedDescription')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
{!canEditDescription && (
|
||||
<div className={styles.descriptionText}>
|
||||
<Markdown>{card.description}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
{imageAttachmentIdsExceptCover.length > 0 && (
|
||||
<div className={styles.thumbnails}>
|
||||
{imageAttachmentIdsExceptCover.map((attachmentId) => (
|
||||
<Thumbnail key={attachmentId} attachmentId={attachmentId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Gallery>
|
||||
<CustomFieldGroups />
|
||||
{attachmentIds.length > 0 && (
|
||||
<div className={styles.contentModule}>
|
||||
<div className={styles.moduleWrapper}>
|
||||
<Icon name="attach" className={styles.moduleIcon} />
|
||||
<div className={styles.moduleHeader}>{t('common.attachments')}</div>
|
||||
<Attachments hideImagesWhenNotAllVisible />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.contentModule}>
|
||||
<div className={styles.moduleWrapper}>
|
||||
<Icon name="list ul" className={styles.moduleIcon} />
|
||||
<Communication />
|
||||
</div>
|
||||
</div>
|
||||
</Grid.Column>
|
||||
<Grid.Column width={4} className={styles.sidebarPadding}>
|
||||
<div className={styles.sticky}>
|
||||
<div className={styles.actions}>
|
||||
<div className={classNames(styles.attachments, styles.attachmentsList)}>
|
||||
<div className={classNames(styles.text, styles.textList)}>{t('common.list')}</div>
|
||||
{canUseLists ? (
|
||||
<ListsPopup currentId={list.id} onSelect={handleListSelect}>
|
||||
<button type="button" className={styles.listButton}>
|
||||
<span className={classNames(styles.list, styles.listHoverable)}>
|
||||
<Icon name="columns" size="small" className={styles.listIcon} />
|
||||
<span className={styles.hidable}>
|
||||
{list.name || t(`common.${list.type}`)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</ListsPopup>
|
||||
) : (
|
||||
<span className={styles.list}>
|
||||
<Icon name="columns" size="small" className={styles.listIcon} />
|
||||
<span className={styles.hidable}>{list.name || t(`common.${list.type}`)}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(canUseMembers || canUseLabels || canAddAttachment || canAddCustomFieldGroup) && (
|
||||
<div className={styles.actions}>
|
||||
<span className={styles.actionsTitle}>{t('action.addToCard')}</span>
|
||||
{canUseLabels && (
|
||||
<LabelsPopup
|
||||
currentIds={labelIds}
|
||||
cardId={card.id}
|
||||
onSelect={handleLabelSelect}
|
||||
onDeselect={handleLabelDeselect}
|
||||
>
|
||||
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
|
||||
<Icon name="bookmark outline" className={styles.actionIcon} />
|
||||
{t('common.labels')}
|
||||
</Button>
|
||||
</LabelsPopup>
|
||||
)}
|
||||
{canAddAttachment && (
|
||||
<AddAttachmentPopup>
|
||||
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
|
||||
<Icon name="attach" className={styles.actionIcon} />
|
||||
{t('common.attachment')}
|
||||
</Button>
|
||||
</AddAttachmentPopup>
|
||||
)}
|
||||
{canAddCustomFieldGroup && (
|
||||
<AddCustomFieldGroupPopup onCreate={handleCustomFieldGroupCreate}>
|
||||
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
|
||||
<Icon name="sticky note outline" className={styles.actionIcon} />
|
||||
{t('common.customField', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Button>
|
||||
</AddCustomFieldGroupPopup>
|
||||
)}
|
||||
{canUseMembers && (
|
||||
<BoardMembershipsPopup
|
||||
currentUserIds={userIds}
|
||||
onUserSelect={handleUserSelect}
|
||||
onUserDeselect={handleUserDeselect}
|
||||
>
|
||||
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
|
||||
<Icon name="user outline" className={styles.actionIcon} />
|
||||
{t('common.members')}
|
||||
</Button>
|
||||
</BoardMembershipsPopup>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{((!board.limitCardTypesToDefaultOne && canEditType) ||
|
||||
canSubscribe ||
|
||||
canJoin ||
|
||||
canDuplicate ||
|
||||
canMove ||
|
||||
(canRestore && (isInArchiveList || isInTrashList)) ||
|
||||
(canArchive && !isInArchiveList) ||
|
||||
canDelete) && (
|
||||
<div className={styles.actions}>
|
||||
<span className={styles.actionsTitle}>{t('common.actions')}</span>
|
||||
{canJoin && (
|
||||
<Button
|
||||
fluid
|
||||
className={classNames(styles.actionButton, styles.hidable)}
|
||||
onClick={handleToggleJointClick}
|
||||
>
|
||||
<Icon
|
||||
name={isJoined ? 'flag outline' : 'flag checkered'}
|
||||
className={styles.actionIcon}
|
||||
/>
|
||||
{isJoined ? t('action.leave') : t('action.join')}
|
||||
</Button>
|
||||
)}
|
||||
{canSubscribe && (
|
||||
<Button
|
||||
fluid
|
||||
disabled={board.isSubscribed}
|
||||
className={classNames(styles.actionButton, styles.hidable)}
|
||||
onClick={handleToggleSubscriptionClick}
|
||||
>
|
||||
{board.isSubscribed ? (
|
||||
<>
|
||||
<Icon name="bell slash outline" className={styles.actionIcon} />
|
||||
{t('common.boardSubscribed')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon
|
||||
name={card.isSubscribed ? 'bell slash outline' : 'bell outline'}
|
||||
className={styles.actionIcon}
|
||||
/>
|
||||
{card.isSubscribed ? t('action.unsubscribe') : t('action.subscribe')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!board.limitCardTypesToDefaultOne && canEditType && (
|
||||
<SelectCardTypePopup
|
||||
withButton
|
||||
defaultValue={card.type}
|
||||
title="common.editType"
|
||||
buttonContent="action.save"
|
||||
onSelect={handleTypeSelect}
|
||||
>
|
||||
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
|
||||
<Icon name="map outline" className={styles.actionIcon} />
|
||||
{t('action.editType', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Button>
|
||||
</SelectCardTypePopup>
|
||||
)}
|
||||
{canDuplicate && (
|
||||
<Button
|
||||
fluid
|
||||
className={classNames(styles.actionButton, styles.hidable)}
|
||||
onClick={handleDuplicateClick}
|
||||
>
|
||||
<Icon name="copy outline" className={styles.actionIcon} />
|
||||
{t('action.duplicate')}
|
||||
</Button>
|
||||
)}
|
||||
{canMove && (
|
||||
<MoveCardPopup id={card.id}>
|
||||
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
|
||||
<Icon name="share square outline" className={styles.actionIcon} />
|
||||
{t('action.move')}
|
||||
</Button>
|
||||
</MoveCardPopup>
|
||||
)}
|
||||
{canRestore && (isInArchiveList || isInTrashList) && (
|
||||
<Button
|
||||
fluid
|
||||
disabled={!prevList}
|
||||
className={classNames(styles.actionButton, styles.hidable)}
|
||||
onClick={handleRestoreClick}
|
||||
>
|
||||
<Icon name="undo alternate" className={styles.actionIcon} />
|
||||
{prevList
|
||||
? t('action.restoreToList', {
|
||||
list: prevList.name || t(`common.${prevList.type}`),
|
||||
})
|
||||
: t('common.selectListToRestoreThisCard')}
|
||||
</Button>
|
||||
)}
|
||||
{canArchive && !isInArchiveList && (
|
||||
<ConfirmationPopup
|
||||
title="common.archiveCard"
|
||||
content="common.areYouSureYouWantToArchiveThisCard"
|
||||
buttonContent="action.archiveCard"
|
||||
onConfirm={handleArchiveConfirm}
|
||||
>
|
||||
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
|
||||
<Icon name="folder open outline" className={styles.actionIcon} />
|
||||
{t('action.archive')}
|
||||
</Button>
|
||||
</ConfirmationPopup>
|
||||
)}
|
||||
{canDelete && (
|
||||
<ConfirmationPopup
|
||||
title={isInTrashList ? 'common.deleteCardForever' : 'common.deleteCard'}
|
||||
content={
|
||||
isInTrashList
|
||||
? 'common.areYouSureYouWantToDeleteThisCardForever'
|
||||
: 'common.areYouSureYouWantToDeleteThisCard'
|
||||
}
|
||||
buttonContent={isInTrashList ? 'action.deleteCardForever' : 'action.deleteCard'}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
>
|
||||
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
|
||||
<Icon name="trash alternate outline" className={styles.actionIcon} />
|
||||
{isInTrashList
|
||||
? t('action.deleteForever', {
|
||||
context: 'title',
|
||||
})
|
||||
: t('action.delete')}
|
||||
</Button>
|
||||
</ConfirmationPopup>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
);
|
||||
});
|
||||
|
||||
StoryContent.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default StoryContent;
|
||||
@@ -0,0 +1,339 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.actionButton {
|
||||
background: #ebeef0;
|
||||
box-shadow: 0 1px 0 0 rgba(9, 30, 66, 0.13);
|
||||
color: #444;
|
||||
margin-top: 8px;
|
||||
padding: 6px 8px 6px 18px;
|
||||
text-align: left;
|
||||
transition: background 85ms ease;
|
||||
|
||||
&:hover {
|
||||
background: #dfe3e6;
|
||||
box-shadow: 0 1px 0 0 rgba(9, 30, 66, 0.25);
|
||||
color: #4c4c4c;
|
||||
}
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
color: #17394d;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-bottom: 24px;
|
||||
|
||||
@media only screen and (width < 768px) {
|
||||
flex: 1;
|
||||
|
||||
&:first-child {
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actionsTitle {
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
letter-spacing: 0.04em;
|
||||
margin-top: 16px;
|
||||
text-transform: uppercase;
|
||||
line-height: 20px;
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
|
||||
.addAttachment {
|
||||
margin: 0 -4.3px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.attachment {
|
||||
display: inline-block;
|
||||
margin: 0 4px 4px 0;
|
||||
max-width: 100%;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
display: inline-block;
|
||||
margin: 0 8px 4px 0;
|
||||
max-width: 100%;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.attachmentsList {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.contentModule {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.contentModuleDescription:hover {
|
||||
.editButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.contentPadding {
|
||||
padding: 8px 24px 0 16px;
|
||||
|
||||
@media only screen and (width < 768px) {
|
||||
padding-right: 16px;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cover {
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
max-height: 450px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.coverWrapper {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.cursorPointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dueDate {
|
||||
background: rgba(9, 30, 66, 0.04);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #6b808c;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
outline: none;
|
||||
padding: 2px 11px;
|
||||
text-align: left;
|
||||
text-decoration: underline;
|
||||
transition: background 0.3s ease;
|
||||
vertical-align: top;
|
||||
|
||||
&:hover {
|
||||
background: rgba(9, 30, 66, 0.08);
|
||||
color: #17394d;
|
||||
}
|
||||
}
|
||||
|
||||
.descriptionButton {
|
||||
background: rgba(9, 30, 66, 0.04);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
color: #6b808c;
|
||||
cursor: pointer;
|
||||
min-height: 54px;
|
||||
outline: none;
|
||||
padding: 8px 12px;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background: rgba(9, 30, 66, 0.08);
|
||||
color: #092d42;
|
||||
}
|
||||
}
|
||||
|
||||
.descriptionButtonText {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
.descriptionText {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.draftChip {
|
||||
background: #f1eee2;
|
||||
border-radius: 3px;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
display: inline-block;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
padding: 2px 12px;
|
||||
}
|
||||
|
||||
.editButton {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
line-height: 28px;
|
||||
margin: 0;
|
||||
min-height: auto;
|
||||
opacity: 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 4px;
|
||||
width: 28px;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
background: rgba(9, 30, 66, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.headerPadding {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
color: #17394d;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
line-height: 34px;
|
||||
}
|
||||
|
||||
.headerTitleWrapper {
|
||||
margin: 4px 0;
|
||||
padding: 6px 0 0;
|
||||
}
|
||||
|
||||
.headerWrapper {
|
||||
margin: 12px 48px 12px 56px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hidable {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.list {
|
||||
align-items: center;
|
||||
background: rgba(9, 30, 66, 0.04);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #6b808c;
|
||||
display: flex;
|
||||
line-height: 20px;
|
||||
outline: none;
|
||||
padding: 6px 12px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.listButton {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.listHoverable:hover {
|
||||
background: rgba(9, 30, 66, 0.08);
|
||||
color: #17394d;
|
||||
}
|
||||
|
||||
.listIcon {
|
||||
margin: 0 8px 0 0;
|
||||
}
|
||||
|
||||
.modalPadding {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.moduleHeader {
|
||||
color: #17394d;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 4px;
|
||||
padding: 6px 0;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.moduleIcon {
|
||||
color: #17394d;
|
||||
font-size: 17px;
|
||||
height: 32px;
|
||||
left: -40px;
|
||||
line-height: 32px;
|
||||
margin-right: 0;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.moduleIconTitle {
|
||||
top: 6px;
|
||||
}
|
||||
|
||||
.moduleWrapper {
|
||||
margin: 0 0 0 40px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.moduleWrapperAttachments {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.sidebarPadding {
|
||||
padding: 8px 16px 8px 8px;
|
||||
|
||||
@media only screen and (width < 768px) {
|
||||
padding-left: 16px;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
@media only screen and (width < 768px) {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #6b808c;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.3px;
|
||||
line-height: 20px;
|
||||
margin: 0 8px 4px 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.textList {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.thumbnails {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
flex-grow: 10;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
background: #f5f6f7;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*!
|
||||
* 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 { useSelector } from 'react-redux';
|
||||
import { Item as GalleryItem } from 'react-photoswipe-gallery';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
|
||||
import styles from './Thumbnail.module.scss';
|
||||
|
||||
const Thumbnail = React.memo(({ attachmentId }) => {
|
||||
const selectAttachmentById = useMemo(() => selectors.makeSelectAttachmentById(), []);
|
||||
|
||||
const attachment = useSelector((state) => selectAttachmentById(state, attachmentId));
|
||||
|
||||
return (
|
||||
<GalleryItem
|
||||
{...attachment.data.image} // eslint-disable-line react/jsx-props-no-spreading
|
||||
original={attachment.data.url}
|
||||
caption={attachment.name}
|
||||
>
|
||||
{({ ref, open }) => (
|
||||
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
|
||||
jsx-a11y/no-noninteractive-element-interactions */
|
||||
<img
|
||||
ref={ref}
|
||||
src={attachment.data.thumbnailUrls.outside360}
|
||||
alt={attachment.name}
|
||||
className={styles.image}
|
||||
onClick={open}
|
||||
/>
|
||||
)}
|
||||
</GalleryItem>
|
||||
);
|
||||
});
|
||||
|
||||
Thumbnail.propTypes = {
|
||||
attachmentId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default Thumbnail;
|
||||
@@ -0,0 +1,14 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.image {
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
flex-grow: 1;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
@@ -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 StoryContent from './StoryContent';
|
||||
|
||||
export default StoryContent;
|
||||
@@ -0,0 +1,119 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import { dequal } from 'dequal';
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form } from 'semantic-ui-react';
|
||||
import { Popup } from '../../../../lib/custom-ui';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import { useForm, useSteps } from '../../../../hooks';
|
||||
import ConfirmationStep from '../../../common/ConfirmationStep';
|
||||
import TaskListEditor from '../../../task-lists/TaskListEditor';
|
||||
|
||||
import styles from './EditStep.module.scss';
|
||||
|
||||
const StepTypes = {
|
||||
DELETE: 'DELETE',
|
||||
};
|
||||
|
||||
const EditStep = React.memo(({ taskListId, onClose }) => {
|
||||
const selectTaskListById = useMemo(() => selectors.makeSelectTaskListById(), []);
|
||||
|
||||
const taskList = useSelector((state) => selectTaskListById(state, taskListId));
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
|
||||
const defaultData = useMemo(
|
||||
() => ({
|
||||
name: taskList.name,
|
||||
showOnFrontOfCard: taskList.showOnFrontOfCard,
|
||||
}),
|
||||
[taskList.name, taskList.showOnFrontOfCard],
|
||||
);
|
||||
|
||||
const [data, handleFieldChange] = useForm(() => ({
|
||||
name: t('common.taskList', {
|
||||
context: 'title',
|
||||
}),
|
||||
showOnFrontOfCard: true,
|
||||
...defaultData,
|
||||
}));
|
||||
|
||||
const [step, openStep, handleBack] = useSteps();
|
||||
|
||||
const taskListEditorRef = useRef(null);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
name: data.name.trim(),
|
||||
};
|
||||
|
||||
if (!cleanData.name) {
|
||||
taskListEditorRef.current.selectNameField();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dequal(cleanData, defaultData)) {
|
||||
dispatch(entryActions.updateTaskList(taskListId, data));
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [taskListId, onClose, dispatch, defaultData, data, taskListEditorRef]);
|
||||
|
||||
const handleDeleteConfirm = useCallback(() => {
|
||||
dispatch(entryActions.deleteTaskList(taskListId));
|
||||
}, [taskListId, dispatch]);
|
||||
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
openStep(StepTypes.DELETE);
|
||||
}, [openStep]);
|
||||
|
||||
if (step && step.type === StepTypes.DELETE) {
|
||||
return (
|
||||
<ConfirmationStep
|
||||
title="common.deleteTaskList"
|
||||
content="common.areYouSureYouWantToDeleteThisTaskList"
|
||||
buttonContent="action.deleteTaskList"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header>
|
||||
{t('common.taskListActions', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<TaskListEditor ref={taskListEditorRef} data={data} onFieldChange={handleFieldChange} />
|
||||
<Button positive content={t('action.save')} />
|
||||
</Form>
|
||||
<Button
|
||||
content={t('action.delete')}
|
||||
className={styles.deleteButton}
|
||||
onClick={handleDeleteClick}
|
||||
/>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
EditStep.propTypes = {
|
||||
taskListId: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default EditStep;
|
||||
@@ -0,0 +1,13 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.deleteButton {
|
||||
bottom: 12px;
|
||||
box-shadow: 0 1px 0 #cbcccc;
|
||||
position: absolute;
|
||||
right: 9px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*!
|
||||
* 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 ReactDOM from 'react-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
import { Button, Icon } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
import { usePopupInClosableContext } from '../../../../hooks';
|
||||
import { BoardMembershipRoles } from '../../../../constants/Enums';
|
||||
import EditStep from './EditStep';
|
||||
import TaskList from '../../../task-lists/TaskList';
|
||||
|
||||
import styles from './Item.module.scss';
|
||||
|
||||
const Item = React.memo(({ id, index }) => {
|
||||
const selectTaskListById = useMemo(() => selectors.makeSelectTaskListById(), []);
|
||||
|
||||
const taskList = useSelector((state) => selectTaskListById(state, id));
|
||||
|
||||
const canEdit = useSelector((state) => {
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
});
|
||||
|
||||
const EditPopup = usePopupInClosableContext(EditStep);
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
draggableId={`task-list:${id}`}
|
||||
index={index}
|
||||
isDragDisabled={!taskList.isPersisted || !canEdit}
|
||||
>
|
||||
{({ innerRef, draggableProps, dragHandleProps }, { isDragging }) => {
|
||||
const contentNode = (
|
||||
<div
|
||||
{...draggableProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
ref={innerRef}
|
||||
className={classNames(styles.wrapper, styles.wrapperDragging)}
|
||||
>
|
||||
<div className={styles.moduleWrapper}>
|
||||
<Icon name="check square outline" className={styles.moduleIcon} />
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<div {...dragHandleProps}>
|
||||
<div
|
||||
className={classNames(
|
||||
styles.moduleHeader,
|
||||
canEdit && styles.moduleHeaderEditable,
|
||||
)}
|
||||
>
|
||||
{taskList.isPersisted && canEdit && (
|
||||
<EditPopup taskListId={taskList.id}>
|
||||
<Button className={styles.editButton}>
|
||||
<Icon fitted name="pencil" size="small" />
|
||||
</Button>
|
||||
</EditPopup>
|
||||
)}
|
||||
<span className={styles.moduleHeaderTitle}>{taskList.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<TaskList id={id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return isDragging ? ReactDOM.createPortal(contentNode, document.body) : contentNode;
|
||||
}}
|
||||
</Draggable>
|
||||
);
|
||||
});
|
||||
|
||||
Item.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default Item;
|
||||
@@ -0,0 +1,75 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.editButton {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
line-height: 28px;
|
||||
margin: 0;
|
||||
min-height: auto;
|
||||
opacity: 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 4px;
|
||||
width: 28px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(9, 30, 66, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.moduleHeader {
|
||||
color: #17394d;
|
||||
display: flex;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 4px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.moduleHeaderEditable {
|
||||
padding-right: 32px;
|
||||
|
||||
&:hover {
|
||||
.editButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.moduleHeaderTitle {
|
||||
min-width: 0;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.moduleIcon {
|
||||
color: #17394d;
|
||||
font-size: 17px;
|
||||
height: 32px;
|
||||
left: -40px;
|
||||
line-height: 32px;
|
||||
margin-right: 0;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.moduleWrapper {
|
||||
margin: 0 0 0 40px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
border-radius: 3px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.wrapperDragging {
|
||||
background: rgba(247, 246, 247, 0.8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
|
||||
import { closePopup } from '../../../../lib/popup';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import parseDndId from '../../../../utils/parse-dnd-id';
|
||||
import DroppableTypes from '../../../../constants/DroppableTypes';
|
||||
import Item from './Item';
|
||||
|
||||
import globalStyles from '../../../../styles.module.scss';
|
||||
|
||||
const TaskLists = React.memo(() => {
|
||||
const taskListIds = useSelector(selectors.selectTaskListIdsForCurrentCard);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleDragStart = useCallback(() => {
|
||||
document.body.classList.add(globalStyles.dragging);
|
||||
closePopup();
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
({ draggableId, type, source, destination }) => {
|
||||
document.body.classList.remove(globalStyles.dragging);
|
||||
|
||||
if (!destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (source.droppableId === destination.droppableId && source.index === destination.index) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = parseDndId(draggableId);
|
||||
|
||||
switch (type) {
|
||||
case DroppableTypes.TASK_LIST:
|
||||
dispatch(entryActions.moveTaskList(id, destination.index));
|
||||
|
||||
break;
|
||||
case DroppableTypes.TASK:
|
||||
dispatch(
|
||||
entryActions.moveTask(id, parseDndId(destination.droppableId), destination.index),
|
||||
);
|
||||
|
||||
break;
|
||||
default:
|
||||
}
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="card" type={DroppableTypes.TASK_LIST} direction="vertical">
|
||||
{({ innerRef, droppableProps, placeholder }) => (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<div {...droppableProps} ref={innerRef}>
|
||||
{taskListIds.map((taskListId, index) => (
|
||||
<Item key={taskListId} id={taskListId} index={index} />
|
||||
))}
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
});
|
||||
|
||||
export default TaskLists;
|
||||
@@ -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 TaskLists from './TaskLists';
|
||||
|
||||
export default TaskLists;
|
||||
+8
@@ -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 CardModal from './CardModal';
|
||||
|
||||
export default CardModal;
|
||||
@@ -0,0 +1,59 @@
|
||||
/*!
|
||||
* 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 { Draggable } from 'react-beautiful-dnd';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import { BoardMembershipRoles } from '../../../constants/Enums';
|
||||
import Card from '../Card';
|
||||
|
||||
import styles from './DraggableCard.module.scss';
|
||||
|
||||
const DraggableCard = React.memo(({ id, index, className, ...props }) => {
|
||||
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
|
||||
|
||||
const card = useSelector((state) => selectCardById(state, id));
|
||||
|
||||
const canDrag = useSelector((state) => {
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
});
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
draggableId={`card:${id}`}
|
||||
index={index}
|
||||
isDragDisabled={!card.isPersisted || !canDrag}
|
||||
>
|
||||
{({ innerRef, draggableProps, dragHandleProps }) => (
|
||||
<div
|
||||
{...draggableProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
{...dragHandleProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
ref={innerRef}
|
||||
className={classNames(styles.wrapper, className)}
|
||||
>
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<Card {...props} id={id} />
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
});
|
||||
|
||||
DraggableCard.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
DraggableCard.defaultProps = {
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export default DraggableCard;
|
||||
@@ -0,0 +1,10 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.wrapper {
|
||||
cursor: auto;
|
||||
}
|
||||
}
|
||||
@@ -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 DraggableCard from './DraggableCard';
|
||||
|
||||
export default DraggableCard;
|
||||
@@ -0,0 +1,150 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import upperFirst from 'lodash/upperFirst';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from 'semantic-ui-react';
|
||||
import { useForceUpdate } from '../../../lib/hooks';
|
||||
|
||||
import getDateFormat from '../../../utils/get-date-format';
|
||||
|
||||
import styles from './DueDateChip.module.scss';
|
||||
|
||||
const Sizes = {
|
||||
TINY: 'tiny',
|
||||
SMALL: 'small',
|
||||
MEDIUM: 'medium',
|
||||
};
|
||||
|
||||
const Statuses = {
|
||||
DUE_SOON: 'dueSoon',
|
||||
OVERDUE: 'overdue',
|
||||
};
|
||||
|
||||
const LONG_DATE_FORMAT_BY_SIZE = {
|
||||
[Sizes.TINY]: 'longDate',
|
||||
[Sizes.SMALL]: 'longDate',
|
||||
[Sizes.MEDIUM]: 'longDateTime',
|
||||
};
|
||||
|
||||
const FULL_DATE_FORMAT_BY_SIZE = {
|
||||
[Sizes.TINY]: 'fullDate',
|
||||
[Sizes.SMALL]: 'fullDate',
|
||||
[Sizes.MEDIUM]: 'fullDateTime',
|
||||
};
|
||||
|
||||
const STATUS_ICON_PROPS_BY_STATUS = {
|
||||
[Statuses.DUE_SOON]: {
|
||||
name: 'hourglass half',
|
||||
color: 'orange',
|
||||
},
|
||||
[Statuses.OVERDUE]: {
|
||||
name: 'hourglass end',
|
||||
color: 'red',
|
||||
},
|
||||
};
|
||||
|
||||
const getStatus = (date) => {
|
||||
const secondsLeft = Math.floor((date.getTime() - new Date().getTime()) / 1000);
|
||||
|
||||
if (secondsLeft <= 0) {
|
||||
return Statuses.OVERDUE;
|
||||
}
|
||||
|
||||
if (secondsLeft <= 24 * 60 * 60) {
|
||||
return Statuses.DUE_SOON;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const DueDateChip = React.memo(
|
||||
({ value, size, isDisabled, withStatus, withStatusIcon, onClick }) => {
|
||||
const [t] = useTranslation();
|
||||
const forceUpdate = useForceUpdate();
|
||||
|
||||
const statusRef = useRef(null);
|
||||
statusRef.current = withStatus ? getStatus(value) : null;
|
||||
|
||||
const intervalRef = useRef(null);
|
||||
|
||||
const dateFormat = getDateFormat(
|
||||
value,
|
||||
LONG_DATE_FORMAT_BY_SIZE[size],
|
||||
FULL_DATE_FORMAT_BY_SIZE[size],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (withStatus && statusRef.current !== Statuses.OVERDUE) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
const status = getStatus(value);
|
||||
|
||||
if (status !== statusRef.current) {
|
||||
forceUpdate();
|
||||
}
|
||||
|
||||
if (status === Statuses.OVERDUE) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [value, withStatus, forceUpdate]);
|
||||
|
||||
const contentNode = (
|
||||
<span
|
||||
className={classNames(
|
||||
styles.wrapper,
|
||||
styles[`wrapper${upperFirst(size)}`],
|
||||
!withStatusIcon && statusRef.current && styles[`wrapper${upperFirst(statusRef.current)}`],
|
||||
onClick && styles.wrapperHoverable,
|
||||
)}
|
||||
>
|
||||
{t(`format:${dateFormat}`, {
|
||||
value,
|
||||
postProcess: 'formatDate',
|
||||
})}
|
||||
{withStatusIcon && statusRef.current && (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<Icon {...STATUS_ICON_PROPS_BY_STATUS[statusRef.current]} className={styles.statusIcon} />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
return onClick ? (
|
||||
<button type="button" disabled={isDisabled} className={styles.button} onClick={onClick}>
|
||||
{contentNode}
|
||||
</button>
|
||||
) : (
|
||||
contentNode
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DueDateChip.propTypes = {
|
||||
value: PropTypes.instanceOf(Date).isRequired,
|
||||
size: PropTypes.oneOf(Object.values(Sizes)),
|
||||
isDisabled: PropTypes.bool,
|
||||
withStatus: PropTypes.bool.isRequired,
|
||||
withStatusIcon: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
DueDateChip.defaultProps = {
|
||||
size: Sizes.MEDIUM,
|
||||
isDisabled: false,
|
||||
withStatusIcon: false,
|
||||
onClick: undefined,
|
||||
};
|
||||
|
||||
export default DueDateChip;
|
||||
@@ -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;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.statusIcon {
|
||||
line-height: 1;
|
||||
margin: 0 0 0 8px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
background: #dce0e4;
|
||||
border-radius: 3px;
|
||||
color: #6a808b;
|
||||
display: inline-block;
|
||||
transition: background 0.3s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wrapperHoverable:hover {
|
||||
background: #d2d8dc;
|
||||
color: #17394d;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
|
||||
.wrapperTiny {
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
padding: 0px 6px;
|
||||
}
|
||||
|
||||
.wrapperSmall {
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.wrapperMedium {
|
||||
line-height: 20px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
/* Statuses */
|
||||
|
||||
.wrapperDueSoon {
|
||||
background: #f2711c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wrapperOverdue {
|
||||
background: #db2828;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
@@ -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 DueDateChip from './DueDateChip';
|
||||
|
||||
export default DueDateChip;
|
||||
@@ -0,0 +1,188 @@
|
||||
/*!
|
||||
* 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 DatePicker from 'react-datepicker';
|
||||
import { Button, Form } from 'semantic-ui-react';
|
||||
import { useDidUpdate, useToggle } from '../../../lib/hooks';
|
||||
import { Input, Popup } from '../../../lib/custom-ui';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { useForm, useNestedRef } from '../../../hooks';
|
||||
import parseTime from '../../../utils/parse-time';
|
||||
|
||||
import styles from './EditDueDateStep.module.scss';
|
||||
|
||||
const EditDueDateStep = React.memo(({ cardId, onBack, onClose }) => {
|
||||
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
|
||||
|
||||
const defaultValue = useSelector((state) => selectCardById(state, cardId).dueDate);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
|
||||
const [data, handleFieldChange, setData] = useForm(() => {
|
||||
const date = defaultValue || new Date().setHours(12, 0, 0, 0);
|
||||
|
||||
return {
|
||||
date: t('format:date', {
|
||||
postProcess: 'formatDate',
|
||||
value: date,
|
||||
}),
|
||||
time: t('format:time', {
|
||||
postProcess: 'formatDate',
|
||||
value: date,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const [selectTimeFieldState, selectTimeField] = useToggle();
|
||||
|
||||
const [dateFieldRef, handleDateFieldRef] = useNestedRef('inputRef');
|
||||
const [timeFieldRef, handleTimeFieldRef] = useNestedRef('inputRef');
|
||||
|
||||
const nullableDate = useMemo(() => {
|
||||
const date = t('format:date', {
|
||||
postProcess: 'parseDate',
|
||||
value: data.date,
|
||||
});
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date;
|
||||
}, [data.date, t]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!nullableDate) {
|
||||
dateFieldRef.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
let value = t('format:dateTime', {
|
||||
postProcess: 'parseDate',
|
||||
value: `${data.date} ${data.time}`,
|
||||
});
|
||||
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
value = parseTime(data.time, nullableDate);
|
||||
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
timeFieldRef.current.select();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!defaultValue || value.getTime() !== defaultValue.getTime()) {
|
||||
dispatch(
|
||||
entryActions.updateCard(cardId, {
|
||||
dueDate: value,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [cardId, onClose, defaultValue, dispatch, t, data, dateFieldRef, timeFieldRef, nullableDate]);
|
||||
|
||||
const handleClearClick = useCallback(() => {
|
||||
if (defaultValue) {
|
||||
dispatch(
|
||||
entryActions.updateCard(cardId, {
|
||||
dueDate: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [cardId, onClose, defaultValue, dispatch]);
|
||||
|
||||
const handleDatePickerChange = useCallback(
|
||||
(date) => {
|
||||
setData((prevData) => ({
|
||||
...prevData,
|
||||
date: t('format:date', {
|
||||
postProcess: 'formatDate',
|
||||
value: date,
|
||||
}),
|
||||
}));
|
||||
selectTimeField();
|
||||
},
|
||||
[t, setData, selectTimeField],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dateFieldRef.current.select();
|
||||
}, [dateFieldRef]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
timeFieldRef.current.select();
|
||||
}, [selectTimeFieldState]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header onBack={onBack}>
|
||||
{t('common.editDueDate', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div className={styles.fieldWrapper}>
|
||||
<div className={styles.fieldBox}>
|
||||
<div className={styles.text}>{t('common.date')}</div>
|
||||
<Input
|
||||
ref={handleDateFieldRef}
|
||||
name="date"
|
||||
value={data.date}
|
||||
maxLength={16}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.fieldBox}>
|
||||
<div className={styles.text}>{t('common.time')}</div>
|
||||
<Input
|
||||
ref={handleTimeFieldRef}
|
||||
name="time"
|
||||
value={data.time}
|
||||
maxLength={16}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DatePicker
|
||||
inline
|
||||
disabledKeyboardNavigation
|
||||
selected={nullableDate}
|
||||
onChange={handleDatePickerChange}
|
||||
/>
|
||||
<Button positive content={t('action.save')} />
|
||||
</Form>
|
||||
<Button
|
||||
negative
|
||||
content={t('action.remove')}
|
||||
className={styles.deleteButton}
|
||||
onClick={handleClearClick}
|
||||
/>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
EditDueDateStep.propTypes = {
|
||||
cardId: PropTypes.string.isRequired,
|
||||
onBack: PropTypes.func,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
EditDueDateStep.defaultProps = {
|
||||
onBack: undefined,
|
||||
};
|
||||
|
||||
export default EditDueDateStep;
|
||||
@@ -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) {
|
||||
.deleteButton {
|
||||
bottom: 12px;
|
||||
box-shadow: 0 1px 0 #cbcccc;
|
||||
position: absolute;
|
||||
right: 9px;
|
||||
}
|
||||
|
||||
.fieldBox {
|
||||
display: inline-block;
|
||||
margin: 0 4px 12px;
|
||||
width: calc(50% - 8px);
|
||||
}
|
||||
|
||||
.fieldWrapper {
|
||||
margin: 0 -4px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 4px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
}
|
||||
@@ -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 EditDueDateStep from './EditDueDateStep';
|
||||
|
||||
export default EditDueDateStep;
|
||||
@@ -0,0 +1,214 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import { dequal } from 'dequal';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form } from 'semantic-ui-react';
|
||||
import { useDidUpdate, useToggle } from '../../../lib/hooks';
|
||||
import { Input, Popup } from '../../../lib/custom-ui';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { useForm, useNestedRef } from '../../../hooks';
|
||||
import {
|
||||
createStopwatch,
|
||||
getStopwatchParts,
|
||||
startStopwatch,
|
||||
stopStopwatch,
|
||||
updateStopwatch,
|
||||
} from '../../../utils/stopwatch';
|
||||
|
||||
import styles from './EditStopwatchStep.module.scss';
|
||||
|
||||
const createData = (stopwatch) => {
|
||||
if (!stopwatch) {
|
||||
return {
|
||||
hours: '0',
|
||||
minutes: '0',
|
||||
seconds: '0',
|
||||
};
|
||||
}
|
||||
|
||||
const { hours, minutes, seconds } = getStopwatchParts(stopwatch);
|
||||
|
||||
return {
|
||||
hours: `${hours}`,
|
||||
minutes: `${minutes}`,
|
||||
seconds: `${seconds}`,
|
||||
};
|
||||
};
|
||||
|
||||
const EditStopwatchStep = React.memo(({ cardId, onBack, onClose }) => {
|
||||
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
|
||||
|
||||
const defaultValue = useSelector((state) => selectCardById(state, cardId).stopwatch);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
const [data, handleFieldChange, setData] = useForm(() => createData(defaultValue));
|
||||
const [isEditing, toggleEditing] = useToggle();
|
||||
|
||||
const [hoursFieldRef, handleHoursFieldRef] = useNestedRef('inputRef');
|
||||
const [minutesFieldRef, handleMinutesFieldRef] = useNestedRef('inputRef');
|
||||
const [secondsFieldRef, handleSecondsFieldRef] = useNestedRef('inputRef');
|
||||
|
||||
const update = useCallback(
|
||||
(stopwatch) => {
|
||||
dispatch(
|
||||
entryActions.updateCard(cardId, {
|
||||
stopwatch,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[cardId, dispatch],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const parts = {
|
||||
hours: parseInt(data.hours, 10),
|
||||
minutes: parseInt(data.minutes, 10),
|
||||
seconds: parseInt(data.seconds, 10),
|
||||
};
|
||||
|
||||
if (Number.isNaN(parts.hours)) {
|
||||
hoursFieldRef.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number.isNaN(parts.minutes) || parts.minutes > 60) {
|
||||
minutesFieldRef.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number.isNaN(parts.seconds) || parts.seconds > 60) {
|
||||
secondsFieldRef.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
if (defaultValue) {
|
||||
if (!dequal(parts, getStopwatchParts(defaultValue))) {
|
||||
update(updateStopwatch(defaultValue, parts));
|
||||
}
|
||||
} else {
|
||||
update(createStopwatch(parts));
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [onClose, defaultValue, data, hoursFieldRef, minutesFieldRef, secondsFieldRef, update]);
|
||||
|
||||
const handleStartClick = useCallback(() => {
|
||||
update(startStopwatch(defaultValue));
|
||||
onClose();
|
||||
}, [onClose, defaultValue, update]);
|
||||
|
||||
const handleStopClick = useCallback(() => {
|
||||
update(stopStopwatch(defaultValue));
|
||||
}, [defaultValue, update]);
|
||||
|
||||
const handleClearClick = useCallback(() => {
|
||||
if (defaultValue) {
|
||||
update(null);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [onClose, defaultValue, update]);
|
||||
|
||||
const handleToggleEditingClick = useCallback(() => {
|
||||
setData(createData(defaultValue));
|
||||
toggleEditing();
|
||||
}, [defaultValue, setData, toggleEditing]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
if (isEditing) {
|
||||
hoursFieldRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header onBack={onBack}>
|
||||
{t('common.editStopwatch', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div className={styles.fieldWrapper}>
|
||||
<div className={styles.fieldBox}>
|
||||
<div className={styles.text}>{t('common.hours')}</div>
|
||||
<Input.Mask
|
||||
ref={handleHoursFieldRef}
|
||||
name="hours"
|
||||
value={data.hours}
|
||||
mask="9999"
|
||||
maskChar={null}
|
||||
disabled={!isEditing}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.fieldBox}>
|
||||
<div className={styles.text}>{t('common.minutes')}</div>
|
||||
<Input.Mask
|
||||
ref={handleMinutesFieldRef}
|
||||
name="minutes"
|
||||
value={data.minutes}
|
||||
mask="99"
|
||||
maskChar={null}
|
||||
disabled={!isEditing}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.fieldBox}>
|
||||
<div className={styles.text}>{t('common.seconds')}</div>
|
||||
<Input.Mask
|
||||
ref={handleSecondsFieldRef}
|
||||
name="seconds"
|
||||
value={data.seconds}
|
||||
mask="99"
|
||||
maskChar={null}
|
||||
disabled={!isEditing}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
icon={isEditing ? 'close' : 'edit'}
|
||||
className={styles.iconButton}
|
||||
onClick={handleToggleEditingClick}
|
||||
/>
|
||||
</div>
|
||||
{isEditing && <Button positive content={t('action.save')} />}
|
||||
</Form>
|
||||
{!isEditing &&
|
||||
(defaultValue && defaultValue.startedAt ? (
|
||||
<Button positive content={t('action.stop')} icon="pause" onClick={handleStopClick} />
|
||||
) : (
|
||||
<Button positive content={t('action.start')} icon="play" onClick={handleStartClick} />
|
||||
))}
|
||||
<Button
|
||||
negative
|
||||
content={t('action.remove')}
|
||||
className={styles.deleteButton}
|
||||
onClick={handleClearClick}
|
||||
/>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
EditStopwatchStep.propTypes = {
|
||||
cardId: PropTypes.string.isRequired,
|
||||
onBack: PropTypes.func,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
EditStopwatchStep.defaultProps = {
|
||||
onBack: undefined,
|
||||
};
|
||||
|
||||
export default EditStopwatchStep;
|
||||
@@ -0,0 +1,42 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.deleteButton {
|
||||
bottom: 12px;
|
||||
box-shadow: 0 1px 0 #cbcccc;
|
||||
position: absolute;
|
||||
right: 9px;
|
||||
}
|
||||
|
||||
.fieldBox {
|
||||
display: inline-block;
|
||||
margin: 0 4px 12px;
|
||||
width: calc(33.3333% - 22px);
|
||||
}
|
||||
|
||||
.fieldWrapper {
|
||||
margin: 0 -4px;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
margin: 0 4px 0 1px;
|
||||
width: 36px;
|
||||
|
||||
&:hover {
|
||||
background: #e9e9e9;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 4px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
}
|
||||
@@ -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 EditStopwatchStep from './EditStopwatchStep';
|
||||
|
||||
export default EditStopwatchStep;
|
||||
@@ -0,0 +1,182 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Dropdown, Form } from 'semantic-ui-react';
|
||||
import { Popup } from '../../../lib/custom-ui';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { useForm } from '../../../hooks';
|
||||
|
||||
import styles from './MoveCardStep.module.scss';
|
||||
|
||||
const MoveCardStep = React.memo(({ id, onBack, onClose }) => {
|
||||
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
|
||||
const selectBoardById = useMemo(() => selectors.makeSelectBoardById(), []);
|
||||
|
||||
const projectsToLists = useSelector(
|
||||
selectors.selectProjectsToListsWithEditorRightsForCurrentUser,
|
||||
);
|
||||
|
||||
const card = useSelector((state) => selectCardById(state, id));
|
||||
const projectId = useSelector((state) => selectBoardById(state, card.boardId).projectId);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
|
||||
const defaultPath = useMemo(
|
||||
() => ({
|
||||
projectId,
|
||||
boardId: card.boardId,
|
||||
listId: card.listId,
|
||||
}),
|
||||
[card.boardId, card.listId, projectId],
|
||||
);
|
||||
|
||||
const [path, handleFieldChange] = useForm(() => ({
|
||||
projectId: null,
|
||||
boardId: null,
|
||||
listId: null,
|
||||
...defaultPath,
|
||||
}));
|
||||
|
||||
const selectedProject = useMemo(
|
||||
() => projectsToLists.find((project) => project.id === path.projectId) || null,
|
||||
[projectsToLists, path.projectId],
|
||||
);
|
||||
|
||||
const selectedBoard = useMemo(
|
||||
() =>
|
||||
(selectedProject && selectedProject.boards.find((board) => board.id === path.boardId)) ||
|
||||
null,
|
||||
[selectedProject, path.boardId],
|
||||
);
|
||||
|
||||
const selectedList = useMemo(
|
||||
() => (selectedBoard && selectedBoard.lists.find((list) => list.id === path.listId)) || null,
|
||||
[selectedBoard, path.listId],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (selectedBoard.id !== defaultPath.boardId) {
|
||||
dispatch(entryActions.transferCard(id, selectedBoard.id, selectedList.id));
|
||||
} else if (selectedList.id !== defaultPath.listId) {
|
||||
dispatch(entryActions.moveCard(id, selectedList.id));
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [id, onClose, dispatch, defaultPath, selectedBoard, selectedList]);
|
||||
|
||||
const handleBoardIdChange = useCallback(
|
||||
(event, data) => {
|
||||
if (selectedProject.boards.find((board) => board.id === data.value).isFetching === null) {
|
||||
dispatch(entryActions.fetchBoard(data.value));
|
||||
}
|
||||
|
||||
handleFieldChange(event, data);
|
||||
},
|
||||
[dispatch, handleFieldChange, selectedProject.boards],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header onBack={onBack}>
|
||||
{t('common.moveCard', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div className={styles.text}>{t('common.project')}</div>
|
||||
<Dropdown
|
||||
fluid
|
||||
selection
|
||||
name="projectId"
|
||||
options={projectsToLists.map((project) => ({
|
||||
text: project.name,
|
||||
value: project.id,
|
||||
}))}
|
||||
value={selectedProject && selectedProject.id}
|
||||
placeholder={
|
||||
projectsToLists.length === 0 ? t('common.noProjects') : t('common.selectProject')
|
||||
}
|
||||
disabled={projectsToLists.length === 0}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
{selectedProject && (
|
||||
<>
|
||||
<div className={styles.text}>{t('common.board')}</div>
|
||||
<Dropdown
|
||||
fluid
|
||||
selection
|
||||
name="boardId"
|
||||
options={selectedProject.boards.map((board) => ({
|
||||
text: board.name,
|
||||
value: board.id,
|
||||
}))}
|
||||
value={selectedBoard && selectedBoard.id}
|
||||
placeholder={
|
||||
selectedProject.boards.length === 0
|
||||
? t('common.noBoards')
|
||||
: t('common.selectBoard')
|
||||
}
|
||||
disabled={selectedProject.boards.length === 0}
|
||||
className={styles.field}
|
||||
onChange={handleBoardIdChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{selectedBoard && (
|
||||
<>
|
||||
<div className={styles.text}>{t('common.list')}</div>
|
||||
<Dropdown
|
||||
fluid
|
||||
selection
|
||||
name="listId"
|
||||
options={selectedBoard.lists.map((list) => ({
|
||||
text: list.name || t(`common.${list.type}`),
|
||||
value: list.id,
|
||||
disabled: !list.isPersisted,
|
||||
}))}
|
||||
value={selectedList && selectedList.id}
|
||||
placeholder={
|
||||
selectedBoard.isFetching === false && selectedBoard.lists.length === 0
|
||||
? t('common.noLists')
|
||||
: t('common.selectList')
|
||||
}
|
||||
loading={selectedBoard.isFetching !== false}
|
||||
disabled={selectedBoard.isFetching !== false || selectedBoard.lists.length === 0}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
positive
|
||||
content={t('action.move')}
|
||||
disabled={(selectedBoard && selectedBoard.isFetching !== false) || !selectedList}
|
||||
/>
|
||||
</Form>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
MoveCardStep.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
onBack: PropTypes.func,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
MoveCardStep.defaultProps = {
|
||||
onBack: undefined,
|
||||
};
|
||||
|
||||
export default MoveCardStep;
|
||||
@@ -0,0 +1,17 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.field {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
}
|
||||
@@ -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 MoveCardStep from './MoveCardStep';
|
||||
|
||||
export default MoveCardStep;
|
||||
@@ -0,0 +1,57 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon, Menu } from 'semantic-ui-react';
|
||||
|
||||
import { CardTypes } from '../../../constants/Enums';
|
||||
import { CardTypeIcons } from '../../../constants/Icons';
|
||||
|
||||
import styles from './SelectCardType.module.scss';
|
||||
|
||||
const DESCRIPTION_BY_TYPE = {
|
||||
[CardTypes.PROJECT]: 'common.taskAssignmentAndProjectCompletion',
|
||||
[CardTypes.STORY]: 'common.referenceDataAndKnowledgeStorage',
|
||||
};
|
||||
|
||||
const SelectCardType = React.memo(({ value, onSelect }) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const handleSelectClick = useCallback(
|
||||
(_, { value: nextValue }) => {
|
||||
if (nextValue !== value) {
|
||||
onSelect(nextValue);
|
||||
}
|
||||
},
|
||||
[value, onSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu secondary vertical className={styles.menu}>
|
||||
{[CardTypes.PROJECT, CardTypes.STORY].map((type) => (
|
||||
<Menu.Item
|
||||
key={type}
|
||||
value={type}
|
||||
active={type === value}
|
||||
className={styles.menuItem}
|
||||
onClick={handleSelectClick}
|
||||
>
|
||||
<Icon name={CardTypeIcons[type]} className={styles.menuItemIcon} />
|
||||
<div className={styles.menuItemTitle}>{t(`common.${type}`)}</div>
|
||||
<p className={styles.menuItemDescription}>{t(DESCRIPTION_BY_TYPE[type])}</p>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
|
||||
SelectCardType.propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default SelectCardType;
|
||||
@@ -0,0 +1,28 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.menu {
|
||||
margin: 0 auto 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.menuItem:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.menuItemDescription {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.menuItemIcon {
|
||||
float: left;
|
||||
margin: 0 0.35714286em 0 0;
|
||||
}
|
||||
|
||||
.menuItemTitle {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
@@ -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 SelectCardType from './SelectCardType';
|
||||
|
||||
export default SelectCardType;
|
||||
@@ -0,0 +1,77 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form } from 'semantic-ui-react';
|
||||
import { Popup } from '../../lib/custom-ui';
|
||||
|
||||
import SelectCardType from './SelectCardType';
|
||||
|
||||
const SelectCardTypeStep = React.memo(
|
||||
({ defaultValue, title, withButton, buttonContent, onSelect, onBack, onClose }) => {
|
||||
const [t] = useTranslation();
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(nextValue) => {
|
||||
if (withButton) {
|
||||
setValue(nextValue);
|
||||
} else {
|
||||
if (nextValue !== defaultValue) {
|
||||
onSelect(nextValue);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[defaultValue, withButton, onSelect, onClose],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (value !== defaultValue) {
|
||||
onSelect(value);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [defaultValue, onSelect, onClose, value]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header onBack={onBack}>
|
||||
{t(title, {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SelectCardType value={value} onSelect={handleSelect} />
|
||||
{withButton && <Button positive content={t(buttonContent)} />}
|
||||
</Form>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SelectCardTypeStep.propTypes = {
|
||||
defaultValue: PropTypes.string.isRequired,
|
||||
title: PropTypes.string,
|
||||
withButton: PropTypes.bool,
|
||||
buttonContent: PropTypes.string,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
onBack: PropTypes.func,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
SelectCardTypeStep.defaultProps = {
|
||||
title: 'common.selectType',
|
||||
withButton: false,
|
||||
buttonContent: 'action.selectType',
|
||||
onBack: undefined,
|
||||
};
|
||||
|
||||
export default SelectCardTypeStep;
|
||||
@@ -0,0 +1,94 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import upperFirst from 'lodash/upperFirst';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useForceUpdate, usePrevious } from '../../../lib/hooks';
|
||||
|
||||
import { formatStopwatch } from '../../../utils/stopwatch';
|
||||
|
||||
import styles from './StopwatchChip.module.scss';
|
||||
|
||||
const Sizes = {
|
||||
TINY: 'tiny',
|
||||
SMALL: 'small',
|
||||
MEDIUM: 'medium',
|
||||
};
|
||||
|
||||
const StopwatchChip = React.memo(({ value, as, size, isDisabled, onClick }) => {
|
||||
const prevStartedAt = usePrevious(value.startedAt);
|
||||
const forceUpdate = useForceUpdate();
|
||||
|
||||
const intervalRef = useRef(null);
|
||||
|
||||
const onStart = useCallback(() => {
|
||||
intervalRef.current = setInterval(() => {
|
||||
forceUpdate();
|
||||
}, 1000);
|
||||
}, [forceUpdate]);
|
||||
|
||||
const onStop = useCallback(() => {
|
||||
clearInterval(intervalRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevStartedAt) {
|
||||
if (!value.startedAt) {
|
||||
onStop();
|
||||
}
|
||||
} else if (value.startedAt) {
|
||||
onStart();
|
||||
}
|
||||
}, [value.startedAt, prevStartedAt, onStart, onStop]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
onStop();
|
||||
},
|
||||
[onStop],
|
||||
);
|
||||
|
||||
const contentNode = (
|
||||
<span
|
||||
className={classNames(
|
||||
styles.wrapper,
|
||||
styles[`wrapper${upperFirst(size)}`],
|
||||
value.startedAt && styles.wrapperActive,
|
||||
onClick && styles.wrapperHoverable,
|
||||
)}
|
||||
>
|
||||
{formatStopwatch(value)}
|
||||
</span>
|
||||
);
|
||||
|
||||
const ElementType = as;
|
||||
|
||||
return onClick ? (
|
||||
<ElementType type="button" disabled={isDisabled} className={styles.button} onClick={onClick}>
|
||||
{contentNode}
|
||||
</ElementType>
|
||||
) : (
|
||||
contentNode
|
||||
);
|
||||
});
|
||||
|
||||
StopwatchChip.propTypes = {
|
||||
value: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
as: PropTypes.elementType,
|
||||
size: PropTypes.oneOf(Object.values(Sizes)),
|
||||
isDisabled: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
StopwatchChip.defaultProps = {
|
||||
as: 'button',
|
||||
size: Sizes.MEDIUM,
|
||||
isDisabled: false,
|
||||
onClick: undefined,
|
||||
};
|
||||
|
||||
export default StopwatchChip;
|
||||
@@ -0,0 +1,58 @@
|
||||
/*!
|
||||
* 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;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
background: #dce0e4;
|
||||
border-radius: 3px;
|
||||
color: #6a808b;
|
||||
display: inline-block;
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.wrapperActive {
|
||||
background: #21ba45;
|
||||
color: #fff;
|
||||
|
||||
&.wrapperHoverable:hover {
|
||||
background: #16ab39;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapperHoverable:hover {
|
||||
background: #d2d8dc;
|
||||
color: #17394d;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
|
||||
.wrapperTiny {
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.wrapperSmall {
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.wrapperMedium {
|
||||
line-height: 20px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
@@ -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 StopwatchChip from './StopwatchChip';
|
||||
|
||||
export default StopwatchChip;
|
||||
Reference in New Issue
Block a user