feat: multiple apps (#1540)
* chore: skip yarn install in pm command * feat: dump sub app by sub app name * feat: dump & restore by sub app * chore: enable application name to edit * chore: field belongsTo uiSchema * test: drop schema * feat: uiSchema migrator * fix: test * fix: remove uiSchema * fix: rerun migration * chore: migrate fieldsHistory uiSchema * fix: set uiSchema options * chore: transaction params * fix: sql error in mysql * fix: sql compatibility * feat: collection group api * chore: restore & dump action template * chore: tmp commit * chore: collectionGroupAction * feat: dumpableCollection api * refactor: dump command * fix: remove uiSchemaUid * chore: get uiSchemaUid from tmp field * feat: return dumped file url in dumper.dump * feat: dump api * refactor: collection groyoup * chore: comment * feat: restore command force option * feat: dump with collection groups * refactor: restore command * feat: restore http api * fix: test * fix: test * fix: restore test * chore: volta pin * fix: sub app load collection options * fix: stop sub app * feat: add stopped status to application to prevent duplicate application stop * chore: tmp commit * test: upgrade * feat: pass upgrade event to sub app * fix: app manager client * fix: remove stopped status * fix: emit beforeStop event * feat: support dump & restore subApp through api * chore: dumpable collections api * refactor: getTableNameWithSchema * fix: schema name * feat: cname * refactor: collection 同步实现方式 * refactor: move collection group manager to database * fix: test * fix: remove uiSchema * fix: uiSchema * fix: remove settings * chore: plugin enable & disable event * feat: modal warning * fix: users_jobs namespace * fix: rolesUischemas namespace * fix: am snippet * feat: beforeSubAppInstall event * fix: improve NOCOBASE_LOCALE_KEY & NOCOBASE_ROLE_KEY --------- Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
parent
243f9e2448
commit
0832a56868
1
.gitignore
vendored
1
.gitignore
vendored
@ -20,3 +20,4 @@ dist/
|
||||
docker/**/storage
|
||||
cache/diskstore-*
|
||||
*.nbdump
|
||||
storage/duplicator/*
|
1
packages/app/client/src/plugins/multi-app-manager.ts
Normal file
1
packages/app/client/src/plugins/multi-app-manager.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from '@nocobase/plugin-multi-app-manager/client';
|
@ -3,14 +3,13 @@ import { i18n as i18next } from 'i18next';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
import { ACLProvider, ACLShortcut } from '../acl';
|
||||
import { ACLProvider } from '../acl';
|
||||
import { AntdConfigProvider } from '../antd-config-provider';
|
||||
import { APIClient, APIClientProvider } from '../api-client';
|
||||
import { BlockSchemaComponentProvider } from '../block-provider';
|
||||
import { CollectionManagerShortcut } from '../collection-manager';
|
||||
import { RemoteDocumentTitleProvider } from '../document-title';
|
||||
import { i18n } from '../i18n';
|
||||
import { PluginManagerProvider } from '../plugin-manager';
|
||||
import { PinnedPluginListProvider } from '../plugin-manager';
|
||||
import PMProvider, { PluginManagerLink, SettingsCenterDropdown } from '../pm';
|
||||
import {
|
||||
AdminLayout,
|
||||
@ -18,17 +17,17 @@ import {
|
||||
RemoteRouteSwitchProvider,
|
||||
RouteSchemaComponent,
|
||||
RouteSwitch,
|
||||
useRoutes,
|
||||
useRoutes
|
||||
} from '../route-switch';
|
||||
import {
|
||||
AntdSchemaComponentProvider,
|
||||
DesignableSwitch,
|
||||
MenuItemInitializers,
|
||||
SchemaComponentProvider,
|
||||
SchemaComponentProvider
|
||||
} from '../schema-component';
|
||||
import { SchemaInitializerProvider } from '../schema-initializer';
|
||||
import { BlockTemplateDetails, BlockTemplatePage, SchemaTemplateShortcut } from '../schema-templates';
|
||||
import { SystemSettingsProvider, SystemSettingsShortcut } from '../system-settings';
|
||||
import { BlockTemplateDetails, BlockTemplatePage } from '../schema-templates';
|
||||
import { SystemSettingsProvider } from '../system-settings';
|
||||
import { SigninPage, SignupPage } from '../user';
|
||||
import { SigninPageExtensionProvider } from '../user/SigninPageExtension';
|
||||
import { compose } from './compose';
|
||||
@ -94,18 +93,16 @@ export class Application {
|
||||
},
|
||||
});
|
||||
this.use(SystemSettingsProvider);
|
||||
this.use(PluginManagerProvider, {
|
||||
components: {
|
||||
ACLShortcut,
|
||||
DesignableSwitch,
|
||||
CollectionManagerShortcut,
|
||||
SystemSettingsShortcut,
|
||||
SchemaTemplateShortcut,
|
||||
PluginManagerLink,
|
||||
SettingsCenterDropdown,
|
||||
this.use(PinnedPluginListProvider, {
|
||||
items: {
|
||||
ui: { order: 100, component: 'DesignableSwitch', pin: true, snippet: 'ui.*' },
|
||||
pm: { order: 200, component: 'PluginManagerLink', pin: true, snippet: 'pm' },
|
||||
sc: { order: 300, component: 'SettingsCenterDropdown', pin: true, snippet: 'pm.*' },
|
||||
},
|
||||
});
|
||||
this.use(SchemaComponentProvider, { components: { Link, NavLink } });
|
||||
this.use(SchemaComponentProvider, {
|
||||
components: { Link, NavLink, DesignableSwitch, PluginManagerLink, SettingsCenterDropdown },
|
||||
});
|
||||
this.use(SchemaInitializerProvider, {
|
||||
initializers: {
|
||||
MenuItemInitializers,
|
||||
|
@ -21,7 +21,7 @@ export const CollectionHistoryProvider: React.FC = (props) => {
|
||||
action: 'list',
|
||||
params: {
|
||||
paginate: false,
|
||||
appends: ['fields', 'fields.uiSchema'],
|
||||
appends: ['fields'],
|
||||
filter: {
|
||||
// inherit: false,
|
||||
},
|
||||
|
@ -38,7 +38,7 @@ export const RemoteCollectionManagerProvider = (props: any) => {
|
||||
action: 'list',
|
||||
params: {
|
||||
paginate: false,
|
||||
appends: ['fields', 'fields.uiSchema', 'category'],
|
||||
appends: ['fields', 'category'],
|
||||
filter: {
|
||||
// inherit: false,
|
||||
},
|
||||
|
@ -6,7 +6,7 @@ import set from 'lodash/set';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAPIClient, useRequest } from '../../api-client';
|
||||
import { useRecord, RecordProvider } from '../../record-provider';
|
||||
import { RecordProvider, useRecord } from '../../record-provider';
|
||||
import { ActionContext, SchemaComponent, useActionContext, useCompile } from '../../schema-component';
|
||||
import { useCancelAction, useUpdateAction } from '../action-hooks';
|
||||
import { useCollectionManager } from '../hooks';
|
||||
@ -131,7 +131,7 @@ export const EditFieldAction = (props) => {
|
||||
onClick={async () => {
|
||||
const { data } = await api.resource('collections.fields', record.collectionName).get({
|
||||
filterByTk: record.name,
|
||||
appends: ['uiSchema', 'reverseField'],
|
||||
appends: ['reverseField'],
|
||||
});
|
||||
setData(data?.data);
|
||||
const interfaceConf = getInterface(record.interface);
|
||||
|
@ -156,7 +156,7 @@ export const OverridingFieldAction = (props) => {
|
||||
if (!disabled) {
|
||||
const { data } = await api.resource('collections.fields', record.collectionName).get({
|
||||
filterByTk: record.name,
|
||||
appends: ['uiSchema', 'reverseField'],
|
||||
appends: ['reverseField'],
|
||||
});
|
||||
setData(data?.data);
|
||||
const interfaceConf = getInterface(record.interface);
|
||||
|
@ -6,7 +6,7 @@ import set from 'lodash/set';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAPIClient, useRequest } from '../../api-client';
|
||||
import { useRecord, RecordProvider } from '../../record-provider';
|
||||
import { RecordProvider, useRecord } from '../../record-provider';
|
||||
import { ActionContext, SchemaComponent, useCompile } from '../../schema-component';
|
||||
import { useCollectionManager } from '../hooks';
|
||||
import { IField } from '../interfaces/types';
|
||||
@ -82,7 +82,7 @@ export const ViewFieldAction = (props) => {
|
||||
onClick={async () => {
|
||||
const { data } = await api.resource('collections.fields', record.collectionName).get({
|
||||
filterByTk: record.name,
|
||||
appends: ['uiSchema', 'reverseField'],
|
||||
appends: ['reverseField'],
|
||||
});
|
||||
setData(data?.data);
|
||||
const interfaceConf = getInterface(record.interface);
|
||||
|
@ -76,7 +76,7 @@ export const collectionFieldSchema: ISchema = {
|
||||
'interface.$not': null,
|
||||
},
|
||||
sort: ['sort'],
|
||||
appends: ['uiSchema'],
|
||||
// appends: ['uiSchema'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useForm } from '@formily/react';
|
||||
import { useField, useForm } from '@formily/react';
|
||||
import { message } from 'antd';
|
||||
import { useEffect } from 'react';
|
||||
import { useCollection, useCollectionManager } from '.';
|
||||
@ -185,15 +185,19 @@ export const useFilterAction = () => {
|
||||
|
||||
export const useCreateAction = () => {
|
||||
const form = useForm();
|
||||
const field = useField();
|
||||
const ctx = useActionContext();
|
||||
const { refresh } = useResourceActionContext();
|
||||
const { resource } = useResourceContext();
|
||||
return {
|
||||
async run() {
|
||||
await form.submit();
|
||||
field.data = field.data || {};
|
||||
field.data.loading = true;
|
||||
await resource.create({ values: form.values });
|
||||
ctx.setVisible(false);
|
||||
await form.reset();
|
||||
field.data.loading = false;
|
||||
refresh();
|
||||
},
|
||||
};
|
||||
@ -242,6 +246,7 @@ export const useMoveAction = () => {
|
||||
};
|
||||
|
||||
export const useUpdateAction = () => {
|
||||
const field = useField();
|
||||
const form = useForm();
|
||||
const ctx = useActionContext();
|
||||
const { refresh } = useResourceActionContext();
|
||||
@ -250,9 +255,12 @@ export const useUpdateAction = () => {
|
||||
return {
|
||||
async run() {
|
||||
await form.submit();
|
||||
field.data = field.data || {};
|
||||
field.data.loading = true;
|
||||
await resource.update({ filterByTk, values: form.values });
|
||||
ctx.setVisible(false);
|
||||
await form.reset();
|
||||
field.data.loading = false;
|
||||
refresh();
|
||||
},
|
||||
};
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
useCollectionManager,
|
||||
useRecord,
|
||||
useRecordIndex,
|
||||
useRequest,
|
||||
useRequest
|
||||
} from '../';
|
||||
import { useAPIClient } from '../api-client';
|
||||
|
||||
@ -183,7 +183,7 @@ export const SubFieldDataSourceProvider = observer((props) => {
|
||||
.resource('fields')
|
||||
.list({
|
||||
paginate: false,
|
||||
appends: ['uiSchema'],
|
||||
// appends: ['uiSchema'],
|
||||
sort: 'sort',
|
||||
filter: {
|
||||
parentKey: record.key,
|
||||
|
@ -0,0 +1,51 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { SchemaOptionsContext } from '@formily/react';
|
||||
import { get } from 'lodash';
|
||||
import React, { useContext } from 'react';
|
||||
import { useACLRoleContext } from '../acl/ACLProvider';
|
||||
import { PinnedPluginListContext } from './context';
|
||||
|
||||
export const PinnedPluginListProvider: React.FC<{ items: any }> = (props) => {
|
||||
const { children, items } = props;
|
||||
const ctx = useContext(PinnedPluginListContext);
|
||||
return (
|
||||
<PinnedPluginListContext.Provider value={{ items: { ...ctx.items, ...items } }}>
|
||||
{children}
|
||||
</PinnedPluginListContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const PinnedPluginList = () => {
|
||||
const { allowAll, snippets } = useACLRoleContext();
|
||||
const getSnippetsAllow = (aclKey) => {
|
||||
return allowAll || snippets?.includes(aclKey);
|
||||
};
|
||||
const ctx = useContext(PinnedPluginListContext);
|
||||
const { components } = useContext(SchemaOptionsContext);
|
||||
return (
|
||||
<div
|
||||
className={css`
|
||||
.ant-btn {
|
||||
border: 0;
|
||||
height: 46px;
|
||||
width: 46px;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
`}
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
{Object.values<any>(ctx.items)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.filter((v) => getSnippetsAllow(v.snippet))
|
||||
.map((item) => {
|
||||
const Action = get(components, item.component);
|
||||
return Action ? <Action /> : null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,3 +1,4 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const PluginManagerContext = createContext<any>({});
|
||||
export const PinnedPluginListContext = createContext({ items: {} });
|
||||
|
@ -1,3 +1,5 @@
|
||||
export * from './context';
|
||||
export * from './PinnedPluginListProvider';
|
||||
export * from './PluginManager';
|
||||
export * from './PluginManagerProvider';
|
||||
|
||||
|
@ -1,27 +1,23 @@
|
||||
import { AppstoreAddOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { Dropdown, Menu } from 'antd';
|
||||
import { ApiOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { Button, Dropdown, Menu } from 'antd';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useACLRoleContext } from '../acl/ACLProvider';
|
||||
import { PluginManager } from '../plugin-manager';
|
||||
import { ActionContext, useCompile } from '../schema-component';
|
||||
import { getPluginsTabs, SettingsCenterContext } from './index';
|
||||
|
||||
export const PluginManagerLink = () => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
return (
|
||||
<ActionContext.Provider value={{ visible, setVisible }}>
|
||||
<PluginManager.Toolbar.Item
|
||||
icon={<AppstoreAddOutlined />}
|
||||
<Button
|
||||
icon={<ApiOutlined />}
|
||||
title={t('Plugin manager')}
|
||||
onClick={() => {
|
||||
history.push('/admin/pm/list');
|
||||
}}
|
||||
/>
|
||||
</ActionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -50,7 +46,6 @@ export const SettingsCenterDropdown = () => {
|
||||
placement="bottom"
|
||||
overlay={
|
||||
<Menu>
|
||||
<Menu.ItemGroup title={t('Bookmark')}>
|
||||
{bookmarkTabs.map((tab) => {
|
||||
return (
|
||||
<Menu.Item
|
||||
@ -63,7 +58,6 @@ export const SettingsCenterDropdown = () => {
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</Menu.ItemGroup>
|
||||
<Menu.Divider></Menu.Divider>
|
||||
<Menu.Item
|
||||
onClick={() => {
|
||||
@ -76,10 +70,10 @@ export const SettingsCenterDropdown = () => {
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<PluginManager.Toolbar.Item
|
||||
<Button
|
||||
icon={<SettingOutlined />}
|
||||
// title={t('All plugin settings')}
|
||||
></PluginManager.Toolbar.Item>
|
||||
/>
|
||||
</Dropdown>
|
||||
</ActionContext.Provider>
|
||||
);
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { DeleteOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { css } from '@emotion/css';
|
||||
import { Avatar, Card, Layout, Menu, message, PageHeader, Popconfirm, Result, Spin, Switch, Tabs } from 'antd';
|
||||
import { Avatar, Card, Layout, Menu, message, Modal, PageHeader, Popconfirm, Result, Spin, Switch, Tabs } from 'antd';
|
||||
import { sortBy } from 'lodash';
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@ -53,11 +53,20 @@ const PluginCard = (props) => {
|
||||
<Switch
|
||||
size={'small'}
|
||||
onChange={async (checked) => {
|
||||
Modal.warn({
|
||||
title: checked ? t('Plugin staring') : t('Plugin stopping'),
|
||||
content: t('The application is reloading, please do not close the page.'),
|
||||
okButtonProps: {
|
||||
style: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
await api.request({
|
||||
url: `pm:${checked ? 'enable' : 'disable'}/${data.name}`,
|
||||
});
|
||||
message.success(checked ? t('插件激活成功') : t('插件禁用成功'));
|
||||
window.location.reload();
|
||||
// message.success(checked ? t('插件激活成功') : t('插件禁用成功'));
|
||||
}}
|
||||
defaultChecked={data.enabled}
|
||||
></Switch>,
|
||||
|
@ -10,15 +10,15 @@ import {
|
||||
CurrentUserProvider,
|
||||
findByUid,
|
||||
findMenuItem,
|
||||
PinnedPluginList,
|
||||
RemoteCollectionManagerProvider,
|
||||
RemotePluginManagerToolbar,
|
||||
RemoteSchemaTemplateManagerProvider,
|
||||
SchemaComponent,
|
||||
useACLRoleContext,
|
||||
useDocumentTitle,
|
||||
useRequest,
|
||||
useRoute,
|
||||
useSystemSettings,
|
||||
useSystemSettings
|
||||
} from '../../../';
|
||||
import { useCollectionManager } from '../../../collection-manager';
|
||||
|
||||
@ -213,7 +213,7 @@ export const InternalAdminLayout = (props: any) => {
|
||||
z-index: 10;
|
||||
`}
|
||||
>
|
||||
<RemotePluginManagerToolbar />
|
||||
<PinnedPluginList />
|
||||
<CurrentUser />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { HighlightOutlined } from '@ant-design/icons';
|
||||
import { Button } from 'antd';
|
||||
import React from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDesignable } from '..';
|
||||
import { PluginManager } from '../../plugin-manager';
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
|
||||
export const DesignableSwitch = () => {
|
||||
const { designable, setDesignable } = useDesignable();
|
||||
@ -14,18 +14,18 @@ export const DesignableSwitch = () => {
|
||||
}
|
||||
|
||||
// 快捷键切换编辑状态
|
||||
useHotkeys('Ctrl+Shift+U', () => setDesignable(!designable), [designable])
|
||||
useHotkeys('Ctrl+Shift+U', () => setDesignable(!designable), [designable]);
|
||||
|
||||
return (
|
||||
<PluginManager.Toolbar.Item
|
||||
selected={designable}
|
||||
<Button
|
||||
// selected={designable}
|
||||
icon={<HighlightOutlined />}
|
||||
title={t('UI Editor')}
|
||||
subtitle={'Ctrl+Shift+U'}
|
||||
// subtitle={'Ctrl+Shift+U'}
|
||||
style={style}
|
||||
onClick={() => {
|
||||
setDesignable(!designable);
|
||||
}}
|
||||
></PluginManager.Toolbar.Item>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -1,18 +1,17 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Dropdown, Menu } from 'antd';
|
||||
import React, { createContext, useState, useContext } from 'react';
|
||||
import React, { createContext, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useAPIClient, useCurrentUserContext } from '..';
|
||||
import { useRequest } from '../api-client';
|
||||
import { useCurrentAppInfo } from '../appInfo/CurrentAppInfoProvider';
|
||||
import { ChangePassword } from './ChangePassword';
|
||||
import { EditProfile } from './EditProfile';
|
||||
import { LanguageSettings } from './LanguageSettings';
|
||||
import { SwitchRole } from './SwitchRole';
|
||||
import {useCurrentAppInfo} from '../appInfo/CurrentAppInfoProvider'
|
||||
|
||||
|
||||
const ApplicationVersion = () => {
|
||||
const data=useCurrentAppInfo();
|
||||
const data = useCurrentAppInfo();
|
||||
return (
|
||||
<Menu.Item key="version" disabled>
|
||||
Version {data?.data?.version}
|
||||
@ -58,7 +57,17 @@ export const CurrentUser = () => {
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<span style={{ cursor: 'pointer', border: 0, padding: '16px', color: 'rgba(255, 255, 255, 0.65)' }}>
|
||||
<span
|
||||
className={css`
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
line-height: 12px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
`}
|
||||
style={{ cursor: 'pointer', border: 0, padding: '16px', color: 'rgba(255, 255, 255, 0.65)' }}
|
||||
>
|
||||
{data?.data?.nickname || data?.data?.email}
|
||||
</span>
|
||||
</Dropdown>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Menu, Select } from 'antd';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useACLRoleContext } from '../acl';
|
||||
import { useAPIClient } from '../api-client';
|
||||
import { useCompile } from '../schema-component';
|
||||
@ -29,6 +30,7 @@ export const SwitchRole = () => {
|
||||
const api = useAPIClient();
|
||||
const roles = useCurrentRoles();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
if (roles.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
@ -47,7 +49,8 @@ export const SwitchRole = () => {
|
||||
onChange={async (roleName) => {
|
||||
api.auth.setRole(roleName);
|
||||
await api.resource('users').setDefaultRole({ values: { roleName } });
|
||||
window.location.href = '/';
|
||||
history.push('/');
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
</Menu.Item>
|
||||
|
94
packages/core/database/src/collection-group-manager.ts
Normal file
94
packages/core/database/src/collection-group-manager.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import Database from './database';
|
||||
import { isString, castArray } from 'lodash';
|
||||
|
||||
export interface CollectionGroup {
|
||||
namespace: string;
|
||||
collections: string[];
|
||||
function: string;
|
||||
|
||||
dumpable: 'required' | 'optional' | 'skip';
|
||||
delayRestore?: any;
|
||||
}
|
||||
|
||||
export class CollectionGroupManager {
|
||||
constructor(public db: Database) {}
|
||||
|
||||
getGroups() {
|
||||
const collections = [...this.db.collections.values()];
|
||||
const groups = new Map<string, CollectionGroup>();
|
||||
|
||||
const skipped = [];
|
||||
|
||||
for (const collection of collections) {
|
||||
const groupKey = collection.options.namespace;
|
||||
|
||||
if (!groupKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [namespace, groupFunc] = groupKey.split('.');
|
||||
|
||||
if (!groupFunc) {
|
||||
skipped.push({
|
||||
name: collection.name,
|
||||
reason: 'no-group-function',
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!groups.has(groupKey)) {
|
||||
const dumpable = (() => {
|
||||
if (!collection.options.duplicator) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isString(collection.options.duplicator)) {
|
||||
return {
|
||||
dumpable: collection.options.duplicator,
|
||||
};
|
||||
}
|
||||
|
||||
return collection.options.duplicator;
|
||||
})();
|
||||
|
||||
if (!dumpable) {
|
||||
skipped.push({
|
||||
name: collection.name,
|
||||
reason: 'no-dumpable',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const group: CollectionGroup = {
|
||||
namespace,
|
||||
function: groupFunc,
|
||||
collections: dumpable.with ? castArray(dumpable.with) : [],
|
||||
dumpable: dumpable.dumpable,
|
||||
};
|
||||
|
||||
if (dumpable.delayRestore) {
|
||||
group.delayRestore = dumpable.delayRestore;
|
||||
}
|
||||
|
||||
groups.set(groupKey, group);
|
||||
}
|
||||
|
||||
const group = groups.get(groupKey);
|
||||
group.collections.push(collection.name);
|
||||
}
|
||||
|
||||
const results = [...groups.values()];
|
||||
const groupCollections = results.map((i) => i.collections).flat();
|
||||
|
||||
for (const skipItem of skipped) {
|
||||
if (groupCollections.includes(skipItem.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.db.logger.warn(`collection ${skipItem.name} is not in any collection group, reason: ${skipItem.reason}.`);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
@ -19,8 +19,19 @@ export type RepositoryType = typeof Repository;
|
||||
|
||||
export type CollectionSortable = string | boolean | { name?: string; scopeKey?: string };
|
||||
|
||||
type dumpable = 'required' | 'optional' | 'skip';
|
||||
|
||||
export interface CollectionOptions extends Omit<ModelOptions, 'name' | 'hooks'> {
|
||||
name: string;
|
||||
namespace?: string;
|
||||
duplicator?:
|
||||
| dumpable
|
||||
| {
|
||||
dumpable: dumpable;
|
||||
with?: string[] | string;
|
||||
delayRestore?: any;
|
||||
};
|
||||
|
||||
tableName?: string;
|
||||
inherits?: string[] | string;
|
||||
filterTargetKey?: string;
|
||||
@ -181,6 +192,7 @@ export class Collection<
|
||||
|
||||
this.on('field.afterRemove', (field: Field) => {
|
||||
field.unbind();
|
||||
this.db.emit('field.afterRemove', field);
|
||||
});
|
||||
}
|
||||
|
||||
@ -308,7 +320,7 @@ export class Collection<
|
||||
})
|
||||
) {
|
||||
const queryInterface = this.db.sequelize.getQueryInterface();
|
||||
await queryInterface.dropTable(this.addSchemaTableName(), options);
|
||||
await queryInterface.dropTable(this.getTableNameWithSchema(), options);
|
||||
}
|
||||
this.remove();
|
||||
}
|
||||
@ -539,7 +551,7 @@ export class Collection<
|
||||
return this.context.database.inheritanceMap.isParentNode(this.name);
|
||||
}
|
||||
|
||||
public addSchemaTableName() {
|
||||
public getTableNameWithSchema() {
|
||||
const tableName = this.model.tableName;
|
||||
|
||||
if (this.collectionSchema()) {
|
||||
@ -550,7 +562,7 @@ export class Collection<
|
||||
}
|
||||
|
||||
public quotedTableName() {
|
||||
return this.db.utils.quoteTable(this.addSchemaTableName());
|
||||
return this.db.utils.quoteTable(this.getTableNameWithSchema());
|
||||
}
|
||||
|
||||
public collectionSchema() {
|
||||
|
@ -67,6 +67,7 @@ import { BaseValueParser, registerFieldValueParsers } from './value-parsers';
|
||||
import buildQueryInterface from './query-interface/query-interface-builder';
|
||||
import QueryInterface from './query-interface/query-interface';
|
||||
import { Logger } from '@nocobase/logger';
|
||||
import { CollectionGroupManager } from './collection-group-manager';
|
||||
|
||||
export interface MergeOptions extends merge.Options {}
|
||||
|
||||
@ -184,6 +185,8 @@ export class Database extends EventEmitter implements AsyncEmitter {
|
||||
|
||||
logger: Logger;
|
||||
|
||||
collectionGroupManager = new CollectionGroupManager(this);
|
||||
|
||||
constructor(options: DatabaseOptions) {
|
||||
super();
|
||||
|
||||
@ -274,7 +277,7 @@ export class Database extends EventEmitter implements AsyncEmitter {
|
||||
name: 'migrations',
|
||||
autoGenId: false,
|
||||
timestamps: false,
|
||||
namespace: 'core',
|
||||
namespace: 'core.migration',
|
||||
duplicator: 'required',
|
||||
fields: [{ type: 'string', name: 'name' }],
|
||||
});
|
||||
@ -398,6 +401,8 @@ export class Database extends EventEmitter implements AsyncEmitter {
|
||||
collection<Attributes = any, CreateAttributes = Attributes>(
|
||||
options: CollectionOptions,
|
||||
): Collection<Attributes, CreateAttributes> {
|
||||
options = lodash.cloneDeep(options);
|
||||
|
||||
if (this.options.underscored) {
|
||||
options.underscored = true;
|
||||
}
|
||||
|
@ -169,7 +169,7 @@ export abstract class Field {
|
||||
columnReferencesCount == 1
|
||||
) {
|
||||
const queryInterface = this.database.sequelize.getQueryInterface();
|
||||
await queryInterface.removeColumn(this.collection.addSchemaTableName(), this.columnName(), options);
|
||||
await queryInterface.removeColumn(this.collection.getTableNameWithSchema(), this.columnName(), options);
|
||||
}
|
||||
|
||||
this.remove();
|
||||
@ -181,22 +181,24 @@ export abstract class Field {
|
||||
};
|
||||
let sql;
|
||||
if (this.database.sequelize.getDialect() === 'sqlite') {
|
||||
sql = `SELECT * from pragma_table_info('${this.collection.model.tableName}') WHERE name = '${this.columnName()}'`;
|
||||
sql = `SELECT *
|
||||
from pragma_table_info('${this.collection.model.tableName}')
|
||||
WHERE name = '${this.columnName()}'`;
|
||||
} else if (this.database.inDialect('mysql')) {
|
||||
sql = `
|
||||
select column_name
|
||||
from INFORMATION_SCHEMA.COLUMNS
|
||||
where TABLE_SCHEMA='${this.database.options.database}' AND TABLE_NAME='${
|
||||
this.collection.model.tableName
|
||||
}' AND column_name='${this.columnName()}'
|
||||
where TABLE_SCHEMA = '${this.database.options.database}'
|
||||
AND TABLE_NAME = '${this.collection.model.tableName}'
|
||||
AND column_name = '${this.columnName()}'
|
||||
`;
|
||||
} else {
|
||||
sql = `
|
||||
select column_name
|
||||
from INFORMATION_SCHEMA.COLUMNS
|
||||
where TABLE_NAME='${
|
||||
this.collection.model.tableName
|
||||
}' AND column_name='${this.columnName()}' AND table_schema='${this.collection.collectionSchema() || 'public'}'
|
||||
where TABLE_NAME = '${this.collection.model.tableName}'
|
||||
AND column_name = '${this.columnName()}'
|
||||
AND table_schema = '${this.collection.collectionSchema() || 'public'}'
|
||||
`;
|
||||
}
|
||||
const [rows] = await this.database.sequelize.query(sql, opts);
|
||||
|
@ -20,4 +20,4 @@ export * from './repository';
|
||||
export * from './update-associations';
|
||||
export { snakeCase } from './utils';
|
||||
export * from './value-parsers';
|
||||
|
||||
export * from './collection-group-manager';
|
||||
|
@ -26,7 +26,7 @@ export class SyncRunner {
|
||||
);
|
||||
}
|
||||
|
||||
const tableName = inheritedCollection.addSchemaTableName();
|
||||
const tableName = inheritedCollection.getTableNameWithSchema();
|
||||
|
||||
const attributes = model.tableAttributes;
|
||||
|
||||
@ -88,7 +88,7 @@ export class SyncRunner {
|
||||
// if we have max sequence, set it to child table
|
||||
if (maxSequenceName) {
|
||||
const parentsDeep = Array.from(db.inheritanceMap.getParents(inheritedCollection.name)).map((parent) =>
|
||||
db.getCollection(parent).addSchemaTableName(),
|
||||
db.getCollection(parent).getTableNameWithSchema(),
|
||||
);
|
||||
|
||||
const sequenceTables = [...parentsDeep, tableName];
|
||||
@ -101,8 +101,10 @@ export class SyncRunner {
|
||||
|
||||
const idColumnQuery = await queryInterface.sequelize.query(
|
||||
`SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name='${queryName}' and column_name='id' and table_schema = '${schemaName}';
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = '${queryName}'
|
||||
and column_name = 'id'
|
||||
and table_schema = '${schemaName}';
|
||||
`,
|
||||
{
|
||||
transaction,
|
||||
@ -163,7 +165,7 @@ WHERE table_name='${queryName}' and column_name='id' and table_schema = '${schem
|
||||
';',
|
||||
` INHERITS (${parents
|
||||
.map((t) => {
|
||||
return t.addSchemaTableName();
|
||||
return t.getTableNameWithSchema();
|
||||
})
|
||||
.join(', ')});`,
|
||||
);
|
||||
|
@ -25,6 +25,10 @@ export interface IResource {
|
||||
export class Auth {
|
||||
protected api: APIClient;
|
||||
|
||||
protected NOCOBASE_LOCALE_KEY = 'NOCOBASE_LOCALE';
|
||||
|
||||
protected NOCOBASE_ROLE_KEY = 'NOCOBASE_ROLE';
|
||||
|
||||
protected options = {
|
||||
token: null,
|
||||
locale: null,
|
||||
@ -33,12 +37,26 @@ export class Auth {
|
||||
|
||||
constructor(api: APIClient) {
|
||||
this.api = api;
|
||||
this.initKeys();
|
||||
this.locale = this.getLocale();
|
||||
this.role = this.getRole();
|
||||
this.token = this.getToken();
|
||||
this.api.axios.interceptors.request.use(this.middleware.bind(this));
|
||||
}
|
||||
|
||||
initKeys() {
|
||||
if (!window) {
|
||||
return;
|
||||
}
|
||||
const match = window.location.pathname.match(/^\/apps\/([^/]*)\//);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
const appName = match[1];
|
||||
this.NOCOBASE_LOCALE_KEY = `${appName.toUpperCase()}_NOCOBASE_LOCALE`;
|
||||
this.NOCOBASE_ROLE_KEY = `${appName.toUpperCase()}_NOCOBASE_ROLE`;
|
||||
}
|
||||
|
||||
get locale() {
|
||||
return this.getLocale();
|
||||
}
|
||||
@ -77,12 +95,12 @@ export class Auth {
|
||||
}
|
||||
|
||||
getLocale() {
|
||||
return this.api.storage.getItem('NOCOBASE_LOCALE');
|
||||
return this.api.storage.getItem(this.NOCOBASE_LOCALE_KEY);
|
||||
}
|
||||
|
||||
setLocale(locale: string) {
|
||||
this.options.locale = locale;
|
||||
this.api.storage.setItem('NOCOBASE_LOCALE', locale || '');
|
||||
this.api.storage.setItem(this.NOCOBASE_LOCALE_KEY, locale || '');
|
||||
}
|
||||
|
||||
getToken() {
|
||||
@ -99,12 +117,12 @@ export class Auth {
|
||||
}
|
||||
|
||||
getRole() {
|
||||
return this.api.storage.getItem('NOCOBASE_ROLE');
|
||||
return this.api.storage.getItem(this.NOCOBASE_ROLE_KEY);
|
||||
}
|
||||
|
||||
setRole(role: string) {
|
||||
this.options.role = role;
|
||||
this.api.storage.setItem('NOCOBASE_ROLE', role || '');
|
||||
this.api.storage.setItem(this.NOCOBASE_ROLE_KEY, role || '');
|
||||
}
|
||||
|
||||
async signIn(values, authenticator: string = 'password'): Promise<AxiosResponse<any>> {
|
||||
|
@ -20,6 +20,7 @@ describe('application', () => {
|
||||
dialect: 'sqlite',
|
||||
dialectModule: require('sqlite3'),
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
},
|
||||
resourcer: {
|
||||
prefix: '/api',
|
||||
|
@ -40,8 +40,6 @@ describe('multiple apps', () => {
|
||||
await app.stop();
|
||||
|
||||
expect(subApp1StopFn).toBeCalledTimes(1);
|
||||
|
||||
await app.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -57,6 +55,27 @@ 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 () => {
|
||||
const sub1 = `a_${uid()}`;
|
||||
const sub2 = `a_${uid()}`;
|
||||
|
33
packages/core/server/src/__tests__/upgrade.test.ts
Normal file
33
packages/core/server/src/__tests__/upgrade.test.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import Application from '../application';
|
||||
import Plugin from '../plugin';
|
||||
|
||||
class TestPlugin extends Plugin {
|
||||
|
||||
}
|
||||
|
||||
describe('upgrade test', () => {
|
||||
let app: Application;
|
||||
beforeEach(async () => {
|
||||
app = new Application({
|
||||
database: {
|
||||
dialect: 'sqlite',
|
||||
dialectModule: require('sqlite3'),
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
},
|
||||
resourcer: {
|
||||
prefix: '/api',
|
||||
},
|
||||
acl: false,
|
||||
dataWrapping: false,
|
||||
registerActions: false,
|
||||
});
|
||||
|
||||
app.plugin(TestPlugin, { name: 'test-plugin' });
|
||||
});
|
||||
|
||||
it('should call upgrade', async () => {
|
||||
await app.upgrade();
|
||||
console.log('1231');
|
||||
});
|
||||
});
|
@ -3,7 +3,9 @@ import EventEmitter from 'events';
|
||||
import http, { IncomingMessage, ServerResponse } from 'http';
|
||||
import Application, { ApplicationOptions } from './application';
|
||||
|
||||
type AppSelector = (req: IncomingMessage) => Application | string | undefined | null;
|
||||
type AppSelectorReturn = Application | string | undefined | null;
|
||||
|
||||
type AppSelector = (req: IncomingMessage) => AppSelectorReturn | Promise<AppSelectorReturn>;
|
||||
|
||||
export class AppManager extends EventEmitter {
|
||||
public applications: Map<string, Application> = new Map<string, Application>();
|
||||
@ -11,23 +13,30 @@ export class AppManager extends EventEmitter {
|
||||
constructor(public app: Application) {
|
||||
super();
|
||||
|
||||
app.on('beforeStop', async (mainApp, options) => {
|
||||
return await Promise.all(
|
||||
[...this.applications.values()].map((application: Application) => application.stop(options)),
|
||||
);
|
||||
const passEventToSubApps = (eventName, method) => {
|
||||
app.on(eventName, async (mainApp, options) => {
|
||||
console.log(`receive event ${eventName} from ${mainApp.name}`);
|
||||
for (const application of this.applications.values()) {
|
||||
console.log(`pass ${eventName} to ${application.name} `);
|
||||
await application[method](options);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
app.on('afterDestroy', async (mainApp, options) => {
|
||||
return await Promise.all(
|
||||
[...this.applications.values()].map((application: Application) => application.destroy(options)),
|
||||
);
|
||||
});
|
||||
passEventToSubApps('beforeDestroy', 'destroy');
|
||||
passEventToSubApps('beforeStop', 'stop');
|
||||
passEventToSubApps('afterUpgrade', 'upgrade');
|
||||
passEventToSubApps('afterReload', 'reload');
|
||||
}
|
||||
|
||||
appSelector: AppSelector = (req: IncomingMessage) => this.app;
|
||||
appSelector: AppSelector = async (req: IncomingMessage) => this.app;
|
||||
|
||||
createApplication(name: string, options: ApplicationOptions): Application {
|
||||
const application = new Application(options);
|
||||
const application = new Application({
|
||||
...options,
|
||||
name,
|
||||
});
|
||||
|
||||
this.applications.set(name, application);
|
||||
return application;
|
||||
}
|
||||
@ -66,7 +75,7 @@ export class AppManager extends EventEmitter {
|
||||
return async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const appManager = this.app.appManager;
|
||||
|
||||
let handleApp: any = appManager.appSelector(req) || appManager.app;
|
||||
let handleApp: any = (await appManager.appSelector(req)) || appManager.app;
|
||||
|
||||
if (typeof handleApp === 'string') {
|
||||
handleApp = await appManager.getApplication(handleApp);
|
||||
|
@ -102,7 +102,7 @@ export class ApplicationVersion {
|
||||
if (!app.db.hasCollection('applicationVersion')) {
|
||||
app.db.collection({
|
||||
name: 'applicationVersion',
|
||||
namespace: 'core',
|
||||
namespace: 'core.applicationVersion',
|
||||
duplicator: 'required',
|
||||
timestamps: false,
|
||||
fields: [{ name: 'value', type: 'string' }],
|
||||
@ -229,13 +229,17 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
|
||||
protected init() {
|
||||
const options = this.options;
|
||||
|
||||
const logger = createAppLogger(options.logger);
|
||||
this._logger = logger.instance;
|
||||
|
||||
// @ts-ignore
|
||||
this._events = [];
|
||||
// @ts-ignore
|
||||
this._eventsCount = [];
|
||||
|
||||
this.removeAllListeners();
|
||||
|
||||
this.middleware = new Toposort<any>();
|
||||
this.plugins = new Map<string, Plugin>();
|
||||
this._acl = createACL();
|
||||
@ -293,6 +297,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
});
|
||||
|
||||
db.setLogger(this._logger);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
@ -350,7 +355,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
|
||||
async load(options?: any) {
|
||||
if (options?.reload) {
|
||||
console.log(`Reload the application configuration`);
|
||||
console.log(`Reload the ${this.name} application configuration`);
|
||||
const oldDb = this._db;
|
||||
this.init();
|
||||
await oldDb.close();
|
||||
@ -366,6 +371,8 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
...options,
|
||||
reload: true,
|
||||
});
|
||||
|
||||
await this.emitAsync('afterReload', this, options);
|
||||
}
|
||||
|
||||
getPlugin<P extends Plugin>(name: string) {
|
||||
@ -412,6 +419,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
|
||||
if (options?.listen?.port) {
|
||||
const pmServer = await this.pm.listen();
|
||||
|
||||
const listen = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const Server = this.listen(options?.listen, () => {
|
||||
@ -452,6 +460,12 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
|
||||
await this.emitAsync('beforeStop', this, options);
|
||||
|
||||
// close http server
|
||||
if (this.listenServer) {
|
||||
await promisify(this.listenServer.close).call(this.listenServer);
|
||||
this.listenServer = null;
|
||||
}
|
||||
|
||||
try {
|
||||
// close database connection
|
||||
// silent if database already closed
|
||||
@ -462,14 +476,9 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
// close http server
|
||||
if (this.listenServer) {
|
||||
await promisify(this.listenServer.close).call(this.listenServer);
|
||||
this.listenServer = null;
|
||||
}
|
||||
|
||||
await this.emitAsync('afterStop', this, options);
|
||||
this.stopped = true;
|
||||
console.log(`${this.name} is stopped`);
|
||||
}
|
||||
|
||||
async destroy(options: any = {}) {
|
||||
|
@ -5,12 +5,14 @@ export default (app: Application) => {
|
||||
.command('pm')
|
||||
.argument('<method>')
|
||||
.arguments('<plugins...>')
|
||||
.option('-S, --skip-yarn-install', 'skip yarn install')
|
||||
.action(async (method, plugins, options, ...args) => {
|
||||
if (method === 'add') {
|
||||
if (method === 'add' && !options.skipYarnInstall) {
|
||||
const { run } = require('@nocobase/cli/src/util');
|
||||
console.log('Install dependencies and rebuild workspaces');
|
||||
await run('yarn', ['install']);
|
||||
}
|
||||
|
||||
app.pm.clientWrite({ method, plugins });
|
||||
});
|
||||
};
|
||||
|
@ -1,2 +1 @@
|
||||
export * from './PluginManager';
|
||||
|
||||
export * from './plugin-manager';
|
||||
|
@ -1,6 +1,8 @@
|
||||
export default {
|
||||
import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'applicationPlugins',
|
||||
namespace: 'core',
|
||||
namespace: 'core.applicationPlugins',
|
||||
duplicator: 'required',
|
||||
repository: 'PluginManagerRepository',
|
||||
fields: [
|
||||
@ -11,4 +13,4 @@ export default {
|
||||
{ type: 'boolean', name: 'builtIn' },
|
||||
{ type: 'json', name: 'options' },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Repository } from '@nocobase/database';
|
||||
import { PluginManager } from './PluginManager';
|
||||
import { PluginManager } from './plugin-manager';
|
||||
|
||||
export class PluginManagerRepository extends Repository {
|
||||
pm: PluginManager;
|
@ -9,7 +9,7 @@ import Application from '../application';
|
||||
import { Plugin } from '../plugin';
|
||||
import collectionOptions from './options/collection';
|
||||
import resourceOptions from './options/resource';
|
||||
import { PluginManagerRepository } from './PluginManagerRepository';
|
||||
import { PluginManagerRepository } from './plugin-manager-repository';
|
||||
|
||||
export interface PluginManagerOptions {
|
||||
app: Application;
|
||||
@ -38,7 +38,9 @@ export class PluginManager {
|
||||
this.app.db.registerRepositories({
|
||||
PluginManagerRepository,
|
||||
});
|
||||
|
||||
this.collection = this.app.db.collection(collectionOptions);
|
||||
|
||||
this.repository = this.collection.repository as PluginManagerRepository;
|
||||
this.repository.setPluginManager(this);
|
||||
this.app.resourcer.define(resourceOptions);
|
||||
@ -48,19 +50,6 @@ export class PluginManager {
|
||||
actions: ['pm:*', 'applicationPlugins:list'],
|
||||
});
|
||||
|
||||
this.server = net.createServer((socket) => {
|
||||
socket.on('data', async (data) => {
|
||||
const { method, plugins } = JSON.parse(data.toString());
|
||||
try {
|
||||
console.log(method, plugins);
|
||||
await this[method](plugins);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
});
|
||||
socket.pipe(socket);
|
||||
});
|
||||
|
||||
this.app.on('beforeLoad', async (app, options) => {
|
||||
if (options?.method && ['install', 'upgrade'].includes(options.method)) {
|
||||
await this.collection.sync();
|
||||
@ -133,6 +122,19 @@ export class PluginManager {
|
||||
}
|
||||
|
||||
async listen(): Promise<net.Server> {
|
||||
this.server = net.createServer((socket) => {
|
||||
socket.on('data', async (data) => {
|
||||
const { method, plugins } = JSON.parse(data.toString());
|
||||
try {
|
||||
console.log(method, plugins);
|
||||
await this[method](plugins);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
});
|
||||
socket.pipe(socket);
|
||||
});
|
||||
|
||||
if (fs.existsSync(this.pmSock)) {
|
||||
await fs.promises.unlink(this.pmSock);
|
||||
}
|
||||
@ -308,6 +310,7 @@ export class PluginManager {
|
||||
try {
|
||||
const pluginNames = await this.repository.enable(name);
|
||||
await this.app.reload();
|
||||
|
||||
await this.app.db.sync();
|
||||
for (const pluginName of pluginNames) {
|
||||
const plugin = this.app.getPlugin(pluginName);
|
||||
@ -317,6 +320,8 @@ export class PluginManager {
|
||||
await plugin.install();
|
||||
await plugin.afterEnable();
|
||||
}
|
||||
|
||||
await this.app.emitAsync('afterEnablePlugin', name);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
@ -333,6 +338,8 @@ export class PluginManager {
|
||||
}
|
||||
await plugin.afterDisable();
|
||||
}
|
||||
|
||||
await this.app.emitAsync('afterDisablePlugin', name);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
@ -27,6 +27,7 @@ interface ActionParams {
|
||||
* @deprecated
|
||||
*/
|
||||
associatedIndex?: string;
|
||||
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@ -41,6 +42,7 @@ interface SortActionParams {
|
||||
method?: string;
|
||||
target?: any;
|
||||
sticky?: boolean;
|
||||
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@ -51,6 +53,7 @@ interface Resource {
|
||||
update: (params?: ActionParams) => Promise<supertest.Response>;
|
||||
destroy: (params?: ActionParams) => Promise<supertest.Response>;
|
||||
sort: (params?: SortActionParams) => Promise<supertest.Response>;
|
||||
|
||||
[name: string]: (params?: ActionParams) => Promise<supertest.Response>;
|
||||
}
|
||||
|
||||
@ -58,6 +61,10 @@ export class MockServer extends Application {
|
||||
async loadAndInstall(options: any = {}) {
|
||||
await this.load({ method: 'install' });
|
||||
|
||||
if (options.afterLoad) {
|
||||
await options.afterLoad(this);
|
||||
}
|
||||
|
||||
await this.install({
|
||||
...options,
|
||||
sync: {
|
||||
|
@ -3,6 +3,6 @@ import { CollectionOptions } from '@nocobase/database';
|
||||
export default {
|
||||
name: 'rolesUsers',
|
||||
duplicator: 'optional',
|
||||
namespace: 'acl',
|
||||
namespace: 'acl.acl',
|
||||
fields: [{ type: 'boolean', name: 'default' }],
|
||||
} as CollectionOptions;
|
||||
|
@ -1,8 +1,11 @@
|
||||
import { CollectionOptions } from '@nocobase/database';
|
||||
|
||||
export default {
|
||||
namespace: 'acl',
|
||||
duplicator: 'required',
|
||||
namespace: 'acl.acl',
|
||||
duplicator: {
|
||||
dumpable: 'required',
|
||||
with: 'uiSchemas',
|
||||
},
|
||||
name: 'roles',
|
||||
title: '{{t("Roles")}}',
|
||||
autoGenId: false,
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CollectionOptions } from '@nocobase/database';
|
||||
|
||||
export default {
|
||||
namespace: 'acl',
|
||||
namespace: 'acl.acl',
|
||||
duplicator: 'required',
|
||||
name: 'rolesResources',
|
||||
model: 'RoleResourceModel',
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CollectionOptions } from '@nocobase/database';
|
||||
|
||||
export default {
|
||||
namespace: 'acl',
|
||||
namespace: 'acl.acl',
|
||||
duplicator: 'required',
|
||||
name: 'rolesResourcesActions',
|
||||
model: 'RoleResourceActionModel',
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CollectionOptions } from '@nocobase/database';
|
||||
|
||||
export default {
|
||||
namespace: 'acl',
|
||||
namespace: 'acl.acl',
|
||||
duplicator: 'required',
|
||||
name: 'rolesResourcesScopes',
|
||||
fields: [
|
||||
|
@ -817,7 +817,7 @@ export class PluginACL extends Plugin {
|
||||
await this.importCollections(resolve(__dirname, 'collections'));
|
||||
this.db.extendCollection({
|
||||
name: 'rolesUischemas',
|
||||
namespace: 'acl',
|
||||
namespace: 'acl.acl',
|
||||
duplicator: 'required',
|
||||
});
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
namespace: 'audit-logs',
|
||||
namespace: 'audit-logs.auditLogs',
|
||||
duplicator: 'optional',
|
||||
name: 'auditChanges',
|
||||
title: '变动值',
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
namespace: 'audit-logs',
|
||||
namespace: 'audit-logs.auditLogs',
|
||||
duplicator: 'optional',
|
||||
name: 'auditLogs',
|
||||
createdBy: false,
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
namespace: 'charts.chartsQueries',
|
||||
duplicator: 'optional',
|
||||
name: 'chartsQueries',
|
||||
fields: [
|
||||
{
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
namespace: 'china-region',
|
||||
namespace: 'china-region.china-region',
|
||||
duplicator: 'skip',
|
||||
name: 'chinaRegions',
|
||||
title: '中国行政区划',
|
||||
|
@ -14,7 +14,8 @@ describe('action test', () => {
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
it('should append uiSchema', async () => {
|
||||
|
||||
it('should get uiSchema', async () => {
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'posts',
|
||||
@ -41,10 +42,14 @@ describe('action test', () => {
|
||||
.resource('collections.fields', 'posts')
|
||||
.list({
|
||||
pageSize: 5,
|
||||
appends: ['uiSchema'],
|
||||
sort: ['sort'],
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(200);
|
||||
const data = response.body.data;
|
||||
|
||||
expect(data[0].uiSchema).toMatchObject({
|
||||
'x-uid': 'test',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -249,7 +249,7 @@ describe('collections repository', () => {
|
||||
|
||||
const testCollection = db.getCollection('tests');
|
||||
const getTableInfo = async () =>
|
||||
await db.sequelize.getQueryInterface().describeTable(testCollection.addSchemaTableName());
|
||||
await db.sequelize.getQueryInterface().describeTable(testCollection.getTableNameWithSchema());
|
||||
|
||||
const tableInfo0 = await getTableInfo();
|
||||
expect(tableInfo0['date_a']).toBeDefined();
|
||||
@ -286,7 +286,7 @@ describe('collections repository', () => {
|
||||
|
||||
const testCollection = db.getCollection('tests');
|
||||
const getTableInfo = async () =>
|
||||
await db.sequelize.getQueryInterface().describeTable(testCollection.addSchemaTableName());
|
||||
await db.sequelize.getQueryInterface().describeTable(testCollection.getTableNameWithSchema());
|
||||
|
||||
const tableInfo0 = await getTableInfo();
|
||||
expect(tableInfo0[createdAt]).toBeDefined();
|
||||
@ -339,7 +339,7 @@ describe('collections repository', () => {
|
||||
testCollection.model.rawAttributes.test_field.field === testCollection.model.rawAttributes.testField.field,
|
||||
).toBe(true);
|
||||
const getTableInfo = async () =>
|
||||
await db.sequelize.getQueryInterface().describeTable(testCollection.addSchemaTableName());
|
||||
await db.sequelize.getQueryInterface().describeTable(testCollection.getTableNameWithSchema());
|
||||
|
||||
const tableInfo0 = await getTableInfo();
|
||||
|
||||
|
@ -59,7 +59,7 @@ describe('field defaultValue', () => {
|
||||
const response2 = await app.agent().resource('test1').create();
|
||||
expect(response2.body.data.field1).toBe('cba');
|
||||
|
||||
const results = await app.db.sequelize.getQueryInterface().describeTable(TestCollection.addSchemaTableName());
|
||||
const results = await app.db.sequelize.getQueryInterface().describeTable(TestCollection.getTableNameWithSchema());
|
||||
|
||||
expect(results.field1.defaultValue).toBe('cba');
|
||||
});
|
||||
|
@ -168,11 +168,10 @@ describe('reverseField options', () => {
|
||||
filter: {
|
||||
key: reverseField.get('key'),
|
||||
},
|
||||
appends: ['uiSchema'],
|
||||
});
|
||||
|
||||
const uiSchema = reverseField.get('uiSchema');
|
||||
expect(uiSchema['schema']).toEqual({ title: '123' });
|
||||
expect(uiSchema).toEqual({ title: '123' });
|
||||
});
|
||||
|
||||
it('should update uiSchema', async () => {
|
||||
@ -211,12 +210,8 @@ describe('reverseField options', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const f2 = await app
|
||||
.agent()
|
||||
.resource('collections.fields', 'a')
|
||||
.get({
|
||||
const f2 = await app.agent().resource('collections.fields', 'a').get({
|
||||
filterByTk: 'f_i02fjvduwmv',
|
||||
appends: ['uiSchema'],
|
||||
});
|
||||
|
||||
expect(f2.body.data.uiSchema.title).toBe('A2');
|
||||
@ -270,12 +265,8 @@ describe('reverseField options', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const f1 = await app
|
||||
.agent()
|
||||
.resource('collections.fields', 'b')
|
||||
.get({
|
||||
const f1 = await app.agent().resource('collections.fields', 'b').get({
|
||||
filterByTk: 'f_dctw6v5gsio',
|
||||
appends: ['uiSchema'],
|
||||
});
|
||||
|
||||
expect(f1.body.data.uiSchema.title).toBe('A');
|
||||
|
@ -97,7 +97,7 @@ describe('collections repository', () => {
|
||||
|
||||
const testCollection = app.db.getCollection('test');
|
||||
|
||||
const tableInfo = await app.db.sequelize.getQueryInterface().describeTable(testCollection.addSchemaTableName());
|
||||
const tableInfo = await app.db.sequelize.getQueryInterface().describeTable(testCollection.getTableNameWithSchema());
|
||||
|
||||
expect(tableInfo['field']).toBeDefined();
|
||||
});
|
||||
@ -460,7 +460,7 @@ describe('collections repository', () => {
|
||||
|
||||
const columnName = collection.model.rawAttributes.testField.field;
|
||||
|
||||
const tableInfo = await app.db.sequelize.getQueryInterface().describeTable(collection.addSchemaTableName());
|
||||
const tableInfo = await app.db.sequelize.getQueryInterface().describeTable(collection.getTableNameWithSchema());
|
||||
|
||||
expect(tableInfo[columnName]).toBeDefined();
|
||||
});
|
||||
@ -504,7 +504,7 @@ describe('collections repository', () => {
|
||||
|
||||
const indexes = (await app.db.sequelize
|
||||
.getQueryInterface()
|
||||
.showIndex(app.db.getCollection('test').addSchemaTableName())) as any;
|
||||
.showIndex(app.db.getCollection('test').getTableNameWithSchema())) as any;
|
||||
|
||||
const columnName = app.db.getCollection('test').model.rawAttributes.testField.field;
|
||||
|
||||
@ -528,7 +528,7 @@ describe('collections repository', () => {
|
||||
|
||||
const afterIndexes = (await app.db.sequelize
|
||||
.getQueryInterface()
|
||||
.showIndex(app.db.getCollection('test').addSchemaTableName())) as any;
|
||||
.showIndex(app.db.getCollection('test').getTableNameWithSchema())) as any;
|
||||
|
||||
expect(
|
||||
afterIndexes.find(
|
||||
|
@ -1,9 +1,11 @@
|
||||
import PluginErrorHandler from '@nocobase/plugin-error-handler';
|
||||
import PluginUiSchema from '@nocobase/plugin-ui-schema-storage';
|
||||
import { mockServer } from '@nocobase/test';
|
||||
import { MockServer, mockServer } from '@nocobase/test';
|
||||
import Plugin from '../';
|
||||
|
||||
export async function createApp(options = {}) {
|
||||
export async function createApp(
|
||||
options: { beforeInstall?: (app: MockServer) => void; beforePlugin?: (app: MockServer) => void } & any = {},
|
||||
) {
|
||||
const app = mockServer({
|
||||
acl: false,
|
||||
...options,
|
||||
@ -12,11 +14,17 @@ export async function createApp(options = {}) {
|
||||
await app.db.clean({ drop: true });
|
||||
await app.db.sync({});
|
||||
|
||||
options.beforePlugin && options.beforePlugin(app);
|
||||
|
||||
app.plugin(PluginErrorHandler, { name: 'error-handler' });
|
||||
app.plugin(Plugin, { name: 'collection-manager' });
|
||||
app.plugin(PluginUiSchema, { name: 'ui-schema-storage' });
|
||||
|
||||
await app.loadAndInstall({ clean: true });
|
||||
if (options.beforeInstall) {
|
||||
await options.beforeInstall(app);
|
||||
}
|
||||
|
||||
await app.install({ clean: true });
|
||||
await app.start();
|
||||
return app;
|
||||
}
|
||||
|
@ -0,0 +1,162 @@
|
||||
import { MockServer } from '@nocobase/test';
|
||||
import { Plugin } from '@nocobase/server';
|
||||
import { Database, MigrationContext } from '@nocobase/database';
|
||||
import { createApp } from '../index';
|
||||
import Migrator from '../../migrations/20230225111111-drop-ui-schema-relation';
|
||||
|
||||
class AddBelongsToPlugin extends Plugin {
|
||||
beforeLoad() {
|
||||
this.app.db.on('beforeDefineCollection', (options) => {
|
||||
if (options.name == 'fields') {
|
||||
options.fields.push({
|
||||
type: 'belongsTo',
|
||||
name: 'uiSchema',
|
||||
target: 'uiSchemas',
|
||||
foreignKey: 'uiSchemaUid',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('skip if already migrated', function () {
|
||||
let app: MockServer;
|
||||
let db: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await createApp({});
|
||||
|
||||
db = app.db;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('should not run migration', async () => {
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'testCollection',
|
||||
fields: [
|
||||
{
|
||||
name: 'testField',
|
||||
type: 'string',
|
||||
uiSchema: {
|
||||
title: '{{t("Collection display name")}}',
|
||||
type: 'number',
|
||||
'x-component': 'Input',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'fieldWithoutSchema',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
|
||||
let error;
|
||||
|
||||
try {
|
||||
const migration = new Migrator({ db } as MigrationContext);
|
||||
migration.context.app = app;
|
||||
await migration.up();
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('drop ui schema', () => {
|
||||
let app: MockServer;
|
||||
let db: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await createApp({
|
||||
beforePlugin(app) {
|
||||
app.plugin(AddBelongsToPlugin, { name: 'test' });
|
||||
},
|
||||
});
|
||||
|
||||
db = app.db;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('should update uiSchema to options field', async () => {
|
||||
const schemaContent = {
|
||||
title: '{{t("Collection display name")}}',
|
||||
type: 'number',
|
||||
'x-component': 'Input',
|
||||
required: true,
|
||||
};
|
||||
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'testCollection',
|
||||
fields: [
|
||||
{
|
||||
name: 'testField',
|
||||
type: 'string',
|
||||
uiSchema: {
|
||||
title: '{{t("Collection display name")}}',
|
||||
type: 'number',
|
||||
'x-component': 'Input',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'fieldWithoutSchema',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
|
||||
const testFieldRecord = await db.getRepository('fields').findOne({
|
||||
filter: {
|
||||
name: 'testField',
|
||||
},
|
||||
});
|
||||
|
||||
expect(testFieldRecord.rawAttributes['uiSchemaUid']).toBeTruthy();
|
||||
|
||||
const options = testFieldRecord.get('options');
|
||||
expect(options.uiSchema).toBeFalsy();
|
||||
|
||||
// remove uiSchema field
|
||||
const fieldCollection = db.getCollection('fields');
|
||||
fieldCollection.removeField('uiSchema');
|
||||
await fieldCollection.sync();
|
||||
|
||||
const testFieldRecord1 = await db.getRepository('fields').findOne({
|
||||
filter: {
|
||||
name: 'testField',
|
||||
},
|
||||
});
|
||||
|
||||
expect(testFieldRecord1.rawAttributes['uiSchemaUid']).toBeFalsy();
|
||||
expect(testFieldRecord1.get('options').uiSchema).toBeFalsy();
|
||||
|
||||
// do migrate
|
||||
const migration = new Migrator({ db } as MigrationContext);
|
||||
migration.context.app = app;
|
||||
await migration.up();
|
||||
|
||||
const testFieldRecord2 = await db.getRepository('fields').findOne({
|
||||
filter: {
|
||||
name: 'testField',
|
||||
},
|
||||
});
|
||||
|
||||
expect(testFieldRecord2.rawAttributes['uiSchemaUid']).toBeFalsy();
|
||||
expect(testFieldRecord2.get('options').uiSchema).toMatchObject(schemaContent);
|
||||
});
|
||||
});
|
@ -1,12 +1,11 @@
|
||||
import { Database, MigrationContext } from '@nocobase/database';
|
||||
import lodash from 'lodash';
|
||||
import Migrator from '../../migrations/20221121111113-update-id-to-bigint';
|
||||
|
||||
const excludeSqlite = () => (process.env.DB_DIALECT != 'sqlite' ? describe.skip : describe.skip);
|
||||
|
||||
import { MockServer } from '@nocobase/test';
|
||||
import { createApp } from '../index';
|
||||
|
||||
const excludeSqlite = () => (process.env.DB_DIALECT != 'sqlite' ? describe.skip : describe.skip);
|
||||
|
||||
excludeSqlite()('update id to bigint test', () => {
|
||||
let app: MockServer;
|
||||
let db: Database;
|
||||
@ -69,7 +68,7 @@ excludeSqlite()('update id to bigint test', () => {
|
||||
|
||||
const assertBigInt = async (collectionName, fieldName) => {
|
||||
const tableName = db.getCollection(collectionName)
|
||||
? db.getCollection(collectionName).addSchemaTableName()
|
||||
? db.getCollection(collectionName).getTableNameWithSchema()
|
||||
: collectionName;
|
||||
|
||||
const tableInfo = await db.sequelize.getQueryInterface().describeTable(tableName);
|
||||
@ -91,7 +90,7 @@ excludeSqlite()('update id to bigint test', () => {
|
||||
|
||||
let usersTableInfo = await db.sequelize
|
||||
.getQueryInterface()
|
||||
.describeTable(db.getCollection('users').addSchemaTableName());
|
||||
.describeTable(db.getCollection('users').getTableNameWithSchema());
|
||||
|
||||
assertInteger(usersTableInfo.id.type);
|
||||
|
||||
|
@ -1,8 +1,11 @@
|
||||
import { CollectionOptions } from '@nocobase/database';
|
||||
|
||||
export default {
|
||||
namespace: 'collection-manager',
|
||||
duplicator: 'required',
|
||||
namespace: 'collection-manager.collections',
|
||||
duplicator: {
|
||||
dumpable: 'required',
|
||||
with: 'collectionCategory',
|
||||
},
|
||||
name: 'collectionCategories',
|
||||
autoGenId: true,
|
||||
sortable: true,
|
||||
@ -11,11 +14,6 @@ export default {
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
},
|
||||
// {
|
||||
// type: 'integer',
|
||||
// name: 'sort',
|
||||
// defaultValue: 0,
|
||||
// },
|
||||
{
|
||||
type: 'string',
|
||||
name: 'color',
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CollectionOptions } from '@nocobase/database';
|
||||
|
||||
export default {
|
||||
namespace: 'collection-manager',
|
||||
namespace: 'collection-manager.collections',
|
||||
duplicator: 'required',
|
||||
name: 'collections',
|
||||
title: '数据表配置',
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CollectionOptions } from '@nocobase/database';
|
||||
|
||||
export default {
|
||||
namespace: 'collection-manager',
|
||||
namespace: 'collection-manager.collections',
|
||||
duplicator: 'required',
|
||||
name: 'fields',
|
||||
autoGenId: false,
|
||||
@ -59,12 +59,6 @@ export default {
|
||||
sourceKey: 'key',
|
||||
foreignKey: 'reverseKey',
|
||||
},
|
||||
{
|
||||
type: 'belongsTo',
|
||||
name: 'uiSchema',
|
||||
target: 'uiSchemas',
|
||||
foreignKey: 'uiSchemaUid',
|
||||
},
|
||||
{
|
||||
type: 'json',
|
||||
name: 'options',
|
||||
|
@ -0,0 +1,82 @@
|
||||
import { Migration } from '@nocobase/server';
|
||||
import { FieldModel } from '../models';
|
||||
import { Collection } from '@nocobase/database';
|
||||
|
||||
export default class extends Migration {
|
||||
async up() {
|
||||
const migratedFieldsCount = await this.db.getRepository('fields').count({
|
||||
filter: {
|
||||
'options.uiSchema': { $exists: true },
|
||||
},
|
||||
});
|
||||
|
||||
if (migratedFieldsCount > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = await this.db.sequelize.transaction();
|
||||
|
||||
const migrateFieldsSchema = async (collection: Collection) => {
|
||||
this.app.log.info(`Start to migrate ${collection.name} collection's ui schema`);
|
||||
|
||||
collection.setField('uiSchemaUid', {
|
||||
type: 'string',
|
||||
});
|
||||
|
||||
const fieldRecords: Array<FieldModel> = await collection.repository.find({
|
||||
transaction,
|
||||
});
|
||||
|
||||
const fieldsCount = await collection.repository.count({
|
||||
transaction,
|
||||
});
|
||||
|
||||
this.app.log.info(`Total ${fieldsCount} fields need to be migrated`);
|
||||
|
||||
let i = 0;
|
||||
|
||||
for (const fieldRecord of fieldRecords) {
|
||||
i++;
|
||||
|
||||
this.app.log.info(
|
||||
`Migrate field ${fieldRecord.get('collectionName')}.${fieldRecord.get('name')}, ${i}/${fieldsCount}`,
|
||||
);
|
||||
|
||||
const uiSchemaUid = fieldRecord.get('uiSchemaUid');
|
||||
|
||||
if (!uiSchemaUid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const uiSchemaRecord = await this.db.getRepository('uiSchemas').findOne({
|
||||
filterByTk: uiSchemaUid,
|
||||
transaction,
|
||||
});
|
||||
|
||||
const uiSchema = uiSchemaRecord.get('schema');
|
||||
|
||||
fieldRecord.set('uiSchema', uiSchema);
|
||||
|
||||
await fieldRecord.save({
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
collection.removeField('uiSchemaUid');
|
||||
this.app.log.info('Migrate uiSchema to options field done');
|
||||
};
|
||||
|
||||
try {
|
||||
await migrateFieldsSchema(this.db.getCollection('fields'));
|
||||
|
||||
if (this.db.getCollection('fieldsHistory')) {
|
||||
await migrateFieldsSchema(this.db.getCollection('fieldsHistory'));
|
||||
}
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
this.app.log.error(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
@ -41,6 +41,11 @@ export class CollectionModel extends MagicAttributeModel {
|
||||
await this.loadFields({ transaction });
|
||||
}
|
||||
|
||||
await this.db.emitAsync('collection:loaded', {
|
||||
collection,
|
||||
transaction,
|
||||
});
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
|
@ -20,7 +20,7 @@ export class FieldModel extends MagicAttributeModel {
|
||||
}
|
||||
|
||||
async load(loadOptions?: LoadOptions) {
|
||||
const { skipExist = false } = loadOptions || {};
|
||||
const { skipExist = false, transaction } = loadOptions || {};
|
||||
const collectionName = this.get('collectionName');
|
||||
|
||||
if (!this.db.hasCollection(collectionName)) {
|
||||
@ -36,16 +36,14 @@ export class FieldModel extends MagicAttributeModel {
|
||||
|
||||
const options = this.get();
|
||||
|
||||
if (options.uiSchemaUid) {
|
||||
const UISchema = this.db.getModel('uiSchemas');
|
||||
const uiSchema = await UISchema.findByPk(options.uiSchemaUid, {
|
||||
transaction: loadOptions.transaction,
|
||||
const field = collection.setField(name, options);
|
||||
|
||||
await this.db.emitAsync('field:loaded', {
|
||||
fieldKey: this.get('key'),
|
||||
transaction,
|
||||
});
|
||||
|
||||
Object.assign(options, { uiSchema: uiSchema.get() });
|
||||
}
|
||||
|
||||
return collection.setField(name, options);
|
||||
return field;
|
||||
}
|
||||
|
||||
async migrate({ isNew, ...options }: MigrateOptions = {}) {
|
||||
@ -107,7 +105,7 @@ export class FieldModel extends MagicAttributeModel {
|
||||
|
||||
const queryInterface = this.db.sequelize.getQueryInterface() as any;
|
||||
|
||||
const existsIndexes = await queryInterface.showIndex(collection.addSchemaTableName(), {
|
||||
const existsIndexes = await queryInterface.showIndex(collection.getTableNameWithSchema(), {
|
||||
transaction: options.transaction,
|
||||
});
|
||||
|
||||
@ -121,7 +119,7 @@ export class FieldModel extends MagicAttributeModel {
|
||||
|
||||
if (existUniqueIndex) {
|
||||
const existsUniqueConstraints = await queryInterface.showConstraint(
|
||||
collection.addSchemaTableName(),
|
||||
collection.getTableNameWithSchema(),
|
||||
constraintName,
|
||||
{},
|
||||
);
|
||||
@ -133,7 +131,7 @@ export class FieldModel extends MagicAttributeModel {
|
||||
// @ts-ignore
|
||||
await collection.sync({ ...options, force: false, alter: { drop: false } });
|
||||
|
||||
await queryInterface.addConstraint(collection.addSchemaTableName(), {
|
||||
await queryInterface.addConstraint(collection.getTableNameWithSchema(), {
|
||||
type: 'unique',
|
||||
fields: [columnName],
|
||||
name: constraintName,
|
||||
@ -144,7 +142,7 @@ export class FieldModel extends MagicAttributeModel {
|
||||
}
|
||||
|
||||
if (!unique && existsUniqueConstraint) {
|
||||
await queryInterface.removeConstraint(collection.addSchemaTableName(), constraintName, {
|
||||
await queryInterface.removeConstraint(collection.getTableNameWithSchema(), constraintName, {
|
||||
transaction: options.transaction,
|
||||
});
|
||||
|
||||
@ -168,7 +166,7 @@ export class FieldModel extends MagicAttributeModel {
|
||||
const queryInterface = collection.db.sequelize.getQueryInterface();
|
||||
|
||||
await queryInterface.changeColumn(
|
||||
collection.addSchemaTableName(),
|
||||
collection.getTableNameWithSchema(),
|
||||
collection.model.rawAttributes[this.get('name')].field,
|
||||
{
|
||||
type: field.dataType,
|
||||
|
@ -11,13 +11,13 @@ import {
|
||||
afterCreateForReverseField,
|
||||
beforeCreateForReverseField,
|
||||
beforeDestroyForeignKey,
|
||||
beforeInitOptions,
|
||||
beforeInitOptions
|
||||
} from './hooks';
|
||||
|
||||
import { InheritedCollection } from '@nocobase/database';
|
||||
import { CollectionModel, FieldModel } from './models';
|
||||
import * as process from 'process';
|
||||
import lodash from 'lodash';
|
||||
import * as process from 'process';
|
||||
import { CollectionModel, FieldModel } from './models';
|
||||
|
||||
export class CollectionManagerPlugin extends Plugin {
|
||||
public schema: string;
|
||||
@ -255,7 +255,7 @@ export class CollectionManagerPlugin extends Plugin {
|
||||
this.app.resourcer.use(async (ctx, next) => {
|
||||
if (ctx.action.resourceName === 'collections.fields' && ['create', 'update'].includes(ctx.action.actionName)) {
|
||||
ctx.action.mergeParams({
|
||||
updateAssociationValues: ['uiSchema', 'reverseField'],
|
||||
updateAssociationValues: ['reverseField'],
|
||||
});
|
||||
}
|
||||
await next();
|
||||
|
@ -6,6 +6,7 @@
|
||||
"main": "./lib/index.js",
|
||||
"types": "./lib/index.d.ts",
|
||||
"dependencies": {
|
||||
"@koa/multer": "^3.0.2",
|
||||
"@nocobase/client": "0.9.1-alpha.2",
|
||||
"@nocobase/database": "0.9.1-alpha.2",
|
||||
"@nocobase/server": "0.9.1-alpha.2",
|
||||
@ -13,6 +14,7 @@
|
||||
"dayjs": "^1.11.7",
|
||||
"decompress": "^4.2.1",
|
||||
"inquirer": "^8.0.0",
|
||||
"koa-send": "^5.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"mkdirp": "^1.0.4",
|
||||
"tar": "^6.1.13"
|
||||
|
73
packages/plugins/duplicator/src/server/__tests__/api.test.ts
Normal file
73
packages/plugins/duplicator/src/server/__tests__/api.test.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { mockServer, MockServer } from '@nocobase/test';
|
||||
import path from 'path';
|
||||
|
||||
describe('duplicator api', () => {
|
||||
let app: MockServer;
|
||||
beforeEach(async () => {
|
||||
app = mockServer();
|
||||
app.plugin(require('../server').default, { name: 'duplicator' });
|
||||
app.plugin('error-handler');
|
||||
app.plugin('collection-manager');
|
||||
await app.loadAndInstall({ clean: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('should get collection groups', async () => {
|
||||
await app.db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'test_collection',
|
||||
title: '测试Collection',
|
||||
fields: [
|
||||
{
|
||||
name: 'test_field1',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
|
||||
const collectionGroupsResponse = await app.agent().resource('duplicator').dumpableCollections();
|
||||
expect(collectionGroupsResponse.status).toBe(200);
|
||||
|
||||
const data = collectionGroupsResponse.body;
|
||||
|
||||
expect(data['requiredGroups']).toBeTruthy();
|
||||
expect(data['optionalGroups']).toBeTruthy();
|
||||
expect(data['userCollections']).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should request dump api', async () => {
|
||||
const dumpResponse = await app.agent().post('/duplicator:dump').send({
|
||||
selectedCollectionGroups: [],
|
||||
selectedUserCollections: [],
|
||||
});
|
||||
|
||||
expect(dumpResponse.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should request restore api', async () => {
|
||||
const packageInfoResponse = await app
|
||||
.agent()
|
||||
.post('/duplicator:upload')
|
||||
.attach('file', path.resolve(__dirname, './fixtures/dump.nbdump.fixture'));
|
||||
|
||||
console.log(packageInfoResponse.body);
|
||||
expect(packageInfoResponse.status).toBe(200);
|
||||
const data = packageInfoResponse.body.data;
|
||||
|
||||
expect(data['key']).toBeTruthy();
|
||||
expect(data['meta']).toBeTruthy();
|
||||
|
||||
const restoreResponse = await app.agent().post('/duplicator:restore').send({
|
||||
restoreKey: data['key'],
|
||||
selectedOptionalGroups: [],
|
||||
selectedUserCollections: [],
|
||||
});
|
||||
|
||||
expect(restoreResponse.status).toBe(200);
|
||||
});
|
||||
});
|
@ -0,0 +1,37 @@
|
||||
import { mockServer, MockServer } from '@nocobase/test';
|
||||
import { CollectionGroupManager } from '../collection-group-manager';
|
||||
|
||||
describe('collection group manager', () => {
|
||||
let app: MockServer;
|
||||
beforeEach(async () => {
|
||||
app = mockServer({
|
||||
plugins: ['error-handler', 'collection-manager'],
|
||||
});
|
||||
|
||||
await app.loadAndInstall({
|
||||
clean: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('should list collection groups from db collections', async () => {
|
||||
const collectionGroups = CollectionGroupManager.getGroups(app);
|
||||
|
||||
expect(collectionGroups.map((i) => i.function)).toMatchObject([
|
||||
'migration',
|
||||
'applicationPlugins',
|
||||
'applicationVersion',
|
||||
'collections',
|
||||
]);
|
||||
|
||||
expect(collectionGroups.find((i) => i.function === 'collections')).toMatchObject({
|
||||
namespace: 'collection-manager',
|
||||
function: 'collections',
|
||||
collections: ['collectionCategory', 'collectionCategories', 'collections', 'fields'],
|
||||
dumpable: 'required',
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,35 @@
|
||||
import { MockServer } from '@nocobase/test';
|
||||
import createApp from './index';
|
||||
import { Dumper } from '../dumper';
|
||||
|
||||
describe('dumper', () => {
|
||||
let app: MockServer;
|
||||
beforeEach(async () => {
|
||||
app = await createApp();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('should get collection groups', async () => {
|
||||
await app.db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'test_collection',
|
||||
fields: [
|
||||
{
|
||||
name: 'test_field1',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
|
||||
const dump = new Dumper(app);
|
||||
const dumpableCollections = await dump.dumpableCollections();
|
||||
|
||||
expect((dumpableCollections.requiredGroups || []).length).toBeGreaterThan(0);
|
||||
expect(dumpableCollections.userCollections[0]['name']).toEqual('test_collection');
|
||||
});
|
||||
});
|
Binary file not shown.
11
packages/plugins/duplicator/src/server/__tests__/index.ts
Normal file
11
packages/plugins/duplicator/src/server/__tests__/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { mockServer } from '@nocobase/test';
|
||||
|
||||
export default async function createApp() {
|
||||
const app = mockServer();
|
||||
app.plugin(require('../server').default, { name: 'duplicator' });
|
||||
app.plugin('error-handler');
|
||||
app.plugin('collection-manager');
|
||||
await app.loadAndInstall({ clean: true });
|
||||
|
||||
return app;
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
import { Dumper } from '../dumper';
|
||||
import send from 'koa-send';
|
||||
import { getApp } from './get-app';
|
||||
|
||||
export default async function dumpAction(ctx, next) {
|
||||
const data = <
|
||||
{
|
||||
selectedOptionalGroupNames: string[];
|
||||
selectedUserCollections: string[];
|
||||
app?: string;
|
||||
}
|
||||
>ctx.request.body;
|
||||
|
||||
const app = await getApp(ctx, data.app);
|
||||
|
||||
const dumper = new Dumper(app);
|
||||
|
||||
const { filePath, dirname } = await dumper.dump(data);
|
||||
|
||||
await send(ctx, filePath.replace(dirname, ''), {
|
||||
root: dirname,
|
||||
});
|
||||
|
||||
await next();
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
import { Dumper } from '../dumper';
|
||||
import { getApp } from './get-app';
|
||||
|
||||
export default async function dumpableCollections(ctx, next) {
|
||||
ctx.withoutDataWrapping = true;
|
||||
|
||||
const app = await getApp(ctx, ctx.request.query.app);
|
||||
const dumper = new Dumper(app);
|
||||
|
||||
ctx.body = await dumper.dumpableCollections();
|
||||
|
||||
await next();
|
||||
}
|
14
packages/plugins/duplicator/src/server/actions/get-app.ts
Normal file
14
packages/plugins/duplicator/src/server/actions/get-app.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export async function getApp(ctx, subAppName) {
|
||||
let app = ctx.app;
|
||||
|
||||
if (subAppName) {
|
||||
const subApp = await app.appManager.getApplication(subAppName);
|
||||
if (!subApp) {
|
||||
throw new Error(`app ${subAppName} not found`);
|
||||
}
|
||||
|
||||
app = subApp;
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
export default async function getDictAction(ctx, next) {
|
||||
ctx.withoutDataWrapping = true;
|
||||
let collectionNames = await ctx.db.getRepository('collections').find();
|
||||
collectionNames = collectionNames.map((item) => item.get('name'));
|
||||
const collections: any[] = [];
|
||||
for (const [name, collection] of ctx.db.collections) {
|
||||
const columns: any[] = [];
|
||||
for (const key in collection.model.rawAttributes) {
|
||||
if (Object.prototype.hasOwnProperty.call(collection.model.rawAttributes, key)) {
|
||||
const attribute = collection.model.rawAttributes[key];
|
||||
columns.push({
|
||||
realName: attribute.field,
|
||||
name: key,
|
||||
});
|
||||
}
|
||||
}
|
||||
const item = {
|
||||
name,
|
||||
title: collection.options.title,
|
||||
namespace: collection.options.namespace,
|
||||
duplicator: collection.options.duplicator,
|
||||
// columns,
|
||||
};
|
||||
if (!item.namespace && collectionNames.includes(name)) {
|
||||
item.namespace = 'collection-manager';
|
||||
if (!item.duplicator) {
|
||||
item.duplicator = 'optional';
|
||||
}
|
||||
}
|
||||
collections.push(item);
|
||||
}
|
||||
ctx.body = collections;
|
||||
await next();
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
import { Restorer } from '../restorer';
|
||||
import * as os from 'os';
|
||||
import path from 'path';
|
||||
import { getApp } from './get-app';
|
||||
|
||||
export async function restoreAction(ctx, next) {
|
||||
const { restoreKey, selectedOptionalGroups, selectedUserCollections } = ctx.request.body;
|
||||
const appName = ctx.request.body.app;
|
||||
|
||||
const tmpDir = os.tmpdir();
|
||||
const filePath = path.resolve(tmpDir, restoreKey);
|
||||
|
||||
const app = await getApp(ctx, appName);
|
||||
|
||||
const restorer = new Restorer(app, {
|
||||
backUpFilePath: filePath,
|
||||
});
|
||||
|
||||
await restorer.restore({
|
||||
selectedOptionalGroupNames: selectedOptionalGroups,
|
||||
selectedUserCollections,
|
||||
});
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
export const getPackageContent = async (ctx, next) => {
|
||||
const file = ctx.file;
|
||||
const fileName = file.filename;
|
||||
|
||||
const restorer = new Restorer(ctx.app, {
|
||||
backUpFilePath: file.path,
|
||||
});
|
||||
|
||||
const restoreMeta = await restorer.parseBackupFile();
|
||||
|
||||
ctx.body = {
|
||||
key: fileName,
|
||||
meta: restoreMeta,
|
||||
};
|
||||
|
||||
await next();
|
||||
};
|
@ -3,11 +3,14 @@ import { applyMixins, AsyncEmitter } from '@nocobase/utils';
|
||||
import crypto from 'crypto';
|
||||
import EventEmitter from 'events';
|
||||
import fsPromises from 'fs/promises';
|
||||
import inquirer from 'inquirer';
|
||||
import lodash from 'lodash';
|
||||
import * as os from 'os';
|
||||
import path from 'path';
|
||||
import { CollectionGroupManager } from './collection-group-manager';
|
||||
|
||||
export type AppMigratorOptions = {
|
||||
workDir?: string;
|
||||
};
|
||||
abstract class AppMigrator extends EventEmitter {
|
||||
protected workDir: string;
|
||||
public app: Application;
|
||||
@ -16,12 +19,7 @@ abstract class AppMigrator extends EventEmitter {
|
||||
|
||||
declare emitAsync: (event: string | symbol, ...args: any[]) => Promise<boolean>;
|
||||
|
||||
constructor(
|
||||
app,
|
||||
options?: {
|
||||
workDir?: string;
|
||||
},
|
||||
) {
|
||||
constructor(app, options?: AppMigratorOptions) {
|
||||
super();
|
||||
|
||||
this.app = app;
|
||||
@ -44,7 +42,14 @@ abstract class AppMigrator extends EventEmitter {
|
||||
async getAppPlugins() {
|
||||
const plugins = await this.app.db.getCollection('applicationPlugins').repository.find();
|
||||
|
||||
return ['core', ...plugins.map((plugin) => plugin.get('name'))];
|
||||
return lodash.uniq(['core', ...this.app.pm.plugins.keys(), ...plugins.map((plugin) => plugin.get('name'))]);
|
||||
}
|
||||
|
||||
async getAppPluginCollectionGroups() {
|
||||
const plugins = await this.getAppPlugins();
|
||||
return CollectionGroupManager.collectionGroups.filter((collectionGroup) =>
|
||||
plugins.includes(collectionGroup.namespace),
|
||||
);
|
||||
}
|
||||
|
||||
async getCustomCollections() {
|
||||
@ -60,64 +65,6 @@ abstract class AppMigrator extends EventEmitter {
|
||||
await this.rmDir(this.workDir);
|
||||
}
|
||||
|
||||
buildInquirerPluginQuestion(requiredGroups, optionalGroups) {
|
||||
return {
|
||||
type: 'checkbox',
|
||||
name: 'collectionGroups',
|
||||
message: `Select the plugin collections to be ${this.direction === 'dump' ? 'dumped' : 'restored'}`,
|
||||
loop: false,
|
||||
pageSize: 20,
|
||||
choices: [
|
||||
new inquirer.Separator('== Required =='),
|
||||
...requiredGroups.map((collectionGroup) => ({
|
||||
name: `${collectionGroup.function} (${collectionGroup.pluginName})`,
|
||||
value: `${collectionGroup.pluginName}.${collectionGroup.function}`,
|
||||
checked: true,
|
||||
disabled: true,
|
||||
})),
|
||||
|
||||
new inquirer.Separator('== Optional =='),
|
||||
...optionalGroups.map((collectionGroup) => ({
|
||||
name: `${collectionGroup.function} (${collectionGroup.pluginName})`,
|
||||
value: `${collectionGroup.pluginName}.${collectionGroup.function}`,
|
||||
checked: this.direction === 'dump',
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
buildInquirerCollectionQuestion(
|
||||
collections: {
|
||||
name: string;
|
||||
title: string;
|
||||
}[],
|
||||
) {
|
||||
return {
|
||||
type: 'checkbox',
|
||||
name: 'userCollections',
|
||||
message: `Select the collection records to be ${this.direction === 'dump' ? 'dumped' : 'restored'}`,
|
||||
loop: false,
|
||||
pageSize: 30,
|
||||
choices: collections.map((collection) => {
|
||||
return {
|
||||
name: collection.title,
|
||||
value: collection.name,
|
||||
checked: this.direction === 'dump',
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
buildInquirerQuestions(requiredGroups, optionalGroups, optionalCollections) {
|
||||
const questions = [this.buildInquirerPluginQuestion(requiredGroups, optionalGroups)];
|
||||
|
||||
if (optionalCollections.length > 0) {
|
||||
questions.push(this.buildInquirerCollectionQuestion(optionalCollections));
|
||||
}
|
||||
|
||||
return questions;
|
||||
}
|
||||
|
||||
findThroughCollections(collections: string[]) {
|
||||
return [
|
||||
...new Set(
|
||||
|
@ -1,40 +1,19 @@
|
||||
import lodash from 'lodash';
|
||||
import { Restorer } from './restorer';
|
||||
|
||||
interface CollectionGroup {
|
||||
pluginName: string;
|
||||
collections: string[];
|
||||
function: string;
|
||||
|
||||
dumpable: 'required' | 'optional' | 'skip';
|
||||
delayRestore?: any;
|
||||
}
|
||||
import { Application } from '@nocobase/server';
|
||||
import { CollectionGroup } from '@nocobase/database';
|
||||
|
||||
export class CollectionGroupManager {
|
||||
static collectionGroups: CollectionGroup[] = [];
|
||||
|
||||
static registerCollectionGroup(collectionGroup: CollectionGroup) {
|
||||
this.collectionGroups.push(collectionGroup);
|
||||
static getGroups(app: Application) {
|
||||
return app.db.collectionGroupManager.getGroups();
|
||||
}
|
||||
|
||||
static getGroupsCollections(groups: string[] | CollectionGroup[]) {
|
||||
if (groups.length == 0) {
|
||||
static getGroupsCollections(groups: CollectionGroup[]) {
|
||||
if (!groups || groups.length == 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (lodash.isPlainObject(groups[0])) {
|
||||
groups = (groups as CollectionGroup[]).map(
|
||||
(collectionGroup) => `${collectionGroup.pluginName}.${collectionGroup.function}`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.collectionGroups
|
||||
.filter((collectionGroup) => {
|
||||
const groupKey = `${collectionGroup.pluginName}.${collectionGroup.function}`;
|
||||
return (groups as string[]).includes(groupKey);
|
||||
})
|
||||
.map((collectionGroup) => collectionGroup.collections)
|
||||
.flat();
|
||||
return groups.map((collectionGroup) => collectionGroup.collections).flat();
|
||||
}
|
||||
|
||||
static classifyCollectionGroups(collectionGroups: CollectionGroup[]) {
|
||||
@ -46,210 +25,4 @@ export class CollectionGroupManager {
|
||||
optionalGroups,
|
||||
};
|
||||
}
|
||||
|
||||
static getDelayRestoreCollectionGroups() {
|
||||
return this.collectionGroups.filter((collectionGroup) => collectionGroup.delayRestore);
|
||||
}
|
||||
}
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'core',
|
||||
function: 'migration',
|
||||
collections: ['migrations'],
|
||||
dumpable: 'required',
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'multi-app-manager',
|
||||
function: 'multi apps',
|
||||
collections: ['applications'],
|
||||
dumpable: 'optional',
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'collection-manager',
|
||||
function: 'collections',
|
||||
collections: ['collections', 'fields', 'collectionCategories', 'collectionCategory'],
|
||||
dumpable: 'required',
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'ui-schema-storage',
|
||||
function: 'uiSchemas',
|
||||
collections: ['uiSchemas', 'uiSchemaServerHooks', 'uiSchemaTemplates', 'uiSchemaTreePath'],
|
||||
dumpable: 'required',
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'ui-routes-storage',
|
||||
function: 'uiRoutes',
|
||||
collections: ['uiRoutes'],
|
||||
dumpable: 'required',
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'acl',
|
||||
function: 'acl',
|
||||
collections: ['roles', 'rolesResources', 'rolesResourcesActions', 'rolesResourcesScopes', 'rolesUischemas'],
|
||||
dumpable: 'required',
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'workflow',
|
||||
function: 'workflowConfig',
|
||||
collections: ['workflows', 'flow_nodes'],
|
||||
dumpable: 'required',
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'snapshot-field',
|
||||
function: 'snapshot-field',
|
||||
collections: ['collectionsHistory', 'fieldsHistory'],
|
||||
dumpable: 'required',
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'workflow',
|
||||
function: 'executionLogs',
|
||||
collections: ['executions', 'jobs'],
|
||||
dumpable: 'optional',
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'sequence-field',
|
||||
function: 'sequences',
|
||||
collections: ['sequences'],
|
||||
dumpable: 'required',
|
||||
async delayRestore(restorer: Restorer) {
|
||||
const app = restorer.app;
|
||||
const importedCollections = restorer.importedCollections;
|
||||
|
||||
const sequenceFields = importedCollections
|
||||
.map((collection) =>
|
||||
[...app.db.getCollection(collection).fields.values()].filter((field) => field.type === 'sequence'),
|
||||
)
|
||||
.flat()
|
||||
.filter(Boolean);
|
||||
|
||||
// a single sequence field refers to a single row in sequences table
|
||||
const sequencesAttributes = sequenceFields
|
||||
.map((field) => {
|
||||
const patterns = field.get('patterns').filter((pattern) => pattern.type === 'integer');
|
||||
|
||||
return patterns.map((pattern) => {
|
||||
return {
|
||||
collection: field.collection.name,
|
||||
field: field.name,
|
||||
key: pattern.options.key,
|
||||
};
|
||||
});
|
||||
})
|
||||
.flat();
|
||||
|
||||
if (sequencesAttributes.length > 0) {
|
||||
await app.db.getRepository('sequences').destroy({
|
||||
filter: {
|
||||
$or: sequencesAttributes,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await restorer.importCollection({
|
||||
name: 'sequences',
|
||||
clear: false,
|
||||
rowCondition(row) {
|
||||
const results = sequencesAttributes.some((attributes) => {
|
||||
return (
|
||||
row.collection === attributes.collection && row.field === attributes.field && row.key === attributes.key
|
||||
);
|
||||
});
|
||||
|
||||
return results;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'users',
|
||||
function: 'users',
|
||||
collections: ['users', 'rolesUsers'],
|
||||
dumpable: 'optional',
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'file-manager',
|
||||
function: 'storageSetting',
|
||||
collections: ['storages'],
|
||||
dumpable: 'optional',
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'file-manager',
|
||||
function: 'attachmentRecords',
|
||||
collections: ['attachments'],
|
||||
dumpable: 'optional',
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'system-settings',
|
||||
function: 'systemSettings',
|
||||
collections: ['systemSettings'],
|
||||
dumpable: 'optional',
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'verification',
|
||||
function: 'verificationProviders',
|
||||
collections: ['verifications_providers'],
|
||||
dumpable: 'optional',
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'verification',
|
||||
function: 'verificationData',
|
||||
collections: ['verifications'],
|
||||
dumpable: 'optional',
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'oidc',
|
||||
function: 'oidcProviders',
|
||||
collections: ['oidcProviders'],
|
||||
dumpable: 'optional',
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'saml',
|
||||
function: 'samlProviders',
|
||||
collections: ['samlProviders'],
|
||||
dumpable: 'optional',
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'map',
|
||||
function: 'mapConfiguration',
|
||||
collections: ['mapConfiguration'],
|
||||
dumpable: 'optional',
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'audit-logs',
|
||||
function: 'auditLogs',
|
||||
collections: ['auditLogs', 'auditChanges'],
|
||||
dumpable: 'optional',
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'graph-collection-manager',
|
||||
function: 'graphCollectionPositions',
|
||||
collections: ['graphPositions'],
|
||||
dumpable: 'optional',
|
||||
});
|
||||
|
||||
CollectionGroupManager.registerCollectionGroup({
|
||||
pluginName: 'iframe-block',
|
||||
function: 'iframe html storage',
|
||||
collections: ['iframeHtml'],
|
||||
dumpable: 'required',
|
||||
});
|
||||
|
@ -0,0 +1,49 @@
|
||||
import inquirer from 'inquirer';
|
||||
import { Application } from '@nocobase/server';
|
||||
import { Dumper } from '../dumper';
|
||||
import InquireQuestionBuilder from './inquire-question-builder';
|
||||
|
||||
export default function addDumpCommand(app: Application) {
|
||||
app
|
||||
.command('dump')
|
||||
.option('-a, --app <appName>', 'sub app name if you dump sub app in multiple apps')
|
||||
.action(async (options) => {
|
||||
let dumpApp = app;
|
||||
|
||||
if (options.app) {
|
||||
const subApp = await app.appManager.getApplication(options.app);
|
||||
if (!subApp) {
|
||||
app.log.error(`app ${options.app} not found`);
|
||||
await app.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
dumpApp = subApp;
|
||||
}
|
||||
|
||||
await dumpCommandAction(dumpApp);
|
||||
});
|
||||
}
|
||||
|
||||
async function dumpCommandAction(app) {
|
||||
const dumper = new Dumper(app);
|
||||
const { requiredGroups, optionalGroups, userCollections } = await dumper.dumpableCollections();
|
||||
|
||||
const questions = InquireQuestionBuilder.buildInquirerQuestions({
|
||||
requiredGroups,
|
||||
optionalGroups,
|
||||
optionalCollections: userCollections,
|
||||
direction: 'dump',
|
||||
});
|
||||
|
||||
const results = await inquirer.prompt(questions);
|
||||
|
||||
const { filePath } = await dumper.dump({
|
||||
selectedOptionalGroupNames: results.collectionGroups,
|
||||
selectedUserCollections: results.userCollections,
|
||||
});
|
||||
|
||||
app.log.info(`dumped to ${filePath}`);
|
||||
|
||||
await app.stop();
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
import { Application } from '@nocobase/server';
|
||||
import { Dumper } from '../dumper';
|
||||
|
||||
export default function addDumpCommand(app: Application) {
|
||||
app.command('dump').action(async () => {
|
||||
await dumpAction(app);
|
||||
});
|
||||
}
|
||||
|
||||
async function dumpAction(app) {
|
||||
const dumper = new Dumper(app);
|
||||
await dumper.dump();
|
||||
|
||||
await app.stop();
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
import inquirer from 'inquirer';
|
||||
import { CollectionGroup } from '@nocobase/database';
|
||||
|
||||
export default class InquireQuestionBuilder {
|
||||
static buildInquirerQuestions(options: {
|
||||
requiredGroups: CollectionGroup[];
|
||||
optionalGroups: CollectionGroup[];
|
||||
optionalCollections: {
|
||||
name: string;
|
||||
title: string;
|
||||
}[];
|
||||
direction: 'dump' | 'restore';
|
||||
}) {
|
||||
const { requiredGroups, optionalGroups, optionalCollections, direction } = options;
|
||||
const questions = [this.buildInquirerPluginQuestion(requiredGroups, optionalGroups, direction)];
|
||||
|
||||
if (optionalCollections.length > 0) {
|
||||
questions.push(this.buildInquirerCollectionQuestion(optionalCollections, direction));
|
||||
}
|
||||
|
||||
return questions;
|
||||
}
|
||||
|
||||
static buildInquirerPluginQuestion(requiredGroups, optionalGroups, direction: 'dump' | 'restore') {
|
||||
return {
|
||||
type: 'checkbox',
|
||||
name: 'collectionGroups',
|
||||
message: `Select the plugin collections to be ${direction === 'dump' ? 'dumped' : 'restored'}`,
|
||||
loop: false,
|
||||
pageSize: 20,
|
||||
choices: [
|
||||
new inquirer.Separator('== Required =='),
|
||||
...requiredGroups.map((collectionGroup) => ({
|
||||
name: `${collectionGroup.function} (${collectionGroup.namespace})`,
|
||||
value: `${collectionGroup.namespace}.${collectionGroup.function}`,
|
||||
checked: true,
|
||||
disabled: true,
|
||||
})),
|
||||
|
||||
new inquirer.Separator('== Optional =='),
|
||||
...optionalGroups.map((collectionGroup) => ({
|
||||
name: `${collectionGroup.function} (${collectionGroup.namespace})`,
|
||||
value: `${collectionGroup.namespace}.${collectionGroup.function}`,
|
||||
checked: direction === 'dump',
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
static buildInquirerCollectionQuestion(
|
||||
collections: {
|
||||
name: string;
|
||||
title: string;
|
||||
}[],
|
||||
direction: 'dump' | 'restore',
|
||||
) {
|
||||
return {
|
||||
type: 'checkbox',
|
||||
name: 'userCollections',
|
||||
message: `Select the collection records to be ${direction === 'dump' ? 'dumped' : 'restored'}`,
|
||||
loop: false,
|
||||
pageSize: 30,
|
||||
choices: collections.map((collection) => {
|
||||
return {
|
||||
name: collection.title,
|
||||
value: collection.name,
|
||||
checked: direction === 'dump',
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,94 @@
|
||||
import { Application } from '@nocobase/server';
|
||||
import { Restorer } from '../restorer';
|
||||
import inquirer from 'inquirer';
|
||||
import InquireQuestionBuilder from './inquire-question-builder';
|
||||
|
||||
export default function addRestoreCommand(app: Application) {
|
||||
app
|
||||
.command('restore')
|
||||
.argument('<string>', 'restore file path')
|
||||
.option('-a, --app <appName>', 'sub app name if you want to restore into a sub app')
|
||||
.option('-f, --force', 'force restore without warning')
|
||||
.action(async (restoreFilePath, options) => {
|
||||
let importApp = app;
|
||||
|
||||
if (options.app) {
|
||||
if (
|
||||
!(await app.db.getCollection('applications').repository.findOne({
|
||||
filter: { name: options.app },
|
||||
}))
|
||||
) {
|
||||
// create sub app if not exists
|
||||
await app.db.getCollection('applications').repository.create({
|
||||
values: {
|
||||
name: options.app,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const subApp = await app.appManager.getApplication(options.app);
|
||||
|
||||
if (!subApp) {
|
||||
app.log.error(`app ${options.app} not found`);
|
||||
await app.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
importApp = subApp;
|
||||
}
|
||||
|
||||
// should confirm data will be overwritten
|
||||
if (!options.force && !(await restoreWarning())) {
|
||||
return;
|
||||
}
|
||||
|
||||
await restoreActionCommand(importApp, restoreFilePath);
|
||||
});
|
||||
}
|
||||
|
||||
interface RestoreContext {
|
||||
app: Application;
|
||||
dir: string;
|
||||
}
|
||||
|
||||
async function restoreWarning() {
|
||||
const results = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message: 'Danger !!! This action will overwrite your current data, please make sure you have a backup❗️❗️',
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
return results.confirm;
|
||||
}
|
||||
|
||||
async function restoreActionCommand(app: Application, restoreFilePath: string) {
|
||||
const restorer = new Restorer(app, {
|
||||
backUpFilePath: restoreFilePath,
|
||||
});
|
||||
const restoreMeta = await restorer.parseBackupFile();
|
||||
|
||||
const { requiredGroups, selectedOptionalGroups, selectedUserCollections } = restoreMeta;
|
||||
|
||||
const questions = InquireQuestionBuilder.buildInquirerQuestions({
|
||||
requiredGroups,
|
||||
optionalGroups: selectedOptionalGroups,
|
||||
optionalCollections: await Promise.all(
|
||||
selectedUserCollections.map(async (name) => {
|
||||
return { name, title: await restorer.getImportCollectionTitle(name) };
|
||||
}),
|
||||
),
|
||||
direction: 'restore',
|
||||
});
|
||||
|
||||
const results = await inquirer.prompt(questions);
|
||||
|
||||
await restorer.restore({
|
||||
selectedOptionalGroupNames: results.collectionGroups,
|
||||
selectedUserCollections: results.userCollections,
|
||||
});
|
||||
|
||||
await app.stop();
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
import { Application } from '@nocobase/server';
|
||||
import { Restorer } from '../restorer';
|
||||
|
||||
export default function addRestoreCommand(app: Application) {
|
||||
app
|
||||
.command('restore')
|
||||
.argument('<string>', 'restore file path')
|
||||
.action(async (restoreFilePath, options) => {
|
||||
await restoreAction(app, restoreFilePath, options);
|
||||
});
|
||||
}
|
||||
|
||||
interface RestoreContext {
|
||||
app: Application;
|
||||
dir: string;
|
||||
}
|
||||
|
||||
async function restoreAction(app: Application, restoreFilePath: string, options) {
|
||||
const restorer = new Restorer(app);
|
||||
await restorer.restore(restoreFilePath);
|
||||
await app.stop();
|
||||
}
|
@ -2,7 +2,6 @@ import archiver from 'archiver';
|
||||
import dayjs from 'dayjs';
|
||||
import fs from 'fs';
|
||||
import fsPromises from 'fs/promises';
|
||||
import inquirer from 'inquirer';
|
||||
import lodash from 'lodash';
|
||||
import mkdirp from 'mkdirp';
|
||||
import path from 'path';
|
||||
@ -10,6 +9,7 @@ import stream from 'stream';
|
||||
import util from 'util';
|
||||
import { AppMigrator } from './app-migrator';
|
||||
import { CollectionGroupManager } from './collection-group-manager';
|
||||
import { CollectionGroup } from '@nocobase/database';
|
||||
import { FieldValueWriter } from './field-value-writer';
|
||||
import { DUMPED_EXTENSION, humanFileSize, sqlAdapter } from './utils';
|
||||
|
||||
@ -18,27 +18,29 @@ const finished = util.promisify(stream.finished);
|
||||
export class Dumper extends AppMigrator {
|
||||
direction = 'dump' as const;
|
||||
|
||||
async dump() {
|
||||
const appPlugins = await this.getAppPlugins();
|
||||
async dumpableCollections(): Promise<{
|
||||
requiredGroups: CollectionGroup[];
|
||||
optionalGroups: CollectionGroup[];
|
||||
userCollections: Array<{
|
||||
name: string;
|
||||
title: string;
|
||||
}>;
|
||||
}> {
|
||||
const appCollectionGroups = CollectionGroupManager.getGroups(this.app);
|
||||
|
||||
// get system available collection groups
|
||||
const collectionGroups = CollectionGroupManager.collectionGroups.filter((collectionGroup) =>
|
||||
appPlugins.includes(collectionGroup.pluginName),
|
||||
);
|
||||
const { requiredGroups, optionalGroups } = CollectionGroupManager.classifyCollectionGroups(appCollectionGroups);
|
||||
const pluginsCollections = CollectionGroupManager.getGroupsCollections(appCollectionGroups);
|
||||
|
||||
const coreCollections = ['applicationPlugins'];
|
||||
const customCollections = await this.getCustomCollections();
|
||||
|
||||
const { requiredGroups, optionalGroups } = CollectionGroupManager.classifyCollectionGroups(collectionGroups);
|
||||
const pluginsCollections = CollectionGroupManager.getGroupsCollections(collectionGroups);
|
||||
|
||||
const optionalCollections = [...customCollections.filter((collection) => !pluginsCollections.includes(collection))];
|
||||
|
||||
const questions = this.buildInquirerQuestions(
|
||||
const userCollections = await this.getCustomCollections();
|
||||
return lodash.cloneDeep({
|
||||
requiredGroups,
|
||||
optionalGroups,
|
||||
await Promise.all(
|
||||
optionalCollections.map(async (name) => {
|
||||
userCollections: await Promise.all(
|
||||
userCollections
|
||||
.filter((collection) => !pluginsCollections.includes(collection)) //remove collection that is in plugins
|
||||
.map(async (name) => {
|
||||
// map user collection to { name, title }
|
||||
|
||||
const collectionInstance = await this.app.db.getRepository('collections').findOne({
|
||||
filterByTk: name,
|
||||
});
|
||||
@ -49,21 +51,30 @@ export class Dumper extends AppMigrator {
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const results = await inquirer.prompt(questions);
|
||||
async dump(options: { selectedOptionalGroupNames: string[]; selectedUserCollections: string[] }) {
|
||||
const { requiredGroups, optionalGroups } = await this.dumpableCollections();
|
||||
let { selectedOptionalGroupNames, selectedUserCollections = [] } = options;
|
||||
|
||||
const userCollections = results.userCollections || [];
|
||||
const throughCollections = this.findThroughCollections(selectedUserCollections);
|
||||
|
||||
const throughCollections = this.findThroughCollections(userCollections);
|
||||
const selectedOptionalGroups = optionalGroups.filter((group) => {
|
||||
return selectedOptionalGroupNames.some((selectedOptionalGroupName) => {
|
||||
const [namespace, functionKey] = selectedOptionalGroupName.split('.');
|
||||
return group.function === functionKey && group.namespace === namespace;
|
||||
});
|
||||
});
|
||||
|
||||
const dumpedCollections = [
|
||||
coreCollections,
|
||||
const dumpedCollections = lodash.uniq(
|
||||
[
|
||||
CollectionGroupManager.getGroupsCollections(requiredGroups),
|
||||
CollectionGroupManager.getGroupsCollections(results.collectionGroups),
|
||||
userCollections,
|
||||
CollectionGroupManager.getGroupsCollections(selectedOptionalGroups),
|
||||
selectedUserCollections,
|
||||
throughCollections,
|
||||
].flat();
|
||||
].flat(),
|
||||
);
|
||||
|
||||
for (const collection of dumpedCollections) {
|
||||
await this.dumpCollection({
|
||||
@ -71,10 +82,30 @@ export class Dumper extends AppMigrator {
|
||||
});
|
||||
}
|
||||
|
||||
await this.dumpMeta();
|
||||
const mapGroupToMetaJson = (groups) =>
|
||||
groups.map((group: CollectionGroup) => {
|
||||
const data = {
|
||||
...group,
|
||||
};
|
||||
|
||||
if (group.delayRestore) {
|
||||
data['delayRestore'] = true;
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
await this.dumpMeta({
|
||||
requiredGroups: mapGroupToMetaJson(requiredGroups),
|
||||
selectedOptionalGroups: mapGroupToMetaJson(selectedOptionalGroups),
|
||||
selectedUserCollections: selectedUserCollections,
|
||||
});
|
||||
|
||||
await this.dumpDb();
|
||||
await this.packDumpedDir();
|
||||
|
||||
const filePath = await this.packDumpedDir();
|
||||
await this.clearWorkDir();
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async dumpDb() {
|
||||
@ -84,18 +115,14 @@ export class Dumper extends AppMigrator {
|
||||
if (dialect === 'postgres') {
|
||||
// get user defined functions in postgres
|
||||
const functions = await db.sequelize.query(
|
||||
`SELECT
|
||||
n.nspname AS function_schema,
|
||||
p.proname AS function_name,
|
||||
pg_get_functiondef(p.oid) AS def
|
||||
FROM
|
||||
pg_proc p
|
||||
LEFT JOIN pg_namespace n ON p.pronamespace = n.oid
|
||||
WHERE
|
||||
n.nspname NOT IN ('pg_catalog', 'information_schema')
|
||||
ORDER BY
|
||||
function_schema,
|
||||
function_name;`,
|
||||
`SELECT n.nspname AS function_schema,
|
||||
p.proname AS function_name,
|
||||
pg_get_functiondef(p.oid) AS def
|
||||
FROM pg_proc p
|
||||
LEFT JOIN pg_namespace n ON p.pronamespace = n.oid
|
||||
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
||||
ORDER BY function_schema,
|
||||
function_name;`,
|
||||
{
|
||||
type: 'SELECT',
|
||||
},
|
||||
@ -106,9 +133,13 @@ function_name;`,
|
||||
}
|
||||
|
||||
// get user defined triggers in postgres
|
||||
const triggers = await db.sequelize.query(`select pg_get_triggerdef(oid) from pg_trigger`, {
|
||||
const triggers = await db.sequelize.query(
|
||||
`select pg_get_triggerdef(oid)
|
||||
from pg_trigger`,
|
||||
{
|
||||
type: 'SELECT',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
for (const t of triggers) {
|
||||
sqlContent.push(t['pg_get_triggerdef']);
|
||||
@ -117,9 +148,9 @@ function_name;`,
|
||||
// get user defined views in postgres
|
||||
const views = await db.sequelize.query(
|
||||
`SELECT table_schema, table_name, pg_get_viewdef("table_name", true) as def
|
||||
FROM information_schema.views
|
||||
WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
|
||||
ORDER BY table_schema, table_name`,
|
||||
FROM information_schema.views
|
||||
WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
|
||||
ORDER BY table_schema, table_name`,
|
||||
{
|
||||
type: 'SELECT',
|
||||
},
|
||||
@ -136,12 +167,16 @@ ORDER BY table_schema, table_name`,
|
||||
}
|
||||
}
|
||||
|
||||
async dumpMeta() {
|
||||
async dumpMeta(additionalMeta: Object = {}) {
|
||||
const metaPath = path.resolve(this.workDir, 'meta');
|
||||
|
||||
await fsPromises.writeFile(
|
||||
metaPath,
|
||||
JSON.stringify({ version: this.app.version.get(), dialect: this.app.db.sequelize.getDialect() }),
|
||||
JSON.stringify({
|
||||
version: await this.app.version.get(),
|
||||
dialect: this.app.db.sequelize.getDialect(),
|
||||
...additionalMeta,
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
@ -177,7 +212,11 @@ ORDER BY table_schema, table_name`,
|
||||
const dataStream = fs.createWriteStream(dataFilePath);
|
||||
|
||||
const rows = await app.db.sequelize.query(
|
||||
sqlAdapter(app.db, `SELECT * FROM ${collection.isParent() ? 'ONLY' : ''} ${collection.quotedTableName()}`),
|
||||
sqlAdapter(
|
||||
app.db,
|
||||
`SELECT *
|
||||
FROM ${collection.isParent() ? 'ONLY' : ''} ${collection.quotedTableName()}`,
|
||||
),
|
||||
{
|
||||
type: 'SELECT',
|
||||
},
|
||||
@ -248,5 +287,9 @@ ORDER BY table_schema, table_name`,
|
||||
|
||||
await archive.finalize();
|
||||
console.log('dumped to', filePath);
|
||||
return {
|
||||
filePath,
|
||||
dirname,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,44 +1,52 @@
|
||||
import decompress from 'decompress';
|
||||
import fs from 'fs';
|
||||
import fsPromises from 'fs/promises';
|
||||
import inquirer from 'inquirer';
|
||||
import path from 'path';
|
||||
import { AppMigrator } from './app-migrator';
|
||||
import { AppMigrator, AppMigratorOptions } from './app-migrator';
|
||||
import { CollectionGroupManager } from './collection-group-manager';
|
||||
import { FieldValueWriter } from './field-value-writer';
|
||||
import { readLines, sqlAdapter } from './utils';
|
||||
import { Application } from '@nocobase/server';
|
||||
|
||||
export class Restorer extends AppMigrator {
|
||||
direction = 'restore' as const;
|
||||
|
||||
backUpFilePath: string;
|
||||
decompressed: boolean = false;
|
||||
importedCollections: string[] = [];
|
||||
|
||||
async restore(backupFilePath: string) {
|
||||
let filePath: string;
|
||||
|
||||
if (path.isAbsolute(backupFilePath)) {
|
||||
filePath = backupFilePath;
|
||||
} else if (path.basename(backupFilePath) === backupFilePath) {
|
||||
const dirname = path.resolve(process.cwd(), 'storage', 'duplicator');
|
||||
filePath = path.resolve(dirname, backupFilePath);
|
||||
} else {
|
||||
filePath = path.resolve(process.cwd(), backupFilePath);
|
||||
}
|
||||
|
||||
const results = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message: 'Danger !!! This action will overwrite your current data, please make sure you have a backup❗️❗️',
|
||||
default: false,
|
||||
constructor(
|
||||
app: Application,
|
||||
options: AppMigratorOptions & {
|
||||
backUpFilePath?: string;
|
||||
},
|
||||
]);
|
||||
) {
|
||||
super(app, options);
|
||||
const { backUpFilePath } = options;
|
||||
|
||||
if (results.confirm !== true) {
|
||||
return;
|
||||
if (backUpFilePath) {
|
||||
this.setBackUpFilePath(backUpFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
await this.decompressBackup(filePath);
|
||||
await this.importCollections();
|
||||
setBackUpFilePath(backUpFilePath) {
|
||||
if (path.isAbsolute(backUpFilePath)) {
|
||||
this.backUpFilePath = backUpFilePath;
|
||||
} else if (path.basename(backUpFilePath) === backUpFilePath) {
|
||||
const dirname = path.resolve(process.cwd(), 'storage', 'duplicator');
|
||||
this.backUpFilePath = path.resolve(dirname, backUpFilePath);
|
||||
} else {
|
||||
this.backUpFilePath = path.resolve(process.cwd(), backUpFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
async parseBackupFile() {
|
||||
await this.decompressBackup(this.backUpFilePath);
|
||||
return await this.getImportMeta();
|
||||
}
|
||||
|
||||
async restore(options: { selectedOptionalGroupNames: string[]; selectedUserCollections: string[] }) {
|
||||
await this.decompressBackup(this.backUpFilePath);
|
||||
await this.importCollections(options);
|
||||
await this.importDb();
|
||||
await this.clearWorkDir();
|
||||
}
|
||||
@ -67,6 +75,7 @@ export class Restorer extends AppMigrator {
|
||||
|
||||
const index = meta.columns.indexOf('name');
|
||||
const row = data.find((row) => JSON.parse(row)[index] === collectionName);
|
||||
|
||||
if (!row) {
|
||||
throw new Error(`Collection ${collectionName} not found`);
|
||||
}
|
||||
@ -78,8 +87,7 @@ export class Restorer extends AppMigrator {
|
||||
|
||||
async getImportCollections() {
|
||||
const collectionsDir = path.resolve(this.workDir, 'collections');
|
||||
const collections = await fsPromises.readdir(collectionsDir);
|
||||
return collections;
|
||||
return await fsPromises.readdir(collectionsDir);
|
||||
}
|
||||
|
||||
async getImportCollectionData(collectionName) {
|
||||
@ -89,47 +97,19 @@ export class Restorer extends AppMigrator {
|
||||
|
||||
async getImportCollectionMeta(collectionName) {
|
||||
const metaData = path.resolve(this.workDir, 'collections', collectionName, 'meta');
|
||||
const meta = JSON.parse(await fsPromises.readFile(metaData, 'utf8'));
|
||||
return meta;
|
||||
return JSON.parse(await fsPromises.readFile(metaData, 'utf8'));
|
||||
}
|
||||
|
||||
async importCollections(options?: { ignore?: string | string[] }) {
|
||||
const coreCollections = ['applicationPlugins'];
|
||||
const collections = await this.getImportCollections();
|
||||
|
||||
const importCustomCollections = await this.getImportCustomCollections();
|
||||
|
||||
const importPlugins = await this.getImportPlugins();
|
||||
|
||||
const collectionGroups = CollectionGroupManager.collectionGroups.filter((collectionGroup) => {
|
||||
return (
|
||||
importPlugins.includes(collectionGroup.pluginName) &&
|
||||
collectionGroup.collections.every((collectionName) => collections.includes(collectionName))
|
||||
);
|
||||
});
|
||||
|
||||
const delayGroups = CollectionGroupManager.getDelayRestoreCollectionGroups();
|
||||
const delayCollections = CollectionGroupManager.getGroupsCollections(delayGroups);
|
||||
|
||||
const { requiredGroups, optionalGroups } = CollectionGroupManager.classifyCollectionGroups(collectionGroups);
|
||||
const pluginsCollections = CollectionGroupManager.getGroupsCollections(collectionGroups);
|
||||
|
||||
const optionalCollections = importCustomCollections.filter(
|
||||
(collection) => !pluginsCollections.includes(collection) && !coreCollections.includes(collection),
|
||||
);
|
||||
|
||||
const questions = this.buildInquirerQuestions(
|
||||
requiredGroups,
|
||||
optionalGroups,
|
||||
await Promise.all(
|
||||
optionalCollections.map(async (name) => {
|
||||
return { name, title: await this.getImportCollectionTitle(name) };
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const results = await inquirer.prompt(questions);
|
||||
async getImportMeta() {
|
||||
const metaFile = path.resolve(this.workDir, 'meta');
|
||||
return JSON.parse(await fsPromises.readFile(metaFile, 'utf8')) as any;
|
||||
}
|
||||
|
||||
async importCollections(options: {
|
||||
ignore?: string | string[];
|
||||
selectedOptionalGroupNames: string[];
|
||||
selectedUserCollections: string[];
|
||||
}) {
|
||||
const importCollection = async (collectionName: string) => {
|
||||
const collectionMetaPath = path.resolve(this.workDir, 'collections', collectionName, 'meta');
|
||||
|
||||
@ -157,16 +137,20 @@ export class Restorer extends AppMigrator {
|
||||
}
|
||||
};
|
||||
|
||||
// import applicationPlugins first
|
||||
await importCollection('applicationPlugins');
|
||||
|
||||
// reload app
|
||||
await this.app.reload();
|
||||
|
||||
const requiredCollections = CollectionGroupManager.getGroupsCollections(requiredGroups).filter(
|
||||
(collection) => !delayCollections.includes(collection),
|
||||
);
|
||||
const { requiredGroups, selectedOptionalGroups } = await this.parseBackupFile();
|
||||
|
||||
const delayGroups = [...requiredGroups, ...selectedOptionalGroups].filter((group) => group.delay);
|
||||
const delayCollections = CollectionGroupManager.getGroupsCollections(delayGroups);
|
||||
|
||||
// import required plugins collections
|
||||
for (const collectionName of requiredCollections) {
|
||||
for (const collectionName of CollectionGroupManager.getGroupsCollections(requiredGroups).filter(
|
||||
(i) => !delayCollections.includes(i) && i != 'applicationPlugins',
|
||||
)) {
|
||||
await importCollection(collectionName);
|
||||
}
|
||||
|
||||
@ -181,11 +165,18 @@ export class Restorer extends AppMigrator {
|
||||
},
|
||||
});
|
||||
|
||||
const userCollections = results.userCollections || [];
|
||||
const userCollections = options.selectedUserCollections || [];
|
||||
const throughCollections = this.findThroughCollections(userCollections);
|
||||
|
||||
const customCollections = [
|
||||
...CollectionGroupManager.getGroupsCollections(results.collectionGroups),
|
||||
...CollectionGroupManager.getGroupsCollections(
|
||||
selectedOptionalGroups.filter((group) => {
|
||||
return options.selectedOptionalGroupNames.some((selectedOptionalGroupName) => {
|
||||
const [namespace, functionKey] = selectedOptionalGroupName.split('.');
|
||||
return group.function === functionKey && group.namespace === namespace;
|
||||
});
|
||||
}),
|
||||
),
|
||||
...userCollections,
|
||||
...throughCollections,
|
||||
];
|
||||
@ -196,15 +187,20 @@ export class Restorer extends AppMigrator {
|
||||
}
|
||||
|
||||
// import delay groups
|
||||
const appGroups = CollectionGroupManager.getGroups(this.app);
|
||||
|
||||
for (const collectionGroup of delayGroups) {
|
||||
await collectionGroup.delayRestore(this);
|
||||
const appCollectionGroup = appGroups.find(
|
||||
(group) => group.namespace === collectionGroup.name && group.function === collectionGroup.function,
|
||||
);
|
||||
await appCollectionGroup.delayRestore(this);
|
||||
}
|
||||
|
||||
await this.emitAsync('restoreCollectionsFinished');
|
||||
}
|
||||
|
||||
async decompressBackup(backupFilePath: string) {
|
||||
await decompress(backupFilePath, this.workDir);
|
||||
if (!this.decompressed) await decompress(backupFilePath, this.workDir);
|
||||
}
|
||||
|
||||
async importCollection(options: {
|
||||
@ -313,10 +309,11 @@ export class Restorer extends AppMigrator {
|
||||
this.on('restoreCollectionsFinished', async () => {
|
||||
if (this.app.db.inDialect('postgres')) {
|
||||
const sequenceNameResult = await app.db.sequelize.query(
|
||||
`SELECT column_default FROM information_schema.columns WHERE
|
||||
table_name='${collection.model.tableName}' and "column_name" = 'id' and table_schema = '${
|
||||
app.db.options.schema || 'public'
|
||||
}';`,
|
||||
`SELECT column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = '${collection.model.tableName}'
|
||||
and "column_name" = 'id'
|
||||
and table_schema = '${app.db.options.schema || 'public'}';`,
|
||||
);
|
||||
|
||||
if (sequenceNameResult[0].length) {
|
||||
@ -327,7 +324,8 @@ export class Restorer extends AppMigrator {
|
||||
const sequenceName = match[1];
|
||||
|
||||
const maxVal = await app.db.sequelize.query(
|
||||
`SELECT MAX("${primaryKeyAttribute.field}") FROM ${tableName}`,
|
||||
`SELECT MAX("${primaryKeyAttribute.field}")
|
||||
FROM ${tableName}`,
|
||||
{
|
||||
type: 'SELECT',
|
||||
},
|
||||
@ -341,7 +339,9 @@ export class Restorer extends AppMigrator {
|
||||
|
||||
if (this.app.db.inDialect('sqlite')) {
|
||||
await app.db.sequelize.query(
|
||||
`UPDATE sqlite_sequence set seq = (SELECT MAX("${primaryKeyAttribute.field}") FROM "${collection.model.tableName}") WHERE name = "${collection.model.tableName}"`,
|
||||
`UPDATE sqlite_sequence
|
||||
set seq = (SELECT MAX("${primaryKeyAttribute.field}") FROM "${collection.model.tableName}")
|
||||
WHERE name = "${collection.model.tableName}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
@ -1,8 +1,14 @@
|
||||
import { Plugin } from '@nocobase/server';
|
||||
import addDumpCommand from './commands/dump';
|
||||
import addRestoreCommand from './commands/restore';
|
||||
import addDumpCommand from './commands/dump-command';
|
||||
import addRestoreCommand from './commands/restore-command';
|
||||
|
||||
import zhCN from './locale/zh-CN';
|
||||
import dumpAction from './actions/dump-action';
|
||||
import { getPackageContent, restoreAction } from './actions/restore-action';
|
||||
import getDictAction from './actions/get-dict-action';
|
||||
import dumpableCollections from './actions/dumpable-collections-action';
|
||||
import multer from '@koa/multer';
|
||||
import * as os from 'os';
|
||||
|
||||
export default class Duplicator extends Plugin {
|
||||
beforeLoad() {
|
||||
@ -15,41 +21,27 @@ export default class Duplicator extends Plugin {
|
||||
async load() {
|
||||
this.app.resourcer.define({
|
||||
name: 'duplicator',
|
||||
actions: {
|
||||
getDict: async (ctx, next) => {
|
||||
ctx.withoutDataWrapping = true;
|
||||
let collectionNames = await this.db.getRepository('collections').find();
|
||||
collectionNames = collectionNames.map((item) => item.get('name'));
|
||||
const collections: any[] = [];
|
||||
for (const [name, collection] of this.db.collections) {
|
||||
const columns: any[] = [];
|
||||
for (const key in collection.model.rawAttributes) {
|
||||
if (Object.prototype.hasOwnProperty.call(collection.model.rawAttributes, key)) {
|
||||
const attribute = collection.model.rawAttributes[key];
|
||||
columns.push({
|
||||
realName: attribute.field,
|
||||
name: key,
|
||||
});
|
||||
middleware: async (ctx, next) => {
|
||||
if (ctx.action.actionName !== 'upload') {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
const item = {
|
||||
name,
|
||||
title: collection.options.title,
|
||||
namespace: collection.options.namespace,
|
||||
duplicator: collection.options.duplicator,
|
||||
// columns,
|
||||
};
|
||||
if (!item.namespace && collectionNames.includes(name)) {
|
||||
item.namespace = 'collection-manager';
|
||||
if (!item.duplicator) {
|
||||
item.duplicator = 'optional';
|
||||
}
|
||||
}
|
||||
collections.push(item);
|
||||
}
|
||||
ctx.body = collections;
|
||||
await next();
|
||||
const storage = multer.diskStorage({
|
||||
destination: os.tmpdir(), // 获取临时目录
|
||||
filename: function (req, file, cb) {
|
||||
const randomName = Date.now().toString() + Math.random().toString().slice(2); // 随机生成文件名
|
||||
cb(null, randomName);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({ storage }).single('file');
|
||||
return upload(ctx, next);
|
||||
},
|
||||
actions: {
|
||||
restore: restoreAction,
|
||||
upload: getPackageContent,
|
||||
dump: dumpAction,
|
||||
dumpableCollections: dumpableCollections,
|
||||
getDict: getDictAction,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CollectionOptions } from '@nocobase/database';
|
||||
|
||||
export default {
|
||||
namespace: 'file-manager',
|
||||
namespace: 'file-manager.attachmentRecords',
|
||||
duplicator: 'optional',
|
||||
name: 'attachments',
|
||||
title: '文件管理器',
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CollectionOptions } from '@nocobase/database';
|
||||
|
||||
export default {
|
||||
namespace: 'file-manager',
|
||||
namespace: 'file-manager.storageSetting',
|
||||
duplicator: 'optional',
|
||||
name: 'storages',
|
||||
title: '存储引擎',
|
||||
|
@ -11,8 +11,18 @@ export default class PluginFileManager extends Plugin {
|
||||
|
||||
async install() {
|
||||
const defaultStorageConfig = getStorageConfig(this.storageType());
|
||||
|
||||
if (defaultStorageConfig) {
|
||||
const Storage = this.db.getCollection('storages');
|
||||
if (
|
||||
await Storage.repository.findOne({
|
||||
filter: {
|
||||
name: defaultStorageConfig.defaults().name,
|
||||
},
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await Storage.repository.create({
|
||||
values: {
|
||||
...defaultStorageConfig.defaults(),
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
namespace: 'graph-collection-manager',
|
||||
namespace: 'graph-collection-manager.graphCollectionPositions',
|
||||
duplicator: 'required',
|
||||
name: 'graphPositions',
|
||||
fields: [
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CollectionOptions } from '@nocobase/database';
|
||||
|
||||
export default {
|
||||
namespace: 'iframe-block',
|
||||
namespace: 'iframe-block.iframe-html-storage',
|
||||
duplicator: 'required',
|
||||
name: 'iframeHtml',
|
||||
createdBy: true,
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { CollectionOptions } from "@nocobase/client";
|
||||
import { MapConfigurationCollectionName } from "../constants";
|
||||
import { CollectionOptions } from '@nocobase/client';
|
||||
import { MapConfigurationCollectionName } from '../constants';
|
||||
|
||||
export default {
|
||||
namespace: 'map',
|
||||
namespace: 'map.mapConfiguration',
|
||||
duplicator: 'optional',
|
||||
name: MapConfigurationCollectionName,
|
||||
title: '{{t("Map Manager")}}',
|
||||
@ -11,19 +11,19 @@ export default {
|
||||
title: 'Access key',
|
||||
comment: '访问密钥',
|
||||
name: 'accessKey',
|
||||
type: 'string'
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
title: 'securityJsCode',
|
||||
comment: 'securityJsCode or serviceHOST',
|
||||
name: 'securityJsCode',
|
||||
type: 'string'
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
title: 'Map type',
|
||||
comment: '地图类型',
|
||||
name: 'type',
|
||||
type: 'string',
|
||||
}
|
||||
]
|
||||
} as CollectionOptions
|
||||
},
|
||||
],
|
||||
} as CollectionOptions;
|
||||
|
4
packages/plugins/multi-app-manager/client.d.ts
vendored
Executable file
4
packages/plugins/multi-app-manager/client.d.ts
vendored
Executable file
@ -0,0 +1,4 @@
|
||||
// @ts-nocheck
|
||||
export * from './lib/client';
|
||||
export { default } from './lib/client';
|
||||
|
30
packages/plugins/multi-app-manager/client.js
Executable file
30
packages/plugins/multi-app-manager/client.js
Executable file
@ -0,0 +1,30 @@
|
||||
"use strict";
|
||||
|
||||
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
|
||||
|
||||
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
|
||||
|
||||
var _index = _interopRequireWildcard(require("./lib/client"));
|
||||
|
||||
Object.defineProperty(exports, "__esModule", {
|
||||
value: true
|
||||
});
|
||||
var _exportNames = {};
|
||||
Object.defineProperty(exports, "default", {
|
||||
enumerable: true,
|
||||
get: function get() {
|
||||
return _index.default;
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(_index).forEach(function (key) {
|
||||
if (key === "default" || key === "__esModule") return;
|
||||
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
|
||||
if (key in exports && exports[key] === _index[key]) return;
|
||||
Object.defineProperty(exports, key, {
|
||||
enumerable: true,
|
||||
get: function get() {
|
||||
return _index[key];
|
||||
}
|
||||
});
|
||||
});
|
4
packages/plugins/multi-app-manager/server.d.ts
vendored
Executable file
4
packages/plugins/multi-app-manager/server.d.ts
vendored
Executable file
@ -0,0 +1,4 @@
|
||||
// @ts-nocheck
|
||||
export * from './lib/server';
|
||||
export { default } from './lib/server';
|
||||
|
30
packages/plugins/multi-app-manager/server.js
Executable file
30
packages/plugins/multi-app-manager/server.js
Executable file
@ -0,0 +1,30 @@
|
||||
"use strict";
|
||||
|
||||
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
|
||||
|
||||
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
|
||||
|
||||
var _index = _interopRequireWildcard(require("./lib/server"));
|
||||
|
||||
Object.defineProperty(exports, "__esModule", {
|
||||
value: true
|
||||
});
|
||||
var _exportNames = {};
|
||||
Object.defineProperty(exports, "default", {
|
||||
enumerable: true,
|
||||
get: function get() {
|
||||
return _index.default;
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(_index).forEach(function (key) {
|
||||
if (key === "default" || key === "__esModule") return;
|
||||
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
|
||||
if (key in exports && exports[key] === _index[key]) return;
|
||||
Object.defineProperty(exports, key, {
|
||||
enumerable: true,
|
||||
get: function get() {
|
||||
return _index[key];
|
||||
}
|
||||
});
|
||||
});
|
21
packages/plugins/multi-app-manager/src/client/AppManager.tsx
Normal file
21
packages/plugins/multi-app-manager/src/client/AppManager.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { SchemaComponent, useRecord } from '@nocobase/client';
|
||||
import { Card } from 'antd';
|
||||
import React from 'react';
|
||||
import { schema } from './settings/schemas/applications';
|
||||
|
||||
const AppVisitor = () => {
|
||||
const record = useRecord();
|
||||
return (
|
||||
<a href={`/apps/${record.name}/admin/`} target={'_blank'}>
|
||||
View
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppManager = () => {
|
||||
return (
|
||||
<Card bordered={false}>
|
||||
<SchemaComponent schema={schema} components={{ AppVisitor }} />
|
||||
</Card>
|
||||
);
|
||||
};
|
@ -0,0 +1,22 @@
|
||||
import { connect, mapReadPretty } from '@formily/react';
|
||||
import { Input as AntdInput } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
const ReadPretty = (props) => {
|
||||
const content = props.value && (
|
||||
<a target={'_blank'} href={`/apps/${props.value}/admin`}>
|
||||
{props.value}
|
||||
</a>
|
||||
);
|
||||
return (
|
||||
<div style={props.style}>
|
||||
{props.addonBefore}
|
||||
{props.prefix}
|
||||
{content}
|
||||
{props.suffix}
|
||||
{props.addonAfter}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppNameInput = connect(AntdInput, mapReadPretty(ReadPretty));
|
15
packages/plugins/multi-app-manager/src/client/Settings.tsx
Normal file
15
packages/plugins/multi-app-manager/src/client/Settings.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { SchemaComponent } from '@nocobase/client';
|
||||
import { Card } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
const schema = {
|
||||
type: 'object',
|
||||
}
|
||||
|
||||
export const Settings = () => {
|
||||
return (
|
||||
<Card bordered={false}>
|
||||
<SchemaComponent schema={schema} />
|
||||
</Card>
|
||||
);
|
||||
};
|
92
packages/plugins/multi-app-manager/src/client/index.tsx
Normal file
92
packages/plugins/multi-app-manager/src/client/index.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import {
|
||||
Icon,
|
||||
PinnedPluginListProvider,
|
||||
SchemaComponentOptions,
|
||||
SettingsCenterProvider,
|
||||
useRequest
|
||||
} from '@nocobase/client';
|
||||
import { Button, Dropdown, Menu } from 'antd';
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { AppManager } from './AppManager';
|
||||
import { AppNameInput } from './AppNameInput';
|
||||
|
||||
const MultiAppManager = () => {
|
||||
const history = useHistory();
|
||||
const { data, loading, run } = useRequest(
|
||||
{
|
||||
resource: 'applications',
|
||||
action: 'listPinned',
|
||||
},
|
||||
{
|
||||
manual: true,
|
||||
},
|
||||
);
|
||||
const menu = (
|
||||
<Menu>
|
||||
{(data?.data || []).map((app) => {
|
||||
return (
|
||||
<Menu.Item
|
||||
key={app.name}
|
||||
onClick={() => {
|
||||
window.open(`/apps/${app.name}/admin/`, '_blank');
|
||||
}}
|
||||
>
|
||||
{app.displayName || app.name}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
{(data?.data || []).length > 0 && <Menu.Divider />}
|
||||
<Menu.Item
|
||||
onClick={() => {
|
||||
history.push('/admin/settings/multi-app-manager/applications');
|
||||
}}
|
||||
>
|
||||
Manage applications
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
);
|
||||
return (
|
||||
<Dropdown
|
||||
onVisibleChange={(visible) => {
|
||||
run();
|
||||
}}
|
||||
overlay={menu}
|
||||
>
|
||||
<Button title={'Apps'} icon={<Icon type={'AppstoreOutlined'} />} />
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default (props) => {
|
||||
return (
|
||||
<PinnedPluginListProvider
|
||||
items={{
|
||||
am: { order: 201, component: 'MultiAppManager', pin: true },
|
||||
}}
|
||||
>
|
||||
<SchemaComponentOptions components={{ MultiAppManager, AppNameInput }}>
|
||||
<SettingsCenterProvider
|
||||
settings={{
|
||||
'multi-app-manager': {
|
||||
title: 'Multi-app manager',
|
||||
icon: 'AppstoreOutlined',
|
||||
tabs: {
|
||||
applications: {
|
||||
title: 'Applications',
|
||||
component: () => <AppManager />,
|
||||
},
|
||||
// settings: {
|
||||
// title: 'Settings',
|
||||
// component: () => <Settings />,
|
||||
// },
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</SettingsCenterProvider>
|
||||
</SchemaComponentOptions>
|
||||
</PinnedPluginListProvider>
|
||||
);
|
||||
};
|
@ -0,0 +1,351 @@
|
||||
import { ISchema } from '@formily/react';
|
||||
import { uid } from '@formily/shared';
|
||||
import {
|
||||
useActionContext,
|
||||
useRecord,
|
||||
useRequest,
|
||||
useResourceActionContext,
|
||||
useResourceContext
|
||||
} from '@nocobase/client';
|
||||
|
||||
const collection = {
|
||||
name: 'applications',
|
||||
targetKey: 'name',
|
||||
fields: [
|
||||
{
|
||||
type: 'uid',
|
||||
name: 'name',
|
||||
primaryKey: true,
|
||||
prefix: 'a',
|
||||
interface: 'input',
|
||||
uiSchema: {
|
||||
type: 'string',
|
||||
title: '{{t("App ID")}}',
|
||||
required: true,
|
||||
'x-component': 'Input',
|
||||
'x-validator': 'uid',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'displayName',
|
||||
interface: 'input',
|
||||
uiSchema: {
|
||||
type: 'string',
|
||||
title: '{{t("App display name")}}',
|
||||
required: true,
|
||||
'x-component': 'Input',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'pinned',
|
||||
interface: 'checkbox',
|
||||
uiSchema: {
|
||||
type: 'boolean',
|
||||
'x-content': '{{t("Pin to menu")}}',
|
||||
'x-component': 'Checkbox',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'status',
|
||||
interface: 'radioGroup',
|
||||
defaultValue: 'pending',
|
||||
uiSchema: {
|
||||
type: 'string',
|
||||
title: '{{t("App status")}}',
|
||||
enum: [
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
{ label: 'Running', value: 'running' },
|
||||
],
|
||||
'x-component': 'Radio.Group',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const useDestroy = () => {
|
||||
const { refresh } = useResourceActionContext();
|
||||
const { resource, targetKey } = useResourceContext();
|
||||
const { [targetKey]: filterByTk } = useRecord();
|
||||
return {
|
||||
async run() {
|
||||
await resource.destroy({ filterByTk });
|
||||
refresh();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const useDestroyAll = () => {
|
||||
const { state, setState, refresh } = useResourceActionContext();
|
||||
const { resource } = useResourceContext();
|
||||
return {
|
||||
async run() {
|
||||
await resource.destroy({
|
||||
filterByTk: state?.selectedRowKeys || [],
|
||||
});
|
||||
setState?.({ selectedRowKeys: [] });
|
||||
refresh();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const schema: ISchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
[uid()]: {
|
||||
type: 'void',
|
||||
'x-decorator': 'ResourceActionProvider',
|
||||
'x-decorator-props': {
|
||||
collection,
|
||||
resourceName: 'applications',
|
||||
request: {
|
||||
resource: 'applications',
|
||||
action: 'list',
|
||||
params: {
|
||||
pageSize: 50,
|
||||
sort: ['-createdAt'],
|
||||
appends: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
'x-component': 'CollectionProvider',
|
||||
'x-component-props': {
|
||||
collection,
|
||||
},
|
||||
properties: {
|
||||
actions: {
|
||||
type: 'void',
|
||||
'x-component': 'ActionBar',
|
||||
'x-component-props': {
|
||||
style: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
},
|
||||
properties: {
|
||||
delete: {
|
||||
type: 'void',
|
||||
title: '{{ t("Delete") }}',
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
useAction: useDestroyAll,
|
||||
confirm: {
|
||||
title: "{{t('Delete')}}",
|
||||
content: "{{t('Are you sure you want to delete it?')}}",
|
||||
},
|
||||
},
|
||||
},
|
||||
create: {
|
||||
type: 'void',
|
||||
title: '{{t("Add new")}}',
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
type: 'primary',
|
||||
},
|
||||
properties: {
|
||||
drawer: {
|
||||
type: 'void',
|
||||
'x-component': 'Action.Drawer',
|
||||
'x-decorator': 'Form',
|
||||
'x-decorator-props': {
|
||||
useValues(options) {
|
||||
const ctx = useActionContext();
|
||||
return useRequest(
|
||||
() =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
name: `a_${uid()}`,
|
||||
},
|
||||
}),
|
||||
{ ...options, refreshDeps: [ctx.visible] },
|
||||
);
|
||||
},
|
||||
},
|
||||
title: '{{t("Add new")}}',
|
||||
properties: {
|
||||
displayName: {
|
||||
'x-component': 'CollectionField',
|
||||
'x-decorator': 'FormItem',
|
||||
},
|
||||
name: {
|
||||
'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.useCreateAction }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
table: {
|
||||
type: 'void',
|
||||
'x-uid': 'input',
|
||||
'x-component': 'Table.Void',
|
||||
'x-component-props': {
|
||||
rowKey: 'name',
|
||||
rowSelection: {
|
||||
type: 'checkbox',
|
||||
},
|
||||
useDataSource: '{{ cm.useDataSourceFromRAC }}',
|
||||
},
|
||||
properties: {
|
||||
displayName: {
|
||||
type: 'void',
|
||||
'x-decorator': 'Table.Column.Decorator',
|
||||
'x-component': 'Table.Column',
|
||||
properties: {
|
||||
displayName: {
|
||||
type: 'string',
|
||||
'x-component': 'CollectionField',
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
},
|
||||
},
|
||||
name: {
|
||||
type: 'void',
|
||||
'x-decorator': 'Table.Column.Decorator',
|
||||
'x-component': 'Table.Column',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
'x-component': 'CollectionField',
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
},
|
||||
},
|
||||
pinned: {
|
||||
type: 'void',
|
||||
title: '{{t("Pin to menu")}}',
|
||||
'x-decorator': 'Table.Column.Decorator',
|
||||
'x-component': 'Table.Column',
|
||||
properties: {
|
||||
pinned: {
|
||||
type: 'string',
|
||||
'x-component': 'CollectionField',
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
type: 'void',
|
||||
title: '{{t("Actions")}}',
|
||||
'x-component': 'Table.Column',
|
||||
properties: {
|
||||
actions: {
|
||||
type: 'void',
|
||||
'x-component': 'Space',
|
||||
'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}}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user