1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-18 12:49:43 +02:00

Add file attachments

This commit is contained in:
Maksim Eltyshev 2020-04-21 05:04:34 +05:00
parent 202abacaec
commit 6a68ec9c1e
103 changed files with 1847 additions and 305 deletions

View file

@ -36,7 +36,8 @@ COPY --from=client-builder /app/build/index.html views
ENV BASE_URL DATABASE_URL
VOLUME /app/public/uploads
VOLUME /app/public/user-avatars
VOLUME /app/public/attachments
EXPOSE 1337

View file

@ -82,11 +82,11 @@ Demo user: demo@demo.demo demo
## Roadmap
- [x] File attachments
- [ ] Member permissions
- [ ] Fetch last data after reconnection
- [ ] File attachments
- [ ] Custom fields
- [ ] Public boards
- [ ] Member permissions
- [ ] Automatic actions
## License

View file

@ -0,0 +1,117 @@
import ActionTypes from '../constants/ActionTypes';
/* Actions */
export const createAttachment = (attachment) => ({
type: ActionTypes.ATTACHMENT_CREATE,
payload: {
attachment,
},
});
export const updateAttachment = (id, data) => ({
type: ActionTypes.ATTACHMENT_UPDATE,
payload: {
id,
data,
},
});
export const deleteAttachment = (id) => ({
type: ActionTypes.ATTACHMENT_DELETE,
payload: {
id,
},
});
/* Events */
export const createAttachmentRequested = (localId, data) => ({
type: ActionTypes.ATTACHMENT_CREATE_REQUESTED,
payload: {
localId,
data,
},
});
export const createAttachmentSucceeded = (localId, attachment) => ({
type: ActionTypes.ATTACHMENT_CREATE_SUCCEEDED,
payload: {
localId,
attachment,
},
});
export const createAttachmentFailed = (localId, error) => ({
type: ActionTypes.ATTACHMENT_CREATE_FAILED,
payload: {
localId,
error,
},
});
export const createAttachmentReceived = (attachment) => ({
type: ActionTypes.ATTACHMENT_CREATE_RECEIVED,
payload: {
attachment,
},
});
export const updateAttachmentRequested = (id, data) => ({
type: ActionTypes.ATTACHMENT_UPDATE_REQUESTED,
payload: {
id,
data,
},
});
export const updateAttachmentSucceeded = (attachment) => ({
type: ActionTypes.ATTACHMENT_UPDATE_SUCCEEDED,
payload: {
attachment,
},
});
export const updateAttachmentFailed = (id, error) => ({
type: ActionTypes.ATTACHMENT_UPDATE_FAILED,
payload: {
id,
error,
},
});
export const updateAttachmentReceived = (attachment) => ({
type: ActionTypes.ATTACHMENT_UPDATE_RECEIVED,
payload: {
attachment,
},
});
export const deleteAttachmentRequested = (id) => ({
type: ActionTypes.ATTACHMENT_DELETE_REQUESTED,
payload: {
id,
},
});
export const deleteAttachmentSucceeded = (attachment) => ({
type: ActionTypes.ATTACHMENT_DELETE_SUCCEEDED,
payload: {
attachment,
},
});
export const deleteAttachmentFailed = (id, error) => ({
type: ActionTypes.ATTACHMENT_DELETE_FAILED,
payload: {
id,
error,
},
});
export const deleteAttachmentReceived = (attachment) => ({
type: ActionTypes.ATTACHMENT_DELETE_RECEIVED,
payload: {
attachment,
},
});

View file

@ -76,6 +76,7 @@ export const fetchBoardSucceeded = (
cardMemberships,
cardLabels,
tasks,
attachments,
) => ({
type: ActionTypes.BOARD_FETCH_SUCCEEDED,
payload: {
@ -86,6 +87,7 @@ export const fetchBoardSucceeded = (
cardMemberships,
cardLabels,
tasks,
attachments,
},
});

View file

@ -0,0 +1,23 @@
import EntryActionTypes from '../../constants/EntryActionTypes';
export const createAttachmentInCurrentCard = (data) => ({
type: EntryActionTypes.ATTACHMENT_IN_CURRENT_CARD_CREATE,
payload: {
data,
},
});
export const updateAttachment = (id, data) => ({
type: EntryActionTypes.ATTACHMENT_UPDATE,
payload: {
id,
data,
},
});
export const deleteAttachment = (id) => ({
type: EntryActionTypes.ATTACHMENT_DELETE,
payload: {
id,
},
});

View file

@ -8,6 +8,7 @@ export * from './list';
export * from './label';
export * from './card';
export * from './task';
export * from './attachment';
export * from './actions';
export * from './comment-action';
export * from './notification';

View file

@ -63,10 +63,10 @@ export const clearCurrentUserUsernameUpdateError = () => ({
payload: {},
});
export const uploadCurrentUserAvatar = (file) => ({
type: EntryActionTypes.CURRENT_USER_AVATAR_UPLOAD,
export const updateCurrentUserAvatar = (data) => ({
type: EntryActionTypes.CURRENT_USER_AVATAR_UPDATE,
payload: {
file,
data,
},
});

View file

@ -14,6 +14,7 @@ export * from './card';
export * from './card-membership';
export * from './card-label';
export * from './task';
export * from './attachment';
export * from './actions';
export * from './action';
export * from './comment-action';

View file

@ -233,23 +233,23 @@ export const updateUserUsernameFailed = (id, error) => ({
},
});
export const uploadUserAvatarRequested = (id) => ({
type: ActionTypes.USER_AVATAR_UPLOAD_REQUESTED,
export const updateUserAvatarRequested = (id) => ({
type: ActionTypes.USER_AVATAR_UPDATE_REQUESTED,
payload: {
id,
},
});
export const uploadUserAvatarSucceeded = (id, avatar) => ({
type: ActionTypes.USER_AVATAR_UPLOAD_SUCCEEDED,
export const updateUserAvatarSucceeded = (id, avatarUrl) => ({
type: ActionTypes.USER_AVATAR_UPDATE_SUCCEEDED,
payload: {
id,
avatar,
avatarUrl,
},
});
export const uploadUserAvatarFailed = (id, error) => ({
type: ActionTypes.USER_AVATAR_UPLOAD_FAILED,
export const updateUserAvatarFailed = (id, error) => ({
type: ActionTypes.USER_AVATAR_UPDATE_FAILED,
payload: {
id,
error,

51
client/src/api/attachments.js Executable file
View file

@ -0,0 +1,51 @@
import http from './http';
import socket from './socket';
/* Transformers */
export const transformAttachment = (attachment) => ({
...attachment,
createdAt: new Date(attachment.createdAt),
});
/* Actions */
const createAttachment = (cardId, data, headers) =>
http.post(`/cards/${cardId}/attachments`, data, headers).then((body) => ({
...body,
item: transformAttachment(body.item),
}));
const updateAttachment = (id, data, headers) =>
socket.patch(`/attachments/${id}`, data, headers).then((body) => ({
...body,
item: transformAttachment(body.item),
}));
const deleteAttachment = (id, headers) =>
socket.delete(`/attachments/${id}`, undefined, headers).then((body) => ({
...body,
item: transformAttachment(body.item),
}));
/* Event handlers */
const makeHandleAttachmentCreate = (next) => (body) => {
next({
...body,
item: transformAttachment(body.item),
});
};
const makeHandleAttachmentUpdate = makeHandleAttachmentCreate;
const makeHandleAttachmentDelete = makeHandleAttachmentCreate;
export default {
createAttachment,
updateAttachment,
deleteAttachment,
makeHandleAttachmentCreate,
makeHandleAttachmentUpdate,
makeHandleAttachmentDelete,
};

View file

@ -1,5 +1,6 @@
import socket from './socket';
import { transformCard } from './cards';
import { transformAttachment } from './attachments';
/* Actions */
@ -12,6 +13,7 @@ const getBoard = (id, headers) =>
included: {
...body.included,
cards: body.included.cards.map(transformCard),
attachments: body.included.attachments.map(transformAttachment),
},
}));

View file

@ -11,6 +11,7 @@ import cards from './cards';
import cardMemberships from './card-memberships';
import cardLabels from './card-labels';
import tasks from './tasks';
import attachments from './attachments';
import actions from './actions';
import commentActions from './comment-actions';
import notifications from './notifications';
@ -29,6 +30,7 @@ export default {
...cardMemberships,
...cardLabels,
...tasks,
...attachments,
...actions,
...commentActions,
...notifications,

View file

@ -19,14 +19,8 @@ const updateUserPassword = (id, data, headers) =>
const updateUserUsername = (id, data, headers) =>
socket.patch(`/users/${id}/username`, data, headers);
const uploadUserAvatar = (id, file, headers) =>
http.post(
`/users/${id}/upload-avatar`,
{
file,
},
headers,
);
const updateUserAvatar = (id, data, headers) =>
http.post(`/users/${id}/update-avatar`, data, headers);
const deleteUser = (id, headers) => socket.delete(`/users/${id}`, undefined, headers);
@ -38,6 +32,6 @@ export default {
updateUserEmail,
updateUserPassword,
updateUserUsername,
uploadUserAvatar,
updateUserAvatar,
deleteUser,
};

View file

@ -60,7 +60,7 @@ const Filter = React.memo(
<span key={user.id} className={styles.filterItem}>
<User
name={user.name}
avatar={user.avatar}
avatarUrl={user.avatarUrl}
size="small"
onClick={() => handleUserRemoveClick(user.id)}
/>

View file

@ -103,7 +103,7 @@ const Card = React.memo(
<span className={classNames(styles.attachments, styles.attachmentsRight)}>
{users.map((user) => (
<span key={user.id} className={classNames(styles.attachment, styles.attachmentRight)}>
<User name={user.name} avatar={user.avatar} size="tiny" />
<User name={user.name} avatarUrl={user.avatarUrl} size="tiny" />
</span>
))}
</span>

View file

@ -58,7 +58,7 @@
.card:hover {
background-color: #f5f6f7;
border-bottom-color: rgba(9, 45, 66, 0.25);
border-bottom-color: rgba(9, 30, 66, 0.25);
}
.card:hover .target {

View file

@ -5,7 +5,7 @@
.field {
background: #fff !important;
border: 1px solid rgba(9, 45, 66, 0.13) !important;
border: 1px solid rgba(9, 30, 66, 0.13) !important;
border-radius: 3px !important;
box-sizing: border-box;
color: #333 !important;

View file

@ -62,7 +62,7 @@ const Item = React.memo(({ type, data, createdAt, user }) => {
return (
<Comment>
<span className={styles.user}>
<User name={user.name} avatar={user.avatar} />
<User name={user.name} avatarUrl={user.avatarUrl} />
</span>
<div className={classNames(styles.content)}>
<div>{contentNode}</div>

View file

@ -24,7 +24,7 @@ const ItemComment = React.memo(
return (
<Comment>
<span className={styles.user}>
<User name={user.name} avatar={user.avatar} />
<User name={user.name} avatarUrl={user.avatarUrl} />
</span>
<div className={classNames(styles.content)}>
<div className={styles.title}>

View file

@ -24,8 +24,8 @@
.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-shadow: 0 1px 2px -1px rgba(9, 30, 66, 0.25),
0 0 0 1px rgba(9, 30, 66, 0.08);
box-sizing: border-box;
color: #17394d;
display: inline-block;

View file

@ -0,0 +1,45 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import Item from './Item';
const Attachments = React.memo(({ items, onUpdate, onDelete }) => {
const handleUpdate = useCallback(
(id, data) => {
onUpdate(id, data);
},
[onUpdate],
);
const handleDelete = useCallback(
(id) => {
onDelete(id);
},
[onDelete],
);
return (
<>
{items.map((item) => (
<Item
key={item.id}
name={item.name}
url={item.url}
thumbnailUrl={item.thumbnailUrl}
createdAt={item.createdAt}
isPersisted={item.isPersisted}
onUpdate={(data) => handleUpdate(item.id, data)}
onDelete={() => handleDelete(item.id)}
/>
))}
</>
);
});
Attachments.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
};
export default Attachments;

View file

@ -0,0 +1,107 @@
import dequal from 'dequal';
import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react';
import { withPopup } from '../../../lib/popup';
import { Input, Popup } from '../../../lib/custom-ui';
import { useForm, useSteps } from '../../../hooks';
import DeleteStep from '../../DeleteStep';
import styles from './EditPopup.module.css';
const StepTypes = {
DELETE: 'DELETE',
};
const EditStep = React.memo(({ defaultData, onUpdate, onDelete, onClose }) => {
const [t] = useTranslation();
const [data, handleFieldChange] = useForm(() => ({
name: '',
...defaultData,
}));
const [step, openStep, handleBack] = useSteps();
const nameField = useRef(null);
const handleSubmit = useCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
nameField.current.select();
return;
}
if (!dequal(cleanData, defaultData)) {
onUpdate(cleanData);
}
onClose();
}, [defaultData, onUpdate, onClose, data]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
useEffect(() => {
nameField.current.select();
}, []);
if (step && step.type === StepTypes.DELETE) {
return (
<DeleteStep
title={t('common.deleteAttachment', {
context: 'title',
})}
content={t('common.areYouSureYouWantToDeleteThisAttachment')}
buttonContent={t('action.deleteAttachment')}
onConfirm={onDelete}
onBack={handleBack}
/>
);
}
return (
<>
<Popup.Header>
{t('common.editAttachment', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.title')}</div>
<Input
fluid
ref={nameField}
name="name"
value={data.name}
className={styles.field}
onChange={handleFieldChange}
/>
<Button positive content={t('action.save')} />
</Form>
<Button
content={t('action.delete')}
className={styles.deleteButton}
onClick={handleDeleteClick}
/>
</Popup.Content>
</>
);
});
EditStep.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default withPopup(EditStep);

View file

@ -0,0 +1,17 @@
.deleteButton {
bottom: 12px;
box-shadow: 0 1px 0 #cbcccc;
position: absolute;
right: 9px;
}
.field {
margin-bottom: 8px;
}
.text {
color: #444444;
font-size: 12px;
font-weight: bold;
padding-bottom: 6px;
}

View file

@ -0,0 +1,85 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Button, Icon, Loader } from 'semantic-ui-react';
import EditPopup from './EditPopup';
import styles from './Item.module.css';
const Item = React.memo(
({ name, url, thumbnailUrl, createdAt, isPersisted, onUpdate, onDelete }) => {
const [t] = useTranslation();
const handleClick = useCallback(() => {
window.open(url, '_blank');
}, [url]);
if (!isPersisted) {
return (
<div className={classNames(styles.wrapper, styles.wrapperSubmitting)}>
<Loader inverted />
</div>
);
}
const filename = url.split('/').pop();
const extension = filename.slice((Math.max(0, filename.lastIndexOf('.')) || Infinity) + 1);
return (
/* eslint-disable jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */
<div className={styles.wrapper} onClick={handleClick}>
{/* eslint-enable jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<div
className={styles.thumbnail}
style={{
backgroundImage: thumbnailUrl && `url(${thumbnailUrl}`,
}}
>
{!thumbnailUrl && <span className={styles.extension}>{extension || '-'}</span>}
</div>
<div className={styles.details}>
<span className={styles.name}>{name}</span>
<span className={styles.options}>
{t('format:longDateTime', {
postProcess: 'formatDate',
value: createdAt,
})}
</span>
</div>
<EditPopup
defaultData={{
name,
}}
onUpdate={onUpdate}
onDelete={onDelete}
>
<Button className={classNames(styles.button, styles.target)}>
<Icon fitted name="pencil" size="small" />
</Button>
</EditPopup>
</div>
);
},
);
Item.propTypes = {
name: PropTypes.string.isRequired,
url: PropTypes.string,
thumbnailUrl: PropTypes.string,
createdAt: PropTypes.instanceOf(Date),
isPersisted: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
};
Item.defaultProps = {
url: undefined,
thumbnailUrl: undefined,
createdAt: undefined,
};
export default Item;

View file

@ -0,0 +1,90 @@
.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, 30, 66, 0.08) !important;
}
.details {
box-sizing: border-box;
padding: 8px 32px 8px 128px;
margin: 0;
min-height: 80px;
z-index: 0;
}
.extension {
color: #5e6c84;
display: block;
font-size: 18px;
font-weight: 700;
height: 100%;
line-height: 80px;
text-align: center;
text-decoration: none;
text-transform: uppercase;
width: 100%;
}
.name {
color: #17394d;
font-size: 14px;
font-weight: 700;
line-height: 20px;
word-wrap: break-word;
}
.options {
display: block;
color: #6b808c;
line-height: 20px;
margin-bottom: 8px;
}
.thumbnail {
border-radius: 3px;
background-color: rgba(9, 30, 66, 0.04);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
border-radius: 3px;
height: 80px;
left: 0;
margin-top: -40px;
position: absolute;
text-align: center;
text-decoration: none;
top: 50%;
width: 112px;
z-index: 1;
}
.wrapper {
cursor: pointer;
margin-bottom: 8px;
min-height: 80px;
position: relative;
}
.wrapper:hover .details {
background-color: rgba(9, 30, 66, 0.04);
}
.wrapper:hover .target {
opacity: 1;
}
.wrapperSubmitting {
background-color: rgba(9, 30, 66, 0.04);
}

View file

@ -0,0 +1,3 @@
import Attachments from './Attachments';
export default Attachments;

View file

@ -3,11 +3,12 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Button, Grid, Icon, Modal } from 'semantic-ui-react';
import { Markdown } from '../../lib/custom-ui';
import { FilePicker, Markdown } from '../../lib/custom-ui';
import NameField from './NameField';
import EditDescription from './EditDescription';
import Tasks from './Tasks';
import Attachments from './Attachments';
import Actions from './Actions';
import User from '../User';
import Label from '../Label';
@ -33,6 +34,7 @@ const CardModal = React.memo(
users,
labels,
tasks,
attachments,
actions,
allProjectMemberships,
allLabels,
@ -49,6 +51,9 @@ const CardModal = React.memo(
onTaskCreate,
onTaskUpdate,
onTaskDelete,
onAttachmentCreate,
onAttachmentUpdate,
onAttachmentDelete,
onActionsFetch,
onCommentActionCreate,
onCommentActionUpdate,
@ -93,6 +98,15 @@ const CardModal = React.memo(
[onUpdate],
);
const handleAttachmentFileSelect = useCallback(
(file) => {
onAttachmentCreate({
file,
});
},
[onAttachmentCreate],
);
const handleToggleSubscribeClick = useCallback(() => {
onUpdate({
isSubscribed: !isSubscribed,
@ -134,7 +148,7 @@ const CardModal = React.memo(
onUserSelect={onUserAdd}
onUserDeselect={onUserRemove}
>
<User name={user.name} avatar={user.avatar} />
<User name={user.name} avatarUrl={user.avatarUrl} />
</ProjectMembershipsPopup>
</span>
))}
@ -255,6 +269,19 @@ const CardModal = React.memo(
/>
</div>
</div>
{attachments.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
items={attachments}
onUpdate={onAttachmentUpdate}
onDelete={onAttachmentDelete}
/>
</div>
</div>
)}
<Actions
items={actions}
isFetching={isActionsFetching}
@ -306,6 +333,12 @@ const CardModal = React.memo(
{t('common.timer')}
</Button>
</EditTimerPopup>
<FilePicker onSelect={handleAttachmentFileSelect}>
<Button fluid className={styles.actionButton}>
<Icon name="attach" className={styles.actionIcon} />
{t('common.attachment')}
</Button>
</FilePicker>
</div>
<div className={styles.actions}>
<span className={styles.actionsTitle}>{t('common.actions')}</span>
@ -347,6 +380,7 @@ CardModal.propTypes = {
users: PropTypes.array.isRequired,
labels: PropTypes.array.isRequired,
tasks: PropTypes.array.isRequired,
attachments: PropTypes.array.isRequired,
actions: PropTypes.array.isRequired,
allProjectMemberships: PropTypes.array.isRequired,
allLabels: PropTypes.array.isRequired,
@ -364,6 +398,9 @@ CardModal.propTypes = {
onTaskCreate: PropTypes.func.isRequired,
onTaskUpdate: PropTypes.func.isRequired,
onTaskDelete: PropTypes.func.isRequired,
onAttachmentCreate: PropTypes.func.isRequired,
onAttachmentUpdate: PropTypes.func.isRequired,
onAttachmentDelete: PropTypes.func.isRequired,
onActionsFetch: PropTypes.func.isRequired,
onCommentActionCreate: PropTypes.func.isRequired,
onCommentActionUpdate: PropTypes.func.isRequired,

View file

@ -1,6 +1,6 @@
.actionButton {
background: #ebeef0 !important;
box-shadow: 0 1px 0 0 rgba(9, 45, 66, 0.13) !important;
box-shadow: 0 1px 0 0 rgba(9, 30, 66, 0.13) !important;
color: #444 !important;
margin-top: 8px !important;
overflow: hidden;
@ -12,7 +12,7 @@
.actionButton:hover {
background: #dfe3e6 !important;
box-shadow: 0 1px 0 0 rgba(9, 45, 66, 0.25) !important;
box-shadow: 0 1px 0 0 rgba(9, 30, 66, 0.25) !important;
color: #4c4c4c !important;
}
@ -65,7 +65,7 @@
}
.dueDate {
background: #dce0e4;
background: rgba(9, 30, 66, 0.04);
border: none;
border-radius: 3px;
color: #6b808c;
@ -79,12 +79,12 @@
}
.dueDate:hover {
background: #d2d8dc;
background: rgba(9, 30, 66, 0.08);
color: #17394d;
}
.descriptionButton {
background: rgba(9, 45, 66, 0.08);
background: rgba(9, 30, 66, 0.04);
border: none;
border-radius: 3px;
display: block;
@ -100,7 +100,7 @@
}
.descriptionButton:hover {
background-color: rgba(9, 45, 66, 0.13);
background-color: rgba(9, 30, 66, 0.08);
color: #092d42;
}

View file

@ -5,7 +5,7 @@
.field {
background: #fff !important;
border: 1px solid rgba(9, 45, 66, 0.13) !important;
border: 1px solid rgba(9, 30, 66, 0.13) !important;
border-radius: 3px !important;
color: #17394d !important;
display: block !important;

View file

@ -5,7 +5,7 @@
.field {
background: #fff !important;
border: 1px solid rgba(9, 45, 66, 0.13) !important;
border: 1px solid rgba(9, 30, 66, 0.08) !important;
border-radius: 3px !important;
color: #17394d !important;
display: block !important;

View file

@ -5,7 +5,7 @@
.field {
background: #fff !important;
border: 1px solid rgba(9, 45, 66, 0.13) !important;
border: 1px solid rgba(9, 30, 66, 0.13) !important;
border-radius: 3px !important;
box-sizing: border-box !important;
color: #17394d !important;

View file

@ -13,7 +13,7 @@
}
.button:hover {
background-color: rgba(9, 45, 66, 0.13) !important;
background-color: rgba(9, 30, 66, 0.08) !important;
}
.checkboxWrapper {
@ -30,7 +30,7 @@
}
.content:hover {
background-color: rgba(9, 45, 66, 0.08);
background-color: rgba(9, 30, 66, 0.04);
}
.content:hover .target {

View file

@ -20,7 +20,7 @@
}
.taskButton:hover {
background-color: rgba(9, 45, 66, 0.13);
background-color: rgba(9, 30, 66, 0.04);
color: #092d42;
}

View file

@ -83,7 +83,7 @@ const NotificationsStep = React.memo(({ items, onDelete, onClose }) => {
<>
<User
name={item.action.user.name}
avatar={item.action.user.avatar}
avatarUrl={item.action.user.avatarUrl}
size="large"
/>
<span className={styles.content}>{renderItemContent(item)}</span>

View file

@ -72,7 +72,7 @@
}
.headerButton:hover {
background-color: rgba(9, 45, 66, 0.13) !important;
background-color: rgba(9, 30, 66, 0.13) !important;
color: #516b7a !important;
}

View file

@ -35,7 +35,7 @@ const AddMembershipStep = React.memo(({ users, currentUserIds, onCreate, onClose
<UserItem
key={user.id}
name={user.name}
avatar={user.avatar}
avatarUrl={user.avatarUrl}
isActive={currentUserIds.includes(user.id)}
onSelect={() => handleUserSelect(user.id)}
/>

View file

@ -6,7 +6,7 @@ import User from '../../User';
import styles from './UserItem.module.css';
const UserItem = React.memo(({ name, avatar, isActive, onSelect }) => (
const UserItem = React.memo(({ name, avatarUrl, isActive, onSelect }) => (
<button
type="button"
disabled={isActive}
@ -14,7 +14,7 @@ const UserItem = React.memo(({ name, avatar, isActive, onSelect }) => (
onClick={onSelect}
>
<span className={styles.user}>
<User name={name} avatar={avatar} />
<User name={name} avatarUrl={avatarUrl} />
</span>
<div className={classNames(styles.menuItemText, isActive && styles.menuItemTextActive)}>
{name}
@ -24,13 +24,13 @@ const UserItem = React.memo(({ name, avatar, isActive, onSelect }) => (
UserItem.propTypes = {
name: PropTypes.string.isRequired,
avatar: PropTypes.string,
avatarUrl: PropTypes.string,
isActive: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired,
};
UserItem.defaultProps = {
avatar: undefined,
avatarUrl: undefined,
};
export default UserItem;

View file

@ -39,7 +39,7 @@ const EditMembershipStep = React.memo(({ user, isEditable, onDelete }) => {
return (
<>
<span className={styles.user}>
<User name={user.name} avatar={user.avatar} size="large" />
<User name={user.name} avatarUrl={user.avatarUrl} size="large" />
</span>
<span className={styles.content}>
<div className={styles.name}>{user.name}</div>

View file

@ -56,7 +56,7 @@ const Project = React.memo(
>
<User
name={membership.user.name}
avatar={membership.user.avatar}
avatarUrl={membership.user.avatarUrl}
size="large"
isDisabled={!membership.isPersisted}
/>

View file

@ -24,7 +24,7 @@ const Item = React.memo(({ isPersisted, isActive, user, onUserSelect, onUserDese
onClick={handleToggleClick}
>
<span className={styles.user}>
<User name={user.name} avatar={user.avatar} />
<User name={user.name} avatarUrl={user.avatarUrl} />
</span>
<div className={classNames(styles.menuItemText, isActive && styles.menuItemTextActive)}>
{user.name}

View file

@ -72,10 +72,10 @@ const getColor = (name) => {
return COLORS[sum % COLORS.length];
};
const User = React.memo(({ name, avatar, size, isDisabled, onClick }) => {
const User = React.memo(({ name, avatarUrl, size, isDisabled, onClick }) => {
const style = {
...STYLES[size],
background: avatar ? `url("${avatar}")` : getColor(name),
background: avatarUrl ? `url("${avatarUrl}")` : getColor(name),
};
const contentNode = (
@ -84,7 +84,7 @@ const User = React.memo(({ name, avatar, size, isDisabled, onClick }) => {
className={classNames(styles.wrapper, onClick && styles.hoverable)}
style={style}
>
{!avatar && <span className={styles.initials}>{initials(name)}</span>}
{!avatarUrl && <span className={styles.initials}>{initials(name)}</span>}
</span>
);
@ -99,14 +99,14 @@ const User = React.memo(({ name, avatar, size, isDisabled, onClick }) => {
User.propTypes = {
name: PropTypes.string.isRequired,
avatar: PropTypes.string,
avatarUrl: PropTypes.string,
size: PropTypes.oneOf(Object.values(SIZES)),
isDisabled: PropTypes.bool,
onClick: PropTypes.func,
};
User.defaultProps = {
avatar: undefined,
avatarUrl: undefined,
size: SIZES.MEDIUM,
isDisabled: false,
onClick: undefined,

View file

@ -17,15 +17,15 @@ const AccountPane = React.memo(
email,
name,
username,
avatar,
avatarUrl,
phone,
organization,
isAvatarUploading,
isAvatarUpdating,
usernameUpdateForm,
emailUpdateForm,
passwordUpdateForm,
onUpdate,
onAvatarUpload,
onAvatarUpdate,
onUsernameUpdate,
onUsernameUpdateMessageDismiss,
onEmailUpdate,
@ -37,18 +37,18 @@ const AccountPane = React.memo(
const handleAvatarDelete = useCallback(() => {
onUpdate({
avatar: null,
avatarUrl: null,
});
}, [onUpdate]);
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<EditAvatarPopup
defaultValue={avatar}
onUpload={onAvatarUpload}
defaultValue={avatarUrl}
onUpdate={onAvatarUpdate}
onDelete={handleAvatarDelete}
>
<User name={name} avatar={avatar} size="massive" isDisabled={isAvatarUploading} />
<User name={name} avatarUrl={avatarUrl} size="massive" isDisabled={isAvatarUpdating} />
</EditAvatarPopup>
<br />
<br />
@ -123,17 +123,17 @@ AccountPane.propTypes = {
email: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
username: PropTypes.string,
avatar: PropTypes.string,
avatarUrl: PropTypes.string,
phone: PropTypes.string,
organization: PropTypes.string,
isAvatarUploading: PropTypes.bool.isRequired,
isAvatarUpdating: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */
usernameUpdateForm: PropTypes.object.isRequired,
emailUpdateForm: PropTypes.object.isRequired,
passwordUpdateForm: PropTypes.object.isRequired,
/* eslint-enable react/forbid-prop-types */
onUpdate: PropTypes.func.isRequired,
onAvatarUpload: PropTypes.func.isRequired,
onAvatarUpdate: PropTypes.func.isRequired,
onUsernameUpdate: PropTypes.func.isRequired,
onUsernameUpdateMessageDismiss: PropTypes.func.isRequired,
onEmailUpdate: PropTypes.func.isRequired,
@ -144,7 +144,7 @@ AccountPane.propTypes = {
AccountPane.defaultProps = {
username: undefined,
avatar: undefined,
avatarUrl: undefined,
phone: undefined,
organization: undefined,
};

View file

@ -3,23 +3,24 @@ import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button } from 'semantic-ui-react';
import { withPopup } from '../../../lib/popup';
import { Popup } from '../../../lib/custom-ui';
import { FilePicker, Popup } from '../../../lib/custom-ui';
import styles from './EditAvatarPopup.module.css';
const EditAvatarStep = React.memo(({ defaultValue, onUpload, onDelete, onClose }) => {
const EditAvatarStep = React.memo(({ defaultValue, onUpdate, onDelete, onClose }) => {
const [t] = useTranslation();
const field = useRef(null);
const handleFieldChange = useCallback(
({ target }) => {
if (target.files[0]) {
onUpload(target.files[0]);
onClose();
}
const handleFileSelect = useCallback(
(file) => {
onUpdate({
file,
});
onClose();
},
[onUpload, onClose],
[onUpdate, onClose],
);
const handleDeleteClick = useCallback(() => {
@ -39,15 +40,14 @@ const EditAvatarStep = React.memo(({ defaultValue, onUpload, onDelete, onClose }
})}
</Popup.Header>
<Popup.Content>
<div className={styles.input}>
<Button content={t('action.uploadNewAvatar')} className={styles.customButton} />
<input
ref={field}
type="file"
accept="image/*"
className={styles.file}
onChange={handleFieldChange}
/>
<div className={styles.action}>
<FilePicker accept="image/*" onSelect={handleFileSelect}>
<Button
ref={field}
content={t('action.uploadNewAvatar')}
className={styles.actionButton}
/>
</FilePicker>
</div>
{defaultValue && (
<Button negative content={t('action.deleteAvatar')} onClick={handleDeleteClick} />
@ -59,7 +59,7 @@ const EditAvatarStep = React.memo(({ defaultValue, onUpload, onDelete, onClose }
EditAvatarStep.propTypes = {
defaultValue: PropTypes.string,
onUpload: PropTypes.func.isRequired,
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};

View file

@ -1,25 +1,4 @@
.customButton {
background: transparent !important;
color: #6b808c !important;
font-weight: normal !important;
height: 36px;
line-height: 24px !important;
padding: 6px 12px !important;
text-align: left !important;
text-decoration: underline !important;
}
.file {
bottom: 0;
left: 0;
opacity: 0;
position: absolute;
right: 0;
top: 0;
z-index: 1;
}
.input {
.action {
border: none;
display: inline-block;
height: 36px;
@ -29,6 +8,18 @@
width: 100%;
}
.input:hover {
.action:hover {
background: #e9e9e9 !important;
}
.actionButton {
background: transparent !important;
color: #6b808c !important;
font-weight: normal !important;
height: 36px;
line-height: 24px !important;
padding: 6px 12px !important;
text-align: left !important;
text-decoration: underline !important;
width: 100%;
}

View file

@ -11,16 +11,16 @@ const UserSettingsModal = React.memo(
email,
name,
username,
avatar,
avatarUrl,
phone,
organization,
subscribeToOwnCards,
isAvatarUploading,
isAvatarUpdating,
usernameUpdateForm,
emailUpdateForm,
passwordUpdateForm,
onUpdate,
onAvatarUpload,
onAvatarUpdate,
onUsernameUpdate,
onUsernameUpdateMessageDismiss,
onEmailUpdate,
@ -41,15 +41,15 @@ const UserSettingsModal = React.memo(
email={email}
name={name}
username={username}
avatar={avatar}
avatarUrl={avatarUrl}
phone={phone}
organization={organization}
isAvatarUploading={isAvatarUploading}
isAvatarUpdating={isAvatarUpdating}
usernameUpdateForm={usernameUpdateForm}
emailUpdateForm={emailUpdateForm}
passwordUpdateForm={passwordUpdateForm}
onUpdate={onUpdate}
onAvatarUpload={onAvatarUpload}
onAvatarUpdate={onAvatarUpdate}
onUsernameUpdate={onUsernameUpdate}
onUsernameUpdateMessageDismiss={onUsernameUpdateMessageDismiss}
onEmailUpdate={onEmailUpdate}
@ -89,18 +89,18 @@ UserSettingsModal.propTypes = {
email: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
username: PropTypes.string,
avatar: PropTypes.string,
avatarUrl: PropTypes.string,
phone: PropTypes.string,
organization: PropTypes.string,
subscribeToOwnCards: PropTypes.bool.isRequired,
isAvatarUploading: PropTypes.bool.isRequired,
isAvatarUpdating: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */
usernameUpdateForm: PropTypes.object.isRequired,
emailUpdateForm: PropTypes.object.isRequired,
passwordUpdateForm: PropTypes.object.isRequired,
/* eslint-enable react/forbid-prop-types */
onUpdate: PropTypes.func.isRequired,
onAvatarUpload: PropTypes.func.isRequired,
onAvatarUpdate: PropTypes.func.isRequired,
onUsernameUpdate: PropTypes.func.isRequired,
onUsernameUpdateMessageDismiss: PropTypes.func.isRequired,
onEmailUpdate: PropTypes.func.isRequired,
@ -112,7 +112,7 @@ UserSettingsModal.propTypes = {
UserSettingsModal.defaultProps = {
username: undefined,
avatar: undefined,
avatarUrl: undefined,
phone: undefined,
organization: undefined,
};

View file

@ -60,9 +60,9 @@ export default {
USER_USERNAME_UPDATE_REQUESTED: 'USER_USERNAME_UPDATE_REQUESTED',
USER_USERNAME_UPDATE_SUCCEEDED: 'USER_USERNAME_UPDATE_SUCCEEDED',
USER_USERNAME_UPDATE_FAILED: 'USER_USERNAME_UPDATE_FAILED',
USER_AVATAR_UPLOAD_REQUESTED: 'USER_AVATAR_UPLOAD_REQUESTED',
USER_AVATAR_UPLOAD_SUCCEEDED: 'USER_AVATAR_UPLOAD_SUCCEEDED',
USER_AVATAR_UPLOAD_FAILED: 'USER_AVATAR_UPLOAD_FAILED',
USER_AVATAR_UPDATE_REQUESTED: 'USER_AVATAR_UPDATE_REQUESTED',
USER_AVATAR_UPDATE_SUCCEEDED: 'USER_AVATAR_UPDATE_SUCCEEDED',
USER_AVATAR_UPDATE_FAILED: 'USER_AVATAR_UPDATE_FAILED',
USER_DELETE_REQUESTED: 'USER_DELETE_REQUESTED',
USER_DELETE_SUCCEEDED: 'USER_DELETE_SUCCEEDED',
USER_DELETE_FAILED: 'USER_DELETE_FAILED',
@ -227,6 +227,24 @@ export default {
TASK_DELETE_FAILED: 'TASK_DELETE_FAILED',
TASK_DELETE_RECEIVED: 'TASK_DELETE_RECEIVED',
/* Task */
ATTACHMENT_CREATE: 'ATTACHMENT_CREATE',
ATTACHMENT_UPDATE: 'ATTACHMENT_UPDATE',
ATTACHMENT_DELETE: 'ATTACHMENT_DELETE',
ATTACHMENT_CREATE_REQUESTED: 'ATTACHMENT_CREATE_REQUESTED',
ATTACHMENT_CREATE_SUCCEEDED: 'ATTACHMENT_CREATE_SUCCEEDED',
ATTACHMENT_CREATE_FAILED: 'ATTACHMENT_CREATE_FAILED',
ATTACHMENT_CREATE_RECEIVED: 'ATTACHMENT_CREATE_RECEIVED',
ATTACHMENT_UPDATE_REQUESTED: 'ATTACHMENT_UPDATE_REQUESTED',
ATTACHMENT_UPDATE_SUCCEEDED: 'ATTACHMENT_UPDATE_SUCCEEDED',
ATTACHMENT_UPDATE_FAILED: 'ATTACHMENT_UPDATE_FAILED',
ATTACHMENT_UPDATE_RECEIVED: 'ATTACHMENT_UPDATE_RECEIVED',
ATTACHMENT_DELETE_REQUESTED: 'ATTACHMENT_DELETE_REQUESTED',
ATTACHMENT_DELETE_SUCCEEDED: 'ATTACHMENT_DELETE_SUCCEEDED',
ATTACHMENT_DELETE_FAILED: 'ATTACHMENT_DELETE_FAILED',
ATTACHMENT_DELETE_RECEIVED: 'ATTACHMENT_DELETE_RECEIVED',
/* Actions */
ACTIONS_FETCH_REQUESTED: 'ACTIONS_FETCH_REQUESTED',

View file

@ -26,7 +26,7 @@ export default {
CURRENT_USER_PASSWORD_UPDATE_ERROR_CLEAR: `${PREFIX}/CURRENT_USER_PASSWORD_UPDATE_ERROR_CLEAR`,
CURRENT_USER_USERNAME_UPDATE: `${PREFIX}/CURRENT_USER_USERNAME_UPDATE`,
CURRENT_USER_USERNAME_UPDATE_ERROR_CLEAR: `${PREFIX}/CURRENT_USER_USERNAME_UPDATE_ERROR_CLEAR`,
CURRENT_USER_AVATAR_UPLOAD: `${PREFIX}/CURRENT_USER_AVATAR_UPLOAD`,
CURRENT_USER_AVATAR_UPDATE: `${PREFIX}/CURRENT_USER_AVATAR_UPDATE`,
USER_DELETE: `${PREFIX}/USER_DELETE`,
USER_TO_CARD_ADD: `${PREFIX}/USER_TO_CARD_ADD`,
USER_TO_CURRENT_CARD_ADD: `${PREFIX}/USER_TO_CURRENT_CARD_ADD`,
@ -87,6 +87,12 @@ export default {
TASK_UPDATE: `${PREFIX}/TASK_UPDATE`,
TASK_DELETE: `${PREFIX}/TASK_DELETE`,
/* Attachment */
ATTACHMENT_IN_CURRENT_CARD_CREATE: `${PREFIX}/ATTACHMENT_IN_CURRENT_CARD_CREATE`,
ATTACHMENT_UPDATE: `${PREFIX}/ATTACHMENT_UPDATE`,
ATTACHMENT_DELETE: `${PREFIX}/ATTACHMENT_DELETE`,
/* Actions */
ACTIONS_IN_CURRENT_CARD_FETCH: `${PREFIX}/ACTIONS_IN_CURRENT_CARD_FETCH`,

View file

@ -5,6 +5,7 @@ import omit from 'lodash/omit';
import {
actionsForCurrentCardSelector,
attachmentsForCurrentCardSelector,
currentCardSelector,
currentUserSelector,
labelsForCurrentBoardSelector,
@ -16,9 +17,11 @@ import {
import {
addLabelToCurrentCard,
addUserToCurrentCard,
createAttachmentInCurrentCard,
createCommentActionInCurrentCard,
createLabelInCurrentBoard,
createTaskInCurrentCard,
deleteAttachment,
deleteCommentAction,
deleteCurrentCard,
deleteLabel,
@ -26,6 +29,7 @@ import {
fetchActionsInCurrentCard,
removeLabelFromCurrentCard,
removeUserFromCurrentCard,
updateAttachment,
updateCommentAction,
updateCurrentCard,
updateLabel,
@ -53,6 +57,7 @@ const mapStateToProps = (state) => {
const users = usersForCurrentCardSelector(state);
const labels = labelsForCurrentCardSelector(state);
const tasks = tasksForCurrentCardSelector(state);
const attachments = attachmentsForCurrentCardSelector(state);
const actions = actionsForCurrentCardSelector(state);
return {
@ -66,6 +71,7 @@ const mapStateToProps = (state) => {
users,
labels,
tasks,
attachments,
actions,
allProjectMemberships,
allLabels,
@ -89,6 +95,9 @@ const mapDispatchToProps = (dispatch) =>
onTaskCreate: createTaskInCurrentCard,
onTaskUpdate: updateTask,
onTaskDelete: deleteTask,
onAttachmentCreate: createAttachmentInCurrentCard,
onAttachmentUpdate: updateAttachment,
onAttachmentDelete: deleteAttachment,
onActionsFetch: fetchActionsInCurrentCard,
onCommentActionCreate: createCommentActionInCurrentCard,
onCommentActionUpdate: updateCommentAction,

View file

@ -8,10 +8,10 @@ import {
clearCurrentUserUsernameUpdateError,
closeModal,
updateCurrentUser,
updateCurrentUserAvatar,
updateCurrentUserEmail,
updateCurrentUserPassword,
updateCurrentUserUsername,
uploadCurrentUserAvatar,
} from '../actions/entry';
import UserSettingsModal from '../components/UserSettingsModal';
@ -20,11 +20,11 @@ const mapStateToProps = (state) => {
email,
name,
username,
avatar,
avatarUrl,
phone,
organization,
subscribeToOwnCards,
isAvatarUploading,
isAvatarUpdating,
emailUpdateForm,
passwordUpdateForm,
usernameUpdateForm,
@ -34,11 +34,11 @@ const mapStateToProps = (state) => {
email,
name,
username,
avatar,
avatarUrl,
phone,
organization,
subscribeToOwnCards,
isAvatarUploading,
isAvatarUpdating,
emailUpdateForm,
passwordUpdateForm,
usernameUpdateForm,
@ -49,7 +49,7 @@ const mapDispatchToProps = (dispatch) =>
bindActionCreators(
{
onUpdate: updateCurrentUser,
onAvatarUpload: uploadCurrentUserAvatar,
onAvatarUpdate: updateCurrentUserAvatar,
onUsernameUpdate: updateCurrentUserUsername,
onUsernameUpdateMessageDismiss: clearCurrentUserUsernameUpdateError,
onEmailUpdate: updateCurrentUserEmail,

View file

@ -0,0 +1,52 @@
import React, { useCallback, useRef } from 'react';
import PropTypes from 'prop-types';
import styles from './FilePicker.module.css';
const FilePicker = React.memo(({ children, accept, onSelect }) => {
const field = useRef(null);
const handleTriggerClick = useCallback(() => {
field.current.click();
}, []);
const handleFieldChange = useCallback(
({ target }) => {
if (target.files[0]) {
onSelect(target.files[0]);
target.value = null; // eslint-disable-line no-param-reassign
}
},
[onSelect],
);
const tigger = React.cloneElement(children, {
onClick: handleTriggerClick,
});
return (
<>
{tigger}
<input
ref={field}
type="file"
accept={accept}
className={styles.field}
onChange={handleFieldChange}
/>
</>
);
});
FilePicker.propTypes = {
children: PropTypes.element.isRequired,
accept: PropTypes.string,
onSelect: PropTypes.func.isRequired,
};
FilePicker.defaultProps = {
accept: undefined,
};
export default FilePicker;

View file

@ -0,0 +1,3 @@
.field {
display: none;
}

View file

@ -0,0 +1,3 @@
import FilePicker from './FilePicker';
export default FilePicker;

View file

@ -1,6 +1,7 @@
import Input from './components/Input';
import Popup from './components/Popup';
import Markdown from './components/Markdown';
import FilePicker from './components/FilePicker';
import DragScroller from './components/DragScroller';
export { Input, Popup, Markdown, DragScroller };
export { Input, Popup, Markdown, FilePicker, DragScroller };

View file

@ -18,6 +18,7 @@ export default {
all: 'All',
allChangesWillBeAutomaticallySavedAfterConnectionRestored:
'All changes will be automatically saved<br />after connection restored',
areYouSureYouWantToDeleteThisAttachment: 'Are you sure you want to delete this attachment?',
areYouSureYouWantToDeleteThisBoard: 'Are you sure you want to delete this board?',
areYouSureYouWantToDeleteThisCard: 'Are you sure you want to delete this card?',
areYouSureYouWantToDeleteThisComment: 'Are you sure you want to delete this comment?',
@ -28,6 +29,8 @@ export default {
areYouSureYouWantToDeleteThisUser: 'Are you sure you want to delete this user?',
areYouSureYouWantToRemoveThisMemberFromProject:
'Are you sure you want to remove this member from project?',
attachment: 'Attachment',
attachments: 'Attachments',
authentication: 'Authentication',
boardNotFound_title: 'Board Not Found',
cardActions_title: 'Card Actions',
@ -42,6 +45,7 @@ export default {
currentPassword: 'Current password',
date: 'Date',
dueDate: 'Due date',
deleteAttachment_title: 'Delete Attachment',
deleteBoard_title: 'Delete Board',
deleteCard_title: 'Delete Card',
deleteComment_title: 'Delete Comment',
@ -51,6 +55,7 @@ export default {
deleteTask_title: 'Delete Task',
deleteUser_title: 'Delete User',
description: 'Description',
editAttachment_title: 'Edit Attachment',
editAvatar_title: 'Edit Avatar',
editBoard_title: 'Edit Board',
editDueDate_title: 'Edit Due Date',
@ -128,6 +133,7 @@ export default {
createNewLabel: 'Create new label',
createProject: 'Create project',
delete: 'Delete',
deleteAttachment: 'Delete attachment',
deleteAvatar: 'Delete avatar',
deleteBoard: 'Delete board',
deleteCard: 'Delete card',

View file

@ -22,6 +22,7 @@ export default {
all: 'Все',
allChangesWillBeAutomaticallySavedAfterConnectionRestored:
'Все изменения сохранятся автоматически,<br />как только подключение восстановится',
areYouSureYouWantToDeleteThisAttachment: 'Вы уверены, что хотите удалить это вложение?',
areYouSureYouWantToDeleteThisBoard: 'Вы уверены, что хотите удалить эту доску?',
areYouSureYouWantToDeleteThisCard: 'Вы уверены, что хотите удалить эту карточку?',
areYouSureYouWantToDeleteThisComment: 'Вы уверены, что хотите удалить этот комментарий?',
@ -32,6 +33,8 @@ export default {
areYouSureYouWantToDeleteThisUser: 'Вы уверены, что хотите удалить этого пользователя?',
areYouSureYouWantToRemoveThisMemberFromProject:
'Вы уверены, что хотите удалить этого участника из проекта?',
attachment: 'Вложение',
attachments: 'Вложения',
authentication: 'Аутентификация',
boardNotFound: 'Доска не найдена',
cardActions: 'Действия с карточкой',
@ -46,6 +49,7 @@ export default {
currentPassword: 'Текущий пароль',
date: 'Дата',
dueDate: 'Срок',
deleteAttachment: 'Удаление вложения',
deleteBoard: 'Удаление доски',
deleteCard: 'Удаление карточки',
deleteComment: 'Удаление комментария',
@ -55,6 +59,7 @@ export default {
deleteTask: 'Удаление задачи',
deleteUser: 'Удаление пользователя',
description: 'Описание',
editAttachment: 'Изменение вложения',
editAvatar: 'Изменение аватара',
editBoard: 'Изменение доски',
editDueDate: 'Изменение срока',
@ -132,6 +137,7 @@ export default {
createNewLabel: 'Создать новую метку',
createProject: 'Создать проект',
delete: 'Удалить',
deleteAttachment: 'Удалить вложение',
deleteAvatar: 'Удалить аватар',
deleteBoard: 'Удалить доску',
deleteCard: 'Удалить карточку',

View file

@ -0,0 +1,57 @@
import { Model, attr, fk } from 'redux-orm';
import ActionTypes from '../constants/ActionTypes';
export default class extends Model {
static modelName = 'Attachment';
static fields = {
id: attr(),
url: attr(),
thumbnailUrl: attr(),
name: attr(),
cardId: fk({
to: 'Card',
as: 'card',
relatedName: 'attachments',
}),
};
static reducer({ type, payload }, Attachment) {
switch (type) {
case ActionTypes.BOARD_FETCH_SUCCEEDED:
payload.attachments.forEach((attachment) => {
Attachment.upsert(attachment);
});
break;
case ActionTypes.ATTACHMENT_CREATE:
case ActionTypes.ATTACHMENT_CREATE_RECEIVED:
Attachment.upsert(payload.attachment);
break;
case ActionTypes.ATTACHMENT_UPDATE:
Attachment.withId(payload.id).update(payload.data);
break;
case ActionTypes.ATTACHMENT_DELETE:
Attachment.withId(payload.id).delete();
break;
case ActionTypes.ATTACHMENT_CREATE_SUCCEEDED:
Attachment.withId(payload.localId).delete();
Attachment.upsert(payload.attachment);
break;
case ActionTypes.ATTACHMENT_UPDATE_RECEIVED:
Attachment.withId(payload.attachment.id).update(payload.attachment);
break;
case ActionTypes.ATTACHMENT_DELETE_RECEIVED:
Attachment.withId(payload.attachment.id).delete();
break;
default:
}
}
}

View file

@ -145,6 +145,10 @@ export default class extends Model {
return this.tasks.orderBy('id');
}
getOrderedAttachmentsQuerySet() {
return this.attachments.orderBy('id', false);
}
getOrderedInCardActionsQuerySet() {
return this.actions.orderBy('id', false);
}

View file

@ -36,7 +36,7 @@ export default class extends Model {
id: attr(),
email: attr(),
name: attr(),
avatar: attr(),
avatarUrl: attr(),
phone: attr(),
organization: attr(),
subscribeToOwnCards: attr(),
@ -44,7 +44,7 @@ export default class extends Model {
isAdmin: attr({
getDefault: () => false,
}),
isAvatarUploading: attr({
isAvatarUpdating: attr({
getDefault: () => false,
}),
emailUpdateForm: attr({
@ -228,22 +228,22 @@ export default class extends Model {
break;
}
case ActionTypes.USER_AVATAR_UPLOAD_REQUESTED:
case ActionTypes.USER_AVATAR_UPDATE_REQUESTED:
User.withId(payload.id).update({
isAvatarUploading: true,
isAvatarUpdating: true,
});
break;
case ActionTypes.USER_AVATAR_UPLOAD_SUCCEEDED:
case ActionTypes.USER_AVATAR_UPDATE_SUCCEEDED:
User.withId(payload.id).update({
avatar: payload.avatar,
isAvatarUploading: false,
avatarUrl: payload.avatarUrl,
isAvatarUpdating: false,
});
break;
case ActionTypes.USER_AVATAR_UPLOAD_FAILED:
case ActionTypes.USER_AVATAR_UPDATE_FAILED:
User.withId(payload.id).update({
isAvatarUploading: false,
isAvatarUpdating: false,
});
break;

View file

@ -6,7 +6,20 @@ import List from './List';
import Label from './Label';
import Card from './Card';
import Task from './Task';
import Attachment from './Attachment';
import Action from './Action';
import Notification from './Notification';
export { User, Project, ProjectMembership, Board, List, Label, Card, Task, Action, Notification };
export {
User,
Project,
ProjectMembership,
Board,
List,
Label,
Card,
Task,
Attachment,
Action,
Notification,
};

View file

@ -2,6 +2,7 @@ import { ORM } from 'redux-orm';
import {
Action,
Attachment,
Board,
Card,
Label,
@ -26,6 +27,7 @@ orm.register(
Label,
Card,
Task,
Attachment,
Action,
Notification,
);

View file

@ -0,0 +1,92 @@
import { call, put } from 'redux-saga/effects';
import request from './request';
import {
createAttachmentFailed,
createAttachmentRequested,
createAttachmentSucceeded,
deleteAttachmentFailed,
deleteAttachmentRequested,
deleteAttachmentSucceeded,
updateAttachmentFailed,
updateAttachmentRequested,
updateAttachmentSucceeded,
} from '../../../actions';
import api from '../../../api';
export function* createAttachmentRequest(cardId, localId, data) {
yield put(
createAttachmentRequested(localId, {
...data,
cardId,
}),
);
try {
const { item } = yield call(request, api.createAttachment, cardId, data);
const action = createAttachmentSucceeded(localId, item);
yield put(action);
return {
success: true,
payload: action.payload,
};
} catch (error) {
const action = createAttachmentFailed(localId, error);
yield put(action);
return {
success: false,
payload: action.payload,
};
}
}
export function* updateAttachmentRequest(id, data) {
yield put(updateAttachmentRequested(id, data));
try {
const { item } = yield call(request, api.updateAttachment, id, data);
const action = updateAttachmentSucceeded(item);
yield put(action);
return {
success: true,
payload: action.payload,
};
} catch (error) {
const action = updateAttachmentFailed(error);
yield put(action);
return {
success: false,
payload: action.payload,
};
}
}
export function* deleteAttachmentRequest(id) {
yield put(deleteAttachmentRequested(id));
try {
const { item } = yield call(request, api.deleteAttachment, id);
const action = deleteAttachmentSucceeded(item);
yield put(action);
return {
success: true,
payload: action.payload,
};
} catch (error) {
const action = deleteAttachmentFailed(error);
yield put(action);
return {
success: false,
payload: action.payload,
};
}
}

View file

@ -55,7 +55,7 @@ export function* fetchBoardRequest(id) {
try {
const {
item,
included: { lists, labels, cards, cardMemberships, cardLabels, tasks },
included: { lists, labels, cards, cardMemberships, cardLabels, tasks, attachments },
} = yield call(request, api.getBoard, id);
const action = fetchBoardSucceeded(
@ -66,6 +66,7 @@ export function* fetchBoardRequest(id) {
cardMemberships,
cardLabels,
tasks,
attachments,
);
yield put(action);

View file

@ -10,6 +10,7 @@ export * from './card';
export * from './card-membership';
export * from './card-label';
export * from './task';
export * from './attachment';
export * from './actions';
export * from './comment-action';
export * from './notifications';

View file

@ -11,6 +11,9 @@ import {
fetchCurrentUserFailed,
fetchCurrentUserRequested,
fetchCurrentUserSucceeded,
updateUserAvatarFailed,
updateUserAvatarRequested,
updateUserAvatarSucceeded,
updateUserEmailFailed,
updateUserEmailRequested,
updateUserEmailSucceeded,
@ -23,9 +26,6 @@ import {
updateUserUsernameFailed,
updateUserUsernameRequested,
updateUserUsernameSucceeded,
uploadUserAvatarFailed,
uploadUserAvatarRequested,
uploadUserAvatarSucceeded,
} from '../../../actions';
import api from '../../../api';
@ -173,13 +173,13 @@ export function* updateUserUsernameRequest(id, data) {
}
}
export function* uploadUserAvatarRequest(id, file) {
yield put(uploadUserAvatarRequested(id));
export function* updateUserAvatarRequest(id, data) {
yield put(updateUserAvatarRequested(id));
try {
const { item } = yield call(request, api.uploadUserAvatar, id, file);
const { item } = yield call(request, api.updateUserAvatar, id, data);
const action = uploadUserAvatarSucceeded(id, item);
const action = updateUserAvatarSucceeded(id, item);
yield put(action);
return {
@ -187,7 +187,7 @@ export function* uploadUserAvatarRequest(id, file) {
payload: action.payload,
};
} catch (error) {
const action = uploadUserAvatarFailed(id, error);
const action = updateUserAvatarFailed(id, error);
yield put(action);
return {

View file

@ -0,0 +1,40 @@
import { call, put, select } from 'redux-saga/effects';
import {
createAttachmentRequest,
deleteAttachmentRequest,
updateAttachmentRequest,
} from '../requests';
import { pathSelector } from '../../../selectors';
import { createAttachment, deleteAttachment, updateAttachment } from '../../../actions';
import { createLocalId } from '../../../utils/local-id';
export function* createAttachmentService(cardId, data) {
const localId = yield call(createLocalId);
yield put(
createAttachment({
cardId,
id: localId,
name: data.file.name,
}),
);
yield call(createAttachmentRequest, cardId, localId, data);
}
export function* createAttachmentInCurrentCardService(data) {
const { cardId } = yield select(pathSelector);
yield call(createAttachmentService, cardId, data);
}
export function* updateAttachmentService(id, data) {
yield put(updateAttachment(id, data));
yield call(updateAttachmentRequest, id, data);
}
export function* deleteAttachmentService(id) {
yield put(deleteAttachment(id));
yield call(deleteAttachmentRequest, id);
}

View file

@ -11,6 +11,7 @@ export * from './list';
export * from './label';
export * from './card';
export * from './task';
export * from './attachment';
export * from './actions';
export * from './comment-action';
export * from './notifications';

View file

@ -12,6 +12,7 @@ import {
} from '../../../selectors';
import {
createActionReceived,
createAttachmentReceived,
createBoardReceived,
createCardLabelReceived,
createCardMembershipReceived,
@ -24,6 +25,7 @@ import {
createTaskReceived,
createUserReceived,
deleteActionReceived,
deleteAttachmentReceived,
deleteCardLabelReceived,
deleteCardMembershipReceived,
deleteCardReceived,
@ -38,6 +40,7 @@ import {
socketDisconnected,
socketReconnected,
updateActionReceived,
updateAttachmentReceived,
updateBoardReceived,
updateCardReceived,
updateLabelReceived,
@ -205,6 +208,18 @@ export function* deleteTaskReceivedService(task) {
yield put(deleteTaskReceived(task));
}
export function* createAttachmentReceivedService(attachment) {
yield put(createAttachmentReceived(attachment));
}
export function* updateAttachmentReceivedService(attachment) {
yield put(updateAttachmentReceived(attachment));
}
export function* deleteAttachmentReceivedService(attachment) {
yield put(deleteAttachmentReceived(attachment));
}
export function* createActionReceivedService(action) {
yield put(createActionReceived(action));
}

View file

@ -5,11 +5,11 @@ import {
createUserRequest,
deleteCardMembershipRequest,
deleteUserRequest,
updateUserAvatarRequest,
updateUserEmailRequest,
updateUserPasswordRequest,
updateUserRequest,
updateUserUsernameRequest,
uploadUserAvatarRequest,
} from '../requests';
import { currentUserIdSelector, pathSelector } from '../../../selectors';
import {
@ -106,14 +106,14 @@ export function* clearCurrentUserUsernameUpdateErrorService() {
yield call(clearUserUsernameUpdateErrorService, id);
}
export function* uploadUserAvatarService(id, file) {
yield call(uploadUserAvatarRequest, id, file);
export function* updateUserAvatarService(id, data) {
yield call(updateUserAvatarRequest, id, data);
}
export function* uploadCurrentUserAvatarService(file) {
export function* updateCurrentUserAvatarService(data) {
const id = yield select(currentUserIdSelector);
yield call(uploadUserAvatarService, id, file);
yield call(updateUserAvatarService, id, data);
}
export function* deleteUserService(id) {

View file

@ -0,0 +1,22 @@
import { all, takeLatest } from 'redux-saga/effects';
import {
createAttachmentInCurrentCardService,
deleteAttachmentService,
updateAttachmentService,
} from '../services';
import EntryActionTypes from '../../../constants/EntryActionTypes';
export default function* () {
yield all([
takeLatest(EntryActionTypes.ATTACHMENT_IN_CURRENT_CARD_CREATE, ({ payload: { data } }) =>
createAttachmentInCurrentCardService(data),
),
takeLatest(EntryActionTypes.ATTACHMENT_UPDATE, ({ payload: { id, data } }) =>
updateAttachmentService(id, data),
),
takeLatest(EntryActionTypes.ATTACHMENT_DELETE, ({ payload: { id } }) =>
deleteAttachmentService(id),
),
]);
}

View file

@ -10,6 +10,7 @@ import list from './list';
import label from './label';
import card from './card';
import task from './task';
import attachment from './attachment';
import actions from './actions';
import commentAction from './comment-action';
import notification from './notification';
@ -27,6 +28,7 @@ export default [
label,
card,
task,
attachment,
actions,
commentAction,
notification,

View file

@ -3,6 +3,7 @@ import { call, cancelled, take } from 'redux-saga/effects';
import {
createActionReceivedService,
createAttachmentReceivedService,
createBoardReceivedService,
createCardLabelReceivedService,
createCardMembershipReceivedService,
@ -15,6 +16,7 @@ import {
createTaskReceivedService,
createUserReceivedService,
deleteActionReceivedService,
deleteAttachmentReceivedService,
deleteCardLabelReceivedService,
deleteCardMembershipReceivedService,
deleteCardReceivedService,
@ -29,6 +31,7 @@ import {
socketDisconnectedService,
socketReconnectedService,
updateActionReceivedService,
updateAttachmentReceivedService,
updateBoardReceivedService,
updateCardReceivedService,
updateLabelReceivedService,
@ -153,6 +156,18 @@ const createSocketEventsChannel = () =>
emit([deleteTaskReceivedService, item]);
};
const handleAttachmentCreate = api.makeHandleAttachmentCreate(({ item }) => {
emit([createAttachmentReceivedService, item]);
});
const handleAttachmentUpdate = api.makeHandleAttachmentUpdate(({ item }) => {
emit([updateAttachmentReceivedService, item]);
});
const handleAttachmentDelete = api.makeHandleAttachmentDelete(({ item }) => {
emit([deleteAttachmentReceivedService, item]);
});
const handleActionCreate = api.makeHandleActionCreate(({ item }) => {
emit([createActionReceivedService, item]);
});
@ -222,6 +237,10 @@ const createSocketEventsChannel = () =>
socket.on('taskUpdate', handleTaskUpdate);
socket.on('taskDelete', handleTaskDelete);
socket.on('attachmentCreate', handleAttachmentCreate);
socket.on('attachmentUpdate', handleAttachmentUpdate);
socket.on('attachmentDelete', handleAttachmentDelete);
socket.on('actionCreate', handleActionCreate);
socket.on('actionUpdate', handleActionUpdate);
socket.on('actionDelete', handleActionDelete);
@ -270,6 +289,10 @@ const createSocketEventsChannel = () =>
socket.off('taskUpdate', handleTaskUpdate);
socket.off('taskDelete', handleTaskDelete);
socket.off('attachmentCreate', handleAttachmentCreate);
socket.off('attachmentUpdate', handleAttachmentUpdate);
socket.off('attachmentDelete', handleAttachmentDelete);
socket.off('actionCreate', handleActionCreate);
socket.off('actionUpdate', handleActionUpdate);
socket.off('actionDelete', handleActionDelete);

View file

@ -14,11 +14,11 @@ import {
removeUserFromCurrentCardService,
removeUserFromFilterInCurrentBoardService,
updateUserService,
updateCurrentUserAvatarService,
updateCurrentUserEmailService,
updateCurrentUserPasswordService,
updateCurrentUserService,
updateCurrentUserUsernameService,
uploadCurrentUserAvatarService,
} from '../services';
import EntryActionTypes from '../../../constants/EntryActionTypes';
@ -50,8 +50,8 @@ export default function* () {
takeLatest(EntryActionTypes.CURRENT_USER_USERNAME_UPDATE_ERROR_CLEAR, () =>
clearCurrentUserUsernameUpdateErrorService(),
),
takeLatest(EntryActionTypes.CURRENT_USER_AVATAR_UPLOAD, ({ payload: { file } }) =>
uploadCurrentUserAvatarService(file),
takeLatest(EntryActionTypes.CURRENT_USER_AVATAR_UPDATE, ({ payload: { data } }) =>
updateCurrentUserAvatarService(data),
),
takeLatest(EntryActionTypes.USER_DELETE, ({ payload: { id } }) => deleteUserService(id)),
takeLatest(EntryActionTypes.USER_TO_CARD_ADD, ({ payload: { id, cardId } }) =>

View file

@ -336,6 +336,30 @@ export const tasksForCurrentCardSelector = createSelector(
},
);
export const attachmentsForCurrentCardSelector = createSelector(
orm,
(state) => pathSelector(state).cardId,
({ Card }, id) => {
if (!id) {
return id;
}
const cardModel = Card.withId(id);
if (!cardModel) {
return cardModel;
}
return cardModel
.getOrderedAttachmentsQuerySet()
.toRefArray()
.map((attachment) => ({
...attachment,
isPersisted: !isLocalId(attachment.id),
}));
},
);
export const actionsForCurrentCardSelector = createSelector(
orm,
(state) => pathSelector(state).cardId,

View file

@ -13,7 +13,8 @@ services:
done; (exit $$s)"
restart: unless-stopped
volumes:
- uploads:/app/public/uploads
- user-avatars:/app/public/user-avatars
- attachments:/app/public/attachments
ports:
- 3000:1337
environment:
@ -30,7 +31,9 @@ services:
- db-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=planka
- POSTGRES_HOST_AUTH_METHOD=trust
volumes:
uploads:
user-avatars:
attachments:
db-data:

9
server/.gitignore vendored
View file

@ -128,9 +128,12 @@ lib-cov
*.pid
public/*
!public/uploads
public/uploads/*
!public/uploads/.gitkeep
!public/user-avatars
public/user-avatars/*
!public/user-avatars/.gitkeep
!public/attachments
public/attachments/*
!public/attachments/.gitkeep
views/*
!views/.gitkeep

View file

@ -0,0 +1,68 @@
const Errors = {
CARD_NOT_FOUND: {
cardNotFound: 'Card not found',
},
};
module.exports = {
inputs: {
cardId: {
type: 'string',
regex: /^[0-9]+$/,
required: true,
},
},
exits: {
cardNotFound: {
responseType: 'notFound',
},
uploadError: {
responseType: 'unprocessableEntity',
},
},
async fn(inputs, exits) {
const { currentUser } = this.req;
const { card, project } = await sails.helpers
.getCardToProjectPath(inputs.cardId)
.intercept('pathNotFound', () => Errors.CARD_NOT_FOUND);
const isUserMemberForProject = await sails.helpers.isUserMemberForProject(
project.id,
currentUser.id,
);
if (!isUserMemberForProject) {
throw Errors.CARD_NOT_FOUND; // Forbidden
}
this.req.file('file').upload(sails.helpers.createAttachmentReceiver(), async (error, files) => {
if (error) {
return exits.uploadError(error.message);
}
if (files.length === 0) {
return exits.uploadError('No file was uploaded');
}
const file = files[0];
const attachment = await sails.helpers.createAttachment(
card,
{
dirname: file.extra.dirname,
filename: file.filename,
isImage: file.extra.isImage,
name: file.filename,
},
this.req,
);
return exits.success({
item: attachment.toJSON(),
});
});
},
};

View file

@ -0,0 +1,51 @@
const Errors = {
ATTACHMENT_NOT_FOUND: {
attachmentNotFound: 'Attachment not found',
},
};
module.exports = {
inputs: {
id: {
type: 'string',
regex: /^[0-9]+$/,
required: true,
},
},
exits: {
attachmentNotFound: {
responseType: 'notFound',
},
},
async fn(inputs, exits) {
const { currentUser } = this.req;
const attachmentToProjectPath = await sails.helpers
.getAttachmentToProjectPath(inputs.id)
.intercept('pathNotFound', () => Errors.ATTACHMENT_NOT_FOUND);
let { attachment } = attachmentToProjectPath;
const { board, project } = attachmentToProjectPath;
const isUserMemberForProject = await sails.helpers.isUserMemberForProject(
project.id,
currentUser.id,
);
if (!isUserMemberForProject) {
throw Errors.ATTACHMENT_NOT_FOUND; // Forbidden
}
attachment = await sails.helpers.deleteAttachment(attachment, board, this.req);
if (!attachment) {
throw Errors.ATTACHMENT_NOT_FOUND;
}
return exits.success({
item: attachment,
});
},
};

View file

@ -0,0 +1,57 @@
const Errors = {
ATTACHMENT_NOT_FOUND: {
attachmentNotFound: 'Attachment not found',
},
};
module.exports = {
inputs: {
id: {
type: 'string',
regex: /^[0-9]+$/,
required: true,
},
name: {
type: 'string',
isNotEmptyString: true,
},
},
exits: {
attachmentNotFound: {
responseType: 'notFound',
},
},
async fn(inputs, exits) {
const { currentUser } = this.req;
const attachmentToProjectPath = await sails.helpers
.getAttachmentToProjectPath(inputs.id)
.intercept('pathNotFound', () => Errors.ATTACHMENT_NOT_FOUND);
let { attachment } = attachmentToProjectPath;
const { board, project } = attachmentToProjectPath;
const isUserMemberForProject = await sails.helpers.isUserMemberForProject(
project.id,
currentUser.id,
);
if (!isUserMemberForProject) {
throw Errors.ATTACHMENT_NOT_FOUND; // Forbidden
}
const values = _.pick(inputs, ['name']);
attachment = await sails.helpers.updateAttachment(attachment, values, board, this.req);
if (!attachment) {
throw Errors.ATTACHMENT_NOT_FOUND;
}
return exits.success({
item: attachment,
});
},
};

View file

@ -55,6 +55,7 @@ module.exports = {
const cardLabels = await sails.helpers.getCardLabelsForCard(cardIds);
const tasks = await sails.helpers.getTasksForCard(cardIds);
const attachments = await sails.helpers.getAttachmentsForCard(cardIds);
const isSubscribedByCardId = cardSubscriptions.reduce(
(result, cardSubscription) => ({
@ -80,6 +81,7 @@ module.exports = {
cardMemberships,
cardLabels,
tasks,
attachments,
},
});
},

View file

@ -0,0 +1,67 @@
const Errors = {
USER_NOT_FOUND: {
userNotFound: 'User not found',
},
};
module.exports = {
inputs: {
id: {
type: 'string',
regex: /^[0-9]+$/,
required: true,
},
},
exits: {
userNotFound: {
responseType: 'notFound',
},
uploadError: {
responseType: 'unprocessableEntity',
},
},
async fn(inputs, exits) {
const { currentUser } = this.req;
let user;
if (currentUser.isAdmin) {
user = await sails.helpers.getUser(inputs.id);
if (!user) {
throw Errors.USER_NOT_FOUND;
}
} else if (inputs.id !== currentUser.id) {
throw Errors.USER_NOT_FOUND; // Forbidden
} else {
user = currentUser;
}
this.req.file('file').upload(sails.helpers.createAvatarReceiver(), async (error, files) => {
if (error) {
return exits.uploadError(error.message);
}
if (files.length === 0) {
return exits.uploadError('No file was uploaded');
}
user = await sails.helpers.updateUser(
user,
{
avatarDirname: files[0].extra.dirname,
},
this.req,
);
if (!user) {
throw Errors.USER_NOT_FOUND;
}
return exits.success({
item: user.toJSON().avatarUrl,
});
});
},
};

View file

@ -18,7 +18,7 @@ module.exports = {
type: 'string',
isNotEmptyString: true,
},
avatar: {
avatarUrl: {
type: 'json',
custom: (value) => _.isNull(value),
},
@ -63,7 +63,7 @@ module.exports = {
const values = _.pick(inputs, [
'isAdmin',
'name',
'avatar',
'avatarUrl',
'phone',
'organization',
'subscribeToOwnCards',

View file

@ -1,122 +0,0 @@
const fs = require('fs');
const path = require('path');
const util = require('util');
const stream = require('stream');
const { v4: uuid } = require('uuid');
const sharp = require('sharp');
const Errors = {
USER_NOT_FOUND: {
userNotFound: 'User not found',
},
};
const pipeline = util.promisify(stream.pipeline);
const createReceiver = () => {
const receiver = stream.Writable({ objectMode: true });
let firstFileHandled = false;
// eslint-disable-next-line no-underscore-dangle
receiver._write = async (file, receiverEncoding, done) => {
if (firstFileHandled) {
file.pipe(
new stream.Writable({
write(chunk, streamEncoding, callback) {
callback();
},
}),
);
return done();
}
firstFileHandled = true;
const resize = sharp().resize(100, 100).jpeg();
const transform = new stream.Transform({
transform(chunk, streamEncoding, callback) {
callback(null, chunk);
},
});
try {
await pipeline(file, resize, transform);
file.fd = `${uuid()}.jpg`; // eslint-disable-line no-param-reassign
await pipeline(
transform,
fs.createWriteStream(path.join(sails.config.custom.uploadsPath, file.fd)),
);
return done();
} catch (error) {
return done(error);
}
};
return receiver;
};
module.exports = {
inputs: {
id: {
type: 'string',
regex: /^[0-9]+$/,
required: true,
},
},
exits: {
userNotFound: {
responseType: 'notFound',
},
uploadError: {
responseType: 'unprocessableEntity',
},
},
async fn(inputs, exits) {
const { currentUser } = this.req;
let user;
if (currentUser.isAdmin) {
user = await sails.helpers.getUser(inputs.id);
if (!user) {
throw Errors.USER_NOT_FOUND;
}
} else if (inputs.id !== currentUser.id) {
throw Errors.USER_NOT_FOUND; // Forbidden
} else {
user = currentUser;
}
this.req.file('file').upload(createReceiver(), async (error, files) => {
if (error) {
return exits.uploadError(error.message);
}
if (files.length === 0) {
return exits.uploadError('No file was uploaded');
}
user = await sails.helpers.updateUser(
user,
{
avatar: files[0].fd,
},
this.req,
);
if (!user) {
throw Errors.USER_NOT_FOUND;
}
return exits.success({
item: user.toJSON().avatar,
});
});
},
};

View file

@ -0,0 +1,65 @@
const fs = require('fs');
const path = require('path');
const util = require('util');
const stream = require('stream');
const streamToArray = require('stream-to-array');
const { v4: uuid } = require('uuid');
const sharp = require('sharp');
const writeFile = util.promisify(fs.writeFile);
module.exports = {
sync: true,
fn(inputs, exits) {
const receiver = stream.Writable({
objectMode: true,
});
let firstFileHandled = false;
// eslint-disable-next-line no-underscore-dangle
receiver._write = async (file, receiverEncoding, done) => {
if (firstFileHandled) {
file.pipe(new stream.Writable());
return done();
}
firstFileHandled = true;
const buffer = await streamToArray(file).then((parts) =>
Buffer.concat(parts.map((part) => (util.isBuffer(part) ? part : Buffer.from(part)))),
);
let thumbnailBuffer;
try {
thumbnailBuffer = await sharp(buffer).resize(240, 240).jpeg().toBuffer();
} catch (error) {} // eslint-disable-line no-empty
try {
const dirname = uuid();
const dirPath = path.join(sails.config.custom.attachmentsPath, dirname);
fs.mkdirSync(dirPath);
if (thumbnailBuffer) {
await writeFile(path.join(dirPath, '240.jpg'), thumbnailBuffer);
}
await writeFile(path.join(dirPath, file.filename), buffer);
// eslint-disable-next-line no-param-reassign
file.extra = {
dirname,
isImage: !!thumbnailBuffer,
};
return done();
} catch (error) {
return done(error);
}
};
return exits.success(receiver);
},
};

View file

@ -0,0 +1,33 @@
module.exports = {
inputs: {
card: {
type: 'ref',
required: true,
},
values: {
type: 'json',
required: true,
},
request: {
type: 'ref',
},
},
async fn(inputs, exits) {
const attachment = await Attachment.create({
...inputs.values,
cardId: inputs.card.id,
}).fetch();
sails.sockets.broadcast(
`board:${inputs.card.boardId}`,
'attachmentCreate',
{
item: attachment,
},
inputs.request,
);
return exits.success(attachment);
},
};

View file

@ -0,0 +1,54 @@
const fs = require('fs');
const path = require('path');
const util = require('util');
const stream = require('stream');
const { v4: uuid } = require('uuid');
const sharp = require('sharp');
const pipeline = util.promisify(stream.pipeline);
module.exports = {
sync: true,
fn(inputs, exits) {
const receiver = stream.Writable({
objectMode: true,
});
let firstFileHandled = false;
// eslint-disable-next-line no-underscore-dangle
receiver._write = async (file, receiverEncoding, done) => {
if (firstFileHandled) {
file.pipe(new stream.Writable());
return done();
}
firstFileHandled = true;
const resize = sharp().resize(100, 100).jpeg();
const passThrought = new stream.PassThrough();
try {
await pipeline(file, resize, passThrought);
const dirname = uuid();
const dirPath = path.join(sails.config.custom.userAvatarsPath, dirname);
fs.mkdirSync(dirPath);
await pipeline(passThrought, fs.createWriteStream(path.join(dirPath, '100.jpg')));
// eslint-disable-next-line no-param-reassign
file.extra = {
dirname,
};
return done();
} catch (error) {
return done(error);
}
};
return exits.success(receiver);
},
};

View file

@ -0,0 +1,41 @@
const path = require('path');
const rimraf = require('rimraf');
module.exports = {
inputs: {
record: {
type: 'ref',
required: true,
},
board: {
type: 'ref',
required: true,
},
request: {
type: 'ref',
},
},
async fn(inputs, exits) {
const attachment = await Attachment.archiveOne(inputs.record.id);
if (attachment) {
try {
rimraf.sync(path.join(sails.config.custom.attachmentsPath, attachment.dirname));
} catch (error) {
console.warn(error.stack); // eslint-disable-line no-console
}
sails.sockets.broadcast(
`board:${inputs.board.id}`,
'attachmentDelete',
{
item: attachment,
},
inputs.request,
);
}
return exits.success(attachment);
},
};

View file

@ -0,0 +1,34 @@
module.exports = {
inputs: {
criteria: {
type: 'json',
required: true,
},
},
exits: {
pathNotFound: {},
},
async fn(inputs, exits) {
const attachment = await Attachment.findOne(inputs.criteria);
if (!attachment) {
throw 'pathNotFound';
}
const path = await sails.helpers
.getCardToProjectPath(attachment.cardId)
.intercept('pathNotFound', (nodes) => ({
pathNotFound: {
attachment,
...nodes,
},
}));
return exits.success({
attachment,
...path,
});
},
};

View file

@ -0,0 +1,17 @@
module.exports = {
inputs: {
id: {
type: 'json',
custom: (value) => _.isString(value) || _.isArray(value),
required: true,
},
},
async fn(inputs, exits) {
const attachments = await sails.helpers.getAttachments({
cardId: inputs.id,
});
return exits.success(attachments);
},
};

View file

@ -0,0 +1,14 @@
module.exports = {
inputs: {
criteria: {
type: 'json',
custom: (value) => _.isArray(value) || _.isPlainObject(value),
},
},
async fn(inputs, exits) {
const attachments = await Attachment.find(inputs.criteria).sort('id');
return exits.success(attachments);
},
};

View file

@ -0,0 +1,36 @@
module.exports = {
inputs: {
record: {
type: 'ref',
required: true,
},
values: {
type: 'json',
required: true,
},
board: {
type: 'ref',
required: true,
},
request: {
type: 'ref',
},
},
async fn(inputs, exits) {
const attachment = await Attachment.updateOne(inputs.record.id).set(inputs.values);
if (attachment) {
sails.sockets.broadcast(
`board:${inputs.board.id}`,
'attachmentUpdate',
{
item: attachment,
},
inputs.request,
);
}
return exits.success(attachment);
},
};

View file

@ -1,6 +1,6 @@
const fs = require('fs');
const path = require('path');
const bcrypt = require('bcrypt');
const rimraf = require('rimraf');
module.exports = {
inputs: {
@ -14,7 +14,8 @@ module.exports = {
_.isPlainObject(value) &&
(_.isUndefined(value.email) || _.isString(value.email)) &&
(_.isUndefined(value.password) || _.isString(value.password)) &&
(!value.username || _.isString(value.username)),
(!value.username || _.isString(value.username)) &&
(_.isUndefined(value.avatarUrl) || _.isNull(value.avatarUrl)),
required: true,
},
request: {
@ -49,6 +50,13 @@ module.exports = {
inputs.values.username = inputs.values.username.toLowerCase();
}
if (!_.isUndefined(inputs.values.avatarUrl)) {
/* eslint-disable no-param-reassign */
inputs.values.avatarDirname = null;
delete inputs.values.avatarUrl;
/* eslint-enable no-param-reassign */
}
const user = await User.updateOne({
id: inputs.record.id,
deletedAt: null,
@ -70,9 +78,9 @@ module.exports = {
);
if (user) {
if (inputs.record.avatar && user.avatar !== inputs.record.avatar) {
if (inputs.record.avatarDirname && user.avatarDirname !== inputs.record.avatarDirname) {
try {
fs.unlinkSync(path.join(sails.config.custom.uploadsPath, inputs.record.avatar));
rimraf.sync(path.join(sails.config.custom.userAvatarsPath, inputs.record.avatarDirname));
} catch (error) {
console.warn(error.stack); // eslint-disable-line no-console
}

View file

@ -0,0 +1,56 @@
/**
* Attachment.js
*
* @description :: A model definition represents a database table/collection.
* @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models
*/
module.exports = {
attributes: {
// ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗
// ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
// ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝
dirname: {
type: 'string',
required: true,
},
filename: {
type: 'string',
required: true,
},
isImage: {
type: 'boolean',
required: true,
columnName: 'is_image',
},
name: {
type: 'string',
required: true,
},
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
// ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝
// ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗
// ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝
cardId: {
model: 'Card',
required: true,
columnName: 'card_id',
},
},
customToJSON() {
return {
..._.omit(this, ['dirname', 'filename', 'isImage']),
url: `${sails.config.custom.attachmentsUrl}/${this.dirname}/${this.filename}`,
thumbnailUrl: this.isImage
? `${sails.config.custom.attachmentsUrl}/${this.dirname}/240.jpg`
: null,
};
},
};

View file

@ -69,5 +69,13 @@ module.exports = {
collection: 'Task',
via: 'cardId',
},
attachments: {
collection: 'Attachment',
via: 'cardId',
},
actions: {
collection: 'Action',
via: 'cardId',
},
},
};

View file

@ -37,10 +37,11 @@ module.exports = {
regex: /^[a-zA-Z0-9]+(_?[a-zA-Z0-9])*$/,
allowNull: true,
},
avatar: {
avatarDirname: {
type: 'string',
isNotEmptyString: true,
allowNull: true,
columnName: 'avatar_dirname',
},
phone: {
type: 'string',
@ -91,8 +92,9 @@ module.exports = {
customToJSON() {
return {
..._.omit(this, 'password'),
avatar: this.avatar && `${sails.config.custom.uploadsUrl}/${this.avatar}`,
..._.omit(this, ['password', 'avatarDirname']),
avatarUrl:
this.avatarDirname && `${sails.config.custom.userAvatarsUrl}/${this.avatarDirname}/100.jpg`,
};
},
};

View file

@ -20,6 +20,9 @@ module.exports.custom = {
baseUrl: process.env.BASE_URL,
uploadsPath: path.join(sails.config.paths.public, 'uploads'),
uploadsUrl: `${process.env.BASE_URL}/uploads`,
userAvatarsPath: path.join(sails.config.paths.public, 'user-avatars'),
userAvatarsUrl: `${process.env.BASE_URL}/user-avatars`,
attachmentsPath: path.join(sails.config.paths.public, 'attachments'),
attachmentsUrl: `${process.env.BASE_URL}/attachments`,
};

View file

@ -329,7 +329,10 @@ module.exports = {
custom: {
baseUrl: process.env.BASE_URL,
uploadsPath: path.join(sails.config.paths.public, 'uploads'),
uploadsUrl: `${process.env.BASE_URL}/uploads`,
userAvatarsPath: path.join(sails.config.paths.public, 'user-avatars'),
userAvatarsUrl: `${process.env.BASE_URL}/user-avatars`,
attachmentsPath: path.join(sails.config.paths.public, 'attachments'),
attachmentsUrl: `${process.env.BASE_URL}/attachments`,
},
};

View file

@ -18,7 +18,7 @@ module.exports.routes = {
'PATCH /api/users/:id/email': 'users/update-email',
'PATCH /api/users/:id/password': 'users/update-password',
'PATCH /api/users/:id/username': 'users/update-username',
'POST /api/users/:id/upload-avatar': 'users/upload-avatar',
'POST /api/users/:id/update-avatar': 'users/update-avatar',
'DELETE /api/users/:id': 'users/delete',
'GET /api/projects': 'projects/index',
@ -55,6 +55,10 @@ module.exports.routes = {
'PATCH /api/tasks/:id': 'tasks/update',
'DELETE /api/tasks/:id': 'tasks/delete',
'POST /api/cards/:cardId/attachments': 'attachments/create',
'PATCH /api/attachments/:id': 'attachments/update',
'DELETE /api/attachments/:id': 'attachments/delete',
'GET /api/cards/:cardId/actions': 'actions/index',
'POST /api/cards/:cardId/comment-actions': 'comment-actions/create',

View file

@ -10,7 +10,7 @@ module.exports.up = (knex) =>
table.boolean('is_admin').notNullable();
table.text('name').notNullable();
table.text('username');
table.text('avatar');
table.text('avatar_dirname');
table.text('phone');
table.text('organization');
table.boolean('subscribe_to_own_cards').notNullable();

View file

@ -0,0 +1,22 @@
module.exports.up = (knex) =>
knex.schema.createTable('attachment', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('card_id').notNullable();
table.text('dirname').notNullable();
table.text('filename').notNullable();
table.boolean('is_image').notNullable();
table.text('name').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.index('card_id');
});
module.exports.down = (knex) => knex.schema.dropTable('attachment');

View file

@ -190,6 +190,11 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
"integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
},
"any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8="
},
"anymatch": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz",
@ -2555,6 +2560,16 @@
"klaw": "^1.0.0",
"path-is-absolute": "^1.0.0",
"rimraf": "^2.2.8"
},
"dependencies": {
"rimraf": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
"requires": {
"glob": "^7.1.3"
}
}
}
},
"fs-minipass": {
@ -4168,6 +4183,16 @@
"rimraf": "^2.6.1",
"semver": "^5.3.0",
"tar": "^4.4.2"
},
"dependencies": {
"rimraf": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
"requires": {
"glob": "^7.1.3"
}
}
}
},
"nodemon": {
@ -5273,9 +5298,9 @@
"integrity": "sha1-/s5hv6DBtSoga9axgZgYS91SOjs="
},
"rimraf": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"requires": {
"glob": "^7.1.3"
}
@ -6357,6 +6382,14 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz",
"integrity": "sha1-+vUbnrdKrvOzrPStX2Gr8ky3uT4="
},
"stream-to-array": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/stream-to-array/-/stream-to-array-2.3.0.tgz",
"integrity": "sha1-u/azn19D7DC8cbq8s3VXrOzzQ1M=",
"requires": {
"any-promise": "^1.1.0"
}
},
"streamifier": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/streamifier/-/streamifier-0.1.1.tgz",
@ -6931,6 +6964,14 @@
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz",
"integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E="
},
"rimraf": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
"requires": {
"glob": "^7.1.3"
}
}
}
},

Some files were not shown because too many files have changed in this diff Show more