feat(client): add board component

This commit is contained in:
chenos 2022-01-29 17:30:40 +08:00
parent 05e2cf3b8d
commit 8da98d8c2b
21 changed files with 1306 additions and 3 deletions

View File

@ -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",

View File

@ -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) => <div ref={ref} style={{ whiteSpace: 'nowrap' }} {...props} />);
const DroppableBoard = withDroppable(Columns);
const Board: any = (props) => {
return props.initialBoard ? <UncontrolledBoard {...props} /> : <ControlledBoard {...props} />;
}
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 (
<BoardContainer
cardAdderPosition={cardAdderPosition}
onCardDragEnd={handleOnCardDragEnd}
onColumnDragEnd={handleOnColumnDragEnd}
renderCardAdder={renderCardAdder}
renderColumnAdder={() => {
if (!allowAddColumn) return null;
if (renderColumnAdder) return renderColumnAdder({ addColumn: handleColumnAdd });
if (!onNewColumnConfirm) return null;
return <ColumnAdder onConfirm={(title) => 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 (
<DefaultCard
dragging={dragging}
allowRemoveCard={allowRemoveCard}
onCardRemove={(card) => handleCardRemove(column, card)}
>
{card}
</DefaultCard>
);
}}
allowRemoveColumn={allowRemoveColumn}
onColumnRemove={handleColumnRemove}
allowRenameColumn={allowRenameColumn}
onColumnRename={handleColumnRename}
disableColumnDrag={disableColumnDrag}
disableCardDrag={disableCardDrag}
onCardNew={(column, card) => handleDraftCardAdd(column, card, allowAddCard)}
allowAddCard={allowAddCard && onNewCardConfirm}
>
{board}
</BoardContainer>
);
}
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 (
<BoardContainer
cardAdderPosition={cardAdderPosition}
onCardDragEnd={handleOnCardDragEnd}
onColumnDragEnd={handleOnColumnDragEnd}
renderCardAdder={renderCardAdder}
renderColumnAdder={() => {
if (!allowAddColumn) return null;
if (renderColumnAdder) return renderColumnAdder();
if (!onNewColumnConfirm) return null;
return <ColumnAdder onConfirm={(title) => onNewColumnConfirm({ title, cards: [] })} />;
}}
{...(renderColumnHeader && { renderColumnHeader: renderColumnHeader })}
renderCard={(column, card, dragging) => {
if (renderCard) return renderCard(card, { column, dragging });
return (
<DefaultCard
dragging={dragging}
allowRemoveCard={allowRemoveCard}
onCardRemove={(card) => onCardRemove(card, column)}
>
{card}
</DefaultCard>
);
}}
allowAddCard={allowAddCard}
allowRemoveColumn={allowRemoveColumn}
onColumnRemove={onColumnRemove}
allowRenameColumn={allowRenameColumn}
onColumnRename={onColumnRename}
disableColumnDrag={disableColumnDrag}
disableCardDrag={disableCardDrag}
>
{board}
</BoardContainer>
);
}
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 (
<DragDropContext onDragEnd={handleOnDragEnd}>
<div style={{ overflowY: 'hidden', display: 'flex', alignItems: 'flex-start' }} className="react-kanban-board">
<DroppableBoard droppableId="board-droppable" direction="horizontal" type="BOARD">
{board.columns.map((column, index) => (
<Column
key={column.id}
index={index}
renderCard={renderCard}
renderCardAdder={renderCardAdder}
renderColumnHeader={(column) =>
renderColumnHeader ? (
renderColumnHeader(column)
) : (
<DefaultColumnHeader
allowRemoveColumn={allowRemoveColumn}
onColumnRemove={onColumnRemove}
allowRenameColumn={allowRenameColumn}
onColumnRename={onColumnRename}
>
{column}
</DefaultColumnHeader>
)
}
cardAdderPosition={cardAdderPosition}
disableColumnDrag={disableColumnDrag}
disableCardDrag={disableCardDrag}
onCardNew={onCardNew}
allowAddCard={allowAddCard}
>
{column}
</Column>
))}
</DroppableBoard>
{renderColumnAdder()}
</div>
</DragDropContext>
);
}
export default Board;

View File

@ -0,0 +1,23 @@
import React from 'react';
import { Draggable } from 'react-beautiful-dnd';
function Card({ children, index, renderCard, disableCardDrag }) {
return (
<Draggable draggableId={String(children.id)} index={index} isDragDisabled={disableCardDrag}>
{(provided, { isDragging }) => {
return (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
data-testid={`card-${children.id}`}
>
<div style={{ display: 'inline-block', whiteSpace: 'normal' }}>{renderCard(isDragging)}</div>
</div>
);
}}
</Draggable>
);
}
export default Card;

View File

@ -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 ? (
<CardForm onConfirm={confirmCard} onCancel={() => setAddingCard(false)} />
) : (
<button className='react-kanban-card-adder-button' onClick={() => setAddingCard(!addingCard)}>
+
</button>
)}
</>
)
}
export default CardAdder;

View File

@ -0,0 +1,44 @@
import React, { useRef } from 'react';
import { when } from './utils';
function CardForm({ onConfirm, onCancel }) {
const inputCardTitle = useRef<any>();
const inputCardDescription = useRef<any>();
function addCard(event) {
event.preventDefault();
when(inputCardTitle.current.value)((value) => {
onConfirm({ title: value, description: inputCardDescription.current.value });
});
}
return (
<div className="react-kanban-card-adder-form">
<form onSubmit={addCard}>
<input
className="react-kanban-card-adder-form__title"
name="title"
autoFocus
defaultValue="Title"
ref={inputCardTitle}
/>
<input
className="react-kanban-card-adder-form__description"
name="description"
defaultValue="Description"
ref={inputCardDescription}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '5px' }}>
<button className="react-kanban-card-adder-form__button" type="submit">
Add
</button>
<button className="react-kanban-card-adder-form__button" type="button" onClick={onCancel}>
Cancel
</button>
</div>
</form>
</div>
);
}
export default CardForm;

View File

@ -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) => (
<div ref={ref} style={{ minHeight: 'inherit', height: 'inherit' }} {...props} />
));
const DroppableColumn = withDroppable(ColumnEmptyPlaceholder);
function Column({
children,
index: columnIndex,
renderCard,
renderCardAdder = ({ column, onConfirm }) => <CardAdder column={column} onConfirm={onConfirm} />,
renderColumnHeader,
disableColumnDrag,
disableCardDrag,
onCardNew,
allowAddCard,
cardAdderPosition = 'top',
}) {
return (
<Draggable draggableId={`column-draggable-${children.id}`} index={columnIndex} isDragDisabled={disableColumnDrag}>
{(columnProvided) => {
const draggablePropsWithoutStyle = pickPropOut(columnProvided.draggableProps, 'style');
return (
<div
ref={columnProvided.innerRef}
{...draggablePropsWithoutStyle}
style={{
height: '100%',
minHeight: '28px',
display: 'inline-block',
verticalAlign: 'top',
...columnProvided.draggableProps.style,
}}
className="react-kanban-column"
data-testid={`column-${children.id}`}
>
<div {...columnProvided.dragHandleProps}>{renderColumnHeader(children)}</div>
{cardAdderPosition === 'top' && allowAddCard && renderCardAdder({ column: children, onConfirm: onCardNew })}
<DroppableColumn droppableId={String(children.id)}>
{children.cards.length ? (
children.cards.map((card, index) => (
<Card
key={card.id}
index={index}
renderCard={(dragging) => renderCard(children, card, dragging)}
disableCardDrag={disableCardDrag}
>
{card}
</Card>
))
) : (
<div className="react-kanban-card-skeleton" />
)}
</DroppableColumn>
{cardAdderPosition === 'bottom' &&
allowAddCard &&
renderCardAdder({ column: children, onConfirm: onCardNew })}
</div>
);
}}
</Draggable>
);
}
export default Column;

View File

@ -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 ? (
<ColumnForm onConfirm={confirmColumn} onCancel={() => setAddingColumn(false)} />
) : (
<div
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minWidth: '230px' }}
className="react-kanban-column-adder-button"
onClick={() => setAddingColumn(true)}
role="button"
>
</div>
);
}
export default ColumnAdder;

View File

@ -0,0 +1,27 @@
import React, { createRef } from 'react';
import { when } from './utils';
function ColumnForm({ onConfirm, onCancel }) {
// FIXME use hook
const inputColumnTitle = createRef<any>()
function addColumn(event) {
event.preventDefault()
when(inputColumnTitle.current.value)(onConfirm)
}
return (
<div className='react-kanban-column' style={{ minWidth: '230px' }}>
<form style={{ display: 'flex', justifyContent: 'space-between' }} onSubmit={addColumn}>
<input type='text' ref={inputColumnTitle} autoFocus />
<button type='submit'>Add</button>
<button type='button' onClick={onCancel}>
Cancel
</button>
</form>
</div>
)
}
export default ColumnForm

View File

@ -0,0 +1,21 @@
import React from 'react';
function DefaultCard({ children: card, dragging, allowRemoveCard, onCardRemove }) {
return (
<div className={`react-kanban-card ${dragging ? 'react-kanban-card--dragging' : ''}`}>
<span>
<div className="react-kanban-card__title">
<span>{card.title}</span>
{allowRemoveCard && (
<span style={{ cursor: 'pointer' }} onClick={() => onCardRemove(card)}>
×
</span>
)}
</div>
</span>
<div className="react-kanban-card__description">{card.description}</div>
</div>
);
}
export default DefaultCard;

View File

@ -0,0 +1,72 @@
import React, { useState } from 'react';
function ColumnTitle({ allowRenameColumn, onClick, children: title }) {
return allowRenameColumn ? (
<span style={{ cursor: 'pointer' }} onClick={onClick}>
{title}
</span>
) : (
<span>{title}</span>
)
}
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 (
<div className='react-kanban-column-header'>
{renameMode ? (
<form onSubmit={handleRenameColumn}>
<span>
<input
type='text'
value={titleInput}
onChange={({ target: { value } }) => setTitleInput(value)}
autoFocus
/>
</span>
<span>
<button className='react-kanban-column-header__button' type='submit'>
Rename
</button>
<button className='react-kanban-column-header__button' type='button' onClick={handleRenameMode}>
Cancel
</button>
</span>
</form>
) : (
<>
<ColumnTitle allowRenameColumn={allowRenameColumn} onClick={handleRenameMode}>
{column.title}
</ColumnTitle>
{allowRemoveColumn && <span onClick={() => onColumnRemove(column)}>×</span>}
</>
)}
</div>
)
}
export default DefaultColumnHeader;

View File

@ -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 (
<Board
disableColumnDrag
allowRemoveCard
allowAddCard={{ on: 'bottom' }}
onLaneRemove={console.log}
onCardRemove={handleCardRemove}
onCardDragEnd={handleCardMove}
onLaneRename={console.log}
cardAdderPosition={'bottom'}
onNewCardConfirm={(draftCard) => draftCard}
onCardNew={console.log}
renderCardAdder={({ column }) => {
return (
<Button
block
type={'text'}
onClick={() => {
const updatedBoard = addCard(controlledBoard, column, {
id: new Date().getTime(),
title: 'Card title ' + new Date().getTime(),
description: 'Card content',
});
setBoard(updatedBoard);
}}
>
</Button>
);
}}
>
{controlledBoard}
</Board>
);
}
export function Kanban() {
return (
<>
<UncontrolledBoard />
</>
);
}

View File

@ -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 (
<Board
disableColumnDrag
allowRemoveCard
allowAddCard={{ on: 'bottom' }}
onLaneRemove={console.log}
onCardRemove={handleCardRemove}
onCardDragEnd={handleCardMove}
onLaneRename={console.log}
cardAdderPosition={'bottom'}
onNewCardConfirm={(draftCard) => draftCard}
onCardNew={console.log}
renderCardAdder={({ column }) => {
return (
<Button
block
type={'text'}
onClick={() => {
const updatedBoard = Board.addCard(controlledBoard, column, {
id: new Date().getTime(),
title: 'Card title ' + new Date().getTime(),
description: 'Card content',
});
setBoard(updatedBoard);
}}
>
</Button>
);
}}
>
{controlledBoard}
</Board>
);
}

View File

@ -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 };

View File

@ -0,0 +1,14 @@
---
nav:
path: /client
group:
path: /client
---
# Board
基于 [@asseinfo/react-kanban](https://github.com/asseinfo/react-kanban) 做了一些改进和 BUG 修复,简化了目录结构,并改为了 TypeScript 版本。
## Examples
<code src="./demos/demo2.tsx" />

View File

@ -0,0 +1,2 @@
export { default as Board } from './Board';

View File

@ -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 }

View File

@ -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;
}
}

View File

@ -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,
}

View File

@ -0,0 +1,19 @@
import React from 'react'
import { Droppable } from 'react-beautiful-dnd'
function withDroppable(Component) {
return function WrapperComponent({ children, ...droppableProps }) {
return (
<Droppable {...droppableProps}>
{(provided) => (
<Component ref={provided.innerRef} {...provided.droppableProps}>
{children}
{provided.placeholder}
</Component>
)}
</Droppable>
)
}
}
export default withDroppable

View File

@ -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';

View File

@ -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"