diff --git a/packages/core/client/package.json b/packages/core/client/package.json index 4b8251798..10dc172f8 100644 --- a/packages/core/client/package.json +++ b/packages/core/client/package.json @@ -25,6 +25,7 @@ "antd": "~4.19.5", "axios": "^0.26.1", "classnames": "^2.3.1", + "cronstrue": "^2.11.0", "file-saver": "^2.0.5", "i18next": "^21.6.0", "json-templates": "^4.2.0", @@ -38,6 +39,7 @@ "react-hotkeys-hook": "^3.4.7", "react-i18next": "^11.15.1", "react-image-lightbox": "^5.1.4", + "react-js-cron": "^1.4.0", "react-quill": "^1.3.5", "react-router-dom": "^5.2.0", "react-to-print": "^2.14.7", diff --git a/packages/core/client/src/collection-manager/interfaces/index.ts b/packages/core/client/src/collection-manager/interfaces/index.ts index 3b2a13772..32388480d 100644 --- a/packages/core/client/src/collection-manager/interfaces/index.ts +++ b/packages/core/client/src/collection-manager/interfaces/index.ts @@ -7,6 +7,7 @@ export * from './createdBy'; export * from './datetime'; export * from './email'; export * from './formula'; +export * from './sequence'; export * from './icon'; export * from './id'; export * from './input'; @@ -30,4 +31,3 @@ export * from './textarea'; export * from './time'; export * from './updatedAt'; export * from './updatedBy'; - diff --git a/packages/core/client/src/collection-manager/interfaces/sequence.tsx b/packages/core/client/src/collection-manager/interfaces/sequence.tsx new file mode 100644 index 000000000..1409e985e --- /dev/null +++ b/packages/core/client/src/collection-manager/interfaces/sequence.tsx @@ -0,0 +1,419 @@ +import React, { useContext } from 'react'; +import { Button, Select } from 'antd'; +import { onFieldValueChange } from '@formily/core'; +import { SchemaOptionsContext, useForm, useFormEffects } from '@formily/react'; +import { ArrayTable, FormButtonGroup, FormDrawer, FormLayout, Submit } from '@formily/antd'; +import { useTranslation } from 'react-i18next'; +import { css } from '@emotion/css'; + +import { Cron, SchemaComponent, SchemaComponentOptions, useCompile } from '../../schema-component'; +import { IField } from './types'; +import { defaultProps, operators, unique } from './properties'; + +function RuleTypeSelect(props) { + const compile = useCompile(); + + const { setValuesIn } = useForm(); + const index = ArrayTable.useIndex(); + + useFormEffects(() => { + onFieldValueChange(`patterns.${index}.type`, (field) => { + setValuesIn(`patterns.${index}.options`, {}); + }); + }); + + return ( + + ); +} + +function RuleOptions() { + const { type, options } = ArrayTable.useRecord(); + const ruleType = RuleTypes[type]; + const compile = useCompile(); + return ( +
+ {Object.keys(options) + .filter(key => typeof options[key] !== 'undefined') + .map(key => { + const Component = ruleType.optionRenders[key]; + const { title } = ruleType.fieldset[key] + return Component + ? ( +
+
+ {compile(title)} +
+
+ +
+
+ ) + : null; + })} +
+ ); +}; + +const RuleTypes = { + string: { + title: '{{t("Fixed text")}}', + optionRenders: { + value(options = { value: '' }) { + return {options.value}; + } + }, + fieldset: { + value: { + type: 'string', + title: '{{t("Text content")}}', + 'x-decorator': 'FormItem', + 'x-component': 'Input' + } + } + }, + integer: { + title: '{{t("Autoincrement")}}', + optionRenders: { + digits({ value }) { + const { t } = useTranslation(); + return ( + + {t('{{value}} Digits', { value })} + + ); + }, + start({ value }) { + const { t } = useTranslation(); + return ( + + {t('Starts from {{value}}', { value })} + + ); + }, + cycle({ value }) { + return ( + + ); + } + }, + fieldset: { + digits: { + type: 'number', + title: '{{t("Digits")}}', + 'x-decorator': 'FormItem', + 'x-component': 'InputNumber', + 'x-component-props': { + min: 1, + max: 10 + }, + required: true, + default: 1 + }, + start: { + type: 'number', + title: '{{t("Start from")}}', + 'x-decorator': 'FormItem', + 'x-component': 'InputNumber', + 'x-component-props': { + min: 0 + }, + required: true, + default: 0 + }, + cycle: { + type: 'string', + title: '{{t("Reset cycle")}}', + 'x-decorator': 'FormItem', + ['x-component']({ value, onChange }) { + const shortValues = [ + { label: '不重置', value: 0 }, + { label: '每天', value: 1, cron: '0 0 * * *' }, + { label: '每周一', value: 2, cron: '0 0 * * 1' }, + { label: '每月', value: 3, cron: '0 0 1 * *' }, + { label: '每年', value: 4, cron: '0 0 1 1 *' }, + { label: '自定义', value: 5, cron: '* * * * *' } + ]; + const option = typeof value === 'undefined' + ? shortValues[0] + : shortValues.find(item => { + return item.cron == value + }) || shortValues[5] + return ( +
+ + {option.value === 5 + ? ( + + ) + : null} +
+ ); + }, + default: null + } + } + }, + date: { + title: '{{t("Date")}}', + optionRenders: { + format(options = { value: 'YYYYMMDD' }) { + return {options.value}; + } + } + } +}; + +function RuleConfigForm() { + const { t } = useTranslation(); + const compile = useCompile(); + const schemaOptions = useContext(SchemaOptionsContext); + const form = useForm(); + const { type, options } = ArrayTable.useRecord(); + const index = ArrayTable.useIndex(); + const ruleType = RuleTypes[type]; + return ruleType?.fieldset + ? ( + + ) + : null; +} + +export const sequence: IField = { + name: 'sequence', + type: 'object', + group: 'advanced', + order: 2, + title: '{{t("Sequence")}}', + sortable: true, + default: { + type: 'sequence', + uiSchema: { + type: 'string', + 'x-component': 'Input', + 'x-component-props': { + readOnly: true, + disabled: true + }, + 'x-read-pretty': true, + }, + }, + hasDefaultValue: false, + properties: { + ...defaultProps, + unique, + patterns: { + type: 'array', + title: '{{t("Sequence rules")}}', + required: true, + 'x-decorator': 'FormItem', + 'x-component': 'ArrayTable', + items: { + type: 'object', + properties: { + sort: { + type: 'void', + 'x-component': 'ArrayTable.Column', + 'x-component-props': { width: 50, title: '', align: 'center' }, + properties: { + sort: { + type: 'void', + 'x-component': 'ArrayTable.SortHandle', + }, + }, + }, + type: { + type: 'void', + 'x-component': 'ArrayTable.Column', + 'x-component-props': { title: '{{t("Type")}}' }, + // 'x-hidden': true, + properties: { + type: { + type: 'string', + name: 'type', + required: true, + 'x-decorator': 'FormItem', + 'x-component': RuleTypeSelect + }, + }, + }, + options: { + type: 'void', + 'x-component': 'ArrayTable.Column', + 'x-component-props': { title: '{{t("Rule content")}}' }, + properties: { + options: { + type: 'object', + name: 'options', + 'x-component': RuleOptions + } + } + }, + operations: { + type: 'void', + 'x-component': 'ArrayTable.Column', + 'x-component-props': { + title: '{{t("Operations")}}', + dataIndex: 'operations', + fixed: 'right', + className: css` + > *:not(:last-child){ + margin-right: .5em; + } + button{ + padding: 0; + } + ` + }, + properties: { + config: { + type: 'void', + // 'x-component': 'span', + properties: { + options: { + type: 'object', + 'x-component': RuleConfigForm + } + } + }, + // configure: { + // type: 'void', + // title: '{{t("Configure")}}', + // 'x-component': 'Action.Link', + // properties: { + // drawer: { + // type: 'void', + // 'x-component': 'Action.Drawer', + // 'x-decorator': 'Form', + // 'x-decorator-props': { + // useValues: useRowOptions + // }, + // title: '{{t("Configure")}}', + // properties: { + // options: { + // type: 'void', + // 'x-component': RuleConfig + // }, + // actions: { + // type: 'void', + // 'x-component': 'Action.Drawer.Footer', + // properties: { + // cancel: { + // title: '{{t("Cancel")}}', + // 'x-component': 'Action', + // 'x-component-props': { + // // useAction: '{{ cm.useCancelAction }}', + // }, + // }, + // submit: { + // title: '{{t("Submit")}}', + // 'x-component': 'Action', + // 'x-component-props': { + // type: 'primary', + // async useAction() { + // const form = useForm(); + // const ctx = useActionContext(); + // await form.submit(); + // console.log(form); + // ctx.setVisible(false); + // } + // } + // } + // } + // } + // } + // }, + // }, + // }, + remove: { + type: 'void', + 'x-component': 'ArrayTable.Remove', + } + } + } + } + }, + properties: { + add: { + type: 'void', + 'x-component': 'ArrayTable.Addition', + 'x-component-props': { + defaultValue: { type: 'integer' } + }, + title: "{{t('Add rule')}}", + } + } + } + }, + filterable: { + operators: operators.string, + } +}; diff --git a/packages/core/client/src/locale/zh_CN.ts b/packages/core/client/src/locale/zh_CN.ts index b9f9967a8..8e47d5e4a 100644 --- a/packages/core/client/src/locale/zh_CN.ts +++ b/packages/core/client/src/locale/zh_CN.ts @@ -130,6 +130,20 @@ export default { "Password": "密码", "Formula": "公式", "Formula description": "基于同一条记录中的其他字段计算出一个值。", + "Sequence": "自动编码", + "Sequence rules": "编号规则", + "Add rule": "添加规则", + "Type": "类型", + "Autoincrement": "自增数字", + "Fixed text": "固定文本", + "Text content": "文本内容", + "Rule content": "规则内容", + "{{value}} Digits": "{{value}} 位数字", + "Digits": "位数", + "Start from": "起始于", + "Starts from {{value}}": "从 {{value}} 开始", + "Reset cycle": "重置周期", + "Operations": "操作", "Choices": "选择类型", "Checkbox": "勾选", "Single select": "下拉菜单(单选)", diff --git a/packages/core/client/src/schema-component/antd/action/demos/demo5.tsx b/packages/core/client/src/schema-component/antd/action/demos/demo5.tsx new file mode 100644 index 000000000..c30457556 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/action/demos/demo5.tsx @@ -0,0 +1,224 @@ +import { + ArrayTable, + Editable, + Form, + FormButtonGroup, + FormDrawer, + FormItem, + FormLayout, + Input, + NumberPicker, + Submit +} from '@formily/antd'; +import { Select } from 'antd'; +import { createForm, Field, onFieldValueChange } from '@formily/core'; +import { connect, createSchemaField, observer, useField, useForm, useFormEffects } from '@formily/react'; +import React from 'react'; + +const ViewOptions = connect((props) => { + return
{JSON.stringify(props.value)}
; +}); + +const types = { + type1: { + input1: { + type: 'string', + title: 'Input11', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + }, + input2: { + type: 'string', + title: 'Input11', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + }, + }, + type2: { + input1: { + type: 'string', + title: 'Input12', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + }, + input2: { + type: 'string', + title: 'Input12', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + }, + }, +}; + +function TypeSelect(props) { + const { setValuesIn } = useForm(); + const index = ArrayTable.useIndex(); + + useFormEffects(() => { + onFieldValueChange(`projects.${index}.type`, (field) => { + setValuesIn(`projects.${index}.options`, {}); + }); + }); + + return ( + + ); +} + +const EditOptions = observer((props) => { + const record = ArrayTable.useRecord(); + const field = useField(); + console.log(record.type); + return ( +
+ { + FormDrawer('Pop-up form', () => { + return ( + + + + + { + return new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + }} + > + Submit + + + + + ); + }) + .open({ + initialValues: field.value, + }) + .then((values) => { + field.value = values; + }); + }} + > + Edit + +
+ ); +}); + +const SchemaField = createSchemaField({ + components: { + FormItem, + Editable, + Input, + NumberPicker, + ArrayTable, + Select, + ViewOptions, + EditOptions, + }, +}); + +const form = createForm(); + +const schema = { + type: 'object', + properties: { + projects: { + type: 'array', + title: 'Projects', + 'x-decorator': 'FormItem', + 'x-component': 'ArrayTable', + items: { + type: 'object', + properties: { + sort: { + type: 'void', + 'x-component': 'ArrayTable.Column', + 'x-component-props': { width: 50, title: '', align: 'center' }, + properties: { + sort: { + type: 'void', + 'x-component': 'ArrayTable.SortHandle', + }, + }, + }, + column_33: { + type: 'void', + 'x-component': 'ArrayTable.Column', + 'x-component-props': { + title: 'Type', + }, + properties: { + type: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': TypeSelect + }, + }, + }, + column_3: { + type: 'void', + 'x-component': 'ArrayTable.Column', + 'x-component-props': { + title: 'Options', + }, + properties: { + options: { + type: 'object', + 'x-component': 'ViewOptions' + }, + }, + }, + column_6: { + type: 'void', + 'x-component': 'ArrayTable.Column', + 'x-component-props': { + title: 'Operations', + }, + properties: { + item: { + type: 'void', + 'x-component': 'FormItem', + properties: { + options: { + type: 'object', + 'x-component': 'EditOptions', + }, + }, + }, + }, + }, + }, + }, + properties: { + add: { + type: 'void', + title: 'Add', + 'x-component': 'ArrayTable.Addition', + }, + }, + }, + }, +}; + +export default () => { + return ( +
+ + + 提交 + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/action/index.md b/packages/core/client/src/schema-component/antd/action/index.md index 8c1ca5c9a..539e33fe0 100644 --- a/packages/core/client/src/schema-component/antd/action/index.md +++ b/packages/core/client/src/schema-component/antd/action/index.md @@ -40,3 +40,5 @@ group: ### Action + Action.Popover + + diff --git a/packages/core/client/src/schema-component/antd/cron/Cron.tsx b/packages/core/client/src/schema-component/antd/cron/Cron.tsx new file mode 100644 index 000000000..87faceb3a --- /dev/null +++ b/packages/core/client/src/schema-component/antd/cron/Cron.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { connect, mapProps, mapReadPretty } from '@formily/react'; +import { Cron as ReactCron, CronProps } from 'react-js-cron'; +import cronstrue from 'cronstrue'; +import 'cronstrue/locales/zh_CN'; +import { css } from '@emotion/css'; + +import localeZhCN from './locale/zh-CN'; + +const ComponentLocales = { + 'zh-CN': localeZhCN, +}; + +const ReadPrettyLocales = { + 'en-US': 'en', + 'zh-CN': 'zh_CN' +}; + +type ComposedCron = React.FC & {} + +export const Cron: ComposedCron = connect( + (props: Exclude & { onChange: (value: string) => void }) => { + const { onChange, ...rest } = props; + const locale = ComponentLocales[localStorage.getItem('NOCOBASE_LOCALE') || 'en-US']; + return ( +
span{ + flex-shrink: 0; + margin: 0 .5em 0 0; + } + + > .react-js-cron-select{ + margin: 0 .5em 0 0; + } + } + `}> + +
+ ); + }, + mapReadPretty((props) => { + const locale = ReadPrettyLocales[localStorage.getItem('NOCOBASE_LOCALE') || 'en-US']; + return props.value + ? ( + + {cronstrue.toString(props.value, { + locale, + use24HourTimeFormat: true + })} + + ) + : null; + }) +); + +export default Cron; diff --git a/packages/core/client/src/schema-component/antd/cron/index.tsx b/packages/core/client/src/schema-component/antd/cron/index.tsx new file mode 100644 index 000000000..3ce35cbf9 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/cron/index.tsx @@ -0,0 +1 @@ +export * from './Cron'; diff --git a/packages/core/client/src/schema-component/antd/cron/locale/zh-CN.ts b/packages/core/client/src/schema-component/antd/cron/locale/zh-CN.ts new file mode 100644 index 000000000..96c338dbc --- /dev/null +++ b/packages/core/client/src/schema-component/antd/cron/locale/zh-CN.ts @@ -0,0 +1,79 @@ +export default { + everyText: '每', + emptyMonths: '每月', + emptyMonthDays: '每日(月)', + emptyMonthDaysShort: '每日', + emptyWeekDays: '每天(周)', + emptyWeekDaysShort: '每天(周)', + emptyHours: '每小时', + emptyMinutes: '每分钟', + emptyMinutesForHourPeriod: '每', + yearOption: '年', + monthOption: '月', + weekOption: '周', + dayOption: '天', + hourOption: '小时', + minuteOption: '分钟', + rebootOption: '重启', + prefixPeriod: '每', + prefixMonths: '的', + prefixMonthDays: '的', + prefixWeekDays: '的', + prefixWeekDaysForMonthAndYearPeriod: '并且', + prefixHours: '的', + prefixMinutes: ':', + prefixMinutesForHourPeriod: '的', + suffixMinutesForHourPeriod: '分钟', + errorInvalidCron: '不符合 cron 规则的表达式', + clearButtonText: '清空', + weekDays: [ + // Order is important, the index will be used as value + '周日', // Sunday must always be first, it's "0" + '周一', + '周二', + '周三', + '周四', + '周五', + '周六', + ], + months: [ + // Order is important, the index will be used as value + '一月', + '二月', + '三月', + '四月', + '五月', + '六月', + '七月', + '八月', + '九月', + '十月', + '十一月', + '十二月', + ], + altWeekDays: [ + // Order is important, the index will be used as value + '周日', // Sunday must always be first, it's "0" + '周一', + '周二', + '周三', + '周四', + '周五', + '周六', + ], + altMonths: [ + // Order is important, the index will be used as value + '一月', + '二月', + '三月', + '四月', + '五月', + '六月', + '七月', + '八月', + '九月', + '十月', + '十一月', + '十二月', + ], +} diff --git a/packages/core/client/src/schema-component/antd/index.ts b/packages/core/client/src/schema-component/antd/index.ts index d97193be7..ca5790054 100644 --- a/packages/core/client/src/schema-component/antd/index.ts +++ b/packages/core/client/src/schema-component/antd/index.ts @@ -4,6 +4,7 @@ export * from './calendar'; export * from './card-item'; export * from './cascader'; export * from './checkbox'; +export * from './cron'; export * from './color-select'; export * from './date-picker'; export * from './filter'; diff --git a/packages/core/client/src/schema-templates/SchemaTemplateManagerProvider.tsx b/packages/core/client/src/schema-templates/SchemaTemplateManagerProvider.tsx index b5dc09304..bb63c746f 100644 --- a/packages/core/client/src/schema-templates/SchemaTemplateManagerProvider.tsx +++ b/packages/core/client/src/schema-templates/SchemaTemplateManagerProvider.tsx @@ -50,7 +50,7 @@ export const useSchemaTemplate = () => { const fieldSchema = useFieldSchema(); const schemaId = fieldSchema['x-uid']; const templateKey = fieldSchema['x-template-key']; - console.log('templateKey', { schemaId, templateKey }) + // console.log('templateKey', { schemaId, templateKey }) return useMemo(() => getTemplateBySchema(fieldSchema), [schemaId, templateKey]); }; diff --git a/packages/core/database/package.json b/packages/core/database/package.json index ffa7a34c7..3bd787d5b 100644 --- a/packages/core/database/package.json +++ b/packages/core/database/package.json @@ -14,10 +14,12 @@ "dependencies": { "@nocobase/utils": "0.7.4-alpha.7", "async-mutex": "^0.3.2", + "cron-parser": "4.4.0", "deepmerge": "^4.2.2", "flat": "^5.0.2", "glob": "^7.1.6", "mathjs": "^10.6.1", + "moment": "2.x", "semver": "^7.3.7", "sequelize": "^6.9.0", "umzug": "^3.1.1" diff --git a/packages/core/database/src/__tests__/fields/sequence-field.test.ts b/packages/core/database/src/__tests__/fields/sequence-field.test.ts new file mode 100644 index 000000000..5005b7aa4 --- /dev/null +++ b/packages/core/database/src/__tests__/fields/sequence-field.test.ts @@ -0,0 +1,455 @@ +import moment from 'moment'; + +import { Database } from '../../database'; +import { mockDatabase } from '..'; + +describe('string field', () => { + let db: Database; + + beforeEach(async () => { + db = mockDatabase(); + }); + + afterEach(async () => { + await db.close(); + }); + + describe('define', () => { + it('without any pattern will throw error', async () => { + expect(() => { + db.collection({ + name: 'tests', + fields: [{ type: 'sequence', name: 'name' }], + }); + }).toThrow(); + }); + + it('with empty pattern will throw error', async () => { + expect(() => { + db.collection({ + name: 'tests', + fields: [{ type: 'sequence', name: 'name', patterns: [] }], + }); + }).toThrow(); + }); + }); + + describe('string pattern', () => { + it('no options', async () => { + expect(() => { + db.collection({ + name: 'tests', + fields: [{ type: 'sequence', name: 'name', patterns: [{ type: 'string' }] }], + }); + }).toThrow(); + }); + + it('constant', async () => { + db.collection({ + name: 'tests', + fields: [ + { + type: 'sequence', + name: 'name', + patterns: [ + { type: 'string', options: { value: 'abc' } } + ] + } + ], + }); + await db.sync(); + + const TestModel = db.getModel('tests'); + const item1 = await TestModel.create(); + expect(item1.get('name')).toBe('abc'); + + const item2 = await TestModel.create(); + expect(item2.get('name')).toBe('abc'); + }); + }); + + describe('integer pattern', () => { + it('default start from 0, digits as 1, no cycle', async () => { + db.collection({ + name: 'tests', + fields: [ + { + type: 'sequence', + name: 'name', + patterns: [ + { + type: 'integer' + } + ] + } + ], + }); + await db.sync(); + + const TestModel = db.getModel('tests'); + const item1 = await TestModel.create(); + expect(item1.get('name')).toBe('0'); + + const item2 = await TestModel.create(); + expect(item2.get('name')).toBe('1'); + }); + + it('start from 9', async () => { + db.collection({ + name: 'tests', + fields: [ + { + type: 'sequence', + name: 'name', + patterns: [ + { + type: 'integer', + options: { + start: 9 + } + } + ] + } + ], + }); + await db.sync(); + + const TestModel = db.getModel('tests'); + const item1 = await TestModel.create(); + expect(item1.get('name')).toBe('9'); + + const item2 = await TestModel.create(); + expect(item2.get('name')).toBe('9'); + }); + + it('start from 0, current set to 9', async () => { + const collection = db.collection({ + name: 'tests', + fields: [ + { + type: 'sequence', + name: 'name', + patterns: [ + { + type: 'integer' + } + ] + } + ], + }); + await db.sync(); + const field = collection.getField('name'); + // set current option in memory + field.options.patterns[0].options = { current: 9 }; + + const TestModel = db.getModel('tests'); + const item1 = await TestModel.create(); + expect(item1.get('name')).toBe('0'); + }); + + it('digits more than 1, start from 9', async () => { + db.collection({ + name: 'tests', + fields: [ + { + type: 'sequence', + name: 'name', + patterns: [ + { + type: 'integer', + options: { + digits: 2, + start: 9 + } + } + ] + } + ], + }); + await db.sync(); + + const TestModel = db.getModel('tests'); + const item1 = await TestModel.create(); + expect(item1.get('name')).toBe('09'); + + const item2 = await TestModel.create(); + expect(item2.get('name')).toBe('10'); + }); + + it('cycle by day', async () => { + db.collection({ + name: 'tests', + fields: [ + { + type: 'sequence', + name: 'name', + patterns: [ + { + type: 'integer', + options: { + cycle: '0 0 * * * *' + } + } + ] + } + ], + }); + await db.sync(); + + const now = new Date(); + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + const TestModel = db.getModel('tests'); + const item1 = await TestModel.create({ + createdAt: yesterday + }); + expect(item1.get('name')).toBe('0'); + + const item2 = await TestModel.create({ + createdAt: yesterday + }); + expect(item2.get('name')).toBe('1'); + + const item3 = await TestModel.create(); + expect(item3.get('name')).toBe('0'); + + const item4 = await TestModel.create(); + expect(item4.get('name')).toBe('1'); + }); + }); + + describe('date pattern', () => { + it('default to current createdAt as YYYYMMDD', async () => { + db.collection({ + name: 'tests', + fields: [ + { + type: 'sequence', + name: 'name', + patterns: [ + { type: 'date' } + ] + } + ], + }); + await db.sync(); + + const now = new Date(); + const YYYYMMDD = moment(now).format('YYYYMMDD'); + + const TestModel = db.getModel('tests'); + const item1 = await TestModel.create(); + expect(item1.get('name')).toBe(YYYYMMDD); + }); + + it('field option', async () => { + db.collection({ + name: 'tests', + fields: [ + { + type: 'sequence', + name: 'name', + patterns: [ + { type: 'date', options: { field: 'date' } } + ] + }, + { + type: 'date', + name: 'date' + } + ], + }); + await db.sync(); + + const date = new Date(2022, 7, 1); + const YYYYMMDD = moment(date).format('YYYYMMDD'); + + const TestModel = db.getModel('tests'); + const item1 = await TestModel.create({ + date + }); + expect(item1.get('name')).toBe(YYYYMMDD); + }); + + it('format option', async () => { + db.collection({ + name: 'tests', + fields: [ + { + type: 'sequence', + name: 'name', + patterns: [ + { type: 'date', options: { format: 'YYYY-MM-DD' } } + ] + } + ], + }); + await db.sync(); + + const now = new Date(); + const YYYYMMDD = moment(now).format('YYYY-MM-DD'); + + const TestModel = db.getModel('tests'); + const item1 = await TestModel.create(); + expect(item1.get('name')).toBe(YYYYMMDD); + }); + }); + + describe('mixed pattern', () => { + it('all patterns', async () => { + db.collection({ + name: 'tests', + fields: [ + { + type: 'sequence', + name: 'name', + patterns: [ + { type: 'string', options: { value: 'A' } }, + { type: 'date' }, + { type: 'integer' } + ] + } + ], + }); + await db.sync(); + + const now = new Date(); + const YYYYMMDD = moment(now).format('YYYYMMDD'); + + const TestModel = db.getModel('tests'); + const item1 = await TestModel.create(); + expect(item1.get('name')).toBe(`A${YYYYMMDD}0`); + + const item2 = await TestModel.create(); + expect(item2.get('name')).toBe(`A${YYYYMMDD}1`); + }); + + it('changed after generated', async () => { + const testsCollection = db.collection({ + name: 'tests', + fields: [ + { + type: 'sequence', + name: 'name', + patterns: [ + { type: 'string', options: { value: 'A' } }, + { type: 'date' }, + { type: 'integer' } + ] + } + ], + }); + await db.sync(); + + const now = new Date(); + const YYYYMMDD = moment(now).format('YYYYMMDD'); + + const TestModel = db.getModel('tests'); + const item1 = await TestModel.create(); + expect(item1.get('name')).toBe(`A${YYYYMMDD}0`); + + testsCollection.setField('name', { + type: 'sequence', + patterns: [ + { type: 'string', options: { value: 'A' } }, + { type: 'date' }, + // change options but no difference with default + { type: 'integer', options: { digits: 1 } } + ] + }); + + const item2 = await TestModel.create(); + expect(item2.get('name')).toBe(`A${YYYYMMDD}1`); + + testsCollection.setField('name', { + type: 'sequence', + patterns: [ + { type: 'string', options: { value: 'A' } }, + { type: 'date' }, + { type: 'integer', options: { digits: 2 } } + ] + }); + + const item3 = await TestModel.create(); + expect(item3.get('name')).toBe(`A${YYYYMMDD}00`); + + testsCollection.setField('name', { + type: 'sequence', + patterns: [ + { type: 'string', options: { value: 'a' } }, + { type: 'date' }, + { type: 'integer', options: { digits: 2 } } + ] + }); + + const item4 = await TestModel.create(); + expect(item4.get('name')).toBe(`a${YYYYMMDD}01`); + + testsCollection.setField('name', { + type: 'sequence', + patterns: [ + { type: 'date' }, + { type: 'integer', options: { digits: 2 } } + ] + }); + + const item5 = await TestModel.create(); + expect(item5.get('name')).toBe(`${YYYYMMDD}00`); + }); + }); + + describe('multiple serial fields', () => { + it('2 fields', async () => { + const testsCollection = db.collection({ + name: 'tests', + fields: [ + { + type: 'sequence', + name: 'name', + patterns: [ + { type: 'string', options: { value: 'A' } }, + { type: 'date' }, + { type: 'integer', options: { digits: 2, cycle: '0 0 * * *' } } + ] + }, + { + type: 'sequence', + name: 'code', + patterns: [ + { type: 'string', options: { value: 'C' } }, + { type: 'integer', options: { digits: 4 }} + ] + } + ] + }); + await db.sync(); + + const now = new Date(); + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const NOW = moment(now).format('YYYYMMDD'); + const YESTERDAY = moment(yesterday).format('YYYYMMDD'); + + const TestModel = db.getModel('tests'); + const item1 = await TestModel.create({ createdAt: yesterday }); + expect(item1.get('name')).toBe(`A${YESTERDAY}00`); + expect(item1.get('code')).toBe(`C0000`); + + const item2 = await TestModel.create(); + expect(item2.get('name')).toBe(`A${NOW}00`); + expect(item2.get('code')).toBe(`C0001`); + + testsCollection.setField('name', { + type: 'sequence', + patterns: [ + { type: 'string', options: { value: 'a' } }, + { type: 'date' }, + { type: 'integer', options: { digits: 1 } } + ] + }); + + const item3 = await TestModel.create(); + expect(item3.get('name')).toBe(`a${NOW}0`); + expect(item3.get('code')).toBe(`C0002`); + }); + }); +}); diff --git a/packages/core/database/src/fields/date-field.ts b/packages/core/database/src/fields/date-field.ts index 3d37b403d..1a07d04bc 100644 --- a/packages/core/database/src/fields/date-field.ts +++ b/packages/core/database/src/fields/date-field.ts @@ -3,7 +3,7 @@ import { BaseColumnFieldOptions, Field } from './field'; export class DateField extends Field { get dataType() { - return DataTypes.DATE; + return DataTypes.DATE(3); } } diff --git a/packages/core/database/src/fields/index.ts b/packages/core/database/src/fields/index.ts index 225529ce0..24e9a728a 100644 --- a/packages/core/database/src/fields/index.ts +++ b/packages/core/database/src/fields/index.ts @@ -24,7 +24,8 @@ import { TimeFieldOptions } from './time-field'; import { UidFieldOptions } from './uid-field'; import { UUIDFieldOptions } from './uuid-field'; import { VirtualFieldOptions } from './virtual-field'; -import { FormulaFieldOptions } from './formula-field' +import { FormulaFieldOptions } from './formula-field'; +import { SequenceFieldOptions } from './sequence-field'; export * from './array-field'; export * from './belongs-to-field'; @@ -48,6 +49,7 @@ export * from './uid-field'; export * from './uuid-field'; export * from './virtual-field'; export * from './formula-field'; +export { SequenceField } from './sequence-field'; export type FieldOptions = | BaseFieldOptions @@ -75,4 +77,5 @@ export type FieldOptions = | BelongsToFieldOptions | HasOneFieldOptions | HasManyFieldOptions - | BelongsToManyFieldOptions; + | BelongsToManyFieldOptions + | SequenceFieldOptions; diff --git a/packages/core/database/src/fields/sequence-field.ts b/packages/core/database/src/fields/sequence-field.ts new file mode 100644 index 000000000..28acf97f0 --- /dev/null +++ b/packages/core/database/src/fields/sequence-field.ts @@ -0,0 +1,200 @@ +import { DataTypes, Transactionable } from 'sequelize'; +import parser from 'cron-parser'; +import moment from 'moment'; +import { escapeRegExp } from 'lodash'; + +import { Registry } from '@nocobase/utils'; + +import { Model } from '..'; +import { BaseColumnFieldOptions, Field, FieldContext } from './field'; + +interface Pattern { + validate?(options): string | null; + generate(this: SequenceField, instance: Model, index: number): string; + getLength(options): number; + getMatcher(options): string; +} + +export const sequencePatterns = new Registry(); + +sequencePatterns.register('string', { + validate(options) { + if (!options?.value) { + return 'options.value should be configured as a non-empty string'; + } + return null; + }, + generate(instance, index) { + const { options } = this.options.patterns[index]; + return options.value; + }, + getLength(options) { + return options.value.length; + }, + getMatcher(options) { + return escapeRegExp(options.value); + } +}); + +sequencePatterns.register('integer', { + generate(instance: Model, index) { + const { options = {} } = this.options.patterns[index]; + const { digits = 1, start = 0, base = 10, cycle } = options; + const max = Math.pow(base, digits) - 1; + const { lastRecord = null } = this.options; + + if (typeof options.current === 'undefined') { + if (lastRecord) { + // if match current pattern + const matcher = this.match(lastRecord.get(this.options.name)); + if (matcher) { + const lastNumber = Number.parseInt(matcher[index + 1], base); + options.current = Number.isNaN(lastNumber) ? start : lastNumber + 1; + } else { + options.current = start; + } + } else { + options.current = start; + } + } else { + options.current += 1; + } + + // cycle as cron string + if (cycle && lastRecord) { + const interval = parser.parseExpression(cycle, { currentDate: lastRecord.get('createdAt') }); + const next = interval.next(); + if ((instance.get('createdAt')).getTime() >= next.getTime()) { + options.current = start; + } + } + + if (options.current > max) { + options.current = start; + } + + // update options + Object.assign(this.options.patterns[index], { options }); + + return options.current.toString(base).padStart(digits, '0'); + }, + + getLength({ digits = 1 } = {}) { + return digits; + }, + + getMatcher(options = {}) { + const { digits = 1, start = 0, base = 10 } = options; + const startLen = start ? start.toString(base).length : 1; + const chars = '0123456789abcdefghijklmnopqrstuvwxyz'.slice(0, base); + return `[${chars}]{${digits}}`; + } +}); + +sequencePatterns.register('date', { + generate(instance, index) { + const { options } = this.options.patterns[index]; + return moment(instance.get(options?.field ?? 'createdAt')).format(options?.format ?? 'YYYYMMDD'); + }, + getLength(options) { + return options.format?.length ?? 8; + }, + getMatcher(options = {}) { + return `.{${options?.format?.length ?? 8}}`; + } +}); + +interface PatternConfig { + type: string; + title?: string; + options?: any; +} +export interface SequenceFieldOptions extends BaseColumnFieldOptions { + type: 'sequence'; + patterns: PatternConfig[] +} + +export class SequenceField extends Field { + get dataType() { + return DataTypes.STRING; + } + + constructor(options: SequenceFieldOptions, context: FieldContext) { + super(options, context); + if (!options.patterns || !options.patterns.length) { + throw new Error('at least one pattern should be defined for sequence type'); + } + options.patterns.forEach(pattern => { + const P = sequencePatterns.get(pattern.type); + if (!P) { + throw new Error(`pattern type ${pattern.type} is not registered`); + } + if (P.validate) { + const error = P.validate(pattern.options); + if (error) { + throw new Error(error); + } + } + }); + + const patterns = options.patterns + .map(({ type, options }) => sequencePatterns.get(type).getMatcher(options)); + this.matcher = new RegExp(`^${patterns.map(p => `(${p})`).join('')}$`, 'i'); + } + + setValue = async (instance: Model, options) => { + const { name, patterns } = this.options; + // NOTE: only load when value is not set, if null stand for no last record + if (typeof this.options.lastRecord === 'undefined') { + const model = instance.constructor; + this.options.lastRecord = await model.findOne({ + attributes: [model.primaryKeyAttribute, this.options.name, 'createdAt'], + order: [ + ['createdAt', 'DESC'], + // TODO(bug): will cause problem if no auto-increment id + [model.primaryKeyAttribute, 'DESC'] + ], + transaction: options.transaction + }); + } + + const results = patterns.reduce((result, p, i) => { + const item = sequencePatterns.get(p.type).generate.call(this, instance, i, options); + return result.concat(item); + }, []); + instance.set(name, results.join('')); + }; + + setLast = (instance: Model, options) => { + this.options.lastRecord = instance; + }; + + match(value) { + return value.match(this.matcher); + } + + parse(value: string, patternIndex: number): string { + for (let i = 0, index = 0; i < this.options.patterns.length; i += 1) { + const { type, options } = this.options.patterns[i]; + const { getLength } = sequencePatterns.get(type); + const length = getLength(options); + if (i === patternIndex) { + return value.substring(index, index + length); + } + index += length; + } + return ''; + } + + bind() { + super.bind(); + this.on('beforeCreate', this.setValue); + this.on('afterCreate', this.setLast); + } + + unbind() { + super.unbind(); + this.off('beforeCreate', this.setValue); + this.off('afterCreate', this.setLast); + } +} diff --git a/yarn.lock b/yarn.lock index 8aacf87bf..1781eb391 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8625,6 +8625,11 @@ croner@~4.1.92: resolved "https://registry.npmjs.org/croner/-/croner-4.1.97.tgz#6e373dc7bb3026fab2deb0d82685feef20796766" integrity sha512-/f6gpQuxDaqXu+1kwQYSckUglPaOrHdbIlBAu0YuW8/Cdb45XwXYNUBXg3r/9Mo6n540Kn/smKcZWko5x99KrQ== +cronstrue@^2.11.0: + version "2.11.0" + resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-2.11.0.tgz#18ff1b95a836b9b4e06854f796db2dc8fa98ce41" + integrity sha512-iIBCSis5yqtFYWtJAmNOiwDveFWWIn+8uV5UYuPHYu/Aeu5CSSJepSbaHMyfc+pPFgnsCcGzfPQEo7LSGmWbTg== + cross-env@^7.0.3: version "7.0.3" resolved "https://registry.npmmirror.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" @@ -15531,6 +15536,11 @@ moment-timezone@^0.5.31: dependencies: moment ">= 2.9.0" +moment@2.x: + version "2.29.4" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" + integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== + "moment@>= 2.9.0", moment@^2.24.0, moment@^2.25.3, moment@^2.26.0: version "2.29.1" resolved "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"