From 15835e97c3db094eaa1d02eb931cf647a750d63f Mon Sep 17 00:00:00 2001 From: typescreep Date: Wed, 28 May 2025 16:48:47 +0300 Subject: [PATCH] marketplace panels --- .../molecules/MarketPlace/MarketPlace.tsx | 295 ++++++++++++++++++ .../MarketPlace/atoms/AddCard/AddCard.tsx | 15 + .../MarketPlace/atoms/AddCard/index.ts | 1 + .../MarketPlace/atoms/AddCard/styled.ts | 14 + .../molecules/MarketPlace/atoms/index.ts | 1 + src/components/molecules/MarketPlace/index.ts | 1 + .../AddEditFormModal/AddEditFormModal.tsx | 164 ++++++++++ .../molecules/AddEditFormModal/index.ts | 1 + .../molecules/CardInProject/CardInProject.tsx | 106 +++++++ .../molecules/CardInProject/index.ts | 1 + .../molecules/CardInProject/styled.ts | 80 +++++ .../molecules/CardInProject/utils.ts | 35 +++ .../SearchTextInput/SearchTextInput.tsx | 19 ++ .../molecules/SearchTextInput/index.ts | 1 + .../molecules/MarketPlace/molecules/index.ts | 3 + src/components/molecules/MarketPlace/types.ts | 34 ++ src/components/molecules/index.ts | 1 + .../organisms/ProjectInfo/ProjectInfo.tsx | 3 +- 18 files changed, 774 insertions(+), 1 deletion(-) create mode 100644 src/components/molecules/MarketPlace/MarketPlace.tsx create mode 100644 src/components/molecules/MarketPlace/atoms/AddCard/AddCard.tsx create mode 100644 src/components/molecules/MarketPlace/atoms/AddCard/index.ts create mode 100644 src/components/molecules/MarketPlace/atoms/AddCard/styled.ts create mode 100644 src/components/molecules/MarketPlace/atoms/index.ts create mode 100644 src/components/molecules/MarketPlace/index.ts create mode 100644 src/components/molecules/MarketPlace/molecules/AddEditFormModal/AddEditFormModal.tsx create mode 100644 src/components/molecules/MarketPlace/molecules/AddEditFormModal/index.ts create mode 100644 src/components/molecules/MarketPlace/molecules/CardInProject/CardInProject.tsx create mode 100644 src/components/molecules/MarketPlace/molecules/CardInProject/index.ts create mode 100644 src/components/molecules/MarketPlace/molecules/CardInProject/styled.ts create mode 100644 src/components/molecules/MarketPlace/molecules/CardInProject/utils.ts create mode 100644 src/components/molecules/MarketPlace/molecules/SearchTextInput/SearchTextInput.tsx create mode 100644 src/components/molecules/MarketPlace/molecules/SearchTextInput/index.ts create mode 100644 src/components/molecules/MarketPlace/molecules/index.ts create mode 100644 src/components/molecules/MarketPlace/types.ts diff --git a/src/components/molecules/MarketPlace/MarketPlace.tsx b/src/components/molecules/MarketPlace/MarketPlace.tsx new file mode 100644 index 0000000..c7e6d9b --- /dev/null +++ b/src/components/molecules/MarketPlace/MarketPlace.tsx @@ -0,0 +1,295 @@ +/* eslint-disable max-lines-per-function */ +import React, { FC, useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' +import { AxiosError } from 'axios' +import { usePermissions, useDirectUnknownResource, DeleteModal } from '@prorobotech/openapi-k8s-toolkit' +import { notification, Typography, Card, Flex, Divider, Switch, theme, Spin, Alert } from 'antd' +import { BASE_API_GROUP, BASE_API_VERSION } from 'constants/customizationApiGroupAndVersion' +import { AddCard } from './atoms' +import { AddEditFormModal, CardInProject, SearchTextInput } from './molecules' +import { + TMarketPlacePanelResponse, + TMarketPlacePanelResource, + TMarketPlacePanel, + TMarketPlaceFiltersAndSorters, +} from './types' + +export const MarketPlace: FC = () => { + const { useToken } = theme + const { token } = useToken() + const [api, contextHolder] = notification.useNotification() + const [isEditMode, setIsEditMode] = useState(false) + const [isAddEditOpen, setIsAddEditOpen] = useState(false) + const [isDeleteOpen, setIsDeleteOpen] = useState<{ name: string } | boolean>(false) + + const [createUpdateError, setCreateUpdateError] = useState() + const [deleteError, setDeleteError] = useState() + + const [initialData, setInitialData] = useState([]) + const [filteredAndSortedData, setFilterAndSortedData] = useState([]) + const [uniqueTags, setUniqueTags] = useState([]) + const [filtersAndSorters, setFiltersAndSorters] = useState({ + searchText: '', + }) + + const { clusterName, namespace } = useParams() + + const { + data: marketplacePanels, + isLoading, + error, + } = useDirectUnknownResource({ + uri: `/api/clusters/${clusterName}/k8s/apis/${BASE_API_GROUP}/${BASE_API_VERSION}/marketplacepanels/`, + refetchInterval: 5000, + queryKey: ['marketplacePanels', clusterName || 'no-cluster'], + isEnabled: clusterName !== undefined, + }) + + const createPermission = usePermissions({ + apiGroup: BASE_API_GROUP, + typeName: 'marketplacepanels', + namespace: '', + clusterName: clusterName || '', + verb: 'create', + refetchInterval: false, + }) + + const updatePermission = usePermissions({ + apiGroup: BASE_API_GROUP, + typeName: 'marketplacepanels', + namespace: '', + clusterName: clusterName || '', + verb: 'update', + refetchInterval: false, + }) + + const deletePermission = usePermissions({ + apiGroup: BASE_API_GROUP, + typeName: 'marketplacepanels', + namespace: '', + clusterName: clusterName || '', + verb: 'delete', + refetchInterval: false, + }) + + const onCreateSuccess = () => + api.success({ + message: 'Card created', + key: 'create-marketplace-success', + }) + + const onUpdateSuccess = () => + api.success({ + message: 'Card modified', + key: 'update-marketplace-success', + }) + + useEffect(() => { + if (marketplacePanels) { + if (isEditMode) { + setInitialData(marketplacePanels.items.map(({ spec }) => spec).sort()) + setUniqueTags( + marketplacePanels.items + .flatMap(({ spec }) => spec.tags) + .filter((value, index, arr) => arr.indexOf(value) === index), + ) + } else { + setInitialData( + marketplacePanels.items + .map(({ spec }) => spec) + .filter(({ hidden }) => hidden !== true) + .sort(), + ) + setUniqueTags( + marketplacePanels.items + .filter(({ spec }) => spec.hidden !== true) + .flatMap(({ spec }) => spec.tags) + .filter((value, index, arr) => arr.indexOf(value) === index), + ) + } + } + }, [marketplacePanels, isEditMode]) + + useEffect(() => { + const { searchText, selectedTag } = filtersAndSorters + + let newData = initialData + .filter( + ({ name, description }) => + name.toLowerCase().includes(searchText.toLowerCase()) || + description.toLowerCase().includes(searchText.toLowerCase()), + ) + .sort() + + if (selectedTag) { + newData = newData.filter(({ tags }) => tags.includes(selectedTag)) + } + + setFilterAndSortedData(newData) + }, [filtersAndSorters, initialData]) + + if (error) { + return
{JSON.stringify(error)}
+ } + + if (isLoading) { + return + } + + if (!marketplacePanels) { + return
No panels
+ } + + const onSearchTextChange = (searchText: string) => { + setFiltersAndSorters({ ...filtersAndSorters, searchText }) + } + + const onTagSelect = (tag: string) => { + if (filtersAndSorters.selectedTag === tag) { + setFiltersAndSorters({ + ...filtersAndSorters, + selectedTag: undefined, + }) + } else { + setFiltersAndSorters({ + ...filtersAndSorters, + selectedTag: tag, + }) + } + } + + const toggleEditMode = () => { + setIsEditMode(!isEditMode) + } + + return ( + <> + {contextHolder} + + + Marketplace + +
+ {(createPermission.data?.status.allowed || + updatePermission.data?.status.allowed || + deletePermission.data?.status.allowed) && ( +
+ Edit Mode +
+ )} +
+
+ {createUpdateError && ( + setCreateUpdateError(undefined)} + type="error" + /> + )} + {deleteError && ( + setDeleteError(undefined)} + type="error" + /> + )} + + + + + + + + setFiltersAndSorters({ + ...filtersAndSorters, + selectedTag: undefined, + }) + } + > + All Items + + {uniqueTags.map(tag => ( + onTagSelect(tag)} + > + {tag} + + ))} + + + +
+ + {clusterName && + namespace && + filteredAndSortedData.map( + ({ name, description, icon, type, pathToNav, typeName, apiGroup, apiVersion, tags, disabled }) => ( + { + const entry = marketplacePanels.items.find(({ spec }) => spec.name === name) + setIsDeleteOpen( + entry + ? { + name: entry.metadata.name, + } + : false, + ) + }} + onEditClick={() => { + setIsAddEditOpen(marketplacePanels.items.find(({ spec }) => spec.name === name) || false) + }} + /> + ), + )} + {isEditMode && ( + { + setIsAddEditOpen(true) + }} + /> + )} + +
+
+ {isAddEditOpen && ( + + )} + {typeof isDeleteOpen !== 'boolean' && ( + setIsDeleteOpen(false)} + endpoint={`/api/clusters/${clusterName}/k8s/apis/${BASE_API_GROUP}/${BASE_API_VERSION}/marketplacepanels/${isDeleteOpen.name}`} + /> + )} + + ) +} diff --git a/src/components/molecules/MarketPlace/atoms/AddCard/AddCard.tsx b/src/components/molecules/MarketPlace/atoms/AddCard/AddCard.tsx new file mode 100644 index 0000000..12a64d2 --- /dev/null +++ b/src/components/molecules/MarketPlace/atoms/AddCard/AddCard.tsx @@ -0,0 +1,15 @@ +import React, { FC } from 'react' +import { PlusOutlined } from '@ant-design/icons' +import { Styled } from './styled' + +type TAddCardProps = { + onAddClick: () => void +} + +export const AddCard: FC = ({ onAddClick }) => { + return ( + + + + ) +} diff --git a/src/components/molecules/MarketPlace/atoms/AddCard/index.ts b/src/components/molecules/MarketPlace/atoms/AddCard/index.ts new file mode 100644 index 0000000..dabc67c --- /dev/null +++ b/src/components/molecules/MarketPlace/atoms/AddCard/index.ts @@ -0,0 +1 @@ +export * from './AddCard' diff --git a/src/components/molecules/MarketPlace/atoms/AddCard/styled.ts b/src/components/molecules/MarketPlace/atoms/AddCard/styled.ts new file mode 100644 index 0000000..dec7609 --- /dev/null +++ b/src/components/molecules/MarketPlace/atoms/AddCard/styled.ts @@ -0,0 +1,14 @@ +import styled from 'styled-components' +import { Card } from 'antd' + +const CustomCard = styled(Card)` + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + width: 256px; +` + +export const Styled = { + CustomCard, +} diff --git a/src/components/molecules/MarketPlace/atoms/index.ts b/src/components/molecules/MarketPlace/atoms/index.ts new file mode 100644 index 0000000..dabc67c --- /dev/null +++ b/src/components/molecules/MarketPlace/atoms/index.ts @@ -0,0 +1 @@ +export * from './AddCard' diff --git a/src/components/molecules/MarketPlace/index.ts b/src/components/molecules/MarketPlace/index.ts new file mode 100644 index 0000000..409c0ca --- /dev/null +++ b/src/components/molecules/MarketPlace/index.ts @@ -0,0 +1 @@ +export * from './MarketPlace' diff --git a/src/components/molecules/MarketPlace/molecules/AddEditFormModal/AddEditFormModal.tsx b/src/components/molecules/MarketPlace/molecules/AddEditFormModal/AddEditFormModal.tsx new file mode 100644 index 0000000..c09d509 --- /dev/null +++ b/src/components/molecules/MarketPlace/molecules/AddEditFormModal/AddEditFormModal.tsx @@ -0,0 +1,164 @@ +import React, { FC, Dispatch, SetStateAction } from 'react' +import { isAxiosError, AxiosError } from 'axios' +import { Form, Input, Select, Switch, Modal } from 'antd' +import { createNewEntry, updateEntry } from '@prorobotech/openapi-k8s-toolkit' +import { useParams } from 'react-router-dom' +import { v4 as uuidv4 } from 'uuid' +import { BASE_API_GROUP, BASE_API_VERSION } from 'constants/customizationApiGroupAndVersion' +import { TMarketPlacePanel, TMarketPlacePanelResource } from '../../types' + +type TAddEditFormModalProps = { + isOpen: boolean | TMarketPlacePanelResource + setIsOpen: Dispatch> + setError: Dispatch> + onCreateSuccess: () => void + onUpdateSuccess: () => void +} + +export const AddEditFormModal: FC = ({ + isOpen, + setIsOpen, + setError, + onCreateSuccess, + onUpdateSuccess, +}) => { + const [form] = Form.useForm() + const type = Form.useWatch('type', form) + const { clusterName } = useParams() + + const defaultValues: TMarketPlacePanel = + typeof isOpen === 'boolean' + ? { + name: '', + description: '', + icon: '', + type: 'direct', + apiGroup: '', + apiVersion: '', + typeName: '', + pathToNav: '', + tags: [], + disabled: false, + hidden: false, + } + : isOpen.spec + + const onSubmit = (values: TMarketPlacePanel) => { + if (typeof isOpen === 'boolean') { + createNewEntry({ + endpoint: `/api/clusters/${clusterName}/k8s/apis/${BASE_API_GROUP}/{BASE_API_VERSION}/marketplacepanels`, + body: { + apiVersion: `${BASE_API_GROUP}/${BASE_API_VERSION}`, + kind: 'MarketplacePanel', + metadata: { + name: uuidv4(), + }, + spec: { ...values }, + }, + }) + .then(() => { + setIsOpen(false) + onCreateSuccess() + }) + .catch(err => { + if (isAxiosError(err) || err instanceof Error) { + setError(err) + } + }) + .finally(() => setIsOpen(false)) + + return + } + + updateEntry({ + endpoint: `/api/clusters/${clusterName}/k8s/apis/${BASE_API_GROUP}/{BASE_API_VERSION}/marketplacepanels/${isOpen.metadata.name}`, + body: { + apiVersion: `${BASE_API_GROUP}/${BASE_API_VERSION}`, + kind: 'MarketplacePanel', + metadata: { + name: isOpen.metadata.name, + resourceVersion: isOpen.metadata.resourceVersion, + }, + spec: { ...values }, + }, + }) + .then(() => { + setIsOpen(false) + onUpdateSuccess() + }) + .catch(err => { + if (isAxiosError(err) || err instanceof Error) { + setError(err) + } + }) + .finally(() => setIsOpen(false)) + } + + const submit = () => { + form + .validateFields() + .then(() => { + onSubmit(form.getFieldsValue()) + }) + // eslint-disable-next-line no-console + .catch(() => console.log('Validating error')) + } + + return ( + setIsOpen(false)} + onOk={() => submit} + > + form={form} name="control-hooks" initialValues={{ ...defaultValues }}> + + + + + + + + + + + + + + + + + + + + + + + { + onSearchTextChange('') + }} + value={searchText} + onChange={e => onSearchTextChange(e.target.value)} + /> +) diff --git a/src/components/molecules/MarketPlace/molecules/SearchTextInput/index.ts b/src/components/molecules/MarketPlace/molecules/SearchTextInput/index.ts new file mode 100644 index 0000000..0e27524 --- /dev/null +++ b/src/components/molecules/MarketPlace/molecules/SearchTextInput/index.ts @@ -0,0 +1 @@ +export * from './SearchTextInput' diff --git a/src/components/molecules/MarketPlace/molecules/index.ts b/src/components/molecules/MarketPlace/molecules/index.ts new file mode 100644 index 0000000..725bb0c --- /dev/null +++ b/src/components/molecules/MarketPlace/molecules/index.ts @@ -0,0 +1,3 @@ +export * from './AddEditFormModal' +export * from './CardInProject' +export * from './SearchTextInput' diff --git a/src/components/molecules/MarketPlace/types.ts b/src/components/molecules/MarketPlace/types.ts new file mode 100644 index 0000000..ed42f5f --- /dev/null +++ b/src/components/molecules/MarketPlace/types.ts @@ -0,0 +1,34 @@ +export type TMarketPlaceFiltersAndSorters = { + searchText: string + selectedTag?: string +} + +export type TMarketPlacePanel = { + name: string + description: string + icon: string + type: 'crd' | 'nonCrd' | 'built-in' | 'direct' + apiGroup?: string + apiVersion?: string + typeName?: string + pathToNav?: string + tags: string[] + disabled?: boolean + hidden?: boolean +} + +export type TMarketPlacePanelResource = { + metadata: { + name: string + resourceVersion: string + uid: string + } + spec: TMarketPlacePanel +} + +export type TMarketPlacePanelResponse = { + metadata: { + name: string + } + items: TMarketPlacePanelResource[] +} diff --git a/src/components/molecules/index.ts b/src/components/molecules/index.ts index b480f3d..c1671f2 100644 --- a/src/components/molecules/index.ts +++ b/src/components/molecules/index.ts @@ -1,2 +1,3 @@ export * from './BlackholeForm' export * from './ManageableBreadcrumbs' +export * from './MarketPlace' diff --git a/src/components/organisms/ProjectInfo/ProjectInfo.tsx b/src/components/organisms/ProjectInfo/ProjectInfo.tsx index bbd293b..4621418 100644 --- a/src/components/organisms/ProjectInfo/ProjectInfo.tsx +++ b/src/components/organisms/ProjectInfo/ProjectInfo.tsx @@ -5,6 +5,7 @@ import { Card, Typography, Flex, Row, Col, Spin } from 'antd' import { useSelector } from 'react-redux' import { RootState } from 'store/store' import { BASE_API_GROUP, BASE_RPROJECTS_VERSION } from 'constants/customizationApiGroupAndVersion' +import { MarketPlace } from 'components' import { DropdownActions } from './molecules' import { Styled } from './styled' @@ -144,7 +145,7 @@ export const ProjectInfo: FC = () => { - Marketplace + {isDeleteModalOpen && (