diff --git a/packages/design-system/src/components/VerticalTabNav/VerticalTabItem.tsx b/packages/design-system/src/components/VerticalTabNav/VerticalTabItem.tsx index 71cc3b65..015a2afa 100644 --- a/packages/design-system/src/components/VerticalTabNav/VerticalTabItem.tsx +++ b/packages/design-system/src/components/VerticalTabNav/VerticalTabItem.tsx @@ -1,5 +1,6 @@ -import React from 'react'; -import { Box, Typography } from '@material-ui/core'; +import React, { useState } from 'react'; +import { Box, Typography, Collapse } from '@material-ui/core'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import { useStyles } from './styles'; export interface TabItemData { @@ -15,31 +16,51 @@ export interface TabItemData { status?: 'success' | 'warning' | 'error' | 'info' | 'default'; /** Whether the tab is disabled */ disabled?: boolean; + /** Optional children tabs for creating collapsible groups */ + children?: TabItemData[]; + /** Whether this is a group item (collapsible) */ + isGroup?: boolean; } interface VerticalTabItemProps { tab: TabItemData; isActive: boolean; - onClick: () => void; + onClick: (tabId: string) => void; + activeTabId?: string; + level?: number; } export const VerticalTabItem: React.FC = ({ tab, isActive, onClick, + activeTabId, + level = 0, }) => { const classes = useStyles(); + const [expanded, setExpanded] = useState(true); + + const isGroup = tab.isGroup || (tab.children && tab.children.length > 0); + // const hasActiveChild = tab.children?.some( + // child => child.id === activeTabId || child.children?.some(c => c.id === activeTabId) + // ); const handleClick = () => { - if (!tab.disabled) { - onClick(); + if (isGroup) { + setExpanded(!expanded); + } else if (!tab.disabled) { + onClick(tab.id); } }; const handleKeyDown = (event: React.KeyboardEvent) => { if ((event.key === 'Enter' || event.key === ' ') && !tab.disabled) { event.preventDefault(); - onClick(); + if (isGroup) { + setExpanded(!expanded); + } else { + onClick(tab.id); + } } }; @@ -58,36 +79,76 @@ export const VerticalTabItem: React.FC = ({ } }; + // Calculate padding: base padding + level offset + icon space if nested without icon + const iconSpace = 16; // Icon width (20px) + gap (12px) + const basePadding = 16; + const levelOffset = level * 16; + + // If nested (level > 0) and no icon, add icon space to align with parent text + const extraPadding = level > 0 && !tab.icon ? iconSpace : 0; + const paddingLeft = basePadding + levelOffset + extraPadding; + return ( - - {tab.icon && {tab.icon}} - - - {tab.label} - - - - {tab.status && ( - - )} - {tab.count !== undefined && ( - {tab.count} - )} + <> + 0 && classes.tabItemNested, + ] + .filter(Boolean) + .join(' ')} + onClick={handleClick} + onKeyDown={handleKeyDown} + style={{ paddingLeft: `${paddingLeft}px` }} + > + {tab.icon && {tab.icon}} + + + {tab.label} + + + + {tab.status && ( + + )} + {tab.count !== undefined && ( + {tab.count} + )} + {isGroup && ( + + + + )} + - + {isGroup && tab.children && ( + + {tab.children.map(child => ( + + ))} + + )} + ); }; diff --git a/packages/design-system/src/components/VerticalTabNav/VerticalTabNav.tsx b/packages/design-system/src/components/VerticalTabNav/VerticalTabNav.tsx index add45907..2322629d 100644 --- a/packages/design-system/src/components/VerticalTabNav/VerticalTabNav.tsx +++ b/packages/design-system/src/components/VerticalTabNav/VerticalTabNav.tsx @@ -64,7 +64,8 @@ export const VerticalTabNav: React.FC = ({ key={tab.id} tab={tab} isActive={activeTabId === tab.id} - onClick={() => onChange(tab.id)} + onClick={onChange} + activeTabId={activeTabId} /> ))} diff --git a/packages/design-system/src/components/VerticalTabNav/styles.ts b/packages/design-system/src/components/VerticalTabNav/styles.ts index 2851adde..1ba58007 100644 --- a/packages/design-system/src/components/VerticalTabNav/styles.ts +++ b/packages/design-system/src/components/VerticalTabNav/styles.ts @@ -112,4 +112,30 @@ export const useStyles = makeStyles((theme: Theme) => ({ overflowY: 'auto', padding: theme.spacing(2), }, + tabItemGroup: { + fontWeight: 600, + '&:hover': { + backgroundColor: + theme.palette.type === 'dark' ? 'rgba(255, 255, 255, 0.05)' : '#fafbfc', + }, + }, + tabItemNested: { + fontSize: 13, + '& $tabLabel': { + fontSize: 13, + }, + }, + expandIcon: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: theme.palette.text.secondary, + transition: 'transform 0.2s ease-in-out', + '& svg': { + fontSize: 18, + }, + }, + expandIconExpanded: { + transform: 'rotate(180deg)', + }, })); diff --git a/plugins/openchoreo-common/src/utils.ts b/plugins/openchoreo-common/src/utils.ts index 00b2a7a7..d34e1548 100644 --- a/plugins/openchoreo-common/src/utils.ts +++ b/plugins/openchoreo-common/src/utils.ts @@ -82,6 +82,79 @@ export function getRepositoryUrl( return `${url}${separator}tree/${branch || 'main'}/${path}`; } +/** + * Common acronyms and abbreviations that should be displayed in uppercase + */ +const KNOWN_ACRONYMS = new Set([ + 'cpu', + 'ai', + 'gpu', + 'ram', + 'api', + 'url', + 'uri', + 'http', + 'https', + 'ftp', + 'ssh', + 'ssl', + 'tls', + 'tcp', + 'udp', + 'ip', + 'dns', + 'html', + 'css', + 'json', + 'xml', + 'yaml', + 'sql', + 'db', + 'id', + 'uuid', + 'jwt', + 'oauth', + 'smtp', + 'imap', + 'pop', + 'csv', + 'pdf', + 'ui', + 'ux', + 'cli', + 'sdk', + 'jvm', + 'npm', + 'ttl', + 'vpc', + 'aws', + 'gcp', + 'iam', + 'cidr', + 'arn', + 'rpc', + 'grpc', + 'rest', + 'cors', + 'csrf', + 'xss', + 'dos', + 'ddos', + 'vm', + 'os', + 'io', + 'ide', + 'gui', + 'ascii', + 'utf', + 'iso', + 'mime', + 'sha', + 'md', + 'aes', + 'rsa', +]); + /** * Converts camelCase or snake_case strings to Title Case for display labels * @@ -96,8 +169,10 @@ export function getRepositoryUrl( * sanitizeLabel('imagePullPolicy') // "Image Pull Policy" * sanitizeLabel('image_pull_policy') // "Image Pull Policy" * sanitizeLabel('CPU') // "CPU" (preserves acronyms) - * sanitizeLabel('httpPort') // "Http Port" + * sanitizeLabel('cpu') // "CPU" (converts known acronym) + * sanitizeLabel('httpPort') // "HTTP Port" * sanitizeLabel('maxRetries3') // "Max Retries 3" + * sanitizeLabel('apiUrl') // "API URL" * ``` */ export function sanitizeLabel(key: string): string { @@ -119,11 +194,18 @@ export function sanitizeLabel(key: string): string { const titleCased = words.map(word => { if (!word) return ''; + const lowerWord = word.toLowerCase(); + // Keep all-caps acronyms as-is (e.g., CPU, HTTP, URL) if (word.length > 1 && word === word.toUpperCase()) { return word; } + // Convert known acronyms to uppercase + if (KNOWN_ACRONYMS.has(lowerWord)) { + return word.toUpperCase(); + } + // Capitalize first letter, lowercase the rest return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); }); diff --git a/plugins/openchoreo/src/components/Environments/DeleteConfirmationDialog.tsx b/plugins/openchoreo/src/components/Environments/DeleteConfirmationDialog.tsx index 640e4a24..3473c5e5 100644 --- a/plugins/openchoreo/src/components/Environments/DeleteConfirmationDialog.tsx +++ b/plugins/openchoreo/src/components/Environments/DeleteConfirmationDialog.tsx @@ -8,6 +8,18 @@ import { Box, Typography, } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles(theme => ({ + deleteButton: { + color: theme.palette.error.dark, + borderColor: theme.palette.error.dark, + '&:hover': { + borderColor: theme.palette.error.dark, + backgroundColor: `${theme.palette.error.dark}0A`, // 4% opacity + }, + }, +})); interface DeleteConfirmationDialogProps { open: boolean; @@ -28,6 +40,7 @@ export const DeleteConfirmationDialog: FC = ({ initialTraitFormDataMap, deleting, }) => { + const classes = useStyles(); const hasInitialComponentTypeOverrides = initialComponentTypeFormData && Object.keys(initialComponentTypeFormData).length > 0; @@ -88,19 +101,21 @@ export const DeleteConfirmationDialog: FC = ({ return ( - Delete Overrides? + + Delete Overrides? + - {getDeleteMessage()} + {getDeleteMessage()} - diff --git a/plugins/openchoreo/src/components/Environments/EnvironmentOverridesPage.tsx b/plugins/openchoreo/src/components/Environments/EnvironmentOverridesPage.tsx index 9bcf80f5..493510db 100644 --- a/plugins/openchoreo/src/components/Environments/EnvironmentOverridesPage.tsx +++ b/plugins/openchoreo/src/components/Environments/EnvironmentOverridesPage.tsx @@ -25,11 +25,13 @@ import { VerticalTabNav, TabItemData, } from '@openchoreo/backstage-design-system'; +import WidgetsIcon from '@material-ui/icons/Widgets'; import SettingsIcon from '@material-ui/icons/Settings'; import ExtensionIcon from '@material-ui/icons/Extension'; import { ContainerContent } from './Workload/WorkloadEditor'; import { useSecretReferences } from '@openchoreo/backstage-plugin-react'; import type { EnvVar } from '@openchoreo/backstage-plugin-common'; +import { TraitParameters } from './TraitParameters'; const useStyles = makeStyles(theme => ({ loadingContainer: { @@ -89,6 +91,7 @@ export const EnvironmentOverridesPage = ({ const classes = useStyles(); const client = useApi(openChoreoClientApiRef); + const [traitTypeMap, setTraitTypeMap] = useState>({}); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); const [deleteTarget, setDeleteTarget] = useState< @@ -103,6 +106,23 @@ export const EnvironmentOverridesPage = ({ // Load secret references for workload overrides const { secretReferences } = useSecretReferences(); + // Fetch component traits to get trait type information + useEffect(() => { + const fetchTraitTypes = async () => { + try { + const traits = await client.fetchComponentTraits(entity); + const typeMap: Record = {}; + traits.forEach(trait => { + typeMap[trait.instanceName] = trait.name; + }); + setTraitTypeMap(typeMap); + } catch (err) { + // Silently fail - trait types are nice to have but not critical + } + }; + fetchTraitTypes(); + }, [client, entity]); + // Use pendingAction release name when promoting/deploying, otherwise use environment's release const releaseNameForOverrides = pendingAction?.releaseName || environment.deployment.releaseName; @@ -185,7 +205,7 @@ export const EnvironmentOverridesPage = ({ tabList.push({ id: 'component', label: 'Component', - icon: , + icon: , status: getTabStatus( hasInitialComponentData, hasComponentChanges, @@ -194,7 +214,20 @@ export const EnvironmentOverridesPage = ({ }); } - // Add trait tabs + // Add Workload tab + // Use hasActualWorkloadOverrides to check if backend has real overrides + const hasInitialWorkloadData = formState.hasActualWorkloadOverrides; + const hasWorkloadChanges = (changes.workload?.length || 0) > 0; + // Workload doesn't use JSON schema, so no required field validation + tabList.push({ + id: 'workload', + label: 'Workload', + icon: , + status: getTabStatus(hasInitialWorkloadData, hasWorkloadChanges, false), + }); + + // Build trait child tabs + const traitTabs: TabItemData[] = []; Object.keys(schemas.traitSchemasMap).forEach(traitName => { const traitSchema = schemas.traitSchemasMap[traitName]; // Use hasActualTraitOverridesMap to check if backend has real overrides for this trait @@ -206,10 +239,10 @@ export const EnvironmentOverridesPage = ({ formState.traitFormDataMap[traitName], ); - tabList.push({ + traitTabs.push({ id: `trait-${traitName}`, label: traitName, - icon: , + // icon: , status: getTabStatus( hasInitialTraitData, hasTraitChanges, @@ -218,17 +251,27 @@ export const EnvironmentOverridesPage = ({ }); }); - // Add Workload tab - // Use hasActualWorkloadOverrides to check if backend has real overrides - const hasInitialWorkloadData = formState.hasActualWorkloadOverrides; - const hasWorkloadChanges = (changes.workload?.length || 0) > 0; - // Workload doesn't use JSON schema, so no required field validation - tabList.push({ - id: 'workload', - label: 'Workload', - icon: , - status: getTabStatus(hasInitialWorkloadData, hasWorkloadChanges, false), - }); + // Add Traits group if there are any trait tabs + if (traitTabs.length > 0) { + // Aggregate status from child traits - prioritize error > info > success + let aggregatedStatus: TabItemData['status'] = undefined; + if (traitTabs.some(t => t.status === 'error')) { + aggregatedStatus = 'error'; + } else if (traitTabs.some(t => t.status === 'info')) { + aggregatedStatus = 'info'; + } else if (traitTabs.some(t => t.status === 'success')) { + aggregatedStatus = 'success'; + } + + tabList.push({ + id: 'traits-group', + label: 'Traits', + icon: , + isGroup: true, + children: traitTabs, + status: aggregatedStatus, + }); + } return tabList; }, [ @@ -806,9 +849,33 @@ export const EnvironmentOverridesPage = ({ const traitName = activeTab.replace('trait-', ''); const traitSchema = schemas.traitSchemasMap[traitName]; if (traitSchema) { + // Get trait type from the map, fallback to generic "Trait" if not found + const traitType = traitTypeMap[traitName] || 'trait'; + return ( + + {traitName} + + ({traitType}) + + + + + } + sectionTitle={ + + + Overrides + + + } schema={traitSchema} formData={formState.traitFormDataMap[traitName] || {}} onChange={newData => diff --git a/plugins/openchoreo/src/components/Environments/OverrideContent.tsx b/plugins/openchoreo/src/components/Environments/OverrideContent.tsx index df48659f..e3582807 100644 --- a/plugins/openchoreo/src/components/Environments/OverrideContent.tsx +++ b/plugins/openchoreo/src/components/Environments/OverrideContent.tsx @@ -5,18 +5,80 @@ import Form from '@rjsf/material-ui'; import validator from '@rjsf/validator-ajv8'; import { JSONSchema7 } from 'json-schema'; import { makeStyles } from '@material-ui/core/styles'; +import { sanitizeLabel } from '@openchoreo/backstage-plugin-common'; const useStyles = makeStyles(theme => ({ container: { padding: theme.spacing(0), }, + formCard: { + padding: theme.spacing(3), + backgroundColor: theme.palette.common.white, + borderRadius: theme.spacing(1), + border: `1px solid ${theme.palette.divider}`, + marginBottom: theme.spacing(2), + }, deleteButton: { marginTop: theme.spacing(2), }, })); +/** + * Recursively generates a UI Schema with sanitized titles for all fields + * that don't already have a title in the JSON Schema. + */ +function generateUiSchemaWithSanitizedTitles(schema: any): any { + if (!schema || typeof schema !== 'object') { + return {}; + } + + const uiSchema: any = {}; + + // Handle object properties + if (schema.properties) { + Object.entries(schema.properties).forEach( + ([key, propSchema]: [string, any]) => { + if (!propSchema || typeof propSchema !== 'object') { + return; + } + + const fieldUiSchema: any = {}; + + // If the property doesn't have a title, add sanitized one + if (!propSchema.title) { + fieldUiSchema['ui:title'] = sanitizeLabel(key); + } + + // Recursively handle nested objects + if (propSchema.properties) { + Object.assign( + fieldUiSchema, + generateUiSchemaWithSanitizedTitles(propSchema), + ); + } + + // Handle array items + if (propSchema.items && propSchema.items.properties) { + fieldUiSchema.items = generateUiSchemaWithSanitizedTitles( + propSchema.items, + ); + } + + if (Object.keys(fieldUiSchema).length > 0) { + uiSchema[key] = fieldUiSchema; + } + }, + ); + } + + return uiSchema; +} + interface OverrideContentProps { title: string; + contentTitle?: React.ReactNode; + /** Optional title to display inside the form card */ + sectionTitle?: React.ReactNode; schema: JSONSchema7 | null; formData: any; onChange: (formData: any) => void; @@ -24,18 +86,23 @@ interface OverrideContentProps { hasInitialData: boolean; disabled?: boolean; customContent?: React.ReactNode; + /** Whether to wrap custom content in form card (default: true for schema forms, false for custom content) */ + wrapCustomContent?: boolean; /** Enable live validation to highlight required fields */ showValidation?: boolean; } export const OverrideContent: FC = ({ title, + contentTitle, + sectionTitle, schema, formData, onChange, onDelete, hasInitialData, customContent, + wrapCustomContent = false, disabled = false, showValidation = false, }) => { @@ -49,7 +116,18 @@ export const OverrideContent: FC = ({ if (customContent) { return ( <> - {customContent} + {contentTitle} + {wrapCustomContent ? ( + + {sectionTitle} + {customContent} + + ) : ( + <> + {sectionTitle} + {customContent} + + )}