refactor: multi-app (#1578)

* feat: compact theme

* fix: theme

* fix: styling

* fix: margin

* feat: improve

* fix: remove console.log

* test: enable plugin test

* refactor: multi app

* test: lazy load sync plugin

* test: lazy load test

* fix: beforeGetApplication Event

* feat: loadFromDatabase options in traverseSubApps

* fix: test

* fix: multi app manager test

* chore: test

* test: should upgrade sub apps when main app upgrade

* feat: plugin require check

* chore: yarn.lock

* fix: sql typo

* feat: share collections

* fix: record name

* test: belongs to many repository

* fix: belongs to many with targetKey alias

* fix: extend collection error

* fix: transaction error

* feat: collection graph

* fix: update options in collection

* chore: collections graph

* chore: export uitls

* feat: connected nodes method in collections graph

* feat: exclude params in connected nodes

* chore: sub app collection list params

* fix: collections graph

* feat: syncToApps migration

* fix:  translation

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
ChengLei Shao 2023-03-19 23:40:42 +08:00 committed by GitHub
parent 4427c70087
commit ca95edf295
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1331 additions and 339 deletions

View File

@ -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<BelongsToManyRepository>('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;

View File

@ -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<ModelOptions, 'name' | 'hooks'>
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);

View File

@ -11,20 +11,29 @@ type CreateBelongsToManyOptions = CreateOptions;
interface IBelongsToManyRepository<M extends Model> {
find(options?: FindOptions): Promise<M[]>;
findAndCount(options?: FindAndCountOptions): Promise<[M[], number]>;
findOne(options?: FindOneOptions): Promise<M>;
// 新增并关联,存在中间表数据
create(options?: CreateOptions): Promise<M>;
// 更新,存在中间表数据
update(options?: UpdateOptions): Promise<M>;
// 删除
destroy(options?: number | string | number[] | string[] | DestroyOptions): Promise<Boolean>;
// 建立关联
set(options: TargetKey | TargetKey[] | AssociatedOptions): Promise<void>;
// 附加关联,存在中间表数据
add(options: TargetKey | TargetKey[] | AssociatedOptions): Promise<void>;
// 移除关联
remove(options: TargetKey | TargetKey[] | AssociatedOptions): Promise<void>;
toggle(options: TargetKey | { pk?: TargetKey; transaction?: Transaction }): Promise<void>;
}
@ -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,
});

View File

@ -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',

View File

@ -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<AppSelectorReturn>;
export class AppManager extends EventEmitter {
export class AppManager {
public applications: Map<string, Application> = new Map<string, Application>();
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<null | Application> {
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<boolean>;
}
applyMixins(AppManager, [AsyncEmitter]);

View File

@ -409,7 +409,6 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
}
async start(options: StartOptions = {}) {
// reconnect database
if (this.db.closed()) {
await this.db.reconnect();
}

View File

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

View File

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

View File

@ -80,6 +80,10 @@ export abstract class Plugin<O = any> implements PluginInterface {
from: this.getName(),
});
}
requiredPlugins() {
return [];
}
}
export default Plugin;

View File

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

View File

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

View File

@ -1,3 +1,4 @@
export * from './collections-graph';
export * from './date';
export * from './merge';
export * from './number';

View File

@ -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<string>; excludes?: Array<string> }) {
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;
}
}

View File

@ -8,3 +8,4 @@ export * from './requireModule';
export * from './toposort';
export * from './uid';
export * from './assign';
export * from './collections-graph';

View File

@ -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<CollectionRepository>('collections')).toBeTruthy();
db.extendCollection({
name: 'collections',
fields: [{ type: 'string', name: 'tests' }],
});
expect(Collection.getField('tests')).toBeTruthy();
const afterRepository = db.getRepository<CollectionRepository>('collections');
expect(afterRepository.load).toBeTruthy();
});
it('should set collection schema from env', async () => {
if (!db.inDialect('postgres')) {
return;

View File

@ -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 (
<a href={`/apps/${record.name}/admin/`} target={'_blank'}>
View
{t('View', { ns: 'client' })}
</a>
);
};

View File

@ -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 = (
<Menu>
{(data?.data || []).map((app) => {
@ -42,7 +44,7 @@ const MultiAppManager = () => {
history.push('/admin/settings/multi-app-manager/applications');
}}
>
Manage applications
{t('Manage applications')}
</Menu.Item>
</Menu>
);
@ -58,7 +60,10 @@ const MultiAppManager = () => {
);
};
export { tableActionColumnSchema } from './settings/schemas/applications';
export default (props) => {
const { t } = usePluginUtils();
return (
<PinnedPluginListProvider
items={{
@ -69,11 +74,11 @@ export default (props) => {
<SettingsCenterProvider
settings={{
'multi-app-manager': {
title: 'Multi-app manager',
title: t('Multi-app manager'),
icon: 'AppstoreOutlined',
tabs: {
applications: {
title: 'Applications',
title: t('Applications'),
component: () => <AppManager />,
},
// settings: {

View File

@ -0,0 +1,9 @@
export default {
'Multi-app manager': '多应用管理',
Applications: '应用',
'App display name': '应用名称',
'App ID': '应用标识',
'Pin to menu': '在菜单上显示',
'Custom domain': '自定义域名',
'Manage applications': '管理应用',
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<void>;
export type AppDbCreator = (app: Application, transaction?: Transactionable) => Promise<void>;
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<any>('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:*'],
});
}

View File

@ -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<any>([]);
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) => <Tag color={category.color}>{category.name}</Tag>),
},
],
[],
);
if (loading) {
return <Spin />;
}
return (
<div>
<Row
gutter={24}
className={css`
.ant-table-tbody > tr.ant-table-row:hover > td {
background: #e6f7ff;
cursor: pointer;
}
`}
>
<Col span={12}>
<div
className={css`
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-bottom: 8px;
`}
>
<strong style={{ fontSize: 16 }}>{t('Unshared collections')}</strong>
<Input.Group compact style={{ width: 360 }}>
<Select
onChange={(value) => {
removedDataSource.setCategoryFilter(value);
}}
mode={'multiple'}
style={{ width: '35%' }}
size={'middle'}
placeholder={t('All categories')}
options={categories}
allowClear
/>
<Input
onChange={(e) => removedDataSource.setNameFilter(e.target.value)}
style={{ width: '65%' }}
placeholder={t('Enter name or title...')}
allowClear
/>
</Input.Group>
</div>
<Table
bordered
rowKey={'name'}
rowSelection={{
type: 'checkbox',
selectedRowKeys: selectedRowKeys1,
onChange(selectedRowKeys) {
const values = removed.filter((s) => !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: (
<div>
<Table
size={'small'}
columns={columns}
dataSource={collections.filter((collection) => adding.includes(collection.name))}
pagination={false}
scroll={{ y: '60vh' }}
/>
</div>
),
onOk() {
change();
},
});
},
})}
/>
</Col>
<Col span={12}>
<div
className={css`
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-bottom: 8px;
`}
>
<strong style={{ fontSize: 16 }}>{t('Shared collections')}</strong>
<Input.Group compact style={{ width: 360 }}>
<Select
onChange={(value) => {
addedDataSource.setCategoryFilter(value);
}}
mode={'multiple'}
style={{ width: '35%' }}
size={'middle'}
placeholder={t('All categories')}
options={categories}
allowClear
/>
<Input
onChange={(e) => addedDataSource.setNameFilter(e.target.value)}
style={{ width: '65%' }}
placeholder={t('Enter name or title...')}
allowClear
/>
</Input.Group>
</div>
<Table
bordered
rowKey={'name'}
rowSelection={{
type: 'checkbox',
selectedRowKeys: selectedRowKeys2,
onChange(selectedRowKeys) {
const values = uniq(removed.concat(selectedRowKeys));
setSelected(values);
onChange(values);
setSelectedRowKeys2([]);
},
}}
pagination={false}
size={'small'}
columns={columns}
dataSource={addedDataSource.dataSource}
// dataSource={collections.filter((collection) => !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: (
<div>
<Table
size={'small'}
columns={columns}
dataSource={collections.filter((collection) => removing.includes(collection.name))}
pagination={false}
scroll={{ y: '60vh' }}
/>
</div>
),
onOk() {
change();
},
});
},
})}
/>
</Col>
</Row>
</div>
);
});
export default TableTransfer;

View File

@ -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 (
<Select
{...props}
mode={'multiple'}
fieldNames={{ value: 'name', label: 'displayName' }}
options={data?.data || []}
loading={loading}
/>
);
};
};
collectionTemplates.calendar.configurableProperties.syncToApps = {
type: 'string',
title: '{{ t("Sync to apps") }}',
'x-decorator': 'FormItem',
'x-component': AppSelect,
const updateSchema = tableActionColumnSchema.properties.update;
const deleteSchema = tableActionColumnSchema.properties.delete;
delete tableActionColumnSchema.properties.update;
delete tableActionColumnSchema.properties.delete;
tableActionColumnSchema.properties['collection'] = {
type: 'void',
title: i18nText('Share collections'),
'x-component': 'Action.Link',
'x-component-props': {},
properties: {
drawer: {
type: 'void',
'x-component': 'Action.Drawer',
'x-component-props': {
width: '95vw',
},
'x-decorator': 'Form',
title: i18nText('Share collections'),
properties: {
names: {
type: 'array',
'x-component': TableTransfer,
'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: useShareCollectionAction,
},
},
},
},
},
},
},
};
collectionTemplates.general.configurableProperties.syncToApps = {
type: 'string',
title: '{{ t("Sync to apps") }}',
'x-decorator': 'FormItem',
'x-component': AppSelect,
};
collectionTemplates.tree.configurableProperties.syncToApps = {
type: 'string',
title: '{{ t("Sync to apps") }}',
'x-decorator': 'FormItem',
'x-component': AppSelect,
};
tableActionColumnSchema.properties.update = updateSchema;
tableActionColumnSchema.properties.delete = deleteSchema;
export default (props) => {
return <>{props.children}</>;

View File

@ -0,0 +1,13 @@
export default {
'Share collections': '共享数据表',
'Unshared collections': '未共享的数据表',
'Shared collections': '已共享的数据表',
'All categories': '所有分类',
'Enter name or title...': '输入数据表标题或标识',
'Are you sure to add the following collections?': '确定添加以下数据表?',
'Are you sure to remove the following collections?': '确定移除以下数据表?',
'Collection display name': '标题',
'Collection name': '标识',
'Collection category': '分类',
};

View File

@ -0,0 +1,11 @@
import { useTranslation } from 'react-i18next';
export const usePluginUtils = () => {
const { t } = useTranslation('multi-app-share-collection');
return { t };
};
export const i18nText = (text) => {
return `{{t("${text}", { ns: 'multi-app-share-collection' })}}`;
};

View File

@ -1,7 +1,43 @@
import { Database } from '@nocobase/database';
import { MockServer, mockServer } from '@nocobase/test';
import Plugin from '..';
const pgOnly = () => (process.env.DB_DIALECT == 'postgres' ? describe : describe.skip);
import { BelongsToManyRepository, Database } from '@nocobase/database';
import { MockServer, mockServer, pgOnly } from '@nocobase/test';
pgOnly()('enable plugin', () => {
let mainDb: Database;
let mainApp: MockServer;
beforeEach(async () => {
const app = mockServer({
acl: false,
plugins: ['nocobase'],
});
await app.load();
await app.install({
clean: true,
});
mainApp = app;
mainDb = mainApp.db;
});
afterEach(async () => {
await mainApp.destroy();
});
it('should throw error when enable plugin, when multi-app plugin is not enabled', async () => {
console.log('enable share collection plugin');
let error;
try {
await mainApp.pm.enable('multi-app-share-collection');
} catch (e) {
error = e;
}
expect(error.message).toBe('multi-app-share-collection plugin need multi-app-manager plugin enabled');
});
});
pgOnly()('collection sync', () => {
let mainDb: Database;
@ -90,6 +126,47 @@ pgOnly()('collection sync', () => {
expect(userInSub2.get('roles').map((item) => item.name)).toContain(defaultRoleInSub2.name);
});
it('should in sub app schema when sub app lazy load', async () => {
await mainApp.db.getRepository('applications').create({
values: {
name: 'sub1',
},
});
await mainApp.appManager.removeApplication('sub1');
const sub1 = await mainApp.appManager.getApplication('sub1');
expect(sub1.db.options.schema).toBe('sub1');
});
it('should sync plugin status into lazy load sub app', async () => {
await mainApp.db.getRepository('applications').create({
values: {
name: 'sub1',
},
});
await mainApp.appManager.removeApplication('sub1');
await mainApp.pm.enable('map');
const sub1 = await mainApp.appManager.getApplication('sub1');
await sub1.reload();
console.log(sub1.pm.plugins);
expect(sub1.pm.plugins.get('map').options.enabled).toBeTruthy();
const sub1MapPlugin = await sub1.db.getRepository('applicationPlugins').findOne({
filter: {
name: 'map',
},
});
expect(sub1MapPlugin.get('enabled')).toBeTruthy();
});
it('should sync plugin status between apps', async () => {
await mainApp.db.getRepository('applications').create({
values: {
@ -109,9 +186,9 @@ pgOnly()('collection sync', () => {
expect((await getSubAppMapRecord(sub1)).get('enabled')).toBeFalsy();
await mainApp.pm.enable('map');
expect((await getSubAppMapRecord(sub1)).get('enabled')).toBeTruthy();
// create new app sub2
await mainApp.db.getRepository('applications').create({
values: {
name: 'sub2',
@ -120,6 +197,7 @@ pgOnly()('collection sync', () => {
const sub2 = await mainApp.appManager.getApplication('sub2');
expect((await getSubAppMapRecord(sub2)).get('enabled')).toBeTruthy();
expect(sub2.pm.plugins.get('map').options.enabled).toBeTruthy();
});
it('should not sync roles in sub app', async () => {
@ -374,4 +452,38 @@ pgOnly()('collection sync', () => {
const sub1TestCollection = await sub1.db.getCollection('testCollection');
expect(sub1TestCollection.collectionSchema()).toEqual(mainTestCollection.collectionSchema());
});
it('should set collection black list', async () => {
await mainApp.db.getRepository('applications').create({
values: {
name: 'sub1',
},
});
await mainApp.db.getRepository('collections').create({
values: {
name: 'testCollection',
fields: [
{
name: 'testField',
type: 'string',
},
],
},
});
const BlackListRepository = await mainApp.db
.getCollection('applications')
.repository.relation<BelongsToManyRepository>('collectionBlacklist')
.of('sub1');
const blackList = await BlackListRepository.find();
expect(blackList.length).toBe(0);
await BlackListRepository.toggle('testCollection');
const blackList1 = await BlackListRepository.find();
expect(blackList1.length).toBe(1);
});
});

View File

@ -0,0 +1,17 @@
import { extendCollection } from '@nocobase/database';
export default extendCollection({
name: 'applications',
fields: [
{
type: 'belongsToMany',
name: 'collectionBlacklist',
through: 'appCollectionBlacklist',
target: 'collections',
targetKey: 'name',
otherKey: 'collectionName',
sourceKey: 'name',
foreignKey: 'applicationName',
},
],
});

View File

@ -0,0 +1,17 @@
import { extendCollection } from '@nocobase/database';
export default extendCollection({
name: 'collections',
fields: [
{
type: 'belongsToMany',
name: 'collectionBlacklist',
through: 'appCollectionBlacklist',
target: 'applications',
targetKey: 'name',
otherKey: 'applicationName',
sourceKey: 'name',
foreignKey: 'collectionName',
},
],
});

View File

@ -0,0 +1,67 @@
import { Migration } from '@nocobase/server';
import { CollectionsGraph } from '@nocobase/utils';
export default class extends Migration {
async up() {
if (!this.app.db.getCollection('applications')) return;
await this.app.db.getCollection('collections').repository.destroy({
where: {
name: 'applications',
},
});
const appSyncedCollections: Map<string, Set<string>> = new Map();
const collections = await this.app.db.getCollection('collections').repository.find();
const collectionsData = collections.map((collection) => collection.toJSON());
for (const collection of collections) {
const collectionSyncToApps = collection.get('syncToApps');
if (collectionSyncToApps) {
for (const app of collectionSyncToApps) {
if (!appSyncedCollections.has(app)) {
appSyncedCollections.set(app, new Set());
}
appSyncedCollections.get(app).add(collection.name);
}
}
}
const allCollections = collections.map((collection) => collection.name);
const appCollectionBlacklist = this.app.db.getCollection('appCollectionBlacklist');
for (const [app, syncedCollections] of appSyncedCollections) {
const blackListCollections = allCollections.filter(
(collection) => !syncedCollections.has(collection) && !['users', 'roles'].includes(collection),
);
const connectedCollections = CollectionsGraph.connectedNodes({
collections: collectionsData,
nodes: blackListCollections,
direction: 'reverse',
});
console.log(
JSON.stringify(
{
app,
connectedCollections,
},
null,
2,
),
);
await appCollectionBlacklist.model.bulkCreate(
connectedCollections.map((collection) => {
return {
applicationName: app,
collectionName: collection,
};
}),
);
}
}
}

View File

@ -1,6 +1,7 @@
import PluginMultiAppManager from '@nocobase/plugin-multi-app-manager';
import { Application, InstallOptions, Plugin } from '@nocobase/server';
import { Application, Plugin } from '@nocobase/server';
import lodash from 'lodash';
import { resolve } from 'path';
const subAppFilteredPlugins = ['multi-app-share-collection', 'multi-app-manager'];
@ -60,22 +61,53 @@ class SubAppPlugin extends Plugin {
}
if (actionName === 'list' && resourceName === 'collections') {
const Collection = mainApp.db.getCollection('collections');
const query = `
select * from "${Collection.collectionSchema()}"."${Collection.model.tableName}"
where (options->'syncToApps')::jsonb ? '${subApp.name}'
`;
const appCollectionBlacklistCollection = mainApp.db.getCollection('appCollectionBlacklist');
const results = await mainApp.db.sequelize.query(query, { type: 'SELECT' });
ctx.action.mergeParams({
filter: {
'name.$in': [...results.map((item) => item['name']), 'users', 'roles'],
const blackList = await appCollectionBlacklistCollection.model.findAll({
where: {
applicationName: subApp.name,
},
});
if (blackList.length > 0) {
ctx.action.mergeParams({
filter: {
'name.$notIn': blackList.map((item) => item.get('collectionName')),
},
});
}
}
await next();
});
// new subApp sync plugins from mainApp
subApp.on('beforeInstall', async () => {
const subAppPluginsCollection = subApp.db.getCollection('applicationPlugins');
const mainAppPluginsCollection = mainApp.db.getCollection('applicationPlugins');
// delete old collection
await subApp.db.sequelize.query(`TRUNCATE ${subAppPluginsCollection.quotedTableName()}`);
await subApp.db.sequelize.query(`
INSERT INTO ${subAppPluginsCollection.quotedTableName()}
SELECT *
FROM ${mainAppPluginsCollection.quotedTableName()}
WHERE "name" not in ('multi-app-manager', 'multi-app-share-collection');
`);
const sequenceNameSql = `SELECT pg_get_serial_sequence('"${subAppPluginsCollection.collectionSchema()}"."${
subAppPluginsCollection.model.tableName
}"', 'id')`;
const sequenceName = (await subApp.db.sequelize.query(sequenceNameSql, { type: 'SELECT' })) as any;
await subApp.db.sequelize.query(`
SELECT setval('${
sequenceName[0]['pg_get_serial_sequence']
}', (SELECT max("id") FROM ${subAppPluginsCollection.quotedTableName()}));
`);
console.log(`sync plugins from ${mainApp.name} app to sub app ${subApp.name}`);
});
}
}
@ -87,7 +119,22 @@ export class MultiAppShareCollectionPlugin extends Plugin {
throw new Error('multi-app-share-collection plugin only support postgres');
}
const traverseSubApps = async (callback: (subApp: Application) => void) => {
const traverseSubApps = async (
callback: (subApp: Application) => void,
options?: {
loadFromDatabase: boolean;
},
) => {
if (lodash.get(options, 'loadFromDatabase')) {
for (const application of await this.app.db.getCollection('applications').repository.find()) {
const appName = application.get('name');
const subApp = await this.app.appManager.getApplication(appName);
await callback(subApp);
}
return;
}
const subApps = [...this.app.appManager.applications.values()];
for (const subApp of subApps) {
@ -95,7 +142,7 @@ export class MultiAppShareCollectionPlugin extends Plugin {
}
};
this.app.on('beforeSubAppLoad', async ({ subApp }: { subApp: Application }) => {
this.app.on('afterSubAppAdded', (subApp) => {
subApp.plugin(SubAppPlugin, { name: 'sub-app', mainApp: this.app });
});
@ -151,17 +198,27 @@ export class MultiAppShareCollectionPlugin extends Plugin {
});
this.app.on('afterEnablePlugin', async (pluginName) => {
await traverseSubApps(async (subApp) => {
if (subAppFilteredPlugins.includes(pluginName)) return;
await subApp.pm.enable(pluginName);
});
await traverseSubApps(
async (subApp) => {
if (subAppFilteredPlugins.includes(pluginName)) return;
await subApp.pm.enable(pluginName);
},
{
loadFromDatabase: true,
},
);
});
this.app.on('afterDisablePlugin', async (pluginName) => {
await traverseSubApps(async (subApp) => {
if (subAppFilteredPlugins.includes(pluginName)) return;
await subApp.pm.disable(pluginName);
});
await traverseSubApps(
async (subApp) => {
if (subAppFilteredPlugins.includes(pluginName)) return;
await subApp.pm.disable(pluginName);
},
{
loadFromDatabase: true,
},
);
});
this.app.db.on('field.afterRemove', (removedField) => {
@ -194,30 +251,24 @@ export class MultiAppShareCollectionPlugin extends Plugin {
return;
}
this.app.on('beforeSubAppInstall', async ({ subApp }) => {
const subAppPluginsCollection = subApp.db.getCollection('applicationPlugins');
const mainAppPluginsCollection = this.app.db.getCollection('applicationPlugins');
await this.db.import({
directory: resolve(__dirname, 'collections'),
});
// delete old collection
await subApp.db.sequelize.query(`TRUNCATE ${subAppPluginsCollection.quotedTableName()}`);
// this.db.addMigrations({
// namespace: 'multi-app-share-collection',
// directory: resolve(__dirname, './migrations'),
// });
await subApp.db.sequelize.query(`
INSERT INTO ${subAppPluginsCollection.quotedTableName()}
SELECT *
FROM ${mainAppPluginsCollection.quotedTableName()}
WHERE "name" not in ('multi-app-manager', 'multi-app-share-collection');
`);
const sequenceNameSql = `SELECT pg_get_serial_sequence('"${subAppPluginsCollection.collectionSchema()}"."${
subAppPluginsCollection.model.tableName
}"', 'id')`;
const sequenceName = await subApp.db.sequelize.query(sequenceNameSql, { type: 'SELECT' });
await subApp.db.sequelize.query(`
SELECT setval('${
sequenceName[0].pg_get_serial_sequence
}', (SELECT max("id") FROM ${subAppPluginsCollection.quotedTableName()}));
`);
this.app.resourcer.registerActionHandlers({
'applications:shareCollections': async (ctx, next) => {
const { filterByTk, values } = ctx.action.params;
ctx.body = {
filterByTk,
values,
};
await next();
},
});
// 子应用启动参数
@ -266,15 +317,9 @@ export class MultiAppShareCollectionPlugin extends Plugin {
});
}
async install(options?: InstallOptions) {}
async afterEnable() {}
async afterDisable() {
// test
requiredPlugins(): any[] {
return ['multi-app-manager'];
}
async remove() {}
}
export default MultiAppShareCollectionPlugin;

View File

@ -78,10 +78,12 @@ export class PresetNocoBase extends Plugin {
this.app.on('beforeUpgrade', async (options) => {
const result = await this.app.version.satisfies('<0.8.0-alpha.1');
if (result) {
console.log(`Initialize all built-in plugins beforeUpgrade`);
await this.addBuiltInPlugins({ method: 'upgrade' });
}
const builtInPlugins = this.getBuiltInPlugins();
const plugins = await this.app.db.getRepository('applicationPlugins').find();
const pluginNames = plugins.map((p) => p.name);