marketplace panels

This commit is contained in:
typescreep
2025-05-28 16:48:47 +03:00
parent 197c2e1895
commit 15835e97c3
18 changed files with 774 additions and 1 deletions

View File

@@ -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<boolean>(false)
const [isAddEditOpen, setIsAddEditOpen] = useState<boolean | TMarketPlacePanelResource>(false)
const [isDeleteOpen, setIsDeleteOpen] = useState<{ name: string } | boolean>(false)
const [createUpdateError, setCreateUpdateError] = useState<AxiosError | Error>()
const [deleteError, setDeleteError] = useState<AxiosError | Error>()
const [initialData, setInitialData] = useState<TMarketPlacePanel[]>([])
const [filteredAndSortedData, setFilterAndSortedData] = useState<TMarketPlacePanel[]>([])
const [uniqueTags, setUniqueTags] = useState<string[]>([])
const [filtersAndSorters, setFiltersAndSorters] = useState<TMarketPlaceFiltersAndSorters>({
searchText: '',
})
const { clusterName, namespace } = useParams()
const {
data: marketplacePanels,
isLoading,
error,
} = useDirectUnknownResource<TMarketPlacePanelResponse>({
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 <div>{JSON.stringify(error)}</div>
}
if (isLoading) {
return <Spin />
}
if (!marketplacePanels) {
return <div>No panels</div>
}
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}
<Flex justify="space-between" align="center" style={{ marginTop: '30px' }}>
<Typography.Title level={4} style={{ marginTop: 0 }}>
Marketplace
</Typography.Title>
<div>
{(createPermission.data?.status.allowed ||
updatePermission.data?.status.allowed ||
deletePermission.data?.status.allowed) && (
<div>
<Switch defaultChecked checked={isEditMode} onClick={toggleEditMode} /> Edit Mode
</div>
)}
</div>
</Flex>
{createUpdateError && (
<Alert
description={JSON.stringify(createUpdateError)}
message="Card was not created"
onClose={() => setCreateUpdateError(undefined)}
type="error"
/>
)}
{deleteError && (
<Alert
description={JSON.stringify(deleteError)}
message="Card was not deleted"
onClose={() => setDeleteError(undefined)}
type="error"
/>
)}
<Flex>
<Card style={{ maxWidth: '255px', marginRight: '8px' }}>
<Flex justify="center" align="center" vertical>
<SearchTextInput searchText={filtersAndSorters.searchText} onSearchTextChange={onSearchTextChange} />
<Divider style={{ borderColor: token.colorBorder }} />
<Flex justify="center" align="center" vertical style={{ width: '100%' }}>
<Typography.Text
style={{ width: '100%', marginBottom: '8px', cursor: 'pointer' }}
type={!filtersAndSorters.selectedTag ? 'success' : undefined}
onClick={() =>
setFiltersAndSorters({
...filtersAndSorters,
selectedTag: undefined,
})
}
>
All Items
</Typography.Text>
{uniqueTags.map(tag => (
<Typography.Text
style={{ width: '100%', marginBottom: '8px', cursor: 'pointer' }}
type={filtersAndSorters.selectedTag === tag ? 'success' : undefined}
key={tag}
onClick={() => onTagSelect(tag)}
>
{tag}
</Typography.Text>
))}
</Flex>
</Flex>
</Card>
<div>
<Flex wrap gap="small">
{clusterName &&
namespace &&
filteredAndSortedData.map(
({ name, description, icon, type, pathToNav, typeName, apiGroup, apiVersion, tags, disabled }) => (
<CardInProject
description={description}
disabled={disabled}
icon={icon}
isEditMode={isEditMode}
key={name}
name={name}
clusterName={clusterName}
namespace={namespace}
type={type}
pathToNav={pathToNav}
typeName={typeName}
apiGroup={apiGroup}
apiVersion={apiVersion}
// pathToNav={pathToNav}
tags={tags}
onDeleteClick={() => {
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 && (
<AddCard
onAddClick={() => {
setIsAddEditOpen(true)
}}
/>
)}
</Flex>
</div>
</Flex>
{isAddEditOpen && (
<AddEditFormModal
isOpen={isAddEditOpen}
setError={setCreateUpdateError}
setIsOpen={setIsAddEditOpen}
onCreateSuccess={onCreateSuccess}
onUpdateSuccess={onUpdateSuccess}
/>
)}
{typeof isDeleteOpen !== 'boolean' && (
<DeleteModal
name={isDeleteOpen.name}
onClose={() => setIsDeleteOpen(false)}
endpoint={`/api/clusters/${clusterName}/k8s/apis/${BASE_API_GROUP}/${BASE_API_VERSION}/marketplacepanels/${isDeleteOpen.name}`}
/>
)}
</>
)
}

View File

@@ -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<TAddCardProps> = ({ onAddClick }) => {
return (
<Styled.CustomCard onClick={onAddClick}>
<PlusOutlined />
</Styled.CustomCard>
)
}

View File

@@ -0,0 +1 @@
export * from './AddCard'

View File

@@ -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,
}

View File

@@ -0,0 +1 @@
export * from './AddCard'

View File

@@ -0,0 +1 @@
export * from './MarketPlace'

View File

@@ -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<SetStateAction<boolean | TMarketPlacePanelResource>>
setError: Dispatch<SetStateAction<AxiosError | Error | undefined>>
onCreateSuccess: () => void
onUpdateSuccess: () => void
}
export const AddEditFormModal: FC<TAddEditFormModalProps> = ({
isOpen,
setIsOpen,
setError,
onCreateSuccess,
onUpdateSuccess,
}) => {
const [form] = Form.useForm<TMarketPlacePanel>()
const type = Form.useWatch<string | undefined>('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 (
<Modal
title={typeof isOpen === 'boolean' ? 'Add card' : 'Edit плитку'}
open={isOpen !== false}
onCancel={() => setIsOpen(false)}
onOk={() => submit}
>
<Form<TMarketPlacePanel> form={form} name="control-hooks" initialValues={{ ...defaultValues }}>
<Form.Item label="Name" name="name">
<Input required />
</Form.Item>
<Form.Item label="Description" name="description">
<Input required />
</Form.Item>
<Form.Item label="Icon" name="icon">
<Input.TextArea placeholder="SVG-иконка, <svg /> -> base64" maxLength={undefined} required />
</Form.Item>
<Form.Item label="Resources type" name="type">
<Select
placeholder="Choose resource type"
options={[
{ value: 'direct', label: 'Direct link' },
{ value: 'crd', label: 'CRD' },
{ value: 'nonCrd', label: 'API' },
{ value: 'built-in', label: 'Built-in' },
]}
/>
</Form.Item>
<Form.Item label="Enter API group" name="apiGroup">
<Input disabled={type === 'direct' || type === 'built-in'} />
</Form.Item>
<Form.Item label="Enter API version" name="apiVersion">
<Input disabled={type === 'direct' || type === 'built-in'} />
</Form.Item>
<Form.Item label="Enter resource type" name="typeName">
<Input disabled={type === 'direct'} />
</Form.Item>
<Form.Item label="Enter path" name="pathToNav">
<Input disabled={type !== 'direct'} />
</Form.Item>
<Form.Item label="Tags" name="tags">
<Select
mode="tags"
placeholder="Enter tags. Separators: comma and space"
tokenSeparators={[',', ' ']}
dropdownStyle={{ display: 'none' }}
/>
</Form.Item>
<Form.Item label="Disabled" name="disabled">
<Switch />
</Form.Item>
<Form.Item label="Hidden" name="hidden">
<Switch />
</Form.Item>
</Form>
</Modal>
)
}

View File

@@ -0,0 +1 @@
export * from './AddEditFormModal'

View File

@@ -0,0 +1,106 @@
/* eslint-disable react/no-danger */
import React, { FC } from 'react'
import { useNavigate } from 'react-router-dom'
import { Typography, Flex, theme } from 'antd'
import { DeleteOutlined, EditOutlined } from '@ant-design/icons'
import { useSelector } from 'react-redux'
import { RootState } from 'store/store'
import { TitleWithNoTopMargin } from 'components/atoms'
import { TMarketPlacePanel } from '../../types'
import { getPathToNav } from './utils'
import { Styled } from './styled'
type TCardInProjectProps = {
clusterName: string
namespace: string
isEditMode?: boolean
onDeleteClick: () => void
onEditClick: () => void
} & Omit<TMarketPlacePanel, 'hidden'>
export const CardInProject: FC<TCardInProjectProps> = ({
description,
name,
icon,
clusterName,
namespace,
type,
pathToNav,
typeName,
apiGroup,
apiVersion,
tags,
disabled,
isEditMode,
onDeleteClick,
onEditClick,
}) => {
const { useToken } = theme
const { token } = useToken()
const navigate = useNavigate()
const baseprefix = useSelector((state: RootState) => state.baseprefix.baseprefix)
let decodedIcon = ''
try {
decodedIcon = window.atob(icon)
} catch {
decodedIcon = "Can't decode"
}
const navigateUrl = getPathToNav({
clusterName,
namespace,
type,
pathToNav,
typeName,
apiGroup,
apiVersion,
baseprefix,
})
return (
<Styled.CustomCard
$isDisabled={disabled}
$hoverColor={token.colorPrimary}
onClick={() => (disabled ? null : navigate(navigateUrl))}
>
<Flex vertical style={{ width: '100%', height: '100%' }} gap="middle" justify="spaceBetween">
<Styled.ControlsAndImageContainer>
<Styled.ImageContainer dangerouslySetInnerHTML={{ __html: decodedIcon }} />
{isEditMode && (
<Styled.ControlsContainer>
<Styled.ControlsItem>
<DeleteOutlined
onClick={e => {
e.preventDefault()
e.stopPropagation()
onDeleteClick()
}}
/>
</Styled.ControlsItem>
<Styled.ControlsItem>
<EditOutlined
onClick={e => {
e.preventDefault()
e.stopPropagation()
onEditClick()
}}
/>
</Styled.ControlsItem>
</Styled.ControlsContainer>
)}
</Styled.ControlsAndImageContainer>
<TitleWithNoTopMargin level={4}>{name}</TitleWithNoTopMargin>
<Styled.FlexGrow>
<Styled.TagsContainer>
{tags.map(tag => (
<Styled.CustomTag key={tag}>{tag}</Styled.CustomTag>
))}
</Styled.TagsContainer>
</Styled.FlexGrow>
<Typography.Text type="secondary">{description}</Typography.Text>
</Flex>
</Styled.CustomCard>
)
}

View File

@@ -0,0 +1 @@
export * from './CardInProject'

View File

@@ -0,0 +1,80 @@
import styled from 'styled-components'
import { Card, Tag } from 'antd'
type TCustomCardProps = {
$hoverColor: string
$isDisabled?: boolean
}
const CustomCard = styled(Card)<TCustomCardProps>`
position: relative;
width: 256px;
min-height: 256px;
cursor: ${({ $isDisabled }) => ($isDisabled ? 'not-allowed' : 'pointer')};
&:hover {
border-color: ${({ $hoverColor, $isDisabled }) => !$isDisabled && $hoverColor};
}
.ant-card-body {
height: 100%;
}
`
const ControlsAndImageContainer = styled.div`
display: flex;
justify-content: space-between;
`
const ImageContainer = styled.div`
min-width: 50px;
min-height: 50px;
svg {
width: 50px;
height: 50px;
}
`
const ControlsContainer = styled.div`
display: flex;
justify-content: flex-end;
`
const ControlsItem = styled.div`
margin-right: 12px;
&:last-child {
margin-right: 0;
}
`
const FlexGrow = styled.div`
flex-grow: 1;
`
const TagsContainer = styled.div`
display: flex;
flex-flow: row wrap;
align-items: flex-start;
`
const CustomTag = styled(Tag)`
padding: 0 12px;
margin-right: 12px;
margin-bottom: 12px;
&:last-child {
margin-right: 0;
}
`
export const Styled = {
CustomCard,
ControlsAndImageContainer,
ImageContainer,
ControlsContainer,
ControlsItem,
FlexGrow,
TagsContainer,
CustomTag,
}

View File

@@ -0,0 +1,35 @@
export const getPathToNav = ({
clusterName,
namespace,
type,
pathToNav,
typeName,
apiGroup,
apiVersion,
baseprefix,
}: {
clusterName: string
namespace: string
type: string
pathToNav?: string
typeName?: string
apiGroup?: string
apiVersion?: string
baseprefix?: string
}): string => {
const apiExtensionVersion = 'v1'
if (type === 'direct' && pathToNav) {
return pathToNav
}
if (type === 'crd') {
return `${baseprefix}/${clusterName}/${namespace}/crd-table/${apiGroup}/${apiVersion}/${apiExtensionVersion}/${typeName}`
}
if (type === 'nonCrd') {
return `${baseprefix}/${clusterName}/${namespace}/non-crd-table/${apiGroup}/${apiVersion}/${typeName}`
}
return `${baseprefix}/${clusterName}/${namespace}/builtin-table/${typeName}`
}

View File

@@ -0,0 +1,19 @@
import React, { FC } from 'react'
import { Input } from 'antd'
type TSearchTextInputProps = {
onSearchTextChange: (searchText: string) => void
searchText: string
}
export const SearchTextInput: FC<TSearchTextInputProps> = ({ searchText, onSearchTextChange }) => (
<Input
placeholder="Поиск"
allowClear
onClear={() => {
onSearchTextChange('')
}}
value={searchText}
onChange={e => onSearchTextChange(e.target.value)}
/>
)

View File

@@ -0,0 +1 @@
export * from './SearchTextInput'

View File

@@ -0,0 +1,3 @@
export * from './AddEditFormModal'
export * from './CardInProject'
export * from './SearchTextInput'

View File

@@ -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[]
}

View File

@@ -1,2 +1,3 @@
export * from './BlackholeForm'
export * from './ManageableBreadcrumbs'
export * from './MarketPlace'

View File

@@ -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 = () => {
</Col>
</Row>
</Card>
Marketplace
<MarketPlace />
{isDeleteModalOpen && (
<DeleteModal
name={project.metadata.name}