You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@devlake.apache.org by li...@apache.org on 2023/02/13 08:26:21 UTC

[incubator-devlake] branch main updated: refactor(config-ui): adjust the connections (#4372)

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

likyh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new 115116b60 refactor(config-ui): adjust the connections (#4372)
115116b60 is described below

commit 115116b60b38a5ef69dc4a5b599ee998aa4314e8
Author: 青湛 <0x...@gmail.com>
AuthorDate: Mon Feb 13 16:26:17 2023 +0800

    refactor(config-ui): adjust the connections (#4372)
    
    * fix(config-ui): adjust the style for connection home page
    
    * refactor(config-ui): adjust the configuration of the plugin
    
    * feat(config-ui): encapsulate plugin connection form component
    
    * refactor(config-ui): use plugin connection form to replace page
    
    * fix(config-ui): the initialization value incorrect
---
 .../connection/form/components/jira-auth/index.tsx | 115 -------------
 .../form/components/rate-limit/index.tsx           |  58 -------
 config-ui/src/pages/connection/form/index.tsx      | 190 +--------------------
 config-ui/src/pages/connection/form/use-form.ts    |  95 -----------
 config-ui/src/pages/connection/home/styled.ts      |  27 ++-
 .../components/connection-form}/api.ts             |   0
 .../components/connection-form/fields/endpoint.tsx | 111 ++++++++++++
 .../components/connection-form/fields/index.tsx    | 113 ++++++++++++
 .../components/connection-form/fields/name.tsx}    |  38 ++---
 .../components/connection-form/fields/password.tsx |  57 +++++++
 .../components/connection-form/fields/proxy.tsx    |  60 +++++++
 .../connection-form/fields/rate-limit.tsx          |  94 ++++++++++
 .../components/connection-form/fields}/styled.ts   |  27 ++-
 .../components/connection-form/fields/token.tsx    |  62 +++++++
 .../components/connection-form/fields/username.tsx |  56 ++++++
 .../plugins/components/connection-form/index.tsx   |  80 +++++++++
 .../components/connection-form/operate}/index.ts   |   6 +-
 .../components/connection-form/operate/save.tsx    |  63 +++++++
 .../components/connection-form/operate/test.tsx}   |  42 +++--
 .../components/connection-form}/styled.ts          |  76 ++++-----
 config-ui/src/plugins/components/index.ts          |   1 +
 config-ui/src/plugins/register/base/config.ts      |  14 --
 .../register/bitbucket/{config.ts => config.tsx}   |  62 +++----
 config-ui/src/plugins/register/github/api.ts       |   2 +
 .../register/github/{config.ts => config.tsx}      |  50 +++---
 .../register/github/connection-fields/graphql.tsx} |  41 ++---
 .../register/github/connection-fields/index.ts}    |  12 +-
 .../register/github/connection-fields}/styled.ts   |  52 +++---
 .../register/github/connection-fields/token.tsx}   |  91 +++++-----
 config-ui/src/plugins/register/gitlab/config.ts    |  60 -------
 config-ui/src/plugins/register/gitlab/config.tsx   |  74 ++++++++
 config-ui/src/plugins/register/jenkins/config.ts   |  46 +++--
 .../register/jira/{config.ts => config.tsx}        |  48 +++---
 .../register/jira/connection-fields/auth.tsx       | 126 ++++++++++++++
 .../jira/connection-fields}/index.ts               |   4 +-
 .../register/jira/connection-fields}/styled.ts     |  67 ++++----
 config-ui/src/plugins/register/tapd/config.ts      |  46 +++--
 config-ui/src/plugins/register/zentao/config.ts    |  43 ++---
 config-ui/src/plugins/types.ts                     |  12 +-
 39 files changed, 1288 insertions(+), 933 deletions(-)

diff --git a/config-ui/src/pages/connection/form/components/jira-auth/index.tsx b/config-ui/src/pages/connection/form/components/jira-auth/index.tsx
deleted file mode 100644
index 638b15324..000000000
--- a/config-ui/src/pages/connection/form/components/jira-auth/index.tsx
+++ /dev/null
@@ -1,115 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *
- */
-
-import React, { useState } from 'react';
-import { FormGroup, RadioGroup, Radio, InputGroup } from '@blueprintjs/core';
-
-type Method = 'BasicAuth' | 'AccessToken';
-
-type Value = {
-  authMethod: string;
-  username?: string;
-  password?: string;
-  token?: string;
-};
-
-interface Props {
-  value: Value;
-  onChange: (value: Value) => void;
-}
-
-export const JIRAAuth = ({ value, onChange }: Props) => {
-  const [method, setMethod] = useState<Method>('BasicAuth');
-
-  const handleChangeMethod = (e: React.FormEvent<HTMLInputElement>) => {
-    const m = (e.target as HTMLInputElement).value as Method;
-
-    setMethod(m);
-    onChange({
-      authMethod: m,
-      username: m === 'BasicAuth' ? value.username : undefined,
-      password: m === 'BasicAuth' ? value.password : undefined,
-      token: m === 'AccessToken' ? value.token : undefined,
-    });
-  };
-
-  const handleChangeUsername = (e: React.ChangeEvent<HTMLInputElement>) => {
-    onChange({
-      authMethod: 'BasicAuth',
-      username: e.target.value,
-    });
-  };
-
-  const handleChangePassword = (e: React.ChangeEvent<HTMLInputElement>) => {
-    onChange({
-      authMethod: 'BasicAuth',
-      password: e.target.value,
-    });
-  };
-
-  const handleChangeToken = (e: React.ChangeEvent<HTMLInputElement>) => {
-    onChange({
-      ...value,
-      token: e.target.value,
-    });
-  };
-
-  return (
-    <div>
-      <FormGroup inline label="Authentication Method" labelInfo="*">
-        <RadioGroup selectedValue={method} onChange={handleChangeMethod}>
-          <Radio value="BasicAuth">Basic Authentication</Radio>
-          <Radio value="AccessToken">Using Personal Access Token</Radio>
-        </RadioGroup>
-      </FormGroup>
-      {method === 'BasicAuth' && (
-        <>
-          <FormGroup inline label="Username/e-mail" labelInfo="*">
-            <InputGroup
-              placeholder="Your Username/e-mail"
-              value={value.username || ''}
-              onChange={handleChangeUsername}
-            />
-          </FormGroup>
-          <FormGroup inline label="Password" labelInfo="*">
-            <InputGroup
-              type="password"
-              placeholder="Your Password"
-              value={value.password || ''}
-              onChange={handleChangePassword}
-            />
-          </FormGroup>
-        </>
-      )}
-      {method === 'AccessToken' && (
-        <FormGroup inline label="Personal Access Token" labelInfo="*">
-          <p>
-            <a
-              href="https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html"
-              target="_blank"
-              rel="noreferrer"
-            >
-              Learn about how to create PAT
-            </a>
-          </p>
-          <InputGroup type="password" placeholder="Your PAT" value={value.token || ''} onChange={handleChangeToken} />
-        </FormGroup>
-      )}
-    </div>
-  );
-};
diff --git a/config-ui/src/pages/connection/form/components/rate-limit/index.tsx b/config-ui/src/pages/connection/form/components/rate-limit/index.tsx
deleted file mode 100644
index 6b7fcffb1..000000000
--- a/config-ui/src/pages/connection/form/components/rate-limit/index.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-/*
- * 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 { NumericInput, Switch } from '@blueprintjs/core';
-
-import * as S from './styled';
-
-interface Props {
-  initialValue?: number;
-  value?: number;
-  onChange?: (value: number) => void;
-}
-
-export const RateLimit = ({ initialValue, value, onChange }: Props) => {
-  const [show, setShow] = useState(false);
-
-  useEffect(() => {
-    setShow(value ? true : false);
-  }, [value]);
-
-  const handleChangeValue = (value: number) => {
-    onChange?.(show ? value : 0);
-  };
-
-  const handleChangeShow = (e: React.FormEvent<HTMLInputElement>) => {
-    const checked = (e.target as HTMLInputElement).checked;
-    setShow(checked);
-    if (!checked) {
-      onChange?.(0);
-    } else {
-      onChange?.(initialValue ?? 0);
-    }
-  };
-
-  return (
-    <S.Wrapper>
-      {show && <NumericInput value={value} onValueChange={handleChangeValue} />}
-      <Switch checked={show} onChange={handleChangeShow} />
-      <span>{show ? `Enabled - ${value} Requests/hr` : 'Disabled'}</span>
-    </S.Wrapper>
-  );
-};
diff --git a/config-ui/src/pages/connection/form/index.tsx b/config-ui/src/pages/connection/form/index.tsx
index dfb34be7b..387e9a338 100644
--- a/config-ui/src/pages/connection/form/index.tsx
+++ b/config-ui/src/pages/connection/form/index.tsx
@@ -16,170 +16,17 @@
  *
  */
 
-import React, { useState, useEffect, useMemo } from 'react';
-import { useParams, useHistory } from 'react-router-dom';
-import { omit, pick } from 'lodash';
-import { FormGroup, InputGroup, Switch, ButtonGroup, Button, Icon, Intent, Position } from '@blueprintjs/core';
-import { Tooltip2 } from '@blueprintjs/popover2';
+import React, { useMemo } from 'react';
+import { useParams } from 'react-router-dom';
 
-import { PageHeader, Card, PageLoading } from '@/components';
-import type { PluginConfigConnectionType } from '@/plugins';
-import { PluginConfig } from '@/plugins';
-
-import { RateLimit, GitHubToken, GitLabToken, JIRAAuth } from './components';
-import { useForm } from './use-form';
-import * as S from './styled';
+import { PageHeader } from '@/components';
+import { PluginConfigConnectionType } from '@/plugins';
+import { PluginConfig, ConnectionForm } from '@/plugins';
 
 export const ConnectionFormPage = () => {
-  const [form, setForm] = useState<Record<string, any>>({});
-
-  const history = useHistory();
   const { plugin, cid } = useParams<{ plugin: string; cid?: string }>();
-  const { loading, operating, connection, onTest, onCreate, onUpdate } = useForm({ plugin, id: cid });
-
-  const {
-    name,
-    connection: { initialValues, fields },
-  } = useMemo(() => PluginConfig.find((p) => p.plugin === plugin) as PluginConfigConnectionType, [plugin]);
-
-  useEffect(() => {
-    setForm({
-      ...form,
-      ...omit(initialValues, 'rateLimitPerHour'),
-      ...(connection ?? {}),
-    });
-  }, [initialValues, connection]);
-
-  const error = useMemo(
-    () =>
-      fields.some((field) => {
-        if (field.required) {
-          return !form[field.key];
-        }
-
-        if (field.checkError) {
-          return !field.checkError(form);
-        }
-
-        return false;
-      }),
-    [form, fields],
-  );
-
-  const handleTest = () =>
-    onTest(pick(form, ['endpoint', 'token', 'username', 'password', 'app_id', 'secret_key', 'proxy', 'authMethod']));
-
-  const handleCancel = () => history.push(`/connections/${plugin}`);
-
-  const handleSave = () => (cid ? onUpdate(cid, form) : onCreate(form));
-
-  const getFormItem = ({
-    key,
-    label,
-    type,
-    required,
-    placeholder,
-    tooltip,
-  }: PluginConfigConnectionType['connection']['fields']['0']) => {
-    if (type === 'jiraAuth') {
-      return (
-        <JIRAAuth
-          key={key}
-          value={{ authMethod: form.authMethod, username: form.username, password: form.password, token: form.token }}
-          onChange={(value) => {
-            setForm({
-              ...form,
-              ...value,
-            });
-          }}
-        />
-      );
-    }
 
-    return (
-      <FormGroup
-        key={key}
-        inline
-        label={
-          <S.Label>
-            <span>{label}</span>
-            {tooltip && (
-              <Tooltip2 position={Position.TOP} content={tooltip}>
-                <Icon icon="help" size={12} />
-              </Tooltip2>
-            )}
-          </S.Label>
-        }
-        labelFor={key}
-        labelInfo={required ? '*' : ''}
-      >
-        {type === 'text' && (
-          <InputGroup
-            placeholder={placeholder}
-            value={form[key] ?? ''}
-            onChange={(e) => setForm({ ...form, [`${key}`]: e.target.value })}
-          />
-        )}
-        {type === 'password' && (
-          <InputGroup
-            placeholder={placeholder}
-            type="password"
-            value={form[key] ?? ''}
-            onChange={(e) => setForm({ ...form, [`${key}`]: e.target.value })}
-          />
-        )}
-        {type === 'switch' && (
-          <S.SwitchWrapper>
-            <Switch
-              checked={form[key] ?? false}
-              onChange={(e) =>
-                setForm({
-                  ...form,
-                  [key]: (e.target as HTMLInputElement).checked,
-                })
-              }
-            />
-          </S.SwitchWrapper>
-        )}
-        {type === 'rateLimit' && (
-          <RateLimit
-            initialValue={initialValues?.['rateLimitPerHour']}
-            value={form.rateLimitPerHour}
-            onChange={(value) =>
-              setForm({
-                ...form,
-                rateLimitPerHour: value,
-              })
-            }
-          />
-        )}
-        {type === 'githubToken' && (
-          <GitHubToken
-            form={form}
-            value={form.token}
-            onChange={(value) =>
-              setForm({
-                ...form,
-                token: value,
-              })
-            }
-          />
-        )}
-        {type === 'gitlabToken' && (
-          <GitLabToken
-            placeholder={placeholder}
-            value={form.token}
-            onChange={(value) =>
-              setForm({
-                ...form,
-                token: value,
-              })
-            }
-          />
-        )}
-      </FormGroup>
-    );
-  };
+  const { name } = useMemo(() => PluginConfig.find((p) => p.plugin === plugin) as PluginConfigConnectionType, [plugin]);
 
   return (
     <PageHeader
@@ -187,33 +34,12 @@ export const ConnectionFormPage = () => {
         { name: 'Connections', path: '/connections' },
         { name, path: `/connections/${plugin}` },
         {
-          name: cid ? cid : 'Create',
+          name: cid ? cid : 'Create a New Connection',
           path: `/connections/${plugin}/${cid ? cid : 'create'}`,
         },
       ]}
     >
-      {loading ? (
-        <PageLoading />
-      ) : (
-        <Card>
-          <S.Wrapper>
-            {fields.map((field) => getFormItem(field))}
-            <div className="footer">
-              <Button disabled={error} loading={operating} text="Test Connection" onClick={handleTest} />
-              <ButtonGroup>
-                <Button text="Cancel" onClick={handleCancel} />
-                <Button
-                  disabled={error}
-                  loading={operating}
-                  intent={Intent.PRIMARY}
-                  text="Save Connection"
-                  onClick={handleSave}
-                />
-              </ButtonGroup>
-            </div>
-          </S.Wrapper>
-        </Card>
-      )}
+      <ConnectionForm plugin={plugin} connectionId={cid} />
     </PageHeader>
   );
 };
diff --git a/config-ui/src/pages/connection/form/use-form.ts b/config-ui/src/pages/connection/form/use-form.ts
deleted file mode 100644
index 5c9431c66..000000000
--- a/config-ui/src/pages/connection/form/use-form.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-/*
- * 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 { useState, useMemo, useEffect } from 'react';
-import { useHistory } from 'react-router-dom';
-
-import { operator } from '@/utils';
-
-import * as API from './api';
-
-interface Props {
-  plugin: string;
-  id?: ID;
-}
-
-export const useForm = ({ plugin, id }: Props) => {
-  const [loading, setLoading] = useState(false);
-  const [operating, setOperating] = useState(false);
-  const [connection, setConnection] = useState<any>();
-
-  const history = useHistory();
-
-  const getConnection = async () => {
-    if (!id) return;
-
-    setLoading(true);
-    try {
-      const res = await API.getConnection(plugin, id);
-      setConnection(res);
-    } finally {
-      setLoading(false);
-    }
-  };
-
-  useEffect(() => {
-    getConnection();
-  }, []);
-
-  const handleTest = async (payload: any) => {
-    const [success] = await operator(() => API.testConnection(plugin, payload), {
-      setOperating,
-      formatReason: () => 'Test Failed.Please check your configuration.',
-    });
-
-    if (success) {
-    }
-  };
-
-  const handleCreate = async (payload: any) => {
-    const [success] = await operator(() => API.createConnection(plugin, payload), {
-      setOperating,
-    });
-
-    if (success) {
-      history.push(`/connections/${plugin}`);
-    }
-  };
-
-  const handleUpdate = async (id: ID, payload: any) => {
-    const [success] = await operator(() => API.updateConnection(plugin, id, payload), {
-      setOperating,
-    });
-
-    if (success) {
-      history.push(`/connections/${plugin}`);
-    }
-  };
-
-  return useMemo(
-    () => ({
-      loading,
-      operating,
-      connection,
-      onTest: handleTest,
-      onCreate: handleCreate,
-      onUpdate: handleUpdate,
-    }),
-    [loading, operating, connection],
-  );
-};
diff --git a/config-ui/src/pages/connection/home/styled.ts b/config-ui/src/pages/connection/home/styled.ts
index 534e8387e..a28976019 100644
--- a/config-ui/src/pages/connection/home/styled.ts
+++ b/config-ui/src/pages/connection/home/styled.ts
@@ -20,23 +20,13 @@ import styled from 'styled-components';
 
 export const Wrapper = styled.div`
   .block + .block {
-    margin-top: 48px;
-  }
-
-  h2 {
-    margin: 0 0 4px;
-  }
-
-  p {
-    margin: 0 0 16px;
+    margin-top: 24px;
   }
 
   ul {
-    margin: 0;
-    padding: 0;
-    list-style: none;
     display: flex;
     align-items: center;
+    flex-wrap: wrap;
   }
 
   li {
@@ -44,7 +34,10 @@ export const Wrapper = styled.div`
     display: flex;
     flex-direction: column;
     align-items: center;
-    padding: 8px 16px;
+    margin-right: 24px;
+    margin-bottom: 12px;
+    padding: 8px 0;
+    width: 120px;
     border: 2px solid transparent;
     cursor: pointer;
     transition: all 0.2s linear;
@@ -55,6 +48,10 @@ export const Wrapper = styled.div`
       box-shadow: 0 2px 2px 0 rgb(0 0 0 / 16%), 0 0 2px 0 rgb(0 0 0 / 12%);
     }
 
+    &:last-child {
+      margin-right: 0;
+    }
+
     & > img {
       width: 45px;
     }
@@ -69,8 +66,4 @@ export const Wrapper = styled.div`
       right: 4px;
     }
   }
-
-  li + li {
-    margin-left: 24px;
-  }
 `;
diff --git a/config-ui/src/pages/connection/form/api.ts b/config-ui/src/plugins/components/connection-form/api.ts
similarity index 100%
rename from config-ui/src/pages/connection/form/api.ts
rename to config-ui/src/plugins/components/connection-form/api.ts
diff --git a/config-ui/src/plugins/components/connection-form/fields/endpoint.tsx b/config-ui/src/plugins/components/connection-form/fields/endpoint.tsx
new file mode 100644
index 000000000..68f509da0
--- /dev/null
+++ b/config-ui/src/plugins/components/connection-form/fields/endpoint.tsx
@@ -0,0 +1,111 @@
+/*
+ * 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.
+ *
+ */
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import React, { useState } from 'react';
+import { FormGroup, RadioGroup, Radio, InputGroup } from '@blueprintjs/core';
+
+import * as S from './styled';
+
+type VersionType = 'cloud' | 'server';
+
+interface Props {
+  subLabel?: string;
+  disabled?: boolean;
+  name: string;
+  multipleVersions?: Record<VersionType, string>;
+  value: string;
+  onChange: (value: string) => void;
+}
+
+export const ConnectionEndpoint = ({ subLabel, disabled = false, name, multipleVersions, value, onChange }: Props) => {
+  const [version, setVersion] = useState<VersionType>('cloud');
+
+  const handleChange = (e: React.FormEvent<HTMLInputElement>) => {
+    const version = (e.target as HTMLInputElement).value as VersionType;
+    if (version === 'cloud') {
+      onChange(multipleVersions?.cloud ?? '');
+    }
+
+    if (version === 'server') {
+      onChange('');
+    }
+
+    setVersion(version);
+  };
+
+  const handleChangeValue = (e: React.ChangeEvent<HTMLInputElement>) => {
+    onChange(e.target.value);
+  };
+
+  if (multipleVersions) {
+    return (
+      <FormGroup label={<S.Label>{name} Version</S.Label>} labelInfo={<S.LabelInfo>*</S.LabelInfo>}>
+        <RadioGroup inline selectedValue={version} onChange={handleChange}>
+          <Radio value="cloud">{name} Cloud</Radio>
+          <Radio value="server" disabled={multipleVersions.server === undefined}>
+            {name} Server
+          </Radio>
+        </RadioGroup>
+        {version === 'cloud' && (
+          <p>
+            If you are using {name} Cloud, you do not need to enter the endpoint URL, which is
+            {multipleVersions.cloud}.
+          </p>
+        )}
+        {version === 'server' && (
+          <FormGroup
+            label={<S.Label>Endpoint URL</S.Label>}
+            labelInfo={<S.LabelInfo>*</S.LabelInfo>}
+            subLabel={
+              <S.LabelDescription>If you are using {name} Server, please enter the endpoint URL.</S.LabelDescription>
+            }
+          >
+            <InputGroup placeholder="Your Endpoint URL" value={value} onChange={handleChangeValue} />
+          </FormGroup>
+        )}
+      </FormGroup>
+    );
+  }
+
+  return (
+    <FormGroup
+      label={<S.Label>Endpoint URL</S.Label>}
+      labelInfo={<S.LabelInfo>*</S.LabelInfo>}
+      subLabel={<S.LabelDescription>{subLabel ?? `Provide the ${name} instance API endpoint.`}</S.LabelDescription>}
+    >
+      <InputGroup disabled={disabled} placeholder="Your Endpoint URL" value={value} onChange={handleChange} />
+    </FormGroup>
+  );
+};
diff --git a/config-ui/src/plugins/components/connection-form/fields/index.tsx b/config-ui/src/plugins/components/connection-form/fields/index.tsx
new file mode 100644
index 000000000..a1cbc4b5d
--- /dev/null
+++ b/config-ui/src/plugins/components/connection-form/fields/index.tsx
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import React from 'react';
+
+import { ConnectionName } from './name';
+import { ConnectionEndpoint } from './endpoint';
+import { ConnectionUsername } from './username';
+import { ConnectionPassword } from './password';
+import { ConnectionToken } from './token';
+import { ConnectionProxy } from './proxy';
+import { ConnectionRateLimit } from './rate-limit';
+
+interface Props {
+  name: string;
+  fields: any[];
+  values: any;
+  setValues: (values: any) => void;
+  error: any;
+  setError: (error: any) => void;
+}
+
+export const Form = ({ name, fields, values, setValues, error, setError }: Props) => {
+  const generateForm = () => {
+    return fields.map((field) => {
+      if (typeof field === 'function') {
+        return field({ values, setValues, error, setError });
+      }
+
+      const key = typeof field === 'string' ? field : field.key;
+
+      switch (key) {
+        case 'name':
+          return (
+            <ConnectionName key={key} value={values.name ?? ''} onChange={(name) => setValues({ ...values, name })} />
+          );
+        case 'endpoint':
+          return (
+            <ConnectionEndpoint
+              {...field}
+              key={key}
+              name={name}
+              value={values.endpoint ?? ''}
+              onChange={(endpoint) => setValues({ ...values, endpoint })}
+            />
+          );
+        case 'username':
+          return (
+            <ConnectionUsername
+              key={key}
+              value={values.username ?? ''}
+              onChange={(username) => setValues({ ...values, username })}
+            />
+          );
+        case 'password':
+          return (
+            <ConnectionPassword
+              {...field}
+              key={key}
+              value={values.password ?? ''}
+              onChange={(password) => setValues({ ...values, password })}
+            />
+          );
+        case 'token':
+          return (
+            <ConnectionToken
+              {...field}
+              key={key}
+              value={values.token ?? ''}
+              onChange={(token) => setValues({ ...values, token })}
+            />
+          );
+        case 'proxy':
+          return (
+            <ConnectionProxy
+              key={key}
+              name={name}
+              value={values.proxy ?? ''}
+              onChange={(proxy) => setValues({ ...values, proxy })}
+            />
+          );
+        case 'rateLimitPerHour':
+          return (
+            <ConnectionRateLimit
+              {...field}
+              key={key}
+              value={values.rateLimitPerHour}
+              onChange={(rateLimitPerHour) => setValues({ ...values, rateLimitPerHour })}
+            />
+          );
+        default:
+          return null;
+      }
+    });
+  };
+
+  return <div>{generateForm()}</div>;
+};
diff --git a/config-ui/src/pages/connection/form/components/gitlab-token/index.tsx b/config-ui/src/plugins/components/connection-form/fields/name.tsx
similarity index 55%
copy from config-ui/src/pages/connection/form/components/gitlab-token/index.tsx
copy to config-ui/src/plugins/components/connection-form/fields/name.tsx
index 9c5579446..f207a2601 100644
--- a/config-ui/src/pages/connection/form/components/gitlab-token/index.tsx
+++ b/config-ui/src/plugins/components/connection-form/fields/name.tsx
@@ -17,31 +17,31 @@
  */
 
 import React from 'react';
-import { InputGroup } from '@blueprintjs/core';
+import { FormGroup, InputGroup } from '@blueprintjs/core';
+
+import * as S from './styled';
 
 interface Props {
-  placeholder?: string;
-  value?: string;
-  onChange?: (value: string) => void;
+  value: string;
+  onChange: (value: string) => void;
 }
 
-export const GitLabToken = ({ placeholder, value, onChange }: Props) => {
-  const handleChangeValue = (e: React.ChangeEvent<HTMLInputElement>) => {
-    onChange?.(e.target.value);
+export const ConnectionName = ({ value, onChange }: Props) => {
+  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    onChange(e.target.value);
   };
 
   return (
-    <div>
-      <p>
-        <a
-          href="https://devlake.apache.org/docs/UserManuals/ConfigUI/GitLab/#auth-tokens"
-          target="_blank"
-          rel="noreferrer"
-        >
-          Learn about how to create a personal access token
-        </a>
-      </p>
-      <InputGroup placeholder={placeholder} type="password" value={value} onChange={handleChangeValue} />
-    </div>
+    <FormGroup
+      label={<S.Label>Connection Name</S.Label>}
+      labelInfo={<S.LabelInfo>*</S.LabelInfo>}
+      subLabel={
+        <S.LabelDescription>
+          Give your connection a unique name to help you identify it in the future.
+        </S.LabelDescription>
+      }
+    >
+      <InputGroup placeholder="Your Connection Name" value={value} onChange={handleChange} />
+    </FormGroup>
   );
 };
diff --git a/config-ui/src/plugins/components/connection-form/fields/password.tsx b/config-ui/src/plugins/components/connection-form/fields/password.tsx
new file mode 100644
index 000000000..5af8dfe11
--- /dev/null
+++ b/config-ui/src/plugins/components/connection-form/fields/password.tsx
@@ -0,0 +1,57 @@
+/*
+ * 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.
+ *
+ */
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import React from 'react';
+import { FormGroup, InputGroup } from '@blueprintjs/core';
+
+import * as S from './styled';
+
+interface Props {
+  label?: string;
+  value: string;
+  onChange: (value: string) => void;
+}
+
+export const ConnectionPassword = ({ label, value, onChange }: Props) => {
+  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    onChange(e.target.value);
+  };
+
+  return (
+    <FormGroup label={<S.Label>{label ?? 'Password'}</S.Label>} labelInfo={<S.LabelInfo>*</S.LabelInfo>}>
+      <InputGroup placeholder="Your Password" value={value} onChange={handleChange} />
+    </FormGroup>
+  );
+};
diff --git a/config-ui/src/plugins/components/connection-form/fields/proxy.tsx b/config-ui/src/plugins/components/connection-form/fields/proxy.tsx
new file mode 100644
index 000000000..e400491f3
--- /dev/null
+++ b/config-ui/src/plugins/components/connection-form/fields/proxy.tsx
@@ -0,0 +1,60 @@
+/*
+ * 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.
+ *
+ */
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import React from 'react';
+import { FormGroup, InputGroup } from '@blueprintjs/core';
+
+import * as S from './styled';
+
+interface Props {
+  name: string;
+  value: string;
+  onChange: (value: string) => void;
+}
+
+export const ConnectionProxy = ({ name, value, onChange }: Props) => {
+  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    onChange(e.target.value);
+  };
+
+  return (
+    <FormGroup
+      label={<S.Label>Proxy URL</S.Label>}
+      subLabel={<S.LabelDescription>Add a proxy if you cannot access {name} directly.</S.LabelDescription>}
+    >
+      <InputGroup placeholder="e.g http://proxy.localhost:8080" value={value} onChange={handleChange} />
+    </FormGroup>
+  );
+};
diff --git a/config-ui/src/plugins/components/connection-form/fields/rate-limit.tsx b/config-ui/src/plugins/components/connection-form/fields/rate-limit.tsx
new file mode 100644
index 000000000..1e1b105b2
--- /dev/null
+++ b/config-ui/src/plugins/components/connection-form/fields/rate-limit.tsx
@@ -0,0 +1,94 @@
+/*
+ * 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.
+ *
+ */
+/*
+ * 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 { FormGroup, Switch, NumericInput } from '@blueprintjs/core';
+
+import { ExternalLink } from '@/components';
+
+import * as S from './styled';
+
+interface Props {
+  subLabel: string;
+  learnMore: string;
+  externalInfo: string;
+  defaultValue: number;
+  value: number;
+  onChange: (value: number) => void;
+}
+
+export const ConnectionRateLimit = ({ subLabel, learnMore, externalInfo, defaultValue, value, onChange }: Props) => {
+  const [checked, setChecked] = useState(true);
+
+  useEffect(() => {
+    setChecked(value ? true : false);
+  }, []);
+
+  const handleChange = (e: React.FormEvent<HTMLInputElement>) => {
+    const checked = (e.target as HTMLInputElement).checked;
+    if (checked) {
+      onChange(defaultValue);
+    } else {
+      onChange(0);
+    }
+    setChecked(checked);
+  };
+
+  const handleChangeValue = (value: number) => {
+    onChange(value);
+  };
+
+  return (
+    <FormGroup
+      label={<S.Label>Custom Rate Limit</S.Label>}
+      subLabel={
+        <S.LabelDescription>
+          {subLabel} {learnMore && <ExternalLink link={learnMore}>Learn more</ExternalLink>}
+        </S.LabelDescription>
+      }
+    >
+      <S.RateLimit>
+        <Switch checked={checked} onChange={handleChange} />
+        {checked && (
+          <>
+            <NumericInput buttonPosition="none" min={0} value={value} onValueChange={handleChangeValue} />
+            <span>requests/hour</span>
+          </>
+        )}
+      </S.RateLimit>
+      {checked && externalInfo && <S.RateLimitInfo dangerouslySetInnerHTML={{ __html: externalInfo }} />}
+    </FormGroup>
+  );
+};
diff --git a/config-ui/src/pages/connection/form/components/rate-limit/styled.ts b/config-ui/src/plugins/components/connection-form/fields/styled.ts
similarity index 69%
copy from config-ui/src/pages/connection/form/components/rate-limit/styled.ts
copy to config-ui/src/plugins/components/connection-form/fields/styled.ts
index 808e8719f..d48455c0e 100644
--- a/config-ui/src/pages/connection/form/components/rate-limit/styled.ts
+++ b/config-ui/src/plugins/components/connection-form/fields/styled.ts
@@ -18,11 +18,32 @@
 
 import styled from 'styled-components';
 
-export const Wrapper = styled.div`
+export const Label = styled.label`
+  font-size: 16px;
+  font-weight: 600;
+`;
+
+export const LabelInfo = styled.i`
+  color: #ff8b8b;
+`;
+
+export const LabelDescription = styled.p`
+  margin: 0;
+`;
+
+export const RateLimit = styled.div`
   display: flex;
   align-items: center;
 
-  & > .bp4-numeric-input {
-    margin-right: 8px;
+  .bp4-input-group {
+    width: 80px !important;
+  }
+
+  & > span {
+    margin-left: 8px;
   }
 `;
+
+export const RateLimitInfo = styled.p`
+  white-space: pre-line;
+`;
diff --git a/config-ui/src/plugins/components/connection-form/fields/token.tsx b/config-ui/src/plugins/components/connection-form/fields/token.tsx
new file mode 100644
index 000000000..d3a1c808d
--- /dev/null
+++ b/config-ui/src/plugins/components/connection-form/fields/token.tsx
@@ -0,0 +1,62 @@
+/*
+ * 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.
+ *
+ */
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import React from 'react';
+import { FormGroup, InputGroup } from '@blueprintjs/core';
+
+import * as S from './styled';
+
+interface Props {
+  label?: string;
+  subLabel?: string;
+  value: string;
+  onChange: (value: string) => void;
+}
+
+export const ConnectionToken = ({ label, subLabel, value, onChange }: Props) => {
+  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    onChange(e.target.value);
+  };
+
+  return (
+    <FormGroup
+      label={<S.Label>{label ?? 'Token'}</S.Label>}
+      labelInfo={<S.LabelInfo>*</S.LabelInfo>}
+      subLabel={<S.LabelDescription>{subLabel}</S.LabelDescription>}
+    >
+      <InputGroup placeholder="Your Token" value={value} onChange={handleChange} />
+    </FormGroup>
+  );
+};
diff --git a/config-ui/src/plugins/components/connection-form/fields/username.tsx b/config-ui/src/plugins/components/connection-form/fields/username.tsx
new file mode 100644
index 000000000..717b51f11
--- /dev/null
+++ b/config-ui/src/plugins/components/connection-form/fields/username.tsx
@@ -0,0 +1,56 @@
+/*
+ * 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.
+ *
+ */
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import React from 'react';
+import { FormGroup, InputGroup } from '@blueprintjs/core';
+
+import * as S from './styled';
+
+interface Props {
+  value: string;
+  onChange: (value: string) => void;
+}
+
+export const ConnectionUsername = ({ value, onChange }: Props) => {
+  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    onChange(e.target.value);
+  };
+
+  return (
+    <FormGroup label={<S.Label>Username</S.Label>} labelInfo={<S.LabelInfo>*</S.LabelInfo>}>
+      <InputGroup placeholder="Your Username" value={value} onChange={handleChange} />
+    </FormGroup>
+  );
+};
diff --git a/config-ui/src/plugins/components/connection-form/index.tsx b/config-ui/src/plugins/components/connection-form/index.tsx
new file mode 100644
index 000000000..acedfeafd
--- /dev/null
+++ b/config-ui/src/plugins/components/connection-form/index.tsx
@@ -0,0 +1,80 @@
+/*
+ * 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, useMemo } from 'react';
+import { ButtonGroup } from '@blueprintjs/core';
+
+import { PageLoading, ExternalLink } from '@/components';
+import { useRefreshData } from '@/hooks';
+import type { PluginConfigConnectionType } from '@/plugins';
+import { PluginConfig } from '@/plugins';
+
+import { Form } from './fields';
+import { Test, Save } from './operate';
+import * as API from './api';
+import * as S from './styled';
+
+interface Props {
+  plugin: string;
+  connectionId?: ID;
+}
+
+export const ConnectionForm = ({ plugin, connectionId }: Props) => {
+  const [form, setForm] = useState<Record<string, any>>({});
+  const [error, setError] = useState<Record<string, any>>({});
+
+  const {
+    name,
+    connection: { docLink, fields, initialValues },
+  } = useMemo(() => PluginConfig.find((p) => p.plugin === plugin) as PluginConfigConnectionType, [plugin]);
+
+  const { ready, data } = useRefreshData(async () => {
+    if (!connectionId) {
+      return {};
+    }
+
+    return API.getConnection(plugin, connectionId);
+  }, [plugin, connectionId]);
+
+  if (connectionId && !ready) {
+    return <PageLoading />;
+  }
+
+  return (
+    <S.Wrapper>
+      <S.Tips>
+        If you run into any problems while creating a new connection for {name},{' '}
+        <ExternalLink link={docLink}>check out this doc</ExternalLink>.
+      </S.Tips>
+      <S.Form>
+        <Form
+          name={name}
+          fields={fields}
+          values={{ ...form, ...initialValues, ...data }}
+          setValues={setForm}
+          error={error}
+          setError={setError}
+        />
+        <ButtonGroup className="btns">
+          <Test plugin={plugin} form={form} error={error} />
+          <Save plugin={plugin} connectionId={connectionId} form={form} error={error} />
+        </ButtonGroup>
+      </S.Form>
+    </S.Wrapper>
+  );
+};
diff --git a/config-ui/src/pages/connection/form/components/index.ts b/config-ui/src/plugins/components/connection-form/operate/index.ts
similarity index 86%
rename from config-ui/src/pages/connection/form/components/index.ts
rename to config-ui/src/plugins/components/connection-form/operate/index.ts
index 448d0ba62..2dda77f3f 100644
--- a/config-ui/src/pages/connection/form/components/index.ts
+++ b/config-ui/src/plugins/components/connection-form/operate/index.ts
@@ -16,7 +16,5 @@
  *
  */
 
-export * from './rate-limit';
-export * from './github-token';
-export * from './gitlab-token';
-export * from './jira-auth';
+export * from './test';
+export * from './save';
diff --git a/config-ui/src/plugins/components/connection-form/operate/save.tsx b/config-ui/src/plugins/components/connection-form/operate/save.tsx
new file mode 100644
index 000000000..8a3a45afa
--- /dev/null
+++ b/config-ui/src/plugins/components/connection-form/operate/save.tsx
@@ -0,0 +1,63 @@
+/*
+ * 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, { useMemo } from 'react';
+import { useHistory } from 'react-router-dom';
+import { Button, Intent } from '@blueprintjs/core';
+
+import { useOperator } from '@/hooks';
+
+import * as API from '../api';
+
+interface Props {
+  plugin: string;
+  connectionId?: ID;
+  form: any;
+  error: any;
+}
+
+export const Save = ({ plugin, connectionId, form, error }: Props) => {
+  const history = useHistory();
+
+  const { operating, onSubmit } = useOperator(
+    (paylaod) =>
+      !connectionId ? API.createConnection(plugin, paylaod) : API.updateConnection(plugin, connectionId, paylaod),
+    {
+      callback: () => history.push(`/connections/${plugin}`),
+    },
+  );
+
+  const disabled = useMemo(() => {
+    return Object.values(error).some((value) => value);
+  }, [error]);
+
+  const handleSubmit = () => {
+    onSubmit(form);
+  };
+
+  return (
+    <Button
+      loading={operating}
+      disabled={disabled}
+      intent={Intent.PRIMARY}
+      outlined
+      text="Save Connection"
+      onClick={handleSubmit}
+    />
+  );
+};
diff --git a/config-ui/src/pages/connection/form/components/gitlab-token/index.tsx b/config-ui/src/plugins/components/connection-form/operate/test.tsx
similarity index 52%
copy from config-ui/src/pages/connection/form/components/gitlab-token/index.tsx
copy to config-ui/src/plugins/components/connection-form/operate/test.tsx
index 9c5579446..976fcb3fd 100644
--- a/config-ui/src/pages/connection/form/components/gitlab-token/index.tsx
+++ b/config-ui/src/plugins/components/connection-form/operate/test.tsx
@@ -16,32 +16,30 @@
  *
  */
 
-import React from 'react';
-import { InputGroup } from '@blueprintjs/core';
+import React, { useMemo } from 'react';
+import { Button } from '@blueprintjs/core';
+import { pick } from 'lodash';
+
+import { useOperator } from '@/hooks';
+
+import * as API from '../api';
 
 interface Props {
-  placeholder?: string;
-  value?: string;
-  onChange?: (value: string) => void;
+  plugin: string;
+  form: any;
+  error: any;
 }
 
-export const GitLabToken = ({ placeholder, value, onChange }: Props) => {
-  const handleChangeValue = (e: React.ChangeEvent<HTMLInputElement>) => {
-    onChange?.(e.target.value);
+export const Test = ({ plugin, form, error }: Props) => {
+  const { operating, onSubmit } = useOperator((payload) => API.testConnection(plugin, payload));
+
+  const disabled = useMemo(() => {
+    return Object.values(error).some((value) => value);
+  }, [error]);
+
+  const handleSubmit = () => {
+    onSubmit(pick(form, ['endpoint', 'token', 'username', 'password', 'app_id', 'secret_key', 'proxy', 'authMethod']));
   };
 
-  return (
-    <div>
-      <p>
-        <a
-          href="https://devlake.apache.org/docs/UserManuals/ConfigUI/GitLab/#auth-tokens"
-          target="_blank"
-          rel="noreferrer"
-        >
-          Learn about how to create a personal access token
-        </a>
-      </p>
-      <InputGroup placeholder={placeholder} type="password" value={value} onChange={handleChangeValue} />
-    </div>
-  );
+  return <Button loading={operating} disabled={disabled} outlined text="Test Connection" onClick={handleSubmit} />;
 };
diff --git a/config-ui/src/pages/connection/home/styled.ts b/config-ui/src/plugins/components/connection-form/styled.ts
similarity index 53%
copy from config-ui/src/pages/connection/home/styled.ts
copy to config-ui/src/plugins/components/connection-form/styled.ts
index 534e8387e..09c870e4d 100644
--- a/config-ui/src/pages/connection/home/styled.ts
+++ b/config-ui/src/plugins/components/connection-form/styled.ts
@@ -18,59 +18,51 @@
 
 import styled from 'styled-components';
 
-export const Wrapper = styled.div`
-  .block + .block {
-    margin-top: 48px;
-  }
+export const Wrapper = styled.div``;
+
+export const Tips = styled.div`
+  margin-bottom: 36px;
+  padding: 24px;
+  color: #3c5088;
+  background: #f0f4fe;
+  border: 1px solid #bdcefb;
+  border-radius: 4px;
+`;
+
+export const Form = styled.div`
+  padding: 24px;
+  background: #ffffff;
+  box-shadow: 0px 2.4px 4.8px -0.8px rgba(0, 0, 0, 0.1), 0px 1.6px 8px rgba(0, 0, 0, 0.07);
+  border-radius: 8px;
 
-  h2 {
-    margin: 0 0 4px;
+  .bp4-form-group label.bp4-label {
+    margin: 0 0 8px 0;
   }
 
-  p {
-    margin: 0 0 16px;
+  .bp4-form-group .bp4-form-group-sub-label {
+    margin: 0 0 8px 0;
   }
 
-  ul {
-    margin: 0;
-    padding: 0;
-    list-style: none;
-    display: flex;
-    align-items: center;
+  .bp4-input-group {
+    width: 386px;
   }
 
-  li {
-    position: relative;
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    padding: 8px 16px;
-    border: 2px solid transparent;
-    cursor: pointer;
-    transition: all 0.2s linear;
+  .bp4-input {
+    border: 1px solid #dbe4fd;
+    box-shadow: none;
+    border-radius: 4px;
 
-    &:hover {
-      background-color: #eeeeee;
-      border-color: #7497f7;
-      box-shadow: 0 2px 2px 0 rgb(0 0 0 / 16%), 0 0 2px 0 rgb(0 0 0 / 12%);
-    }
-
-    & > img {
-      width: 45px;
+    &::placeholder {
+      color: #b8b8bf;
     }
+  }
 
-    & > span {
-      margin-top: 4px;
-    }
+  .btns {
+    display: flex;
+    justify-content: end;
 
-    & > .bp4-tag {
-      position: absolute;
-      top: -4px;
-      right: 4px;
+    .bp4-button + .bp4-button {
+      margin-left: 8px;
     }
   }
-
-  li + li {
-    margin-left: 24px;
-  }
 `;
diff --git a/config-ui/src/plugins/components/index.ts b/config-ui/src/plugins/components/index.ts
index 1fdd73324..e0d0014d6 100644
--- a/config-ui/src/plugins/components/index.ts
+++ b/config-ui/src/plugins/components/index.ts
@@ -16,6 +16,7 @@
  *
  */
 
+export * from './connection-form';
 export * from './data-scope-list';
 export * from './data-scope';
 export * from './transformation';
diff --git a/config-ui/src/plugins/register/base/config.ts b/config-ui/src/plugins/register/base/config.ts
index 684ee1970..2de8f19e4 100644
--- a/config-ui/src/plugins/register/base/config.ts
+++ b/config-ui/src/plugins/register/base/config.ts
@@ -20,20 +20,6 @@ import { PluginType } from '../../types';
 
 import Icon from './assets/icon.svg';
 
-export const BaseConnectionConfig = {
-  type: PluginType.Connection,
-  plugin: undefined,
-  name: undefined,
-  icon: Icon,
-  isBeta: undefined,
-  connection: {
-    initialValues: [],
-    fields: [],
-  },
-  entities: [],
-  transformation: {},
-} as const;
-
 export const BasePipelineConfig = {
   type: PluginType.Pipeline,
   plugin: undefined,
diff --git a/config-ui/src/plugins/register/bitbucket/config.ts b/config-ui/src/plugins/register/bitbucket/config.tsx
similarity index 51%
rename from config-ui/src/plugins/register/bitbucket/config.ts
rename to config-ui/src/plugins/register/bitbucket/config.tsx
index 77225837c..3582ee64b 100644
--- a/config-ui/src/plugins/register/bitbucket/config.ts
+++ b/config-ui/src/plugins/register/bitbucket/config.tsx
@@ -17,47 +17,47 @@
  */
 
 import type { PluginConfigType } from '@/plugins';
-
-import {
-  BaseConnectionConfig,
-  ConnectionName,
-  ConnectionEndpoint,
-  ConnectionUsername,
-  ConnectionPassword,
-  ConnectionProxy,
-  ConnectionRatelimit,
-} from '../base';
+import { PluginType } from '@/plugins';
 
 import Icon from './assets/icon.svg';
 
 export const BitBucketConfig: PluginConfigType = {
-  ...BaseConnectionConfig,
+  type: PluginType.Connection,
   plugin: 'bitbucket',
   name: 'BitBucket',
   icon: Icon,
-  isBeta: true,
+  sort: 5,
   connection: {
-    initialValues: {
-      name: 'BitBucket',
-      endpoint: 'https://api.bitbucket.org/2.0/',
-      rateLimitPerHour: 10000,
-    },
+    docLink: 'https://devlake.apache.org/docs/Configuration/BitBucket',
     fields: [
-      ConnectionName({
-        placeholder: 'eg. BitBucket',
-      }),
-      ConnectionEndpoint({
-        placeholder: 'eg. https://api.bitbucket.org/2.0/',
-      }),
-      ConnectionUsername(),
-      ConnectionPassword({
+      'name',
+      {
+        key: 'endpoint',
+        multipleVersions: {
+          cloud: 'https://api.bitbucket.org/2.0/',
+        },
+      },
+      'username',
+      {
+        key: 'password',
         label: 'App Password',
-        placeholder: 'App Password',
-      }),
-      ConnectionProxy(),
-      ConnectionRatelimit(),
+      },
+      'proxy',
+      {
+        key: 'rateLimitPerHour',
+        subLabel:
+          'By default, DevLake uses dynamic rate limit for optimized data collection for BitBucket. But you can adjust the collection speed by entering a fixed value.',
+        learnMore: 'https://devlake.apache.org/docs/Configuration/BitBucket#fixed-rate-limit-optional',
+        externalInfo:
+          'The maximum rate limit for different entities in BitBucket Cloud is 60,000 or 1,000 requests/hour.',
+        defaultValue: 10000,
+      },
     ],
   },
-  entities: [],
-  transformation: {},
+  entities: ['TICKET', 'CROSS'],
+  transformation: {
+    storyPointField: '',
+    remotelinkCommitShaPattern: '',
+    typeMappings: {},
+  },
 };
diff --git a/config-ui/src/plugins/register/github/api.ts b/config-ui/src/plugins/register/github/api.ts
index d174fe6b9..dddf94570 100644
--- a/config-ui/src/plugins/register/github/api.ts
+++ b/config-ui/src/plugins/register/github/api.ts
@@ -52,3 +52,5 @@ export const searchRepo = (prefix: string, params: SearchRepoParams) =>
     method: 'get',
     data: params,
   });
+
+export const testConnection = (payload: any) => request('/plugins/github/test', { method: 'post', data: payload });
diff --git a/config-ui/src/plugins/register/github/config.ts b/config-ui/src/plugins/register/github/config.tsx
similarity index 59%
rename from config-ui/src/plugins/register/github/config.ts
rename to config-ui/src/plugins/register/github/config.tsx
index bad965268..d3298d205 100644
--- a/config-ui/src/plugins/register/github/config.ts
+++ b/config-ui/src/plugins/register/github/config.tsx
@@ -16,43 +16,47 @@
  *
  */
 
-import type { PluginConfigType } from '@/plugins';
+import React from 'react';
 
-import {
-  BaseConnectionConfig,
-  ConnectionName,
-  ConnectionEndpoint,
-  ConnectionGitHubToken,
-  ConnectionGitHubGraphql,
-  ConnectionProxy,
-  ConnectionRatelimit,
-} from '../base';
+import type { PluginConfigType } from '@/plugins';
+import { PluginType } from '@/plugins';
 
 import Icon from './assets/icon.svg';
+import { Token, Graphql } from './connection-fields';
 
 export const GitHubConfig: PluginConfigType = {
-  ...BaseConnectionConfig,
+  type: PluginType.Connection,
   plugin: 'github',
   name: 'GitHub',
   icon: Icon,
+  sort: 1,
   connection: {
+    docLink: 'https://devlake.apache.org/docs/UserManuals/ConfigUI/GitHub',
     initialValues: {
-      name: 'GitHub',
       endpoint: 'https://api.github.com/',
       enableGraphql: true,
-      rateLimitPerHour: 4500,
     },
     fields: [
-      ConnectionName({
-        placeholder: 'eg. GitHub',
-      }),
-      ConnectionEndpoint({
-        placeholder: 'eg. https://api.github.com/',
-      }),
-      ConnectionGitHubToken(),
-      ConnectionGitHubGraphql(),
-      ConnectionProxy(),
-      ConnectionRatelimit(),
+      'name',
+      {
+        key: 'endpoint',
+        multipleVersions: {
+          cloud: 'https://api.github.com/',
+          server: '',
+        },
+      },
+      (props: any) => <Token key="token" {...props} />,
+      'proxy',
+      (props: any) => <Graphql key="graphql" {...props} />,
+      {
+        key: 'rateLimitPerHour',
+        subLabel:
+          'By default, DevLake uses dynamic rate limit for optimized data collection for GitHub. But you can adjust the collection speed by entering a fixed value. Learn more',
+        learnMore: 'https://devlake.apache.org/docs/UserManuals/ConfigUI/GitHub/#fixed-rate-limit-optional',
+        externalInfo:
+          'Rate Limit Value Reference\nGitHub: 0-5,000 requests/hour\nGitHub Enterprise: 0-15,000 requests/hour',
+        defaultValue: 4500,
+      },
     ],
   },
   entities: ['CODE', 'TICKET', 'CODEREVIEW', 'CROSS', 'CICD'],
diff --git a/config-ui/src/pages/connection/form/components/gitlab-token/index.tsx b/config-ui/src/plugins/register/github/connection-fields/graphql.tsx
similarity index 54%
rename from config-ui/src/pages/connection/form/components/gitlab-token/index.tsx
rename to config-ui/src/plugins/register/github/connection-fields/graphql.tsx
index 9c5579446..24ebecba5 100644
--- a/config-ui/src/pages/connection/form/components/gitlab-token/index.tsx
+++ b/config-ui/src/plugins/register/github/connection-fields/graphql.tsx
@@ -17,31 +17,34 @@
  */
 
 import React from 'react';
-import { InputGroup } from '@blueprintjs/core';
+import { FormGroup, Switch } from '@blueprintjs/core';
+
+import * as S from './styled';
 
 interface Props {
-  placeholder?: string;
-  value?: string;
-  onChange?: (value: string) => void;
+  values: any;
+  setValues: any;
 }
 
-export const GitLabToken = ({ placeholder, value, onChange }: Props) => {
-  const handleChangeValue = (e: React.ChangeEvent<HTMLInputElement>) => {
-    onChange?.(e.target.value);
+export const Graphql = ({ values, setValues }: Props) => {
+  const handleChange = (e: React.FormEvent<HTMLInputElement>) => {
+    const enableGraphql = (e.target as HTMLInputElement).checked;
+    setValues({
+      ...values,
+      enableGraphql,
+    });
   };
 
   return (
-    <div>
-      <p>
-        <a
-          href="https://devlake.apache.org/docs/UserManuals/ConfigUI/GitLab/#auth-tokens"
-          target="_blank"
-          rel="noreferrer"
-        >
-          Learn about how to create a personal access token
-        </a>
-      </p>
-      <InputGroup placeholder={placeholder} type="password" value={value} onChange={handleChangeValue} />
-    </div>
+    <FormGroup
+      label={<S.Label>Use GraphQL APIs</S.Label>}
+      subLabel={
+        <S.LabelDescription>
+          GraphQL APIs are 10+ times faster than REST APIs, but they may not be supported in GitHub Server.
+        </S.LabelDescription>
+      }
+    >
+      <Switch checked={values.enableGraphql ?? false} onChange={handleChange} />
+    </FormGroup>
   );
 };
diff --git a/config-ui/src/pages/connection/form/components/rate-limit/styled.ts b/config-ui/src/plugins/register/github/connection-fields/index.ts
similarity index 82%
rename from config-ui/src/pages/connection/form/components/rate-limit/styled.ts
rename to config-ui/src/plugins/register/github/connection-fields/index.ts
index 808e8719f..fc074058f 100644
--- a/config-ui/src/pages/connection/form/components/rate-limit/styled.ts
+++ b/config-ui/src/plugins/register/github/connection-fields/index.ts
@@ -16,13 +16,5 @@
  *
  */
 
-import styled from 'styled-components';
-
-export const Wrapper = styled.div`
-  display: flex;
-  align-items: center;
-
-  & > .bp4-numeric-input {
-    margin-right: 8px;
-  }
-`;
+export * from './token';
+export * from './graphql';
diff --git a/config-ui/src/pages/connection/form/components/github-token/styled.ts b/config-ui/src/plugins/register/github/connection-fields/styled.ts
similarity index 66%
rename from config-ui/src/pages/connection/form/components/github-token/styled.ts
rename to config-ui/src/plugins/register/github/connection-fields/styled.ts
index ffb01932d..062843441 100644
--- a/config-ui/src/pages/connection/form/components/github-token/styled.ts
+++ b/config-ui/src/plugins/register/github/connection-fields/styled.ts
@@ -19,38 +19,44 @@
 import { Colors } from '@blueprintjs/core';
 import styled from 'styled-components';
 
-export const Wrapper = styled.div`
+export const Label = styled.label`
+  font-size: 16px;
+  font-weight: 600;
+`;
+
+export const LabelInfo = styled.i`
+  color: #ff8b8b;
+`;
+
+export const LabelDescription = styled.p`
+  margin: 0;
+`;
+
+export const Endpoint = styled.div`
   p {
-    margin: 0 0 8px;
+    margin: 10px 0;
   }
+`;
 
-  h3 {
-    margin: 0 0 8px;
-    padding: 0;
-    font-size: 14px;
-  }
+export const Token = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 8px;
 
-  .token {
+  .input {
     display: flex;
     align-items: center;
-    justify-content: space-between;
-    margin-bottom: 8px;
-
-    .input {
-      display: flex;
-      align-items: center;
+  }
 
-      & > span {
-        margin-left: 4px;
+  .info {
+    margin-left: 4px;
 
-        &.error {
-          color: ${Colors.RED3};
-        }
+    span.error {
+      color: ${Colors.RED3};
+    }
 
-        &.success {
-          color: ${Colors.GREEN3};
-        }
-      }
+    span.success {
+      color: ${Colors.GREEN3};
     }
   }
 `;
diff --git a/config-ui/src/pages/connection/form/components/github-token/index.tsx b/config-ui/src/plugins/register/github/connection-fields/token.tsx
similarity index 58%
rename from config-ui/src/pages/connection/form/components/github-token/index.tsx
rename to config-ui/src/plugins/register/github/connection-fields/token.tsx
index e6943fb13..4b5b8bfb5 100644
--- a/config-ui/src/pages/connection/form/components/github-token/index.tsx
+++ b/config-ui/src/plugins/register/github/connection-fields/token.tsx
@@ -17,10 +17,12 @@
  */
 
 import React, { useEffect, useState } from 'react';
-import { InputGroup, Button, Intent } from '@blueprintjs/core';
+import { FormGroup, InputGroup, Button, Intent } from '@blueprintjs/core';
 import { pick } from 'lodash';
 
-import * as API from '../../api';
+import { ExternalLink } from '@/components';
+
+import * as API from '../api';
 
 import * as S from './styled';
 
@@ -31,18 +33,19 @@ type TokenItem = {
 };
 
 interface Props {
-  form: any;
-  value?: string;
-  onChange?: (value: string) => void;
+  values: any;
+  setValues: any;
+  error: any;
+  setError: any;
 }
 
-export const GitHubToken = ({ form, value, onChange }: Props) => {
+export const Token = ({ values, setValues, error, setError }: Props) => {
   const [tokens, setTokens] = useState<TokenItem[]>([{ value: '', status: 'idle' }]);
 
   const testToken = async (token: string): Promise<TokenItem> => {
     try {
-      const res = await API.testConnection('github', {
-        ...pick(form, ['endpoint', 'proxy']),
+      const res = await API.testConnection({
+        ...pick(values, ['endpoint', 'proxy']),
         token,
       });
       return {
@@ -64,30 +67,26 @@ export const GitHubToken = ({ form, value, onChange }: Props) => {
   };
 
   useEffect(() => {
-    if (value) {
-      checkTokens(value);
+    if (values.token) {
+      checkTokens(values.token);
     }
   }, []);
 
   useEffect(() => {
-    onChange?.(tokens.map((it) => it.value).join(','));
+    const token = tokens.map((it) => it.value).join(',');
+    setValues({ ...values, token });
+    setError({ ...error, token: tokens.every((it) => it.value && it.status === 'valid') ? '' : 'error' });
   }, [tokens]);
 
-  const handleCreateToken = () => {
-    setTokens([...tokens, { value: '', status: 'idle' }]);
-  };
+  const handleCreateToken = () => setTokens([...tokens, { value: '', status: 'idle' }]);
 
-  const handleRemoveToken = (key: number) => {
-    setTokens(tokens.filter((_, i) => (i === key ? false : true)));
-  };
+  const handleRemoveToken = (key: number) => setTokens(tokens.filter((_, i) => (i === key ? false : true)));
 
-  const handleChangeToken = (key: number, value: string) => {
+  const handleChangeToken = (key: number, value: string) =>
     setTokens(tokens.map((it, i) => (i === key ? { value, status: 'idle' } : it)));
-  };
 
   const handleTestToken = async (key: number) => {
     const token = tokens.find((_, i) => i === key) as TokenItem;
-
     if (token.status === 'idle' && token.value) {
       const res = await testToken(token.value);
       setTokens((tokens) => tokens.map((it, i) => (i === key ? res : it)));
@@ -95,40 +94,38 @@ export const GitHubToken = ({ form, value, onChange }: Props) => {
   };
 
   return (
-    <S.Wrapper>
-      <p>
-        Add one or more personal token(s) for authentication from you and your organization members. Multiple tokens can
-        help speed up the data collection process.{' '}
-      </p>
-      <p>
-        <a
-          href="https://devlake.apache.org/docs/UserManuals/ConfigUI/GitHub/#auth-tokens"
-          target="_blank"
-          rel="noreferrer"
-        >
-          Learn about how to create a personal access token
-        </a>
-      </p>
-      <h3>Personal Access Token(s)</h3>
+    <FormGroup
+      label={<S.Label>Personal Access Token(s) </S.Label>}
+      labelInfo={<S.LabelInfo>*</S.LabelInfo>}
+      subLabel={
+        <S.LabelDescription>
+          Add one or more personal token(s) for authentication from you and your organization members. Multiple tokens
+          can help speed up the data collection process.{' '}
+          <ExternalLink link="https://devlake.apache.org/docs/UserManuals/ConfigUI/GitHub/#auth-tokens">
+            Learn how to create a personal access token
+          </ExternalLink>
+        </S.LabelDescription>
+      }
+    >
       {tokens.map(({ value, status, from }, i) => (
-        <div className="token" key={i}>
-          <div className="input">
-            <InputGroup
-              placeholder="token"
-              type="password"
-              value={value ?? ''}
-              onChange={(e) => handleChangeToken(i, e.target.value)}
-              onBlur={() => handleTestToken(i)}
-            />
+        <S.Token key={i}>
+          <InputGroup
+            placeholder="Token"
+            type="password"
+            value={value ?? ''}
+            onChange={(e) => handleChangeToken(i, e.target.value)}
+            onBlur={() => handleTestToken(i)}
+          />
+          <Button minimal icon="cross" onClick={() => handleRemoveToken(i)} />
+          <div className="info">
             {status === 'invalid' && <span className="error">Invalid</span>}
             {status === 'valid' && <span className="success">Valid From: {from}</span>}
           </div>
-          <Button minimal icon="cross" onClick={() => handleRemoveToken(i)} />
-        </div>
+        </S.Token>
       ))}
       <div className="action">
         <Button outlined small intent={Intent.PRIMARY} text="Another Token" icon="plus" onClick={handleCreateToken} />
       </div>
-    </S.Wrapper>
+    </FormGroup>
   );
 };
diff --git a/config-ui/src/plugins/register/gitlab/config.ts b/config-ui/src/plugins/register/gitlab/config.ts
deleted file mode 100644
index 728679052..000000000
--- a/config-ui/src/plugins/register/gitlab/config.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- * 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 type { PluginConfigType } from '@/plugins';
-
-import {
-  BaseConnectionConfig,
-  ConnectionName,
-  ConnectionEndpoint,
-  ConnectionGitLabToken,
-  ConnectionProxy,
-  ConnectionRatelimit,
-} from '../base';
-
-import Icon from './assets/icon.svg';
-
-export const GitLabConfig: PluginConfigType = {
-  ...BaseConnectionConfig,
-  plugin: 'gitlab',
-  name: 'GitLab',
-  icon: Icon,
-  connection: {
-    initialValues: {
-      name: 'GitLab',
-      endpoint: 'https://gitlab.com/api/v4/',
-      rateLimitPerHour: 5000,
-    },
-    fields: [
-      ConnectionName({
-        placeholder: 'eg. GitLab',
-      }),
-      ConnectionEndpoint({
-        placeholder: 'eg. https://gitlab.com/api/v4/',
-      }),
-      ConnectionGitLabToken(),
-      ConnectionProxy(),
-      ConnectionRatelimit(),
-    ],
-  },
-  entities: ['CODE', 'TICKET', 'CODEREVIEW', 'CROSS', 'CICD'],
-  transformation: {
-    productionPattern: '',
-    deploymentPattern: '',
-  },
-};
diff --git a/config-ui/src/plugins/register/gitlab/config.tsx b/config-ui/src/plugins/register/gitlab/config.tsx
new file mode 100644
index 000000000..82cac3353
--- /dev/null
+++ b/config-ui/src/plugins/register/gitlab/config.tsx
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import React from 'react';
+
+import type { PluginConfigType } from '@/plugins';
+import { PluginType } from '@/plugins';
+
+import { ExternalLink } from '@/components';
+
+import Icon from './assets/icon.svg';
+
+export const GitLabConfig: PluginConfigType = {
+  type: PluginType.Connection,
+  plugin: 'gitlab',
+  name: 'GitLab',
+  icon: Icon,
+  sort: 2,
+  connection: {
+    docLink: 'https://devlake.apache.org/docs/Configuration/GitLab',
+    initialValues: {
+      endpoint: 'https://gitlab.com/api/v4/',
+    },
+    fields: [
+      'name',
+      {
+        key: 'endpoint',
+        multipleVersions: {
+          cloud: 'https://gitlab.com/api/v4/',
+          server: '',
+        },
+      },
+      {
+        key: 'token',
+        label: 'Personal Access Token',
+        subLabel: (
+          <ExternalLink link="https://devlake.apache.org/docs/UserManuals/ConfigUI/GitLab/#auth-tokens">
+            Learn how to create a personal access token
+          </ExternalLink>
+        ),
+      },
+      'proxy',
+      {
+        key: 'rateLimitPerHour',
+        subLabel:
+          'By default, DevLake uses dynamic rate limit around 12,000 requests/hour for optimized data collection for GitLab. But you can adjust the collection speed by entering a fixed value.',
+        learnMore: 'https://devlake.apache.org/docs/Configuration/GitLab#fixed-rate-limit-optional',
+        externalInfo:
+          'The maximum rate limit for GitLab Cloud is 120,000 requests/hour. Tokens under the same IP address share the rate limit, so the actual rate limit for your token will be lower than this number.',
+        defaultValue: 12000,
+      },
+    ],
+  },
+  entities: ['CODE', 'TICKET', 'CODEREVIEW', 'CROSS', 'CICD'],
+  transformation: {
+    productionPattern: '',
+    deploymentPattern: '',
+  },
+};
diff --git a/config-ui/src/plugins/register/jenkins/config.ts b/config-ui/src/plugins/register/jenkins/config.ts
index 14bb3fbcc..58dd0b7b7 100644
--- a/config-ui/src/plugins/register/jenkins/config.ts
+++ b/config-ui/src/plugins/register/jenkins/config.ts
@@ -17,41 +17,35 @@
  */
 
 import type { PluginConfigType } from '@/plugins';
-
-import {
-  BaseConnectionConfig,
-  ConnectionName,
-  ConnectionEndpoint,
-  ConnectionUsername,
-  ConnectionPassword,
-  ConnectionProxy,
-  ConnectionRatelimit,
-} from '../base';
+import { PluginType } from '@/plugins';
 
 import Icon from './assets/icon.svg';
 
 export const JenkinsConfig: PluginConfigType = {
-  ...BaseConnectionConfig,
+  type: PluginType.Connection,
   plugin: 'jenkins',
   name: 'Jenkins',
   icon: Icon,
+  sort: 4,
   connection: {
-    initialValues: {
-      name: 'Jenkins',
-      endpoint: 'https://api.jenkins.io/',
-      rateLimitPerHour: 10000,
-    },
+    docLink: 'https://devlake.apache.org/docs/Configuration/Jenkins',
     fields: [
-      ConnectionName({
-        placeholder: 'eg. Jenkins',
-      }),
-      ConnectionEndpoint({
-        placeholder: 'eg. https://api.jenkins.io/',
-      }),
-      ConnectionUsername(),
-      ConnectionPassword(),
-      ConnectionProxy(),
-      ConnectionRatelimit(),
+      'name',
+      {
+        key: 'endpoint',
+        subLabel: 'Provide the Jenkins instance API endpoint. E.g. https://api.jenkins.io',
+      },
+      'username',
+      'password',
+      'proxy',
+      {
+        key: 'rateLimitPerHour',
+        subLabel:
+          'By default, DevLake uses 10,000 requests/hour for data collection for Jenkins. But you can adjust the collection speed by setting up your desirable rate limit.',
+        learnMore: 'https://devlake.apache.org/docs/Configuration/Jenkins/#fixed-rate-limit-optional',
+        externalInfo: 'Jenkins does not specify a maximum value of rate limit.',
+        defaultValue: 10000,
+      },
     ],
   },
   entities: ['CICD'],
diff --git a/config-ui/src/plugins/register/jira/config.ts b/config-ui/src/plugins/register/jira/config.tsx
similarity index 54%
rename from config-ui/src/plugins/register/jira/config.ts
rename to config-ui/src/plugins/register/jira/config.tsx
index 77dcbea3e..0eb528b48 100644
--- a/config-ui/src/plugins/register/jira/config.ts
+++ b/config-ui/src/plugins/register/jira/config.tsx
@@ -16,40 +16,40 @@
  *
  */
 
-import type { PluginConfigType } from '@/plugins';
+import React from 'react';
 
-import {
-  BaseConnectionConfig,
-  ConnectionName,
-  ConnectionEndpoint,
-  ConnectionProxy,
-  ConnectionRatelimit,
-  ConnectionJIRAAuth,
-} from '../base';
+import type { PluginConfigType } from '@/plugins';
+import { PluginType } from '@/plugins';
 
 import Icon from './assets/icon.svg';
+import { Auth } from './connection-fields';
 
 export const JIRAConfig: PluginConfigType = {
-  ...BaseConnectionConfig,
+  type: PluginType.Connection,
   plugin: 'jira',
   name: 'JIRA',
   icon: Icon,
+  sort: 3,
   connection: {
-    initialValues: {
-      name: 'JIRA',
-      endpoint: 'https://your-domain.atlassian.net/rest/',
-      rateLimitPerHour: 3000,
-    },
+    docLink: 'https://devlake.apache.org/docs/Configuration/Jira',
     fields: [
-      ConnectionName({
-        placeholder: 'eg. JIRA',
-      }),
-      ConnectionEndpoint({
-        placeholder: 'eg. https://your-domain.atlassian.net/rest/',
-      }),
-      ConnectionJIRAAuth(),
-      ConnectionProxy(),
-      ConnectionRatelimit(),
+      'name',
+      {
+        key: 'endpoint',
+        subLabel:
+          'Provide the Jira instance API endpoint. For Jira Cloud, e.g. https://your-company.atlassian.net/rest/',
+      },
+      (props: any) => <Auth {...props} />,
+      'proxy',
+      {
+        key: 'rateLimitPerHour',
+        subLabel:
+          'By default, DevLake uses dynamic rate limit for optimized data collection. But you can adjust the collection speed by setting up your desirable rate limit.',
+        learnMore: 'https://devlake.apache.org/docs/Configuration/Jira/#fixed-rate-limit-optional',
+        externalInfo:
+          'Jira Cloud does not specify a maximum value of rate limit. For Jira Server, please contact your admin for more information.',
+        defaultValue: 10000,
+      },
     ],
   },
   entities: ['TICKET', 'CROSS'],
diff --git a/config-ui/src/plugins/register/jira/connection-fields/auth.tsx b/config-ui/src/plugins/register/jira/connection-fields/auth.tsx
new file mode 100644
index 000000000..71d179ea7
--- /dev/null
+++ b/config-ui/src/plugins/register/jira/connection-fields/auth.tsx
@@ -0,0 +1,126 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import React, { useState } from 'react';
+import { FormGroup, RadioGroup, Radio, InputGroup } from '@blueprintjs/core';
+
+import { ExternalLink } from '@/components';
+
+import * as S from './styled';
+
+type Method = 'BasicAuth' | 'AccessToken';
+
+interface Props {
+  values: any;
+  setValues: (value: any) => void;
+}
+
+export const Auth = ({ values, setValues }: Props) => {
+  const [method, setMethod] = useState<Method>('BasicAuth');
+
+  const handleChangeMethod = (e: React.FormEvent<HTMLInputElement>) => {
+    const m = (e.target as HTMLInputElement).value as Method;
+
+    setMethod(m);
+    setValues({
+      ...values,
+      authMethod: m,
+      username: m === 'BasicAuth' ? values.username : undefined,
+      password: m === 'BasicAuth' ? values.password : undefined,
+      token: m === 'AccessToken' ? values.token : undefined,
+    });
+  };
+
+  const handleChangeUsername = (e: React.ChangeEvent<HTMLInputElement>) => {
+    setValues({
+      ...values,
+      authMethod: 'BasicAuth',
+      username: e.target.value,
+    });
+  };
+
+  const handleChangePassword = (e: React.ChangeEvent<HTMLInputElement>) => {
+    setValues({
+      ...values,
+      authMethod: 'BasicAuth',
+      password: e.target.value,
+    });
+  };
+
+  const handleChangeToken = (e: React.ChangeEvent<HTMLInputElement>) => {
+    setValues({
+      ...values,
+      token: e.target.value,
+    });
+  };
+
+  return (
+    <FormGroup label={<S.Label>Authentication Method</S.Label>} labelInfo={<S.LabelInfo>*</S.LabelInfo>}>
+      <RadioGroup inline selectedValue={method} onChange={handleChangeMethod}>
+        <Radio value="BasicAuth">Basic Authentication</Radio>
+        <Radio value="AccessToken">Using Personal Access Token</Radio>
+      </RadioGroup>
+      {method === 'BasicAuth' && (
+        <>
+          <FormGroup label={<S.Label>Username/e-mail</S.Label>} labelInfo={<S.LabelInfo>*</S.LabelInfo>}>
+            <InputGroup
+              placeholder="Your Username/e-mail"
+              value={values.username || ''}
+              onChange={handleChangeUsername}
+            />
+          </FormGroup>
+          <FormGroup
+            label={<S.Label>Password</S.Label>}
+            labelInfo={<S.LabelInfo>*</S.LabelInfo>}
+            subLabel={
+              <S.LabelDescription>
+                For Jira Cloud, please enter your{' '}
+                <ExternalLink link="https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html">
+                  Personal Access Token
+                </ExternalLink>{' '}
+                For Jira Server v8+, please enter the password of your Jira account.
+              </S.LabelDescription>
+            }
+          >
+            <InputGroup
+              type="password"
+              placeholder="Your Token/Password"
+              value={values.password || ''}
+              onChange={handleChangePassword}
+            />
+          </FormGroup>
+        </>
+      )}
+      {method === 'AccessToken' && (
+        <FormGroup
+          label={<S.Label>Personal Access Token</S.Label>}
+          labelInfo={<S.LabelInfo>*</S.LabelInfo>}
+          subLabel={
+            <S.LabelDescription>
+              <ExternalLink link="https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html">
+                Learn about how to create PAT
+              </ExternalLink>
+            </S.LabelDescription>
+          }
+        >
+          <InputGroup type="password" placeholder="Your PAT" value={values.token || ''} onChange={handleChangeToken} />
+        </FormGroup>
+      )}
+    </FormGroup>
+  );
+};
diff --git a/config-ui/src/plugins/components/index.ts b/config-ui/src/plugins/register/jira/connection-fields/index.ts
similarity index 89%
copy from config-ui/src/plugins/components/index.ts
copy to config-ui/src/plugins/register/jira/connection-fields/index.ts
index 1fdd73324..7a88dcdb2 100644
--- a/config-ui/src/plugins/components/index.ts
+++ b/config-ui/src/plugins/register/jira/connection-fields/index.ts
@@ -16,6 +16,4 @@
  *
  */
 
-export * from './data-scope-list';
-export * from './data-scope';
-export * from './transformation';
+export * from './auth';
diff --git a/config-ui/src/pages/connection/form/styled.ts b/config-ui/src/plugins/register/jira/connection-fields/styled.ts
similarity index 59%
rename from config-ui/src/pages/connection/form/styled.ts
rename to config-ui/src/plugins/register/jira/connection-fields/styled.ts
index 7140b12f3..062843441 100644
--- a/config-ui/src/pages/connection/form/styled.ts
+++ b/config-ui/src/plugins/register/jira/connection-fields/styled.ts
@@ -19,51 +19,44 @@
 import { Colors } from '@blueprintjs/core';
 import styled from 'styled-components';
 
-export const Wrapper = styled.div`
-  .bp4-form-group {
-    display: flex;
-    align-items: start;
-    justify-content: space-between;
-
-    .bp4-label {
-      flex: 0 0 200px;
-      font-weight: 600;
-
-      .bp4-popover2-target {
-        display: inline;
-        margin: 0;
-        line-height: 1;
-        margin-left: 4px;
+export const Label = styled.label`
+  font-size: 16px;
+  font-weight: 600;
+`;
 
-        & > .bp4-icon {
-          display: block;
-        }
-      }
+export const LabelInfo = styled.i`
+  color: #ff8b8b;
+`;
 
-      .bp4-text-muted {
-        color: ${Colors.RED3};
-      }
-    }
+export const LabelDescription = styled.p`
+  margin: 0;
+`;
 
-    .bp4-form-content {
-      flex: auto;
-    }
+export const Endpoint = styled.div`
+  p {
+    margin: 10px 0;
   }
+`;
 
-  .footer {
+export const Token = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 8px;
+
+  .input {
     display: flex;
     align-items: center;
-    justify-content: space-between;
-    margin-top: 32px;
   }
-`;
 
-export const Label = styled.span`
-  display: inline-flex;
-  align-items: center;
-`;
+  .info {
+    margin-left: 4px;
 
-export const SwitchWrapper = styled.div`
-  display: flex;
-  align-items: center;
+    span.error {
+      color: ${Colors.RED3};
+    }
+
+    span.success {
+      color: ${Colors.GREEN3};
+    }
+  }
 `;
diff --git a/config-ui/src/plugins/register/tapd/config.ts b/config-ui/src/plugins/register/tapd/config.ts
index 7dd81e831..9bcb20c9c 100644
--- a/config-ui/src/plugins/register/tapd/config.ts
+++ b/config-ui/src/plugins/register/tapd/config.ts
@@ -17,42 +17,40 @@
  */
 
 import type { PluginConfigType } from '@/plugins';
-
-import {
-  BaseConnectionConfig,
-  ConnectionName,
-  ConnectionEndpoint,
-  ConnectionUsername,
-  ConnectionPassword,
-  ConnectionProxy,
-  ConnectionRatelimit,
-} from '../base';
+import { PluginType } from '@/plugins';
 
 import Icon from './assets/icon.svg';
 
 export const TAPDConfig: PluginConfigType = {
-  ...BaseConnectionConfig,
+  type: PluginType.Connection,
   plugin: 'tapd',
   name: 'TAPD',
   isBeta: true,
   icon: Icon,
+  sort: 6,
   connection: {
+    docLink: 'https://devlake.apache.org/docs/Configuration/Tapd',
     initialValues: {
-      name: 'TAPD',
-      endpoint: 'https://api.tapd.cn/',
-      rateLimitPerHour: 3000,
+      endpoint: 'https://api.tapd.cn',
     },
     fields: [
-      ConnectionName({
-        placeholder: 'eg. TAPD',
-      }),
-      ConnectionEndpoint({
-        placeholder: 'eg. https://api.tapd.cn/',
-      }),
-      ConnectionUsername(),
-      ConnectionPassword(),
-      ConnectionProxy(),
-      ConnectionRatelimit(),
+      'name',
+      {
+        key: 'endpoint',
+        subLabel: 'You do not need to enter the endpoint URL, because all versions use the same URL.',
+        disabled: true,
+      },
+      'username',
+      'password',
+      'proxy',
+      {
+        key: 'rateLimitPerHour',
+        subLabel:
+          'By default, DevLake uses 3,000 requests/hour for data collection for TAPD. But you can adjust the collection speed by setting up your desirable rate limit.',
+        learnMore: 'https://devlake.apache.org/docs/Configuration/Tapdt#fixed-rate-limit-optional',
+        externalInfo: 'The maximum rate limit of TAPD is 3,600 requests/hour.',
+        defaultValue: 3000,
+      },
     ],
   },
   entities: ['TICKET'],
diff --git a/config-ui/src/plugins/register/zentao/config.ts b/config-ui/src/plugins/register/zentao/config.ts
index 692141ee8..47a54faab 100644
--- a/config-ui/src/plugins/register/zentao/config.ts
+++ b/config-ui/src/plugins/register/zentao/config.ts
@@ -17,42 +17,33 @@
  */
 
 import type { PluginConfigType } from '@/plugins';
-
-import {
-  BaseConnectionConfig,
-  ConnectionName,
-  ConnectionEndpoint,
-  ConnectionUsername,
-  ConnectionPassword,
-  ConnectionProxy,
-  ConnectionRatelimit,
-} from '../base';
+import { PluginType } from '@/plugins';
 
 import Icon from './assets/icon.svg';
 
 export const ZenTaoConfig: PluginConfigType = {
-  ...BaseConnectionConfig,
+  type: PluginType.Connection,
   plugin: 'zentao',
   name: 'ZenTao',
   isBeta: true,
   icon: Icon,
+  sort: 7,
   connection: {
-    initialValues: {
-      name: 'ZenTao',
-      endpoint: 'https://your-domain:port/api.php/v1/',
-      rateLimitPerHour: 10000,
-    },
+    docLink: 'https://devlake.apache.org/docs/Configuration/Zentao',
     fields: [
-      ConnectionName({
-        placeholder: 'eg. ZenTao',
-      }),
-      ConnectionEndpoint({
-        placeholder: 'eg. https://your-domain:port/api.php/v1/',
-      }),
-      ConnectionUsername(),
-      ConnectionPassword(),
-      ConnectionProxy(),
-      ConnectionRatelimit(),
+      'name',
+      'endpoint',
+      'username',
+      'password',
+      'proxy',
+      {
+        key: 'rateLimitPerHour',
+        subLabel:
+          'By default, DevLake uses 10,000 requests/hour for data collection for ZenTao. But you can adjust the collection speed by setting up your desirable rate limit.',
+        learnMore: 'https://devlake.apache.org/docs/Configuration/Zentao/#custom-rate-limit-optional',
+        externalInfo: 'Jenkins does not specify a maximum value of rate limit.',
+        defaultValue: 10000,
+      },
     ],
   },
   entities: ['TICKET'],
diff --git a/config-ui/src/plugins/types.ts b/config-ui/src/plugins/types.ts
index f91389553..84e0fd1dd 100644
--- a/config-ui/src/plugins/types.ts
+++ b/config-ui/src/plugins/types.ts
@@ -27,18 +27,12 @@ export type PluginConfigConnectionType = {
   plugin: string;
   name: string;
   icon: string;
+  sort: number;
   isBeta?: boolean;
   connection: {
+    docLink: string;
     initialValues?: Record<string, any>;
-    fields: Array<{
-      key: string;
-      type: 'text' | 'password' | 'switch' | 'rateLimit' | 'githubToken' | 'gitlabToken' | 'jiraAuth';
-      label?: string;
-      required?: boolean;
-      placeholder?: string;
-      tooltip?: string;
-      checkError?: (value: any) => boolean;
-    }>;
+    fields: any[];
   };
   entities: string[];
   transformation: any;