mirror of
https://github.com/plankanban/planka.git
synced 2025-07-27 09:09:46 +02:00
Initial commit
This commit is contained in:
commit
5ffef61fe7
613 changed files with 91659 additions and 0 deletions
101
client/src/components/CardModal/Actions/Actions.jsx
Executable file
101
client/src/components/CardModal/Actions/Actions.jsx
Executable file
|
@ -0,0 +1,101 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Comment, Icon, Loader, Visibility,
|
||||
} from 'semantic-ui-react';
|
||||
|
||||
import { ActionTypes } from '../../../constants/Enums';
|
||||
import AddComment from './AddComment';
|
||||
import Item from './Item';
|
||||
|
||||
import styles from './Actions.module.css';
|
||||
|
||||
const Actions = React.memo(
|
||||
({
|
||||
items,
|
||||
isFetching,
|
||||
isAllFetched,
|
||||
isEditable,
|
||||
onFetch,
|
||||
onCommentCreate,
|
||||
onCommentUpdate,
|
||||
onCommentDelete,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const handleCommentUpdate = useCallback(
|
||||
(id, data) => {
|
||||
onCommentUpdate(id, data);
|
||||
},
|
||||
[onCommentUpdate],
|
||||
);
|
||||
|
||||
const handleCommentDelete = useCallback(
|
||||
(id) => {
|
||||
onCommentDelete(id);
|
||||
},
|
||||
[onCommentDelete],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.contentModule}>
|
||||
<div className={styles.moduleWrapper}>
|
||||
<Icon name="comment outline" className={styles.moduleIcon} />
|
||||
<div className={styles.moduleHeader}>{t('common.addComment')}</div>
|
||||
<AddComment onCreate={onCommentCreate} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.contentModule}>
|
||||
<div className={styles.moduleWrapper}>
|
||||
<Icon name="list ul" className={styles.moduleIcon} />
|
||||
<div className={styles.moduleHeader}>{t('common.actions')}</div>
|
||||
<div className={styles.wrapper}>
|
||||
<Comment.Group>
|
||||
{items.map((item) => (item.type === ActionTypes.COMMENT_CARD ? (
|
||||
<Item.Comment
|
||||
key={item.id}
|
||||
data={item.data}
|
||||
createdAt={item.createdAt}
|
||||
isPersisted={item.isPersisted}
|
||||
user={item.user}
|
||||
isEditable={isEditable}
|
||||
onUpdate={(data) => handleCommentUpdate(item.id, data)}
|
||||
onDelete={() => handleCommentDelete(item.id)}
|
||||
/>
|
||||
) : (
|
||||
<Item
|
||||
key={item.id}
|
||||
type={item.type}
|
||||
data={item.data}
|
||||
createdAt={item.createdAt}
|
||||
user={item.user}
|
||||
/>
|
||||
)))}
|
||||
</Comment.Group>
|
||||
</div>
|
||||
{isFetching ? (
|
||||
<Loader active inverted inline="centered" size="small" className={styles.loader} />
|
||||
) : (
|
||||
!isAllFetched && <Visibility fireOnMount onOnScreen={onFetch} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Actions.propTypes = {
|
||||
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isAllFetched: PropTypes.bool.isRequired,
|
||||
isEditable: PropTypes.bool.isRequired,
|
||||
onFetch: PropTypes.func.isRequired,
|
||||
onCommentCreate: PropTypes.func.isRequired,
|
||||
onCommentUpdate: PropTypes.func.isRequired,
|
||||
onCommentDelete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Actions;
|
38
client/src/components/CardModal/Actions/Actions.module.css
Normal file
38
client/src/components/CardModal/Actions/Actions.module.css
Normal file
|
@ -0,0 +1,38 @@
|
|||
.contentModule {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.loader {
|
||||
margin-top: 10px !important;
|
||||
}
|
||||
|
||||
.moduleHeader {
|
||||
color: #17394d;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
line-height: 20px;
|
||||
margin: 0 0 4px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.moduleIcon {
|
||||
color: #17394d;
|
||||
font-size: 17px !important;
|
||||
height: 32px !important;
|
||||
left: -40px;
|
||||
line-height: 32px;
|
||||
margin-right: 0 !important;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
width: 32px !important;
|
||||
}
|
||||
|
||||
.moduleWrapper {
|
||||
margin: 0 0 0 40px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
margin-left: -40px;
|
||||
margin-top: 12px;
|
||||
}
|
75
client/src/components/CardModal/Actions/AddComment.jsx
Executable file
75
client/src/components/CardModal/Actions/AddComment.jsx
Executable file
|
@ -0,0 +1,75 @@
|
|||
import React, { useCallback, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { Button, Form, TextArea } from 'semantic-ui-react';
|
||||
|
||||
import { useDeepCompareCallback, useForm } from '../../../hooks';
|
||||
|
||||
import styles from './AddComment.module.css';
|
||||
|
||||
const DEFAULT_DATA = {
|
||||
text: '',
|
||||
};
|
||||
|
||||
const AddComment = React.memo(({ onCreate }) => {
|
||||
const [t] = useTranslation();
|
||||
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
|
||||
|
||||
const textField = useRef(null);
|
||||
|
||||
const submit = useDeepCompareCallback(() => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
text: data.text.trim(),
|
||||
};
|
||||
|
||||
if (!cleanData.text) {
|
||||
textField.current.ref.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
onCreate(cleanData);
|
||||
|
||||
setData(DEFAULT_DATA);
|
||||
}, [onCreate, data, setData]);
|
||||
|
||||
const handleFieldKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.ctrlKey && event.key === 'Enter') {
|
||||
submit();
|
||||
}
|
||||
},
|
||||
[submit],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
submit();
|
||||
}, [submit]);
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<TextArea
|
||||
ref={textField}
|
||||
as={TextareaAutosize}
|
||||
name="text"
|
||||
value={data.text}
|
||||
placeholder={t('common.writeComment')}
|
||||
minRows={3}
|
||||
spellCheck={false}
|
||||
className={styles.field}
|
||||
onKeyDown={handleFieldKeyDown}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<div className={styles.controls}>
|
||||
<Button positive content={t('action.addComment')} disabled={!data.text} />
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
AddComment.propTypes = {
|
||||
onCreate: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default AddComment;
|
|
@ -0,0 +1,23 @@
|
|||
.controls {
|
||||
clear: both;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.field {
|
||||
background: #fff !important;
|
||||
border: 0 !important;
|
||||
box-sizing: border-box;
|
||||
color: #333 !important;
|
||||
display: block;
|
||||
line-height: 1.5 !important;
|
||||
font-size: 14px !important;
|
||||
margin-bottom: 6px !important;
|
||||
overflow: hidden;
|
||||
padding: 8px 12px !important;
|
||||
resize: none !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.field:focus {
|
||||
outline: none;
|
||||
}
|
122
client/src/components/CardModal/Actions/EditComment.jsx
Executable file
122
client/src/components/CardModal/Actions/EditComment.jsx
Executable file
|
@ -0,0 +1,122 @@
|
|||
import dequal from 'dequal';
|
||||
import React, {
|
||||
useCallback, useEffect, useImperativeHandle, useRef, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { Button, Form, TextArea } from 'semantic-ui-react';
|
||||
|
||||
import { useClosableForm, useDeepCompareCallback, useForm } from '../../../hooks';
|
||||
|
||||
import styles from './EditComment.module.css';
|
||||
|
||||
const EditComment = React.forwardRef(({ children, defaultData, onUpdate }, ref) => {
|
||||
const [t] = useTranslation();
|
||||
const [isOpened, setIsOpened] = useState(false);
|
||||
const [data, handleFieldChange, setData] = useForm(null);
|
||||
|
||||
const textField = useRef(null);
|
||||
|
||||
const open = useDeepCompareCallback(() => {
|
||||
setIsOpened(true);
|
||||
setData({
|
||||
text: '',
|
||||
...defaultData,
|
||||
});
|
||||
}, [defaultData, setData]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpened(false);
|
||||
setData(null);
|
||||
}, [setData]);
|
||||
|
||||
const submit = useDeepCompareCallback(() => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
text: data.text.trim(),
|
||||
};
|
||||
|
||||
if (!cleanData.text) {
|
||||
textField.current.ref.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dequal(cleanData, defaultData)) {
|
||||
onUpdate(cleanData);
|
||||
}
|
||||
|
||||
close();
|
||||
}, [defaultData, onUpdate, data, close]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
open,
|
||||
close,
|
||||
}),
|
||||
[open, close],
|
||||
);
|
||||
|
||||
const handleFieldKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.ctrlKey && event.key === 'Enter') {
|
||||
submit();
|
||||
}
|
||||
},
|
||||
[submit],
|
||||
);
|
||||
|
||||
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
|
||||
isOpened,
|
||||
close,
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
submit();
|
||||
}, [submit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpened) {
|
||||
textField.current.ref.current.select();
|
||||
}
|
||||
}, [isOpened]);
|
||||
|
||||
if (!isOpened) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<TextArea
|
||||
ref={textField}
|
||||
as={TextareaAutosize}
|
||||
name="text"
|
||||
value={data.text}
|
||||
minRows={3}
|
||||
spellCheck={false}
|
||||
className={styles.field}
|
||||
onKeyDown={handleFieldKeyDown}
|
||||
onChange={handleFieldChange}
|
||||
onBlur={handleFieldBlur}
|
||||
/>
|
||||
<div className={styles.controls}>
|
||||
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
|
||||
<Button
|
||||
positive
|
||||
content={t('action.save')}
|
||||
onMouseOver={handleControlMouseOver}
|
||||
onMouseOut={handleControlMouseOut}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
EditComment.propTypes = {
|
||||
children: PropTypes.element.isRequired,
|
||||
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(EditComment);
|
|
@ -0,0 +1,24 @@
|
|||
.controls {
|
||||
clear: both;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.field {
|
||||
background: #fff !important;
|
||||
border: 1px solid rgba(9, 45, 66, 0.13) !important;
|
||||
border-radius: 3px !important;
|
||||
box-sizing: border-box;
|
||||
color: #333 !important;
|
||||
display: block;
|
||||
line-height: 1.4 !important;
|
||||
font-size: 14px !important;
|
||||
margin-bottom: 4px !important;
|
||||
overflow: hidden;
|
||||
padding: 8px 12px !important;
|
||||
resize: none !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.field:focus {
|
||||
outline: none;
|
||||
}
|
91
client/src/components/CardModal/Actions/Item.jsx
Executable file
91
client/src/components/CardModal/Actions/Item.jsx
Executable file
|
@ -0,0 +1,91 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
import { Comment } from 'semantic-ui-react';
|
||||
|
||||
import { ActionTypes } from '../../../constants/Enums';
|
||||
import ItemComment from './ItemComment';
|
||||
import User from '../../User';
|
||||
|
||||
import styles from './Item.module.css';
|
||||
|
||||
const Item = React.memo(({
|
||||
type, data, createdAt, user,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
let contentNode;
|
||||
|
||||
switch (type) {
|
||||
case ActionTypes.CREATE_CARD:
|
||||
contentNode = (
|
||||
<Trans
|
||||
i18nKey="common.userAddedThisCardToList"
|
||||
values={{
|
||||
user: user.name,
|
||||
list: data.list.name,
|
||||
}}
|
||||
>
|
||||
<span className={styles.author}>{user.name}</span>
|
||||
<span className={styles.text}>
|
||||
{' added this card to '}
|
||||
{data.list.name}
|
||||
</span>
|
||||
</Trans>
|
||||
);
|
||||
|
||||
break;
|
||||
case ActionTypes.MOVE_CARD:
|
||||
contentNode = (
|
||||
<Trans
|
||||
i18nKey="common.userMovedThisCardFromListToList"
|
||||
values={{
|
||||
user: user.name,
|
||||
fromList: data.fromList.name,
|
||||
toList: data.toList.name,
|
||||
}}
|
||||
>
|
||||
<span className={styles.author}>{user.name}</span>
|
||||
<span className={styles.text}>
|
||||
{' moved this card from '}
|
||||
{data.fromList.name}
|
||||
{' to '}
|
||||
{data.toList.name}
|
||||
</span>
|
||||
</Trans>
|
||||
);
|
||||
|
||||
break;
|
||||
default:
|
||||
contentNode = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Comment>
|
||||
<span className={styles.user}>
|
||||
<User name={user.name} avatar={user.avatar} />
|
||||
</span>
|
||||
<div className={classNames(styles.content)}>
|
||||
<div>{contentNode}</div>
|
||||
<span className={styles.date}>
|
||||
{t('format:longDateTime', {
|
||||
postProcess: 'formatDate',
|
||||
value: createdAt,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</Comment>
|
||||
);
|
||||
});
|
||||
|
||||
Item.Comment = ItemComment;
|
||||
|
||||
Item.propTypes = {
|
||||
type: PropTypes.string.isRequired,
|
||||
data: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
createdAt: PropTypes.instanceOf(Date).isRequired,
|
||||
user: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
};
|
||||
|
||||
export default Item;
|
31
client/src/components/CardModal/Actions/Item.module.css
Normal file
31
client/src/components/CardModal/Actions/Item.module.css
Normal file
|
@ -0,0 +1,31 @@
|
|||
.author {
|
||||
color: #17394d;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
border-bottom: 1px solid #092d4221;
|
||||
display: inline-block;
|
||||
padding-bottom: 14px;
|
||||
vertical-align: top;
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.date {
|
||||
color: #6b808c;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.text {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.user {
|
||||
display: inline-block;
|
||||
padding: 4px 8px 0 0;
|
||||
vertical-align: top;
|
||||
}
|
87
client/src/components/CardModal/Actions/ItemComment.jsx
Executable file
87
client/src/components/CardModal/Actions/ItemComment.jsx
Executable file
|
@ -0,0 +1,87 @@
|
|||
import React, { useCallback, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Comment } from 'semantic-ui-react';
|
||||
|
||||
import EditComment from './EditComment';
|
||||
import User from '../../User';
|
||||
import DeletePopup from '../../DeletePopup';
|
||||
|
||||
import styles from './ItemComment.module.css';
|
||||
|
||||
const ItemComment = React.memo(
|
||||
({
|
||||
data, createdAt, isPersisted, user, isEditable, onUpdate, onDelete,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const editComment = useRef(null);
|
||||
|
||||
const handleEditClick = useCallback(() => {
|
||||
editComment.current.open();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Comment>
|
||||
<span className={styles.user}>
|
||||
<User name={user.name} avatar={user.avatar} />
|
||||
</span>
|
||||
<div className={classNames(styles.content)}>
|
||||
<div className={styles.title}>
|
||||
<span className={styles.author}>{user.name}</span>
|
||||
<span className={styles.date}>
|
||||
{t('format:longDateTime', {
|
||||
postProcess: 'formatDate',
|
||||
value: createdAt,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<EditComment ref={editComment} defaultData={data} onUpdate={onUpdate}>
|
||||
<>
|
||||
<p className={styles.text}>{data.text}</p>
|
||||
<Comment.Actions>
|
||||
{user.isCurrent && (
|
||||
<Comment.Action
|
||||
as="button"
|
||||
content={t('action.edit')}
|
||||
disabled={!isPersisted}
|
||||
onClick={handleEditClick}
|
||||
/>
|
||||
)}
|
||||
{(user.isCurrent || isEditable) && (
|
||||
<DeletePopup
|
||||
title={t('common.deleteComment', {
|
||||
context: 'title',
|
||||
})}
|
||||
content={t('common.areYouSureYouWantToDeleteThisComment')}
|
||||
buttonContent={t('action.deleteComment')}
|
||||
onConfirm={onDelete}
|
||||
>
|
||||
<Comment.Action
|
||||
as="button"
|
||||
content={t('action.delete')}
|
||||
disabled={!isPersisted}
|
||||
/>
|
||||
</DeletePopup>
|
||||
)}
|
||||
</Comment.Actions>
|
||||
</>
|
||||
</EditComment>
|
||||
</div>
|
||||
</Comment>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ItemComment.propTypes = {
|
||||
data: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
createdAt: PropTypes.instanceOf(Date).isRequired,
|
||||
isPersisted: PropTypes.bool.isRequired,
|
||||
user: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
isEditable: PropTypes.bool.isRequired,
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ItemComment;
|
|
@ -0,0 +1,49 @@
|
|||
.author {
|
||||
color: #17394d;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
line-height: 20px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.content {
|
||||
border-bottom: 1px solid #092d4221;
|
||||
display: inline-block;
|
||||
padding-bottom: 14px;
|
||||
vertical-align: top;
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.date {
|
||||
color: #6b808c;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.text {
|
||||
background-color: #fff;
|
||||
border-radius: 0px 8px 8px;
|
||||
box-shadow: 0 1px 2px -1px rgba(9, 45, 66, 0.25),
|
||||
0 0 0 1px rgba(9, 45, 66, 0.08);
|
||||
box-sizing: border-box;
|
||||
color: #17394d;
|
||||
display: inline-block;
|
||||
margin: 1px 2px 4px 1px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 8px 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: pre-line;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.user {
|
||||
display: inline-block;
|
||||
padding: 4px 8px 0 0;
|
||||
vertical-align: top;
|
||||
}
|
3
client/src/components/CardModal/Actions/index.js
Executable file
3
client/src/components/CardModal/Actions/index.js
Executable file
|
@ -0,0 +1,3 @@
|
|||
import Actions from './Actions';
|
||||
|
||||
export default Actions;
|
388
client/src/components/CardModal/CardModal.jsx
Executable file
388
client/src/components/CardModal/CardModal.jsx
Executable file
|
@ -0,0 +1,388 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button, Grid, Icon, Modal,
|
||||
} from 'semantic-ui-react';
|
||||
|
||||
import NameField from './NameField';
|
||||
import EditDescription from './EditDescription';
|
||||
import Tasks from './Tasks';
|
||||
import Actions from './Actions';
|
||||
import User from '../User';
|
||||
import Label from '../Label';
|
||||
import Deadline from '../Deadline';
|
||||
import Timer from '../Timer';
|
||||
import ProjectMembershipsPopup from '../ProjectMembershipsPopup';
|
||||
import LabelsPopup from '../LabelsPopup';
|
||||
import EditDeadlinePopup from '../EditDeadlinePopup';
|
||||
import EditTimerPopup from '../EditTimerPopup';
|
||||
import DeletePopup from '../DeletePopup';
|
||||
|
||||
import styles from './CardModal.module.css';
|
||||
|
||||
const CardModal = React.memo(
|
||||
({
|
||||
name,
|
||||
description,
|
||||
deadline,
|
||||
timer,
|
||||
isSubscribed,
|
||||
isActionsFetching,
|
||||
isAllActionsFetched,
|
||||
users,
|
||||
labels,
|
||||
tasks,
|
||||
actions,
|
||||
allProjectMemberships,
|
||||
allLabels,
|
||||
isEditable,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onUserAdd,
|
||||
onUserRemove,
|
||||
onLabelAdd,
|
||||
onLabelRemove,
|
||||
onLabelCreate,
|
||||
onLabelUpdate,
|
||||
onLabelDelete,
|
||||
onTaskCreate,
|
||||
onTaskUpdate,
|
||||
onTaskDelete,
|
||||
onActionsFetch,
|
||||
onCommentActionCreate,
|
||||
onCommentActionUpdate,
|
||||
onCommentActionDelete,
|
||||
onClose,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const handleNameUpdate = useCallback(
|
||||
(newName) => {
|
||||
onUpdate({
|
||||
name: newName,
|
||||
});
|
||||
},
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleDescriptionUpdate = useCallback(
|
||||
(newDescription) => {
|
||||
onUpdate({
|
||||
description: newDescription,
|
||||
});
|
||||
},
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleDeadlineUpdate = useCallback(
|
||||
(newDeadline) => {
|
||||
onUpdate({
|
||||
deadline: newDeadline,
|
||||
});
|
||||
},
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleTimerUpdate = useCallback(
|
||||
(newTimer) => {
|
||||
onUpdate({
|
||||
timer: newTimer,
|
||||
});
|
||||
},
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleToggleSubscribeClick = useCallback(() => {
|
||||
onUpdate({
|
||||
isSubscribed: !isSubscribed,
|
||||
});
|
||||
}, [isSubscribed, onUpdate]);
|
||||
|
||||
const userIds = users.map((user) => user.id);
|
||||
const labelIds = labels.map((label) => label.id);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
closeIcon
|
||||
size="small"
|
||||
centered={false}
|
||||
className={styles.wrapper}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Grid className={styles.grid}>
|
||||
<Grid.Row className={styles.headerPadding}>
|
||||
<Grid.Column width={16} className={styles.headerPadding}>
|
||||
<div className={styles.headerWrapper}>
|
||||
<Icon name="list alternate outline" className={styles.moduleIcon} />
|
||||
<div className={styles.headerTitle}>
|
||||
<NameField defaultValue={name} onUpdate={handleNameUpdate} />
|
||||
</div>
|
||||
</div>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
<Grid.Row className={styles.modalPadding}>
|
||||
<Grid.Column width={12} className={styles.contentPadding}>
|
||||
{(users.length > 0 || labels.length > 0 || deadline || timer) && (
|
||||
<div className={styles.moduleWrapper}>
|
||||
{users.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
<div className={styles.text}>
|
||||
{t('common.members', {
|
||||
context: 'title',
|
||||
})}
|
||||
</div>
|
||||
{users.map((user) => (
|
||||
<span key={user.id} className={styles.attachment}>
|
||||
<ProjectMembershipsPopup
|
||||
items={allProjectMemberships}
|
||||
currentUserIds={userIds}
|
||||
onUserSelect={onUserAdd}
|
||||
onUserDeselect={onUserRemove}
|
||||
>
|
||||
<User name={user.name} avatar={user.avatar} />
|
||||
</ProjectMembershipsPopup>
|
||||
</span>
|
||||
))}
|
||||
<ProjectMembershipsPopup
|
||||
items={allProjectMemberships}
|
||||
currentUserIds={userIds}
|
||||
onUserSelect={onUserAdd}
|
||||
onUserDeselect={onUserRemove}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(styles.attachment, styles.deadline)}
|
||||
>
|
||||
<Icon name="add" size="small" className={styles.addAttachment} />
|
||||
</button>
|
||||
</ProjectMembershipsPopup>
|
||||
</div>
|
||||
)}
|
||||
{labels.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
<div className={styles.text}>
|
||||
{t('common.labels', {
|
||||
context: 'title',
|
||||
})}
|
||||
</div>
|
||||
{labels.map((label) => (
|
||||
<span key={label.id} className={styles.attachment}>
|
||||
<LabelsPopup
|
||||
key={label.id}
|
||||
items={allLabels}
|
||||
currentIds={labelIds}
|
||||
onSelect={onLabelAdd}
|
||||
onDeselect={onLabelRemove}
|
||||
onCreate={onLabelCreate}
|
||||
onUpdate={onLabelUpdate}
|
||||
onDelete={onLabelDelete}
|
||||
>
|
||||
<Label name={label.name} color={label.color} />
|
||||
</LabelsPopup>
|
||||
</span>
|
||||
))}
|
||||
<LabelsPopup
|
||||
items={allLabels}
|
||||
currentIds={labelIds}
|
||||
onSelect={onLabelAdd}
|
||||
onDeselect={onLabelRemove}
|
||||
onCreate={onLabelCreate}
|
||||
onUpdate={onLabelUpdate}
|
||||
onDelete={onLabelDelete}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(styles.attachment, styles.deadline)}
|
||||
>
|
||||
<Icon name="add" size="small" className={styles.addAttachment} />
|
||||
</button>
|
||||
</LabelsPopup>
|
||||
</div>
|
||||
)}
|
||||
{deadline && (
|
||||
<div className={styles.attachments}>
|
||||
<div className={styles.text}>
|
||||
{t('common.deadline', {
|
||||
context: 'title',
|
||||
})}
|
||||
</div>
|
||||
<span className={styles.attachment}>
|
||||
<EditDeadlinePopup defaultValue={deadline} onUpdate={handleDeadlineUpdate}>
|
||||
<Deadline value={deadline} />
|
||||
</EditDeadlinePopup>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{timer && (
|
||||
<div className={styles.attachments}>
|
||||
<div className={styles.text}>
|
||||
{t('common.timer', {
|
||||
context: 'title',
|
||||
})}
|
||||
</div>
|
||||
<span className={styles.attachment}>
|
||||
<EditTimerPopup defaultValue={timer} onUpdate={handleTimerUpdate}>
|
||||
<Timer startedAt={timer.startedAt} total={timer.total} />
|
||||
</EditTimerPopup>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.contentModule}>
|
||||
<div className={styles.moduleWrapper}>
|
||||
<Icon name="align justify" className={styles.moduleIcon} />
|
||||
<div className={styles.moduleHeader}>{t('common.description')}</div>
|
||||
<EditDescription defaultValue={description} onUpdate={handleDescriptionUpdate}>
|
||||
{description ? (
|
||||
<button type="button" className={styles.descriptionText}>
|
||||
{description}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className={styles.descriptionButton}>
|
||||
<span className={styles.descriptionButtonText}>
|
||||
{t('action.addMoreDetailedDescription')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</EditDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.contentModule}>
|
||||
<div className={styles.moduleWrapper}>
|
||||
<Icon name="check square outline" className={styles.moduleIcon} />
|
||||
<div className={styles.moduleHeader}>{t('common.tasks')}</div>
|
||||
<Tasks
|
||||
items={tasks}
|
||||
onCreate={onTaskCreate}
|
||||
onUpdate={onTaskUpdate}
|
||||
onDelete={onTaskDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Actions
|
||||
items={actions}
|
||||
isFetching={isActionsFetching}
|
||||
isAllFetched={isAllActionsFetched}
|
||||
isEditable={isEditable}
|
||||
onFetch={onActionsFetch}
|
||||
onCommentCreate={onCommentActionCreate}
|
||||
onCommentUpdate={onCommentActionUpdate}
|
||||
onCommentDelete={onCommentActionDelete}
|
||||
/>
|
||||
</Grid.Column>
|
||||
<Grid.Column width={4} className={styles.sidebarPadding}>
|
||||
<div className={styles.actions}>
|
||||
<span className={styles.actionsTitle}>{t('action.addToCard')}</span>
|
||||
<ProjectMembershipsPopup
|
||||
items={allProjectMemberships}
|
||||
currentUserIds={userIds}
|
||||
onUserSelect={onUserAdd}
|
||||
onUserDeselect={onUserRemove}
|
||||
>
|
||||
<Button fluid className={styles.actionButton}>
|
||||
<Icon name="user outline" className={styles.actionIcon} />
|
||||
{t('common.members')}
|
||||
</Button>
|
||||
</ProjectMembershipsPopup>
|
||||
<LabelsPopup
|
||||
items={allLabels}
|
||||
currentIds={labelIds}
|
||||
onSelect={onLabelAdd}
|
||||
onDeselect={onLabelRemove}
|
||||
onCreate={onLabelCreate}
|
||||
onUpdate={onLabelUpdate}
|
||||
onDelete={onLabelDelete}
|
||||
>
|
||||
<Button fluid className={styles.actionButton}>
|
||||
<Icon name="bookmark outline" className={styles.actionIcon} />
|
||||
{t('common.labels')}
|
||||
</Button>
|
||||
</LabelsPopup>
|
||||
<EditDeadlinePopup defaultValue={deadline} onUpdate={handleDeadlineUpdate}>
|
||||
<Button fluid className={styles.actionButton}>
|
||||
<Icon name="calendar check outline" className={styles.actionIcon} />
|
||||
{t('common.deadline')}
|
||||
</Button>
|
||||
</EditDeadlinePopup>
|
||||
<EditTimerPopup defaultValue={timer} onUpdate={handleTimerUpdate}>
|
||||
<Button fluid className={styles.actionButton}>
|
||||
<Icon name="clock outline" className={styles.actionIcon} />
|
||||
{t('common.timer')}
|
||||
</Button>
|
||||
</EditTimerPopup>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<span className={styles.actionsTitle}>{t('common.actions')}</span>
|
||||
<Button fluid className={styles.actionButton} onClick={handleToggleSubscribeClick}>
|
||||
<Icon name="paper plane outline" className={styles.actionIcon} />
|
||||
{isSubscribed ? t('action.unsubscribe') : t('action.subscribe')}
|
||||
</Button>
|
||||
<DeletePopup
|
||||
title={t('common.deleteCard', {
|
||||
context: 'title',
|
||||
})}
|
||||
content={t('common.areYouSureYouWantToDeleteThisCard')}
|
||||
buttonContent={t('action.deleteCard')}
|
||||
onConfirm={onDelete}
|
||||
>
|
||||
<Button fluid className={styles.actionButton}>
|
||||
<Icon name="trash alternate outline" className={styles.actionIcon} />
|
||||
{t('action.delete')}
|
||||
</Button>
|
||||
</DeletePopup>
|
||||
</div>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CardModal.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
description: PropTypes.string,
|
||||
deadline: PropTypes.instanceOf(Date),
|
||||
timer: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
isSubscribed: PropTypes.bool.isRequired,
|
||||
isActionsFetching: PropTypes.bool.isRequired,
|
||||
isAllActionsFetched: PropTypes.bool.isRequired,
|
||||
/* eslint-disable react/forbid-prop-types */
|
||||
users: PropTypes.array.isRequired,
|
||||
labels: PropTypes.array.isRequired,
|
||||
tasks: PropTypes.array.isRequired,
|
||||
actions: PropTypes.array.isRequired,
|
||||
allProjectMemberships: PropTypes.array.isRequired,
|
||||
allLabels: PropTypes.array.isRequired,
|
||||
/* eslint-enable react/forbid-prop-types */
|
||||
isEditable: PropTypes.bool.isRequired,
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onUserAdd: PropTypes.func.isRequired,
|
||||
onUserRemove: PropTypes.func.isRequired,
|
||||
onLabelAdd: PropTypes.func.isRequired,
|
||||
onLabelRemove: PropTypes.func.isRequired,
|
||||
onLabelCreate: PropTypes.func.isRequired,
|
||||
onLabelUpdate: PropTypes.func.isRequired,
|
||||
onLabelDelete: PropTypes.func.isRequired,
|
||||
onTaskCreate: PropTypes.func.isRequired,
|
||||
onTaskUpdate: PropTypes.func.isRequired,
|
||||
onTaskDelete: PropTypes.func.isRequired,
|
||||
onActionsFetch: PropTypes.func.isRequired,
|
||||
onCommentActionCreate: PropTypes.func.isRequired,
|
||||
onCommentActionUpdate: PropTypes.func.isRequired,
|
||||
onCommentActionDelete: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
CardModal.defaultProps = {
|
||||
description: undefined,
|
||||
deadline: undefined,
|
||||
timer: undefined,
|
||||
};
|
||||
|
||||
export default CardModal;
|
211
client/src/components/CardModal/CardModal.module.css
Normal file
211
client/src/components/CardModal/CardModal.module.css
Normal file
|
@ -0,0 +1,211 @@
|
|||
.actionButton {
|
||||
background: #ebeef0 !important;
|
||||
box-shadow: 0 1px 0 0 rgba(9, 45, 66, 0.13) !important;
|
||||
color: #444 !important;
|
||||
margin-top: 8px !important;
|
||||
padding: 6px 8px 6px 18px !important;
|
||||
text-align: left !important;
|
||||
transition: background 85ms ease !important;
|
||||
}
|
||||
|
||||
.actionButton:hover {
|
||||
background: #dfe3e6 !important;
|
||||
box-shadow: 0 1px 0 0 rgba(9, 45, 66, 0.25) !important;
|
||||
color: #4c4c4c !important;
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
color: #17394d !important;
|
||||
margin-right: 8px !important;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.actionsTitle {
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
margin-top: 16px;
|
||||
text-transform: uppercase;
|
||||
line-height: 20px;
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
|
||||
.addAttachment {
|
||||
margin: 0 -4.3px !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.attachment {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
margin: 0 4px 4px 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
display: inline-block;
|
||||
margin: 0 8px 8px 0;
|
||||
max-width: 100%;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.contentModule {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.contentPadding {
|
||||
padding: 8px 8px 0 16px !important;
|
||||
}
|
||||
|
||||
.deadline {
|
||||
background: #dce0e4;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #6b808c;
|
||||
line-height: 20px;
|
||||
outline: none;
|
||||
padding: 6px 14px;
|
||||
text-align: left;
|
||||
text-decoration: underline;
|
||||
transition: background 0.3s ease;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.deadline:hover {
|
||||
background: #d2d8dc;
|
||||
color: #17394d;
|
||||
}
|
||||
|
||||
.descriptionButton {
|
||||
background: rgba(9, 45, 66, 0.08);
|
||||
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%;
|
||||
}
|
||||
|
||||
.descriptionButton:hover {
|
||||
background-color: rgba(9, 45, 66, 0.13);
|
||||
color: #092d42;
|
||||
}
|
||||
|
||||
.descriptionButtonText {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
.descriptionText {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #17394d;
|
||||
cursor: pointer;
|
||||
line-height: 1.5;
|
||||
font-size: 15px;
|
||||
margin-bottom: 8px;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
white-space: pre-line;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid {
|
||||
background: #f5f6f7;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.headerPadding {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
margin: 4px 0;
|
||||
padding: 6px 0 0;
|
||||
}
|
||||
|
||||
.headerWrapper {
|
||||
margin: 12px 48px 12px 56px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.labels {
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 32px;
|
||||
margin: 0 4px 4px 0;
|
||||
max-width: 100%;
|
||||
min-width: 40px;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
padding: 0 12px;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: rgba(0, 0, 0, 0.2) 1px 1px 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.modalPadding {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
.moduleHeader {
|
||||
color: #17394d;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
line-height: 20px;
|
||||
margin: 0 0 4px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.moduleIcon {
|
||||
color: #17394d;
|
||||
font-size: 17px !important;
|
||||
height: 32px !important;
|
||||
left: -40px;
|
||||
line-height: 32px;
|
||||
margin-right: 0 !important;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
width: 32px !important;
|
||||
}
|
||||
|
||||
.moduleWrapper {
|
||||
margin: 0 0 0 40px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #6b808c;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.3px;
|
||||
line-height: 20px;
|
||||
margin: 0 8px 4px 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sidebarPadding {
|
||||
padding: 8px 16px 0 8px !important;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
width: 768px !important;
|
||||
}
|
122
client/src/components/CardModal/EditDescription.jsx
Executable file
122
client/src/components/CardModal/EditDescription.jsx
Executable file
|
@ -0,0 +1,122 @@
|
|||
import React, {
|
||||
useCallback, useEffect, useImperativeHandle, useRef, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { Button, Form, TextArea } from 'semantic-ui-react';
|
||||
|
||||
import { useClosableForm, useField } from '../../hooks';
|
||||
|
||||
import styles from './EditDescription.module.css';
|
||||
|
||||
const EditDescription = React.forwardRef(({ children, defaultValue, onUpdate }, ref) => {
|
||||
const [t] = useTranslation();
|
||||
const [isOpened, setIsOpened] = useState(false);
|
||||
const [value, handleFieldChange, setValue] = useField(null);
|
||||
|
||||
const field = useRef(null);
|
||||
|
||||
const open = useCallback(() => {
|
||||
setIsOpened(true);
|
||||
setValue(defaultValue || '');
|
||||
}, [defaultValue, setValue]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpened(false);
|
||||
setValue(null);
|
||||
}, [setValue]);
|
||||
|
||||
const submit = useCallback(() => {
|
||||
const cleanValue = value.trim() || null;
|
||||
|
||||
if (cleanValue !== defaultValue) {
|
||||
onUpdate(cleanValue);
|
||||
}
|
||||
|
||||
close();
|
||||
}, [defaultValue, onUpdate, value, close]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
open,
|
||||
close,
|
||||
}),
|
||||
[open, close],
|
||||
);
|
||||
|
||||
const handleChildrenClick = useCallback(() => {
|
||||
open();
|
||||
}, [open]);
|
||||
|
||||
const handleFieldKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
|
||||
submit();
|
||||
}
|
||||
},
|
||||
[submit],
|
||||
);
|
||||
|
||||
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
|
||||
isOpened,
|
||||
close,
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
submit();
|
||||
}, [submit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpened) {
|
||||
field.current.ref.current.select();
|
||||
}
|
||||
}, [isOpened]);
|
||||
|
||||
if (!isOpened) {
|
||||
return React.cloneElement(children, {
|
||||
onClick: handleChildrenClick,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<TextArea
|
||||
ref={field}
|
||||
as={TextareaAutosize}
|
||||
value={value}
|
||||
placeholder={t('common.enterDescription')}
|
||||
minRows={3}
|
||||
spellCheck={false}
|
||||
className={styles.field}
|
||||
onKeyDown={handleFieldKeyDown}
|
||||
onChange={handleFieldChange}
|
||||
onBlur={handleFieldBlur}
|
||||
/>
|
||||
<div className={styles.controls}>
|
||||
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
|
||||
<Button
|
||||
positive
|
||||
content={t('action.save')}
|
||||
onMouseOver={handleControlMouseOver}
|
||||
onMouseOut={handleControlMouseOut}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
EditDescription.propTypes = {
|
||||
children: PropTypes.element.isRequired,
|
||||
defaultValue: PropTypes.string,
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
EditDescription.defaultProps = {
|
||||
defaultValue: undefined,
|
||||
};
|
||||
|
||||
export default React.memo(EditDescription);
|
22
client/src/components/CardModal/EditDescription.module.css
Normal file
22
client/src/components/CardModal/EditDescription.module.css
Normal file
|
@ -0,0 +1,22 @@
|
|||
.controls {
|
||||
clear: both !important;
|
||||
margin-top: 6px !important;
|
||||
}
|
||||
|
||||
.field {
|
||||
background: #fff !important;
|
||||
border: 1px solid rgba(9, 45, 66, 0.13) !important;
|
||||
border-radius: 3px !important;
|
||||
color: #17394d !important;
|
||||
display: block !important;
|
||||
font-size: 14px !important;
|
||||
line-height: 1.5 !important;
|
||||
margin-bottom: 4px !important;
|
||||
overflow: hidden;
|
||||
padding: 8px 12px !important;
|
||||
resize: none !important;
|
||||
}
|
||||
|
||||
.field:focus {
|
||||
outline: none !important;
|
||||
}
|
67
client/src/components/CardModal/NameField.jsx
Executable file
67
client/src/components/CardModal/NameField.jsx
Executable file
|
@ -0,0 +1,67 @@
|
|||
import React, { useCallback, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { TextArea } from 'semantic-ui-react';
|
||||
|
||||
import { useDidUpdate, useField, usePrevious } from '../../hooks';
|
||||
|
||||
import styles from './NameField.module.css';
|
||||
|
||||
const NameField = React.memo(({ defaultValue, onUpdate }) => {
|
||||
const prevDefaultValue = usePrevious(defaultValue);
|
||||
const [value, handleChange, setValue] = useField(defaultValue);
|
||||
|
||||
const isFocused = useRef(false);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
isFocused.current = true;
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback((event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
|
||||
event.target.blur();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
isFocused.current = false;
|
||||
|
||||
const cleanValue = value.trim();
|
||||
|
||||
if (cleanValue) {
|
||||
if (cleanValue !== defaultValue) {
|
||||
onUpdate(cleanValue);
|
||||
}
|
||||
} else {
|
||||
setValue(defaultValue);
|
||||
}
|
||||
}, [defaultValue, onUpdate, value, setValue]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
if (!isFocused.current && defaultValue !== prevDefaultValue) {
|
||||
setValue(defaultValue);
|
||||
}
|
||||
}, [defaultValue, prevDefaultValue, setValue]);
|
||||
|
||||
return (
|
||||
<TextArea
|
||||
as={TextareaAutosize}
|
||||
value={value}
|
||||
spellCheck={false}
|
||||
className={styles.field}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
NameField.propTypes = {
|
||||
defaultValue: PropTypes.string.isRequired,
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default NameField;
|
22
client/src/components/CardModal/NameField.module.css
Normal file
22
client/src/components/CardModal/NameField.module.css
Normal file
|
@ -0,0 +1,22 @@
|
|||
.field {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
box-shadow: none;
|
||||
color: #17394d;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
margin: -5px;
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
resize: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field:focus {
|
||||
background: #fff;
|
||||
border-color: #5ba4cf;
|
||||
box-shadow: 0 0 2px 0 #5ba4cf;
|
||||
outline: 0;
|
||||
}
|
75
client/src/components/CardModal/Tasks/ActionsPopup.jsx
Executable file
75
client/src/components/CardModal/Tasks/ActionsPopup.jsx
Executable file
|
@ -0,0 +1,75 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Menu } from 'semantic-ui-react';
|
||||
import { withPopup } from '../../../lib/popup';
|
||||
import { Popup } from '../../../lib/custom-ui';
|
||||
|
||||
import { useSteps } from '../../../hooks';
|
||||
import DeleteStep from '../../DeleteStep';
|
||||
|
||||
import styles from './ActionsPopup.module.css';
|
||||
|
||||
const StepTypes = {
|
||||
DELETE: 'DELETE',
|
||||
};
|
||||
|
||||
const ActionsStep = React.memo(({ onNameEdit, onDelete, onClose }) => {
|
||||
const [t] = useTranslation();
|
||||
const [step, openStep, handleBack] = useSteps();
|
||||
|
||||
const handleNameEditClick = useCallback(() => {
|
||||
onNameEdit();
|
||||
onClose();
|
||||
}, [onNameEdit, onClose]);
|
||||
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
openStep(StepTypes.DELETE);
|
||||
}, [openStep]);
|
||||
|
||||
if (step && step.type === StepTypes.DELETE) {
|
||||
return (
|
||||
<DeleteStep
|
||||
title={t('common.deleteTask', {
|
||||
context: 'title',
|
||||
})}
|
||||
content={t('common.areYouSureYouWantToDeleteThisTask')}
|
||||
buttonContent={t('action.deleteTask')}
|
||||
onConfirm={onDelete}
|
||||
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={handleNameEditClick}>
|
||||
{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 = {
|
||||
onNameEdit: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default withPopup(ActionsStep);
|
|
@ -0,0 +1,9 @@
|
|||
.menu {
|
||||
margin: -7px -12px -5px !important;
|
||||
width: calc(100% + 24px) !important;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
margin: 0 !important;
|
||||
padding-left: 14px !important;
|
||||
}
|
138
client/src/components/CardModal/Tasks/Add.jsx
Executable file
138
client/src/components/CardModal/Tasks/Add.jsx
Executable file
|
@ -0,0 +1,138 @@
|
|||
import React, {
|
||||
useCallback, useEffect, useImperativeHandle, useRef, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { Button, Form, TextArea } from 'semantic-ui-react';
|
||||
|
||||
import {
|
||||
useClosableForm,
|
||||
useDeepCompareCallback,
|
||||
useDidUpdate,
|
||||
useForm,
|
||||
useToggle,
|
||||
} from '../../../hooks';
|
||||
|
||||
import styles from './Add.module.css';
|
||||
|
||||
const DEFAULT_DATA = {
|
||||
name: '',
|
||||
};
|
||||
|
||||
const Add = React.forwardRef(({ children, onCreate }, ref) => {
|
||||
const [t] = useTranslation();
|
||||
const [isOpened, setIsOpened] = useState(false);
|
||||
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
|
||||
const [selectNameFieldState, selectNameField] = useToggle();
|
||||
|
||||
const nameField = useRef(null);
|
||||
|
||||
const open = useCallback(() => {
|
||||
setIsOpened(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpened(false);
|
||||
}, []);
|
||||
|
||||
const submit = useDeepCompareCallback(() => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
name: data.name.trim(),
|
||||
};
|
||||
|
||||
if (!cleanData.name) {
|
||||
nameField.current.ref.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
onCreate(cleanData);
|
||||
|
||||
setData(DEFAULT_DATA);
|
||||
selectNameField();
|
||||
}, [onCreate, data, setData, selectNameField]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
open,
|
||||
close,
|
||||
}),
|
||||
[open, close],
|
||||
);
|
||||
|
||||
const handleChildrenClick = useCallback(() => {
|
||||
open();
|
||||
}, [open]);
|
||||
|
||||
const handleFieldKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
|
||||
submit();
|
||||
}
|
||||
},
|
||||
[submit],
|
||||
);
|
||||
|
||||
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
|
||||
isOpened,
|
||||
close,
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
submit();
|
||||
}, [submit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpened) {
|
||||
nameField.current.ref.current.select();
|
||||
}
|
||||
}, [isOpened]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
nameField.current.ref.current.select();
|
||||
}, [selectNameFieldState]);
|
||||
|
||||
if (!isOpened) {
|
||||
return React.cloneElement(children, {
|
||||
onClick: handleChildrenClick,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form className={styles.wrapper} onSubmit={handleSubmit}>
|
||||
<TextArea
|
||||
ref={nameField}
|
||||
as={TextareaAutosize}
|
||||
name="name"
|
||||
value={data.name}
|
||||
placeholder={t('common.enterTaskDescription')}
|
||||
minRows={2}
|
||||
spellCheck={false}
|
||||
className={styles.field}
|
||||
onKeyDown={handleFieldKeyDown}
|
||||
onChange={handleFieldChange}
|
||||
onBlur={handleFieldBlur}
|
||||
/>
|
||||
<div className={styles.controls}>
|
||||
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
|
||||
<Button
|
||||
positive
|
||||
content={t('action.addTask')}
|
||||
onMouseOver={handleControlMouseOver}
|
||||
onMouseOut={handleControlMouseOut}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
Add.propTypes = {
|
||||
children: PropTypes.element.isRequired,
|
||||
onCreate: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(Add);
|
23
client/src/components/CardModal/Tasks/Add.module.css
Normal file
23
client/src/components/CardModal/Tasks/Add.module.css
Normal file
|
@ -0,0 +1,23 @@
|
|||
.controls {
|
||||
clear: both !important;
|
||||
margin-top: 6px !important;
|
||||
}
|
||||
|
||||
.field {
|
||||
background: #fff !important;
|
||||
border: 1px solid rgba(9, 45, 66, 0.13) !important;
|
||||
border-radius: 3px !important;
|
||||
color: #17394d !important;
|
||||
display: block !important;
|
||||
line-height: 1.5 !important;
|
||||
font-size: 14px !important;
|
||||
margin-bottom: 4px !important;
|
||||
overflow: hidden;
|
||||
padding: 8px 12px !important;
|
||||
resize: none !important;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
margin-top: 6px !important;
|
||||
padding-bottom: 8px !important;
|
||||
}
|
116
client/src/components/CardModal/Tasks/EditName.jsx
Executable file
116
client/src/components/CardModal/Tasks/EditName.jsx
Executable file
|
@ -0,0 +1,116 @@
|
|||
import React, {
|
||||
useCallback, useEffect, useImperativeHandle, useRef, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { Button, Form, TextArea } from 'semantic-ui-react';
|
||||
|
||||
import { useClosableForm, useField } from '../../../hooks';
|
||||
|
||||
import styles from './EditName.module.css';
|
||||
|
||||
const EditName = React.forwardRef(({ children, defaultValue, onUpdate }, ref) => {
|
||||
const [t] = useTranslation();
|
||||
const [isOpened, setIsOpened] = useState(false);
|
||||
const [value, handleFieldChange, setValue] = useField(null);
|
||||
|
||||
const field = useRef(null);
|
||||
|
||||
const open = useCallback(() => {
|
||||
setIsOpened(true);
|
||||
setValue(defaultValue);
|
||||
}, [defaultValue, setValue]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpened(false);
|
||||
setValue(null);
|
||||
}, [setValue]);
|
||||
|
||||
const submit = useCallback(() => {
|
||||
const cleanValue = value.trim();
|
||||
|
||||
if (!cleanValue) {
|
||||
field.current.ref.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cleanValue !== defaultValue) {
|
||||
onUpdate(cleanValue);
|
||||
}
|
||||
|
||||
close();
|
||||
}, [defaultValue, onUpdate, value, close]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
open,
|
||||
close,
|
||||
}),
|
||||
[open, close],
|
||||
);
|
||||
|
||||
const handleFieldKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
|
||||
submit();
|
||||
}
|
||||
},
|
||||
[submit],
|
||||
);
|
||||
|
||||
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
|
||||
isOpened,
|
||||
close,
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
submit();
|
||||
}, [submit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpened) {
|
||||
field.current.ref.current.select();
|
||||
}
|
||||
}, [isOpened]);
|
||||
|
||||
if (!isOpened) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} className={styles.wrapper}>
|
||||
<TextArea
|
||||
ref={field}
|
||||
as={TextareaAutosize}
|
||||
value={value}
|
||||
minRows={2}
|
||||
spellCheck={false}
|
||||
className={styles.field}
|
||||
onKeyDown={handleFieldKeyDown}
|
||||
onChange={handleFieldChange}
|
||||
onBlur={handleFieldBlur}
|
||||
/>
|
||||
<div className={styles.controls}>
|
||||
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
|
||||
<Button
|
||||
positive
|
||||
content={t('action.save')}
|
||||
onMouseOver={handleControlMouseOver}
|
||||
onMouseOut={handleControlMouseOut}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
EditName.propTypes = {
|
||||
children: PropTypes.element.isRequired,
|
||||
defaultValue: PropTypes.string.isRequired,
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(EditName);
|
26
client/src/components/CardModal/Tasks/EditName.module.css
Normal file
26
client/src/components/CardModal/Tasks/EditName.module.css
Normal file
|
@ -0,0 +1,26 @@
|
|||
.controls {
|
||||
clear: both;
|
||||
margin-top: 6px !important;
|
||||
}
|
||||
|
||||
.field {
|
||||
background: #fff !important;
|
||||
border: 1px solid rgba(9, 45, 66, 0.13) !important;
|
||||
border-radius: 3px !important;
|
||||
box-sizing: border-box !important;
|
||||
color: #17394d !important;
|
||||
display: block !important;
|
||||
font-size: 14px !important;
|
||||
line-height: 1.5 !important;
|
||||
overflow: hidden !important;
|
||||
padding: 8px 12px !important;
|
||||
resize: none !important;
|
||||
}
|
||||
|
||||
.field:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
padding: 9px 32px 16px 40px;
|
||||
}
|
83
client/src/components/CardModal/Tasks/Item.jsx
Executable file
83
client/src/components/CardModal/Tasks/Item.jsx
Executable file
|
@ -0,0 +1,83 @@
|
|||
import React, { useCallback, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Button, Checkbox, Icon } from 'semantic-ui-react';
|
||||
|
||||
import EditName from './EditName';
|
||||
import ActionsPopup from './ActionsPopup';
|
||||
|
||||
import styles from './Item.module.css';
|
||||
|
||||
const Item = React.memo(({
|
||||
name, isCompleted, isPersisted, onUpdate, onDelete,
|
||||
}) => {
|
||||
const editName = useRef(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isPersisted) {
|
||||
editName.current.open();
|
||||
}
|
||||
}, [isPersisted]);
|
||||
|
||||
const handleNameUpdate = useCallback(
|
||||
(newName) => {
|
||||
onUpdate({
|
||||
name: newName,
|
||||
});
|
||||
},
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleToggleChange = useCallback(() => {
|
||||
onUpdate({
|
||||
isCompleted: !isCompleted,
|
||||
});
|
||||
}, [isCompleted, onUpdate]);
|
||||
|
||||
const handleNameEdit = useCallback(() => {
|
||||
editName.current.open();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<span className={styles.checkboxWrapper}>
|
||||
<Checkbox
|
||||
checked={isCompleted}
|
||||
disabled={!isPersisted}
|
||||
className={styles.checkbox}
|
||||
onChange={handleToggleChange}
|
||||
/>
|
||||
</span>
|
||||
<EditName ref={editName} defaultValue={name} onUpdate={handleNameUpdate}>
|
||||
<div className={styles.content}>
|
||||
{/* eslint-disable jsx-a11y/click-events-have-key-events,
|
||||
jsx-a11y/no-static-element-interactions */}
|
||||
<span className={styles.text} onClick={handleClick}>
|
||||
{/* eslint-enable jsx-a11y/click-events-have-key-events,
|
||||
jsx-a11y/no-static-element-interactions */}
|
||||
<span className={classNames(styles.task, isCompleted && styles.taskCompleted)}>
|
||||
{name}
|
||||
</span>
|
||||
</span>
|
||||
{isPersisted && (
|
||||
<ActionsPopup onNameEdit={handleNameEdit} onDelete={onDelete}>
|
||||
<Button className={classNames(styles.button, styles.target)}>
|
||||
<Icon fitted name="pencil" size="small" />
|
||||
</Button>
|
||||
</ActionsPopup>
|
||||
)}
|
||||
</div>
|
||||
</EditName>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Item.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
isCompleted: PropTypes.bool.isRequired,
|
||||
isPersisted: PropTypes.bool.isRequired,
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Item;
|
73
client/src/components/CardModal/Tasks/Item.module.css
Normal file
73
client/src/components/CardModal/Tasks/Item.module.css
Normal file
|
@ -0,0 +1,73 @@
|
|||
.button {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
line-height: 28px !important;
|
||||
margin: 0 !important;
|
||||
min-height: auto !important;
|
||||
opacity: 0;
|
||||
padding: 0 !important;
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 2px;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background-color: rgba(9, 45, 66, 0.13) !important;
|
||||
}
|
||||
|
||||
.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: 32px;
|
||||
}
|
||||
|
||||
.content:hover {
|
||||
background-color: rgba(9, 45, 66, 0.08);
|
||||
}
|
||||
|
||||
.content:hover .target {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.task {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
padding: 8px 0;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.taskCompleted {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.text {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
color: #17394d;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
min-height: 32px;
|
||||
padding: 0 32px 0 40px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
border-radius: 3px;
|
||||
margin-left: -40px;
|
||||
min-height: 32px;
|
||||
position: relative;
|
||||
transition: all 0.14s ease-in;
|
||||
width: calc(100% + 40px);
|
||||
}
|
72
client/src/components/CardModal/Tasks/Tasks.jsx
Executable file
72
client/src/components/CardModal/Tasks/Tasks.jsx
Executable file
|
@ -0,0 +1,72 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Progress } from 'semantic-ui-react';
|
||||
|
||||
import Item from './Item';
|
||||
import Add from './Add';
|
||||
|
||||
import styles from './Tasks.module.css';
|
||||
|
||||
const Tasks = React.memo(({
|
||||
items, onCreate, onUpdate, onDelete,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(id, data) => {
|
||||
onUpdate(id, data);
|
||||
},
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id) => {
|
||||
onDelete(id);
|
||||
},
|
||||
[onDelete],
|
||||
);
|
||||
|
||||
const completedItems = items.filter((item) => item.isCompleted);
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.length > 0 && (
|
||||
<Progress
|
||||
autoSuccess
|
||||
value={completedItems.length}
|
||||
total={items.length}
|
||||
color="blue"
|
||||
size="tiny"
|
||||
className={styles.progress}
|
||||
/>
|
||||
)}
|
||||
{items.map((item) => (
|
||||
<Item
|
||||
key={item.id}
|
||||
name={item.name}
|
||||
isCompleted={item.isCompleted}
|
||||
isPersisted={item.isPersisted}
|
||||
onUpdate={(data) => handleUpdate(item.id, data)}
|
||||
onDelete={() => handleDelete(item.id)}
|
||||
/>
|
||||
))}
|
||||
<Add onCreate={onCreate}>
|
||||
<button type="button" className={styles.taskButton}>
|
||||
<span className={styles.taskButtonText}>
|
||||
{items.length > 0 ? t('action.addAnotherTask') : t('action.addTask')}
|
||||
</span>
|
||||
</button>
|
||||
</Add>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Tasks.propTypes = {
|
||||
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
onCreate: PropTypes.func.isRequired,
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Tasks;
|
30
client/src/components/CardModal/Tasks/Tasks.module.css
Normal file
30
client/src/components/CardModal/Tasks/Tasks.module.css
Normal file
|
@ -0,0 +1,30 @@
|
|||
.progress {
|
||||
margin: 0 0 16px !important;
|
||||
}
|
||||
|
||||
.taskButton {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #6b808c;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
min-height: 54px;
|
||||
outline: none;
|
||||
padding: 8px 12px;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.taskButton:hover {
|
||||
background-color: rgba(9, 45, 66, 0.13);
|
||||
color: #092d42;
|
||||
}
|
||||
|
||||
.taskButtonText {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
}
|
3
client/src/components/CardModal/Tasks/index.js
Executable file
3
client/src/components/CardModal/Tasks/index.js
Executable file
|
@ -0,0 +1,3 @@
|
|||
import Task from './Tasks';
|
||||
|
||||
export default Task;
|
3
client/src/components/CardModal/index.js
Executable file
3
client/src/components/CardModal/index.js
Executable file
|
@ -0,0 +1,3 @@
|
|||
import CardModal from './CardModal';
|
||||
|
||||
export default CardModal;
|
Loading…
Add table
Add a link
Reference in a new issue