feat: Version 2

Closes #627, closes #1047
This commit is contained in:
Maksim Eltyshev
2025-05-10 02:09:06 +02:00
parent ad7fb51cfa
commit 2ee1166747
1557 changed files with 76832 additions and 47042 deletions
@@ -0,0 +1,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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import CardModal from './CardModal';
export default CardModal;