diff --git a/packages/core/database/src/__tests__/relation-repository/belongs-to-many-repository.test.ts b/packages/core/database/src/__tests__/relation-repository/belongs-to-many-repository.test.ts index 0c2a8fdd1..c1fabfd80 100644 --- a/packages/core/database/src/__tests__/relation-repository/belongs-to-many-repository.test.ts +++ b/packages/core/database/src/__tests__/relation-repository/belongs-to-many-repository.test.ts @@ -3,6 +3,88 @@ import Database from '../../database'; import { BelongsToManyRepository } from '../../relation-repository/belongs-to-many-repository'; import { mockDatabase } from '../index'; +describe('belongs to many with collection that has no id key', () => { + let db: Database; + beforeEach(async () => { + db = mockDatabase(); + + await db.clean({ drop: true }); + }); + + afterEach(async () => { + await db.close(); + }); + + it('should set relation', async () => { + const A = db.collection({ + name: 'a', + autoGenId: false, + fields: [ + { + type: 'string', + name: 'name', + primaryKey: true, + }, + { + type: 'belongsToMany', + name: 'bs', + target: 'b', + through: 'asbs', + sourceKey: 'name', + foreignKey: 'aName', + otherKey: 'bName', + targetKey: 'name', + }, + ], + }); + + const B = db.collection({ + name: 'b', + autoGenId: false, + fields: [ + { + type: 'string', + name: 'key', + primaryKey: true, + }, + { + type: 'string', + name: 'name', + unique: true, + }, + { + type: 'belongsToMany', + name: 'as', + target: 'a', + through: 'asbs', + sourceKey: 'name', + foreignKey: 'bName', + otherKey: 'aName', + targetKey: 'name', + }, + ], + }); + + await db.sync(); + const a = await A.repository.create({ + values: { + name: 'a1', + }, + }); + const b = await B.repository.create({ + values: { + key: 'b1_key', + name: 'b1', + }, + }); + + const a1bsRepository = await A.repository.relation('bs').of('a1'); + expect(await a1bsRepository.find()).toHaveLength(0); + await a1bsRepository.toggle('b1'); + expect(await a1bsRepository.find()).toHaveLength(1); + }); +}); + describe('belongs to many with target key', function () { let db: Database; let Tag: Collection; diff --git a/packages/core/database/src/collection.ts b/packages/core/database/src/collection.ts index 919653948..aca9aa543 100644 --- a/packages/core/database/src/collection.ts +++ b/packages/core/database/src/collection.ts @@ -7,7 +7,7 @@ import { QueryInterfaceDropTableOptions, SyncOptions, Transactionable, - Utils + Utils, } from 'sequelize'; import { Database } from './database'; import { BelongsToField, Field, FieldOptions, HasManyField } from './fields'; @@ -49,6 +49,7 @@ export interface CollectionOptions extends Omit magicAttribute?: string; tree?: string; + [key: string]: any; } @@ -386,7 +387,9 @@ export class Collection< this.options = newOptions; this.setFields(options.fields, false); - this.setRepository(options.repository); + if (options.repository) { + this.setRepository(options.repository); + } this.context.database.emit('afterUpdateCollection', this); diff --git a/packages/core/database/src/relation-repository/belongs-to-many-repository.ts b/packages/core/database/src/relation-repository/belongs-to-many-repository.ts index 2495036cf..c8707fdb6 100644 --- a/packages/core/database/src/relation-repository/belongs-to-many-repository.ts +++ b/packages/core/database/src/relation-repository/belongs-to-many-repository.ts @@ -11,20 +11,29 @@ type CreateBelongsToManyOptions = CreateOptions; interface IBelongsToManyRepository { find(options?: FindOptions): Promise; + findAndCount(options?: FindAndCountOptions): Promise<[M[], number]>; + findOne(options?: FindOneOptions): Promise; + // 新增并关联,存在中间表数据 create(options?: CreateOptions): Promise; + // 更新,存在中间表数据 update(options?: UpdateOptions): Promise; + // 删除 destroy(options?: number | string | number[] | string[] | DestroyOptions): Promise; + // 建立关联 set(options: TargetKey | TargetKey[] | AssociatedOptions): Promise; + // 附加关联,存在中间表数据 add(options: TargetKey | TargetKey[] | AssociatedOptions): Promise; + // 移除关联 remove(options: TargetKey | TargetKey[] | AssociatedOptions): Promise; + toggle(options: TargetKey | { pk?: TargetKey; transaction?: Transaction }): Promise; } @@ -157,7 +166,17 @@ export class BelongsToManyRepository extends MultipleRelationRepository implemen return carry; }, {}); - await sourceModel[this.accessors()[call]](Object.keys(setObj), { + const targetKeys = Object.keys(setObj); + const association = this.association; + + const targetObjects = await this.targetModel.findAll({ + where: { + [association['targetKey']]: targetKeys, + }, + transaction, + }); + + await sourceModel[this.accessors()[call]](targetObjects, { transaction, }); diff --git a/packages/core/server/src/__tests__/multiple-application.test.ts b/packages/core/server/src/__tests__/multiple-application.test.ts index 7c8a72df6..d51caef38 100644 --- a/packages/core/server/src/__tests__/multiple-application.test.ts +++ b/packages/core/server/src/__tests__/multiple-application.test.ts @@ -2,6 +2,7 @@ import { mockServer, MockServer } from '@nocobase/test'; import { uid } from '@nocobase/utils'; import { IncomingMessage } from 'http'; import * as url from 'url'; +import Application from '../application'; describe('multiple apps', () => { it('should emit beforeGetApplication event', async () => { @@ -9,11 +10,14 @@ describe('multiple apps', () => { const app = mockServer(); - app.appManager.on('beforeGetApplication', beforeGetApplicationFn); + app.on('beforeGetApplication', beforeGetApplicationFn); - app.appManager.createApplication('sub1', { - database: app.db, - }); + app.appManager.addSubApp( + new Application({ + database: app.db, + name: 'sub1', + }), + ); app.appManager.setAppSelector(() => 'sub1'); @@ -29,9 +33,12 @@ describe('multiple apps', () => { it('should listen stop event', async () => { const app = mockServer(); - const subApp1 = app.appManager.createApplication('sub1', { - database: app.db, - }); + const subApp1 = app.appManager.addSubApp( + new Application({ + database: app.db, + name: 'sub1', + }), + ); const subApp1StopFn = jest.fn(); @@ -55,35 +62,18 @@ describe('multiple application', () => { await app.destroy(); }); - it('should upgrade sub apps when main app upgraded', async () => { - const subApp1 = app.appManager.createApplication('sub1', { - database: app.db, - }); - const subApp2 = app.appManager.createApplication('sub2', { - database: app.db, - }); - const subApp1UpgradeFn = jest.fn(); - const subApp2UpgradeFn = jest.fn(); - - subApp1.on('afterUpgrade', subApp1UpgradeFn); - subApp2.on('afterUpgrade', subApp2UpgradeFn); - - await app.upgrade(); - - expect(subApp1UpgradeFn).toBeCalledTimes(1); - expect(subApp2UpgradeFn).toBeCalledTimes(1); - await subApp2.stop(); - await subApp1.stop(); - }); - - it('should create multiple apps', async () => { + it('should add multiple apps', async () => { const sub1 = `a_${uid()}`; const sub2 = `a_${uid()}`; const sub3 = `a_${uid()}`; - const subApp1 = app.appManager.createApplication(sub1, { - database: app.db, - acl: false, - }); + + const subApp1 = app.appManager.addSubApp( + new Application({ + database: app.db, + acl: false, + name: sub1, + }), + ); subApp1.resourcer.define({ name: 'test', @@ -94,10 +84,13 @@ describe('multiple application', () => { }, }); - const subApp2 = app.appManager.createApplication(sub2, { - database: app.db, - acl: false, - }); + const subApp2 = app.appManager.addSubApp( + new Application({ + database: app.db, + acl: false, + name: sub2, + }), + ); subApp2.resourcer.define({ name: 'test', diff --git a/packages/core/server/src/app-manager.ts b/packages/core/server/src/app-manager.ts index c3cff0568..74e167afa 100644 --- a/packages/core/server/src/app-manager.ts +++ b/packages/core/server/src/app-manager.ts @@ -1,18 +1,15 @@ -import { applyMixins, AsyncEmitter } from '@nocobase/utils'; -import EventEmitter from 'events'; import http, { IncomingMessage, ServerResponse } from 'http'; -import Application, { ApplicationOptions } from './application'; +import Application from './application'; type AppSelectorReturn = Application | string | undefined | null; type AppSelector = (req: IncomingMessage) => AppSelectorReturn | Promise; -export class AppManager extends EventEmitter { +export class AppManager { public applications: Map = new Map(); public app: Application; constructor(app: Application) { - super(); this.bindMainApplication(app); } @@ -30,19 +27,13 @@ export class AppManager extends EventEmitter { passEventToSubApps('beforeDestroy', 'destroy'); passEventToSubApps('beforeStop', 'stop'); - passEventToSubApps('afterUpgrade', 'upgrade'); - passEventToSubApps('afterReload', 'reload'); } appSelector: AppSelector = async (req: IncomingMessage) => this.app; - createApplication(name: string, options: ApplicationOptions): Application { - const application = new Application({ - ...options, - name, - }); - - this.applications.set(name, application); + addSubApp(application): Application { + this.applications.set(application.name, application); + this.app.emit('afterSubAppAdded', application); return application; } @@ -54,6 +45,7 @@ export class AppManager extends EventEmitter { await application.destroy(); + console.log(`remove application ${name}`); this.applications.delete(name); } @@ -67,7 +59,7 @@ export class AppManager extends EventEmitter { } async getApplication(appName: string, options = {}): Promise { - await this.emitAsync('beforeGetApplication', { + await this.app.emitAsync('beforeGetApplication', { appManager: this, name: appName, options, @@ -84,7 +76,6 @@ export class AppManager extends EventEmitter { if (typeof handleApp === 'string') { handleApp = await appManager.getApplication(handleApp); - if (!handleApp) { res.statusCode = 404; return res.end( @@ -98,13 +89,11 @@ export class AppManager extends EventEmitter { }), ); } + + if (handleApp.stopped) await handleApp.start(); } handleApp.callback()(req, res); }; } - - declare emitAsync: (event: string | symbol, ...args: any[]) => Promise; } - -applyMixins(AppManager, [AsyncEmitter]); diff --git a/packages/core/server/src/application.ts b/packages/core/server/src/application.ts index 0e9fbba73..c49157292 100644 --- a/packages/core/server/src/application.ts +++ b/packages/core/server/src/application.ts @@ -409,7 +409,6 @@ export class Application exten } async start(options: StartOptions = {}) { - // reconnect database if (this.db.closed()) { await this.db.reconnect(); } diff --git a/packages/core/server/src/plugin-manager/plugin-manager-repository.ts b/packages/core/server/src/plugin-manager/plugin-manager-repository.ts index a7d76dc1c..6fa76eb36 100644 --- a/packages/core/server/src/plugin-manager/plugin-manager-repository.ts +++ b/packages/core/server/src/plugin-manager/plugin-manager-repository.ts @@ -18,6 +18,18 @@ export class PluginManagerRepository extends Repository { async enable(name: string | string[]) { const pluginNames = typeof name === 'string' ? [name] : name; + const plugins = pluginNames.map((name) => this.pm.plugins.get(name)); + + for (const plugin of plugins) { + const requiredPlugins = plugin.requiredPlugins(); + for (const requiredPluginName of requiredPlugins) { + const requiredPlugin = this.pm.plugins.get(requiredPluginName); + if (!requiredPlugin.enabled) { + throw new Error(`${plugin.name} plugin need ${requiredPluginName} plugin enabled`); + } + } + } + await this.update({ filter: { name, diff --git a/packages/core/server/src/plugin-manager/plugin-manager.ts b/packages/core/server/src/plugin-manager/plugin-manager.ts index 709e4fd71..c9b3a28d8 100644 --- a/packages/core/server/src/plugin-manager/plugin-manager.ts +++ b/packages/core/server/src/plugin-manager/plugin-manager.ts @@ -58,6 +58,7 @@ export class PluginManager { const exists = await this.app.db.collectionExistsInDb('applicationPlugins'); if (!exists) { + this.app.log.warn(`applicationPlugins collection not exists in ${this.app.name}`); return; } diff --git a/packages/core/server/src/plugin.ts b/packages/core/server/src/plugin.ts index 1f74d5de9..498a41331 100644 --- a/packages/core/server/src/plugin.ts +++ b/packages/core/server/src/plugin.ts @@ -80,6 +80,10 @@ export abstract class Plugin implements PluginInterface { from: this.getName(), }); } + + requiredPlugins() { + return []; + } } export default Plugin; diff --git a/packages/core/utils/package.json b/packages/core/utils/package.json index ecf4e102d..5fe165d74 100644 --- a/packages/core/utils/package.json +++ b/packages/core/utils/package.json @@ -7,7 +7,8 @@ "dependencies": { "@hapi/topo": "^6.0.0", "deepmerge": "^4.2.2", - "flat-to-nested": "^1.1.1" + "flat-to-nested": "^1.1.1", + "graphlib": "^2.1.8" }, "peerDependencies": { "moment": "2.x", diff --git a/packages/core/utils/src/__tests__/collection-graph.test.ts b/packages/core/utils/src/__tests__/collection-graph.test.ts new file mode 100644 index 000000000..1a4682087 --- /dev/null +++ b/packages/core/utils/src/__tests__/collection-graph.test.ts @@ -0,0 +1,55 @@ +import { CollectionsGraph } from '../collections-graph'; + +describe('collection graph', () => { + it('should build collection graph', async () => { + const collections = [ + { + name: 'a', + fields: [], + }, + { + name: 'b', + inherits: ['a'], + fields: [ + { + name: 'bField', + type: 'hasMany', + target: 'c', + }, + ], + }, + { + name: 'c', + }, + + { + name: 'a1', + fields: [ + { + name: 'a1Field', + type: 'hasMany', + target: 'b1', + }, + ], + }, + { + name: 'b1', + }, + ]; + + const connectedNodes = CollectionsGraph.connectedNodes({ + collections, + nodes: ['b', 'a1'], + }); + + expect(connectedNodes).toEqual(['b', 'a', 'c', 'a1', 'b1']); + + const preOrderReverse = CollectionsGraph.preOrder({ + collections, + node: 'a', + direction: 'reverse', + }); + + expect(preOrderReverse).toEqual(['a', 'b']); + }); +}); diff --git a/packages/core/utils/src/client.ts b/packages/core/utils/src/client.ts index 3545a5d5f..eb670f6f5 100644 --- a/packages/core/utils/src/client.ts +++ b/packages/core/utils/src/client.ts @@ -1,3 +1,4 @@ +export * from './collections-graph'; export * from './date'; export * from './merge'; export * from './number'; diff --git a/packages/core/utils/src/collections-graph.ts b/packages/core/utils/src/collections-graph.ts new file mode 100644 index 000000000..dece31f78 --- /dev/null +++ b/packages/core/utils/src/collections-graph.ts @@ -0,0 +1,77 @@ +import * as graphlib from 'graphlib'; +import { castArray } from 'lodash'; + +type BuildGraphOptions = { + direction?: 'forward' | 'reverse'; + collections: any[]; +}; + +export class CollectionsGraph { + static graphlib() { + return graphlib; + } + + static connectedNodes(options: BuildGraphOptions & { nodes: Array; excludes?: Array }) { + const nodes = castArray(options.nodes); + const excludes = castArray(options.excludes || []); + + const graph = CollectionsGraph.build(options); + const connectedNodes = new Set(); + for (const node of nodes) { + const connected = graphlib.alg.preorder(graph, node); + for (const connectedNode of connected) { + if (excludes.includes(connectedNode)) continue; + connectedNodes.add(connectedNode); + } + } + + return Array.from(connectedNodes); + } + + static preOrder(options: BuildGraphOptions & { node: string }) { + return CollectionsGraph.graphlib().alg.preorder(CollectionsGraph.build(options), options.node); + } + + static build(options: BuildGraphOptions) { + const collections = options.collections; + const direction = options?.direction || 'forward'; + const isForward = direction === 'forward'; + + const graph = new graphlib.Graph(); + + for (const collection of collections) { + graph.setNode(collection.name); + } + + for (const collection of collections) { + const parents = collection.inherits || []; + for (const parent of parents) { + if (isForward) { + graph.setEdge(collection.name, parent); + } else { + graph.setEdge(parent, collection.name); + } + } + + for (const field of collection.fields || []) { + if (field.type === 'hasMany' || field.type === 'belongsTo' || field.type === 'hasOne') { + isForward ? graph.setEdge(collection.name, field.target) : graph.setEdge(field.target, collection.name); + } + + if (field.type === 'belongsToMany') { + const throughCollection = field.through; + + if (isForward) { + graph.setEdge(collection.name, throughCollection); + graph.setEdge(throughCollection, field.target); + } else { + graph.setEdge(field.target, throughCollection); + graph.setEdge(throughCollection, collection.name); + } + } + } + } + + return graph; + } +} diff --git a/packages/core/utils/src/index.ts b/packages/core/utils/src/index.ts index 7f3e4e78b..af0f7e6ca 100644 --- a/packages/core/utils/src/index.ts +++ b/packages/core/utils/src/index.ts @@ -8,3 +8,4 @@ export * from './requireModule'; export * from './toposort'; export * from './uid'; export * from './assign'; +export * from './collections-graph'; diff --git a/packages/plugins/collection-manager/src/__tests__/collections.repository.test.ts b/packages/plugins/collection-manager/src/__tests__/collections.repository.test.ts index 7bde3505c..bc3699529 100644 --- a/packages/plugins/collection-manager/src/__tests__/collections.repository.test.ts +++ b/packages/plugins/collection-manager/src/__tests__/collections.repository.test.ts @@ -1,7 +1,7 @@ import Database, { Collection as DBCollection } from '@nocobase/database'; import Application from '@nocobase/server'; import { createApp } from '.'; -import CollectionManagerPlugin from '@nocobase/plugin-collection-manager'; +import CollectionManagerPlugin, { CollectionRepository } from '@nocobase/plugin-collection-manager'; describe('collections repository', () => { let db: Database; @@ -20,6 +20,20 @@ describe('collections repository', () => { await app.destroy(); }); + it('should extend collections collection', async () => { + expect(db.getRepository('collections')).toBeTruthy(); + + db.extendCollection({ + name: 'collections', + fields: [{ type: 'string', name: 'tests' }], + }); + + expect(Collection.getField('tests')).toBeTruthy(); + const afterRepository = db.getRepository('collections'); + + expect(afterRepository.load).toBeTruthy(); + }); + it('should set collection schema from env', async () => { if (!db.inDialect('postgres')) { return; diff --git a/packages/plugins/multi-app-manager/src/client/AppManager.tsx b/packages/plugins/multi-app-manager/src/client/AppManager.tsx index 1ffcfa885..5b283474b 100644 --- a/packages/plugins/multi-app-manager/src/client/AppManager.tsx +++ b/packages/plugins/multi-app-manager/src/client/AppManager.tsx @@ -2,12 +2,14 @@ import { SchemaComponent, useRecord } from '@nocobase/client'; import { Card } from 'antd'; import React from 'react'; import { schema } from './settings/schemas/applications'; +import { usePluginUtils } from './utils'; const AppVisitor = () => { const record = useRecord(); + const { t } = usePluginUtils(); return ( - View + {t('View', { ns: 'client' })} ); }; diff --git a/packages/plugins/multi-app-manager/src/client/index.tsx b/packages/plugins/multi-app-manager/src/client/index.tsx index ef3f34f17..db2a4c7e7 100644 --- a/packages/plugins/multi-app-manager/src/client/index.tsx +++ b/packages/plugins/multi-app-manager/src/client/index.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { useHistory } from 'react-router-dom'; import { AppManager } from './AppManager'; import { AppNameInput } from './AppNameInput'; +import { usePluginUtils } from './utils'; const MultiAppManager = () => { const history = useHistory(); @@ -22,6 +23,7 @@ const MultiAppManager = () => { manual: true, }, ); + const { t } = usePluginUtils(); const menu = ( {(data?.data || []).map((app) => { @@ -42,7 +44,7 @@ const MultiAppManager = () => { history.push('/admin/settings/multi-app-manager/applications'); }} > - Manage applications + {t('Manage applications')} ); @@ -58,7 +60,10 @@ const MultiAppManager = () => { ); }; +export { tableActionColumnSchema } from './settings/schemas/applications'; + export default (props) => { + const { t } = usePluginUtils(); return ( { , }, // settings: { diff --git a/packages/plugins/multi-app-manager/src/client/locale/zh-CN.ts b/packages/plugins/multi-app-manager/src/client/locale/zh-CN.ts new file mode 100644 index 000000000..3820182fc --- /dev/null +++ b/packages/plugins/multi-app-manager/src/client/locale/zh-CN.ts @@ -0,0 +1,9 @@ +export default { + 'Multi-app manager': '多应用管理', + Applications: '应用', + 'App display name': '应用名称', + 'App ID': '应用标识', + 'Pin to menu': '在菜单上显示', + 'Custom domain': '自定义域名', + 'Manage applications': '管理应用', +}; diff --git a/packages/plugins/multi-app-manager/src/client/settings/schemas/applications.ts b/packages/plugins/multi-app-manager/src/client/settings/schemas/applications.ts index 816574119..755aa6d16 100644 --- a/packages/plugins/multi-app-manager/src/client/settings/schemas/applications.ts +++ b/packages/plugins/multi-app-manager/src/client/settings/schemas/applications.ts @@ -7,6 +7,7 @@ import { useResourceActionContext, useResourceContext } from '@nocobase/client'; +import { i18nText } from '../../utils'; const collection = { name: 'applications', @@ -20,7 +21,7 @@ const collection = { interface: 'input', uiSchema: { type: 'string', - title: '{{t("App ID")}}', + title: i18nText('App ID'), required: true, 'x-component': 'Input', 'x-validator': 'uid', @@ -32,7 +33,7 @@ const collection = { interface: 'input', uiSchema: { type: 'string', - title: '{{t("App display name")}}', + title: i18nText('App display name'), required: true, 'x-component': 'Input', }, @@ -43,7 +44,7 @@ const collection = { interface: 'checkbox', uiSchema: { type: 'boolean', - 'x-content': '{{t("Pin to menu")}}', + 'x-content': i18nText('Pin to menu'), 'x-component': 'Checkbox', }, }, @@ -54,7 +55,7 @@ const collection = { defaultValue: 'pending', uiSchema: { type: 'string', - title: '{{t("App status")}}', + title: i18nText('App status'), enum: [ { label: 'Pending', value: 'pending' }, { label: 'Running', value: 'running' }, @@ -91,6 +92,81 @@ export const useDestroyAll = () => { }; }; +export const tableActionColumnSchema = { + properties: { + view: { + type: 'void', + 'x-component': 'AppVisitor', + 'x-component-props': {}, + }, + update: { + type: 'void', + title: '{{t("Edit")}}', + 'x-component': 'Action.Link', + 'x-component-props': {}, + properties: { + drawer: { + type: 'void', + 'x-component': 'Action.Drawer', + 'x-decorator': 'Form', + 'x-decorator-props': { + useValues: '{{ cm.useValuesFromRecord }}', + }, + title: '{{t("Edit")}}', + properties: { + displayName: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + pinned: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + cname: { + title: i18nText('Custom domain'), + 'x-component': 'Input', + 'x-decorator': 'FormItem', + }, + footer: { + type: 'void', + 'x-component': 'Action.Drawer.Footer', + properties: { + cancel: { + title: '{{t("Cancel")}}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ cm.useCancelAction }}', + }, + }, + submit: { + title: '{{t("Submit")}}', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + useAction: '{{ cm.useUpdateAction }}', + }, + }, + }, + }, + }, + }, + }, + }, + delete: { + type: 'void', + title: '{{ t("Delete") }}', + 'x-component': 'Action.Link', + 'x-component-props': { + confirm: { + title: "{{t('Delete')}}", + content: "{{t('Are you sure you want to delete it?')}}", + }, + useAction: '{{cm.useDestroyAction}}', + }, + }, + }, +}; + export const schema: ISchema = { type: 'object', properties: { @@ -177,7 +253,7 @@ export const schema: ISchema = { 'x-decorator': 'FormItem', }, cname: { - title: '{{t("Custom domain")}}', + title: i18nText('Custom domain'), 'x-component': 'Input', 'x-decorator': 'FormItem', }, @@ -246,7 +322,7 @@ export const schema: ISchema = { }, pinned: { type: 'void', - title: '{{t("Pin to menu")}}', + title: i18nText('Pin to menu'), 'x-decorator': 'Table.Column.Decorator', 'x-component': 'Table.Column', properties: { @@ -268,78 +344,7 @@ export const schema: ISchema = { 'x-component-props': { split: '|', }, - properties: { - view: { - type: 'void', - 'x-component': 'AppVisitor', - 'x-component-props': {}, - }, - update: { - type: 'void', - title: '{{t("Edit")}}', - 'x-component': 'Action.Link', - 'x-component-props': {}, - properties: { - drawer: { - type: 'void', - 'x-component': 'Action.Drawer', - 'x-decorator': 'Form', - 'x-decorator-props': { - useValues: '{{ cm.useValuesFromRecord }}', - }, - title: '{{t("Edit")}}', - properties: { - displayName: { - 'x-component': 'CollectionField', - 'x-decorator': 'FormItem', - }, - pinned: { - 'x-component': 'CollectionField', - 'x-decorator': 'FormItem', - }, - cname: { - title: '{{t("Custom domain")}}', - 'x-component': 'Input', - 'x-decorator': 'FormItem', - }, - footer: { - type: 'void', - 'x-component': 'Action.Drawer.Footer', - properties: { - cancel: { - title: '{{t("Cancel")}}', - 'x-component': 'Action', - 'x-component-props': { - useAction: '{{ cm.useCancelAction }}', - }, - }, - submit: { - title: '{{t("Submit")}}', - 'x-component': 'Action', - 'x-component-props': { - type: 'primary', - useAction: '{{ cm.useUpdateAction }}', - }, - }, - }, - }, - }, - }, - }, - }, - delete: { - type: 'void', - title: '{{ t("Delete") }}', - 'x-component': 'Action.Link', - 'x-component-props': { - confirm: { - title: "{{t('Delete')}}", - content: "{{t('Are you sure you want to delete it?')}}", - }, - useAction: '{{cm.useDestroyAction}}', - }, - }, - }, + ...tableActionColumnSchema, }, }, }, diff --git a/packages/plugins/multi-app-manager/src/client/utils.tsx b/packages/plugins/multi-app-manager/src/client/utils.tsx new file mode 100644 index 000000000..ab990af2d --- /dev/null +++ b/packages/plugins/multi-app-manager/src/client/utils.tsx @@ -0,0 +1,10 @@ +import { useTranslation } from 'react-i18next'; + +export const usePluginUtils = () => { + const { t } = useTranslation('multi-app-manager'); + return { t }; +}; + +export const i18nText = (text) => { + return `{{t("${text}", { ns: 'multi-app-manager' })}}`; +}; diff --git a/packages/plugins/multi-app-manager/src/server/__tests__/mock-get-schema.test.ts b/packages/plugins/multi-app-manager/src/server/__tests__/mock-get-schema.test.ts index bbe1e865d..40b5a63a3 100644 --- a/packages/plugins/multi-app-manager/src/server/__tests__/mock-get-schema.test.ts +++ b/packages/plugins/multi-app-manager/src/server/__tests__/mock-get-schema.test.ts @@ -47,7 +47,7 @@ describe('test with start', () => { }, }); - expect(loadFn).toHaveBeenCalledTimes(1); + expect(loadFn).toHaveBeenCalled(); expect(installFn).toHaveBeenCalledTimes(1); const subApp = await app.appManager.getApplication(name); @@ -90,10 +90,8 @@ describe('test with start', () => { let app = mockServer(); await app.cleanDb(); - app.plugin(PluginMultiAppManager); - await app.loadAndInstall(); await app.start(); diff --git a/packages/plugins/multi-app-manager/src/server/__tests__/multiple-apps.test.ts b/packages/plugins/multi-app-manager/src/server/__tests__/multiple-apps.test.ts index 5b7327588..c9f927db8 100644 --- a/packages/plugins/multi-app-manager/src/server/__tests__/multiple-apps.test.ts +++ b/packages/plugins/multi-app-manager/src/server/__tests__/multiple-apps.test.ts @@ -1,7 +1,6 @@ import { Database } from '@nocobase/database'; import { mockServer, MockServer } from '@nocobase/test'; import { uid } from '@nocobase/utils'; -import { ApplicationModel } from '..'; import { PluginMultiAppManager } from '../server'; describe('multiple apps create', () => { @@ -128,20 +127,27 @@ describe('multiple apps create', () => { expect(app.appManager.applications.has(name)).toBeTruthy(); }); - it('should change handleAppStart', async () => { - const customHandler = jest.fn(); - ApplicationModel.handleAppStart = customHandler; - const name = `td_${uid()}`; + it('should upgrade sub apps when main app upgrade', async () => { + const subAppName = `t_${uid()}`; - await db.getRepository('applications').create({ + await app.db.getRepository('applications').create({ values: { - name, + name: subAppName, options: { - plugins: ['ui-schema-storage'], + plugins: [], }, }, }); - expect(customHandler).toHaveBeenCalledTimes(1); + const subApp = await app.appManager.getApplication(subAppName); + const jestFn = jest.fn(); + + subApp.on('afterUpgrade', () => { + jestFn(); + }); + + await app.upgrade(); + + expect(jestFn).toBeCalled(); }); }); diff --git a/packages/plugins/multi-app-manager/src/server/models/application.ts b/packages/plugins/multi-app-manager/src/server/models/application.ts index a83a3f882..c53d0608b 100644 --- a/packages/plugins/multi-app-manager/src/server/models/application.ts +++ b/packages/plugins/multi-app-manager/src/server/models/application.ts @@ -1,83 +1,28 @@ import { Model, Transactionable } from '@nocobase/database'; import { Application } from '@nocobase/server'; -import { AppDbCreator, AppOptionsFactory } from '../server'; +import { AppOptionsFactory } from '../server'; export interface registerAppOptions extends Transactionable { skipInstall?: boolean; - dbCreator: AppDbCreator; appOptionsFactory: AppOptionsFactory; } export class ApplicationModel extends Model { - static async handleAppStart(mainApp: Application, app: Application, options: registerAppOptions) { - await mainApp.emitAsync('beforeSubAppLoad', { - mainApp, - subApp: app, - }); - - await app.load(); - - if (!(await app.isInstalled())) { - await app.db.sync(); - - await mainApp.emitAsync('beforeSubAppInstall', { - subApp: app, - mainApp, - }); - - await app.install(); - - // emit an event on mainApp - // current if you add listener on subApp through `subApp.on('afterInstall')` , it will be clear after subApp installed - await mainApp.emitAsync('afterSubAppInstalled', { - mainApp, - subApp: app, - }); - } - - await app.start(); - } - - async registerToMainApp(mainApp: Application, options: registerAppOptions) { + registerToMainApp(mainApp: Application, options: registerAppOptions) { const appName = this.get('name') as string; const appOptions = (this.get('options') as any) || {}; - const AppModel = this.constructor as typeof ApplicationModel; - - const app = mainApp.appManager.createApplication(appName, { + const subAppOptions = { ...options.appOptionsFactory(appName, mainApp), ...appOptions, name: appName, - }); + }; - const isInstalled = await (async () => { - try { - return await app.isInstalled(); - } catch (e) { - if (e.message.includes('does not exist') || e.message.includes('Unknown database')) { - return false; - } - throw e; - } - })(); + const subApp = new Application(subAppOptions); - if (!isInstalled) { - await options.dbCreator(app); - } + mainApp.appManager.addSubApp(subApp); - await AppModel.handleAppStart(mainApp, app, options); - - await AppModel.update( - { - status: 'running', - }, - { - transaction: options.transaction, - where: { - [AppModel.primaryKeyAttribute]: this.get(AppModel.primaryKeyAttribute), - }, - hooks: false, - }, - ); + console.log(`register application ${appName} to main app`); + return subApp; } } diff --git a/packages/plugins/multi-app-manager/src/server/server.ts b/packages/plugins/multi-app-manager/src/server/server.ts index 558f9ce35..9d957573e 100644 --- a/packages/plugins/multi-app-manager/src/server/server.ts +++ b/packages/plugins/multi-app-manager/src/server/server.ts @@ -1,11 +1,11 @@ -import Database, { IDatabaseOptions } from '@nocobase/database'; -import Application, { AppManager, InstallOptions, Plugin } from '@nocobase/server'; +import Database, { IDatabaseOptions, Transactionable } from '@nocobase/database'; +import Application, { AppManager, Plugin } from '@nocobase/server'; import lodash from 'lodash'; import * as path from 'path'; import { resolve } from 'path'; import { ApplicationModel } from './models/application'; -export type AppDbCreator = (app: Application) => Promise; +export type AppDbCreator = (app: Application, transaction?: Transactionable) => Promise; export type AppOptionsFactory = (appName: string, mainApp: Application) => any; const defaultDbCreator = async (app: Application) => { @@ -86,13 +86,6 @@ export class PluginMultiAppManager extends Plugin { return lodash.cloneDeep(lodash.omit(oldConfig, ['migrator'])); } - async install(options?: InstallOptions) { - // const repo = this.db.getRepository('collections'); - // if (repo) { - // await repo.db2cm('applications'); - // } - } - beforeLoad() { this.db.registerModels({ ApplicationModel, @@ -121,40 +114,90 @@ export class PluginMultiAppManager extends Plugin { directory: resolve(__dirname, 'collections'), }); + // after application created this.db.on('applications.afterCreateWithAssociations', async (model: ApplicationModel, options) => { const { transaction } = options; - await model.registerToMainApp(this.app, { - transaction, - dbCreator: this.appDbCreator, + const subApp = model.registerToMainApp(this.app, { appOptionsFactory: this.appOptionsFactory, }); + + // create database + await this.appDbCreator(subApp, transaction); + + // reload subApp plugin + await subApp.reload(); + + // sync subApp collections + await subApp.db.sync(); + + // install subApp + await subApp.install(); + + await subApp.reload(); }); this.db.on('applications.afterDestroy', async (model: ApplicationModel) => { await this.app.appManager.removeApplication(model.get('name') as string); }); - this.app.appManager.on( + // lazy load application + // if application not in appManager, load it from database + this.app.on( 'beforeGetApplication', - async ({ appManager, name }: { appManager: AppManager; name: string }) => { - if (!appManager.applications.has(name)) { - const existsApplication = (await this.app.db.getRepository('applications').findOne({ - filter: { - name, - }, - })) as ApplicationModel | null; + async ({ appManager, name, options }: { appManager: AppManager; name: string; options: any }) => { + if (appManager.applications.has(name)) { + return; + } - if (existsApplication) { - await existsApplication.registerToMainApp(this.app, { - dbCreator: this.appDbCreator, - appOptionsFactory: this.appOptionsFactory, - }); - } + const applicationRecord = (await this.app.db.getRepository('applications').findOne({ + filter: { + name, + }, + })) as ApplicationModel | null; + + if (!applicationRecord) { + return; + } + + const subApp = await applicationRecord.registerToMainApp(this.app, { + appOptionsFactory: this.appOptionsFactory, + }); + + // must skip load on upgrade + if (!options?.upgrading) { + await subApp.load(); } }, ); + this.app.on('afterUpgrade', async (app, options) => { + const cliArgs = options?.cliArgs; + const repository = this.db.getRepository('applications'); + const instances = await repository.find(); + for (const instance of instances) { + const subApp = await this.app.appManager.getApplication(instance.name, { + upgrading: true, + }); + + try { + console.log(`${instance.name}: upgrading...`); + + await subApp.upgrade({ + cliArgs, + }); + + await subApp.stop({ + cliArgs, + }); + } catch (error) { + console.log(`${instance.name}: upgrade failed`); + this.app.logger.error(error); + console.error(error); + } + } + }); + this.app.resourcer.registerActionHandlers({ 'applications:listPinned': async (ctx, next) => { const items = await this.db.getRepository('applications').find({ @@ -169,7 +212,13 @@ export class PluginMultiAppManager extends Plugin { this.app.acl.allow('applications', 'listPinned', 'loggedIn'); this.app.acl.registerSnippet({ - name: `pm.${this.name}.applications`, + name: ` + pm.$; + { + this.name; + } + . + applications`, actions: ['applications:*'], }); } diff --git a/packages/plugins/multi-app-share-collection/src/client/TableTransfer.tsx b/packages/plugins/multi-app-share-collection/src/client/TableTransfer.tsx new file mode 100644 index 000000000..323e3860d --- /dev/null +++ b/packages/plugins/multi-app-share-collection/src/client/TableTransfer.tsx @@ -0,0 +1,388 @@ +import { css } from '@emotion/css'; +import { connect } from '@formily/react'; +import { useCollectionManager, useRecord, useRequest } from '@nocobase/client'; +import { CollectionsGraph } from '@nocobase/utils/client'; +import { Col, Input, Modal, Row, Select, Spin, Table, Tag } from 'antd'; +import debounce from 'lodash/debounce'; +import uniq from 'lodash/uniq'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +const excludeCollections = ['users', 'roles', 'applications']; + +const useCollectionsGraph = ({ removed = [] }) => { + const { collections } = useCollectionManager(); + + const findAddable = useCallback( + (name) => { + return CollectionsGraph.connectedNodes({ + collections, + nodes: [name], + excludes: excludeCollections, + }).filter((name) => removed.includes(name)); + }, + [removed], + ); + + const findRemovable = useCallback( + (name) => { + return CollectionsGraph.connectedNodes({ + collections, + nodes: [name], + excludes: excludeCollections, + direction: 'reverse', + }).filter((name) => !removed.includes(name)); + }, + [removed], + ); + + return { + findAddable, + findRemovable, + }; +}; + +const useCollections = () => { + const record = useRecord(); + const [selected, setSelected] = useState([]); + + const res1 = useRequest( + { + url: `applications/${record.name}/collectionBlacklist:list`, + params: { + paginate: false, + params: { + fields: ['name'], + }, + }, + }, + { + onSuccess(data) { + setSelected(data.data?.map((data) => data.name)); + }, + }, + ); + + const res2 = useRequest({ + url: `collections`, + params: { + fields: ['name', 'title', 'hidden', 'category.name', 'category.color', 'category.sort'], + sort: 'sort', + paginate: false, + }, + }); + + const res3 = useRequest({ + url: `collectionCategories`, + params: { + sort: 'sort', + paginate: false, + }, + }); + + return { + loading: res1.loading || res2.loading || res3.loading, + collections: (res2.data?.data || []).filter((item) => !item.hidden && !excludeCollections.includes(item.name)), + removed: selected, + setSelected, + categories: (res3.data?.data || []).map((cat) => ({ label: cat.name, value: cat.name })), + }; +}; + +const includes = (text: string, s: string | string[]) => { + const values = Array.isArray(s) ? s : [s]; + for (const val of values) { + if (text.toLowerCase().includes(val)) { + return true; + } + } + return false; +}; + +const useRemovedDataSource = ({ collections, removed }) => { + const [filter, setFilter] = useState({ name: '', category: [] }); + const dataSource = useMemo(() => { + return collections.filter((collection) => { + const { name, title, category = [] } = collection; + const results = [removed.includes(collection.name)]; + if (filter.name) { + results.push(includes(name, filter.name) || includes(title, filter.name)); + } + if (filter.category.length > 0) { + results.push(category.some((item) => includes(item.name, filter.category))); + } + return !results.includes(false); + }); + }, [collections, removed, filter]); + const setNameFilter = useMemo( + () => + debounce((name) => { + setFilter({ + ...filter, + name, + }); + }, 300), + [], + ); + return { + dataSource, + setNameFilter, + setCategoryFilter: (category) => { + setFilter({ + ...filter, + category, + }); + }, + }; +}; + +const useAddedDataSource = ({ collections, removed }) => { + const [filter, setFilter] = useState({ name: '', category: [] }); + const dataSource = collections.filter((collection) => { + const { name, title, category = [] } = collection; + const results = [!removed.includes(collection.name)]; + if (filter.name) { + results.push(includes(name, filter.name) || includes(title, filter.name)); + } + if (filter.category.length > 0) { + results.push(category.some((item) => includes(item.name, filter.category))); + } + return !results.includes(false); + }); + const setNameFilter = useMemo( + () => + debounce((name) => { + setFilter({ + ...filter, + name, + }); + }, 300), + [], + ); + return { + dataSource, + setNameFilter, + setCategoryFilter: (category) => { + setFilter({ + ...filter, + category, + }); + }, + }; +}; + +export const TableTransfer = connect((props) => { + const { onChange } = props; + const { loading, collections, categories, removed, setSelected } = useCollections(); + const [selectedRowKeys1, setSelectedRowKeys1] = useState([]); + const [selectedRowKeys2, setSelectedRowKeys2] = useState([]); + const { findAddable, findRemovable } = useCollectionsGraph({ removed }); + const addedDataSource = useAddedDataSource({ collections, removed }); + const removedDataSource = useRemovedDataSource({ collections, removed }); + const { t } = useTranslation('multi-app-share-collection'); + const columns = useMemo( + () => [ + { + title: t('Collection display name'), + dataIndex: 'title', + }, + { + title: t('Collection name'), + dataIndex: 'name', + }, + { + title: t('Collection category'), + dataIndex: 'category', + render: (categories) => categories.map((category) => {category.name}), + }, + ], + [], + ); + if (loading) { + return ; + } + return ( +
+ tr.ant-table-row:hover > td { + background: #e6f7ff; + cursor: pointer; + } + `} + > + +
+ {t('Unshared collections')} + + removedDataSource.setNameFilter(e.target.value)} + style={{ width: '65%' }} + placeholder={t('Enter name or title...')} + allowClear + /> + +
+ !selectedRowKeys.includes(s)); + setSelected(values); + onChange(values); + setSelectedRowKeys1([]); + }, + }} + pagination={false} + size={'small'} + columns={columns} + // dataSource={collections.filter((collection) => removed.includes(collection.name))} + dataSource={removedDataSource.dataSource} + scroll={{ y: 'calc(100vh - 260px)' }} + onRow={({ name, disabled }) => ({ + onClick: () => { + if (disabled) return; + const adding = findAddable(name); + const change = () => { + const values = removed.filter((s) => !adding.includes(s)); + setSelected(values); + onChange(values); + }; + if (adding.length === 1) { + return change(); + } + Modal.confirm({ + title: t('Are you sure to add the following collections?'), + width: '60%', + content: ( +
+
adding.includes(collection.name))} + pagination={false} + scroll={{ y: '60vh' }} + /> + + ), + onOk() { + change(); + }, + }); + }, + })} + /> + + +
+ {t('Shared collections')} + + addedDataSource.setNameFilter(e.target.value)} + style={{ width: '65%' }} + placeholder={t('Enter name or title...')} + allowClear + /> + +
+
!selected.includes(collection.name))} + scroll={{ y: 'calc(100vh - 260px)' }} + onRow={({ name }) => ({ + onClick: () => { + const removing = findRemovable(name); + const change = () => { + removed.push(...removing); + const values = uniq([...removed]); + setSelected(values); + onChange(values); + }; + if (removing.length === 1) { + return change(); + } + Modal.confirm({ + title: t('Are you sure to remove the following collections?'), + width: '60%', + content: ( +
+
removing.includes(collection.name))} + pagination={false} + scroll={{ y: '60vh' }} + /> + + ), + onOk() { + change(); + }, + }); + }, + })} + /> + + + + ); +}); + +export default TableTransfer; diff --git a/packages/plugins/multi-app-share-collection/src/client/index.tsx b/packages/plugins/multi-app-share-collection/src/client/index.tsx index 5eb14892c..0e319fd6b 100644 --- a/packages/plugins/multi-app-share-collection/src/client/index.tsx +++ b/packages/plugins/multi-app-share-collection/src/client/index.tsx @@ -1,45 +1,85 @@ -import { collectionTemplates, Select, useRequest } from '@nocobase/client'; +import { useForm } from '@formily/react'; +import { useActionContext, useAPIClient, useRecord } from '@nocobase/client'; +import { tableActionColumnSchema } from '@nocobase/plugin-multi-app-manager/client'; +import { message } from 'antd'; import React from 'react'; +import { TableTransfer } from './TableTransfer'; +import { i18nText } from './utils'; -const AppSelect = (props) => { - const { data, loading } = useRequest({ - resource: 'applications', - action: 'list', - params: { - paginate: false, +const useShareCollectionAction = () => { + const form = useForm(); + const ctx = useActionContext(); + const api = useAPIClient(); + const record = useRecord(); + return { + async run() { + console.log(form.values.names); + await api.request({ + url: `applications/${record.name}/collectionBlacklist`, + data: form.values.names, + method: 'post', + }); + ctx.setVisible(false); + form.reset(); + message.success('Saved successfully'); }, - }); - return ( -