feat: kanban view

This commit is contained in:
chenos 2021-03-25 16:11:45 +08:00
parent eba121b998
commit 330d08ab35
11 changed files with 534 additions and 65 deletions

View File

@ -23,6 +23,7 @@
"dependencies": { "dependencies": {
"@ant-design/pro-layout": "^5.0.12", "@ant-design/pro-layout": "^5.0.12",
"@formily/antd-components": "^1.3.6", "@formily/antd-components": "^1.3.6",
"@lourenci/react-kanban": "^2.1.0",
"@types/react-big-calendar": "^0.24.8", "@types/react-big-calendar": "^0.24.8",
"@umijs/preset-react": "1.x", "@umijs/preset-react": "1.x",
"@umijs/test": "^3.2.23", "@umijs/test": "^3.2.23",

View File

@ -8,14 +8,14 @@ import set from 'lodash/set';
export function Create(props) { export function Create(props) {
const { size, onFinish, schema = {}, associatedKey, ...restProps } = props; const { size, onFinish, schema = {}, associatedKey, ...restProps } = props;
const { title, viewName, transform } = schema; const { title, pageTitle, viewName, transform, componentProps = {} } = schema;
return ( return (
<> <>
<Button <Button
size={size} size={size}
onClick={() => { onClick={() => {
Drawer.open({ Drawer.open({
title: title, title: pageTitle || title,
content: ({resolve, closeWithConfirm}) => ( content: ({resolve, closeWithConfirm}) => (
<div> <div>
<View <View
@ -53,6 +53,7 @@ export function Create(props) {
}} }}
icon={<PlusOutlined />} icon={<PlusOutlined />}
type={'primary'} type={'primary'}
{...componentProps}
>{ title }</Button> >{ title }</Button>
</> </>
) )
@ -157,9 +158,9 @@ export function Add(props) {
export function Destroy(props) { export function Destroy(props) {
const { size, schema = {}, onFinish } = props; const { size, schema = {}, onFinish } = props;
const { title } = schema; const { title, componentProps = {} } = schema;
return ( return (
<Popconfirm title="确认删除吗?" onConfirm={async () => { <Popconfirm title="确认删除吗?" onConfirm={async (e) => {
onFinish && await onFinish(); onFinish && await onFinish();
}}> }}>
<Button <Button
@ -167,6 +168,7 @@ export function Destroy(props) {
danger danger
type={'ghost'} type={'ghost'}
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
{...componentProps}
>{ title }</Button> >{ title }</Button>
</Popconfirm> </Popconfirm>
) )

View File

@ -90,7 +90,7 @@ export function fields2properties(fields = [], options: any = {}) {
const actions = createFormActions(); const actions = createFormActions();
export function Form(props: any) { export function Form(props: any) {
const { onValueChange, onReset, __parent, noRequest = false, onFinish, onDraft, resolve, data: record = {}, associatedKey, schema = {} } = props; const { initialValues = {}, onValueChange, onReset, __parent, noRequest = false, onFinish, onDraft, resolve, data: record = {}, associatedKey, schema = {} } = props;
console.log({ noRequest, record, associatedKey, __parent }); console.log({ noRequest, record, associatedKey, __parent });
const { statusable, resourceName, rowKey = 'id', fields = [], appends = [], associationField = {} } = schema; const { statusable, resourceName, rowKey = 'id', fields = [], appends = [], associationField = {} } = schema;
@ -116,6 +116,7 @@ export function Form(props: any) {
layout={'vertical'} layout={'vertical'}
initialValues={{ initialValues={{
...data, ...data,
...initialValues,
associatedKey, associatedKey,
resourceKey, resourceKey,
}} }}

View File

@ -0,0 +1,301 @@
import React, { useState } from "react";
import { useRequest, useHistory } from 'umi';
import api from '@/api-client';
import { Descriptions, Popconfirm, Card, Button } from 'antd';
import Board, { moveCard } from "@lourenci/react-kanban";
import "@lourenci/react-kanban/dist/styles.css";
import { PlusOutlined, CloseOutlined, RightOutlined } from '@ant-design/icons';
import Drawer from '@/components/pages/AdminLoader/Drawer';
import { Actions, Create, Destroy } from '../../Actions';
import get from 'lodash/get';
import filter from 'lodash/filter';
import Field from '@/components/views/Field';
import { Details, DetailsPage } from '../Table';
import merge from 'lodash/merge';
import { Spin } from '@nocobase/client';
import './style.less';
export function Kanban(props: any) {
const { defaultFilter, associatedKey, schema = {} } = props;
const {
filter: schemaFilter,
sort,
appends,
groupField = {},
resourceName,
createViewName = 'form',
disableCardDrag = false,
// allowAddCard = false,
// allowRemoveCard = false,
labelField,
actions = [],
fields = [],
detailsOpenMode,
details = [],
rowKey = 'id',
} = schema;
const { dataSource = [] } = groupField;
console.log({groupField, dataSource})
const paginated = false;
const history = useHistory();
const { data, loading, mutate, refresh, run, params } = useRequest((params = {}, ...args) => {
const { current, pageSize, sorter, filter, ...restParams } = params;
console.log('paramsparamsparamsparamsparams', params, args);
return api.resource(resourceName).list({
associatedKey,
page: paginated ? current : 1,
perPage: paginated ? pageSize : -1,
sorter,
sort: [`${groupField.name}_sort`],
'fields[appends]': appends,
// filter,
// ...actionDefaultParams,
filter: {
and: [
defaultFilter,
schemaFilter,
filter,
// __parent ? {
// collection_name: __parent,
// } : null,
].filter(obj => obj && Object.keys(obj).length)
}
// ...args2,
})
.then(({data = [], meta = {}}) => {
return {
data: {
list: data,
total: meta.count||data.length,
},
};
});
}, {
paginated,
});
if (loading) {
return <Spin/>;
}
const columns = dataSource.map(group => {
return {
id: group.value,
title: group.label,
cards: filter(data?.list, (item) => {
return get(item, groupField.name) === group.value;
}),
};
});
console.log({columns});
const bodyStyle: any = {};
if (fields.length === 0) {
bodyStyle.padding = 0;
}
const createAction = actions.find(action => {
return action.type === 'create';
});
let allowAddCard = !!props.allowAddCard;
if (createAction) {
allowAddCard = true;
}
let allowRemoveCard = !!props.allowRemoveCard;
const destroyAction = actions.find(action => {
return action.type === 'destroy';
});
if (destroyAction) {
allowRemoveCard = true;
}
return (
<div className={'noco-kanban-view'}>
<Actions
associatedKey={associatedKey}
style={{ marginBottom: 14 }}
actions={actions.filter(action => !['create', 'destroy'].includes(action.type))}
onTrigger={{
async filter(values) {
const items = values.filter.and || values.filter.or;
// @ts-ignore
run({...params[0], filter: values.filter});
// refresh();
},
}}
/>
<Board
allowRemoveLane
allowRenameColumn
allowRemoveCard={allowRemoveCard}
disableColumnDrag
disableCardDrag={disableCardDrag}
onLaneRemove={console.log}
renderCard={(data, {dragging, removeCard}) => {
const openDetails = (e) => {
if (!detailsOpenMode || !details.length) {
return;
}
if (detailsOpenMode === 'window') {
const paths = history.location.pathname.split('/');
history.push(`/admin/${paths[2]}/${data[rowKey]}/0`);
} else {
Drawer.open({
headerStyle: details.length > 1 ? {
paddingBottom: 0,
borderBottom: 0,
// paddingTop: 16,
// marginBottom: -4,
} : {},
title: details.length > 1 ? undefined : data[labelField],
content: ({resolve, closeWithConfirm}) => (
<div>
<Details
associatedKey={associatedKey}
resourceName={resourceName}
onFinish={async () => {
await refresh();
resolve();
}}
onValueChange={() => {
closeWithConfirm && closeWithConfirm(true);
}}
onDraft={async () => {
await refresh();
resolve();
}}
onReset={resolve}
onDataChange={async () => {
await refresh();
}}
data={data}
resolve={resolve}
items={details}
/>
</div>
),
});
}
}
return (
<Card
hoverable
size={'small'}
style={{background: '#fff', minWidth: 250, maxWidth: 250, marginBottom: 12}}
bordered={false}
title={(
<div onClick={openDetails}>
{data[labelField]}
</div>
)}
bodyStyle={bodyStyle}
extra={
allowRemoveCard && (
<Destroy
schema={merge(destroyAction, {
title: null,
componentProps: {
danger: false,
type: 'text',
size: 'small',
},
})}
onFinish={async () => {
await api.resource(resourceName).destroy({
associatedKey,
filter: {
[`${rowKey}.in`]: [data[rowKey]],
},
});
await refresh();
}}
/>
)
}
>
<div onClick={openDetails}>
{fields.length > 0 && (
<Descriptions column={1} size={'small'} layout={'vertical'}>
{fields.map((field: any) => {
return (
<Descriptions.Item label={field.title||field.name}>
<Field data={data} viewType={'descriptions'} schema={field} value={get(data, field.name)}/>
</Descriptions.Item>
)
})}
</Descriptions>
)}
</div>
</Card>
);
}}
onCardDragEnd={async ({ columns }, card, source, destination) => {
await api.resource(resourceName).update({
associatedKey,
resourceKey: card[rowKey],
values: {
[groupField.name]: destination.toColumnId,
},
});
const destColumn = columns.find( column => column.id === destination.toColumnId);
const targetIndex = get(destColumn, ['cards', destination.toPosition+1, rowKey]);
console.log({targetIndex, card, destination})
await api.resource(resourceName).sort({
associatedKey,
resourceKey: card[rowKey],
values: {
field: `${groupField.name}_sort`,
sticky: destination.toPosition === 0,
target: {
[rowKey]: targetIndex,
},
},
});
}}
initialBoard={{columns}}
// allowAddCard={{ on: "bottom" }}
allowAddCard={false}
// onNewCardConfirm={draftCard => ({
// id: new Date().getTime(),
// ...draftCard
// })}
renderColumnHeader={({ title, id }, { addCard }) => {
return (
<>
<div className="react-kanban-column-header"><span>{ title }</span></div>
{allowAddCard && <Create
onFinish={(values: any) => {
refresh();
}}
initialValues={{
[groupField.name]: id,
}}
schema={merge(createAction, {
title: null,
pageTitle: '新增',
componentProps: {
type: 'text',
block: true,
className: 'noco-card-adder-button',
style: {
marginBottom: 10,
border: 0,
background: '#fff',
},
},
})}/>}
</>
);
}}
onCardNew={console.log}
/>
</div>
);
}

View File

@ -0,0 +1,42 @@
.react-kanban-board {
padding: 0;
margin: -24px -36px;
background-color: #eff2f5;
.react-kanban-column {
background-color: #eff2f5;
margin: 0;
padding: 12px;
}
}
.react-kanban-column-header {
font-weight: 500;
}
.noco-card-adder-button:hover {
background: rgba(255, 255, 255, .4) !important;
}
.noco-kanban-view {
.action-buttons {
margin: -24px -24px 0 !important;
background-color: #eff2f5;
padding-bottom: 40px;
.ant-btn {
border: 0;
}
.filter-action-button {
margin-left: 0;
left: 0;
}
}
.action-buttons + .react-kanban-board {
margin-top: 0;
}
.ant-descriptions-item-label {
width: auto;
}
.ant-descriptions-item {
// padding-bottom: 4px;
}
}

View File

@ -21,7 +21,7 @@ import pathToRegexp from 'path-to-regexp'
export const icon = <LoadingOutlined style={{ fontSize: 36 }} spin />; export const icon = <LoadingOutlined style={{ fontSize: 36 }} spin />;
export function Details(props) { export function Details(props) {
const { __parent, associatedKey, resourceName, onFinish, onReset, onDataChange, data, items = [], resolve, onValueChange } = props; const { __parent, associatedKey, resourceName, onFinish, onDraft, onReset, onDataChange, data, items = [], resolve, onValueChange } = props;
if (!items || items.length === 0) { if (!items || items.length === 0) {
return null; return null;
} }
@ -48,6 +48,7 @@ export function Details(props) {
} }
return ( return (
<View <View
onDraft={onDraft}
onValueChange={onValueChange} onValueChange={onValueChange}
__parent={__parent} __parent={__parent}
associatedKey={associatedKey} onReset={onReset} onFinish={onFinish} onDataChange={onDataChange} data={data} viewName={viewName}/> associatedKey={associatedKey} onReset={onReset} onFinish={onFinish} onDataChange={onDataChange} data={data} viewName={viewName}/>

View File

@ -21,6 +21,7 @@ import { FilterForm } from './FilterForm';
import { SubTable } from './SubTable'; import { SubTable } from './SubTable';
import { Wysiwyg } from './Wysiwyg'; import { Wysiwyg } from './Wysiwyg';
import { Calendar } from './Calendar'; import { Calendar } from './Calendar';
import { Kanban } from './Kanban';
const VIEWS = new Map(); const VIEWS = new Map();
@ -66,5 +67,6 @@ registerView('descriptions', Descriptions);
registerView('association', Association); registerView('association', Association);
registerView('wysiwyg', Wysiwyg); registerView('wysiwyg', Wysiwyg);
registerView('calendar', Calendar); registerView('calendar', Calendar);
registerView('kanban', Kanban);
export default View; export default View;

View File

@ -374,6 +374,16 @@ export const getInfo = async (ctx: actions.Context, next) => {
data.sort = []; data.sort = [];
} }
if (data.type === 'kanban' && data.groupField) {
const groupField = await Field.findOne({
where: {
name: data.groupField,
collection_name: data.collection_name,
}
});
data.groupField = groupField;
}
ctx.body = data; ctx.body = data;
await next(); await next();
}; };

View File

@ -24,7 +24,7 @@ export default {
name: 'type', name: 'type',
title: '操作类型', title: '操作类型',
dataSource: [ dataSource: [
{ label: '过滤', value: 'filter' }, { label: '筛选', value: 'filter' },
{ label: '新增', value: 'create' }, { label: '新增', value: 'create' },
{ label: '编辑', value: 'update' }, { label: '编辑', value: 'update' },
{ label: '删除', value: 'destroy' }, { label: '删除', value: 'destroy' },

View File

@ -86,73 +86,84 @@ export default async function (options = {}) {
const createDetailsViews = async (model, options) => { const createDetailsViews = async (model, options) => {
const data = model.get(); const data = model.get();
const View = database.getModel('views_v2'); const View = database.getModel('views_v2');
const tableDetials = _.get(data, 'x-table-props.details') || []; const types = ['table', 'calendar', 'kanban'];
if (tableDetials.length) { for (const type of types) {
const details = []; const items = _.get(data, `x-${type}-props.details`) || [];
for (const item of tableDetials) { if (items.length) {
if (item.view) { const details = [];
if (!item.view.id) { for (const item of items) {
const view = await View.create(item.view); if (item.view) {
await view.updateAssociations(item.view); if (!item.view.id) {
item.view.id = view.id; const view = await View.create(item.view);
} else { await view.updateAssociations(item.view);
const view = await View.findByPk(item.view.id); item.view.id = view.id;
await view.update(item.view); } else {
await view.updateAssociations(item.view); const view = await View.findByPk(item.view.id);
} if (view) {
const view = await View.findOne(View.parseApiJson({ await view.update(item.view);
filter: { await view.updateAssociations(item.view);
id: item.view.id, }
}, }
fields: { const view = await View.findOne(View.parseApiJson({
appends: ['collection', 'targetField', 'targetView'], filter: {
}, id: item.view.id,
})); },
if (view) { fields: {
console.log({view}); appends: ['collection', 'targetField', 'targetView'],
item.view = view.toJSON(); },
}));
if (view) {
console.log({view});
item.view = view.toJSON();
}
} }
details.push(item);
} }
details.push(item); model.set(`options.x-${type}-props.details`, details);
} }
model.set('options.x-table-props.details', details);
}
const calendarDetials = _.get(data, 'x-calendar-props.details') || [];
if (calendarDetials.length) {
const details = [];
for (const item of calendarDetials) {
if (item.view) {
if (!item.view.id) {
const view = await View.create(item.view);
await view.updateAssociations(item.view);
item.view.id = view.id;
} else {
const view = await View.findByPk(item.view.id);
await view.update(item.view);
await view.updateAssociations(item.view);
}
const view = await View.findOne(View.parseApiJson({
filter: {
id: item.view.id,
},
fields: {
appends: ['collection', 'targetField', 'targetView'],
},
}));
if (view) {
console.log({view});
item.view = view.toJSON();
}
}
details.push(item);
}
model.set('options.x-calendar-props.details', details);
} }
}; };
database.getModel('views_v2').addHook('beforeCreate', createDetailsViews); database.getModel('views_v2').addHook('beforeCreate', createDetailsViews);
database.getModel('views_v2').addHook('beforeUpdate', createDetailsViews); database.getModel('views_v2').addHook('beforeUpdate', createDetailsViews);
database.getModel('views_v2').addHook('beforeSave', async (model, options) => {
const data = model.get();
if (data.type !== 'kanban') {
return;
}
let groupField = _.get(data, `x-kanban-props.groupField`);
if (!groupField) {
return;
}
if (typeof groupField === 'object' && groupField.name) {
groupField = groupField.name;
}
const Field = database.getModel('fields');
let field = await Field.findOne({
where: {
name: `${groupField}_sort`,
collection_name: data.collection_name,
},
});
if (field) {
return;
}
await Field.create({
interface: 'sort',
type: 'sort',
name: `${groupField}_sort`,
// TODO: 不支持相关数据
collection_name: data.collection_name,
scope: [groupField],
title: '看板分组排序',
developerMode: true,
component: {
type: 'sort',
},
});
});
database.getModel('menus').addHook('beforeSave', async (model, options) => { database.getModel('menus').addHook('beforeSave', async (model, options) => {
const { transaction } = options; const { transaction } = options;
// console.log('beforeSave', model.get('views')); // console.log('beforeSave', model.get('views'));

View File

@ -304,6 +304,15 @@ export const calendar = {
}, },
}, },
info3: { info3: {
interface: 'description',
type: 'virtual',
title: '操作按钮配置',
component: {
type: 'description',
},
},
actions,
info4: {
interface: 'description', interface: 'description',
type: 'virtual', type: 'virtual',
title: '单条数据页面配置', title: '单条数据页面配置',
@ -319,6 +328,95 @@ export const calendar = {
}, },
}; };
export const kanban = {
title: '看板',
options: {
},
properties: {
info1: {
interface: 'description',
type: 'virtual',
title: '看板配置',
component: {
type: 'description',
},
},
labelField: {
interface: 'select',
type: 'virtual',
title: '作为单条数据标题的字段',
name: 'labelField',
required: true,
component: {
type: 'remoteSelect',
resourceName: 'collections.fields',
labelField: 'title',
valueField: 'name',
filter: {
type: 'string',
},
},
},
groupField: {
interface: 'select',
type: 'virtual',
title: '看板分组字段',
name: 'groupField',
required: true,
component: {
type: 'remoteSelect',
resourceName: 'collections.fields',
labelField: 'title',
valueField: 'name',
filter: {
type: 'string',
},
},
},
fields: {
...fields,
title: '显示在看板里的字段'
},
info2: {
interface: 'description',
type: 'virtual',
title: '数据配置',
component: {
type: 'description',
},
},
filter: {
interface: 'json',
type: 'virtual',
title: '只显示符合以下条件的数据',
mode: 'replace',
defaultValue: {},
component: {
type: 'filter',
},
},
info3: {
interface: 'description',
type: 'virtual',
title: '操作按钮配置',
component: {
type: 'description',
},
},
actions,
info4: {
interface: 'description',
type: 'virtual',
title: '单条数据页面配置',
component: {
type: 'description',
},
},
detailsOpenMode,
details,
},
};
export const wysiwyg = { export const wysiwyg = {
title: '富文本', title: '富文本',
options: { options: {