mirror of
https://github.com/plankanban/planka.git
synced 2025-07-18 20:59:44 +02:00
parent
a6a836c9a0
commit
f8f2d7345e
27 changed files with 341 additions and 94 deletions
|
@ -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: {
|
||||
|
|
|
@ -114,6 +114,7 @@
|
|||
}
|
||||
|
||||
.wrapper {
|
||||
cursor: auto;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
@ -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,
|
||||
|
|
|
@ -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 (
|
||||
<Draggable draggableId={id} index={index} isDragDisabled={!isPersisted || !canEdit}>
|
||||
{({ innerRef, draggableProps, dragHandleProps }, { isDragging }) => {
|
||||
const contentNode = (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<div {...draggableProps} {...dragHandleProps} ref={innerRef} className={styles.wrapper}>
|
||||
<span className={styles.checkboxWrapper}>
|
||||
<Checkbox
|
||||
checked={isCompleted}
|
||||
disabled={!isPersisted || !canEdit}
|
||||
className={styles.checkbox}
|
||||
onChange={handleToggleChange}
|
||||
/>
|
||||
</span>
|
||||
<NameEdit ref={nameEdit} defaultValue={name} onUpdate={handleNameUpdate}>
|
||||
<div className={classNames(canEdit && styles.contentHoverable)}>
|
||||
{/* eslint-disable jsx-a11y/click-events-have-key-events,
|
||||
jsx-a11y/no-static-element-interactions */}
|
||||
<span
|
||||
className={classNames(styles.text, canEdit && styles.textEditable)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* eslint-enable jsx-a11y/click-events-have-key-events,
|
||||
jsx-a11y/no-static-element-interactions */}
|
||||
<span className={classNames(styles.task, isCompleted && styles.taskCompleted)}>
|
||||
{name}
|
||||
</span>
|
||||
</span>
|
||||
{isPersisted && canEdit && (
|
||||
<ActionsPopup onNameEdit={handleNameEdit} onDelete={onDelete}>
|
||||
<Button className={classNames(styles.button, styles.target)}>
|
||||
<Icon fitted name="pencil" size="small" />
|
||||
</Button>
|
||||
</ActionsPopup>
|
||||
)}
|
||||
</div>
|
||||
</NameEdit>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<span className={styles.checkboxWrapper}>
|
||||
<Checkbox
|
||||
checked={isCompleted}
|
||||
disabled={!isPersisted || !canEdit}
|
||||
className={styles.checkbox}
|
||||
onChange={handleToggleChange}
|
||||
/>
|
||||
</span>
|
||||
<NameEdit ref={nameEdit} defaultValue={name} onUpdate={handleNameUpdate}>
|
||||
<div className={classNames(canEdit && styles.contentHoverable)}>
|
||||
{/* eslint-disable jsx-a11y/click-events-have-key-events,
|
||||
jsx-a11y/no-static-element-interactions */}
|
||||
<span
|
||||
className={classNames(styles.text, canEdit && styles.textEditable)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* eslint-enable jsx-a11y/click-events-have-key-events,
|
||||
jsx-a11y/no-static-element-interactions */}
|
||||
<span className={classNames(styles.task, isCompleted && styles.taskCompleted)}>
|
||||
{name}
|
||||
</span>
|
||||
</span>
|
||||
{isPersisted && canEdit && (
|
||||
<ActionsPopup onNameEdit={handleNameEdit} onDelete={onDelete}>
|
||||
<Button className={classNames(styles.button, styles.target)}>
|
||||
<Icon fitted name="pencil" size="small" />
|
||||
</Button>
|
||||
</ActionsPopup>
|
||||
)}
|
||||
</div>
|
||||
</NameEdit>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return isDragging ? ReactDOM.createPortal(contentNode, document.body) : contentNode;
|
||||
}}
|
||||
</Draggable>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Item.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
isCompleted: PropTypes.bool.isRequired,
|
||||
isPersisted: PropTypes.bool.isRequired,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) => (
|
||||
<Item
|
||||
key={item.id}
|
||||
name={item.name}
|
||||
isCompleted={item.isCompleted}
|
||||
isPersisted={item.isPersisted}
|
||||
canEdit={canEdit}
|
||||
onUpdate={(data) => handleUpdate(item.id, data)}
|
||||
onDelete={() => handleDelete(item.id)}
|
||||
/>
|
||||
))}
|
||||
{canEdit && (
|
||||
<Add onCreate={onCreate}>
|
||||
<button type="button" className={styles.taskButton}>
|
||||
<span className={styles.taskButtonText}>
|
||||
{items.length > 0 ? t('action.addAnotherTask') : t('action.addTask')}
|
||||
</span>
|
||||
</button>
|
||||
</Add>
|
||||
)}
|
||||
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="tasks" type={DroppableTypes.TASK}>
|
||||
{({ innerRef, droppableProps, placeholder }) => (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<div {...droppableProps} ref={innerRef}>
|
||||
{items.map((item, index) => (
|
||||
<Item
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
index={index}
|
||||
name={item.name}
|
||||
isCompleted={item.isCompleted}
|
||||
isPersisted={item.isPersisted}
|
||||
canEdit={canEdit}
|
||||
onUpdate={(data) => handleUpdate(item.id, data)}
|
||||
onDelete={() => handleDelete(item.id)}
|
||||
/>
|
||||
))}
|
||||
{placeholder}
|
||||
{canEdit && (
|
||||
<Add onCreate={onCreate}>
|
||||
<button type="button" className={styles.taskButton}>
|
||||
<span className={styles.taskButtonText}>
|
||||
{items.length > 0 ? t('action.addAnotherTask') : t('action.addTask')}
|
||||
</span>
|
||||
</button>
|
||||
</Add>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
const BOARD = 'BOARD';
|
||||
const LIST = 'LIST';
|
||||
const CARD = 'CARD';
|
||||
const TASK = 'TASK';
|
||||
|
||||
export default {
|
||||
BOARD,
|
||||
LIST,
|
||||
CARD,
|
||||
TASK,
|
||||
};
|
||||
|
|
|
@ -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`,
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -32,14 +32,12 @@ const Markdown = React.memo(({ linkStopPropagation, ...props }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
<ReactMarkdown
|
||||
{...props}
|
||||
{...props} // eslint-disable-line react/jsx-props-no-spreading
|
||||
components={components}
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
className="markdown-body"
|
||||
/>
|
||||
/* eslint-enable react/jsx-props-no-spreading */
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -206,7 +206,7 @@ export default class extends Model {
|
|||
}
|
||||
|
||||
getOrderedTasksQuerySet() {
|
||||
return this.tasks.orderBy('id');
|
||||
return this.tasks.orderBy('position');
|
||||
}
|
||||
|
||||
getOrderedAttachmentsQuerySet() {
|
||||
|
|
|
@ -7,6 +7,7 @@ export default class extends Model {
|
|||
|
||||
static fields = {
|
||||
id: attr(),
|
||||
position: attr(),
|
||||
name: attr(),
|
||||
isCompleted: attr({
|
||||
getDefault: () => false,
|
||||
|
|
|
@ -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));
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -71,7 +71,7 @@ export const makeTasksByCardIdSelector = () =>
|
|||
return cardModel;
|
||||
}
|
||||
|
||||
return cardModel.tasks.toRefArray();
|
||||
return cardModel.getOrderedTasksQuerySet().toRefArray();
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
|
@ -10,4 +10,5 @@ export * from './board';
|
|||
export * from './board-membership';
|
||||
export * from './list';
|
||||
export * from './card';
|
||||
export * from './task';
|
||||
export * from './attachment';
|
||||
|
|
20
client/src/selectors/task.js
Normal file
20
client/src/selectors/task.js
Normal file
|
@ -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();
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -7,6 +7,6 @@ module.exports = {
|
|||
},
|
||||
|
||||
async fn(inputs) {
|
||||
return Task.find(inputs.criteria).sort('id');
|
||||
return Task.find(inputs.criteria).sort('position');
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -11,6 +11,10 @@ module.exports = {
|
|||
// ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
|
||||
// ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝
|
||||
|
||||
position: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
|
|
|
@ -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');
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue