diff --git a/packages/client/package.json b/packages/client/package.json index 8ca73b55d..6fef00eff 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -28,6 +28,7 @@ "file-saver": "^2.0.5", "i18next": "^21.6.0", "marked": "^4.0.12", + "react-beautiful-dnd": "^13.1.0", "react-helmet": "^6.1.0", "react-i18next": "^11.15.1", "react-image-lightbox": "^5.1.4", diff --git a/packages/client/src/board/Board.tsx b/packages/client/src/board/Board.tsx new file mode 100644 index 000000000..0d5e1f57e --- /dev/null +++ b/packages/client/src/board/Board.tsx @@ -0,0 +1,295 @@ +import React, { forwardRef, useState } from 'react'; +import { DragDropContext } from 'react-beautiful-dnd'; +import Column from './Column'; +import ColumnAdder from './ColumnAdder'; +import DefaultCard from './DefaultCard'; +import DefaultColumnHeader from './DefaultColumnHeader'; +import * as helpers from './helpers'; +import { addCard, addColumn, changeColumn, moveCard, moveColumn, removeCard, removeColumn } from './helpers'; +import { + getCard, + getCoordinates, + isAColumnMove, + isMovingACardToAnotherPosition, + isMovingAColumnToAnotherPosition +} from './services'; +import { partialRight, when } from './utils'; +import withDroppable from './withDroppable'; + +const Columns = forwardRef((props, ref: any) =>
); + +const DroppableBoard = withDroppable(Columns); + +const Board: any = (props) => { + return props.initialBoard ? : ; +} + +Object.keys(helpers).forEach((key) => { + Board[key] = helpers[key]; +}); + +function UncontrolledBoard({ + initialBoard, + onCardDragEnd, + onColumnDragEnd, + allowAddColumn, + cardAdderPosition, + renderCardAdder, + renderColumnAdder, + onNewColumnConfirm, + onColumnRemove, + renderColumnHeader, + allowRemoveColumn, + allowRenameColumn, + onColumnRename, + onCardNew, + renderCard, + allowRemoveCard, + onCardRemove, + onColumnNew, + disableCardDrag, + disableColumnDrag, + allowAddCard, + onNewCardConfirm, +}) { + const [board, setBoard] = useState(initialBoard); + const handleOnCardDragEnd = partialRight(handleOnDragEnd, { moveCallback: moveCard, notifyCallback: onCardDragEnd }); + const handleOnColumnDragEnd = partialRight(handleOnDragEnd, { + moveCallback: moveColumn, + notifyCallback: onColumnDragEnd, + }); + + function handleOnDragEnd({ source, destination, subject }, { moveCallback, notifyCallback }) { + const reorderedBoard = moveCallback(board, source, destination); + when(notifyCallback)((callback) => callback(reorderedBoard, subject, source, destination)); + setBoard(reorderedBoard); + } + + async function handleColumnAdd(newColumn) { + const column = renderColumnAdder ? newColumn : await onNewColumnConfirm(newColumn); + const boardWithNewColumn = addColumn(board, column); + onColumnNew(boardWithNewColumn, column); + setBoard(boardWithNewColumn); + } + + function handleColumnRemove(column) { + const filteredBoard = removeColumn(board, column); + onColumnRemove(filteredBoard, column); + setBoard(filteredBoard); + } + + function handleColumnRename(column, title) { + const boardWithRenamedColumn = changeColumn(board, column, { title }); + onColumnRename(boardWithRenamedColumn, { ...column, title }); + setBoard(boardWithRenamedColumn); + } + + function handleCardAdd(column, card, options = {}) { + const boardWithNewCard = addCard(board, column, card, options); + + onCardNew( + boardWithNewCard, + boardWithNewCard.columns.find(({ id }) => id === column.id), + card, + ); + setBoard(boardWithNewCard); + } + + async function handleDraftCardAdd(column, card, options = {}) { + const newCard = await onNewCardConfirm(card); + handleCardAdd(column, newCard, options); + } + + function handleCardRemove(column, card) { + const boardWithoutCard = removeCard(board, column, card); + onCardRemove( + boardWithoutCard, + boardWithoutCard.columns.find(({ id }) => id === column.id), + card, + ); + setBoard(boardWithoutCard); + } + + return ( + { + if (!allowAddColumn) return null; + if (renderColumnAdder) return renderColumnAdder({ addColumn: handleColumnAdd }); + if (!onNewColumnConfirm) return null; + return handleColumnAdd({ title, cards: [] })} />; + }} + {...(renderColumnHeader && { + renderColumnHeader: (column) => + renderColumnHeader(column, { + removeColumn: handleColumnRemove.bind(null, column), + renameColumn: handleColumnRename.bind(null, column), + addCard: handleCardAdd.bind(null, column), + }), + })} + renderCard={(column, card, dragging) => { + if (renderCard) return renderCard(card, { removeCard: handleCardRemove.bind(null, column, card), dragging }); + return ( + handleCardRemove(column, card)} + > + {card} + + ); + }} + allowRemoveColumn={allowRemoveColumn} + onColumnRemove={handleColumnRemove} + allowRenameColumn={allowRenameColumn} + onColumnRename={handleColumnRename} + disableColumnDrag={disableColumnDrag} + disableCardDrag={disableCardDrag} + onCardNew={(column, card) => handleDraftCardAdd(column, card, allowAddCard)} + allowAddCard={allowAddCard && onNewCardConfirm} + > + {board} + + ); +} + +function ControlledBoard({ + children: board, + onCardDragEnd, + onColumnDragEnd, + allowAddColumn, + renderCardAdder, + renderColumnAdder, + onNewColumnConfirm, + onColumnRemove, + renderColumnHeader, + allowRemoveColumn, + allowRenameColumn, + onColumnRename, + renderCard, + allowAddCard, + allowRemoveCard, + onCardRemove, + disableCardDrag, + disableColumnDrag, + cardAdderPosition, +}) { + const handleOnCardDragEnd = partialRight(handleOnDragEnd, { notifyCallback: onCardDragEnd }); + const handleOnColumnDragEnd = partialRight(handleOnDragEnd, { notifyCallback: onColumnDragEnd }); + + function handleOnDragEnd({ source, destination, subject }, { notifyCallback }) { + when(notifyCallback)((callback) => callback(subject, source, destination)); + } + + return ( + { + if (!allowAddColumn) return null; + if (renderColumnAdder) return renderColumnAdder(); + if (!onNewColumnConfirm) return null; + return onNewColumnConfirm({ title, cards: [] })} />; + }} + {...(renderColumnHeader && { renderColumnHeader: renderColumnHeader })} + renderCard={(column, card, dragging) => { + if (renderCard) return renderCard(card, { column, dragging }); + return ( + onCardRemove(card, column)} + > + {card} + + ); + }} + allowAddCard={allowAddCard} + allowRemoveColumn={allowRemoveColumn} + onColumnRemove={onColumnRemove} + allowRenameColumn={allowRenameColumn} + onColumnRename={onColumnRename} + disableColumnDrag={disableColumnDrag} + disableCardDrag={disableCardDrag} + > + {board} + + ); +} + +function BoardContainer(props) { + const { + children: board, + renderCard, + disableColumnDrag, + disableCardDrag, + cardAdderPosition, + renderCardAdder, + renderColumnHeader, + renderColumnAdder, + allowRemoveColumn, + onColumnRemove, + allowRenameColumn, + onColumnRename, + onColumnDragEnd, + onCardDragEnd, + onCardNew, + allowAddCard, + } = props; + function handleOnDragEnd(event) { + const coordinates = getCoordinates(event, board); + if (!coordinates.source) return; + + isAColumnMove(event.type) + ? isMovingAColumnToAnotherPosition(coordinates) && + onColumnDragEnd({ ...coordinates, subject: board.columns[coordinates.source.fromPosition] }) + : isMovingACardToAnotherPosition(coordinates) && + onCardDragEnd({ ...coordinates, subject: getCard(board, coordinates.source) }); + } + + return ( + +
+ + {board.columns.map((column, index) => ( + + renderColumnHeader ? ( + renderColumnHeader(column) + ) : ( + + {column} + + ) + } + cardAdderPosition={cardAdderPosition} + disableColumnDrag={disableColumnDrag} + disableCardDrag={disableCardDrag} + onCardNew={onCardNew} + allowAddCard={allowAddCard} + > + {column} + + ))} + + {renderColumnAdder()} +
+
+ ); +} + +export default Board; diff --git a/packages/client/src/board/Card.tsx b/packages/client/src/board/Card.tsx new file mode 100644 index 000000000..b2ff16add --- /dev/null +++ b/packages/client/src/board/Card.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Draggable } from 'react-beautiful-dnd'; + +function Card({ children, index, renderCard, disableCardDrag }) { + return ( + + {(provided, { isDragging }) => { + return ( +
+
{renderCard(isDragging)}
+
+ ); + }} +
+ ); +} + +export default Card; diff --git a/packages/client/src/board/CardAdder.tsx b/packages/client/src/board/CardAdder.tsx new file mode 100644 index 000000000..96694f88f --- /dev/null +++ b/packages/client/src/board/CardAdder.tsx @@ -0,0 +1,24 @@ +import React, { useState } from 'react'; +import CardForm from './CardForm'; + +function CardAdder({ column, onConfirm }) { + function confirmCard(card) { + onConfirm(column, card) + setAddingCard(false) + } + + const [addingCard, setAddingCard] = useState(false) + + return ( + <> + {addingCard ? ( + setAddingCard(false)} /> + ) : ( + + )} + + ) +} +export default CardAdder; diff --git a/packages/client/src/board/CardForm.tsx b/packages/client/src/board/CardForm.tsx new file mode 100644 index 000000000..348ba5434 --- /dev/null +++ b/packages/client/src/board/CardForm.tsx @@ -0,0 +1,44 @@ +import React, { useRef } from 'react'; +import { when } from './utils'; + +function CardForm({ onConfirm, onCancel }) { + const inputCardTitle = useRef(); + const inputCardDescription = useRef(); + + function addCard(event) { + event.preventDefault(); + when(inputCardTitle.current.value)((value) => { + onConfirm({ title: value, description: inputCardDescription.current.value }); + }); + } + + return ( +
+
+ + +
+ + +
+
+
+ ); +} + +export default CardForm; diff --git a/packages/client/src/board/Column.tsx b/packages/client/src/board/Column.tsx new file mode 100644 index 000000000..e32973032 --- /dev/null +++ b/packages/client/src/board/Column.tsx @@ -0,0 +1,73 @@ +import React, { forwardRef } from 'react'; +import { Draggable } from 'react-beautiful-dnd'; +import Card from './Card'; +import CardAdder from './CardAdder'; +import { pickPropOut } from './utils'; +import withDroppable from './withDroppable'; + +const ColumnEmptyPlaceholder = forwardRef((props, ref: any) => ( +
+)); + +const DroppableColumn = withDroppable(ColumnEmptyPlaceholder); + +function Column({ + children, + index: columnIndex, + renderCard, + renderCardAdder = ({ column, onConfirm }) => , + renderColumnHeader, + disableColumnDrag, + disableCardDrag, + onCardNew, + allowAddCard, + cardAdderPosition = 'top', +}) { + return ( + + {(columnProvided) => { + const draggablePropsWithoutStyle = pickPropOut(columnProvided.draggableProps, 'style'); + + return ( +
+
{renderColumnHeader(children)}
+ {cardAdderPosition === 'top' && allowAddCard && renderCardAdder({ column: children, onConfirm: onCardNew })} + + {children.cards.length ? ( + children.cards.map((card, index) => ( + renderCard(children, card, dragging)} + disableCardDrag={disableCardDrag} + > + {card} + + )) + ) : ( +
+ )} + + {cardAdderPosition === 'bottom' && + allowAddCard && + renderCardAdder({ column: children, onConfirm: onCardNew })} +
+ ); + }} + + ); +} + +export default Column; diff --git a/packages/client/src/board/ColumnAdder.tsx b/packages/client/src/board/ColumnAdder.tsx new file mode 100644 index 000000000..4f0445676 --- /dev/null +++ b/packages/client/src/board/ColumnAdder.tsx @@ -0,0 +1,26 @@ +import React, { useState } from 'react'; +import ColumnForm from './ColumnForm'; + +function ColumnAdder({ onConfirm }) { + const [isAddingColumn, setAddingColumn] = useState(false); + + function confirmColumn(title) { + onConfirm(title); + setAddingColumn(false); + } + + return isAddingColumn ? ( + setAddingColumn(false)} /> + ) : ( +
setAddingColumn(true)} + role="button" + > + ➕ +
+ ); +} + +export default ColumnAdder; diff --git a/packages/client/src/board/ColumnForm.tsx b/packages/client/src/board/ColumnForm.tsx new file mode 100644 index 000000000..2b3b512ea --- /dev/null +++ b/packages/client/src/board/ColumnForm.tsx @@ -0,0 +1,27 @@ +import React, { createRef } from 'react'; +import { when } from './utils'; + +function ColumnForm({ onConfirm, onCancel }) { + // FIXME use hook + const inputColumnTitle = createRef() + + function addColumn(event) { + event.preventDefault() + + when(inputColumnTitle.current.value)(onConfirm) + } + + return ( +
+
+ + + +
+
+ ) +} + +export default ColumnForm diff --git a/packages/client/src/board/DefaultCard.tsx b/packages/client/src/board/DefaultCard.tsx new file mode 100644 index 000000000..16d9d6ac0 --- /dev/null +++ b/packages/client/src/board/DefaultCard.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +function DefaultCard({ children: card, dragging, allowRemoveCard, onCardRemove }) { + return ( +
+ +
+ {card.title} + {allowRemoveCard && ( + onCardRemove(card)}> + × + + )} +
+
+
{card.description}
+
+ ); +} + +export default DefaultCard; diff --git a/packages/client/src/board/DefaultColumnHeader.tsx b/packages/client/src/board/DefaultColumnHeader.tsx new file mode 100644 index 000000000..af1f13b55 --- /dev/null +++ b/packages/client/src/board/DefaultColumnHeader.tsx @@ -0,0 +1,72 @@ +import React, { useState } from 'react'; + +function ColumnTitle({ allowRenameColumn, onClick, children: title }) { + return allowRenameColumn ? ( + + {title} + + ) : ( + {title} + ) +} + +function useRenameMode(state) { + const [renameMode, setRenameMode] = useState(state) + + function toggleRenameMode() { + setRenameMode(!renameMode) + } + + return [renameMode, toggleRenameMode] +} + +function DefaultColumnHeader({ children: column, allowRemoveColumn, onColumnRemove, allowRenameColumn, onColumnRename }) { + const [renameMode, toggleRenameMode] = useRenameMode(false) + const [titleInput, setTitleInput] = useState('') + + function handleRenameColumn(event) { + event.preventDefault() + + onColumnRename(column, titleInput) + toggleRenameMode() + } + + function handleRenameMode() { + setTitleInput(column.title) + toggleRenameMode() + } + + return ( +
+ {renameMode ? ( +
+ + setTitleInput(value)} + autoFocus + /> + + + + + +
+ ) : ( + <> + + {column.title} + + {allowRemoveColumn && onColumnRemove(column)}>×} + + )} +
+ ) +} + +export default DefaultColumnHeader; diff --git a/packages/client/src/board/Kanban.tsx b/packages/client/src/board/Kanban.tsx new file mode 100644 index 000000000..257d63a22 --- /dev/null +++ b/packages/client/src/board/Kanban.tsx @@ -0,0 +1,128 @@ +import '@asseinfo/react-kanban/dist/styles.css'; +import { Button } from 'antd'; +import React, { useState } from 'react'; +import Board from './Board'; +import { addCard, moveCard, removeCard } from './helpers'; + +const board = { + columns: [ + { + id: 1, + title: 'Backlog', + cards: [ + { + id: 1, + title: 'Card title 1', + description: 'Card content', + }, + { + id: 2, + title: 'Card title 2', + description: 'Card content', + }, + { + id: 3, + title: 'Card title 3', + description: 'Card content', + }, + ], + }, + { + id: 2, + title: 'Doing', + cards: [ + { + id: 9, + title: 'Card title 9', + description: 'Card content', + }, + ], + }, + { + id: 3, + title: 'Q&A', + cards: [ + { + id: 10, + title: 'Card title 10', + description: 'Card content', + }, + { + id: 11, + title: 'Card title 11', + description: 'Card content', + }, + ], + }, + { + id: 4, + title: 'Production', + cards: [ + { + id: 12, + title: 'Card title 12', + description: 'Card content', + }, + { + id: 13, + title: 'Card title 13', + description: 'Card content', + }, + ], + }, + ], +}; + +function UncontrolledBoard() { + const [controlledBoard, setBoard] = useState(board); + function handleCardMove(card, source, destination) { + const updatedBoard = moveCard(controlledBoard, source, destination); + setBoard(updatedBoard); + } + function handleCardRemove(card, column) { + const updatedBoard = removeCard(controlledBoard, column, card); + setBoard(updatedBoard); + } + return ( + draftCard} + onCardNew={console.log} + renderCardAdder={({ column }) => { + return ( + + ); + }} + > + {controlledBoard} + + ); +} + +export function Kanban() { + return ( + <> + + + ); +} diff --git a/packages/client/src/board/demos/demo2.tsx b/packages/client/src/board/demos/demo2.tsx new file mode 100644 index 000000000..b24d8c408 --- /dev/null +++ b/packages/client/src/board/demos/demo2.tsx @@ -0,0 +1,119 @@ +import { Board } from '@nocobase/client'; +import { Button } from 'antd'; +import React, { useState } from 'react'; +import '../style.less'; + +const board = { + columns: [ + { + id: 1, + title: 'Backlog', + cards: [ + { + id: 1, + title: 'Card title 1', + description: 'Card content', + }, + { + id: 2, + title: 'Card title 2', + description: 'Card content', + }, + { + id: 3, + title: 'Card title 3', + description: 'Card content', + }, + ], + }, + { + id: 2, + title: 'Doing', + cards: [ + { + id: 9, + title: 'Card title 9', + description: 'Card content', + }, + ], + }, + { + id: 3, + title: 'Q&A', + cards: [ + { + id: 10, + title: 'Card title 10', + description: 'Card content', + }, + { + id: 11, + title: 'Card title 11', + description: 'Card content', + }, + ], + }, + { + id: 4, + title: 'Production', + cards: [ + { + id: 12, + title: 'Card title 12', + description: 'Card content', + }, + { + id: 13, + title: 'Card title 13', + description: 'Card content', + }, + ], + }, + ], +}; + +export default function App() { + const [controlledBoard, setBoard] = useState(board); + function handleCardMove(card, source, destination) { + const updatedBoard = Board.moveCard(controlledBoard, source, destination); + setBoard(updatedBoard); + } + function handleCardRemove(card, column) { + const updatedBoard = Board.removeCard(controlledBoard, column, card); + setBoard(updatedBoard); + } + return ( + draftCard} + onCardNew={console.log} + renderCardAdder={({ column }) => { + return ( + + ); + }} + > + {controlledBoard} + + ); +} diff --git a/packages/client/src/board/helpers.ts b/packages/client/src/board/helpers.ts new file mode 100755 index 000000000..b39b25af2 --- /dev/null +++ b/packages/client/src/board/helpers.ts @@ -0,0 +1,91 @@ +import { + addInArrayAtPosition, + changeElementOfPositionInArray, + removeFromArrayAtPosition, + replaceElementOfArray +} from './utils'; + +function reorderCardsOnColumn(column, reorderCards) { + return { ...column, cards: reorderCards(column.cards) }; +} + +function moveColumn(board, { fromPosition }, { toPosition }) { + return { ...board, columns: changeElementOfPositionInArray(board.columns, fromPosition, toPosition) }; +} + +function moveCard(board, { fromPosition, fromColumnId }, { toPosition, toColumnId }) { + const sourceColumn = board.columns.find((column) => column.id === fromColumnId); + const destinationColumn = board.columns.find((column) => column.id === toColumnId); + + const reorderColumnsOnBoard = (reorderColumnsMapper) => ({ + ...board, + columns: board.columns.map(reorderColumnsMapper), + }); + const reorderCardsOnSourceColumn = reorderCardsOnColumn.bind(null, sourceColumn); + const reorderCardsOnDestinationColumn = reorderCardsOnColumn.bind(null, destinationColumn); + + if (sourceColumn.id === destinationColumn.id) { + const reorderedCardsOnColumn = reorderCardsOnSourceColumn((cards) => { + return changeElementOfPositionInArray(cards, fromPosition, toPosition); + }); + return reorderColumnsOnBoard((column) => (column.id === sourceColumn.id ? reorderedCardsOnColumn : column)); + } else { + const reorderedCardsOnSourceColumn = reorderCardsOnSourceColumn((cards) => { + return removeFromArrayAtPosition(cards, fromPosition); + }); + const reorderedCardsOnDestinationColumn = reorderCardsOnDestinationColumn((cards) => { + return addInArrayAtPosition(cards, sourceColumn.cards[fromPosition], toPosition); + }); + return reorderColumnsOnBoard((column) => { + if (column.id === sourceColumn.id) return reorderedCardsOnSourceColumn; + if (column.id === destinationColumn.id) return reorderedCardsOnDestinationColumn; + return column; + }); + } +} + +function addColumn(board, column) { + return { ...board, columns: addInArrayAtPosition(board.columns, column, board.columns.length) }; +} + +function removeColumn(board, column) { + return { ...board, columns: board.columns.filter(({ id }) => id !== column.id) }; +} + +function changeColumn(board, column, newColumn) { + const changedColumns = replaceElementOfArray(board.columns)({ + when: ({ id }) => id === column.id, + for: (value) => ({ ...value, ...newColumn }), + }); + return { ...board, columns: changedColumns }; +} + +function addCard(board, inColumn, card, { on }: any = {}) { + const columnToAdd = board.columns.find(({ id }) => id === inColumn.id); + const cards = addInArrayAtPosition(columnToAdd.cards, card, on === 'top' ? 0 : columnToAdd.cards.length); + const columns = replaceElementOfArray(board.columns)({ + when: ({ id }) => inColumn.id === id, + for: (value) => ({ ...value, cards }), + }); + return { ...board, columns }; +} + +function removeCard(board, fromColumn, card) { + const columnToRemove = board.columns.find(({ id }) => id === fromColumn.id); + const filteredCards = columnToRemove.cards.filter(({ id }) => card.id !== id); + const columnWithoutCard = { ...columnToRemove, cards: filteredCards }; + const filteredColumns = board.columns.map((column) => (fromColumn.id === column.id ? columnWithoutCard : column)); + return { ...board, columns: filteredColumns }; +} + +function changeCard(board, cardId, newCard) { + const changedCards = (cards) => + replaceElementOfArray(cards)({ + when: ({ id }) => id === cardId, + for: (card) => ({ ...card, ...newCard }), + }); + + return { ...board, columns: board.columns.map((column) => ({ ...column, cards: changedCards(column.cards) })) }; +} + +export { moveColumn, moveCard, addColumn, removeColumn, changeColumn, addCard, removeCard, changeCard }; diff --git a/packages/client/src/board/index.md b/packages/client/src/board/index.md new file mode 100644 index 000000000..11d67700b --- /dev/null +++ b/packages/client/src/board/index.md @@ -0,0 +1,14 @@ +--- +nav: + path: /client +group: + path: /client +--- + +# Board + +基于 [@asseinfo/react-kanban](https://github.com/asseinfo/react-kanban) 做了一些改进和 BUG 修复,简化了目录结构,并改为了 TypeScript 版本。 + +## Examples + + diff --git a/packages/client/src/board/index.ts b/packages/client/src/board/index.ts new file mode 100644 index 000000000..8b3da9b8c --- /dev/null +++ b/packages/client/src/board/index.ts @@ -0,0 +1,2 @@ +export { default as Board } from './Board'; + diff --git a/packages/client/src/board/services.ts b/packages/client/src/board/services.ts new file mode 100644 index 000000000..b25ab83b9 --- /dev/null +++ b/packages/client/src/board/services.ts @@ -0,0 +1,41 @@ +function getCoordinates(event, board) { + if (event.destination === null) return {} + + const columnSource = { fromPosition: event.source.index } + const columnDestination = { toPosition: event.destination.index } + + if (isAColumnMove(event.type)) { + return { source: columnSource, destination: columnDestination } + } + + return { + source: { ...columnSource, fromColumnId: getColumn(board, event.source.droppableId).id }, + destination: { ...columnDestination, toColumnId: getColumn(board, event.destination.droppableId).id }, + } +} + +function isAColumnMove(type) { + return type === 'BOARD' +} + +function getCard(board, sourceCoordinate) { + const column = board.columns.find((column) => column.id === sourceCoordinate.fromColumnId) + return column.cards[sourceCoordinate.fromPosition] +} + +function getColumn(board, droppableId) { + return board.columns.find(({ id }) => String(id) === droppableId) +} + +function isMovingAColumnToAnotherPosition(coordinates) { + return coordinates.source.fromPosition !== coordinates.destination.toPosition +} + +function isMovingACardToAnotherPosition(coordinates) { + return !( + coordinates.source.fromPosition === coordinates.destination.toPosition && + coordinates.source.fromColumnId === coordinates.destination.toColumnId + ) +} + +export { getCard, getCoordinates, isAColumnMove, isMovingAColumnToAnotherPosition, isMovingACardToAnotherPosition } diff --git a/packages/client/src/board/style.less b/packages/client/src/board/style.less new file mode 100644 index 000000000..33e455edf --- /dev/null +++ b/packages/client/src/board/style.less @@ -0,0 +1,147 @@ +.react-kanban-board { + padding: 5px; +} + +.react-kanban-card { + box-sizing: border-box; + max-width: 250px; + min-width: 250px; + border-radius: 3px; + background-color: #fff; + padding: 10px; + margin-bottom: 7px; +} + +.react-kanban-card-skeleton { + box-sizing: border-box; + max-width: 250px; + min-width: 250px; +} + +.react-kanban-card--dragging { + box-shadow: 2px 2px grey; +} + +.react-kanban-card__description { + padding-top: 10px; +} + +.react-kanban-card__title { + border-bottom: 1px solid #eee; + padding-bottom: 5px; + font-weight: bold; + display: flex; + justify-content: space-between; +} + +.react-kanban-column { + padding: 15px; + border-radius: 2px; + background-color: #eee; + margin: 5px; + input { + &:focus { + outline: none; + } + } +} + +.react-kanban-card-adder-form { + box-sizing: border-box; + max-width: 250px; + min-width: 250px; + border-radius: 3px; + background-color: #fff; + padding: 10px; + margin-bottom: 7px; + input { + border: 0px; + font-family: inherit; + font-size: inherit; + } +} + +.react-kanban-card-adder-button { + width: 100%; + margin-top: 5px; + background-color: transparent; + cursor: pointer; + border: 1px solid #ccc; + transition: 0.3s; + border-radius: 3px; + font-size: 20px; + margin-bottom: 10px; + font-weight: bold; + &:hover { + background-color: #ccc; + } +} + +.react-kanban-card-adder-form__title { + font-weight: bold; + border-bottom: 1px solid #eee; + padding-bottom: 5px; + font-weight: bold; + display: flex; + justify-content: space-between; + width: 100%; + padding: 0px; + &:focus { + outline: none; + } +} + +.react-kanban-card-adder-form__description { + width: 100%; + margin-top: 10px; + &:focus { + outline: none; + } +} + +.react-kanban-card-adder-form__button { + background-color: #eee; + border: none; + padding: 5px; + width: 45%; + margin-top: 5px; + border-radius: 3px; + &:hover { + transition: 0.3s; + cursor: pointer; + background-color: #ccc; + } +} + +.react-kanban-column-header { + padding-bottom: 10px; + font-weight: bold; + + input { + &:focus { + outline: none; + } + } +} + +.react-kanban-column-header__button { + color: #333333; + background-color: #ffffff; + border-color: #cccccc; + + &:hover, + &:focus, + &:active { + background-color: #e6e6e6; + } +} + +.react-kanban-column-adder-button { + border: 2px dashed #eee; + height: 132px; + margin: 5px; + + &:hover { + cursor: pointer; + } +} diff --git a/packages/client/src/board/utils.ts b/packages/client/src/board/utils.ts new file mode 100755 index 000000000..efd69c98c --- /dev/null +++ b/packages/client/src/board/utils.ts @@ -0,0 +1,56 @@ +function compose(...fns) { + return (arg) => fns.reduce((acc, fn) => fn(acc), arg) +} + +function partialRight(fn, ...args) { + return (...leftArgs) => fn(...leftArgs, ...args) +} + +function addInArrayAtPosition(array, element, position) { + const arrayCopy = [...array] + arrayCopy.splice(position, 0, element) + return arrayCopy +} + +function removeFromArrayAtPosition(array, position) { + return array.reduce((acc, value, idx) => (idx === position ? acc : [...acc, value]), []) +} + +function changeElementOfPositionInArray(array, from, to) { + const removeFromArrayAtPositionFrom = partialRight(removeFromArrayAtPosition, from) + const addInArrayAtPositionTo = partialRight(addInArrayAtPosition, array[from], to) + + return compose(removeFromArrayAtPositionFrom, addInArrayAtPositionTo)(array) +} + +function identity(value) { + return value +} + +function when(value, predicate = identity) { + return function callback(callback) { + if (predicate(value)) return callback(value) + } +} + +function replaceElementOfArray(array) { + return function (options) { + return array.map((element) => (options.when(element) ? options.for(element) : element)) + } +} + +function pickPropOut(object, prop) { + return Object.keys(object).reduce((obj, key) => { + return key === prop ? obj : { ...obj, [key]: object[key] } + }, {}) +} + +export { + addInArrayAtPosition, + removeFromArrayAtPosition, + changeElementOfPositionInArray, + when, + replaceElementOfArray, + partialRight, + pickPropOut, +} diff --git a/packages/client/src/board/withDroppable.tsx b/packages/client/src/board/withDroppable.tsx new file mode 100644 index 000000000..fdf43f32b --- /dev/null +++ b/packages/client/src/board/withDroppable.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import { Droppable } from 'react-beautiful-dnd' + +function withDroppable(Component) { + return function WrapperComponent({ children, ...droppableProps }) { + return ( + + {(provided) => ( + + {children} + {provided.placeholder} + + )} + + ) + } +} + +export default withDroppable diff --git a/packages/client/src/index.tsx b/packages/client/src/index.tsx index b4229b262..179c1f91c 100644 --- a/packages/client/src/index.tsx +++ b/packages/client/src/index.tsx @@ -6,6 +6,7 @@ export * from './antd-config-provider'; export * from './api-client'; export * from './application'; export * from './async-data-provider'; +export * from './board'; export * from './collection-manager'; export * from './current-user'; export * from './document-title'; diff --git a/yarn.lock b/yarn.lock index ffb2f7814..aae12500d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1006,6 +1006,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.15.4", "@babel/runtime@^7.9.2": + version "7.16.7" + resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.7.tgz#03ff99f64106588c9c403c6ecb8c3bafbbdff1fa" + integrity sha512-9E9FJowqAsytyOY6LG+1KuueckRL+aQW+mKvXRXnuFGyRAyepJPmEo9vgMfXUA6O9u3IeEdv9MAkppFcaQwogQ== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.16.0", "@babel/template@^7.3.3": version "7.16.0" resolved "https://registry.npmjs.org/@babel/template/-/template-7.16.0.tgz#d16a35ebf4cd74e202083356fab21dd89363ddd6" @@ -2857,6 +2864,14 @@ resolved "https://registry.npmjs.org/@types/history/-/history-4.7.9.tgz#1cfb6d60ef3822c589f18e70f8b12f9a28ce8724" integrity sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ== +"@types/hoist-non-react-statics@^3.3.0": + version "3.3.1" + resolved "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" + integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== + dependencies: + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + "@types/http-assert@*": version "1.5.3" resolved "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.3.tgz#ef8e3d1a8d46c387f04ab0f2e8ab8cb0c5078661" @@ -3060,6 +3075,16 @@ dependencies: "@types/react" "*" +"@types/react-redux@^7.1.20": + version "7.1.22" + resolved "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.22.tgz#0eab76a37ef477cc4b53665aeaf29cb60631b72a" + integrity sha512-GxIA1kM7ClU73I6wg9IRTVwSO9GS+SAKZKe0Enj+82HMU6aoESFU2HNAdNi3+J53IaOHPiUfT3kSG4L828joDQ== + dependencies: + "@types/hoist-non-react-statics" "^3.3.0" + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + redux "^4.0.0" + "@types/react-router-config@5.0.2": version "5.0.2" resolved "https://registry.npmjs.org/@types/react-router-config/-/react-router-config-5.0.2.tgz#4d3b52e71ed363a1976a12321e67b09a99ad6d10" @@ -5459,6 +5484,13 @@ css-blank-pseudo@^0.1.4: dependencies: postcss "^7.0.5" +css-box-model@^1.2.0: + version "1.2.1" + resolved "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1" + integrity sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw== + dependencies: + tiny-invariant "^1.0.6" + css-has-pseudo@^0.10.0: version "0.10.0" resolved "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-0.10.0.tgz#3c642ab34ca242c59c41a125df9105841f6966ee" @@ -7607,7 +7639,7 @@ hmac-drbg@^1.0.1: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -9869,6 +9901,11 @@ media-typer@0.3.0: resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= +memoize-one@^5.1.1: + version "5.2.1" + resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== + memoize-one@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045" @@ -12270,6 +12307,11 @@ quick-lru@^4.0.1: resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g== +raf-schd@^4.0.2: + version "4.0.3" + resolved "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz#5d6c34ef46f8b2a0e880a8fcdb743efc5bfdbc1a" + integrity sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ== + randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5: version "2.1.0" resolved "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -12721,6 +12763,19 @@ react-attr-converter@^0.3.1: resolved "https://registry.npmjs.org/react-attr-converter/-/react-attr-converter-0.3.1.tgz#4a2abf6d907b7ddae4d862dfec80e489ce41ad6e" integrity sha512-dSxo2Mn6Zx4HajeCeQNLefwEO4kNtV/0E682R1+ZTyFRPqxDa5zYb5qM/ocqw9Bxr/kFQO0IUiqdV7wdHw+Cdg== +react-beautiful-dnd@^13.1.0: + version "13.1.0" + resolved "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz#ec97c81093593526454b0de69852ae433783844d" + integrity sha512-aGvblPZTJowOWUNiwd6tNfEpgkX5OxmpqxHKNW/4VmvZTNTbeiq7bA3bn5T+QSF2uibXB0D1DmJsb1aC/+3cUA== + dependencies: + "@babel/runtime" "^7.9.2" + css-box-model "^1.2.0" + memoize-one "^5.1.1" + raf-schd "^4.0.2" + react-redux "^7.2.0" + redux "^4.0.4" + use-memo-one "^1.1.1" + react-docgen-typescript-dumi-tmp@^1.22.1-0: version "1.22.1-0" resolved "https://registry.npmjs.org/react-docgen-typescript-dumi-tmp/-/react-docgen-typescript-dumi-tmp-1.22.1-0.tgz#6f452de05c5c114a6e1dd60b34930afaa7ae39a0" @@ -12782,7 +12837,7 @@ react-is@^16.12.0, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-i resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^17.0.1: +react-is@^17.0.1, react-is@^17.0.2: version "17.0.2" resolved "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== @@ -12802,6 +12857,18 @@ react-modal@^3.11.1: react-lifecycles-compat "^3.0.0" warning "^4.0.3" +react-redux@^7.2.0: + version "7.2.6" + resolved "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz#49633a24fe552b5f9caf58feb8a138936ddfe9aa" + integrity sha512-10RPdsz0UUrRL1NZE0ejTkucnclYSgXp5q+tB5SWx2qeG2ZJQJyymgAhwKy73yiL/13btfB6fPr+rgbMAaZIAQ== + dependencies: + "@babel/runtime" "^7.15.4" + "@types/react-redux" "^7.1.20" + hoist-non-react-statics "^3.3.2" + loose-envify "^1.4.0" + prop-types "^15.7.2" + react-is "^17.0.2" + react-refresh@0.10.0: version "0.10.0" resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.10.0.tgz#2f536c9660c0b9b1d500684d9e52a65e7404f7e3" @@ -13084,6 +13151,13 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" +redux@^4.0.0, redux@^4.0.4: + version "4.1.2" + resolved "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz#140f35426d99bb4729af760afcf79eaaac407104" + integrity sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw== + dependencies: + "@babel/runtime" "^7.9.2" + regenerate-unicode-properties@^9.0.0: version "9.0.0" resolved "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-9.0.0.tgz#54d09c7115e1f53dc2314a974b32c1c344efe326" @@ -14572,7 +14646,7 @@ timers-browserify@^2.0.4: dependencies: setimmediate "^1.0.4" -tiny-invariant@^1.0.2: +tiny-invariant@^1.0.2, tiny-invariant@^1.0.6: version "1.2.0" resolved "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz#a1141f86b672a9148c72e978a19a73b9b94a15a9" integrity sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg== @@ -15227,6 +15301,11 @@ use-deep-compare-effect@^1.8.1: "@babel/runtime" "^7.12.5" dequal "^2.0.2" +use-memo-one@^1.1.1: + version "1.1.2" + resolved "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.2.tgz#0c8203a329f76e040047a35a1197defe342fab20" + integrity sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ== + use-subscription@1.5.1: version "1.5.1" resolved "https://registry.npmjs.org/use-subscription/-/use-subscription-1.5.1.tgz#73501107f02fad84c6dd57965beb0b75c68c42d1"