Feature/plugin automations (#65)

* feat: add automations plugin

* feat: support users views as submenu

* fix: reload users collection options on initialization

* 表单细节

* 细节更新

* filterable

* fix: can not disassociate before destroy data

* 暂存

* 表单联动细节

* 补充细节

* 补充测试和细节改进

* 补充细节和测试

* 再来一波更新
This commit is contained in:
chenos 2021-02-07 17:39:47 +08:00 committed by GitHub
parent 9bd79cf082
commit eb5581646c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 3176 additions and 22 deletions

View File

@ -67,5 +67,6 @@ api.registerPlugin('plugin-pages', [path.resolve(__dirname, '../../../plugin-pag
api.registerPlugin('plugin-users', [path.resolve(__dirname, '../../../plugin-users'), {}]);
api.registerPlugin('plugin-file-manager', [path.resolve(__dirname, '../../../plugin-file-manager'), {}]);
api.registerPlugin('plugin-permissions', [path.resolve(__dirname, '../../../plugin-permissions'), {}]);
api.registerPlugin('plugin-automations', [path.resolve(__dirname, '../../../plugin-automations'), {}]);
export default api;

View File

@ -80,6 +80,7 @@ api.resourcer.use(async (ctx: actions.Context, next) => {
await api.database.getModel('collections').load({where: {
name: 'users',
}});
await api.database.getModel('automations').load();
api.listen(process.env.HTTP_PORT, () => {
console.log(`http://localhost:${process.env.HTTP_PORT}/`);
});

View File

@ -0,0 +1,19 @@
import api from '../app';
import Database from '@nocobase/database';
(async () => {
await api.loadPlugins();
const database: Database = api.database;
await api.database.sync({
tables: ['automations', 'automations_jobs', 'actions_scopes'],
});
const [Collection, User, Role] = database.getModels(['collections', 'users', 'roles']);
const tables = database.getTables(['automations', 'automations_jobs', 'actions_scopes']);
for (let table of tables) {
console.log(table.getName());
await Collection.import(table.getOptions(), { update: true, migrate: false });
}
})();

View File

@ -132,6 +132,15 @@ const data = [
sort: 120,
showInMenu: true,
},
{
title: '自动化配置',
type: 'collection',
collection: 'automations',
path: '/settings/automations',
icon: 'TableOutlined',
sort: 130,
showInMenu: true,
},
]
},
],

View File

@ -0,0 +1,286 @@
import React, { useEffect, useState } from 'react';
import { connect } from '@formily/react-schema-renderer'
import { Button, Select, DatePicker, Tag, InputNumber, TimePicker, Input } from 'antd';
import {
Select as AntdSelect,
mapStyledProps,
mapTextComponent
} from '../shared'
import moment from 'moment';
import './style.less';
import api from '@/api-client';
import { useRequest } from 'umi';
export const DateTime = connect({
getProps: mapStyledProps,
getComponent: mapTextComponent,
})((props) => {
const { associatedKey, automationType, filter, onChange } = props;
const [aKey, setaKey] = useState(associatedKey);
const [aType, setaType] = useState(automationType);
console.log('Automations.DateTime', aKey, associatedKey)
const [value, setValue] = useState(props.value||{});
const [offsetType, setOffsetType] = useState(() => {
if (!value.offset) {
return 'current';
}
if (value.offset > 0) {
return 'after';
}
if (value.offset < 0) {
return 'before';
}
return 'current';
})
useEffect(() => {
if (associatedKey !== aKey || automationType !== aType) {
setOffsetType('current');
setValue({
value: null,
byField: null,
offset: 0,
unit: undefined,
});
onChange({
value: null,
byField: null,
offset: 0,
unit: undefined,
});
setaKey(associatedKey)
}
}, [ associatedKey, automationType, aKey, aType ]);
const { data = [], loading = true } = useRequest(() => {
return associatedKey && automationType !== 'schedule' ? api.resource('collections.fields').list({
associatedKey,
filter: filter||{
type: 'date',
},
}) : Promise.resolve({data: []});
}, {
refreshDeps: [associatedKey, automationType, filter]
});
console.log({data});
return (
<div>
<div>
{automationType === 'schedule' ? (
<DatePicker showTime onChange={(m, dateString) => {
onChange({value: m.toISOString()});
setValue({value: m.toISOString()});
// console.log('Automations.DateTime', m.toISOString(), {m, dateString})
}} defaultValue={(() => {
if (!value.value) {
return undefined;
}
const m = moment(value.value);
return m.isValid() ? m : undefined;
})()}/>
) : (
<Input.Group compact>
<Select style={{width: 120}} value={value.byField} onChange={(v) => {
setValue({...value, byField: v});
onChange({...value, byField: v});
}} loading={loading} options={data.map(item => ({
value: item.name,
label: item.title||item.name,
}))} placeholder={'选择日期字段'}></Select>
<Select onChange={(offsetType) => {
let values = {...value};
switch (offsetType) {
case 'current':
values = {byField: values.byField, offset: 0};
break;
case 'before':
if (values.offset) {
values.offset = -1 * Math.abs(values.offset);
}
break;
case 'after':
if (values.offset) {
values.offset = Math.abs(values.offset);
}
break;
}
setOffsetType(offsetType);
setValue(values);
onChange(values);
}} value={offsetType} placeholder={'选择日期字段'}>
<Select.Option value={'current'}></Select.Option>
<Select.Option value={'before'}></Select.Option>
<Select.Option value={'after'}></Select.Option>
</Select>
{offsetType !== 'current' && (
<InputNumber step={1} min={1} value={Math.abs(value.offset)||undefined} onChange={(offset: number) => {
const values = {
unit: 'day',...value,
}
if (offsetType === 'before') {
values.offset = -1 * Math.abs(offset);
} else if (offsetType === 'after') {
values.offset = Math.abs(offset);
}
setValue(values);
onChange(values);
console.log('Automations.DateTime', values)
// console.log(offsetType);
}} placeholder={'数字'}/>
)}
{offsetType !== 'current' && (
<Select onChange={(v) => {
setValue({...value, unit: v});
onChange({...value, unit: v});
}} value={value.unit} placeholder={'选择单位'}>
<Select.Option value={'second'}></Select.Option>
<Select.Option value={'minute'}></Select.Option>
<Select.Option value={'hour'}></Select.Option>
<Select.Option value={'day'}></Select.Option>
<Select.Option value={'week'}></Select.Option>
<Select.Option value={'month'}></Select.Option>
</Select>
)}
{offsetType !== 'current' && value.unit && ['day', 'week', 'month'].indexOf(value.unit) !== -1 && (
<TimePicker value={(() => {
const m = moment(value.time, 'HH:mm:ss');
return m.isValid() ? m : undefined;
})()} onChange={(m, dateString) => {
console.log('Automations.DateTime', m, dateString)
setValue({...value, time: dateString});
onChange({...value, time: dateString});
}}/>
)}
</Input.Group>
)}
</div>
</div>
)
})
const cronmap = {
none: '不重复',
everysecond: '每秒',
everyminute: '每分钟',
everyhour: '每小时',
everyday: '每天',
everyweek: '每周',
everymonth: '每月',
custom: '自定义',
};
export const Cron = connect({
getProps: mapStyledProps,
getComponent: mapTextComponent,
})((props) => {
const { value, onChange } = props;
console.log('Automations.DateTime', {value})
const re = /every_(\d+)_(.+)/i;
const match = cronmap[value] ? null : re.exec(value);
const [unit, setUnit] = useState(match ? match[2] : 'days');
const [num, setNum] = useState<any>(match ? parseInt(match[1]) : undefined);
const [cron, setCron] = useState(() => {
if (!value) {
return 'none';
}
return match ? 'custom' : cronmap[value];
})
return (
<div>
<Input.Group compact>
<Select value={cron} onChange={(v) => {
setCron(v);
onChange(v);
}}>
{Object.keys(cronmap).map(key => {
return (
<Select.Option value={key}>{cronmap[key]}</Select.Option>
);
})}
</Select>
{cron === 'custom' && (
<Input type={'number'} onChange={(e) => {
const v = parseInt(e.target.value);
setNum(v);
onChange(`every_${v}_${unit}`);
}} defaultValue={num} addonBefore={'每'}/>
)}
{cron === 'custom' && (
<Select onChange={(v) => {
setUnit(v);
onChange(`every_${num}_${v}`);
}} defaultValue={unit}>
<Select.Option value={'seconds'}></Select.Option>
<Select.Option value={'minutes'}></Select.Option>
<Select.Option value={'hours'}></Select.Option>
<Select.Option value={'days'}></Select.Option>
<Select.Option value={'weeks'}></Select.Option>
<Select.Option value={'months'}></Select.Option>
</Select>
)}
</Input.Group>
</div>
)
})
export const EndMode = connect({
getProps: mapStyledProps,
getComponent: mapTextComponent,
})((props) => {
const { value = 'none', onChange, automationType } = props;
const re = /after_(\d+)_times/i;
const match = re.exec(value);
const [mode, setMode] = useState(() => {
if (automationType === 'schedule' && value === 'byField') {
return 'none';
} else if (automationType === 'collections:schedule' && value === 'customTime') {
return 'none';
}
return match ? 'times' : value;
});
const [num, setNum] = useState(match ? parseInt(match[1]) : undefined);
useEffect(() => {
if (automationType === 'schedule' && value === 'byField') {
setMode('none')
onChange('none')
} else if (automationType === 'collections:schedule' && value === 'customTime') {
setMode('none')
onChange('none')
}
}, [automationType]);
console.log('Automations.DateTime', {value, automationType, mode})
return (
<div>
<Input.Group compact>
<Select style={{width: 150}} value={mode} onChange={(v) => {
setMode(v);
onChange(v);
}}>
<Select.Option value={'none'}></Select.Option>
<Select.Option value={'times'}></Select.Option>
{automationType === 'schedule' && <Select.Option value={'customTime'}></Select.Option>}
{automationType === 'collections:schedule' && <Select.Option value={'byField'}></Select.Option>}
</Select>
{mode === 'times' && <Input type={'number'} onChange={(e) => {
const v = parseInt(e.target.value);
setNum(v);
onChange(`after_${v}_times`);
}} defaultValue={num} addonAfter={'次'}/>}
</Input.Group>
</div>
)
})
export const Automations = {
DateTime, Cron, EndMode
};

View File

@ -0,0 +1,5 @@
.ant-input-group.ant-input-group-compact {
.ant-input-group-wrapper {
width: 120px;
}
}

View File

@ -11,7 +11,7 @@ import api from '@/api-client';
import { useRequest } from 'umi';
export function FilterGroup(props: any) {
const { showDeleteButton = true, fields = [], onDelete, onChange, onAdd, dataSource = {} } = props;
const { showDeleteButton = true, fields = [], sourceFields = [], onDelete, onChange, onAdd, dataSource = {} } = props;
const { list, getKey, push, remove, replace } = useDynamicList<any>(dataSource.list || [
{
type: 'item',
@ -50,6 +50,7 @@ export function FilterGroup(props: any) {
<div style={{marginBottom: 8}}>
{<Component
fields={fields}
sourceFields={sourceFields}
dataSource={item}
// showDeleteButton={list.length > 1}
onChange={(value) => {
@ -319,10 +320,11 @@ function NullControl(props) {
}
export function FilterItem(props: FilterItemProps) {
const { index, fields = [], showDeleteButton = true, onDelete, onChange } = props;
const { index, fields = [], sourceFields = [], showDeleteButton = true, onDelete, onChange } = props;
const [type, setType] = useState('string');
const [field, setField] = useState<any>({});
const [dataSource, setDataSource] = useState(props.dataSource||{});
const [valueType, setValueType] = useState('custom');
useEffect(() => {
const field = fields.find(field => field.name === props.dataSource.column);
if (field) {
@ -334,6 +336,9 @@ export function FilterItem(props: FilterItemProps) {
setType(componentType);
}
setDataSource({...props.dataSource});
if (/^{{.+}}$/.test(props.dataSource.value)) {
setValueType('ref');
}
}, [
props.dataSource, type,
]);
@ -347,7 +352,7 @@ export function FilterItem(props: FilterItemProps) {
// let multiple = true;
// if ()
const opOptions = op[type]||op.string;
console.log({field, dataSource, type, ValueControl});
console.log({valueType});
return (
<Space>
<Select value={dataSource.column}
@ -358,6 +363,7 @@ export function FilterItem(props: FilterItemProps) {
componentType = 'multipleSelect';
}
setType(componentType);
setValueType('custom');
onChange({...dataSource, column: value, op: get(op, [componentType, 0, 'value']), value: undefined});
}}
style={{ width: 120 }}
@ -377,17 +383,43 @@ export function FilterItem(props: FilterItemProps) {
<Select.Option value={option.value}>{option.label}</Select.Option>
))} */}
</Select>
<ValueControl
field={field}
multiple={type === 'checkboxes' || !!field.multiple}
op={dataSource.op}
options={field.dataSource}
value={dataSource.value}
onChange={(value) => {
onChange({...dataSource, value: value});
}}
style={{ width: 180 }}
/>
{sourceFields.length > 0 && (
<Select
style={{ minWidth: 100 }}
onChange={(value) => {
setDataSource({...dataSource, value: undefined})
onChange({...dataSource, value: undefined});
setValueType(value);
}}
defaultValue={valueType}>
<Select.Option value={'custom'}></Select.Option>
<Select.Option value={'ref'}></Select.Option>
</Select>
)}
{valueType !== 'ref' ? (
<ValueControl
field={field}
multiple={type === 'checkboxes' || !!field.multiple}
op={dataSource.op}
options={field.dataSource}
value={dataSource.value}
onChange={(value) => {
onChange({...dataSource, value: value});
}}
style={{ width: 180 }}
/>
) : (sourceFields.length > 0 ? (
<Select value={dataSource.value}
onChange={(value) => {
onChange({...dataSource, value: value});
}}
style={{ width: 120 }}
placeholder={'选择字段'}>
{sourceFields.map(field => (
<Select.Option value={`{{ ${field.name} }}`}>{field.title}</Select.Option>
))}
</Select>
) : null)}
{showDeleteButton && (
<Button className={'filter-remove-link filter-item'} type={'link'} style={{padding: 0}} onClick={(e) => {
onDelete && onDelete(e);
@ -447,7 +479,7 @@ export const Filter = connect({
}
],
};
const { value, onChange, associatedKey, filter = {}, fields = [], ...restProps } = props;
const { value, onChange, associatedKey, filter = {}, sourceName, sourceFilter = {}, fields = [], ...restProps } = props;
const { data = [], loading = true } = useRequest(() => {
return associatedKey ? api.resource(`collections.fields`).list({
@ -460,10 +492,23 @@ export const Filter = connect({
refreshDeps: [associatedKey]
});
const { data: sourceFields = [] } = useRequest(() => {
return sourceName ? api.resource(`collections.fields`).list({
associatedKey: sourceName,
filter: sourceFilter,
}) : Promise.resolve({
data: [],
});
}, {
refreshDeps: [sourceName]
});
console.log({sourceName, sourceFields});
return <FilterGroup showDeleteButton={false} dataSource={value ? toValues(value) : dataSource} onChange={(values) => {
console.log(values);
onChange(toFilter(values));
}} {...restProps} fields={data.filter(item => item.filterable)}/>
}} {...restProps} sourceFields={sourceFields} fields={data.filter(item => item.filterable)}/>
});
export default Filter;

View File

@ -21,6 +21,8 @@ import { Icon } from './icons'
import { ColorSelect } from './color-select'
import { Permissions } from './permissions'
import { DraggableTable } from './draggable-table'
import { Values } from './values'
import { Automations } from './automations'
export const setup = () => {
registerFormFields({
@ -55,8 +57,12 @@ export const setup = () => {
colorSelect: ColorSelect,
subTable: SubTable,
draggableTable: DraggableTable,
values: Values,
'permissions.actions': Permissions.Actions,
'permissions.fields': Permissions.Fields,
'permissions.tabs': Permissions.Tabs,
})
'automations.datetime': Automations.DateTime,
'automations.endmode': Automations.EndMode,
'automations.cron': Automations.Cron,
});
}

View File

@ -14,7 +14,7 @@ import api from '@/api-client';
import { Spin } from '@nocobase/client'
function RemoteSelectComponent(props) {
const { value, onChange, disabled, resourceName, associatedKey, filter, labelField, valueField = 'id', objectValue, placeholder } = props;
const { value, onChange, disabled, resourceName, associatedKey, filter, labelField, valueField = 'id', objectValue, placeholder, multiple } = props;
const { data = [], loading = true } = useRequest(() => {
return api.resource(resourceName).list({
associatedKey,
@ -23,9 +23,14 @@ function RemoteSelectComponent(props) {
}, {
refreshDeps: [resourceName, associatedKey]
});
const selectProps: any = {};
if (multiple) {
selectProps.mode = 'multiple'
}
return (
<>
<Select
{...selectProps}
placeholder={placeholder}
disabled={disabled}
notFoundContent={loading ? <Spin/> : undefined}

View File

@ -0,0 +1,399 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Select, Input, Space, Form, InputNumber, DatePicker, TimePicker, Radio } from 'antd';
import { PlusCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import useDynamicList from './useDynamicList';
import { connect } from '@formily/react-schema-renderer'
import { mapStyledProps } from '../shared'
import get from 'lodash/get';
import moment from 'moment';
import './style.less';
import api from '@/api-client';
import { useRequest } from 'umi';
export function FilterGroup(props: any) {
const { fields = [], sourceFields = [], onDelete, onChange, onAdd, dataSource = [] } = props;
const { list, getKey, push, remove, replace } = useDynamicList<any>(dataSource);
let style: any = {
position: 'relative',
};
return (
<div style={style}>
<div>
{list.map((item, index) => {
// console.log(item);
// const Component = item.type === 'group' ? FilterGroup : FilterItem;
return (
<div style={{marginBottom: 8}}>
{<FilterItem
fields={fields}
sourceFields={sourceFields}
dataSource={item}
// showDeleteButton={list.length > 1}
onChange={(value) => {
replace(index, value);
const newList = [...list];
newList[index] = value;
onChange(newList);
// console.log(list, value, index);
}}
onDelete={() => {
remove(index);
const newList = [...list];
newList.splice(index, 1);
onChange(newList);
// console.log(list, index);
}}
/>}
</div>
);
})}
</div>
<div>
<Space>
<Button style={{padding: 0}} type={'link'} onClick={() => {
const data = {};
push(data);
const newList = [...list];
newList.push(data);
onChange(newList);
}}>
<PlusCircleOutlined />
</Button>
</Space>
</div>
</div>
);
}
interface FieldOptions {
name: string;
title: string;
interface: string;
[key: string]: any;
}
interface FilterItemProps {
fields: FieldOptions[];
[key: string]: any;
}
const OP_MAP = {
string: [
{label: '包含', value: '$includes', selected: true},
{label: '不包含', value: '$notIncludes'},
{label: '等于', value: 'eq'},
{label: '不等于', value: 'ne'},
{label: '非空', value: '$notNull'},
{label: '为空', value: '$null'},
],
number: [
{label: '等于', value: 'eq', selected: true},
{label: '不等于', value: 'ne'},
{label: '大于', value: 'gt'},
{label: '大于等于', value: 'gte'},
{label: '小于', value: 'lt'},
{label: '小于等于', value: 'lte'},
// {label: '介于', value: 'between'},
{label: '非空', value: '$notNull'},
{label: '为空', value: '$null'},
],
file: [
{label: '存在', value: 'id.gt'},
{label: '不存在', value: 'id.$null'},
],
boolean: [
{label: '是', value: '$isTruly', selected: true},
{label: '否', value: '$isFalsy'},
],
select: [
{label: '等于', value: 'eq', selected: true},
{label: '不等于', value: 'ne'},
{label: '包含', value: 'in'},
{label: '不包含', value: 'notIn'},
{label: '非空', value: '$notNull'},
{label: '为空', value: '$null'},
],
multipleSelect: [
{label: '等于', value: '$match', selected: true},
{label: '不等于', value: '$notMatch'},
{label: '包含', value: '$anyOf'},
{label: '不包含', value: '$noneOf'},
{label: '非空', value: '$notNull'},
{label: '为空', value: '$null'},
],
datetime: [
{label: '等于', value: '$dateOn', selected: true},
{label: '不等于', value: '$dateNotOn'},
{label: '早于', value: '$dateBefore'},
{label: '晚于', value: '$dateAfter'},
{label: '不早于', value: '$dateNotBefore'},
{label: '不晚于', value: '$dateNotAfter'},
// {label: '介于', value: 'between'},
{label: '非空', value: '$notNull'},
{label: '为空', value: '$null'},
// {label: '是今天', value: 'now'},
// {label: '在今天之前', value: 'before_today'},
// {label: '在今天之后', value: 'after_today'},
],
time: [
{label: '等于', value: 'eq', selected: true},
{label: '不等于', value: 'neq'},
{label: '大于', value: 'gt'},
{label: '大于等于', value: 'gte'},
{label: '小于', value: 'lt'},
{label: '小于等于', value: 'lte'},
// {label: '介于', value: 'between'},
{label: '非空', value: '$notNull'},
{label: '为空', value: '$null'},
// {label: '是今天', value: 'now'},
// {label: '在今天之前', value: 'before_today'},
// {label: '在今天之后', value: 'after_today'},
],
// linkTo: [
// {label: '包含', value: 'cont'},
// {label: '不包含', value: 'ncont'},
// {label: '非空', value: '$notNull'},
// {label: '为空', value: '$null'},
// ],
};
const op = {
string: OP_MAP.string,
textarea: OP_MAP.string,
number: OP_MAP.number,
percent: OP_MAP.number,
datetime: OP_MAP.datetime,
date: OP_MAP.datetime,
time: OP_MAP.time,
checkbox: OP_MAP.boolean,
boolean: OP_MAP.boolean,
select: OP_MAP.select,
multipleSelect: OP_MAP.multipleSelect,
checkboxes: OP_MAP.multipleSelect,
radio: OP_MAP.select,
upload: OP_MAP.file,
attachment: OP_MAP.file,
};
const StringInput = (props) => {
const {value, onChange, ...restProps } = props;
return (
<Input {...restProps} defaultValue={value} onChange={(e) => {
onChange(e.target.value);
}}/>
);
}
const controls = {
string: StringInput,
textarea: StringInput,
number: InputNumber,
percent: (props) => (
<InputNumber
formatter={value => value ? `${value}%` : ''}
parser={value => value.replace('%', '')}
{...props}
/>
),
boolean: BooleanControl,
checkbox: BooleanControl,
select: OptionControl,
radio: OptionControl,
checkboxes: OptionControl,
multipleSelect: OptionControl,
time: TimeControl,
date: DateControl,
};
function DateControl(props: any) {
const { field, value, onChange, ...restProps } = props;
let format = field.dateFormat;
// if (field.showTime) {
// format += ` ${field.timeFormat}`;
// }
const m = moment(value, format);
return (
<DatePicker format={format} value={m.isValid() ? m : null} onChange={(value) => {
onChange(value ? value.format('YYYY-MM-DD') : null)
}}/>
);
// return (
// <DatePicker format={format} showTime={field.showTime} value={m.isValid() ? m : null} onChange={(value) => {
// onChange(value ? value.format(field.showTime ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD') : null)
// }}/>
// );
}
function TimeControl(props: any) {
const { field, value, onChange, ...restProps } = props;
let format = field.timeFormat;
const m = moment(value, format);
return <TimePicker
value={m.isValid() ? m : null}
format={field.timeFormat}
onChange={(value) => {
onChange(value ? value.format('HH:mm:ss') : null)
}}
/>
}
function OptionControl(props) {
const { multiple = true, op, options, value, onChange, ...restProps } = props;
let mode: any = 'multiple';
if (!multiple && ['eq', 'ne'].indexOf(op) !== -1) {
mode = undefined;
}
return (
<Select style={{ minWidth: 120 }} mode={mode} value={value} onChange={(value) => {
onChange(value);
}} options={options}>
</Select>
);
}
function BooleanControl(props) {
const { value, onChange, ...restProps } = props;
return (
<Radio.Group value={value} onChange={(e) => {
onChange(e.target.value);
}}>
<Radio value={true}></Radio>
<Radio value={false}></Radio>
</Radio.Group>
);
}
function NullControl(props) {
return null;
}
export function FilterItem(props: FilterItemProps) {
const { index, fields = [], sourceFields = [], showDeleteButton = true, onDelete, onChange } = props;
const [type, setType] = useState('string');
const [field, setField] = useState<any>({});
const [dataSource, setDataSource] = useState(props.dataSource||{});
useEffect(() => {
const field = fields.find(field => field.name === props.dataSource.column);
if (field) {
setField(field);
let componentType = field.component.type;
if (field.component.type === 'select' && field.multiple) {
componentType = 'multipleSelect';
}
setType(componentType);
}
setDataSource({...props.dataSource});
}, [
props.dataSource, type,
]);
let ValueControl = controls[type]||controls.string;
if (['truncate'].indexOf(dataSource.op) !== -1) {
ValueControl = NullControl;
} else if (dataSource.op === 'ref') {
ValueControl = () => {
return (
<Select value={dataSource.value}
onChange={(value) => {
onChange({...dataSource, value: value});
}}
style={{ width: 120 }}
placeholder={'选择字段'}>
{sourceFields.map(field => (
<Select.Option value={field.name}>{field.title}</Select.Option>
))}
</Select>
)
}
}
// let multiple = true;
// if ()
// const opOptions = op[type]||op.string;
const opOptions = [
{label: '自定义填写', value: 'eq', selected: true},
{label: '等于触发数据', value: 'ref'},
{label: '清空数据', value: 'truncate'},
];
console.log({field, dataSource, type, ValueControl});
return (
<Space>
<Select value={dataSource.column}
onChange={(value) => {
const field = fields.find(field => field.name === value);
let componentType = field.component.type;
if (field.component.type === 'select' && field.multiple) {
componentType = 'multipleSelect';
}
setType(componentType);
onChange({...dataSource, column: value, op: get(opOptions, [0, 'value']), value: undefined});
}}
style={{ width: 120 }}
placeholder={'选择字段'}>
{fields.map(field => (
<Select.Option value={field.name}>{field.title}</Select.Option>
))}
</Select>
<Select value={dataSource.column ? dataSource.op : null} style={{ minWidth: 130 }}
onChange={(value) => {
onChange({...dataSource, op: value, value: undefined});
}}
defaultValue={get(opOptions, [0, 'value'])}
options={opOptions}
>
{/* {(op[type]||op.string).map(option => (
<Select.Option value={option.value}>{option.label}</Select.Option>
))} */}
</Select>
<ValueControl
field={field}
multiple={type === 'checkboxes' || !!field.multiple}
op={dataSource.op}
options={field.dataSource}
value={dataSource.value}
onChange={(value) => {
onChange({...dataSource, value: value});
}}
style={{ width: 180 }}
/>
{showDeleteButton && (
<Button className={'filter-remove-link filter-item'} type={'link'} style={{padding: 0}} onClick={(e) => {
onDelete && onDelete(e);
}}><CloseCircleOutlined /></Button>
)}
</Space>
);
}
export const Values = connect({
getProps: mapStyledProps,
})((props) => {
const { value = [], onChange, associatedKey, sourceName, sourceFilter = {}, filter = {}, fields = [], ...restProps } = props;
const { data = [], loading = true } = useRequest(() => {
return associatedKey ? api.resource(`collections.fields`).list({
associatedKey,
filter,
}) : Promise.resolve({
data: fields,
});
}, {
refreshDeps: [associatedKey]
});
const { data: sourceFields = [] } = useRequest(() => {
return sourceName ? api.resource(`collections.fields`).list({
associatedKey: sourceName,
filter: sourceFilter,
}) : Promise.resolve({
data: [],
});
}, {
refreshDeps: [sourceName]
});
return <FilterGroup dataSource={Array.isArray(value) ? value.filter(item => Object.keys(item).length) : []} onChange={(values) => {
onChange(values.filter(item => Object.keys(item).length));
}} {...restProps} fields={data} sourceFields={sourceFields}/>
});
export default Values;

View File

@ -0,0 +1,3 @@
.filter-remove-link {
color:#d9d9d9;
}

View File

@ -0,0 +1,160 @@
import { useCallback, useRef, useState } from 'react';
export default <T>(initialValue: T[]) => {
const counterRef = useRef(-1);
// key 存储器
const keyList = useRef<number[]>([]);
// 内部方法
const setKey = useCallback((index: number) => {
counterRef.current += 1;
keyList.current.splice(index, 0, counterRef.current);
}, []);
const [list, setList] = useState(() => {
(initialValue || []).forEach((_, index) => {
setKey(index);
});
return initialValue || [];
});
const resetList = (newList: T[] = []) => {
keyList.current = [];
counterRef.current = -1;
setList(() => {
(newList || []).forEach((_, index) => {
setKey(index);
});
return newList || [];
});
};
const insert = (index: number, obj: T) => {
setList((l) => {
const temp = [...l];
temp.splice(index, 0, obj);
setKey(index);
return temp;
});
};
const getAll = () => list;
const getKey = (index: number) => keyList.current[index];
const getIndex = (index: number) => keyList.current.findIndex((ele) => ele === index);
const merge = (index: number, obj: T[]) => {
setList((l) => {
const temp = [...l];
obj.forEach((_, i) => {
setKey(index + i);
});
temp.splice(index, 0, ...obj);
return temp;
});
};
const replace = (index: number, obj: T) => {
setList((l) => {
const temp = [...l];
temp[index] = obj;
return temp;
});
};
const remove = (index: number) => {
setList((l) => {
const temp = [...l];
temp.splice(index, 1);
// remove keys if necessary
try {
keyList.current.splice(index, 1);
} catch (e) {
console.error(e);
}
return temp;
});
};
const move = (oldIndex: number, newIndex: number) => {
if (oldIndex === newIndex) {
return;
}
setList((l) => {
const newList = [...l];
const temp = newList.filter((_: {}, index: number) => index !== oldIndex);
temp.splice(newIndex, 0, newList[oldIndex]);
// move keys if necessary
try {
const keyTemp = keyList.current.filter((_: {}, index: number) => index !== oldIndex);
keyTemp.splice(newIndex, 0, keyList.current[oldIndex]);
keyList.current = keyTemp;
} catch (e) {
console.error(e);
}
return temp;
});
};
const push = (obj: T) => {
setList((l) => {
setKey(l.length);
return l.concat([obj]);
});
};
const pop = () => {
// remove keys if necessary
try {
keyList.current = keyList.current.slice(0, keyList.current.length - 1);
} catch (e) {
console.error(e);
}
setList((l) => l.slice(0, l.length - 1));
};
const unshift = (obj: T) => {
setList((l) => {
setKey(0);
return [obj].concat(l);
});
};
const sortForm = (result: unknown[]) =>
result
.map((item, index) => ({ key: index, item })) // add index into obj
.sort((a, b) => getIndex(a.key) - getIndex(b.key)) // sort based on the index of table
.filter((item) => !!item.item) // remove undefined(s)
.map((item) => item.item); // retrive the data
const shift = () => {
// remove keys if necessary
try {
keyList.current = keyList.current.slice(1, keyList.current.length);
} catch (e) {
console.error(e);
}
setList((l) => l.slice(1, l.length));
};
return {
list,
insert,
merge,
replace,
remove,
getAll,
getKey,
getIndex,
move,
push,
pop,
unshift,
shift,
sortForm,
resetList,
};
};

View File

@ -371,7 +371,7 @@ function LogField(props) {
function LogFieldValue(props) {
const { value, schema, data } = props;
return (
<div>{value}</div>
<div>{JSON.stringify(value)}</div>
)
}

View File

@ -28,7 +28,7 @@ import cleanDeep from 'clean-deep';
import scopes from './scopes';
export const DrawerForm = forwardRef((props: any, ref) => {
console.log(props);
console.log('DrawerForm', props);
const {
activeTab = {},
pageInfo = {},
@ -42,7 +42,7 @@ export const DrawerForm = forwardRef((props: any, ref) => {
const [form, setForm] = useState<any>({});
const [changed, setChanged] = useState(false);
console.log(associatedKey);
const { title, actionDefaultParams = {}, fields = {} } = props.schema||{};
const { title, initialValues = {}, actionDefaultParams = {}, fields = {} } = props.schema||{};
const [resourceKey, setResourceKey] = useState(props.resourceKey);
const [visible, setVisible] = useState(false);
const name = associatedName ? `${associatedName}.${resourceName}` : resourceName;
@ -116,7 +116,7 @@ export const DrawerForm = forwardRef((props: any, ref) => {
colon={true}
layout={'vertical'}
// 暂时先这么处理,如果有 associatedKey 注入表单里
initialValues={{associatedKey, resourceKey, ...data}}
initialValues={{associatedKey, resourceKey, ...initialValues, ...data}}
// actions={actions}
schema={{
type: 'object',

View File

@ -0,0 +1,17 @@
{
"name": "@nocobase/plugin-automations",
"version": "0.3.0-alpha.0",
"main": "lib/index.js",
"license": "MIT",
"dependencies": {
"@nocobase/actions": "^0.3.0-alpha.0",
"@nocobase/database": "^0.3.0-alpha.0",
"@nocobase/resourcer": "^0.3.0-alpha.0",
"@nocobase/server": "^0.3.0-alpha.0",
"json-templates": "^4.1.0",
"node-schedule": "^2.0.0"
},
"devDependencies": {
"@types/node-schedule": "^1.3.1"
}
}

View File

@ -0,0 +1,586 @@
import { Application } from '@nocobase/server';
import Database, { Model, ModelCtor } from '@nocobase/database'
import { getApp, getAPI, getAgent } from '.';
import { AutomationModel } from '../models/automation';
import _ from 'lodash';
jest.setTimeout(300000);
describe('automations', () => {
let app: Application;
let db: Database;
let Test: ModelCtor<Model>;
let Target: ModelCtor<Model>;
let Automation: ModelCtor<AutomationModel>;
beforeEach(async () => {
app = await getApp();
db = app.database;
Automation = db.getModel('automations') as any;
Test = db.getModel('tests');
Target = db.getModel('targets');
});
afterEach(() => db.close());
describe('collections:afterCreate', () => {
it('collections:afterCreate', async () => {
const automation = await Automation.create({
title: 'a1',
enabled: true,
type: 'collections:afterCreate',
collection_name: 'tests',
filter: {},
}, {
hooks: false,
});
let data = {}
const arr = [];
automation.startJob('test', async (model) => {
data = _.cloneDeep(model.get());
arr.push('afterCreate');
});
const test = await Test.create({
name1: 'n1',
name2: 'n2',
});
const t = _.cloneDeep(test.get());
expect(t).toEqual(data);
await test.update({
name1: 'n3',
});
expect(t).toEqual(data);
expect(arr.length).toBe(1);
});
it('collections:afterCreate - filter', async () => {
const automation = await Automation.create({
title: 'a1',
enabled: true,
type: 'collections:afterCreate',
collection_name: 'tests',
filter: {
name1: 'n1',
},
}, {
hooks: false,
});
const arr = [];
automation.startJob('test', async (model) => {
arr.push('afterCreate');
});
await Test.create({
name1: 'n1',
name2: 'n2',
});
expect(arr.length).toBe(1);
await Test.create({
name1: 'n3',
name2: 'n4',
});
expect(arr.length).toBe(1);
});
});
describe('collections:afterUpdate', () => {
it('collections:afterUpdate', async () => {
const automation = await Automation.create({
title: 'a1',
enabled: true,
type: 'collections:afterUpdate',
collection_name: 'tests',
filter: {},
}, {
hooks: false,
});
const arr = [];
automation.startJob('test', async (model) => {
arr.push('afterUpdate');
});
const test = await Test.create({
name1: 'n1',
name2: 'n2',
});
expect(arr.length).toBe(0);
await test.update({
name1: 'n3',
});
expect(arr.length).toBe(1);
});
it('collections:afterUpdate - changed', async () => {
const automation = await Automation.create({
title: 'a1',
enabled: true,
type: 'collections:afterUpdate',
collection_name: 'tests',
filter: {},
changed: ['name2']
}, {
hooks: false,
});
const arr = [];
automation.startJob('test', async (model) => {
arr.push('afterUpdate');
});
const test = await Test.create({
name1: 'n1',
name2: 'n2',
});
expect(arr.length).toBe(0);
await test.update({
name1: 'n3',
});
expect(arr.length).toBe(0);
await test.update({
name2: 'n4',
});
expect(arr.length).toBe(1);
});
it('collections:afterUpdate - filter/changed', async () => {
const automation = await Automation.create({
title: 'a1',
enabled: true,
type: 'collections:afterUpdate',
collection_name: 'tests',
filter: {
name1: 'n7',
},
changed: ['name2']
}, {
hooks: false,
});
const arr = [];
automation.startJob('test', async (model) => {
arr.push('afterUpdate');
});
const test = await Test.create({
name1: 'n1',
name2: 'n2',
});
expect(arr.length).toBe(0);
await test.update({
name1: 'n3',
});
expect(arr.length).toBe(0);
await test.update({
name2: 'n4',
});
expect(arr.length).toBe(0);
await test.update({
name1: 'n5',
name2: 'n6',
});
expect(arr.length).toBe(0);
await test.update({
name1: 'n7',
name2: 'n8',
});
expect(arr.length).toBe(1);
});
});
describe('collections:afterCreateOrUpdate', () => {
it('collections:afterCreateOrUpdate', async () => {
const automation = await Automation.create({
title: 'a1',
enabled: true,
type: 'collections:afterCreateOrUpdate',
collection_name: 'tests',
filter: {},
}, {
hooks: false,
});
const arr = [];
automation.startJob('test', async (model) => {
arr.push('afterUpdate');
});
const test = await Test.create({
name1: 'n1',
name2: 'n2',
});
expect(arr.length).toBe(1);
await test.update({
name1: 'n3',
name2: 'n4',
});
expect(arr.length).toBe(2);
});
it('collections:afterCreateOrUpdate - changed', async () => {
const automation = await Automation.create({
title: 'a1',
enabled: true,
type: 'collections:afterCreateOrUpdate',
collection_name: 'tests',
changed: ['name2'],
filter: {},
}, {
hooks: false,
});
const arr = [];
automation.startJob('test', async (model) => {
arr.push('afterUpdate');
});
const test = await Test.create({
name1: 'n1',
name2: 'n2',
});
expect(arr.length).toBe(1);
await test.update({
name1: 'n3',
});
expect(arr.length).toBe(1);
await test.update({
name2: 'n4',
});
expect(arr.length).toBe(2);
});
it('collections:afterCreateOrUpdate - filter', async () => {
const automation = await Automation.create({
title: 'a1',
enabled: true,
type: 'collections:afterCreateOrUpdate',
collection_name: 'tests',
filter: {
name1: 'n7',
},
}, {
hooks: false,
});
const arr = [];
automation.startJob('test', async (model) => {
arr.push('afterUpdate');
});
await Test.create({
name1: 'n1',
});
expect(arr.length).toBe(0);
const test = await Test.create({
name1: 'n7',
});
expect(arr.length).toBe(1);
await test.update({
name1: 'n3',
});
expect(arr.length).toBe(1);
await test.update({
name1: 'n7',
});
expect(arr.length).toBe(2);
});
it('collections:afterCreateOrUpdate - filter/changed', async () => {
const automation = await Automation.create({
title: 'a1',
enabled: true,
type: 'collections:afterCreateOrUpdate',
collection_name: 'tests',
filter: {
name1: 'n7',
},
changed: ['name2']
}, {
hooks: false,
});
const arr = [];
automation.startJob('test', async (model) => {
arr.push('afterUpdate');
});
const test = await Test.create({
name1: 'n1',
name2: 'n2',
});
expect(arr.length).toBe(0);
await test.update({
name1: 'n3',
});
expect(arr.length).toBe(0);
await test.update({
name2: 'n4',
});
expect(arr.length).toBe(0);
await test.update({
name1: 'n7',
});
expect(arr.length).toBe(0);
await test.update({
name1: 'n5',
name2: 'n6',
});
expect(arr.length).toBe(0);
await test.update({
name1: 'n7',
name2: 'n8',
});
expect(arr.length).toBe(1);
});
});
describe('collections:schedule', () => {
it('collections:schedule', async () => {
const automation = await Automation.create({
title: 'a1',
enabled: true,
type: 'collections:schedule',
collection_name: 'tests',
startTime: {
byField: 'date1',
},
// cron: 'none', // 不重复
}, {
hooks: false,
});
const arr = [];
automation.startJob('test', async (model) => {
arr.push('schedule');
});
await Test.create({
date1: new Date(Date.now() + 200).toISOString(),
});
await new Promise((r) => setTimeout(r, 1000));
expect(arr.length).toBe(1);
});
it('collections:schedule - cron', async () => {
const automation = await Automation.create({
title: 'a1',
enabled: true,
type: 'collections:schedule',
collection_name: 'tests',
startTime: {
byField: 'date1',
},
cron: '* * * * * *',
}, {
hooks: false,
});
const arr = [];
automation.startJob('test', async (model) => {
arr.push('schedule');
console.log('schedule', new Date(), arr.length);
});
await Test.create({
date1: new Date(Date.now() + 1000).toISOString(),
});
await new Promise((r) => setTimeout(r, 3000));
expect(arr.length).toBe(2);
await automation.cancelJob('test');
});
it('collections:schedule - endField', async () => {
const automation = await Automation.create({
title: 'a1',
enabled: true,
type: 'collections:schedule',
collection_name: 'tests',
startTime: {
byField: 'date1',
},
endMode: 'byField',
endTime: {
byField: 'date2',
},
cron: '* * * * * *',
}, {
hooks: false,
});
const arr = [];
automation.startJob('test', async (model) => {
arr.push('schedule');
console.log('schedule', new Date(), arr.length);
});
await Test.create({
date1: new Date(Date.now() + 1000).toISOString(),
date2: new Date(Date.now() + 3000).toISOString(),
});
await new Promise((r) => setTimeout(r, 4000));
expect(arr.length).toBe(2);
await automation.cancelJob('test');
});
});
describe('schedule', () => {
it('schedule', async () => {
const automation = await Automation.create({
title: 'a1',
enabled: true,
type: 'schedule',
startTime: {
value: new Date(Date.now() + 100).toISOString(),
},
// cron: 'none', // 不重复
}, {
hooks: false,
});
const arr = [];
automation.startJob('test', async (model) => {
arr.push('schedule');
});
await new Promise((r) => setTimeout(r, 500));
expect(arr.length).toBe(1);
});
it('schedule - cron', async () => {
const automation = await Automation.create({
title: 'a1',
enabled: true,
type: 'schedule',
startTime: {
value: new Date(Date.now()).toISOString(),
},
cron: '* * * * * *',
}, {
hooks: false,
});
const arr = [];
automation.startJob('test', async (date) => {
arr.push('schedule');
console.log('schedule', date, arr.length);
});
await new Promise((r) => setTimeout(r, 3000));
expect(arr.length).toBe(3);
await automation.cancelJob('test');
});
it('schedule - endTime', async () => {
const automation = await Automation.create({
title: 'a1',
enabled: true,
type: 'schedule',
startTime: {
value: new Date(Date.now()).toISOString(),
},
endMode: 'customTime',
endTime: {
value: new Date(Date.now()+2000).toISOString(),
},
cron: '* * * * * *',
}, {
hooks: false,
});
const arr = [];
automation.startJob('test', async (date) => {
arr.push('schedule');
console.log('schedule', date, arr.length);
});
await new Promise((r) => setTimeout(r, 3000));
expect(arr.length).toBe(2);
await automation.cancelJob('test');
});
});
describe('jobs', () => {
it('create', async () => {
const automation = await Automation.create({
title: 'a1',
enabled: true,
type: 'collections:afterCreate',
collection_name: 'tests',
});
await automation.updateAssociations({
jobs: [
{
title: 'j1',
enabled: true,
type: 'create',
collection_name: 'targets',
values: [
{
column: 'col1',
op: 'eq',
value: 'n1'
},
{
column: 'col2',
op: 'ref',
value: 'name2'
},
],
}
],
});
await Test.create({
name1: 'n11',
name2: 'n22',
});
await new Promise((r) => setTimeout(r, 1000));
const count = await Target.count({
where: { col1: 'n1', col2: 'n22' }
});
expect(count).toBe(1);
});
});
});

View File

@ -0,0 +1,15 @@
import { TableOptions } from '@nocobase/database';
export default {
name: 'targets',
fields: [
{
type: 'string',
name: 'col1',
},
{
type: 'string',
name: 'col2',
}
],
} as TableOptions;

View File

@ -0,0 +1,23 @@
import { TableOptions } from '@nocobase/database';
export default {
name: 'tests',
fields: [
{
type: 'string',
name: 'name1',
},
{
type: 'string',
name: 'name2',
},
{
type: 'date',
name: 'date1',
},
{
type: 'date',
name: 'date2',
}
],
} as TableOptions;

View File

@ -0,0 +1,144 @@
import path from 'path';
import qs from 'qs';
import supertest from 'supertest';
import bodyParser from 'koa-bodyparser';
import { Dialect } from 'sequelize';
import Database from '@nocobase/database';
import { actions, middlewares } from '@nocobase/actions';
import { Application } from '@nocobase/server';
import middleware from '@nocobase/server/src/middleware';
import plugin from '../server';
function getTestKey() {
const { id } = require.main;
const key = id
.replace(`${process.env.PWD}/packages`, '')
.replace(/src\/__tests__/g, '')
.replace('.test.ts', '')
.replace(/[^\w]/g, '_')
.replace(/_+/g, '_');
return key
}
const config = {
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
host: process.env.DB_HOST,
port: Number.parseInt(process.env.DB_PORT, 10),
dialect: process.env.DB_DIALECT as Dialect,
logging: process.env.DB_LOG_SQL === 'on',
sync: {
force: true,
alter: {
drop: true,
},
},
hooks: {
beforeDefine(columns, model) {
model.tableName = `${getTestKey()}_${model.tableName || model.name.plural}`;
}
},
};
export function getDatabase() {
return new Database(config);
};
export async function getApp() {
const app = new Application({
database: config,
resourcer: {
prefix: '/api',
},
});
app.resourcer.use(middlewares.associated);
app.resourcer.registerActionHandlers({...actions.associate, ...actions.common});
app.registerPlugin({
collections: path.resolve(__dirname, '../../../plugin-collections'),
automations: plugin
});
await app.loadPlugins();
const testTables = app.database.import({
directory: path.resolve(__dirname, './collections')
});
try {
await app.database.sync();
} catch(err) {
console.error(err);
}
for (const table of testTables.values()) {
// TODO(bug): 由于每个用例结束后不会清理用于测试的数据表,导致再次创建和更新
// 创建和更新里面仍会再次创建 fields导致创建相关的数据重复数据库报错。
await app.database.getModel('collections').import(table.getOptions(), { update: true, migrate: false });
}
app.context.db = app.database;
app.use(bodyParser());
app.use(middleware({
prefix: '/api',
resourcer: app.resourcer,
database: app.database,
}));
return app;
}
interface ActionParams {
resourceKey?: string | number;
// resourceName?: string;
// associatedName?: string;
associatedKey?: string | number;
fields?: any;
filter?: any;
values?: any;
[key: string]: any;
}
interface Handler {
get: (params?: ActionParams) => Promise<supertest.Response>;
list: (params?: ActionParams) => Promise<supertest.Response>;
create: (params?: ActionParams) => Promise<supertest.Response>;
update: (params?: ActionParams) => Promise<supertest.Response>;
destroy: (params?: ActionParams) => Promise<supertest.Response>;
[name: string]: (params?: ActionParams) => Promise<supertest.Response>;
}
export interface Agent {
resource: (name: string) => Handler;
}
export function getAgent(app: Application) {
return supertest.agent(app.callback());
}
export function getAPI(agent) {
return {
resource(name: string): any {
return new Proxy({}, {
get(target, method: string, receiver) {
return (params: ActionParams = {}) => {
const { associatedKey, resourceKey, values = {}, filePath, ...restParams } = params;
let url = `/api/${name}`;
if (associatedKey) {
url = `/api/${name.split('.').join(`/${associatedKey}/`)}`;
}
url += `:${method as string}`;
if (resourceKey) {
url += `/${resourceKey}`;
}
switch (method) {
case 'list':
case 'get':
return agent.get(`${url}?${qs.stringify(restParams)}`);
default:
return agent.post(`${url}?${qs.stringify(restParams)}`).send(values);
}
}
}
});
}
};
}

View File

@ -0,0 +1,357 @@
import { Application } from '@nocobase/server';
import Database, { Model, ModelCtor } from '@nocobase/database'
import { getApp, getAPI, getAgent } from '.';
import { AutomationModel } from '../models/automation';
import { AutomationJobModel } from '../models/automation-job';
import _ from 'lodash';
jest.setTimeout(300000);
describe('automations.jobs', () => {
let app: Application;
let db: Database;
let Test: ModelCtor<Model>;
let Job: ModelCtor<AutomationJobModel>;
async function expectCount(options, result) {
const count = await Test.count(options);
expect(count).toBe(result);
}
beforeEach(async () => {
app = await getApp();
db = app.database;
Job = db.getModel('automations_jobs') as any;
Test = db.getModel('tests');
});
afterEach(() => db.close());
describe('create', () => {
it('values', async () => {
const job = await Job.create({
title: 'j1',
enabled: true,
type: 'create',
collection_name: 'tests',
values: [
{
column: 'name1',
op: 'eq',
value: 'n1'
},
],
}, {
hooks: false,
});
await job.process();
await expectCount({
where: {
name1: 'n1',
}
}, 1);
});
it('values/ref', async () => {
const job = await Job.create({
title: 'j1',
enabled: true,
type: 'create',
collection_name: 'tests',
values: [
{
column: 'name1',
op: 'eq',
value: 'n1'
},
{
column: 'name2',
op: 'ref',
value: 'col2'
},
],
}, {
hooks: false,
});
await job.process({
col2: 'n2'
});
await expectCount({
where: {
name1: 'n1',
name2: 'n2',
}
}, 1);
});
it('values/ref', async () => {
const job = await Job.create({
title: 'j1',
enabled: true,
type: 'create',
collection_name: 'tests',
values: [
{
column: 'name1',
op: 'truncate',
},
{
column: 'name2',
op: 'ref',
value: 'col2'
},
],
}, {
hooks: false,
});
await job.process({
col1: 'n1',
col2: 'n2'
});
await expectCount({
where: {
name1: null,
name2: 'n2',
}
}, 1);
});
});
describe('update', () => {
it('values', async () => {
const job = await Job.create({
title: 'j1',
enabled: true,
type: 'update',
collection_name: 'tests',
values: [
{
column: 'name1',
op: 'eq',
value: 'n111'
},
],
}, {
hooks: false,
});
await Test.bulkCreate([
{
name1: 'n1',
},
{
name1: 'n2',
},
]);
await job.process();
await expectCount({
where: {}
}, 2);
await expectCount({
where: {
name1: 'n111',
}
}, 2);
});
it('values', async () => {
const job = await Job.create({
title: 'j1',
enabled: true,
type: 'update',
collection_name: 'tests',
values: [
{
column: 'name1',
op: 'truncate',
},
],
}, {
hooks: false,
});
await Test.bulkCreate([
{
name1: 'n1',
},
{
name1: 'n2',
},
]);
await job.process();
await expectCount({
where: {}
}, 2);
await expectCount({
where: {
name1: null,
}
}, 2);
});
it('filter', async () => {
const job = await Job.create({
title: 'j1',
enabled: true,
type: 'update',
collection_name: 'tests',
filter: {
name1: 'n1',
},
values: [
{
column: 'name1',
op: 'eq',
value: 'n111'
},
],
}, {
hooks: false,
});
await Test.bulkCreate([
{
name1: 'n1',
},
{
name1: 'n2',
},
]);
await job.process();
await expectCount({
where: {}
}, 2);
await expectCount({
where: {
name1: 'n111',
}
}, 1);
});
it('filter - ref', async () => {
const job = await Job.create({
title: 'j1',
enabled: true,
type: 'update',
collection_name: 'tests',
filter: {
name1: '{{name1}}',
},
values: [
{
column: 'name2',
op: 'ref',
value: 'name2'
},
],
}, {
hooks: false,
});
await Test.bulkCreate([
{
name1: 'n1',
},
{
name1: 'n2',
},
]);
await job.process({
name1: 'n1',
name2: 's2',
});
await expectCount({
where: {}
}, 2);
await expectCount({
where: {
name1: 'n1',
name2: 's2',
}
}, 1);
});
});
describe('destroy', () => {
it('values', async () => {
const job = await Job.create({
title: 'j1',
enabled: true,
type: 'destroy',
collection_name: 'tests',
}, {
hooks: false,
});
await Test.bulkCreate([
{
name1: 'n1',
},
{
name1: 'n2',
},
]);
await job.process();
await expectCount({
where: {}
}, 0);
await expectCount({
where: {
name1: 'n111',
}
}, 0);
});
it('filter', async () => {
const job = await Job.create({
title: 'j1',
enabled: true,
type: 'destroy',
collection_name: 'tests',
filter: {
name1: 'n1',
},
}, {
hooks: false,
});
await Test.bulkCreate([
{
name1: 'n1',
},
{
name1: 'n2',
},
]);
await job.process();
await expectCount({
where: {}
}, 1);
await expectCount({
where: {
name1: 'n111',
}
}, 0);
});
it('filter - ref', async () => {
const job = await Job.create({
title: 'j1',
enabled: true,
type: 'destroy',
collection_name: 'tests',
filter: {
name1: '{{name1}}',
},
}, {
hooks: false,
});
await Test.bulkCreate([
{
name1: 'n1',
},
{
name1: 'n2',
},
]);
await job.process({
name1: 'n1',
});
await expectCount({
where: {}
}, 1);
await expectCount({
where: {
name1: 'n1',
}
}, 0);
});
});
});

View File

@ -0,0 +1,510 @@
import { TableOptions } from '@nocobase/database';
export default {
name: 'automations',
model: 'AutomationModel',
title: '自动化',
internal: true,
developerMode: true,
fields: [
{
interface: 'string',
type: 'string',
name: 'title',
title: '自动化名称',
required: true,
component: {
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
interface: 'boolean',
type: 'boolean',
name: 'enabled',
title: '启用',
component: {
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
interface: 'textarea',
type: 'text',
name: 'description',
title: '描述',
component: {
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
interface: 'select',
type: 'string',
title: '触发方式',
name: 'type',
required: true,
createOnly: true,
dataSource: [
{
label: '数据表事件',
children: [
{
value: 'collections:afterCreate',
label: '新增数据时',
},
{
value: 'collections:afterUpdate',
label: '更新数据时',
},
{
value: 'collections:afterCreateOrUpdate',
label: '新增或更新数据时',
},
],
},
{
label: '定时任务',
children: [
{
value: 'schedule',
label: '自定义时间触发',
},
{
value: 'collections:schedule',
label: '根据日期字段触发',
},
],
},
],
component: {
showInTable: true,
showInDetail: true,
showInForm: true,
"x-linkages": [
{
"type": "value:visible",
"target": "changed",
"condition": "{{ ['collections:afterUpdate', 'collections:afterCreateOrUpdate'].indexOf($self.value) !== -1 }}"
},
{
"type": "value:visible",
"target": "startTime",
"condition": "{{ ['schedule', 'collections:schedule'].indexOf($self.value) !== -1 }}"
},
// {
// "type": "value:visible",
// "target": "endDateField",
// "condition": "{{ ['collections:schedule'].indexOf($self.value) !== -1 }}"
// },
// {
// "type": "value:visible",
// "target": "startTime",
// "condition": "{{ ['schedule'].indexOf($self.value) !== -1 }}"
// },
// {
// "type": "value:visible",
// "target": "endTime",
// "condition": "{{ ['schedule'].indexOf($self.value) !== -1 }}"
// },
{
"type": "value:visible",
"target": "cron",
"condition": "{{ ['collections:schedule', 'schedule'].indexOf($self.value) !== -1 }}"
},
// {
// "type": "value:visible",
// "target": "endMode",
// "condition": "{{ ['collections:schedule', 'schedule'].indexOf($self.value) !== -1 }}"
// },
{
"type": "value:visible",
"target": "collection",
"condition": "{{ $self.value && $self.value !== 'schedule' }}"
},
{
"type": "value:visible",
"target": "filter",
"condition": "{{ $self.value && $self.value !== 'schedule' }}"
},
{
"type": "value:schema",
"target": "startTime",
"condition": "{{ $self.value === 'collections:schedule' }}",
schema: {
title: '开始日期字段',
'x-component-props': {
automationType: '{{ $self.value }}'
},
},
},
{
"type": "value:schema",
"target": "endMode",
"condition": "{{ ['collections:schedule', 'schedule'].indexOf($self.value) !== -1 }}",
schema: {
'x-component-props': {
automationType: '{{ $self.value }}'
},
},
},
{
"type": "value:schema",
"target": "endTime",
"condition": "{{ $self.value === 'collections:schedule' }}",
schema: {
title: '结束日期字段',
'x-component-props': {
automationType: '{{ $self.value }}'
},
},
},
{
"type": "value:schema",
"target": "startTime",
"condition": "{{ $self.value === 'schedule' }}",
schema: {
title: '开始时间',
'x-component-props': {
automationType: '{{ $self.value }}'
},
},
},
{
"type": "value:schema",
"target": "endTime",
"condition": "{{ $self.value === 'schedule' }}",
schema: {
title: '结束时间',
'x-component-props': {
automationType: '{{ $self.value }}'
},
},
},
// {
// "type": "value:state",
// "target": "cron",
// "condition": "{{ ['collections:schedule', 'schedule'].indexOf($self.value) !== -1 }}",
// state: {
// value: 'none',
// },
// },
// {
// "type": "value:state",
// "target": "endMode",
// "condition": "{{ ['collections:schedule', 'schedule'].indexOf($self.value) !== -1 }}",
// state: {
// value: 'none',
// },
// }
],
},
},
{
interface: 'linkTo',
type: 'belongsTo',
name: 'collection',
target: 'collections',
targetKey: 'name',
title: '触发数据表',
labelField: 'title',
valueField: 'name',
required: true,
multiple: false,
createOnly: true,
component: {
type: 'remoteSelect',
showInDetail: true,
showInForm: true,
'x-component-props': {
mode: 'multiple',
resourceName: 'collections',
labelField: 'title',
valueField: 'name',
},
"x-linkages": [
{
"type": "value:visible",
"target": "changed",
"condition": "{{ $self.value && ['collections:afterUpdate', 'collections:afterCreateOrUpdate'].indexOf($form.values.type) !== -1 }}"
},
// {
// "type": "value:visible",
// "target": "startTime",
// "condition": "{{ $self.value && ['collections:schedule'].indexOf($form.values.type) !== -1 }}"
// },
{
type: "value:schema",
target: "changed",
// condition: "{{ $self.value }}",
schema: {
"x-component-props": {
"associatedKey": "{{ typeof $self.value === 'string' ? $self.value : $form.values.collection_name }}"
},
},
},
{
type: "value:schema",
target: "startTime",
// condition: "{{ $self.value }}",
schema: {
"x-component-props": {
"associatedKey": "{{ typeof $self.value === 'string' ? $self.value : $form.values.collection_name }}"
},
},
},
{
type: "value:schema",
target: "endTime",
// condition: "{{ $self.value }}",
schema: {
"x-component-props": {
"associatedKey": "{{ typeof $self.value === 'string' ? $self.value : $form.values.collection_name }}"
},
},
},
{
type: "value:schema",
target: "filter",
// condition: "{{ $self.value }}",
schema: {
"x-component-props": {
"associatedKey": "{{ typeof $self.value === 'string' ? $self.value : $form.values.collection_name }}"
},
},
},
],
},
},
{
interface: 'multipleSelect',
type: 'json',
name: 'changed',
title: '发生变动的字段',
labelField: 'title',
valueField: 'name',
component: {
type: 'remoteSelect',
showInDetail: true,
showInForm: true,
'x-component-props': {
mode: 'simple',
multiple: true,
resourceName: 'collections.fields',
labelField: 'title',
valueField: 'name',
},
},
},
{
interface: 'json',
type: 'json',
name: 'startTime',
title: '开始时间',
showTime: true,
required: true,
component: {
type: 'automations.datetime',
showInDetail: true,
showInForm: true,
},
},
{
interface: 'json',
type: 'json',
name: 'cron',
title: '重复周期',
required: true,
component: {
type: 'automations.cron',
showInDetail: true,
showInForm: true,
// default: 'none',
"x-linkages": [
{
"type": "value:visible",
"target": "endMode",
"condition": "{{ $self.value && $self.value !== 'none' }}"
},
// {
// type: "value:schema",
// target: "endMode",
// condition: "{{ $form.values.type === 'schedule' && $self.value && $self.value !== 'norepeat' }}",
// schema: {
// enum: [
// { label: '永不结束', value: 'never' },
// { label: '指定重复次数', value: 'repeatTime' },
// { label: '自定义结束时间', value: 'customTime' },
// ]
// },
// },
// {
// type: "value:schema",
// target: "endMode",
// condition: "{{ $form.values.type === 'collections:schedule' && $self.value && $self.value !== 'norepeat' }}",
// schema: {
// enum: [
// { label: '永不结束', value: 'never' },
// { label: '指定重复次数', value: 'repeatTime' },
// { label: '根据日期字段', value: 'customField' },
// ]
// },
// },
// {
// "type": "value:visible",
// "target": "endDateField",
// "condition": "{{ $self.value && $self.value !== 'norepeat' }}"
// },
// {
// "type": "value:visible",
// "target": "endTime",
// "condition": "{{ $self.value && $self.value !== 'norepeat' }}"
// },
]
},
},
{
type: 'integer',
name: 'times',
developerMode: true,
},
{
interface: 'select',
type: 'string',
name: 'endMode',
title: '结束方式',
required: true,
// dataSource: [
// { label: '永不结束', value: 'never' },
// { label: '指定重复次数', value: 'repeatTime' },
// { label: '根据日期字段', value: 'customField' },
// { label: '自定义结束时间', value: 'customTime' },
// ],
component: {
type: 'automations.endmode',
showInDetail: true,
showInForm: true,
default: 'none',
"x-linkages": [
{
"type": "value:visible",
"target": "endTime",
"condition": "{{ $self.value === 'byField' || $self.value === 'customTime' }}"
},
// {
// "type": "value:visible",
// "target": "endTime",
// "condition": "{{ $self.value === 'customTime' }}"
// },
{
type: "value:schema",
target: "endTime",
condition: "{{ ($form.values.collection_name || $form.values.collection) && $self.value === 'customField' }}",
schema: {
"x-component-props": {
"associatedKey": "{{ $form.values.collection_name || $form.values.collection }}"
},
},
},
],
},
},
// {
// interface: 'string',
// type: 'string',
// name: 'endDateField',
// title: '结束日期字段',
// required: true,
// labelField: 'title',
// valueField: 'name',
// component: {
// type: 'remoteSelect',
// showInDetail: true,
// showInForm: true,
// 'x-component-props': {
// mode: 'simple',
// resourceName: 'collections.fields',
// labelField: 'title',
// valueField: 'name',
// },
// },
// },
{
interface: 'json',
type: 'json',
name: 'endTime',
title: '结束时间',
showTime: true,
required: true,
component: {
type: 'automations.datetime',
showInDetail: true,
showInForm: true,
},
},
{
interface: 'json',
type: 'json',
name: 'filter',
title: '数据符合以下条件才会触发',
component: {
type: 'filter',
showInDetail: true,
showInForm: true,
},
},
{
interface: 'linkTo',
type: 'hasMany',
name: 'jobs',
target: 'automations_jobs',
title: '任务',
},
],
views: [
{
type: 'form',
name: 'form',
title: '表单',
template: 'DrawerForm',
developerMode: true,
},
{
type: 'details',
name: 'details',
title: '详情',
template: 'Details',
actionNames: ['update'],
developerMode: true,
},
{
type: 'table',
name: 'table',
title: '全部数据',
template: 'Table',
actionNames: ['destroy', 'create'],
default: true,
draggable: true,
},
],
tabs: [
{
type: 'details',
name: 'details',
title: '详情',
viewName: 'details',
},
{
type: 'association',
name: 'jobs',
title: '任务',
association: 'jobs',
viewName: 'table',
default: true,
},
],
} as TableOptions;

View File

@ -0,0 +1,163 @@
import { TableOptions } from '@nocobase/database';
export default {
name: 'automations_jobs',
model: 'AutomationJobModel',
title: '任务',
internal: true,
developerMode: true,
fields: [
{
interface: 'linkTo',
name: 'automation',
type: 'belongsTo',
component: {
type: 'hidden',
showInForm: true,
},
},
{
interface: 'string',
type: 'string',
name: 'title',
title: '任务名称',
component: {
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
interface: 'select',
type: 'string',
name: 'type',
title: '任务类型',
dataSource: [
{ label: '新增数据', value: 'create' },
{ label: '更新数据', value: 'update' },
{ label: '删除数据', value: 'destroy' },
{ label: '发送通知', value: 'message', disabled: true },
],
component: {
showInTable: true,
showInDetail: true,
showInForm: true,
"x-linkages": [
{
"type": "value:visible",
"target": "filter",
"condition": "{{ ['update', 'destroy'].indexOf($self.value) !== -1 }}"
},
{
"type": "value:visible",
"target": "values",
"condition": "{{ ['create', 'update'].indexOf($self.value) !== -1 }}"
},
],
},
},
{
interface: 'linkTo',
type: 'belongsTo',
name: 'collection',
target: 'collections',
targetKey: 'name',
title: '操作数据表',
labelField: 'title',
valueField: 'name',
required: true,
multiple: false,
component: {
type: 'remoteSelect',
showInDetail: true,
showInForm: true,
'x-component-props': {
resourceName: 'collections',
labelField: 'title',
valueField: 'name',
},
"x-linkages": [
{
type: "value:schema",
target: "filter",
// condition: "{{ $self.value }}",
schema: {
"x-component-props": {
"associatedKey": "{{ typeof $self.value === 'string' ? $self.value : $form.values.collection_name }}",
"sourceName": "{{ $form.values.automation ? $form.values.automation.collection_name : null }}"
},
},
},
{
type: "value:schema",
target: "values",
// condition: "{{ $self.value }}",
schema: {
"x-component-props": {
"associatedKey": "{{ typeof $self.value === 'string' ? $self.value : $form.values.collection_name }}",
"sourceName": "{{ $form.values.automation ? $form.values.automation.collection_name : null }}"
},
},
},
],
},
},
{
interface: 'json',
type: 'json',
name: 'filter',
title: '满足以下条件的数据才会被操作',
component: {
type: 'filter',
showInDetail: true,
showInForm: true,
},
},
{
interface: 'json',
type: 'json',
name: 'values',
title: '数据操作',
component: {
type: 'values',
showInForm: true,
},
},
],
views: [
{
type: 'form',
name: 'form',
title: '表单',
template: 'DrawerForm',
developerMode: true,
},
{
type: 'details',
name: 'details',
title: '详情',
template: 'Details',
actionNames: ['update'],
developerMode: true,
},
{
type: 'table',
name: 'table',
title: '全部数据',
template: 'Table',
mode: 'simple',
actionNames: ['destroy', 'create'],
default: true,
draggable: true,
},
],
tabs: [
{
type: 'details',
name: 'details',
title: '详情',
viewName: 'details',
default: true,
},
],
} as TableOptions;

View File

@ -0,0 +1,78 @@
import _ from 'lodash';
import { Model } from '@nocobase/database';
import parse from 'json-templates';
export class AutomationJobModel extends Model {
async bootstrap() {
let automation = this.getDataValue('automation');
if (!automation) {
automation = await this.getAutomation();
this.setDataValue('automation', automation);
}
automation.startJob(`job-${this.id}`, async (result: any, options: any = {}) => {
this.process(result, {...options});
});
}
toFilter(result) {
let source = {};
if (result && typeof result === 'object') {
if (result.toJSON) {
source = result.toJSON();
} else {
source = result;
}
}
return parse(this.get('filter')||{})(source);
}
toValues(result) {
let source = {};
if (result && typeof result === 'object') {
if (result.toJSON) {
source = result.toJSON();
} else {
source = result;
}
}
const data: any = {}
const values = (this.get('values')||[]) as any[];
for (const item of values) {
let value = item.value;
if (item.op === 'truncate') {
value = null;
} else if (item.op === 'ref') {
value = _.get(source, item.value);
}
_.set(data, item.column, value);
}
return data;
}
async process(result?: any, options?: any) {
const jobType = this.get('type');
const collectionName = this.get('collection_name');
const M = this.database.getModel(collectionName);
let filter: any = this.toFilter(result);
let data: any = this.toValues(result);
const { where = {} } = M.parseApiJson({ filter });
console.log({data, where});
switch (jobType) {
case 'create':
await M.create(data);
break;
case 'update':
Object.keys(data).length && await M.update(data, { where });
break;
case 'destroy':
await M.destroy(where ? { where }: {});
break;
}
}
async cancel() {
const automation = this.getDataValue('automation') || await this.getAutomation();
await automation.cancelJob(`job-${this.id}`);
}
}

View File

@ -0,0 +1,265 @@
import _ from 'lodash';
import { Model } from '@nocobase/database';
import schedule from 'node-schedule';
const scheduleJobs = new Map<string, any>();
export class AutomationModel extends Model {
static async load() {
const automations = await this.findAll({
where: {
enabled: true,
}
});
for (const automation of automations) {
await automation.loadJobs();
}
}
isEnabled() {
if (!this.get('enabled')) {
return false;
}
const type = this.get('type');
if (!['collections:schedule', 'schedule'].includes(type)) {
return true;
}
if (this.get('cron') === 'none') {
return true;
}
return true;
}
async loadJobs() {
if (!this.get('enabled')) {
return false;
}
const jobs = await this.getJobs();
for (const job of jobs) {
job.setDataValue('automation', this);
await job.bootstrap();
}
}
async cancelJobs() {
const jobs = await this.getJobs();
for (const job of jobs) {
job.setDataValue('automation', this);
await job.cancel();
}
}
getRule() {
const { type, startTime = {}, endTime = {}, cron = 'none', endMode = 'none' } = this.get();
if (type !== 'schedule') {
return;
}
if (!startTime || !startTime.value) {
return;
}
let options: any = { start: new Date(startTime.value) };
if (!cron || cron === 'none') {
return options.start;
}
if (endMode === 'customTime' && endTime && endTime.value) {
options.end = new Date(endTime.value);
}
if (typeof cron === 'object') {
Object.assign(options, cron);
} else if (typeof cron === 'string') {
const map = {
everysecond: '* * * * * *',
};
options.rule = map[cron] || cron;
}
return options;
}
startJob(jobName: string, callback: any) {
const collectionName = this.get('collection_name');
const hookName = `automation-${this.id}-${jobName}`;
const filter = this.get('filter') || {};
const changedFields = (this.get('changed') as any) || [];
const M = this.database.getModel(collectionName);
const automationType = this.get('type');
switch (automationType) {
case 'collections:afterCreate':
M.addHook('afterCreate', hookName, async (model, options) => {
filter[M.primaryKeyAttribute] = model[M.primaryKeyAttribute];
const { where } = M.parseApiJson({
filter,
});
const result = await M.findOne({
...options,
where,
});
// console.log({M, filter, result});
if (result) {
await callback(model, { ...options, automationType });
}
});
break;
case 'collections:afterUpdate':
M.addHook('afterUpdate', hookName, async (model, options) => {
const changed = model.changed();
if (!changed) {
return;
}
if (changedFields.length) {
const arr = _.intersection(changed, changedFields);
console.log(arr);
if (arr.length === 0) {
return;
}
}
filter[M.primaryKeyAttribute] = model[M.primaryKeyAttribute];
const { where } = M.parseApiJson({
filter,
});
const result = await M.findOne({
...options,
where,
});
if (result) {
await callback(model, {...options, automationType});
}
});
break;
case 'collections:afterCreateOrUpdate':
M.addHook('afterCreate', hookName, async (model, options) => {
filter[M.primaryKeyAttribute] = model[M.primaryKeyAttribute];
const { where } = M.parseApiJson({
filter,
});
const result = await M.findOne({
...options,
where,
});
if (result) {
await callback(model, {...options, automationType});
}
});
M.addHook('afterUpdate', hookName, async (model, options) => {
const changed = model.changed();
if (!changed) {
return;
}
if (changedFields.length) {
const arr = _.intersection(changed, changedFields);
if (arr.length === 0) {
return;
}
}
filter[M.primaryKeyAttribute] = model[M.primaryKeyAttribute]
const { where } = M.parseApiJson({
filter,
});
const result = await M.findOne({
...options,
where,
});
if (result) {
await callback(model, { ...options, automationType });
}
});
break;
case 'collections:schedule':
const { startTime = {}, endTime = {}, cron = 'none', endMode = 'none' } = this.get();
if (!startTime) {
break;
}
const startField = startTime.byField;
if (!startField) {
break;
}
const endField = endTime ? endTime.byField : null;
const { where = {} } = M.parseApiJson({
filter,
});
const scheduleJob = (item: any) => {
if (!item[startField]) {
return;
}
let rule: any = {};
if (!cron || cron === 'none') {
rule = new Date(item[startField]);
} else {
rule.start = new Date(item[startField]);
if (typeof cron === 'object') {
Object.assign(rule, cron);
} else if (typeof cron === 'string') {
const map = {
everysecond: '* * * * * *',
};
rule.rule = map[cron] || cron;
}
if (endMode === 'byField' && endField && item[endField]) {
rule.end = new Date(item[endField]);
}
}
console.log({rule});
schedule.scheduleJob(`${hookName}-${item.id}`, rule, (date) => {
(async () => {
await callback(date, { automationType });
})();
});
}
M.addHook('afterCreate', hookName, async (model) => {
scheduleJob(model);
});
M.addHook('afterUpdate', hookName, async (model) => {
schedule.cancelJob(`${hookName}-${model.get('id')}`);
scheduleJob(model);
});
M.addHook('afterDestroy', hookName, async (model) => {
schedule.cancelJob(`${hookName}-${model.get('id')}`);
});
// TODO: 待优化,每条数据都要绑定上 scheduleJob
M.findAll(where).then(items => {
for (const item of items) {
scheduleJob(item);
}
});
break;
case 'schedule':
const rule = this.getRule();
console.log({rule});
schedule.scheduleJob(hookName, rule, (date) => {
(async () => {
await callback(date, { automationType });
})();
});
break;
}
}
async cancelJob(jobName: string) {
const collectionName = this.get('collection_name');
const hookName = `automation-${this.id}-${jobName}`;
const M = this.database.getModel(collectionName);
switch (this.get('type')) {
case 'collections:afterCreate':
M.removeHook('afterCreate', hookName);
break;
case 'collections:afterCreate':
M.removeHook('afterUpdate', hookName);
break;
case 'collections:afterCreateOrUpdate':
M.removeHook('afterCreate', hookName);
M.removeHook('afterUpdate', hookName);
break;
case 'collections:schedule':
M.removeHook('afterCreate', hookName);
M.removeHook('afterUpdate', hookName);
M.removeHook('afterDestroy', hookName);
const items = await M.findAll();
for (const item of items) {
schedule.cancelJob(`${hookName}-${item.id}`);
}
break;
case 'schedule':
schedule.cancelJob(hookName);
break;
}
}
}

View File

@ -0,0 +1,44 @@
import Database, { registerModels } from '@nocobase/database';
import Resourcer from '@nocobase/resourcer';
import path from 'path';
import { AutomationModel } from './models/automation';
import { AutomationJobModel } from './models/automation-job';
export default async function (options = {}) {
const database: Database = this.database;
const resourcer: Resourcer = this.resourcer;
registerModels({
AutomationModel,
AutomationJobModel,
});
database.import({
directory: path.resolve(__dirname, 'collections'),
});
const [Automation, AutomationJob] = database.getModels(['automations', 'automations_jobs']);
Automation.addHook('afterCreate', async (model: AutomationModel) => {
model.get('enabled') && await model.loadJobs();
});
Automation.addHook('afterUpdate', async (model: AutomationModel) => {
if (!model.changed('enabled' as any)) {
return;
}
model.get('enabled') ? await model.loadJobs() : await model.cancelJobs();
});
Automation.addHook('beforeDestroy', async (model: AutomationModel) => {
await model.cancelJobs();
});
AutomationJob.addHook('afterCreate', async (model: AutomationJobModel) => {
await model.bootstrap();
});
AutomationJob.addHook('beforeDestroy', async (model: AutomationJobModel) => {
await model.cancel();
});
}

View File

@ -350,6 +350,17 @@ export default async (ctx, next) => {
}
const appends = [];
const others: any = {};
if (viewType === 'form') {
if (associatedName === 'automations' && resourceFieldName === 'jobs' && mode !== 'update') {
const Automation = ctx.db.getModel('automations');
others['initialValues'] = {
automation: await Automation.findByPk(associatedKey),
};
}
}
for (const field of fields) {
if (!['subTable', 'linkTo', 'attachment', 'createdBy', 'updatedBy'].includes(field.get('interface'))) {
continue;
@ -550,6 +561,7 @@ export default async (ctx, next) => {
}
ctx.body = {
...view.get(),
...others,
title,
actionDefaultParams,
original: fields,

View File

@ -65,6 +65,7 @@ export default {
title: 'Token',
unique: true,
hidden: true,
filterable: false,
},
],
actions: [