Dunqing 1fdc456c0b
feat: improve plugin manager ui (#1650)
* feat: improve plugin view

* feat: work compatibility

* feat: avoid cause error

* feat: complete

* docs: revert

* fix: header cannot displayed

* feat: improve

* feat: update page css

* feat: update fixedblock design

* chore: upgrade antd

* fix: improve code

* fix: build error

* fix: build error

* fix: pagination cannot be fully displayed

* feat: improve

* fix: ts error

* chore: sqlite view field test

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
Co-authored-by: chareice <chareice@live.com>
2023-04-12 12:24:09 +08:00

578 lines
16 KiB
TypeScript

import { css } from '@emotion/css';
import {
Layout,
Menu,
message,
Modal,
PageHeader,
Popconfirm,
Result,
Space,
Spin,
Table,
TableProps,
Tabs,
TabsProps,
Tag,
Typography,
} from 'antd';
import { sortBy } from 'lodash';
import React, { createContext, useContext, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Redirect, useHistory, useRouteMatch } from 'react-router-dom';
import { ACLPane } from '../acl';
import { useACLRoleContext } from '../acl/ACLProvider';
import { useAPIClient, useRequest } from '../api-client';
import { CollectionManagerPane } from '../collection-manager';
import { useDocumentTitle } from '../document-title';
import { Icon } from '../icon';
import { RouteSwitchContext } from '../route-switch';
import { useCompile, useTableSize } from '../schema-component';
import { useParseMarkdown } from '../schema-component/antd/markdown/util';
import { BlockTemplatesPane } from '../schema-templates';
import { SystemSettingsPane } from '../system-settings';
const { Link } = Typography;
export const SettingsCenterContext = createContext<any>({});
interface PluginTableProps {
filter: any;
builtIn?: boolean;
}
interface PluginDocumentProps {
path: string;
name: string;
}
const PluginDocument: React.FC<PluginDocumentProps> = (props) => {
const { data, loading, error } = useRequest(
{
url: '/plugins:getTabInfo',
params: {
filterByTk: props.name,
path: props.path,
},
},
{
refreshDeps: [props.name, props.path],
},
);
const { html, loading: parseLoading } = useParseMarkdown(data?.data?.content);
return (
<div
className={css`
background: #ffffff;
padding: var(--nb-spacing);
height: 60vh;
overflow-y: auto;
`}
>
{loading || parseLoading ? (
<Spin />
) : (
<div className="nb-markdown" dangerouslySetInnerHTML={{ __html: error ? '' : html }}></div>
)}
</div>
);
};
const PluginTable: React.FC<PluginTableProps> = (props) => {
const { builtIn } = props;
const history = useHistory<any>();
const api = useAPIClient();
const [plugin, setPlugin] = useState<any>(null);
const { t, i18n } = useTranslation();
const settingItems = useContext(SettingsCenterContext);
const { data, loading } = useRequest({
url: 'applicationPlugins:list',
params: {
filter: props.filter,
sort: 'name',
paginate: false,
},
});
const { data: tabsData, run } = useRequest(
{
url: '/plugins:getTabs',
},
{
manual: true,
},
);
const columns = useMemo<TableProps<any>['columns']>(() => {
return [
{
title: t('Plugin name'),
dataIndex: 'displayName',
width: 300,
render: (displayName, record) => displayName || record.name,
},
{
title: t('Description'),
dataIndex: 'description',
ellipsis: true,
},
{
title: t('Version'),
dataIndex: 'version',
width: 300,
},
// {
// title: t('Author'),
// dataIndex: 'author',
// width: 200,
// },
{
title: t('Actions'),
width: 300,
render(data) {
return (
<Space>
<Link
onClick={() => {
setPlugin(data);
run({
params: {
filterByTk: data.name,
},
});
}}
>
{t('View')}
</Link>
{data.enabled && settingItems[data.name] ? (
<Link
onClick={() => {
history.push(`/admin/settings/${data.name}`);
}}
>
{t('Setting')}
</Link>
) : null}
{!builtIn ? (
<>
<Link
onClick={async () => {
const checked = !data.enabled;
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}`,
});
window.location.reload();
// message.success(checked ? t('插件激活成功') : t('插件禁用成功'));
}}
>
{t(data.enabled ? 'Disable' : 'Enable')}
</Link>
<Popconfirm
title={t('Are you sure to delete this plugin?')}
onConfirm={async () => {
await api.request({
url: `pm:remove/${data.name}`,
});
message.success(t('插件删除成功'));
window.location.reload();
}}
onCancel={() => {}}
okText={t('Yes')}
cancelText={t('No')}
>
<Link>{t('Delete')}</Link>
</Popconfirm>
</>
) : null}
</Space>
);
},
},
];
}, [t, builtIn]);
const items = useMemo<TabsProps['items']>(() => {
return tabsData?.data?.tabs.map((item) => {
return {
label: item.title,
key: item.path,
children: React.createElement(PluginDocument, {
name: tabsData?.data.filterByTk,
path: item.path,
}),
};
});
}, [tabsData?.data]);
const { height, tableSizeRefCallback } = useTableSize();
return (
<div
className={css`
width: 100%;
height: 100%;
background: #fff;
padding: var(--nb-spacing);
`}
>
<Modal
footer={false}
className={css`
.ant-modal-header {
background: #f0f2f5;
padding-bottom: 8px;
}
.ant-modal-body {
padding-top: 0;
}
.ant-modal-body {
background: #f0f2f5;
.plugin-desc {
padding-bottom: 8px;
}
}
`}
width="70%"
title={
<Typography.Title level={2} style={{ margin: 0 }}>
{plugin?.displayName || plugin?.name}
<Tag
className={css`
vertical-align: middle;
margin-top: -3px;
margin-left: 8px;
`}
>
v{plugin?.version}
</Tag>
</Typography.Title>
}
open={!!plugin}
onCancel={() => setPlugin(null)}
>
{plugin?.description && <div className={'plugin-desc'}>{plugin?.description}</div>}
<Tabs items={items}></Tabs>
</Modal>
<Table
ref={tableSizeRefCallback}
pagination={false}
className={css`
.ant-spin-nested-loading {
height: 100%;
.ant-spin-container {
height: 100%;
display: flex;
flex-direction: column;
.ant-table {
flex: 1;
}
}
}
height: 100%;
`}
scroll={{
y: height,
}}
dataSource={data?.data || []}
loading={loading}
columns={columns}
/>
</div>
);
};
const LocalPlugins = () => {
return (
<PluginTable
filter={{
'builtIn.$isFalsy': true,
}}
></PluginTable>
);
};
const BuiltinPlugins = () => {
return (
<PluginTable
builtIn
filter={{
'builtIn.$isTruly': true,
}}
></PluginTable>
);
};
const MarketplacePlugins = () => {
const { t } = useTranslation();
return <div style={{ fontSize: 18 }}>{t('Coming soon...')}</div>;
};
const PluginList = (props) => {
const match = useRouteMatch<any>();
const history = useHistory<any>();
const { tabName = 'local' } = match.params || {};
const { setTitle } = useDocumentTitle();
const { t } = useTranslation();
const { snippets = [] } = useACLRoleContext();
return snippets.includes('pm') ? (
<div
className={css`
flex: 1;
flex-direction: column;
overflow: hidden;
display: flex;
`}
>
<PageHeader
ghost={false}
title={t('Plugin manager')}
footer={
<Tabs
activeKey={tabName}
onChange={(activeKey) => {
history.push(`/admin/pm/list/${activeKey}`);
}}
>
<Tabs.TabPane tab={t('Local')} key={'local'} />
<Tabs.TabPane tab={t('Built-in')} key={'built-in'} />
<Tabs.TabPane tab={t('Marketplace')} key={'marketplace'} />
</Tabs>
}
/>
<div style={{ margin: 'var(--nb-spacing)', flex: 1, display: 'flex', flexFlow: 'row wrap' }}>
{React.createElement(
{
local: LocalPlugins,
'built-in': BuiltinPlugins,
marketplace: MarketplacePlugins,
}[tabName],
)}
</div>
</div>
) : (
<Result status="404" title="404" subTitle="Sorry, the page you visited does not exist." />
);
};
const settings = {
acl: {
title: '{{t("ACL")}}',
icon: 'LockOutlined',
tabs: {
roles: {
isBookmark: true,
title: '{{t("Roles & Permissions")}}',
component: ACLPane,
},
},
},
'ui-schema-storage': {
title: '{{t("Block templates")}}',
icon: 'LayoutOutlined',
tabs: {
'block-templates': {
title: '{{t("Block templates")}}',
component: BlockTemplatesPane,
},
},
},
'collection-manager': {
icon: 'DatabaseOutlined',
title: '{{t("Collection manager")}}',
tabs: {
collections: {
isBookmark: true,
title: '{{t("Collections & Fields")}}',
component: CollectionManagerPane,
},
},
},
'system-settings': {
icon: 'SettingOutlined',
title: '{{t("System settings")}}',
tabs: {
'system-settings': {
isBookmark: true,
title: '{{t("System settings")}}',
component: SystemSettingsPane,
},
},
},
};
export const getPluginsTabs = (items, snippets) => {
const pluginsTabs = Object.keys(items).map((plugin) => {
const tabsObj = items[plugin].tabs;
const tabs = sortBy(
Object.keys(tabsObj).map((tab) => {
return {
key: tab,
...tabsObj[tab],
isAllow: snippets.includes('pm.*') && !snippets?.includes(`!pm.${plugin}.${tab}`),
};
}),
(o) => !o.isAllow,
);
return {
...items[plugin],
key: plugin,
tabs,
isAllow: !tabs.every((v) => !v.isAllow),
};
});
return sortBy(pluginsTabs, (o) => !o.isAllow);
};
const SettingsCenter = (props) => {
const { snippets = [] } = useACLRoleContext();
const match = useRouteMatch<any>();
const history = useHistory<any>();
const items = useContext(SettingsCenterContext);
const pluginsTabs = getPluginsTabs(items, snippets);
const compile = useCompile();
const firstUri = useMemo(() => {
const pluginName = pluginsTabs[0].key;
const tabName = pluginsTabs[0].tabs[0].key;
return `/admin/settings/${pluginName}/${tabName}`;
}, [pluginsTabs]);
const { pluginName, tabName } = match.params || {};
const activePlugin = pluginsTabs.find((v) => v.key === pluginName);
const aclPluginTabCheck = activePlugin?.isAllow && activePlugin.tabs.find((v) => v.key === tabName)?.isAllow;
if (!pluginName) {
return <Redirect to={firstUri} />;
}
if (!items[pluginName]) {
return <Redirect to={firstUri} />;
}
if (!tabName) {
const firstTabName = Object.keys(items[pluginName]?.tabs).shift();
return <Redirect to={`/admin/settings/${pluginName}/${firstTabName}`} />;
}
const component = items[pluginName]?.tabs?.[tabName]?.component;
const plugin: any = pluginsTabs.find((v) => v.key === pluginName);
const menuItems: any = pluginsTabs
.filter((plugin) => plugin.isAllow)
.map((plugin) => {
return {
label: compile(plugin.title),
key: plugin.key,
icon: plugin.icon ? <Icon type={plugin.icon} /> : null,
};
});
return (
<div>
<Layout>
<div
style={
{
'--side-menu-width': '200px',
} as Record<string, string>
}
className={css`
width: var(--side-menu-width);
overflow: hidden;
flex: 0 0 var(--side-menu-width);
max-width: var(--side-menu-width);
min-width: var(--side-menu-width);
pointer-events: none;
`}
></div>
<Layout.Sider
className={css`
height: 100%;
position: fixed;
padding-top: 46px;
left: 0;
top: 0;
background: rgba(0, 0, 0, 0);
z-index: 100;
`}
theme={'light'}
>
<Menu
selectedKeys={[pluginName]}
style={{ height: 'calc(100vh - 46px)', overflowY: 'auto', overflowX: 'hidden' }}
onClick={(e) => {
const item = items[e.key];
const tabKey = Object.keys(item.tabs).shift();
history.push(`/admin/settings/${e.key}/${tabKey}`);
}}
items={menuItems as any}
/>
</Layout.Sider>
<Layout.Content>
{aclPluginTabCheck && (
<PageHeader
ghost={false}
title={compile(items[pluginName]?.title)}
footer={
<Tabs
activeKey={tabName}
onChange={(activeKey) => {
history.push(`/admin/settings/${pluginName}/${activeKey}`);
}}
>
{plugin.tabs?.map((tab) => {
return tab.isAllow && <Tabs.TabPane tab={compile(tab?.title)} key={tab.key} />;
})}
</Tabs>
}
/>
)}
<div style={{ margin: 'var(--nb-spacing)' }}>
{aclPluginTabCheck ? (
component && React.createElement(component)
) : (
<Result status="404" title="404" subTitle="Sorry, the page you visited does not exist." />
)}
</div>
</Layout.Content>
</Layout>
</div>
);
};
export const SettingsCenterProvider = (props) => {
const { settings = {} } = props;
const items = useContext(SettingsCenterContext);
return (
<SettingsCenterContext.Provider value={{ ...items, ...settings }}>{props.children}</SettingsCenterContext.Provider>
);
};
export const PMProvider = (props) => {
const { routes, ...others } = useContext(RouteSwitchContext);
routes[1].routes.unshift(
{
type: 'route',
path: '/admin/pm/list/:tabName?',
component: PluginList,
},
{
type: 'route',
path: '/admin/settings/:pluginName?/:tabName?',
component: SettingsCenter,
uiSchemaUid: routes[1].uiSchemaUid,
},
);
return (
<SettingsCenterProvider settings={settings}>
<RouteSwitchContext.Provider value={{ ...others, routes }}>{props.children}</RouteSwitchContext.Provider>
</SettingsCenterProvider>
);
};
export default PMProvider;
export * from './PluginManagerLink';