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 (
+
+ );
+ },
+ 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 (
+
+ );
+});
+
+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 (
+
+ );
+ },
+ 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"