You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@apisix.apache.org by ju...@apache.org on 2020/03/21 01:49:23 UTC

[incubator-apisix-dashboard] branch next updated: feat: added Plugin typings (#157)

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

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


The following commit(s) were added to refs/heads/next by this push:
     new ff0459c  feat: added Plugin typings (#157)
ff0459c is described below

commit ff0459c58d30db74196395141a107f9866af1dde
Author: 琚致远 <ju...@apache.org>
AuthorDate: Sat Mar 21 09:49:15 2020 +0800

    feat: added Plugin typings (#157)
    
    * feat: added Plugin typings
    
    * style: ajust property position
    
    * style: adjust interface
    
    * feat: added plugin validator
    
    * feat: update validator rule
    
    * feat: destruction object
    
    * feat: added array selector
    
    * feat: update schema
    
    * feat: uuid for key-auth
    
    * feat: added transform for plugin
    
    * codes clean
    
    * feat: added modal title
    
    * feat: refactor transformPropertyToRules
    
    * feat: transform node-status
    
    * feat: transform heartbeat
    
    * style: form layout
    
    * fix: object value
    
    * fix: maxItems
    
    * feat: refactor ArrayComponent
    
    * refactor: PluginForm
    
    * feat: added key
    
    * feat: added base & publicPath
    
    * feat: added i18n
---
 config/config.ts                     |   2 +
 package.json                         |  28 ++++--
 src/components/PluginForm/index.tsx  | 177 +++++++++++++++++++++++++++++++++++
 src/components/PluginModal/index.tsx |  28 ++++++
 src/locales/en-US/component.ts       |   2 +
 src/locales/zh-CN/component.ts       |   2 +
 src/services/plugin.ts               |   7 ++
 src/transforms/plugin.ts             |  88 +++++++++++++++++
 src/typings.d.ts                     |  45 ++++++++-
 9 files changed, 372 insertions(+), 7 deletions(-)

diff --git a/config/config.ts b/config/config.ts
index 369ca8c..ca064fe 100644
--- a/config/config.ts
+++ b/config/config.ts
@@ -86,6 +86,8 @@ export default {
   targets: {
     ie: 11,
   },
+  base: '/dashboard/',
+  publicPath: '/dashboard/',
   // umi routes: https://umijs.org/zh/guide/router.html
   routes: [
     {
diff --git a/package.json b/package.json
index 8f08c2b..c8d9ec2 100644
--- a/package.json
+++ b/package.json
@@ -31,13 +31,23 @@
     "tsc": "tsc",
     "ui": "umi ui"
   },
-  "husky": { "hooks": { "pre-commit": "npm run lint-staged" } },
+  "husky": {
+    "hooks": {
+      "pre-commit": "npm run lint-staged"
+    }
+  },
   "lint-staged": {
     "**/*.less": "stylelint --syntax less",
     "**/*.{js,jsx,ts,tsx}": "npm run lint-staged:js",
-    "**/*.{js,jsx,tsx,ts,less,md,json}": ["prettier --write"]
+    "**/*.{js,jsx,tsx,ts,less,md,json}": [
+      "prettier --write"
+    ]
   },
-  "browserslist": ["> 1%", "last 2 versions", "not ie <= 10"],
+  "browserslist": [
+    "> 1%",
+    "last 2 versions",
+    "not ie <= 10"
+  ],
   "dependencies": {
     "@ant-design/icons": "^4.0.0-alpha.19",
     "@ant-design/pro-layout": "^5.0.0",
@@ -62,7 +72,8 @@
     "umi-plugin-pro-block": "^1.3.2",
     "umi-plugin-react": "^1.14.10",
     "umi-request": "^1.0.8",
-    "use-merge-value": "^1.0.1"
+    "use-merge-value": "^1.0.1",
+    "uuid": "^7.0.2"
   },
   "devDependencies": {
     "@ant-design/pro-cli": "^1.0.18",
@@ -75,6 +86,7 @@
     "@types/react": "^16.9.17",
     "@types/react-dom": "^16.8.4",
     "@types/react-helmet": "^5.0.13",
+    "@types/uuid": "^7.0.0",
     "@umijs/fabric": "^2.0.2",
     "chalk": "^3.0.0",
     "cross-env": "^7.0.0",
@@ -96,8 +108,12 @@
     "umi-plugin-pro": "^1.0.2",
     "umi-types": "^0.5.9"
   },
-  "optionalDependencies": { "puppeteer": "^2.0.0" },
-  "engines": { "node": ">=10.0.0" },
+  "optionalDependencies": {
+    "puppeteer": "^2.0.0"
+  },
+  "engines": {
+    "node": ">=10.0.0"
+  },
   "checkFiles": [
     "src/**/*.js*",
     "src/**/*.ts*",
diff --git a/src/components/PluginForm/index.tsx b/src/components/PluginForm/index.tsx
new file mode 100644
index 0000000..5e7799a
--- /dev/null
+++ b/src/components/PluginForm/index.tsx
@@ -0,0 +1,177 @@
+import React, { useState, useEffect } from 'react';
+import { Form, Input, Switch, Select, InputNumber, Button } from 'antd';
+import { useForm } from 'antd/es/form/util';
+import { PlusOutlined, MinusCircleOutlined } from '@ant-design/icons';
+import { formatMessage } from 'umi-plugin-react/locale';
+
+import { fetchPluginSchema } from '@/services/plugin';
+import { transformPropertyToRules } from '@/transforms/plugin';
+
+interface Props {
+  name: string;
+  initialData?: PluginSchema;
+  onFinish(values: any): void;
+}
+
+const formLayout = {
+  labelCol: { span: 10 },
+  wrapperCol: { span: 14 },
+};
+
+interface RenderComponentProps {
+  placeholder?: string;
+}
+
+const renderComponentByProperty = (
+  propertyValue: PluginProperty,
+  restProps?: RenderComponentProps,
+) => {
+  const { type, minimum, maximum } = propertyValue;
+
+  if (type === 'string') {
+    if (propertyValue.enum) {
+      return (
+        <Select>
+          {propertyValue.enum.map(enumValue => (
+            <Select.Option value={enumValue} key={enumValue}>
+              {enumValue}
+            </Select.Option>
+          ))}
+        </Select>
+      );
+    }
+    return <Input {...restProps} />;
+  }
+
+  if (type === 'boolean') {
+    return <Switch />;
+  }
+
+  if (type === 'number' || type === 'integer') {
+    return (
+      <InputNumber
+        min={minimum ?? Number.MIN_SAFE_INTEGER}
+        max={maximum ?? Number.MAX_SAFE_INTEGER}
+      />
+    );
+  }
+
+  return <Input {...restProps} />;
+};
+
+interface ArrayComponentProps {
+  schema: PluginSchema;
+  propertyName: string;
+  propertyValue: PluginProperty;
+}
+
+const ArrayComponent: React.FC<ArrayComponentProps> = ({
+  propertyName,
+  propertyValue,
+  schema,
+  children,
+}) => (
+  <Form.List key={propertyName} name={propertyName}>
+    {(fields, { add, remove }) => (
+      <>
+        {fields.map((field, index) => (
+          <Form.Item
+            key={field.key}
+            rules={transformPropertyToRules(schema!, propertyName, propertyValue)}
+            label={`${propertyName}-${index + 1}`}
+          >
+            {children}
+            {fields.length > 1 ? (
+              <MinusCircleOutlined onClick={() => remove(field.name)} />
+            ) : (
+              <React.Fragment />
+            )}
+          </Form.Item>
+        ))}
+        {/* BUG: There should also care about minItems */}
+        {fields.length < (propertyValue.maxItems ?? Number.MAX_SAFE_INTEGER) ? (
+          <Form.Item label={propertyName}>
+            <Button type="dashed" onClick={add}>
+              <PlusOutlined /> {formatMessage({ id: 'component.global.add' })}
+            </Button>
+          </Form.Item>
+        ) : null}
+      </>
+    )}
+  </Form.List>
+);
+
+const PluginForm: React.FC<Props> = ({ name, initialData = {}, onFinish }) => {
+  const [schema, setSchema] = useState<PluginSchema>();
+  const [form] = useForm();
+
+  useEffect(() => {
+    if (name) {
+      fetchPluginSchema(name).then(data => {
+        setSchema(data);
+        console.log(name, data);
+
+        const propertyDefaultData = {};
+        Object.entries(data.properties || {}).forEach(([propertyName, propertyValue]) => {
+          if (propertyValue.hasOwnProperty('default')) {
+            propertyDefaultData[propertyName] = propertyValue.default;
+          }
+        });
+        form.setFieldsValue(propertyDefaultData);
+
+        requestAnimationFrame(() => {
+          form.setFieldsValue(initialData);
+        });
+      });
+    }
+  }, [name]);
+
+  return (
+    <Form {...formLayout} form={form} onFinish={onFinish} labelAlign="left">
+      {Object.entries(schema?.properties || {}).map(([propertyName, propertyValue]) => {
+        // eslint-disable-next-line arrow-body-style
+        if (propertyValue.type === 'array') {
+          return (
+            <ArrayComponent
+              key={propertyName}
+              schema={schema!}
+              propertyName={propertyName}
+              propertyValue={propertyValue}
+            >
+              {renderComponentByProperty({ type: 'string' })}
+            </ArrayComponent>
+          );
+        }
+
+        if (propertyValue.type === 'object') {
+          return (
+            <ArrayComponent
+              key={propertyName}
+              schema={schema!}
+              propertyName={propertyName}
+              propertyValue={propertyValue}
+            >
+              {/* TODO: there should not be fixed value, and it should receive custom key */}
+              {renderComponentByProperty({ type: 'string' }, { placeholder: 'Header' })}
+              {renderComponentByProperty({ type: 'string' }, { placeholder: 'Value' })}
+            </ArrayComponent>
+          );
+        }
+
+        return (
+          <Form.Item
+            label={propertyName}
+            name={propertyName}
+            key={propertyName}
+            rules={transformPropertyToRules(schema!, propertyName, propertyValue)}
+            valuePropName={propertyValue.type === 'boolean' ? 'checked' : 'value'}
+          >
+            {renderComponentByProperty(propertyValue)}
+          </Form.Item>
+        );
+      })}
+    </Form>
+  );
+};
+
+export default PluginForm;
diff --git a/src/components/PluginModal/index.tsx b/src/components/PluginModal/index.tsx
new file mode 100644
index 0000000..dfdd4d0
--- /dev/null
+++ b/src/components/PluginModal/index.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import { Modal } from 'antd';
+import { formatMessage } from 'umi-plugin-react/locale';
+
+import PluginForm from '@/components/PluginForm';
+
+interface Props {
+  visible: boolean;
+  name: string;
+  initialData?: PluginSchema;
+  onFinish(values: any): void;
+}
+
+const PluginModal: React.FC<Props> = props => {
+  const { name, visible } = props;
+
+  return (
+    <Modal
+      destroyOnClose
+      visible={visible}
+      title={`${formatMessage({ id: 'component.global.edit.plugin' })} ${name}`}
+    >
+      <PluginForm {...props} />
+    </Modal>
+  );
+};
+
+export default PluginModal;
diff --git a/src/locales/en-US/component.ts b/src/locales/en-US/component.ts
index 112f2ef..0157420 100644
--- a/src/locales/en-US/component.ts
+++ b/src/locales/en-US/component.ts
@@ -6,11 +6,13 @@ export default {
   'component.global.cancel': 'Cancel',
   'component.global.submit': 'Submit',
   'component.global.create': 'Create',
+  'component.global.add': 'Add',
   'component.global.save': 'Save',
   'component.global.edit': 'Edit',
   'component.global.action': 'Action',
   'component.global.update': 'Update',
   'component.global.get': 'Get',
+  'component.global.edit.plugin': 'Edit plugin',
   'component.status.success': 'Successfully',
   'component.status.fail': 'Failed',
   // SSL Module
diff --git a/src/locales/zh-CN/component.ts b/src/locales/zh-CN/component.ts
index 3e77ed0..5999e3c 100644
--- a/src/locales/zh-CN/component.ts
+++ b/src/locales/zh-CN/component.ts
@@ -6,11 +6,13 @@ export default {
   'component.global.cancel': '取消',
   'component.global.submit': '提交',
   'component.global.create': '创建',
+  'component.global.add': '增加',
   'component.global.save': '保存',
   'component.global.edit': '编辑',
   'component.global.action': '操作',
   'component.global.update': '更新',
   'component.global.get': '获取',
+  'component.global.edit.plugin': '编辑插件',
   'component.status.success': '成功',
   'component.status.fail': '失败',
   // SSL 模块
diff --git a/src/services/plugin.ts b/src/services/plugin.ts
new file mode 100644
index 0000000..b75f23f
--- /dev/null
+++ b/src/services/plugin.ts
@@ -0,0 +1,7 @@
+import request from '@/utils/request';
+import { transformSchemaFromAPI } from '@/transforms/plugin';
+
+export const fetchList = () => request('/api/plugins/list');
+
+export const fetchPluginSchema = (name: string): Promise<PluginSchema> =>
+  request(`/api/schema/plugins/${name}`).then(data => transformSchemaFromAPI(data, name));
diff --git a/src/transforms/plugin.ts b/src/transforms/plugin.ts
new file mode 100644
index 0000000..39b66f7
--- /dev/null
+++ b/src/transforms/plugin.ts
@@ -0,0 +1,88 @@
+import { v4 as uuidv4 } from 'uuid';
+import { Rule } from 'antd/es/form';
+
+/**
+ * Transform schema data from API for target plugin.
+ */
+export const transformSchemaFromAPI = (schema: PluginSchema, pluginName: string): PluginSchema => {
+  if (pluginName === 'key-auth') {
+    return {
+      ...schema,
+      properties: {
+        key: {
+          ...schema.properties!.key,
+          default: uuidv4(),
+        },
+      },
+    };
+  }
+
+  if (pluginName === 'prometheus' || pluginName === 'node-status' || pluginName === 'heartbeat') {
+    return {
+      ...schema,
+      properties: {
+        enabled: {
+          // TODO: i18n
+          type: 'boolean',
+          default: false,
+        },
+      },
+    };
+  }
+
+  return schema;
+};
+
+/**
+ * Transform schema data to be compatible with API.
+ */
+// eslint-disable-next-line arrow-body-style
+export const transformSchemaToAPI = (schema: PluginSchema, pluginName: string) => {
+  return { schema, pluginName };
+};
+
+/**
+ * Transform schema's property to rules.
+ */
+export const transformPropertyToRules = (
+  schema: PluginSchema,
+  propertyName: string,
+  propertyValue: PluginProperty,
+): Rule[] => {
+  if (!schema) {
+    return [];
+  }
+
+  const { type, minLength, maxLength, minimum, maximum, pattern } = propertyValue;
+
+  const requiredRule = schema.required?.includes(propertyName) ? [{ required: true }] : [];
+  const typeRule = [{ type }];
+  const enumRule = propertyValue.enum ? [{ type: 'enum', enum: propertyValue.enum }] : [];
+  const rangeRule =
+    type !== 'string' &&
+    type !== 'array' &&
+    (propertyValue.hasOwnProperty('minimum') || propertyValue.hasOwnProperty('maximum'))
+      ? [
+          {
+            min: minimum ?? Number.MIN_SAFE_INTEGER,
+            max: maximum ?? Number.MAX_SAFE_INTEGER,
+          },
+        ]
+      : [];
+  const lengthRule =
+    type === 'string' || type === 'array'
+      ? [{ min: minLength ?? Number.MIN_SAFE_INTEGER, max: maxLength ?? Number.MAX_SAFE_INTEGER }]
+      : [];
+  const customPattern = pattern ? [{ pattern: new RegExp(pattern) }] : [];
+
+  const rules = [
+    ...requiredRule,
+    ...typeRule,
+    ...enumRule,
+    ...rangeRule,
+    ...lengthRule,
+    ...customPattern,
+  ];
+  const flattend = rules.reduce((prev, next) => ({ ...prev, ...next }));
+  return [flattend] as Rule[];
+};
diff --git a/src/typings.d.ts b/src/typings.d.ts
index c2f9e64..b52e470 100644
--- a/src/typings.d.ts
+++ b/src/typings.d.ts
@@ -37,4 +37,47 @@ declare let ANT_DESIGN_PRO_ONLY_DO_NOT_USE_IN_YOUR_PRODUCTION: 'site' | undefine
 
 declare const REACT_APP_ENV: 'test' | 'dev' | 'pre' | false;
 
-declare type PageMode = 'CREATE' | 'EDIT' | 'VIEW';
+type PageMode = 'CREATE' | 'EDIT' | 'VIEW';
+
+interface PluginProperty {
+  type: 'number' | 'string' | 'integer' | 'array' | 'boolean' | 'object';
+  // the same as type
+  default?: any;
+  description?: string;
+  // NOTE: maybe 0.00001
+  minimum?: number;
+  maximum?: number;
+  minLength?: number;
+  maxLength?: number;
+  minItems?: number;
+  maxItems?: number;
+  // e.g "^/.*"
+  pattern?: string;
+  enum?: string[];
+  requried?: string[];
+  minProperties?: number;
+  additionalProperties?: boolean;
+  items?: {
+    type: string;
+    anyOf?: Array<{
+      type?: string;
+      description?: string;
+      enum?: string[];
+      pattern?: string;
+    }>;
+  };
+}
+
+interface PluginSchema {
+  type: 'object';
+  id?: string;
+  required?: string[];
+  additionalProperties?: boolean;
+  minProperties?: number;
+  oneOf?: Array<{
+    required: string[];
+  }>;
+  properties?: {
+    [propertyName: string]: PluginProperty;
+  };
+}