diff --git a/.gitignore b/.gitignore index 04f148e317..1997f52ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ api/build-tools/apisix api/coverage.txt api/dag-to-lua/ +# frontend e2e test output +web/.nyc_output +web/coverage diff --git a/web/config/routes.ts b/web/config/routes.ts index 097102d25a..b47c7cdc5a 100644 --- a/web/config/routes.ts +++ b/web/config/routes.ts @@ -99,6 +99,18 @@ const routes = [ path: '/settings', component: './Setting', }, + { + path: '/plugin-template/list', + component: './PluginTemplate/List', + }, + { + path: 'plugin-template/create', + component: './PluginTemplate/Create', + }, + { + path: '/plugin-template/:id/edit', + component: './PluginTemplate/Create', + }, { path: '/user/login', component: './User/Login', diff --git a/web/cypress/fixtures/data.json b/web/cypress/fixtures/data.json index 536a71b7e1..94be061069 100644 --- a/web/cypress/fixtures/data.json +++ b/web/cypress/fixtures/data.json @@ -27,5 +27,11 @@ "updateSuccessfully": "Update Configuration Successfully", "deleteSSLSuccess": "Remove target SSL successfully", "sslErrorAlert": "key and cert don't match", - "pluginErrorAlert": "Invalid plugin data" + "pluginErrorAlert": "Invalid plugin data", + "pluginTemplateName": "test_plugin_template1", + "pluginTemplateName2": "test_plugin_template2", + "createPluginTemplateSuccess": "Create Plugin Template Successfully", + "editPluginTemplateSuccess": "Edit Plugin Template Successfully", + "deletePluginTemplateSuccess": "Delete Plugin Template Successfully", + "pluginTemplateErrorAlert": "Request Error Code: 10000" } diff --git a/web/cypress/fixtures/selector.json b/web/cypress/fixtures/selector.json index ffb125a609..e7b2abb165 100644 --- a/web/cypress/fixtures/selector.json +++ b/web/cypress/fixtures/selector.json @@ -64,5 +64,10 @@ "passwordInput": "#control-ref_password", "drawer": ".ant-drawer-content", "codemirrorScroll": ".CodeMirror-scroll", - "drawerClose": ".ant-drawer-close" + "drawerClose": ".ant-drawer-close", + "descriptionSelector": "[title=Description]", + "customSelector": "[title=Custom]", + "errorAlertClose": ".anticon-close", + "redirectURIInput": "#redirectURI", + "redirectCodeSelector": "#ret_code" } 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 69cd8c5053..4cd5d51095 100644 --- a/web/cypress/integration/consumer/create_and_delete_consumer.spec.js +++ b/web/cypress/integration/consumer/create_and_delete_consumer.spec.js @@ -36,7 +36,9 @@ context('Create and Delete Consumer', () => { // plugin config cy.contains(this.domSelector.pluginCard, 'key-auth').within(() => { - cy.get('button').first().click(); + cy.get('button').click({ + force: true + }); }); cy.get(this.domSelector.disabledSwitcher).click(); @@ -91,7 +93,9 @@ context('Create and Delete Consumer', () => { // plugin config cy.contains(this.domSelector.pluginCard, 'key-auth').within(() => { - cy.get('button').first().click(); + cy.get('button').click({ + force: true + }); }); // edit codeMirror cy.get(this.domSelector.codeMirror) @@ -107,4 +111,3 @@ context('Create and Delete Consumer', () => { cy.get(this.domSelector.notification).should('contain', this.data.pluginErrorAlert); }); }); - diff --git a/web/cypress/integration/pluginTemplate/create-edit-delete-plugin-template.spec.js b/web/cypress/integration/pluginTemplate/create-edit-delete-plugin-template.spec.js new file mode 100644 index 0000000000..1294c6c28a --- /dev/null +++ b/web/cypress/integration/pluginTemplate/create-edit-delete-plugin-template.spec.js @@ -0,0 +1,84 @@ +/* + * 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. + */ +/* eslint-disable no-undef */ + +context('Create Edit and Delete PluginTemplate', () => { + const timeout = 5000; + beforeEach(() => { + cy.login(); + + cy.fixture('selector.json').as('domSelector'); + cy.fixture('data.json').as('data'); + }); + + it('should create pluginTemplate', function () { + cy.visit('/'); + cy.contains('Route').click(); + cy.contains('Plugin Template Config').click(); + cy.contains('Create').click(); + + cy.get(this.domSelector.description).type(this.data.pluginTemplateName); + cy.contains('Next').click(); + cy.contains('Enable').click({ + force: true + }); + cy.focused(this.domSelector.drawer).should('exist'); + cy.get(this.domSelector.drawer, { + timeout + }).within(() => { + cy.get(this.domSelector.disabledSwitcher).click({ + force: true, + }); + }); + cy.contains('Submit').click(); + cy.contains('Next').click(); + cy.contains('Submit').click(); + cy.get(this.domSelector.notification).should('contain', this.data.createPluginTemplateSuccess); + }); + + it('should edit the pluginTemplate', function () { + cy.visit('plugin-template/list'); + + cy.get(this.domSelector.refresh).click(); + cy.get(this.domSelector.descriptionSelector).type(this.data.pluginTemplateName); + cy.contains('button', 'Search').click(); + cy.contains(this.data.pluginTemplateName).siblings().contains('Edit').click(); + + cy.get(this.domSelector.description).clear().type(this.data.pluginTemplateName2); + cy.contains('Next').click(); + cy.contains('Next').click(); + cy.contains('Submit').click(); + + cy.get(this.domSelector.notification).should('contain', this.data.editPluginTemplateSuccess); + }); + + it('should delete pluginTemplate', function () { + cy.visit('plugin-template/list'); + + cy.get(this.domSelector.refresh).click(); + cy.get(this.domSelector.descriptionSelector).type(this.data.pluginTemplateName); + cy.contains('button', 'Search').click(); + cy.get(this.domSelector.empty).should('exist'); + + cy.contains('button', 'Reset').click(); + cy.get(this.domSelector.descriptionSelector).type(this.data.pluginTemplateName2); + cy.contains('button', 'Search').click(); + cy.contains(this.data.pluginTemplateName2).siblings().contains('Delete').click(); + cy.contains('button', 'Confirm').click(); + cy.get(this.domSelector.notification).should('contain', this.data.deletePluginTemplateSuccess); + }); +}); diff --git a/web/cypress/integration/pluginTemplate/create-plugin-template-with-route.spec.js b/web/cypress/integration/pluginTemplate/create-plugin-template-with-route.spec.js new file mode 100644 index 0000000000..67bd664a40 --- /dev/null +++ b/web/cypress/integration/pluginTemplate/create-plugin-template-with-route.spec.js @@ -0,0 +1,102 @@ +/* + * 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. + */ +/* eslint-disable no-undef */ + +context('Create PluginTemplate Binding To Route', () => { + beforeEach(() => { + cy.login(); + + cy.fixture('selector.json').as('domSelector'); + cy.fixture('data.json').as('data'); + }); + + it('should create test pluginTemplate', function () { + cy.visit('/'); + cy.contains('Route').click(); + cy.contains('Plugin Template Config').click(); + cy.contains('Create').click(); + cy.get(this.domSelector.description).type(this.data.pluginTemplateName); + cy.contains('Next').click(); + cy.contains('Next').click(); + cy.contains('Submit').click(); + cy.get(this.domSelector.notification).should('contain', this.data.createPluginTemplateSuccess); + + cy.visit('routes/list'); + cy.contains('Create').click(); + cy.get(this.domSelector.name).type(this.data.routeName); + cy.contains('Next').click(); + cy.get(this.domSelector.nodes_0_host).type(this.data.ip1); + cy.contains('Next').click(); + cy.get(this.domSelector.customSelector).click(); + cy.contains(this.data.pluginTemplateName).click(); + + cy.contains('Next').click(); + cy.contains('Submit').click(); + cy.contains(this.data.submitSuccess); + cy.contains('Goto List').click(); + cy.url().should('contains', 'routes/list'); + }); + + it('should delete the pluginTemplate failure', function () { + cy.visit('plugin-template/list'); + cy.get(this.domSelector.refresh).click(); + + cy.get(this.domSelector.descriptionSelector).type(this.data.pluginTemplateName); + cy.contains('button', 'Search').click(); + cy.contains(this.data.pluginTemplateName).siblings().contains('Delete').click(); + cy.contains('button', 'Confirm').click(); + cy.get(this.domSelector.notification).should('contain', this.data.pluginTemplateErrorAlert); + cy.get(this.domSelector.errorAlertClose).should('be.visible').click(); + }); + + it('should edit the route with pluginTemplate', function () { + cy.visit('routes/list'); + + cy.get(this.domSelector.nameSelector).type(this.data.routeName); + cy.contains('Search').click(); + cy.contains(this.data.routeName).siblings().contains('Edit').click(); + + cy.contains('Forbidden').click(); + cy.contains('Custom').click(); + cy.get(this.domSelector.redirectURIInput).clear().type('123'); + cy.get(this.domSelector.redirectCodeSelector).click(); + cy.contains('301(Permanent Redirect)').click(); + cy.contains('Next').click(); + cy.contains('Submit').click(); + cy.contains(this.data.submitSuccess); + cy.contains('Goto List').click(); + cy.url().should('contains', 'routes/list'); + }); + + it('should delete the pluginTemplate successfully', function () { + cy.visit('plugin-template/list'); + + cy.get(this.domSelector.refresh).click(); + cy.get(this.domSelector.descriptionSelector).type(this.data.pluginTemplateName); + cy.contains('button', 'Search').click(); + cy.contains(this.data.pluginTemplateName).siblings().contains('Delete').click(); + cy.contains('button', 'Confirm').click(); + cy.get(this.domSelector.notification).should('contain', this.data.deletePluginTemplateSuccess); + + cy.visit('/routes/list'); + cy.get(this.domSelector.nameSelector).type(this.data.routeName); + cy.contains('Search').click(); + cy.contains(this.data.routeName).siblings().contains('Delete').click(); + cy.contains('button', 'Confirm').click(); + cy.get(this.domSelector.notification).should('contain', this.data.deleteRouteSuccess); + }); +}); diff --git a/web/cypress/integration/route/create-edit-delete-route.spec.js b/web/cypress/integration/route/create-edit-delete-route.spec.js index f799a69011..86d9fb261d 100644 --- a/web/cypress/integration/route/create-edit-delete-route.spec.js +++ b/web/cypress/integration/route/create-edit-delete-route.spec.js @@ -73,7 +73,9 @@ context('Create and Delete Route', () => { // config prometheus plugin cy.contains(this.domSelector.pluginCard, 'prometheus').within(() => { - cy.get('button').first().click(); + cy.get('button').first().click({ + force: true + }); }); cy.contains('button', 'Cancel').click(); cy.contains('Next').click(); @@ -138,4 +140,3 @@ context('Create and Delete Route', () => { cy.get(this.domSelector.notification).should('contain', this.data.deleteRouteSuccess); }); }); - diff --git a/web/src/pages/Route/components/Step1/LabelsDrawer.tsx b/web/src/components/LabelsfDrawer/LabelsDrawer.tsx similarity index 93% rename from web/src/pages/Route/components/Step1/LabelsDrawer.tsx rename to web/src/components/LabelsfDrawer/LabelsDrawer.tsx index 5a176827a5..33f58805fc 100644 --- a/web/src/pages/Route/components/Step1/LabelsDrawer.tsx +++ b/web/src/components/LabelsfDrawer/LabelsDrawer.tsx @@ -19,22 +19,23 @@ import { AutoComplete, Button, Col, Drawer, Form, notification, Row } from 'antd import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; import { useIntl } from 'umi'; -import { transformLableValueToKeyValue } from '../../transform'; -import { fetchLabelList } from '../../service'; +import { transformLableValueToKeyValue } from '../../helpers'; type Props = { title?: string; actionName: string; dataSource: string[]; + filterList?: string[], + fetchLabelList: any, disabled: boolean; onClose: () => void; } & Pick; -const LabelList = (disabled: boolean, labelList: RouteModule.LabelList) => { +const LabelList = (disabled: boolean, labelList: LabelList, filterList: string[] = []) => { const { formatMessage } = useIntl(); const keyOptions = Object.keys(labelList || {}) - .filter((item) => item !== 'API_VERSION') + .filter((item) => !filterList.includes(item)) .map((item) => ({ value: item })); return ( @@ -116,14 +117,16 @@ const LabelsDrawer: React.FC = ({ actionName = '', disabled = false, dataSource = [], + filterList = [], + fetchLabelList, onClose, - onChange = () => {}, + onChange = () => { }, }) => { const transformLabel = transformLableValueToKeyValue(dataSource); const { formatMessage } = useIntl(); const [form] = Form.useForm(); - const [labelList, setLabelList] = useState({}); + const [labelList, setLabelList] = useState({}); form.setFieldsValue({ labels: transformLabel }); useEffect(() => { @@ -172,7 +175,7 @@ const LabelsDrawer: React.FC = ({ } >
- {LabelList(disabled, labelList || {})} + {LabelList(disabled, labelList || {}, filterList)}
); diff --git a/web/src/components/LabelsfDrawer/index.ts b/web/src/components/LabelsfDrawer/index.ts new file mode 100644 index 0000000000..a42e5e534e --- /dev/null +++ b/web/src/components/LabelsfDrawer/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ +export { default } from './LabelsDrawer'; diff --git a/web/src/components/Plugin/PluginPage.tsx b/web/src/components/Plugin/PluginPage.tsx index 26d9b22535..03d2fec23c 100644 --- a/web/src/components/Plugin/PluginPage.tsx +++ b/web/src/components/Plugin/PluginPage.tsx @@ -15,23 +15,25 @@ * limitations under the License. */ import React, { useEffect, useState } from 'react'; -import { Anchor, Layout, Card, Button } from 'antd'; +import { Anchor, Layout, Card, Button, Form, Select, Alert } from 'antd'; import { PanelSection } from '@api7-dashboard/ui'; import { omit, orderBy } from 'lodash'; import { useIntl } from 'umi'; import PluginDetail from './PluginDetail'; -import { fetchList } from './service'; +import { fetchList, fetchPluginTemplateList } from './service'; import { PLUGIN_ICON_LIST, PLUGIN_FILTER_LIST } from './data'; import defaultPluginImg from '../../../public/static/default-plugin.png'; type Props = { readonly?: boolean; type?: 'global' | 'scoped'; - initialData?: PluginComponent.Data; + initialData?: PluginComponent.Data, + plugin_config_id?: string, schemaType?: PluginComponent.Schema; referPage?: PluginComponent.ReferPage; - onChange?: (data: PluginComponent.Data) => void; + showSelector?: boolean, + onChange?: (plugins: PluginComponent.Data, plugin_config_id?: string) => void; }; const PanelSectionStyle = { @@ -50,19 +52,24 @@ const NEVER_EXIST_PLUGIN_FLAG = 'NEVER_EXIST_PLUGIN_FLAG'; const PluginPage: React.FC = ({ readonly = false, initialData = {}, + plugin_config_id = "", schemaType = 'route', referPage = '', type = 'scoped', - onChange = () => {}, + showSelector = false, + onChange = () => { }, }) => { const { formatMessage } = useIntl(); - + const [form] = Form.useForm(); const [pluginList, setPluginList] = useState([]); + const [pluginTemplateList, setPluginTemplateList] = useState([]); const [name, setName] = useState(NEVER_EXIST_PLUGIN_FLAG); const [typeList, setTypeList] = useState([]); + const [plugins, setPlugins] = useState({}); const firstUpperCase = ([first, ...rest]: string) => first.toUpperCase() + rest.join(''); useEffect(() => { + setPlugins(initialData); fetchList().then((data) => { const filteredData = data.filter( (item) => @@ -79,6 +86,10 @@ const PluginPage: React.FC = ({ }); setTypeList(categoryList.sort()); }); + fetchPluginTemplateList().then((data) => { + setPluginTemplateList(data); + form.setFieldsValue({ plugin_config_id }) + }) }, []); const PluginList = () => ( @@ -104,6 +115,54 @@ const PluginPage: React.FC = ({ + {showSelector && ( + <> +
+ { + if (prev.plugin_config_id !== next.plugin_config_id) { + const id = next.plugin_config_id; + if (id) { + form.setFieldsValue({ + plugin_config_id: id, + }); + } + } + return prev.plugin_config_id !== next.plugin_config_id; + }} + > + + +
+ +

{formatMessage({ id: 'component.plugin.pluginTemplate.tip1' })}

+

{formatMessage({ id: 'component.plugin.pluginTemplate.tip2' })}

+ } type="info" /> + + )} {typeList.map((typeItem) => { return ( = ({ setName(NEVER_EXIST_PLUGIN_FLAG); }} onChange={({ codemirrorData, formData, shouldDelete }) => { - let plugins = { + let newPlugins = { ...initialData, [name]: { ...codemirrorData, disable: !formData.disable }, }; if (shouldDelete === true) { - plugins = omit(plugins, name); + newPlugins = omit(newPlugins, name); } - onChange(plugins); + onChange(newPlugins, form.getFieldValue('plugin_config_id')); + setPlugins(newPlugins); setName(NEVER_EXIST_PLUGIN_FLAG); }} /> diff --git a/web/src/components/Plugin/locales/en-US.ts b/web/src/components/Plugin/locales/en-US.ts index 00e968950b..f70e668ca8 100644 --- a/web/src/components/Plugin/locales/en-US.ts +++ b/web/src/components/Plugin/locales/en-US.ts @@ -17,4 +17,8 @@ export default { 'component.plugin.tip1': 'NOTE: After customizing the plugin, you need to update schema.json.', 'component.plugin.tip2': 'How to update?', + 'component.select.pluginTemplate': 'Select a plugin template', + 'component.step.select.pluginTemplate.select.option': 'Custom', + 'component.plugin.pluginTemplate.tip1': '1. When a route already have plugins field configured, the plugins in the plugin template will be merged into it.', + 'component.plugin.pluginTemplate.tip2': '2. The same plugin in the plugin template will override one in the plugins' }; diff --git a/web/src/components/Plugin/locales/zh-CN.ts b/web/src/components/Plugin/locales/zh-CN.ts index d4d9b6464a..afc85c13db 100644 --- a/web/src/components/Plugin/locales/zh-CN.ts +++ b/web/src/components/Plugin/locales/zh-CN.ts @@ -17,4 +17,8 @@ export default { 'component.plugin.tip1': '注意:自定义插件后(修改、新增、删除等),需更新 schema.json。', 'component.plugin.tip2': '如何更新?', + "component.select.pluginTemplate": '选择插件模板', + 'component.step.select.pluginTemplate.select.option': '手动配置', + 'component.plugin.pluginTemplate.tip1': '1. 若路由已配置插件,则插件模板数据将与已配置的插件数据合并。', + 'component.plugin.pluginTemplate.tip2': '2. 插件模板相同的插件会覆盖掉原有的插件。' }; diff --git a/web/src/components/Plugin/service.ts b/web/src/components/Plugin/service.ts index b3177b09aa..5a1b42724e 100644 --- a/web/src/components/Plugin/service.ts +++ b/web/src/components/Plugin/service.ts @@ -51,3 +51,9 @@ export const fetchSchema = async ( } return cachedPluginSchema[schemaType][name]; }; + +export const fetchPluginTemplateList = () => { + return request>>('/plugin_configs').then((data) => { + return data.data.rows; + }); +}; diff --git a/web/src/helpers.tsx b/web/src/helpers.tsx index de5bc4a85b..5ee05b009b 100644 --- a/web/src/helpers.tsx +++ b/web/src/helpers.tsx @@ -130,3 +130,33 @@ export const timestampToLocaleString = (timestamp: number) => { return moment.unix(timestamp).format('YYYY-MM-DD HH:mm:ss'); }; + +export const transformLableValueToKeyValue = (data: string[]) => { + return (data || []).map((item) => { + const index = item.indexOf(':'); + const labelKey = item.substring(0, index); + const labelValue = item.substring(index + 1); + return { labelKey, labelValue, key: Math.random().toString(36).slice(2) }; + }); +}; + +export const transformLabelList = (data: ResponseLabelList) => { + if (!data) { + return {}; + } + const transformData = {}; + data.forEach((item) => { + const key = Object.keys(item)[0]; + const value = item[key]; + if (!transformData[key]) { + transformData[key] = []; + transformData[key].push(value); + return; + } + + if (transformData[key] && !transformData[key][value]) { + transformData[key].push(value); + } + }); + return transformData; +}; diff --git a/web/src/locales/en-US/menu.ts b/web/src/locales/en-US/menu.ts index c2dac51158..e84a578a75 100644 --- a/web/src/locales/en-US/menu.ts +++ b/web/src/locales/en-US/menu.ts @@ -65,6 +65,7 @@ export default { 'menu.editor.koni': 'Koni Editor', 'menu.metrics': 'Metrics', 'menu.routes': 'Route', + 'menu.pluginTemplate': 'Plugin Template', 'menu.ssl': 'SSL', 'menu.upstream': 'Upstream', 'menu.consumer': 'Consumer', diff --git a/web/src/locales/zh-CN/menu.ts b/web/src/locales/zh-CN/menu.ts index 59129c17ff..19b4c6eba1 100644 --- a/web/src/locales/zh-CN/menu.ts +++ b/web/src/locales/zh-CN/menu.ts @@ -62,6 +62,7 @@ export default { 'menu.editor.koni': '拓扑编辑器', 'menu.metrics': '监控', 'menu.routes': '路由', + 'menu.pluginTemplate': '插件模板', 'menu.ssl': '证书', 'menu.upstream': '上游', 'menu.consumer': '消费者', diff --git a/web/src/pages/PluginTemplate/Create.tsx b/web/src/pages/PluginTemplate/Create.tsx new file mode 100644 index 0000000000..3da8cb1b12 --- /dev/null +++ b/web/src/pages/PluginTemplate/Create.tsx @@ -0,0 +1,121 @@ +/* + * 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, { useState, useEffect } from 'react'; +import { PageContainer } from '@ant-design/pro-layout'; +import { Card, Steps, notification, Form } from 'antd'; +import { history, useIntl } from 'umi'; + +import ActionBar from '@/components/ActionBar'; +import PluginPage from '@/components/Plugin'; +import { transformLableValueToKeyValue } from '@/helpers'; + +import Step1 from './components/Step1'; +import Preview from './components/Preview'; +import { fetchItem, create, update, } from './service'; + +const Page: React.FC = (props) => { + const [step, setStep] = useState(1); + const [plugins, setPlugins] = useState({}); + const [form1] = Form.useForm(); + const { formatMessage } = useIntl(); + + useEffect(() => { + const { id } = (props as any).match.params; + if (id) { + fetchItem(id).then(({ data }) => { + const { desc, labels = {}, ...rest } = data; + form1.setFieldsValue({ + id, desc, custom_normal_labels: Object.keys(labels) + .map((key) => `${key}:${labels[key]}`) + }); + setPlugins(rest.plugins); + }); + } + }, []); + + const onSubmit = () => { + const { desc, custom_normal_labels } = form1.getFieldsValue(); + const labels: Record = {}; + transformLableValueToKeyValue(custom_normal_labels || []).forEach(({ labelKey, labelValue }) => { + labels[labelKey] = labelValue; + }); + const data = { desc, labels, plugins } as PluginTemplateModule.Entity; + + const { id } = (props as any).match.params; + (id ? update(id, data) : create(data)) + .then(() => { + notification.success({ + message: `${id + ? formatMessage({ id: 'component.global.edit' }) + : formatMessage({ id: 'component.global.create' }) + } ${formatMessage({ id: 'menu.pluginTemplate' })} ${formatMessage({ + id: 'component.status.success', + })}`, + }); + history.push('/plugin-template/list'); + }) + .catch(() => { + setStep(3); + }); + }; + + const onStepChange = (nextStep: number) => { + if (step === 1) { + form1.validateFields().then(() => { + setStep(nextStep); + }); + } else if (nextStep === 3) { + setStep(3); + } else if (nextStep === 4) { + onSubmit(); + } else { + setStep(nextStep); + } + }; + + return ( + <> + + + + + + + + + {step === 1 && } + {step === 2 && ( + + )} + {step === 3 && } + + + + + ); +}; + +export default Page; diff --git a/web/src/pages/PluginTemplate/List.tsx b/web/src/pages/PluginTemplate/List.tsx new file mode 100644 index 0000000000..6e0fa45a5a --- /dev/null +++ b/web/src/pages/PluginTemplate/List.tsx @@ -0,0 +1,160 @@ +/* + * 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 { PageHeaderWrapper } from '@ant-design/pro-layout'; +import { history, useIntl } from 'umi'; +import ProTable from '@ant-design/pro-table'; +import type { ActionType, ProColumns } from '@ant-design/pro-table'; +import { Button, notification, Popconfirm, Select, Space, Tag } from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; + +import { fetchList, remove, fetchLabelList } from './service'; + +const Page: React.FC = () => { + const ref = useRef(); + const [labelList, setLabelList] = useState({}); + const { formatMessage } = useIntl(); + + useEffect(() => { + fetchLabelList().then(setLabelList); + }, []); + + const handleTableActionSuccessResponse = (msgTip: string) => { + notification.success({ + message: msgTip, + }); + + ref.current?.reload(); + }; + + const columns: ProColumns[] = [ + { + title: 'ID', + dataIndex: 'id', + hideInSearch: true, + }, + { + title: formatMessage({ id: 'component.global.description' }), + dataIndex: 'desc', + }, + { + title: formatMessage({ id: 'component.global.labels' }), + dataIndex: 'labels', + render: (_, record) => { + return Object.keys(record.labels || {}) + .map((item) => ( + + {item}:{record.labels[item]} + + )); + }, renderFormItem: (_, { type }) => { + if (type === 'form') { + return null; + } + + return ( + + ); + }, + }, + { + title: formatMessage({ id: 'component.global.operation' }), + valueType: 'option', + render: (_, record) => ( + <> + + + + { + remove(record.id!).then(() => { + handleTableActionSuccessResponse( + `${formatMessage({ id: 'component.global.delete' })} ${formatMessage({ + id: 'menu.pluginTemplate', + })} ${formatMessage({ id: 'component.status.success' })}`, + ); + }); + }} + okText={formatMessage({ id: 'component.global.confirm' })} + cancelText={formatMessage({ id: 'component.global.cancel' })} + > + + + + + ), + }, + ]; + + return ( + + + actionRef={ref} + rowKey="id" + columns={columns} + request={fetchList} + search={{ + searchText: formatMessage({ id: 'component.global.search' }), + resetText: formatMessage({ id: 'component.global.reset' }), + }} + toolBarRender={() => [ + , + ]} + /> + + ); +}; + +export default Page; diff --git a/web/src/pages/PluginTemplate/components/Preview.tsx b/web/src/pages/PluginTemplate/components/Preview.tsx new file mode 100644 index 0000000000..4e140ad3a9 --- /dev/null +++ b/web/src/pages/PluginTemplate/components/Preview.tsx @@ -0,0 +1,37 @@ +/* + * 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 from 'react'; +import type { FormInstance } from 'antd/lib/form'; + +import PluginPage from '@/components/Plugin'; +import Step1 from './Step1'; + +type Props = { + form1: FormInstance; + plugins: PluginComponent.Data; +}; + +const Page: React.FC = ({ form1, plugins }) => { + return ( + <> + + + + ); +}; + +export default Page; diff --git a/web/src/pages/PluginTemplate/components/Step1.tsx b/web/src/pages/PluginTemplate/components/Step1.tsx new file mode 100644 index 0000000000..dbeea66c96 --- /dev/null +++ b/web/src/pages/PluginTemplate/components/Step1.tsx @@ -0,0 +1,112 @@ +/* + * 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, { useState } from 'react'; +import { Button, Form, Input, Select, Tag } from 'antd'; +import type { FormInstance } from 'antd/lib/form'; +import { useIntl } from 'umi'; + +import LabelsDrawer from '@/components/LabelsfDrawer'; +import { fetchLabelList } from '../service'; + +const FORM_LAYOUT = { + labelCol: { + span: 2, + }, + wrapperCol: { + span: 8, + }, +}; + +type Props = { + form: FormInstance; + disabled?: boolean; +}; + +const Step1: React.FC = ({ form, disabled }) => { + const [visible, setVisible] = useState(false); + const { formatMessage } = useIntl(); + + const NormalLabelComponent = () => { + const field = 'custom_normal_labels'; + const title = 'Label Manager'; + return ( + + +