Merge pull request #6 from PRO-Robotech/feature/Factory-Admin

feature/Factory Admin
This commit is contained in:
typescreep
2025-06-03 20:37:38 +03:00
committed by GitHub
17 changed files with 743 additions and 5 deletions

8
package-lock.json generated
View File

@@ -11,7 +11,7 @@
"@ant-design/icons": "5.6.0",
"@monaco-editor/react": "4.6.0",
"@originjs/vite-plugin-federation": "1.3.6",
"@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.20",
"@prorobotech/openapi-k8s-toolkit": "^0.0.1-alpha.21",
"@readme/openapi-parser": "4.0.0",
"@reduxjs/toolkit": "2.2.5",
"@tanstack/react-query": "5.62.2",
@@ -2752,9 +2752,9 @@
}
},
"node_modules/@prorobotech/openapi-k8s-toolkit": {
"version": "0.0.1-alpha.20",
"resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.20.tgz",
"integrity": "sha512-sooiZUyFwOpc0iUX+uNGy9Qr1iwJkjvn286dkCAyuWbLjhWPXUMi5hiJ1Y0DdtdeZV31zHIx+bVFJ1jBhw1YsQ==",
"version": "0.0.1-alpha.21",
"resolved": "https://registry.npmjs.org/@prorobotech/openapi-k8s-toolkit/-/openapi-k8s-toolkit-0.0.1-alpha.21.tgz",
"integrity": "sha512-1uXnvf5s82VyuJTnNwmliQtIOtIMmd20/xwXHN4uh0BP8Y6dOCpUP5tGUlo7zhbvm6Gh2uZhYhSFqqxZPw11Kw==",
"license": "MIT",
"dependencies": {
"@monaco-editor/react": "4.6.0",

View File

@@ -17,7 +17,7 @@
"@ant-design/icons": "5.6.0",
"@monaco-editor/react": "4.6.0",
"@originjs/vite-plugin-federation": "1.3.6",
"@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.20",
"@prorobotech/openapi-k8s-toolkit": "0.0.1-alpha.21",
"@readme/openapi-parser": "4.0.0",
"@reduxjs/toolkit": "2.2.5",
"@tanstack/react-query": "5.62.2",

View File

@@ -29,6 +29,7 @@ import {
FormApiPage,
FormCrdPage,
FactoryPage,
FactoryAdminPage,
} from 'pages'
import { getBasePrefix } from 'utils/getBaseprefix'
import { colorsLight, colorsDark, sizes } from 'constants/colors'
@@ -127,6 +128,7 @@ export const App: FC<TAppProps> = ({ isFederation, forcedTheme }) => {
element={<FormCrdPage forcedTheme={forcedTheme} />}
/>
<Route path={`${prefix}/:clusterName/factory/:key/*`} element={<FactoryPage forcedTheme={forcedTheme} />} />
<Route path={`${prefix}/factoryAdmin/*`} element={<FactoryAdminPage />} />
</Routes>
)

View File

@@ -0,0 +1,52 @@
import React, { useState } from 'react'
import { Modal, Button, Form, Select } from 'antd'
import { ComponentType } from './types'
export const componentTypes: ComponentType[] = [
'antdText',
'antdCard',
'antdFlex',
'antdRow',
'antdCol',
'partsOfUrl',
'multiQuery',
'parsedText',
]
interface AddComponentModalProps {
onAdd: (type: ComponentType) => void
title?: string
}
export const AddComponentModal: React.FC<AddComponentModalProps> = ({ onAdd, title = 'Add Component' }) => {
const [visible, setVisible] = useState(false)
const [form] = Form.useForm()
const handleSubmit = (values: { type: ComponentType }) => {
onAdd(values.type)
setVisible(false)
form.resetFields()
}
return (
<>
<Button onClick={() => setVisible(true)}>Add</Button>
<Modal title={title} visible={visible} onCancel={() => setVisible(false)} footer={null}>
<Form form={form} onFinish={handleSubmit}>
<Form.Item label="Component Type" name="type" rules={[{ required: true }]}>
<Select>
{componentTypes.map(type => (
<Select.Option key={type} value={type}>
{type}
</Select.Option>
))}
</Select>
</Form.Item>
<Button type="primary" htmlType="submit">
Add
</Button>
</Form>
</Modal>
</>
)
}

View File

@@ -0,0 +1,73 @@
/* eslint-disable no-console */
/* eslint-disable no-param-reassign */
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect } from 'react'
import { Form, Input, Select, Button, Switch } from 'antd'
const { TextArea } = Input
const { Option } = Select
interface AntdCardFormProps {
initialValues: any // This would be `TDynamicComponentsAppTypeMap['antdCard']`
onSave: (data: any) => void
}
export const AntdCardForm: React.FC<AntdCardFormProps> = ({ initialValues, onSave }) => {
const [form] = Form.useForm()
useEffect(() => {
form.setFieldsValue(initialValues)
}, [initialValues, form])
const handleSubmit = (values: any) => {
try {
if (values.style) {
values.style = JSON.parse(values.style)
}
} catch (e) {
console.log('Invalid JSON for style', e)
return
}
onSave(values)
}
return (
<Form form={form} onFinish={handleSubmit} layout="vertical" initialValues={initialValues}>
<Form.Item label="ID" name="id" rules={[{ required: true }]}>
<Input disabled />
</Form.Item>
<Form.Item label="Title" name="title">
<Input placeholder="Card title" />
</Form.Item>
<Form.Item label="Bordered" name="bordered" valuePropName="checked">
<Switch defaultChecked />
</Form.Item>
<Form.Item label="Cover Image URL" name="cover">
<Input placeholder="https://example.com/image.jpg" />
</Form.Item>
<Form.Item label="Style (JSON)" name="style">
<TextArea rows={4} placeholder='e.g. {"width": "300px", "margin": "10px"}' />
</Form.Item>
<Form.Item label="Size" name="size">
<Select defaultValue="default">
<Option value="default">Default</Option>
<Option value="small">Small</Option>
</Select>
</Form.Item>
<Form.Item label="Extra Content (Right Side)" name="extra">
<Input placeholder="Extra content like links or buttons" />
</Form.Item>
<Button type="primary" htmlType="submit">
Save
</Button>
</Form>
)
}

View File

@@ -0,0 +1,55 @@
/* eslint-disable no-console */
/* eslint-disable no-param-reassign */
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect } from 'react'
import { Form, Input, Select, Button } from 'antd'
interface AntdTextFormProps {
initialValues: any
onSave: (data: any) => void
}
export const AntdTextForm: React.FC<AntdTextFormProps> = ({ initialValues, onSave }) => {
const [form] = Form.useForm()
useEffect(() => {
form.setFieldsValue(initialValues)
}, [initialValues, form])
const handleSubmit = (values: any) => {
try {
if (values.style) {
values.style = JSON.parse(values.style)
}
} catch (e) {
console.log('Invalid JSON for style', e)
}
onSave(values)
}
return (
<Form form={form} onFinish={handleSubmit} layout="vertical" initialValues={initialValues}>
<Form.Item label="ID" name="id" rules={[{ required: true }]}>
<Input disabled />
</Form.Item>
<Form.Item label="Text" name="text" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item label="Type" name="type">
<Select allowClear>
<Select.Option value="">Default</Select.Option>
<Select.Option value="secondary">Secondary</Select.Option>
<Select.Option value="success">Success</Select.Option>
<Select.Option value="warning">Warning</Select.Option>
<Select.Option value="danger">Danger</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Style (JSON)" name="style">
<Input.TextArea rows={4} placeholder='e.g. {"color": "red"}' />
</Form.Item>
<Button type="primary" htmlType="submit">
Save
</Button>
</Form>
)
}

View File

@@ -0,0 +1,127 @@
/* eslint-disable no-console */
import React, { useState } from 'react'
import { Button, Typography, Modal, Input } from 'antd'
import { ComponentNode } from './ComponentNode'
import { AddComponentModal } from './AddComponentModal'
import { Component, ComponentType, TDynamicComponentsAppTypeMap } from './types'
const { Title } = Typography
const { TextArea } = Input
export const AppComponentAdmin: React.FC = () => {
const [components, setComponents] = useState<Component[]>([])
const [maxId, setMaxId] = useState(1)
const [jsonOutput, setJsonOutput] = useState<string>('')
const [isModalVisible, setIsModalVisible] = useState(false)
const getNewId = () => {
const newId = maxId + 1
setMaxId(newId)
return newId
}
const generateDefaultData = (type: ComponentType): TDynamicComponentsAppTypeMap[ComponentType] => {
const base = { id: getNewId() }
switch (type) {
case 'antdText':
return { ...base, text: '' }
case 'antdCard':
return { ...base, title: '' }
case 'antdFlex':
return { ...base, justify: 'start', align: 'start' }
case 'antdRow':
return { ...base }
case 'antdCol':
return { ...base, span: 12 }
case 'partsOfUrl':
case 'multiQuery':
case 'parsedText':
return { ...base, text: '' }
default:
return base
}
}
const addComponent = (type: ComponentType) => {
const newComponent: Component = {
type,
data: generateDefaultData(type),
children: [],
}
setComponents(prev => [...prev, newComponent])
}
const updateComponent = (index: number, updated: Component) => {
setComponents(prev => {
const newComponents = [...prev]
newComponents[index] = updated
return newComponents
})
}
const deleteComponent = (index: number) => {
setComponents(prev => {
const newComponents = [...prev]
newComponents.splice(index, 1)
return newComponents
})
}
const handleSaveAll = () => {
const output = JSON.stringify(
{
data: components,
},
null,
2,
)
setJsonOutput(output)
setIsModalVisible(true)
}
const handleCopyToClipboard = () => {
navigator.clipboard.writeText(jsonOutput)
console.log('Copied to clipboard!')
}
return (
<div style={{ padding: 20 }}>
<Title level={3}>Component Admin Panel</Title>
<AddComponentModal onAdd={addComponent} title="Add Root Component" />
<div style={{ marginTop: 20 }}>
{components.map((component, index) => (
<ComponentNode
key={component.data.id}
component={component}
onUpdate={updated => updateComponent(index, updated)}
onDelete={() => deleteComponent(index)}
/>
))}
</div>
{/* Save Button */}
<Button type="primary" onClick={handleSaveAll} style={{ marginTop: 20 }}>
Save All as JSON
</Button>
{/* JSON Output Modal */}
<Modal
title="Final JSON Output"
open={isModalVisible}
onCancel={() => setIsModalVisible(false)}
footer={[
<Button key="copy" onClick={handleCopyToClipboard}>
Copy to Clipboard
</Button>,
<Button key="close" onClick={() => setIsModalVisible(false)}>
Close
</Button>,
]}
>
<TextArea value={jsonOutput} readOnly rows={20} style={{ fontFamily: 'monospace' }} />
</Modal>
</div>
)
}

View File

@@ -0,0 +1,15 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// src/ComponentForm.tsx
import React from 'react'
import { DynamicComponentForm } from './DynamicComponentForm'
import type { ComponentType } from './types'
interface ComponentFormProps {
type: ComponentType
data: any
onSave: (data: any) => void
}
export const ComponentForm: React.FC<ComponentFormProps> = ({ type, data, onSave }) => {
return <DynamicComponentForm type={type} initialValues={data} onSave={onSave} />
}

View File

@@ -0,0 +1,27 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from 'react'
import { ComponentType } from './types'
import { AntdTextForm } from './AntdTextForm'
import { AntdCardForm } from './AntdCardForm'
import { TextWithIdForm } from './TextWithIdForm'
interface ComponentFormProps {
type: ComponentType
data: any
onSave: (data: any) => void
}
export const ComponentForm: React.FC<ComponentFormProps> = ({ type, data, onSave }) => {
switch (type) {
case 'antdText':
return <AntdTextForm initialValues={data} onSave={onSave} />
case 'antdCard':
return <AntdCardForm initialValues={data} onSave={onSave} />
case 'partsOfUrl':
case 'multiQuery':
case 'parsedText':
return <TextWithIdForm initialValues={data} onSave={onSave} />
default:
return <div>Unsupported component type</div>
}
}

View File

@@ -0,0 +1,64 @@
import React from 'react'
import { Button } from 'antd'
import { Component, ComponentType } from './types'
import { EditComponentModal } from './EditComponentModal'
import { AddComponentModal } from './AddComponentModal'
import { generateDefaultData } from './utils'
interface ComponentNodeProps {
component: Component
onUpdate: (component: Component) => void
onDelete: () => void
}
export const ComponentNode: React.FC<ComponentNodeProps> = ({ component, onUpdate, onDelete }) => {
const handleAddChild = (type: ComponentType) => {
const newChild: Component = {
type,
data: generateDefaultData(type),
children: [],
}
const updated = { ...component }
updated.children = updated.children ? [...updated.children, newChild] : [newChild]
onUpdate(updated)
}
const handleUpdateChild = (index: number, updatedChild: Component) => {
const updated = { ...component }
updated.children![index] = updatedChild
onUpdate(updated)
}
const handleDeleteChild = (index: number) => {
const updated = { ...component }
updated.children!.splice(index, 1)
onUpdate(updated)
}
return (
<div style={{ border: '1px solid #ccc', padding: 10, margin: '10px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>
<strong>{component.type}</strong> (ID: {component.data.id})
</span>
<div>
<EditComponentModal component={component} onSave={onUpdate} />
<AddComponentModal onAdd={handleAddChild} title="Add Child" />
<Button danger size="small" onClick={onDelete} style={{ marginLeft: 8 }}>
Delete
</Button>
</div>
</div>
<div style={{ marginLeft: 20, marginTop: 10 }}>
{component.children?.map((child, idx) => (
<ComponentNode
key={child.data.id}
component={child}
onUpdate={updated => handleUpdateChild(idx, updated)}
onDelete={() => handleDeleteChild(idx)}
/>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,111 @@
/* eslint-disable no-console */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
// src/DynamicComponentForm.tsx
import React, { useEffect } from 'react'
import { Form, Input, Select, Switch, Button, Typography } from 'antd'
import { componentMetaMap } from './utils'
import type { ComponentType } from './types'
const { TextArea } = Input
const { Option } = Select
interface DynamicComponentFormProps {
type: ComponentType
initialValues: any
onSave: (data: any) => void
}
export const DynamicComponentForm: React.FC<DynamicComponentFormProps> = ({ type, initialValues, onSave }) => {
const [form] = Form.useForm()
useEffect(() => {
form.setFieldsValue(initialValues)
}, [initialValues, form])
const handleSubmit = (values: any) => {
try {
const parsedValues = Object.entries(values).reduce((acc, [key, value]) => {
const meta = componentMetaMap[type]?.[key]
if (meta?.inputType === 'json') {
if (typeof value === 'string') {
try {
acc[key] = JSON.parse(value)
} catch (e) {
console.warn(`Invalid JSON for ${key}:`, value)
acc[key] = value // Keep original string
}
} else {
acc[key] = value // Already parsed or non-string
}
} else {
acc[key] = value
}
return acc
}, {} as any)
onSave(parsedValues)
} catch (e) {
console.log('Invalid JSON input', e)
}
}
const renderFormItem = (prop: string, meta: any) => {
const value = initialValues[prop]
switch (meta.inputType) {
case 'text':
return <Input placeholder={meta.label} />
case 'textarea':
return <TextArea rows={4} placeholder={meta.label} />
case 'json':
return (
<TextArea
rows={4}
placeholder={`e.g. {"color": "red"}`}
defaultValue={value ? JSON.stringify(value, null, 2) : ''}
/>
)
case 'boolean':
return <Switch defaultChecked={value} />
case 'select':
return (
<Select defaultValue={value}>
{meta.options?.map((opt: { label: string; value: string }) => (
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
)
case 'number':
return <Input type="number" placeholder={meta.label} />
default:
return <Input />
}
}
return (
<Form form={form} onFinish={handleSubmit} layout="vertical">
{/* ID (always present and read-only) */}
<Form.Item label="ID" name="id" rules={[{ required: true }]}>
<Input disabled />
</Form.Item>
{/* Render dynamic props */}
{Object.entries(componentMetaMap[type] || {}).map(([prop, meta]) => (
<Form.Item key={prop} label={meta.label} name={prop}>
{renderFormItem(prop, meta)}
</Form.Item>
))}
{/* Submit Button */}
<Button type="primary" htmlType="submit" style={{ marginTop: 16 }}>
Save
</Button>
</Form>
)
}

View File

@@ -0,0 +1,34 @@
/* eslint-disable react/button-has-type */
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useState } from 'react'
import { Modal } from 'antd'
import { ComponentForm } from './ComponentForm'
import { Component } from './types'
interface EditComponentModalProps {
component: Component
onSave: (component: Component) => void
}
export const EditComponentModal: React.FC<EditComponentModalProps> = ({ component, onSave }) => {
const [open, setOpen] = useState(false)
const handleSave = (data: any) => {
onSave({ ...component, data })
setOpen(false)
}
return (
<>
<button onClick={() => setOpen(true)}>Edit</button>
<Modal
title={`Edit ${component.type}`}
open={open} // ✅ Use open instead of visible
onCancel={() => setOpen(false)}
footer={null}
>
<ComponentForm type={component.type} data={component.data} onSave={handleSave} />
</Modal>
</>
)
}

View File

@@ -0,0 +1,39 @@
import React, { useEffect } from 'react'
import { Form, Input, Button } from 'antd'
interface TextWithIdFormProps {
initialValues: {
id: number
text: string
}
onSave: (data: { id: number; text: string }) => void
}
export const TextWithIdForm: React.FC<TextWithIdFormProps> = ({ initialValues, onSave }) => {
const [form] = Form.useForm()
useEffect(() => {
form.setFieldsValue(initialValues)
}, [initialValues, form])
const handleSubmit = (values: { id: number; text: string }) => {
onSave(values)
}
return (
<Form form={form} onFinish={handleSubmit} layout="vertical" initialValues={initialValues}>
<Form.Item label="ID" name="id" rules={[{ required: true }]}>
<Input disabled />
</Form.Item>
<Form.Item label="Text" name="text" rules={[{ required: true }]}>
<Input.TextArea
rows={4}
placeholder="Enter text (you can use variables like {5}, {reqs[0]['metadata']} etc.)"
/>
</Form.Item>
<Button type="primary" htmlType="submit">
Save
</Button>
</Form>
)
}

View File

@@ -0,0 +1 @@
export { AppComponentAdmin as FactoryAdminPage } from './AppComponentAdmin'

View File

@@ -0,0 +1,26 @@
import type {
CardProps as AntdCardProps,
FlexProps as AntdFlexProps,
RowProps as AntdRowProps,
ColProps as AntdColProps,
} from 'antd'
import { TextProps as AntdTextProps } from 'antd/es/typography/Text'
export type TDynamicComponentsAppTypeMap = {
antdText: { id: number; text: string } & Omit<AntdTextProps, 'id'>
antdCard: { id: number } & Omit<AntdCardProps, 'id'>
antdFlex: { id: number } & Omit<AntdFlexProps, 'id' | 'children'>
antdRow: { id: number } & Omit<AntdRowProps, 'id' | 'children'>
antdCol: { id: number } & Omit<AntdColProps, 'id' | 'children'>
partsOfUrl: { id: number; text: string }
multiQuery: { id: number; text: string }
parsedText: { id: number; text: string }
}
export type ComponentType = keyof TDynamicComponentsAppTypeMap
export type Component = {
type: ComponentType
data: TDynamicComponentsAppTypeMap[ComponentType]
children?: Component[]
}

View File

@@ -0,0 +1,111 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// src/utils.ts
import type { ComponentType, TDynamicComponentsAppTypeMap } from './types'
export const generateDefaultData = (type: ComponentType): TDynamicComponentsAppTypeMap[ComponentType] => {
const base = { id: Math.floor(Math.random() * 100000) } // Simplified ID generation
switch (type) {
case 'antdText':
return { ...base, text: '' }
case 'antdCard':
return { ...base, title: '' }
case 'antdFlex':
return { ...base, justify: 'start', align: 'start' }
case 'antdRow':
return { ...base }
case 'antdCol':
return { ...base, span: 12 }
case 'partsOfUrl':
case 'multiQuery':
case 'parsedText':
return { ...base, text: '' }
default:
return base
}
}
// Define property metadata for each component
type PropertyMeta = {
label: string
inputType: 'text' | 'textarea' | 'json' | 'boolean' | 'select' | 'number'
options?: { label: string; value: any }[]
}
type ComponentMetaMap = {
[key in ComponentType]: Record<string, PropertyMeta>
}
export const componentMetaMap: ComponentMetaMap = {
antdText: {
text: { label: 'Text', inputType: 'textarea' },
type: {
label: 'Type',
inputType: 'select',
options: [
{ label: 'Default', value: '' },
{ label: 'Secondary', value: 'secondary' },
{ label: 'Success', value: 'success' },
{ label: 'Warning', value: 'warning' },
{ label: 'Danger', value: 'danger' },
],
},
style: { label: 'Style (JSON)', inputType: 'json' },
},
antdCard: {
title: { label: 'Title', inputType: 'text' },
bordered: { label: 'Bordered', inputType: 'boolean' },
cover: { label: 'Cover Image URL', inputType: 'text' },
size: {
label: 'Size',
inputType: 'select',
options: [
{ label: 'Default', value: 'default' },
{ label: 'Small', value: 'small' },
],
},
extra: { label: 'Extra Content', inputType: 'text' },
style: { label: 'Style (JSON)', inputType: 'json' },
},
antdFlex: {
justify: {
label: 'Justify',
inputType: 'select',
options: [
{ label: 'Start', value: 'start' },
{ label: 'End', value: 'end' },
{ label: 'Center', value: 'center' },
{ label: 'Space Around', value: 'space-around' },
{ label: 'Space Between', value: 'space-between' },
],
},
align: {
label: 'Align',
inputType: 'select',
options: [
{ label: 'Start', value: 'start' },
{ label: 'End', value: 'end' },
{ label: 'Center', value: 'center' },
{ label: 'Baseline', value: 'baseline' },
{ label: 'Stretch', value: 'stretch' },
],
},
gap: { label: 'Gap (number)', inputType: 'number' },
},
antdRow: {
gutter: { label: 'Gutter', inputType: 'number' },
style: { label: 'Style (JSON)', inputType: 'json' },
},
antdCol: {
span: { label: 'Span (1-24)', inputType: 'number' },
offset: { label: 'Offset', inputType: 'number' },
},
partsOfUrl: {
text: { label: 'Text', inputType: 'textarea' },
},
multiQuery: {
text: { label: 'Text', inputType: 'textarea' },
},
parsedText: {
text: { label: 'Text', inputType: 'textarea' },
},
}

View File

@@ -16,3 +16,4 @@ export { FormCrdPage } from './FormCrdPage'
export { FormApiPage } from './FormApiPage'
export { FormBuiltinPage } from './FormBuiltinPage'
export { FactoryPage } from './FactoryPage'
export { FactoryAdminPage } from './FactoryAdminPage'