diff --git a/packages/app/src/api/app.ts b/packages/app/src/api/app.ts index 43d281ad6f..9542a1c022 100644 --- a/packages/app/src/api/app.ts +++ b/packages/app/src/api/app.ts @@ -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; diff --git a/packages/app/src/api/index.ts b/packages/app/src/api/index.ts index 44923e15fd..e0bb237b61 100644 --- a/packages/app/src/api/index.ts +++ b/packages/app/src/api/index.ts @@ -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}/`); }); diff --git a/packages/app/src/api/migrations/create-automations.ts b/packages/app/src/api/migrations/create-automations.ts new file mode 100644 index 0000000000..8cefb956a1 --- /dev/null +++ b/packages/app/src/api/migrations/create-automations.ts @@ -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 }); + } +})(); diff --git a/packages/app/src/api/migrations/init.ts b/packages/app/src/api/migrations/init.ts index 5ae5848b02..fda9139969 100644 --- a/packages/app/src/api/migrations/init.ts +++ b/packages/app/src/api/migrations/init.ts @@ -132,6 +132,15 @@ const data = [ sort: 120, showInMenu: true, }, + { + title: '自动化配置', + type: 'collection', + collection: 'automations', + path: '/settings/automations', + icon: 'TableOutlined', + sort: 130, + showInMenu: true, + }, ] }, ], diff --git a/packages/app/src/components/form.fields/automations/index.tsx b/packages/app/src/components/form.fields/automations/index.tsx new file mode 100644 index 0000000000..7660d6c5e7 --- /dev/null +++ b/packages/app/src/components/form.fields/automations/index.tsx @@ -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 ( +
+
+ {automationType === 'schedule' ? ( + { + 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; + })()}/> + ) : ( + + + + {offsetType !== 'current' && ( + { + 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' && ( + + )} + {offsetType !== 'current' && value.unit && ['day', 'week', 'month'].indexOf(value.unit) !== -1 && ( + { + 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}); + }}/> + )} + + )} +
+
+ ) +}) + +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(match ? parseInt(match[1]) : undefined); + const [cron, setCron] = useState(() => { + if (!value) { + return 'none'; + } + return match ? 'custom' : cronmap[value]; + }) + + return ( +
+ + + {cron === 'custom' && ( + { + const v = parseInt(e.target.value); + setNum(v); + onChange(`every_${v}_${unit}`); + }} defaultValue={num} addonBefore={'每'}/> + )} + {cron === 'custom' && ( + + )} + +
+ ) +}) + +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 ( +
+ + + {mode === 'times' && { + const v = parseInt(e.target.value); + setNum(v); + onChange(`after_${v}_times`); + }} defaultValue={num} addonAfter={'次'}/>} + +
+ ) +}) + +export const Automations = { + DateTime, Cron, EndMode +}; + diff --git a/packages/app/src/components/form.fields/automations/style.less b/packages/app/src/components/form.fields/automations/style.less new file mode 100644 index 0000000000..6d89298814 --- /dev/null +++ b/packages/app/src/components/form.fields/automations/style.less @@ -0,0 +1,5 @@ +.ant-input-group.ant-input-group-compact { + .ant-input-group-wrapper { + width: 120px; + } +} \ No newline at end of file diff --git a/packages/app/src/components/form.fields/filter/index.tsx b/packages/app/src/components/form.fields/filter/index.tsx index eeb5d52013..95a349da49 100644 --- a/packages/app/src/components/form.fields/filter/index.tsx +++ b/packages/app/src/components/form.fields/filter/index.tsx @@ -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(dataSource.list || [ { type: 'item', @@ -50,6 +50,7 @@ export function FilterGroup(props: any) {
{ 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({}); 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 ( - { - onChange({...dataSource, value: value}); - }} - style={{ width: 180 }} - /> + {sourceFields.length > 0 && ( + + )} + {valueType !== 'ref' ? ( + { + onChange({...dataSource, value: value}); + }} + style={{ width: 180 }} + /> + ) : (sourceFields.length > 0 ? ( + + ) : null)} {showDeleteButton && ( + )} + + ); +} + +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 Object.keys(item).length) : []} onChange={(values) => { + onChange(values.filter(item => Object.keys(item).length)); + }} {...restProps} fields={data} sourceFields={sourceFields}/> +}); + +export default Values; diff --git a/packages/app/src/components/form.fields/values/style.less b/packages/app/src/components/form.fields/values/style.less new file mode 100644 index 0000000000..ad607dfb73 --- /dev/null +++ b/packages/app/src/components/form.fields/values/style.less @@ -0,0 +1,3 @@ +.filter-remove-link { + color:#d9d9d9; +} \ No newline at end of file diff --git a/packages/app/src/components/form.fields/values/useDynamicList.ts b/packages/app/src/components/form.fields/values/useDynamicList.ts new file mode 100644 index 0000000000..31b7b7a9e0 --- /dev/null +++ b/packages/app/src/components/form.fields/values/useDynamicList.ts @@ -0,0 +1,160 @@ +import { useCallback, useRef, useState } from 'react'; + +export default (initialValue: T[]) => { + const counterRef = useRef(-1); + // key 存储器 + const keyList = useRef([]); + + // 内部方法 + 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, + }; +}; diff --git a/packages/app/src/components/views/Field/index.tsx b/packages/app/src/components/views/Field/index.tsx index 7068960d0f..cf59d9a123 100644 --- a/packages/app/src/components/views/Field/index.tsx +++ b/packages/app/src/components/views/Field/index.tsx @@ -371,7 +371,7 @@ function LogField(props) { function LogFieldValue(props) { const { value, schema, data } = props; return ( -
{value}
+
{JSON.stringify(value)}
) } diff --git a/packages/app/src/components/views/Form/DrawerForm.tsx b/packages/app/src/components/views/Form/DrawerForm.tsx index c8eee0f805..82c4ef5393 100644 --- a/packages/app/src/components/views/Form/DrawerForm.tsx +++ b/packages/app/src/components/views/Form/DrawerForm.tsx @@ -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({}); 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', diff --git a/packages/plugin-automations/package.json b/packages/plugin-automations/package.json new file mode 100644 index 0000000000..22614c4035 --- /dev/null +++ b/packages/plugin-automations/package.json @@ -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" + } +} diff --git a/packages/plugin-automations/src/__tests__/automations.test.ts b/packages/plugin-automations/src/__tests__/automations.test.ts new file mode 100644 index 0000000000..8c47379f5d --- /dev/null +++ b/packages/plugin-automations/src/__tests__/automations.test.ts @@ -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; + let Target: ModelCtor; + let Automation: ModelCtor; + + 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); + }); + }); +}); diff --git a/packages/plugin-automations/src/__tests__/collections/targets.ts b/packages/plugin-automations/src/__tests__/collections/targets.ts new file mode 100644 index 0000000000..d1b6ed448f --- /dev/null +++ b/packages/plugin-automations/src/__tests__/collections/targets.ts @@ -0,0 +1,15 @@ +import { TableOptions } from '@nocobase/database'; + +export default { + name: 'targets', + fields: [ + { + type: 'string', + name: 'col1', + }, + { + type: 'string', + name: 'col2', + } + ], +} as TableOptions; diff --git a/packages/plugin-automations/src/__tests__/collections/tests.ts b/packages/plugin-automations/src/__tests__/collections/tests.ts new file mode 100644 index 0000000000..73df8800dc --- /dev/null +++ b/packages/plugin-automations/src/__tests__/collections/tests.ts @@ -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; diff --git a/packages/plugin-automations/src/__tests__/index.ts b/packages/plugin-automations/src/__tests__/index.ts new file mode 100644 index 0000000000..1b147a7332 --- /dev/null +++ b/packages/plugin-automations/src/__tests__/index.ts @@ -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; + list: (params?: ActionParams) => Promise; + create: (params?: ActionParams) => Promise; + update: (params?: ActionParams) => Promise; + destroy: (params?: ActionParams) => Promise; + [name: string]: (params?: ActionParams) => Promise; +} + +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); + } + } + } + }); + } + }; +} diff --git a/packages/plugin-automations/src/__tests__/jobs.test.ts b/packages/plugin-automations/src/__tests__/jobs.test.ts new file mode 100644 index 0000000000..64233e939b --- /dev/null +++ b/packages/plugin-automations/src/__tests__/jobs.test.ts @@ -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; + let Job: ModelCtor; + + 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); + }); + }); +}); diff --git a/packages/plugin-automations/src/collections/automations.ts b/packages/plugin-automations/src/collections/automations.ts new file mode 100644 index 0000000000..6da7e8abac --- /dev/null +++ b/packages/plugin-automations/src/collections/automations.ts @@ -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; diff --git a/packages/plugin-automations/src/collections/automations_jobs.ts b/packages/plugin-automations/src/collections/automations_jobs.ts new file mode 100644 index 0000000000..764dc34fc6 --- /dev/null +++ b/packages/plugin-automations/src/collections/automations_jobs.ts @@ -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; diff --git a/packages/plugin-automations/src/models/automation-job.ts b/packages/plugin-automations/src/models/automation-job.ts new file mode 100644 index 0000000000..ab1934af87 --- /dev/null +++ b/packages/plugin-automations/src/models/automation-job.ts @@ -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}`); + } +} diff --git a/packages/plugin-automations/src/models/automation.ts b/packages/plugin-automations/src/models/automation.ts new file mode 100644 index 0000000000..8fc84ef885 --- /dev/null +++ b/packages/plugin-automations/src/models/automation.ts @@ -0,0 +1,265 @@ +import _ from 'lodash'; +import { Model } from '@nocobase/database'; +import schedule from 'node-schedule'; + +const scheduleJobs = new Map(); + +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; + } + } +} diff --git a/packages/plugin-automations/src/server.ts b/packages/plugin-automations/src/server.ts new file mode 100644 index 0000000000..83b64f5087 --- /dev/null +++ b/packages/plugin-automations/src/server.ts @@ -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(); + }); +} diff --git a/packages/plugin-pages/src/actions/getView.ts b/packages/plugin-pages/src/actions/getView.ts index b2ee977221..475533a46b 100644 --- a/packages/plugin-pages/src/actions/getView.ts +++ b/packages/plugin-pages/src/actions/getView.ts @@ -349,6 +349,17 @@ export default async (ctx, next) => { actionDefaultParams.filter = view.filter; } 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'))) { @@ -550,6 +561,7 @@ export default async (ctx, next) => { } ctx.body = { ...view.get(), + ...others, title, actionDefaultParams, original: fields, diff --git a/packages/plugin-users/src/collections/users.ts b/packages/plugin-users/src/collections/users.ts index fdfade30f1..b89d482636 100644 --- a/packages/plugin-users/src/collections/users.ts +++ b/packages/plugin-users/src/collections/users.ts @@ -65,6 +65,7 @@ export default { title: 'Token', unique: true, hidden: true, + filterable: false, }, ], actions: [