diff --git a/packages/database/src/__tests__/magic-attribute-model.test.ts b/packages/database/src/__tests__/magic-attribute-model.test.ts index ae2b33d5f..01b05faca 100644 --- a/packages/database/src/__tests__/magic-attribute-model.test.ts +++ b/packages/database/src/__tests__/magic-attribute-model.test.ts @@ -29,6 +29,8 @@ describe('magic-attribute-model', () => { test.set('x-component-props', { arr2: [1, 2, 3] }); + await test.save(); + expect(test.toJSON()).toMatchObject({ title: 'aa', 'x-component-props': { @@ -39,6 +41,8 @@ describe('magic-attribute-model', () => { }, 'x-decorator-props': { key1: 'val1' }, }); + + await db.close(); }); it('case 2', async () => { @@ -57,7 +61,7 @@ describe('magic-attribute-model', () => { await db.sync(); - const test = await Test.model.create({ + let test = await Test.model.create({ title: 'aa', 'x-component-props': { key1: 'val1', arr1: [1, 2, 3], arr2: [4, 5] }, }); @@ -69,15 +73,27 @@ describe('magic-attribute-model', () => { test.set('x-component-props', { arr2: [1, 2, 3] }); + await test.save(); + + test = await Test.model.findByPk(test.get('id') as string); + + await test.update({ + 'x-component-props': { arr2: [1, 2, 3, 4] } + }); + + test = await Test.model.findByPk(test.get('id') as string); + expect(test.toJSON()).toMatchObject({ title: 'aa', 'x-component-props': { key1: 'val1', key2: 'val2', arr1: [3, 4], - arr2: [1, 2, 3], + arr2: [1, 2, 3, 4], }, 'x-decorator-props': { key1: 'val1' }, }); + + await db.close(); }); }); diff --git a/packages/database/src/fields/uid-field.ts b/packages/database/src/fields/uid-field.ts index 3e2be1ba3..fb4372aa0 100644 --- a/packages/database/src/fields/uid-field.ts +++ b/packages/database/src/fields/uid-field.ts @@ -1,6 +1,6 @@ import { DataTypes } from 'sequelize'; -import { uid } from '../utils'; import { BaseColumnFieldOptions, Field } from './field'; +import { uid } from '@nocobase/utils'; export class UidField extends Field { get dataType() { diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 905f5a80e..fd147082f 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -1,7 +1,6 @@ export * from './database'; export * from './collection'; export * from './repository'; -export * from './utils'; export { Database as default } from './database'; export * from './relation-repository/belongs-to-many-repository'; export * from './relation-repository/belongs-to-repository'; diff --git a/packages/database/src/magic-attribute-model.ts b/packages/database/src/magic-attribute-model.ts index 87b2dab0e..21274660c 100644 --- a/packages/database/src/magic-attribute-model.ts +++ b/packages/database/src/magic-attribute-model.ts @@ -5,7 +5,7 @@ import Database from './database'; export class MagicAttributeModel extends Model { get magicAttribute() { - const db: Database = (this.constructor as any).database; + const db: Database = (this.constructor).database; const collection = db.getCollection(this.constructor.name); return collection.options.magicAttribute || 'options'; } @@ -50,4 +50,10 @@ export class MagicAttributeModel extends Model { ...data[this.magicAttribute], }; } + + async update(values?: any, options?: any) { + // @ts-ignore + this._changed = new Set(); + return super.update(values, options); + } } diff --git a/packages/database/src/repository.ts b/packages/database/src/repository.ts index fedc7a0d3..8db37b309 100644 --- a/packages/database/src/repository.ts +++ b/packages/database/src/repository.ts @@ -287,7 +287,7 @@ export class Repository(options: CreateOptions): Promise { const transaction = await this.getTransaction(options); - const guard = UpdateGuard.fromOptions(this.model, options); + const guard = UpdateGuard.fromOptions(this.model, { ...options, action: 'create' }); const values = guard.sanitize(options.values || {}); const instance = await this.model.create(values, { diff --git a/packages/database/src/update-guard.ts b/packages/database/src/update-guard.ts index d35e3e92c..3d97852be 100644 --- a/packages/database/src/update-guard.ts +++ b/packages/database/src/update-guard.ts @@ -11,18 +11,28 @@ type UpdateValues = { [key: string]: UpdateValueItem | Array; }; +type UpdateAction = 'create' | 'update'; export class UpdateGuard { model: ModelCtor; + action: UpdateAction; private associationKeysToBeUpdate: AssociationKeysToBeUpdate; private blackList: BlackList; private whiteList: WhiteList; + setAction(action: UpdateAction) { + this.action = action; + } + setModel(model: ModelCtor) { this.model = model; } setAssociationKeysToBeUpdate(associationKeysToBeUpdate: AssociationKeysToBeUpdate) { - this.associationKeysToBeUpdate = associationKeysToBeUpdate; + if (this.action == 'create') { + this.associationKeysToBeUpdate = Object.keys(this.model.associations); + } else { + this.associationKeysToBeUpdate = associationKeysToBeUpdate; + } } setWhiteList(whiteList: WhiteList) { @@ -148,8 +158,8 @@ export class UpdateGuard { guard.setModel(model); guard.setWhiteList(options.whitelist); guard.setBlackList(options.blacklist); + guard.setAction(lodash.get(options, 'action', 'update')); guard.setAssociationKeysToBeUpdate(options.updateAssociationValues); - return guard; } } diff --git a/packages/database/src/utils.ts b/packages/database/src/utils.ts deleted file mode 100644 index e3e576fe6..000000000 --- a/packages/database/src/utils.ts +++ /dev/null @@ -1,10 +0,0 @@ -let IDX = 36, - HEX = ''; -while (IDX--) HEX += IDX.toString(36); - -export function uid(len?: number) { - let str = '', - num = len || 11; - while (num--) str += HEX[(Math.random() * 36) | 0]; - return str; -} diff --git a/packages/plugin-ui-schema/.npmignore b/packages/plugin-ui-schema/.npmignore new file mode 100644 index 000000000..461574b2f --- /dev/null +++ b/packages/plugin-ui-schema/.npmignore @@ -0,0 +1,7 @@ +node_modules +*.log +docs +__tests__ +tsconfig.json +src +.fatherrc.ts \ No newline at end of file diff --git a/packages/plugin-ui-schema/package.json b/packages/plugin-ui-schema/package.json new file mode 100644 index 000000000..9a4c9e780 --- /dev/null +++ b/packages/plugin-ui-schema/package.json @@ -0,0 +1,17 @@ +{ + "name": "@nocobase/plugin-ui-schema", + "version": "0.6.0-alpha.0", + "main": "lib/index.js", + "license": "MIT", + "scripts": { + "build": "rimraf -rf lib esm dist && npm run build:cjs && npm run build:esm", + "build:cjs": "tsc --project tsconfig.build.json", + "build:esm": "tsc --project tsconfig.build.json --module es2015 --outDir esm" + }, + "dependencies": {}, + "devDependencies": { + "@formily/json-schema": "^2.0.6", + "@nocobase/test": "^0.6.0-alpha.0" + }, + "gitHead": "e7df1f93c4e23b9a666d99ee7372c02bdaec97c4" +} diff --git a/packages/plugin-ui-schema/src/__tests__/action.test.ts b/packages/plugin-ui-schema/src/__tests__/action.test.ts new file mode 100644 index 000000000..26a22a98f --- /dev/null +++ b/packages/plugin-ui-schema/src/__tests__/action.test.ts @@ -0,0 +1,216 @@ +import { MockServer, mockServer } from '@nocobase/test'; +import { Database } from '@nocobase/database'; +import PluginUiSchema from '../server'; + +describe('action test', () => { + let app: MockServer; + let db: Database; + + beforeEach(async () => { + app = mockServer({ + registerActions: true, + }); + + db = app.db; + + const queryInterface = db.sequelize.getQueryInterface(); + await queryInterface.dropAllTables(); + + app.plugin(PluginUiSchema); + + await app.load(); + await db.sync({ + force: false, + alter: { + drop: false, + }, + }); + }); + + afterEach(async () => { + await app.destroy(); + }); + + test('insert action', async () => { + const response = await app + .agent() + .resource('ui_schemas') + .insert({ + values: { + 'x-uid': 'n1', + name: 'a', + type: 'object', + properties: { + b: { + 'x-uid': 'n2', + type: 'object', + properties: { + c: { 'x-uid': 'n3' }, + }, + }, + d: { 'x-uid': 'n4' }, + }, + }, + }); + + expect(response.statusCode).toEqual(200); + }); + + test('getJsonSchema', async () => { + await app + .agent() + .resource('ui_schemas') + .insert({ + values: { + 'x-uid': 'n1', + name: 'a', + type: 'object', + properties: { + b: { + 'x-uid': 'n2', + type: 'object', + properties: { + c: { 'x-uid': 'n3' }, + }, + }, + d: { 'x-uid': 'n4' }, + }, + }, + }); + + const response = await app.agent().resource('ui_schemas').getJsonSchema({ + resourceIndex: 'n1', + }); + + const { data } = response.body; + expect(data.properties.b.properties.c['x-uid']).toEqual('n3'); + }); + + test('remove', async () => { + await app + .agent() + .resource('ui_schemas') + .insert({ + values: { + 'x-uid': 'n1', + name: 'a', + type: 'object', + properties: { + b: { + 'x-uid': 'n2', + type: 'object', + properties: { + c: { 'x-uid': 'n3' }, + }, + }, + d: { 'x-uid': 'n4' }, + }, + }, + }); + + let response = await app.agent().resource('ui_schemas').remove({ + resourceIndex: 'n2', + }); + + expect(response.statusCode).toEqual(200); + + response = await app.agent().resource('ui_schemas').getJsonSchema({ + resourceIndex: 'n1', + }); + + const { data } = response.body; + expect(data.properties.b).not.toBeDefined(); + }); + + test('patch', async () => { + await app + .agent() + .resource('ui_schemas') + .insert({ + values: { + 'x-uid': 'n1', + name: 'a', + type: 'object', + properties: { + b: { + 'x-uid': 'n2', + type: 'object', + properties: { + c: { 'x-uid': 'n3' }, + }, + }, + d: { 'x-uid': 'n4' }, + }, + }, + }); + + let response = await app + .agent() + .resource('ui_schemas') + .patch({ + values: { + 'x-uid': 'n1', + properties: { + b: { + properties: { + c: { + title: 'c-title', + }, + }, + }, + }, + }, + }); + + expect(response.statusCode).toEqual(200); + response = await app.agent().resource('ui_schemas').getJsonSchema({ + resourceIndex: 'n1', + }); + + const { data } = response.body; + expect(data.properties.b['properties']['c']['title']).toEqual('c-title'); + }); + + test('insert adjacent', async () => { + await app + .agent() + .resource('ui_schemas') + .insert({ + values: { + 'x-uid': 'n1', + name: 'a', + type: 'object', + properties: { + b: { + 'x-uid': 'n2', + type: 'object', + properties: { + c: { 'x-uid': 'n3' }, + }, + }, + d: { 'x-uid': 'n4' }, + }, + }, + }); + + let response = await app + .agent() + .resource('ui_schemas') + .insertAdjacent({ + resourceIndex: 'n2', + position: 'beforeBegin', + values: { + 'x-uid': 'n5', + name: 'e', + }, + }); + + expect(response.statusCode).toEqual(200); + response = await app.agent().resource('ui_schemas').getJsonSchema({ + resourceIndex: 'n1', + }); + + const { data } = response.body; + expect(data.properties.e['x-uid']).toEqual('n5'); + }); +}); diff --git a/packages/plugin-ui-schema/src/__tests__/ui-schema-model.test.ts b/packages/plugin-ui-schema/src/__tests__/ui-schema-model.test.ts new file mode 100644 index 000000000..abd046a0e --- /dev/null +++ b/packages/plugin-ui-schema/src/__tests__/ui-schema-model.test.ts @@ -0,0 +1,144 @@ +import { mockServer, MockServer } from '@nocobase/test'; +import { Collection, Database } from '@nocobase/database'; +import PluginUiSchema, { UiSchemaRepository } from '@nocobase/plugin-ui-schema'; + +describe('ui schema model', () => { + let app: MockServer; + let db: Database; + + let RelatedCollection: Collection; + + afterEach(async () => { + await app.destroy(); + }); + + beforeEach(async () => { + app = mockServer({ + registerActions: true, + }); + + db = app.db; + + const queryInterface = db.sequelize.getQueryInterface(); + await queryInterface.dropAllTables(); + + app.plugin(PluginUiSchema); + + await app.load(); + + RelatedCollection = db.collection({ + name: 'hasUiSchemaCollection', + fields: [ + { + type: 'belongsTo', + name: 'uiSchema', + target: 'ui_schemas', + }, + ], + }); + + await db.sync({ force: true, alter: { drop: false } }); + }); + + it('should create schema tree after ui_schema created', async () => { + const uiSchemaRepository = db.getCollection('ui_schemas').repository as UiSchemaRepository; + + await RelatedCollection.repository.create({ + values: { + uiSchema: { + 'x-uid': 'root-uid', + title: 'root-node', + name: 'root-node', + properties: { + child1: { + title: 'child1', + }, + }, + }, + }, + }); + + const child1 = await uiSchemaRepository.findOne({ + filter: { + name: 'child1', + }, + }); + + const tree = await uiSchemaRepository.getJsonSchema('root-uid'); + expect(tree).toMatchObject({ + title: 'root-node', + properties: { + child1: { + title: 'child1', + 'x-uid': child1.get('uid'), + 'x-async': false, + 'x-index': 1, + }, + }, + name: 'root-node', + 'x-uid': 'root-uid', + 'x-async': false, + 'x-index': null, + }); + }); + + it('should update schema tree after ui_schema updated', async () => { + const uiSchemaRepository = db.getCollection('ui_schemas').repository as UiSchemaRepository; + + const relatedInstance = await RelatedCollection.repository.create({ + values: { + uiSchema: { + 'x-uid': 'root-uid', + title: 'root-node', + name: 'root-node', + properties: { + child1: { + title: 'child1', + }, + }, + }, + }, + }); + + const child1 = await uiSchemaRepository.findOne({ + filter: { + name: 'child1', + }, + }); + + await RelatedCollection.repository.update({ + updateAssociationValues: ['uiSchema'], + filterByTk: relatedInstance.get('id') as string, + values: { + uiSchema: { + 'x-uid': 'root-uid', + title: 'new-root-title', + name: 'new-root-name', + properties: { + child1: { + title: 'new-child1-title', + }, + }, + }, + }, + }); + + const tree = await uiSchemaRepository.getJsonSchema('root-uid'); + + expect(tree).toMatchObject({ + title: 'new-root-title', + properties: { + child1: { + title: 'new-child1-title', + 'x-uid': child1.get('uid'), + 'x-async': false, + 'x-index': 1, + }, + }, + name: 'new-root-name', + 'x-uid': 'root-uid', + 'x-async': false, + 'x-index': null, + }); + }); +}); diff --git a/packages/plugin-ui-schema/src/__tests__/ui-schema-repository.test.ts b/packages/plugin-ui-schema/src/__tests__/ui-schema-repository.test.ts new file mode 100644 index 000000000..e3f2f48f1 --- /dev/null +++ b/packages/plugin-ui-schema/src/__tests__/ui-schema-repository.test.ts @@ -0,0 +1,732 @@ +import { mockServer, MockServer } from '@nocobase/test'; +import { Collection, Database } from '@nocobase/database'; +import PluginUiSchema from '../server'; +import UiSchemaRepository from '../repository'; +import { SchemaNode } from '../dao/ui_schema_node_dao'; + +describe('ui_schema repository', () => { + let app: MockServer; + let db: Database; + let repository: UiSchemaRepository; + + let treePathCollection: Collection; + + afterEach(async () => { + await app.destroy(); + }); + + beforeEach(async () => { + app = mockServer({ + registerActions: true, + + database: {}, + }); + + db = app.db; + + const queryInterface = db.sequelize.getQueryInterface(); + await queryInterface.dropAllTables(); + + app.plugin(PluginUiSchema); + + await app.load(); + await db.sync({ + force: false, + alter: { + drop: false, + }, + }); + repository = db.getCollection('ui_schemas').repository as UiSchemaRepository; + treePathCollection = db.getCollection('ui_schema_tree_path'); + }); + + it('should be registered', async () => { + expect(db.getCollection('ui_schemas').repository).toBeInstanceOf(UiSchemaRepository); + }); + + it('should insert single ui schema node', async () => { + const singleNode: SchemaNode = { + name: 'test', + 'x-uid': 'test', + schema: {}, + }; + + const transaction = await db.sequelize.transaction(); + await repository.insertSingleNode(singleNode, transaction); + + await transaction.commit(); + // it should save in ui schema tables + const testNode = await repository.findOne({ + filter: { + uid: 'test', + }, + }); + + expect(testNode).not.toBeNull(); + + // it should save tree path + const results = await treePathCollection.repository.find(); + expect(results.length).toEqual(1); + }); + + it('should insert child node', async () => { + const singleNode: SchemaNode = { + name: 'test', + 'x-uid': 'test', + schema: {}, + }; + + const transaction = await db.sequelize.transaction(); + + await repository.insertSingleNode(singleNode, transaction); + + const child1: SchemaNode = { + name: 'child1', + 'x-uid': 'child1', + schema: {}, + childOptions: { + parentUid: 'test', + type: 'test', + }, + }; + + await repository.insertSingleNode(child1, transaction); + + const child11: SchemaNode = { + name: 'child11', + 'x-uid': 'child11', + schema: {}, + childOptions: { + parentUid: 'child1', + type: 'test', + }, + }; + await repository.insertSingleNode(child11, transaction); + + await transaction.commit(); + expect( + ( + await treePathCollection.repository.findOne({ + filter: { + ancestor: 'test', + descendant: 'test', + }, + }) + ).get('depth'), + ).toEqual(0); + + expect( + ( + await treePathCollection.repository.findOne({ + filter: { + ancestor: 'test', + descendant: 'child1', + }, + }) + ).get('depth'), + ).toEqual(1); + + expect( + ( + await treePathCollection.repository.findOne({ + filter: { + ancestor: 'test', + descendant: 'child11', + }, + }) + ).get('depth'), + ).toEqual(2); + + expect( + ( + await treePathCollection.repository.findOne({ + filter: { + ancestor: 'child1', + descendant: 'child11', + }, + }) + ).get('depth'), + ).toEqual(1); + }); + + it('should insert child node with difference type', async () => { + const schema = { + name: 'root-name', + 'x-uid': 'root', + properties: { + p1: { + 'x-uid': 'p1', + }, + p2: { + 'x-uid': 'p2', + }, + }, + items: [ + { + name: 'i1', + 'x-uid': 'i1', + }, + { + name: 'i2', + 'x-uid': 'i2', + }, + ], + }; + + const tree = await repository.insert(schema); + expect(tree).toMatchObject({ + items: [ + { + name: 'i1', + 'x-uid': 'i1', + 'x-async': false, + 'x-index': 1, + }, + { + name: 'i2', + 'x-uid': 'i2', + 'x-async': false, + 'x-index': 2, + }, + ], + properties: { + p1: { 'x-uid': 'p1', 'x-async': false, 'x-index': 1 }, + p2: { 'x-uid': 'p2', 'x-async': false, 'x-index': 2 }, + }, + name: 'root-name', + 'x-uid': 'root', + 'x-async': false, + 'x-index': null, + }); + }); + + describe('schema', () => { + let schema; + beforeEach(() => { + schema = { + type: 'object', + title: 'title', + name: 'root', + properties: { + a1: { + type: 'string', + title: 'A1', + 'x-component': 'Input', + }, + b1: { + 'x-async': true, // 添加了一个异步节点 + type: 'string', + title: 'B1', + properties: { + c1: { + type: 'string', + title: 'C1', + }, + d1: { + 'x-async': true, + type: 'string', + title: 'D1', + }, + }, + }, + }, + }; + }); + + it('should turn schema to single nodes', async () => { + const nodes = UiSchemaRepository.schemaToSingleNodes(schema); + expect(nodes.length).toEqual(5); + }); + + it('should save schema', async () => { + await repository.insert(schema); + + expect(await repository.count()).toEqual(5); + }); + + it('should save schema with sort', async () => { + await repository.insert(schema); + + const root = await repository.findOne({ + filter: { + name: 'root', + }, + }); + const a1 = await repository.findOne({ + filter: { + name: 'a1', + }, + }); + + const b1 = await repository.findOne({ + filter: { + name: 'b1', + }, + }); + + const c1 = await repository.findOne({ + filter: { + name: 'c1', + }, + }); + + const d1 = await repository.findOne({ + filter: { + name: 'd1', + }, + }); + + expect( + ( + await treePathCollection.repository.findOne({ + filter: { + ancestor: root.get('uid'), + descendant: a1.get('uid'), + depth: 1, + }, + }) + ).get('sort'), + ).toEqual(1); + + expect( + ( + await treePathCollection.repository.findOne({ + filter: { + ancestor: root.get('uid'), + descendant: b1.get('uid'), + depth: 1, + }, + }) + ).get('sort'), + ).toEqual(2); + + expect( + ( + await treePathCollection.repository.findOne({ + filter: { + ancestor: b1.get('uid'), + descendant: c1.get('uid'), + depth: 1, + }, + }) + ).get('sort'), + ).toEqual(1); + + expect( + ( + await treePathCollection.repository.findOne({ + filter: { + ancestor: b1.get('uid'), + descendant: d1.get('uid'), + depth: 1, + }, + }) + ).get('sort'), + ).toEqual(2); + }); + + it('should getJsonSchema', async () => { + await repository.insert(schema); + const rootNode = await repository.findOne({ + filter: { + name: 'root', + }, + }); + + const results = await repository.getJsonSchema(rootNode.get('uid') as string); + + expect(results).toMatchObject({ + type: 'object', + title: 'title', + properties: { + a1: { + type: 'string', + title: 'A1', + 'x-component': 'Input', + }, + }, + name: 'root', + }); + }); + + it('should getJsonSchema by subTree', async () => { + await repository.insert({ + 'x-uid': 'n1', + name: 'a', + type: 'object', + properties: { + b: { + 'x-uid': 'n2', + type: 'object', + properties: { + c: { 'x-uid': 'n3' }, + }, + }, + d: { 'x-uid': 'n4' }, + }, + }); + + const schema = await repository.getJsonSchema('n2'); + expect(schema).toBeDefined(); + }); + + it('should getProperties', async () => { + await repository.insert(schema); + const rootNode = await repository.findOne({ + filter: { + name: 'root', + }, + }); + + const results = await repository.getProperties(rootNode.get('uid') as string); + + expect(results).toMatchObject({ + type: 'object', + properties: { + a1: {}, + b1: { + 'x-async': true, + properties: { + c1: {}, + }, + }, + }, + }); + }); + }); + + describe('insert with items', () => { + test('insert with items', async () => { + const schema = { + name: 'root-name', + 'x-uid': 'root', + properties: { + p1: { + 'x-uid': 'p1', + }, + p2: { + 'x-uid': 'p2', + }, + }, + items: [ + { + name: 'i1', + 'x-uid': 'i1', + }, + { + name: 'i2', + 'x-uid': 'i2', + }, + ], + }; + + await repository.insert(schema); + await repository.insertBeforeBegin('i1', { + 'x-uid': 'i2', + }); + + const results = await repository.getJsonSchema('root'); + expect(results['items'][0]['name']).toEqual('i2'); + expect(results['items'][1]['name']).toEqual('i1'); + }); + }); + + describe('insert', () => { + let rootNode; + let rootUid: string; + + beforeEach(async () => { + const root = { + type: 'object', + title: 'title', + name: 'root', + properties: { + a1: { + type: 'string', + title: 'A1', + 'x-component': 'Input', + }, + b1: { + type: 'string', + title: 'B1', + properties: { + c1: { + type: 'string', + title: 'C1', + }, + d1: { + type: 'string', + title: 'D1', + }, + }, + }, + }, + }; + + await repository.insert(root); + + rootNode = await repository.findOne({ + filter: { + name: 'root', + }, + }); + + rootUid = rootNode.get('uid') as string; + }); + + it('should insertAfterBegin', async () => { + const newNode = { + name: 'newNode', + }; + + await repository.insertAfterBegin(rootUid, newNode); + const schema = await repository.getJsonSchema(rootUid); + expect(schema.properties['newNode']['x-index']).toEqual(1); + expect(schema.properties.a1['x-index']).toEqual(2); + expect(schema.properties.b1['x-index']).toEqual(3); + }); + + it('should insertBeforeEnd', async () => { + const newNode = { + name: 'newNode', + }; + + await repository.insertBeforeEnd(rootUid, newNode); + const schema = await repository.getJsonSchema(rootUid); + expect(schema.properties['newNode']['x-index']).toEqual(3); + expect(schema.properties.a1['x-index']).toEqual(1); + expect(schema.properties.b1['x-index']).toEqual(2); + }); + + it('should insertBeforeBegin', async () => { + const newNode = { + name: 'newNode', + }; + + const b1Node = await repository.findOne({ + filter: { + name: 'b1', + }, + }); + + await repository.insertBeforeBegin(b1Node.get('uid') as string, newNode); + + const schema = await repository.getJsonSchema(rootUid); + expect(schema.properties['newNode']['x-index']).toEqual(2); + expect(schema.properties.a1['x-index']).toEqual(1); + expect(schema.properties.b1['x-index']).toEqual(3); + }); + + it('should insertAfterEnd a1', async () => { + const newNode = { + name: 'newNode', + }; + + const a1Node = await repository.findOne({ + filter: { + name: 'a1', + }, + }); + + await repository.insertAfterEnd(a1Node.get('uid') as string, newNode); + + const schema = await repository.getJsonSchema(rootUid); + expect(schema.properties['newNode']['x-index']).toEqual(2); + expect(schema.properties.a1['x-index']).toEqual(1); + expect(schema.properties.b1['x-index']).toEqual(3); + }); + + it('should insertAfterEnd b1', async () => { + const newNode = { + name: 'newNode', + }; + + const b1Node = await repository.findOne({ + filter: { + name: 'b1', + }, + }); + + await repository.insertAfterEnd(b1Node.get('uid') as string, newNode); + + const schema = await repository.getJsonSchema(rootUid); + expect(schema.properties['newNode']['x-index']).toEqual(3); + expect(schema.properties.a1['x-index']).toEqual(1); + expect(schema.properties.b1['x-index']).toEqual(2); + }); + }); + + describe('insert with x-uid', () => { + it('should insertAfterBegin by tree', async () => { + await repository.insert({ + 'x-uid': 'n1', + name: 'a', + type: 'object', + properties: { + b: { + 'x-uid': 'n2', + type: 'object', + properties: { + c: { 'x-uid': 'n3' }, + }, + }, + d: { + 'x-uid': 'n4', + properties: { + e: { + 'x-uid': 'n5', + }, + }, + }, + }, + }); + + await repository.insertAfterBegin('n1', { + name: 'f', + 'x-uid': 'n6', + properties: { + g: { + 'x-uid': 'n7', + properties: { + d: { 'x-uid': 'n4' }, + }, + }, + }, + }); + + const schema = await repository.getJsonSchema('n1'); + expect(schema.properties.f.properties.g.properties.d.properties.e['x-uid']).toEqual('n5'); + }); + + it('should insertAfterBegin by node', async () => { + await repository.insert({ + 'x-uid': 'n1', + name: 'a', + type: 'object', + properties: { + b: { + 'x-uid': 'n2', + type: 'object', + properties: { + c: { 'x-uid': 'n3' }, + }, + }, + d: { 'x-uid': 'n4' }, + }, + }); + + await repository.insertAfterBegin('n2', 'n4'); + const schema = await repository.getJsonSchema('n1'); + expect(schema.properties.b.properties.d['x-uid']).toEqual('n4'); + }); + }); + + describe('remove', () => { + it('should remove node', async () => { + await repository.insert({ + 'x-uid': 'n1', + name: 'a', + type: 'object', + properties: { + b: { + 'x-uid': 'n2', + type: 'object', + properties: { + c: { 'x-uid': 'n3' }, + }, + }, + d: { 'x-uid': 'n4' }, + }, + }); + + await repository.remove('n4'); + const schema = await repository.getJsonSchema('n1'); + expect(schema.properties['d']).not.toBeDefined(); + }); + + it('should remove tree', async () => { + await repository.insert({ + 'x-uid': 'n1', + name: 'a', + type: 'object', + properties: { + b: { + 'x-uid': 'n2', + type: 'object', + properties: { + c: { 'x-uid': 'n3' }, + }, + }, + d: { 'x-uid': 'n4' }, + }, + }); + + await repository.remove('n2'); + const schema = await repository.getJsonSchema('n1'); + expect(schema.properties['b']).not.toBeDefined(); + expect(schema.properties['d']).toBeDefined(); + }); + }); + + describe('patch', function () { + let rootNode; + let rootUid: string; + + beforeEach(async () => { + const root = { + type: 'object', + title: 'title', + name: 'root', + properties: { + a1: { + type: 'string', + title: 'A1', + 'x-component': 'Input', + }, + b1: { + type: 'string', + title: 'B1', + properties: { + c1: { + type: 'string', + title: 'C1', + }, + d1: { + type: 'string', + title: 'D1', + }, + }, + }, + }, + }; + + await repository.insert(root); + + rootNode = await repository.findOne({ + filter: { + name: 'root', + }, + }); + + rootUid = rootNode.get('uid') as string; + }); + + it('should patch root ui schema', async () => { + await repository.patch({ + 'x-uid': rootUid, + title: 'test-title', + properties: { + a1: { + type: 'string', + title: 'new a1 title', + 'x-component': 'Input', + }, + }, + }); + + const newTree = await repository.getJsonSchema(rootUid); + expect(newTree.title).toEqual('test-title'); + expect(newTree.properties.a1.title).toEqual('new a1 title'); + }); + }); +}); diff --git a/packages/plugin-ui-schema/src/__tests__/ui-schema.test.ts b/packages/plugin-ui-schema/src/__tests__/ui-schema.test.ts new file mode 100644 index 000000000..e3be671a0 --- /dev/null +++ b/packages/plugin-ui-schema/src/__tests__/ui-schema.test.ts @@ -0,0 +1,336 @@ +import { ISchema } from '@formily/json-schema'; +import { mockServer, MockServer } from '@nocobase/test'; +import { Collection, Database } from '@nocobase/database'; +import PluginUiSchema, { UiSchemaRepository } from '@nocobase/plugin-ui-schema'; + +describe('ui-schema', () => { + let app: MockServer; + let db: Database; + + let uiSchemaRepository: UiSchemaRepository; + + afterEach(async () => { + await app.destroy(); + }); + + beforeEach(async () => { + app = mockServer({ + registerActions: true, + }); + + db = app.db; + + await db.sequelize.getQueryInterface().dropAllTables(); + + app.plugin(PluginUiSchema); + + await app.load(); + uiSchemaRepository = db.getCollection('ui_schemas').repository as UiSchemaRepository; + }); + + type SchemaProperties = Record; + // properties、patternProperties、definitions 都是这种类型的 + describe('SchemaProperties', () => { + it('properties is plain object', () => { + const items = [ + { + properties: {}, // 有效 + }, + { + properties: null, // 跳过 + }, + { + properties: 'aaa', // 无效,跳过并警告 + }, + { + properties: [], // 无效,跳过并警告 + }, + { + properties: 1, // 无效,跳过并警告 + }, + { + properties: true, // 无效,跳过并警告 + }, + ]; + }); + it('properties node is plain object', () => { + const schema = { + properties: { + a: {}, // 有效 {name: 'a'} + b: null, // 跳过,不警告 + c: 'aaa', // 无效,跳过并警告 + d: true, // 无效,跳过并警告 + e: 1, // 无效,跳过并警告 + f: [], // 无效,跳过并警告 + }, + }; + }); + it('nested', () => { + const schema = { + properties: { + a: { + properties: { + b: {}, + }, + }, + }, + }; + }); + }); + + type SchemaItems = ISchema | ISchema[]; + + describe('items', () => { + it('should insert items node', async () => { + const schema = { + type: 'object', + properties: { + string_array: { + type: 'array', + 'x-component': 'ArrayCards', + maxItems: 3, + 'x-decorator': 'FormItem', + 'x-component-props': { + title: 'String array', + }, + items: { + type: 'void', + properties: { + index: { + type: 'void', + 'x-component': 'ArrayCards.Index', + }, + input: { + type: 'string', + 'x-decorator': 'FormItem', + title: 'Input', + required: true, + 'x-component': 'Input', + }, + remove: { + type: 'void', + 'x-component': 'ArrayCards.Remove', + }, + moveUp: { + type: 'void', + 'x-component': 'ArrayCards.MoveUp', + }, + moveDown: { + type: 'void', + 'x-component': 'ArrayCards.MoveDown', + }, + }, + }, + properties: { + addition: { + type: 'void', + title: 'Add entry', + 'x-component': 'ArrayCards.Addition', + }, + }, + }, + array: { + type: 'array', + 'x-component': 'ArrayCards', + maxItems: 3, + 'x-decorator': 'FormItem', + 'x-component-props': { + title: 'Object array', + }, + items: { + type: 'object', + properties: { + index: { + type: 'void', + 'x-component': 'ArrayCards.Index', + }, + input: { + type: 'string', + 'x-decorator': 'FormItem', + title: 'Input', + required: true, + 'x-component': 'Input', + }, + remove: { + type: 'void', + 'x-component': 'ArrayCards.Remove', + }, + moveUp: { + type: 'void', + 'x-component': 'ArrayCards.MoveUp', + }, + moveDown: { + type: 'void', + 'x-component': 'ArrayCards.MoveDown', + }, + }, + }, + properties: { + addition: { + type: 'void', + title: 'Add entry', + 'x-component': 'ArrayCards.Addition', + }, + }, + }, + }, + }; + + const result = await uiSchemaRepository.insert(schema); + console.log(JSON.stringify(result, null, 2)); + }); + + it('items is array or plain object', () => { + const examples = [ + { + items: {}, // 有效,等同于 [{}] + }, + { + items: [{}], // 有效 + }, + { + items: [], // 有效,但无节点 + }, + { + items: 'str', // 无效,跳过并警告 + }, + ]; + }); + it('items node is plain object', () => { + const schema = { + items: [ + {}, + [], // 无效,跳过并警告 + null, // 无效,跳过并警告 + 'aa', // 无效,跳过并警告 + ], + }; + }); + }); + describe('additionalProperties & additionalItems', () => { + it('additionalProperties and additionalItems are plain object', () => { + const schema = { + additionalProperties: {}, + additionalItems: { + properties: {}, + }, + }; + }); + it('null', () => { + const schema = { + additionalProperties: null, + additionalItems: null, + }; + }); + it('null', () => { + const schema = { + additionalProperties: 1, // 跳过并警告 + additionalItems: 'str', // 跳过并警告 + }; + }); + }); + + it('all props', () => { + const schema = { + definitions: { + address: { + type: 'object', + properties: { + street_address: { + type: 'string', + }, + city: { + type: 'string', + }, + state: { + type: 'string', + }, + }, + required: ['street_address', 'city', 'state'], + }, + }, + type: 'object', + title: 'title', + description: 'description', + patternProperties: { + '^[a-zA-Z0-9]*$': { + properties: { + model: { type: 'string' }, + made: { type: 'string' }, + year: { type: 'string' }, + }, + }, + }, + additionalProperties: { + type: 'string', + }, + properties: { + string: { + type: 'string', + default: 'default', + required: true, + 'x-component': 'Input', + 'x-component-props': { + placeholder: 'placeholder', + }, + 'x-decorator': 'FormItem', + 'x-decorator-props': { + labelCol: 3, + }, + 'x-disabled': true, + 'x-display': 'visible', + 'x-editable': false, + 'x-hidden': false, + 'x-pattern': 'readPretty', + 'x-read-only': true, + 'x-validator': ['phone'], + 'x-reactions': [ + { + target: 'xxx', + when: '{{aa > bb}}', + }, + ], + }, + boolean: { + type: 'boolean', + default: false, + }, + number: { + type: 'number', + default: 100, + }, + date: { + type: 'date', + default: '2020-12-23', + }, + datetime: { + type: 'datetime', + default: '2020-12-23 23:00:00', + }, + array: { + type: 'array', + items: { + type: 'string', + }, + additionalItems: { + type: 'number', + }, + }, + array2: { + type: 'array', + items: [ + { + type: 'string', + }, + { + type: 'object', + }, + ], + }, + void: { + type: 'void', + }, + }, + }; + }); +}); diff --git a/packages/plugin-ui-schema/src/actions/ui-schema-action.ts b/packages/plugin-ui-schema/src/actions/ui-schema-action.ts new file mode 100644 index 000000000..78f704aeb --- /dev/null +++ b/packages/plugin-ui-schema/src/actions/ui-schema-action.ts @@ -0,0 +1,76 @@ +import { Context } from '@nocobase/actions'; +import UiSchemaRepository from '../repository'; + +const getRepositoryFromCtx = (ctx: Context) => { + return ctx.db.getCollection('ui_schemas').repository as UiSchemaRepository; +}; + +export const uiSchemaActions = { + async getJsonSchema(ctx: Context, next) { + const { resourceIndex } = ctx.action.params; + const repository = getRepositoryFromCtx(ctx); + ctx.body = await repository.getJsonSchema(resourceIndex); + await next(); + }, + + async getProperties(ctx: Context, next) { + const { resourceIndex } = ctx.action.params; + const repository = getRepositoryFromCtx(ctx); + ctx.body = await repository.getProperties(resourceIndex); + await next(); + }, + + async insert(ctx: Context, next) { + const { values } = ctx.action.params; + const repository = getRepositoryFromCtx(ctx); + + ctx.body = await repository.insert(values); + await next(); + }, + + async remove(ctx: Context, next) { + const { resourceIndex } = ctx.action.params; + const repository = getRepositoryFromCtx(ctx); + await repository.remove(resourceIndex); + + ctx.body = { + result: 'ok', + }; + + await next(); + }, + + async patch(ctx: Context, next) { + const { values } = ctx.action.params; + const repository = getRepositoryFromCtx(ctx); + await repository.patch(values); + + ctx.body = { + result: 'ok', + }; + + await next(); + }, + + async insertAdjacent(ctx: Context, next) { + const { resourceIndex, position, values } = ctx.action.params; + const repository = getRepositoryFromCtx(ctx); + + ctx.body = await repository.insertAdjacent(position, resourceIndex, values); + + await next(); + }, + insertBeforeBegin: insertPositionActionBuilder('beforeBegin'), + insertAfterBegin: insertPositionActionBuilder('afterBegin'), + insertBeforeEnd: insertPositionActionBuilder('beforeEnd'), + insertAfterEnd: insertPositionActionBuilder('afterEnd'), +}; + +function insertPositionActionBuilder(position: 'beforeBegin' | 'afterBegin' | 'beforeEnd' | 'afterEnd') { + return async function (ctx: Context, next) { + const { resourceIndex, values } = ctx.action.params; + const repository = getRepositoryFromCtx(ctx); + ctx.body = await repository.insertAdjacent(position, resourceIndex, values); + await next(); + }; +} diff --git a/packages/plugin-ui-schema/src/collections/ui_schema_tree_path.ts b/packages/plugin-ui-schema/src/collections/ui_schema_tree_path.ts new file mode 100644 index 000000000..a5f6c4d75 --- /dev/null +++ b/packages/plugin-ui-schema/src/collections/ui_schema_tree_path.ts @@ -0,0 +1,35 @@ +export default { + name: 'ui_schema_tree_path', + autoGenId: false, + timestamps: false, + fields: [ + { + type: 'string', + name: 'ancestor', + primaryKey: true, + }, + { + type: 'string', + name: 'descendant', + primaryKey: true, + }, + { + type: 'integer', + name: 'depth', + }, + { + type: 'boolean', + name: 'async', + }, + { + type: 'string', + name: 'type', + comment: 'type of node', + }, + { + type: 'integer', + name: 'sort', + comment: 'sort of node in adjacency', + }, + ], +}; diff --git a/packages/plugin-ui-schema/src/collections/ui_schemas.ts b/packages/plugin-ui-schema/src/collections/ui_schemas.ts new file mode 100644 index 000000000..6e05213a4 --- /dev/null +++ b/packages/plugin-ui-schema/src/collections/ui_schemas.ts @@ -0,0 +1,34 @@ +import { CollectionOptions } from '@nocobase/database'; + +export default { + name: 'ui_schemas', + title: '字段配置', + autoGenId: false, + timestamps: false, + repository: 'UiSchemaRepository', + model: 'MagicAttributeModel', + magicAttribute: 'schema', + fields: [ + { + type: 'uid', + name: 'x-uid', + field: 'uid', + primaryKey: true, + }, + { + type: 'string', + name: 'uid', + field: 'uid', + primaryKey: true, + }, + { + type: 'string', + name: 'name', + }, + { + type: 'json', + name: 'schema', + defaultValue: {}, + }, + ], +} as CollectionOptions; diff --git a/packages/plugin-ui-schema/src/dao/ui_schema_dao.ts b/packages/plugin-ui-schema/src/dao/ui_schema_dao.ts new file mode 100644 index 000000000..89d1c0c81 --- /dev/null +++ b/packages/plugin-ui-schema/src/dao/ui_schema_dao.ts @@ -0,0 +1,5 @@ +export class UiSchemaDAO { + nodeKeys = ['properties', 'patternProperties', 'additionalProperties']; + + constructor(schema: any) {} +} diff --git a/packages/plugin-ui-schema/src/dao/ui_schema_node_dao.ts b/packages/plugin-ui-schema/src/dao/ui_schema_node_dao.ts new file mode 100644 index 000000000..bbb9ed1f3 --- /dev/null +++ b/packages/plugin-ui-schema/src/dao/ui_schema_node_dao.ts @@ -0,0 +1,24 @@ +export interface TargetPosition { + type: 'before' | 'after'; + target: string; +} +export interface ChildOptions { + parentUid: string; + type: string; + position?: 'first' | 'last' | TargetPosition; +} + +export interface SchemaNode { + name: string; + 'x-uid': string; + schema: object; + 'x-async'?: boolean; + childOptions?: ChildOptions; +} + +export class UiSchemaNodeDAO { + schemaNode: SchemaNode; + constructor(schemaNode: SchemaNode) { + this.schemaNode = schemaNode; + } +} diff --git a/packages/plugin-ui-schema/src/helper.ts b/packages/plugin-ui-schema/src/helper.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/plugin-ui-schema/src/index.ts b/packages/plugin-ui-schema/src/index.ts new file mode 100644 index 000000000..3868e2220 --- /dev/null +++ b/packages/plugin-ui-schema/src/index.ts @@ -0,0 +1,4 @@ +import PluginUiSchema from './server'; +import UiSchemaRepository from './repository'; +export default PluginUiSchema; +export { UiSchemaRepository }; diff --git a/packages/plugin-ui-schema/src/model.ts b/packages/plugin-ui-schema/src/model.ts new file mode 100644 index 000000000..22183897d --- /dev/null +++ b/packages/plugin-ui-schema/src/model.ts @@ -0,0 +1,5 @@ +import { Model } from '@nocobase/database'; + +export class UiSchemaModel extends Model { + +} diff --git a/packages/plugin-ui-schema/src/repository.ts b/packages/plugin-ui-schema/src/repository.ts new file mode 100644 index 000000000..8d350221d --- /dev/null +++ b/packages/plugin-ui-schema/src/repository.ts @@ -0,0 +1,636 @@ +import { Repository } from '@nocobase/database'; +import lodash from 'lodash'; +import { ChildOptions, SchemaNode, TargetPosition, UiSchemaNodeDAO } from './dao/ui_schema_node_dao'; +import { uid } from '@nocobase/utils'; +import { Transaction } from 'sequelize'; + +interface GetJsonSchemaOptions { + includeAsyncNode?: boolean; + transaction?: Transaction; +} + +const nodeKeys = ['properties', 'definitions', 'patternProperties', 'additionalProperties', 'items']; + +export default class UiSchemaRepository extends Repository { + static schemaToSingleNodes(schema: any, carry: SchemaNode[] = [], childOptions: ChildOptions = null): SchemaNode[] { + const node = lodash.cloneDeep( + lodash.isString(schema) + ? { + 'x-uid': schema, + } + : schema, + ); + + if (!lodash.get(node, 'name')) { + node.name = uid(); + } + + if (!lodash.get(node, 'x-uid')) { + node['x-uid'] = uid(); + } + + if (childOptions) { + node.childOptions = childOptions; + } + + carry.push(node); + + for (const nodeKey of nodeKeys) { + const nodeProperty = lodash.get(node, nodeKey); + + // array items + if (nodeKey === 'items' && nodeProperty) { + const handleItems = lodash.isArray(nodeProperty) ? nodeProperty : [nodeProperty]; + for (const item of handleItems) { + carry = this.schemaToSingleNodes(item, carry, { + parentUid: node['x-uid'], + type: nodeKey, + }); + } + } else if (lodash.isPlainObject(nodeProperty)) { + const subNodeNames = lodash.keys(lodash.get(node, nodeKey)); + + delete node[nodeKey]; + + for (const subNodeName of subNodeNames) { + const subSchema = { + name: subNodeName, + ...lodash.get(nodeProperty, subNodeName), + }; + + carry = this.schemaToSingleNodes(subSchema, carry, { + parentUid: node['x-uid'], + type: nodeKey, + }); + } + } + } + + return carry; + } + + async getProperties(uid: string) { + const db = this.database; + const treeCollection = db.getCollection('ui_schema_tree_path'); + + const rawSql = ` + SELECT SchemaTable.uid as uid, SchemaTable.name as name, SchemaTable.schema as "schema", + TreePath.depth as depth, + NodeInfo.type as type, NodeInfo.async as async, ParentPath.ancestor as parent + FROM ${treeCollection.model.tableName} as TreePath + LEFT JOIN ${this.model.tableName} as SchemaTable ON SchemaTable.uid = TreePath.descendant + LEFT JOIN ${treeCollection.model.tableName} as NodeInfo ON NodeInfo.descendant = SchemaTable.uid and NodeInfo.descendant = NodeInfo.ancestor and NodeInfo.depth = 0 + LEFT JOIN ${treeCollection.model.tableName} as ParentPath ON (ParentPath.descendant = SchemaTable.uid AND ParentPath.depth = 1) + WHERE TreePath.ancestor = :ancestor AND (NodeInfo.async = false or TreePath.depth = 1)`; + + const nodes = await db.sequelize.query(rawSql, { + replacements: { + ancestor: uid, + }, + }); + + const schema = this.nodesToSchema(nodes[0], uid); + return lodash.pick(schema, ['type', 'properties']); + } + + async getJsonSchema(uid: string, options?: GetJsonSchemaOptions) { + const db = this.database; + const treeCollection = db.getCollection('ui_schema_tree_path'); + + const treeTable = treeCollection.model.tableName; + + const rawSql = ` + SELECT SchemaTable.uid as uid, SchemaTable.name as name, SchemaTable.schema as "schema" , + TreePath.depth as depth, + NodeInfo.type as type, NodeInfo.async as async, ParentPath.ancestor as parent, ParentPath.sort as sort + FROM ${treeTable} as TreePath + LEFT JOIN ${this.model.tableName} as SchemaTable ON SchemaTable.uid = TreePath.descendant + LEFT JOIN ${treeTable} as NodeInfo ON NodeInfo.descendant = SchemaTable.uid and NodeInfo.descendant = NodeInfo.ancestor and NodeInfo.depth = 0 + LEFT JOIN ${treeTable} as ParentPath ON (ParentPath.descendant = SchemaTable.uid AND ParentPath.depth = 1) + WHERE TreePath.ancestor = :ancestor ${options?.includeAsyncNode ? '' : 'AND (NodeInfo.async != true )'} + `; + + const nodes = await db.sequelize.query(rawSql, { + replacements: { + ancestor: uid, + }, + transaction: options?.transaction, + }); + + const schema = this.nodesToSchema(nodes[0], uid); + return schema; + } + + nodesToSchema(nodes, rootUid) { + const nodeAttributeSanitize = (node) => { + const schema = { + ...(lodash.isPlainObject(node.schema) ? node.schema : JSON.parse(node.schema)), + ...lodash.pick(node, [...nodeKeys, 'name']), + ['x-uid']: node.uid, + ['x-async']: !!node.async, + ['x-index']: node.sort, + }; + + return schema; + }; + + const buildTree = (rootNode) => { + const children = nodes.filter((node) => node.parent == rootNode.uid); + + if (children.length > 0) { + const childrenGroupByType = lodash.groupBy(children, 'type'); + + for (const childType of Object.keys(childrenGroupByType)) { + const properties = childrenGroupByType[childType] + .map((child) => buildTree(child)) + .sort((a, b) => a['x-index'] - b['x-index']) as any; + + rootNode[childType] = + childType == 'items' + ? properties.length == 1 + ? properties[0] + : properties + : properties.reduce((carry, item) => { + carry[item.name] = item; + delete item['name']; + return carry; + }, {}); + } + } + + return nodeAttributeSanitize(rootNode); + }; + + return buildTree(nodes.find((node) => node.uid == rootUid)); + } + + treeCollection() { + return this.database.getCollection('ui_schema_tree_path'); + } + + async patch(newSchema: any, options?) { + let handleTransaction = true; + let transaction; + if (options?.transaction) { + handleTransaction = false; + transaction = options.transaction; + } else { + transaction = await this.database.sequelize.transaction(); + } + + const rootUid = newSchema['x-uid']; + const oldTree = await this.getJsonSchema(rootUid); + + const traverSchemaTree = async (schema, path = []) => { + const node = schema; + const oldNode = path.length == 0 ? oldTree : lodash.get(oldTree, path); + const oldNodeUid = oldNode['x-uid']; + + await this.updateNode(oldNodeUid, node, transaction); + + const properties = node.properties; + if (lodash.isPlainObject(properties)) { + for (const name of Object.keys(properties)) { + await traverSchemaTree(properties[name], [...path, 'properties', name]); + } + } + }; + + try { + await traverSchemaTree(newSchema); + + handleTransaction && (await transaction.commit()); + } catch (err) { + handleTransaction && (await transaction.rollback()); + throw err; + } + } + + async updateNode(uid: string, schema: any, transaction?: Transaction) { + const nodeModel = await this.findOne({ + filter: { + uid, + }, + }); + + await nodeModel.update( + { + schema: { + ...(nodeModel.get('schema') as any), + ...lodash.omit(schema, ['x-async', 'name', 'x-uid', 'properties']), + }, + }, + { + hooks: false, + transaction, + }, + ); + } + + async remove(uid: string, options?) { + let handleTransaction: boolean = true; + let transaction; + + if (options?.transaction) { + transaction = options.transaction; + handleTransaction = false; + } else { + transaction = await this.database.sequelize.transaction(); + } + + const treePathTable = this.treeCollection().model.tableName; + + try { + await this.database.sequelize.query( + `DELETE FROM ${this.model.tableName} WHERE uid IN ( + SELECT descendant FROM ${treePathTable} WHERE ancestor = :uid + ) + `, + { + replacements: { + uid, + }, + transaction, + }, + ); + await this.database.sequelize.query( + ` + DELETE FROM ${treePathTable} + WHERE descendant IN ( + select descendant FROM + (SELECT descendant + FROM ${treePathTable} + WHERE ancestor = :uid)as descendantTable) `, + { + replacements: { + uid, + }, + transaction, + }, + ); + + if (handleTransaction) { + await transaction.commit(); + } + } catch (err) { + if (handleTransaction) { + await transaction.rollback(); + } + throw err; + } + } + + async insertBeside(targetUid: string, schema: any, side: 'before' | 'after') { + const targetParent = await this.treeCollection().repository.findOne({ + filter: { + descendant: targetUid, + depth: 1, + }, + }); + + const db = this.database; + const treeTable = this.treeCollection().model.tableName; + const typeQuery = await db.sequelize.query(`SELECT type from ${treeTable} WHERE ancestor = :uid AND depth = 0;`, { + type: 'SELECT', + replacements: { + uid: targetUid, + }, + }); + + const nodes = UiSchemaRepository.schemaToSingleNodes(schema); + + const rootNode = nodes[0]; + + rootNode.childOptions = { + parentUid: targetParent.get('ancestor') as string, + type: typeQuery[0]['type'], + position: { + type: side, + target: targetUid, + }, + }; + + const insertedNodes = await this.insertNodes(nodes); + return await this.getJsonSchema(insertedNodes[0].get('uid')); + } + + async insertInner(targetUid: string, schema: any, position: 'first' | 'last') { + const nodes = UiSchemaRepository.schemaToSingleNodes(schema); + const rootNode = nodes[0]; + rootNode.childOptions = { + parentUid: targetUid, + type: lodash.get(schema, 'x-node-type', 'properties'), + position, + }; + + const insertedNodes = await this.insertNodes(nodes); + return await this.getJsonSchema(insertedNodes[0].get('uid')); + } + + async insertAdjacent(position: 'beforeBegin' | 'afterBegin' | 'beforeEnd' | 'afterEnd', target: string, schema: any) { + return await this[`insert${lodash.upperFirst(position)}`](target, schema); + } + + async insertAfterBegin(targetUid: string, schema: any) { + return await this.insertInner(targetUid, schema, 'first'); + } + + async insertBeforeEnd(targetUid: string, schema: any) { + return await this.insertInner(targetUid, schema, 'last'); + } + + async insertBeforeBegin(targetUid: string, schema: any) { + return await this.insertBeside(targetUid, schema, 'before'); + } + + async insertAfterEnd(targetUid: string, schema: any) { + return await this.insertBeside(targetUid, schema, 'after'); + } + + async insertNodes(nodes: SchemaNode[], options?) { + let handleTransaction: boolean = true; + let transaction; + + if (options?.transaction) { + transaction = options.transaction; + handleTransaction = false; + } else { + transaction = await this.database.sequelize.transaction(); + } + + const insertedNodes = []; + + try { + for (const node of nodes) { + insertedNodes.push(await this.insertSingleNode(node, transaction)); + } + + if (handleTransaction) { + await transaction.commit(); + } + return insertedNodes; + } catch (err) { + if (handleTransaction) { + await transaction.rollback(); + } + throw err; + } + } + + async insert(schema: any, options?) { + const nodes = UiSchemaRepository.schemaToSingleNodes(schema); + const insertedNodes = await this.insertNodes(nodes, options); + return this.getJsonSchema(insertedNodes[0].get('uid'), { + transaction: options?.transaction, + }); + } + + async insertSingleNode(schema: SchemaNode, transaction: Transaction) { + const db = this.database; + const treeCollection = db.getCollection('ui_schema_tree_path'); + + const uid = schema['x-uid']; + const name = schema['name']; + const async = lodash.get(schema, 'x-async', false); + const childOptions = schema['childOptions']; + + delete schema['x-uid']; + delete schema['x-async']; + delete schema['name']; + delete schema['childOptions']; + + let savedNode; + + // check node exists or not + const existsNode = await this.findOne({ + filter: { + uid, + }, + transaction, + }); + + const treeTable = treeCollection.model.tableName; + + if (existsNode) { + savedNode = existsNode; + } else { + savedNode = await this.create({ + values: { + name, + uid, + schema, + }, + transaction, + hooks: false, + }); + } + + if (childOptions) { + const parentUid = childOptions.parentUid; + + const isTreeQuery = await db.sequelize.query( + `SELECT COUNT(*) as childrenCount from ${treeTable} WHERE ancestor = :ancestor AND descendant != ancestor`, + { + type: 'SELECT', + replacements: { + ancestor: uid, + }, + transaction, + }, + ); + + const isTree = isTreeQuery[0]['childrenCount']; + + // if node is a tree root move tree to new path + if (isTree) { + await db.sequelize.query( + `DELETE FROM ${treeTable} + WHERE descendant IN (SELECT descendant FROM (SELECT descendant FROM ${treeTable} WHERE ancestor = :uid) as descendantTable ) + AND ancestor IN (SELECT ancestor FROM (SELECT ancestor FROM ${treeTable} WHERE descendant = :uid AND ancestor != descendant) as ancestorTable) + `, + { + type: 'DELETE', + replacements: { + uid, + }, + transaction, + }, + ); + + await db.sequelize.query( + `INSERT INTO ${treeTable} (ancestor, descendant, depth) + SELECT supertree.ancestor, subtree.descendant, supertree.depth + subtree.depth + 1 + FROM ${treeTable} AS supertree + CROSS JOIN ${treeTable} AS subtree + WHERE supertree.descendant = :parentUid + AND subtree.ancestor = :uid;`, + { + type: 'INSERT', + replacements: { + uid, + parentUid, + }, + transaction, + }, + ); + } + + if (!isTree) { + if (existsNode) { + // remove old path + await db.sequelize.query(`DELETE FROM ${treeTable} WHERE descendant = :uid AND ancestor != descendant`, { + type: 'DELETE', + replacements: { + uid, + }, + transaction, + }); + } + + // insert tree path + await db.sequelize.query( + `INSERT INTO ${treeTable} (ancestor, descendant, depth) + SELECT t.ancestor, :modelKey, depth + 1 FROM ${treeTable} AS t WHERE t.descendant = :modelParentKey `, + { + type: 'INSERT', + transaction, + replacements: { + modelKey: savedNode.get('uid'), + modelParentKey: parentUid, + }, + }, + ); + } + + if (!existsNode) { + // insert type && async + await db.sequelize.query( + `INSERT INTO ${treeTable}(ancestor, descendant, depth, type, async) VALUES (:modelKey, :modelKey, 0, :type, :async )`, + { + type: 'INSERT', + replacements: { + modelKey: savedNode.get('uid'), + type: childOptions.type, + async, + }, + transaction, + }, + ); + } + + const nodePosition = childOptions.position || 'last'; + + let sort; + + // insert at first + if (nodePosition === 'first') { + sort = 1; + // move all child last index + await db.sequelize.query( + `UPDATE ${treeTable} as TreeTable + SET sort = TreeTable.sort + 1 + FROM ${treeTable} as NodeInfo + WHERE NodeInfo.descendant = TreeTable.descendant and NodeInfo.depth = 0 + AND TreeTable.depth = 1 AND TreeTable.ancestor = :ancestor and NodeInfo.type = :type`, + { + replacements: { + ancestor: childOptions.parentUid, + type: childOptions.type, + }, + transaction, + }, + ); + } + + if (nodePosition === 'last') { + const maxSort = await db.sequelize.query( + `SELECT ${ + this.database.sequelize.getDialect() === 'postgres' ? 'coalesce' : 'ifnull' + }(MAX(TreeTable.sort), 0) as maxsort FROM ${treeTable} as TreeTable + LEFT JOIN ${treeTable} as NodeInfo + ON NodeInfo.descendant = TreeTable.descendant and NodeInfo.depth = 0 + WHERE TreeTable.depth = 1 AND TreeTable.ancestor = :ancestor and NodeInfo.type = :type`, + { + type: 'SELECT', + replacements: { + ancestor: childOptions.parentUid, + type: childOptions.type, + }, + transaction, + }, + ); + + sort = parseInt(maxSort[0]['maxsort']) + 1; + } + + if (lodash.isPlainObject(nodePosition)) { + const targetPosition = nodePosition as TargetPosition; + const target = targetPosition.target; + + const targetSort = await db.sequelize.query( + `SELECT TreeTable.sort as sort FROM ${treeTable} as TreeTable + LEFT JOIN ${treeTable} as NodeInfo + ON NodeInfo.descendant = TreeTable.descendant and NodeInfo.depth = 0 WHERE TreeTable.depth = 1 AND TreeTable.ancestor = :ancestor AND TreeTable.descendant = :descendant and NodeInfo.type = :type`, + { + type: 'SELECT', + replacements: { + ancestor: childOptions.parentUid, + descendant: target, + type: childOptions.type, + }, + transaction, + }, + ); + + sort = targetSort[0].sort; + + if (targetPosition.type == 'after') { + sort += 1; + } + + await db.sequelize.query( + `UPDATE ${treeTable} as TreeTable + SET sort = TreeTable.sort + 1 + FROM ${treeTable} as NodeInfo + WHERE NodeInfo.descendant = TreeTable.descendant and NodeInfo.depth = 0 + AND TreeTable.depth = 1 AND TreeTable.ancestor = :ancestor and TreeTable.sort >= :sort and NodeInfo.type = :type`, + { + replacements: { + ancestor: childOptions.parentUid, + sort, + type: childOptions.type, + }, + transaction, + }, + ); + } + + // update order + const updateSql = `UPDATE ${treeTable} SET sort = :sort WHERE depth = 1 AND ancestor = :ancestor AND descendant = :descendant`; + await db.sequelize.query(updateSql, { + type: 'UPDATE', + replacements: { + ancestor: childOptions.parentUid, + sort, + descendant: uid, + }, + transaction, + }); + } else { + // insert root node path + await db.sequelize.query( + `INSERT INTO ${treeTable}(ancestor, descendant, depth, async) VALUES (:modelKey, :modelKey, 0, :async )`, + { + type: 'INSERT', + replacements: { + modelKey: savedNode.get('uid'), + async, + }, + transaction, + }, + ); + } + + return savedNode; + } +} diff --git a/packages/plugin-ui-schema/src/server.ts b/packages/plugin-ui-schema/src/server.ts new file mode 100644 index 000000000..729ebfdc2 --- /dev/null +++ b/packages/plugin-ui-schema/src/server.ts @@ -0,0 +1,66 @@ +import { MagicAttributeModel } from '@nocobase/database'; +import { Plugin } from '@nocobase/server'; +import path from 'path'; +import { uiSchemaActions } from './actions/ui-schema-action'; +import UiSchemaRepository from './repository'; + +export default class PluginUiSchema extends Plugin { + registerRepository() { + this.app.db.registerRepositories({ + UiSchemaRepository, + }); + } + + async load() { + const db = this.app.db; + + this.app.db.registerModels({ MagicAttributeModel }); + + this.registerRepository(); + + await db.import({ + directory: path.resolve(__dirname, 'collections'), + }); + + db.on('ui_schemas.beforeCreate', (model) => { + model.set('uid', model.get('x-uid')); + }); + + db.on('ui_schemas.afterCreate', async (model, options) => { + const { transaction } = options; + const uiSchemaRepository = db.getCollection('ui_schemas').repository as UiSchemaRepository; + + await uiSchemaRepository.insert(model.toJSON(), { + transaction, + }); + }); + + db.on('ui_schemas.afterUpdate', async (model, options) => { + const { transaction } = options; + const uiSchemaRepository = db.getCollection('ui_schemas').repository as UiSchemaRepository; + + await uiSchemaRepository.patch(model.toJSON(), { + transaction, + }); + }); + + await db.getCollection('ui_schemas').sync({ + force: false, + alter: { + drop: false, + }, + }); + + await db.getCollection('ui_schema_tree_path').sync({ + force: false, + alter: { + drop: false, + }, + }); + + this.app.resourcer.define({ + name: 'ui_schemas', + actions: uiSchemaActions, + }); + } +} diff --git a/packages/plugin-ui-schema/tsconfig.build.json b/packages/plugin-ui-schema/tsconfig.build.json new file mode 100644 index 000000000..0db344533 --- /dev/null +++ b/packages/plugin-ui-schema/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "./lib", + "declaration": true + }, + "include": ["./src/**/*.ts", "./src/**/*.tsx"], + "exclude": ["./src/__tests__/*", "./esm/*", "./lib/*"] +} diff --git a/packages/plugin-ui-schema/tsconfig.json b/packages/plugin-ui-schema/tsconfig.json new file mode 100644 index 000000000..67a583a32 --- /dev/null +++ b/packages/plugin-ui-schema/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./src/**/*.ts", "./src/**/*.tsx"], + "exclude": ["./esm/*", "./lib/*"] +} diff --git a/packages/test/src/mockDatabase.ts b/packages/test/src/mockDatabase.ts index 1d66114bd..de0461092 100644 --- a/packages/test/src/mockDatabase.ts +++ b/packages/test/src/mockDatabase.ts @@ -17,6 +17,7 @@ export function getConfig(config = {}, options?: any): DatabaseOptions { { username: process.env.DB_USER, password: process.env.DB_PASSWORD, + storage: process.env.DB_STORAGE, database: process.env.DB_DATABASE, host: process.env.DB_HOST, port: process.env.DB_PORT,