feat: collection options & hooks (#21)

* feat: collection hooks

* export action middlewares

* add associated middleware

* cleanup

* add field interface options

* 调整配置参数

* 补充字段类型 options

* 继续调整配置参数

* 支持排序

* filterable & sortable & draggable

* feat: add random name for creating table (#23)

* feat: add random name for creating table

* fix: random number

* Feature: collections field (#24)

* feat: add random name for field and update table options

* fix: make field name required

* fix: this declaration

* showInXX 参数调整

* showInXX 放 component 里

* 继续调整参数

* 字段分组、pages 表配置参数等

* change date to datetime

* 选择类型字段的 options 改为 dataSource

* feat: refactor hooks initialization and add field options by interface (#25)

* feat: refactor hooks initialization and add field options by interface

* refactor: use model.set to build input values

* refactor: extend setter/getter to adapt field options

* fix: try to fix virtual field

* refactor: setter/getter of FieldModel

* 改进自定义 model 等细节

* 补充注释

* bugfix

Co-authored-by: Junyi <mytharcher@users.noreply.github.com>
This commit is contained in:
chenos 2020-12-01 20:11:39 +08:00 committed by GitHub
parent d9e6d2e614
commit b5ddd6a6ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 2088 additions and 336 deletions

View File

@ -384,7 +384,7 @@ export async function sort(ctx: Context, next: Next) {
const Model = ctx.db.getModel(resourceName);
const table = ctx.db.getTable(resourceName);
if (!table.getOptions().sortable || !values.offset) {
if (!values.offset) {
return next();
}
const [primaryField] = Model.primaryKeyAttributes;

View File

@ -1,5 +1,5 @@
import * as actions from './actions';
export * from './middleware';
export * as actions from './actions';
export * as middlewares from './middlewares';
export default actions;

View File

@ -0,0 +1,2 @@
export * from './associated';
export * from './json-reponse';

View File

@ -1,31 +1,32 @@
import { request } from 'umi';
interface ResourceProxyConstructor {
new <T, H extends object>(target: T, handler: ProxyHandler<H>): H
}
const ResourceProxy = Proxy as ResourceProxyConstructor;
interface Params {
interface ActionParams {
resourceKey?: string | number;
// resourceName?: string;
// associatedName?: string;
associatedKey?: string | number;
fields?: any;
filter?: any;
values?: any;
page?: any;
perPage?: any;
[key: string]: any;
}
interface Handler {
[name: string]: (params?: Params) => Promise<any>;
interface Resource {
get: (params?: ActionParams) => Promise<any>;
list: (params?: ActionParams) => Promise<any>;
create: (params?: ActionParams) => Promise<any>;
update: (params?: ActionParams) => Promise<any>;
destroy: (params?: ActionParams) => Promise<any>;
[name: string]: (params?: ActionParams) => Promise<any>;
}
class APIClient {
resource(name: string) {
return new ResourceProxy<object, Handler>({}, {
class ApiClient {
resource(name: string): Resource {
const proxy: any = new Proxy({}, {
get(target, method, receiver) {
return (params: Params = {}) => {
console.log(params);
return (params: ActionParams = {}) => {
const { associatedKey, resourceKey, ...restParams } = params;
let url = `/${name}`;
let options: any = {};
@ -49,9 +50,10 @@ class APIClient {
};
}
});
return proxy;
}
}
const api = new APIClient();
const api = new ApiClient();
export default api;

View File

@ -36,7 +36,7 @@ const api = Api.create({
});
api.resourcer.use(associated);
api.resourcer.registerHandlers(actions.associate);
api.resourcer.registerHandlers({...actions.common, ...actions.associate});
const data = {
title: '后台应用',
@ -158,159 +158,21 @@ const data = {
const database: Database = api.database;
await database.sync();
await database.sync({
// tables: ['collections', 'fields', 'actions', 'views', 'tabs'],
});
const Collection = database.getModel('collections');
const tables = database.getTables([]);
for (let table of tables) {
await Collection.import(table.getOptions(), { hooks: false });
}
const Page = database.getModel('pages');
const page = await Page.create(data);
await page.updateAssociations(data);
const [Collection, View, Action, Tab] = database.getModels(['collections', 'views', 'actions', 'tabs']);
const tables = database.getTables([]);
for (let table of tables) {
const options = table.getOptions();
const collection = await Collection.create(options);
// console.log(options);
const associations: any = {};
if (options.fields) {
associations['fields'] = options.fields.map((item, sort) => ({
...item,
options: item,
sort,
}))
}
if (options.tabs) {
associations['tabs'] = options.tabs.map((item, sort) => ({
...item,
options: item,
sort,
}))
}
if (options.actions) {
associations['actions'] = options.actions.map((item, sort) => ({
...item,
options: item,
sort,
}))
}
if (options.views) {
associations['views'] = options.views.map((item, sort) => ({
...item,
options: item,
sort,
}))
}
await collection.updateAssociations(associations);
}
// const actions = await Action.findAll();
// for (const action of actions) {
// const viewName = action.options.viewName;
// console.log({viewName});
// if (viewName) {
// const view = await View.findOne({
// where: {
// name: viewName,
// collection_name: action.collection_name
// },
// });
// if (view) {
// action.options.viewId = view.id;
// console.log(action.options);
// action.setDataValue('options', action.options);
// action.changed('options', true);
// await action.save();
// }
// }
// }
// const tabs = await Tab.findAll();
// for (const tab of tabs) {
// const viewName = tab.options.viewName;
// if (!viewName) {
// continue;
// }
// let view: any;
// if (tab.type === 'association') {
// view = await View.findOne({
// where: {
// name: viewName,
// collection_name: tab.options.association,
// },
// });
// } else {
// view = await View.findOne({
// where: {
// name: viewName,
// collection_name: tab.collection_name,
// },
// });
// }
// if (view) {
// tab.options.viewId = view.id;
// tab.setDataValue('options', tab.options);
// tab.changed('options', true);
// await tab.save();
// }
// }
// const views = await View.findAll();
// for (const view of views) {
// const detailsViewName = view.options.detailsViewName;
// if (detailsViewName) {
// const v = await View.findOne({
// where: {
// name: detailsViewName,
// collection_name: view.collection_name
// },
// });
// if (v) {
// view.options.detailsViewId = v.id;
// view.setDataValue('options', view.options);
// view.changed('options', true);
// await view.save();
// }
// }
// const updateViewName = view.options.updateViewName;
// if (updateViewName) {
// const v = await View.findOne({
// where: {
// name: updateViewName,
// collection_name: view.collection_name
// },
// });
// if (v) {
// view.options.updateViewId = v.id;
// view.setDataValue('options', view.options);
// view.changed('options', true);
// await view.save();
// }
// }
// console.log({detailsViewName, updateViewName});
// }
// for (let table of tables) {
// const options = table.getOptions();
// const collection = await Collection.findOne({
// where: {
// name: options.name,
// },
// });
// const tabs = await collection.getTabs() as Model[];
// const actions = await collection.getActions() as Model[];
// const views = await collection.getViews() as Model[];
// for (const tab of tabs) {
// tab.options.viewName;
// }
// }
// const collections = await Collection.findAll();
// await Promise.all(collections.map(async (collection) => {
// return await collection.modelInit();
// }));
await Page.create({
title: '登录页面',
path: '/login',

View File

@ -20,10 +20,11 @@ export const setup = () => {
timerange: TimePicker.RangePicker,
transfer: Transfer,
boolean: Switch,
checkbox: Switch,
array: ArrayCards,
cards: ArrayCards,
table: ArrayTable,
checkbox: Checkbox.Group,
checkboxes: Checkbox.Group,
date: DatePicker,
daterange: DatePicker.RangePicker,
year: DatePicker.YearPicker,

View File

@ -29,9 +29,9 @@ export function SimpleTable(props: SimpleTableProps) {
const { rowKey = 'id', fields = [], rowViewName, actions = [], paginated = true, defaultPageSize = 10 } = schema;
const { sourceKey = 'id' } = activeTab.field||{};
const drawerRef = useRef<any>();
const { data, loading, pagination, mutate } = useRequest((params = {}) => {
const { current, pageSize, ...restParams } = params;
const name = associatedName ? `${associatedName}.${resourceName}` : resourceName;
const { data, loading, pagination, mutate, refresh } = useRequest((params = {}) => {
const { current, pageSize, ...restParams } = params;
return api.resource(name).list({
associatedKey,
page: paginated ? current : 1,
@ -75,7 +75,21 @@ export function SimpleTable(props: SimpleTableProps) {
loading={loading}
columns={fields2columns(fields)}
dataSource={data?.list||(data as any)}
components={components({data, mutate})}
components={components({
data,
mutate,
rowKey,
onMoved: async ({resourceKey, offset}) => {
await api.resource(name).sort({
associatedKey,
resourceKey,
field: 'sort',
offset,
});
await refresh();
console.log({resourceKey, offset});
}
})}
onRow={(record) => ({
onClick: () => {
drawerRef.current.setVisible(true);

View File

@ -14,20 +14,30 @@ export const DragHandle = sortableHandle(() => (
<MenuOutlined style={{ cursor: 'pointer', color: '#999' }} />
));
export const components = ({data = {}, mutate}: {data: any, mutate: any}) => {
interface Props {
data: any,
mutate: any,
rowKey: any,
onMoved: any,
}
export const components = ({data = {}, rowKey, mutate, onMoved}: Props) => {
return {
body: {
wrapper: props => (
<SortableContainer
useDragHandle
helperClass="row-dragging"
onSortEnd={({ oldIndex, newIndex }) => {
onSortEnd={async ({ oldIndex, newIndex, ...restProps }) => {
if (oldIndex !== newIndex) {
const list = arrayMove([].concat(data.list), oldIndex, newIndex).filter(el => !!el);
console.log({oldIndex, newIndex, list});
mutate({
...data,
list,
});
const resourceKey = get(list, [newIndex, rowKey]);
await onMoved({resourceKey, offset: newIndex - oldIndex});
}
}}
{...props}
@ -35,7 +45,7 @@ export const components = ({data = {}, mutate}: {data: any, mutate: any}) => {
),
row: ({ className, style, ...restProps }) => {
// function findIndex base on Table rowKey props and should always be a right array index
const index = findIndex(data.list, (x: any) => x.id === restProps['data-row-key']);
const index = findIndex(data.list, (x: any) => x[rowKey] === restProps['data-row-key']);
return <SortableItem index={index} {...restProps} />;
},
},

View File

@ -42,13 +42,12 @@ export function Table(props: TableProps) {
associatedKey,
} = props;
const { fields, defaultTabName, rowKey = 'id', actions = [], paginated = true, defaultPageSize = 10 } = schema;
const name = associatedName ? `${associatedName}.${resourceName}` : resourceName;
// const { data, mutate } = useRequest(() => api.resource(name).list({
// associatedKey,
// }));
const { data, loading, pagination, mutate } = useRequest((params = {}) => {
const { current, pageSize, ...restParams } = params;
const name = associatedName ? `${associatedName}.${resourceName}` : resourceName;
const { data, loading, pagination, mutate, refresh } = useRequest((params = {}) => {
const { current, pageSize, ...restParams } = params;
return api.resource(name).list({
associatedKey,
page: paginated ? current : 1,
@ -86,7 +85,21 @@ export function Table(props: TableProps) {
rowKey={rowKey}
columns={fields2columns(fields)}
dataSource={data?.list||(data as any)}
components={components({data, mutate})}
components={components({
data,
mutate,
rowKey,
onMoved: async ({resourceKey, offset}) => {
await api.resource(name).sort({
associatedKey,
resourceKey,
field: 'sort',
offset,
});
await refresh();
console.log({resourceKey, offset});
}
})}
onRow={(data) => ({
onClick: () => {
redirectTo({

View File

@ -0,0 +1,23 @@
import { registerModels } from '../..';
import { getDatabase } from '../';
import Model from '../../model';
describe('custom model', () => {
it('custom model', async () => {
class BaseModel extends Model {};
const database = getDatabase();
registerModels({BaseModel});
database.table({
name: 'tests',
model: 'BaseModel',
fields: [
{
name: 'name',
type: 'string',
},
],
});
await database.sync();
await database.close();
});
});

View File

@ -16,6 +16,28 @@ import {
import Database from './database';
import { Model, ModelCtor } from './model';
const registeredModels = new Map<string, any>();
export function registerModel(key: string, model: any) {
registeredModels.set(key, model);
}
export function registerModels(models) {
for (const key in models) {
if (models.hasOwnProperty(key)) {
registerModel(key, models[key]);
}
}
}
// TODO: 判断如果 key 是 model 直接返回
export function getRegisteredModel(key) {
if (typeof key === 'string') {
return registeredModels.get(key);
}
return key;
}
export interface TableOptions extends Omit<ModelOptions<Model>, 'name'|'modelName'> {
/**
@ -32,7 +54,7 @@ export interface TableOptions extends Omit<ModelOptions<Model>, 'name'|'modelNam
/**
* model
*/
model?: ModelCtor<Model>;
model?: ModelCtor<Model> | string;
/**
*
@ -95,6 +117,8 @@ export class Table {
protected Model: ModelCtor<Model>;
protected defaultModel: ModelCtor<Model>;
/**
*
*/
@ -108,6 +132,7 @@ export class Table {
name,
fields = [],
indexes = [],
model,
...restOptions
} = options;
this.options = options;
@ -118,6 +143,8 @@ export class Table {
sequelize: database.sequelize,
...restOptions,
};
// 初始化的时候获取
this.defaultModel = getRegisteredModel(model);
this.modelAttributes = {};
// 在 set fields 之前 model init 的原因是因为关系字段可能需要用到 model 的相关配置
this.addIndexes(indexes, 'modelOnly');
@ -127,7 +154,7 @@ export class Table {
public modelInit(reinitialize: Reinitialize = false) {
if (reinitialize || !this.Model) {
this.Model = this.options.model || class extends Model {};
this.Model = this.defaultModel || class extends Model {};
this.Model.database = this.database;
// 关系的建立是在 model.init 之后在配置中表字段Column和关系Relation都在 fields
// 所以需要单独提炼出 associations 字段,并在 Model.init 之后执行 Model.associate
@ -250,7 +277,17 @@ export class Table {
sourceTable: this,
database: this.database,
});
// 添加字段后 table.options 中的 fields 并不会更新,这导致 table.getOptions() 拿不到最新的字段配置
// 所以在同时更新 table.options.fields 数组
const existIndex = this.options.fields.findIndex(field => field.name === name);
if (existIndex !== -1) {
this.options.fields.splice(existIndex, 1, options);
} else {
this.options.fields.push(options);
}
this.fields.set(name, field);
if (field instanceof Relation) {
// 关系字段先放到 associating 里待处理,等相关 target model 初始化之后,再通过 associate 建立关系
this.associating.set(name, field);

View File

@ -4,8 +4,11 @@
"main": "lib/index.js",
"license": "MIT",
"dependencies": {
"@nocobase/server": "^0.3.0-alpha.0",
"@nocobase/database": "^0.3.0-alpha.0",
"@nocobase/resourcer": "^0.3.0-alpha.0"
"@nocobase/resourcer": "^0.3.0-alpha.0",
"@nocobase/server": "^0.3.0-alpha.0"
},
"devDependencies": {
"@nocobase/actions": "^0.3.0-alpha.0"
}
}

View File

@ -0,0 +1,146 @@
import Database, { ModelCtor } from '@nocobase/database';
import { getDatabase } from '.';
import BaseModel from '../models/base';
describe('base model', () => {
let database: Database;
let TestModel: ModelCtor<BaseModel>;
let test: BaseModel;
beforeEach(async () => {
database = getDatabase();
database.table({
name: 'tests',
model: BaseModel,
additionalAttribute: 'options',
fields: [
{
name: 'name',
type: 'string',
},
{
type: 'json',
name: 'component',
defaultValue: {},
},
{
type: 'json',
name: 'options',
defaultValue: {},
},
],
});
await database.sync();
TestModel = database.getModel('tests') as ModelCtor<BaseModel>;
test = await TestModel.create({
name: '123',
abc: {aa: 'aa'},
'abc.bb': 'bb',
component: {
a: 'a',
},
'component.b': 'b',
options: {
bcd: 'bbb',
},
arr: [{a: 'a'}, {b: 'b'}],
});
});
afterEach(() => database.close());
it('get all attribute', async () => {
// 获取所有字段
expect(test.get()).toMatchObject({
abc: { aa: 'aa', bb: 'bb' },
bcd: 'bbb',
name: '123',
component: { a: 'a', b: 'b' },
arr: [{a: 'a'}, {b: 'b'}],
});
});
it('get options attribute', async () => {
// 直接取 options 字段
expect(test.get('options')).toEqual({
abc: {
aa: 'aa',
bb: 'bb',
},
bcd: 'bbb',
arr: [{a: 'a'}, {b: 'b'}],
});
});
it('get component attribute', async () => {
expect(test.get('component')).toEqual({ a: 'a', b: 'b' });
});
it('set component attribute with dot key', async () => {
test.set('component.c', 'c');
await test.save();
expect(test.get()).toMatchObject({
abc: { aa: 'aa', bb: 'bb' },
bcd: 'bbb',
name: '123',
component: { a: 'a', b: 'b' },
arr: [{a: 'a'}, {b: 'b'}],
});
expect(test.get('component')).toEqual({ a: 'a', b: 'b', c: 'c' });
});
it('set options attribute with dot key', async () => {
test.set('options.cccc', 'cccc');
await test.save();
expect(test.get()).toMatchObject({
abc: { aa: 'aa', bb: 'bb' },
bcd: 'bbb',
name: '123',
cccc: 'cccc',
component: { a: 'a', b: 'b' },
arr: [{a: 'a'}, {b: 'b'}],
});
});
it('set options attribute without options prefix', async () => {
test.set('dddd', 'dddd');
await test.save();
expect(test.get()).toMatchObject({
abc: { aa: 'aa', bb: 'bb' },
bcd: 'bbb',
name: '123',
dddd: 'dddd',
component: { a: 'a', b: 'b' },
arr: [{a: 'a'}, {b: 'b'}],
});
});
it('refind', async () => {
test.set('component.c', 'c');
await test.save();
// 重新查询
const test2 = await TestModel.findByPk(test.id);
expect(test2.get()).toMatchObject({
abc: { aa: 'aa', bb: 'bb' },
bcd: 'bbb',
name: '123',
component: { a: 'a', b: 'b', c: 'c' },
arr: [{a: 'a'}, {b: 'b'}],
});
expect(test2.get('component')).toEqual({ a: 'a', b: 'b', c: 'c' });
});
it('update', async () => {
await test.update({
'name123': 'xxx',
'component.d': 'd',
});
expect(test.get()).toMatchObject({
abc: { aa: 'aa', bb: 'bb' },
bcd: 'bbb',
name: '123',
name123: 'xxx',
component: { a: 'a', b: 'b', d: 'd' },
arr: [{a: 'a'}, {b: 'b'}],
});
});
});

View File

@ -0,0 +1,159 @@
import { Agent, getAgent, getApp } from '.';
import { Application } from '@nocobase/server';
import * as types from '../interfaces/types';
describe('collection hooks', () => {
let app: Application;
let agent: Agent;
beforeEach(async () => {
app = await getApp();
agent = getAgent(app);
});
afterEach(() => app.database.close());
it('create table', async () => {
const response = await agent.resource('collections').create({
values: {
name: 'tests',
title: 'tests',
},
});
const table = app.database.getTable('tests');
expect(table).toBeDefined();
});
it('create table without name', async () => {
const created = await agent.resource('collections').create({
values: {
title: 'tests',
},
});
const { name } = created.body;
const table = app.database.getTable(name);
expect(table).toBeDefined();
expect(table.getOptions().title).toBe('tests');
const list = await agent.resource('collections').list();
expect(list.body.rows.length).toBe(1);
await table.getModel().drop();
});
it('list fields', async () => {
const response = await agent.resource('collections.fields').list({
associatedKey: 'tests',
// values: {
// type: 'string',
// name: 'title',
// title: '标题',
// },
});
// console.log(response.body);
});
it('create field', async () => {
await agent.resource('collections').create({
values: {
name: 'tests',
title: 'tests',
},
});
await agent.resource('collections.fields').create({
associatedKey: 'tests',
values: {
type: 'string',
name: 'name',
options: {
type: 'string',
name: 'name',
},
},
});
const table = app.database.getTable('tests');
expect(table.getField('name')).toBeDefined();
const { body } = await agent.resource('tests').create({
values: { name: 'a' }
});
expect(body.name).toBe('a');
});
it('create field without name', async () => {
await agent.resource('collections').create({
values: {
name: 'tests',
title: 'tests',
},
});
const createdField = await agent.resource('collections.fields').create({
associatedKey: 'tests',
values: {
type: 'string',
},
});
const { name: createdFieldName } = createdField.body;
const table = app.database.getTable('tests');
expect(table.getField(createdFieldName)).toBeDefined();
const createdRow = await agent.resource('tests').create({
values: { [createdFieldName]: 'a' }
});
expect(createdRow.body[createdFieldName]).toBe('a');
});
it('create string field by interface', async () => {
await agent.resource('collections').create({
values: {
name: 'tests',
title: 'tests',
},
});
const values = {
interface: 'string',
title: '名称',
name: 'name',
required: true,
viewable: true,
sortable: true,
filterable: true,
'component.tooltip': 'test'
}
const createdField = await agent.resource('collections.fields').create({
associatedKey: 'tests',
values,
});
expect(createdField.body).toMatchObject({
...{
interface: 'string',
title: '名称',
name: 'name',
required: true,
viewable: true,
sortable: true,
filterable: true,
},
...types['string'].options,
sort: 1,
collection_name: 'tests',
});
const gotField = await agent.resource('fields').get({
resourceKey: createdField.body.id
});
expect(gotField.body).toEqual(createdField.body);
});
});

View File

@ -0,0 +1,144 @@
import qs from 'qs';
import plugin from '../server';
import supertest from 'supertest';
import bodyParser from 'koa-bodyparser';
import { Dialect } from 'sequelize';
import Database from '@nocobase/database';
import { actions, middlewares } from '@nocobase/actions';
import { Application, middleware } from '@nocobase/server';
function getTestKey() {
const { id } = require.main;
const key = id
.replace(`${process.env.PWD}/packages`, '')
.replace('.test.ts', '')
.replace(/[^\w]/g, '_')
.replace(/_+/g, '_');
return key
}
const config = {
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
host: process.env.DB_HOST,
port: Number.parseInt(process.env.DB_PORT, 10),
dialect: process.env.DB_DIALECT as Dialect,
define: {
hooks: {
beforeCreate(model, options) {
},
},
},
logging: false,
sync: {
force: true,
alter: {
drop: true,
},
},
};
export async function getApp() {
const app = new Application({
database: {
...config,
hooks: {
beforeDefine(columns, model) {
model.tableName = `${getTestKey()}_${model.tableName || model.name.plural}`;
}
},
},
resourcer: {
prefix: '/api',
},
});
app.resourcer.use(middlewares.associated);
app.resourcer.registerHandlers({...actions.associate, ...actions.common});
await app.plugins([plugin]);
await app.database.sync({
force: true,
});
// 表配置信息存到数据库里
// const tables = app.database.getTables([]);
// for (const table of tables) {
// const Collection = app.database.getModel('collections');
// await Collection.import(table.getOptions(), { hooks: false });
// }
app.use(async (ctx, next) => {
ctx.db = app.database;
await next();
});
app.use(bodyParser());
app.use(middleware({
prefix: '/api',
resourcer: app.resourcer,
database: app.database,
}));
return app;
}
interface ActionParams {
resourceKey?: string | number;
// resourceName?: string;
// associatedName?: string;
associatedKey?: string | number;
fields?: any;
filter?: any;
values?: any;
[key: string]: any;
}
interface Handler {
get: (params?: ActionParams) => Promise<supertest.Response>;
list: (params?: ActionParams) => Promise<supertest.Response>;
create: (params?: ActionParams) => Promise<supertest.Response>;
update: (params?: ActionParams) => Promise<supertest.Response>;
destroy: (params?: ActionParams) => Promise<supertest.Response>;
[name: string]: (params?: ActionParams) => Promise<supertest.Response>;
}
export interface Agent {
resource: (name: string) => Handler;
}
export function getAgent(app: Application): Agent {
const agent = supertest.agent(app.callback());
return {
resource(name: string): any {
return new Proxy({}, {
get(target, method, receiver) {
return (params: ActionParams = {}) => {
const { associatedKey, resourceKey, values = {}, ...restParams } = params;
let url = `/api/${name}`;
if (associatedKey) {
url = `/api/${name.split('.').join(`/${associatedKey}/`)}`;
}
url += `:${method as string}`;
if (resourceKey) {
url += `/${resourceKey}`;
}
console.log(url);
if (['list', 'get'].indexOf(method as string) !== -1) {
return agent.get(`${url}?${qs.stringify(restParams)}`);
} else {
return agent.post(`${url}?${qs.stringify(restParams)}`).send(values);
}
}
}
});
}
};
}
export function getDatabase() {
return new Database({
...config,
hooks: {
beforeDefine(columns, model) {
model.tableName = `${getTestKey()}_${model.tableName || model.name.plural}`;
}
}
});
};

View File

@ -3,8 +3,11 @@ import { TableOptions } from '@nocobase/database';
export default {
name: 'actions',
title: '操作配置',
draggable: true,
model: 'ActionModel',
fields: [
{
interface: 'sort',
type: 'integer',
name: 'sort',
title: '排序',
@ -12,36 +15,66 @@ export default {
type: 'sort',
className: 'drag-visible',
width: 60,
showInTable: true,
},
},
{
interface: 'string',
type: 'string',
name: 'title',
title: '名称',
component: {
type: 'string',
className: 'drag-visible',
showInForm: true,
showInTable: true,
showInDetail: true,
},
},
{
interface: 'string',
type: 'string',
name: 'name',
title: '标识',
component: {
type: 'string',
showInForm: true,
showInTable: true,
showInDetail: true,
},
},
{
interface: 'string',
type: 'string',
name: 'type',
title: '类型',
component: {
type: 'string',
className: 'drag-visible',
showInForm: true,
showInTable: true,
showInDetail: true,
},
},
{
type: 'string',
name: 'name',
title: '标识',
},
{
type: 'string',
name: 'title',
title: '名称',
},
{
type: 'json',
name: 'options',
},
{
interface: 'linkTo',
type: 'belongsTo',
name: 'collection',
title: '所属数据表',
target: 'collections',
targetKey: 'name',
component: {
type: 'drawerSelect',
},
},
{
interface: 'json',
type: 'json',
name: 'options',
title: '配置信息',
defaultValue: {},
component: {
type: 'hidden',
},
},
],
actions: [

View File

@ -1,12 +1,14 @@
import { TableOptions } from '@nocobase/database';
import CollectionModel from '../models/collection';
export default {
name: 'collections',
title: '数据表配置',
model: CollectionModel,
sortable: true,
draggable: true,
model: 'CollectionModel',
fields: [
{
interface: 'sort',
type: 'integer',
name: 'sort',
title: '排序',
@ -14,20 +16,25 @@ export default {
type: 'sort',
className: 'drag-visible',
width: 60,
showInTable: true,
},
},
{
interface: 'string',
type: 'string',
name: 'title',
title: '名称',
showInTable: true,
title: '数据表名称',
required: true,
component: {
type: 'string',
className: 'drag-visible',
showInTable: true,
showInForm: true,
showInDetail: true,
},
},
{
interface: 'string',
type: 'string',
name: 'name',
title: '标识',
@ -35,65 +42,175 @@ export default {
required: true,
component: {
type: 'string',
'x-rules': [
{
format: 'slug',
message: '只允许英文数字和下划线',
},
],
showInTable: true,
showInForm: true,
showInDetail: true,
},
},
{
type: 'string',
name: 'description',
title: '描述',
interface: 'string',
type: 'virtual',
name: 'options.icon',
title: '图标',
component: {
type: 'textarea',
type: 'string',
showInTable: true,
showInForm: true,
showInDetail: true,
},
},
{
interface: 'radio',
type: 'virtual',
name: 'options.defaultView',
title: '默认视图',
defaultValue: 'table',
dataSource: [
{label: '表格', value: 'table'},
{label: '看板', value: 'kanban', disabled: true},
{label: '日历', value: 'calendar', disabled: true},
],
component: {
type: 'radio',
showInTable: true,
showInForm: true,
showInDetail: true,
},
},
{
interface: 'radio',
type: 'virtual',
name: 'options.mode',
title: '表格模式',
defaultValue: 'default',
dataSource: [
{label: '常规模式', value: 'default'},
{label: '简易模式', value: 'simple'},
],
component: {
type: 'radio',
tooltip: `
<p><br/></p>
`,
showInForm: true,
showInDetail: true,
},
},
{
interface: 'radio',
type: 'virtual',
name: 'options.defaultPerPage',
title: '每页显示几行数据',
defaultValue: 50,
dataSource: [
{label: '20', value: 20},
{label: '50', value: 50},
{label: '100', value: 100},
],
component: {
type: 'radio',
showInForm: true,
showInDetail: true,
},
},
{
interface: 'boolean',
type: 'virtual',
name: 'options.draggable',
title: '支持拖拽数据排序',
showInForm: true,
showInDetail: true,
component: {
type: 'checkbox',
showInForm: true,
showInDetail: true,
},
},
{
interface: 'boolean',
type: 'boolean',
name: 'showInDataMenu',
title: '显示在“数据”菜单里',
component: {
type: 'checkbox',
showInTable: true,
showInForm: true,
showInDetail: true,
},
},
{
interface: 'json',
type: 'json',
name: 'options',
title: '配置信息',
defaultValue: {},
component: {
type: 'hidden',
},
},
{
interface: 'linkTo',
type: 'hasMany',
name: 'fields',
title: '字段',
sourceKey: 'name',
draggable: true,
actions: {
list: {
sort: 'sort',
},
},
component: {
type: 'drawerSelect',
},
},
{
interface: 'linkTo',
type: 'hasMany',
name: 'actions',
title: '动作',
sourceKey: 'name',
draggable: true,
actions: {
list: {
sort: 'sort',
},
},
component: {
type: 'drawerSelect',
},
},
{
interface: 'linkTo',
type: 'hasMany',
name: 'tabs',
title: '标签页',
sourceKey: 'name',
draggable: true,
actions: {
list: {
sort: 'sort',
},
},
component: {
type: 'drawerSelect',
},
},
{
interface: 'linkTo',
type: 'hasMany',
name: 'views',
title: '视图',
sourceKey: 'name',
draggable: true,
actions: {
list: {
sort: 'sort',
},
},
component: {
type: 'drawerSelect',
},
},
],
actions: [

View File

@ -1,58 +1,230 @@
import { TableOptions } from '@nocobase/database';
import { options } from '../interfaces';
export default {
name: 'fields',
title: '字段配置',
draggable: true,
model: 'FieldModel',
fields: [
{
interface: 'sort',
type: 'integer',
name: 'sort',
title: '排序',
defaultValue: 1,
component: {
type: 'sort',
className: 'drag-visible',
width: 60,
showInTable: true,
},
},
{
interface: 'string',
type: 'string',
name: 'type',
title: '类型',
name: 'title',
title: '字段名称',
component: {
type: 'string',
className: 'drag-visible',
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
interface: 'string',
type: 'string',
name: 'name',
title: '标识',
},
{
required: true,
component: {
type: 'string',
name: 'title',
title: '名称',
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
interface: 'select',
type: 'string',
name: 'interface',
title: '字段类型',
dataSource: options,
component: {
type: 'select',
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
interface: 'subTable',
type: 'virtual',
name: 'options.dataSource',
title: '可选项',
component: {
type: 'table',
// showInTable: true,
// showInDetail: true,
showInForm: true,
items: {
type: 'object',
properties: {
value: {
type: "string",
title: "值",
required: true
},
label: {
type: "string",
title: "选项",
required: true
},
},
},
},
},
{
interface: 'string',
type: 'string',
name: 'type',
title: '数据类型',
component: {
type: 'string',
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
interface: 'number',
type: 'integer',
name: 'parent_id',
title: '所属分组',
component: {
type: 'number',
},
},
{
interface: 'linkTo',
multiple: false,
type: 'belongsTo',
name: 'parent',
title: '所属分组',
target: 'fields',
foreignKey: 'parent_id',
targetKey: 'id',
component: {
type: 'drawerSelect',
},
},
{
interface: 'linkTo',
multiple: true,
type: 'hasMany',
name: 'children',
title: '子字段',
target: 'fields',
foreignKey: 'parent_id',
sourceKey: 'id',
component: {
type: 'drawerSelect',
},
},
{
interface: 'string',
type: 'virtual',
name: 'component.tooltip',
title: '提示信息',
component: {
type: 'string',
showInDetail: true,
showInForm: true,
},
},
{
interface: 'boolean',
type: 'boolean',
name: 'showInListAction',
title: '显示在表格里',
name: 'required',
title: '必填项',
component: {
type: 'checkbox',
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
type: 'boolean',
name: 'showInGetAction',
title: '显示在详情里',
interface: 'boolean',
type: 'virtual',
name: 'component.showInTable',
title: '显示在表格中',
component: {
type: 'checkbox',
tooltip: '若勾选,该字段将作为一列显示在数据表里',
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
type: 'json',
name: 'options',
interface: 'boolean',
type: 'virtual',
name: 'component.showInForm',
title: '显示在表单中',
component: {
type: 'checkbox',
tooltip: '若勾选,该字段将出现在表单中',
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
interface: 'boolean',
type: 'virtual',
name: 'component.showInDetail',
title: '显示在详情中',
component: {
type: 'checkbox',
tooltip: '若勾选,该字段将出现在详情中',
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
interface: 'linkTo',
type: 'belongsTo',
name: 'collection',
title: '所属数据表',
target: 'collections',
targetKey: 'name',
component: {
type: 'drawerSelect',
},
},
{
interface: 'json',
type: 'json',
name: 'component',
title: '前端组件',
defaultValue: {},
component: {
type: 'hidden',
},
},
{
interface: 'json',
type: 'json',
name: 'options',
title: '配置信息',
defaultValue: {},
component: {
type: 'hidden',
},
},
],
actions: [

View File

@ -3,8 +3,11 @@ import { TableOptions } from '@nocobase/database';
export default {
name: 'tabs',
title: '标签配置',
sortable: true,
model: 'TabModel',
fields: [
{
interface: 'sort',
type: 'integer',
name: 'sort',
title: '排序',
@ -12,42 +15,108 @@ export default {
type: 'sort',
className: 'drag-visible',
width: 60,
showInTable: true,
},
},
{
type: 'string',
name: 'type',
title: '类型',
component: {
type: 'string',
className: 'drag-visible',
},
},
{
type: 'string',
name: 'name',
title: '标识',
},
{
interface: 'string',
type: 'string',
name: 'title',
title: '名称',
component: {
type: 'string',
className: 'drag-visible',
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
interface: 'string',
type: 'string',
name: 'name',
title: '标识',
component: {
type: 'string',
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
interface: 'radio',
type: 'string',
name: 'type',
title: '类型',
dataSource: [
{ label: '详情数据', value: 'details' },
{ label: '相关数据', value: 'association' },
{ label: '模块组合', value: 'module' },
],
component: {
type: 'radio',
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
interface: 'string',
type: 'virtual',
name: 'options.association',
title: '相关数据表',
component: {
type: 'string',
showInDetail: true,
showInForm: true,
},
},
{
interface: 'boolean',
type: 'boolean',
name: 'default',
title: '默认标签页',
defaultValue: false,
component: {
type: 'checkbox',
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
type: 'json',
name: 'options',
interface: 'boolean',
type: 'boolean',
name: 'enabled',
title: '启动',
defaultValue: false,
component: {
type: 'checkbox',
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
interface: 'linkTo',
type: 'belongsTo',
name: 'collection',
title: '所属数据表',
target: 'collections',
targetKey: 'name',
component: {
type: 'drawerSelect',
},
},
{
interface: 'json',
type: 'json',
name: 'options',
title: '配置信息',
defaultValue: {},
component: {
type: 'hidden',
},
},
],
actions: [

View File

@ -3,8 +3,11 @@ import { TableOptions } from '@nocobase/database';
export default {
name: 'views',
title: '视图配置',
sortable: true,
model: 'ViewModel',
fields: [
{
interface: 'sort',
type: 'integer',
name: 'sort',
title: '排序',
@ -12,56 +15,126 @@ export default {
type: 'sort',
className: 'drag-visible',
width: 60,
showInTable: true,
},
},
{
interface: 'string',
type: 'string',
name: 'type',
title: '类型',
name: 'title',
title: '视图名称',
component: {
type: 'string',
className: 'drag-visible',
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
interface: 'string',
type: 'string',
name: 'name',
title: '标识',
},
{
component: {
type: 'string',
name: 'title',
title: '名称',
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
interface: 'radio',
type: 'string',
name: 'type',
title: '视图类型',
dataSource: [
{ label: '表格', value: 'table' },
{ label: '看板', value: 'kanban', disabled: true },
{ label: '日历', value: 'calendar', disabled: true },
{ label: '地图', value: 'map', disabled: true },
],
component: {
type: 'radio',
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
interface: 'string',
type: 'string',
name: 'template',
title: '模板',
component: {
type: 'string',
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
interface: 'boolean',
type: 'boolean',
name: 'default',
title: '默认视图',
defaultValue: false,
component: {
type: 'checkbox',
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
type: 'json',
name: 'options',
interface: 'boolean',
type: 'boolean',
name: 'showInDataMenu',
title: '作为数据表子菜单',
defaultValue: false,
component: {
type: 'checkbox',
showInTable: true,
showInDetail: true,
showInForm: true,
},
},
{
interface: 'linkTo',
type: 'belongsTo',
name: 'collection',
title: '所属数据表',
target: 'collections',
targetKey: 'name',
component: {
type: 'drawerSelect',
},
},
{
type: 'belongsToMany',
name: 'fields',
interface: 'json',
type: 'json',
name: 'options',
title: '配置信息',
defaultValue: {},
component: {
type: 'hidden',
},
{
type: 'belongsToMany',
name: 'actions',
},
// 以下暂不考虑
// {
// type: 'belongsToMany',
// name: 'fields',
// component: {
// type: 'drawerSelect',
// },
// },
// {
// type: 'belongsToMany',
// name: 'actions',
// component: {
// type: 'drawerSelect',
// },
// },
],
actions: [
{

View File

@ -0,0 +1,5 @@
import CollectionModel from '../models/collection';
export default async function (model: CollectionModel) {
await model.migrate();
}

View File

@ -0,0 +1,7 @@
import CollectionModel from '../models/collection';
export default async function (model: CollectionModel) {
if (!model.get('name')) {
model.setDataValue('name', this.generateName());
}
}

View File

@ -0,0 +1,6 @@
import FieldModel from '../models/field';
export default async function (model: FieldModel) {
// console.log('afterCreate', model.toJSON());
await model.migrate();
}

View File

@ -0,0 +1,28 @@
import FieldModel from '../models/field';
import * as types from '../interfaces/types';
export default async function (model: FieldModel) {
const values = model.get();
if (!values.name) {
values.name = this.generateName();
}
if (values.interface) {
const { options } = types[values.interface];
Object.keys(options).forEach(key => {
switch (typeof values[key]) {
case 'undefined':
values[key] = options[key];
break;
case 'object':
values[key] = {
...options[key],
...values[key]
};
break;
}
});
}
model.set(values, { raw: true });
}

View File

@ -0,0 +1,16 @@
import collectionsBeforeValidate from './collections-before-validate';
import collectionsAfterCreate from './collections-after-create';
import fieldsBeforeValidate from './fields-before-validate';
import fieldsAfterCreate from './fields-after-create';
export default {
collections: {
beforeValidate: collectionsBeforeValidate,
afterCreate: collectionsAfterCreate,
},
fields: {
beforeValidate: fieldsBeforeValidate,
afterCreate: fieldsAfterCreate
}
};

View File

@ -0,0 +1,76 @@
/**
* Interface 便
*/
import * as types from './types';
export * as types from './types';
export const options = [
{
title: '基本类型',
children: [
types.string,
types.textarea,
types.phone,
types.email,
types.number,
types.percent,
],
},
{
title: '多媒体类型',
children: [
types.wysiwyg,
types.attachment,
],
},
{
title: '选择类型',
children: [
types.boolean,
types.select,
types.multipleSelect,
types.radio,
types.checkboxes,
],
},
{
title: '日期和时间',
children: [
types.datetime,
types.time,
],
},
{
title: '关系类型',
children: [
types.subTable,
types.linkTo,
],
},
{
title: '系统信息',
children: [
types.createdAt,
types.createdBy,
types.updatedAt,
types.updatedBy,
],
},
{
title: '开发者模式',
children: [
types.primaryKey,
types.sort,
types.password,
types.json,
],
}
].map(({title, children}) => ({
label: title,
children: children.map(child => ({
label: child.title,
value: child.options.interface,
})),
}));
export default options;

View File

@ -0,0 +1,460 @@
// mergeinterface 模板,旧数据,用户数据
// TODO: 删除的情况怎么处理
// 联动的原则:尽量减少干预,尤其是尽量少改动 typetype 兼容
// 参数的优先级:
// 1、interfacetype 尽量只随 interface 变动,而不受别的字段影响(特殊情况除外)
// 2、
// TODO: interface 的修改
export const string = {
title: '单行文本',
options: {
interface: 'string',
type: 'string',
component: {
type: 'string',
},
},
};
export const textarea = {
title: '多行文本',
options: {
interface: 'textarea',
type: 'text',
filterable: true,
component: {
type: 'textarea',
},
}
};
export const phone = {
title: '手机号码',
options: {
interface: 'phone',
type: 'string',
filterable: true,
format: 'phone', // 验证的问题
component: {
type: 'string',
},
},
};
export const email = {
title: '邮箱',
options: {
interface: 'email',
type: 'string',
filterable: true,
format: 'email',
component: {
type: 'string',
},
},
};
/**
* precision
*/
export const number = {
title: '数字',
options: {
interface: 'number',
type: 'integer',
filterable: true,
sortable: true,
precision: 0, // 需要考虑
component: {
type: 'number',
},
}
};
/**
* precision
*
*/
export const percent = {
title: '百分比',
options: {
interface: 'percent',
type: 'integer',
filterable: true,
sortable: true,
precision: 0,
component: {
type: 'number',
suffix: '%',
},
},
};
export const wysiwyg = {
title: '可视化编辑器',
options: {
interface: 'wysiwyg',
type: 'text',
component: {
type: 'wysiwyg',
},
},
};
/**
*
*/
export const attachment = {
title: '附件',
options: {
interface: 'attachment',
type: 'belongsToMany',
filterable: true,
target: 'attachments',
component: {
type: 'fileManager',
},
},
};
/**
*
*/
export const select = {
title: '下拉选择(单选)',
options: {
interface: 'select',
type: 'string',
filterable: true,
dataSource: [],
component: {
type: 'select',
},
},
};
/**
* type
* json
* type=array
* array
* filter
* json hasMany
*
* 🤔 select合并成一个 interfacemultiple type
*/
export const multipleSelect = {
title: '下拉选择(多选)',
options: {
interface: 'multipleSelect',
type: 'json', // json 过滤
filterable: true,
dataSource: [],
multiple: true, // 需要重点考虑
component: {
type: 'select',
},
},
};
export const radio = {
title: '单选框',
options: {
interface: 'radio',
type: 'string',
filterable: true,
dataSource: [],
component: {
type: 'radio',
},
},
};
export const checkboxes = {
title: '多选框',
options: {
interface: 'checkboxes',
type: 'json',
filterable: true,
dataSource: [],
component: {
type: 'checkboxes',
},
},
};
export const boolean = {
title: '是/否',
options: {
interface: 'boolean',
type: 'boolean',
filterable: true,
component: {
type: 'checkbox', // switch
},
},
};
/**
* dateonly type
* dateonly
*/
export const datetime = {
title: '日期',
options: {
interface: 'datetime',
type: 'date',
dateonly: false, // dateonly
filterable: true,
sortable: true,
format: 'YYYY-MM-DD HH:mm:ss',
component: {
type: 'date',
},
},
};
export const time = {
title: '时间',
options: {
interface: 'time',
type: 'time',
filterable: true,
sortable: true,
format: 'HH:mm:ss',
component: {
type: 'time',
},
},
};
/**
*
*
* hasMany
* fields
*
* - virtual
* - hasMany
* - targetsource
*/
// database.table({
// name: 'tablename',
// fields: [
// {
// type: 'hasMany',
// name: 'foos',
// target: 'foos',
// fields: [
// {
// type: 'string',
// name: 'xxx',
// }
// ],
// }
// ],
// });
// database.table({
// name: 'foos',
// fields: [
// {
// type: 'string',
// name: 'xxx',
// }
// ],
// });
export const subTable = {
title: '子表格',
options: {
interface: 'subTable',
type: 'hasMany',
// fields: [],
component: {
type: 'subTable',
},
},
};
/**
* multiple
*
*
* name target addField target
* name targetname
* name target
*/
// database.table({
// name: 'foos',
// fields: [
// {
// type: 'hasMany',
// name: 'bars',
// // target: 'bars',
// // sourceKey: 'id',
// // foreignKey: 'foo_id',
// },
// {
// type: 'hasMany',
// name: 'xxxxx', // 如果没有随机生成
// target: 'bars',
// // sourceKey: 'id',
// // foreignKey: 'foo_id',
// },
// {
// type: 'hasMany',
// name: 'xxxxx', // 如果没有随机生成
// target: 'bars',
// sourceKey: 'id',
// foreignKey: 'foo_id',
// }
// ],
// });
// const field = table.addField({
// type: 'hasMany',
// name: 'xxx', // xxx
// });
export const linkTo = {
title: '关联数据',
options: {
interface: 'linkTo',
multiple: true, // 可能影响 type
type: 'belongsToMany',
// name,
// target: '关联表', // 用户会输入
filterable: true,
component: {
type: 'drawerSelect',
},
},
};
export const createdBy = {
title: '创建者',
options: {
interface: 'createdBy',
type: 'belongsTo',
filterable: true,
component: {
type: 'drawerSelect',
},
},
};
export const createdAt = {
title: '创建时间',
options: {
interface: 'createdAt',
type: 'date',
required: true,
filterable: true,
sortable: true,
component: {
type: 'date',
},
},
};
export const updatedBy = {
title: '更新人',
options: {
interface: 'updatedBy',
type: 'belongsTo',
filterable: true,
component: {
type: 'drawerSelect',
},
},
};
export const updatedAt = {
title: '更新日期',
options: {
interface: 'updatedAt',
type: 'date',
required: true,
filterable: true,
sortable: true,
component: {
type: 'date',
},
},
};
/**
*
*
*
*/
export const group = {
title: '字段组',
options: {
interface: 'group',
// name: 'id',
type: 'virtual',
component: {
type: 'hidden',
},
},
};
/**
*
*/
export const primaryKey = {
title: '主键',
options: {
interface: 'primaryKey',
name: 'id',
type: 'integer',
required: true,
autoIncrement: true,
primaryKey: true,
filterable: true,
component: {
type: 'number',
},
},
};
/**
*
* scope
*/
export const sort = {
title: '排序',
options: {
interface: 'sort',
type: 'integer',
required: true,
component: {
type: 'sort',
showInTable: true,
},
},
};
export const password = {
title: '密码',
options: {
interface: 'password',
type: 'password',
hidden: true, // hidden 用来控制 api 不输出这个字段,但是可能这个字段显示在表单里 showInForm
component: {
type: 'password',
},
},
};
export const json = {
title: 'JSON',
options: {
interface: 'json',
type: 'json',
component: {
type: 'hidden',
},
},
};

View File

@ -0,0 +1,6 @@
import _ from 'lodash';
import BaseModel from './base';
export class ActionModel extends BaseModel {
}

View File

@ -0,0 +1,102 @@
import _ from 'lodash';
import { Model } from '@nocobase/database';
export class BaseModel extends Model {
get additionalAttribute() {
const tableOptions = this.database.getTable(this.constructor.name).getOptions();
return _.get(tableOptions, 'additionalAttribute') || 'options';
}
get(key?: any, options?: any) {
if (typeof key === 'string') {
const [column, ...path] = key.split('.');
const attribute = this.rawAttributes[column];
if (attribute) {
const value = super.get(column, options);
if (path.length) {
return _.get(value, path);
}
return value;
}
return _.get(super.get(this.additionalAttribute, options) || {}, key);
}
const data = super.get();
return {
...(data[this.additionalAttribute]||{}),
..._.omit(data, [this.additionalAttribute]),
};
}
getDataValue(key: any) {
const [column, ...path] = key.split('.');
const attribute = this.rawAttributes[column];
if (attribute) {
const value = super.getDataValue(column);
if (path.length) {
return _.get(value, path);
}
return value;
}
const options = super.getDataValue(this.additionalAttribute) || {};
return _.get(options, key);
}
set(key?: any, value?: any, options?: any) {
if (typeof key === 'string') {
// 不处理关系数据
// @ts-ignore
if (_.get(this.constructor.associations, key)) {
return this;
}
// 如果是 object 数据merge 处理
if (_.isPlainObject(value)) {
value = _.merge(this.get(key)||{}, value);
}
const [column, ...path] = key.split('.');
this.changed(column, true);
const attribute = this.rawAttributes[column];
if (attribute) {
if (!path.length) {
return super.set(key, value, options);
}
const values = this.get(column, options) || {};
_.set(values, path, value);
return super.set(column, values, options);
}
// 如果未设置 attribute存到 additionalAttribute 里
const opts = this.get(this.additionalAttribute, options) || {};
_.set(opts, key, value);
this.changed(this.additionalAttribute, true);
return super.set(this.additionalAttribute, opts, options);
}
return super.set(key, value, options);
}
setDataValue(key: any, value: any) {
// 不处理关系数据
// @ts-ignore
if (_.get(this.constructor.associations, key)) {
return this;
}
if (_.isPlainObject(value)) {
value = _.merge(this.get(key)||{}, value);
}
const [column, ...path] = key.split('.');
this.changed(column, true);
const attribute = this.rawAttributes[column];
if (attribute) {
if (!path.length) {
return super.setDataValue(key, value);
}
const values = this.get(column) || {};
_.set(values, path, value);
return super.setDataValue(column, values);
}
const opts = this.get(this.additionalAttribute) || {};
_.set(opts, key, value);
this.changed(this.additionalAttribute, true);
return super.setDataValue(this.additionalAttribute, opts);
}
}
export default BaseModel;

View File

@ -1,22 +1,92 @@
import { Model } from '@nocobase/database';
import _ from 'lodash';
import BaseModel from './base';
import { TableOptions } from '@nocobase/database';
import { SaveOptions, Utils } from 'sequelize';
export class CollectionModel extends Model {
async modelInit() {
if (['collections', 'fields'].includes(this.get('name'))) {
return;
export class CollectionModel extends BaseModel {
/**
* name collection
*
* @param name
*/
static async findByName(name: string) {
return this.findOne({ where: { name } });
}
/**
*
*
* 使 3+2
* 1. id
* 2.
* 3.
* 4.
* 5.
*
* @param title
*/
static generateName(title?: string): string {
return `t_${Date.now().toString(36)}_${Math.random().toString(36).replace('0.', '').slice(-4).padStart(4, '0')}`;
}
/**
*
*/
async migrate() {
const options = await this.getOptions();
const prevTable = this.database.getTable(this.get('name'));
const prevOptions = prevTable ? prevTable.getOptions() : {};
// table 是初始化和重新初始化
const table = this.database.table({...prevOptions, ...options});
return await table.sync({
force: false,
alter: {
drop: false,
}
const Field = this.database.getModel('fields');
const fields = await Field.findAll();
this.database.table({
name: this.get('name'),
fields: fields.map(field => {
return {
name: field.get('name'),
type: field.get('type'),
};
}),
});
console.log('modelInit', this.get('name'));
}
async getFieldsOptions() {
const fieldsOptions = [];
const fields = await this.getFields();
for (const field of fields) {
fieldsOptions.push(await field.getOptions());
}
return fieldsOptions;
}
async getOptions(): Promise<TableOptions> {
return {
...this.get('options'),
name: this.get('name'),
title: this.get('title'),
fields: await this.getFieldsOptions(),
};
}
static async import(data: TableOptions, options: SaveOptions = {}): Promise<CollectionModel> {
data = _.cloneDeep(data);
const collection = await this.create({
...data,
}, options);
const items: any = {};
const associations = ['fields', 'tabs', 'actions', 'views'];
for (const key of associations) {
if (!Array.isArray(data[key])) {
continue;
}
items[key] = data[key].map((item, sort) => ({
...item,
sort,
}));
for (const item of items[key]) {
await collection[`create${_.upperFirst(Utils.singularize(key))}`](item);
}
}
// updateAssociations 有 BUG
// await collection.updateAssociations(items, options);
return collection;
}
}

View File

@ -1,7 +1,30 @@
import { Model } from '@nocobase/database';
import _ from 'lodash';
import BaseModel from './base';
import { FieldOptions } from '@nocobase/database';
export class FieldModel extends Model {
export class FieldModel extends BaseModel {
static generateName(title?: string): string {
return `f_${Math.random().toString(36).replace('0.', '').slice(-4).padStart(4, '0')}`;
}
async getOptions(): Promise<FieldOptions> {
return {
...this.get('options'),
type: this.get('type'),
name: this.get('name'),
};
}
async migrate() {
const table = this.database.getTable(this.get('collection_name'));
table.addField(await this.getOptions());
await table.sync({
force: false,
alter: {
drop: false,
}
});
}
}
export default FieldModel;

View File

@ -0,0 +1,5 @@
export * from './action';
export * from './collection';
export * from './field';
export * from './tab';
export * from './view';

View File

@ -0,0 +1,6 @@
import _ from 'lodash';
import BaseModel from './base';
export class TabModel extends BaseModel {
}

View File

@ -0,0 +1,6 @@
import _ from 'lodash';
import BaseModel from './base';
export class ViewModel extends BaseModel {
}

View File

@ -1,12 +1,28 @@
import path from 'path';
import Database, { ModelCtor } from '@nocobase/database';
import Resourcer from '@nocobase/resourcer';
import { Application } from '@nocobase/server';
import hooks from './hooks';
import { registerModels } from '@nocobase/database';
import * as models from './models';
export default async function (this: any, options = {}) {
const database: Database = this.database;
const resourcer: Resourcer = this.resourcer;
export default async function (this: Application, options = {}) {
const database = this.database;
const resourcer = this.resourcer;
// 提供全局的 models 注册机制
registerModels(models);
database.import({
directory: path.resolve(__dirname, 'collections'),
});
Object.keys(hooks).forEach(modelName => {
const Model = database.getModel(modelName);
Object.keys(hooks[modelName]).forEach(hookKey => {
// TODO(types): 多层 map 映射类型定义较为复杂,暂时忽略
// @ts-ignore
Model.addHook(hookKey, hooks[modelName][hookKey]);
});
});
// 加载数据库表 collections 中已经保存的表配置
// await Collection.findAll();
}

View File

@ -6,6 +6,9 @@ const transforms = {
table: async (fields: Model[]) => {
const arr = [];
for (const field of fields) {
if (!get(field.component, 'showInTable')) {
continue;
}
arr.push({
...field.toJSON(),
...field.options,
@ -17,9 +20,27 @@ const transforms = {
form: async (fields: Model[]) => {
const schema = {};
for (const field of fields) {
schema[field.name] = {
type: 'string',
if (!get(field.component, 'showInForm')) {
continue;
}
const type = get(field.component, 'type', 'string');
const prop: any = {
type,
title: field.title||field.name,
...(field.component||{}),
}
if (type === 'select') {
prop.type = 'string'
}
const defaultValue = get(field.options, 'defaultValue');
if (defaultValue) {
prop.default = defaultValue;
}
if (['radio', 'select', 'checkboxes'].includes(type)) {
prop.enum = get(field.options, 'dataSource', []);
}
schema[field.name] = {
...prop,
};
}
return schema;
@ -27,6 +48,9 @@ const transforms = {
details: async (fields: Model[]) => {
const arr = [];
for (const field of fields) {
if (!get(field.component, 'showInDetail')) {
continue;
}
arr.push({
...field.toJSON(),
...field.options,

View File

@ -5,6 +5,7 @@ export default {
title: '页面配置',
fields: [
{
interface: 'sort',
type: 'integer',
name: 'sort',
title: '排序',
@ -12,53 +13,64 @@ export default {
type: 'sort',
className: 'drag-visible',
width: 60,
showInTable: true,
},
},
{
interface: 'string',
type: 'string',
name: 'title',
title: '名称',
showInTable: true,
isMainTitle: true,
component: {
type: 'string',
className: 'drag-visible',
showInTable: true,
showInForm: true,
showInDetail: true,
},
},
{
interface: 'number',
type: 'integer',
name: 'parent_id',
title: '父级页面',
component: {
type: 'number',
showInForm: true,
showInDetail: true,
},
},
{
interface: 'string',
type: 'string',
name: 'path',
title: '路径',
unique: true,
showInTable: true,
component: {
type: 'string',
showInTable: true,
showInForm: true,
showInDetail: true,
},
},
{
interface: 'string',
type: 'string',
name: 'icon',
title: '图标',
component: {
type: 'string',
showInTable: true,
showInForm: true,
showInDetail: true,
},
},
{
interface: 'select',
type: 'string',
name: 'type',
title: '类型',
showInTable: true,
component: {
type: 'string',
enum: [
options: [
{
label: '页面',
value: 'page',
@ -72,6 +84,11 @@ export default {
value: 'collection',
},
],
component: {
type: 'string',
showInTable: true,
showInForm: true,
showInDetail: true,
'x-linkages': [
{
"type": "value:visible",
@ -82,85 +99,84 @@ export default {
},
},
{
interface: 'select',
type: 'string',
name: 'collection',
title: '属于哪种数据集?',
component: {
type: 'string',
type: 'select',
showInForm: true,
showInDetail: true,
},
},
{
interface: 'select',
type: 'string',
name: 'template',
title: '模板',
showInTable: true,
component: {
type: 'string',
enum: [
options: [
{
label: '顶部菜单布局',
value: 'LayoutWithTopMenu',
value: 'TopMenuLayout',
},
{
label: '左侧菜单布局',
value: 'LayoutWithSideMenu',
},
{
label: '数据集(全部)',
value: 'collections',
},
{
label: '数据集(某种)',
value: 'collection',
},
{
label: '登录',
value: 'login',
},
{
label: '注册',
value: 'register',
},
{
label: '分析页',
value: 'analysis',
},
{
label: '工作区',
value: 'workplace',
value: 'SideMenuLayout',
},
],
component: {
type: 'select',
showInTable: true,
showInForm: true,
showInDetail: true,
},
},
{
interface: 'boolean',
type: 'boolean',
name: 'showInMenu',
title: '在菜单里显示',
// showInTable: true,
defaultValue: false,
component: {
type: 'boolean',
type: 'checkbox',
showInTable: true,
showInForm: true,
showInDetail: true,
},
},
{
interface: 'boolean',
type: 'boolean',
name: 'inherit',
title: '继承父级页面内容',
defaultValue: true,
component: {
type: 'boolean',
type: 'checkbox',
showInTable: true,
showInForm: true,
showInDetail: true,
},
},
{
interface: 'linkTo',
type: 'hasMany',
name: 'children',
title: '子页面',
target: 'pages',
foreignKey: 'parent_id',
sourceKey: 'id',
component: {
type: 'drawerSelect',
},
},
{
interface: 'json',
type: 'json',
name: 'options',
title: '元数据',
component: {
type: 'hidden',
},
},
],
actions: [