diff --git a/client/src/actions/entry/task.js b/client/src/actions/entry/task.js index 570df0a9..c406a800 100755 --- a/client/src/actions/entry/task.js +++ b/client/src/actions/entry/task.js @@ -29,6 +29,14 @@ export const handleTaskUpdate = (task) => ({ }, }); +export const moveTask = (id, index) => ({ + type: EntryActionTypes.TASK_MOVE, + payload: { + id, + index, + }, +}); + export const deleteTask = (id) => ({ type: EntryActionTypes.TASK_DELETE, payload: { diff --git a/client/src/components/Card/Card.module.scss b/client/src/components/Card/Card.module.scss index 217a3f72..1aa7cdf8 100644 --- a/client/src/components/Card/Card.module.scss +++ b/client/src/components/Card/Card.module.scss @@ -114,6 +114,7 @@ } .wrapper { + cursor: auto; display: block; margin-bottom: 8px; } diff --git a/client/src/components/CardModal/Attachments/Attachments.module.scss b/client/src/components/CardModal/Attachments/Attachments.module.scss index fbcbe095..a097a3c7 100644 --- a/client/src/components/CardModal/Attachments/Attachments.module.scss +++ b/client/src/components/CardModal/Attachments/Attachments.module.scss @@ -37,4 +37,4 @@ color: #092d42; } } -} \ No newline at end of file +} diff --git a/client/src/components/CardModal/CardModal.jsx b/client/src/components/CardModal/CardModal.jsx index 1764eb15..3f52a72b 100755 --- a/client/src/components/CardModal/CardModal.jsx +++ b/client/src/components/CardModal/CardModal.jsx @@ -61,6 +61,7 @@ const CardModal = React.memo( onLabelDelete, onTaskCreate, onTaskUpdate, + onTaskMove, onTaskDelete, onAttachmentCreate, onAttachmentUpdate, @@ -331,6 +332,7 @@ const CardModal = React.memo( canEdit={canEdit} onCreate={onTaskCreate} onUpdate={onTaskUpdate} + onMove={onTaskMove} onDelete={onTaskDelete} /> @@ -513,6 +515,7 @@ CardModal.propTypes = { onLabelDelete: PropTypes.func.isRequired, onTaskCreate: PropTypes.func.isRequired, onTaskUpdate: PropTypes.func.isRequired, + onTaskMove: PropTypes.func.isRequired, onTaskDelete: PropTypes.func.isRequired, onAttachmentCreate: PropTypes.func.isRequired, onAttachmentUpdate: PropTypes.func.isRequired, diff --git a/client/src/components/CardModal/Tasks/Item.jsx b/client/src/components/CardModal/Tasks/Item.jsx index 45ac7e2a..d8d4bdc6 100755 --- a/client/src/components/CardModal/Tasks/Item.jsx +++ b/client/src/components/CardModal/Tasks/Item.jsx @@ -1,6 +1,8 @@ import React, { useCallback, useRef } from 'react'; +import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +import { Draggable } from 'react-beautiful-dnd'; import { Button, Checkbox, Icon } from 'semantic-ui-react'; import NameEdit from './NameEdit'; @@ -8,72 +10,85 @@ import ActionsPopup from './ActionsPopup'; import styles from './Item.module.scss'; -const Item = React.memo(({ name, isCompleted, isPersisted, canEdit, onUpdate, onDelete }) => { - const nameEdit = useRef(null); +const Item = React.memo( + ({ id, index, name, isCompleted, isPersisted, canEdit, onUpdate, onDelete }) => { + const nameEdit = useRef(null); - const handleClick = useCallback(() => { - if (isPersisted && canEdit) { - nameEdit.current.open(); - } - }, [isPersisted, canEdit]); + const handleClick = useCallback(() => { + if (isPersisted && canEdit) { + nameEdit.current.open(); + } + }, [isPersisted, canEdit]); - const handleNameUpdate = useCallback( - (newName) => { + const handleNameUpdate = useCallback( + (newName) => { + onUpdate({ + name: newName, + }); + }, + [onUpdate], + ); + + const handleToggleChange = useCallback(() => { onUpdate({ - name: newName, + isCompleted: !isCompleted, }); - }, - [onUpdate], - ); + }, [isCompleted, onUpdate]); - const handleToggleChange = useCallback(() => { - onUpdate({ - isCompleted: !isCompleted, - }); - }, [isCompleted, onUpdate]); + const handleNameEdit = useCallback(() => { + nameEdit.current.open(); + }, []); - const handleNameEdit = useCallback(() => { - nameEdit.current.open(); - }, []); + return ( + + {({ innerRef, draggableProps, dragHandleProps }, { isDragging }) => { + const contentNode = ( + // eslint-disable-next-line react/jsx-props-no-spreading +
+ + + + +
+ {/* eslint-disable jsx-a11y/click-events-have-key-events, + jsx-a11y/no-static-element-interactions */} + + {/* eslint-enable jsx-a11y/click-events-have-key-events, + jsx-a11y/no-static-element-interactions */} + + {name} + + + {isPersisted && canEdit && ( + + + + )} +
+
+
+ ); - return ( -
- - - - -
- {/* eslint-disable jsx-a11y/click-events-have-key-events, - jsx-a11y/no-static-element-interactions */} - - {/* eslint-enable jsx-a11y/click-events-have-key-events, - jsx-a11y/no-static-element-interactions */} - - {name} - - - {isPersisted && canEdit && ( - - - - )} -
-
-
- ); -}); + return isDragging ? ReactDOM.createPortal(contentNode, document.body) : contentNode; + }} +
+ ); + }, +); Item.propTypes = { + id: PropTypes.string.isRequired, + index: PropTypes.number.isRequired, name: PropTypes.string.isRequired, isCompleted: PropTypes.bool.isRequired, isPersisted: PropTypes.bool.isRequired, diff --git a/client/src/components/CardModal/Tasks/Item.module.scss b/client/src/components/CardModal/Tasks/Item.module.scss index 8f3214d3..9e5e38c1 100644 --- a/client/src/components/CardModal/Tasks/Item.module.scss +++ b/client/src/components/CardModal/Tasks/Item.module.scss @@ -27,7 +27,7 @@ vertical-align: top; z-index: 2000; line-height: 1; - height: 32px; + height: 100%; } .contentHoverable:hover { @@ -69,10 +69,10 @@ .wrapper { border-radius: 3px; + cursor: auto; margin-left: -40px; min-height: 32px; position: relative; - transition: all 0.14s ease-in; width: calc(100% + 40px); } } diff --git a/client/src/components/CardModal/Tasks/Tasks.jsx b/client/src/components/CardModal/Tasks/Tasks.jsx index fabb5702..54d5194a 100755 --- a/client/src/components/CardModal/Tasks/Tasks.jsx +++ b/client/src/components/CardModal/Tasks/Tasks.jsx @@ -1,16 +1,34 @@ import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; +import { DragDropContext, Droppable } from 'react-beautiful-dnd'; import { Progress } from 'semantic-ui-react'; +import { closePopup } from '../../../lib/popup'; +import DroppableTypes from '../../../constants/DroppableTypes'; import Item from './Item'; import Add from './Add'; import styles from './Tasks.module.scss'; -const Tasks = React.memo(({ items, canEdit, onCreate, onUpdate, onDelete }) => { +const Tasks = React.memo(({ items, canEdit, onCreate, onUpdate, onMove, onDelete }) => { const [t] = useTranslation(); + const handleDragStart = useCallback(() => { + closePopup(); + }, []); + + const handleDragEnd = useCallback( + ({ draggableId, source, destination }) => { + if (!destination || source.index === destination.index) { + return; + } + + onMove(draggableId, destination.index); + }, + [onMove], + ); + const handleUpdate = useCallback( (id, data) => { onUpdate(id, data); @@ -39,26 +57,38 @@ const Tasks = React.memo(({ items, canEdit, onCreate, onUpdate, onDelete }) => { className={styles.progress} /> )} - {items.map((item) => ( - handleUpdate(item.id, data)} - onDelete={() => handleDelete(item.id)} - /> - ))} - {canEdit && ( - - - - )} + + + {({ innerRef, droppableProps, placeholder }) => ( + // eslint-disable-next-line react/jsx-props-no-spreading +
+ {items.map((item, index) => ( + handleUpdate(item.id, data)} + onDelete={() => handleDelete(item.id)} + /> + ))} + {placeholder} + {canEdit && ( + + + + )} +
+ )} +
+
); }); @@ -68,6 +98,7 @@ Tasks.propTypes = { canEdit: PropTypes.bool.isRequired, onCreate: PropTypes.func.isRequired, onUpdate: PropTypes.func.isRequired, + onMove: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired, }; diff --git a/client/src/constants/DroppableTypes.js b/client/src/constants/DroppableTypes.js index 294f9a11..33cf0bd9 100755 --- a/client/src/constants/DroppableTypes.js +++ b/client/src/constants/DroppableTypes.js @@ -1,9 +1,11 @@ const BOARD = 'BOARD'; const LIST = 'LIST'; const CARD = 'CARD'; +const TASK = 'TASK'; export default { BOARD, LIST, CARD, + TASK, }; diff --git a/client/src/constants/EntryActionTypes.js b/client/src/constants/EntryActionTypes.js index 0627c3ca..61540832 100755 --- a/client/src/constants/EntryActionTypes.js +++ b/client/src/constants/EntryActionTypes.js @@ -138,6 +138,7 @@ export default { TASK_CREATE_HANDLE: `${PREFIX}/TASK_CREATE_HANDLE`, TASK_UPDATE: `${PREFIX}/TASK_UPDATE`, TASK_UPDATE_HANDLE: `${PREFIX}/TASK_UPDATE_HANDLE`, + TASK_MOVE: `${PREFIX}/TASK_MOVE`, TASK_DELETE: `${PREFIX}/TASK_DELETE`, TASK_DELETE_HANDLE: `${PREFIX}/TASK_DELETE_HANDLE`, diff --git a/client/src/containers/CardModalContainer.js b/client/src/containers/CardModalContainer.js index 57bb77fe..2fb07ca3 100755 --- a/client/src/containers/CardModalContainer.js +++ b/client/src/containers/CardModalContainer.js @@ -32,6 +32,7 @@ import { fetchActionsInCurrentCard, fetchBoard, moveCurrentCard, + moveTask, removeLabelFromCurrentCard, removeUserFromCurrentCard, transferCurrentCard, @@ -111,6 +112,7 @@ const mapDispatchToProps = (dispatch) => onLabelDelete: deleteLabel, onTaskCreate: createTaskInCurrentCard, onTaskUpdate: updateTask, + onTaskMove: moveTask, onTaskDelete: deleteTask, onAttachmentCreate: createAttachmentInCurrentCard, onAttachmentUpdate: updateAttachment, diff --git a/client/src/lib/custom-ui/components/Markdown/Markdown.jsx b/client/src/lib/custom-ui/components/Markdown/Markdown.jsx index 6f59bbe0..3a083678 100644 --- a/client/src/lib/custom-ui/components/Markdown/Markdown.jsx +++ b/client/src/lib/custom-ui/components/Markdown/Markdown.jsx @@ -32,14 +32,12 @@ const Markdown = React.memo(({ linkStopPropagation, ...props }) => { } return ( - /* eslint-disable react/jsx-props-no-spreading */ - /* eslint-enable react/jsx-props-no-spreading */ ); }); diff --git a/client/src/models/Card.js b/client/src/models/Card.js index e4cebbd2..c4002aa2 100755 --- a/client/src/models/Card.js +++ b/client/src/models/Card.js @@ -206,7 +206,7 @@ export default class extends Model { } getOrderedTasksQuerySet() { - return this.tasks.orderBy('id'); + return this.tasks.orderBy('position'); } getOrderedAttachmentsQuerySet() { diff --git a/client/src/models/Task.js b/client/src/models/Task.js index 7ef33fa9..c9a59777 100755 --- a/client/src/models/Task.js +++ b/client/src/models/Task.js @@ -7,6 +7,7 @@ export default class extends Model { static fields = { id: attr(), + position: attr(), name: attr(), isCompleted: attr({ getDefault: () => false, diff --git a/client/src/sagas/core/services/task.js b/client/src/sagas/core/services/task.js index 5c611298..4ec49340 100644 --- a/client/src/sagas/core/services/task.js +++ b/client/src/sagas/core/services/task.js @@ -1,7 +1,7 @@ import { call, put, select } from 'redux-saga/effects'; import request from '../request'; -import { pathSelector } from '../../../selectors'; +import { nextTaskPositionSelector, pathSelector, taskByIdSelector } from '../../../selectors'; import { createTask, deleteTask, @@ -14,11 +14,16 @@ import api from '../../../api'; import { createLocalId } from '../../../utils/local-id'; export function* createTaskService(cardId, data) { + const nextData = { + ...data, + position: yield select(nextTaskPositionSelector, cardId), + }; + const localId = yield call(createLocalId); yield put( createTask({ - ...data, + ...nextData, cardId, id: localId, }), @@ -26,7 +31,7 @@ export function* createTaskService(cardId, data) { let task; try { - ({ item: task } = yield call(request, api.createTask, cardId, data)); + ({ item: task } = yield call(request, api.createTask, cardId, nextData)); } catch (error) { yield put(createTask.failure(localId, error)); return; @@ -63,6 +68,15 @@ export function* handleTaskUpdateService(task) { yield put(handleTaskUpdate(task)); } +export function* moveTaskService(id, index) { + const { cardId } = yield select(taskByIdSelector, id); + const position = yield select(nextTaskPositionSelector, cardId, index, id); + + yield call(updateTaskService, id, { + position, + }); +} + export function* deleteTaskService(id) { yield put(deleteTask(id)); diff --git a/client/src/sagas/core/watchers/task.js b/client/src/sagas/core/watchers/task.js index 934e3247..dacd0575 100644 --- a/client/src/sagas/core/watchers/task.js +++ b/client/src/sagas/core/watchers/task.js @@ -6,6 +6,7 @@ import { handleTaskCreateService, handleTaskDeleteService, handleTaskUpdateService, + moveTaskService, updateTaskService, } from '../services'; import EntryActionTypes from '../../../constants/EntryActionTypes'; @@ -24,6 +25,9 @@ export default function* taskWatchers() { takeEvery(EntryActionTypes.TASK_UPDATE_HANDLE, ({ payload: { task } }) => handleTaskUpdateService(task), ), + takeEvery(EntryActionTypes.TASK_MOVE, ({ payload: { id, index } }) => + moveTaskService(id, index), + ), takeEvery(EntryActionTypes.TASK_DELETE, ({ payload: { id } }) => deleteTaskService(id)), takeEvery(EntryActionTypes.TASK_DELETE_HANDLE, ({ payload: { task } }) => handleTaskDeleteService(task), diff --git a/client/src/selectors/card.js b/client/src/selectors/card.js index dd7de706..3fe2a878 100644 --- a/client/src/selectors/card.js +++ b/client/src/selectors/card.js @@ -71,7 +71,7 @@ export const makeTasksByCardIdSelector = () => return cardModel; } - return cardModel.tasks.toRefArray(); + return cardModel.getOrderedTasksQuerySet().toRefArray(); }, ); diff --git a/client/src/selectors/core.js b/client/src/selectors/core.js index f1aada13..0bc79ed7 100755 --- a/client/src/selectors/core.js +++ b/client/src/selectors/core.js @@ -76,3 +76,19 @@ export const nextCardPositionSelector = createSelector( return nextPosition(listModel.getOrderedFilteredCardsModelArray(), index, excludedId); }, ); + +export const nextTaskPositionSelector = createSelector( + orm, + (_, cardId) => cardId, + (_, __, index) => index, + (_, __, ___, excludedId) => excludedId, + ({ Card }, cardId, index, excludedId) => { + const cardModel = Card.withId(cardId); + + if (!cardModel) { + return cardModel; + } + + return nextPosition(cardModel.getOrderedTasksQuerySet().toRefArray(), index, excludedId); + }, +); diff --git a/client/src/selectors/index.js b/client/src/selectors/index.js index d4c8525f..7a77a3bb 100755 --- a/client/src/selectors/index.js +++ b/client/src/selectors/index.js @@ -10,4 +10,5 @@ export * from './board'; export * from './board-membership'; export * from './list'; export * from './card'; +export * from './task'; export * from './attachment'; diff --git a/client/src/selectors/task.js b/client/src/selectors/task.js new file mode 100644 index 00000000..5ffdd49e --- /dev/null +++ b/client/src/selectors/task.js @@ -0,0 +1,20 @@ +import { createSelector } from 'redux-orm'; + +import orm from '../orm'; + +export const makeTaskByIdSelector = () => + createSelector( + orm, + (_, id) => id, + ({ Task }, id) => { + const taskModel = Task.withId(id); + + if (!taskModel) { + return taskModel; + } + + return taskModel.ref; + }, + ); + +export const taskByIdSelector = makeTaskByIdSelector(); diff --git a/server/api/controllers/tasks/create.js b/server/api/controllers/tasks/create.js index b3a68802..d7bad1be 100755 --- a/server/api/controllers/tasks/create.js +++ b/server/api/controllers/tasks/create.js @@ -11,6 +11,10 @@ module.exports = { regex: /^[0-9]+$/, required: true, }, + position: { + type: 'number', + required: true, + }, name: { type: 'string', required: true, @@ -39,7 +43,7 @@ module.exports = { throw Errors.CARD_NOT_FOUND; // Forbidden } - const values = _.pick(inputs, ['name', 'isCompleted']); + const values = _.pick(inputs, ['position', 'name', 'isCompleted']); const task = await sails.helpers.tasks.createOne(values, card, this.req); return { diff --git a/server/api/controllers/tasks/update.js b/server/api/controllers/tasks/update.js index c27ff981..b8aa370d 100755 --- a/server/api/controllers/tasks/update.js +++ b/server/api/controllers/tasks/update.js @@ -11,6 +11,9 @@ module.exports = { regex: /^[0-9]+$/, required: true, }, + position: { + type: 'number', + }, name: { type: 'string', isNotEmptyString: true, @@ -42,7 +45,7 @@ module.exports = { throw Errors.TASK_NOT_FOUND; // Forbidden } - const values = _.pick(inputs, ['name', 'isCompleted']); + const values = _.pick(inputs, ['position', 'name', 'isCompleted']); task = await sails.helpers.tasks.updateOne(task, values, board, this.req); if (!task) { diff --git a/server/api/helpers/cards/get-tasks.js b/server/api/helpers/cards/get-tasks.js index 97550d17..10b921bf 100644 --- a/server/api/helpers/cards/get-tasks.js +++ b/server/api/helpers/cards/get-tasks.js @@ -5,11 +5,23 @@ module.exports = { custom: (value) => _.isString(value) || _.every(value, _.isString), required: true, }, + exceptTaskIdOrIds: { + type: 'json', + custom: (value) => _.isString(value) || _.every(value, _.isString), + }, }, async fn(inputs) { - return sails.helpers.tasks.getMany({ + const criteria = { cardId: inputs.idOrIds, - }); + }; + + if (!_.isUndefined(inputs.exceptTaskIdOrIds)) { + criteria.id = { + '!=': inputs.exceptTaskIdOrIds, + }; + } + + return sails.helpers.tasks.getMany(criteria); }, }; diff --git a/server/api/helpers/tasks/create-one.js b/server/api/helpers/tasks/create-one.js index 9e3dcf35..bed7c900 100644 --- a/server/api/helpers/tasks/create-one.js +++ b/server/api/helpers/tasks/create-one.js @@ -2,6 +2,7 @@ module.exports = { inputs: { values: { type: 'json', + custom: (value) => _.isPlainObject(value) && _.isFinite(value.position), required: true, }, card: { @@ -14,8 +15,32 @@ module.exports = { }, async fn(inputs) { + const tasks = await sails.helpers.cards.getTasks(inputs.card.id); + + const { position, repositions } = sails.helpers.utils.insertToPositionables( + inputs.values.position, + tasks, + ); + + repositions.forEach(async ({ id, position: nextPosition }) => { + await Task.update({ + id, + cardId: inputs.card.id, + }).set({ + position: nextPosition, + }); + + sails.sockets.broadcast(`board:${inputs.card.boardId}`, 'taskUpdate', { + item: { + id, + position: nextPosition, + }, + }); + }); + const task = await Task.create({ ...inputs.values, + position, cardId: inputs.card.id, }).fetch(); diff --git a/server/api/helpers/tasks/get-many.js b/server/api/helpers/tasks/get-many.js index f7bcce0a..17ac1406 100644 --- a/server/api/helpers/tasks/get-many.js +++ b/server/api/helpers/tasks/get-many.js @@ -7,6 +7,6 @@ module.exports = { }, async fn(inputs) { - return Task.find(inputs.criteria).sort('id'); + return Task.find(inputs.criteria).sort('position'); }, }; diff --git a/server/api/helpers/tasks/update-one.js b/server/api/helpers/tasks/update-one.js index 1c2bf27b..17aac814 100644 --- a/server/api/helpers/tasks/update-one.js +++ b/server/api/helpers/tasks/update-one.js @@ -6,6 +6,17 @@ module.exports = { }, values: { type: 'json', + custom: (value) => { + if (!_.isPlainObject(value)) { + return false; + } + + if (!_.isUndefined(value.position) && !_.isFinite(value.position)) { + return false; + } + + return true; + }, required: true, }, board: { @@ -18,6 +29,33 @@ module.exports = { }, async fn(inputs) { + if (!_.isUndefined(inputs.values.position)) { + const tasks = await sails.helpers.cards.getTasks(inputs.record.cardId, inputs.record.id); + + const { position, repositions } = sails.helpers.utils.insertToPositionables( + inputs.values.position, + tasks, + ); + + inputs.values.position = position; // eslint-disable-line no-param-reassign + + repositions.forEach(async ({ id, position: nextPosition }) => { + await Task.update({ + id, + cardId: inputs.record.cardId, + }).set({ + position: nextPosition, + }); + + sails.sockets.broadcast(`board:${inputs.board.id}`, 'taskUpdate', { + item: { + id, + position: nextPosition, + }, + }); + }); + } + const task = await Task.updateOne(inputs.record.id).set(inputs.values); if (task) { diff --git a/server/api/models/Task.js b/server/api/models/Task.js index de87cf0c..92d172cb 100755 --- a/server/api/models/Task.js +++ b/server/api/models/Task.js @@ -11,6 +11,10 @@ module.exports = { // ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗ // ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝ + position: { + type: 'number', + required: true, + }, name: { type: 'string', required: true, diff --git a/server/db/migrations/20220713145452_add_position_to_task_table.js b/server/db/migrations/20220713145452_add_position_to_task_table.js new file mode 100644 index 00000000..fd875fdd --- /dev/null +++ b/server/db/migrations/20220713145452_add_position_to_task_table.js @@ -0,0 +1,44 @@ +const POSITION_GAP = 65535; + +module.exports.up = async (knex) => { + await knex.schema.table('task', (table) => { + /* Columns */ + + table.specificType('position', 'double precision'); + + /* Indexes */ + + table.index('position'); + }); + + const tasks = await knex('task').orderBy(['card_id', 'id']); + + let prevCardId; + let position; + + // eslint-disable-next-line no-restricted-syntax + for (task of tasks) { + if (task.card_id === prevCardId) { + position += POSITION_GAP; + } else { + prevCardId = task.card_id; + position = POSITION_GAP; + } + + // eslint-disable-next-line no-await-in-loop + await knex('task') + .update({ + position, + }) + .where('id', task.id); + } + + return knex.schema.table('task', (table) => { + table.dropNullable('position'); + }); +}; + +module.exports.down = async (knex) => + knex.schema.table('task', (table) => { + table.dropColumn('position'); + });