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:
parent
4427c70087
commit
ca95edf295
@ -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;
|
||||
|
@ -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);
|
||||
if (options.repository) {
|
||||
this.setRepository(options.repository);
|
||||
}
|
||||
|
||||
this.context.database.emit('afterUpdateCollection', this);
|
||||
|
||||
|
@ -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,
|
||||
});
|
||||
|
||||
|
@ -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', {
|
||||
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', {
|
||||
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, {
|
||||
|
||||
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, {
|
||||
const subApp2 = app.appManager.addSubApp(
|
||||
new Application({
|
||||
database: app.db,
|
||||
acl: false,
|
||||
});
|
||||
name: sub2,
|
||||
}),
|
||||
);
|
||||
|
||||
subApp2.resourcer.define({
|
||||
name: 'test',
|
||||
|
@ -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]);
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -80,6 +80,10 @@ export abstract class Plugin<O = any> implements PluginInterface {
|
||||
from: this.getName(),
|
||||
});
|
||||
}
|
||||
|
||||
requiredPlugins() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default Plugin;
|
||||
|
@ -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",
|
||||
|
55
packages/core/utils/src/__tests__/collection-graph.test.ts
Normal file
55
packages/core/utils/src/__tests__/collection-graph.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
@ -1,3 +1,4 @@
|
||||
export * from './collections-graph';
|
||||
export * from './date';
|
||||
export * from './merge';
|
||||
export * from './number';
|
||||
|
77
packages/core/utils/src/collections-graph.ts
Normal file
77
packages/core/utils/src/collections-graph.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -8,3 +8,4 @@ export * from './requireModule';
|
||||
export * from './toposort';
|
||||
export * from './uid';
|
||||
export * from './assign';
|
||||
export * from './collections-graph';
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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: {
|
||||
|
@ -0,0 +1,9 @@
|
||||
export default {
|
||||
'Multi-app manager': '多应用管理',
|
||||
Applications: '应用',
|
||||
'App display name': '应用名称',
|
||||
'App ID': '应用标识',
|
||||
'Pin to menu': '在菜单上显示',
|
||||
'Custom domain': '自定义域名',
|
||||
'Manage applications': '管理应用',
|
||||
};
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
10
packages/plugins/multi-app-manager/src/client/utils.tsx
Normal file
10
packages/plugins/multi-app-manager/src/client/utils.tsx
Normal 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' })}}`;
|
||||
};
|
@ -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();
|
||||
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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({
|
||||
async ({ appManager, name, options }: { appManager: AppManager; name: string; options: any }) => {
|
||||
if (appManager.applications.has(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const applicationRecord = (await this.app.db.getRepository('applications').findOne({
|
||||
filter: {
|
||||
name,
|
||||
},
|
||||
})) as ApplicationModel | null;
|
||||
|
||||
if (existsApplication) {
|
||||
await existsApplication.registerToMainApp(this.app, {
|
||||
dbCreator: this.appDbCreator,
|
||||
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:*'],
|
||||
});
|
||||
}
|
||||
|
@ -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;
|
@ -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',
|
||||
});
|
||||
return (
|
||||
<Select
|
||||
{...props}
|
||||
mode={'multiple'}
|
||||
fieldNames={{ value: 'name', label: 'displayName' }}
|
||||
options={data?.data || []}
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
ctx.setVisible(false);
|
||||
form.reset();
|
||||
message.success('Saved successfully');
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
collectionTemplates.calendar.configurableProperties.syncToApps = {
|
||||
type: 'string',
|
||||
title: '{{ t("Sync to apps") }}',
|
||||
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',
|
||||
'x-component': AppSelect,
|
||||
},
|
||||
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}</>;
|
||||
|
@ -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': '分类',
|
||||
};
|
||||
|
@ -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' })}}`;
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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',
|
||||
},
|
||||
],
|
||||
});
|
@ -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',
|
||||
},
|
||||
],
|
||||
});
|
@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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' });
|
||||
const blackList = await appCollectionBlacklistCollection.model.findAll({
|
||||
where: {
|
||||
applicationName: subApp.name,
|
||||
},
|
||||
});
|
||||
|
||||
if (blackList.length > 0) {
|
||||
ctx.action.mergeParams({
|
||||
filter: {
|
||||
'name.$in': [...results.map((item) => item['name']), 'users', 'roles'],
|
||||
'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) => {
|
||||
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) => {
|
||||
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;
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user