You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@devlake.apache.org by mi...@apache.org on 2024/09/05 03:32:36 UTC

(incubator-devlake) 01/01: feat: use new project detail layout

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

mintsweet pushed a commit to branch feat-project-layout
in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git

commit 078e78ea299c800fa2b974e4cd3597bc5155e718
Author: mintsweet <0x...@gmail.com>
AuthorDate: Thu Sep 5 15:32:16 2024 +1200

    feat: use new project detail layout
---
 config-ui/src/api/project/index.ts                 |   2 +-
 config-ui/src/app/routrer.tsx                      |  35 ++++--
 config-ui/src/hooks/index.ts                       |   1 +
 .../styled.ts => hooks/use-outside-click.ts}       |  22 ++--
 .../routes/blueprint/detail/blueprint-detail.tsx   |   1 -
 .../index.tsx}                                     |  36 +++---
 config-ui/src/routes/project/detail/index.tsx      |  98 -----------------
 .../styled.ts => general-settings/index.tsx}       |  22 +++-
 config-ui/src/routes/project/index.ts              |   5 +-
 config-ui/src/routes/project/layout/index.tsx      | 102 +++++++++++++++++
 .../src/routes/project/layout/project-selector.tsx | 122 +++++++++++++++++++++
 .../routes/project/{detail => layout}/styled.ts    |  52 ++++++++-
 .../webhooks-panel.tsx => webhook/index.tsx}       |  60 +++++-----
 13 files changed, 393 insertions(+), 165 deletions(-)

diff --git a/config-ui/src/api/project/index.ts b/config-ui/src/api/project/index.ts
index 52e3d2351..6023ad8da 100644
--- a/config-ui/src/api/project/index.ts
+++ b/config-ui/src/api/project/index.ts
@@ -20,7 +20,7 @@ import type { IProject } from '@/types';
 import { encodeName } from '@/routes';
 import { request } from '@/utils';
 
-export const list = (data: Pagination): Promise<{ count: number; projects: IProject[] }> =>
+export const list = (data: Pagination & { keyword?: string }): Promise<{ count: number; projects: IProject[] }> =>
   request('/projects', { data });
 
 export const get = (name: string): Promise<IProject> => request(`/projects/${encodeName(name)}`);
diff --git a/config-ui/src/app/routrer.tsx b/config-ui/src/app/routrer.tsx
index ac3113db7..a11f599b9 100644
--- a/config-ui/src/app/routrer.tsx
+++ b/config-ui/src/app/routrer.tsx
@@ -27,7 +27,10 @@ import {
   Connections,
   Connection,
   ProjectHomePage,
-  ProjectDetailPage,
+  ProjectLayout,
+  ProjectGeneralSettings,
+  ProjectWebhook,
+  ProjectAdditionalSettings,
   BlueprintHomePage,
   BlueprintDetailPage,
   BlueprintConnectionDetailPage,
@@ -52,6 +55,28 @@ export const router = createBrowserRouter([
     path: `${PATH_PREFIX}/onboard`,
     element: <Onboard />,
   },
+  {
+    path: `${PATH_PREFIX}/projects/:pname`,
+    element: <ProjectLayout />,
+    children: [
+      {
+        index: true,
+        element: <Navigate to="general-settings" />,
+      },
+      {
+        path: 'general-settings',
+        element: <ProjectGeneralSettings />,
+      },
+      {
+        path: 'webhooks',
+        element: <ProjectWebhook />,
+      },
+      {
+        path: 'additional-settings',
+        element: <ProjectAdditionalSettings />,
+      },
+    ],
+  },
   {
     path: `${PATH_PREFIX}`,
     element: <Layout />,
@@ -66,14 +91,6 @@ export const router = createBrowserRouter([
         path: 'projects',
         element: <ProjectHomePage />,
       },
-      {
-        path: 'projects/:pname',
-        element: <ProjectDetailPage />,
-      },
-      {
-        path: 'projects/:pname/:unique',
-        element: <BlueprintConnectionDetailPage />,
-      },
       {
         path: 'connections',
         element: <Connections />,
diff --git a/config-ui/src/hooks/index.ts b/config-ui/src/hooks/index.ts
index 076b27375..1d6200f14 100644
--- a/config-ui/src/hooks/index.ts
+++ b/config-ui/src/hooks/index.ts
@@ -18,5 +18,6 @@
 
 export * from './extend-redux';
 export * from './use-auto-refresh';
+export * from './use-outside-click';
 export * from './use-refresh-data';
 export * from './user-proxy-prefix';
diff --git a/config-ui/src/routes/project/detail/styled.ts b/config-ui/src/hooks/use-outside-click.ts
similarity index 60%
copy from config-ui/src/routes/project/detail/styled.ts
copy to config-ui/src/hooks/use-outside-click.ts
index f2ac11cfb..f1eb4fe7e 100644
--- a/config-ui/src/routes/project/detail/styled.ts
+++ b/config-ui/src/hooks/use-outside-click.ts
@@ -16,11 +16,19 @@
  *
  */
 
-import styled from 'styled-components';
+import type { MutableRefObject } from 'react';
+import { useEffect } from 'react';
 
-export const Wrapper = styled.div``;
-
-export const DialogBody = styled.div`
-  display: flex;
-  align-items: center;
-`;
+export const useOutsideClick = (ref: MutableRefObject<HTMLDivElement | null>, cb: () => void) => {
+  useEffect(() => {
+    function handleClickOutside(event: any) {
+      if (ref.current && !ref.current.contains(event.target)) {
+        cb();
+      }
+    }
+    document.addEventListener('mousedown', handleClickOutside);
+    return () => {
+      document.removeEventListener('mousedown', handleClickOutside);
+    };
+  }, [ref, cb]);
+};
diff --git a/config-ui/src/routes/blueprint/detail/blueprint-detail.tsx b/config-ui/src/routes/blueprint/detail/blueprint-detail.tsx
index 78a7b6e4f..b0b52cd11 100644
--- a/config-ui/src/routes/blueprint/detail/blueprint-detail.tsx
+++ b/config-ui/src/routes/blueprint/detail/blueprint-detail.tsx
@@ -67,7 +67,6 @@ export const BlueprintDetail = ({ id, from }: Props) => {
   return (
     <S.Wrapper>
       <Tabs
-        centered
         items={[
           {
             key: 'status',
diff --git a/config-ui/src/routes/project/detail/settings-panel.tsx b/config-ui/src/routes/project/additional-settings/index.tsx
similarity index 92%
rename from config-ui/src/routes/project/detail/settings-panel.tsx
rename to config-ui/src/routes/project/additional-settings/index.tsx
index 5f3ce4048..11e72c074 100644
--- a/config-ui/src/routes/project/detail/settings-panel.tsx
+++ b/config-ui/src/routes/project/additional-settings/index.tsx
@@ -17,25 +17,18 @@
  */
 
 import { useEffect, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
+import { useParams, useNavigate } from 'react-router-dom';
 import { Flex, Space, Card, Modal, Input, Checkbox, Button } from 'antd';
 
 import API from '@/api';
 import { Block, HelpTooltip, Message } from '@/components';
 import { PATHS } from '@/config';
-import { IProject } from '@/types';
+import { useRefreshData } from '@/hooks';
 import { operator } from '@/utils';
 
-import * as S from './styled';
-
 const RegexPrIssueDefaultValue = '(?mi)(Closes)[\\s]*.*(((and )?#\\d+[ ]*)+)';
 
-interface Props {
-  project: IProject;
-  onRefresh: () => void;
-}
-
-export const SettingsPanel = ({ project, onRefresh }: Props) => {
+export const ProjectAdditionalSettings = () => {
   const [name, setName] = useState('');
   const [dora, setDora] = useState({
     enable: false,
@@ -49,10 +42,19 @@ export const SettingsPanel = ({ project, onRefresh }: Props) => {
   });
   const [operating, setOperating] = useState(false);
   const [open, setOpen] = useState(false);
+  const [version, setVersion] = useState(0);
 
   const navigate = useNavigate();
 
+  const { pname } = useParams() as { pname: string };
+
+  const { data: project } = useRefreshData(() => API.project.get(pname), [pname, version]);
+
   useEffect(() => {
+    if (!project) {
+      return;
+    }
+
     const dora = project.metrics.find((ms) => ms.pluginName === 'dora');
     const linker = project.metrics.find((ms) => ms.pluginName === 'linker');
     const issueTrace = project.metrics.find((ms) => ms.pluginName === 'issue_trace');
@@ -71,6 +73,10 @@ export const SettingsPanel = ({ project, onRefresh }: Props) => {
   }, [project]);
 
   const handleUpdate = async () => {
+    if (!project) {
+      return;
+    }
+
     const [success] = await operator(
       () =>
         API.project.update(project.name, {
@@ -102,7 +108,7 @@ export const SettingsPanel = ({ project, onRefresh }: Props) => {
     );
 
     if (success) {
-      onRefresh();
+      setVersion((v) => v + 1);
       navigate(PATHS.PROJECT(name), {
         state: {
           tabId: 'settings',
@@ -120,6 +126,10 @@ export const SettingsPanel = ({ project, onRefresh }: Props) => {
   };
 
   const handleDelete = async () => {
+    if (!project) {
+      return;
+    }
+
     const [success] = await operator(() => API.project.remove(project.name), {
       setOperating,
       formatMessage: () => 'Delete project successful.',
@@ -215,9 +225,9 @@ export const SettingsPanel = ({ project, onRefresh }: Props) => {
         onCancel={handleHideDeleteDialog}
         onOk={handleDelete}
       >
-        <S.DialogBody>
+        <Flex align="center">
           <Message content="This operation cannot be undone. Deleting a Data Connection will delete all data that have been collected in this Connection." />
-        </S.DialogBody>
+        </Flex>
       </Modal>
     </Flex>
   );
diff --git a/config-ui/src/routes/project/detail/index.tsx b/config-ui/src/routes/project/detail/index.tsx
deleted file mode 100644
index fb37a68eb..000000000
--- a/config-ui/src/routes/project/detail/index.tsx
+++ /dev/null
@@ -1,98 +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 { useEffect, useState } from 'react';
-import { useParams, useLocation } from 'react-router-dom';
-import { Helmet } from 'react-helmet';
-import { Tabs } from 'antd';
-
-import API from '@/api';
-import { PageHeader, PageLoading } from '@/components';
-import { PATHS } from '@/config';
-import { useRefreshData } from '@/hooks';
-import { BlueprintDetail, FromEnum } from '@/routes';
-
-import { WebhooksPanel } from './webhooks-panel';
-import { SettingsPanel } from './settings-panel';
-import * as S from './styled';
-
-const brandName = import.meta.env.DEVLAKE_BRAND_NAME ?? 'DevLake';
-
-export const ProjectDetailPage = () => {
-  const [version, setVersion] = useState(1);
-  const [tabId, setTabId] = useState('blueprint');
-
-  const { pname } = useParams() as { pname: string };
-  const { state } = useLocation();
-
-  useEffect(() => {
-    setTabId(state?.tabId ?? 'blueprint');
-  }, [state]);
-
-  const { ready, data } = useRefreshData(() => API.project.get(pname), [pname, version]);
-
-  const handleChangeTabId = (tabId: string) => {
-    setTabId(tabId);
-  };
-
-  const handleRefresh = () => {
-    setVersion((v) => v + 1);
-  };
-
-  if (!ready || !data) {
-    return <PageLoading />;
-  }
-
-  return (
-    <PageHeader
-      breadcrumbs={[
-        { name: 'Projects', path: PATHS.PROJECTS() },
-        { name: data.name, path: PATHS.PROJECT(pname) },
-      ]}
-    >
-      <Helmet>
-        <title>
-          {data.name} - {brandName}
-        </title>
-      </Helmet>
-      <S.Wrapper>
-        <Tabs
-          items={[
-            {
-              key: 'blueprint',
-              label: 'Blueprint',
-              children: <BlueprintDetail id={data.blueprint.id} from={FromEnum.project} />,
-            },
-            {
-              key: 'webhook',
-              label: 'Webhooks',
-              children: <WebhooksPanel project={data} onRefresh={handleRefresh} />,
-            },
-            {
-              key: 'settings',
-              label: 'Settings',
-              children: <SettingsPanel project={data} onRefresh={handleRefresh} />,
-            },
-          ]}
-          activeKey={tabId}
-          onChange={handleChangeTabId}
-        />
-      </S.Wrapper>
-    </PageHeader>
-  );
-};
diff --git a/config-ui/src/routes/project/detail/styled.ts b/config-ui/src/routes/project/general-settings/index.tsx
similarity index 59%
copy from config-ui/src/routes/project/detail/styled.ts
copy to config-ui/src/routes/project/general-settings/index.tsx
index f2ac11cfb..ef538afcc 100644
--- a/config-ui/src/routes/project/detail/styled.ts
+++ b/config-ui/src/routes/project/general-settings/index.tsx
@@ -16,11 +16,21 @@
  *
  */
 
-import styled from 'styled-components';
+import { useParams } from 'react-router-dom';
 
-export const Wrapper = styled.div``;
+import API from '@/api';
+import { PageLoading } from '@/components';
+import { useRefreshData } from '@/hooks';
+import { BlueprintDetail, FromEnum } from '@/routes';
 
-export const DialogBody = styled.div`
-  display: flex;
-  align-items: center;
-`;
+export const ProjectGeneralSettings = () => {
+  const { pname } = useParams() as { pname: string };
+
+  const { ready, data: project } = useRefreshData(() => API.project.get(pname), [pname]);
+
+  if (!ready || !project) {
+    return <PageLoading />;
+  }
+
+  return <BlueprintDetail id={project.blueprint.id} from={FromEnum.project} />;
+};
diff --git a/config-ui/src/routes/project/index.ts b/config-ui/src/routes/project/index.ts
index de11b8b83..59597a4c7 100644
--- a/config-ui/src/routes/project/index.ts
+++ b/config-ui/src/routes/project/index.ts
@@ -16,6 +16,9 @@
  *
  */
 
+export * from './additional-settings';
 export * from './utils';
 export * from './home';
-export * from './detail';
+export * from './general-settings';
+export * from './layout';
+export * from './webhook';
diff --git a/config-ui/src/routes/project/layout/index.tsx b/config-ui/src/routes/project/layout/index.tsx
new file mode 100644
index 000000000..579fc16df
--- /dev/null
+++ b/config-ui/src/routes/project/layout/index.tsx
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { useMemo } from 'react';
+import { useParams, useNavigate, useLocation, Outlet } from 'react-router-dom';
+import { RollbackOutlined } from '@ant-design/icons';
+import { Layout, Menu } from 'antd';
+
+import { PageHeader } from '@/components';
+import { PATHS } from '@/config';
+
+import { ProjectSelector } from './project-selector';
+import * as S from './styled';
+
+const { Sider, Content } = Layout;
+
+const items = [
+  {
+    key: 'general-settings',
+    label: 'General Settings',
+    style: {
+      paddingLeft: 8,
+    },
+  },
+  {
+    key: 'webhooks',
+    label: 'Webhooks',
+    style: {
+      paddingLeft: 8,
+    },
+  },
+  {
+    key: 'additional-settings',
+    label: 'Additional Settings',
+    style: {
+      paddingLeft: 8,
+    },
+  },
+];
+
+export const ProjectLayout = () => {
+  const { pname } = useParams() as { pname: string };
+  const navigate = useNavigate();
+  const { pathname } = useLocation();
+
+  const { selectedKeys, breadcrumbs } = useMemo(() => {
+    const key = pathname.split('/').pop();
+    const item = items.find((i) => i.key === key);
+
+    return {
+      selectedKeys: key ? [key] : [],
+      breadcrumbs: [
+        {
+          name: item?.label ?? '',
+          path: '',
+        },
+      ],
+    };
+  }, [pathname]);
+
+  return (
+    <Layout style={{ height: '100%', overflow: 'hidden' }}>
+      <Sider width={240} style={{ padding: '36px 12px', backgroundColor: '#F9F9FA', borderRight: '1px solid #E7E9F3' }}>
+        <S.Top onClick={() => navigate(PATHS.PROJECTS())}>
+          <RollbackOutlined />
+          <span className="back">Back to Projects</span>
+        </S.Top>
+        <ProjectSelector name={pname} />
+        <Menu
+          mode="inline"
+          style={{ backgroundColor: '#F9F9FA', border: 'none' }}
+          items={items}
+          selectedKeys={selectedKeys}
+          onClick={({ key }) => navigate(`${PATHS.PROJECT(pname)}/${key}`)}
+        />
+      </Sider>
+      <Layout>
+        <Content style={{ padding: '36px 48px', overflowY: 'auto' }}>
+          <p>Configurations / Projects / {pname} /</p>
+          <PageHeader breadcrumbs={breadcrumbs}>
+            <Outlet />
+          </PageHeader>
+        </Content>
+      </Layout>
+    </Layout>
+  );
+};
diff --git a/config-ui/src/routes/project/layout/project-selector.tsx b/config-ui/src/routes/project/layout/project-selector.tsx
new file mode 100644
index 000000000..bcfffd615
--- /dev/null
+++ b/config-ui/src/routes/project/layout/project-selector.tsx
@@ -0,0 +1,122 @@
+/*
+ * 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, useEffect, useReducer, useRef } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { CaretDownOutlined } from '@ant-design/icons';
+import { Input, Button, Flex } from 'antd';
+import { useDebounce } from 'ahooks';
+
+import { PageLoading } from '@/components';
+import { PATHS } from '@/config';
+import { useOutsideClick } from '@/hooks';
+import { operator } from '@/utils';
+
+import API from '@/api';
+
+import * as S from './styled';
+
+type StateType = { name: string }[];
+
+const reducer = (state: StateType, action: { type: string; payload: StateType }) => {
+  switch (action.type) {
+    case 'RESET':
+      return [...action.payload];
+    case 'APPEND':
+      return [...state, ...action.payload];
+    default:
+      return state;
+  }
+};
+
+interface Props {
+  name: string;
+}
+
+export const ProjectSelector = ({ name }: Props) => {
+  const [open, setOpen] = useState(false);
+  const [total, setTotal] = useState(0);
+  const [page, setPage] = useState(1);
+  const [pageSize] = useState(10);
+  const [keyword, setKeyword] = useState('');
+  const [operating, setOperating] = useState(false);
+
+  const [state, dispatch] = useReducer(reducer, []);
+
+  const ref = useRef(null);
+
+  useOutsideClick(ref, () => setOpen(false));
+
+  const navigate = useNavigate();
+
+  const keywordDebounce = useDebounce(keyword, { wait: 500 });
+
+  useEffect(() => {
+    setOperating(true);
+    (async () => {
+      const res = await API.project.list({ page: 1, pageSize, keyword: keywordDebounce });
+      dispatch({ type: 'RESET', payload: res.projects });
+      setTotal(res.count);
+      setOperating(false);
+    })();
+  }, [keywordDebounce]);
+
+  const handleAppend = async () => {
+    const [success, res] = await operator(() => API.project.list({ page: page + 1, pageSize, keyword }), {
+      hideToast: true,
+      setOperating,
+    });
+
+    if (success) {
+      setPage(page + 1);
+      dispatch({ type: 'APPEND', payload: res.projects });
+    }
+  };
+
+  return (
+    <S.ProjectSelector ref={ref}>
+      <h1 onClick={() => setOpen(true)}>
+        <span>{name}</span>
+        <CaretDownOutlined style={{ fontSize: 12 }} />
+      </h1>
+      {open && (
+        <S.Selector>
+          <Input.Search placeholder="Search" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
+          {operating ? (
+            <PageLoading />
+          ) : state.length ? (
+            <ul>
+              {state.map((it) => (
+                <li key={it.name} onClick={() => navigate(PATHS.PROJECT(it.name))}>
+                  {it.name}
+                </li>
+              ))}
+            </ul>
+          ) : (
+            <p style={{ textAlign: 'center' }}>No Results</p>
+          )}
+          {total > state.length && (
+            <Flex style={{ marginTop: 16 }} align="center" justify="center" onClick={handleAppend}>
+              <Button loading={operating}>Load More</Button>
+            </Flex>
+          )}
+        </S.Selector>
+      )}
+    </S.ProjectSelector>
+  );
+};
diff --git a/config-ui/src/routes/project/detail/styled.ts b/config-ui/src/routes/project/layout/styled.ts
similarity index 50%
rename from config-ui/src/routes/project/detail/styled.ts
rename to config-ui/src/routes/project/layout/styled.ts
index f2ac11cfb..722e8453c 100644
--- a/config-ui/src/routes/project/detail/styled.ts
+++ b/config-ui/src/routes/project/layout/styled.ts
@@ -18,9 +18,53 @@
 
 import styled from 'styled-components';
 
-export const Wrapper = styled.div``;
+export const Top = styled.div`
+  cursor: pointer;
 
-export const DialogBody = styled.div`
-  display: flex;
-  align-items: center;
+  & > span.back {
+    margin-left: 8px;
+    text-decoration: underline;
+  }
+`;
+
+export const ProjectSelector = styled.div`
+  position: relative;
+  padding: 16px 8px;
+
+  & > h1 {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    cursor: pointer;
+  }
+`;
+
+export const Selector = styled.div`
+  position: absolute;
+  top: 50px;
+  right: 0;
+  left: 0;
+  padding: 16px 8px;
+  background-color: #fff;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+  border-radius: 6px;
+  z-index: 1;
+
+  & > ul,
+  & > p {
+    margin-top: 16px;
+  }
+
+  & > ul > li {
+    display: flex;
+    padding: 8px 16px;
+    align-items: center;
+    min-height: 40px;
+    transition: background-color 0.3s;
+    cursor: pointer;
+
+    &:hover {
+      background-color: #f5f5f5;
+    }
+  }
 `;
diff --git a/config-ui/src/routes/project/detail/webhooks-panel.tsx b/config-ui/src/routes/project/webhook/index.tsx
similarity index 80%
rename from config-ui/src/routes/project/detail/webhooks-panel.tsx
rename to config-ui/src/routes/project/webhook/index.tsx
index aefbbb7f2..fafd41d05 100644
--- a/config-ui/src/routes/project/detail/webhooks-panel.tsx
+++ b/config-ui/src/routes/project/webhook/index.tsx
@@ -17,32 +17,32 @@
  */
 
 import { useState, useMemo } from 'react';
-import { Link } from 'react-router-dom';
+import { useParams, Link } from 'react-router-dom';
 import { PlusOutlined } from '@ant-design/icons';
 import { Alert, Button } from 'antd';
 
 import API from '@/api';
 import { NoData } from '@/components';
+import { useRefreshData } from '@/hooks';
 import type { WebhookItemType } from '@/plugins/register/webhook';
 import { WebhookCreateDialog, WebhookSelectorDialog, WebHookConnection } from '@/plugins/register/webhook';
-import { IProject } from '@/types';
 import { operator } from '@/utils';
 
-interface Props {
-  project: IProject;
-  onRefresh: () => void;
-}
-
-export const WebhooksPanel = ({ project, onRefresh }: Props) => {
+export const ProjectWebhook = () => {
   const [type, setType] = useState<'selectExist' | 'create'>();
   const [operating, setOperating] = useState(false);
+  const [version, setVersion] = useState(0);
+
+  const { pname } = useParams() as { pname: string };
+
+  const { data } = useRefreshData(() => API.project.get(pname), [pname, version]);
 
   const webhookIds = useMemo(
     () =>
-      project.blueprint
-        ? project.blueprint.connections.filter((cs) => cs.pluginName === 'webhook').map((cs: any) => cs.connectionId)
+      data?.blueprint
+        ? data?.blueprint.connections.filter((cs) => cs.pluginName === 'webhook').map((cs: any) => cs.connectionId)
         : [],
-    [project],
+    [data],
   );
 
   const handleCancel = () => {
@@ -50,10 +50,14 @@ export const WebhooksPanel = ({ project, onRefresh }: Props) => {
   };
 
   const handleCreate = async (id: ID) => {
+    if (!data) {
+      return;
+    }
+
     const payload = {
-      ...project.blueprint,
+      ...data.blueprint,
       connections: [
-        ...project.blueprint.connections,
+        ...data.blueprint.connections,
         {
           pluginName: 'webhook',
           connectionId: id,
@@ -61,20 +65,24 @@ export const WebhooksPanel = ({ project, onRefresh }: Props) => {
       ],
     };
 
-    const [success] = await operator(() => API.blueprint.update(project.blueprint.id, payload), {
+    const [success] = await operator(() => API.blueprint.update(data.blueprint.id, payload), {
       setOperating,
     });
 
     if (success) {
-      onRefresh();
+      setVersion(version + 1);
     }
   };
 
   const handleSelect = async (items: WebhookItemType[]) => {
+    if (!data) {
+      return;
+    }
+
     const payload = {
-      ...project.blueprint,
+      ...data.blueprint,
       connections: [
-        ...project.blueprint.connections,
+        ...data.blueprint.connections,
         ...items.map((it) => ({
           pluginName: 'webhook',
           connectionId: it.id,
@@ -82,29 +90,31 @@ export const WebhooksPanel = ({ project, onRefresh }: Props) => {
       ],
     };
 
-    const [success] = await operator(() => API.blueprint.update(project.blueprint.id, payload), {
+    const [success] = await operator(() => API.blueprint.update(data.blueprint.id, payload), {
       setOperating,
     });
 
     if (success) {
-      onRefresh();
+      setVersion(version + 1);
     }
   };
 
   const handleDelete = async (id: ID) => {
+    if (!data) {
+      return;
+    }
+
     const payload = {
-      ...project.blueprint,
-      connections: project.blueprint.connections.filter(
-        (cs) => !(cs.pluginName === 'webhook' && cs.connectionId === id),
-      ),
+      ...data.blueprint,
+      connections: data.blueprint.connections.filter((cs) => !(cs.pluginName === 'webhook' && cs.connectionId === id)),
     };
 
-    const [success] = await operator(() => API.blueprint.update(project.blueprint.id, payload), {
+    const [success] = await operator(() => API.blueprint.update(data.blueprint.id, payload), {
       setOperating,
     });
 
     if (success) {
-      onRefresh();
+      setVersion(version + 1);
     }
   };