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 d4792fc  feat: add route group (#999)
d4792fc is described below

commit d4792fcf4eea985ea44a76f86e93d5c784d2ae5c
Author: litesun <[email protected]>
AuthorDate: Fri Dec 25 16:36:55 2020 +0800

    feat: add route group (#999)
    
    * feat: added route label search
    
    * fix: react key warning
    
    * feat: add label drawer
    
    * feat: add save and cancel button in drawer
    
    * feat: added edit feature in labelDrawer
    
    * fix: lost click event when div child is empty
    
    * feat: update route  transform with labels
    
    * feat: add AutoComplete
    
    * feat: use form.list render labels
    
    * feat: clean code
    
    * fix: warning when click save button
    
    * feat: update fetchLabelList
    
    * feat: update create route
    
    * feat: update typing
    
    * Merge branch master into feat-route-group-1
    
    * Update component.ts
    
    * feat: update code
    
    * feat: update i18n
    
    Co-authored-by: 琚致远 <[email protected]>
---
 web/src/locales/en-US/component.ts                 |   1 +
 web/src/locales/zh-CN/component.ts                 |   1 +
 web/src/pages/Route/Create.tsx                     |  12 +-
 web/src/pages/Route/List.tsx                       |  61 +++++++-
 .../pages/Route/components/Step1/LabelsDrawer.tsx  | 174 +++++++++++++++++++++
 web/src/pages/Route/components/Step1/MetaView.tsx  |  50 +++++-
 web/src/pages/Route/constants.ts                   |   1 +
 web/src/pages/Route/service.ts                     |  13 +-
 web/src/pages/Route/transform.ts                   |  39 ++++-
 web/src/pages/Route/typing.d.ts                    |  15 +-
 10 files changed, 349 insertions(+), 18 deletions(-)

diff --git a/web/src/locales/en-US/component.ts 
b/web/src/locales/en-US/component.ts
index ac63595..a712701 100644
--- a/web/src/locales/en-US/component.ts
+++ b/web/src/locales/en-US/component.ts
@@ -32,6 +32,7 @@ export default {
   'component.global.loading': 'Loading',
   'component.global.list': 'List',
   'component.global.description': 'Description',
+  'component.global.labels': 'Labels',
   'component.global.operation': 'Operation',
   'component.status.success': 'Successfully',
   'component.status.fail': 'Failed',
diff --git a/web/src/locales/zh-CN/component.ts 
b/web/src/locales/zh-CN/component.ts
index a074362..b62ba40 100644
--- a/web/src/locales/zh-CN/component.ts
+++ b/web/src/locales/zh-CN/component.ts
@@ -32,6 +32,7 @@ export default {
   'component.global.loading': '加载中',
   'component.global.list': '列表',
   'component.global.description': '描述',
+  'component.global.labels': '标签',
   'component.global.operation': '操作',
   'component.status.success': '成功',
   'component.status.fail': '失败',
diff --git a/web/src/pages/Route/Create.tsx b/web/src/pages/Route/Create.tsx
index 5755505..4fcc921 100644
--- a/web/src/pages/Route/Create.tsx
+++ b/web/src/pages/Route/Create.tsx
@@ -112,6 +112,9 @@ const Page: React.FC<Props> = (props) => {
             if (action === 'advancedMatchingRulesChange') {
               setAdvancedMatchingRules(data);
             }
+            if (action === 'labelsChange') {
+              form1.setFieldsValue({ ...form1.getFieldsValue(), labels: data })
+            }
           }}
           isEdit={props.route.path.indexOf('edit') > 0}
         />
@@ -253,11 +256,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 30979fc..c131899 100644
--- a/web/src/pages/Route/List.tsx
+++ b/web/src/pages/Route/List.tsx
@@ -14,21 +14,29 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import React, { useRef, useState } from 'react';
+import React, { useRef, useEffect, useState } from 'react';
 import { PageHeaderWrapper } from '@ant-design/pro-layout';
 import ProTable, { ProColumns, ActionType } from '@ant-design/pro-table';
-import { Button, Popconfirm, notification, Tag, Space } from 'antd';
+import { Button, Popconfirm, notification, Tag, Space, Select } from 'antd';
 import { history, useIntl } from 'umi';
 import { PlusOutlined, BugOutlined } from '@ant-design/icons';
-import { timestampToLocaleString } from '@/helpers';
 
-import { fetchList, remove, updateRouteStatus } from './service';
+import { timestampToLocaleString } from '@/helpers';
+import { fetchList, remove, fetchLabelList, updateRouteStatus } from 
'./service';
 import { DebugDrawView } from './components/DebugViews';
 
+
+const { OptGroup, Option } = Select;
+
 const Page: React.FC = () => {
   const ref = useRef<ActionType>();
   const { formatMessage } = useIntl();
 
+  const [labelList, setLabelList] = useState<RouteModule.LabelList>({});
+
+  useEffect(() => {
+    fetchLabelList().then(setLabelList);
+  }, []);
   enum RouteStatus {
     Offline = 0,
     Publish,
@@ -91,6 +99,47 @@ const Page: React.FC = () => {
       hideInSearch: true,
     },
     {
+      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 (
+                <OptGroup label={key} 
key={Math.random().toString(36).slice(2)}>
+                  {(labelList[key] || []).map((value: string) => (
+                    <Option key={Math.random().toString(36).slice(2)} 
value={`${key}:${value}`}> {value} </Option>
+                  ))}
+                </OptGroup>
+              );
+            })}
+          </Select>
+        );
+      }
+    },
+    {
       title: formatMessage({ id: 'page.route.status' }),
       dataIndex: 'status',
       render: (_, record) => (
@@ -98,8 +147,8 @@ const Page: React.FC = () => {
           {record.status ? (
             <Tag color="green">{formatMessage({ id: 'page.route.published' 
})}</Tag>
           ) : (
-            <Tag color="red">{formatMessage({ id: 'page.route.unpublished' 
})}</Tag>
-          )}
+              <Tag color="red">{formatMessage({ id: 'page.route.unpublished' 
})}</Tag>
+            )}
         </>
       ),
     },
diff --git a/web/src/pages/Route/components/Step1/LabelsDrawer.tsx 
b/web/src/pages/Route/components/Step1/LabelsDrawer.tsx
new file mode 100644
index 0000000..6fb0a70
--- /dev/null
+++ b/web/src/pages/Route/components/Step1/LabelsDrawer.tsx
@@ -0,0 +1,174 @@
+/*
+ * 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, useState } from 'react';
+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';
+
+interface Props extends Pick<RouteModule.Step1PassProps, 'onChange'> {
+  labelsDataSource: string[];
+  disabled: boolean;
+  onClose(): void;
+}
+
+const LabelList = (disabled: boolean, labelList: RouteModule.LabelList) => {
+  const { formatMessage } = useIntl();
+
+  const keyOptions = Object.keys(labelList || {}).map((item) => ({ value: item 
}));
+  return (
+    <Form.List name="labels">
+      {(fields, { add, remove }) => {
+        return (
+          <>
+            {fields.map((field, index) => (
+              <Form.Item
+                key={field.key}
+                label={index === 0 && 'Label'}
+                labelCol={{ span: index === 0 ? 3 : 0 }}
+                wrapperCol={{ offset: index === 0 ? 0 : 3 }}
+              >
+                <Row style={{ marginBottom: 10 }} gutter={16}>
+                  <Col>
+                    <Form.Item
+                      style={{ marginBottom: 0 }}
+                      name={[field.name, 'labelKey']}
+                      rules={[
+                        {
+                          required: true,
+                          message: 'Please input key',
+                        },
+                      ]}
+                    >
+                      <AutoComplete options={keyOptions} style={{ width: 100 
}} />
+                    </Form.Item>
+                  </Col>
+                  <Col>
+                    <Form.Item shouldUpdate noStyle>
+                      {({ getFieldValue }) => {
+                        const key = getFieldValue(['labels', field.name, 
'labelKey']);
+                        let valueOptions = [{ value: '' }];
+                        if (labelList) {
+                          valueOptions = (labelList[key] || []).map((item) => 
({ value: item }));
+                        }
+
+                        return (
+                          <Form.Item
+                            noStyle
+                            name={[field.name, 'labelValue']}
+                            fieldKey={[field.fieldKey, 'labelValue']}
+                            rules={[
+                              {
+                                required: true,
+                                message: 'Please input value',
+                              },
+                            ]}
+                          >
+                            <AutoComplete options={valueOptions} style={{ 
width: 100 }} />
+                          </Form.Item>
+                        );
+                      }}
+                    </Form.Item>
+                  </Col>
+                  <Col>
+                    {!disabled && <MinusCircleOutlined onClick={() => 
remove(field.name)} />}
+                  </Col>
+                </Row>
+              </Form.Item>
+            ))}
+            {!disabled && (
+              <Form.Item wrapperCol={{ offset: 3 }}>
+                <Button type="dashed" onClick={add}>
+                  <PlusOutlined />
+                  {formatMessage({ id: 'component.global.add' })}
+                </Button>
+              </Form.Item>
+            )}
+          </>
+        );
+      }}
+    </Form.List>
+  );
+};
+
+const LabelsDrawer: React.FC<Props> = ({
+  disabled,
+  labelsDataSource,
+  onClose,
+  onChange = () => { },
+}) => {
+  const transformLabel = transformLableValueToKeyValue(labelsDataSource);
+
+  const { formatMessage } = useIntl();
+  const [form] = Form.useForm();
+  const [labelList, setLabelList] = useState<RouteModule.LabelList>({});
+  form.setFieldsValue({ labels: transformLabel });
+
+  useEffect(() => {
+    fetchLabelList().then(setLabelList);
+  }, []);
+
+  return (
+    <Drawer
+      title="Edit labels"
+      placement="right"
+      width={512}
+      visible
+      closable
+      onClose={onClose}
+      footer={
+        <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+          <Button onClick={onClose}>{formatMessage({ id: 
'component.global.cancel' })}</Button>
+          <Button
+            type="primary"
+            style={{ marginRight: 8, marginLeft: 8 }}
+            onClick={(e) => {
+              e.persist();
+              form.validateFields().then(({ labels }) => {
+                const data = labels.map((item: any) => 
`${item.labelKey}:${item.labelValue}`)
+                // check for duplicates
+                if (new Set(data).size !== data.length) {
+                  notification.warning({
+                    message: `Config Error`,
+                    description: 'Please do not enter duplicate labels',
+                  });
+                  return;
+                }
+
+                onChange({
+                  action: 'labelsChange',
+                  data,
+                });
+                onClose();
+              });
+            }}
+          >
+            {formatMessage({ id: 'component.global.confirm' })}
+          </Button>
+        </div >
+      }
+    >
+      <Form form={form} layout="horizontal">
+        {LabelList(disabled, labelList || {})}
+      </Form>
+    </Drawer >
+  );
+};
+
+export default LabelsDrawer;
diff --git a/web/src/pages/Route/components/Step1/MetaView.tsx 
b/web/src/pages/Route/components/Step1/MetaView.tsx
index a7d86eb..8234be9 100644
--- a/web/src/pages/Route/components/Step1/MetaView.tsx
+++ b/web/src/pages/Route/components/Step1/MetaView.tsx
@@ -14,17 +14,38 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import React from 'react';
+import React, { useState } from 'react';
 import Form from 'antd/es/form';
-import { Input, Switch } from 'antd';
+import { Input, Switch, Select, Button, Tag } from 'antd';
 import { useIntl } from 'umi';
 import { PanelSection } from '@api7-dashboard/ui';
 
-const MetaView: React.FC<RouteModule.Step1PassProps> = ({ disabled, isEdit }) 
=> {
+import { FORM_ITEM_WITHOUT_LABEL } from '@/pages/Route/constants';
+import LabelsDrawer from './LabelsDrawer';
+
+const MetaView: React.FC<RouteModule.Step1PassProps> = ({ disabled, form, 
isEdit, onChange, }) => {
   const { formatMessage } = useIntl();
+  const [visible, setVisible] = useState(false);
 
   return (
     <PanelSection title={formatMessage({ id: 
'page.route.panelSection.title.nameDescription' })}>
+      {visible && (
+        <Form.Item shouldUpdate noStyle>
+          {() => {
+            if (form.getFieldValue('labels')) {
+              return (
+                <LabelsDrawer
+                  labelsDataSource={form.getFieldValue('labels')}
+                  disabled={disabled || false}
+                  onChange={onChange}
+                  onClose={() => setVisible(false)}
+                />
+              );
+            }
+            return null;
+          }}
+        </Form.Item>
+      )}
       <Form.Item
         label={formatMessage({ id: 'component.global.name' })}
         name="name"
@@ -49,6 +70,29 @@ const MetaView: React.FC<RouteModule.Step1PassProps> = ({ 
disabled, isEdit }) =>
           disabled={disabled}
         />
       </Form.Item>
+      <Form.Item label={formatMessage({ id: 'component.global.labels' })} 
name="labels">
+        <Select
+          mode="tags"
+          style={{ width: '100%' }}
+          placeholder="--"
+          disabled={disabled}
+          open={false}
+          bordered={false}
+          tagRender={(props) => {
+            const { value, closable, onClose } = props;
+            return (
+              <Tag closable={closable && !disabled} onClose={onClose} style={{ 
marginRight: 3 }}>
+                {value}
+              </Tag>
+            );
+          }}
+        />
+      </Form.Item>
+      <Form.Item {...FORM_ITEM_WITHOUT_LABEL}>
+        <Button disabled={disabled} onClick={() => setVisible(true)}>
+          {formatMessage({ id: 'component.global.edit' })}
+        </Button>
+      </Form.Item>
       <Form.Item label={formatMessage({ id: 'component.global.description' })} 
name="desc">
         <Input.TextArea
           placeholder={formatMessage({ id: 
'component.global.input.placeholder.description' })}
diff --git a/web/src/pages/Route/constants.ts b/web/src/pages/Route/constants.ts
index 1082e5e..ab6c30c 100644
--- a/web/src/pages/Route/constants.ts
+++ b/web/src/pages/Route/constants.ts
@@ -43,6 +43,7 @@ export const FORM_ITEM_WITHOUT_LABEL = {
 export const DEFAULT_STEP_1_DATA: RouteModule.Form1Data = {
   name: '',
   desc: '',
+  labels:[],
   status: 1,
   priority: 0,
   websocket: false,
diff --git a/web/src/pages/Route/service.ts b/web/src/pages/Route/service.ts
index 12e2df1..099428b 100644
--- a/web/src/pages/Route/service.ts
+++ b/web/src/pages/Route/service.ts
@@ -17,7 +17,12 @@
 import { request } from 'umi';
 import { pickBy, identity } from 'lodash';
 
-import { transformStepData, transformRouteData, transformUpstreamNodes } from 
'./transform';
+import {
+  transformStepData,
+  transformRouteData,
+  transformUpstreamNodes,
+  transformLabelList
+} from './transform';
 
 export const create = (data: RouteModule.RequestData) =>
   request(`/routes`, {
@@ -32,13 +37,15 @@ export const update = (rid: number, data: 
RouteModule.RequestData) =>
   });
 
 export const fetchItem = (rid: number) =>
-  request(`/routes/${rid}`).then((data) => transformRouteData(data.data));
+  request(`/routes/${rid}`).then((data) => (transformRouteData(data.data)));
 
 export const fetchList = ({ current = 1, pageSize = 10, ...res }) => {
+  const { labels } = res;
   return request<Res<ResListData<RouteModule.ResponseBody>>>('/routes', {
     params: {
       name: res.name,
       uri: res.uri,
+      label: (labels || []).join(','),
       page: current,
       page_size: pageSize,
     },
@@ -86,6 +93,8 @@ export const checkHostWithSSL = (hosts: string[]) =>
     data: hosts,
   });
 
+export const fetchLabelList = () =>
+  request('/labels/route').then(({ data }) => ((transformLabelList(data.rows)) 
as RouteModule.LabelList));
 
 export const updateRouteStatus = (rid: string, status: 
RouteModule.RouteStatus) =>
   request(`/routes/${rid}`, {
diff --git a/web/src/pages/Route/transform.ts b/web/src/pages/Route/transform.ts
index 3c3de56..c7b442d 100644
--- a/web/src/pages/Route/transform.ts
+++ b/web/src/pages/Route/transform.ts
@@ -16,6 +16,15 @@
  */
 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) };
+  });
+};
+
 export const transformStepData = ({
   form1Data,
   form2Data,
@@ -35,10 +44,15 @@ export const transformStepData = ({
     };
   }
 
+  const labels = {};
+  transformLableValueToKeyValue(form1Data.labels).forEach(item => {
+    labels[item.labelKey] = item.labelValue;
+  })
   const { service_id = '' } = form1Data;
 
   const data: Partial<RouteModule.Body> = {
-    ...form1Data,
+    ...omit(form1Data, 'labels'),
+    labels,
     ...step3DataCloned,
     vars: advancedMatchingRules.map((rule) => {
       const { operator, position, name, value } = rule;
@@ -144,6 +158,7 @@ export const transformRouteData = (data: RouteModule.Body) 
=> {
   const {
     name,
     desc,
+    labels,
     methods = [],
     uris,
     uri,
@@ -165,6 +180,7 @@ export const transformRouteData = (data: RouteModule.Body) 
=> {
     hosts: hosts || (host && [host]) || [''],
     uris: uris || (uri && [uri]) || [],
     remote_addrs: remote_addrs || [''],
+    labels: Object.keys(labels || []).map((item) => `${item}:${labels[item]}`),
     // @ts-ignore
     methods: methods.length ? methods : ["ALL"],
     priority,
@@ -201,3 +217,24 @@ 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 0363b32..335e9dc 100644
--- a/web/src/pages/Route/typing.d.ts
+++ b/web/src/pages/Route/typing.d.ts
@@ -72,6 +72,7 @@ declare namespace RouteModule {
     id?: number;
     status: number;
     name: string;
+    labels: Record<string, string>;
     desc: string;
     priority?: number;
     methods: HttpMethod[];
@@ -118,13 +119,23 @@ 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[];
     disabled?: boolean;
     isEdit?: boolean;
     onChange?(data: {
-      action: 'redirectOptionChange' | 'advancedMatchingRulesChange';
+      action: 'redirectOptionChange' | 'advancedMatchingRulesChange' | 
'labelsChange';
       data: T;
     }): void;
   };
@@ -132,6 +143,7 @@ declare namespace RouteModule {
   type Form1Data = {
     name: string;
     desc: string;
+    labels: string[];
     priority: number;
     websocket: boolean;
     hosts: string[];
@@ -215,6 +227,7 @@ declare namespace RouteModule {
     remote_addrs: string[];
     script: any;
     desc?: string;
+    labels: Record<string, string>;
     upstream: {
       checks: UpstreamModule.HealthCheck;
       create_time: number;

Reply via email to