feat(ui): Add plugin configuration

This commit is contained in:
robin 2023-01-13 16:13:47 +08:00
parent b8cfde0425
commit 152d8d2f68
10 changed files with 320 additions and 70 deletions

View File

@ -1379,6 +1379,7 @@ ui:
status: Status status: Status
action: Action action: Action
deactivate: Deactivate deactivate: Deactivate
activate: Activate
settings: Settings settings: Settings

View File

@ -1,3 +1,5 @@
import { UIOptions, UIWidget } from '@/components/SchemaForm';
export interface FormValue<T = any> { export interface FormValue<T = any> {
value: T; value: T;
isInvalid: boolean; isInvalid: boolean;
@ -537,3 +539,24 @@ export interface UserOauthConnectorItem extends OauthConnectorItem {
binding: boolean; binding: boolean;
external_id: string; external_id: string;
} }
export interface PluginOption {
label: string;
value: string;
}
export interface PluginItem {
name: string;
type: UIWidget;
title: string;
description: string;
uiOptions?: UIOptions;
option?: PluginOption[];
value?: string;
required?: boolean;
}
export interface PluginConfig {
name: string;
slug_name: string;
config_fields: PluginItem[];
}

View File

@ -32,7 +32,9 @@ function MenuNode({
'text-nowrap d-flex flex-nowrap align-items-center w-100', 'text-nowrap d-flex flex-nowrap align-items-center w-100',
{ expanding, 'link-dark': activeKey !== menu.name }, { expanding, 'link-dark': activeKey !== menu.name },
)}> )}>
<span className="me-auto">{t(menu.name)}</span> <span className="me-auto">
{menu.displayName ? menu.displayName : t(menu.name)}
</span>
{menu.badgeContent ? ( {menu.badgeContent ? (
<span className="badge text-bg-dark">{menu.badgeContent}</span> <span className="badge text-bg-dark">{menu.badgeContent}</span>
) : null} ) : null}

View File

@ -28,45 +28,48 @@ export interface JSONSchema {
}; };
}; };
} }
export interface UIOptions {
rows?: number;
placeholder?: string;
type?:
| 'color'
| 'date'
| 'datetime-local'
| 'email'
| 'month'
| 'number'
| 'password'
| 'range'
| 'search'
| 'tel'
| 'text'
| 'time'
| 'url'
| 'week';
empty?: string;
className?: string | string[];
validator?: (
value,
formData?,
) => Promise<string | true | void> | true | string;
textRender?: () => React.ReactElement;
imageType?: Type.UploadType;
acceptType?: string;
}
export type UIWidget =
| 'textarea'
| 'text'
| 'checkbox'
| 'radio'
| 'select'
| 'upload'
| 'timezone'
| 'switch';
export interface UISchema { export interface UISchema {
[key: string]: { [key: string]: {
'ui:widget'?: 'ui:widget'?: UIWidget;
| 'textarea' 'ui:options'?: UIOptions;
| 'text'
| 'checkbox'
| 'radio'
| 'select'
| 'upload'
| 'timezone'
| 'switch';
'ui:options'?: {
rows?: number;
placeholder?: string;
type?:
| 'color'
| 'date'
| 'datetime-local'
| 'email'
| 'month'
| 'number'
| 'password'
| 'range'
| 'search'
| 'tel'
| 'text'
| 'time'
| 'url'
| 'week';
empty?: string;
className?: string | string[];
validator?: (
value,
formData?,
) => Promise<string | true | void> | true | string;
textRender?: () => React.ReactElement;
imageType?: Type.UploadType;
acceptType?: string;
};
}; };
} }
@ -309,7 +312,11 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
<Form.Select <Form.Select
aria-label={description} aria-label={description}
name={key} name={key}
value={formData[key]?.value || defaultValue} value={
formData[key]?.value !== undefined
? formData[key]?.value
: defaultValue
}
onChange={handleSelectChange} onChange={handleSelectChange}
isInvalid={formData[key].isInvalid}> isInvalid={formData[key].isInvalid}>
<option disabled selected> <option disabled selected>
@ -350,7 +357,11 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
name={key} name={key}
id={`form-${String(item)}`} id={`form-${String(item)}`}
label={properties[key].enumNames?.[index]} label={properties[key].enumNames?.[index]}
checked={(formData[key]?.value || defaultValue) === item} checked={
(formData[key]?.value !== undefined
? formData[key]?.value
: defaultValue) === item
}
feedback={formData[key]?.errorMsg} feedback={formData[key]?.errorMsg}
feedbackType="invalid" feedbackType="invalid"
isInvalid={formData[key].isInvalid} isInvalid={formData[key].isInvalid}
@ -382,7 +393,11 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
name={key} name={key}
type="switch" type="switch"
label={label} label={label}
checked={formData[key]?.value || defaultValue} checked={
formData[key]?.value !== undefined
? formData[key]?.value
: defaultValue
}
feedback={formData[key]?.errorMsg} feedback={formData[key]?.errorMsg}
feedbackType="invalid" feedbackType="invalid"
isInvalid={formData[key].isInvalid} isInvalid={formData[key].isInvalid}
@ -405,7 +420,11 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
controlId={key}> controlId={key}>
<Form.Label>{title}</Form.Label> <Form.Label>{title}</Form.Label>
<TimeZonePicker <TimeZonePicker
value={formData[key]?.value || defaultValue} value={
formData[key]?.value !== undefined
? formData[key]?.value
: defaultValue
}
name={key} name={key}
onChange={handleSelectChange} onChange={handleSelectChange}
/> />
@ -464,7 +483,11 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
name={key} name={key}
placeholder={options?.placeholder || ''} placeholder={options?.placeholder || ''}
type={options?.type || 'text'} type={options?.type || 'text'}
value={formData[key]?.value || defaultValue} value={
formData[key]?.value !== undefined
? formData[key]?.value
: defaultValue
}
onChange={handleInputChange} onChange={handleInputChange}
isInvalid={formData[key].isInvalid} isInvalid={formData[key].isInvalid}
rows={options?.rows || 3} rows={options?.rows || 3}
@ -490,7 +513,11 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
name={key} name={key}
placeholder={options?.placeholder || ''} placeholder={options?.placeholder || ''}
type={options?.type || 'text'} type={options?.type || 'text'}
value={formData[key]?.value || defaultValue} value={
formData[key]?.value !== undefined
? formData[key]?.value
: defaultValue
}
onChange={handleInputChange} onChange={handleInputChange}
style={options?.type === 'color' ? { width: '6rem' } : {}} style={options?.type === 'color' ? { width: '6rem' } : {}}
isInvalid={formData[key].isInvalid} isInvalid={formData[key].isInvalid}

View File

@ -0,0 +1,110 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { isEmpty } from 'lodash';
import { useToast } from '@/hooks';
import type * as Types from '@/common/interface';
import { SchemaForm, JSONSchema, initFormData, UISchema } from '@/components';
import { useQueryPluginConfig, updatePluginConfig } from '@/services';
const Config = () => {
const { slug_name } = useParams<{ slug_name: string }>();
const { data } = useQueryPluginConfig({ plugin_slug_name: slug_name });
const Toast = useToast();
const { t } = useTranslation('translation');
const [schema, setSchema] = useState<JSONSchema | null>(null);
const uiSchema: UISchema = {};
const required: string[] = [];
const [formData, setFormData] = useState<Types.FormDataType | null>(null);
useEffect(() => {
if (!data) {
return;
}
const properties: JSONSchema['properties'] = {};
data.config_fields?.forEach((item) => {
properties[item.name] = {
type: 'string',
title: item.title,
description: item.description,
default: item.value,
};
if (item.uiOptions) {
uiSchema[item.name] = {
'ui:options': item.uiOptions,
};
}
if (item.required) {
required.push(item.name);
}
});
setSchema({
title: data?.name || '',
required,
properties,
});
}, [data?.config_fields]);
useEffect(() => {
if (!schema || formData) {
return;
}
setFormData(initFormData(schema));
}, [schema, formData]);
const onSubmit = (evt) => {
if (!formData) {
return;
}
evt.preventDefault();
evt.stopPropagation();
const config_fields = {};
Object.keys(formData).forEach((key) => {
config_fields[key] = formData[key].value;
});
const params = {
plugin_slug_name: slug_name,
config_fields,
};
updatePluginConfig(params).then((res) => {
console.log(res);
Toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',
});
});
};
const handleOnChange = (form) => {
console.log(form);
setFormData(form);
};
if (!data || !schema || !formData) {
return null;
}
if (isEmpty(schema.properties)) {
return <h3 className="mb-4">{data?.name}</h3>;
}
return (
<>
<h3 className="mb-4">{data?.name}</h3>
<SchemaForm
schema={schema}
uiSchema={uiSchema}
formData={formData}
onSubmit={onSubmit}
onChange={handleOnChange}
/>
</>
);
};
export default Config;

View File

@ -5,21 +5,18 @@ import { useTranslation } from 'react-i18next';
import classNames from 'classnames'; import classNames from 'classnames';
import { Pagination, Empty, QueryGroup, Icon } from '@/components'; import { Empty, QueryGroup, Icon } from '@/components';
import * as Type from '@/common/interface'; import * as Type from '@/common/interface';
import { useQueryUsers } from '@/services'; import { useQueryPlugins, updatePluginStatus } from '@/services';
const InstalledPluginsFilterKeys: Type.InstalledPluginsFilterBy[] = [ const InstalledPluginsFilterKeys: Type.InstalledPluginsFilterBy[] = [
'all', 'all',
'active', 'active',
'inactive', 'inactive',
'outdated',
]; ];
const bgMap = { const bgMap = {
normal: 'text-bg-success', active: 'text-bg-success',
suspended: 'text-bg-danger',
deleted: 'text-bg-danger',
inactive: 'text-bg-secondary', inactive: 'text-bg-secondary',
}; };
@ -34,7 +31,7 @@ const Users: FC = () => {
urlSearchParams.get('filter') || InstalledPluginsFilterKeys[0]; urlSearchParams.get('filter') || InstalledPluginsFilterKeys[0];
const curPage = Number(urlSearchParams.get('page') || '1'); const curPage = Number(urlSearchParams.get('page') || '1');
const curQuery = urlSearchParams.get('query') || ''; const curQuery = urlSearchParams.get('query') || '';
const { data, isLoading } = useQueryUsers({ const { data, isLoading, mutate } = useQueryPlugins({
page: curPage, page: curPage,
page_size: PAGE_SIZE, page_size: PAGE_SIZE,
query: curQuery, query: curQuery,
@ -46,6 +43,22 @@ const Users: FC = () => {
}); });
const handleAction = (type, plugin) => { const handleAction = (type, plugin) => {
if (type === 'deactivate') {
updatePluginStatus({
enabled: false,
plugin_slug_name: plugin.slug_name,
}).then(() => {
mutate();
});
}
if (type === 'activate') {
updatePluginStatus({
enabled: true,
plugin_slug_name: plugin.slug_name,
}).then(() => {
mutate();
});
}
console.log(type, plugin); console.log(type, plugin);
}; };
@ -76,19 +89,23 @@ const Users: FC = () => {
</tr> </tr>
</thead> </thead>
<tbody className="align-middle"> <tbody className="align-middle">
{data?.list.map((plugin) => { {data?.map((plugin) => {
return ( return (
<tr key={plugin.user_id}> <tr key={plugin.slug_name}>
<td> <td>
<div>Twitter Logins</div> <div>{plugin.name}</div>
<div className="text-muted text-small"> <div className="text-muted text-small">
Enable login with Twitter {plugin.description}
</div> </div>
</td> </td>
<td className="text-break">{plugin.version}</td> <td className="text-break">{plugin.version}</td>
<td> <td>
<span className={classNames('badge', bgMap[plugin.status])}> <span
{t(`filter.${plugin.status}`)} className={classNames(
'badge',
bgMap[plugin.enabled ? 'active' : 'inactive'],
)}>
{t(`filter.${plugin.enabled ? 'active' : 'inactive'}`)}
</span> </span>
</td> </td>
{curFilter !== 'deleted' ? ( {curFilter !== 'deleted' ? (
@ -98,10 +115,18 @@ const Users: FC = () => {
<Icon name="three-dots-vertical" /> <Icon name="three-dots-vertical" />
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu> <Dropdown.Menu>
<Dropdown.Item {plugin.enabled ? (
onClick={() => handleAction('deactivate', plugin)}> <Dropdown.Item
{t('deactivate')} onClick={() => handleAction('deactivate', plugin)}>
</Dropdown.Item> {t('deactivate')}
</Dropdown.Item>
) : (
<Dropdown.Item
onClick={() => handleAction('activate', plugin)}>
{t('activate')}
</Dropdown.Item>
)}
<Dropdown.Item <Dropdown.Item
onClick={() => handleAction('settings', plugin)}> onClick={() => handleAction('settings', plugin)}>
{t('settings')} {t('settings')}
@ -115,14 +140,7 @@ const Users: FC = () => {
})} })}
</tbody> </tbody>
</Table> </Table>
{Number(data?.count) <= 0 && !isLoading && <Empty />} {Number(data?.length) <= 0 && !isLoading && <Empty />}
<div className="mt-4 mb-2 d-flex justify-content-center">
<Pagination
currentPage={curPage}
totalSize={data?.count || 0}
pageSize={PAGE_SIZE}
/>
</div>
</> </>
); );
}; };

View File

@ -3,9 +3,12 @@ import { Container, Row, Col } from 'react-bootstrap';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Outlet, useLocation } from 'react-router-dom'; import { Outlet, useLocation } from 'react-router-dom';
import { cloneDeep } from 'lodash';
import { usePageTags } from '@/hooks'; import { usePageTags } from '@/hooks';
import { AccordionNav } from '@/components'; import { AccordionNav } from '@/components';
import { ADMIN_NAV_MENUS } from '@/common/constants'; import { ADMIN_NAV_MENUS } from '@/common/constants';
import { useQueryPlugins } from '@/services';
import './index.scss'; import './index.scss';
@ -24,9 +27,29 @@ const formPaths = [
const Index: FC = () => { const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'page_title' }); const { t } = useTranslation('translation', { keyPrefix: 'page_title' });
const { pathname } = useLocation(); const { pathname } = useLocation();
const { data: plugins } = useQueryPlugins({
query: 'active',
});
usePageTags({ usePageTags({
title: t('admin'), title: t('admin'),
}); });
const inactivePlugins = plugins?.filter((v) => v.enabled) || [];
const menus = cloneDeep(ADMIN_NAV_MENUS);
if (inactivePlugins?.length > 0) {
menus.forEach((item) => {
if (item.name === 'plugins' && item.children) {
item.children = [
...item.children,
...inactivePlugins.map((plugin) => ({
name: plugin.slug_name,
displayName: plugin.name,
})),
];
}
});
}
return ( return (
<> <>
<div className="bg-light py-2"> <div className="bg-light py-2">
@ -39,7 +62,7 @@ const Index: FC = () => {
<Container className="admin-container"> <Container className="admin-container">
<Row> <Row>
<Col lg={2}> <Col lg={2}>
<AccordionNav menus={ADMIN_NAV_MENUS} path="/admin/" /> <AccordionNav menus={menus} path="/admin/" />
</Col> </Col>
<Col lg={formPaths.find((v) => pathname.includes(v)) ? 6 : 10}> <Col lg={formPaths.find((v) => pathname.includes(v)) ? 6 : 10}>
<Outlet /> <Outlet />

View File

@ -315,6 +315,10 @@ const routes: RouteNode[] = [
path: 'installed_plugins', path: 'installed_plugins',
page: 'pages/Admin/Plugins/Installed', page: 'pages/Admin/Plugins/Installed',
}, },
{
path: ':slug_name',
page: 'pages/Admin/Plugins/Config',
},
], ],
}, },
// for review // for review

View File

@ -4,3 +4,4 @@ export * from './question';
export * from './settings'; export * from './settings';
export * from './users'; export * from './users';
export * from './dashboard'; export * from './dashboard';
export * from './plugins';

View File

@ -0,0 +1,41 @@
import qs from 'qs';
import useSWR from 'swr';
import type * as Types from '@/common/interface';
import request from '@/utils/request';
export const useQueryPlugins = (params) => {
const apiUrl = `/answer/admin/api/plugins?${qs.stringify(params)}`;
const { data, error, mutate } = useSWR<any[], Error>(
apiUrl,
request.instance.get,
);
return {
data,
isLoading: !data && !error,
error,
mutate,
};
};
export const updatePluginStatus = (params) => {
return request.put('/answer/admin/api/plugin/status', params);
};
export const useQueryPluginConfig = (params) => {
const apiUrl = `/answer/admin/api/plugin/config?${qs.stringify(params)}`;
const { data, error, mutate } = useSWR<Types.PluginConfig, Error>(
apiUrl,
request.instance.get,
);
return {
data,
isLoading: !data && !error,
error,
mutate,
};
};
export const updatePluginConfig = (params) => {
return request.put('/answer/admin/api/plugin/config', params);
};