diff --git a/config-ui/src/api/project/index.ts b/config-ui/src/api/project/index.ts index 52e3d2351345..6023ad8da24a 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 => request(`/projects/${encodeName(name)}`); diff --git a/config-ui/src/app/routrer.tsx b/config-ui/src/app/routrer.tsx index ac3113db7895..a11f599b9308 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: , }, + { + path: `${PATH_PREFIX}/projects/:pname`, + element: , + children: [ + { + index: true, + element: , + }, + { + path: 'general-settings', + element: , + }, + { + path: 'webhooks', + element: , + }, + { + path: 'additional-settings', + element: , + }, + ], + }, { path: `${PATH_PREFIX}`, element: , @@ -66,14 +91,6 @@ export const router = createBrowserRouter([ path: 'projects', element: , }, - { - path: 'projects/:pname', - element: , - }, - { - path: 'projects/:pname/:unique', - element: , - }, { path: 'connections', element: , diff --git a/config-ui/src/hooks/index.ts b/config-ui/src/hooks/index.ts index 076b27375d62..1d6200f141c7 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% rename from config-ui/src/routes/project/detail/styled.ts rename to config-ui/src/hooks/use-outside-click.ts index f2ac11cfbce2..f1eb4fe7e8ee 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, 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 78a7b6e4fae8..b0b52cd115a4 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 ( 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} > - + - + ); 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 fb37a68ebe75..000000000000 --- 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 ; - } - - return ( - - - - {data.name} - {brandName} - - - - , - }, - { - key: 'webhook', - label: 'Webhooks', - children: , - }, - { - key: 'settings', - label: 'Settings', - children: , - }, - ]} - activeKey={tabId} - onChange={handleChangeTabId} - /> - - - ); -}; diff --git a/config-ui/src/routes/project/general-settings/index.tsx b/config-ui/src/routes/project/general-settings/index.tsx new file mode 100644 index 000000000000..ef538afcc1bd --- /dev/null +++ b/config-ui/src/routes/project/general-settings/index.tsx @@ -0,0 +1,36 @@ +/* + * 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 { useParams } from 'react-router-dom'; + +import API from '@/api'; +import { PageLoading } from '@/components'; +import { useRefreshData } from '@/hooks'; +import { BlueprintDetail, FromEnum } from '@/routes'; + +export const ProjectGeneralSettings = () => { + const { pname } = useParams() as { pname: string }; + + const { ready, data: project } = useRefreshData(() => API.project.get(pname), [pname]); + + if (!ready || !project) { + return ; + } + + return ; +}; diff --git a/config-ui/src/routes/project/index.ts b/config-ui/src/routes/project/index.ts index de11b8b83204..59597a4c7499 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 000000000000..579fc16df160 --- /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 ( + + + navigate(PATHS.PROJECTS())}> + + Back to Projects + + + navigate(`${PATHS.PROJECT(pname)}/${key}`)} + /> + + + +

Configurations / Projects / {pname} /

+ + + +
+
+ + ); +}; 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 000000000000..bcfffd615f47 --- /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 ( + +

setOpen(true)}> + {name} + +

+ {open && ( + + setKeyword(e.target.value)} /> + {operating ? ( + + ) : state.length ? ( +
    + {state.map((it) => ( +
  • navigate(PATHS.PROJECT(it.name))}> + {it.name} +
  • + ))} +
+ ) : ( +

No Results

+ )} + {total > state.length && ( + + + + )} +
+ )} +
+ ); +}; diff --git a/config-ui/src/routes/project/layout/styled.ts b/config-ui/src/routes/project/layout/styled.ts new file mode 100644 index 000000000000..722e8453c745 --- /dev/null +++ b/config-ui/src/routes/project/layout/styled.ts @@ -0,0 +1,70 @@ +/* + * 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 styled from 'styled-components'; + +export const Top = styled.div` + cursor: pointer; + + & > 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 aefbbb7f258d..fafd41d05e55 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); } };