diff --git a/packages/actions/src/actions/common.ts b/packages/actions/src/actions/common.ts index 73ab611a1..80c3e1fad 100644 --- a/packages/actions/src/actions/common.ts +++ b/packages/actions/src/actions/common.ts @@ -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; diff --git a/packages/actions/src/index.ts b/packages/actions/src/index.ts index 47d2550ee..2855d163c 100644 --- a/packages/actions/src/index.ts +++ b/packages/actions/src/index.ts @@ -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; diff --git a/packages/actions/src/middlewares/index.ts b/packages/actions/src/middlewares/index.ts new file mode 100644 index 000000000..782137c25 --- /dev/null +++ b/packages/actions/src/middlewares/index.ts @@ -0,0 +1,2 @@ +export * from './associated'; +export * from './json-reponse'; \ No newline at end of file diff --git a/packages/app/src/api-client.ts b/packages/app/src/api-client.ts index e329eef78..fe6566c64 100644 --- a/packages/app/src/api-client.ts +++ b/packages/app/src/api-client.ts @@ -1,31 +1,32 @@ import { request } from 'umi'; -interface ResourceProxyConstructor { - new (target: T, handler: ProxyHandler): 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; +interface Resource { + get: (params?: ActionParams) => Promise; + list: (params?: ActionParams) => Promise; + create: (params?: ActionParams) => Promise; + update: (params?: ActionParams) => Promise; + destroy: (params?: ActionParams) => Promise; + [name: string]: (params?: ActionParams) => Promise; } -class APIClient { - resource(name: string) { - return new ResourceProxy({}, { +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; diff --git a/packages/app/src/api/index.ts b/packages/app/src/api/index.ts index 1f6a1045f..5799fc682 100644 --- a/packages/app/src/api/index.ts +++ b/packages/app/src/api/index.ts @@ -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', diff --git a/packages/app/src/components/form.fields/registry.ts b/packages/app/src/components/form.fields/registry.ts index 3d001e5c1..740d007e8 100644 --- a/packages/app/src/components/form.fields/registry.ts +++ b/packages/app/src/components/form.fields/registry.ts @@ -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, diff --git a/packages/app/src/components/views/SimpleTable.tsx b/packages/app/src/components/views/SimpleTable.tsx index b464695eb..ce5653984 100644 --- a/packages/app/src/components/views/SimpleTable.tsx +++ b/packages/app/src/components/views/SimpleTable.tsx @@ -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(); - const { data, loading, pagination, mutate } = useRequest((params = {}) => { + const name = associatedName ? `${associatedName}.${resourceName}` : resourceName; + const { data, loading, pagination, mutate, refresh } = useRequest((params = {}) => { const { current, pageSize, ...restParams } = params; - const name = associatedName ? `${associatedName}.${resourceName}` : resourceName; 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); diff --git a/packages/app/src/components/views/SortableTable/index.tsx b/packages/app/src/components/views/SortableTable/index.tsx index d47976310..ffa204b56 100644 --- a/packages/app/src/components/views/SortableTable/index.tsx +++ b/packages/app/src/components/views/SortableTable/index.tsx @@ -14,20 +14,30 @@ export const DragHandle = sortableHandle(() => ( )); -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 => ( { + 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 ; }, }, diff --git a/packages/app/src/components/views/Table.tsx b/packages/app/src/components/views/Table.tsx index 56f6448a8..2711a28e4 100644 --- a/packages/app/src/components/views/Table.tsx +++ b/packages/app/src/components/views/Table.tsx @@ -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 name = associatedName ? `${associatedName}.${resourceName}` : resourceName; + const { data, loading, pagination, mutate, refresh } = useRequest((params = {}) => { const { current, pageSize, ...restParams } = params; - const name = associatedName ? `${associatedName}.${resourceName}` : resourceName; 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({ diff --git a/packages/database/src/__tests__/model/custom.test.ts b/packages/database/src/__tests__/model/custom.test.ts new file mode 100644 index 000000000..6cf1299a9 --- /dev/null +++ b/packages/database/src/__tests__/model/custom.test.ts @@ -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(); + }); +}); diff --git a/packages/database/src/table.ts b/packages/database/src/table.ts index 6cf9c80d5..c4dc7ea1a 100644 --- a/packages/database/src/table.ts +++ b/packages/database/src/table.ts @@ -16,6 +16,28 @@ import { import Database from './database'; import { Model, ModelCtor } from './model'; +const registeredModels = new Map(); + +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, 'name'|'modelName'> { /** @@ -32,7 +54,7 @@ export interface TableOptions extends Omit, 'name'|'modelNam /** * 自定义 model */ - model?: ModelCtor; + model?: ModelCtor | string; /** * 字段配置 @@ -95,6 +117,8 @@ export class Table { protected Model: ModelCtor; + protected defaultModel: ModelCtor; + /** * 是否是中间表 */ @@ -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); @@ -342,4 +379,4 @@ export class Table { } } -export default Table; \ No newline at end of file +export default Table; diff --git a/packages/plugin-collections/package.json b/packages/plugin-collections/package.json index 620f53fb9..934411ff0 100644 --- a/packages/plugin-collections/package.json +++ b/packages/plugin-collections/package.json @@ -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" } } diff --git a/packages/plugin-collections/src/__tests__/base-model.test.ts b/packages/plugin-collections/src/__tests__/base-model.test.ts new file mode 100644 index 000000000..86904f003 --- /dev/null +++ b/packages/plugin-collections/src/__tests__/base-model.test.ts @@ -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; + 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; + 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'}], + }); + }); +}); diff --git a/packages/plugin-collections/src/__tests__/collections.test.ts b/packages/plugin-collections/src/__tests__/collections.test.ts new file mode 100644 index 000000000..f92b6dd1a --- /dev/null +++ b/packages/plugin-collections/src/__tests__/collections.test.ts @@ -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); + }); +}); diff --git a/packages/plugin-collections/src/__tests__/index.ts b/packages/plugin-collections/src/__tests__/index.ts new file mode 100644 index 000000000..cafd2e04e --- /dev/null +++ b/packages/plugin-collections/src/__tests__/index.ts @@ -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; + list: (params?: ActionParams) => Promise; + create: (params?: ActionParams) => Promise; + update: (params?: ActionParams) => Promise; + destroy: (params?: ActionParams) => Promise; + [name: string]: (params?: ActionParams) => Promise; +} + +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}`; + } + } + }); +}; diff --git a/packages/plugin-collections/src/collections/actions.ts b/packages/plugin-collections/src/collections/actions.ts index 0755dc43f..c7a9d9f61 100644 --- a/packages/plugin-collections/src/collections/actions.ts +++ b/packages/plugin-collections/src/collections/actions.ts @@ -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: [ diff --git a/packages/plugin-collections/src/collections/collections.ts b/packages/plugin-collections/src/collections/collections.ts index be25ba3a3..15b27dcd7 100644 --- a/packages/plugin-collections/src/collections/collections.ts +++ b/packages/plugin-collections/src/collections/collections.ts @@ -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: ` +

常规模式:点击数据进入详情页进行各项查看和操作;
简易模式:点击数据直接打开编辑表单

+ `, + 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: [ diff --git a/packages/plugin-collections/src/collections/fields.ts b/packages/plugin-collections/src/collections/fields.ts index b7f4d3135..4d68921ab 100644 --- a/packages/plugin-collections/src/collections/fields.ts +++ b/packages/plugin-collections/src/collections/fields.ts @@ -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', + showInTable: true, + showInDetail: true, + showInForm: true, + }, }, { + interface: 'select', type: 'string', - name: 'title', - title: '名称', + 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: [ diff --git a/packages/plugin-collections/src/collections/tabs.ts b/packages/plugin-collections/src/collections/tabs.ts index 872343b73..4cae10ab8 100644 --- a/packages/plugin-collections/src/collections/tabs.ts +++ b/packages/plugin-collections/src/collections/tabs.ts @@ -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: [ diff --git a/packages/plugin-collections/src/collections/views.ts b/packages/plugin-collections/src/collections/views.ts index 8d35f4b2f..816a1c7da 100644 --- a/packages/plugin-collections/src/collections/views.ts +++ b/packages/plugin-collections/src/collections/views.ts @@ -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', + showInTable: true, + showInDetail: true, + showInForm: true, + }, }, { + interface: 'radio', type: 'string', - name: 'title', - title: '名称', + 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', - }, - { - type: 'belongsToMany', - name: 'actions', + interface: 'json', + type: 'json', + name: 'options', + title: '配置信息', + defaultValue: {}, + component: { + type: 'hidden', + }, }, + // 以下暂不考虑 + // { + // type: 'belongsToMany', + // name: 'fields', + // component: { + // type: 'drawerSelect', + // }, + // }, + // { + // type: 'belongsToMany', + // name: 'actions', + // component: { + // type: 'drawerSelect', + // }, + // }, ], actions: [ { diff --git a/packages/plugin-collections/src/hooks/collections-after-create.ts b/packages/plugin-collections/src/hooks/collections-after-create.ts new file mode 100644 index 000000000..e88a5ec5f --- /dev/null +++ b/packages/plugin-collections/src/hooks/collections-after-create.ts @@ -0,0 +1,5 @@ +import CollectionModel from '../models/collection'; + +export default async function (model: CollectionModel) { + await model.migrate(); +} diff --git a/packages/plugin-collections/src/hooks/collections-before-validate.ts b/packages/plugin-collections/src/hooks/collections-before-validate.ts new file mode 100644 index 000000000..005929e43 --- /dev/null +++ b/packages/plugin-collections/src/hooks/collections-before-validate.ts @@ -0,0 +1,7 @@ +import CollectionModel from '../models/collection'; + +export default async function (model: CollectionModel) { + if (!model.get('name')) { + model.setDataValue('name', this.generateName()); + } +} diff --git a/packages/plugin-collections/src/hooks/fields-after-create.ts b/packages/plugin-collections/src/hooks/fields-after-create.ts new file mode 100644 index 000000000..d00e51bd5 --- /dev/null +++ b/packages/plugin-collections/src/hooks/fields-after-create.ts @@ -0,0 +1,6 @@ +import FieldModel from '../models/field'; + +export default async function (model: FieldModel) { + // console.log('afterCreate', model.toJSON()); + await model.migrate(); +} diff --git a/packages/plugin-collections/src/hooks/fields-before-validate.ts b/packages/plugin-collections/src/hooks/fields-before-validate.ts new file mode 100644 index 000000000..d89910053 --- /dev/null +++ b/packages/plugin-collections/src/hooks/fields-before-validate.ts @@ -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 }); +} diff --git a/packages/plugin-collections/src/hooks/index.ts b/packages/plugin-collections/src/hooks/index.ts new file mode 100644 index 000000000..d6f1b98e6 --- /dev/null +++ b/packages/plugin-collections/src/hooks/index.ts @@ -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 + } +}; diff --git a/packages/plugin-collections/src/interfaces/index.ts b/packages/plugin-collections/src/interfaces/index.ts new file mode 100644 index 000000000..bd70b9229 --- /dev/null +++ b/packages/plugin-collections/src/interfaces/index.ts @@ -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; diff --git a/packages/plugin-collections/src/interfaces/types.ts b/packages/plugin-collections/src/interfaces/types.ts new file mode 100644 index 000000000..a7e2e33dc --- /dev/null +++ b/packages/plugin-collections/src/interfaces/types.ts @@ -0,0 +1,460 @@ +// merge:interface 模板,旧数据,用户数据 +// TODO: 删除的情况怎么处理 +// 联动的原则:尽量减少干预,尤其是尽量少改动 type,type 兼容 +// 参数的优先级: +// 1、interface,type 尽量只随 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合并成一个 interface,multiple 会影响 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 + * - 子字段只属于子表格字段关联的表(target),不属于当前表(source) + */ +// 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 但是有 target,name 随机生成 + * 有 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', + }, + }, +}; diff --git a/packages/plugin-collections/src/models/action.ts b/packages/plugin-collections/src/models/action.ts new file mode 100644 index 000000000..ffd3c4235 --- /dev/null +++ b/packages/plugin-collections/src/models/action.ts @@ -0,0 +1,6 @@ +import _ from 'lodash'; +import BaseModel from './base'; + +export class ActionModel extends BaseModel { + +} diff --git a/packages/plugin-collections/src/models/base.ts b/packages/plugin-collections/src/models/base.ts new file mode 100644 index 000000000..c2dc8d8ad --- /dev/null +++ b/packages/plugin-collections/src/models/base.ts @@ -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; diff --git a/packages/plugin-collections/src/models/collection.ts b/packages/plugin-collections/src/models/collection.ts index a28bd1b0c..5292598fb 100644 --- a/packages/plugin-collections/src/models/collection.ts +++ b/packages/plugin-collections/src/models/collection.ts @@ -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; - } - 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'), - }; - }), +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, + } }); - 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 { + return { + ...this.get('options'), + name: this.get('name'), + title: this.get('title'), + fields: await this.getFieldsOptions(), + }; + } + + static async import(data: TableOptions, options: SaveOptions = {}): Promise { + 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; } } diff --git a/packages/plugin-collections/src/models/field.ts b/packages/plugin-collections/src/models/field.ts index 6a2f04991..eba06c8ee 100644 --- a/packages/plugin-collections/src/models/field.ts +++ b/packages/plugin-collections/src/models/field.ts @@ -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 { + 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; diff --git a/packages/plugin-collections/src/models/index.ts b/packages/plugin-collections/src/models/index.ts new file mode 100644 index 000000000..1b713505f --- /dev/null +++ b/packages/plugin-collections/src/models/index.ts @@ -0,0 +1,5 @@ +export * from './action'; +export * from './collection'; +export * from './field'; +export * from './tab'; +export * from './view'; diff --git a/packages/plugin-collections/src/models/tab.ts b/packages/plugin-collections/src/models/tab.ts new file mode 100644 index 000000000..e3e2df774 --- /dev/null +++ b/packages/plugin-collections/src/models/tab.ts @@ -0,0 +1,6 @@ +import _ from 'lodash'; +import BaseModel from './base'; + +export class TabModel extends BaseModel { + +} diff --git a/packages/plugin-collections/src/models/view.ts b/packages/plugin-collections/src/models/view.ts new file mode 100644 index 000000000..4822adbb4 --- /dev/null +++ b/packages/plugin-collections/src/models/view.ts @@ -0,0 +1,6 @@ +import _ from 'lodash'; +import BaseModel from './base'; + +export class ViewModel extends BaseModel { + +} diff --git a/packages/plugin-collections/src/server.ts b/packages/plugin-collections/src/server.ts index 830e2ab30..5e9f9b4b5 100644 --- a/packages/plugin-collections/src/server.ts +++ b/packages/plugin-collections/src/server.ts @@ -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(); } diff --git a/packages/plugin-pages/src/actions/getView.ts b/packages/plugin-pages/src/actions/getView.ts index ee98e0281..2f9293d56 100644 --- a/packages/plugin-pages/src/actions/getView.ts +++ b/packages/plugin-pages/src/actions/getView.ts @@ -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, diff --git a/packages/plugin-pages/src/collections/pages.ts b/packages/plugin-pages/src/collections/pages.ts index b45acbed6..01d2888bf 100644 --- a/packages/plugin-pages/src/collections/pages.ts +++ b/packages/plugin-pages/src/collections/pages.ts @@ -5,6 +5,7 @@ export default { title: '页面配置', fields: [ { + interface: 'sort', type: 'integer', name: 'sort', title: '排序', @@ -12,66 +13,82 @@ 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, + options: [ + { + label: '页面', + value: 'page', + }, + { + label: '布局', + value: 'layout', + }, + { + label: '数据集', + value: 'collection', + }, + ], component: { type: 'string', - enum: [ - { - label: '页面', - value: 'page', - }, - { - label: '布局', - value: 'layout', - }, - { - label: '数据集', - value: 'collection', - }, - ], + 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, + options: [ + { + label: '顶部菜单布局', + value: 'TopMenuLayout', + }, + { + label: '左侧菜单布局', + value: 'SideMenuLayout', + }, + ], component: { - type: 'string', - enum: [ - { - label: '顶部菜单布局', - value: 'LayoutWithTopMenu', - }, - { - label: '左侧菜单布局', - value: 'LayoutWithSideMenu', - }, - { - label: '数据集(全部)', - value: 'collections', - }, - { - label: '数据集(某种)', - value: 'collection', - }, - { - label: '登录', - value: 'login', - }, - { - label: '注册', - value: 'register', - }, - { - label: '分析页', - value: 'analysis', - }, - { - label: '工作区', - value: 'workplace', - }, - ], + 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: [