mirror of https://gitee.com/answerdev/answer.git
feat(ui): Add plugin configuration
This commit is contained in:
parent
b8cfde0425
commit
152d8d2f68
|
@ -1379,6 +1379,7 @@ ui:
|
|||
status: Status
|
||||
action: Action
|
||||
deactivate: Deactivate
|
||||
activate: Activate
|
||||
settings: Settings
|
||||
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { UIOptions, UIWidget } from '@/components/SchemaForm';
|
||||
|
||||
export interface FormValue<T = any> {
|
||||
value: T;
|
||||
isInvalid: boolean;
|
||||
|
@ -537,3 +539,24 @@ export interface UserOauthConnectorItem extends OauthConnectorItem {
|
|||
binding: boolean;
|
||||
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[];
|
||||
}
|
||||
|
|
|
@ -32,7 +32,9 @@ function MenuNode({
|
|||
'text-nowrap d-flex flex-nowrap align-items-center w-100',
|
||||
{ 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 ? (
|
||||
<span className="badge text-bg-dark">{menu.badgeContent}</span>
|
||||
) : null}
|
||||
|
|
|
@ -28,18 +28,8 @@ export interface JSONSchema {
|
|||
};
|
||||
};
|
||||
}
|
||||
export interface UISchema {
|
||||
[key: string]: {
|
||||
'ui:widget'?:
|
||||
| 'textarea'
|
||||
| 'text'
|
||||
| 'checkbox'
|
||||
| 'radio'
|
||||
| 'select'
|
||||
| 'upload'
|
||||
| 'timezone'
|
||||
| 'switch';
|
||||
'ui:options'?: {
|
||||
|
||||
export interface UIOptions {
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
type?:
|
||||
|
@ -66,7 +56,20 @@ export interface UISchema {
|
|||
textRender?: () => React.ReactElement;
|
||||
imageType?: Type.UploadType;
|
||||
acceptType?: string;
|
||||
};
|
||||
}
|
||||
export type UIWidget =
|
||||
| 'textarea'
|
||||
| 'text'
|
||||
| 'checkbox'
|
||||
| 'radio'
|
||||
| 'select'
|
||||
| 'upload'
|
||||
| 'timezone'
|
||||
| 'switch';
|
||||
export interface UISchema {
|
||||
[key: string]: {
|
||||
'ui:widget'?: UIWidget;
|
||||
'ui:options'?: UIOptions;
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -309,7 +312,11 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
|
|||
<Form.Select
|
||||
aria-label={description}
|
||||
name={key}
|
||||
value={formData[key]?.value || defaultValue}
|
||||
value={
|
||||
formData[key]?.value !== undefined
|
||||
? formData[key]?.value
|
||||
: defaultValue
|
||||
}
|
||||
onChange={handleSelectChange}
|
||||
isInvalid={formData[key].isInvalid}>
|
||||
<option disabled selected>
|
||||
|
@ -350,7 +357,11 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
|
|||
name={key}
|
||||
id={`form-${String(item)}`}
|
||||
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}
|
||||
feedbackType="invalid"
|
||||
isInvalid={formData[key].isInvalid}
|
||||
|
@ -382,7 +393,11 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
|
|||
name={key}
|
||||
type="switch"
|
||||
label={label}
|
||||
checked={formData[key]?.value || defaultValue}
|
||||
checked={
|
||||
formData[key]?.value !== undefined
|
||||
? formData[key]?.value
|
||||
: defaultValue
|
||||
}
|
||||
feedback={formData[key]?.errorMsg}
|
||||
feedbackType="invalid"
|
||||
isInvalid={formData[key].isInvalid}
|
||||
|
@ -405,7 +420,11 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
|
|||
controlId={key}>
|
||||
<Form.Label>{title}</Form.Label>
|
||||
<TimeZonePicker
|
||||
value={formData[key]?.value || defaultValue}
|
||||
value={
|
||||
formData[key]?.value !== undefined
|
||||
? formData[key]?.value
|
||||
: defaultValue
|
||||
}
|
||||
name={key}
|
||||
onChange={handleSelectChange}
|
||||
/>
|
||||
|
@ -464,7 +483,11 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
|
|||
name={key}
|
||||
placeholder={options?.placeholder || ''}
|
||||
type={options?.type || 'text'}
|
||||
value={formData[key]?.value || defaultValue}
|
||||
value={
|
||||
formData[key]?.value !== undefined
|
||||
? formData[key]?.value
|
||||
: defaultValue
|
||||
}
|
||||
onChange={handleInputChange}
|
||||
isInvalid={formData[key].isInvalid}
|
||||
rows={options?.rows || 3}
|
||||
|
@ -490,7 +513,11 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
|
|||
name={key}
|
||||
placeholder={options?.placeholder || ''}
|
||||
type={options?.type || 'text'}
|
||||
value={formData[key]?.value || defaultValue}
|
||||
value={
|
||||
formData[key]?.value !== undefined
|
||||
? formData[key]?.value
|
||||
: defaultValue
|
||||
}
|
||||
onChange={handleInputChange}
|
||||
style={options?.type === 'color' ? { width: '6rem' } : {}}
|
||||
isInvalid={formData[key].isInvalid}
|
||||
|
|
|
@ -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;
|
|
@ -5,21 +5,18 @@ import { useTranslation } from 'react-i18next';
|
|||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Pagination, Empty, QueryGroup, Icon } from '@/components';
|
||||
import { Empty, QueryGroup, Icon } from '@/components';
|
||||
import * as Type from '@/common/interface';
|
||||
import { useQueryUsers } from '@/services';
|
||||
import { useQueryPlugins, updatePluginStatus } from '@/services';
|
||||
|
||||
const InstalledPluginsFilterKeys: Type.InstalledPluginsFilterBy[] = [
|
||||
'all',
|
||||
'active',
|
||||
'inactive',
|
||||
'outdated',
|
||||
];
|
||||
|
||||
const bgMap = {
|
||||
normal: 'text-bg-success',
|
||||
suspended: 'text-bg-danger',
|
||||
deleted: 'text-bg-danger',
|
||||
active: 'text-bg-success',
|
||||
inactive: 'text-bg-secondary',
|
||||
};
|
||||
|
||||
|
@ -34,7 +31,7 @@ const Users: FC = () => {
|
|||
urlSearchParams.get('filter') || InstalledPluginsFilterKeys[0];
|
||||
const curPage = Number(urlSearchParams.get('page') || '1');
|
||||
const curQuery = urlSearchParams.get('query') || '';
|
||||
const { data, isLoading } = useQueryUsers({
|
||||
const { data, isLoading, mutate } = useQueryPlugins({
|
||||
page: curPage,
|
||||
page_size: PAGE_SIZE,
|
||||
query: curQuery,
|
||||
|
@ -46,6 +43,22 @@ const Users: FC = () => {
|
|||
});
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
|
@ -76,19 +89,23 @@ const Users: FC = () => {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody className="align-middle">
|
||||
{data?.list.map((plugin) => {
|
||||
{data?.map((plugin) => {
|
||||
return (
|
||||
<tr key={plugin.user_id}>
|
||||
<tr key={plugin.slug_name}>
|
||||
<td>
|
||||
<div>Twitter Logins</div>
|
||||
<div>{plugin.name}</div>
|
||||
<div className="text-muted text-small">
|
||||
Enable login with Twitter
|
||||
{plugin.description}
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-break">{plugin.version}</td>
|
||||
<td>
|
||||
<span className={classNames('badge', bgMap[plugin.status])}>
|
||||
{t(`filter.${plugin.status}`)}
|
||||
<span
|
||||
className={classNames(
|
||||
'badge',
|
||||
bgMap[plugin.enabled ? 'active' : 'inactive'],
|
||||
)}>
|
||||
{t(`filter.${plugin.enabled ? 'active' : 'inactive'}`)}
|
||||
</span>
|
||||
</td>
|
||||
{curFilter !== 'deleted' ? (
|
||||
|
@ -98,10 +115,18 @@ const Users: FC = () => {
|
|||
<Icon name="three-dots-vertical" />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
{plugin.enabled ? (
|
||||
<Dropdown.Item
|
||||
onClick={() => handleAction('deactivate', plugin)}>
|
||||
{t('deactivate')}
|
||||
</Dropdown.Item>
|
||||
) : (
|
||||
<Dropdown.Item
|
||||
onClick={() => handleAction('activate', plugin)}>
|
||||
{t('activate')}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
|
||||
<Dropdown.Item
|
||||
onClick={() => handleAction('settings', plugin)}>
|
||||
{t('settings')}
|
||||
|
@ -115,14 +140,7 @@ const Users: FC = () => {
|
|||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
{Number(data?.count) <= 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>
|
||||
{Number(data?.length) <= 0 && !isLoading && <Empty />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -3,9 +3,12 @@ import { Container, Row, Col } from 'react-bootstrap';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
import { usePageTags } from '@/hooks';
|
||||
import { AccordionNav } from '@/components';
|
||||
import { ADMIN_NAV_MENUS } from '@/common/constants';
|
||||
import { useQueryPlugins } from '@/services';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
|
@ -24,9 +27,29 @@ const formPaths = [
|
|||
const Index: FC = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'page_title' });
|
||||
const { pathname } = useLocation();
|
||||
const { data: plugins } = useQueryPlugins({
|
||||
query: 'active',
|
||||
});
|
||||
usePageTags({
|
||||
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 (
|
||||
<>
|
||||
<div className="bg-light py-2">
|
||||
|
@ -39,7 +62,7 @@ const Index: FC = () => {
|
|||
<Container className="admin-container">
|
||||
<Row>
|
||||
<Col lg={2}>
|
||||
<AccordionNav menus={ADMIN_NAV_MENUS} path="/admin/" />
|
||||
<AccordionNav menus={menus} path="/admin/" />
|
||||
</Col>
|
||||
<Col lg={formPaths.find((v) => pathname.includes(v)) ? 6 : 10}>
|
||||
<Outlet />
|
||||
|
|
|
@ -315,6 +315,10 @@ const routes: RouteNode[] = [
|
|||
path: 'installed_plugins',
|
||||
page: 'pages/Admin/Plugins/Installed',
|
||||
},
|
||||
{
|
||||
path: ':slug_name',
|
||||
page: 'pages/Admin/Plugins/Config',
|
||||
},
|
||||
],
|
||||
},
|
||||
// for review
|
||||
|
|
|
@ -4,3 +4,4 @@ export * from './question';
|
|||
export * from './settings';
|
||||
export * from './users';
|
||||
export * from './dashboard';
|
||||
export * from './plugins';
|
||||
|
|
|
@ -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);
|
||||
};
|
Loading…
Reference in New Issue