mirror of
https://github.com/plankanban/planka.git
synced 2025-07-21 22:29:42 +02:00
parent
ddf9a32ea9
commit
ad67b9ba24
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) => ({
|
export const deleteTask = (id) => ({
|
||||||
type: EntryActionTypes.TASK_DELETE,
|
type: EntryActionTypes.TASK_DELETE,
|
||||||
payload: {
|
payload: {
|
||||||
|
|
|
@ -114,6 +114,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
|
cursor: auto;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,4 +37,4 @@
|
||||||
color: #092d42;
|
color: #092d42;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,6 +61,7 @@ const CardModal = React.memo(
|
||||||
onLabelDelete,
|
onLabelDelete,
|
||||||
onTaskCreate,
|
onTaskCreate,
|
||||||
onTaskUpdate,
|
onTaskUpdate,
|
||||||
|
onTaskMove,
|
||||||
onTaskDelete,
|
onTaskDelete,
|
||||||
onAttachmentCreate,
|
onAttachmentCreate,
|
||||||
onAttachmentUpdate,
|
onAttachmentUpdate,
|
||||||
|
@ -331,6 +332,7 @@ const CardModal = React.memo(
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
onCreate={onTaskCreate}
|
onCreate={onTaskCreate}
|
||||||
onUpdate={onTaskUpdate}
|
onUpdate={onTaskUpdate}
|
||||||
|
onMove={onTaskMove}
|
||||||
onDelete={onTaskDelete}
|
onDelete={onTaskDelete}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -513,6 +515,7 @@ CardModal.propTypes = {
|
||||||
onLabelDelete: PropTypes.func.isRequired,
|
onLabelDelete: PropTypes.func.isRequired,
|
||||||
onTaskCreate: PropTypes.func.isRequired,
|
onTaskCreate: PropTypes.func.isRequired,
|
||||||
onTaskUpdate: PropTypes.func.isRequired,
|
onTaskUpdate: PropTypes.func.isRequired,
|
||||||
|
onTaskMove: PropTypes.func.isRequired,
|
||||||
onTaskDelete: PropTypes.func.isRequired,
|
onTaskDelete: PropTypes.func.isRequired,
|
||||||
onAttachmentCreate: PropTypes.func.isRequired,
|
onAttachmentCreate: PropTypes.func.isRequired,
|
||||||
onAttachmentUpdate: PropTypes.func.isRequired,
|
onAttachmentUpdate: PropTypes.func.isRequired,
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import React, { useCallback, useRef } from 'react';
|
import React, { useCallback, useRef } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { Draggable } from 'react-beautiful-dnd';
|
||||||
import { Button, Checkbox, Icon } from 'semantic-ui-react';
|
import { Button, Checkbox, Icon } from 'semantic-ui-react';
|
||||||
|
|
||||||
import NameEdit from './NameEdit';
|
import NameEdit from './NameEdit';
|
||||||
|
@ -8,72 +10,85 @@ import ActionsPopup from './ActionsPopup';
|
||||||
|
|
||||||
import styles from './Item.module.scss';
|
import styles from './Item.module.scss';
|
||||||
|
|
||||||
const Item = React.memo(({ name, isCompleted, isPersisted, canEdit, onUpdate, onDelete }) => {
|
const Item = React.memo(
|
||||||
const nameEdit = useRef(null);
|
({ id, index, name, isCompleted, isPersisted, canEdit, onUpdate, onDelete }) => {
|
||||||
|
const nameEdit = useRef(null);
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
if (isPersisted && canEdit) {
|
if (isPersisted && canEdit) {
|
||||||
nameEdit.current.open();
|
nameEdit.current.open();
|
||||||
}
|
}
|
||||||
}, [isPersisted, canEdit]);
|
}, [isPersisted, canEdit]);
|
||||||
|
|
||||||
const handleNameUpdate = useCallback(
|
const handleNameUpdate = useCallback(
|
||||||
(newName) => {
|
(newName) => {
|
||||||
|
onUpdate({
|
||||||
|
name: newName,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onUpdate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleChange = useCallback(() => {
|
||||||
onUpdate({
|
onUpdate({
|
||||||
name: newName,
|
isCompleted: !isCompleted,
|
||||||
});
|
});
|
||||||
},
|
}, [isCompleted, onUpdate]);
|
||||||
[onUpdate],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleToggleChange = useCallback(() => {
|
const handleNameEdit = useCallback(() => {
|
||||||
onUpdate({
|
nameEdit.current.open();
|
||||||
isCompleted: !isCompleted,
|
}, []);
|
||||||
});
|
|
||||||
}, [isCompleted, onUpdate]);
|
|
||||||
|
|
||||||
const handleNameEdit = useCallback(() => {
|
return (
|
||||||
nameEdit.current.open();
|
<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 (
|
return isDragging ? ReactDOM.createPortal(contentNode, document.body) : contentNode;
|
||||||
<div className={styles.wrapper}>
|
}}
|
||||||
<span className={styles.checkboxWrapper}>
|
</Draggable>
|
||||||
<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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Item.propTypes = {
|
Item.propTypes = {
|
||||||
|
id: PropTypes.string.isRequired,
|
||||||
|
index: PropTypes.number.isRequired,
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
isCompleted: PropTypes.bool.isRequired,
|
isCompleted: PropTypes.bool.isRequired,
|
||||||
isPersisted: PropTypes.bool.isRequired,
|
isPersisted: PropTypes.bool.isRequired,
|
||||||
|
|
|
@ -27,7 +27,7 @@
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
z-index: 2000;
|
z-index: 2000;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
height: 32px;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contentHoverable:hover {
|
.contentHoverable:hover {
|
||||||
|
@ -69,10 +69,10 @@
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
cursor: auto;
|
||||||
margin-left: -40px;
|
margin-left: -40px;
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: all 0.14s ease-in;
|
|
||||||
width: calc(100% + 40px);
|
width: calc(100% + 40px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,34 @@
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
|
||||||
import { Progress } from 'semantic-ui-react';
|
import { Progress } from 'semantic-ui-react';
|
||||||
|
import { closePopup } from '../../../lib/popup';
|
||||||
|
|
||||||
|
import DroppableTypes from '../../../constants/DroppableTypes';
|
||||||
import Item from './Item';
|
import Item from './Item';
|
||||||
import Add from './Add';
|
import Add from './Add';
|
||||||
|
|
||||||
import styles from './Tasks.module.scss';
|
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 [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(
|
const handleUpdate = useCallback(
|
||||||
(id, data) => {
|
(id, data) => {
|
||||||
onUpdate(id, data);
|
onUpdate(id, data);
|
||||||
|
@ -39,26 +57,38 @@ const Tasks = React.memo(({ items, canEdit, onCreate, onUpdate, onDelete }) => {
|
||||||
className={styles.progress}
|
className={styles.progress}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{items.map((item) => (
|
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||||
<Item
|
<Droppable droppableId="tasks" type={DroppableTypes.TASK}>
|
||||||
key={item.id}
|
{({ innerRef, droppableProps, placeholder }) => (
|
||||||
name={item.name}
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
isCompleted={item.isCompleted}
|
<div {...droppableProps} ref={innerRef}>
|
||||||
isPersisted={item.isPersisted}
|
{items.map((item, index) => (
|
||||||
canEdit={canEdit}
|
<Item
|
||||||
onUpdate={(data) => handleUpdate(item.id, data)}
|
key={item.id}
|
||||||
onDelete={() => handleDelete(item.id)}
|
id={item.id}
|
||||||
/>
|
index={index}
|
||||||
))}
|
name={item.name}
|
||||||
{canEdit && (
|
isCompleted={item.isCompleted}
|
||||||
<Add onCreate={onCreate}>
|
isPersisted={item.isPersisted}
|
||||||
<button type="button" className={styles.taskButton}>
|
canEdit={canEdit}
|
||||||
<span className={styles.taskButtonText}>
|
onUpdate={(data) => handleUpdate(item.id, data)}
|
||||||
{items.length > 0 ? t('action.addAnotherTask') : t('action.addTask')}
|
onDelete={() => handleDelete(item.id)}
|
||||||
</span>
|
/>
|
||||||
</button>
|
))}
|
||||||
</Add>
|
{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,
|
canEdit: PropTypes.bool.isRequired,
|
||||||
onCreate: PropTypes.func.isRequired,
|
onCreate: PropTypes.func.isRequired,
|
||||||
onUpdate: PropTypes.func.isRequired,
|
onUpdate: PropTypes.func.isRequired,
|
||||||
|
onMove: PropTypes.func.isRequired,
|
||||||
onDelete: PropTypes.func.isRequired,
|
onDelete: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
const BOARD = 'BOARD';
|
const BOARD = 'BOARD';
|
||||||
const LIST = 'LIST';
|
const LIST = 'LIST';
|
||||||
const CARD = 'CARD';
|
const CARD = 'CARD';
|
||||||
|
const TASK = 'TASK';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
BOARD,
|
BOARD,
|
||||||
LIST,
|
LIST,
|
||||||
CARD,
|
CARD,
|
||||||
|
TASK,
|
||||||
};
|
};
|
||||||
|
|
|
@ -138,6 +138,7 @@ export default {
|
||||||
TASK_CREATE_HANDLE: `${PREFIX}/TASK_CREATE_HANDLE`,
|
TASK_CREATE_HANDLE: `${PREFIX}/TASK_CREATE_HANDLE`,
|
||||||
TASK_UPDATE: `${PREFIX}/TASK_UPDATE`,
|
TASK_UPDATE: `${PREFIX}/TASK_UPDATE`,
|
||||||
TASK_UPDATE_HANDLE: `${PREFIX}/TASK_UPDATE_HANDLE`,
|
TASK_UPDATE_HANDLE: `${PREFIX}/TASK_UPDATE_HANDLE`,
|
||||||
|
TASK_MOVE: `${PREFIX}/TASK_MOVE`,
|
||||||
TASK_DELETE: `${PREFIX}/TASK_DELETE`,
|
TASK_DELETE: `${PREFIX}/TASK_DELETE`,
|
||||||
TASK_DELETE_HANDLE: `${PREFIX}/TASK_DELETE_HANDLE`,
|
TASK_DELETE_HANDLE: `${PREFIX}/TASK_DELETE_HANDLE`,
|
||||||
|
|
||||||
|
|
|
@ -32,6 +32,7 @@ import {
|
||||||
fetchActionsInCurrentCard,
|
fetchActionsInCurrentCard,
|
||||||
fetchBoard,
|
fetchBoard,
|
||||||
moveCurrentCard,
|
moveCurrentCard,
|
||||||
|
moveTask,
|
||||||
removeLabelFromCurrentCard,
|
removeLabelFromCurrentCard,
|
||||||
removeUserFromCurrentCard,
|
removeUserFromCurrentCard,
|
||||||
transferCurrentCard,
|
transferCurrentCard,
|
||||||
|
@ -111,6 +112,7 @@ const mapDispatchToProps = (dispatch) =>
|
||||||
onLabelDelete: deleteLabel,
|
onLabelDelete: deleteLabel,
|
||||||
onTaskCreate: createTaskInCurrentCard,
|
onTaskCreate: createTaskInCurrentCard,
|
||||||
onTaskUpdate: updateTask,
|
onTaskUpdate: updateTask,
|
||||||
|
onTaskMove: moveTask,
|
||||||
onTaskDelete: deleteTask,
|
onTaskDelete: deleteTask,
|
||||||
onAttachmentCreate: createAttachmentInCurrentCard,
|
onAttachmentCreate: createAttachmentInCurrentCard,
|
||||||
onAttachmentUpdate: updateAttachment,
|
onAttachmentUpdate: updateAttachment,
|
||||||
|
|
|
@ -32,14 +32,12 @@ const Markdown = React.memo(({ linkStopPropagation, ...props }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
/* eslint-disable react/jsx-props-no-spreading */
|
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
{...props}
|
{...props} // eslint-disable-line react/jsx-props-no-spreading
|
||||||
components={components}
|
components={components}
|
||||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||||
className="markdown-body"
|
className="markdown-body"
|
||||||
/>
|
/>
|
||||||
/* eslint-enable react/jsx-props-no-spreading */
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -206,7 +206,7 @@ export default class extends Model {
|
||||||
}
|
}
|
||||||
|
|
||||||
getOrderedTasksQuerySet() {
|
getOrderedTasksQuerySet() {
|
||||||
return this.tasks.orderBy('id');
|
return this.tasks.orderBy('position');
|
||||||
}
|
}
|
||||||
|
|
||||||
getOrderedAttachmentsQuerySet() {
|
getOrderedAttachmentsQuerySet() {
|
||||||
|
|
|
@ -7,6 +7,7 @@ export default class extends Model {
|
||||||
|
|
||||||
static fields = {
|
static fields = {
|
||||||
id: attr(),
|
id: attr(),
|
||||||
|
position: attr(),
|
||||||
name: attr(),
|
name: attr(),
|
||||||
isCompleted: attr({
|
isCompleted: attr({
|
||||||
getDefault: () => false,
|
getDefault: () => false,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { call, put, select } from 'redux-saga/effects';
|
import { call, put, select } from 'redux-saga/effects';
|
||||||
|
|
||||||
import request from '../request';
|
import request from '../request';
|
||||||
import { pathSelector } from '../../../selectors';
|
import { nextTaskPositionSelector, pathSelector, taskByIdSelector } from '../../../selectors';
|
||||||
import {
|
import {
|
||||||
createTask,
|
createTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
|
@ -14,11 +14,16 @@ import api from '../../../api';
|
||||||
import { createLocalId } from '../../../utils/local-id';
|
import { createLocalId } from '../../../utils/local-id';
|
||||||
|
|
||||||
export function* createTaskService(cardId, data) {
|
export function* createTaskService(cardId, data) {
|
||||||
|
const nextData = {
|
||||||
|
...data,
|
||||||
|
position: yield select(nextTaskPositionSelector, cardId),
|
||||||
|
};
|
||||||
|
|
||||||
const localId = yield call(createLocalId);
|
const localId = yield call(createLocalId);
|
||||||
|
|
||||||
yield put(
|
yield put(
|
||||||
createTask({
|
createTask({
|
||||||
...data,
|
...nextData,
|
||||||
cardId,
|
cardId,
|
||||||
id: localId,
|
id: localId,
|
||||||
}),
|
}),
|
||||||
|
@ -26,7 +31,7 @@ export function* createTaskService(cardId, data) {
|
||||||
|
|
||||||
let task;
|
let task;
|
||||||
try {
|
try {
|
||||||
({ item: task } = yield call(request, api.createTask, cardId, data));
|
({ item: task } = yield call(request, api.createTask, cardId, nextData));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
yield put(createTask.failure(localId, error));
|
yield put(createTask.failure(localId, error));
|
||||||
return;
|
return;
|
||||||
|
@ -63,6 +68,15 @@ export function* handleTaskUpdateService(task) {
|
||||||
yield put(handleTaskUpdate(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) {
|
export function* deleteTaskService(id) {
|
||||||
yield put(deleteTask(id));
|
yield put(deleteTask(id));
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
handleTaskCreateService,
|
handleTaskCreateService,
|
||||||
handleTaskDeleteService,
|
handleTaskDeleteService,
|
||||||
handleTaskUpdateService,
|
handleTaskUpdateService,
|
||||||
|
moveTaskService,
|
||||||
updateTaskService,
|
updateTaskService,
|
||||||
} from '../services';
|
} from '../services';
|
||||||
import EntryActionTypes from '../../../constants/EntryActionTypes';
|
import EntryActionTypes from '../../../constants/EntryActionTypes';
|
||||||
|
@ -24,6 +25,9 @@ export default function* taskWatchers() {
|
||||||
takeEvery(EntryActionTypes.TASK_UPDATE_HANDLE, ({ payload: { task } }) =>
|
takeEvery(EntryActionTypes.TASK_UPDATE_HANDLE, ({ payload: { task } }) =>
|
||||||
handleTaskUpdateService(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, ({ payload: { id } }) => deleteTaskService(id)),
|
||||||
takeEvery(EntryActionTypes.TASK_DELETE_HANDLE, ({ payload: { task } }) =>
|
takeEvery(EntryActionTypes.TASK_DELETE_HANDLE, ({ payload: { task } }) =>
|
||||||
handleTaskDeleteService(task),
|
handleTaskDeleteService(task),
|
||||||
|
|
|
@ -71,7 +71,7 @@ export const makeTasksByCardIdSelector = () =>
|
||||||
return cardModel;
|
return cardModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cardModel.tasks.toRefArray();
|
return cardModel.getOrderedTasksQuerySet().toRefArray();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -76,3 +76,19 @@ export const nextCardPositionSelector = createSelector(
|
||||||
return nextPosition(listModel.getOrderedFilteredCardsModelArray(), index, excludedId);
|
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 './board-membership';
|
||||||
export * from './list';
|
export * from './list';
|
||||||
export * from './card';
|
export * from './card';
|
||||||
|
export * from './task';
|
||||||
export * from './attachment';
|
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]+$/,
|
regex: /^[0-9]+$/,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
position: {
|
||||||
|
type: 'number',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
name: {
|
name: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
required: true,
|
required: true,
|
||||||
|
@ -39,7 +43,7 @@ module.exports = {
|
||||||
throw Errors.CARD_NOT_FOUND; // Forbidden
|
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);
|
const task = await sails.helpers.tasks.createOne(values, card, this.req);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -11,6 +11,9 @@ module.exports = {
|
||||||
regex: /^[0-9]+$/,
|
regex: /^[0-9]+$/,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
position: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
name: {
|
name: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
isNotEmptyString: true,
|
isNotEmptyString: true,
|
||||||
|
@ -42,7 +45,7 @@ module.exports = {
|
||||||
throw Errors.TASK_NOT_FOUND; // Forbidden
|
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);
|
task = await sails.helpers.tasks.updateOne(task, values, board, this.req);
|
||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
|
|
|
@ -5,11 +5,23 @@ module.exports = {
|
||||||
custom: (value) => _.isString(value) || _.every(value, _.isString),
|
custom: (value) => _.isString(value) || _.every(value, _.isString),
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
exceptTaskIdOrIds: {
|
||||||
|
type: 'json',
|
||||||
|
custom: (value) => _.isString(value) || _.every(value, _.isString),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
async fn(inputs) {
|
async fn(inputs) {
|
||||||
return sails.helpers.tasks.getMany({
|
const criteria = {
|
||||||
cardId: inputs.idOrIds,
|
cardId: inputs.idOrIds,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (!_.isUndefined(inputs.exceptTaskIdOrIds)) {
|
||||||
|
criteria.id = {
|
||||||
|
'!=': inputs.exceptTaskIdOrIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return sails.helpers.tasks.getMany(criteria);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,6 +2,7 @@ module.exports = {
|
||||||
inputs: {
|
inputs: {
|
||||||
values: {
|
values: {
|
||||||
type: 'json',
|
type: 'json',
|
||||||
|
custom: (value) => _.isPlainObject(value) && _.isFinite(value.position),
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
|
@ -14,8 +15,32 @@ module.exports = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async fn(inputs) {
|
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({
|
const task = await Task.create({
|
||||||
...inputs.values,
|
...inputs.values,
|
||||||
|
position,
|
||||||
cardId: inputs.card.id,
|
cardId: inputs.card.id,
|
||||||
}).fetch();
|
}).fetch();
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,6 @@ module.exports = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async fn(inputs) {
|
async fn(inputs) {
|
||||||
return Task.find(inputs.criteria).sort('id');
|
return Task.find(inputs.criteria).sort('position');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,6 +6,17 @@ module.exports = {
|
||||||
},
|
},
|
||||||
values: {
|
values: {
|
||||||
type: 'json',
|
type: 'json',
|
||||||
|
custom: (value) => {
|
||||||
|
if (!_.isPlainObject(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_.isUndefined(value.position) && !_.isFinite(value.position)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
board: {
|
board: {
|
||||||
|
@ -18,6 +29,33 @@ module.exports = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async fn(inputs) {
|
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);
|
const task = await Task.updateOne(inputs.record.id).set(inputs.values);
|
||||||
|
|
||||||
if (task) {
|
if (task) {
|
||||||
|
|
|
@ -11,6 +11,10 @@ module.exports = {
|
||||||
// ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
|
// ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
|
||||||
// ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝
|
// ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝
|
||||||
|
|
||||||
|
position: {
|
||||||
|
type: 'number',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
name: {
|
name: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
required: true,
|
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