blob: 983765ab56d2e5b14645fa718792e0fccd142f5b [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect, useRef, useState } from 'react';
import {
Button,
notification,
PageHeader,
Switch,
Form,
Select,
Divider,
Drawer,
Alert,
Space,
Popconfirm,
Tooltip,
Input,
} from 'antd';
import { useIntl } from 'umi';
import CodeMirror from '@uiw/react-codemirror';
import { js_beautify } from 'js-beautify';
import { LinkOutlined } from '@ant-design/icons';
import Ajv from 'ajv';
import type { DefinedError } from 'ajv';
import addFormats from 'ajv-formats';
import { fetchSchema } from './service';
import { json2yaml, yaml2json } from '../../helpers';
import { PluginForm, PLUGIN_UI_LIST } from './UI';
import { PluginType } from './data';
type Props = {
name: string;
type?: 'global' | 'scoped';
schemaType: PluginComponent.Schema;
initialData: Record<string, any>;
pluginList: PluginComponent.Meta[];
readonly?: boolean;
visible: boolean;
maskClosable?: boolean;
onClose?: () => void;
onChange?: (data: any) => void;
};
const ajv = new Ajv();
addFormats(ajv);
const FORM_ITEM_LAYOUT = {
labelCol: {
span: 3,
},
wrapperCol: {
span: 16,
},
};
// NOTE: This function has side effect because it mutates the original schema data
const injectDisableProperty = (schema: Record<string, any>) => {
// NOTE: The frontend will inject the disable property into schema just like the manager-api does
if (!schema.properties) {
// eslint-disable-next-line
schema.properties = {};
}
// eslint-disable-next-line
(schema.properties as any).disable = {
type: 'boolean',
};
return schema;
};
const PluginDetail: React.FC<Props> = ({
name,
type = 'scoped',
schemaType = 'route',
visible,
pluginList = [],
readonly = false,
maskClosable = true,
initialData = {},
onClose = () => { },
onChange = () => { },
}) => {
const { formatMessage } = useIntl();
enum codeMirrorModeList {
JSON = 'JSON',
YAML = 'YAML',
UIForm = 'Form'
}
const [form] = Form.useForm();
const [UIForm] = Form.useForm();
const ref = useRef<any>(null);
const data = initialData[name] || {};
const pluginType = pluginList.find((item) => item.name === name)?.type;
const [codeMirrorMode, setCodeMirrorMode] = useState<PluginComponent.CodeMirrorMode>(
codeMirrorModeList.JSON,
);
const modeOptions: { label: string; value: string }[] = [
{ label: codeMirrorModeList.JSON, value: codeMirrorModeList.JSON },
{ label: codeMirrorModeList.YAML, value: codeMirrorModeList.YAML },
];
if (PLUGIN_UI_LIST.includes(name)) {
modeOptions.push({ label: formatMessage({ id: 'component.plugin.form' }), value: codeMirrorModeList.UIForm });
}
const getUIFormData = () => {
if (name === 'cors') {
const formData = UIForm.getFieldsValue();
const newMethods = formData.allow_methods.join(",");
return { ...formData, allow_methods: newMethods };
}
return UIForm.getFieldsValue();
};
const setUIFormData = (formData: any) => {
if (name === 'cors' && formData) {
const methods = (formData.allow_methods || '').length ? formData.allow_methods.split(",") : ["*"];
UIForm.setFieldsValue({ ...formData, allow_methods: methods });
return;
}
UIForm.setFieldsValue(formData);
};
useEffect(() => {
form.setFieldsValue({
disable: initialData[name] && !initialData[name].disable,
scope: 'global',
});
if (PLUGIN_UI_LIST.includes(name)) {
setCodeMirrorMode(codeMirrorModeList.UIForm);
setUIFormData(initialData[name]);
};
}, []);
const validateData = (pluginName: string, value: PluginComponent.Data) => {
return fetchSchema(pluginName, schemaType).then((schema) => {
return new Promise((resolve) => {
if (schema.oneOf) {
(schema.oneOf || []).forEach((item: any) => {
injectDisableProperty(item);
});
} else {
injectDisableProperty(schema);
}
const validate = ajv.compile(schema);
if (validate(value)) {
resolve(value);
return;
}
// eslint-disable-next-line
for (const err of validate.errors as DefinedError[]) {
let description = '';
switch (err.keyword) {
case 'enum':
description = `${err.dataPath} ${err.message}: ${err.params.allowedValues.join(
', ',
)}`;
break;
case 'minItems':
case 'type':
description = `${err.dataPath} ${err.message}`;
break;
case 'oneOf':
case 'required':
description = err.message || '';
break;
default:
description = `${err.schemaPath} ${err.message}`;
}
notification.error({
message: 'Invalid plugin data',
description,
});
}
});
});
};
const handleModeChange = (value: PluginComponent.CodeMirrorMode) => {
switch (value) {
case codeMirrorModeList.JSON: {
if (codeMirrorMode === codeMirrorModeList.YAML) {
const { data: yamlData, error } = yaml2json(ref.current.editor.getValue(), true);
if (error) {
notification.error({
message: 'Invalid Yaml data',
});
return;
}
ref.current.editor.setValue(
js_beautify(yamlData, {
indent_size: 2,
}),
);
} else {
ref.current.editor.setValue(
js_beautify(JSON.stringify(getUIFormData()), {
indent_size: 2,
}),
);
}
break;
}
case codeMirrorModeList.YAML: {
const { data: jsonData, error } = json2yaml(codeMirrorMode === codeMirrorModeList.JSON ? ref.current.editor.getValue() : JSON.stringify(getUIFormData()));
if (error) {
notification.error({
message: 'Invalid Json data',
});
return;
}
ref.current.editor.setValue(jsonData);
break;
}
case codeMirrorModeList.UIForm: {
if (codeMirrorMode === codeMirrorModeList.JSON) {
setUIFormData(JSON.parse(ref.current.editor.getValue()));
} else {
const { data: yamlData, error } = yaml2json(ref.current.editor.getValue(), true);
if (error) {
notification.error({
message: 'Invalid Yaml data',
});
return;
}
setUIFormData(JSON.parse(yamlData));
}
break;
}
default:
break;
}
setCodeMirrorMode(value);
};
const formatCodes = () => {
try {
if (ref.current) {
ref.current.editor.setValue(
js_beautify(ref.current.editor.getValue(), {
indent_size: 2,
}),
);
}
} catch (error) {
notification.error({
message: 'Format failed',
});
}
};
return (
<>
<Drawer
title={formatMessage({ id: 'component.plugin.editor' })}
visible={visible}
placement="right"
closable={false}
maskClosable={maskClosable}
onClose={onClose}
width={700}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
{' '}
<Button onClick={onClose} key={1}>
{formatMessage({ id: 'component.global.cancel' })}
</Button>
<Space>
<Popconfirm
title={formatMessage({ id: 'page.plugin.drawer.popconfirm.title.delete' })}
okText={formatMessage({ id: 'component.global.confirm' })}
cancelText={formatMessage({ id: 'component.global.cancel' })}
disabled={readonly}
onConfirm={() => {
onChange({
formData: form.getFieldsValue(),
codemirrorData: {},
shouldDelete: true,
});
}}
>
{initialData[name] ? (
<Button key={3} type="primary" danger disabled={readonly}>
{formatMessage({ id: 'component.global.delete' })}
</Button>
) : null}
</Popconfirm>
<Button
key={2}
disabled={readonly}
type="primary"
onClick={() => {
try {
let editorData;
if (codeMirrorMode === codeMirrorModeList.JSON) {
editorData = JSON.parse(ref.current?.editor.getValue());
} else if (codeMirrorMode === codeMirrorModeList.YAML) {
editorData = yaml2json(ref.current?.editor.getValue(), false).data;
} else {
editorData = getUIFormData();
}
validateData(name, editorData).then((value) => {
onChange({ formData: form.getFieldsValue(), codemirrorData: value });
});
} catch (error) {
notification.error({
message: 'Invalid JSON data',
});
}
}}
>
{formatMessage({ id: 'component.global.submit' })}
</Button>
</Space>
</div>
}
>
<style>
{`
.site-page-header {
border: 1px solid rgb(235, 237, 240);
margin-top:10px;
}
.ant-input[disabled] {
color: #000;
}
`}
</style>
<Form {...FORM_ITEM_LAYOUT} style={{ marginTop: '10px' }} form={form}>
<Form.Item label={formatMessage({ id: 'component.global.name' })}>
<Input value={name} bordered={false} disabled />
</Form.Item>
<Form.Item label={formatMessage({ id: 'component.global.enable' })} valuePropName="checked" name="disable">
<Switch
defaultChecked={initialData[name] && !initialData[name].disable}
disabled={readonly}
/>
</Form.Item>
{type === 'global' && (
<Form.Item label={formatMessage({ id: 'component.global.scope' })} name="scope">
<Select disabled>
<Select.Option value="global">{formatMessage({ id: "other.global" })}</Select.Option>
</Select>
</Form.Item>
)}
</Form>
<Divider orientation="left">{formatMessage({ id: 'component.global.data.editor' })}</Divider>
<PageHeader
title=""
subTitle={
pluginType === PluginType.authentication && schemaType !== 'consumer' && (codeMirrorMode !== codeMirrorModeList.UIForm) ? (
<Alert message={formatMessage({ id: 'component.plugin.noConfigurationRequired' })} type="warning" />
) : null
}
ghost={false}
extra={[
<Select
defaultValue={codeMirrorModeList.JSON}
value={codeMirrorMode}
options={modeOptions}
onChange={(value: PluginComponent.CodeMirrorMode) => {
handleModeChange(value);
}}
data-cy='code-mirror-mode'
key={1}
></Select>,
<Tooltip title={formatMessage({ id: "component.plugin.format-codes.disable" })} key={2}>
<Button type="primary" onClick={formatCodes} disabled={codeMirrorMode === codeMirrorModeList.UIForm}>
{formatMessage({ id: 'component.global.format' })}
</Button>
</Tooltip>,
<Button
type="default"
icon={<LinkOutlined />}
onClick={() => {
if (name.startsWith('serverless')) {
window.open('https://apisix.apache.org/docs/apisix/plugins/serverless');
} else {
window.open(`https://apisix.apache.org/docs/apisix/plugins/${name}`);
}
}}
key={3}
>
{formatMessage({ id: 'component.global.document' })}
</Button>
]}
/>
{Boolean(codeMirrorMode === codeMirrorModeList.UIForm) && <PluginForm name={name} form={UIForm} renderForm={!(pluginType === PluginType.authentication && schemaType !== 'consumer')} />}
<div style={{ display: codeMirrorMode === codeMirrorModeList.UIForm ? 'none' : 'unset' }}><CodeMirror
ref={(codemirror) => {
ref.current = codemirror;
if (codemirror) {
// NOTE: for debug & test
// @ts-ignore
window.codemirror = codemirror.editor;
}
}}
value={JSON.stringify(data, null, 2)}
options={{
mode: codeMirrorMode,
readOnly: readonly ? 'nocursor' : '',
lineWrapping: true,
lineNumbers: true,
showCursorWhenSelecting: true,
autofocus: true,
}} />
</div>
</Drawer>
</>
);
};
export default PluginDetail;