refactor(sequence-field): move to plugin and use table to record (#1209)
* refactor(sequence-field): move to plugin and use table to record * fix(database): skip test case which not in core * fix(plugin-sequence): add migration * fix(plugin-sequence): fix types * test(plugin-sequence): fix test cases * fix(plugin-sequence): fix configuration ui * fix(plugin-sequence): fix merge * fix(plugin-sequence): fix schema and error message
This commit is contained in:
parent
720cbc76e2
commit
e3e352ffeb
20
.vscode/launch.json
vendored
20
.vscode/launch.json
vendored
@ -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": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen"
|
||||
},
|
||||
]
|
||||
}
|
||||
|
1
packages/app/client/src/plugins/sequence-field.ts
Normal file
1
packages/app/client/src/plugins/sequence-field.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from '@nocobase/plugin-sequence-field/client';
|
@ -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';
|
||||
|
@ -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": "下拉菜单(单选)",
|
||||
|
@ -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' }],
|
||||
|
@ -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;
|
||||
|
@ -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<Pattern>();
|
||||
|
||||
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: <Date>lastRecord.get('createdAt') });
|
||||
const next = interval.next();
|
||||
if ((<Date>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 = <typeof 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);
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
export default {
|
||||
'unique violation': '{{field}} 字段值是唯一的',
|
||||
'notNull Violation': '{{field}} 字段不能为空',
|
||||
'notNull violation': '{{field}} 字段不能为空',
|
||||
'Validation error': '{{field}} 字段规则验证失败',
|
||||
};
|
||||
|
4
packages/plugins/sequence-field/client.d.ts
vendored
Normal file
4
packages/plugins/sequence-field/client.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
// @ts-nocheck
|
||||
export * from './lib/client';
|
||||
export { default } from './lib/client';
|
||||
|
30
packages/plugins/sequence-field/client.js
Normal file
30
packages/plugins/sequence-field/client.js
Normal file
@ -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];
|
||||
}
|
||||
});
|
||||
});
|
27
packages/plugins/sequence-field/package.json
Normal file
27
packages/plugins/sequence-field/package.json
Normal file
@ -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"
|
||||
}
|
4
packages/plugins/sequence-field/server.d.ts
vendored
Normal file
4
packages/plugins/sequence-field/server.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
// @ts-nocheck
|
||||
export * from './lib/server';
|
||||
export { default } from './lib/server';
|
||||
|
30
packages/plugins/sequence-field/server.js
Normal file
30
packages/plugins/sequence-field/server.js
Normal file
@ -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];
|
||||
}
|
||||
});
|
||||
});
|
23
packages/plugins/sequence-field/src/client/index.tsx
Normal file
23
packages/plugins/sequence-field/src/client/index.tsx
Normal file
@ -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 (
|
||||
<SchemaComponentOptions
|
||||
components={{
|
||||
RuleConfigForm,
|
||||
}}
|
||||
>
|
||||
<CollectionManagerContext.Provider value={{ ...ctx, interfaces: { ...ctx.interfaces, sequence } }}>
|
||||
{props.children}
|
||||
</CollectionManagerContext.Provider>
|
||||
</SchemaComponentOptions>
|
||||
);
|
||||
}
|
16
packages/plugins/sequence-field/src/client/locale/index.ts
Normal file
16
packages/plugins/sequence-field/src/client/locale/index.ts
Normal file
@ -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);
|
||||
}
|
24
packages/plugins/sequence-field/src/client/locale/zh-CN.ts
Normal file
24
packages/plugins/sequence-field/src/client/locale/zh-CN.ts
Normal file
@ -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": "自定义",
|
||||
};
|
@ -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
|
||||
? (
|
||||
<dl key={key} className={css`
|
||||
@ -70,7 +69,7 @@ function RuleOptions() {
|
||||
|
||||
const RuleTypes = {
|
||||
string: {
|
||||
title: '{{t("Fixed text")}}',
|
||||
title: `{{t("Fixed text", { ns: "${NAMESPACE}" })}}`,
|
||||
optionRenders: {
|
||||
value(options = { value: '' }) {
|
||||
return <code>{options.value}</code>;
|
||||
@ -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 (
|
||||
<span>
|
||||
{t('{{value}} Digits', { value })}
|
||||
{t('{{value}} Digits', { ns: NAMESPACE, value })}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
@ -100,7 +99,7 @@ const RuleTypes = {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span>
|
||||
{t('Starts from {{value}}', { value })}
|
||||
{t('Starts from {{value}}', { ns: NAMESPACE, value })}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
@ -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 = {
|
||||
<fieldset>
|
||||
<Select value={option.value} onChange={(v) => onChange(shortValues[v].cron)}>
|
||||
{shortValues.map(item => (
|
||||
<Select.Option key={item.value} value={item.value}>{t(item.label)}</Select.Option>
|
||||
<Select.Option key={item.value} value={item.value}>{lang(item.label)}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
{option.value === 5
|
||||
@ -184,7 +198,7 @@ const RuleTypes = {
|
||||
}
|
||||
},
|
||||
date: {
|
||||
title: '{{t("Date")}}',
|
||||
title: `{{t("Date", { ns: "${NAMESPACE}" })}}`,
|
||||
optionRenders: {
|
||||
format(options = { value: 'YYYYMMDD' }) {
|
||||
return <code>{options.value}</code>;
|
||||
@ -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,
|
||||
}
|
||||
};
|
1
packages/plugins/sequence-field/src/index.ts
Normal file
1
packages/plugins/sequence-field/src/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './server';
|
99
packages/plugins/sequence-field/src/server/Plugin.ts
Normal file
99
packages/plugins/sequence-field/src/server/Plugin.ts
Normal file
@ -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<Pattern>();
|
||||
|
||||
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<any>, 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<any>, 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() {}
|
||||
}
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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'
|
||||
}
|
||||
]
|
||||
};
|
@ -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> | string;
|
||||
getLength(options): number;
|
||||
getMatcher(options): string;
|
||||
update?(this: SequenceField, instance: Model, value: string, options, transactionable: Transactionable): Promise<void>;
|
||||
}
|
||||
|
||||
export const sequencePatterns = new Registry<Pattern>();
|
||||
|
||||
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 = <Date>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: <Date>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 = <Date>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: <Date>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);
|
||||
}
|
||||
}
|
2
packages/plugins/sequence-field/src/server/index.ts
Normal file
2
packages/plugins/sequence-field/src/server/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './fields/sequence-field';
|
||||
export { default } from './Plugin';
|
@ -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() {}
|
||||
}
|
@ -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) {
|
||||
|
@ -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",
|
||||
|
@ -12,6 +12,7 @@ export class PresetNocoBase extends Plugin {
|
||||
'ui-routes-storage',
|
||||
'file-manager',
|
||||
'system-settings',
|
||||
'sequence-field',
|
||||
'verification',
|
||||
'users',
|
||||
'acl',
|
||||
|
Loading…
Reference in New Issue
Block a user