mirror of
https://github.com/plankanban/planka.git
synced 2025-07-18 12:49:43 +02:00
parent
d260d2dac0
commit
a741e26ccb
33 changed files with 370 additions and 104 deletions
|
@ -25,6 +25,7 @@ const BoardActions = React.memo(
|
|||
onLabelFromFilterRemove,
|
||||
onLabelCreate,
|
||||
onLabelUpdate,
|
||||
onLabelMove,
|
||||
onLabelDelete,
|
||||
}) => {
|
||||
return (
|
||||
|
@ -54,6 +55,7 @@ const BoardActions = React.memo(
|
|||
onLabelRemove={onLabelFromFilterRemove}
|
||||
onLabelCreate={onLabelCreate}
|
||||
onLabelUpdate={onLabelUpdate}
|
||||
onLabelMove={onLabelMove}
|
||||
onLabelDelete={onLabelDelete}
|
||||
/>
|
||||
</div>
|
||||
|
@ -82,6 +84,7 @@ BoardActions.propTypes = {
|
|||
onLabelFromFilterRemove: PropTypes.func.isRequired,
|
||||
onLabelCreate: PropTypes.func.isRequired,
|
||||
onLabelUpdate: PropTypes.func.isRequired,
|
||||
onLabelMove: PropTypes.func.isRequired,
|
||||
onLabelDelete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ const Filters = React.memo(
|
|||
onLabelRemove,
|
||||
onLabelCreate,
|
||||
onLabelUpdate,
|
||||
onLabelMove,
|
||||
onLabelDelete,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
|
@ -80,6 +81,7 @@ const Filters = React.memo(
|
|||
onDeselect={onLabelRemove}
|
||||
onCreate={onLabelCreate}
|
||||
onUpdate={onLabelUpdate}
|
||||
onMove={onLabelMove}
|
||||
onDelete={onLabelDelete}
|
||||
>
|
||||
<button type="button" className={styles.filterButton}>
|
||||
|
@ -117,6 +119,7 @@ Filters.propTypes = {
|
|||
onLabelRemove: PropTypes.func.isRequired,
|
||||
onLabelCreate: PropTypes.func.isRequired,
|
||||
onLabelUpdate: PropTypes.func.isRequired,
|
||||
onLabelMove: PropTypes.func.isRequired,
|
||||
onLabelDelete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
|
|
|
@ -45,6 +45,7 @@ const ActionsStep = React.memo(
|
|||
onLabelRemove,
|
||||
onLabelCreate,
|
||||
onLabelUpdate,
|
||||
onLabelMove,
|
||||
onLabelDelete,
|
||||
onClose,
|
||||
}) => {
|
||||
|
@ -119,6 +120,7 @@ const ActionsStep = React.memo(
|
|||
onDeselect={onLabelRemove}
|
||||
onCreate={onLabelCreate}
|
||||
onUpdate={onLabelUpdate}
|
||||
onMove={onLabelMove}
|
||||
onDelete={onLabelDelete}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
|
@ -241,6 +243,7 @@ ActionsStep.propTypes = {
|
|||
onLabelRemove: PropTypes.func.isRequired,
|
||||
onLabelCreate: PropTypes.func.isRequired,
|
||||
onLabelUpdate: PropTypes.func.isRequired,
|
||||
onLabelMove: PropTypes.func.isRequired,
|
||||
onLabelDelete: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
|
|
@ -48,6 +48,7 @@ const Card = React.memo(
|
|||
onLabelRemove,
|
||||
onLabelCreate,
|
||||
onLabelUpdate,
|
||||
onLabelMove,
|
||||
onLabelDelete,
|
||||
}) => {
|
||||
const nameEdit = useRef(null);
|
||||
|
@ -189,6 +190,7 @@ const Card = React.memo(
|
|||
onLabelRemove={onLabelRemove}
|
||||
onLabelCreate={onLabelCreate}
|
||||
onLabelUpdate={onLabelUpdate}
|
||||
onLabelMove={onLabelMove}
|
||||
onLabelDelete={onLabelDelete}
|
||||
>
|
||||
<Button className={classNames(styles.actionsButton, styles.target)}>
|
||||
|
@ -241,6 +243,7 @@ Card.propTypes = {
|
|||
onLabelRemove: PropTypes.func.isRequired,
|
||||
onLabelCreate: PropTypes.func.isRequired,
|
||||
onLabelUpdate: PropTypes.func.isRequired,
|
||||
onLabelMove: PropTypes.func.isRequired,
|
||||
onLabelDelete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
|
|
|
@ -62,6 +62,7 @@ const CardModal = React.memo(
|
|||
onLabelRemove,
|
||||
onLabelCreate,
|
||||
onLabelUpdate,
|
||||
onLabelMove,
|
||||
onLabelDelete,
|
||||
onTaskCreate,
|
||||
onTaskUpdate,
|
||||
|
@ -235,6 +236,7 @@ const CardModal = React.memo(
|
|||
onDeselect={onLabelRemove}
|
||||
onCreate={onLabelCreate}
|
||||
onUpdate={onLabelUpdate}
|
||||
onMove={onLabelMove}
|
||||
onDelete={onLabelDelete}
|
||||
>
|
||||
<Label name={label.name} color={label.color} />
|
||||
|
@ -252,6 +254,7 @@ const CardModal = React.memo(
|
|||
onDeselect={onLabelRemove}
|
||||
onCreate={onLabelCreate}
|
||||
onUpdate={onLabelUpdate}
|
||||
onMove={onLabelMove}
|
||||
onDelete={onLabelDelete}
|
||||
>
|
||||
<button
|
||||
|
@ -419,6 +422,7 @@ const CardModal = React.memo(
|
|||
onDeselect={onLabelRemove}
|
||||
onCreate={onLabelCreate}
|
||||
onUpdate={onLabelUpdate}
|
||||
onMove={onLabelMove}
|
||||
onDelete={onLabelDelete}
|
||||
>
|
||||
<Button fluid className={styles.actionButton}>
|
||||
|
@ -546,6 +550,7 @@ CardModal.propTypes = {
|
|||
onLabelRemove: PropTypes.func.isRequired,
|
||||
onLabelCreate: PropTypes.func.isRequired,
|
||||
onLabelUpdate: PropTypes.func.isRequired,
|
||||
onLabelMove: PropTypes.func.isRequired,
|
||||
onLabelDelete: PropTypes.func.isRequired,
|
||||
onTaskCreate: PropTypes.func.isRequired,
|
||||
onTaskUpdate: PropTypes.func.isRequired,
|
||||
|
|
|
@ -1,53 +1,69 @@
|
|||
import upperFirst from 'lodash/upperFirst';
|
||||
import camelCase from 'lodash/camelCase';
|
||||
import React, { useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
import { Button } from 'semantic-ui-react';
|
||||
|
||||
import styles from './Item.module.scss';
|
||||
import globalStyles from '../../styles.module.scss';
|
||||
|
||||
const Item = React.memo(
|
||||
({ name, color, isPersisted, isActive, canEdit, onSelect, onDeselect, onEdit }) => {
|
||||
({ id, index, name, color, isPersisted, isActive, canEdit, onSelect, onDeselect, onEdit }) => {
|
||||
const handleToggleClick = useCallback(() => {
|
||||
if (isActive) {
|
||||
onDeselect();
|
||||
} else {
|
||||
onSelect();
|
||||
if (isPersisted) {
|
||||
if (isActive) {
|
||||
onDeselect();
|
||||
} else {
|
||||
onSelect();
|
||||
}
|
||||
}
|
||||
}, [isActive, onSelect, onDeselect]);
|
||||
}, [isPersisted, isActive, onSelect, onDeselect]);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Button
|
||||
fluid
|
||||
content={name}
|
||||
active={isActive}
|
||||
disabled={!isPersisted}
|
||||
className={classNames(
|
||||
styles.labelButton,
|
||||
isActive && styles.labelButtonActive,
|
||||
globalStyles[`background${upperFirst(camelCase(color))}`],
|
||||
)}
|
||||
onClick={handleToggleClick}
|
||||
/>
|
||||
{canEdit && (
|
||||
<Button
|
||||
icon="pencil"
|
||||
size="small"
|
||||
floated="right"
|
||||
disabled={!isPersisted}
|
||||
className={styles.editButton}
|
||||
onClick={onEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<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} ref={innerRef} className={styles.wrapper}>
|
||||
{/* eslint-disable jsx-a11y/click-events-have-key-events,
|
||||
jsx-a11y/no-static-element-interactions */}
|
||||
<span
|
||||
{...dragHandleProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
className={classNames(
|
||||
styles.name,
|
||||
isActive && styles.nameActive,
|
||||
globalStyles[`background${upperFirst(camelCase(color))}`],
|
||||
)}
|
||||
onClick={handleToggleClick}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
{canEdit && (
|
||||
<Button
|
||||
icon="pencil"
|
||||
size="small"
|
||||
floated="right"
|
||||
disabled={!isPersisted}
|
||||
className={styles.editButton}
|
||||
onClick={onEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return isDragging ? ReactDOM.createPortal(contentNode, document.body) : contentNode;
|
||||
}}
|
||||
</Draggable>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Item.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
name: PropTypes.string,
|
||||
color: PropTypes.string.isRequired,
|
||||
isPersisted: PropTypes.bool.isRequired,
|
||||
|
|
|
@ -12,16 +12,15 @@
|
|||
}
|
||||
}
|
||||
|
||||
.labelButton {
|
||||
.name {
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
flex: 1 1 auto;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
margin-right: 0;
|
||||
overflow: hidden;
|
||||
padding: 8px 32px 8px 10px;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.2);
|
||||
|
||||
|
@ -30,7 +29,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.labelButtonActive:before {
|
||||
.nameActive:before {
|
||||
bottom: 1px;
|
||||
content: "Г";
|
||||
font-size: 18px;
|
||||
|
|
|
@ -2,10 +2,12 @@ import pick from 'lodash/pick';
|
|||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
|
||||
import { Button } from 'semantic-ui-react';
|
||||
import { Input, Popup } from '../../lib/custom-ui';
|
||||
|
||||
import { useField, useSteps } from '../../hooks';
|
||||
import DroppableTypes from '../../constants/DroppableTypes';
|
||||
import AddStep from './AddStep';
|
||||
import EditStep from './EditStep';
|
||||
import Item from './Item';
|
||||
|
@ -27,6 +29,7 @@ const LabelsStep = React.memo(
|
|||
onDeselect,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
onMove,
|
||||
onDelete,
|
||||
onBack,
|
||||
}) => {
|
||||
|
@ -74,6 +77,17 @@ const LabelsStep = React.memo(
|
|||
[onDeselect],
|
||||
);
|
||||
|
||||
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);
|
||||
|
@ -145,21 +159,45 @@ const LabelsStep = React.memo(
|
|||
onChange={handleSearchChange}
|
||||
/>
|
||||
{filteredItems.length > 0 && (
|
||||
<div className={styles.items}>
|
||||
{filteredItems.map((item) => (
|
||||
<Item
|
||||
key={item.id}
|
||||
name={item.name}
|
||||
color={item.color}
|
||||
isPersisted={item.isPersisted}
|
||||
isActive={currentIds.includes(item.id)}
|
||||
canEdit={canEdit}
|
||||
onSelect={() => handleSelect(item.id)}
|
||||
onDeselect={() => handleDeselect(item.id)}
|
||||
onEdit={() => handleEdit(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="labels" type={DroppableTypes.LABEL}>
|
||||
{({ innerRef, droppableProps, placeholder }) => (
|
||||
<div
|
||||
{...droppableProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
ref={innerRef}
|
||||
className={styles.items}
|
||||
>
|
||||
{filteredItems.map((item, index) => (
|
||||
<Item
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
index={index}
|
||||
name={item.name}
|
||||
color={item.color}
|
||||
isPersisted={item.isPersisted}
|
||||
isActive={currentIds.includes(item.id)}
|
||||
canEdit={canEdit}
|
||||
onSelect={() => handleSelect(item.id)}
|
||||
onDeselect={() => handleDeselect(item.id)}
|
||||
onEdit={() => handleEdit(item.id)}
|
||||
/>
|
||||
))}
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
<Droppable droppableId="labels:hack" type={DroppableTypes.LABEL}>
|
||||
{({ innerRef, droppableProps, placeholder }) => (
|
||||
<div
|
||||
{...droppableProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
ref={innerRef}
|
||||
className={styles.droppableHack}
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button
|
||||
|
@ -186,6 +224,7 @@ LabelsStep.propTypes = {
|
|||
onDeselect: PropTypes.func.isRequired,
|
||||
onCreate: PropTypes.func.isRequired,
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
onMove: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onBack: PropTypes.func,
|
||||
};
|
||||
|
|
|
@ -15,6 +15,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
.droppableHack {
|
||||
display: none;
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.items {
|
||||
margin-top: 8px;
|
||||
max-height: 60vh;
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
const BOARD = 'BOARD';
|
||||
const LABEL = 'LABEL';
|
||||
const LIST = 'LIST';
|
||||
const CARD = 'CARD';
|
||||
const TASK = 'TASK';
|
||||
|
||||
export default {
|
||||
BOARD,
|
||||
LABEL,
|
||||
LIST,
|
||||
CARD,
|
||||
TASK,
|
||||
|
|
|
@ -99,6 +99,7 @@ export default {
|
|||
LABEL_CREATE_HANDLE: `${PREFIX}/LABEL_CREATE_HANDLE`,
|
||||
LABEL_UPDATE: `${PREFIX}/LABEL_UPDATE`,
|
||||
LABEL_UPDATE_HANDLE: `${PREFIX}/LABEL_UPDATE_HANDLE`,
|
||||
LABEL_MOVE: `${PREFIX}/LABEL_MOVE`,
|
||||
LABEL_DELETE: `${PREFIX}/LABEL_DELETE`,
|
||||
LABEL_DELETE_HANDLE: `${PREFIX}/LABEL_DELETE_HANDLE`,
|
||||
LABEL_TO_CARD_ADD: `${PREFIX}/LABEL_TO_CARD_ADD`,
|
||||
|
|
|
@ -41,6 +41,7 @@ const mapDispatchToProps = (dispatch) =>
|
|||
onLabelFromFilterRemove: entryActions.removeLabelFromFilterInCurrentBoard,
|
||||
onLabelCreate: entryActions.createLabelInCurrentBoard,
|
||||
onLabelUpdate: entryActions.updateLabel,
|
||||
onLabelMove: entryActions.moveLabel,
|
||||
onLabelDelete: entryActions.deleteLabel,
|
||||
},
|
||||
dispatch,
|
||||
|
|
|
@ -70,6 +70,7 @@ const mapDispatchToProps = (dispatch, { id }) =>
|
|||
onLabelRemove: (labelId) => entryActions.removeLabelFromCard(labelId, id),
|
||||
onLabelCreate: (data) => entryActions.createLabelInCurrentBoard(data),
|
||||
onLabelUpdate: (labelId, data) => entryActions.updateLabel(labelId, data),
|
||||
onLabelMove: (labelId, index) => entryActions.moveLabel(labelId, index),
|
||||
onLabelDelete: (labelId) => entryActions.deleteLabel(labelId),
|
||||
},
|
||||
dispatch,
|
||||
|
|
|
@ -86,6 +86,7 @@ const mapDispatchToProps = (dispatch) =>
|
|||
onLabelRemove: entryActions.removeLabelFromCurrentCard,
|
||||
onLabelCreate: entryActions.createLabelInCurrentBoard,
|
||||
onLabelUpdate: entryActions.updateLabel,
|
||||
onLabelMove: entryActions.moveLabel,
|
||||
onLabelDelete: entryActions.deleteLabel,
|
||||
onTaskCreate: entryActions.createTaskInCurrentCard,
|
||||
onTaskUpdate: entryActions.updateTask,
|
||||
|
|
|
@ -29,6 +29,14 @@ const handleLabelUpdate = (label) => ({
|
|||
},
|
||||
});
|
||||
|
||||
const moveLabel = (id, index) => ({
|
||||
type: EntryActionTypes.LABEL_MOVE,
|
||||
payload: {
|
||||
id,
|
||||
index,
|
||||
},
|
||||
});
|
||||
|
||||
const deleteLabel = (id) => ({
|
||||
type: EntryActionTypes.LABEL_DELETE,
|
||||
payload: {
|
||||
|
@ -106,6 +114,7 @@ export default {
|
|||
handleLabelCreate,
|
||||
updateLabel,
|
||||
handleLabelUpdate,
|
||||
moveLabel,
|
||||
deleteLabel,
|
||||
handleLabelDelete,
|
||||
addLabelToCard,
|
||||
|
|
|
@ -175,6 +175,10 @@ export default class extends BaseModel {
|
|||
return this.memberships.orderBy('id');
|
||||
}
|
||||
|
||||
getOrderedLabelsQuerySet() {
|
||||
return this.labels.orderBy('position');
|
||||
}
|
||||
|
||||
getOrderedListsQuerySet() {
|
||||
return this.lists.orderBy('position');
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ export default class extends BaseModel {
|
|||
|
||||
static fields = {
|
||||
id: attr(),
|
||||
position: attr(),
|
||||
name: attr(),
|
||||
color: attr(),
|
||||
boardId: fk({
|
||||
|
|
|
@ -7,11 +7,16 @@ import api from '../../../api';
|
|||
import { createLocalId } from '../../../utils/local-id';
|
||||
|
||||
export function* createLabel(boardId, data) {
|
||||
const nextData = {
|
||||
...data,
|
||||
position: yield select(selectors.selectNextLabelPosition, boardId),
|
||||
};
|
||||
|
||||
const localId = yield call(createLocalId);
|
||||
|
||||
yield put(
|
||||
actions.createLabel({
|
||||
...data,
|
||||
...nextData,
|
||||
boardId,
|
||||
id: localId,
|
||||
}),
|
||||
|
@ -19,7 +24,7 @@ export function* createLabel(boardId, data) {
|
|||
|
||||
let label;
|
||||
try {
|
||||
({ item: label } = yield call(request, api.createLabel, boardId, data));
|
||||
({ item: label } = yield call(request, api.createLabel, boardId, nextData));
|
||||
} catch (error) {
|
||||
yield put(actions.createLabel.failure(localId, error));
|
||||
return;
|
||||
|
@ -56,6 +61,15 @@ export function* handleLabelUpdate(label) {
|
|||
yield put(actions.handleLabelUpdate(label));
|
||||
}
|
||||
|
||||
export function* moveLabel(id, index) {
|
||||
const { boardId } = yield select(selectors.selectLabelById, id);
|
||||
const position = yield select(selectors.selectNextLabelPosition, boardId, index, id);
|
||||
|
||||
yield call(updateLabel, id, {
|
||||
position,
|
||||
});
|
||||
}
|
||||
|
||||
export function* deleteLabel(id) {
|
||||
yield put(actions.deleteLabel(id));
|
||||
|
||||
|
@ -150,6 +164,7 @@ export default {
|
|||
handleLabelCreate,
|
||||
updateLabel,
|
||||
handleLabelUpdate,
|
||||
moveLabel,
|
||||
deleteLabel,
|
||||
handleLabelDelete,
|
||||
addLabelToCard,
|
||||
|
|
|
@ -17,6 +17,9 @@ export default function* labelsWatchers() {
|
|||
takeEvery(EntryActionTypes.LABEL_UPDATE_HANDLE, ({ payload: { label } }) =>
|
||||
services.handleLabelUpdate(label),
|
||||
),
|
||||
takeEvery(EntryActionTypes.LABEL_MOVE, ({ payload: { id, index } }) =>
|
||||
services.moveLabel(id, index),
|
||||
),
|
||||
takeEvery(EntryActionTypes.LABEL_DELETE, ({ payload: { id } }) => services.deleteLabel(id)),
|
||||
takeEvery(EntryActionTypes.LABEL_DELETE_HANDLE, ({ payload: { label } }) =>
|
||||
services.handleLabelDelete(label),
|
||||
|
|
|
@ -108,10 +108,13 @@ export const selectLabelsForCurrentBoard = createSelector(
|
|||
return boardModel;
|
||||
}
|
||||
|
||||
return boardModel.labels.toRefArray().map((label) => ({
|
||||
...label,
|
||||
isPersisted: !isLocalId(label.id),
|
||||
}));
|
||||
return boardModel
|
||||
.getOrderedLabelsQuerySet()
|
||||
.toRefArray()
|
||||
.map((label) => ({
|
||||
...label,
|
||||
isPersisted: !isLocalId(label.id),
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
@ -49,6 +49,22 @@ export const selectNextBoardPosition = createSelector(
|
|||
},
|
||||
);
|
||||
|
||||
export const selectNextLabelPosition = createSelector(
|
||||
orm,
|
||||
(_, boardId) => boardId,
|
||||
(_, __, index) => index,
|
||||
(_, __, ___, excludedId) => excludedId,
|
||||
({ Board }, boardId, index, excludedId) => {
|
||||
const boardModel = Board.withId(boardId);
|
||||
|
||||
if (!boardModel) {
|
||||
return boardModel;
|
||||
}
|
||||
|
||||
return nextPosition(boardModel.getOrderedLabelsQuerySet().toRefArray(), index, excludedId);
|
||||
},
|
||||
);
|
||||
|
||||
export const selectNextListPosition = createSelector(
|
||||
orm,
|
||||
(_, boardId) => boardId,
|
||||
|
@ -102,6 +118,7 @@ export default {
|
|||
selectIsCoreInitializing,
|
||||
selectIsLogouting,
|
||||
selectNextBoardPosition,
|
||||
selectNextLabelPosition,
|
||||
selectNextListPosition,
|
||||
selectNextCardPosition,
|
||||
selectNextTaskPosition,
|
||||
|
|
|
@ -7,6 +7,7 @@ import projects from './projects';
|
|||
import projectManagers from './project-managers';
|
||||
import boards from './boards';
|
||||
import boardMemberships from './board-memberships';
|
||||
import labels from './labels';
|
||||
import lists from './lists';
|
||||
import cards from './cards';
|
||||
import tasks from './tasks';
|
||||
|
@ -22,6 +23,7 @@ export default {
|
|||
...projectManagers,
|
||||
...boards,
|
||||
...boardMemberships,
|
||||
...labels,
|
||||
...lists,
|
||||
...cards,
|
||||
...tasks,
|
||||
|
|
25
client/src/selectors/labels.js
Normal file
25
client/src/selectors/labels.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { createSelector } from 'redux-orm';
|
||||
|
||||
import orm from '../orm';
|
||||
|
||||
export const makeSelectLabelById = () =>
|
||||
createSelector(
|
||||
orm,
|
||||
(_, id) => id,
|
||||
({ Label }, id) => {
|
||||
const labelModel = Label.withId(id);
|
||||
|
||||
if (!labelModel) {
|
||||
return labelModel;
|
||||
}
|
||||
|
||||
return labelModel.ref;
|
||||
},
|
||||
);
|
||||
|
||||
export const selectLabelById = makeSelectLabelById();
|
||||
|
||||
export default {
|
||||
makeSelectLabelById,
|
||||
selectLabelById,
|
||||
};
|
|
@ -14,6 +14,10 @@ module.exports = {
|
|||
regex: /^[0-9]+$/,
|
||||
required: true,
|
||||
},
|
||||
position: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
isNotEmptyString: true,
|
||||
|
@ -55,7 +59,7 @@ module.exports = {
|
|||
throw Errors.NOT_ENOUGH_RIGHTS;
|
||||
}
|
||||
|
||||
const values = _.pick(inputs, ['name', 'color']);
|
||||
const values = _.pick(inputs, ['position', 'name', 'color']);
|
||||
|
||||
const label = await sails.helpers.labels.createOne.with({
|
||||
values: {
|
||||
|
|
|
@ -14,6 +14,9 @@ module.exports = {
|
|||
regex: /^[0-9]+$/,
|
||||
required: true,
|
||||
},
|
||||
position: {
|
||||
type: 'number',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
isNotEmptyString: true,
|
||||
|
@ -22,7 +25,6 @@ module.exports = {
|
|||
color: {
|
||||
type: 'string',
|
||||
isIn: Label.COLORS,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
|
@ -55,7 +57,7 @@ module.exports = {
|
|||
throw Errors.NOT_ENOUGH_RIGHTS;
|
||||
}
|
||||
|
||||
const values = _.pick(inputs, ['name', 'color']);
|
||||
const values = _.pick(inputs, ['position', 'name', 'color']);
|
||||
|
||||
label = await sails.helpers.labels.updateOne.with({
|
||||
values,
|
||||
|
|
|
@ -7,11 +7,23 @@ module.exports = {
|
|||
custom: idOrIdsValidator,
|
||||
required: true,
|
||||
},
|
||||
exceptLabelIdOrIds: {
|
||||
type: 'json',
|
||||
custom: idOrIdsValidator,
|
||||
},
|
||||
},
|
||||
|
||||
async fn(inputs) {
|
||||
return sails.helpers.labels.getMany({
|
||||
const criteria = {
|
||||
boardId: inputs.idOrIds,
|
||||
});
|
||||
};
|
||||
|
||||
if (!_.isUndefined(inputs.exceptLabelIdOrIds)) {
|
||||
criteria.id = {
|
||||
'!=': inputs.exceptLabelIdOrIds,
|
||||
};
|
||||
}
|
||||
|
||||
return sails.helpers.labels.getMany(criteria);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -3,6 +3,10 @@ const valuesValidator = (value) => {
|
|||
return false;
|
||||
}
|
||||
|
||||
if (!_.isFinite(value.position)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_.isPlainObject(value.board)) {
|
||||
return false;
|
||||
}
|
||||
|
@ -25,8 +29,32 @@ module.exports = {
|
|||
async fn(inputs) {
|
||||
const { values } = inputs;
|
||||
|
||||
const labels = await sails.helpers.boards.getLabels(values.board.id);
|
||||
|
||||
const { position, repositions } = sails.helpers.utils.insertToPositionables(
|
||||
values.position,
|
||||
labels,
|
||||
);
|
||||
|
||||
repositions.forEach(async ({ id, position: nextPosition }) => {
|
||||
await Label.update({
|
||||
id,
|
||||
boardId: values.board.id,
|
||||
}).set({
|
||||
position: nextPosition,
|
||||
});
|
||||
|
||||
sails.sockets.broadcast(`board:${values.board.id}`, 'labelUpdate', {
|
||||
item: {
|
||||
id,
|
||||
position: nextPosition,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const label = await Label.create({
|
||||
...values,
|
||||
position,
|
||||
boardId: values.board.id,
|
||||
}).fetch();
|
||||
|
||||
|
|
|
@ -9,6 +9,6 @@ module.exports = {
|
|||
},
|
||||
|
||||
async fn(inputs) {
|
||||
return Label.find(inputs.criteria).sort('id');
|
||||
return Label.find(inputs.criteria).sort('position');
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,3 +1,15 @@
|
|||
const valuesValidator = (value) => {
|
||||
if (!_.isPlainObject(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_.isUndefined(value.position) && !_.isFinite(value.position)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
inputs: {
|
||||
record: {
|
||||
|
@ -6,6 +18,7 @@ module.exports = {
|
|||
},
|
||||
values: {
|
||||
type: 'json',
|
||||
custom: valuesValidator,
|
||||
required: true,
|
||||
},
|
||||
request: {
|
||||
|
@ -16,6 +29,33 @@ module.exports = {
|
|||
async fn(inputs) {
|
||||
const { values } = inputs;
|
||||
|
||||
if (!_.isUndefined(values.position)) {
|
||||
const labels = await sails.helpers.boards.getLabels(inputs.record.boardId, inputs.record.id);
|
||||
|
||||
const { position, repositions } = sails.helpers.utils.insertToPositionables(
|
||||
values.position,
|
||||
labels,
|
||||
);
|
||||
|
||||
values.position = position;
|
||||
|
||||
repositions.forEach(async ({ id, position: nextPosition }) => {
|
||||
await Label.update({
|
||||
id,
|
||||
boardId: inputs.record.boardId,
|
||||
}).set({
|
||||
position: nextPosition,
|
||||
});
|
||||
|
||||
sails.sockets.broadcast(`board:${inputs.record.boardId}`, 'labelUpdate', {
|
||||
item: {
|
||||
id,
|
||||
position: nextPosition,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const label = await Label.updateOne(inputs.record.id).set({ ...values });
|
||||
|
||||
if (label) {
|
||||
|
|
|
@ -41,6 +41,10 @@ module.exports = {
|
|||
// ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
|
||||
// ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝
|
||||
|
||||
position: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
isNotEmptyString: true,
|
||||
|
|
|
@ -1,44 +1,5 @@
|
|||
const POSITION_GAP = 65535;
|
||||
const { addPosition, removePosition } = require('../../utils/migrations');
|
||||
|
||||
module.exports.up = async (knex) => {
|
||||
await knex.schema.table('task', (table) => {
|
||||
/* Columns */
|
||||
module.exports.up = (knex) => addPosition(knex, 'task', 'card_id');
|
||||
|
||||
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');
|
||||
});
|
||||
module.exports.down = (knex) => removePosition(knex, 'task');
|
||||
|
|
5
server/db/migrations/20230108213138_labels_reordering.js
Normal file
5
server/db/migrations/20230108213138_labels_reordering.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
const { addPosition, removePosition } = require('../../utils/migrations');
|
||||
|
||||
module.exports.up = (knex) => addPosition(knex, 'label', 'board_id');
|
||||
|
||||
module.exports.down = (knex) => removePosition(knex, 'label');
|
49
server/utils/migrations.js
Normal file
49
server/utils/migrations.js
Normal file
|
@ -0,0 +1,49 @@
|
|||
const POSITION_GAP = 65535;
|
||||
|
||||
const addPosition = async (knex, tableName, parentFieldName) => {
|
||||
await knex.schema.table(tableName, (table) => {
|
||||
/* Columns */
|
||||
|
||||
table.specificType('position', 'double precision');
|
||||
|
||||
/* Indexes */
|
||||
|
||||
table.index('position');
|
||||
});
|
||||
|
||||
const records = await knex(tableName).orderBy([parentFieldName, 'id']);
|
||||
|
||||
let prevParentId;
|
||||
let position;
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (record of records) {
|
||||
if (record[parentFieldName] === prevParentId) {
|
||||
position += POSITION_GAP;
|
||||
} else {
|
||||
prevParentId = record[parentFieldName];
|
||||
position = POSITION_GAP;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex(tableName)
|
||||
.update({
|
||||
position,
|
||||
})
|
||||
.where('id', record.id);
|
||||
}
|
||||
|
||||
return knex.schema.table(tableName, (table) => {
|
||||
table.dropNullable('position');
|
||||
});
|
||||
};
|
||||
|
||||
const removePosition = (knex, tableName) =>
|
||||
knex.schema.table(tableName, (table) => {
|
||||
table.dropColumn('position');
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
addPosition,
|
||||
removePosition,
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue