You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@apisix.apache.org by su...@apache.org on 2021/03/22 04:06:32 UTC

[apisix-dashboard] branch master updated: feat: add rawDataEditor edit mode (#1601)

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 548bbe3  feat: add rawDataEditor edit mode (#1601)
548bbe3 is described below

commit 548bbe38f66ff18a6de6567bf13dc5b81a995a46
Author: litesun <su...@apache.org>
AuthorDate: Mon Mar 22 12:06:26 2021 +0800

    feat: add rawDataEditor edit mode (#1601)
---
 web/cypress/fixtures/rawDataEditor-dataset.json    |  86 ++++++++++++++
 web/cypress/fixtures/selector.json                 |   1 +
 .../rawDataEditor/test-rawDataEditor.spec.js       |  96 ++++++++++++++++
 web/src/components/RawDataEditor/RawDataEditor.tsx | 124 +++++++++++++++++++--
 web/src/constants.ts                               |   2 +
 web/src/locales/en-US/component.ts                 |   1 +
 web/src/locales/zh-CN/component.ts                 |   1 +
 web/src/pages/Consumer/List.tsx                    |  37 ++++--
 web/src/pages/Route/List.tsx                       |  37 ++++--
 .../Route/components/Step1/MatchingRulesView.tsx   |   1 +
 web/src/pages/Route/service.ts                     |   8 +-
 web/src/pages/Route/typing.d.ts                    |   4 +-
 web/src/pages/Service/List.tsx                     |  37 ++++--
 web/src/pages/Upstream/List.tsx                    |  39 +++++--
 14 files changed, 430 insertions(+), 44 deletions(-)

diff --git a/web/cypress/fixtures/rawDataEditor-dataset.json b/web/cypress/fixtures/rawDataEditor-dataset.json
new file mode 100644
index 0000000..613322f
--- /dev/null
+++ b/web/cypress/fixtures/rawDataEditor-dataset.json
@@ -0,0 +1,86 @@
+{
+  "Route": {
+    "uris": [
+      "/*"
+    ],
+    "name": "root",
+    "methods": [
+      "GET",
+      "HEAD",
+      "POST",
+      "PUT",
+      "DELETE",
+      "OPTIONS",
+      "PATCH"
+    ],
+    "upstream": {
+      "nodes": [
+        {
+          "host": "12.12.12.12",
+          "port": 80,
+          "weight": 1
+        }
+      ],
+      "timeout": {
+        "connect": 6000,
+        "read": 6000,
+        "send": 6000
+      },
+      "type": "roundrobin",
+      "pass_host": "pass"
+    },
+    "status": 1
+  },
+  "Upstream": {
+    "nodes": [
+      {
+        "host": "12.12.12.12",
+        "port": 80,
+        "weight": 1
+      }
+    ],
+    "timeout": {
+      "connect": 6,
+      "read": 6,
+      "send": 6
+    },
+    "type": "roundrobin",
+    "pass_host": "pass",
+    "name": "test"
+  },
+  "Service": {
+    "name": "test",
+    "upstream": {
+      "nodes": [
+        {
+          "host": "12.12.12.12",
+          "port": 80,
+          "weight": 1
+        }
+      ],
+      "timeout": {
+        "connect": 6,
+        "read": 6,
+        "send": 6
+      },
+      "type": "roundrobin",
+      "pass_host": "pass"
+    },
+    "plugins": {
+      "jwt-auth": {
+        "disable": false
+      }
+    }
+  },
+  "Consumer": {
+    "username": "test",
+    "desc": "desc",
+    "plugins": {
+      "basic-auth": {
+        "disable": false,
+        "password": "test",
+        "username": "test"
+      }
+    }
+  }
+}
diff --git a/web/cypress/fixtures/selector.json b/web/cypress/fixtures/selector.json
index 121adf1..0e1c8d3 100644
--- a/web/cypress/fixtures/selector.json
+++ b/web/cypress/fixtures/selector.json
@@ -13,6 +13,7 @@
   "pluginCard": ".ant-card",
   "pageContent": ".ant-pro-page-container",
 
+  "tableBody":".ant-table-tbody",
   "tableCell": ".ant-table-cell",
   "empty": ".ant-empty-normal",
   "refresh": ".anticon-reload",
diff --git a/web/cypress/integration/rawDataEditor/test-rawDataEditor.spec.js b/web/cypress/integration/rawDataEditor/test-rawDataEditor.spec.js
new file mode 100644
index 0000000..91856ee
--- /dev/null
+++ b/web/cypress/integration/rawDataEditor/test-rawDataEditor.spec.js
@@ -0,0 +1,96 @@
+/*
+ * 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('Test RawDataEditor', () => {
+  const timeout = 1000;
+
+  beforeEach(() => {
+    cy.login();
+
+    cy.fixture('rawDataEditor-dataset.json').as('dataset');
+    cy.fixture('selector.json').as('domSelector');
+    cy.fixture('data.json').as('data');
+  });
+
+  it('should create and update with rawDataEditor', function () {
+    const menuList = ['Route', 'Service', 'Upstream', 'Consumer'];
+    const publicData = this.data;
+    const dateset = this.dataset;
+    const domSelector = this.domSelector;
+    menuList.forEach(function (item) {
+      cy.visit('/');
+      cy.contains(item).click();
+      cy.contains('Create with Editor').click();
+      const data = dateset[item];
+
+      // create with editor
+      cy.window().then(({ codemirror }) => {
+        if (codemirror) {
+          codemirror.setValue(JSON.stringify(data));
+        }
+        cy.get(domSelector.drawer).should('exist');
+        cy.get(domSelector.drawer, { timeout }).within(() => {
+          cy.contains('Submit').click({
+            force: true,
+          });
+          cy.get(domSelector.drawer).should('not.exist');
+        });
+      });
+
+      cy.reload();
+      // update with editor
+      cy.contains(item === 'Consumer' ? data.username : data.name)
+        .siblings()
+        .contains('View')
+        .click();
+
+      cy.window().then(({ codemirror }) => {
+        if (codemirror) {
+          if (item === 'Consumer') {
+            codemirror.setValue(JSON.stringify({ ...data, desc: 'newDesc' }));
+          } else {
+            codemirror.setValue(JSON.stringify({ ...data, name: 'newName' }));
+          }
+        }
+        cy.get(domSelector.drawer).should('exist');
+        cy.get(domSelector.drawer, { timeout }).within(() => {
+          cy.contains('Submit').click({
+            force: true,
+          });
+          cy.get(domSelector.drawer).should('not.exist');
+        });
+      });
+
+      cy.reload();
+      cy.get(domSelector.tableBody).should('contain', item === 'Consumer' ? 'newDesc' : 'newName');
+
+      // delete resource
+      cy.contains(item === 'Consumer' ? 'newDesc' : 'newName')
+        .siblings()
+        .contains('Delete')
+        .click();
+      cy.contains('button', 'Confirm').click();
+
+      cy.get(domSelector.notification).should('contain', publicData[`delete${item}Success`]);
+      cy.get(domSelector.notificationClose).should('be.visible').click({
+        force: true,
+        multiple: true,
+      });
+    });
+  });
+});
diff --git a/web/src/components/RawDataEditor/RawDataEditor.tsx b/web/src/components/RawDataEditor/RawDataEditor.tsx
index ad8e209..588cebd 100644
--- a/web/src/components/RawDataEditor/RawDataEditor.tsx
+++ b/web/src/components/RawDataEditor/RawDataEditor.tsx
@@ -14,12 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import React, { useRef } from 'react';
-import { Button, Drawer, PageHeader, Tooltip, notification } from 'antd';
+import React, { useEffect, useRef, useState } from 'react';
+import { Button, Drawer, PageHeader, notification, Space, Select } from 'antd';
 import { LinkOutlined } from '@ant-design/icons';
 import CodeMirror from '@uiw/react-codemirror';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { useIntl } from 'umi';
+import { js_beautify } from 'js-beautify';
+
+import { json2yaml, yaml2json } from '../../helpers';
 
 type Props = {
   visible: boolean,
@@ -27,11 +30,81 @@ type Props = {
   type: 'route' | 'service' | 'consumer' | 'upstream'
   data: Record<string, any>,
   onClose?: () => void;
+  onSubmit?: (data: Record<string, any>) => void;
 };
 
-const RawDataEditor: React.FC<Props> = ({ visible, readonly = true, type, data = {}, onClose }) => {
+enum codeMirrorModeList {
+  Json = 'Json',
+  Yaml = 'Yaml',
+}
+
+const RawDataEditor: React.FC<Props> = ({ visible, readonly = true, type, data = {}, onClose = () => { }, onSubmit = () => { } }) => {
   const ref = useRef<any>(null);
   const { formatMessage } = useIntl();
+  const [codeMirrorMode, setCodeMirrorMode] = useState<PluginComponent.CodeMirrorMode>(
+    codeMirrorModeList.Json,
+  );
+
+  useEffect(() => {
+    setCodeMirrorMode(codeMirrorModeList.Json);
+  }, [visible])
+
+  const modeOptions = [
+    { label: codeMirrorModeList.Json, value: codeMirrorModeList.Json },
+    { label: codeMirrorModeList.Yaml, value: codeMirrorModeList.Yaml },
+  ];
+
+  const handleModeChange = (value: PluginComponent.CodeMirrorMode) => {
+    switch (value) {
+      case codeMirrorModeList.Json: {
+        const { data: yamlData, error } = yaml2json(ref.current.editor.getValue(), true);
+
+        if (error) {
+          notification.error({
+            message: 'Invalid Yaml data',
+          });
+          return;
+        }
+        ref.current.editor.setValue(
+          js_beautify(yamlData, {
+            indent_size: 2,
+          }),
+        );
+        break;
+      }
+      case codeMirrorModeList.Yaml: {
+        const { data: jsonData, error } = json2yaml(ref.current.editor.getValue());
+
+        if (error) {
+          notification.error({
+            message: 'Invalid Json data',
+          });
+          return;
+        }
+        ref.current.editor.setValue(jsonData);
+        break;
+      }
+      default:
+        break;
+    }
+    setCodeMirrorMode(value);
+  };
+
+  const formatCodes = () => {
+    try {
+      if (ref.current) {
+        ref.current.editor.setValue(
+          js_beautify(ref.current.editor.getValue(), {
+            indent_size: 2,
+          }),
+        );
+      }
+    } catch (error) {
+      notification.error({
+        message: 'Format failed',
+      });
+    }
+  };
 
   return (
     <>
@@ -41,6 +114,34 @@ const RawDataEditor: React.FC<Props> = ({ visible, readonly = true, type, data =
         width={700}
         visible={visible}
         onClose={onClose}
+        footer={
+          <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+            <Button onClick={onClose} key={1}>
+              {formatMessage({ id: 'component.global.cancel' })}
+            </Button>
+            <Space>
+              <Button
+                key={2}
+                type="primary"
+                onClick={() => {
+                  try {
+                    const editorData =
+                      codeMirrorMode === codeMirrorModeList.Json
+                        ? JSON.parse(ref.current?.editor.getValue())
+                        : yaml2json(ref.current?.editor.getValue(), false).data;
+                    onSubmit(editorData);
+                  } catch (error) {
+                    notification.error({
+                      message: 'Invalid JSON data',
+                    });
+                  }
+                }}
+              >
+                {formatMessage({ id: 'component.global.submit' })}
+              </Button>
+            </Space>
+          </div>
+        }
       >
         <PageHeader
           title=""
@@ -57,11 +158,18 @@ const RawDataEditor: React.FC<Props> = ({ visible, readonly = true, type, data =
             >
               Document
             </Button>,
-            <Tooltip placement="top" title={formatMessage({ id: 'component.rawDataEditor.tip' })}>
-              <Button type="primary" onClick={() => { }} key={2}>
-                Format
-            </Button>
-            </Tooltip>,
+            <Select
+              defaultValue={codeMirrorModeList.Json}
+              value={codeMirrorMode}
+              options={modeOptions}
+              onChange={(value: PluginComponent.CodeMirrorMode) => {
+                handleModeChange(value);
+              }}
+              data-cy='code-mirror-mode'
+            ></Select>,
+            <Button type="primary" onClick={formatCodes} key={2}>
+              Format
+            </Button>,
             <CopyToClipboard text={JSON.stringify(data)} onCopy={(_: string, result: boolean) => {
               if (!result) {
                 notification.error({
diff --git a/web/src/constants.ts b/web/src/constants.ts
index 3df1c0c..f924a05 100644
--- a/web/src/constants.ts
+++ b/web/src/constants.ts
@@ -33,3 +33,5 @@ export const codeMessage = {
 };
 
 export const DEFAULT_GLOBAL_RULE_ID = '1';
+
+export const DELETE_FIELDS = ['id', 'create_time', 'update_time'];
diff --git a/web/src/locales/en-US/component.ts b/web/src/locales/en-US/component.ts
index 0579b26..ee763d0 100644
--- a/web/src/locales/en-US/component.ts
+++ b/web/src/locales/en-US/component.ts
@@ -25,6 +25,7 @@ export default {
   'component.global.cancel': 'Cancel',
   'component.global.submit': 'Submit',
   'component.global.create': 'Create',
+  'component.global.createWithEditor': 'Create with Editor',
   'component.global.add': 'Add',
   'component.global.save': 'Save',
   'component.global.edit': 'Edit',
diff --git a/web/src/locales/zh-CN/component.ts b/web/src/locales/zh-CN/component.ts
index 1ee9e67..7076d05 100644
--- a/web/src/locales/zh-CN/component.ts
+++ b/web/src/locales/zh-CN/component.ts
@@ -25,6 +25,7 @@ export default {
   'component.global.cancel': '取消',
   'component.global.submit': '提交',
   'component.global.create': '创建',
+  'component.global.createWithEditor': '使用编辑器创建',
   'component.global.add': '新建',
   'component.global.save': '保存',
   'component.global.edit': '编辑',
diff --git a/web/src/pages/Consumer/List.tsx b/web/src/pages/Consumer/List.tsx
index 0957383..713e377 100644
--- a/web/src/pages/Consumer/List.tsx
+++ b/web/src/pages/Consumer/List.tsx
@@ -22,17 +22,21 @@ import { Popconfirm, Button, notification } from 'antd';
 import { history, useIntl } from 'umi';
 import { PlusOutlined } from '@ant-design/icons';
 import querystring from 'query-string'
+import { omit } from 'lodash';
 
+import { DELETE_FIELDS } from '@/constants';
 import { timestampToLocaleString } from '@/helpers';
 import { RawDataEditor } from '@/components/RawDataEditor';
 
-import { fetchList, remove } from './service';
+import { fetchList, remove, create, update } from './service';
 
 const Page: React.FC = () => {
   const ref = useRef<ActionType>();
   const { formatMessage } = useIntl();
-  const [rawDataEditorVisible, setRawDataEditorVisible] = useState(false);
-  const [rawData, setRawData] = useState({});
+  const [visible, setVisible] = useState(false);
+  const [rawData, setRawData] = useState<Record<string, any>>({});
+  const [id, setId] = useState('');
+  const [editorMode, setEditorMode] = useState<'create' | 'update'>('create');
   const [paginationConfig, setPaginationConfig] = useState({ pageSize: 10, current: 1 });
 
   const savePageList = (page = 1, pageSize = 10) => {
@@ -74,8 +78,10 @@ const Page: React.FC = () => {
             {formatMessage({ id: 'component.global.edit' })}
           </Button>
           <Button type="primary" style={{ marginRight: 10 }} onClick={() => {
-            setRawData(record);
-            setRawDataEditorVisible(true);
+            setId(record.id);
+            setRawData(omit(record, DELETE_FIELDS));
+            setVisible(true);
+            setEditorMode('update');
           }}>
             {formatMessage({ id: 'component.global.view' })}
           </Button>
@@ -125,14 +131,29 @@ const Page: React.FC = () => {
             <PlusOutlined />
             {formatMessage({ id: 'component.global.create' })}
           </Button>,
+          <Button type="primary" onClick={() => {
+            setVisible(true);
+            setEditorMode('create');
+            setRawData({});
+          }}>
+            <PlusOutlined />
+            {formatMessage({ id: 'component.global.createWithEditor' })}
+          </Button>,
         ]}
       />
       <RawDataEditor
-        visible={rawDataEditorVisible}
+        visible={visible}
         type='consumer'
-        readonly={true}
+        readonly={false}
         data={rawData}
-        onClose={() => { setRawDataEditorVisible(false) }}
+        onClose={() => { setVisible(false) }}
+        onSubmit={(data: any) => {
+          (editorMode === 'create' ? create(data) : update(id, data))
+            .then(() => {
+              setVisible(false);
+              ref.current?.reload();
+            })
+        }}
       />
     </PageContainer>
   );
diff --git a/web/src/pages/Route/List.tsx b/web/src/pages/Route/List.tsx
index c799bf2..2fbbe9f 100644
--- a/web/src/pages/Route/List.tsx
+++ b/web/src/pages/Route/List.tsx
@@ -38,10 +38,14 @@ import yaml from 'js-yaml';
 import moment from 'moment';
 import { saveAs } from 'file-saver';
 import querystring from 'query-string'
+import { omit } from 'lodash';
 
+import { DELETE_FIELDS } from '@/constants';
 import { timestampToLocaleString } from '@/helpers';
 import type { RcFile } from 'antd/lib/upload';
 import {
+  update,
+  create,
   fetchList,
   remove,
   fetchLabelList,
@@ -74,8 +78,10 @@ const Page: React.FC = () => {
   const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
   const [uploadFileList, setUploadFileList] = useState<RcFile[]>([]);
   const [showImportModal, setShowImportModal] = useState(false);
-  const [rawDataEditorVisible, setRawDataEditorVisible] = useState(false);
-  const [rawData, setRawData] = useState({});
+  const [visible, setVisible] = useState(false);
+  const [rawData, setRawData] = useState<Record<string, any>>({});
+  const [id, setId] = useState('');
+  const [editorMode, setEditorMode] = useState<'create' | 'update'>('create');
   const [paginationConfig, setPaginationConfig] = useState({ pageSize: 10, current: 1 });
 
   const savePageList = (page = 1, pageSize = 10) => {
@@ -393,8 +399,10 @@ const Page: React.FC = () => {
               {formatMessage({ id: 'component.global.edit' })}
             </Button>
             <Button type="primary" onClick={() => {
-              setRawData(record);
-              setRawDataEditorVisible(true);
+              setId(record.id);
+              setRawData(omit(record, DELETE_FIELDS));
+              setVisible(true);
+              setEditorMode('update');
             }}>
               {formatMessage({ id: 'component.global.view' })}
             </Button>
@@ -450,6 +458,14 @@ const Page: React.FC = () => {
             <PlusOutlined />
             {formatMessage({ id: 'component.global.create' })}
           </Button>,
+          <Button type="primary" onClick={() => {
+            setVisible(true);
+            setEditorMode('create');
+            setRawData({});
+          }}>
+            <PlusOutlined />
+            {formatMessage({ id: 'component.global.createWithEditor' })}
+          </Button>,
           <Button
             type="primary"
             onClick={() => {
@@ -476,11 +492,18 @@ const Page: React.FC = () => {
         }}
       />
       <RawDataEditor
-        visible={rawDataEditorVisible}
+        visible={visible}
         type='route'
-        readonly={true}
+        readonly={false}
         data={rawData}
-        onClose={() => { setRawDataEditorVisible(false) }}
+        onClose={() => { setVisible(false) }}
+        onSubmit={(data: any) => {
+          (editorMode === 'create' ? create(data, 'RawData') : update(id, data, 'RawData'))
+            .then(() => {
+              setVisible(false);
+              ref.current?.reload();
+            })
+        }}
       />
       <Modal
         title={formatMessage({ id: 'page.route.button.importOpenApi' })}
diff --git a/web/src/pages/Route/components/Step1/MatchingRulesView.tsx b/web/src/pages/Route/components/Step1/MatchingRulesView.tsx
index fdc4985..7216bee 100644
--- a/web/src/pages/Route/components/Step1/MatchingRulesView.tsx
+++ b/web/src/pages/Route/components/Step1/MatchingRulesView.tsx
@@ -170,6 +170,7 @@ const MatchingRulesView: React.FC<RouteModule.Step1PassProps> = ({
       }}
       okText={formatMessage({ id: 'component.global.confirm' })}
       cancelText={formatMessage({ id: 'component.global.cancel' })}
+      maskClosable={false}
       destroyOnClose
     >
       <Form form={modalForm} layout="vertical">
diff --git a/web/src/pages/Route/service.ts b/web/src/pages/Route/service.ts
index 1d3792e..f2edf2e 100644
--- a/web/src/pages/Route/service.ts
+++ b/web/src/pages/Route/service.ts
@@ -24,16 +24,16 @@ import {
 } from './transform';
 import { transformLabelList } from '@/helpers';
 
-export const create = (data: RouteModule.RequestData) =>
+export const create = (data: RouteModule.RequestData, mode?: RouteModule.RequestMode) =>
   request(`/routes`, {
     method: 'POST',
-    data: transformStepData(data),
+    data: mode === 'RawData' ? data : transformStepData(data),
   });
 
-export const update = (rid: number, data: RouteModule.RequestData) =>
+export const update = (rid: string, data: RouteModule.RequestData, mode?: RouteModule.RequestMode) =>
   request(`/routes/${rid}`, {
     method: 'PUT',
-    data: transformStepData(data),
+    data: mode === 'RawData' ? data : transformStepData(data),
   });
 
 export const fetchItem = (rid: number) =>
diff --git a/web/src/pages/Route/typing.d.ts b/web/src/pages/Route/typing.d.ts
index 5145f3e..5fed522 100644
--- a/web/src/pages/Route/typing.d.ts
+++ b/web/src/pages/Route/typing.d.ts
@@ -21,6 +21,8 @@ declare namespace RouteModule {
 
   type RequestProtocol = 'https' | 'http' | 'websocket';
 
+  type RequestMode = 'Normal' | 'RawData';
+
   type BaseData = {
     id?: number;
     name: string;
@@ -145,7 +147,7 @@ declare namespace RouteModule {
     regex_uri?: string[];
     host?: string;
     kvHeaders?: Kvobject[];
-    headers?: Record<string,string>;
+    headers?: Record<string, string>;
   }
 
   type AdvancedMatchingRules = {
diff --git a/web/src/pages/Service/List.tsx b/web/src/pages/Service/List.tsx
index 467be78..39f7970 100644
--- a/web/src/pages/Service/List.tsx
+++ b/web/src/pages/Service/List.tsx
@@ -22,15 +22,19 @@ import type { ActionType, ProColumns } from '@ant-design/pro-table';
 import { PlusOutlined } from '@ant-design/icons';
 import { Button, notification, Popconfirm, Space } from 'antd';
 import querystring from 'query-string'
+import { omit } from 'lodash';
 
+import { DELETE_FIELDS } from '@/constants';
 import { RawDataEditor } from '@/components/RawDataEditor';
-import { fetchList, remove } from './service';
+import { fetchList, remove, create, update } from './service';
 
 const Page: React.FC = () => {
   const ref = useRef<ActionType>();
   const { formatMessage } = useIntl();
-  const [rawDataEditorVisible, setRawDataEditorVisible] = useState(false);
-  const [rawData, setRawData] = useState({});
+  const [visible, setVisible] = useState(false);
+  const [rawData, setRawData] = useState<Record<string, any>>({});
+  const [id, setId] = useState('');
+  const [editorMode, setEditorMode] = useState<'create' | 'update'>('create');
   const [paginationConfig, setPaginationConfig] = useState({ pageSize: 10, current: 1 });
 
   const savePageList = (page = 1, pageSize = 10) => {
@@ -71,8 +75,10 @@ const Page: React.FC = () => {
               {formatMessage({ id: 'component.global.edit' })}
             </Button>
             <Button type="primary" onClick={() => {
-              setRawData(record);
-              setRawDataEditorVisible(true);
+              setId(record.id);
+              setRawData(omit(record, DELETE_FIELDS));
+              setVisible(true);
+              setEditorMode('update');
             }}>
               {formatMessage({ id: 'component.global.view' })}
             </Button>
@@ -123,14 +129,29 @@ const Page: React.FC = () => {
             <PlusOutlined />
             {formatMessage({ id: 'component.global.create' })}
           </Button>,
+          <Button type="primary" onClick={() => {
+            setVisible(true);
+            setEditorMode('create');
+            setRawData({});
+          }}>
+            <PlusOutlined />
+            {formatMessage({ id: 'component.global.createWithEditor' })}
+          </Button>,
         ]}
       />
       <RawDataEditor
-        visible={rawDataEditorVisible}
+        visible={visible}
         type='service'
-        readonly={true}
+        readonly={false}
         data={rawData}
-        onClose={() => { setRawDataEditorVisible(false) }}
+        onClose={() => { setVisible(false) }}
+        onSubmit={(data: any) => {
+          (editorMode === 'create' ? create(data) : update(id, data))
+            .then(() => {
+              setVisible(false);
+              ref.current?.reload();
+            })
+        }}
       />
     </PageHeaderWrapper>
   );
diff --git a/web/src/pages/Upstream/List.tsx b/web/src/pages/Upstream/List.tsx
index ce5d6e7..4872955 100644
--- a/web/src/pages/Upstream/List.tsx
+++ b/web/src/pages/Upstream/List.tsx
@@ -22,20 +22,26 @@ import { Popconfirm, Button, notification } from 'antd';
 import { history, useIntl } from 'umi';
 import { PlusOutlined } from '@ant-design/icons';
 import querystring from 'query-string'
+import { omit } from 'lodash';
+
+import { DELETE_FIELDS } from '@/constants';
 
 import { RawDataEditor } from '@/components/RawDataEditor';
 import { timestampToLocaleString } from '@/helpers';
 
-import { fetchList, remove } from './service';
+import { fetchList, remove, create, update } from './service';
 
 const Page: React.FC = () => {
   const ref = useRef<ActionType>();
-  const [rawDataEditorVisible, setRawDataEditorVisible] = useState(false);
-  const [rawData, setRawData] = useState({});
+  const [visible, setVisible] = useState(false);
+  const [rawData, setRawData] = useState<Record<string, any>>({});
+  const [id, setId] = useState('');
+  const [editorMode, setEditorMode] = useState<'create' | 'update'>('create');
   const [paginationConfig, setPaginationConfig] = useState({ pageSize: 10, current: 1 });
 
   const { formatMessage } = useIntl();
 
+
   const savePageList = (page = 1, pageSize = 10) => {
     history.replace(`/upstream/list?page=${page}&pageSize=${pageSize}`);
   };
@@ -80,8 +86,10 @@ const Page: React.FC = () => {
             {formatMessage({ id: 'page.upstream.list.edit' })}
           </Button>
           <Button type="primary" onClick={() => {
-            setRawData(record);
-            setRawDataEditorVisible(true);
+            setId(record.id);
+            setRawData(omit(record, DELETE_FIELDS));
+            setVisible(true);
+            setEditorMode('update');
           }}>
             {formatMessage({ id: 'component.global.view' })}
           </Button>
@@ -129,14 +137,29 @@ const Page: React.FC = () => {
             <PlusOutlined />
             {formatMessage({ id: 'component.global.create' })}
           </Button>,
+          <Button type="primary" onClick={() => {
+            setVisible(true);
+            setEditorMode('create');
+            setRawData({});
+          }}>
+            <PlusOutlined />
+            {formatMessage({ id: 'component.global.createWithEditor' })}
+          </Button>,
         ]}
       />
       <RawDataEditor
-        visible={rawDataEditorVisible}
+        visible={visible}
         type='upstream'
-        readonly={true}
+        readonly={false}
         data={rawData}
-        onClose={() => { setRawDataEditorVisible(false) }}
+        onClose={() => { setVisible(false) }}
+        onSubmit={(data: any) => {
+          (editorMode === 'create' ? create(data) : update(id, data))
+            .then(() => {
+              setVisible(false);
+              ref.current?.reload();
+            })
+        }}
       />
     </PageContainer>
   );