Nocobase next kanban (#223)

* feat: add kanban component

* feat: add kanban designer

* feat: kanban  completed

* refactor: modify kanban

* feat: kanban card

* feat: modify kanban

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
SemmyWong 2022-03-09 09:31:51 +08:00 committed by GitHub
parent 60a915b50c
commit 36ae278302
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 597 additions and 23 deletions

View File

@ -11,7 +11,7 @@ import {
getCoordinates, getCoordinates,
isAColumnMove, isAColumnMove,
isMovingACardToAnotherPosition, isMovingACardToAnotherPosition,
isMovingAColumnToAnotherPosition isMovingAColumnToAnotherPosition,
} from './services'; } from './services';
import { partialRight, when } from './utils'; import { partialRight, when } from './utils';
import withDroppable from './withDroppable'; import withDroppable from './withDroppable';
@ -22,7 +22,7 @@ const DroppableBoard = withDroppable(Columns);
const Board: any = (props) => { const Board: any = (props) => {
return props.initialBoard ? <UncontrolledBoard {...props} /> : <ControlledBoard {...props} />; return props.initialBoard ? <UncontrolledBoard {...props} /> : <ControlledBoard {...props} />;
} };
Object.keys(helpers).forEach((key) => { Object.keys(helpers).forEach((key) => {
Board[key] = helpers[key]; Board[key] = helpers[key];
@ -247,19 +247,18 @@ function BoardContainer(props) {
isAColumnMove(event.type) isAColumnMove(event.type)
? isMovingAColumnToAnotherPosition(coordinates) && ? isMovingAColumnToAnotherPosition(coordinates) &&
onColumnDragEnd({ ...coordinates, subject: board.columns[coordinates.source.fromPosition] }) onColumnDragEnd({ ...coordinates, subject: board.columns?.[coordinates.source.fromPosition] })
: isMovingACardToAnotherPosition(coordinates) && : isMovingACardToAnotherPosition(coordinates) &&
onCardDragEnd({ ...coordinates, subject: getCard(board, coordinates.source) }); onCardDragEnd({ ...coordinates, subject: getCard(board, coordinates.source) });
} }
return ( return (
<DragDropContext onDragEnd={handleOnDragEnd}> <DragDropContext onDragEnd={handleOnDragEnd}>
<div style={{ overflowY: 'hidden', display: 'flex', alignItems: 'flex-start' }} className="react-kanban-board"> <div style={{ overflowY: 'hidden', display: 'flex', alignItems: 'flex-start' }} className="react-kanban-board">
<DroppableBoard droppableId="board-droppable" direction="horizontal" type="BOARD"> <DroppableBoard droppableId="board-droppable" direction="horizontal" type="BOARD">
{board.columns.map((column, index) => ( {board.columns?.map((column, index) => (
<Column <Column
key={column.id} key={column.id}
index={index} index={column?.index ?? index}
renderCard={renderCard} renderCard={renderCard}
renderCardAdder={renderCardAdder} renderCardAdder={renderCardAdder}
renderColumnHeader={(column) => renderColumnHeader={(column) =>

View File

@ -45,7 +45,7 @@ function Column({
<div {...columnProvided.dragHandleProps}>{renderColumnHeader(children)}</div> <div {...columnProvided.dragHandleProps}>{renderColumnHeader(children)}</div>
{cardAdderPosition === 'top' && allowAddCard && renderCardAdder({ column: children, onConfirm: onCardNew })} {cardAdderPosition === 'top' && allowAddCard && renderCardAdder({ column: children, onConfirm: onCardNew })}
<DroppableColumn droppableId={String(children.id)}> <DroppableColumn droppableId={String(children.id)}>
{children.cards.length ? ( {children?.cards?.length ? (
children.cards.map((card, index) => ( children.cards.map((card, index) => (
<Card <Card
key={card.id} key={card.id}

View File

@ -28,13 +28,16 @@ export const ActionDrawer: ComposedActionDrawer = observer((props) => {
destroyOnClose destroyOnClose
visible={visible} visible={visible}
onClose={() => setVisible(false)} onClose={() => setVisible(false)}
className={classNames(others.className, css` className={classNames(
&.nb-action-popup { others.className,
.ant-drawer-content { css`
background: #f0f2f5; &.nb-action-popup {
.ant-drawer-content {
background: #f0f2f5;
}
} }
} `,
`)} )}
footer={ footer={
footerSchema && ( footerSchema && (
<div <div

View File

@ -0,0 +1,58 @@
import { MenuOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { useField, useFieldSchema } from '@formily/react';
import { Space } from 'antd';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useCompile, useDesignable } from '../../../schema-component';
import { SchemaInitializer } from '../../../schema-initializer';
import { useCardItemInitializerFields } from './hoooks';
const titleCss = css`
pointer-events: none;
position: absolute;
font-size: 12px;
background: #f18b62;
color: #fff;
padding: 0 5px;
line-height: 16px;
height: 16px;
border-bottom-right-radius: 2px;
border-radius: 2px;
top: 2px;
left: 2px;
`;
export const KanbanCardDesigner = (props: any) => {
const { dn, designable } = useDesignable();
const { t } = useTranslation();
const field = useField();
const fieldSchema = useFieldSchema();
const compile = useCompile();
const schemaSettingsProps = {
dn,
field,
fieldSchema,
};
if (!designable) {
return null;
}
return (
<div className={'general-schema-designer'}>
<div className={'general-schema-designer-icons'}>
<Space size={2} align={'center'}>
<SchemaInitializer.Button
items={[
{
type: 'itemGroup',
title: t('Display fields'),
children: useCardItemInitializerFields(),
},
]}
component={<MenuOutlined style={{ cursor: 'pointer', fontSize: 12 }} />}
/>
</Space>
</div>
</div>
);
};

View File

@ -0,0 +1,27 @@
import { createForm } from '@formily/core';
import { FieldContext, FormContext, observer } from '@formily/react';
import { Card } from 'antd';
import React, { useContext, useMemo } from 'react';
import { BlockItem } from '../block-item';
import { CardContext } from './context';
export const KanbanCard: any = observer((props: any) => {
const { allowRemoveCard, onCardRemove, children } = props;
const { card, dragging } = useContext(CardContext);
const form = useMemo(
() =>
createForm({
values: { card: { ...card } },
}),
[card],
);
return (
<BlockItem className={'noco-card-item'}>
<FieldContext.Provider value={undefined}>
<FormContext.Provider value={form}>
<Card style={{ width: 220, marginBottom: 15, cursor: 'pointer' }}>{children}</Card>
</FormContext.Provider>
</FieldContext.Provider>
</BlockItem>
);
});

View File

@ -0,0 +1,5 @@
import { observer } from '@formily/react';
export const KanbanCardViewer: any = observer((props: any) => {
return props.children;
});

View File

@ -0,0 +1,17 @@
import React from 'react';
import { useCollection } from '../../../collection-manager';
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
export const KanbanDesigner = () => {
const { name, title } = useCollection();
return (
<GeneralSchemaDesigner title={title || name}>
<SchemaSettings.Remove
removeParentsIfNoChildren
breakRemoveOn={{
'x-component': 'Grid',
}}
/>
</GeneralSchemaDesigner>
);
};

View File

@ -1,5 +1,131 @@
import React from 'react'; import { ArrayField } from '@formily/core';
import { observer, RecursionField, Schema, useField, useFieldSchema } from '@formily/react';
import { uid } from '@formily/shared';
import { Card } from 'antd';
import React, { useState } from 'react';
import { SchemaComponent } from '../..';
import { AsyncDataProvider, RecordProvider, useRequest } from '../../../';
import { Board } from '../../../board';
import { Action, ActionContext } from '../action';
import { CardContext, ColumnContext } from './context';
import { KanbanCardDesigner } from './Kanban.Card.Designer';
import { KanbanCardViewer } from './Kanban.CardViewer';
import { KanbanDesigner } from './Kanban.Designer';
import { toGroupDataSource } from './utils';
export const Kanban = () => { const useRequestProps = (props) => {
return <div>Kanban</div>; const { request, dataSource } = props;
if (request) {
return request;
}
return (params: any = {}) => {
return Promise.resolve({
data: dataSource,
});
};
}; };
const useDefDataSource = (options, props) => {
return useRequest(useRequestProps(props), options);
};
export const Kanban: ComposedKanban = observer((props: any) => {
const { useDataSource = useDefDataSource, groupField, useDragEndAction, ...restProps } = props;
const field = useField<ArrayField>();
const fieldSchema = useFieldSchema();
const [board, setBoard] = useState<any>({ columns: [] });
const [visible, setVisible] = useState(false);
const [record, setRecord] = useState<any>({});
const { run: runDragEnd } = useDragEndAction?.() ?? {};
const result = useDataSource(
{
uid: fieldSchema['x-uid'],
// refreshDeps: [props.dataSource],
onSuccess({ data }) {
const ds = toGroupDataSource(groupField, data);
setBoard(ds);
field.value = ds.columns;
},
},
props,
);
const cardSchema: Schema = fieldSchema.reduceProperties((buf, current) => {
if (current['x-component'] === 'Kanban.Card') {
return current;
}
return buf;
}, null);
console.log('board', board);
const cardAdderSchema: Schema = fieldSchema.reduceProperties((buf, current) => {
if (current['x-component'] === 'Kanban.CardAdder') {
return current;
}
return buf;
}, null);
const cardViewerSchema: Schema = fieldSchema.reduceProperties((buf, current) => {
if (current['x-component'] === 'Kanban.CardViewer') {
return current;
}
return buf;
}, null);
const cardRemoveHandler = (card, column) => {
const updatedBoard = Board.removeCard({ columns: field.value }, column, card);
field.value = updatedBoard.columns;
};
const cardDragEndHandler = (card, fromColumn, toColumn) => {
const updatedBoard = Board.moveCard({ columns: field.value }, fromColumn, toColumn);
field.value = updatedBoard.columns;
runDragEnd?.(card, fromColumn, toColumn);
};
return (
<AsyncDataProvider value={result}>
{cardViewerSchema && (
<ActionContext.Provider value={{ visible, setVisible }}>
<RecordProvider record={record}>
<SchemaComponent name={record.id} schema={cardViewerSchema as any} onlyRenderProperties />
</RecordProvider>
</ActionContext.Provider>
)}
<Board
onCardRemove={cardRemoveHandler}
onCardDragEnd={cardDragEndHandler}
renderCard={(card, { column, dragging }) => {
const columnIndex = field.value?.indexOf(column);
const cardIndex = column?.cards?.indexOf(card);
return (
<RecordProvider record={card}>
<CardContext.Provider value={{ card, column, dragging }}>
<Card style={{ width: 220, marginBottom: 15, cursor: 'pointer' }}>
<RecursionField name={`${columnIndex}.cards.${cardIndex}`} schema={cardSchema} onlyRenderProperties />
</Card>
</CardContext.Provider>
</RecordProvider>
);
}}
renderCardAdder={({ column }) => {
return (
<ColumnContext.Provider value={{ column }}>
<SchemaComponent memoized name={uid()} schema={cardAdderSchema as any} />
</ColumnContext.Provider>
);
}}
{...restProps}
>
{{
columns: field.value?.slice() || [],
}}
</Board>
</AsyncDataProvider>
);
});
Kanban.Card = () => null;
Kanban.CardAdder = Action;
Kanban.CardViewer = KanbanCardViewer;
Kanban.Card.Designer = KanbanCardDesigner;
Kanban.Designer = KanbanDesigner;

View File

@ -0,0 +1,4 @@
import { createContext } from 'react';
export const CardContext = createContext(null);
export const ColumnContext = createContext(null);

View File

@ -0,0 +1,266 @@
/**
* title: Kanban
*/
import { ISchema, useForm } from '@formily/react';
import { observable } from '@formily/reactive';
import {
ActionContext,
AntdSchemaComponentProvider,
CollectionField,
CollectionManagerProvider,
CollectionProvider,
SchemaComponent,
SchemaComponentProvider,
SchemaInitializerProvider
} from '@nocobase/client';
import React, { useContext } from 'react';
const dataSource = observable([
{
id: 1,
title: 'Card title 1',
description: 'Card content',
status: 'doing',
},
{
id: 2,
title: 'Card title 2',
description: 'Card content',
status: 'doing',
},
{
id: 3,
title: 'Card title 3',
description: 'Card content',
status: 'undo',
},
{
id: 4,
title: 'Card title 4',
description: 'Card content',
status: 'doing',
},
{
id: 5,
title: 'Card title 5',
description: 'Card content',
status: 'done',
},
]);
const groupField = {
name: 'status',
enum: [
{ label: '未开始', value: 'undo', index: 1 },
{ label: '进行中', value: 'doing', index: 2 },
{ label: '已完成', value: 'done', index: 3 },
],
};
const schema: ISchema = {
type: 'array',
name: 'kanban',
'x-component': 'Kanban',
'x-component-props': {
dataSource,
groupField,
cardAdderPosition: 'bottom',
allowAddCard: { on: 'bottom' },
disableColumnDrag: true,
useDragEndAction: '{{ useDragEndHandler }}',
},
properties: {
card: {
type: 'void',
name: 'card',
'x-component': 'Kanban.Card',
properties: {
title: {
'x-decorator': 'div',
'x-component': 'Input',
'x-read-pretty': true,
},
description: {
'x-decorator': 'div',
'x-component': 'Input',
'x-read-pretty': true,
},
},
// 'x-designer': 'Kanban.Card.Designer',
},
cardAdder: {
type: 'void',
name: 'cardAdder',
'x-component': 'Kanban.CardAdder',
'x-component-props': {
block: true,
type: 'text',
},
title: '添加卡片',
properties: {
modal: {
'x-component': 'Action.Drawer',
'x-decorator': 'Form',
type: 'void',
title: 'Drawer Title',
properties: {
grid: {
type: 'void',
'x-component': 'Grid',
'x-initializer': 'GridFormItemInitializers',
},
footer: {
'x-component': 'Action.Drawer.Footer',
type: 'void',
properties: {
action1: {
title: '{{ t("Cancel") }}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ useCancelAction }}',
},
},
action2: {
title: '{{ t("Submit") }}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ useOkAction }}',
},
},
},
},
},
},
},
},
cardViewer: {
type: 'void',
name: 'cardViewer',
'x-component': 'Kanban.CardViewer',
properties: {
modal: {
'x-component': 'Action.Drawer',
'x-decorator': 'Form',
type: 'void',
title: 'Drawer Title',
properties: {
grid: {
type: 'void',
'x-component': 'Grid',
},
},
},
},
},
},
};
const collection = {
name: 'KanbanCollection',
title: '看板',
fields: [
{
type: 'string',
name: 'id',
interface: 'input',
title: 'ID',
uiSchema: {
type: 'string',
'x-component': 'Input',
'x-decorator': 'FormItem',
},
},
{
type: 'string',
name: 'title',
interface: 'input',
title: '标题',
uiSchema: {
type: 'string',
'x-component': 'Input',
'x-decorator': 'FormItem',
},
},
{
type: 'string',
name: 'description',
interface: 'input',
title: '描述',
uiSchema: {
type: 'string',
'x-component': 'Input',
'x-decorator': 'FormItem',
},
},
{
type: 'string',
name: 'status',
interface: 'select',
title: '状态',
uiSchema: {
type: 'string',
'x-component': 'Select',
enum: [
{ label: '未开始', value: 'undo' },
{ label: '进行中', value: 'doing' },
{ label: '已完成', value: 'done' },
],
'x-decorator': 'FormItem',
},
},
],
};
export default () => {
const useDragEndHandler = () => {
return {
async run(card, fromColumn, toColumn) {
for (const ds of dataSource) {
if (ds.id === card.id) {
ds.status = toColumn.toColumnId;
break;
}
}
},
};
};
const useOkAction = () => {
const form = useForm();
const { setVisible } = useContext(ActionContext);
return {
async run() {
console.log(form);
dataSource.push(form.values);
setVisible(false);
},
};
};
const useCancelAction = () => {
const form = useForm();
const { setVisible } = useContext(ActionContext);
return {
async run() {
setVisible(false);
},
};
};
return (
<CollectionManagerProvider>
<CollectionProvider collection={collection}>
<SchemaComponentProvider
designable={true}
components={{ CollectionField }}
scope={{ useOkAction, useCancelAction, useDragEndHandler }}
>
<SchemaInitializerProvider>
<AntdSchemaComponentProvider>
<SchemaComponent schema={schema} />
</AntdSchemaComponentProvider>
</SchemaInitializerProvider>
</SchemaComponentProvider>
</CollectionProvider>
</CollectionManagerProvider>
);
};

View File

@ -0,0 +1 @@
export * from './useCardItemInitializerFields';

View File

@ -0,0 +1,27 @@
import { useCollection } from '../../../../collection-manager';
import type { SchemaInitializerItemOptions } from '../../../../schema-initializer';
import { removeGridFormItem } from '../../../../schema-initializer/Initializers/utils';
export const useCardItemInitializerFields = () => {
const { name, fields } = useCollection();
return fields
?.filter((field) => field?.interface)
?.map((field) => {
return {
type: 'item',
title: field?.uiSchema?.title || field.name,
component: 'CollectionFieldInitializer',
remove: removeGridFormItem,
schema: {
name: field.name,
title: field?.uiSchema?.title || field.name,
'x-designer': 'FormItem.Designer',
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-read-pretty': true,
'x-collection-field': `${name}.${field.name}`,
...field?.uiSchema,
},
} as SchemaInitializerItemOptions;
});
};

View File

@ -9,15 +9,17 @@ group:
## 参数说明 ## 参数说明
- dataSource 数据源 * dataSource 数据源
- groupField 分组字段dataSource 以哪个字段作为分组 * groupField 分组字段dataSource 以哪个字段作为分组
- useDataSource动态数据源 * useDataSource动态数据源
- useGroupField动态的分组字段 * useGroupField动态的分组字段
## 示例 ## 示例
### 静态数据 ### 静态数据
<code src="./demos/demo1.tsx" />
```ts ```ts
{ {
'x-component': 'Kanban', 'x-component': 'Kanban',
@ -60,9 +62,9 @@ group:
### 特殊节点 ### 特殊节点
- Kanban.Card 卡片的 schema * Kanban. Card 卡片的 schema
- Kanban.CardViewer 卡片点击打开的详情 * Kanban. CardViewer 卡片点击打开的详情
- Kanban.CardAdder 添加卡片的按钮 * Kanban. CardAdder 添加卡片的按钮
```ts ```ts
{ {

View File

@ -0,0 +1,17 @@
interface IGroupField {
name: string;
enum: Array<{ label: string; value: string; index: number }>;
}
interface IBoard {
columns: Array<any>;
}
type ComposedKanban = React.FC<any> & {
Card?: React.FC<any> & {
Designer?: React.FC<any>;
};
CardAdder?: React.FC<any>;
CardViewer?: React.FC<any>;
Designer?: React.FC<any>;
};

View File

@ -0,0 +1,21 @@
export const toGroupDataSource = (groupField: IGroupField, dataSource: Array<any> = []) => {
if (dataSource.length === 0) {
return { columns: [] };
}
const groupDataSource = [];
groupField.enum.forEach((item, index) => {
groupDataSource.push({
id: item.value,
title: item.label,
index: item.index,
cards: [],
});
});
dataSource.forEach((ds) => {
const group = groupDataSource.find((g) => g.id === ds[groupField.name]);
if (group) {
group.cards.push(ds);
}
});
return { columns: groupDataSource };
};

View File

@ -164,6 +164,7 @@ export const useFormItemInitializerFields = () => {
remove: removeGridFormItem, remove: removeGridFormItem,
schema: { schema: {
name: field.name, name: field.name,
title: field?.uiSchema?.title || field.name,
'x-designer': 'FormItem.Designer', 'x-designer': 'FormItem.Designer',
'x-component': 'CollectionField', 'x-component': 'CollectionField',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',