mirror of
https://github.com/plankanban/planka.git
synced 2025-07-19 21:29:43 +02:00
parent
ad7fb51cfa
commit
2ee1166747
1557 changed files with 76832 additions and 47042 deletions
84
client/src/components/task-lists/TaskList/Task/ActionsStep.jsx
Executable file
84
client/src/components/task-lists/TaskList/Task/ActionsStep.jsx
Executable file
|
@ -0,0 +1,84 @@
|
|||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Menu } from 'semantic-ui-react';
|
||||
import { Popup } from '../../../../lib/custom-ui';
|
||||
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import { useSteps } from '../../../../hooks';
|
||||
import ConfirmationStep from '../../../common/ConfirmationStep';
|
||||
|
||||
import styles from './ActionsStep.module.scss';
|
||||
|
||||
const StepTypes = {
|
||||
DELETE: 'DELETE',
|
||||
};
|
||||
|
||||
const ActionsStep = React.memo(({ taskId, onNameEdit, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
const [step, openStep, handleBack] = useSteps();
|
||||
|
||||
const handleDeleteConfirm = useCallback(() => {
|
||||
dispatch(entryActions.deleteTask(taskId));
|
||||
}, [taskId, dispatch]);
|
||||
|
||||
const handleEditNameClick = useCallback(() => {
|
||||
onNameEdit();
|
||||
onClose();
|
||||
}, [onNameEdit, onClose]);
|
||||
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
openStep(StepTypes.DELETE);
|
||||
}, [openStep]);
|
||||
|
||||
if (step && step.type === StepTypes.DELETE) {
|
||||
return (
|
||||
<ConfirmationStep
|
||||
title="common.deleteTask"
|
||||
content="common.areYouSureYouWantToDeleteThisTask"
|
||||
buttonContent="action.deleteTask"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header>
|
||||
{t('common.taskActions', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Menu secondary vertical className={styles.menu}>
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEditNameClick}>
|
||||
{t('action.editDescription', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
|
||||
{t('action.deleteTask', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ActionsStep.propTypes = {
|
||||
taskId: 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;
|
||||
}
|
||||
}
|
104
client/src/components/task-lists/TaskList/Task/EditName.jsx
Executable file
104
client/src/components/task-lists/TaskList/Task/EditName.jsx
Executable file
|
@ -0,0 +1,104 @@
|
|||
/*!
|
||||
* 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(({ taskId, onClose }) => {
|
||||
const selectTaskById = useMemo(() => selectors.makeSelectTaskById(), []);
|
||||
|
||||
const defaultValue = useSelector((state) => selectTaskById(state, taskId).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 && cleanValue !== defaultValue) {
|
||||
dispatch(
|
||||
entryActions.updateTask(taskId, {
|
||||
name: cleanValue,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [taskId, onClose, defaultValue, dispatch, value]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
submit();
|
||||
}, [submit]);
|
||||
|
||||
const handleFieldKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
} else if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose, submit],
|
||||
);
|
||||
|
||||
const handleClickAwayCancel = useCallback(() => {
|
||||
fieldRef.current.focus();
|
||||
}, [fieldRef]);
|
||||
|
||||
const clickAwayProps = useClickAwayListener([fieldRef, buttonRef], submit, handleClickAwayCancel);
|
||||
|
||||
useEffect(() => {
|
||||
focusEnd(fieldRef.current);
|
||||
}, [fieldRef]);
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} className={styles.wrapper}>
|
||||
<TextArea
|
||||
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
ref={handleFieldRef}
|
||||
as={TextareaAutosize}
|
||||
value={value}
|
||||
maxLength={1024}
|
||||
minRows={2}
|
||||
spellCheck={false}
|
||||
className={styles.field}
|
||||
onKeyDown={handleFieldKeyDown}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<div className={styles.controls}>
|
||||
<Button
|
||||
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
ref={handleButtonRef}
|
||||
positive
|
||||
content={t('action.save')}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
EditName.propTypes = {
|
||||
taskId: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default EditName;
|
|
@ -0,0 +1,33 @@
|
|||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.controls {
|
||||
clear: both;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.field {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(9, 30, 66, 0.13);
|
||||
border-radius: 3px;
|
||||
box-sizing: border-box;
|
||||
color: #17394d;
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
padding: 8px 12px;
|
||||
resize: none;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
padding: 9px 0 16px 40px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
/*!
|
||||
* 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 BoardMembershipsStep from '../../../board-memberships/BoardMembershipsStep';
|
||||
|
||||
const SelectAssigneeStep = React.memo(
|
||||
({ currentUserId, onUserSelect, onUserDeselect, onBack, onClose }) => {
|
||||
const deselectUser = useCallback(() => {
|
||||
onUserDeselect();
|
||||
onClose();
|
||||
}, [onUserDeselect, onClose]);
|
||||
|
||||
const handleUserSelect = useCallback(
|
||||
(userId) => {
|
||||
onUserSelect(userId);
|
||||
onClose();
|
||||
},
|
||||
[onUserSelect, onClose],
|
||||
);
|
||||
|
||||
const handleUserDeselect = useCallback(() => {
|
||||
deselectUser();
|
||||
}, [deselectUser]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
deselectUser();
|
||||
}, [deselectUser]);
|
||||
|
||||
return (
|
||||
<BoardMembershipsStep
|
||||
currentUserIds={currentUserId ? [currentUserId] : []}
|
||||
title="common.selectAssignee"
|
||||
clearButtonContent="action.removeAssignee"
|
||||
onUserSelect={handleUserSelect}
|
||||
onUserDeselect={handleUserDeselect}
|
||||
onClear={handleClear}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SelectAssigneeStep.propTypes = {
|
||||
currentUserId: PropTypes.string,
|
||||
onUserSelect: PropTypes.func.isRequired,
|
||||
onUserDeselect: PropTypes.func.isRequired,
|
||||
onBack: PropTypes.func,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
SelectAssigneeStep.defaultProps = {
|
||||
currentUserId: undefined,
|
||||
onBack: undefined,
|
||||
};
|
||||
|
||||
export default SelectAssigneeStep;
|
199
client/src/components/task-lists/TaskList/Task/Task.jsx
Executable file
199
client/src/components/task-lists/TaskList/Task/Task.jsx
Executable file
|
@ -0,0 +1,199 @@
|
|||
/*!
|
||||
* 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 ReactDOM from 'react-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
import { Button, Checkbox, Icon } from 'semantic-ui-react';
|
||||
import { useDidUpdate } from '../../../../lib/hooks';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import { usePopupInClosableContext } from '../../../../hooks';
|
||||
import { isListArchiveOrTrash } from '../../../../utils/record-helpers';
|
||||
import { BoardMembershipRoles } from '../../../../constants/Enums';
|
||||
import { ClosableContext } from '../../../../contexts';
|
||||
import EditName from './EditName';
|
||||
import SelectAssigneeStep from './SelectAssigneeStep';
|
||||
import ActionsStep from './ActionsStep';
|
||||
import Linkify from '../../../common/Linkify';
|
||||
import UserAvatar from '../../../users/UserAvatar';
|
||||
|
||||
import styles from './Task.module.scss';
|
||||
|
||||
const Task = React.memo(({ id, index }) => {
|
||||
const selectTaskById = useMemo(() => selectors.makeSelectTaskById(), []);
|
||||
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
|
||||
|
||||
const task = useSelector((state) => selectTaskById(state, id));
|
||||
|
||||
const { canEdit, canToggle } = useSelector((state) => {
|
||||
const { listId } = selectors.selectCurrentCard(state);
|
||||
const list = selectListById(state, listId);
|
||||
|
||||
if (isListArchiveOrTrash(list)) {
|
||||
return {
|
||||
canEdit: false,
|
||||
canToggle: false,
|
||||
};
|
||||
}
|
||||
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
|
||||
return {
|
||||
canEdit: isEditor,
|
||||
canToggle: isEditor,
|
||||
};
|
||||
}, shallowEqual);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [isEditNameOpened, setIsEditNameOpened] = useState(false);
|
||||
const [, , setIsClosableActive] = useContext(ClosableContext);
|
||||
|
||||
const handleToggleChange = useCallback(() => {
|
||||
dispatch(
|
||||
entryActions.updateTask(id, {
|
||||
isCompleted: !task.isCompleted,
|
||||
}),
|
||||
);
|
||||
}, [id, task.isCompleted, dispatch]);
|
||||
|
||||
const handleUserSelect = useCallback(
|
||||
(userId) => {
|
||||
dispatch(
|
||||
entryActions.updateTask(id, {
|
||||
assigneeUserId: userId,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[id, dispatch],
|
||||
);
|
||||
|
||||
const handleUserDeselect = useCallback(() => {
|
||||
dispatch(
|
||||
entryActions.updateTask(id, {
|
||||
assigneeUserId: null,
|
||||
}),
|
||||
);
|
||||
}, [id, dispatch]);
|
||||
|
||||
const isEditable = task.isPersisted && canEdit;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isEditable) {
|
||||
setIsEditNameOpened(true);
|
||||
}
|
||||
}, [isEditable]);
|
||||
|
||||
const handleNameEdit = useCallback(() => {
|
||||
setIsEditNameOpened(true);
|
||||
}, []);
|
||||
|
||||
const handleEditNameClose = useCallback(() => {
|
||||
setIsEditNameOpened(false);
|
||||
}, []);
|
||||
|
||||
useDidUpdate(() => {
|
||||
setIsClosableActive(isEditNameOpened);
|
||||
}, [isEditNameOpened]);
|
||||
|
||||
const SelectAssigneePopup = usePopupInClosableContext(SelectAssigneeStep);
|
||||
const ActionsPopup = usePopupInClosableContext(ActionsStep);
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
draggableId={`task:${id}`}
|
||||
index={index}
|
||||
isDragDisabled={isEditNameOpened || !isEditable}
|
||||
>
|
||||
{({ innerRef, draggableProps, dragHandleProps }, { isDragging }) => {
|
||||
const contentNode = (
|
||||
<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, isDragging && styles.wrapperDragging)}
|
||||
>
|
||||
<span className={styles.checkboxWrapper}>
|
||||
<Checkbox
|
||||
checked={task.isCompleted}
|
||||
disabled={!task.isPersisted || !canToggle}
|
||||
className={styles.checkbox}
|
||||
onChange={handleToggleChange}
|
||||
/>
|
||||
</span>
|
||||
{isEditNameOpened ? (
|
||||
<EditName taskId={id} onClose={handleEditNameClose} />
|
||||
) : (
|
||||
<div className={classNames(canEdit && styles.contentHoverable)}>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
|
||||
jsx-a11y/no-static-element-interactions */}
|
||||
<span
|
||||
className={classNames(styles.text, canEdit && styles.textEditable)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span
|
||||
className={classNames(styles.task, task.isCompleted && styles.taskCompleted)}
|
||||
>
|
||||
<Linkify linkStopPropagation>{task.name}</Linkify>
|
||||
</span>
|
||||
</span>
|
||||
{(task.assigneeUserId || isEditable) && (
|
||||
<div className={classNames(styles.actions, isEditable && styles.actionsEditable)}>
|
||||
{isEditable ? (
|
||||
<>
|
||||
<SelectAssigneePopup
|
||||
currentUserId={task.assigneeUserId}
|
||||
onUserSelect={handleUserSelect}
|
||||
onUserDeselect={handleUserDeselect}
|
||||
>
|
||||
{task.assigneeUserId ? (
|
||||
<UserAvatar
|
||||
id={task.assigneeUserId}
|
||||
size="tiny"
|
||||
className={styles.assigneeUserAvatar}
|
||||
/>
|
||||
) : (
|
||||
<Button className={styles.button}>
|
||||
<Icon fitted name="add user" size="small" />
|
||||
</Button>
|
||||
)}
|
||||
</SelectAssigneePopup>
|
||||
<ActionsPopup taskId={id} onNameEdit={handleNameEdit}>
|
||||
<Button className={styles.button}>
|
||||
<Icon fitted name="pencil" size="small" />
|
||||
</Button>
|
||||
</ActionsPopup>
|
||||
</>
|
||||
) : (
|
||||
<UserAvatar
|
||||
id={task.assigneeUserId}
|
||||
size="tiny"
|
||||
className={styles.assigneeUserAvatar}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return isDragging ? ReactDOM.createPortal(contentNode, document.body) : contentNode;
|
||||
}}
|
||||
</Draggable>
|
||||
);
|
||||
});
|
||||
|
||||
Task.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default Task;
|
102
client/src/components/task-lists/TaskList/Task/Task.module.scss
Normal file
102
client/src/components/task-lists/TaskList/Task/Task.module.scss
Normal file
|
@ -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) {
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 4px;
|
||||
}
|
||||
|
||||
.actionsEditable {
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
.assigneeUserAvatar {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
line-height: 28px;
|
||||
margin: 0;
|
||||
min-height: auto;
|
||||
opacity: 0;
|
||||
padding: 0;
|
||||
width: 28px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(9, 30, 66, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.checkboxWrapper {
|
||||
display: inline-block;
|
||||
padding: 10px 15px 0px 8px;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
top: 0;
|
||||
left: 0;
|
||||
vertical-align: top;
|
||||
z-index: 2000;
|
||||
line-height: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.contentHoverable:hover {
|
||||
background: rgba(9, 30, 66, 0.04);
|
||||
|
||||
.button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.task {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
padding: 8px 0;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.taskCompleted {
|
||||
color: #aaa;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.text {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
color: #17394d;
|
||||
display: inline-block;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
min-height: 32px;
|
||||
padding: 0 34px 0 40px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.textEditable {
|
||||
cursor: pointer;
|
||||
padding-right: 68px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
border-radius: 3px;
|
||||
cursor: auto;
|
||||
margin-left: -40px;
|
||||
min-height: 32px;
|
||||
position: relative;
|
||||
width: calc(100% + 40px);
|
||||
}
|
||||
|
||||
.wrapperDragging {
|
||||
background: rgba(247, 246, 247, 0.8);
|
||||
}
|
||||
}
|
8
client/src/components/task-lists/TaskList/Task/index.js
Normal file
8
client/src/components/task-lists/TaskList/Task/index.js
Normal 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 Task from './Task';
|
||||
|
||||
export default Task;
|
Loading…
Add table
Add a link
Reference in a new issue