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:
parent
9bd79cf082
commit
eb5581646c
@ -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;
|
||||
|
@ -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}/`);
|
||||
});
|
||||
|
19
packages/app/src/api/migrations/create-automations.ts
Normal file
19
packages/app/src/api/migrations/create-automations.ts
Normal 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 });
|
||||
}
|
||||
})();
|
@ -132,6 +132,15 @@ const data = [
|
||||
sort: 120,
|
||||
showInMenu: true,
|
||||
},
|
||||
{
|
||||
title: '自动化配置',
|
||||
type: 'collection',
|
||||
collection: 'automations',
|
||||
path: '/settings/automations',
|
||||
icon: 'TableOutlined',
|
||||
sort: 130,
|
||||
showInMenu: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
],
|
||||
|
286
packages/app/src/components/form.fields/automations/index.tsx
Normal file
286
packages/app/src/components/form.fields/automations/index.tsx
Normal 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
|
||||
};
|
||||
|
@ -0,0 +1,5 @@
|
||||
.ant-input-group.ant-input-group-compact {
|
||||
.ant-input-group-wrapper {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
@ -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}
|
||||
|
399
packages/app/src/components/form.fields/values/index.tsx
Normal file
399
packages/app/src/components/form.fields/values/index.tsx
Normal 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;
|
@ -0,0 +1,3 @@
|
||||
.filter-remove-link {
|
||||
color:#d9d9d9;
|
||||
}
|
160
packages/app/src/components/form.fields/values/useDynamicList.ts
Normal file
160
packages/app/src/components/form.fields/values/useDynamicList.ts
Normal 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,
|
||||
};
|
||||
};
|
@ -371,7 +371,7 @@ function LogField(props) {
|
||||
function LogFieldValue(props) {
|
||||
const { value, schema, data } = props;
|
||||
return (
|
||||
<div>{value}</div>
|
||||
<div>{JSON.stringify(value)}</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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',
|
||||
|
17
packages/plugin-automations/package.json
Normal file
17
packages/plugin-automations/package.json
Normal 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"
|
||||
}
|
||||
}
|
586
packages/plugin-automations/src/__tests__/automations.test.ts
Normal file
586
packages/plugin-automations/src/__tests__/automations.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,15 @@
|
||||
import { TableOptions } from '@nocobase/database';
|
||||
|
||||
export default {
|
||||
name: 'targets',
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'col1',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'col2',
|
||||
}
|
||||
],
|
||||
} as TableOptions;
|
@ -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;
|
144
packages/plugin-automations/src/__tests__/index.ts
Normal file
144
packages/plugin-automations/src/__tests__/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
357
packages/plugin-automations/src/__tests__/jobs.test.ts
Normal file
357
packages/plugin-automations/src/__tests__/jobs.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
510
packages/plugin-automations/src/collections/automations.ts
Normal file
510
packages/plugin-automations/src/collections/automations.ts
Normal 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;
|
163
packages/plugin-automations/src/collections/automations_jobs.ts
Normal file
163
packages/plugin-automations/src/collections/automations_jobs.ts
Normal 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;
|
78
packages/plugin-automations/src/models/automation-job.ts
Normal file
78
packages/plugin-automations/src/models/automation-job.ts
Normal 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}`);
|
||||
}
|
||||
}
|
265
packages/plugin-automations/src/models/automation.ts
Normal file
265
packages/plugin-automations/src/models/automation.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
44
packages/plugin-automations/src/server.ts
Normal file
44
packages/plugin-automations/src/server.ts
Normal 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();
|
||||
});
|
||||
}
|
@ -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,
|
||||
|
@ -65,6 +65,7 @@ export default {
|
||||
title: 'Token',
|
||||
unique: true,
|
||||
hidden: true,
|
||||
filterable: false,
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
|
Loading…
Reference in New Issue
Block a user