diff --git a/web/config/routes.ts b/web/config/routes.ts index dac171691f..097102d25a 100644 --- a/web/config/routes.ts +++ b/web/config/routes.ts @@ -75,6 +75,14 @@ const routes = [ path: '/consumer/:username/edit', component: './Consumer/Create', }, + { + path: '/plugin/list', + component: './Plugin/List', + }, + { + path: '/plugin/market', + component: './Plugin/PluginMarket', + }, { path: '/service/list', component: './Service/List', diff --git a/web/cypress/integration/consumer/create_and_delete_consumer.spec.js b/web/cypress/integration/consumer/create_and_delete_consumer.spec.js index e434df7cb6..f94c260d67 100644 --- a/web/cypress/integration/consumer/create_and_delete_consumer.spec.js +++ b/web/cypress/integration/consumer/create_and_delete_consumer.spec.js @@ -46,6 +46,8 @@ context('Create and Delete Consumer', () => { cy.contains(domSelectors.pluginsCard, 'key-auth').within(() => { cy.get('button').first().click(); }); + + cy.get('#disable').click(); // edit codemirror cy.get('.CodeMirror') .first() diff --git a/web/src/components/Plugin/CodeMirrorDrawer.tsx b/web/src/components/Plugin/CodeMirrorDrawer.tsx deleted file mode 100644 index 5bf5093ffc..0000000000 --- a/web/src/components/Plugin/CodeMirrorDrawer.tsx +++ /dev/null @@ -1,143 +0,0 @@ -/* - * 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, { useRef } from 'react'; -import { Drawer, Button, notification, PageHeader } from 'antd'; -import CodeMirror from '@uiw/react-codemirror'; -import { js_beautify } from 'js-beautify'; -import { LinkOutlined } from '@ant-design/icons'; - -type Props = { - name: string; - visible?: boolean; - data?: object; - readonly?: boolean; - onClose?: () => void; - onSubmit?: (data: object) => void; -}; - -const CodeMirrorDrawer: React.FC = ({ - name, - visible = false, - readonly = false, - data = {}, - onClose, - onSubmit, -}) => { - const ref = useRef(null); - - 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 ( - <> - - - - - - ) - } - > - } - onClick={() => { - window.open(`https://github.com/apache/apisix/blob/master/doc/plugins/${name}.md`); - }} - key={1} - > - Document - , - , - ]} - > - - - - - ); -}; - -export default CodeMirrorDrawer; diff --git a/web/src/components/Plugin/PluginDetail.tsx b/web/src/components/Plugin/PluginDetail.tsx new file mode 100644 index 0000000000..932dee6645 --- /dev/null +++ b/web/src/components/Plugin/PluginDetail.tsx @@ -0,0 +1,242 @@ +/* + * 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 } from 'react'; +import { Button, notification, PageHeader, Switch, Form, Select, Divider, Drawer } 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, { DefinedError } from 'ajv'; +import { fetchSchema } from './service'; + +type Props = { + name: string; + type?: 'global' | 'scoped'; + schemaType: PluginComponent.Schema; + initialData: object; + readonly?: boolean; + visible: boolean; + onClose?: () => void; + onChange?: (data: any) => void; +}; + +const ajv = new 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) => { + // 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 = ({ + name, + type = 'scoped', + schemaType = 'route', + visible, + readonly = false, + initialData = {}, + onClose = () => { }, + onChange = () => { }, +}) => { + const { formatMessage } = useIntl(); + const [form] = Form.useForm(); + const ref = useRef(null); + const data = initialData[name]; + + useEffect(() => { + form.setFieldsValue({ disable: initialData[name] && !initialData[name].disable }); + }, []); + + 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 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 ( + <> + + {' '} + + + + } + > + + +
+ + + + {type === 'global' && ( + + + + )} +
+ Data Editor + } + onClick={() => { + window.open(`https://github.com/apache/apisix/blob/master/doc/plugins/${name}.md`); + }} + key={1} + > + Document + , + , + ]} + /> + +
+ + ); +}; + +export default PluginDetail; diff --git a/web/src/components/Plugin/PluginPage.tsx b/web/src/components/Plugin/PluginPage.tsx index 3a3f76f98e..272b00c29e 100644 --- a/web/src/components/Plugin/PluginPage.tsx +++ b/web/src/components/Plugin/PluginPage.tsx @@ -15,16 +15,16 @@ * limitations under the License. */ import React, { useEffect, useState } from 'react'; -import { Anchor, Layout, Switch, Card, Tooltip, Button, notification } from 'antd'; -import { SettingFilled } from '@ant-design/icons'; +import { Anchor, Layout, Card, Button } from 'antd'; import { PanelSection } from '@api7-dashboard/ui'; -import Ajv, { DefinedError } from 'ajv'; +import { orderBy } from 'lodash'; +import PluginDetail from './PluginDetail'; import { fetchList } from './service'; -import CodeMirrorDrawer from './CodeMirrorDrawer'; type Props = { readonly?: boolean; + type?: 'global' | 'scoped'; initialData?: PluginComponent.Data; schemaType?: PluginComponent.Schema; onChange?: (data: PluginComponent.Data) => void; @@ -32,7 +32,7 @@ type Props = { const PanelSectionStyle = { display: 'grid', - gridTemplateColumns: 'repeat(3, 33.333333%)', + gridTemplateColumns: 'repeat(5, 20%)', gridRowGap: 15, gridColumnGap: 10, width: 'calc(100% - 20px)', @@ -43,13 +43,12 @@ const { Sider, Content } = Layout; // NOTE: use this flag as plugin's name to hide drawer const NEVER_EXIST_PLUGIN_FLAG = 'NEVER_EXIST_PLUGIN_FLAG'; -const ajv = new Ajv(); - const PluginPage: React.FC = ({ readonly = false, initialData = {}, - schemaType = '', - onChange = () => {}, + schemaType = 'route', + type = 'scoped', + onChange = () => { }, }) => { const [pluginList, setPluginList] = useState([]); const [name, setName] = useState(NEVER_EXIST_PLUGIN_FLAG); @@ -70,159 +69,101 @@ const PluginPage: React.FC = ({ }); }, []); - // NOTE: This function has side effect because it mutates the original schema data - const injectDisableProperty = (schema: Record) => { - // 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 validateData = (pluginName: string, value: PluginComponent.Data) => { - const plugin = pluginList.find((item) => item.name === pluginName); - let schema: any = {}; - - if (schemaType === 'consumer' && plugin?.consumer_schema) { - schema = plugin.consumer_schema; - } else if (plugin?.schema) { - schema = plugin.schema; - } - - if (schema.oneOf) { - (schema.oneOf || []).forEach((item: any) => { - injectDisableProperty(item); - }); - } else { - injectDisableProperty(schema); - } - - const validate = ajv.compile(schema); - - if (validate(value)) { - setName(NEVER_EXIST_PLUGIN_FLAG); - onChange({ ...initialData, [pluginName]: 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, - }); - } - setName(pluginName); - }; - - return ( + const PluginList = () => ( <> - - - - - {typeList.map((type) => { - return ; - })} - - - + + + {/* eslint-disable-next-line no-shadow */} {typeList.map((type) => { - return ( - - {pluginList - .filter((item) => item.type === type.toLowerCase()) - .map((item) => ( - {item.name}]} - style={{ height: 66 }} - extra={[ - - , + ]} + bodyStyle={{ + height: 151, + display: 'flex', + justifyContent: 'center', + textAlign: 'center', + }} + title={[ +
+ {item.name} +
, + ]} + style={{ height: 258, width: 200 }} + /> + ))} +
+ ); + })} +
+ + ); + + const Plugin = () => ( + + { setName(NEVER_EXIST_PLUGIN_FLAG); }} - onSubmit={(value) => { - validateData(name, value); + onChange={({ codemirrorData, formData }) => { + onChange({ + ...initialData, + [name]: { ...codemirrorData, disable: !formData.disable }, + }); + setName(NEVER_EXIST_PLUGIN_FLAG); }} /> + + ); + return ( + <> + + + + + ); }; diff --git a/web/src/components/Plugin/service.ts b/web/src/components/Plugin/service.ts index 7a156395f8..c04530b8f6 100644 --- a/web/src/components/Plugin/service.ts +++ b/web/src/components/Plugin/service.ts @@ -18,9 +18,9 @@ import { omit } from 'lodash'; import { request } from 'umi'; export const fetchList = () => { - return request>('/plugins?all=true').then(data => { + return request>('/plugins?all=true').then((data) => { return data.data; - }) + }); }; /** diff --git a/web/src/components/PluginOrchestration/customConfig.tsx b/web/src/components/PluginOrchestration/customConfig.tsx index 1d09f0bc9e..4141743e71 100644 --- a/web/src/components/PluginOrchestration/customConfig.tsx +++ b/web/src/components/PluginOrchestration/customConfig.tsx @@ -27,7 +27,10 @@ export const NodeInnerCustom = ({ node }: INodeInnerDefaultProps) => { if (customData.type === PanelType.Condition) { return ( -

{formatMessage({ id: "page.panel.condition.name" })}:{customData.name || `(${formatMessage({ id: 'page.panel.condition.tips' })})`}

+

+ {formatMessage({ id: 'page.panel.condition.name' })}: + {customData.name || `(${formatMessage({ id: 'page.panel.condition.tips' })})`} +

); } @@ -35,7 +38,10 @@ export const NodeInnerCustom = ({ node }: INodeInnerDefaultProps) => { if (customData.type === PanelType.Plugin) { return ( -

{formatMessage({ id: "page.panel.plugin.name" })}: {customData.name || `(${formatMessage({ id: 'page.panel.plugin.tips' })})`}

+

+ {formatMessage({ id: 'page.panel.plugin.name' })}:{' '} + {customData.name || `(${formatMessage({ id: 'page.panel.plugin.tips' })})`} +

); } diff --git a/web/src/components/PluginOrchestration/index.tsx b/web/src/components/PluginOrchestration/index.tsx index 37607e6141..2fa894c00c 100644 --- a/web/src/components/PluginOrchestration/index.tsx +++ b/web/src/components/PluginOrchestration/index.tsx @@ -20,7 +20,7 @@ import { FlowChart, IFlowChartCallbacks } from '@mrblenny/react-flow-chart'; import * as actions from '@mrblenny/react-flow-chart/src/container/actions'; import { Form, Input, Button, Divider, Card, Select } from 'antd'; import { withTheme } from '@rjsf/core'; -import { useIntl } from 'umi' +import { useIntl } from 'umi'; // @ts-ignore import { Theme as AntDTheme } from '@rjsf/antd'; @@ -99,7 +99,7 @@ const SelectedSidebar: React.FC = ({ data = {}, onChange, readonly = fals const { type, name } = getCustomDataById(args.nodeId); setSelectedType(type); if (type === PanelType.Plugin && name) { - const plugin = pluginList.find(item => item.name === name); + const plugin = pluginList.find((item) => item.name === name); if (plugin) { setSchema(plugin.schema); } @@ -111,19 +111,19 @@ const SelectedSidebar: React.FC = ({ data = {}, onChange, readonly = fals return clonedObj; }, {}) as IFlowChartCallbacks; - const firstUpperCase = ([first, ...rest]: string) => first.toUpperCase() + rest.join(""); + const firstUpperCase = ([first, ...rest]: string) => first.toUpperCase() + rest.join(''); useEffect(() => { // eslint-disable-next-line no-shadow fetchList().then((data) => { const categoryList: string[] = []; - data.forEach(item => { + data.forEach((item) => { if (!categoryList.includes(firstUpperCase(item.type))) { categoryList.push(firstUpperCase(item.type)); } }); setTypeList(['All', ...categoryList.sort()]); setPluginList(data); - setShowList(data.map(item => item.name).sort()); + setShowList(data.map((item) => item.name).sort()); }); }, []); @@ -147,13 +147,18 @@ const SelectedSidebar: React.FC = ({ data = {}, onChange, readonly = fals @@ -180,7 +185,7 @@ const SelectedSidebar: React.FC = ({ data = {}, onChange, readonly = fals @@ -189,7 +194,9 @@ const SelectedSidebar: React.FC = ({ data = {}, onChange, readonly = fals return ( - {formatMessage({ id: 'page.siderBar.tips' })} + + {formatMessage({ id: 'page.siderBar.tips' })} + = ({ data = {}, onChange, readonly = fals {formatMessage({ id: 'page.siderBar.plugin' })}