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