This is an automated email from the ASF dual-hosted git repository.

sunyi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git


The following commit(s) were added to refs/heads/master by this push:
     new 938b2b9  feat: add plugin template config feature (#1540)
938b2b9 is described below

commit 938b2b9f34c5fa5e7ef6fddef29acb29b25c09a1
Author: litesun <[email protected]>
AuthorDate: Fri Mar 5 12:33:09 2021 +0800

    feat: add plugin template config feature (#1540)
    
    Co-authored-by: guoqqqi <[email protected]>
---
 .gitignore                                         |   3 +
 web/config/routes.ts                               |  12 ++
 web/cypress/fixtures/data.json                     |   8 +-
 web/cypress/fixtures/selector.json                 |   7 +-
 .../consumer/create_and_delete_consumer.spec.js    |   9 +-
 .../create-edit-delete-plugin-template.spec.js     |  84 +++++++++++
 .../create-plugin-template-with-route.spec.js      | 102 +++++++++++++
 .../route/create-edit-delete-route.spec.js         |   5 +-
 .../LabelsfDrawer}/LabelsDrawer.tsx                |  17 ++-
 .../locales/en-US.ts => LabelsfDrawer/index.ts}    |   5 +-
 web/src/components/Plugin/PluginPage.tsx           |  78 ++++++++--
 web/src/components/Plugin/locales/en-US.ts         |   4 +
 web/src/components/Plugin/locales/zh-CN.ts         |   4 +
 web/src/components/Plugin/service.ts               |   6 +
 web/src/helpers.tsx                                |  30 ++++
 web/src/locales/en-US/menu.ts                      |   1 +
 web/src/locales/zh-CN/menu.ts                      |   1 +
 web/src/pages/PluginTemplate/Create.tsx            | 121 ++++++++++++++++
 web/src/pages/PluginTemplate/List.tsx              | 160 +++++++++++++++++++++
 .../PluginTemplate/components/Preview.tsx}         |  23 ++-
 .../components/Step1.tsx}                          | 109 +++++---------
 web/src/pages/PluginTemplate/service.ts            |  59 ++++++++
 .../en-US.ts => pages/PluginTemplate/typing.d.ts}  |  17 ++-
 web/src/pages/Route/Create.tsx                     |  13 +-
 web/src/pages/Route/List.tsx                       |   6 +-
 .../Route/components/CreateStep4/CreateStep4.tsx   |   8 +-
 web/src/pages/Route/components/Step1/MetaView.tsx  |   6 +-
 web/src/pages/Route/components/Step3/index.tsx     |  11 +-
 web/src/pages/Route/constants.ts                   |   1 +
 web/src/pages/Route/locales/en-US.ts               |   1 +
 web/src/pages/Route/locales/zh-CN.ts               |   1 +
 web/src/pages/Route/service.ts                     |   4 +-
 web/src/pages/Route/transform.ts                   |  36 +----
 web/src/pages/Route/typing.d.ts                    |  12 +-
 web/src/typings.d.ts                               |   4 +
 35 files changed, 798 insertions(+), 170 deletions(-)

diff --git a/.gitignore b/.gitignore
index 04f148e..1997f52 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 097102d..b47c7cd 100644
--- a/web/config/routes.ts
+++ b/web/config/routes.ts
@@ -100,6 +100,18 @@ const routes = [
     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',
     layout: false,
diff --git a/web/cypress/fixtures/data.json b/web/cypress/fixtures/data.json
index 536a71b..94be061 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 ffb125a..e7b2abb 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 69cd8c5..4cd5d51 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 0000000..1294c6c
--- /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 0000000..67bd664
--- /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 f799a69..86d9fb2 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 5a17682..33f5880 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<RouteModule.Step1PassProps, 'onChange'>;
 
-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 (
     <Form.List name="labels">
@@ -116,14 +117,16 @@ const LabelsDrawer: React.FC<Props> = ({
   actionName = '',
   disabled = false,
   dataSource = [],
+  filterList = [],
+  fetchLabelList,
   onClose,
-  onChange = () => {},
+  onChange = () => { },
 }) => {
   const transformLabel = transformLableValueToKeyValue(dataSource);
 
   const { formatMessage } = useIntl();
   const [form] = Form.useForm();
-  const [labelList, setLabelList] = useState<RouteModule.LabelList>({});
+  const [labelList, setLabelList] = useState<LabelList>({});
   form.setFieldsValue({ labels: transformLabel });
 
   useEffect(() => {
@@ -172,7 +175,7 @@ const LabelsDrawer: React.FC<Props> = ({
       }
     >
       <Form form={form} layout="horizontal">
-        {LabelList(disabled, labelList || {})}
+        {LabelList(disabled, labelList || {}, filterList)}
       </Form>
     </Drawer>
   );
diff --git a/web/src/components/Plugin/locales/en-US.ts 
b/web/src/components/LabelsfDrawer/index.ts
similarity index 83%
copy from web/src/components/Plugin/locales/en-US.ts
copy to web/src/components/LabelsfDrawer/index.ts
index 00e9689..a42e5e5 100644
--- a/web/src/components/Plugin/locales/en-US.ts
+++ b/web/src/components/LabelsfDrawer/index.ts
@@ -14,7 +14,4 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-export default {
-  'component.plugin.tip1': 'NOTE: After customizing the plugin, you need to 
update schema.json.',
-  'component.plugin.tip2': 'How to update?',
-};
+export { default } from './LabelsDrawer';
diff --git a/web/src/components/Plugin/PluginPage.tsx 
b/web/src/components/Plugin/PluginPage.tsx
index 26d9b22..03d2fec 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<Props> = ({
   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<PluginComponent.Meta[]>([]);
+  const [pluginTemplateList, setPluginTemplateList] = 
useState<PluginTemplateModule.ResEntity[]>([]);
   const [name, setName] = useState<string>(NEVER_EXIST_PLUGIN_FLAG);
   const [typeList, setTypeList] = useState<string[]>([]);
+  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<Props> = ({
       });
       setTypeList(categoryList.sort());
     });
+    fetchPluginTemplateList().then((data) => {
+      setPluginTemplateList(data);
+      form.setFieldsValue({ plugin_config_id })
+    })
   }, []);
 
   const PluginList = () => (
@@ -104,6 +115,54 @@ const PluginPage: React.FC<Props> = ({
         </Anchor>
       </Sider>
       <Content style={{ padding: '0 10px', backgroundColor: '#fff', minHeight: 
1400 }}>
+        {showSelector && (
+          <>
+            <Form form={form}>
+              <Form.Item
+                label={formatMessage({ id: 'component.select.pluginTemplate' 
})}
+                name="plugin_config_id"
+                shouldUpdate={(prev, next) => {
+                  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;
+                }}
+              >
+                <Select
+                  data-cy="pluginTemplateSelector"
+                  disabled={readonly}
+                  onChange={(id) => {
+                    form.setFieldsValue({
+                      plugin_config_id: id,
+                    });
+                    onChange(plugins, id as string);
+                  }}
+                >
+                  {[
+                    {
+                      id: '',
+                      desc: formatMessage({ id: 
'component.step.select.pluginTemplate.select.option' }),
+                    },
+                    ...pluginTemplateList,
+                  ].map((item) => (
+                    <Select.Option value={item.id!} key={item.id}>
+                      {item.desc}
+                    </Select.Option>
+                  ))}
+                </Select>
+              </Form.Item>
+            </Form>
+            <Alert message={<>
+              <p>{formatMessage({ id: 'component.plugin.pluginTemplate.tip1' 
})}</p>
+              <p>{formatMessage({ id: 'component.plugin.pluginTemplate.tip2' 
})}</p>
+            </>} type="info" />
+          </>
+        )}
         {typeList.map((typeItem) => {
           return (
             <PanelSection
@@ -187,14 +246,15 @@ const PluginPage: React.FC<Props> = ({
         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 00e9689..f70e668 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 d4d9b64..afc85c1 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 b3177b0..5a1b427 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<Res<ResListData<PluginTemplateModule.ResEntity>>>('/plugin_configs').then((data)
 => {
+    return data.data.rows;
+  });
+};
diff --git a/web/src/helpers.tsx b/web/src/helpers.tsx
index de5bc4a..5ee05b0 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 c2dac51..e84a578 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 59129c1..19b4c6e 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 0000000..3da8cb1
--- /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<PluginComponent.Data>({});
+  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<string, string> = {};
+    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 (
+    <>
+      <PageContainer
+        title={`${(props as any).match.params.id
+          ? formatMessage({ id: 'component.global.edit' })
+          : formatMessage({ id: 'component.global.create' })
+          } ${formatMessage({ id: 'menu.pluginTemplate' })}`}
+      >
+        <Card bordered={false}>
+          <Steps current={step - 1} style={{ marginBottom: 30 }}>
+            <Steps.Step
+              title={formatMessage({ id: 
'component.global.steps.stepTitle.basicInformation' })}
+            />
+            <Steps.Step
+              title={formatMessage({ id: 
'component.global.steps.stepTitle.pluginConfig' })}
+            />
+            <Steps.Step title={formatMessage({ id: 
'component.global.steps.stepTitle.preview' })} />
+          </Steps>
+
+          {step === 1 && <Step1 form={form1} />}
+          {step === 2 && (
+            <PluginPage initialData={plugins} onChange={setPlugins} 
schemaType="route" />
+          )}
+          {step === 3 && <Preview form1={form1} plugins={plugins} />}
+        </Card>
+      </PageContainer>
+      <ActionBar step={step} lastStep={3} onChange={onStepChange} />
+    </>
+  );
+};
+
+export default Page;
diff --git a/web/src/pages/PluginTemplate/List.tsx 
b/web/src/pages/PluginTemplate/List.tsx
new file mode 100644
index 0000000..6e0fa45
--- /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<ActionType>();
+  const [labelList, setLabelList] = useState<LabelList>({});
+  const { formatMessage } = useIntl();
+
+  useEffect(() => {
+    fetchLabelList().then(setLabelList);
+  }, []);
+
+  const handleTableActionSuccessResponse = (msgTip: string) => {
+    notification.success({
+      message: msgTip,
+    });
+
+    ref.current?.reload();
+  };
+
+  const columns: ProColumns<PluginTemplateModule.ResEntity>[] = [
+    {
+      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) => (
+            <Tag key={Math.random().toString(36).slice(2)}>
+              {item}:{record.labels[item]}
+            </Tag>
+          ));
+      }, renderFormItem: (_, { type }) => {
+        if (type === 'form') {
+          return null;
+        }
+
+        return (
+          <Select
+            mode="tags"
+            style={{ width: '100%' }}
+            tagRender={(props) => {
+              const { value, closable, onClose } = props;
+              return (
+                <Tag closable={closable} onClose={onClose} style={{ 
marginRight: 3 }}>
+                  {value}
+                </Tag>
+              );
+            }}
+          >
+            {Object.keys(labelList)
+              .map((key) => {
+                return (
+                  <Select.OptGroup label={key} 
key={Math.random().toString(36).slice(2)}>
+                    {(labelList[key] || []).map((value: string) => (
+                      <Select.Option key={Math.random().toString(36).slice(2)} 
value={`${key}:${value}`}>
+                        {value}
+                      </Select.Option>
+                    ))}
+                  </Select.OptGroup>
+                );
+              })}
+          </Select>
+        );
+      },
+    },
+    {
+      title: formatMessage({ id: 'component.global.operation' }),
+      valueType: 'option',
+      render: (_, record) => (
+        <>
+          <Space align="baseline">
+            <Button
+              type="primary"
+              onClick={() => {
+                history.push(`/plugin-template/${record.id}/edit`)
+              }}
+              style={{ marginRight: 10 }}
+            >
+              {formatMessage({ id: 'component.global.edit' })}
+            </Button>
+
+            <Popconfirm
+              title={formatMessage({ id: 
'component.global.popconfirm.title.delete' })}
+              onConfirm={() => {
+                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' })}
+            >
+              <Button type="primary" danger>
+                {formatMessage({ id: 'component.global.delete' })}
+              </Button>
+            </Popconfirm>
+          </Space>
+        </>
+      ),
+    },
+  ];
+
+  return (
+    <PageHeaderWrapper title={formatMessage({ id: 'page.plugin.list' })}>
+      <ProTable<PluginTemplateModule.ResEntity>
+        actionRef={ref}
+        rowKey="id"
+        columns={columns}
+        request={fetchList}
+        search={{
+          searchText: formatMessage({ id: 'component.global.search' }),
+          resetText: formatMessage({ id: 'component.global.reset' }),
+        }}
+        toolBarRender={() => [
+          <Button type="primary" onClick={() => 
history.push('/plugin-template/create')}>
+            <PlusOutlined />
+            {formatMessage({ id: 'component.global.create' })}
+          </Button>,
+        ]}
+      />
+    </PageHeaderWrapper>
+  );
+};
+
+export default Page;
diff --git a/web/src/components/Plugin/locales/en-US.ts 
b/web/src/pages/PluginTemplate/components/Preview.tsx
similarity index 65%
copy from web/src/components/Plugin/locales/en-US.ts
copy to web/src/pages/PluginTemplate/components/Preview.tsx
index 00e9689..4e140ad 100644
--- a/web/src/components/Plugin/locales/en-US.ts
+++ b/web/src/pages/PluginTemplate/components/Preview.tsx
@@ -14,7 +14,24 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-export default {
-  'component.plugin.tip1': 'NOTE: After customizing the plugin, you need to 
update schema.json.',
-  'component.plugin.tip2': 'How to update?',
+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<Props> = ({ form1, plugins }) => {
+  return (
+    <>
+      <Step1 form={form1} disabled />
+      <PluginPage initialData={plugins} readonly />
+    </>
+  );
+};
+
+export default Page;
diff --git a/web/src/pages/Route/components/Step1/MetaView.tsx 
b/web/src/pages/PluginTemplate/components/Step1.tsx
similarity index 52%
copy from web/src/pages/Route/components/Step1/MetaView.tsx
copy to web/src/pages/PluginTemplate/components/Step1.tsx
index e0728dc..dbeea66 100644
--- a/web/src/pages/Route/components/Step1/MetaView.tsx
+++ b/web/src/pages/PluginTemplate/components/Step1.tsx
@@ -14,30 +14,35 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import React, { useEffect, useState } from 'react';
-import Form from 'antd/es/form';
-import { Input, Switch, Select, Button, Tag, AutoComplete } from 'antd';
+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 { PanelSection } from '@api7-dashboard/ui';
 
-import { FORM_ITEM_WITHOUT_LABEL } from '@/pages/Route/constants';
-import LabelsDrawer from './LabelsDrawer';
-import { fetchLabelList } from '../../service';
+import LabelsDrawer from '@/components/LabelsfDrawer';
+import { fetchLabelList } from '../service';
 
-const MetaView: React.FC<RouteModule.Step1PassProps> = ({ disabled, form, 
isEdit, onChange }) => {
-  const { formatMessage } = useIntl();
-  const [visible, setVisible] = useState(false);
-  const [labelList, setLabelList] = useState<RouteModule.LabelList>({});
+const FORM_LAYOUT = {
+  labelCol: {
+    span: 2,
+  },
+  wrapperCol: {
+    span: 8,
+  },
+};
 
-  useEffect(() => {
-    // TODO: use a better state name
-    fetchLabelList().then(setLabelList);
-  }, []);
+type Props = {
+  form: FormInstance;
+  disabled?: boolean;
+};
+
+const Step1: React.FC<Props> = ({ form, disabled }) => {
+  const [visible, setVisible] = useState(false);
+  const { formatMessage } = useIntl();
 
   const NormalLabelComponent = () => {
     const field = 'custom_normal_labels';
     const title = 'Label Manager';
-
     return (
       <React.Fragment>
         <Form.Item label={formatMessage({ id: 'component.global.labels' })} 
name={field}>
@@ -58,7 +63,7 @@ const MetaView: React.FC<RouteModule.Step1PassProps> = ({ 
disabled, form, isEdit
             }}
           />
         </Form.Item>
-        <Form.Item {...FORM_ITEM_WITHOUT_LABEL}>
+        <Form.Item wrapperCol={{ offset: 2 }}>
           <Button type="dashed" disabled={disabled} onClick={() => 
setVisible(true)}>
             {formatMessage({ id: 'component.global.manage' })}
           </Button>
@@ -73,8 +78,13 @@ const MetaView: React.FC<RouteModule.Step1PassProps> = ({ 
disabled, form, isEdit
                   actionName={field}
                   dataSource={labels}
                   disabled={disabled || false}
-                  onChange={onChange}
+                  onChange={({ data }) => {
+                    const handledLabels = [...new 
Set([...(form.getFieldValue('custom_normal_labels') || []), ...data])];
+                    form.setFieldsValue({ ...form.getFieldsValue(), 
custom_normal_labels: handledLabels });
+                  }}
                   onClose={() => setVisible(false)}
+                  filterList={[]}
+                  fetchLabelList={fetchLabelList}
                 />
               );
             }}
@@ -84,70 +94,19 @@ const MetaView: React.FC<RouteModule.Step1PassProps> = ({ 
disabled, form, isEdit
     );
   };
 
-  const VersionLabelComponent = () => {
-    return (
-      <React.Fragment>
-        <Form.Item
-          label={formatMessage({ id: 'component.global.version' })}
-          name="custom_version_label"
-        >
-          <AutoComplete
-            options={(labelList.API_VERSION || []).map((item) => ({ value: 
item }))}
-            disabled={disabled}
-          />
-        </Form.Item>
-      </React.Fragment>
-    );
-  };
-
   return (
-    <PanelSection title={formatMessage({ id: 
'page.route.panelSection.title.nameDescription' })}>
-      <Form.Item
-        label={formatMessage({ id: 'component.global.name' })}
-        name="name"
-        rules={[
-          {
-            required: true,
-            message: `${formatMessage({ id: 'component.global.pleaseEnter' })} 
${formatMessage({
-              id: 'page.route.form.itemLabel.apiName',
-            })}`,
-          },
-          {
-            pattern: new RegExp(/^[a-zA-Z][a-zA-Z0-9_-]{0,100}$/, 'g'),
-            message: formatMessage({ id: 
'page.route.form.itemRulesPatternMessage.apiNameRule' }),
-          },
-        ]}
-        extra={formatMessage({ id: 
'page.route.form.itemRulesPatternMessage.apiNameRule' })}
-      >
-        <Input
+    <Form {...FORM_LAYOUT} form={form}>
+      <Form.Item label={formatMessage({ id: 'component.global.description' })} 
name="desc">
+        <Input.TextArea
           placeholder={`${formatMessage({ id: 'component.global.pleaseEnter' 
})} ${formatMessage({
-            id: 'page.route.form.itemLabel.apiName',
+            id: 'component.global.description',
           })}`}
           disabled={disabled}
         />
       </Form.Item>
-
       <NormalLabelComponent />
-      <VersionLabelComponent />
-
-      <Form.Item label={formatMessage({ id: 'component.global.description' })} 
name="desc">
-        <Input.TextArea
-          placeholder={formatMessage({ id: 
'component.global.input.placeholder.description' })}
-          disabled={disabled}
-          showCount
-          maxLength={256}
-        />
-      </Form.Item>
-
-      <Form.Item
-        label={formatMessage({ id: 'page.route.publish' })}
-        name="status"
-        valuePropName="checked"
-      >
-        <Switch disabled={isEdit} />
-      </Form.Item>
-    </PanelSection>
+    </Form>
   );
 };
 
-export default MetaView;
+export default Step1;
diff --git a/web/src/pages/PluginTemplate/service.ts 
b/web/src/pages/PluginTemplate/service.ts
new file mode 100644
index 0000000..edd449d
--- /dev/null
+++ b/web/src/pages/PluginTemplate/service.ts
@@ -0,0 +1,59 @@
+/*
+ * 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 { request } from 'umi';
+
+import { transformLabelList } from '@/helpers';
+
+export const fetchList = ({ current = 1, pageSize = 10, ...res }) => {
+  const { labels = [] } = res;
+
+  return request('/plugin_configs', {
+    params: {
+      search: res.desc,
+      label: labels.join(','),
+      page: current,
+      page_size: pageSize,
+    },
+  }).then(({ data }) => {
+    return {
+      data: data.rows,
+      total: data.total_size,
+    }
+  });
+}
+
+export const remove = (rid: string) => request(`/plugin_configs/${rid}`, { 
method: 'DELETE' });
+
+export const fetchItem = (id: string) =>
+  request<{ data: PluginTemplateModule.ResEntity }>(`/plugin_configs/${id}`);
+
+export const create = (data: PluginTemplateModule.Entity) =>
+  request('/plugin_configs', {
+    method: 'POST',
+    data,
+  });
+
+export const update = (id: string, data: PluginTemplateModule.Entity) =>
+  request(`/plugin_configs/${id}`, {
+    method: 'PATCH',
+    data,
+  });
+
+export const fetchLabelList = () =>
+  request('/labels/plugin_config').then(
+    ({ data }) => transformLabelList(data.rows) as LabelList,
+  );
diff --git a/web/src/components/Plugin/locales/en-US.ts 
b/web/src/pages/PluginTemplate/typing.d.ts
similarity index 77%
copy from web/src/components/Plugin/locales/en-US.ts
copy to web/src/pages/PluginTemplate/typing.d.ts
index 00e9689..4e2ee88 100644
--- a/web/src/components/Plugin/locales/en-US.ts
+++ b/web/src/pages/PluginTemplate/typing.d.ts
@@ -14,7 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-export default {
-  'component.plugin.tip1': 'NOTE: After customizing the plugin, you need to 
update schema.json.',
-  'component.plugin.tip2': 'How to update?',
-};
+
+declare namespace PluginTemplateModule {
+  type Entity = {
+    desc: string;
+    labels: Record<string, string>;
+    plugins: Record<string, any>;
+  };
+
+  type ResEntity = Entity & {
+    id: string;
+    update_time: string;
+  };
+}
diff --git a/web/src/pages/Route/Create.tsx b/web/src/pages/Route/Create.tsx
index 0de1d73..f6ac4dd 100644
--- a/web/src/pages/Route/Create.tsx
+++ b/web/src/pages/Route/Create.tsx
@@ -149,8 +149,8 @@ const Page: React.FC<Props> = (props) => {
         <Step3
           data={step3Data}
           isForceHttps={form1.getFieldValue('redirectOption') === 'forceHttps'}
-          onChange={({ plugins, script = INIT_CHART }) => {
-            setStep3Data({ plugins, script });
+          onChange={({ plugins, script = INIT_CHART, plugin_config_id }) => {
+            setStep3Data({ plugins, script, plugin_config_id });
             setChart(script);
           }}
         />
@@ -262,11 +262,10 @@ const Page: React.FC<Props> = (props) => {
   return (
     <>
       <PageHeaderWrapper
-        title={`${
-          (props as any).match.params.rid
-            ? formatMessage({ id: 'component.global.edit' })
-            : formatMessage({ id: 'component.global.create' })
-        } ${formatMessage({ id: 'menu.routes' })}`}
+        title={`${(props as any).match.params.rid
+          ? formatMessage({ id: 'component.global.edit' })
+          : formatMessage({ id: 'component.global.create' })
+          } ${formatMessage({ id: 'menu.routes' })}`}
       >
         <Card bordered={false}>
           <Steps current={step - 1} className={styles.steps}>
diff --git a/web/src/pages/Route/List.tsx b/web/src/pages/Route/List.tsx
index 1abf05f..e82dfbe 100644
--- a/web/src/pages/Route/List.tsx
+++ b/web/src/pages/Route/List.tsx
@@ -69,7 +69,7 @@ const Page: React.FC = () => {
     YAML,
   }
 
-  const [labelList, setLabelList] = useState<RouteModule.LabelList>({});
+  const [labelList, setLabelList] = useState<LabelList>({});
   const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
   const [uploadFileList, setUploadFileList] = useState<RcFile[]>([]);
   const [showImportModal, setShowImportModal] = useState(false);
@@ -426,6 +426,10 @@ const Page: React.FC = () => {
           resetText: formatMessage({ id: 'component.global.reset' }),
         }}
         toolBarRender={() => [
+          <Button type="primary" onClick={() => { 
history.push('/plugin-template/list') }}>
+            <PlusOutlined />
+            {formatMessage({ id: 'page.route.pluginTemplateConfig' })}
+          </Button>,
           <Button type="primary" onClick={() => 
history.push(`/routes/create`)}>
             <PlusOutlined />
             {formatMessage({ id: 'component.global.create' })}
diff --git a/web/src/pages/Route/components/CreateStep4/CreateStep4.tsx 
b/web/src/pages/Route/components/CreateStep4/CreateStep4.tsx
index 11a1034..f999f09 100644
--- a/web/src/pages/Route/components/CreateStep4/CreateStep4.tsx
+++ b/web/src/pages/Route/components/CreateStep4/CreateStep4.tsx
@@ -39,7 +39,7 @@ const style = {
 
 const CreateStep4: React.FC<Props> = ({ form1, form2, redirect, upstreamRef, 
...rest }) => {
   const { formatMessage } = useIntl();
-  const { plugins = {}, script = {} } = rest.step3Data;
+  const { plugins = {}, script = {}, plugin_config_id = '' } = rest.step3Data;
 
   return (
     <>
@@ -59,11 +59,9 @@ const CreateStep4: React.FC<Props> = ({ form1, form2, 
redirect, upstreamRef, ...
           <h2 style={style}>
             {formatMessage({ id: 
'component.global.steps.stepTitle.pluginConfig' })}
           </h2>
-          {Boolean(Object.keys(plugins).length !== 0) && (
-            <PluginPage initialData={rest.step3Data.plugins} readonly />
-          )}
+          {Boolean(Object.keys(plugins).length !== 0 || plugin_config_id !== 
'') && <PluginPage initialData={plugins} plugin_config_id={plugin_config_id} 
showSelector readonly />}
           {Boolean(Object.keys(script).length !== 0) && (
-            <PluginOrchestration data={rest.step3Data.script.chart} readonly 
onChange={() => {}} />
+            <PluginOrchestration data={rest.step3Data.script.chart} readonly 
onChange={() => { }} />
           )}
         </>
       )}
diff --git a/web/src/pages/Route/components/Step1/MetaView.tsx 
b/web/src/pages/Route/components/Step1/MetaView.tsx
index e0728dc..8bfb2e1 100644
--- a/web/src/pages/Route/components/Step1/MetaView.tsx
+++ b/web/src/pages/Route/components/Step1/MetaView.tsx
@@ -21,13 +21,13 @@ import { useIntl } from 'umi';
 import { PanelSection } from '@api7-dashboard/ui';
 
 import { FORM_ITEM_WITHOUT_LABEL } from '@/pages/Route/constants';
-import LabelsDrawer from './LabelsDrawer';
+import LabelsDrawer from '@/components/LabelsfDrawer';
 import { fetchLabelList } from '../../service';
 
 const MetaView: React.FC<RouteModule.Step1PassProps> = ({ disabled, form, 
isEdit, onChange }) => {
   const { formatMessage } = useIntl();
   const [visible, setVisible] = useState(false);
-  const [labelList, setLabelList] = useState<RouteModule.LabelList>({});
+  const [labelList, setLabelList] = useState<LabelList>({});
 
   useEffect(() => {
     // TODO: use a better state name
@@ -75,6 +75,8 @@ const MetaView: React.FC<RouteModule.Step1PassProps> = ({ 
disabled, form, isEdit
                   disabled={disabled || false}
                   onChange={onChange}
                   onClose={() => setVisible(false)}
+                  filterList={["API_VERSION"]}
+                  fetchLabelList={fetchLabelList}
                 />
               );
             }}
diff --git a/web/src/pages/Route/components/Step3/index.tsx 
b/web/src/pages/Route/components/Step3/index.tsx
index fc1aa26..d610817 100644
--- a/web/src/pages/Route/components/Step3/index.tsx
+++ b/web/src/pages/Route/components/Step3/index.tsx
@@ -26,8 +26,9 @@ type Props = {
   data: {
     plugins: PluginComponent.Data;
     script: Record<string, any>;
+    plugin_config_id?: string;
   };
-  onChange: (data: { plugins: PluginComponent.Data; script: any }) => void;
+  onChange: (data: { plugins: PluginComponent.Data; script: any, 
plugin_config_id?: string; }) => void;
   readonly?: boolean;
   isForceHttps: boolean;
 };
@@ -35,7 +36,7 @@ type Props = {
 type Mode = 'NORMAL' | 'DRAW';
 
 const Page: React.FC<Props> = ({ data, onChange, readonly = false, 
isForceHttps }) => {
-  const { plugins = {}, script = {} } = data;
+  const { plugins = {}, script = {}, plugin_config_id = '' } = data;
 
   // NOTE: Currently only compatible with chrome
   const disableDraw = !isChrome || isForceHttps;
@@ -84,9 +85,13 @@ const Page: React.FC<Props> = ({ data, onChange, readonly = 
false, isForceHttps
       {Boolean(mode === 'NORMAL') && (
         <PluginPage
           initialData={plugins}
+          plugin_config_id={plugin_config_id}
           schemaType="route"
           referPage="route"
-          onChange={(pluginsData) => onChange({ plugins: pluginsData, script: 
{} })}
+          showSelector
+          onChange={(pluginsData, id) => {
+            onChange({ plugins: pluginsData, script: {}, plugin_config_id: id 
})
+          }}
         />
       )}
       {Boolean(mode === 'DRAW') && (
diff --git a/web/src/pages/Route/constants.ts b/web/src/pages/Route/constants.ts
index f2a9b39..7707486 100644
--- a/web/src/pages/Route/constants.ts
+++ b/web/src/pages/Route/constants.ts
@@ -60,6 +60,7 @@ export const DEFAULT_STEP_1_DATA: RouteModule.Form1Data = {
 export const DEFAULT_STEP_3_DATA: RouteModule.Step3Data = {
   plugins: {},
   script: {},
+  plugin_config_id: ""
 };
 
 export const INIT_CHART = {
diff --git a/web/src/pages/Route/locales/en-US.ts 
b/web/src/pages/Route/locales/en-US.ts
index 3147138..39d9899 100644
--- a/web/src/pages/Route/locales/en-US.ts
+++ b/web/src/pages/Route/locales/en-US.ts
@@ -18,6 +18,7 @@ export default {
   'page.route.button.returnList': 'Goto List',
   'page.route.button.send': 'Send',
   'page.route.onlineDebug': 'Online Debug',
+  'page.route.pluginTemplateConfig': 'Plugin Template Config',
 
   'page.route.parameterPosition': 'Parameter Position',
   'page.route.httpRequestHeader': 'HTTP Request Header',
diff --git a/web/src/pages/Route/locales/zh-CN.ts 
b/web/src/pages/Route/locales/zh-CN.ts
index e9845f5..74c0bf7 100644
--- a/web/src/pages/Route/locales/zh-CN.ts
+++ b/web/src/pages/Route/locales/zh-CN.ts
@@ -41,6 +41,7 @@ export default {
   'page.route.published': '已发布',
   'page.route.unpublished': '未发布',
   'page.route.onlineDebug': '在线调试',
+  'page.route.pluginTemplateConfig': '插件模版配置',
   'page.route.service': '服务',
   'page.route.instructions': '说明',
   'page.route.import': '导入',
diff --git a/web/src/pages/Route/service.ts b/web/src/pages/Route/service.ts
index 538c186..1d3792e 100644
--- a/web/src/pages/Route/service.ts
+++ b/web/src/pages/Route/service.ts
@@ -21,8 +21,8 @@ import {
   transformStepData,
   transformRouteData,
   transformUpstreamNodes,
-  transformLabelList,
 } from './transform';
+import { transformLabelList } from '@/helpers';
 
 export const create = (data: RouteModule.RequestData) =>
   request(`/routes`, {
@@ -97,7 +97,7 @@ export const checkHostWithSSL = (hosts: string[]) =>
 
 export const fetchLabelList = () =>
   request('/labels/route').then(
-    ({ data }) => transformLabelList(data.rows) as RouteModule.LabelList,
+    ({ data }) => transformLabelList(data.rows) as LabelList,
   );
 
 export const updateRouteStatus = (rid: string, status: 
RouteModule.RouteStatus) =>
diff --git a/web/src/pages/Route/transform.ts b/web/src/pages/Route/transform.ts
index 3b9fa4f..a16cf27 100644
--- a/web/src/pages/Route/transform.ts
+++ b/web/src/pages/Route/transform.ts
@@ -16,14 +16,7 @@
  */
 import { omit, pick, cloneDeep } from 'lodash';
 
-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) };
-  });
-};
+import { transformLableValueToKeyValue } from '@/helpers';
 
 // Transform Route data then sent to API
 export const transformStepData = ({
@@ -113,6 +106,7 @@ export const transformStepData = ({
       form1Data.hosts.filter(Boolean).length === 0 ? 'hosts' : '',
       form1Data.redirectOption === 'disabled' ? 'redirect' : '',
       data.remote_addrs?.filter(Boolean).length === 0 ? 'remote_addrs' : '',
+      step3DataCloned.plugin_config_id === '' ? 'plugin_config_id' : ''
     ]);
   }
 
@@ -128,10 +122,10 @@ export const transformStepData = ({
     'redirect',
     'vars',
     'plugins',
+    'labels',
     service_id.length !== 0 ? 'service_id' : '',
     form1Data.hosts.filter(Boolean).length !== 0 ? 'hosts' : '',
     data.remote_addrs?.filter(Boolean).length !== 0 ? 'remote_addrs' : '',
-    form1Data.custom_version_label.length !== 0 ? 'labels' : '',
   ]);
 };
 
@@ -222,11 +216,12 @@ export const transformRouteData = (data: 
RouteModule.Body) => {
 
   const form2Data: RouteModule.Form2Data = upstream || { upstream_id };
 
-  const { plugins, script } = data;
+  const { plugins, script, plugin_config_id = '' } = data;
 
   const step3Data: RouteModule.Step3Data = {
     plugins,
     script,
+    plugin_config_id,
   };
 
   return {
@@ -236,24 +231,3 @@ export const transformRouteData = (data: RouteModule.Body) 
=> {
     advancedMatchingRules,
   };
 };
-
-export const transformLabelList = (data: RouteModule.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/pages/Route/typing.d.ts b/web/src/pages/Route/typing.d.ts
index 6e34a11..069ea3e 100644
--- a/web/src/pages/Route/typing.d.ts
+++ b/web/src/pages/Route/typing.d.ts
@@ -34,6 +34,7 @@ declare namespace RouteModule {
     plugins: PluginPage.PluginData;
     //  TEMP
     script: any;
+    plugin_config_id?: string
   };
 
   type UpstreamHost = {
@@ -90,6 +91,7 @@ declare namespace RouteModule {
     };
     upstream_id?: string;
     plugins: Record<string, any>;
+    plugin_config_id?: string;
     script: Record<string, any>;
     url?: string;
     enable_websocket?: boolean;
@@ -104,16 +106,6 @@ declare namespace RouteModule {
     key: string;
   };
 
-  type ResponseLabelList = Record<string, string>[];
-
-  type LabelList = Record<string, string[]>;
-
-  type LabelTableProps = {
-    labelKey: string;
-    labelValue: string;
-    key: string;
-  };
-
   type Step1PassProps = {
     form: FormInstance;
     advancedMatchingRules: MatchingRule[];
diff --git a/web/src/typings.d.ts b/web/src/typings.d.ts
index fa45e4d..dc17a7c 100644
--- a/web/src/typings.d.ts
+++ b/web/src/typings.d.ts
@@ -69,3 +69,7 @@ type ResListData<T> = {
 };
 
 type HttpMethod = 'GET' | 'POST' | 'DELETE' | 'PUT' | 'OPTIONS' | 'HEAD' | 
'PATCH';
+
+type ResponseLabelList = Record<string, string>[];
+
+type LabelList = Record<string, string[]>;

Reply via email to