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 ? (
+
+ ) : (
+ <>
+
+ {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"