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
|
docker/**/storage
|
||||||
cache/diskstore-*
|
cache/diskstore-*
|
||||||
*.nbdump
|
*.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 React, { useEffect, useState } from 'react';
|
||||||
import { I18nextProvider } from 'react-i18next';
|
import { I18nextProvider } from 'react-i18next';
|
||||||
import { Link, NavLink } from 'react-router-dom';
|
import { Link, NavLink } from 'react-router-dom';
|
||||||
import { ACLProvider, ACLShortcut } from '../acl';
|
import { ACLProvider } from '../acl';
|
||||||
import { AntdConfigProvider } from '../antd-config-provider';
|
import { AntdConfigProvider } from '../antd-config-provider';
|
||||||
import { APIClient, APIClientProvider } from '../api-client';
|
import { APIClient, APIClientProvider } from '../api-client';
|
||||||
import { BlockSchemaComponentProvider } from '../block-provider';
|
import { BlockSchemaComponentProvider } from '../block-provider';
|
||||||
import { CollectionManagerShortcut } from '../collection-manager';
|
|
||||||
import { RemoteDocumentTitleProvider } from '../document-title';
|
import { RemoteDocumentTitleProvider } from '../document-title';
|
||||||
import { i18n } from '../i18n';
|
import { i18n } from '../i18n';
|
||||||
import { PluginManagerProvider } from '../plugin-manager';
|
import { PinnedPluginListProvider } from '../plugin-manager';
|
||||||
import PMProvider, { PluginManagerLink, SettingsCenterDropdown } from '../pm';
|
import PMProvider, { PluginManagerLink, SettingsCenterDropdown } from '../pm';
|
||||||
import {
|
import {
|
||||||
AdminLayout,
|
AdminLayout,
|
||||||
@ -18,17 +17,17 @@ import {
|
|||||||
RemoteRouteSwitchProvider,
|
RemoteRouteSwitchProvider,
|
||||||
RouteSchemaComponent,
|
RouteSchemaComponent,
|
||||||
RouteSwitch,
|
RouteSwitch,
|
||||||
useRoutes,
|
useRoutes
|
||||||
} from '../route-switch';
|
} from '../route-switch';
|
||||||
import {
|
import {
|
||||||
AntdSchemaComponentProvider,
|
AntdSchemaComponentProvider,
|
||||||
DesignableSwitch,
|
DesignableSwitch,
|
||||||
MenuItemInitializers,
|
MenuItemInitializers,
|
||||||
SchemaComponentProvider,
|
SchemaComponentProvider
|
||||||
} from '../schema-component';
|
} from '../schema-component';
|
||||||
import { SchemaInitializerProvider } from '../schema-initializer';
|
import { SchemaInitializerProvider } from '../schema-initializer';
|
||||||
import { BlockTemplateDetails, BlockTemplatePage, SchemaTemplateShortcut } from '../schema-templates';
|
import { BlockTemplateDetails, BlockTemplatePage } from '../schema-templates';
|
||||||
import { SystemSettingsProvider, SystemSettingsShortcut } from '../system-settings';
|
import { SystemSettingsProvider } from '../system-settings';
|
||||||
import { SigninPage, SignupPage } from '../user';
|
import { SigninPage, SignupPage } from '../user';
|
||||||
import { SigninPageExtensionProvider } from '../user/SigninPageExtension';
|
import { SigninPageExtensionProvider } from '../user/SigninPageExtension';
|
||||||
import { compose } from './compose';
|
import { compose } from './compose';
|
||||||
@ -94,18 +93,16 @@ export class Application {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.use(SystemSettingsProvider);
|
this.use(SystemSettingsProvider);
|
||||||
this.use(PluginManagerProvider, {
|
this.use(PinnedPluginListProvider, {
|
||||||
components: {
|
items: {
|
||||||
ACLShortcut,
|
ui: { order: 100, component: 'DesignableSwitch', pin: true, snippet: 'ui.*' },
|
||||||
DesignableSwitch,
|
pm: { order: 200, component: 'PluginManagerLink', pin: true, snippet: 'pm' },
|
||||||
CollectionManagerShortcut,
|
sc: { order: 300, component: 'SettingsCenterDropdown', pin: true, snippet: 'pm.*' },
|
||||||
SystemSettingsShortcut,
|
|
||||||
SchemaTemplateShortcut,
|
|
||||||
PluginManagerLink,
|
|
||||||
SettingsCenterDropdown,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.use(SchemaComponentProvider, { components: { Link, NavLink } });
|
this.use(SchemaComponentProvider, {
|
||||||
|
components: { Link, NavLink, DesignableSwitch, PluginManagerLink, SettingsCenterDropdown },
|
||||||
|
});
|
||||||
this.use(SchemaInitializerProvider, {
|
this.use(SchemaInitializerProvider, {
|
||||||
initializers: {
|
initializers: {
|
||||||
MenuItemInitializers,
|
MenuItemInitializers,
|
||||||
|
@ -21,7 +21,7 @@ export const CollectionHistoryProvider: React.FC = (props) => {
|
|||||||
action: 'list',
|
action: 'list',
|
||||||
params: {
|
params: {
|
||||||
paginate: false,
|
paginate: false,
|
||||||
appends: ['fields', 'fields.uiSchema'],
|
appends: ['fields'],
|
||||||
filter: {
|
filter: {
|
||||||
// inherit: false,
|
// inherit: false,
|
||||||
},
|
},
|
||||||
|
@ -38,7 +38,7 @@ export const RemoteCollectionManagerProvider = (props: any) => {
|
|||||||
action: 'list',
|
action: 'list',
|
||||||
params: {
|
params: {
|
||||||
paginate: false,
|
paginate: false,
|
||||||
appends: ['fields', 'fields.uiSchema', 'category'],
|
appends: ['fields', 'category'],
|
||||||
filter: {
|
filter: {
|
||||||
// inherit: false,
|
// inherit: false,
|
||||||
},
|
},
|
||||||
|
@ -6,7 +6,7 @@ import set from 'lodash/set';
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAPIClient, useRequest } from '../../api-client';
|
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 { ActionContext, SchemaComponent, useActionContext, useCompile } from '../../schema-component';
|
||||||
import { useCancelAction, useUpdateAction } from '../action-hooks';
|
import { useCancelAction, useUpdateAction } from '../action-hooks';
|
||||||
import { useCollectionManager } from '../hooks';
|
import { useCollectionManager } from '../hooks';
|
||||||
@ -131,7 +131,7 @@ export const EditFieldAction = (props) => {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const { data } = await api.resource('collections.fields', record.collectionName).get({
|
const { data } = await api.resource('collections.fields', record.collectionName).get({
|
||||||
filterByTk: record.name,
|
filterByTk: record.name,
|
||||||
appends: ['uiSchema', 'reverseField'],
|
appends: ['reverseField'],
|
||||||
});
|
});
|
||||||
setData(data?.data);
|
setData(data?.data);
|
||||||
const interfaceConf = getInterface(record.interface);
|
const interfaceConf = getInterface(record.interface);
|
||||||
|
@ -156,7 +156,7 @@ export const OverridingFieldAction = (props) => {
|
|||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
const { data } = await api.resource('collections.fields', record.collectionName).get({
|
const { data } = await api.resource('collections.fields', record.collectionName).get({
|
||||||
filterByTk: record.name,
|
filterByTk: record.name,
|
||||||
appends: ['uiSchema', 'reverseField'],
|
appends: ['reverseField'],
|
||||||
});
|
});
|
||||||
setData(data?.data);
|
setData(data?.data);
|
||||||
const interfaceConf = getInterface(record.interface);
|
const interfaceConf = getInterface(record.interface);
|
||||||
|
@ -6,7 +6,7 @@ import set from 'lodash/set';
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAPIClient, useRequest } from '../../api-client';
|
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 { ActionContext, SchemaComponent, useCompile } from '../../schema-component';
|
||||||
import { useCollectionManager } from '../hooks';
|
import { useCollectionManager } from '../hooks';
|
||||||
import { IField } from '../interfaces/types';
|
import { IField } from '../interfaces/types';
|
||||||
@ -82,7 +82,7 @@ export const ViewFieldAction = (props) => {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const { data } = await api.resource('collections.fields', record.collectionName).get({
|
const { data } = await api.resource('collections.fields', record.collectionName).get({
|
||||||
filterByTk: record.name,
|
filterByTk: record.name,
|
||||||
appends: ['uiSchema', 'reverseField'],
|
appends: ['reverseField'],
|
||||||
});
|
});
|
||||||
setData(data?.data);
|
setData(data?.data);
|
||||||
const interfaceConf = getInterface(record.interface);
|
const interfaceConf = getInterface(record.interface);
|
||||||
|
@ -76,7 +76,7 @@ export const collectionFieldSchema: ISchema = {
|
|||||||
'interface.$not': null,
|
'interface.$not': null,
|
||||||
},
|
},
|
||||||
sort: ['sort'],
|
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 { message } from 'antd';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useCollection, useCollectionManager } from '.';
|
import { useCollection, useCollectionManager } from '.';
|
||||||
@ -185,15 +185,19 @@ export const useFilterAction = () => {
|
|||||||
|
|
||||||
export const useCreateAction = () => {
|
export const useCreateAction = () => {
|
||||||
const form = useForm();
|
const form = useForm();
|
||||||
|
const field = useField();
|
||||||
const ctx = useActionContext();
|
const ctx = useActionContext();
|
||||||
const { refresh } = useResourceActionContext();
|
const { refresh } = useResourceActionContext();
|
||||||
const { resource } = useResourceContext();
|
const { resource } = useResourceContext();
|
||||||
return {
|
return {
|
||||||
async run() {
|
async run() {
|
||||||
await form.submit();
|
await form.submit();
|
||||||
|
field.data = field.data || {};
|
||||||
|
field.data.loading = true;
|
||||||
await resource.create({ values: form.values });
|
await resource.create({ values: form.values });
|
||||||
ctx.setVisible(false);
|
ctx.setVisible(false);
|
||||||
await form.reset();
|
await form.reset();
|
||||||
|
field.data.loading = false;
|
||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -242,6 +246,7 @@ export const useMoveAction = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useUpdateAction = () => {
|
export const useUpdateAction = () => {
|
||||||
|
const field = useField();
|
||||||
const form = useForm();
|
const form = useForm();
|
||||||
const ctx = useActionContext();
|
const ctx = useActionContext();
|
||||||
const { refresh } = useResourceActionContext();
|
const { refresh } = useResourceActionContext();
|
||||||
@ -250,9 +255,12 @@ export const useUpdateAction = () => {
|
|||||||
return {
|
return {
|
||||||
async run() {
|
async run() {
|
||||||
await form.submit();
|
await form.submit();
|
||||||
|
field.data = field.data || {};
|
||||||
|
field.data.loading = true;
|
||||||
await resource.update({ filterByTk, values: form.values });
|
await resource.update({ filterByTk, values: form.values });
|
||||||
ctx.setVisible(false);
|
ctx.setVisible(false);
|
||||||
await form.reset();
|
await form.reset();
|
||||||
|
field.data.loading = false;
|
||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -8,7 +8,7 @@ import {
|
|||||||
useCollectionManager,
|
useCollectionManager,
|
||||||
useRecord,
|
useRecord,
|
||||||
useRecordIndex,
|
useRecordIndex,
|
||||||
useRequest,
|
useRequest
|
||||||
} from '../';
|
} from '../';
|
||||||
import { useAPIClient } from '../api-client';
|
import { useAPIClient } from '../api-client';
|
||||||
|
|
||||||
@ -183,7 +183,7 @@ export const SubFieldDataSourceProvider = observer((props) => {
|
|||||||
.resource('fields')
|
.resource('fields')
|
||||||
.list({
|
.list({
|
||||||
paginate: false,
|
paginate: false,
|
||||||
appends: ['uiSchema'],
|
// appends: ['uiSchema'],
|
||||||
sort: 'sort',
|
sort: 'sort',
|
||||||
filter: {
|
filter: {
|
||||||
parentKey: record.key,
|
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';
|
import { createContext } from 'react';
|
||||||
|
|
||||||
export const PluginManagerContext = createContext<any>({});
|
export const PluginManagerContext = createContext<any>({});
|
||||||
|
export const PinnedPluginListContext = createContext({ items: {} });
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
export * from './context';
|
export * from './context';
|
||||||
|
export * from './PinnedPluginListProvider';
|
||||||
export * from './PluginManager';
|
export * from './PluginManager';
|
||||||
export * from './PluginManagerProvider';
|
export * from './PluginManagerProvider';
|
||||||
|
|
||||||
|
@ -1,27 +1,23 @@
|
|||||||
import { AppstoreAddOutlined, SettingOutlined } from '@ant-design/icons';
|
import { ApiOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
import { Dropdown, Menu } from 'antd';
|
import { Button, Dropdown, Menu } from 'antd';
|
||||||
import React, { useContext, useState } from 'react';
|
import React, { useContext, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { useACLRoleContext } from '../acl/ACLProvider';
|
import { useACLRoleContext } from '../acl/ACLProvider';
|
||||||
import { PluginManager } from '../plugin-manager';
|
|
||||||
import { ActionContext, useCompile } from '../schema-component';
|
import { ActionContext, useCompile } from '../schema-component';
|
||||||
import { getPluginsTabs, SettingsCenterContext } from './index';
|
import { getPluginsTabs, SettingsCenterContext } from './index';
|
||||||
|
|
||||||
export const PluginManagerLink = () => {
|
export const PluginManagerLink = () => {
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
return (
|
return (
|
||||||
<ActionContext.Provider value={{ visible, setVisible }}>
|
<Button
|
||||||
<PluginManager.Toolbar.Item
|
icon={<ApiOutlined />}
|
||||||
icon={<AppstoreAddOutlined />}
|
|
||||||
title={t('Plugin manager')}
|
title={t('Plugin manager')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
history.push('/admin/pm/list');
|
history.push('/admin/pm/list');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ActionContext.Provider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -50,7 +46,6 @@ export const SettingsCenterDropdown = () => {
|
|||||||
placement="bottom"
|
placement="bottom"
|
||||||
overlay={
|
overlay={
|
||||||
<Menu>
|
<Menu>
|
||||||
<Menu.ItemGroup title={t('Bookmark')}>
|
|
||||||
{bookmarkTabs.map((tab) => {
|
{bookmarkTabs.map((tab) => {
|
||||||
return (
|
return (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
@ -63,7 +58,6 @@ export const SettingsCenterDropdown = () => {
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Menu.ItemGroup>
|
|
||||||
<Menu.Divider></Menu.Divider>
|
<Menu.Divider></Menu.Divider>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -76,10 +70,10 @@ export const SettingsCenterDropdown = () => {
|
|||||||
</Menu>
|
</Menu>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<PluginManager.Toolbar.Item
|
<Button
|
||||||
icon={<SettingOutlined />}
|
icon={<SettingOutlined />}
|
||||||
// title={t('All plugin settings')}
|
// title={t('All plugin settings')}
|
||||||
></PluginManager.Toolbar.Item>
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</ActionContext.Provider>
|
</ActionContext.Provider>
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { DeleteOutlined, SettingOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
import { css } from '@emotion/css';
|
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 { sortBy } from 'lodash';
|
||||||
import React, { createContext, useContext, useMemo } from 'react';
|
import React, { createContext, useContext, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@ -53,11 +53,20 @@ const PluginCard = (props) => {
|
|||||||
<Switch
|
<Switch
|
||||||
size={'small'}
|
size={'small'}
|
||||||
onChange={async (checked) => {
|
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({
|
await api.request({
|
||||||
url: `pm:${checked ? 'enable' : 'disable'}/${data.name}`,
|
url: `pm:${checked ? 'enable' : 'disable'}/${data.name}`,
|
||||||
});
|
});
|
||||||
message.success(checked ? t('插件激活成功') : t('插件禁用成功'));
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
|
// message.success(checked ? t('插件激活成功') : t('插件禁用成功'));
|
||||||
}}
|
}}
|
||||||
defaultChecked={data.enabled}
|
defaultChecked={data.enabled}
|
||||||
></Switch>,
|
></Switch>,
|
||||||
|
@ -10,15 +10,15 @@ import {
|
|||||||
CurrentUserProvider,
|
CurrentUserProvider,
|
||||||
findByUid,
|
findByUid,
|
||||||
findMenuItem,
|
findMenuItem,
|
||||||
|
PinnedPluginList,
|
||||||
RemoteCollectionManagerProvider,
|
RemoteCollectionManagerProvider,
|
||||||
RemotePluginManagerToolbar,
|
|
||||||
RemoteSchemaTemplateManagerProvider,
|
RemoteSchemaTemplateManagerProvider,
|
||||||
SchemaComponent,
|
SchemaComponent,
|
||||||
useACLRoleContext,
|
useACLRoleContext,
|
||||||
useDocumentTitle,
|
useDocumentTitle,
|
||||||
useRequest,
|
useRequest,
|
||||||
useRoute,
|
useRoute,
|
||||||
useSystemSettings,
|
useSystemSettings
|
||||||
} from '../../../';
|
} from '../../../';
|
||||||
import { useCollectionManager } from '../../../collection-manager';
|
import { useCollectionManager } from '../../../collection-manager';
|
||||||
|
|
||||||
@ -213,7 +213,7 @@ export const InternalAdminLayout = (props: any) => {
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<RemotePluginManagerToolbar />
|
<PinnedPluginList />
|
||||||
<CurrentUser />
|
<CurrentUser />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { HighlightOutlined } from '@ant-design/icons';
|
import { HighlightOutlined } from '@ant-design/icons';
|
||||||
|
import { Button } from 'antd';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useDesignable } from '..';
|
import { useDesignable } from '..';
|
||||||
import { PluginManager } from '../../plugin-manager';
|
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
|
||||||
|
|
||||||
export const DesignableSwitch = () => {
|
export const DesignableSwitch = () => {
|
||||||
const { designable, setDesignable } = useDesignable();
|
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 (
|
return (
|
||||||
<PluginManager.Toolbar.Item
|
<Button
|
||||||
selected={designable}
|
// selected={designable}
|
||||||
icon={<HighlightOutlined />}
|
icon={<HighlightOutlined />}
|
||||||
title={t('UI Editor')}
|
title={t('UI Editor')}
|
||||||
subtitle={'Ctrl+Shift+U'}
|
// subtitle={'Ctrl+Shift+U'}
|
||||||
style={style}
|
style={style}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDesignable(!designable);
|
setDesignable(!designable);
|
||||||
}}
|
}}
|
||||||
></PluginManager.Toolbar.Item>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,15 +1,14 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
import { Dropdown, Menu } from 'antd';
|
import { Dropdown, Menu } from 'antd';
|
||||||
import React, { createContext, useState, useContext } from 'react';
|
import React, { createContext, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { useAPIClient, useCurrentUserContext } from '..';
|
import { useAPIClient, useCurrentUserContext } from '..';
|
||||||
import { useRequest } from '../api-client';
|
import { useCurrentAppInfo } from '../appInfo/CurrentAppInfoProvider';
|
||||||
import { ChangePassword } from './ChangePassword';
|
import { ChangePassword } from './ChangePassword';
|
||||||
import { EditProfile } from './EditProfile';
|
import { EditProfile } from './EditProfile';
|
||||||
import { LanguageSettings } from './LanguageSettings';
|
import { LanguageSettings } from './LanguageSettings';
|
||||||
import { SwitchRole } from './SwitchRole';
|
import { SwitchRole } from './SwitchRole';
|
||||||
import {useCurrentAppInfo} from '../appInfo/CurrentAppInfoProvider'
|
|
||||||
|
|
||||||
|
|
||||||
const ApplicationVersion = () => {
|
const ApplicationVersion = () => {
|
||||||
const data = useCurrentAppInfo();
|
const data = useCurrentAppInfo();
|
||||||
@ -58,7 +57,17 @@ export const CurrentUser = () => {
|
|||||||
</Menu>
|
</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}
|
{data?.data?.nickname || data?.data?.email}
|
||||||
</span>
|
</span>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Menu, Select } from 'antd';
|
import { Menu, Select } from 'antd';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
import { useACLRoleContext } from '../acl';
|
import { useACLRoleContext } from '../acl';
|
||||||
import { useAPIClient } from '../api-client';
|
import { useAPIClient } from '../api-client';
|
||||||
import { useCompile } from '../schema-component';
|
import { useCompile } from '../schema-component';
|
||||||
@ -29,6 +30,7 @@ export const SwitchRole = () => {
|
|||||||
const api = useAPIClient();
|
const api = useAPIClient();
|
||||||
const roles = useCurrentRoles();
|
const roles = useCurrentRoles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const history = useHistory();
|
||||||
if (roles.length <= 1) {
|
if (roles.length <= 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -47,7 +49,8 @@ export const SwitchRole = () => {
|
|||||||
onChange={async (roleName) => {
|
onChange={async (roleName) => {
|
||||||
api.auth.setRole(roleName);
|
api.auth.setRole(roleName);
|
||||||
await api.resource('users').setDefaultRole({ values: { roleName } });
|
await api.resource('users').setDefaultRole({ values: { roleName } });
|
||||||
window.location.href = '/';
|
history.push('/');
|
||||||
|
window.location.reload();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Menu.Item>
|
</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 };
|
export type CollectionSortable = string | boolean | { name?: string; scopeKey?: string };
|
||||||
|
|
||||||
|
type dumpable = 'required' | 'optional' | 'skip';
|
||||||
|
|
||||||
export interface CollectionOptions extends Omit<ModelOptions, 'name' | 'hooks'> {
|
export interface CollectionOptions extends Omit<ModelOptions, 'name' | 'hooks'> {
|
||||||
name: string;
|
name: string;
|
||||||
|
namespace?: string;
|
||||||
|
duplicator?:
|
||||||
|
| dumpable
|
||||||
|
| {
|
||||||
|
dumpable: dumpable;
|
||||||
|
with?: string[] | string;
|
||||||
|
delayRestore?: any;
|
||||||
|
};
|
||||||
|
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
inherits?: string[] | string;
|
inherits?: string[] | string;
|
||||||
filterTargetKey?: string;
|
filterTargetKey?: string;
|
||||||
@ -181,6 +192,7 @@ export class Collection<
|
|||||||
|
|
||||||
this.on('field.afterRemove', (field: Field) => {
|
this.on('field.afterRemove', (field: Field) => {
|
||||||
field.unbind();
|
field.unbind();
|
||||||
|
this.db.emit('field.afterRemove', field);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -308,7 +320,7 @@ export class Collection<
|
|||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
const queryInterface = this.db.sequelize.getQueryInterface();
|
const queryInterface = this.db.sequelize.getQueryInterface();
|
||||||
await queryInterface.dropTable(this.addSchemaTableName(), options);
|
await queryInterface.dropTable(this.getTableNameWithSchema(), options);
|
||||||
}
|
}
|
||||||
this.remove();
|
this.remove();
|
||||||
}
|
}
|
||||||
@ -539,7 +551,7 @@ export class Collection<
|
|||||||
return this.context.database.inheritanceMap.isParentNode(this.name);
|
return this.context.database.inheritanceMap.isParentNode(this.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
public addSchemaTableName() {
|
public getTableNameWithSchema() {
|
||||||
const tableName = this.model.tableName;
|
const tableName = this.model.tableName;
|
||||||
|
|
||||||
if (this.collectionSchema()) {
|
if (this.collectionSchema()) {
|
||||||
@ -550,7 +562,7 @@ export class Collection<
|
|||||||
}
|
}
|
||||||
|
|
||||||
public quotedTableName() {
|
public quotedTableName() {
|
||||||
return this.db.utils.quoteTable(this.addSchemaTableName());
|
return this.db.utils.quoteTable(this.getTableNameWithSchema());
|
||||||
}
|
}
|
||||||
|
|
||||||
public collectionSchema() {
|
public collectionSchema() {
|
||||||
|
@ -67,6 +67,7 @@ import { BaseValueParser, registerFieldValueParsers } from './value-parsers';
|
|||||||
import buildQueryInterface from './query-interface/query-interface-builder';
|
import buildQueryInterface from './query-interface/query-interface-builder';
|
||||||
import QueryInterface from './query-interface/query-interface';
|
import QueryInterface from './query-interface/query-interface';
|
||||||
import { Logger } from '@nocobase/logger';
|
import { Logger } from '@nocobase/logger';
|
||||||
|
import { CollectionGroupManager } from './collection-group-manager';
|
||||||
|
|
||||||
export interface MergeOptions extends merge.Options {}
|
export interface MergeOptions extends merge.Options {}
|
||||||
|
|
||||||
@ -184,6 +185,8 @@ export class Database extends EventEmitter implements AsyncEmitter {
|
|||||||
|
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
|
|
||||||
|
collectionGroupManager = new CollectionGroupManager(this);
|
||||||
|
|
||||||
constructor(options: DatabaseOptions) {
|
constructor(options: DatabaseOptions) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
@ -274,7 +277,7 @@ export class Database extends EventEmitter implements AsyncEmitter {
|
|||||||
name: 'migrations',
|
name: 'migrations',
|
||||||
autoGenId: false,
|
autoGenId: false,
|
||||||
timestamps: false,
|
timestamps: false,
|
||||||
namespace: 'core',
|
namespace: 'core.migration',
|
||||||
duplicator: 'required',
|
duplicator: 'required',
|
||||||
fields: [{ type: 'string', name: 'name' }],
|
fields: [{ type: 'string', name: 'name' }],
|
||||||
});
|
});
|
||||||
@ -398,6 +401,8 @@ export class Database extends EventEmitter implements AsyncEmitter {
|
|||||||
collection<Attributes = any, CreateAttributes = Attributes>(
|
collection<Attributes = any, CreateAttributes = Attributes>(
|
||||||
options: CollectionOptions,
|
options: CollectionOptions,
|
||||||
): Collection<Attributes, CreateAttributes> {
|
): Collection<Attributes, CreateAttributes> {
|
||||||
|
options = lodash.cloneDeep(options);
|
||||||
|
|
||||||
if (this.options.underscored) {
|
if (this.options.underscored) {
|
||||||
options.underscored = true;
|
options.underscored = true;
|
||||||
}
|
}
|
||||||
|
@ -169,7 +169,7 @@ export abstract class Field {
|
|||||||
columnReferencesCount == 1
|
columnReferencesCount == 1
|
||||||
) {
|
) {
|
||||||
const queryInterface = this.database.sequelize.getQueryInterface();
|
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();
|
this.remove();
|
||||||
@ -181,22 +181,24 @@ export abstract class Field {
|
|||||||
};
|
};
|
||||||
let sql;
|
let sql;
|
||||||
if (this.database.sequelize.getDialect() === 'sqlite') {
|
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')) {
|
} else if (this.database.inDialect('mysql')) {
|
||||||
sql = `
|
sql = `
|
||||||
select column_name
|
select column_name
|
||||||
from INFORMATION_SCHEMA.COLUMNS
|
from INFORMATION_SCHEMA.COLUMNS
|
||||||
where TABLE_SCHEMA='${this.database.options.database}' AND TABLE_NAME='${
|
where TABLE_SCHEMA = '${this.database.options.database}'
|
||||||
this.collection.model.tableName
|
AND TABLE_NAME = '${this.collection.model.tableName}'
|
||||||
}' AND column_name='${this.columnName()}'
|
AND column_name = '${this.columnName()}'
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
sql = `
|
sql = `
|
||||||
select column_name
|
select column_name
|
||||||
from INFORMATION_SCHEMA.COLUMNS
|
from INFORMATION_SCHEMA.COLUMNS
|
||||||
where TABLE_NAME='${
|
where TABLE_NAME = '${this.collection.model.tableName}'
|
||||||
this.collection.model.tableName
|
AND column_name = '${this.columnName()}'
|
||||||
}' AND column_name='${this.columnName()}' AND table_schema='${this.collection.collectionSchema() || 'public'}'
|
AND table_schema = '${this.collection.collectionSchema() || 'public'}'
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
const [rows] = await this.database.sequelize.query(sql, opts);
|
const [rows] = await this.database.sequelize.query(sql, opts);
|
||||||
|
@ -20,4 +20,4 @@ export * from './repository';
|
|||||||
export * from './update-associations';
|
export * from './update-associations';
|
||||||
export { snakeCase } from './utils';
|
export { snakeCase } from './utils';
|
||||||
export * from './value-parsers';
|
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;
|
const attributes = model.tableAttributes;
|
||||||
|
|
||||||
@ -88,7 +88,7 @@ export class SyncRunner {
|
|||||||
// if we have max sequence, set it to child table
|
// if we have max sequence, set it to child table
|
||||||
if (maxSequenceName) {
|
if (maxSequenceName) {
|
||||||
const parentsDeep = Array.from(db.inheritanceMap.getParents(inheritedCollection.name)).map((parent) =>
|
const parentsDeep = Array.from(db.inheritanceMap.getParents(inheritedCollection.name)).map((parent) =>
|
||||||
db.getCollection(parent).addSchemaTableName(),
|
db.getCollection(parent).getTableNameWithSchema(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const sequenceTables = [...parentsDeep, tableName];
|
const sequenceTables = [...parentsDeep, tableName];
|
||||||
@ -102,7 +102,9 @@ export class SyncRunner {
|
|||||||
const idColumnQuery = await queryInterface.sequelize.query(
|
const idColumnQuery = await queryInterface.sequelize.query(
|
||||||
`SELECT column_name
|
`SELECT column_name
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_name='${queryName}' and column_name='id' and table_schema = '${schemaName}';
|
WHERE table_name = '${queryName}'
|
||||||
|
and column_name = 'id'
|
||||||
|
and table_schema = '${schemaName}';
|
||||||
`,
|
`,
|
||||||
{
|
{
|
||||||
transaction,
|
transaction,
|
||||||
@ -163,7 +165,7 @@ WHERE table_name='${queryName}' and column_name='id' and table_schema = '${schem
|
|||||||
';',
|
';',
|
||||||
` INHERITS (${parents
|
` INHERITS (${parents
|
||||||
.map((t) => {
|
.map((t) => {
|
||||||
return t.addSchemaTableName();
|
return t.getTableNameWithSchema();
|
||||||
})
|
})
|
||||||
.join(', ')});`,
|
.join(', ')});`,
|
||||||
);
|
);
|
||||||
|
@ -25,6 +25,10 @@ export interface IResource {
|
|||||||
export class Auth {
|
export class Auth {
|
||||||
protected api: APIClient;
|
protected api: APIClient;
|
||||||
|
|
||||||
|
protected NOCOBASE_LOCALE_KEY = 'NOCOBASE_LOCALE';
|
||||||
|
|
||||||
|
protected NOCOBASE_ROLE_KEY = 'NOCOBASE_ROLE';
|
||||||
|
|
||||||
protected options = {
|
protected options = {
|
||||||
token: null,
|
token: null,
|
||||||
locale: null,
|
locale: null,
|
||||||
@ -33,12 +37,26 @@ export class Auth {
|
|||||||
|
|
||||||
constructor(api: APIClient) {
|
constructor(api: APIClient) {
|
||||||
this.api = api;
|
this.api = api;
|
||||||
|
this.initKeys();
|
||||||
this.locale = this.getLocale();
|
this.locale = this.getLocale();
|
||||||
this.role = this.getRole();
|
this.role = this.getRole();
|
||||||
this.token = this.getToken();
|
this.token = this.getToken();
|
||||||
this.api.axios.interceptors.request.use(this.middleware.bind(this));
|
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() {
|
get locale() {
|
||||||
return this.getLocale();
|
return this.getLocale();
|
||||||
}
|
}
|
||||||
@ -77,12 +95,12 @@ export class Auth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getLocale() {
|
getLocale() {
|
||||||
return this.api.storage.getItem('NOCOBASE_LOCALE');
|
return this.api.storage.getItem(this.NOCOBASE_LOCALE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLocale(locale: string) {
|
setLocale(locale: string) {
|
||||||
this.options.locale = locale;
|
this.options.locale = locale;
|
||||||
this.api.storage.setItem('NOCOBASE_LOCALE', locale || '');
|
this.api.storage.setItem(this.NOCOBASE_LOCALE_KEY, locale || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
getToken() {
|
getToken() {
|
||||||
@ -99,12 +117,12 @@ export class Auth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getRole() {
|
getRole() {
|
||||||
return this.api.storage.getItem('NOCOBASE_ROLE');
|
return this.api.storage.getItem(this.NOCOBASE_ROLE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
setRole(role: string) {
|
setRole(role: string) {
|
||||||
this.options.role = role;
|
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>> {
|
async signIn(values, authenticator: string = 'password'): Promise<AxiosResponse<any>> {
|
||||||
|
@ -20,6 +20,7 @@ describe('application', () => {
|
|||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
dialectModule: require('sqlite3'),
|
dialectModule: require('sqlite3'),
|
||||||
storage: ':memory:',
|
storage: ':memory:',
|
||||||
|
logging: false,
|
||||||
},
|
},
|
||||||
resourcer: {
|
resourcer: {
|
||||||
prefix: '/api',
|
prefix: '/api',
|
||||||
|
@ -40,8 +40,6 @@ describe('multiple apps', () => {
|
|||||||
await app.stop();
|
await app.stop();
|
||||||
|
|
||||||
expect(subApp1StopFn).toBeCalledTimes(1);
|
expect(subApp1StopFn).toBeCalledTimes(1);
|
||||||
|
|
||||||
await app.destroy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -57,6 +55,27 @@ describe('multiple application', () => {
|
|||||||
await app.destroy();
|
await app.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should upgrade sub apps when main app upgraded', async () => {
|
||||||
|
const subApp1 = app.appManager.createApplication('sub1', {
|
||||||
|
database: app.db,
|
||||||
|
});
|
||||||
|
const subApp2 = app.appManager.createApplication('sub2', {
|
||||||
|
database: app.db,
|
||||||
|
});
|
||||||
|
const subApp1UpgradeFn = jest.fn();
|
||||||
|
const subApp2UpgradeFn = jest.fn();
|
||||||
|
|
||||||
|
subApp1.on('afterUpgrade', subApp1UpgradeFn);
|
||||||
|
subApp2.on('afterUpgrade', subApp2UpgradeFn);
|
||||||
|
|
||||||
|
await app.upgrade();
|
||||||
|
|
||||||
|
expect(subApp1UpgradeFn).toBeCalledTimes(1);
|
||||||
|
expect(subApp2UpgradeFn).toBeCalledTimes(1);
|
||||||
|
await subApp2.stop();
|
||||||
|
await subApp1.stop();
|
||||||
|
});
|
||||||
|
|
||||||
it('should create multiple apps', async () => {
|
it('should create multiple apps', async () => {
|
||||||
const sub1 = `a_${uid()}`;
|
const sub1 = `a_${uid()}`;
|
||||||
const sub2 = `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 http, { IncomingMessage, ServerResponse } from 'http';
|
||||||
import Application, { ApplicationOptions } from './application';
|
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 {
|
export class AppManager extends EventEmitter {
|
||||||
public applications: Map<string, Application> = new Map<string, Application>();
|
public applications: Map<string, Application> = new Map<string, Application>();
|
||||||
@ -11,23 +13,30 @@ export class AppManager extends EventEmitter {
|
|||||||
constructor(public app: Application) {
|
constructor(public app: Application) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
app.on('beforeStop', async (mainApp, options) => {
|
const passEventToSubApps = (eventName, method) => {
|
||||||
return await Promise.all(
|
app.on(eventName, async (mainApp, options) => {
|
||||||
[...this.applications.values()].map((application: Application) => application.stop(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) => {
|
passEventToSubApps('beforeDestroy', 'destroy');
|
||||||
return await Promise.all(
|
passEventToSubApps('beforeStop', 'stop');
|
||||||
[...this.applications.values()].map((application: Application) => application.destroy(options)),
|
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 {
|
createApplication(name: string, options: ApplicationOptions): Application {
|
||||||
const application = new Application(options);
|
const application = new Application({
|
||||||
|
...options,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
|
||||||
this.applications.set(name, application);
|
this.applications.set(name, application);
|
||||||
return application;
|
return application;
|
||||||
}
|
}
|
||||||
@ -66,7 +75,7 @@ export class AppManager extends EventEmitter {
|
|||||||
return async (req: IncomingMessage, res: ServerResponse) => {
|
return async (req: IncomingMessage, res: ServerResponse) => {
|
||||||
const appManager = this.app.appManager;
|
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') {
|
if (typeof handleApp === 'string') {
|
||||||
handleApp = await appManager.getApplication(handleApp);
|
handleApp = await appManager.getApplication(handleApp);
|
||||||
|
@ -102,7 +102,7 @@ export class ApplicationVersion {
|
|||||||
if (!app.db.hasCollection('applicationVersion')) {
|
if (!app.db.hasCollection('applicationVersion')) {
|
||||||
app.db.collection({
|
app.db.collection({
|
||||||
name: 'applicationVersion',
|
name: 'applicationVersion',
|
||||||
namespace: 'core',
|
namespace: 'core.applicationVersion',
|
||||||
duplicator: 'required',
|
duplicator: 'required',
|
||||||
timestamps: false,
|
timestamps: false,
|
||||||
fields: [{ name: 'value', type: 'string' }],
|
fields: [{ name: 'value', type: 'string' }],
|
||||||
@ -229,13 +229,17 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
|
|
||||||
protected init() {
|
protected init() {
|
||||||
const options = this.options;
|
const options = this.options;
|
||||||
|
|
||||||
const logger = createAppLogger(options.logger);
|
const logger = createAppLogger(options.logger);
|
||||||
this._logger = logger.instance;
|
this._logger = logger.instance;
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
this._events = [];
|
this._events = [];
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
this._eventsCount = [];
|
this._eventsCount = [];
|
||||||
|
|
||||||
this.removeAllListeners();
|
this.removeAllListeners();
|
||||||
|
|
||||||
this.middleware = new Toposort<any>();
|
this.middleware = new Toposort<any>();
|
||||||
this.plugins = new Map<string, Plugin>();
|
this.plugins = new Map<string, Plugin>();
|
||||||
this._acl = createACL();
|
this._acl = createACL();
|
||||||
@ -293,6 +297,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
});
|
});
|
||||||
|
|
||||||
db.setLogger(this._logger);
|
db.setLogger(this._logger);
|
||||||
|
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -350,7 +355,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
|
|
||||||
async load(options?: any) {
|
async load(options?: any) {
|
||||||
if (options?.reload) {
|
if (options?.reload) {
|
||||||
console.log(`Reload the application configuration`);
|
console.log(`Reload the ${this.name} application configuration`);
|
||||||
const oldDb = this._db;
|
const oldDb = this._db;
|
||||||
this.init();
|
this.init();
|
||||||
await oldDb.close();
|
await oldDb.close();
|
||||||
@ -366,6 +371,8 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
...options,
|
...options,
|
||||||
reload: true,
|
reload: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.emitAsync('afterReload', this, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
getPlugin<P extends Plugin>(name: string) {
|
getPlugin<P extends Plugin>(name: string) {
|
||||||
@ -412,6 +419,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
|
|
||||||
if (options?.listen?.port) {
|
if (options?.listen?.port) {
|
||||||
const pmServer = await this.pm.listen();
|
const pmServer = await this.pm.listen();
|
||||||
|
|
||||||
const listen = () =>
|
const listen = () =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
const Server = this.listen(options?.listen, () => {
|
const Server = this.listen(options?.listen, () => {
|
||||||
@ -452,6 +460,12 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
|
|
||||||
await this.emitAsync('beforeStop', this, options);
|
await this.emitAsync('beforeStop', this, options);
|
||||||
|
|
||||||
|
// close http server
|
||||||
|
if (this.listenServer) {
|
||||||
|
await promisify(this.listenServer.close).call(this.listenServer);
|
||||||
|
this.listenServer = null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// close database connection
|
// close database connection
|
||||||
// silent if database already closed
|
// silent if database already closed
|
||||||
@ -462,14 +476,9 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
console.log(e);
|
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);
|
await this.emitAsync('afterStop', this, options);
|
||||||
this.stopped = true;
|
this.stopped = true;
|
||||||
|
console.log(`${this.name} is stopped`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async destroy(options: any = {}) {
|
async destroy(options: any = {}) {
|
||||||
|
@ -5,12 +5,14 @@ export default (app: Application) => {
|
|||||||
.command('pm')
|
.command('pm')
|
||||||
.argument('<method>')
|
.argument('<method>')
|
||||||
.arguments('<plugins...>')
|
.arguments('<plugins...>')
|
||||||
|
.option('-S, --skip-yarn-install', 'skip yarn install')
|
||||||
.action(async (method, plugins, options, ...args) => {
|
.action(async (method, plugins, options, ...args) => {
|
||||||
if (method === 'add') {
|
if (method === 'add' && !options.skipYarnInstall) {
|
||||||
const { run } = require('@nocobase/cli/src/util');
|
const { run } = require('@nocobase/cli/src/util');
|
||||||
console.log('Install dependencies and rebuild workspaces');
|
console.log('Install dependencies and rebuild workspaces');
|
||||||
await run('yarn', ['install']);
|
await run('yarn', ['install']);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.pm.clientWrite({ method, plugins });
|
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',
|
name: 'applicationPlugins',
|
||||||
namespace: 'core',
|
namespace: 'core.applicationPlugins',
|
||||||
duplicator: 'required',
|
duplicator: 'required',
|
||||||
repository: 'PluginManagerRepository',
|
repository: 'PluginManagerRepository',
|
||||||
fields: [
|
fields: [
|
||||||
@ -11,4 +13,4 @@ export default {
|
|||||||
{ type: 'boolean', name: 'builtIn' },
|
{ type: 'boolean', name: 'builtIn' },
|
||||||
{ type: 'json', name: 'options' },
|
{ type: 'json', name: 'options' },
|
||||||
],
|
],
|
||||||
};
|
});
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Repository } from '@nocobase/database';
|
import { Repository } from '@nocobase/database';
|
||||||
import { PluginManager } from './PluginManager';
|
import { PluginManager } from './plugin-manager';
|
||||||
|
|
||||||
export class PluginManagerRepository extends Repository {
|
export class PluginManagerRepository extends Repository {
|
||||||
pm: PluginManager;
|
pm: PluginManager;
|
@ -9,7 +9,7 @@ import Application from '../application';
|
|||||||
import { Plugin } from '../plugin';
|
import { Plugin } from '../plugin';
|
||||||
import collectionOptions from './options/collection';
|
import collectionOptions from './options/collection';
|
||||||
import resourceOptions from './options/resource';
|
import resourceOptions from './options/resource';
|
||||||
import { PluginManagerRepository } from './PluginManagerRepository';
|
import { PluginManagerRepository } from './plugin-manager-repository';
|
||||||
|
|
||||||
export interface PluginManagerOptions {
|
export interface PluginManagerOptions {
|
||||||
app: Application;
|
app: Application;
|
||||||
@ -38,7 +38,9 @@ export class PluginManager {
|
|||||||
this.app.db.registerRepositories({
|
this.app.db.registerRepositories({
|
||||||
PluginManagerRepository,
|
PluginManagerRepository,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.collection = this.app.db.collection(collectionOptions);
|
this.collection = this.app.db.collection(collectionOptions);
|
||||||
|
|
||||||
this.repository = this.collection.repository as PluginManagerRepository;
|
this.repository = this.collection.repository as PluginManagerRepository;
|
||||||
this.repository.setPluginManager(this);
|
this.repository.setPluginManager(this);
|
||||||
this.app.resourcer.define(resourceOptions);
|
this.app.resourcer.define(resourceOptions);
|
||||||
@ -48,19 +50,6 @@ export class PluginManager {
|
|||||||
actions: ['pm:*', 'applicationPlugins:list'],
|
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) => {
|
this.app.on('beforeLoad', async (app, options) => {
|
||||||
if (options?.method && ['install', 'upgrade'].includes(options.method)) {
|
if (options?.method && ['install', 'upgrade'].includes(options.method)) {
|
||||||
await this.collection.sync();
|
await this.collection.sync();
|
||||||
@ -133,6 +122,19 @@ export class PluginManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listen(): Promise<net.Server> {
|
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)) {
|
if (fs.existsSync(this.pmSock)) {
|
||||||
await fs.promises.unlink(this.pmSock);
|
await fs.promises.unlink(this.pmSock);
|
||||||
}
|
}
|
||||||
@ -308,6 +310,7 @@ export class PluginManager {
|
|||||||
try {
|
try {
|
||||||
const pluginNames = await this.repository.enable(name);
|
const pluginNames = await this.repository.enable(name);
|
||||||
await this.app.reload();
|
await this.app.reload();
|
||||||
|
|
||||||
await this.app.db.sync();
|
await this.app.db.sync();
|
||||||
for (const pluginName of pluginNames) {
|
for (const pluginName of pluginNames) {
|
||||||
const plugin = this.app.getPlugin(pluginName);
|
const plugin = this.app.getPlugin(pluginName);
|
||||||
@ -317,6 +320,8 @@ export class PluginManager {
|
|||||||
await plugin.install();
|
await plugin.install();
|
||||||
await plugin.afterEnable();
|
await plugin.afterEnable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.app.emitAsync('afterEnablePlugin', name);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -333,6 +338,8 @@ export class PluginManager {
|
|||||||
}
|
}
|
||||||
await plugin.afterDisable();
|
await plugin.afterDisable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.app.emitAsync('afterDisablePlugin', name);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
@ -27,6 +27,7 @@ interface ActionParams {
|
|||||||
* @deprecated
|
* @deprecated
|
||||||
*/
|
*/
|
||||||
associatedIndex?: string;
|
associatedIndex?: string;
|
||||||
|
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,6 +42,7 @@ interface SortActionParams {
|
|||||||
method?: string;
|
method?: string;
|
||||||
target?: any;
|
target?: any;
|
||||||
sticky?: boolean;
|
sticky?: boolean;
|
||||||
|
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,6 +53,7 @@ interface Resource {
|
|||||||
update: (params?: ActionParams) => Promise<supertest.Response>;
|
update: (params?: ActionParams) => Promise<supertest.Response>;
|
||||||
destroy: (params?: ActionParams) => Promise<supertest.Response>;
|
destroy: (params?: ActionParams) => Promise<supertest.Response>;
|
||||||
sort: (params?: SortActionParams) => Promise<supertest.Response>;
|
sort: (params?: SortActionParams) => Promise<supertest.Response>;
|
||||||
|
|
||||||
[name: string]: (params?: ActionParams) => Promise<supertest.Response>;
|
[name: string]: (params?: ActionParams) => Promise<supertest.Response>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,6 +61,10 @@ export class MockServer extends Application {
|
|||||||
async loadAndInstall(options: any = {}) {
|
async loadAndInstall(options: any = {}) {
|
||||||
await this.load({ method: 'install' });
|
await this.load({ method: 'install' });
|
||||||
|
|
||||||
|
if (options.afterLoad) {
|
||||||
|
await options.afterLoad(this);
|
||||||
|
}
|
||||||
|
|
||||||
await this.install({
|
await this.install({
|
||||||
...options,
|
...options,
|
||||||
sync: {
|
sync: {
|
||||||
|
@ -3,6 +3,6 @@ import { CollectionOptions } from '@nocobase/database';
|
|||||||
export default {
|
export default {
|
||||||
name: 'rolesUsers',
|
name: 'rolesUsers',
|
||||||
duplicator: 'optional',
|
duplicator: 'optional',
|
||||||
namespace: 'acl',
|
namespace: 'acl.acl',
|
||||||
fields: [{ type: 'boolean', name: 'default' }],
|
fields: [{ type: 'boolean', name: 'default' }],
|
||||||
} as CollectionOptions;
|
} as CollectionOptions;
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
import { CollectionOptions } from '@nocobase/database';
|
import { CollectionOptions } from '@nocobase/database';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
namespace: 'acl',
|
namespace: 'acl.acl',
|
||||||
duplicator: 'required',
|
duplicator: {
|
||||||
|
dumpable: 'required',
|
||||||
|
with: 'uiSchemas',
|
||||||
|
},
|
||||||
name: 'roles',
|
name: 'roles',
|
||||||
title: '{{t("Roles")}}',
|
title: '{{t("Roles")}}',
|
||||||
autoGenId: false,
|
autoGenId: false,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { CollectionOptions } from '@nocobase/database';
|
import { CollectionOptions } from '@nocobase/database';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
namespace: 'acl',
|
namespace: 'acl.acl',
|
||||||
duplicator: 'required',
|
duplicator: 'required',
|
||||||
name: 'rolesResources',
|
name: 'rolesResources',
|
||||||
model: 'RoleResourceModel',
|
model: 'RoleResourceModel',
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { CollectionOptions } from '@nocobase/database';
|
import { CollectionOptions } from '@nocobase/database';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
namespace: 'acl',
|
namespace: 'acl.acl',
|
||||||
duplicator: 'required',
|
duplicator: 'required',
|
||||||
name: 'rolesResourcesActions',
|
name: 'rolesResourcesActions',
|
||||||
model: 'RoleResourceActionModel',
|
model: 'RoleResourceActionModel',
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { CollectionOptions } from '@nocobase/database';
|
import { CollectionOptions } from '@nocobase/database';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
namespace: 'acl',
|
namespace: 'acl.acl',
|
||||||
duplicator: 'required',
|
duplicator: 'required',
|
||||||
name: 'rolesResourcesScopes',
|
name: 'rolesResourcesScopes',
|
||||||
fields: [
|
fields: [
|
||||||
|
@ -817,7 +817,7 @@ export class PluginACL extends Plugin {
|
|||||||
await this.importCollections(resolve(__dirname, 'collections'));
|
await this.importCollections(resolve(__dirname, 'collections'));
|
||||||
this.db.extendCollection({
|
this.db.extendCollection({
|
||||||
name: 'rolesUischemas',
|
name: 'rolesUischemas',
|
||||||
namespace: 'acl',
|
namespace: 'acl.acl',
|
||||||
duplicator: 'required',
|
duplicator: 'required',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { defineCollection } from '@nocobase/database';
|
import { defineCollection } from '@nocobase/database';
|
||||||
|
|
||||||
export default defineCollection({
|
export default defineCollection({
|
||||||
namespace: 'audit-logs',
|
namespace: 'audit-logs.auditLogs',
|
||||||
duplicator: 'optional',
|
duplicator: 'optional',
|
||||||
name: 'auditChanges',
|
name: 'auditChanges',
|
||||||
title: '变动值',
|
title: '变动值',
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { defineCollection } from '@nocobase/database';
|
import { defineCollection } from '@nocobase/database';
|
||||||
|
|
||||||
export default defineCollection({
|
export default defineCollection({
|
||||||
namespace: 'audit-logs',
|
namespace: 'audit-logs.auditLogs',
|
||||||
duplicator: 'optional',
|
duplicator: 'optional',
|
||||||
name: 'auditLogs',
|
name: 'auditLogs',
|
||||||
createdBy: false,
|
createdBy: false,
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { defineCollection } from '@nocobase/database';
|
import { defineCollection } from '@nocobase/database';
|
||||||
|
|
||||||
export default defineCollection({
|
export default defineCollection({
|
||||||
|
namespace: 'charts.chartsQueries',
|
||||||
|
duplicator: 'optional',
|
||||||
name: 'chartsQueries',
|
name: 'chartsQueries',
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { defineCollection } from '@nocobase/database';
|
import { defineCollection } from '@nocobase/database';
|
||||||
|
|
||||||
export default defineCollection({
|
export default defineCollection({
|
||||||
namespace: 'china-region',
|
namespace: 'china-region.china-region',
|
||||||
duplicator: 'skip',
|
duplicator: 'skip',
|
||||||
name: 'chinaRegions',
|
name: 'chinaRegions',
|
||||||
title: '中国行政区划',
|
title: '中国行政区划',
|
||||||
|
@ -14,7 +14,8 @@ describe('action test', () => {
|
|||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await app.destroy();
|
await app.destroy();
|
||||||
});
|
});
|
||||||
it('should append uiSchema', async () => {
|
|
||||||
|
it('should get uiSchema', async () => {
|
||||||
await db.getRepository('collections').create({
|
await db.getRepository('collections').create({
|
||||||
values: {
|
values: {
|
||||||
name: 'posts',
|
name: 'posts',
|
||||||
@ -41,10 +42,14 @@ describe('action test', () => {
|
|||||||
.resource('collections.fields', 'posts')
|
.resource('collections.fields', 'posts')
|
||||||
.list({
|
.list({
|
||||||
pageSize: 5,
|
pageSize: 5,
|
||||||
appends: ['uiSchema'],
|
|
||||||
sort: ['sort'],
|
sort: ['sort'],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toEqual(200);
|
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 testCollection = db.getCollection('tests');
|
||||||
const getTableInfo = async () =>
|
const getTableInfo = async () =>
|
||||||
await db.sequelize.getQueryInterface().describeTable(testCollection.addSchemaTableName());
|
await db.sequelize.getQueryInterface().describeTable(testCollection.getTableNameWithSchema());
|
||||||
|
|
||||||
const tableInfo0 = await getTableInfo();
|
const tableInfo0 = await getTableInfo();
|
||||||
expect(tableInfo0['date_a']).toBeDefined();
|
expect(tableInfo0['date_a']).toBeDefined();
|
||||||
@ -286,7 +286,7 @@ describe('collections repository', () => {
|
|||||||
|
|
||||||
const testCollection = db.getCollection('tests');
|
const testCollection = db.getCollection('tests');
|
||||||
const getTableInfo = async () =>
|
const getTableInfo = async () =>
|
||||||
await db.sequelize.getQueryInterface().describeTable(testCollection.addSchemaTableName());
|
await db.sequelize.getQueryInterface().describeTable(testCollection.getTableNameWithSchema());
|
||||||
|
|
||||||
const tableInfo0 = await getTableInfo();
|
const tableInfo0 = await getTableInfo();
|
||||||
expect(tableInfo0[createdAt]).toBeDefined();
|
expect(tableInfo0[createdAt]).toBeDefined();
|
||||||
@ -339,7 +339,7 @@ describe('collections repository', () => {
|
|||||||
testCollection.model.rawAttributes.test_field.field === testCollection.model.rawAttributes.testField.field,
|
testCollection.model.rawAttributes.test_field.field === testCollection.model.rawAttributes.testField.field,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
const getTableInfo = async () =>
|
const getTableInfo = async () =>
|
||||||
await db.sequelize.getQueryInterface().describeTable(testCollection.addSchemaTableName());
|
await db.sequelize.getQueryInterface().describeTable(testCollection.getTableNameWithSchema());
|
||||||
|
|
||||||
const tableInfo0 = await getTableInfo();
|
const tableInfo0 = await getTableInfo();
|
||||||
|
|
||||||
|
@ -59,7 +59,7 @@ describe('field defaultValue', () => {
|
|||||||
const response2 = await app.agent().resource('test1').create();
|
const response2 = await app.agent().resource('test1').create();
|
||||||
expect(response2.body.data.field1).toBe('cba');
|
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');
|
expect(results.field1.defaultValue).toBe('cba');
|
||||||
});
|
});
|
||||||
|
@ -168,11 +168,10 @@ describe('reverseField options', () => {
|
|||||||
filter: {
|
filter: {
|
||||||
key: reverseField.get('key'),
|
key: reverseField.get('key'),
|
||||||
},
|
},
|
||||||
appends: ['uiSchema'],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const uiSchema = reverseField.get('uiSchema');
|
const uiSchema = reverseField.get('uiSchema');
|
||||||
expect(uiSchema['schema']).toEqual({ title: '123' });
|
expect(uiSchema).toEqual({ title: '123' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update uiSchema', async () => {
|
it('should update uiSchema', async () => {
|
||||||
@ -211,12 +210,8 @@ describe('reverseField options', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const f2 = await app
|
const f2 = await app.agent().resource('collections.fields', 'a').get({
|
||||||
.agent()
|
|
||||||
.resource('collections.fields', 'a')
|
|
||||||
.get({
|
|
||||||
filterByTk: 'f_i02fjvduwmv',
|
filterByTk: 'f_i02fjvduwmv',
|
||||||
appends: ['uiSchema'],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(f2.body.data.uiSchema.title).toBe('A2');
|
expect(f2.body.data.uiSchema.title).toBe('A2');
|
||||||
@ -270,12 +265,8 @@ describe('reverseField options', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const f1 = await app
|
const f1 = await app.agent().resource('collections.fields', 'b').get({
|
||||||
.agent()
|
|
||||||
.resource('collections.fields', 'b')
|
|
||||||
.get({
|
|
||||||
filterByTk: 'f_dctw6v5gsio',
|
filterByTk: 'f_dctw6v5gsio',
|
||||||
appends: ['uiSchema'],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(f1.body.data.uiSchema.title).toBe('A');
|
expect(f1.body.data.uiSchema.title).toBe('A');
|
||||||
|
@ -97,7 +97,7 @@ describe('collections repository', () => {
|
|||||||
|
|
||||||
const testCollection = app.db.getCollection('test');
|
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();
|
expect(tableInfo['field']).toBeDefined();
|
||||||
});
|
});
|
||||||
@ -460,7 +460,7 @@ describe('collections repository', () => {
|
|||||||
|
|
||||||
const columnName = collection.model.rawAttributes.testField.field;
|
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();
|
expect(tableInfo[columnName]).toBeDefined();
|
||||||
});
|
});
|
||||||
@ -504,7 +504,7 @@ describe('collections repository', () => {
|
|||||||
|
|
||||||
const indexes = (await app.db.sequelize
|
const indexes = (await app.db.sequelize
|
||||||
.getQueryInterface()
|
.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;
|
const columnName = app.db.getCollection('test').model.rawAttributes.testField.field;
|
||||||
|
|
||||||
@ -528,7 +528,7 @@ describe('collections repository', () => {
|
|||||||
|
|
||||||
const afterIndexes = (await app.db.sequelize
|
const afterIndexes = (await app.db.sequelize
|
||||||
.getQueryInterface()
|
.getQueryInterface()
|
||||||
.showIndex(app.db.getCollection('test').addSchemaTableName())) as any;
|
.showIndex(app.db.getCollection('test').getTableNameWithSchema())) as any;
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
afterIndexes.find(
|
afterIndexes.find(
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import PluginErrorHandler from '@nocobase/plugin-error-handler';
|
import PluginErrorHandler from '@nocobase/plugin-error-handler';
|
||||||
import PluginUiSchema from '@nocobase/plugin-ui-schema-storage';
|
import PluginUiSchema from '@nocobase/plugin-ui-schema-storage';
|
||||||
import { mockServer } from '@nocobase/test';
|
import { MockServer, mockServer } from '@nocobase/test';
|
||||||
import Plugin from '../';
|
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({
|
const app = mockServer({
|
||||||
acl: false,
|
acl: false,
|
||||||
...options,
|
...options,
|
||||||
@ -12,11 +14,17 @@ export async function createApp(options = {}) {
|
|||||||
await app.db.clean({ drop: true });
|
await app.db.clean({ drop: true });
|
||||||
await app.db.sync({});
|
await app.db.sync({});
|
||||||
|
|
||||||
|
options.beforePlugin && options.beforePlugin(app);
|
||||||
|
|
||||||
app.plugin(PluginErrorHandler, { name: 'error-handler' });
|
app.plugin(PluginErrorHandler, { name: 'error-handler' });
|
||||||
app.plugin(Plugin, { name: 'collection-manager' });
|
app.plugin(Plugin, { name: 'collection-manager' });
|
||||||
app.plugin(PluginUiSchema, { name: 'ui-schema-storage' });
|
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();
|
await app.start();
|
||||||
return app;
|
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 { Database, MigrationContext } from '@nocobase/database';
|
||||||
import lodash from 'lodash';
|
import lodash from 'lodash';
|
||||||
import Migrator from '../../migrations/20221121111113-update-id-to-bigint';
|
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 { MockServer } from '@nocobase/test';
|
||||||
import { createApp } from '../index';
|
import { createApp } from '../index';
|
||||||
|
|
||||||
|
const excludeSqlite = () => (process.env.DB_DIALECT != 'sqlite' ? describe.skip : describe.skip);
|
||||||
|
|
||||||
excludeSqlite()('update id to bigint test', () => {
|
excludeSqlite()('update id to bigint test', () => {
|
||||||
let app: MockServer;
|
let app: MockServer;
|
||||||
let db: Database;
|
let db: Database;
|
||||||
@ -69,7 +68,7 @@ excludeSqlite()('update id to bigint test', () => {
|
|||||||
|
|
||||||
const assertBigInt = async (collectionName, fieldName) => {
|
const assertBigInt = async (collectionName, fieldName) => {
|
||||||
const tableName = db.getCollection(collectionName)
|
const tableName = db.getCollection(collectionName)
|
||||||
? db.getCollection(collectionName).addSchemaTableName()
|
? db.getCollection(collectionName).getTableNameWithSchema()
|
||||||
: collectionName;
|
: collectionName;
|
||||||
|
|
||||||
const tableInfo = await db.sequelize.getQueryInterface().describeTable(tableName);
|
const tableInfo = await db.sequelize.getQueryInterface().describeTable(tableName);
|
||||||
@ -91,7 +90,7 @@ excludeSqlite()('update id to bigint test', () => {
|
|||||||
|
|
||||||
let usersTableInfo = await db.sequelize
|
let usersTableInfo = await db.sequelize
|
||||||
.getQueryInterface()
|
.getQueryInterface()
|
||||||
.describeTable(db.getCollection('users').addSchemaTableName());
|
.describeTable(db.getCollection('users').getTableNameWithSchema());
|
||||||
|
|
||||||
assertInteger(usersTableInfo.id.type);
|
assertInteger(usersTableInfo.id.type);
|
||||||
|
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
import { CollectionOptions } from '@nocobase/database';
|
import { CollectionOptions } from '@nocobase/database';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
namespace: 'collection-manager',
|
namespace: 'collection-manager.collections',
|
||||||
duplicator: 'required',
|
duplicator: {
|
||||||
|
dumpable: 'required',
|
||||||
|
with: 'collectionCategory',
|
||||||
|
},
|
||||||
name: 'collectionCategories',
|
name: 'collectionCategories',
|
||||||
autoGenId: true,
|
autoGenId: true,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
@ -11,11 +14,6 @@ export default {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
name: 'name',
|
name: 'name',
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// type: 'integer',
|
|
||||||
// name: 'sort',
|
|
||||||
// defaultValue: 0,
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
type: 'string',
|
type: 'string',
|
||||||
name: 'color',
|
name: 'color',
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { CollectionOptions } from '@nocobase/database';
|
import { CollectionOptions } from '@nocobase/database';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
namespace: 'collection-manager',
|
namespace: 'collection-manager.collections',
|
||||||
duplicator: 'required',
|
duplicator: 'required',
|
||||||
name: 'collections',
|
name: 'collections',
|
||||||
title: '数据表配置',
|
title: '数据表配置',
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { CollectionOptions } from '@nocobase/database';
|
import { CollectionOptions } from '@nocobase/database';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
namespace: 'collection-manager',
|
namespace: 'collection-manager.collections',
|
||||||
duplicator: 'required',
|
duplicator: 'required',
|
||||||
name: 'fields',
|
name: 'fields',
|
||||||
autoGenId: false,
|
autoGenId: false,
|
||||||
@ -59,12 +59,6 @@ export default {
|
|||||||
sourceKey: 'key',
|
sourceKey: 'key',
|
||||||
foreignKey: 'reverseKey',
|
foreignKey: 'reverseKey',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
type: 'belongsTo',
|
|
||||||
name: 'uiSchema',
|
|
||||||
target: 'uiSchemas',
|
|
||||||
foreignKey: 'uiSchemaUid',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: 'json',
|
type: 'json',
|
||||||
name: 'options',
|
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.loadFields({ transaction });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.db.emitAsync('collection:loaded', {
|
||||||
|
collection,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
return collection;
|
return collection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -20,7 +20,7 @@ export class FieldModel extends MagicAttributeModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async load(loadOptions?: LoadOptions) {
|
async load(loadOptions?: LoadOptions) {
|
||||||
const { skipExist = false } = loadOptions || {};
|
const { skipExist = false, transaction } = loadOptions || {};
|
||||||
const collectionName = this.get('collectionName');
|
const collectionName = this.get('collectionName');
|
||||||
|
|
||||||
if (!this.db.hasCollection(collectionName)) {
|
if (!this.db.hasCollection(collectionName)) {
|
||||||
@ -36,16 +36,14 @@ export class FieldModel extends MagicAttributeModel {
|
|||||||
|
|
||||||
const options = this.get();
|
const options = this.get();
|
||||||
|
|
||||||
if (options.uiSchemaUid) {
|
const field = collection.setField(name, options);
|
||||||
const UISchema = this.db.getModel('uiSchemas');
|
|
||||||
const uiSchema = await UISchema.findByPk(options.uiSchemaUid, {
|
await this.db.emitAsync('field:loaded', {
|
||||||
transaction: loadOptions.transaction,
|
fieldKey: this.get('key'),
|
||||||
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.assign(options, { uiSchema: uiSchema.get() });
|
return field;
|
||||||
}
|
|
||||||
|
|
||||||
return collection.setField(name, options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async migrate({ isNew, ...options }: MigrateOptions = {}) {
|
async migrate({ isNew, ...options }: MigrateOptions = {}) {
|
||||||
@ -107,7 +105,7 @@ export class FieldModel extends MagicAttributeModel {
|
|||||||
|
|
||||||
const queryInterface = this.db.sequelize.getQueryInterface() as any;
|
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,
|
transaction: options.transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -121,7 +119,7 @@ export class FieldModel extends MagicAttributeModel {
|
|||||||
|
|
||||||
if (existUniqueIndex) {
|
if (existUniqueIndex) {
|
||||||
const existsUniqueConstraints = await queryInterface.showConstraint(
|
const existsUniqueConstraints = await queryInterface.showConstraint(
|
||||||
collection.addSchemaTableName(),
|
collection.getTableNameWithSchema(),
|
||||||
constraintName,
|
constraintName,
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
@ -133,7 +131,7 @@ export class FieldModel extends MagicAttributeModel {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
await collection.sync({ ...options, force: false, alter: { drop: false } });
|
await collection.sync({ ...options, force: false, alter: { drop: false } });
|
||||||
|
|
||||||
await queryInterface.addConstraint(collection.addSchemaTableName(), {
|
await queryInterface.addConstraint(collection.getTableNameWithSchema(), {
|
||||||
type: 'unique',
|
type: 'unique',
|
||||||
fields: [columnName],
|
fields: [columnName],
|
||||||
name: constraintName,
|
name: constraintName,
|
||||||
@ -144,7 +142,7 @@ export class FieldModel extends MagicAttributeModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!unique && existsUniqueConstraint) {
|
if (!unique && existsUniqueConstraint) {
|
||||||
await queryInterface.removeConstraint(collection.addSchemaTableName(), constraintName, {
|
await queryInterface.removeConstraint(collection.getTableNameWithSchema(), constraintName, {
|
||||||
transaction: options.transaction,
|
transaction: options.transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -168,7 +166,7 @@ export class FieldModel extends MagicAttributeModel {
|
|||||||
const queryInterface = collection.db.sequelize.getQueryInterface();
|
const queryInterface = collection.db.sequelize.getQueryInterface();
|
||||||
|
|
||||||
await queryInterface.changeColumn(
|
await queryInterface.changeColumn(
|
||||||
collection.addSchemaTableName(),
|
collection.getTableNameWithSchema(),
|
||||||
collection.model.rawAttributes[this.get('name')].field,
|
collection.model.rawAttributes[this.get('name')].field,
|
||||||
{
|
{
|
||||||
type: field.dataType,
|
type: field.dataType,
|
||||||
|
@ -11,13 +11,13 @@ import {
|
|||||||
afterCreateForReverseField,
|
afterCreateForReverseField,
|
||||||
beforeCreateForReverseField,
|
beforeCreateForReverseField,
|
||||||
beforeDestroyForeignKey,
|
beforeDestroyForeignKey,
|
||||||
beforeInitOptions,
|
beforeInitOptions
|
||||||
} from './hooks';
|
} from './hooks';
|
||||||
|
|
||||||
import { InheritedCollection } from '@nocobase/database';
|
import { InheritedCollection } from '@nocobase/database';
|
||||||
import { CollectionModel, FieldModel } from './models';
|
|
||||||
import * as process from 'process';
|
|
||||||
import lodash from 'lodash';
|
import lodash from 'lodash';
|
||||||
|
import * as process from 'process';
|
||||||
|
import { CollectionModel, FieldModel } from './models';
|
||||||
|
|
||||||
export class CollectionManagerPlugin extends Plugin {
|
export class CollectionManagerPlugin extends Plugin {
|
||||||
public schema: string;
|
public schema: string;
|
||||||
@ -255,7 +255,7 @@ export class CollectionManagerPlugin extends Plugin {
|
|||||||
this.app.resourcer.use(async (ctx, next) => {
|
this.app.resourcer.use(async (ctx, next) => {
|
||||||
if (ctx.action.resourceName === 'collections.fields' && ['create', 'update'].includes(ctx.action.actionName)) {
|
if (ctx.action.resourceName === 'collections.fields' && ['create', 'update'].includes(ctx.action.actionName)) {
|
||||||
ctx.action.mergeParams({
|
ctx.action.mergeParams({
|
||||||
updateAssociationValues: ['uiSchema', 'reverseField'],
|
updateAssociationValues: ['reverseField'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await next();
|
await next();
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
"main": "./lib/index.js",
|
"main": "./lib/index.js",
|
||||||
"types": "./lib/index.d.ts",
|
"types": "./lib/index.d.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@koa/multer": "^3.0.2",
|
||||||
"@nocobase/client": "0.9.1-alpha.2",
|
"@nocobase/client": "0.9.1-alpha.2",
|
||||||
"@nocobase/database": "0.9.1-alpha.2",
|
"@nocobase/database": "0.9.1-alpha.2",
|
||||||
"@nocobase/server": "0.9.1-alpha.2",
|
"@nocobase/server": "0.9.1-alpha.2",
|
||||||
@ -13,6 +14,7 @@
|
|||||||
"dayjs": "^1.11.7",
|
"dayjs": "^1.11.7",
|
||||||
"decompress": "^4.2.1",
|
"decompress": "^4.2.1",
|
||||||
"inquirer": "^8.0.0",
|
"inquirer": "^8.0.0",
|
||||||
|
"koa-send": "^5.0.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mkdirp": "^1.0.4",
|
"mkdirp": "^1.0.4",
|
||||||
"tar": "^6.1.13"
|
"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 crypto from 'crypto';
|
||||||
import EventEmitter from 'events';
|
import EventEmitter from 'events';
|
||||||
import fsPromises from 'fs/promises';
|
import fsPromises from 'fs/promises';
|
||||||
import inquirer from 'inquirer';
|
|
||||||
import lodash from 'lodash';
|
import lodash from 'lodash';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { CollectionGroupManager } from './collection-group-manager';
|
||||||
|
|
||||||
|
export type AppMigratorOptions = {
|
||||||
|
workDir?: string;
|
||||||
|
};
|
||||||
abstract class AppMigrator extends EventEmitter {
|
abstract class AppMigrator extends EventEmitter {
|
||||||
protected workDir: string;
|
protected workDir: string;
|
||||||
public app: Application;
|
public app: Application;
|
||||||
@ -16,12 +19,7 @@ abstract class AppMigrator extends EventEmitter {
|
|||||||
|
|
||||||
declare emitAsync: (event: string | symbol, ...args: any[]) => Promise<boolean>;
|
declare emitAsync: (event: string | symbol, ...args: any[]) => Promise<boolean>;
|
||||||
|
|
||||||
constructor(
|
constructor(app, options?: AppMigratorOptions) {
|
||||||
app,
|
|
||||||
options?: {
|
|
||||||
workDir?: string;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.app = app;
|
this.app = app;
|
||||||
@ -44,7 +42,14 @@ abstract class AppMigrator extends EventEmitter {
|
|||||||
async getAppPlugins() {
|
async getAppPlugins() {
|
||||||
const plugins = await this.app.db.getCollection('applicationPlugins').repository.find();
|
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() {
|
async getCustomCollections() {
|
||||||
@ -60,64 +65,6 @@ abstract class AppMigrator extends EventEmitter {
|
|||||||
await this.rmDir(this.workDir);
|
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[]) {
|
findThroughCollections(collections: string[]) {
|
||||||
return [
|
return [
|
||||||
...new Set(
|
...new Set(
|
||||||
|
@ -1,40 +1,19 @@
|
|||||||
import lodash from 'lodash';
|
import { Application } from '@nocobase/server';
|
||||||
import { Restorer } from './restorer';
|
import { CollectionGroup } from '@nocobase/database';
|
||||||
|
|
||||||
interface CollectionGroup {
|
|
||||||
pluginName: string;
|
|
||||||
collections: string[];
|
|
||||||
function: string;
|
|
||||||
|
|
||||||
dumpable: 'required' | 'optional' | 'skip';
|
|
||||||
delayRestore?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CollectionGroupManager {
|
export class CollectionGroupManager {
|
||||||
static collectionGroups: CollectionGroup[] = [];
|
static collectionGroups: CollectionGroup[] = [];
|
||||||
|
|
||||||
static registerCollectionGroup(collectionGroup: CollectionGroup) {
|
static getGroups(app: Application) {
|
||||||
this.collectionGroups.push(collectionGroup);
|
return app.db.collectionGroupManager.getGroups();
|
||||||
}
|
}
|
||||||
|
|
||||||
static getGroupsCollections(groups: string[] | CollectionGroup[]) {
|
static getGroupsCollections(groups: CollectionGroup[]) {
|
||||||
if (groups.length == 0) {
|
if (!groups || groups.length == 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lodash.isPlainObject(groups[0])) {
|
return groups.map((collectionGroup) => collectionGroup.collections).flat();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static classifyCollectionGroups(collectionGroups: CollectionGroup[]) {
|
static classifyCollectionGroups(collectionGroups: CollectionGroup[]) {
|
||||||
@ -46,210 +25,4 @@ export class CollectionGroupManager {
|
|||||||
optionalGroups,
|
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 dayjs from 'dayjs';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import fsPromises from 'fs/promises';
|
import fsPromises from 'fs/promises';
|
||||||
import inquirer from 'inquirer';
|
|
||||||
import lodash from 'lodash';
|
import lodash from 'lodash';
|
||||||
import mkdirp from 'mkdirp';
|
import mkdirp from 'mkdirp';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@ -10,6 +9,7 @@ import stream from 'stream';
|
|||||||
import util from 'util';
|
import util from 'util';
|
||||||
import { AppMigrator } from './app-migrator';
|
import { AppMigrator } from './app-migrator';
|
||||||
import { CollectionGroupManager } from './collection-group-manager';
|
import { CollectionGroupManager } from './collection-group-manager';
|
||||||
|
import { CollectionGroup } from '@nocobase/database';
|
||||||
import { FieldValueWriter } from './field-value-writer';
|
import { FieldValueWriter } from './field-value-writer';
|
||||||
import { DUMPED_EXTENSION, humanFileSize, sqlAdapter } from './utils';
|
import { DUMPED_EXTENSION, humanFileSize, sqlAdapter } from './utils';
|
||||||
|
|
||||||
@ -18,27 +18,29 @@ const finished = util.promisify(stream.finished);
|
|||||||
export class Dumper extends AppMigrator {
|
export class Dumper extends AppMigrator {
|
||||||
direction = 'dump' as const;
|
direction = 'dump' as const;
|
||||||
|
|
||||||
async dump() {
|
async dumpableCollections(): Promise<{
|
||||||
const appPlugins = await this.getAppPlugins();
|
requiredGroups: CollectionGroup[];
|
||||||
|
optionalGroups: CollectionGroup[];
|
||||||
|
userCollections: Array<{
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
const appCollectionGroups = CollectionGroupManager.getGroups(this.app);
|
||||||
|
|
||||||
// get system available collection groups
|
const { requiredGroups, optionalGroups } = CollectionGroupManager.classifyCollectionGroups(appCollectionGroups);
|
||||||
const collectionGroups = CollectionGroupManager.collectionGroups.filter((collectionGroup) =>
|
const pluginsCollections = CollectionGroupManager.getGroupsCollections(appCollectionGroups);
|
||||||
appPlugins.includes(collectionGroup.pluginName),
|
|
||||||
);
|
|
||||||
|
|
||||||
const coreCollections = ['applicationPlugins'];
|
const userCollections = await this.getCustomCollections();
|
||||||
const customCollections = await this.getCustomCollections();
|
return lodash.cloneDeep({
|
||||||
|
|
||||||
const { requiredGroups, optionalGroups } = CollectionGroupManager.classifyCollectionGroups(collectionGroups);
|
|
||||||
const pluginsCollections = CollectionGroupManager.getGroupsCollections(collectionGroups);
|
|
||||||
|
|
||||||
const optionalCollections = [...customCollections.filter((collection) => !pluginsCollections.includes(collection))];
|
|
||||||
|
|
||||||
const questions = this.buildInquirerQuestions(
|
|
||||||
requiredGroups,
|
requiredGroups,
|
||||||
optionalGroups,
|
optionalGroups,
|
||||||
await Promise.all(
|
userCollections: await Promise.all(
|
||||||
optionalCollections.map(async (name) => {
|
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({
|
const collectionInstance = await this.app.db.getRepository('collections').findOne({
|
||||||
filterByTk: name,
|
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 = [
|
const dumpedCollections = lodash.uniq(
|
||||||
coreCollections,
|
[
|
||||||
CollectionGroupManager.getGroupsCollections(requiredGroups),
|
CollectionGroupManager.getGroupsCollections(requiredGroups),
|
||||||
CollectionGroupManager.getGroupsCollections(results.collectionGroups),
|
CollectionGroupManager.getGroupsCollections(selectedOptionalGroups),
|
||||||
userCollections,
|
selectedUserCollections,
|
||||||
throughCollections,
|
throughCollections,
|
||||||
].flat();
|
].flat(),
|
||||||
|
);
|
||||||
|
|
||||||
for (const collection of dumpedCollections) {
|
for (const collection of dumpedCollections) {
|
||||||
await this.dumpCollection({
|
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.dumpDb();
|
||||||
await this.packDumpedDir();
|
|
||||||
|
const filePath = await this.packDumpedDir();
|
||||||
await this.clearWorkDir();
|
await this.clearWorkDir();
|
||||||
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
async dumpDb() {
|
async dumpDb() {
|
||||||
@ -84,17 +115,13 @@ export class Dumper extends AppMigrator {
|
|||||||
if (dialect === 'postgres') {
|
if (dialect === 'postgres') {
|
||||||
// get user defined functions in postgres
|
// get user defined functions in postgres
|
||||||
const functions = await db.sequelize.query(
|
const functions = await db.sequelize.query(
|
||||||
`SELECT
|
`SELECT n.nspname AS function_schema,
|
||||||
n.nspname AS function_schema,
|
|
||||||
p.proname AS function_name,
|
p.proname AS function_name,
|
||||||
pg_get_functiondef(p.oid) AS def
|
pg_get_functiondef(p.oid) AS def
|
||||||
FROM
|
FROM pg_proc p
|
||||||
pg_proc p
|
|
||||||
LEFT JOIN pg_namespace n ON p.pronamespace = n.oid
|
LEFT JOIN pg_namespace n ON p.pronamespace = n.oid
|
||||||
WHERE
|
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
||||||
n.nspname NOT IN ('pg_catalog', 'information_schema')
|
ORDER BY function_schema,
|
||||||
ORDER BY
|
|
||||||
function_schema,
|
|
||||||
function_name;`,
|
function_name;`,
|
||||||
{
|
{
|
||||||
type: 'SELECT',
|
type: 'SELECT',
|
||||||
@ -106,9 +133,13 @@ function_name;`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// get user defined triggers in postgres
|
// 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',
|
type: 'SELECT',
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
for (const t of triggers) {
|
for (const t of triggers) {
|
||||||
sqlContent.push(t['pg_get_triggerdef']);
|
sqlContent.push(t['pg_get_triggerdef']);
|
||||||
@ -136,12 +167,16 @@ ORDER BY table_schema, table_name`,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async dumpMeta() {
|
async dumpMeta(additionalMeta: Object = {}) {
|
||||||
const metaPath = path.resolve(this.workDir, 'meta');
|
const metaPath = path.resolve(this.workDir, 'meta');
|
||||||
|
|
||||||
await fsPromises.writeFile(
|
await fsPromises.writeFile(
|
||||||
metaPath,
|
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',
|
'utf8',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -177,7 +212,11 @@ ORDER BY table_schema, table_name`,
|
|||||||
const dataStream = fs.createWriteStream(dataFilePath);
|
const dataStream = fs.createWriteStream(dataFilePath);
|
||||||
|
|
||||||
const rows = await app.db.sequelize.query(
|
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',
|
type: 'SELECT',
|
||||||
},
|
},
|
||||||
@ -248,5 +287,9 @@ ORDER BY table_schema, table_name`,
|
|||||||
|
|
||||||
await archive.finalize();
|
await archive.finalize();
|
||||||
console.log('dumped to', filePath);
|
console.log('dumped to', filePath);
|
||||||
|
return {
|
||||||
|
filePath,
|
||||||
|
dirname,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,44 +1,52 @@
|
|||||||
import decompress from 'decompress';
|
import decompress from 'decompress';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import fsPromises from 'fs/promises';
|
import fsPromises from 'fs/promises';
|
||||||
import inquirer from 'inquirer';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { AppMigrator } from './app-migrator';
|
import { AppMigrator, AppMigratorOptions } from './app-migrator';
|
||||||
import { CollectionGroupManager } from './collection-group-manager';
|
import { CollectionGroupManager } from './collection-group-manager';
|
||||||
import { FieldValueWriter } from './field-value-writer';
|
import { FieldValueWriter } from './field-value-writer';
|
||||||
import { readLines, sqlAdapter } from './utils';
|
import { readLines, sqlAdapter } from './utils';
|
||||||
|
import { Application } from '@nocobase/server';
|
||||||
|
|
||||||
export class Restorer extends AppMigrator {
|
export class Restorer extends AppMigrator {
|
||||||
direction = 'restore' as const;
|
direction = 'restore' as const;
|
||||||
|
backUpFilePath: string;
|
||||||
|
decompressed: boolean = false;
|
||||||
importedCollections: string[] = [];
|
importedCollections: string[] = [];
|
||||||
|
|
||||||
async restore(backupFilePath: string) {
|
constructor(
|
||||||
let filePath: string;
|
app: Application,
|
||||||
|
options: AppMigratorOptions & {
|
||||||
if (path.isAbsolute(backupFilePath)) {
|
backUpFilePath?: string;
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
]);
|
) {
|
||||||
|
super(app, options);
|
||||||
|
const { backUpFilePath } = options;
|
||||||
|
|
||||||
if (results.confirm !== true) {
|
if (backUpFilePath) {
|
||||||
return;
|
this.setBackUpFilePath(backUpFilePath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.decompressBackup(filePath);
|
setBackUpFilePath(backUpFilePath) {
|
||||||
await this.importCollections();
|
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.importDb();
|
||||||
await this.clearWorkDir();
|
await this.clearWorkDir();
|
||||||
}
|
}
|
||||||
@ -67,6 +75,7 @@ export class Restorer extends AppMigrator {
|
|||||||
|
|
||||||
const index = meta.columns.indexOf('name');
|
const index = meta.columns.indexOf('name');
|
||||||
const row = data.find((row) => JSON.parse(row)[index] === collectionName);
|
const row = data.find((row) => JSON.parse(row)[index] === collectionName);
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
throw new Error(`Collection ${collectionName} not found`);
|
throw new Error(`Collection ${collectionName} not found`);
|
||||||
}
|
}
|
||||||
@ -78,8 +87,7 @@ export class Restorer extends AppMigrator {
|
|||||||
|
|
||||||
async getImportCollections() {
|
async getImportCollections() {
|
||||||
const collectionsDir = path.resolve(this.workDir, 'collections');
|
const collectionsDir = path.resolve(this.workDir, 'collections');
|
||||||
const collections = await fsPromises.readdir(collectionsDir);
|
return await fsPromises.readdir(collectionsDir);
|
||||||
return collections;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getImportCollectionData(collectionName) {
|
async getImportCollectionData(collectionName) {
|
||||||
@ -89,47 +97,19 @@ export class Restorer extends AppMigrator {
|
|||||||
|
|
||||||
async getImportCollectionMeta(collectionName) {
|
async getImportCollectionMeta(collectionName) {
|
||||||
const metaData = path.resolve(this.workDir, 'collections', collectionName, 'meta');
|
const metaData = path.resolve(this.workDir, 'collections', collectionName, 'meta');
|
||||||
const meta = JSON.parse(await fsPromises.readFile(metaData, 'utf8'));
|
return JSON.parse(await fsPromises.readFile(metaData, 'utf8'));
|
||||||
return meta;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async importCollections(options?: { ignore?: string | string[] }) {
|
async getImportMeta() {
|
||||||
const coreCollections = ['applicationPlugins'];
|
const metaFile = path.resolve(this.workDir, 'meta');
|
||||||
const collections = await this.getImportCollections();
|
return JSON.parse(await fsPromises.readFile(metaFile, 'utf8')) as any;
|
||||||
|
}
|
||||||
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 importCollections(options: {
|
||||||
|
ignore?: string | string[];
|
||||||
|
selectedOptionalGroupNames: string[];
|
||||||
|
selectedUserCollections: string[];
|
||||||
|
}) {
|
||||||
const importCollection = async (collectionName: string) => {
|
const importCollection = async (collectionName: string) => {
|
||||||
const collectionMetaPath = path.resolve(this.workDir, 'collections', collectionName, 'meta');
|
const collectionMetaPath = path.resolve(this.workDir, 'collections', collectionName, 'meta');
|
||||||
|
|
||||||
@ -157,16 +137,20 @@ export class Restorer extends AppMigrator {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// import applicationPlugins first
|
||||||
await importCollection('applicationPlugins');
|
await importCollection('applicationPlugins');
|
||||||
|
// reload app
|
||||||
await this.app.reload();
|
await this.app.reload();
|
||||||
|
|
||||||
const requiredCollections = CollectionGroupManager.getGroupsCollections(requiredGroups).filter(
|
const { requiredGroups, selectedOptionalGroups } = await this.parseBackupFile();
|
||||||
(collection) => !delayCollections.includes(collection),
|
|
||||||
);
|
const delayGroups = [...requiredGroups, ...selectedOptionalGroups].filter((group) => group.delay);
|
||||||
|
const delayCollections = CollectionGroupManager.getGroupsCollections(delayGroups);
|
||||||
|
|
||||||
// import required plugins collections
|
// 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);
|
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 throughCollections = this.findThroughCollections(userCollections);
|
||||||
|
|
||||||
const customCollections = [
|
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,
|
...userCollections,
|
||||||
...throughCollections,
|
...throughCollections,
|
||||||
];
|
];
|
||||||
@ -196,15 +187,20 @@ export class Restorer extends AppMigrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// import delay groups
|
// import delay groups
|
||||||
|
const appGroups = CollectionGroupManager.getGroups(this.app);
|
||||||
|
|
||||||
for (const collectionGroup of delayGroups) {
|
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');
|
await this.emitAsync('restoreCollectionsFinished');
|
||||||
}
|
}
|
||||||
|
|
||||||
async decompressBackup(backupFilePath: string) {
|
async decompressBackup(backupFilePath: string) {
|
||||||
await decompress(backupFilePath, this.workDir);
|
if (!this.decompressed) await decompress(backupFilePath, this.workDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
async importCollection(options: {
|
async importCollection(options: {
|
||||||
@ -313,10 +309,11 @@ export class Restorer extends AppMigrator {
|
|||||||
this.on('restoreCollectionsFinished', async () => {
|
this.on('restoreCollectionsFinished', async () => {
|
||||||
if (this.app.db.inDialect('postgres')) {
|
if (this.app.db.inDialect('postgres')) {
|
||||||
const sequenceNameResult = await app.db.sequelize.query(
|
const sequenceNameResult = await app.db.sequelize.query(
|
||||||
`SELECT column_default FROM information_schema.columns WHERE
|
`SELECT column_default
|
||||||
table_name='${collection.model.tableName}' and "column_name" = 'id' and table_schema = '${
|
FROM information_schema.columns
|
||||||
app.db.options.schema || 'public'
|
WHERE table_name = '${collection.model.tableName}'
|
||||||
}';`,
|
and "column_name" = 'id'
|
||||||
|
and table_schema = '${app.db.options.schema || 'public'}';`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (sequenceNameResult[0].length) {
|
if (sequenceNameResult[0].length) {
|
||||||
@ -327,7 +324,8 @@ export class Restorer extends AppMigrator {
|
|||||||
const sequenceName = match[1];
|
const sequenceName = match[1];
|
||||||
|
|
||||||
const maxVal = await app.db.sequelize.query(
|
const maxVal = await app.db.sequelize.query(
|
||||||
`SELECT MAX("${primaryKeyAttribute.field}") FROM ${tableName}`,
|
`SELECT MAX("${primaryKeyAttribute.field}")
|
||||||
|
FROM ${tableName}`,
|
||||||
{
|
{
|
||||||
type: 'SELECT',
|
type: 'SELECT',
|
||||||
},
|
},
|
||||||
@ -341,7 +339,9 @@ export class Restorer extends AppMigrator {
|
|||||||
|
|
||||||
if (this.app.db.inDialect('sqlite')) {
|
if (this.app.db.inDialect('sqlite')) {
|
||||||
await app.db.sequelize.query(
|
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 { Plugin } from '@nocobase/server';
|
||||||
import addDumpCommand from './commands/dump';
|
import addDumpCommand from './commands/dump-command';
|
||||||
import addRestoreCommand from './commands/restore';
|
import addRestoreCommand from './commands/restore-command';
|
||||||
|
|
||||||
import zhCN from './locale/zh-CN';
|
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 {
|
export default class Duplicator extends Plugin {
|
||||||
beforeLoad() {
|
beforeLoad() {
|
||||||
@ -15,41 +21,27 @@ export default class Duplicator extends Plugin {
|
|||||||
async load() {
|
async load() {
|
||||||
this.app.resourcer.define({
|
this.app.resourcer.define({
|
||||||
name: 'duplicator',
|
name: 'duplicator',
|
||||||
actions: {
|
middleware: async (ctx, next) => {
|
||||||
getDict: async (ctx, next) => {
|
if (ctx.action.actionName !== 'upload') {
|
||||||
ctx.withoutDataWrapping = true;
|
return next();
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
const storage = multer.diskStorage({
|
||||||
const item = {
|
destination: os.tmpdir(), // 获取临时目录
|
||||||
name,
|
filename: function (req, file, cb) {
|
||||||
title: collection.options.title,
|
const randomName = Date.now().toString() + Math.random().toString().slice(2); // 随机生成文件名
|
||||||
namespace: collection.options.namespace,
|
cb(null, randomName);
|
||||||
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 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';
|
import { CollectionOptions } from '@nocobase/database';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
namespace: 'file-manager',
|
namespace: 'file-manager.attachmentRecords',
|
||||||
duplicator: 'optional',
|
duplicator: 'optional',
|
||||||
name: 'attachments',
|
name: 'attachments',
|
||||||
title: '文件管理器',
|
title: '文件管理器',
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { CollectionOptions } from '@nocobase/database';
|
import { CollectionOptions } from '@nocobase/database';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
namespace: 'file-manager',
|
namespace: 'file-manager.storageSetting',
|
||||||
duplicator: 'optional',
|
duplicator: 'optional',
|
||||||
name: 'storages',
|
name: 'storages',
|
||||||
title: '存储引擎',
|
title: '存储引擎',
|
||||||
|
@ -11,8 +11,18 @@ export default class PluginFileManager extends Plugin {
|
|||||||
|
|
||||||
async install() {
|
async install() {
|
||||||
const defaultStorageConfig = getStorageConfig(this.storageType());
|
const defaultStorageConfig = getStorageConfig(this.storageType());
|
||||||
|
|
||||||
if (defaultStorageConfig) {
|
if (defaultStorageConfig) {
|
||||||
const Storage = this.db.getCollection('storages');
|
const Storage = this.db.getCollection('storages');
|
||||||
|
if (
|
||||||
|
await Storage.repository.findOne({
|
||||||
|
filter: {
|
||||||
|
name: defaultStorageConfig.defaults().name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await Storage.repository.create({
|
await Storage.repository.create({
|
||||||
values: {
|
values: {
|
||||||
...defaultStorageConfig.defaults(),
|
...defaultStorageConfig.defaults(),
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { defineCollection } from '@nocobase/database';
|
import { defineCollection } from '@nocobase/database';
|
||||||
|
|
||||||
export default defineCollection({
|
export default defineCollection({
|
||||||
namespace: 'graph-collection-manager',
|
namespace: 'graph-collection-manager.graphCollectionPositions',
|
||||||
duplicator: 'required',
|
duplicator: 'required',
|
||||||
name: 'graphPositions',
|
name: 'graphPositions',
|
||||||
fields: [
|
fields: [
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { CollectionOptions } from '@nocobase/database';
|
import { CollectionOptions } from '@nocobase/database';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
namespace: 'iframe-block',
|
namespace: 'iframe-block.iframe-html-storage',
|
||||||
duplicator: 'required',
|
duplicator: 'required',
|
||||||
name: 'iframeHtml',
|
name: 'iframeHtml',
|
||||||
createdBy: true,
|
createdBy: true,
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { CollectionOptions } from "@nocobase/client";
|
import { CollectionOptions } from '@nocobase/client';
|
||||||
import { MapConfigurationCollectionName } from "../constants";
|
import { MapConfigurationCollectionName } from '../constants';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
namespace: 'map',
|
namespace: 'map.mapConfiguration',
|
||||||
duplicator: 'optional',
|
duplicator: 'optional',
|
||||||
name: MapConfigurationCollectionName,
|
name: MapConfigurationCollectionName,
|
||||||
title: '{{t("Map Manager")}}',
|
title: '{{t("Map Manager")}}',
|
||||||
@ -11,19 +11,19 @@ export default {
|
|||||||
title: 'Access key',
|
title: 'Access key',
|
||||||
comment: '访问密钥',
|
comment: '访问密钥',
|
||||||
name: 'accessKey',
|
name: 'accessKey',
|
||||||
type: 'string'
|
type: 'string',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'securityJsCode',
|
title: 'securityJsCode',
|
||||||
comment: 'securityJsCode or serviceHOST',
|
comment: 'securityJsCode or serviceHOST',
|
||||||
name: 'securityJsCode',
|
name: 'securityJsCode',
|
||||||
type: 'string'
|
type: 'string',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Map type',
|
title: 'Map type',
|
||||||
comment: '地图类型',
|
comment: '地图类型',
|
||||||
name: 'type',
|
name: 'type',
|
||||||
type: 'string',
|
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