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
action: Action
deactivate: Deactivate
activate: Activate
settings: Settings

View File

@ -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[];
}

View File

@ -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}

View File

@ -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}

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 { 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 />}
</>
);
};

View File

@ -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 />

View File

@ -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

View File

@ -4,3 +4,4 @@ export * from './question';
export * from './settings';
export * from './users';
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);
};