From 772b58198a0bf6c83947ff70fd83aa0354b03225 Mon Sep 17 00:00:00 2001 From: Alice1319 Date: Mon, 17 Jun 2024 15:48:36 +0800 Subject: [PATCH] Add service catalog to CSP role. --- src/App.tsx | 15 ++ .../registeredServiceProps.ts | 126 +++++++++++++++ .../registeredServices/RegisteredServices.tsx | 142 ++++++++++++++++ .../tree/RegisteredServicesFullView.tsx | 150 +++++++++++++++++ .../tree/RegisteredServicesTree.tsx | 46 ++++++ .../tree/ServiceContent.tsx | 151 ++++++++++++++++++ src/components/layouts/sider/menuItems.ts | 3 +- src/components/layouts/sider/servicesMenu.tsx | 12 ++ src/components/utils/constants.tsx | 4 + 9 files changed, 648 insertions(+), 1 deletion(-) create mode 100644 src/components/content/common/registeredServices/registeredServiceProps.ts create mode 100644 src/components/content/registeredServices/RegisteredServices.tsx create mode 100644 src/components/content/registeredServices/tree/RegisteredServicesFullView.tsx create mode 100644 src/components/content/registeredServices/tree/RegisteredServicesTree.tsx create mode 100644 src/components/content/registeredServices/tree/ServiceContent.tsx diff --git a/src/App.tsx b/src/App.tsx index 042dd8720..d78297ac1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import { registerInvalidRoute, registerPageRoute, registerSuccessfulRoute, + registeredServicesPageRoute, reportsRoute, serviceReviewsPageRoute, servicesPageRoute, @@ -44,6 +45,7 @@ const Policies = lazy(() => import('.//components/content/policies/Policies.tsx' const Reports = lazy(() => import('./components/content/deployedServices/reports/Reports.tsx')); const Workflows = lazy(() => import('./components/content/workflows/Workflows.tsx')); const ServiceReviews = lazy(() => import('./components/content/review/ServiceReviews.tsx')); +const AvailableServices = lazy(() => import('./components/content/registeredServices/RegisteredServices.tsx')); const CreateService = lazy(() => import('./components/content/order/create/CreateService.tsx')); const OrderSubmitPage = lazy(() => import('./components/content/order/create/OrderSubmit.tsx')); const NotFoundPage = lazy(() => import('./components/notFound/NotFoundPage.tsx')); @@ -254,6 +256,19 @@ function App(): React.JSX.Element { } /> + + + }> + + + + + } + /> => { + const serviceMapperByNamespace: Map = new Map< + string, + ServiceTemplateDetailVo[] + >(); + for (const serviceTemplate of serviceTemplateList) { + if (serviceTemplate.namespace) { + if (!serviceMapperByNamespace.has(serviceTemplate.namespace)) { + serviceMapperByNamespace.set( + serviceTemplate.namespace, + serviceTemplateList.filter((data) => data.namespace === serviceTemplate.namespace) + ); + } + } + } + + return serviceMapperByNamespace; +}; + +export const groupServicesByCategoryForSpecificNamespace = ( + currentNamespace: string, + serviceTemplateList: ServiceTemplateDetailVo[] +): Map => { + const categoryMapper: Map = new Map(); + const namespaceMapper: Map = + groupServiceTemplatesByNamespace(serviceTemplateList); + namespaceMapper.forEach((serviceList, namespace) => { + if (namespace === currentNamespace) { + for (const service of serviceList) { + if (service.category.toString()) { + if (!categoryMapper.has(service.category.toString())) { + categoryMapper.set( + service.category.toString(), + serviceList.filter((data) => data.category === service.category) + ); + } + } + } + } + }); + return categoryMapper; +}; + +export const groupServicesByNameForSpecificCategory = ( + currentNamespace: string, + currentCategory: string, + serviceTemplateList: ServiceTemplateDetailVo[] +): Map => { + const serviceNameMapper: Map = new Map(); + const categoryMapper: Map = groupServicesByCategoryForSpecificNamespace( + currentNamespace, + serviceTemplateList + ); + categoryMapper.forEach((serviceList, category) => { + if (category === currentCategory) { + for (const service of serviceList) { + if (service.name) { + if (!serviceNameMapper.has(service.name)) { + serviceNameMapper.set( + service.name, + serviceList.filter((data) => data.name === service.name) + ); + } + } + } + } + }); + return serviceNameMapper; +}; + +export const groupRegisteredServicesByVersionForSpecificServiceName = ( + currentNamespace: string, + currentCategory: string, + currentServiceName: string, + serviceTemplateList: ServiceTemplateDetailVo[] +): Map => { + const versionMapper: Map = new Map(); + const serviceNameMapper: Map = groupServicesByNameForSpecificCategory( + currentNamespace, + currentCategory, + serviceTemplateList + ); + serviceNameMapper.forEach((serviceList, serviceName) => { + if (serviceName === currentServiceName) { + for (const service of serviceList) { + if (service.version) { + if (!versionMapper.has(service.version)) { + versionMapper.set( + service.version, + serviceList.filter((data) => data.version === service.version) + ); + } + } + } + } + }); + return versionMapper; +}; + +export function getFourthLevelKeysFromAvailableServicesTree(treeData: DataNode[]): React.Key[] { + const fourthLevelKeys: React.Key[] = []; + + const traverseTree = (nodes: DataNode[], level: number) => { + for (const node of nodes) { + if (level === 4) { + fourthLevelKeys.push(node.key); + } + if (node.children && level < 4) { + traverseTree(node.children, level + 1); + } + } + }; + + traverseTree(treeData, 1); // Start traversal from level 1 + return fourthLevelKeys; +} diff --git a/src/components/content/registeredServices/RegisteredServices.tsx b/src/components/content/registeredServices/RegisteredServices.tsx new file mode 100644 index 000000000..377044fea --- /dev/null +++ b/src/components/content/registeredServices/RegisteredServices.tsx @@ -0,0 +1,142 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * SPDX-FileCopyrightText: Huawei Inc. + */ + +import { CloudServerOutlined, GroupOutlined, TagOutlined } from '@ant-design/icons'; +import { Alert, Empty, Skeleton } from 'antd'; +import { DataNode } from 'antd/es/tree'; +import React from 'react'; +import catalogStyles from '../../../styles/catalog.module.css'; +import servicesEmptyStyles from '../../../styles/services-empty.module.css'; +import { ApiError, Response, ServiceTemplateDetailVo } from '../../../xpanse-api/generated'; +import { convertStringArrayToUnorderedList } from '../../utils/generateUnorderedList.tsx'; +import { + groupRegisteredServicesByVersionForSpecificServiceName, + groupServiceTemplatesByNamespace, + groupServicesByCategoryForSpecificNamespace, + groupServicesByNameForSpecificCategory, +} from '../common/registeredServices/registeredServiceProps.ts'; +import useListAllServiceTemplatesQuery from '../review/query/useListAllServiceTemplatesQuery.ts'; +import { RegisteredServicesFullView } from './tree/RegisteredServicesFullView.tsx'; + +export default function RegisteredServices(): React.JSX.Element { + const treeData: DataNode[] = []; + let availableServiceList: ServiceTemplateDetailVo[] = []; + + const availableServiceTemplatesQuery = useListAllServiceTemplatesQuery(); + + if (availableServiceTemplatesQuery.isSuccess && availableServiceTemplatesQuery.data.length > 0) { + availableServiceList = availableServiceTemplatesQuery.data; + const availableServicesData: Map = + groupServiceTemplatesByNamespace(availableServiceList); + + availableServicesData.forEach((_value: ServiceTemplateDetailVo[], namespace: string) => { + const dataNode: DataNode = { + title:
{namespace}
, + key: namespace || '', + children: [], + }; + + const categoryServiceMapper = groupServicesByCategoryForSpecificNamespace(namespace, availableServiceList); + categoryServiceMapper.forEach((_value: ServiceTemplateDetailVo[], category: string) => { + const categoryNode: DataNode = { + title: category, + key: `${namespace}@${category}`, + icon: , + children: [], + }; + + const serviceMapper = groupServicesByNameForSpecificCategory(namespace, category, availableServiceList); + serviceMapper.forEach((_value: ServiceTemplateDetailVo[], serviceName: string) => { + const serviceNode: DataNode = { + title: serviceName, + key: `${namespace}@${category}@${serviceName}`, + icon: , + children: [], + }; + + const versionMapper = groupRegisteredServicesByVersionForSpecificServiceName( + namespace, + category, + serviceName, + availableServiceList + ); + versionMapper.forEach((_value: ServiceTemplateDetailVo[], version: string) => { + const versionNode: DataNode = { + title: version, + key: `${namespace}@${category}@${serviceName}@${version}`, + icon: , + children: [], + }; + + serviceNode.children?.push(versionNode); + }); + + categoryNode.children?.push(serviceNode); + }); + + dataNode.children?.push(categoryNode); + }); + + treeData.push(dataNode); + }); + } + + if (availableServiceTemplatesQuery.isError) { + if ( + availableServiceTemplatesQuery.error instanceof ApiError && + availableServiceTemplatesQuery.error.body && + 'details' in availableServiceTemplatesQuery.error.body + ) { + const response: Response = availableServiceTemplatesQuery.error.body as Response; + return ( + + ); + } else { + return ( + + ); + } + } + + if (availableServiceTemplatesQuery.isLoading || availableServiceTemplatesQuery.isFetching) { + return ( + + ); + } + + if (availableServiceTemplatesQuery.data && availableServiceTemplatesQuery.data.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+
+ +
+
+ ); +} diff --git a/src/components/content/registeredServices/tree/RegisteredServicesFullView.tsx b/src/components/content/registeredServices/tree/RegisteredServicesFullView.tsx new file mode 100644 index 000000000..dd850adde --- /dev/null +++ b/src/components/content/registeredServices/tree/RegisteredServicesFullView.tsx @@ -0,0 +1,150 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * SPDX-FileCopyrightText: Huawei Inc. + */ + +import { HomeOutlined } from '@ant-design/icons'; +import { DataNode } from 'antd/es/tree'; +import React, { useEffect, useMemo, useState } from 'react'; +import { createSearchParams, useNavigate, useSearchParams } from 'react-router-dom'; +import catalogStyles from '../../../../styles/catalog.module.css'; +import { ServiceTemplateDetailVo } from '../../../../xpanse-api/generated'; +import { + registeredServicesPageRoute, + serviceCategoryQuery, + serviceNameKeyQuery, + serviceNamespaceQuery, + serviceVersionKeyQuery, +} from '../../../utils/constants.tsx'; +import { getFourthLevelKeysFromAvailableServicesTree } from '../../common/registeredServices/registeredServiceProps.ts'; +import { RegisteredServicesTree } from './RegisteredServicesTree.tsx'; +import ServiceContent from './ServiceContent.tsx'; + +export function RegisteredServicesFullView({ + treeData, + availableServiceList, +}: { + treeData: DataNode[]; + availableServiceList: ServiceTemplateDetailVo[]; +}): React.JSX.Element { + const [urlParams] = useSearchParams(); + + const serviceNamespaceInQuery = useMemo(() => { + const queryInUri = decodeURI(urlParams.get(serviceNamespaceQuery) ?? ''); + if (queryInUri.length > 0) { + return queryInUri; + } + return ''; + }, [urlParams]); + + const serviceCategoryInQuery = useMemo(() => { + const queryInUri = decodeURI(urlParams.get(serviceCategoryQuery) ?? ''); + if (queryInUri.length > 0) { + return queryInUri; + } + return ''; + }, [urlParams]); + + const serviceNameInQuery = useMemo(() => { + const queryInUri = decodeURI(urlParams.get(serviceNameKeyQuery) ?? ''); + if (queryInUri.length > 0) { + return queryInUri; + } + return ''; + }, [urlParams]); + + const serviceVersionInQuery = useMemo(() => { + const queryInUri = decodeURI(urlParams.get(serviceVersionKeyQuery) ?? ''); + if (queryInUri.length > 0) { + return queryInUri; + } + return ''; + }, [urlParams]); + + const navigate = useNavigate(); + const allKeysInTree: React.Key[] = getFourthLevelKeysFromAvailableServicesTree(treeData); + + const [selectedKeyInTree, setSelectedKeyInTree] = useState(getDefaultSelectedKey()); + + function getDefaultSelectedKey() { + //if user directly opens an url. + if (serviceNamespaceInQuery && serviceCategoryInQuery && serviceNameInQuery && serviceVersionInQuery) { + const fullKey = `${serviceNamespaceInQuery}@${serviceCategoryInQuery}@${serviceNameInQuery}@${serviceVersionInQuery}`; + + if (allKeysInTree.includes(fullKey)) { + return fullKey; + } + } + return allKeysInTree[0]; + } + + // useEffect necessary since we are updating URL outside the React context. + useEffect(() => { + let namespace: string = ''; + let category: string = ''; + let name: string = ''; + let version: string = ''; + if (selectedKeyInTree && typeof selectedKeyInTree === 'string') { + const parts = selectedKeyInTree.split('@'); + if (parts.length === 4) { + namespace = parts[0]; + category = parts[1]; + name = parts[2]; + version = parts[3]; + } + } + + if (availableServiceList.length > 0) { + for (const value of availableServiceList) { + if ( + value.namespace === namespace && + value.category.toString() === category && + value.name === name && + value.version === version + ) { + if (selectedKeyInTree.toString().length > 0) { + navigate({ + pathname: registeredServicesPageRoute, + search: createSearchParams({ + namespace: namespace, + category: category, + serviceName: name, + version: version, + hostingType: value.serviceHostingType, + }).toString(), + }); + } + break; + } + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedKeyInTree]); + + return ( + <> +
+
+ +  Services +
+ +
+
+
+
Service Details
+ +
+ + ); +} diff --git a/src/components/content/registeredServices/tree/RegisteredServicesTree.tsx b/src/components/content/registeredServices/tree/RegisteredServicesTree.tsx new file mode 100644 index 000000000..c7b3e4b92 --- /dev/null +++ b/src/components/content/registeredServices/tree/RegisteredServicesTree.tsx @@ -0,0 +1,46 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * SPDX-FileCopyrightText: Huawei Inc. + */ + +import { Tree } from 'antd'; +import { DataNode } from 'antd/es/tree'; +import React from 'react'; + +export function RegisteredServicesTree({ + treeData, + selectedKeyInTree, + setSelectedKeyInTree, +}: { + treeData: DataNode[]; + selectedKeyInTree: React.Key; + setSelectedKeyInTree: (selectedKey: React.Key) => void; +}): React.JSX.Element { + function isParentTreeSelected(selectedKeyInTree: React.Key): boolean { + let isParentNode: boolean = false; + treeData.forEach((dataNode: DataNode) => { + if (dataNode.key === selectedKeyInTree) { + isParentNode = true; + } + }); + return isParentNode; + } + + const onSelect = (selectedKeys: React.Key[]) => { + if (selectedKeys.length === 0 || isParentTreeSelected(selectedKeys[0])) { + return; + } + setSelectedKeyInTree(selectedKeys[0]); + }; + + return ( + + ); +} diff --git a/src/components/content/registeredServices/tree/ServiceContent.tsx b/src/components/content/registeredServices/tree/ServiceContent.tsx new file mode 100644 index 000000000..847370a5a --- /dev/null +++ b/src/components/content/registeredServices/tree/ServiceContent.tsx @@ -0,0 +1,151 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * SPDX-FileCopyrightText: Huawei Inc. + */ + +import { EnvironmentOutlined } from '@ant-design/icons'; +import { Empty } from 'antd'; +import React, { useMemo } from 'react'; +import { createSearchParams, useNavigate, useSearchParams } from 'react-router-dom'; +import catalogStyles from '../../../../styles/catalog.module.css'; +import { ServiceTemplateDetailVo } from '../../../../xpanse-api/generated'; +import { + registeredServicesPageRoute, + serviceCategoryQuery, + serviceHostingTypeQuery, + serviceNameKeyQuery, + serviceNamespaceQuery, + serviceVersionKeyQuery, +} from '../../../utils/constants.tsx'; +import ServiceDetail from '../../catalog/services/details/ServiceDetail.tsx'; +import { ServiceHostingOptions } from '../../catalog/services/details/ServiceHostingOptions.tsx'; +import { ServiceProviderSkeleton } from '../../catalog/services/details/ServiceProviderSkeleton.tsx'; + +function ServiceContent({ + availableServiceList, + selectedServiceNamespaceInTree, + selectedServiceCategoryInTree, + selectedServiceNameInTree, + selectedServiceVersionInTree, +}: { + availableServiceList: ServiceTemplateDetailVo[]; + selectedServiceNamespaceInTree: string; + selectedServiceCategoryInTree: string; + selectedServiceNameInTree: string; + selectedServiceVersionInTree: string; +}): React.JSX.Element { + const [urlParams] = useSearchParams(); + const navigate = useNavigate(); + + const serviceNamespaceInQuery = useMemo(() => { + const queryInUri = decodeURI(urlParams.get(serviceNamespaceQuery) ?? ''); + if (queryInUri.length > 0) { + return queryInUri; + } + return ''; + }, [urlParams]); + + const serviceCategoryInQuery = useMemo(() => { + const queryInUri = decodeURI(urlParams.get(serviceCategoryQuery) ?? ''); + if (queryInUri.length > 0) { + return queryInUri; + } + return ''; + }, [urlParams]); + + const serviceNameInQuery = useMemo(() => { + const queryInUri = decodeURI(urlParams.get(serviceNameKeyQuery) ?? ''); + if (queryInUri.length > 0) { + return queryInUri; + } + return ''; + }, [urlParams]); + + const serviceVersionInQuery = useMemo(() => { + const queryInUri = decodeURI(urlParams.get(serviceVersionKeyQuery) ?? ''); + if (queryInUri.length > 0) { + return queryInUri; + } + return ''; + }, [urlParams]); + + const serviceHostingTypeInQuery = useMemo(() => { + const queryInUri = decodeURI(urlParams.get(serviceHostingTypeQuery) ?? ''); + if (queryInUri.length > 0) { + return queryInUri; + } + return ''; + }, [urlParams]); + + let activeServiceDetail: ServiceTemplateDetailVo | undefined = undefined; + + if (serviceNameInQuery) { + if (availableServiceList.length > 0) { + for (const value of availableServiceList) { + if ( + value.category.toString() === serviceCategoryInQuery && + value.name === serviceNameInQuery && + value.version === serviceVersionInQuery && + value.serviceHostingType.toString() === serviceHostingTypeInQuery + ) { + activeServiceDetail = value; + } + } + } + } + + const onChangeServiceHostingType = (serviceTemplateDetailVo: ServiceTemplateDetailVo) => { + navigate({ + pathname: registeredServicesPageRoute, + search: createSearchParams({ + namespace: selectedServiceNamespaceInTree, + category: selectedServiceCategoryInTree, + serviceName: selectedServiceNameInTree, + version: selectedServiceVersionInTree, + hostingType: serviceTemplateDetailVo.serviceHostingType.toString(), + }).toString(), + }); + }; + + // this component renders even before the values are set in the URL. We must wait until it is done. + if ( + !serviceNamespaceInQuery && + !serviceCategoryInQuery && + !serviceNameInQuery && + !serviceVersionInQuery && + !serviceHostingTypeInQuery + ) { + return ; + } + + return ( + <> + {selectedServiceNameInTree.length > 0 ? ( + <> + {activeServiceDetail ? ( + <> +

+ +  Service Hosting Options +

+ + + + ) : ( + // Necessary when user manually enters wrong details in the URL query parameters. + + )} + + ) : ( + + )} + + ); +} + +export default ServiceContent; diff --git a/src/components/layouts/sider/menuItems.ts b/src/components/layouts/sider/menuItems.ts index 7a7972a5c..01b6d9ee6 100644 --- a/src/components/layouts/sider/menuItems.ts +++ b/src/components/layouts/sider/menuItems.ts @@ -14,6 +14,7 @@ import { monitorMenu, myServicesMenu, policiesMenu, + registeredServicesMenu, reportsMenu, serviceReviewsMenu, servicesMenu, @@ -27,7 +28,7 @@ export function getMenuItems(): ItemType[] { } else if (useCurrentUserRoleStore.getState().currentUserRole === 'admin') { return [healthCheckMenu()]; } else if (useCurrentUserRoleStore.getState().currentUserRole === 'csp') { - return [serviceReviewsMenu()]; + return [serviceReviewsMenu(), registeredServicesMenu()]; } else { return [ servicesMenu(serviceCategories), diff --git a/src/components/layouts/sider/servicesMenu.tsx b/src/components/layouts/sider/servicesMenu.tsx index 53a309e81..79d41016a 100644 --- a/src/components/layouts/sider/servicesMenu.tsx +++ b/src/components/layouts/sider/servicesMenu.tsx @@ -6,6 +6,7 @@ import { AreaChartOutlined, AuditOutlined, + BarsOutlined, BranchesOutlined, CloudServerOutlined, DashboardOutlined, @@ -26,6 +27,8 @@ import { myServicesRoute, policiesLabelName, policiesRoute, + registeredServicesLabelName, + registeredServicesPageRoute, reportsLabelName, reportsRoute, serviceReviewsLabelName, @@ -126,3 +129,12 @@ export const serviceReviewsMenu = (): ItemType => { title: 'ReviewService', }; }; + +export const registeredServicesMenu = (): ItemType => { + return { + key: registeredServicesPageRoute, + label: {registeredServicesLabelName}, + icon: , + title: 'RegisteredServices', + }; +}; diff --git a/src/components/utils/constants.tsx b/src/components/utils/constants.tsx index eaf5931bd..48f1fd7f3 100644 --- a/src/components/utils/constants.tsx +++ b/src/components/utils/constants.tsx @@ -43,8 +43,12 @@ export const serviceNameKeyQuery: string = 'serviceName'; export const serviceCspQuery: string = 'csp'; export const serviceVersionKeyQuery: string = 'version'; export const serviceHostingTypeQuery: string = 'hostingType'; +export const serviceNamespaceQuery: string = 'namespace'; +export const serviceCategoryQuery: string = 'category'; export const workflowsPageRoute: string = '/workflows'; export const workflowsLabelName: string = 'Workflows'; export const serviceReviewsPageRoute: string = '/reviewService'; export const serviceReviewsLabelName: string = 'Review Service'; +export const registeredServicesPageRoute: string = '/registeredServices'; +export const registeredServicesLabelName: string = 'Registered Services';