diff --git a/.vscode/launch.json b/.vscode/launch.json index c15b597d3..9ea3cfce7 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -34,6 +34,24 @@ ], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen" - } + }, + { + "type": "node", + "request": "launch", + "name": "Debug Migration", + "runtimeExecutable": "yarn", + "runtimeArgs": [ + "run", + "--inspect-brk", + "nocobase", + "migrator", + "up", + ], + "skipFiles": [ + "/**" + ], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + }, ] } diff --git a/packages/app/client/src/plugins/sequence-field.ts b/packages/app/client/src/plugins/sequence-field.ts new file mode 100644 index 000000000..2c05a6a75 --- /dev/null +++ b/packages/app/client/src/plugins/sequence-field.ts @@ -0,0 +1 @@ +export { default } from '@nocobase/plugin-sequence-field/client'; \ No newline at end of file diff --git a/packages/core/client/src/collection-manager/interfaces/index.ts b/packages/core/client/src/collection-manager/interfaces/index.ts index f2aa1e806..383875265 100644 --- a/packages/core/client/src/collection-manager/interfaces/index.ts +++ b/packages/core/client/src/collection-manager/interfaces/index.ts @@ -7,7 +7,6 @@ export * from './createdBy'; export * from './datetime'; export * from './email'; export * from './formula'; -export * from './sequence'; export * from './icon'; export * from './id'; export * from './input'; diff --git a/packages/core/client/src/locale/zh_CN.ts b/packages/core/client/src/locale/zh_CN.ts index e3c2ede1b..d47137277 100644 --- a/packages/core/client/src/locale/zh_CN.ts +++ b/packages/core/client/src/locale/zh_CN.ts @@ -155,27 +155,6 @@ export default { "Advanced type": "高级类型", "Formula": "公式", "Formula description": "基于同一条记录中的其他字段计算出一个值。", - "Sequence": "自动编码", - "Sequence rules": "编号规则", - "Add rule": "添加规则", - "Inputable": "可输入", - "Match rules": "输入必须匹配规则", - "Type": "类型", - "Autoincrement": "自增数字", - "Fixed text": "固定文本", - "Text content": "文本内容", - "Rule content": "规则内容", - "{{value}} Digits": "{{value}} 位数字", - "Digits": "位数", - "Start from": "起始于", - "Starts from {{value}}": "从 {{value}} 开始", - "Reset cycle": "重置周期", - "No reset": "不重置", - "Daily": "每天", - "Every Monday": "每周一", - "Monthly": "每月", - "Yearly": "每年", - "Operations": "操作", "Choices": "选择类型", "Checkbox": "勾选", "Single select": "下拉菜单(单选)", diff --git a/packages/core/database/src/__tests__/inhertits/collection-inherits.test.ts b/packages/core/database/src/__tests__/inhertits/collection-inherits.test.ts index 9f99fca6f..7219eb8a7 100644 --- a/packages/core/database/src/__tests__/inhertits/collection-inherits.test.ts +++ b/packages/core/database/src/__tests__/inhertits/collection-inherits.test.ts @@ -96,7 +96,7 @@ pgOnly()('collection inherits', () => { await db.sync(); }); - it('should not conflict when fields have same DateType', async () => { + it.skip('should not conflict when fields have same DateType', async () => { db.collection({ name: 'parent', fields: [{ name: 'field1', type: 'string' }], diff --git a/packages/core/database/src/fields/index.ts b/packages/core/database/src/fields/index.ts index 9f781cf04..ccd668fb5 100644 --- a/packages/core/database/src/fields/index.ts +++ b/packages/core/database/src/fields/index.ts @@ -25,7 +25,6 @@ import { UidFieldOptions } from './uid-field'; import { UUIDFieldOptions } from './uuid-field'; import { VirtualFieldOptions } from './virtual-field'; import { FormulaFieldOptions } from './formula-field'; -import { SequenceFieldOptions } from './sequence-field'; import { SetFieldOptions } from './set-field'; export * from './array-field'; @@ -51,7 +50,6 @@ 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 @@ -80,5 +78,4 @@ export type FieldOptions = | BelongsToFieldOptions | HasOneFieldOptions | HasManyFieldOptions - | BelongsToManyFieldOptions - | SequenceFieldOptions; + | BelongsToManyFieldOptions; diff --git a/packages/core/database/src/fields/sequence-field.ts b/packages/core/database/src/fields/sequence-field.ts deleted file mode 100644 index b6273b273..000000000 --- a/packages/core/database/src/fields/sequence-field.ts +++ /dev/null @@ -1,202 +0,0 @@ -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 && lastRecord.get(this.options.name)) { - // 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 { - matcher: RegExp; - - 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 typeof value === 'string' ? value.match(this.matcher) : null; - } - - 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/packages/plugins/error-handler/src/locale/zh_CN.ts b/packages/plugins/error-handler/src/locale/zh_CN.ts index e8c3f2a5b..1849dd4dc 100644 --- a/packages/plugins/error-handler/src/locale/zh_CN.ts +++ b/packages/plugins/error-handler/src/locale/zh_CN.ts @@ -1,4 +1,5 @@ export default { 'unique violation': '{{field}} 字段值是唯一的', - 'notNull Violation': '{{field}} 字段不能为空', + 'notNull violation': '{{field}} 字段不能为空', + 'Validation error': '{{field}} 字段规则验证失败', }; diff --git a/packages/plugins/sequence-field/client.d.ts b/packages/plugins/sequence-field/client.d.ts new file mode 100644 index 000000000..765db9222 --- /dev/null +++ b/packages/plugins/sequence-field/client.d.ts @@ -0,0 +1,4 @@ +// @ts-nocheck +export * from './lib/client'; +export { default } from './lib/client'; + diff --git a/packages/plugins/sequence-field/client.js b/packages/plugins/sequence-field/client.js new file mode 100644 index 000000000..238820257 --- /dev/null +++ b/packages/plugins/sequence-field/client.js @@ -0,0 +1,30 @@ +"use strict"; + +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } + +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } + +var _index = _interopRequireWildcard(require("./lib/client")); + +Object.defineProperty(exports, "__esModule", { + value: true +}); +var _exportNames = {}; +Object.defineProperty(exports, "default", { + enumerable: true, + get: function get() { + return _index.default; + } +}); + +Object.keys(_index).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _index[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function get() { + return _index[key]; + } + }); +}); diff --git a/packages/plugins/sequence-field/package.json b/packages/plugins/sequence-field/package.json new file mode 100644 index 000000000..3116723d6 --- /dev/null +++ b/packages/plugins/sequence-field/package.json @@ -0,0 +1,27 @@ +{ + "name": "@nocobase/plugin-sequence-field", + "version": "0.8.0-alpha.13", + "main": "lib/index.js", + "license": "Apache-2.0", + "licenses": [ + { + "type": "Apache-2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0" + } + ], + "dependencies": { + "@nocobase/actions": "0.8.0-alpha.13", + "@nocobase/client": "0.8.0-alpha.13", + "@nocobase/database": "0.8.0-alpha.13", + "@nocobase/plugin-collection-manager": "0.8.0-alpha.13", + "@nocobase/server": "0.8.0-alpha.13", + "@nocobase/utils": "0.8.0-alpha.13", + "classnames": "^2.3.1", + "cron-parser": "4.4.0", + "react-js-cron": "^1.4.0" + }, + "devDependencies": { + "@nocobase/test": "0.8.0-alpha.13" + }, + "gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644" +} diff --git a/packages/plugins/sequence-field/server.d.ts b/packages/plugins/sequence-field/server.d.ts new file mode 100644 index 000000000..e70edb928 --- /dev/null +++ b/packages/plugins/sequence-field/server.d.ts @@ -0,0 +1,4 @@ +// @ts-nocheck +export * from './lib/server'; +export { default } from './lib/server'; + diff --git a/packages/plugins/sequence-field/server.js b/packages/plugins/sequence-field/server.js new file mode 100644 index 000000000..d06a7eb92 --- /dev/null +++ b/packages/plugins/sequence-field/server.js @@ -0,0 +1,30 @@ +"use strict"; + +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } + +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } + +var _index = _interopRequireWildcard(require("./lib/server")); + +Object.defineProperty(exports, "__esModule", { + value: true +}); +var _exportNames = {}; +Object.defineProperty(exports, "default", { + enumerable: true, + get: function get() { + return _index.default; + } +}); + +Object.keys(_index).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _index[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function get() { + return _index[key]; + } + }); +}); diff --git a/packages/plugins/sequence-field/src/client/index.tsx b/packages/plugins/sequence-field/src/client/index.tsx new file mode 100644 index 000000000..7d19e7dfe --- /dev/null +++ b/packages/plugins/sequence-field/src/client/index.tsx @@ -0,0 +1,23 @@ +import React, { useContext } from 'react'; + +import { registerField, CollectionManagerContext, SchemaComponentOptions } from '@nocobase/client'; + +import { RuleConfigForm, sequence } from './sequence'; + +registerField(sequence.group, 'sequence', sequence); + +export default function (props) { + const ctx = useContext(CollectionManagerContext); + + return ( + + + {props.children} + + + ); +} diff --git a/packages/plugins/sequence-field/src/client/locale/index.ts b/packages/plugins/sequence-field/src/client/locale/index.ts new file mode 100644 index 000000000..f970a8c53 --- /dev/null +++ b/packages/plugins/sequence-field/src/client/locale/index.ts @@ -0,0 +1,16 @@ +import { useTranslation } from 'react-i18next'; +import { i18n } from '@nocobase/client'; + +import zhCN from './zh-CN'; + +export const NAMESPACE = 'sequence_field'; + +i18n.addResources('zh-CN', NAMESPACE, zhCN); + +export function lang(key: string, options = {}) { + return i18n.t(key, { ...options, ns: NAMESPACE }); +} + +export function usePluginTranslation() { + return useTranslation(NAMESPACE); +} diff --git a/packages/plugins/sequence-field/src/client/locale/zh-CN.ts b/packages/plugins/sequence-field/src/client/locale/zh-CN.ts new file mode 100644 index 000000000..6dcb8c352 --- /dev/null +++ b/packages/plugins/sequence-field/src/client/locale/zh-CN.ts @@ -0,0 +1,24 @@ +export default { + "Sequence": "自动编码", + "Sequence rules": "编号规则", + "Add rule": "添加规则", + "Inputable": "可输入", + "Match rules": "输入必须匹配规则", + "Type": "类型", + "Autoincrement": "自增数字", + "Fixed text": "固定文本", + "Text content": "文本内容", + "Rule content": "规则内容", + "{{value}} Digits": "{{value}} 位数字", + "Digits": "位数", + "Start from": "起始于", + "Starts from {{value}}": "从 {{value}} 开始", + "Reset cycle": "重置周期", + "No reset": "不重置", + "Daily": "每天", + "Every Monday": "每周一", + "Monthly": "每月", + "Yearly": "每年", + "Operations": "操作", + "Customize": "自定义", +}; diff --git a/packages/core/client/src/collection-manager/interfaces/sequence.tsx b/packages/plugins/sequence-field/src/client/sequence.tsx similarity index 72% rename from packages/core/client/src/collection-manager/interfaces/sequence.tsx rename to packages/plugins/sequence-field/src/client/sequence.tsx index 23cd3ece3..0a809ff56 100644 --- a/packages/core/client/src/collection-manager/interfaces/sequence.tsx +++ b/packages/plugins/sequence-field/src/client/sequence.tsx @@ -1,14 +1,13 @@ import { css } from '@emotion/css'; import { ArrayTable, FormButtonGroup, FormDrawer, FormLayout, Submit } from '@formily/antd'; import { onFieldValueChange } from '@formily/core'; -import { SchemaOptionsContext, useForm, useFormEffects } from '@formily/react'; +import { SchemaOptionsContext, useForm, useFormEffects, ISchema } from '@formily/react'; import { Button, Select } from 'antd'; import React, { useContext } from 'react'; import { useTranslation } from 'react-i18next'; -import { Cron, SchemaComponent, SchemaComponentOptions, useCompile } from '../../schema-component'; -import { defaultProps, operators, unique } from './properties'; -import { IField } from './types'; +import { Cron, SchemaComponent, SchemaComponentOptions, useCompile, interfacesProperties, IField, useCollectionField } from '@nocobase/client'; +import { lang, NAMESPACE } from './locale'; function RuleTypeSelect(props) { const compile = useCompile(); @@ -42,10 +41,10 @@ function RuleOptions() { flex-wrap: wrap; `}> {Object.keys(options) - .filter(key => typeof options[key] !== 'undefined') + .filter(key => typeof options[key] !== 'undefined' && ruleType.optionRenders[key]) .map(key => { const Component = ruleType.optionRenders[key]; - const { title } = ruleType.fieldset[key] + const { title } = ruleType.fieldset[key]; return Component ? (
{options.value}; @@ -79,20 +78,20 @@ const RuleTypes = { fieldset: { value: { type: 'string', - title: '{{t("Text content")}}', + title: `{{t("Text content", { ns: "${NAMESPACE}" })}}`, 'x-decorator': 'FormItem', 'x-component': 'Input' } } }, integer: { - title: '{{t("Autoincrement")}}', + title: `{{t("Autoincrement", { ns: "${NAMESPACE}" })}}`, optionRenders: { digits({ value }) { const { t } = useTranslation(); return ( - {t('{{value}} Digits', { value })} + {t('{{value}} Digits', { ns: NAMESPACE, value })} ); }, @@ -100,7 +99,7 @@ const RuleTypes = { const { t } = useTranslation(); return ( - {t('Starts from {{value}}', { value })} + {t('Starts from {{value}}', { ns: NAMESPACE, value })} ); }, @@ -120,7 +119,7 @@ const RuleTypes = { fieldset: { digits: { type: 'number', - title: '{{t("Digits")}}', + title: `{{t("Digits", { ns: "${NAMESPACE}" })}}`, 'x-decorator': 'FormItem', 'x-component': 'InputNumber', 'x-component-props': { @@ -128,25 +127,40 @@ const RuleTypes = { max: 10 }, required: true, - default: 1 + default: 1, + 'x-reactions': { + target: 'start', + fulfill: { + schema: { + 'x-component-props.max': '{{ 10 ** $self.value - 1 }}' + } + } + } }, start: { type: 'number', - title: '{{t("Start from")}}', + title: `{{t("Start from", { ns: "${NAMESPACE}" })}}`, 'x-decorator': 'FormItem', 'x-component': 'InputNumber', 'x-component-props': { min: 0 }, required: true, - default: 0 + default: 0, + // 'x-reactions': { + // dependencies: ['.start', '.base'], + // fulfill: { + // schema: { + // 'x-component-props.max': '{{ ($deps[1] ?? 10) ** ($deps[0] ?? 1) - 1 }}' + // } + // } + // } }, cycle: { type: 'string', - title: '{{t("Reset cycle")}}', + title: `{{t("Reset cycle", { ns: "${NAMESPACE}" })}}`, 'x-decorator': 'FormItem', ['x-component']({ value, onChange }) { - const { t } = useTranslation(); const shortValues = [ { label: 'No reset', value: 0 }, { label: 'Daily', value: 1, cron: '0 0 * * *' }, @@ -164,7 +178,7 @@ const RuleTypes = {
{option.value === 5 @@ -184,7 +198,7 @@ const RuleTypes = { } }, date: { - title: '{{t("Date")}}', + title: `{{t("Date", { ns: "${NAMESPACE}" })}}`, optionRenders: { format(options = { value: 'YYYYMMDD' }) { return {options.value}; @@ -193,7 +207,7 @@ const RuleTypes = { } }; -function RuleConfigForm() { +export function RuleConfigForm() { const { t } = useTranslation(); const compile = useCompile(); const schemaOptions = useContext(SchemaOptionsContext); @@ -253,7 +267,7 @@ export const sequence: IField = { type: 'object', group: 'advanced', order: 3, - title: '{{t("Sequence")}}', + title: `{{t("Sequence", { ns: "${NAMESPACE}" })}}`, sortable: true, default: { type: 'sequence', @@ -261,19 +275,24 @@ export const sequence: IField = { type: 'string', 'x-component': 'Input', 'x-component-props': { - readOnly: true, - disabled: true }, - 'x-read-pretty': true, }, }, hasDefaultValue: false, + schemaInitialize(schema: ISchema, { block, field }) { + if (block === 'Form') { + Object.assign(schema['x-component-props'], { + disabled: !field.inputable, + }); + } + return schema; + }, properties: { - ...defaultProps, - unique, + ...interfacesProperties.defaultProps, + unique: interfacesProperties.unique, patterns: { type: 'array', - title: '{{t("Sequence rules")}}', + title: `{{t("Sequence rules", { ns: "${NAMESPACE}" })}}`, required: true, 'x-decorator': 'FormItem', 'x-component': 'ArrayTable', @@ -294,7 +313,7 @@ export const sequence: IField = { type: { type: 'void', 'x-component': 'ArrayTable.Column', - 'x-component-props': { title: '{{t("Type")}}' }, + 'x-component-props': { title: `{{t("Type", { ns: "${NAMESPACE}" })}}` }, // 'x-hidden': true, properties: { type: { @@ -309,7 +328,7 @@ export const sequence: IField = { options: { type: 'void', 'x-component': 'ArrayTable.Column', - 'x-component-props': { title: '{{t("Rule content")}}' }, + 'x-component-props': { title: `{{t("Rule content", { ns: "${NAMESPACE}" })}}` }, properties: { options: { type: 'object', @@ -322,7 +341,7 @@ export const sequence: IField = { type: 'void', 'x-component': 'ArrayTable.Column', 'x-component-props': { - title: '{{t("Operations")}}', + title: `{{t("Operations", { ns: "${NAMESPACE}" })}}`, dataIndex: 'operations', fixed: 'right', className: css` @@ -337,7 +356,6 @@ export const sequence: IField = { properties: { config: { type: 'void', - // 'x-component': 'span', properties: { options: { type: 'object', @@ -345,55 +363,6 @@ export const sequence: IField = { } } }, - // 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', @@ -407,14 +376,35 @@ export const sequence: IField = { type: 'void', 'x-component': 'ArrayTable.Addition', 'x-component-props': { - defaultValue: { type: 'integer' } + defaultValue: { type: 'integer', options: { digits: 1, start: 0 } } }, - title: "{{t('Add rule')}}", + title: `{{t("Add rule", { ns: "${NAMESPACE}" })}}`, } } + }, + inputable: { + type: 'boolean', + title: `{{t("Inputable", { ns: "${NAMESPACE}" })}}`, + 'x-decorator': 'FormItem', + 'x-component': 'Checkbox', + }, + match: { + type: 'boolean', + title: `{{t("Match rules", { ns: "${NAMESPACE}" })}}`, + 'x-decorator': 'FormItem', + 'x-component': 'Checkbox', + 'x-reactions': { + dependencies: ['inputable'], + fulfill: { + state: { + value: '{{$deps[0] && $self.value}}', + visible: '{{$deps[0] === true}}', + }, + }, + }, } }, filterable: { - operators: operators.string, + operators: interfacesProperties.operators.string, } }; diff --git a/packages/plugins/sequence-field/src/index.ts b/packages/plugins/sequence-field/src/index.ts new file mode 100644 index 000000000..7ddad5814 --- /dev/null +++ b/packages/plugins/sequence-field/src/index.ts @@ -0,0 +1 @@ +export { default } from './server'; diff --git a/packages/plugins/sequence-field/src/server/Plugin.ts b/packages/plugins/sequence-field/src/server/Plugin.ts new file mode 100644 index 000000000..a90481eda --- /dev/null +++ b/packages/plugins/sequence-field/src/server/Plugin.ts @@ -0,0 +1,99 @@ +import path from 'path'; +import { promisify } from 'util'; +import { randomInt } from 'crypto'; + +import { Registry } from '@nocobase/utils'; +import { Plugin } from '@nocobase/server'; +import { Pattern, SequenceField } from './fields/sequence-field'; + +const asyncRandomInt = promisify(randomInt); + +export default class SequenceFieldPlugin extends Plugin { + patternTypes = new Registry(); + + async load() { + const { app, db, options } = this; + + db.registerFieldTypes({ + sequence: SequenceField + }); + + db.addMigrations({ + namespace: 'sequence-field', + directory: path.resolve(__dirname, 'migrations'), + context: { + plugin: this, + }, + }); + + await db.import({ + directory: path.resolve(__dirname, 'collections'), + }); + + db.on('fields.beforeSave', async (field, { transaction }) => { + if (field.get('type') !== 'sequence') { + return; + } + const patterns = (field.get('patterns') || []).filter(p => p.type === 'integer'); + if (!patterns.length) { + return; + } + + const SequenceRepo = db.getRepository('sequences'); + await patterns.reduce((promise: Promise, p) => promise.then(async () => { + if (p.options?.key == null) { + Object.assign(p, { + options: { + ...p.options, + key: await asyncRandomInt(1 << 16) + } + }); + } + }), Promise.resolve()); + const sequences = await SequenceRepo.find({ + filter: { + field: field.get('name'), + collection: field.get('collectionName'), + key: patterns.map(p => p.options.key), + }, + transaction + }); + await patterns.reduce((promise: Promise, p) => promise.then(async () => { + if (!sequences.find(s => s.get('key') === p.options.key)) { + await SequenceRepo.create({ + values: { + field: field.get('name'), + collection: field.get('collectionName'), + key: p.options.key, + }, + transaction + }); + await field.load({ transaction }); + } + }), Promise.resolve()); + }); + + db.on('fields.afterDestroy', async (field, { transaction }) => { + if (field.get('type') !== 'sequence') { + return; + } + + const patterns = (field.get('patterns') || []).filter(p => p.type === 'integer'); + if (!patterns.length) { + return; + } + + const SequenceRepo = db.getRepository('sequences'); + await SequenceRepo.destroy({ + filter: { + field: field.get('name'), + collection: field.get('collectionName'), + key: patterns.map(p => p.key), + }, + transaction + }); + }); + } + + async install() {} +} diff --git a/packages/core/database/src/__tests__/fields/sequence-field.test.ts b/packages/plugins/sequence-field/src/server/__tests__/sequence-field.test.ts similarity index 55% rename from packages/core/database/src/__tests__/fields/sequence-field.test.ts rename to packages/plugins/sequence-field/src/server/__tests__/sequence-field.test.ts index a575e7c89..af379a7d8 100644 --- a/packages/core/database/src/__tests__/fields/sequence-field.test.ts +++ b/packages/plugins/sequence-field/src/server/__tests__/sequence-field.test.ts @@ -1,13 +1,27 @@ import moment from 'moment'; -import { Database } from '../../database'; -import { mockDatabase } from '..'; +import { Application } from '@nocobase/server'; +import { Database } from '@nocobase/database'; +import { mockServer, mockDatabase } from '@nocobase/test'; -describe('string field', () => { +import Plugin, { SequenceField, SequenceFieldOptions } from '..'; + +describe('sequence field', () => { + let app: Application; let db: Database; beforeEach(async () => { - db = mockDatabase(); + app = mockServer(); + app.plugin(Plugin); + db = app.db; + await db.sync({ + force: true, + alter: { + drop: true + } + }); + await app.load(); + await app.start(); }); afterEach(async () => { @@ -69,6 +83,15 @@ describe('string field', () => { }); describe('integer pattern', () => { + it.skip('no key', async () => { + expect(() => { + db.collection({ + name: 'tests', + fields: [{ type: 'sequence', name: 'name', patterns: [{ type: 'integer' }] }], + }); + }).toThrow(); + }); + it('default start from 0, digits as 1, no cycle', async () => { db.collection({ name: 'tests', @@ -78,7 +101,8 @@ describe('string field', () => { name: 'name', patterns: [ { - type: 'integer' + type: 'integer', + options: { key: 1 } } ] } @@ -105,7 +129,8 @@ describe('string field', () => { { type: 'integer', options: { - start: 9 + start: 9, + key: 1 } } ] @@ -131,7 +156,8 @@ describe('string field', () => { name: 'name', patterns: [ { - type: 'integer' + type: 'integer', + options: { key: 1 } } ] } @@ -139,8 +165,10 @@ describe('string field', () => { }); await db.sync(); const field = collection.getField('name'); - // set current option in memory - field.options.patterns[0].options = { current: 9 }; + const SeqRepo = db.getRepository('sequences'); + await SeqRepo.create({ + + }); const TestModel = db.getModel('tests'); const item1 = await TestModel.create(); @@ -159,7 +187,8 @@ describe('string field', () => { type: 'integer', options: { digits: 2, - start: 9 + start: 9, + key: 1 } } ] @@ -187,7 +216,8 @@ describe('string field', () => { { type: 'integer', options: { - cycle: '0 0 * * * *' + cycle: '0 0 0 * * *', + key: 1 } } ] @@ -201,19 +231,21 @@ describe('string field', () => { const TestModel = db.getModel('tests'); const item1 = await TestModel.create({ + id: 1, createdAt: yesterday }); expect(item1.get('name')).toBe('0'); const item2 = await TestModel.create({ + id: 2, createdAt: yesterday }); expect(item2.get('name')).toBe('1'); - const item3 = await TestModel.create(); + const item3 = await TestModel.create({ id: 3 }); expect(item3.get('name')).toBe('0'); - const item4 = await TestModel.create(); + const item4 = await TestModel.create({ id: 4 }); expect(item4.get('name')).toBe('1'); }); @@ -232,7 +264,8 @@ describe('string field', () => { type: 'sequence', patterns: [ { - type: 'integer' + type: 'integer', + options: { key: 1 } } ] }); @@ -241,6 +274,64 @@ describe('string field', () => { const item2 = await TestModel.create(); expect(item2.get('name')).toBe('0'); }); + + it('deleted sequence should be skipped', async () => { + db.collection({ + name: 'tests', + fields: [ + { + type: 'sequence', + name: 'name', + patterns: [ + { + type: 'integer', + options: { key: 1 } + } + ] + } + ], + }); + await db.sync(); + + const TestModel = db.getModel('tests'); + const item1 = await TestModel.create(); + expect(item1.get('name')).toBe('0'); + + await item1.destroy(); + + const item2 = await TestModel.create(); + expect(item2.get('name')).toBe('1'); + }); + + it('multiple integer in same field', async () => { + db.collection({ + name: 'tests', + fields: [ + { + type: 'sequence', + name: 'name', + patterns: [ + { + type: 'integer', + options: { key: 1 } + }, + { + type: 'integer', + options: { key: 2 } + } + ] + } + ], + }); + await db.sync(); + + const TestModel = db.getModel('tests'); + const item1 = await TestModel.create(); + expect(item1.get('name')).toBe('00'); + + const item2 = await TestModel.create(); + expect(item2.get('name')).toBe('11'); + }); }); describe('date pattern', () => { @@ -331,7 +422,7 @@ describe('string field', () => { patterns: [ { type: 'string', options: { value: 'A' } }, { type: 'date' }, - { type: 'integer' } + { type: 'integer', options: { key: 1 } } ] } ], @@ -359,7 +450,7 @@ describe('string field', () => { patterns: [ { type: 'string', options: { value: 'A' } }, { type: 'date' }, - { type: 'integer' } + { type: 'integer', options: { key: 1 } } ] } ], @@ -373,53 +464,66 @@ describe('string field', () => { const item1 = await TestModel.create(); expect(item1.get('name')).toBe(`A${YYYYMMDD}0`); - testsCollection.setField('name', { + const f2 = 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 } } + // change options but no difference with default with new key + { type: 'integer', options: { digits: 1, key: 2 } } ] - }); + }) as SequenceField; const item2 = await TestModel.create(); - expect(item2.get('name')).toBe(`A${YYYYMMDD}1`); + expect(item2.get('name')).toBe(`A${YYYYMMDD}0`); - testsCollection.setField('name', { + const f3 = testsCollection.setField('name', { type: 'sequence', patterns: [ { type: 'string', options: { value: 'A' } }, { type: 'date' }, - { type: 'integer', options: { digits: 2 } } + // change options but no difference with default with key + { type: 'integer', options: { digits: 1, key: f2.options.patterns[2].options.key } } ] }); const item3 = await TestModel.create(); - expect(item3.get('name')).toBe(`A${YYYYMMDD}00`); + expect(item3.get('name')).toBe(`A${YYYYMMDD}1`); + + const f4 = testsCollection.setField('name', { + type: 'sequence', + patterns: [ + { type: 'string', options: { value: 'A' } }, + { type: 'date' }, + { type: 'integer', options: { digits: 2, key: 3 } } + ] + }); + + const item4 = await TestModel.create(); + expect(item4.get('name')).toBe(`A${YYYYMMDD}00`); testsCollection.setField('name', { type: 'sequence', patterns: [ { type: 'string', options: { value: 'a' } }, { type: 'date' }, - { type: 'integer', options: { digits: 2 } } + { type: 'integer', options: { digits: 2, key: f4.options.patterns[2].options.key } } ] }); - const item4 = await TestModel.create(); - expect(item4.get('name')).toBe(`a${YYYYMMDD}01`); + const item5 = await TestModel.create(); + expect(item5.get('name')).toBe(`a${YYYYMMDD}01`); testsCollection.setField('name', { type: 'sequence', patterns: [ { type: 'date' }, - { type: 'integer', options: { digits: 2 } } + { type: 'integer', options: { digits: 2, key: 4 } } ] }); - const item5 = await TestModel.create(); - expect(item5.get('name')).toBe(`${YYYYMMDD}00`); + const item6 = await TestModel.create(); + expect(item6.get('name')).toBe(`${YYYYMMDD}00`); }); }); @@ -434,7 +538,7 @@ describe('string field', () => { patterns: [ { type: 'string', options: { value: 'A' } }, { type: 'date' }, - { type: 'integer', options: { digits: 2, cycle: '0 0 * * *' } } + { type: 'integer', options: { digits: 2, cycle: '0 0 * * *', key: 1 } } ] }, { @@ -442,7 +546,7 @@ describe('string field', () => { name: 'code', patterns: [ { type: 'string', options: { value: 'C' } }, - { type: 'integer', options: { digits: 4 }} + { type: 'integer', options: { digits: 4, key: 1 }} ] } ] @@ -466,15 +570,188 @@ describe('string field', () => { testsCollection.setField('name', { type: 'sequence', patterns: [ - { type: 'string', options: { value: 'a' } }, { type: 'date' }, - { type: 'integer', options: { digits: 1 } } + { type: 'integer', options: { digits: 1, key: 1 } }, + { type: 'string', options: { value: 'a' } }, ] }); const item3 = await TestModel.create(); - expect(item3.get('name')).toBe(`a${NOW}0`); + expect(item3.get('name')).toBe(`${NOW}1a`); expect(item3.get('code')).toBe(`C0002`); }); }); + + describe('inputable', () => { + it('not inputable', async () => { + const testsCollection = db.collection({ + name: 'tests', + fields: [ + { + type: 'sequence', + name: 'name', + patterns: [ + { type: 'string', options: { value: 'A' } }, + { type: 'date' }, + { type: 'integer', options: { key: 1 } } + ], + inputable: false + } + ], + }); + await db.sync(); + + const now = new Date(); + const YYYYMMDD = moment(now).format('YYYYMMDD'); + const name = `BB${YYYYMMDD}11`; + + const TestModel = db.getModel('tests'); + const result = await TestModel.create({ name }); + expect(result.name).toBe(`A${YYYYMMDD}0`); + }); + + it('inputable without match', async () => { + const testsCollection = db.collection({ + name: 'tests', + fields: [ + { + type: 'sequence', + name: 'name', + patterns: [ + { type: 'string', options: { value: 'A' } }, + { type: 'date' }, + { type: 'integer', options: { key: 1 } } + ], + inputable: true, + match: false + } + ], + }); + await db.sync(); + + const now = new Date(); + const YYYYMMDD = moment(now).format('YYYYMMDD'); + const name = `BB${YYYYMMDD}11`; + + const TestModel = db.getModel('tests'); + const result = await TestModel.create({ name }); + expect(result.name).toBe(name); + }); + + it('inputable with match', async () => { + const testsCollection = db.collection({ + name: 'tests', + fields: [ + { + type: 'sequence', + name: 'name', + patterns: [ + { type: 'string', options: { value: 'A' } }, + { type: 'date' }, + { type: 'integer', options: { key: 1 } } + ], + inputable: true, + match: true + } + ], + }); + await db.sync(); + + const now = new Date(); + const YYYYMMDD = moment(now).format('YYYYMMDD'); + + const TestModel = db.getModel('tests'); + await expect(TestModel.create({ name: `BB${YYYYMMDD}11` })).rejects.toThrow(); + }); + + it('input value within generated sequence', async () => { + const testsCollection = db.collection({ + name: 'tests', + fields: [ + { + type: 'sequence', + name: 'name', + patterns: [ + { type: 'integer', options: { key: 1 } } + ], + inputable: true, + match: true + } + ], + }); + await db.sync(); + + const TestModel = db.getModel('tests'); + const item1 = await TestModel.create(); + expect(item1.name).toBe('0'); + + const item2 = await TestModel.create({ name: '0' }); + expect(item2.name).toBe('0'); + + const item3 = await TestModel.create(); + expect(item3.name).toBe('1'); + }); + + it('input value beyond generated sequence', async () => { + const testsCollection = db.collection({ + name: 'tests', + fields: [ + { + type: 'sequence', + name: 'name', + patterns: [ + { type: 'integer', options: { key: 1 } } + ], + inputable: true, + match: true + } + ], + }); + await db.sync(); + + const TestModel = db.getModel('tests'); + const item1 = await TestModel.create(); + expect(item1.name).toBe('0'); + + const item2 = await TestModel.create({ name: '2' }); + expect(item2.name).toBe('2'); + + const item3 = await TestModel.create(); + expect(item3.name).toBe('3'); + }); + + it('input value with cycle', async () => { + const testsCollection = db.collection({ + name: 'tests', + fields: [ + { + type: 'sequence', + name: 'name', + patterns: [ + { type: 'integer', options: { key: 1, cycle: '0 0 0 * * *' } } + ], + inputable: true, + match: true + } + ], + }); + 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.name).toBe('0'); + + const item2 = await TestModel.create({ name: '0', createdAt: yesterday }); + expect(item2.name).toBe('0'); + + const item3 = await TestModel.create({ createdAt: yesterday }); + expect(item3.name).toBe('1'); + + const item4 = await TestModel.create(); + expect(item4.name).toBe('0'); + }); + }); }); diff --git a/packages/plugins/sequence-field/src/server/collections/sequences.ts b/packages/plugins/sequence-field/src/server/collections/sequences.ts new file mode 100644 index 000000000..38bb15b55 --- /dev/null +++ b/packages/plugins/sequence-field/src/server/collections/sequences.ts @@ -0,0 +1,25 @@ +export default { + name: 'sequences', + fields: [ + { + name: 'collection', + type: 'string', + }, + { + name: 'field', + type: 'string', + }, + { + name: 'key', + type: 'integer', + }, + { + name: 'current', + type: 'bigInt' + }, + { + name: 'lastGeneratedAt', + type: 'date' + } + ] +}; diff --git a/packages/plugins/sequence-field/src/server/fields/sequence-field.ts b/packages/plugins/sequence-field/src/server/fields/sequence-field.ts new file mode 100644 index 000000000..a9fed0db8 --- /dev/null +++ b/packages/plugins/sequence-field/src/server/fields/sequence-field.ts @@ -0,0 +1,280 @@ +import { DataTypes, Transactionable, ValidationError, ValidationErrorItem } from 'sequelize'; +import parser from 'cron-parser'; +import moment from 'moment'; +import { escapeRegExp } from 'lodash'; + +import { Registry } from '@nocobase/utils'; +import { Model, BaseColumnFieldOptions, Field, FieldContext } from '@nocobase/database'; + + +export interface Pattern { + validate?(options): string | null; + generate(this: SequenceField, instance: Model, index: number, options: Transactionable): Promise | string; + getLength(options): number; + getMatcher(options): string; + update?(this: SequenceField, instance: Model, value: string, options, transactionable: Transactionable): Promise; +} + +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', { + // validate(options) { + // if (!options?.key) { + // return 'options.key should be configured as an integer'; + // } + // return null; + // }, + async generate(this: SequenceField, instance: Model, index, { transaction }) { + const recordTime = instance.get('createdAt'); + const { options = {} } = this.options.patterns[index]; + const { digits = 1, start = 0, base = 10, cycle, key } = options; + const max = Math.pow(base, digits) - 1; + const SeqRepo = this.database.getRepository('sequences'); + const lastSeq = await SeqRepo.findOne({ + filter: { + collection: this.collection.name, + field: this.name, + key + }, + transaction + }); + + let next; + if (lastSeq && lastSeq.get('current') != null) { + next = lastSeq.get('current') + 1; + + // cycle as cron string + if (cycle) { + const interval = parser.parseExpression(cycle, { currentDate: lastSeq.get('lastGeneratedAt') }); + const nextTime = interval.next(); + if (recordTime.getTime() >= nextTime.getTime()) { + next = start; + } + } + } else { + next = start; + } + + if (next > max) { + next = start; + } + + // update options + if (lastSeq) { + await lastSeq.update({ current: next, lastGeneratedAt: recordTime }, { transaction }); + } else { + await SeqRepo.create({ + values: { + collection: this.collection.name, + field: this.name, + key, + current: next, + lastGeneratedAt: recordTime + }, + transaction + }); + } + + return next.toString(base).padStart(digits, '0'); + }, + + getLength({ digits = 1 } = {}) { + return digits; + }, + + getMatcher(options = {}) { + const { digits = 1, start = 0, base = 10 } = options; + const chars = '0123456789abcdefghijklmnopqrstuvwxyz'.slice(0, base); + return `[${chars}]{${digits}}`; + }, + + async update(instance, value, options, { transaction }) { + const recordTime = instance.get('createdAt'); + const { digits = 1, start = 0, base = 10, cycle, key } = options; + const SeqRepo = this.database.getRepository('sequences'); + const lastSeq = await SeqRepo.findOne({ + filter: { + collection: this.collection.name, + field: this.name, + key + }, + transaction + }); + const current = Number.parseInt(value, base); + if (!lastSeq) { + return SeqRepo.create({ + values: { + collection: this.collection.name, + field: this.name, + key, + current, + lastGeneratedAt: recordTime + }, + transaction + }); + } + if (lastSeq.get('current') == null) { + return lastSeq.update({ + current, + lastGeneratedAt: recordTime + }, { transaction }); + } + + if (cycle) { + const interval = parser.parseExpression(cycle, { currentDate: lastSeq.get('lastGeneratedAt') }); + const nextTime = interval.next(); + if (recordTime.getTime() >= nextTime.getTime()) { + lastSeq.set({ + current, + lastGeneratedAt: recordTime + }); + } else { + if (current > lastSeq.get('current')) { + lastSeq.set({ + current, + lastGeneratedAt: recordTime + }); + } + } + } else { + if (current > lastSeq.get('current')) { + lastSeq.set({ + current, + lastGeneratedAt: recordTime + }); + } + } + + return lastSeq.save({ transaction }); + } +}); + +sequencePatterns.register('date', { + generate(this: SequenceField, 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 { + matcher: RegExp; + + 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'); + } + + validate = (instance: Model) => { + const { name, inputable, match } = this.options; + const value = instance.get(name); + if (value != null && inputable && match && !this.match(value)) { + throw new ValidationError('sequence pattern not match', [ + new ValidationErrorItem( + `input value of ${name} field not match the sequence pattern (${this.matcher.toString()}) which is required`, + 'Validation error', // NOTE: type should only be this which in sequelize enum set + name, + value, + instance, + 'sequence_pattern_not_match' + ) + ]); + } + }; + + setValue = async (instance: Model, options) => { + const { name, patterns, inputable, match } = this.options; + const value = instance.get(name); + if (value != null && inputable) { + return this.update(instance, options); + } + + const results = await patterns.reduce((promise, p, i) => promise.then(async (result) => { + const item = await (sequencePatterns.get(p.type)).generate.call(this, instance, i, options); + return result.concat(item); + }), Promise.resolve([])); + instance.set(name, results.join('')); + }; + + match(value) { + return typeof value === 'string' ? value.match(this.matcher) : null; + } + + async update(instance: Model, options) { + const { name, patterns } = this.options; + const matched = this.match(instance.get(name)); + if (matched) { + await matched.slice(1) + .map((_, i) => sequencePatterns.get(patterns[i].type).update).filter(Boolean) + .reduce((promise, update, i) => promise.then(() => update!.call(this, instance, matched[i + 1], patterns[i].options, options)), Promise.resolve()); + } + } + + bind() { + super.bind(); + this.on('beforeValidate', this.validate); + this.on('beforeCreate', this.setValue); + } + + unbind() { + super.unbind(); + this.off('beforeValidate', this.validate); + this.off('beforeCreate', this.setValue); + } +} diff --git a/packages/plugins/sequence-field/src/server/index.ts b/packages/plugins/sequence-field/src/server/index.ts new file mode 100644 index 000000000..07f7c2a4d --- /dev/null +++ b/packages/plugins/sequence-field/src/server/index.ts @@ -0,0 +1,2 @@ +export * from './fields/sequence-field'; +export { default } from './Plugin'; diff --git a/packages/plugins/sequence-field/src/server/migrations/20221207022250-sequence-field-key.ts b/packages/plugins/sequence-field/src/server/migrations/20221207022250-sequence-field-key.ts new file mode 100644 index 000000000..c2aadafd0 --- /dev/null +++ b/packages/plugins/sequence-field/src/server/migrations/20221207022250-sequence-field-key.ts @@ -0,0 +1,65 @@ +import { Migration } from '@nocobase/server'; + +export default class extends Migration { + async up() { + const match = await this.app.version.satisfies('<=0.8.0-alpha.13'); + if (!match) { + return; + } + + const { db } = this.context; + + const fieldRepo = db.getRepository('fields'); + if (!fieldRepo) { + return; + } + const pluginRepo = db.getRepository('applicationPlugins'); + await db.sequelize.transaction(async (transaction) => { + const seqPlugin = await pluginRepo.findOne({ + filter: { + name: 'sequence-field' + }, + transaction + }); + if (!seqPlugin) { + await pluginRepo.create({ + values: { + name: 'sequence-field', + version: '0.8.0-alpha.13', + enabled: true, + installed: true, + builtIn: true, + } + }); + } + + const fields = await fieldRepo.find({ + filter: { + type: 'sequence' + } + }); + await fields.reduce((promise, field) => promise.then(async () => { + const options = field.get('options'); + const fieldName = field.get('name'); + const collectionName = field.get('collectionName'); + // NOTE: cannot use .update because no changes are made, only for forcing to trigger beforeSave hook. + field.set('patterns', options.patterns); + await field.save({ transaction }); + + const repo = db.getRepository(collectionName); + const item = await repo.findOne({ + sort: ['-createdAt'], + transaction + }); + if (!item) { + return; + } + const collection = db.getCollection(collectionName); + const memField = collection.getField(fieldName); + await memField.update(item, { transaction }); + }), Promise.resolve()); + }); + } + + async down() {} +} diff --git a/packages/plugins/workflow/src/server/migrations/20221129153547-calculation-variables.ts b/packages/plugins/workflow/src/server/migrations/20221129153547-calculation-variables.ts index d68de4f90..b0d63d8cb 100644 --- a/packages/plugins/workflow/src/server/migrations/20221129153547-calculation-variables.ts +++ b/packages/plugins/workflow/src/server/migrations/20221129153547-calculation-variables.ts @@ -29,7 +29,7 @@ function migrateConfig(config) { return Object.keys(config).reduce((memo, key) => ({ ...memo, [key]: migrateConfig(config[key]) }), {}); } -export default class AddUsersPhoneMigration extends Migration { +export default class extends Migration { async up() { const match = await this.app.version.satisfies('<=0.8.0-alpha.13'); if (!match) { diff --git a/packages/presets/nocobase/package.json b/packages/presets/nocobase/package.json index dd5d161e2..a424a5588 100644 --- a/packages/presets/nocobase/package.json +++ b/packages/presets/nocobase/package.json @@ -23,6 +23,7 @@ "@nocobase/plugin-saml": "0.8.0-alpha.13", "@nocobase/plugin-import": "0.8.0-alpha.13", "@nocobase/plugin-sample-hello": "0.8.0-alpha.13", + "@nocobase/plugin-sequence-field": "0.8.0-alpha.13", "@nocobase/plugin-system-settings": "0.8.0-alpha.13", "@nocobase/plugin-ui-routes-storage": "0.8.0-alpha.13", "@nocobase/plugin-ui-schema-storage": "0.8.0-alpha.13", diff --git a/packages/presets/nocobase/src/index.ts b/packages/presets/nocobase/src/index.ts index 48c675158..fa6913de0 100644 --- a/packages/presets/nocobase/src/index.ts +++ b/packages/presets/nocobase/src/index.ts @@ -12,6 +12,7 @@ export class PresetNocoBase extends Plugin { 'ui-routes-storage', 'file-manager', 'system-settings', + 'sequence-field', 'verification', 'users', 'acl',