Feat/plugin UI schema v0.6 (#143)

* v0.6

* plugin-ui-schema: insert && getJsonSchema

* plugin-ui-schema: insert schema with sort

* plugin-ui-schema: node with x-index

* insert adjacent method

* chore: insert

* typo

* insert with x-uid

* fix: getSchema by subtree

* add ui-schema actions

* fix: mysql compatibility

* remove ui-schema when remove node tree

* ui schema patch

* ui_schemas.create

* test cases

* test cases

* fix(database): reset changed before update

* feat: insert ui schema node after created

* feat:  patch ui schema node after updated

* fix: sqlite error

* uid

* cleanup

* test cases

* feat: ui_schema items type support

* fix: insert items node

* fix: get inner type

* change items struct

* add insert return value

* add insert return value

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
ChengLei Shao 2022-01-19 10:09:30 +08:00 committed by GitHub
parent 380b5e8c7a
commit f67658129f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 2391 additions and 18 deletions

View File

@ -29,6 +29,8 @@ describe('magic-attribute-model', () => {
test.set('x-component-props', { arr2: [1, 2, 3] }); test.set('x-component-props', { arr2: [1, 2, 3] });
await test.save();
expect(test.toJSON()).toMatchObject({ expect(test.toJSON()).toMatchObject({
title: 'aa', title: 'aa',
'x-component-props': { 'x-component-props': {
@ -39,6 +41,8 @@ describe('magic-attribute-model', () => {
}, },
'x-decorator-props': { key1: 'val1' }, 'x-decorator-props': { key1: 'val1' },
}); });
await db.close();
}); });
it('case 2', async () => { it('case 2', async () => {
@ -57,7 +61,7 @@ describe('magic-attribute-model', () => {
await db.sync(); await db.sync();
const test = await Test.model.create({ let test = await Test.model.create({
title: 'aa', title: 'aa',
'x-component-props': { key1: 'val1', arr1: [1, 2, 3], arr2: [4, 5] }, '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] }); 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({ expect(test.toJSON()).toMatchObject({
title: 'aa', title: 'aa',
'x-component-props': { 'x-component-props': {
key1: 'val1', key1: 'val1',
key2: 'val2', key2: 'val2',
arr1: [3, 4], arr1: [3, 4],
arr2: [1, 2, 3], arr2: [1, 2, 3, 4],
}, },
'x-decorator-props': { key1: 'val1' }, 'x-decorator-props': { key1: 'val1' },
}); });
await db.close();
}); });
}); });

View File

@ -1,6 +1,6 @@
import { DataTypes } from 'sequelize'; import { DataTypes } from 'sequelize';
import { uid } from '../utils';
import { BaseColumnFieldOptions, Field } from './field'; import { BaseColumnFieldOptions, Field } from './field';
import { uid } from '@nocobase/utils';
export class UidField extends Field { export class UidField extends Field {
get dataType() { get dataType() {

View File

@ -1,7 +1,6 @@
export * from './database'; export * from './database';
export * from './collection'; export * from './collection';
export * from './repository'; export * from './repository';
export * from './utils';
export { Database as default } from './database'; export { Database as default } from './database';
export * from './relation-repository/belongs-to-many-repository'; export * from './relation-repository/belongs-to-many-repository';
export * from './relation-repository/belongs-to-repository'; export * from './relation-repository/belongs-to-repository';

View File

@ -5,7 +5,7 @@ import Database from './database';
export class MagicAttributeModel extends Model { export class MagicAttributeModel extends Model {
get magicAttribute() { get magicAttribute() {
const db: Database = (this.constructor as any).database; const db: Database = (<any>this.constructor).database;
const collection = db.getCollection(this.constructor.name); const collection = db.getCollection(this.constructor.name);
return collection.options.magicAttribute || 'options'; return collection.options.magicAttribute || 'options';
} }
@ -50,4 +50,10 @@ export class MagicAttributeModel extends Model {
...data[this.magicAttribute], ...data[this.magicAttribute],
}; };
} }
async update(values?: any, options?: any) {
// @ts-ignore
this._changed = new Set();
return super.update(values, options);
}
} }

View File

@ -287,7 +287,7 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
async create<M extends Model>(options: CreateOptions): Promise<M> { async create<M extends Model>(options: CreateOptions): Promise<M> {
const transaction = await this.getTransaction(options); 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 values = guard.sanitize(options.values || {});
const instance = await this.model.create<any>(values, { const instance = await this.model.create<any>(values, {

View File

@ -11,19 +11,29 @@ type UpdateValues = {
[key: string]: UpdateValueItem | Array<UpdateValueItem>; [key: string]: UpdateValueItem | Array<UpdateValueItem>;
}; };
type UpdateAction = 'create' | 'update';
export class UpdateGuard { export class UpdateGuard {
model: ModelCtor<any>; model: ModelCtor<any>;
action: UpdateAction;
private associationKeysToBeUpdate: AssociationKeysToBeUpdate; private associationKeysToBeUpdate: AssociationKeysToBeUpdate;
private blackList: BlackList; private blackList: BlackList;
private whiteList: WhiteList; private whiteList: WhiteList;
setAction(action: UpdateAction) {
this.action = action;
}
setModel(model: ModelCtor<any>) { setModel(model: ModelCtor<any>) {
this.model = model; this.model = model;
} }
setAssociationKeysToBeUpdate(associationKeysToBeUpdate: AssociationKeysToBeUpdate) { setAssociationKeysToBeUpdate(associationKeysToBeUpdate: AssociationKeysToBeUpdate) {
if (this.action == 'create') {
this.associationKeysToBeUpdate = Object.keys(this.model.associations);
} else {
this.associationKeysToBeUpdate = associationKeysToBeUpdate; this.associationKeysToBeUpdate = associationKeysToBeUpdate;
} }
}
setWhiteList(whiteList: WhiteList) { setWhiteList(whiteList: WhiteList) {
this.whiteList = whiteList; this.whiteList = whiteList;
@ -148,8 +158,8 @@ export class UpdateGuard {
guard.setModel(model); guard.setModel(model);
guard.setWhiteList(options.whitelist); guard.setWhiteList(options.whitelist);
guard.setBlackList(options.blacklist); guard.setBlackList(options.blacklist);
guard.setAction(lodash.get(options, 'action', 'update'));
guard.setAssociationKeysToBeUpdate(options.updateAssociationValues); guard.setAssociationKeysToBeUpdate(options.updateAssociationValues);
return guard; return guard;
} }
} }

View File

@ -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;
}

View File

@ -0,0 +1,7 @@
node_modules
*.log
docs
__tests__
tsconfig.json
src
.fatherrc.ts

View File

@ -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"
}

View File

@ -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');
});
});

View File

@ -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,
});
});
});

View File

@ -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');
});
});
});

View File

@ -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<string, ISchema>;
// 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',
},
},
};
});
});

View File

@ -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();
};
}

View File

@ -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',
},
],
};

View File

@ -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;

View File

@ -0,0 +1,5 @@
export class UiSchemaDAO {
nodeKeys = ['properties', 'patternProperties', 'additionalProperties'];
constructor(schema: any) {}
}

View File

@ -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;
}
}

View File

View File

@ -0,0 +1,4 @@
import PluginUiSchema from './server';
import UiSchemaRepository from './repository';
export default PluginUiSchema;
export { UiSchemaRepository };

View File

@ -0,0 +1,5 @@
import { Model } from '@nocobase/database';
export class UiSchemaModel extends Model {
}

View File

@ -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;
}
}

View File

@ -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,
});
}
}

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.build.json",
"compilerOptions": {
"outDir": "./lib",
"declaration": true
},
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
"exclude": ["./src/__tests__/*", "./esm/*", "./lib/*"]
}

View File

@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.json",
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
"exclude": ["./esm/*", "./lib/*"]
}

View File

@ -17,6 +17,7 @@ export function getConfig(config = {}, options?: any): DatabaseOptions {
{ {
username: process.env.DB_USER, username: process.env.DB_USER,
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
storage: process.env.DB_STORAGE,
database: process.env.DB_DATABASE, database: process.env.DB_DATABASE,
host: process.env.DB_HOST, host: process.env.DB_HOST,
port: process.env.DB_PORT, port: process.env.DB_PORT,