From 781a9fc623ef8b6c630f6968d704594f0cc502ca Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Mon, 11 May 2026 11:58:30 +0530 Subject: [PATCH 01/16] feat(setup-card): add release listing API and readiness hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the Setup card revamp that splits release creation from deployment. Adds list-releases support end-to-end and extracts the release-readiness gating logic into a shared hook. - backend: new GET /component-releases route + listComponentReleases service method, calling the platform API at /api/v1/namespaces/{ns}/componentreleases?component={name} - client: new ComponentReleasesResponse type + listComponentReleases method on OpenChoreoClientApi - hooks: new useReleases (fetch + sort newest-first + refetch) and useReleaseReadiness (extracted from WorkloadButton gating) No user-visible changes yet — these are consumed by the new Setup card components in follow-up commits Signed-off-by: Kavith Lokuhewage --- plugins/openchoreo-backend/src/router.ts | 23 +++ .../EnvironmentInfoService.ts | 54 +++++++ .../openchoreo/src/api/OpenChoreoClient.ts | 18 +++ .../openchoreo/src/api/OpenChoreoClientApi.ts | 11 ++ .../components/Environments/hooks/index.ts | 6 + .../Environments/hooks/useReleaseReadiness.ts | 143 ++++++++++++++++++ .../Environments/hooks/useReleases.ts | 53 +++++++ 7 files changed, 308 insertions(+) create mode 100644 plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.ts create mode 100644 plugins/openchoreo/src/components/Environments/hooks/useReleases.ts diff --git a/plugins/openchoreo-backend/src/router.ts b/plugins/openchoreo-backend/src/router.ts index ca9e25407..eebd0d202 100644 --- a/plugins/openchoreo-backend/src/router.ts +++ b/plugins/openchoreo-backend/src/router.ts @@ -1141,6 +1141,29 @@ export async function createRouter({ ); }); + router.get('/component-releases', async (req, res) => { + const { componentName, namespaceName } = req.query; + + if (!componentName || !namespaceName) { + throw new InputError( + 'componentName and namespaceName are required query parameters', + ); + } + + const userToken = getUserTokenFromRequest(req); + + const rawReleases = await environmentInfoService.listComponentReleases( + { + componentName: componentName as string, + namespaceName: namespaceName as string, + }, + userToken, + ); + const items = (rawReleases as any)?.items ?? []; + res.json({ success: true, data: { items } }); + }); + + router.put('/update-release-binding', requireAuth, async (req, res) => { const { componentName, diff --git a/plugins/openchoreo-backend/src/services/EnvironmentService/EnvironmentInfoService.ts b/plugins/openchoreo-backend/src/services/EnvironmentService/EnvironmentInfoService.ts index b41a07d5c..d6c1f0c24 100644 --- a/plugins/openchoreo-backend/src/services/EnvironmentService/EnvironmentInfoService.ts +++ b/plugins/openchoreo-backend/src/services/EnvironmentService/EnvironmentInfoService.ts @@ -2028,6 +2028,60 @@ export class EnvironmentInfoService implements EnvironmentService { } } + /** + * Lists component releases for a specific component within a namespace. + * + * @param request.componentName - Name of the component to filter releases by + * @param request.namespaceName - Name of the namespace + * @returns Paginated list of component releases (frozen workload snapshots) + */ + async listComponentReleases( + request: { + componentName: string; + namespaceName: string; + }, + token?: string, + ) { + const startTime = Date.now(); + this.logger.debug( + `Listing component releases for ${request.componentName}`, + ); + + try { + const client = createOpenChoreoApiClient({ + baseUrl: this.baseUrl, + token, + logger: this.logger, + }); + + const { data, error, response } = await client.GET( + '/api/v1/namespaces/{namespaceName}/componentreleases', + { + params: { + path: { namespaceName: request.namespaceName }, + query: { component: request.componentName }, + }, + }, + ); + + assertApiResponse({ data, error, response }, 'list component releases'); + + const totalTime = Date.now() - startTime; + this.logger.debug( + `Component releases listed for ${request.componentName}: Total: ${totalTime}ms`, + ); + + return data; + } catch (error: unknown) { + const totalTime = Date.now() - startTime; + this.logger.error( + `Error listing component releases for ${request.componentName} (${totalTime}ms):`, + error as Error, + ); + throw error; + } + } + /** * Fetches the (Cluster)ResourceType referenced by `resource.spec.type` * and returns its `spec.retainPolicy`, or `undefined` if neither the diff --git a/plugins/openchoreo/src/api/OpenChoreoClient.ts b/plugins/openchoreo/src/api/OpenChoreoClient.ts index 06424d2f0..a1ef23395 100644 --- a/plugins/openchoreo/src/api/OpenChoreoClient.ts +++ b/plugins/openchoreo/src/api/OpenChoreoClient.ts @@ -10,6 +10,7 @@ import type { OpenChoreoClientApi, ActionInfo, ComponentReleaseResponse, + ComponentReleasesResponse, CreateReleaseResponse, SchemaResponse, ReleaseBindingsResponse, @@ -68,6 +69,7 @@ const API_ENDPOINTS = { RESOURCE_ENVIRONMENT_INFO: '/resource-environment-info', UPDATE_RESOURCE_RELEASE_BINDING: '/update-resource-release-binding', DELETE_RESOURCE_RELEASE_BINDING: '/delete-resource-release-binding', + COMPONENT_RELEASES: '/component-releases', UPDATE_RELEASE_BINDING: '/update-release-binding', PATCH_RELEASE_BINDING: '/patch-release-binding', RESOURCE_TREE: '/resourcetree', @@ -504,6 +506,22 @@ export class OpenChoreoClient implements OpenChoreoClientApi { }); } + async listComponentReleases( + entity: Entity, + ): Promise { + const metadata = extractEntityMetadata(entity); + + return this.apiFetch( + API_ENDPOINTS.COMPONENT_RELEASES, + { + params: { + componentName: metadata.component, + namespaceName: metadata.namespace, + }, + }, + ); + } + async updateReleaseBinding( entity: Entity, environment: string, diff --git a/plugins/openchoreo/src/api/OpenChoreoClientApi.ts b/plugins/openchoreo/src/api/OpenChoreoClientApi.ts index b44baced7..a7f49f1d3 100644 --- a/plugins/openchoreo/src/api/OpenChoreoClientApi.ts +++ b/plugins/openchoreo/src/api/OpenChoreoClientApi.ts @@ -233,6 +233,14 @@ export interface ComponentReleaseResponse { data?: ComponentRelease; } +/** Component releases list response */ +export interface ComponentReleasesResponse { + success: boolean; + data?: { + items: ComponentRelease[]; + }; +} + /** Workflow schema response */ export interface WorkflowSchemaResponse { success: boolean; @@ -662,6 +670,9 @@ export interface OpenChoreoClientApi { environment: string, ): Promise; + /** List all component releases for a component (sorted newest first by caller) */ + listComponentReleases(entity: Entity): Promise; + /** Create or update a release binding for deploy/promote actions */ updateReleaseBinding( entity: Entity, diff --git a/plugins/openchoreo/src/components/Environments/hooks/index.ts b/plugins/openchoreo/src/components/Environments/hooks/index.ts index eeda425fe..5e033b057 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/index.ts +++ b/plugins/openchoreo/src/components/Environments/hooks/index.ts @@ -26,6 +26,12 @@ export { type UsePromotionActionResult, } from './usePromotionAction'; export { useInvokeUrl } from './useInvokeUrl'; +export { useReleases, type UseReleasesResult } from './useReleases'; +export { + useReleaseReadiness, + type UseReleaseReadinessResult, + type ReleaseReadinessAlertSeverity, +} from './useReleaseReadiness'; export { useEnvironmentRouting, type EnvironmentView, diff --git a/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.ts b/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.ts new file mode 100644 index 000000000..f553ab62b --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.ts @@ -0,0 +1,143 @@ +import { useEffect, useState } from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { + useApi, + discoveryApiRef, + fetchApiRef, +} from '@backstage/core-plugin-api'; +import { ModelsBuild } from '@openchoreo/backstage-plugin-common'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { isFromSourceComponent } from '../../../utils/componentUtils'; + +export type ReleaseReadinessAlertSeverity = 'error' | 'warning' | 'info'; + +export interface UseReleaseReadinessResult { + loading: boolean; + /** True when a release can be created (workload exists and any required build succeeded). */ + canCreateRelease: boolean; + /** When canCreateRelease is false, a human-readable reason. */ + alertMessage: string | null; + alertSeverity: ReleaseReadinessAlertSeverity; + hasWorkload: boolean; + isFromSource: boolean; +} + +/** + * Determines whether a component is ready for a new release. + * + * Extracted from the old WorkloadButton so both the "Create release" and + * "Edit workload" entry points share the same gating logic. + */ +export const useReleaseReadiness = ( + entity: Entity, +): UseReleaseReadinessResult => { + const discovery = useApi(discoveryApiRef); + const fetchApi = useApi(fetchApiRef); + const client = useApi(openChoreoClientApiRef); + + const [workloadLoading, setWorkloadLoading] = useState(true); + const [hasWorkload, setHasWorkload] = useState(false); + const [builds, setBuilds] = useState([]); + const [buildsLoading, setBuildsLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setWorkloadLoading(true); + const fetchWorkload = async () => { + try { + await client.fetchWorkloadInfo(entity); + if (!cancelled) setHasWorkload(true); + } catch { + if (!cancelled) setHasWorkload(false); + } finally { + if (!cancelled) setWorkloadLoading(false); + } + }; + fetchWorkload(); + return () => { + cancelled = true; + }; + }, [entity, client]); + + useEffect(() => { + let cancelled = false; + setBuildsLoading(true); + const fetchBuilds = async () => { + try { + const componentName = entity.metadata.name; + const projectName = + entity.metadata.annotations?.['openchoreo.io/project']; + const namespaceName = + entity.metadata.annotations?.['openchoreo.io/namespace']; + const baseUrl = await discovery.getBaseUrl('openchoreo'); + + if (projectName && namespaceName && componentName) { + const response = await fetchApi.fetch( + `${baseUrl}/builds?componentName=${encodeURIComponent( + componentName, + )}&projectName=${encodeURIComponent( + projectName, + )}&namespaceName=${encodeURIComponent(namespaceName)}`, + ); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const data = await response.json(); + if (!cancelled) setBuilds(data); + } + } catch { + if (!cancelled) setBuilds([]); + } finally { + if (!cancelled) setBuildsLoading(false); + } + }; + fetchBuilds(); + return () => { + cancelled = true; + }; + }, [entity.metadata.name, entity.metadata.annotations, fetchApi, discovery]); + + const isFromSource = isFromSourceComponent(entity); + const hasBuilds = builds.length > 0; + const hasSuccessfulBuild = builds.some(build => !!build.image); + const loading = workloadLoading || buildsLoading; + + const canCreateRelease = (() => { + if (loading) return false; + if (isFromSource) { + return hasBuilds && hasSuccessfulBuild && hasWorkload; + } + return hasWorkload; + })(); + + const alertMessage: string | null = (() => { + if (loading) return null; + if (isFromSource) { + if (!hasBuilds) { + return 'Build your application first to generate a container image.'; + } + if (hasSuccessfulBuild && !hasWorkload) { + return 'Workload configuration was not created automatically. Please re-run the build workflow or contact support.'; + } + } + if (!hasWorkload) { + return 'Configure your workload to enable deployment.'; + } + return null; + })(); + + const alertSeverity: ReleaseReadinessAlertSeverity = (() => { + if (isFromSource && hasSuccessfulBuild && !hasWorkload) return 'error'; + if (isFromSource && !hasBuilds) return 'warning'; + return 'info'; + })(); + + return { + loading, + canCreateRelease, + alertMessage, + alertSeverity, + hasWorkload, + isFromSource, + }; +}; diff --git a/plugins/openchoreo/src/components/Environments/hooks/useReleases.ts b/plugins/openchoreo/src/components/Environments/hooks/useReleases.ts new file mode 100644 index 000000000..ceaa6960d --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/hooks/useReleases.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core-plugin-api'; +import type { ComponentRelease } from '@openchoreo/backstage-plugin-common'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; + +export interface UseReleasesResult { + releases: ComponentRelease[]; + loading: boolean; + error: string | null; + refetch: () => Promise; +} + +const getCreationTime = (release: ComponentRelease): number => { + const ts = release.metadata?.creationTimestamp; + return ts ? new Date(ts).getTime() : 0; +}; + +/** + * Fetches the list of ComponentReleases for a component, newest first. + */ +export const useReleases = (entity: Entity): UseReleasesResult => { + const client = useApi(openChoreoClientApiRef); + const [releases, setReleases] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchReleases = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await client.listComponentReleases(entity); + const items = response.data?.items ?? []; + const sorted = [...items].sort( + (a, b) => getCreationTime(b) - getCreationTime(a), + ); + setReleases(sorted); + } catch (e: unknown) { + const message = + e instanceof Error ? e.message : 'Failed to load releases'; + setError(message); + setReleases([]); + } finally { + setLoading(false); + } + }, [client, entity]); + + useEffect(() => { + fetchReleases(); + }, [fetchReleases]); + + return { releases, loading, error, refetch: fetchReleases }; +}; From cbb7328f073bf862f1967968c60dbeb1de17541a Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Mon, 11 May 2026 12:08:49 +0530 Subject: [PATCH 02/16] refactor(workload-config): decouple release creation from save flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workload config page used to apply workload changes, create a release, and then navigate to environment overrides for the first env — a single "Save & Next" button doing three jobs. With the upcoming Setup card revamp, release creation and deployment are owned by the Setup card, so this page should only edit and save workload + traits + parameters. - WorkloadConfigPage: handleNext → handleSave. Drops the createComponentRelease call and the navigate-to-overrides step. Renames onNext(releaseName, env) to onSaved(). Removes the unused lowestEnvironment prop. Buttons relabeled to "Save workload" / "Saving..." / "Done"; in-page alert points users back to the Set up panel for release creation. - WorkloadConfigWrapper: wires onSaved and onBack to navigateToList; no longer constructs a deploy PendingAction. The Environments.tsx pending-action handler is unchanged because the Promote/Redeploy paths on EnvironmentDetailPanel still use it. Signed-off-by: Kavith Lokuhewage --- .../Workload/WorkloadConfigPage.tsx | 43 ++++++------------- .../wrappers/WorkloadConfigWrapper.tsx | 29 +++---------- 2 files changed, 20 insertions(+), 52 deletions(-) diff --git a/plugins/openchoreo/src/components/Environments/Workload/WorkloadConfigPage.tsx b/plugins/openchoreo/src/components/Environments/Workload/WorkloadConfigPage.tsx index 36afd71ea..122c7ce08 100644 --- a/plugins/openchoreo/src/components/Environments/Workload/WorkloadConfigPage.tsx +++ b/plugins/openchoreo/src/components/Environments/Workload/WorkloadConfigPage.tsx @@ -61,12 +61,11 @@ const useStyles = makeStyles(theme => ({ interface WorkloadConfigPageProps { onBack: () => void; - /** Called after workload is applied and release is created, navigates to overrides */ - onNext: (releaseName: string, targetEnvironment: string) => void; - /** The lowest environment name (first in deployment pipeline) */ - lowestEnvironment: string; + /** Called after workload + traits/parameters are saved successfully */ + onSaved: () => void; /** - * Data plane of the lowest environment. + * Data plane of the lowest environment, used by the workload editor for + * endpoint previews. */ lowestEnvDataPlane?: { kind?: string; name?: string }; /** Initial tab to display (from URL) */ @@ -77,8 +76,7 @@ interface WorkloadConfigPageProps { export const WorkloadConfigPage = ({ onBack, - onNext, - lowestEnvironment, + onSaved, lowestEnvDataPlane, initialTab, onTabChange, @@ -434,31 +432,28 @@ export const WorkloadConfigPage = ({ const hasAnyChanges = workloadChanges.hasChanges || hasTraitChanges || hasParameterChanges; - const handleNext = async () => { + const handleSave = async () => { if (!workloadResource) { return; } if (!isFromSource && !spec?.container?.image?.trim()) { - setSaveError('A container image is required before proceeding.'); + setSaveError('A container image is required before saving.'); return; } setIsProcessing(true); setSaveError(null); try { - // Step 1: Apply workload (if changed) — send the full resource as-is if (workloadChanges.hasChanges) { const result = await client.applyWorkload( entity, workloadResource, isNewWorkload, ); - // Advance the saved baseline so retries don't reapply the same change setWorkloadResource(result); setInitialResource(JSON.parse(JSON.stringify(result))); setIsNewWorkload(false); } - // Step 2: Update component config (traits/parameters) if changed if (hasTraitChanges || hasParameterChanges) { await client.updateComponentConfig( entity, @@ -467,19 +462,9 @@ export const WorkloadConfigPage = ({ ); } - // Step 3: Create ComponentRelease - const releaseResponse = await client.createComponentRelease(entity); - - if (!releaseResponse.data?.name) { - throw new Error('Failed to create release: no release name returned'); - } - - const releaseName = releaseResponse.data.name; - - // Step 4: Navigate to overrides page setIsProcessing(false); allowNavigationRef.current = true; - onNext(releaseName, lowestEnvironment); + onSaved(); } catch (e: unknown) { setIsProcessing(false); setSaveError(getErrorMessage(e)); @@ -494,20 +479,20 @@ export const WorkloadConfigPage = ({ if (isFromSource && !hasImage) { return 'Build your application first to generate a container image.'; } - return 'Configure your workload to enable deployment.'; + return 'Configure your workload, then return to the Set up panel to create a release.'; }; const handleButtonClick = () => { if (hasAnyChanges) { setShowConfirmDialog(true); } else { - handleNext(); + onBack(); } }; const handleConfirmSave = () => { setShowConfirmDialog(false); - handleNext(); + handleSave(); }; const handleBackClick = () => { @@ -519,9 +504,9 @@ export const WorkloadConfigPage = ({ }; const getButtonText = () => { - if (isProcessing) return 'Processing...'; - if (hasAnyChanges) return 'Save & Next'; - return 'Next'; + if (isProcessing) return 'Saving...'; + if (hasAnyChanges) return 'Save workload'; + return 'Done'; }; const totalChanges = diff --git a/plugins/openchoreo/src/components/Environments/wrappers/WorkloadConfigWrapper.tsx b/plugins/openchoreo/src/components/Environments/wrappers/WorkloadConfigWrapper.tsx index 493417c28..300d1d5ad 100644 --- a/plugins/openchoreo/src/components/Environments/wrappers/WorkloadConfigWrapper.tsx +++ b/plugins/openchoreo/src/components/Environments/wrappers/WorkloadConfigWrapper.tsx @@ -3,10 +3,12 @@ import { useSearchParams, useNavigate } from 'react-router-dom'; import { useEnvironmentsContext } from '../EnvironmentsContext'; import { useEnvironmentRouting } from '../hooks/useEnvironmentRouting'; import { WorkloadConfigPage } from '../Workload/WorkloadConfigPage'; -import type { PendingAction } from '../types'; /** * Wrapper component for WorkloadConfigPage that handles URL-based navigation. + * + * Saving workload + traits/parameters returns the user to the deploy list + * view; release creation is owned by the Set up panel there. */ export const WorkloadConfigWrapper = () => { const [searchParams] = useSearchParams(); @@ -18,42 +20,23 @@ export const WorkloadConfigWrapper = () => { const envDataPlane = lowestEnv ? { kind: lowestEnv.dataPlaneKind, name: lowestEnv.dataPlaneRef } : undefined; - const { navigateToList, navigateToOverrides } = useEnvironmentRouting(); + const { navigateToList } = useEnvironmentRouting(); - // Get active tab from URL (container, endpoints, dependencies) const activeTab = searchParams.get('tab') || 'container'; - // Handle tab change - update URL - // When replace is true (default tab initialization), don't add to history - // When replace is false (user interaction), add to history for back button support const handleTabChange = useCallback( (tab: string, replace = false) => { const newParams = new URLSearchParams(searchParams); - // Always set tab param for consistency (including first tab) newParams.set('tab', tab); navigate(`?${newParams.toString()}`, { replace }); }, [searchParams, navigate], ); - const handleBack = () => { - navigateToList(); - }; - - const handleNext = (releaseName: string, targetEnvironment: string) => { - const pendingAction: PendingAction = { - type: 'deploy', - releaseName, - targetEnvironment, - }; - navigateToOverrides(targetEnvironment, pendingAction); - }; - return ( Date: Mon, 11 May 2026 14:30:19 +0530 Subject: [PATCH 03/16] feat(setup-card): split release creation from deployment Replaces the single "Configure & Deploy" button with two clear stories on the deploy page Set up panel: - Create release: dialog with optional name (DNS-1123 + uniqueness validated), snapshots current workload/traits/parameters. Surfaces an Auto Deploy notice when the controller will auto-bind. - Deploy a release: searchable picker over existing releases showing timestamp, image, "current in " badges, and view-YAML. "Deploy now" uses release defaults; "Configure overrides" deep-links to the existing overrides page. Edit workload stays as a separate entry; the underlying WorkloadConfigPage was decoupled from release creation in a prior commit. SetupCard pruned to a passive canvas tile. Tests rewritten for the new contract; all Environments tests pass. Signed-off-by: Kavith Lokuhewage --- .../components/CreateReleaseDialog.tsx | 186 +++++++++++++ .../components/DeployReleasePanel.tsx | 201 ++++++++++++++ .../Environments/components/ReleasePicker.tsx | 185 +++++++++++++ .../components/SetupCard.test.tsx | 251 ++---------------- .../Environments/components/SetupCard.tsx | 192 +------------- .../components/SetupDetailPane.test.tsx | 251 ++++++++++++++++++ .../components/SetupDetailPane.tsx | 212 +++++++++++---- 7 files changed, 1018 insertions(+), 460 deletions(-) create mode 100644 plugins/openchoreo/src/components/Environments/components/CreateReleaseDialog.tsx create mode 100644 plugins/openchoreo/src/components/Environments/components/DeployReleasePanel.tsx create mode 100644 plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx create mode 100644 plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx diff --git a/plugins/openchoreo/src/components/Environments/components/CreateReleaseDialog.tsx b/plugins/openchoreo/src/components/Environments/components/CreateReleaseDialog.tsx new file mode 100644 index 000000000..b91302593 --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/components/CreateReleaseDialog.tsx @@ -0,0 +1,186 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, + Typography, +} from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import { useApi } from '@backstage/core-plugin-api'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import type { ComponentRelease } from '@openchoreo/backstage-plugin-common'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { getErrorMessage } from '../../../utils/errorUtils'; + +/** + * DNS-1123 subdomain: lowercase alphanumeric, '-', '.', max 253 chars, + * must start and end with alphanumeric. We use the stricter "label" + * shape (no dots) because ComponentRelease names are k8s object names. + */ +const DNS_1123_LABEL = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/; +const MAX_NAME_LENGTH = 253; + +const validateReleaseName = ( + name: string, + existingNames: Set, +): string | null => { + if (!name) return null; // empty is fine — backend auto-generates + if (name.length > MAX_NAME_LENGTH) { + return `Name must be ${MAX_NAME_LENGTH} characters or fewer.`; + } + if (!DNS_1123_LABEL.test(name)) { + return 'Use lowercase letters, digits, and hyphens. Must start and end with a letter or digit.'; + } + if (existingNames.has(name)) { + return 'A release with this name already exists.'; + } + return null; +}; + +export interface CreateReleaseDialogProps { + open: boolean; + onClose: () => void; + /** Called with the created release's name once the API call succeeds. */ + onCreated: (releaseName: string) => void; + /** Existing releases used for client-side uniqueness validation. */ + existingReleases: ComponentRelease[]; + /** + * When true, surface a notice that the controller will auto-deploy the + * new release to the first environment. + */ + autoDeployEnabled?: boolean; + /** Display name of the first environment (used in the auto-deploy notice). */ + firstEnvironmentName?: string; +} + +export const CreateReleaseDialog = ({ + open, + onClose, + onCreated, + existingReleases, + autoDeployEnabled, + firstEnvironmentName, +}: CreateReleaseDialogProps) => { + const client = useApi(openChoreoClientApiRef); + const { entity } = useEntity(); + + const [name, setName] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + useEffect(() => { + if (!open) { + setName(''); + setSubmitError(null); + setSubmitting(false); + } + }, [open]); + + const existingNames = useMemo( + () => + new Set( + existingReleases + .map(r => r.metadata?.name) + .filter((n): n is string => !!n), + ), + [existingReleases], + ); + + const validationError = validateReleaseName(name.trim(), existingNames); + + const handleSubmit = async () => { + if (validationError) return; + setSubmitting(true); + setSubmitError(null); + try { + const trimmed = name.trim(); + const response = await client.createComponentRelease( + entity, + trimmed || undefined, + ); + const created = response.data?.name; + if (!created) { + throw new Error('Release was created but no name was returned.'); + } + onCreated(created); + } catch (e: unknown) { + setSubmitError(getErrorMessage(e)); + setSubmitting(false); + } + }; + + return ( + + Create release + + + A release captures an immutable snapshot of the current workload, + traits, and parameters. You can deploy it now or later. + + + + setName(e.target.value)} + error={!!validationError} + helperText={ + validationError || + 'Leave blank to let OpenChoreo generate a name. Lowercase letters, digits, and hyphens only.' + } + fullWidth + disabled={submitting} + inputProps={{ maxLength: MAX_NAME_LENGTH }} + /> + + + {autoDeployEnabled && ( + + + Auto Deploy is on, so this release will also deploy to{' '} + {firstEnvironmentName || 'the first environment'}{' '} + automatically. + + + )} + + {submitError && ( + + setSubmitError(null)}> + {submitError} + + + )} + + + + + + + ); +}; diff --git a/plugins/openchoreo/src/components/Environments/components/DeployReleasePanel.tsx b/plugins/openchoreo/src/components/Environments/components/DeployReleasePanel.tsx new file mode 100644 index 000000000..76db70b0e --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/components/DeployReleasePanel.tsx @@ -0,0 +1,201 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Box, + Button, + CircularProgress, + Tooltip, + Typography, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { Alert } from '@material-ui/lab'; +import TuneOutlinedIcon from '@material-ui/icons/TuneOutlined'; +import { useApi } from '@backstage/core-plugin-api'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import type { ComponentRelease } from '@openchoreo/backstage-plugin-common'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { useNotification } from '../../../hooks'; +import { getErrorMessage } from '../../../utils/errorUtils'; +import { useEnvironmentsContext } from '../EnvironmentsContext'; +import { useEnvironmentRouting } from '../hooks/useEnvironmentRouting'; +import { ReleasePicker, type ReleaseDeployments } from './ReleasePicker'; + +const useStyles = makeStyles(theme => ({ + panel: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1.5), + }, + actionsRow: { + display: 'flex', + justifyContent: 'flex-end', + gap: theme.spacing(1), + }, +})); + +export interface DeployReleasePanelProps { + releases: ComponentRelease[]; + releasesLoading: boolean; + releasesError: string | null; + /** Map release name → environments where it is currently bound. */ + deployments: ReleaseDeployments; + /** Externally controlled selection (so dialogs can preselect newly created release). */ + selectedReleaseName: string | null; + onSelectedReleaseChange: (releaseName: string | null) => void; + /** Display name of the first env (e.g. "development"). */ + firstEnvironmentName: string; + /** Refetch releases + bindings after a successful deploy. */ + onDeployed: () => void; + disabled?: boolean; + disabledReason?: string; +} + +/** + * Story 2: pick an existing release and deploy it to the first environment. + * + * Deploy now → calls updateReleaseBinding directly with no override payload + * (release defaults apply). + * Configure overrides → deep-links to the existing /overrides/ page with + * a 'deploy' pendingAction. That page already saves overrides + * and triggers updateReleaseBinding on save. + */ +export const DeployReleasePanel = ({ + releases, + releasesLoading, + releasesError, + deployments, + selectedReleaseName, + onSelectedReleaseChange, + firstEnvironmentName, + onDeployed, + disabled, + disabledReason, +}: DeployReleasePanelProps) => { + const classes = useStyles(); + const client = useApi(openChoreoClientApiRef); + const { entity } = useEntity(); + const notification = useNotification(); + const { refetch } = useEnvironmentsContext(); + const { navigateToOverrides } = useEnvironmentRouting(); + + const [deploying, setDeploying] = useState(false); + const [deployError, setDeployError] = useState(null); + + // Preselect the most recent release when one is available and nothing is + // selected yet. Only run when the list of names actually changes, so the + // user's explicit selection survives refetches. + const newestName = releases[0]?.metadata?.name ?? null; + useEffect(() => { + if (!selectedReleaseName && newestName) { + onSelectedReleaseChange(newestName); + } + }, [newestName, selectedReleaseName, onSelectedReleaseChange]); + + const handleConfigureOverrides = () => { + if (!selectedReleaseName) return; + navigateToOverrides(firstEnvironmentName, { + type: 'deploy', + releaseName: selectedReleaseName, + targetEnvironment: firstEnvironmentName, + }); + }; + + const handleDeployNow = async () => { + if (!selectedReleaseName) return; + setDeploying(true); + setDeployError(null); + try { + await client.updateReleaseBinding( + entity, + firstEnvironmentName, + selectedReleaseName, + ); + notification.showSuccess( + `Deployed ${selectedReleaseName} to ${firstEnvironmentName}`, + ); + refetch(); + onDeployed(); + } catch (e: unknown) { + setDeployError(getErrorMessage(e)); + } finally { + setDeploying(false); + } + }; + + const noReleases = !releasesLoading && releases.length === 0; + const deployDisabled = useMemo( + () => disabled || deploying || !selectedReleaseName || noReleases, + [disabled, deploying, selectedReleaseName, noReleases], + ); + + return ( + + + Deploy to {firstEnvironmentName} + + + {releasesError && {releasesError}} + + {noReleases && !releasesError && ( + + No releases yet. Create one above to deploy it here. + + )} + + + + {deployError && ( + setDeployError(null)}> + {deployError} + + )} + + + + + + + + + + + + + + + ); +}; diff --git a/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx b/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx new file mode 100644 index 000000000..60288b18a --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx @@ -0,0 +1,185 @@ +import { useMemo, useState } from 'react'; +import { + Box, + Chip, + IconButton, + TextField, + Tooltip, + Typography, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { Autocomplete } from '@material-ui/lab'; +import VisibilityOutlinedIcon from '@material-ui/icons/VisibilityOutlined'; +import type { ComponentRelease } from '@openchoreo/backstage-plugin-common'; +import { ReleaseManifestDialog } from './ReleaseManifestDialog'; + +const useStyles = makeStyles(theme => ({ + optionRow: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + width: '100%', + }, + optionMain: { + flexGrow: 1, + minWidth: 0, + }, + optionName: { + fontWeight: 500, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + optionMeta: { + color: theme.palette.text.secondary, + fontSize: 12, + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(1), + }, + currentChip: { + height: 20, + fontSize: 11, + }, +})); + +/** Map from releaseName → list of environment names where it is currently bound. */ +export type ReleaseDeployments = Record; + +export interface ReleasePickerProps { + releases: ComponentRelease[]; + selectedReleaseName: string | null; + onChange: (releaseName: string | null) => void; + /** Environments where each release is currently deployed. Used for badges. */ + deployments?: ReleaseDeployments; + /** Env name passed to the YAML preview dialog. */ + environmentName: string; + disabled?: boolean; + loading?: boolean; + label?: string; +} + +const formatRelativeTime = (iso?: string): string => { + if (!iso) return ''; + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ''; + const diffSec = Math.max(0, Math.floor((Date.now() - then) / 1000)); + if (diffSec < 60) return 'just now'; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 30) return `${diffDay}d ago`; + return new Date(iso).toLocaleDateString(); +}; + +/** Pull `spec.workload.spec.container.image` (or any reasonable fallback) for display. */ +const extractImage = (release: ComponentRelease): string | undefined => { + const workload = release.spec?.workload as + | { spec?: { container?: { image?: string } } } + | undefined; + return workload?.spec?.container?.image; +}; + +const shortenImage = (image: string): string => { + const lastSlash = image.lastIndexOf('/'); + return lastSlash >= 0 ? image.slice(lastSlash + 1) : image; +}; + +export const ReleasePicker = ({ + releases, + selectedReleaseName, + onChange, + deployments = {}, + environmentName, + disabled, + loading, + label = 'Release', +}: ReleasePickerProps) => { + const classes = useStyles(); + const [yamlReleaseName, setYamlReleaseName] = useState(null); + + const selected = useMemo( + () => releases.find(r => r.metadata?.name === selectedReleaseName) ?? null, + [releases, selectedReleaseName], + ); + + return ( + <> + onChange(next?.metadata?.name ?? null)} + getOptionLabel={r => r.metadata?.name ?? ''} + getOptionSelected={(opt, val) => + opt.metadata?.name === val.metadata?.name + } + disabled={disabled} + loading={loading} + renderInput={params => ( + + )} + renderOption={release => { + const name = release.metadata?.name ?? '(unnamed)'; + const created = formatRelativeTime( + release.metadata?.creationTimestamp, + ); + const image = extractImage(release); + const deployedIn = deployments[name] ?? []; + return ( + + + + {name} + + + {created && {created}} + {image && img: {shortenImage(image)}} + {deployedIn.map(env => ( + + ))} + + + + { + e.stopPropagation(); + e.preventDefault(); + }} + onClick={e => { + e.stopPropagation(); + setYamlReleaseName(name); + }} + aria-label="View release YAML" + > + + + + + ); + }} + /> + + setYamlReleaseName(null)} + releaseName={yamlReleaseName ?? undefined} + environmentName={environmentName} + /> + + ); +}; diff --git a/plugins/openchoreo/src/components/Environments/components/SetupCard.test.tsx b/plugins/openchoreo/src/components/Environments/components/SetupCard.test.tsx index 2d6b000a7..81edae4dc 100644 --- a/plugins/openchoreo/src/components/Environments/components/SetupCard.test.tsx +++ b/plugins/openchoreo/src/components/Environments/components/SetupCard.test.tsx @@ -1,245 +1,38 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { MemoryRouter } from 'react-router-dom'; -import { TestApiProvider } from '@backstage/test-utils'; -import { EntityProvider } from '@backstage/plugin-catalog-react'; -import { - createMockOpenChoreoClient, - mockComponentEntity, -} from '@openchoreo/test-utils'; -import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { render, screen } from '@testing-library/react'; import { SetupCard } from './SetupCard'; -// ---- Mocks ---- - jest.mock('./LoadingSkeleton', () => ({ LoadingSkeleton: ({ variant }: { variant: string }) => (
), })); -jest.mock('../Workload/WorkloadButton', () => ({ - WorkloadButton: ({ onConfigureWorkload }: any) => ( - - ), -})); - -const mockUpdateAutoDeploy = jest.fn(); -jest.mock('../hooks/useAutoDeployUpdate', () => ({ - useAutoDeployUpdate: () => ({ - updateAutoDeploy: mockUpdateAutoDeploy, - isUpdating: false, - error: null, - }), -})); - -const mockShowSuccess = jest.fn(); -const mockShowError = jest.fn(); -jest.mock('../../../hooks', () => ({ - useNotification: () => ({ - notification: null, - showSuccess: mockShowSuccess, - showError: mockShowError, - hide: jest.fn(), - }), -})); - -// ---- Helpers ---- - -const mockClient = createMockOpenChoreoClient(); -const testEntity = mockComponentEntity(); - -function renderSetupCard( - props: Partial> = {}, -) { - const defaultProps = { - loading: false, - environmentsExist: true, - isWorkloadEditorSupported: false, - onConfigureWorkload: jest.fn(), - }; - - return render( - - - - - - - , - ); -} - -// ---- Tests ---- - -describe('SetupCard', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockClient.getComponentDetails.mockResolvedValue({}); - }); - - it('shows loading skeleton when loading with no environments', () => { - renderSetupCard({ loading: true, environmentsExist: false }); +describe('SetupCard (compact canvas tile)', () => { + it('shows the loading skeleton while no environments are loaded yet', () => { + render( + , + ); expect(screen.getByTestId('loading-skeleton-setup')).toBeInTheDocument(); - expect(screen.queryByText('Auto Deploy')).not.toBeInTheDocument(); - }); - - it('shows content when loaded', () => { - renderSetupCard(); - - expect(screen.getByText('Set up')).toBeInTheDocument(); - expect( - screen.getByText('Manage deployment configuration and settings'), - ).toBeInTheDocument(); - expect(screen.getByText('Auto Deploy')).toBeInTheDocument(); - }); - - it('fetches and displays autoDeploy=true from component details', async () => { - mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: true }); - - renderSetupCard(); - - await waitFor(() => { - const switchEl = screen.getByRole('checkbox', { name: /auto deploy/i }); - expect(switchEl).toBeChecked(); - }); - }); - - it('fetches and displays autoDeploy=false from component details', async () => { - mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: false }); - - renderSetupCard(); - - await waitFor(() => { - const switchEl = screen.getByRole('checkbox', { name: /auto deploy/i }); - expect(switchEl).not.toBeChecked(); - }); - }); - - it('switch defaults to unchecked when autoDeploy is undefined', () => { - mockClient.getComponentDetails.mockResolvedValue({}); - - renderSetupCard(); - - const switchEl = screen.getByRole('checkbox', { name: /auto deploy/i }); - expect(switchEl).not.toBeChecked(); + expect(screen.queryByText('Releases & deployment')).not.toBeInTheDocument(); }); - it('opens confirmation dialog when toggle is clicked', async () => { - const user = userEvent.setup(); - mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: false }); - - renderSetupCard(); - - await waitFor(() => { - expect( - screen.getByRole('checkbox', { name: /auto deploy/i }), - ).not.toBeChecked(); - }); - - await user.click(screen.getByRole('checkbox', { name: /auto deploy/i })); - - expect(screen.getByText('Enable Auto Deploy?')).toBeInTheDocument(); - expect(screen.getByText('Confirm')).toBeInTheDocument(); - expect(screen.getByText('Cancel')).toBeInTheDocument(); - }); - - it('calls updateAutoDeploy on confirm and shows success notification', async () => { - const user = userEvent.setup(); - mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: false }); - mockUpdateAutoDeploy.mockResolvedValue(true); - - renderSetupCard(); - - await waitFor(() => { - expect( - screen.getByRole('checkbox', { name: /auto deploy/i }), - ).not.toBeChecked(); - }); - - await user.click(screen.getByRole('checkbox', { name: /auto deploy/i })); - await user.click(screen.getByText('Confirm')); - - expect(mockUpdateAutoDeploy).toHaveBeenCalledWith(true); - await waitFor(() => { - expect(mockShowSuccess).toHaveBeenCalledWith( - 'Auto deploy enabled successfully', - ); - }); - }); - - it('shows error notification when updateAutoDeploy fails', async () => { - const user = userEvent.setup(); - mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: true }); - mockUpdateAutoDeploy.mockResolvedValue(false); - - renderSetupCard(); - - await waitFor(() => { - expect( - screen.getByRole('checkbox', { name: /auto deploy/i }), - ).toBeChecked(); - }); - - await user.click(screen.getByRole('checkbox', { name: /auto deploy/i })); - await user.click(screen.getByText('Confirm')); - - expect(mockUpdateAutoDeploy).toHaveBeenCalledWith(false); - await waitFor(() => { - expect(mockShowError).toHaveBeenCalledWith( - 'Failed to update auto deploy setting', - ); - }); - }); - - it('closes dialog on cancel without calling updateAutoDeploy', async () => { - const user = userEvent.setup(); - renderSetupCard(); - - await waitFor(() => { - expect( - screen.getByRole('checkbox', { name: /auto deploy/i }), - ).toBeEnabled(); - }); - - await user.click(screen.getByRole('checkbox', { name: /auto deploy/i })); - expect(screen.getByText('Enable Auto Deploy?')).toBeInTheDocument(); - - await user.click(screen.getByText('Cancel')); - - await waitFor(() => { - expect(screen.queryByText('Enable Auto Deploy?')).not.toBeInTheDocument(); - }); - expect(mockUpdateAutoDeploy).not.toHaveBeenCalled(); - }); - - it('shows WorkloadButton when isWorkloadEditorSupported is true', () => { - renderSetupCard({ isWorkloadEditorSupported: true }); - - expect(screen.getByTestId('workload-button')).toBeInTheDocument(); - }); - - it('hides WorkloadButton when isWorkloadEditorSupported is false', () => { - renderSetupCard({ isWorkloadEditorSupported: false }); - - expect(screen.queryByTestId('workload-button')).not.toBeInTheDocument(); - }); - - it('silently handles getComponentDetails failure', async () => { - mockClient.getComponentDetails.mockRejectedValue( - new Error('Network error'), + it('shows the title and hint once loaded', () => { + render( + , ); - renderSetupCard(); - - // Should render normally with switch unchecked (default) - await waitFor(() => { - expect( - screen.getByRole('checkbox', { name: /auto deploy/i }), - ).not.toBeChecked(); - }); + expect(screen.getByText('Set up')).toBeInTheDocument(); + expect(screen.getByText('Releases & deployment')).toBeInTheDocument(); }); }); diff --git a/plugins/openchoreo/src/components/Environments/components/SetupCard.tsx b/plugins/openchoreo/src/components/Environments/components/SetupCard.tsx index 96aa7731c..2387cba40 100644 --- a/plugins/openchoreo/src/components/Environments/components/SetupCard.tsx +++ b/plugins/openchoreo/src/components/Environments/components/SetupCard.tsx @@ -1,27 +1,17 @@ -import { useState, useEffect, useCallback } from 'react'; -import { - Box, - Typography, - FormControlLabel, - Switch, - Tooltip, - IconButton, -} from '@material-ui/core'; +import { Box, Typography } from '@material-ui/core'; import SettingsOutlinedIcon from '@material-ui/icons/SettingsOutlined'; -import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; import clsx from 'clsx'; -import { useApi } from '@backstage/core-plugin-api'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { useSetupCardStyles, useSetupCardCompactStyles } from '../styles'; +import { useSetupCardCompactStyles } from '../styles'; import { SetupCardProps } from '../types'; import { LoadingSkeleton } from './LoadingSkeleton'; -import { WorkloadButton } from '../Workload/WorkloadButton'; -import { AutoDeployConfirmationDialog } from './AutoDeployConfirmationDialog'; -import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; -import { useAutoDeployUpdate } from '../hooks/useAutoDeployUpdate'; -import { useNotification } from '../../../hooks'; -const CompactSetupTile = ({ +/** + * Compact passive tile rendered on the deploy minimap canvas. All Setup + * actions (Auto Deploy, Create release, Edit workload, Deploy) live in the + * right-pane SetupDetailPane when the tile is selected — the canvas stays + * action-free. + */ +export const SetupCard = ({ loading, environmentsExist, selected, @@ -40,170 +30,8 @@ const CompactSetupTile = ({ {loading && !environmentsExist ? ( ) : ( - - Auto Deploy & component configuration - + Releases & deployment )} ); }; - -const FullSetupCard = ({ - loading, - environmentsExist, - isWorkloadEditorSupported, - onConfigureWorkload, -}: SetupCardProps) => { - const fullStyles = useSetupCardStyles(); - const { entity } = useEntity(); - const client = useApi(openChoreoClientApiRef); - const notification = useNotification(); - const { updateAutoDeploy, isUpdating: autoDeployUpdating } = - useAutoDeployUpdate(entity); - - const [autoDeploy, setAutoDeploy] = useState(undefined); - const [autoDeployLoaded, setAutoDeployLoaded] = useState(false); - const [showConfirmDialog, setShowConfirmDialog] = useState(false); - const [pendingAutoDeployValue, setPendingAutoDeployValue] = useState(false); - - useEffect(() => { - let cancelled = false; - setAutoDeployLoaded(false); - - const fetchComponentData = async () => { - try { - const componentData = await client.getComponentDetails(entity); - if (!cancelled && componentData?.autoDeploy !== undefined) { - setAutoDeploy(componentData.autoDeploy); - } - } catch { - return; - } - if (!cancelled) { - setAutoDeployLoaded(true); - } - }; - - fetchComponentData(); - return () => { - cancelled = true; - }; - }, [entity, client]); - - const handleAutoDeployChange = useCallback( - async (newAutoDeploy: boolean) => { - const success = await updateAutoDeploy(newAutoDeploy); - if (success) { - setAutoDeploy(newAutoDeploy); - notification.showSuccess( - `Auto deploy ${newAutoDeploy ? 'enabled' : 'disabled'} successfully`, - ); - } else { - notification.showError('Failed to update auto deploy setting'); - } - }, - [updateAutoDeploy, notification], - ); - - const handleToggleChange = (event: React.ChangeEvent) => { - const newValue = event.target.checked; - setPendingAutoDeployValue(newValue); - setShowConfirmDialog(true); - }; - - const handleConfirm = () => { - handleAutoDeployChange(pendingAutoDeployValue); - setShowConfirmDialog(false); - }; - - const handleCancel = () => { - setShowConfirmDialog(false); - }; - - const autoDeploySwitch = ( - - } - label={Auto Deploy} - /> - ); - - const autoDeployTooltip = ( - - - - - - ); - - return ( - <> - - - - - Set up - - - {loading && !environmentsExist ? ( - - ) : ( - <> - - Manage deployment configuration and settings - - - - - {autoDeploySwitch} - {autoDeployTooltip} - - - - {isWorkloadEditorSupported && ( - - )} - - )} - - - - - - ); -}; - -/** - * Setup card showing workload deployment options and auto deploy toggle. - * - * Compact mode renders a passive tile for the deploy minimap canvas — the - * Auto Deploy switch and Configure & Deploy button live in the right-pane - * SetupDetailPane when the user clicks the tile, so the canvas stays - * action-free. - */ -export const SetupCard = (props: SetupCardProps) => { - if (props.compact) { - return ; - } - return ; -}; diff --git a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx new file mode 100644 index 000000000..db0a31b56 --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx @@ -0,0 +1,251 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { TestApiProvider } from '@backstage/test-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { + createMockOpenChoreoClient, + mockComponentEntity, +} from '@openchoreo/test-utils'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { SetupDetailPane } from './SetupDetailPane'; + +// ---- Mocks ---- + +jest.mock('./LoadingSkeleton', () => ({ + LoadingSkeleton: ({ variant }: { variant: string }) => ( +
+ ), +})); + +// Heavy child components — replace with thin doubles so we can assert on +// the SetupDetailPane wiring without dragging in their dependencies. +jest.mock('./CreateReleaseDialog', () => ({ + CreateReleaseDialog: ({ open, onCreated, autoDeployEnabled }: any) => + open ? ( +
+ {String(autoDeployEnabled)} + +
+ ) : null, +})); + +jest.mock('./DeployReleasePanel', () => ({ + DeployReleasePanel: ({ disabled }: any) => ( +
+ ), +})); + +const mockUpdateAutoDeploy = jest.fn(); +jest.mock('../hooks/useAutoDeployUpdate', () => ({ + useAutoDeployUpdate: () => ({ + updateAutoDeploy: mockUpdateAutoDeploy, + isUpdating: false, + error: null, + }), +})); + +let readinessOverride: any = null; +jest.mock('../hooks/useReleaseReadiness', () => ({ + useReleaseReadiness: () => + readinessOverride ?? { + loading: false, + canCreateRelease: true, + alertMessage: null, + alertSeverity: 'info', + hasWorkload: true, + isFromSource: false, + }, +})); + +jest.mock('../hooks/useReleases', () => ({ + useReleases: () => ({ + releases: [], + loading: false, + error: null, + refetch: jest.fn(), + }), +})); + +let permissionOverride: any = null; +jest.mock('@openchoreo/backstage-plugin-react', () => { + const actual = jest.requireActual('@openchoreo/backstage-plugin-react'); + return { + ...actual, + useConfigureAndDeployPermission: () => + permissionOverride ?? { + canConfigureAndDeploy: true, + loading: false, + deniedTooltip: '', + }, + }; +}); + +const mockShowSuccess = jest.fn(); +const mockShowError = jest.fn(); +jest.mock('../../../hooks', () => ({ + useNotification: () => ({ + notification: null, + showSuccess: mockShowSuccess, + showError: mockShowError, + hide: jest.fn(), + }), +})); + +jest.mock('../EnvironmentsContext', () => ({ + useEnvironmentsContext: () => ({ + environments: [{ name: 'development', deployment: {}, endpoints: [] }], + displayEnvironments: [], + loading: false, + refetch: jest.fn(), + lowestEnvironment: 'development', + isWorkloadEditorSupported: true, + onPendingActionComplete: jest.fn(), + canViewEnvironments: true, + environmentReadPermissionLoading: false, + canViewBindings: true, + bindingsPermissionLoading: false, + selection: null, + setSelection: jest.fn(), + }), +})); + +// ---- Helpers ---- + +const mockClient = createMockOpenChoreoClient(); +const testEntity = mockComponentEntity(); + +const renderPane = ( + props: Partial> = {}, +) => + render( + + + + + + + , + ); + +beforeEach(() => { + jest.clearAllMocks(); + readinessOverride = null; + permissionOverride = null; + mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: false }); +}); + +describe('SetupDetailPane', () => { + it('renders both stories: Create release button and the deploy panel', async () => { + renderPane(); + + expect( + await screen.findByRole('button', { name: /create release/i }), + ).toBeEnabled(); + expect(screen.getByTestId('deploy-release-panel')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /edit workload/i }), + ).toBeInTheDocument(); + }); + + it('disables Create release when readiness blocks it and surfaces the reason', async () => { + readinessOverride = { + loading: false, + canCreateRelease: false, + alertMessage: + 'Build your application first to generate a container image.', + alertSeverity: 'warning', + hasWorkload: false, + isFromSource: true, + }; + + renderPane(); + + const button = await screen.findByRole('button', { + name: /create release/i, + }); + expect(button).toBeDisabled(); + expect( + screen.getByText( + 'Build your application first to generate a container image.', + ), + ).toBeInTheDocument(); + }); + + it('passes the auto-deploy flag into the create-release dialog', async () => { + mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: true }); + const user = userEvent.setup(); + renderPane(); + + await waitFor(() => { + expect( + screen.getByRole('checkbox', { name: /auto deploy/i }), + ).toBeChecked(); + }); + + await user.click(screen.getByRole('button', { name: /create release/i })); + + expect(screen.getByTestId('auto-deploy-flag')).toHaveTextContent('true'); + }); + + it('confirms auto-deploy changes through the confirmation dialog', async () => { + const user = userEvent.setup(); + mockUpdateAutoDeploy.mockResolvedValue(true); + renderPane(); + + await waitFor(() => { + expect( + screen.getByRole('checkbox', { name: /auto deploy/i }), + ).not.toBeChecked(); + }); + + await user.click(screen.getByRole('checkbox', { name: /auto deploy/i })); + expect(screen.getByText('Enable Auto Deploy?')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /confirm/i })); + + expect(mockUpdateAutoDeploy).toHaveBeenCalledWith(true); + await waitFor(() => { + expect(mockShowSuccess).toHaveBeenCalledWith( + 'Auto deploy enabled successfully', + ); + }); + }); + + it('disables actions when the user lacks the deploy permission', async () => { + permissionOverride = { + canConfigureAndDeploy: false, + loading: false, + deniedTooltip: 'You do not have permission to deploy.', + }; + + renderPane(); + + expect( + await screen.findByRole('button', { name: /create release/i }), + ).toBeDisabled(); + expect( + screen.getByRole('button', { name: /edit workload/i }), + ).toBeDisabled(); + expect(screen.getByTestId('deploy-release-panel')).toHaveAttribute( + 'data-disabled', + 'true', + ); + }); +}); diff --git a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx index cdf290270..00c6bdae1 100644 --- a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx +++ b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx @@ -1,24 +1,35 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Box, - Typography, + Button, + Divider, FormControlLabel, + IconButton, Switch, Tooltip, - IconButton, + Typography, } from '@material-ui/core'; -import SettingsOutlinedIcon from '@material-ui/icons/SettingsOutlined'; -import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; +import { Alert } from '@material-ui/lab'; +import AddIcon from '@material-ui/icons/Add'; import CloseIcon from '@material-ui/icons/Close'; +import EditOutlinedIcon from '@material-ui/icons/EditOutlined'; +import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; +import SettingsOutlinedIcon from '@material-ui/icons/SettingsOutlined'; import { useApi } from '@backstage/core-plugin-api'; import { useEntity } from '@backstage/plugin-catalog-react'; import { useEnvironmentDetailPanelStyles } from '../styles'; import { LoadingSkeleton } from './LoadingSkeleton'; -import { WorkloadButton } from '../Workload/WorkloadButton'; import { AutoDeployConfirmationDialog } from './AutoDeployConfirmationDialog'; +import { CreateReleaseDialog } from './CreateReleaseDialog'; +import { DeployReleasePanel } from './DeployReleasePanel'; import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; import { useAutoDeployUpdate } from '../hooks/useAutoDeployUpdate'; +import { useReleases } from '../hooks/useReleases'; +import { useReleaseReadiness } from '../hooks/useReleaseReadiness'; +import { useEnvironmentsContext } from '../EnvironmentsContext'; +import { useConfigureAndDeployPermission } from '@openchoreo/backstage-plugin-react'; import { useNotification } from '../../../hooks'; +import type { ReleaseDeployments } from './ReleasePicker'; export interface SetupDetailPaneProps { environmentsExist: boolean; @@ -29,9 +40,13 @@ export interface SetupDetailPaneProps { } /** - * Right-pane body shown when the canvas Setup tile is selected. Owns the - * Auto Deploy fetch + update flow and renders the Configure & Deploy - * action so the canvas tile itself can stay passive. + * Right-pane body shown when the canvas Setup tile is selected. + * + * Story 1 ("Create a release"): edit workload (via existing config page) and + * snapshot the current state as a named ComponentRelease. + * + * Story 2 ("Deploy a release"): pick from existing releases and deploy to + * the first environment, with the option to configure per-env overrides. */ export const SetupDetailPane = ({ environmentsExist, @@ -44,47 +59,61 @@ export const SetupDetailPane = ({ const { entity } = useEntity(); const client = useApi(openChoreoClientApiRef); const notification = useNotification(); + const { environments, lowestEnvironment } = useEnvironmentsContext(); const { updateAutoDeploy, isUpdating: autoDeployUpdating } = useAutoDeployUpdate(entity); + const { + canConfigureAndDeploy, + loading: permissionLoading, + deniedTooltip, + } = useConfigureAndDeployPermission(); + const readiness = useReleaseReadiness(entity); + + const { + releases, + loading: releasesLoading, + error: releasesError, + refetch: refetchReleases, + } = useReleases(entity); const [autoDeploy, setAutoDeploy] = useState(undefined); const [autoDeployLoaded, setAutoDeployLoaded] = useState(false); - const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [showAutoDeployConfirm, setShowAutoDeployConfirm] = useState(false); const [pendingAutoDeployValue, setPendingAutoDeployValue] = useState(false); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [selectedReleaseName, setSelectedReleaseName] = useState( + null, + ); + // Fetch auto-deploy from component details (same pattern as before). useEffect(() => { let cancelled = false; setAutoDeployLoaded(false); - - const fetchComponentData = async () => { + const load = async () => { try { const componentData = await client.getComponentDetails(entity); if (!cancelled && componentData?.autoDeploy !== undefined) { setAutoDeploy(componentData.autoDeploy); } } catch { - // Transient fetch failure — leave autoDeploy undefined and let - // the toggle render with the default. Don't block "loaded". + // Leave undefined; toggle renders unchecked. } finally { - if (!cancelled) { - setAutoDeployLoaded(true); - } + if (!cancelled) setAutoDeployLoaded(true); } }; - - fetchComponentData(); + load(); return () => { cancelled = true; }; }, [entity, client]); const handleAutoDeployChange = useCallback( - async (newAutoDeploy: boolean) => { - const success = await updateAutoDeploy(newAutoDeploy); - if (success) { - setAutoDeploy(newAutoDeploy); + async (next: boolean) => { + const ok = await updateAutoDeploy(next); + if (ok) { + setAutoDeploy(next); notification.showSuccess( - `Auto deploy ${newAutoDeploy ? 'enabled' : 'disabled'} successfully`, + `Auto deploy ${next ? 'enabled' : 'disabled'} successfully`, ); } else { notification.showError('Failed to update auto deploy setting'); @@ -94,20 +123,44 @@ export const SetupDetailPane = ({ ); const handleToggleChange = (event: React.ChangeEvent) => { - const newValue = event.target.checked; - setPendingAutoDeployValue(newValue); - setShowConfirmDialog(true); + setPendingAutoDeployValue(event.target.checked); + setShowAutoDeployConfirm(true); }; - const handleConfirm = () => { + const handleConfirmAutoDeploy = () => { handleAutoDeployChange(pendingAutoDeployValue); - setShowConfirmDialog(false); + setShowAutoDeployConfirm(false); }; - const handleCancel = () => { - setShowConfirmDialog(false); + // Build deployments map: releaseName → [envName, ...] + const deployments: ReleaseDeployments = useMemo(() => { + const map: ReleaseDeployments = {}; + for (const env of environments) { + const name = env.deployment?.releaseName; + if (!name) continue; + if (!map[name]) map[name] = []; + map[name].push(env.name); + } + return map; + }, [environments]); + + const handleReleaseCreated = (releaseName: string) => { + setCreateDialogOpen(false); + notification.showSuccess(`Created release ${releaseName}`); + setSelectedReleaseName(releaseName); + refetchReleases(); }; + const createDisabledReason = (() => { + if (!canConfigureAndDeploy) return deniedTooltip; + if (!readiness.canCreateRelease) { + return readiness.alertMessage ?? 'Not ready to create a release.'; + } + return ''; + })(); + const canCreate = + !permissionLoading && canConfigureAndDeploy && readiness.canCreateRelease; + return ( @@ -116,15 +169,13 @@ export const SetupDetailPane = ({ Set up - - - - - + + + @@ -134,7 +185,8 @@ export const SetupDetailPane = ({ ) : ( <> - Manage component configuration and choose how new versions deploy. + Create releases from your component, then deploy them to{' '} + {lowestEnvironment}. @@ -151,7 +203,7 @@ export const SetupDetailPane = ({ label={Auto Deploy} /> @@ -161,19 +213,81 @@ export const SetupDetailPane = ({ - {isWorkloadEditorSupported && ( - - + + + {/* Story 1 — Release */} + + Release + {readiness.alertMessage && ( + + {readiness.alertMessage} + + )} + + + + + + + {isWorkloadEditorSupported && ( + + + + + + )} - )} + + + + + {/* Story 2 — Deploy */} + )} + setCreateDialogOpen(false)} + onCreated={handleReleaseCreated} + existingReleases={releases} + autoDeployEnabled={autoDeploy ?? false} + firstEnvironmentName={lowestEnvironment} + /> + setShowAutoDeployConfirm(false)} + onConfirm={handleConfirmAutoDeploy} isEnabling={pendingAutoDeployValue} isUpdating={autoDeployUpdating} /> From 4034800a70505a0f6b007d922c5cb74649f34f32 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Mon, 11 May 2026 15:20:29 +0530 Subject: [PATCH 04/16] refactor(setup-card): unify Create release into workload flow, simplify Deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per UX feedback: - Create release now starts on the workload config page. The Set up panel's Create release button navigates there; on Save the page prompts for an optional release name and creates the snapshot, then returns to the deploy list. The standalone "Edit workload" button is gone — workload editing is part of the release flow. - Deploy is now a single button that always goes through the existing /overrides/ step. The inline "Deploy now" shortcut and direct updateReleaseBinding call are removed. WorkloadConfigPage owns the CreateReleaseDialog mount; SetupDetailPane no longer renders it. Tests updated accordingly — 226 Environments tests pass. Signed-off-by: Kavith Lokuhewage --- .../Workload/WorkloadConfigPage.tsx | 66 ++++++++-- .../components/DeployReleasePanel.tsx | 113 ++++-------------- .../components/SetupDetailPane.test.tsx | 52 ++------ .../components/SetupDetailPane.tsx | 56 ++------- .../wrappers/WorkloadConfigWrapper.tsx | 7 +- 5 files changed, 103 insertions(+), 191 deletions(-) diff --git a/plugins/openchoreo/src/components/Environments/Workload/WorkloadConfigPage.tsx b/plugins/openchoreo/src/components/Environments/Workload/WorkloadConfigPage.tsx index 122c7ce08..9ecbf5697 100644 --- a/plugins/openchoreo/src/components/Environments/Workload/WorkloadConfigPage.tsx +++ b/plugins/openchoreo/src/components/Environments/Workload/WorkloadConfigPage.tsx @@ -35,6 +35,10 @@ import { useWorkloadChanges } from './hooks/useWorkloadChanges'; import { WorkloadSaveConfirmationDialog } from './WorkloadSaveConfirmationDialog'; import { usePendingChanges } from '../../Traits/hooks/usePendingChanges'; import { deepCompareObjects } from '@openchoreo/backstage-plugin-react'; +import { CreateReleaseDialog } from '../components/CreateReleaseDialog'; +import { useReleases } from '../hooks/useReleases'; +import { useEnvironmentsContext } from '../EnvironmentsContext'; +import { useNotification } from '../../../hooks'; /** Stable empty array to avoid unnecessary rerenders in WorkloadProvider */ const EMPTY_BUILDS: never[] = []; @@ -61,8 +65,8 @@ const useStyles = makeStyles(theme => ({ interface WorkloadConfigPageProps { onBack: () => void; - /** Called after workload + traits/parameters are saved successfully */ - onSaved: () => void; + /** Called after the release is successfully created. */ + onReleaseCreated: () => void; /** * Data plane of the lowest environment, used by the workload editor for * endpoint previews. @@ -76,7 +80,7 @@ interface WorkloadConfigPageProps { export const WorkloadConfigPage = ({ onBack, - onSaved, + onReleaseCreated, lowestEnvDataPlane, initialTab, onTabChange, @@ -85,6 +89,30 @@ export const WorkloadConfigPage = ({ const client = useApi(openChoreoClientApiRef); const catalogApi = useApi(catalogApiRef); const { entity } = useEntity(); + const { lowestEnvironment } = useEnvironmentsContext(); + const { releases, refetch: refetchReleases } = useReleases(entity); + const notification = useNotification(); + const [showCreateReleaseDialog, setShowCreateReleaseDialog] = useState(false); + const [autoDeployEnabled, setAutoDeployEnabled] = useState(false); + + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const componentData = await client.getComponentDetails(entity); + if (!cancelled && componentData?.autoDeploy !== undefined) { + setAutoDeployEnabled(componentData.autoDeploy); + } + } catch { + // Leave autoDeployEnabled at its default (false) — the dialog will + // simply skip its auto-deploy notice if we can't determine it. + } + }; + load(); + return () => { + cancelled = true; + }; + }, [entity, client]); // Single source of truth: the full K8s workload resource const [workloadResource, setWorkloadResource] = @@ -464,13 +492,20 @@ export const WorkloadConfigPage = ({ setIsProcessing(false); allowNavigationRef.current = true; - onSaved(); + setShowCreateReleaseDialog(true); } catch (e: unknown) { setIsProcessing(false); setSaveError(getErrorMessage(e)); } }; + const handleReleaseCreated = (releaseName: string) => { + setShowCreateReleaseDialog(false); + notification.showSuccess(`Created release ${releaseName}`); + refetchReleases(); + onReleaseCreated(); + }; + const enableNext = isFromSource ? hasImage && !isLoading : !isLoading && !!spec?.container?.image?.trim(); @@ -479,14 +514,16 @@ export const WorkloadConfigPage = ({ if (isFromSource && !hasImage) { return 'Build your application first to generate a container image.'; } - return 'Configure your workload, then return to the Set up panel to create a release.'; + return 'Configure your workload, then save to create a release snapshot.'; }; const handleButtonClick = () => { if (hasAnyChanges) { setShowConfirmDialog(true); } else { - onBack(); + // No changes — workload is already up-to-date; jump straight to + // naming and creating the release snapshot. + setShowCreateReleaseDialog(true); } }; @@ -505,8 +542,8 @@ export const WorkloadConfigPage = ({ const getButtonText = () => { if (isProcessing) return 'Saving...'; - if (hasAnyChanges) return 'Save workload'; - return 'Done'; + if (hasAnyChanges) return 'Save & continue'; + return 'Continue'; }; const totalChanges = @@ -554,8 +591,8 @@ export const WorkloadConfigPage = ({ return ( @@ -671,6 +708,15 @@ export const WorkloadConfigPage = ({ }} changeCount={totalChanges} /> + + setShowCreateReleaseDialog(false)} + onCreated={handleReleaseCreated} + existingReleases={releases} + autoDeployEnabled={autoDeployEnabled} + firstEnvironmentName={lowestEnvironment} + /> ); }; diff --git a/plugins/openchoreo/src/components/Environments/components/DeployReleasePanel.tsx b/plugins/openchoreo/src/components/Environments/components/DeployReleasePanel.tsx index 76db70b0e..46e48cd5a 100644 --- a/plugins/openchoreo/src/components/Environments/components/DeployReleasePanel.tsx +++ b/plugins/openchoreo/src/components/Environments/components/DeployReleasePanel.tsx @@ -1,21 +1,8 @@ -import { useEffect, useMemo, useState } from 'react'; -import { - Box, - Button, - CircularProgress, - Tooltip, - Typography, -} from '@material-ui/core'; +import { useEffect } from 'react'; +import { Box, Button, Tooltip, Typography } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import { Alert } from '@material-ui/lab'; -import TuneOutlinedIcon from '@material-ui/icons/TuneOutlined'; -import { useApi } from '@backstage/core-plugin-api'; -import { useEntity } from '@backstage/plugin-catalog-react'; import type { ComponentRelease } from '@openchoreo/backstage-plugin-common'; -import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; -import { useNotification } from '../../../hooks'; -import { getErrorMessage } from '../../../utils/errorUtils'; -import { useEnvironmentsContext } from '../EnvironmentsContext'; import { useEnvironmentRouting } from '../hooks/useEnvironmentRouting'; import { ReleasePicker, type ReleaseDeployments } from './ReleasePicker'; @@ -43,8 +30,6 @@ export interface DeployReleasePanelProps { onSelectedReleaseChange: (releaseName: string | null) => void; /** Display name of the first env (e.g. "development"). */ firstEnvironmentName: string; - /** Refetch releases + bindings after a successful deploy. */ - onDeployed: () => void; disabled?: boolean; disabledReason?: string; } @@ -52,11 +37,10 @@ export interface DeployReleasePanelProps { /** * Story 2: pick an existing release and deploy it to the first environment. * - * Deploy now → calls updateReleaseBinding directly with no override payload - * (release defaults apply). - * Configure overrides → deep-links to the existing /overrides/ page with - * a 'deploy' pendingAction. That page already saves overrides - * and triggers updateReleaseBinding on save. + * The Deploy button always navigates to the existing /overrides/ page + * with a 'deploy' pendingAction; that page reviews overrides and triggers + * updateReleaseBinding on save. There is no "deploy without reviewing + * overrides" shortcut — going through overrides is the canonical path. */ export const DeployReleasePanel = ({ releases, @@ -66,23 +50,15 @@ export const DeployReleasePanel = ({ selectedReleaseName, onSelectedReleaseChange, firstEnvironmentName, - onDeployed, disabled, disabledReason, }: DeployReleasePanelProps) => { const classes = useStyles(); - const client = useApi(openChoreoClientApiRef); - const { entity } = useEntity(); - const notification = useNotification(); - const { refetch } = useEnvironmentsContext(); const { navigateToOverrides } = useEnvironmentRouting(); - const [deploying, setDeploying] = useState(false); - const [deployError, setDeployError] = useState(null); - - // Preselect the most recent release when one is available and nothing is - // selected yet. Only run when the list of names actually changes, so the - // user's explicit selection survives refetches. + // Preselect the most recent release when nothing is selected yet. Only + // react to the newest name changing so the user's explicit selection + // survives refetches. const newestName = releases[0]?.metadata?.name ?? null; useEffect(() => { if (!selectedReleaseName && newestName) { @@ -90,7 +66,7 @@ export const DeployReleasePanel = ({ } }, [newestName, selectedReleaseName, onSelectedReleaseChange]); - const handleConfigureOverrides = () => { + const handleDeploy = () => { if (!selectedReleaseName) return; navigateToOverrides(firstEnvironmentName, { type: 'deploy', @@ -99,33 +75,14 @@ export const DeployReleasePanel = ({ }); }; - const handleDeployNow = async () => { - if (!selectedReleaseName) return; - setDeploying(true); - setDeployError(null); - try { - await client.updateReleaseBinding( - entity, - firstEnvironmentName, - selectedReleaseName, - ); - notification.showSuccess( - `Deployed ${selectedReleaseName} to ${firstEnvironmentName}`, - ); - refetch(); - onDeployed(); - } catch (e: unknown) { - setDeployError(getErrorMessage(e)); - } finally { - setDeploying(false); - } - }; - const noReleases = !releasesLoading && releases.length === 0; - const deployDisabled = useMemo( - () => disabled || deploying || !selectedReleaseName || noReleases, - [disabled, deploying, selectedReleaseName, noReleases], - ); + const deployDisabled = disabled || !selectedReleaseName || noReleases; + + const getTooltip = () => { + if (deployDisabled && disabledReason) return disabledReason; + if (!selectedReleaseName) return 'Pick a release first'; + return ''; + }; return ( @@ -151,47 +108,17 @@ export const DeployReleasePanel = ({ disabled={disabled || noReleases} /> - {deployError && ( - setDeployError(null)}> - {deployError} - - )} - - - - - - - + diff --git a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx index db0a31b56..a6cbcf9ae 100644 --- a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx +++ b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx @@ -18,24 +18,6 @@ jest.mock('./LoadingSkeleton', () => ({ ), })); -// Heavy child components — replace with thin doubles so we can assert on -// the SetupDetailPane wiring without dragging in their dependencies. -jest.mock('./CreateReleaseDialog', () => ({ - CreateReleaseDialog: ({ open, onCreated, autoDeployEnabled }: any) => - open ? ( -
- {String(autoDeployEnabled)} - -
- ) : null, -})); - jest.mock('./DeployReleasePanel', () => ({ DeployReleasePanel: ({ disabled }: any) => (
{ await screen.findByRole('button', { name: /create release/i }), ).toBeEnabled(); expect(screen.getByTestId('deploy-release-panel')).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: /edit workload/i }), - ).toBeInTheDocument(); + }); + + it('Create release navigates to the workload page (onConfigureWorkload)', async () => { + const onConfigureWorkload = jest.fn(); + const user = userEvent.setup(); + renderPane({ onConfigureWorkload }); + + await user.click( + await screen.findByRole('button', { name: /create release/i }), + ); + + expect(onConfigureWorkload).toHaveBeenCalledTimes(1); }); it('disables Create release when readiness blocks it and surfaces the reason', async () => { @@ -188,22 +179,6 @@ describe('SetupDetailPane', () => { ).toBeInTheDocument(); }); - it('passes the auto-deploy flag into the create-release dialog', async () => { - mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: true }); - const user = userEvent.setup(); - renderPane(); - - await waitFor(() => { - expect( - screen.getByRole('checkbox', { name: /auto deploy/i }), - ).toBeChecked(); - }); - - await user.click(screen.getByRole('button', { name: /create release/i })); - - expect(screen.getByTestId('auto-deploy-flag')).toHaveTextContent('true'); - }); - it('confirms auto-deploy changes through the confirmation dialog', async () => { const user = userEvent.setup(); mockUpdateAutoDeploy.mockResolvedValue(true); @@ -240,9 +215,6 @@ describe('SetupDetailPane', () => { expect( await screen.findByRole('button', { name: /create release/i }), ).toBeDisabled(); - expect( - screen.getByRole('button', { name: /edit workload/i }), - ).toBeDisabled(); expect(screen.getByTestId('deploy-release-panel')).toHaveAttribute( 'data-disabled', 'true', diff --git a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx index 00c6bdae1..1717bf403 100644 --- a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx +++ b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx @@ -12,7 +12,6 @@ import { import { Alert } from '@material-ui/lab'; import AddIcon from '@material-ui/icons/Add'; import CloseIcon from '@material-ui/icons/Close'; -import EditOutlinedIcon from '@material-ui/icons/EditOutlined'; import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; import SettingsOutlinedIcon from '@material-ui/icons/SettingsOutlined'; import { useApi } from '@backstage/core-plugin-api'; @@ -20,7 +19,6 @@ import { useEntity } from '@backstage/plugin-catalog-react'; import { useEnvironmentDetailPanelStyles } from '../styles'; import { LoadingSkeleton } from './LoadingSkeleton'; import { AutoDeployConfirmationDialog } from './AutoDeployConfirmationDialog'; -import { CreateReleaseDialog } from './CreateReleaseDialog'; import { DeployReleasePanel } from './DeployReleasePanel'; import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; import { useAutoDeployUpdate } from '../hooks/useAutoDeployUpdate'; @@ -73,14 +71,12 @@ export const SetupDetailPane = ({ releases, loading: releasesLoading, error: releasesError, - refetch: refetchReleases, } = useReleases(entity); const [autoDeploy, setAutoDeploy] = useState(undefined); const [autoDeployLoaded, setAutoDeployLoaded] = useState(false); const [showAutoDeployConfirm, setShowAutoDeployConfirm] = useState(false); const [pendingAutoDeployValue, setPendingAutoDeployValue] = useState(false); - const [createDialogOpen, setCreateDialogOpen] = useState(false); const [selectedReleaseName, setSelectedReleaseName] = useState( null, ); @@ -144,13 +140,6 @@ export const SetupDetailPane = ({ return map; }, [environments]); - const handleReleaseCreated = (releaseName: string) => { - setCreateDialogOpen(false); - notification.showSuccess(`Created release ${releaseName}`); - setSelectedReleaseName(releaseName); - refetchReleases(); - }; - const createDisabledReason = (() => { if (!canConfigureAndDeploy) return deniedTooltip; if (!readiness.canCreateRelease) { @@ -215,7 +204,7 @@ export const SetupDetailPane = ({ - {/* Story 1 — Release */} + {/* Story 1 — Create release (routes to workload page) */} Release {readiness.alertMessage && ( @@ -223,37 +212,24 @@ export const SetupDetailPane = ({ {readiness.alertMessage} )} - - - - - - - {isWorkloadEditorSupported && ( - + {isWorkloadEditorSupported && ( + + - )} - + + )} @@ -267,7 +243,6 @@ export const SetupDetailPane = ({ selectedReleaseName={selectedReleaseName} onSelectedReleaseChange={setSelectedReleaseName} firstEnvironmentName={lowestEnvironment} - onDeployed={refetchReleases} disabled={permissionLoading || !canConfigureAndDeploy} disabledReason={deniedTooltip} /> @@ -275,15 +250,6 @@ export const SetupDetailPane = ({ )} - setCreateDialogOpen(false)} - onCreated={handleReleaseCreated} - existingReleases={releases} - autoDeployEnabled={autoDeploy ?? false} - firstEnvironmentName={lowestEnvironment} - /> - setShowAutoDeployConfirm(false)} diff --git a/plugins/openchoreo/src/components/Environments/wrappers/WorkloadConfigWrapper.tsx b/plugins/openchoreo/src/components/Environments/wrappers/WorkloadConfigWrapper.tsx index 300d1d5ad..6c9f8de57 100644 --- a/plugins/openchoreo/src/components/Environments/wrappers/WorkloadConfigWrapper.tsx +++ b/plugins/openchoreo/src/components/Environments/wrappers/WorkloadConfigWrapper.tsx @@ -7,8 +7,9 @@ import { WorkloadConfigPage } from '../Workload/WorkloadConfigPage'; /** * Wrapper component for WorkloadConfigPage that handles URL-based navigation. * - * Saving workload + traits/parameters returns the user to the deploy list - * view; release creation is owned by the Set up panel there. + * The page hosts the full Create release flow: review workload + traits + + * parameters, save, then snapshot as a named release. On success, we + * return the user to the deploy list view. */ export const WorkloadConfigWrapper = () => { const [searchParams] = useSearchParams(); @@ -36,7 +37,7 @@ export const WorkloadConfigWrapper = () => { return ( Date: Mon, 11 May 2026 15:47:50 +0530 Subject: [PATCH 05/16] feat(overrides): show View diff on deploy flows, not just promote Signed-off-by: Kavith Lokuhewage --- .../Environments/EnvironmentOverridesPage.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/plugins/openchoreo/src/components/Environments/EnvironmentOverridesPage.tsx b/plugins/openchoreo/src/components/Environments/EnvironmentOverridesPage.tsx index 6aed3c97e..e28ab6784 100644 --- a/plugins/openchoreo/src/components/Environments/EnvironmentOverridesPage.tsx +++ b/plugins/openchoreo/src/components/Environments/EnvironmentOverridesPage.tsx @@ -698,8 +698,14 @@ export const EnvironmentOverridesPage = ({ Delete All )} - {pendingAction?.type === 'promote' && pendingAction.releaseName && ( - + {pendingAction?.releaseName && environment.deployment.releaseName && ( + + + + + {yamlLoading && !currentManifest && ( + + + + )} + {currentManifest?.error && ( + + {currentManifest.error} + + )} + {currentManifest?.yaml && ( + + )} + + ) : ( + + + Pick a release on the left to see details. + + + )} + + + )} + + + + + + + + ); +}; diff --git a/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx b/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx index 60288b18a..a8967192b 100644 --- a/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx +++ b/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx @@ -1,46 +1,57 @@ import { useMemo, useState } from 'react'; -import { - Box, - Chip, - IconButton, - TextField, - Tooltip, - Typography, -} from '@material-ui/core'; +import { Box, Button, Chip, Typography } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; -import { Autocomplete } from '@material-ui/lab'; -import VisibilityOutlinedIcon from '@material-ui/icons/VisibilityOutlined'; +import { Skeleton } from '@material-ui/lab'; import type { ComponentRelease } from '@openchoreo/backstage-plugin-common'; -import { ReleaseManifestDialog } from './ReleaseManifestDialog'; +import { ReleaseBrowserDialog } from './ReleaseBrowserDialog'; const useStyles = makeStyles(theme => ({ - optionRow: { + wrapper: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), + }, + label: { + color: theme.palette.text.secondary, + fontWeight: 600, + fontSize: 11, + letterSpacing: '0.06em', + textTransform: 'uppercase', + marginBottom: theme.spacing(0.5), + }, + summaryRow: { display: 'flex', alignItems: 'center', - gap: theme.spacing(1), - width: '100%', + gap: theme.spacing(1.5), }, - optionMain: { + summary: { flexGrow: 1, minWidth: 0, + display: 'flex', + flexDirection: 'column', }, - optionName: { + name: { fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }, - optionMeta: { + meta: { color: theme.palette.text.secondary, fontSize: 12, display: 'flex', flexWrap: 'wrap', - gap: theme.spacing(1), + alignItems: 'center', + gap: theme.spacing(0.75), }, - currentChip: { + chip: { height: 20, fontSize: 11, }, + empty: { + color: theme.palette.text.secondary, + fontStyle: 'italic', + }, })); /** Map from releaseName → list of environment names where it is currently bound. */ @@ -52,7 +63,7 @@ export interface ReleasePickerProps { onChange: (releaseName: string | null) => void; /** Environments where each release is currently deployed. Used for badges. */ deployments?: ReleaseDeployments; - /** Env name passed to the YAML preview dialog. */ + /** Env name passed to the browser dialog for context. */ environmentName: string; disabled?: boolean; loading?: boolean; @@ -74,7 +85,6 @@ const formatRelativeTime = (iso?: string): string => { return new Date(iso).toLocaleDateString(); }; -/** Pull `spec.workload.spec.container.image` (or any reasonable fallback) for display. */ const extractImage = (release: ComponentRelease): string | undefined => { const workload = release.spec?.workload as | { spec?: { container?: { image?: string } } } @@ -95,51 +105,42 @@ export const ReleasePicker = ({ environmentName, disabled, loading, - label = 'Release', + label = 'Selected release', }: ReleasePickerProps) => { const classes = useStyles(); - const [yamlReleaseName, setYamlReleaseName] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); const selected = useMemo( () => releases.find(r => r.metadata?.name === selectedReleaseName) ?? null, [releases, selectedReleaseName], ); + const noReleases = !loading && releases.length === 0; + const created = selected + ? formatRelativeTime(selected.metadata?.creationTimestamp) + : ''; + const image = selected ? extractImage(selected) : undefined; + const deployedIn = selected + ? deployments[selected.metadata?.name ?? ''] ?? [] + : []; + return ( - <> - onChange(next?.metadata?.name ?? null)} - getOptionLabel={r => r.metadata?.name ?? ''} - getOptionSelected={(opt, val) => - opt.metadata?.name === val.metadata?.name - } - disabled={disabled} - loading={loading} - renderInput={params => ( - - )} - renderOption={release => { - const name = release.metadata?.name ?? '(unnamed)'; - const created = formatRelativeTime( - release.metadata?.creationTimestamp, - ); - const image = extractImage(release); - const deployedIn = deployments[name] ?? []; - return ( - - - - {name} + + + {label} + + + {loading ? ( + + ) : ( + + + {selected ? ( + <> + + {selected.metadata?.name} - + {created && {created}} {image && img: {shortenImage(image)}} {deployedIn.map(env => ( @@ -148,38 +149,38 @@ export const ReleasePicker = ({ label={`current in ${env}`} size="small" color="primary" - className={classes.currentChip} + className={classes.chip} /> ))} - - - { - e.stopPropagation(); - e.preventDefault(); - }} - onClick={e => { - e.stopPropagation(); - setYamlReleaseName(name); - }} - aria-label="View release YAML" - > - - - - - ); - }} - /> + + ) : ( + + {noReleases ? 'No releases yet' : 'No release selected'} + + )} + + + + )} - setYamlReleaseName(null)} - releaseName={yamlReleaseName ?? undefined} + setDialogOpen(false)} + releases={releases} + deployments={deployments} + selectedReleaseName={selectedReleaseName} + onConfirm={name => onChange(name)} environmentName={environmentName} + loading={loading} /> - + ); }; diff --git a/plugins/openchoreo/src/components/Environments/wrappers/OverridesWrapper.tsx b/plugins/openchoreo/src/components/Environments/wrappers/OverridesWrapper.tsx index 1da33814c..76186a1dc 100644 --- a/plugins/openchoreo/src/components/Environments/wrappers/OverridesWrapper.tsx +++ b/plugins/openchoreo/src/components/Environments/wrappers/OverridesWrapper.tsx @@ -19,7 +19,7 @@ export const OverridesWrapper = () => { const { entity } = useEntity(); const { environments, refetch, onPendingActionComplete } = useEnvironmentsContext(); - const { navigateToList, navigateToWorkloadConfig } = useEnvironmentRouting(); + const { navigateToList } = useEnvironmentRouting(); // Parse pending action from URL const pendingAction = useMemo( @@ -81,10 +81,10 @@ export const OverridesWrapper = () => { refetch(); }; - const handlePrevious = - pendingAction?.type === 'deploy' - ? () => navigateToWorkloadConfig() - : undefined; + // Deploy and promote flows have no "previous step" in the Setup-card-driven + // UX — both are launched from the deploy list. Leaving onPrevious undefined + // hides the Previous button; users use Back/Cancel to return to the list. + const handlePrevious = undefined; // Error state: environment not found if (!environment) { From 922680ceb9cbeb3eefffa0c458f40c548cc98978 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Tue, 12 May 2026 14:41:10 +0530 Subject: [PATCH 07/16] feat(openchoreo): flag overrides not in the release's workload - New `extra` status alongside `new`: keys not in base are `extra` if already on the binding, `new` if added in this session - Unified "Not in workload" section with help-icon tooltip; NEW chip on session-added rows - GroupedSection gains optional `titleTooltip` prop Signed-off-by: Kavith Lokuhewage --- .../EnvVarStatusBadge/EnvVarStatusBadge.tsx | 11 +++- .../FileVarStatusBadge/FileVarStatusBadge.tsx | 11 +++- .../GroupedSection/GroupedSection.tsx | 25 ++++++++- .../OverrideEnvVarList/OverrideEnvVarList.tsx | 51 +++++++++++++++---- .../OverrideFileVarList.tsx | 51 +++++++++++++++---- .../src/utils/envVarUtils.test.ts | 35 ++++++++++++- .../openchoreo-react/src/utils/envVarUtils.ts | 28 +++++++--- .../src/utils/fileVarUtils.test.ts | 26 +++++++++- .../src/utils/fileVarUtils.ts | 25 +++++---- .../src/utils/overrideGroupUtils.test.ts | 46 +++++++++++++---- .../src/utils/overrideGroupUtils.ts | 19 +++++-- .../Environments/EnvironmentOverridesPage.tsx | 5 ++ .../WorkloadEditor/ContainerContent.tsx | 14 +++++ 13 files changed, 293 insertions(+), 54 deletions(-) diff --git a/plugins/openchoreo-react/src/components/EnvVarStatusBadge/EnvVarStatusBadge.tsx b/plugins/openchoreo-react/src/components/EnvVarStatusBadge/EnvVarStatusBadge.tsx index 3291f791a..7e844937d 100644 --- a/plugins/openchoreo-react/src/components/EnvVarStatusBadge/EnvVarStatusBadge.tsx +++ b/plugins/openchoreo-react/src/components/EnvVarStatusBadge/EnvVarStatusBadge.tsx @@ -30,6 +30,10 @@ const useStyles = makeStyles((theme: Theme) => ({ backgroundColor: theme.palette.success.light, color: theme.palette.success.contrastText, }, + extra: { + backgroundColor: theme.palette.warning.light, + color: theme.palette.warning.contrastText, + }, })); const statusConfig: Record< @@ -37,7 +41,7 @@ const statusConfig: Record< { label: string; tooltip: string; - className: 'inherited' | 'overridden' | 'new'; + className: 'inherited' | 'overridden' | 'new' | 'extra'; } > = { inherited: { @@ -55,6 +59,11 @@ const statusConfig: Record< tooltip: 'New environment variable', className: 'new', }, + extra: { + label: 'Extra', + tooltip: 'Not in current workload', + className: 'extra', + }, }; /** diff --git a/plugins/openchoreo-react/src/components/FileVarStatusBadge/FileVarStatusBadge.tsx b/plugins/openchoreo-react/src/components/FileVarStatusBadge/FileVarStatusBadge.tsx index 9fd884ed5..47717d550 100644 --- a/plugins/openchoreo-react/src/components/FileVarStatusBadge/FileVarStatusBadge.tsx +++ b/plugins/openchoreo-react/src/components/FileVarStatusBadge/FileVarStatusBadge.tsx @@ -33,6 +33,10 @@ const useStyles = makeStyles((theme: Theme) => ({ backgroundColor: theme.palette.success.light, color: theme.palette.success.contrastText, }, + extra: { + backgroundColor: theme.palette.warning.light, + color: theme.palette.warning.contrastText, + }, })); const statusConfig: Record< @@ -40,7 +44,7 @@ const statusConfig: Record< { label: string; tooltip: string; - className: 'inherited' | 'overridden' | 'new'; + className: 'inherited' | 'overridden' | 'new' | 'extra'; } > = { inherited: { @@ -58,6 +62,11 @@ const statusConfig: Record< tooltip: 'New file mount', className: 'new', }, + extra: { + label: 'Extra', + tooltip: 'Not in current workload', + className: 'extra', + }, }; /** diff --git a/plugins/openchoreo-react/src/components/GroupedSection/GroupedSection.tsx b/plugins/openchoreo-react/src/components/GroupedSection/GroupedSection.tsx index 612071935..7a9001cb2 100644 --- a/plugins/openchoreo-react/src/components/GroupedSection/GroupedSection.tsx +++ b/plugins/openchoreo-react/src/components/GroupedSection/GroupedSection.tsx @@ -1,5 +1,13 @@ import { useState, type FC, type ReactNode } from 'react'; -import { Box, Typography, IconButton, Collapse, Chip } from '@material-ui/core'; +import { + Box, + Typography, + IconButton, + Collapse, + Chip, + Tooltip, +} from '@material-ui/core'; +import HelpOutlineIcon from '@material-ui/icons/HelpOutline'; import { makeStyles, Theme } from '@material-ui/core/styles'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import ExpandLessIcon from '@material-ui/icons/ExpandLess'; @@ -9,6 +17,8 @@ export type GroupedSectionStatus = 'overridden' | 'new' | 'inherited'; export interface GroupedSectionProps { /** Section title (falls back to default based on status if not provided) */ title?: string; + /** Optional tooltip explaining the section; renders a help icon next to the title. */ + titleTooltip?: string; /** Number of items in section */ count: number; /** Status type for color accent */ @@ -77,6 +87,10 @@ const useStyles = makeStyles((theme: Theme) => ({ fontSize: '1rem', color: theme.palette.text.disabled, }, + helpIcon: { + fontSize: '0.95rem', + color: theme.palette.text.secondary, + }, content: { paddingLeft: theme.spacing(1.5), paddingTop: theme.spacing(1), @@ -95,6 +109,7 @@ const statusTitles: Record = { */ export const GroupedSection: FC = ({ title, + titleTooltip, count, status, defaultExpanded = true, @@ -136,6 +151,14 @@ export const GroupedSection: FC = ({ {displayTitle} + {titleTooltip && ( + + e.stopPropagation()} + /> + + )} ({ borderRadius: 6, border: `1px dashed ${theme.palette.grey[300]}`, }, + rowHeader: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + marginBottom: theme.spacing(0.5), + }, + newChip: { + height: 18, + fontSize: 10, + letterSpacing: '0.04em', + }, addButton: { marginTop: theme.spacing(1), }, @@ -36,7 +47,13 @@ export interface OverrideEnvVarListProps { envVars: EnvVar[]; /** Base workload environment variables (for comparison) */ baseEnvVars: EnvVar[]; - /** Environment name for display in section titles */ + /** + * Override env vars as initially loaded from the binding. Used to + * distinguish entries that were already persisted (`extra`) from + * ones the user just added in this session (`new`). + */ + initialEnvVars?: EnvVar[]; + /** Environment name for display in section titles (currently unused). */ environmentName?: string; /** Available secrets for reference selection */ secretOptions: SecretOption[]; @@ -76,7 +93,8 @@ export const OverrideEnvVarList: FC = ({ containerName, envVars, baseEnvVars, - environmentName, + initialEnvVars, + // environmentName intentionally unused — section title is no longer per-env. secretOptions, envModes, disabled, @@ -91,8 +109,8 @@ export const OverrideEnvVarList: FC = ({ // Merge base and override env vars with status metadata const mergedEnvVars = useMemo( - () => mergeEnvVarsWithStatus(baseEnvVars, envVars), - [baseEnvVars, envVars], + () => mergeEnvVarsWithStatus(baseEnvVars, envVars, initialEnvVars), + [baseEnvVars, envVars, initialEnvVars], ); // Group items by status @@ -162,6 +180,16 @@ export const OverrideEnvVarList: FC = ({ key={`${item.status}-${item.envVar.key}-${displayIndex}`} className={classes.envVarRowWrapper} > + {item.status === 'new' && ( + + + + )} = ({ return ( - {/* Environment-specific section */} - {grouped.new.length > 0 && ( + {/* Not in current workload — overrides whose key isn't in the bound release's workload. + Includes both stale entries already on the binding ('extra') and ones the user + just added in this form session ('new'). 'new' rows show a NEW chip. */} + {grouped.extra.length + grouped.new.length > 0 && ( - {grouped.new.map((item, index) => + {[...grouped.extra, ...grouped.new].map((item, index) => renderEditableRow(item as EnvVarWithStatus, index), )} diff --git a/plugins/openchoreo-react/src/components/OverrideFileVarList/OverrideFileVarList.tsx b/plugins/openchoreo-react/src/components/OverrideFileVarList/OverrideFileVarList.tsx index 1b2e00c8d..18980893f 100644 --- a/plugins/openchoreo-react/src/components/OverrideFileVarList/OverrideFileVarList.tsx +++ b/plugins/openchoreo-react/src/components/OverrideFileVarList/OverrideFileVarList.tsx @@ -1,5 +1,5 @@ import { useMemo, type FC } from 'react'; -import { Box, Button } from '@material-ui/core'; +import { Box, Button, Chip } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import AddIcon from '@material-ui/icons/Add'; import type { FileVar } from '@openchoreo/backstage-plugin-common'; @@ -24,6 +24,17 @@ const useStyles = makeStyles(theme => ({ borderRadius: 6, border: `1px dashed ${theme.palette.grey[300]}`, }, + rowHeader: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + marginBottom: theme.spacing(0.5), + }, + newChip: { + height: 18, + fontSize: 10, + letterSpacing: '0.04em', + }, addButton: { marginTop: theme.spacing(1), }, @@ -36,7 +47,13 @@ export interface OverrideFileVarListProps { fileVars: FileVar[]; /** Base workload file mounts (for comparison) */ baseFileVars: FileVar[]; - /** Environment name for display in section titles */ + /** + * Override file mounts as initially loaded from the binding. Used to + * distinguish entries that were already persisted (`extra`) from + * ones the user just added in this session (`new`). + */ + initialFileVars?: FileVar[]; + /** Environment name for display in section titles (currently unused). */ environmentName?: string; /** Available secrets for reference selection */ secretOptions: SecretOption[]; @@ -76,7 +93,8 @@ export const OverrideFileVarList: FC = ({ containerName, fileVars, baseFileVars, - environmentName, + initialFileVars, + // environmentName intentionally unused — section title is no longer per-env. secretOptions, fileModes, disabled, @@ -91,8 +109,8 @@ export const OverrideFileVarList: FC = ({ // Merge base and override file vars with status metadata const mergedFileVars = useMemo( - () => mergeFileVarsWithStatus(baseFileVars, fileVars), - [baseFileVars, fileVars], + () => mergeFileVarsWithStatus(baseFileVars, fileVars, initialFileVars), + [baseFileVars, fileVars, initialFileVars], ); // Group items by status @@ -165,6 +183,16 @@ export const OverrideFileVarList: FC = ({ key={`${item.status}-${item.fileVar.key}-${displayIndex}`} className={classes.fileVarRowWrapper} > + {item.status === 'new' && ( + + + + )} = ({ return ( - {/* Environment-specific section */} - {grouped.new.length > 0 && ( + {/* Not in current workload — overrides whose key isn't in the bound release's workload. + Includes both stale entries already on the binding ('extra') and ones the user + just added in this form session ('new'). 'new' rows show a NEW chip. */} + {grouped.extra.length + grouped.new.length > 0 && ( - {grouped.new.map((item, index) => + {[...grouped.extra, ...grouped.new].map((item, index) => renderEditableRow(item as FileVarWithStatus, index), )} diff --git a/plugins/openchoreo-react/src/utils/envVarUtils.test.ts b/plugins/openchoreo-react/src/utils/envVarUtils.test.ts index 2ba863418..8e2c7de27 100644 --- a/plugins/openchoreo-react/src/utils/envVarUtils.test.ts +++ b/plugins/openchoreo-react/src/utils/envVarUtils.test.ts @@ -30,7 +30,7 @@ describe('mergeEnvVarsWithStatus', () => { expect(result[0].actualIndex).toBe(0); }); - it('marks new override vars', () => { + it('marks not-in-base vars as new when no initial snapshot is provided (legacy)', () => { const base: EnvVar[] = []; const override: EnvVar[] = [{ key: 'NEW_VAR', value: 'hello' }]; const result = mergeEnvVarsWithStatus(base, override); @@ -41,6 +41,39 @@ describe('mergeEnvVarsWithStatus', () => { expect(result[0].actualIndex).toBe(0); }); + it('marks vars present in initial-but-not-in-base as extra', () => { + const base: EnvVar[] = []; + const initial: EnvVar[] = [{ key: 'STALE', value: 'old' }]; + const override: EnvVar[] = [{ key: 'STALE', value: 'old' }]; + const result = mergeEnvVarsWithStatus(base, override, initial); + + expect(result).toHaveLength(1); + expect(result[0].status).toBe('extra'); + expect(result[0].envVar.key).toBe('STALE'); + }); + + it('marks vars absent from initial as new when initial is provided', () => { + const base: EnvVar[] = []; + const initial: EnvVar[] = []; + const override: EnvVar[] = [{ key: 'JUST_ADDED', value: '1' }]; + const result = mergeEnvVarsWithStatus(base, override, initial); + + expect(result).toHaveLength(1); + expect(result[0].status).toBe('new'); + }); + + it('mixes extra and new in the same merge when initial is partial', () => { + const base: EnvVar[] = []; + const initial: EnvVar[] = [{ key: 'STALE', value: 'old' }]; + const override: EnvVar[] = [ + { key: 'STALE', value: 'old' }, + { key: 'JUST_ADDED', value: '1' }, + ]; + const result = mergeEnvVarsWithStatus(base, override, initial); + + expect(result.map(r => r.status)).toEqual(['extra', 'new']); + }); + it('handles mixed inherited, overridden, and new', () => { const base: EnvVar[] = [ { key: 'KEEP', value: 'a' }, diff --git a/plugins/openchoreo-react/src/utils/envVarUtils.ts b/plugins/openchoreo-react/src/utils/envVarUtils.ts index 573d699ac..4269fdc08 100644 --- a/plugins/openchoreo-react/src/utils/envVarUtils.ts +++ b/plugins/openchoreo-react/src/utils/envVarUtils.ts @@ -7,9 +7,11 @@ import type { * Status of an environment variable in the override context. * - 'inherited': From base workload, not overridden * - 'overridden': Has a base value that is being overridden - * - 'new': New env var added in override, not in base workload + * - 'extra': In the persisted overrides but not in the base workload (e.g. a + * stale entry left over after the bound release changed) + * - 'new': Added by the user in the current form session and not in base */ -export type EnvVarStatus = 'inherited' | 'overridden' | 'new'; +export type EnvVarStatus = 'inherited' | 'overridden' | 'extra' | 'new'; /** * Environment variable with its override status metadata. @@ -27,18 +29,28 @@ export interface EnvVarWithStatus { /** * Merges base workload env vars with override env vars into a unified list. - * Returns status for each: inherited, overridden, or new. + * Returns status for each: inherited, overridden, extra, or new. + * + * When `initialOverrideEnvVars` is provided, an override key not in `base` is + * tagged `'extra'` if it was already in `initial` (i.e. loaded from the + * binding) and `'new'` otherwise (i.e. added in the current form session). + * When omitted, everything not-in-base falls back to `'new'` for backwards + * compatibility with legacy callers. * * @param baseEnvVars - Environment variables from the base workload * @param overrideEnvVars - Environment variables from the override form - * @returns Unified list with status metadata for each env var + * @param initialOverrideEnvVars - Snapshot of overrides as initially loaded */ export function mergeEnvVarsWithStatus( baseEnvVars: EnvVar[], overrideEnvVars: EnvVar[], + initialOverrideEnvVars?: EnvVar[], ): EnvVarWithStatus[] { const result: EnvVarWithStatus[] = []; const baseMap = new Map(baseEnvVars.map(e => [e.key, e])); + const initialKeys = initialOverrideEnvVars + ? new Set(initialOverrideEnvVars.map(e => e.key)) + : undefined; // Create a map of override keys to their actual indices in the array const overrideIndexMap = new Map( @@ -63,13 +75,17 @@ export function mergeEnvVarsWithStatus( } } - // Add new override env vars (not in base) + // Add override env vars not present in base. + // Without an `initial` snapshot, mark every such entry 'new' (legacy). + // With it, persisted-but-not-in-base entries become 'extra'. for (let i = 0; i < overrideEnvVars.length; i++) { const overrideEnv = overrideEnvVars[i]; if (!baseMap.has(overrideEnv.key)) { + const status: EnvVarStatus = + initialKeys && initialKeys.has(overrideEnv.key) ? 'extra' : 'new'; result.push({ envVar: overrideEnv, - status: 'new', + status, actualIndex: i, }); } diff --git a/plugins/openchoreo-react/src/utils/fileVarUtils.test.ts b/plugins/openchoreo-react/src/utils/fileVarUtils.test.ts index 90fce2014..6ee9216c7 100644 --- a/plugins/openchoreo-react/src/utils/fileVarUtils.test.ts +++ b/plugins/openchoreo-react/src/utils/fileVarUtils.test.ts @@ -34,7 +34,7 @@ describe('mergeFileVarsWithStatus', () => { expect(result[0].actualIndex).toBe(0); }); - it('marks new files in override', () => { + it('marks not-in-base files as new when no initial snapshot is provided (legacy)', () => { const result = mergeFileVarsWithStatus( [], [{ key: 'new.txt', mountPath: '/tmp', value: 'data' }], @@ -45,6 +45,30 @@ describe('mergeFileVarsWithStatus', () => { expect(result[0].actualIndex).toBe(0); }); + it('marks files present in initial-but-not-in-base as extra', () => { + const initial: FileVar[] = [ + { key: 'stale.yaml', mountPath: '/etc', value: 'old' }, + ]; + const override: FileVar[] = [ + { key: 'stale.yaml', mountPath: '/etc', value: 'old' }, + ]; + const result = mergeFileVarsWithStatus([], override, initial); + + expect(result).toHaveLength(1); + expect(result[0].status).toBe('extra'); + }); + + it('marks files absent from initial as new when initial is provided', () => { + const result = mergeFileVarsWithStatus( + [], + [{ key: 'fresh.txt', mountPath: '/x', value: 'y' }], + [], + ); + + expect(result).toHaveLength(1); + expect(result[0].status).toBe('new'); + }); + it('handles mixed statuses', () => { const base: FileVar[] = [ { key: 'keep.txt', mountPath: '/a', value: 'keep' }, diff --git a/plugins/openchoreo-react/src/utils/fileVarUtils.ts b/plugins/openchoreo-react/src/utils/fileVarUtils.ts index 1ade2f6c6..309686371 100644 --- a/plugins/openchoreo-react/src/utils/fileVarUtils.ts +++ b/plugins/openchoreo-react/src/utils/fileVarUtils.ts @@ -7,9 +7,10 @@ import type { * Status of a file mount in the override context. * - 'inherited': From base workload, not overridden * - 'overridden': Has a base value that is being overridden - * - 'new': New file mount added in override, not in base workload + * - 'extra': In the persisted overrides but not in the base workload + * - 'new': Added by the user in the current form session and not in base */ -export type FileVarStatus = 'inherited' | 'overridden' | 'new'; +export type FileVarStatus = 'inherited' | 'overridden' | 'extra' | 'new'; /** * File mount with its override status metadata. @@ -27,20 +28,24 @@ export interface FileVarWithStatus { /** * Merges base workload file mounts with override file mounts into a unified list. - * Returns status for each: inherited, overridden, or new. + * Returns status for each: inherited, overridden, extra, or new. * - * File mounts are matched by their key (file name). + * When `initialOverrideFileVars` is provided, an override key not in `base` is + * tagged `'extra'` if it was already in `initial` and `'new'` otherwise. + * When omitted, falls back to legacy behavior (everything not-in-base → `'new'`). * - * @param baseFileVars - File mounts from the base workload - * @param overrideFileVars - File mounts from the override form - * @returns Unified list with status metadata for each file mount + * File mounts are matched by their key (file name). */ export function mergeFileVarsWithStatus( baseFileVars: FileVar[], overrideFileVars: FileVar[], + initialOverrideFileVars?: FileVar[], ): FileVarWithStatus[] { const result: FileVarWithStatus[] = []; const baseMap = new Map(baseFileVars.map(f => [f.key, f])); + const initialKeys = initialOverrideFileVars + ? new Set(initialOverrideFileVars.map(f => f.key)) + : undefined; // Create a map of override keys to their actual indices in the array const overrideIndexMap = new Map( @@ -65,13 +70,15 @@ export function mergeFileVarsWithStatus( } } - // Add new override file vars (not in base) + // Add override file vars not present in base ('extra' if loaded, else 'new'). for (let i = 0; i < overrideFileVars.length; i++) { const overrideFile = overrideFileVars[i]; if (!baseMap.has(overrideFile.key)) { + const status: FileVarStatus = + initialKeys && initialKeys.has(overrideFile.key) ? 'extra' : 'new'; result.push({ fileVar: overrideFile, - status: 'new', + status, actualIndex: i, }); } diff --git a/plugins/openchoreo-react/src/utils/overrideGroupUtils.test.ts b/plugins/openchoreo-react/src/utils/overrideGroupUtils.test.ts index 5b9f047e9..cc0e1edef 100644 --- a/plugins/openchoreo-react/src/utils/overrideGroupUtils.test.ts +++ b/plugins/openchoreo-react/src/utils/overrideGroupUtils.test.ts @@ -1,4 +1,4 @@ -import type { EnvVarWithStatus } from './envVarUtils'; +import type { EnvVarStatus, EnvVarWithStatus } from './envVarUtils'; import { groupByStatus, getStatusCounts, @@ -6,16 +6,19 @@ import { getTotalCount, } from './overrideGroupUtils'; -function makeItem( - status: 'inherited' | 'overridden' | 'new', -): EnvVarWithStatus { +function makeItem(status: EnvVarStatus): EnvVarWithStatus { return { envVar: { key: `key-${status}` }, status }; } describe('groupByStatus', () => { it('returns empty groups for empty array', () => { const result = groupByStatus([]); - expect(result).toEqual({ overridden: [], new: [], inherited: [] }); + expect(result).toEqual({ + overridden: [], + new: [], + extra: [], + inherited: [], + }); }); it('groups items by status', () => { @@ -23,6 +26,7 @@ describe('groupByStatus', () => { makeItem('inherited'), makeItem('overridden'), makeItem('new'), + makeItem('extra'), makeItem('inherited'), ]; const result = groupByStatus(items); @@ -30,6 +34,7 @@ describe('groupByStatus', () => { expect(result.inherited).toHaveLength(2); expect(result.overridden).toHaveLength(1); expect(result.new).toHaveLength(1); + expect(result.extra).toHaveLength(1); }); it('handles all same status', () => { @@ -39,6 +44,7 @@ describe('groupByStatus', () => { expect(result.new).toHaveLength(2); expect(result.inherited).toHaveLength(0); expect(result.overridden).toHaveLength(0); + expect(result.extra).toHaveLength(0); }); }); @@ -47,6 +53,7 @@ describe('getStatusCounts', () => { expect(getStatusCounts([])).toEqual({ overridden: 0, new: 0, + extra: 0, inherited: 0, }); }); @@ -57,33 +64,50 @@ describe('getStatusCounts', () => { makeItem('overridden'), makeItem('new'), makeItem('new'), + makeItem('extra'), ]; expect(getStatusCounts(items)).toEqual({ inherited: 1, overridden: 1, new: 2, + extra: 1, }); }); }); describe('hasAnyItems', () => { it('returns false when all zeros', () => { - expect(hasAnyItems({ overridden: 0, new: 0, inherited: 0 })).toBe(false); + expect(hasAnyItems({ overridden: 0, new: 0, extra: 0, inherited: 0 })).toBe( + false, + ); }); it('returns true when any category has items', () => { - expect(hasAnyItems({ overridden: 0, new: 1, inherited: 0 })).toBe(true); - expect(hasAnyItems({ overridden: 1, new: 0, inherited: 0 })).toBe(true); - expect(hasAnyItems({ overridden: 0, new: 0, inherited: 3 })).toBe(true); + expect(hasAnyItems({ overridden: 0, new: 1, extra: 0, inherited: 0 })).toBe( + true, + ); + expect(hasAnyItems({ overridden: 1, new: 0, extra: 0, inherited: 0 })).toBe( + true, + ); + expect(hasAnyItems({ overridden: 0, new: 0, extra: 2, inherited: 0 })).toBe( + true, + ); + expect(hasAnyItems({ overridden: 0, new: 0, extra: 0, inherited: 3 })).toBe( + true, + ); }); }); describe('getTotalCount', () => { it('sums all categories', () => { - expect(getTotalCount({ overridden: 2, new: 3, inherited: 5 })).toBe(10); + expect( + getTotalCount({ overridden: 2, new: 3, extra: 1, inherited: 5 }), + ).toBe(11); }); it('returns 0 for all zeros', () => { - expect(getTotalCount({ overridden: 0, new: 0, inherited: 0 })).toBe(0); + expect( + getTotalCount({ overridden: 0, new: 0, extra: 0, inherited: 0 }), + ).toBe(0); }); }); diff --git a/plugins/openchoreo-react/src/utils/overrideGroupUtils.ts b/plugins/openchoreo-react/src/utils/overrideGroupUtils.ts index 1c3477211..4b8581116 100644 --- a/plugins/openchoreo-react/src/utils/overrideGroupUtils.ts +++ b/plugins/openchoreo-react/src/utils/overrideGroupUtils.ts @@ -7,6 +7,7 @@ import type { FileVarStatus, FileVarWithStatus } from './fileVarUtils'; export interface StatusCounts { overridden: number; new: number; + extra: number; inherited: number; } @@ -16,6 +17,7 @@ export interface StatusCounts { export interface GroupedItems { overridden: T[]; new: T[]; + extra: T[]; inherited: T[]; } @@ -23,7 +25,7 @@ type ItemWithStatus = EnvVarWithStatus | FileVarWithStatus; type StatusType = EnvVarStatus | FileVarStatus; /** - * Groups items by their status (overridden, new, inherited). + * Groups items by their status (overridden, new, extra, inherited). * Maintains the original actualIndex for each item. * * @param items - Array of items with status metadata @@ -35,6 +37,7 @@ export function groupByStatus( const result: GroupedItems = { overridden: [], new: [], + extra: [], inherited: [], }; @@ -44,6 +47,8 @@ export function groupByStatus( result.overridden.push(item); } else if (status === 'new') { result.new.push(item); + } else if (status === 'extra') { + result.extra.push(item); } else { result.inherited.push(item); } @@ -64,6 +69,7 @@ export function getStatusCounts( const counts: StatusCounts = { overridden: 0, new: 0, + extra: 0, inherited: 0, }; @@ -73,6 +79,8 @@ export function getStatusCounts( counts.overridden++; } else if (status === 'new') { counts.new++; + } else if (status === 'extra') { + counts.extra++; } else { counts.inherited++; } @@ -88,7 +96,12 @@ export function getStatusCounts( * @returns True if any category has items */ export function hasAnyItems(counts: StatusCounts): boolean { - return counts.overridden > 0 || counts.new > 0 || counts.inherited > 0; + return ( + counts.overridden > 0 || + counts.new > 0 || + counts.extra > 0 || + counts.inherited > 0 + ); } /** @@ -98,5 +111,5 @@ export function hasAnyItems(counts: StatusCounts): boolean { * @returns Total count */ export function getTotalCount(counts: StatusCounts): number { - return counts.overridden + counts.new + counts.inherited; + return counts.overridden + counts.new + counts.extra + counts.inherited; } diff --git a/plugins/openchoreo/src/components/Environments/EnvironmentOverridesPage.tsx b/plugins/openchoreo/src/components/Environments/EnvironmentOverridesPage.tsx index e28ab6784..58cae45fe 100644 --- a/plugins/openchoreo/src/components/Environments/EnvironmentOverridesPage.tsx +++ b/plugins/openchoreo/src/components/Environments/EnvironmentOverridesPage.tsx @@ -850,6 +850,11 @@ export const EnvironmentOverridesPage = ({ hideContainerFields secretReferences={secretReferences} baseWorkloadData={formState.baseWorkloadData} + initialWorkloadData={ + formState.initialWorkloadFormData as + | typeof formState.baseWorkloadData + | null + } showEnvVarStatus onStartOverride={handleStartOverride} onStartFileOverride={handleStartFileOverride} diff --git a/plugins/openchoreo/src/components/Environments/Workload/WorkloadEditor/ContainerContent.tsx b/plugins/openchoreo/src/components/Environments/Workload/WorkloadEditor/ContainerContent.tsx index e87be6594..4e099d98f 100644 --- a/plugins/openchoreo/src/components/Environments/Workload/WorkloadEditor/ContainerContent.tsx +++ b/plugins/openchoreo/src/components/Environments/Workload/WorkloadEditor/ContainerContent.tsx @@ -57,6 +57,13 @@ export interface ContainerContentProps { secretReferences?: SecretReference[]; /** Base workload data for reference display (optional) */ baseWorkloadData?: ModelsWorkload | null; + /** + * Initial override workload data — the form's overrides as loaded from the + * binding, before any user edits in this session. Lets the override lists + * distinguish stale persisted entries (`extra`) from freshly-added rows + * (`new`). + */ + initialWorkloadData?: ModelsWorkload | null; /** Whether to show env var status badges and enable inline override (optional) */ showEnvVarStatus?: boolean; /** Callback when user starts overriding an inherited env var (optional) */ @@ -92,6 +99,7 @@ export function ContainerContent({ hideContainerFields = false, secretReferences = [], baseWorkloadData, + initialWorkloadData, showEnvVarStatus = false, onStartOverride, onStartFileOverride, @@ -267,6 +275,9 @@ export function ContainerContent({ containerName={CONTAINER_KEY} envVars={container.env || []} baseEnvVars={getBaseEnvVarsForContainer(baseWorkloadData)} + initialEnvVars={getBaseEnvVarsForContainer( + initialWorkloadData ?? null, + )} environmentName={environmentName} secretOptions={secretOptions} envModes={envModes} @@ -314,6 +325,9 @@ export function ContainerContent({ containerName={CONTAINER_KEY} fileVars={(container as any).files || []} baseFileVars={getBaseFileVarsForContainer(baseWorkloadData)} + initialFileVars={getBaseFileVarsForContainer( + initialWorkloadData ?? null, + )} environmentName={environmentName} secretOptions={secretOptions} fileModes={fileModes} From 23335137ab7c943f2dc66751e176c0ca0cc22295 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Wed, 13 May 2026 05:15:35 +0530 Subject: [PATCH 08/16] feat(openchoreo): simplify setup card under auto-deploy - Auto-deploy on: hide Create/Deploy stories; show Configure component + read-only Latest release row. - Skip CreateReleaseDialog on save under auto-deploy; show toast instead. - Hoist autoDeploy into EnvironmentsContext so Setup card and workload page share one fetch and render a skeleton until it loads (no flicker). - ReleaseBrowserDialog: optional readOnly mode (no Select button). - DetailPageLayout title accepts ReactNode. Signed-off-by: Kavith Lokuhewage --- .../DetailPageLayout/DetailPageLayout.tsx | 2 +- .../Environments/Environments.test.tsx | 5 + .../components/Environments/Environments.tsx | 15 ++ .../Environments/EnvironmentsContext.tsx | 6 + .../Workload/WorkloadConfigPage.tsx | 76 +++--- .../components/ReleaseBrowserDialog.tsx | 32 ++- .../components/SetupDetailPane.test.tsx | 46 ++++ .../components/SetupDetailPane.tsx | 252 +++++++++++++----- .../components/Environments/hooks/index.ts | 1 + .../Environments/hooks/useAutoDeploy.ts | 37 +++ 10 files changed, 359 insertions(+), 113 deletions(-) create mode 100644 plugins/openchoreo/src/components/Environments/hooks/useAutoDeploy.ts diff --git a/plugins/openchoreo-react/src/components/DetailPageLayout/DetailPageLayout.tsx b/plugins/openchoreo-react/src/components/DetailPageLayout/DetailPageLayout.tsx index 3dc0d127e..60b5de695 100644 --- a/plugins/openchoreo-react/src/components/DetailPageLayout/DetailPageLayout.tsx +++ b/plugins/openchoreo-react/src/components/DetailPageLayout/DetailPageLayout.tsx @@ -56,7 +56,7 @@ const useStyles = makeStyles(theme => ({ })); export interface DetailPageLayoutProps { - title: string; + title: ReactNode; subtitle?: ReactNode; onBack: () => void; actions?: ReactNode; diff --git a/plugins/openchoreo/src/components/Environments/Environments.test.tsx b/plugins/openchoreo/src/components/Environments/Environments.test.tsx index 0b3f42ea1..a18cf6c90 100644 --- a/plugins/openchoreo/src/components/Environments/Environments.test.tsx +++ b/plugins/openchoreo/src/components/Environments/Environments.test.tsx @@ -25,6 +25,11 @@ jest.mock('./hooks', () => ({ isPending: false, }), useEnvironmentPolling: jest.fn(), + useAutoDeploy: () => ({ + autoDeploy: false, + loading: false, + refetch: jest.fn(), + }), useEnvironmentRouting: () => ({ state: { view: 'list' as const }, navigateToList: jest.fn(), diff --git a/plugins/openchoreo/src/components/Environments/Environments.tsx b/plugins/openchoreo/src/components/Environments/Environments.tsx index 2d63b2635..cc7bbcdb6 100644 --- a/plugins/openchoreo/src/components/Environments/Environments.tsx +++ b/plugins/openchoreo/src/components/Environments/Environments.tsx @@ -3,6 +3,7 @@ import { useEntity } from '@backstage/plugin-catalog-react'; import { useNotification } from '../../hooks'; import { + useAutoDeploy, useEnvironmentData, useStaleEnvironments, useEnvironmentPolling, @@ -39,6 +40,14 @@ export const Environments = () => { const { canViewBindings, loading: bindingsPermissionLoading } = useReleaseBindingPermission(); + // Component-level auto-deploy flag — loaded once and shared via context so + // child pages don't flicker on their own defaults during initial fetch. + const { + autoDeploy, + loading: autoDeployLoading, + refetch: refetchAutoDeploy, + } = useAutoDeploy(entity); + // Notifications const notification = useNotification(); @@ -100,6 +109,9 @@ export const Environments = () => { environmentReadPermissionLoading, canViewBindings, bindingsPermissionLoading, + autoDeploy, + autoDeployLoading, + refetchAutoDeploy, selection, setSelection, }), @@ -114,6 +126,9 @@ export const Environments = () => { environmentReadPermissionLoading, canViewBindings, bindingsPermissionLoading, + autoDeploy, + autoDeployLoading, + refetchAutoDeploy, selection, ], ); diff --git a/plugins/openchoreo/src/components/Environments/EnvironmentsContext.tsx b/plugins/openchoreo/src/components/Environments/EnvironmentsContext.tsx index 8f4db0c0d..52d8beb81 100644 --- a/plugins/openchoreo/src/components/Environments/EnvironmentsContext.tsx +++ b/plugins/openchoreo/src/components/Environments/EnvironmentsContext.tsx @@ -36,6 +36,12 @@ interface EnvironmentsContextValue { canViewBindings: boolean; /** Whether the release binding permission check is still loading */ bindingsPermissionLoading: boolean; + /** Component-level auto-deploy flag (shared across SetupDetailPane and WorkloadConfigPage). */ + autoDeploy: boolean; + /** Whether the auto-deploy value is still being fetched. */ + autoDeployLoading: boolean; + /** Re-read the auto-deploy value from the server (e.g. after toggling it). */ + refetchAutoDeploy: () => void; /** Currently selected canvas tile (env or setup). Null = nothing selected. */ selection: Selection; /** Setter for the canvas selection. */ diff --git a/plugins/openchoreo/src/components/Environments/Workload/WorkloadConfigPage.tsx b/plugins/openchoreo/src/components/Environments/Workload/WorkloadConfigPage.tsx index 9ecbf5697..e09e40c6a 100644 --- a/plugins/openchoreo/src/components/Environments/Workload/WorkloadConfigPage.tsx +++ b/plugins/openchoreo/src/components/Environments/Workload/WorkloadConfigPage.tsx @@ -89,30 +89,14 @@ export const WorkloadConfigPage = ({ const client = useApi(openChoreoClientApiRef); const catalogApi = useApi(catalogApiRef); const { entity } = useEntity(); - const { lowestEnvironment } = useEnvironmentsContext(); + const { + lowestEnvironment, + autoDeploy: autoDeployEnabled, + autoDeployLoading, + } = useEnvironmentsContext(); const { releases, refetch: refetchReleases } = useReleases(entity); const notification = useNotification(); const [showCreateReleaseDialog, setShowCreateReleaseDialog] = useState(false); - const [autoDeployEnabled, setAutoDeployEnabled] = useState(false); - - useEffect(() => { - let cancelled = false; - const load = async () => { - try { - const componentData = await client.getComponentDetails(entity); - if (!cancelled && componentData?.autoDeploy !== undefined) { - setAutoDeployEnabled(componentData.autoDeploy); - } - } catch { - // Leave autoDeployEnabled at its default (false) — the dialog will - // simply skip its auto-deploy notice if we can't determine it. - } - }; - load(); - return () => { - cancelled = true; - }; - }, [entity, client]); // Single source of truth: the full K8s workload resource const [workloadResource, setWorkloadResource] = @@ -492,7 +476,17 @@ export const WorkloadConfigPage = ({ setIsProcessing(false); allowNavigationRef.current = true; - setShowCreateReleaseDialog(true); + if (autoDeployEnabled) { + // Auto-deploy controller will create a ComponentRelease itself; manual + // creation here would be a redundant sibling release that's never bound. + notification.showSuccess( + `Component saved. Auto-deploy will create a release and roll it out to ${lowestEnvironment} shortly.`, + ); + refetchReleases(); + onReleaseCreated(); + } else { + setShowCreateReleaseDialog(true); + } } catch (e: unknown) { setIsProcessing(false); setSaveError(getErrorMessage(e)); @@ -520,11 +514,17 @@ export const WorkloadConfigPage = ({ const handleButtonClick = () => { if (hasAnyChanges) { setShowConfirmDialog(true); - } else { - // No changes — workload is already up-to-date; jump straight to - // naming and creating the release snapshot. - setShowCreateReleaseDialog(true); + return; } + if (autoDeployEnabled) { + // No changes + auto-deploy on: nothing to do here. The controller already + // owns the release lifecycle — just hand control back to the caller. + onReleaseCreated(); + return; + } + // No changes — workload is already up-to-date; jump straight to + // naming and creating the release snapshot. + setShowCreateReleaseDialog(true); }; const handleConfirmSave = () => { @@ -542,8 +542,8 @@ export const WorkloadConfigPage = ({ const getButtonText = () => { if (isProcessing) return 'Saving...'; - if (hasAnyChanges) return 'Save & continue'; - return 'Continue'; + if (hasAnyChanges) return autoDeployEnabled ? 'Save' : 'Save & continue'; + return autoDeployEnabled ? 'Done' : 'Continue'; }; const totalChanges = @@ -589,10 +589,26 @@ export const WorkloadConfigPage = ({ [undoDelete], ); + const getTitle = () => { + if (autoDeployLoading) { + return ; + } + return autoDeployEnabled ? 'Configure component' : 'Create release'; + }; + + const getSubtitle = () => { + if (autoDeployLoading) { + return ; + } + return autoDeployEnabled + ? "Review and update your component's configuration. Auto-deploy will create a release automatically on save." + : "Review and update your component's configuration, then snapshot it as a release."; + }; + return ( diff --git a/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.tsx b/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.tsx index eec694463..2ef933b86 100644 --- a/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.tsx +++ b/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.tsx @@ -188,6 +188,11 @@ export interface ReleaseBrowserDialogProps { environmentName: string; /** Suppress the list while parent is fetching. */ loading?: boolean; + /** + * When true, render as an inspector — no Select button. Used under + * auto-deploy where the user cannot pick a release. + */ + readOnly?: boolean; } interface ManifestState { @@ -204,6 +209,7 @@ export const ReleaseBrowserDialog = ({ onConfirm, environmentName, loading, + readOnly, }: ReleaseBrowserDialogProps) => { const classes = useStyles(); const api = useApi(openChoreoClientApiRef); @@ -308,7 +314,7 @@ export const ReleaseBrowserDialog = ({ - Select release + {readOnly ? 'Releases' : 'Select release'} setHighlightedName(name)} onDoubleClick={() => { + if (readOnly) { + setHighlightedName(name); + return; + } setHighlightedName(name); onConfirm(name); onClose(); @@ -501,15 +511,17 @@ export const ReleaseBrowserDialog = ({ - - + + {!readOnly && ( + + )} ); diff --git a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx index a6cbcf9ae..e739019b0 100644 --- a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx +++ b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx @@ -27,6 +27,16 @@ jest.mock('./DeployReleasePanel', () => ({ ), })); +jest.mock('./ReleaseBrowserDialog', () => ({ + ReleaseBrowserDialog: ({ open, readOnly }: any) => + open ? ( +
+ ) : null, +})); + const mockUpdateAutoDeploy = jest.fn(); jest.mock('../hooks/useAutoDeployUpdate', () => ({ useAutoDeployUpdate: () => ({ @@ -83,6 +93,11 @@ jest.mock('../../../hooks', () => ({ }), })); +const mockRefetchAutoDeploy = jest.fn(); +let contextOverride: Partial<{ + autoDeploy: boolean; + autoDeployLoading: boolean; +}> = {}; jest.mock('../EnvironmentsContext', () => ({ useEnvironmentsContext: () => ({ environments: [{ name: 'development', deployment: {}, endpoints: [] }], @@ -96,8 +111,12 @@ jest.mock('../EnvironmentsContext', () => ({ environmentReadPermissionLoading: false, canViewBindings: true, bindingsPermissionLoading: false, + autoDeploy: false, + autoDeployLoading: false, + refetchAutoDeploy: mockRefetchAutoDeploy, selection: null, setSelection: jest.fn(), + ...contextOverride, }), })); @@ -130,6 +149,7 @@ beforeEach(() => { jest.clearAllMocks(); readinessOverride = null; permissionOverride = null; + contextOverride = {}; mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: false }); }); @@ -203,6 +223,32 @@ describe('SetupDetailPane', () => { }); }); + it('hides the deploy panel and shows Configure component when auto-deploy is on', async () => { + contextOverride = { autoDeploy: true }; + renderPane(); + + expect( + await screen.findByRole('button', { name: /configure component/i }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /create release/i }), + ).toBeNull(); + expect(screen.queryByTestId('deploy-release-panel')).toBeNull(); + }); + + it('renders the setup skeleton while auto-deploy is loading', () => { + contextOverride = { autoDeployLoading: true }; + renderPane(); + + expect(screen.getByTestId('loading-skeleton-setup')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /create release/i }), + ).toBeNull(); + expect( + screen.queryByRole('button', { name: /configure component/i }), + ).toBeNull(); + }); + it('disables actions when the user lacks the deploy permission', async () => { permissionOverride = { canConfigureAndDeploy: false, diff --git a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx index 1717bf403..4c5f29977 100644 --- a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx +++ b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { Box, Button, @@ -11,16 +11,17 @@ import { } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import AddIcon from '@material-ui/icons/Add'; +import ChevronRightIcon from '@material-ui/icons/ChevronRight'; import CloseIcon from '@material-ui/icons/Close'; import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; import SettingsOutlinedIcon from '@material-ui/icons/SettingsOutlined'; -import { useApi } from '@backstage/core-plugin-api'; import { useEntity } from '@backstage/plugin-catalog-react'; import { useEnvironmentDetailPanelStyles } from '../styles'; import { LoadingSkeleton } from './LoadingSkeleton'; import { AutoDeployConfirmationDialog } from './AutoDeployConfirmationDialog'; import { DeployReleasePanel } from './DeployReleasePanel'; -import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { ReleaseBrowserDialog } from './ReleaseBrowserDialog'; +import type { ComponentRelease } from '@openchoreo/backstage-plugin-common'; import { useAutoDeployUpdate } from '../hooks/useAutoDeployUpdate'; import { useReleases } from '../hooks/useReleases'; import { useReleaseReadiness } from '../hooks/useReleaseReadiness'; @@ -29,6 +30,84 @@ import { useConfigureAndDeployPermission } from '@openchoreo/backstage-plugin-re import { useNotification } from '../../../hooks'; import type { ReleaseDeployments } from './ReleasePicker'; +interface LatestReleaseRowProps { + releases: ComponentRelease[]; + releasesLoading: boolean; + deployments: ReleaseDeployments; + firstEnvironmentName: string; +} + +/** Read-only "Latest release" row used when auto-deploy is on. Clicking opens + * the release browser in read-only mode so the user can inspect YAML without + * being able to pick a release (the controller controls that under auto-deploy). */ +const LatestReleaseRow = ({ + releases, + releasesLoading, + deployments, + firstEnvironmentName, +}: LatestReleaseRowProps) => { + const [browserOpen, setBrowserOpen] = useState(false); + // 'Latest' under auto-deploy = release currently bound to the first env. + // Falls back to the first release in the list (sorted newest-first) when + // no binding exists yet. + const latest = + releases.find(r => + (deployments[r.metadata?.name ?? ''] ?? []).includes( + firstEnvironmentName, + ), + ) ?? + releases[0] ?? + null; + + return ( + + Latest release + {releasesLoading && !latest && ( + + Loading… + + )} + {!releasesLoading && !latest && ( + + No release yet. Auto-deploy will create one after you save the + workload. + + )} + {latest && ( + setBrowserOpen(true)} + display="flex" + alignItems="center" + style={{ cursor: 'pointer', gap: 8 }} + > + + {latest.metadata?.name} + + {(deployments[latest.metadata?.name ?? ''] ?? []).includes( + firstEnvironmentName, + ) && ( + + current in {firstEnvironmentName} + + )} + + + )} + setBrowserOpen(false)} + releases={releases} + deployments={deployments} + selectedReleaseName={latest?.metadata?.name ?? null} + onConfirm={() => {}} + environmentName={firstEnvironmentName} + loading={releasesLoading} + readOnly + /> + + ); +}; + export interface SetupDetailPaneProps { environmentsExist: boolean; isWorkloadEditorSupported: boolean; @@ -55,9 +134,14 @@ export const SetupDetailPane = ({ }: SetupDetailPaneProps) => { const classes = useEnvironmentDetailPanelStyles(); const { entity } = useEntity(); - const client = useApi(openChoreoClientApiRef); const notification = useNotification(); - const { environments, lowestEnvironment } = useEnvironmentsContext(); + const { + environments, + lowestEnvironment, + autoDeploy, + autoDeployLoading, + refetchAutoDeploy, + } = useEnvironmentsContext(); const { updateAutoDeploy, isUpdating: autoDeployUpdating } = useAutoDeployUpdate(entity); const { @@ -73,41 +157,17 @@ export const SetupDetailPane = ({ error: releasesError, } = useReleases(entity); - const [autoDeploy, setAutoDeploy] = useState(undefined); - const [autoDeployLoaded, setAutoDeployLoaded] = useState(false); const [showAutoDeployConfirm, setShowAutoDeployConfirm] = useState(false); const [pendingAutoDeployValue, setPendingAutoDeployValue] = useState(false); const [selectedReleaseName, setSelectedReleaseName] = useState( null, ); - // Fetch auto-deploy from component details (same pattern as before). - useEffect(() => { - let cancelled = false; - setAutoDeployLoaded(false); - const load = async () => { - try { - const componentData = await client.getComponentDetails(entity); - if (!cancelled && componentData?.autoDeploy !== undefined) { - setAutoDeploy(componentData.autoDeploy); - } - } catch { - // Leave undefined; toggle renders unchecked. - } finally { - if (!cancelled) setAutoDeployLoaded(true); - } - }; - load(); - return () => { - cancelled = true; - }; - }, [entity, client]); - const handleAutoDeployChange = useCallback( async (next: boolean) => { const ok = await updateAutoDeploy(next); if (ok) { - setAutoDeploy(next); + refetchAutoDeploy(); notification.showSuccess( `Auto deploy ${next ? 'enabled' : 'disabled'} successfully`, ); @@ -115,7 +175,7 @@ export const SetupDetailPane = ({ notification.showError('Failed to update auto deploy setting'); } }, - [updateAutoDeploy, notification], + [updateAutoDeploy, refetchAutoDeploy, notification], ); const handleToggleChange = (event: React.ChangeEvent) => { @@ -169,7 +229,7 @@ export const SetupDetailPane = ({ - {loading && !environmentsExist ? ( + {(loading && !environmentsExist) || autoDeployLoading ? ( ) : ( <> @@ -186,7 +246,7 @@ export const SetupDetailPane = ({ onChange={handleToggleChange} name="autoDeploy" color="primary" - disabled={!autoDeployLoaded || autoDeployUpdating} + disabled={autoDeployLoading || autoDeployUpdating} /> } label={Auto Deploy} @@ -204,48 +264,96 @@ export const SetupDetailPane = ({ - {/* Story 1 — Create release (routes to workload page) */} - - Release - {readiness.alertMessage && ( - - {readiness.alertMessage} - - )} - {isWorkloadEditorSupported && ( - - - - - - + {autoDeploy ? ( + /* Auto-deploy ON: configure component + read-only latest release */ + <> + + Component + {readiness.alertMessage && ( + + {readiness.alertMessage} + + )} + {isWorkloadEditorSupported && ( + + + + + + + + )} + + Auto-deploy is on. Saving any configuration change creates a + release automatically and rolls it out to{' '} + {lowestEnvironment}. + - )} - - + + + + + ) : ( + <> + {/* Story 1 — Create release (routes to workload page) */} + + Release + {readiness.alertMessage && ( + + {readiness.alertMessage} + + )} + {isWorkloadEditorSupported && ( + + + + + + + + )} + + + - {/* Story 2 — Deploy */} - + {/* Story 2 — Deploy */} + + + )} )} diff --git a/plugins/openchoreo/src/components/Environments/hooks/index.ts b/plugins/openchoreo/src/components/Environments/hooks/index.ts index 5e033b057..98573bc58 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/index.ts +++ b/plugins/openchoreo/src/components/Environments/hooks/index.ts @@ -27,6 +27,7 @@ export { } from './usePromotionAction'; export { useInvokeUrl } from './useInvokeUrl'; export { useReleases, type UseReleasesResult } from './useReleases'; +export { useAutoDeploy } from './useAutoDeploy'; export { useReleaseReadiness, type UseReleaseReadinessResult, diff --git a/plugins/openchoreo/src/components/Environments/hooks/useAutoDeploy.ts b/plugins/openchoreo/src/components/Environments/hooks/useAutoDeploy.ts new file mode 100644 index 000000000..40acba6ea --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/hooks/useAutoDeploy.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useApi } from '@backstage/core-plugin-api'; +import type { Entity } from '@backstage/catalog-model'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; + +/** + * Loads the component's `autoDeploy` flag once via `getComponentDetails` and + * exposes a refetch handle so consumers (e.g. the Setup card toggle) can + * trigger a re-read after they update the value on the server. + * + * Lives at the Environments-page level so all child views (SetupDetailPane, + * WorkloadConfigPage) read a single source of truth and don't render + * auto-deploy-dependent UI against a stale default during initial load. + */ +export const useAutoDeploy = (entity: Entity) => { + const client = useApi(openChoreoClientApiRef); + const [autoDeploy, setAutoDeploy] = useState(false); + const [loading, setLoading] = useState(true); + + const fetchOnce = useCallback(async () => { + setLoading(true); + try { + const componentData = await client.getComponentDetails(entity); + setAutoDeploy(!!componentData?.autoDeploy); + } catch { + // Leave at the previous value — toggle stays in its last-known state. + } finally { + setLoading(false); + } + }, [client, entity]); + + useEffect(() => { + fetchOnce(); + }, [fetchOnce]); + + return { autoDeploy, loading, refetch: fetchOnce }; +}; From 6883b6ef2e9e6468f34164612a5960cc6cb1d2b4 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Wed, 13 May 2026 05:33:14 +0530 Subject: [PATCH 09/16] feat(openchoreo): action-aware titles on configure overrides page - Deploy / promote flows show "Deploy to {env}" / "Promote to {env}" instead of generic "Configure Environment Overrides". - Subtitles call out target env and (for promote) source env. - Direct binding edits keep today's "Configure overrides" wording. Signed-off-by: Kavith Lokuhewage --- .../Environments/EnvironmentOverridesPage.tsx | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/plugins/openchoreo/src/components/Environments/EnvironmentOverridesPage.tsx b/plugins/openchoreo/src/components/Environments/EnvironmentOverridesPage.tsx index 58cae45fe..2b22a2425 100644 --- a/plugins/openchoreo/src/components/Environments/EnvironmentOverridesPage.tsx +++ b/plugins/openchoreo/src/components/Environments/EnvironmentOverridesPage.tsx @@ -685,6 +685,35 @@ export const EnvironmentOverridesPage = ({ return `Fill in the required fields below before promoting to ${pendingAction.targetEnvironment}.`; }; + // Title reflects the user's intent (deploy / promote) so they don't lose + // context on the overrides page. Falls back to today's "Configure overrides" + // wording when there's no pending action (editing an existing binding). + const getPageTitle = () => { + if (!pendingAction) return 'Configure overrides'; + const blocking = missingRequiredFields.length > 0; + if (blocking) + return `Required overrides for ${pendingAction.targetEnvironment}`; + return pendingAction.type === 'deploy' + ? `Deploy to ${pendingAction.targetEnvironment}` + : `Promote to ${pendingAction.targetEnvironment}`; + }; + + const getPageSubtitle = () => { + if (!pendingAction) return environment.name; + const blocking = missingRequiredFields.length > 0; + if (blocking) { + return pendingAction.type === 'deploy' + ? 'Fill in the required fields below before deploying.' + : 'Fill in the required fields below before promoting.'; + } + if (pendingAction.type === 'promote' && pendingAction.sourceEnvironment) { + return `Review environment overrides in ${pendingAction.targetEnvironment} before promoting from ${pendingAction.sourceEnvironment}.`; + } + return pendingAction.type === 'deploy' + ? `Review environment overrides in ${pendingAction.targetEnvironment} before deploying.` + : `Review environment overrides in ${pendingAction.targetEnvironment} before promoting.`; + }; + // Header actions - show when content exists OR when there's a pending action (for Skip & Deploy) const showActions = !loading && !error && (hasAnyContent || pendingAction); const headerActions = showActions ? ( @@ -871,12 +900,8 @@ export const EnvironmentOverridesPage = ({ return ( <> 0 - ? 'Configure Required Environment Overrides' - : 'Configure Environment Overrides' - } - subtitle={environment.name} + title={getPageTitle()} + subtitle={getPageSubtitle()} onBack={handleBackClick} actions={headerActions} > From 6001f7427f52783de5f70232ca60885c843146bd Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Wed, 13 May 2026 10:31:42 +0530 Subject: [PATCH 10/16] feat(detail-page-layout): add Esc shortcut and fix viewport height bound Bound height to calc(100vh - 200px) so inner pages scroll internally instead of overflowing Backstage's page-level scroll. Esc closes via the same path as the back arrow with a visible kbd chip under the icon. Signed-off-by: Kavith Lokuhewage --- .../DetailPageLayout.test.tsx | 43 +++++++++ .../DetailPageLayout/DetailPageLayout.tsx | 88 +++++++++++++++---- 2 files changed, 113 insertions(+), 18 deletions(-) create mode 100644 plugins/openchoreo-react/src/components/DetailPageLayout/DetailPageLayout.test.tsx diff --git a/plugins/openchoreo-react/src/components/DetailPageLayout/DetailPageLayout.test.tsx b/plugins/openchoreo-react/src/components/DetailPageLayout/DetailPageLayout.test.tsx new file mode 100644 index 000000000..2693cffbf --- /dev/null +++ b/plugins/openchoreo-react/src/components/DetailPageLayout/DetailPageLayout.test.tsx @@ -0,0 +1,43 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { DetailPageLayout } from './DetailPageLayout'; + +describe('DetailPageLayout', () => { + it('calls onBack when Escape is pressed', () => { + const onBack = jest.fn(); + render( + +
body
+
, + ); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onBack).toHaveBeenCalledTimes(1); + }); + + it('does not call onBack when Escape is pressed while an input is focused', () => { + const onBack = jest.fn(); + render( + + + , + ); + + const input = screen.getByTestId('field') as HTMLInputElement; + input.focus(); + expect(document.activeElement).toBe(input); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onBack).not.toHaveBeenCalled(); + }); + + it('renders the Esc shortcut chip in the header', () => { + render( + +
+ , + ); + expect(screen.getByLabelText(/press escape to go back/i)).toHaveTextContent( + 'Esc', + ); + }); +}); diff --git a/plugins/openchoreo-react/src/components/DetailPageLayout/DetailPageLayout.tsx b/plugins/openchoreo-react/src/components/DetailPageLayout/DetailPageLayout.tsx index 60b5de695..5d9044531 100644 --- a/plugins/openchoreo-react/src/components/DetailPageLayout/DetailPageLayout.tsx +++ b/plugins/openchoreo-react/src/components/DetailPageLayout/DetailPageLayout.tsx @@ -1,16 +1,18 @@ import { Box, IconButton, Typography } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import ArrowBackIcon from '@material-ui/icons/ArrowBack'; -import type { ReactNode } from 'react'; +import { useEffect, type ReactNode } from 'react'; const useStyles = makeStyles(theme => ({ container: { display: 'flex', flexDirection: 'column', - // Constrain height to prevent page-level scrolling - // Accounts for: Backstage header (~72px) + tabs (~69px) + Content padding (48px) + buffer - height: 'calc(100vh - 240px)', - maxHeight: 'calc(100vh - 240px)', + // Match the deploy list view's height contract so the inner page never + // pushes Backstage's into external scroll. 200px accounts for the + // entity header (~104px) + tab strip (~50px) + 24px top + 24px + // bottom + a small bottom buffer. `min-height` covers tiny viewports. + height: 'calc(100vh - 200px)', + minHeight: 480, overflow: 'hidden', }, header: { @@ -18,18 +20,41 @@ const useStyles = makeStyles(theme => ({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', - padding: theme.spacing(2, 0), + padding: theme.spacing(2), borderBottom: `1px solid ${theme.palette.divider}`, flexShrink: 0, }, + backControl: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 2, + marginRight: theme.spacing(1), + }, + kbdChip: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: 22, + height: 14, + padding: '0 4px', + border: `1px solid ${theme.palette.divider}`, + borderRadius: 3, + backgroundColor: theme.palette.background.default, + color: theme.palette.text.secondary, + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace', + fontSize: 9, + lineHeight: 1, + fontWeight: 500, + letterSpacing: '0.04em', + textTransform: 'uppercase', + }, headerLeft: { display: 'flex', alignItems: 'center', gap: theme.spacing(1), }, - backButton: { - marginRight: theme.spacing(1), - }, titleContainer: { display: 'flex', flexDirection: 'column', @@ -51,7 +76,7 @@ const useStyles = makeStyles(theme => ({ content: { flex: 1, overflowY: 'auto', - paddingTop: theme.spacing(2), + padding: theme.spacing(2), }, })); @@ -76,18 +101,45 @@ export const DetailPageLayout = ({ }: DetailPageLayoutProps) => { const classes = useStyles(); + // Esc closes the page via the same path as the back arrow, so the + // unsaved-changes dialog (when present) still fires. Skip when the user + // is typing in a field — Esc-while-typing is a common reflex. + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + const target = document.activeElement as HTMLElement | null; + if (!target) { + onBack(); + return; + } + const tag = target.tagName; + const isEditable = + tag === 'INPUT' || + tag === 'TEXTAREA' || + tag === 'SELECT' || + target.isContentEditable; + if (isEditable) return; + onBack(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onBack]); + return ( - - - + + + + + + Esc + + {title} From 5b91ead78d0caea900b9f3e12e75d23f9160099b Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Wed, 13 May 2026 10:54:38 +0530 Subject: [PATCH 11/16] feat(deploy): polish setup card UX and deploy panel layout - Order section caption above action button in setup card (Release and Component sections) to match Deploy section rhythm - Add caption under Create release explaining what it does - Rename Deploy section header from "Deploy to {env}" to "Deploy" with a descriptive subtitle - Disable Deploy button when the selected release is already current in the target env, with explanatory tooltip - Wrap selected-release summary in a tinted panel so name, meta, and Change button read as one group; stack name+Change on top line and meta chips below so the layout no longer breaks for multi-env releases Signed-off-by: Kavith Lokuhewage --- .../components/DeployReleasePanel.tsx | 21 ++++-- .../Environments/components/ReleasePicker.tsx | 71 ++++++++++--------- .../components/SetupDetailPane.tsx | 14 ++-- 3 files changed, 64 insertions(+), 42 deletions(-) diff --git a/plugins/openchoreo/src/components/Environments/components/DeployReleasePanel.tsx b/plugins/openchoreo/src/components/Environments/components/DeployReleasePanel.tsx index 46e48cd5a..e957600b4 100644 --- a/plugins/openchoreo/src/components/Environments/components/DeployReleasePanel.tsx +++ b/plugins/openchoreo/src/components/Environments/components/DeployReleasePanel.tsx @@ -76,18 +76,31 @@ export const DeployReleasePanel = ({ }; const noReleases = !releasesLoading && releases.length === 0; - const deployDisabled = disabled || !selectedReleaseName || noReleases; + // `deployments` keeps env names in their original casing (e.g. "Development") + // while `firstEnvironmentName` is lowercased upstream, so compare loosely. + const targetEnv = firstEnvironmentName.toLowerCase(); + const alreadyDeployed = + !!selectedReleaseName && + (deployments[selectedReleaseName] ?? []).some( + e => e.toLowerCase() === targetEnv, + ); + const deployDisabled = + disabled || !selectedReleaseName || noReleases || alreadyDeployed; const getTooltip = () => { - if (deployDisabled && disabledReason) return disabledReason; + if (disabled && disabledReason) return disabledReason; if (!selectedReleaseName) return 'Pick a release first'; + if (alreadyDeployed) { + return `This release is already deployed to ${firstEnvironmentName}.`; + } return ''; }; return ( - - Deploy to {firstEnvironmentName} + Deploy + + Pick a release and deploy it to {firstEnvironmentName}. {releasesError && {releasesError}} diff --git a/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx b/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx index a8967192b..1785e1bda 100644 --- a/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx +++ b/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx @@ -21,20 +21,25 @@ const useStyles = makeStyles(theme => ({ }, summaryRow: { display: 'flex', - alignItems: 'center', - gap: theme.spacing(1.5), + flexDirection: 'column', + gap: theme.spacing(0.75), + padding: theme.spacing(1.25, 1.5), + backgroundColor: theme.palette.action.hover, + borderRadius: 6, }, - summary: { - flexGrow: 1, - minWidth: 0, + topLine: { display: 'flex', - flexDirection: 'column', + alignItems: 'center', + justifyContent: 'space-between', + gap: theme.spacing(1.5), }, name: { fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', + flexGrow: 1, + minWidth: 0, }, meta: { color: theme.palette.text.secondary, @@ -134,40 +139,40 @@ export const ReleasePicker = ({ ) : ( - + {selected ? ( - <> - - {selected.metadata?.name} - - - {created && {created}} - {image && img: {shortenImage(image)}} - {deployedIn.map(env => ( - - ))} - - + + {selected.metadata?.name} + ) : ( {noReleases ? 'No releases yet' : 'No release selected'} )} + - + {selected && ( + + {created && {created}} + {image && img: {shortenImage(image)}} + {deployedIn.map(env => ( + + ))} + + )} )} diff --git a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx index 4c5f29977..2a8fc1c94 100644 --- a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx +++ b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx @@ -269,6 +269,11 @@ export const SetupDetailPane = ({ <> Component + + Auto-deploy is on. Saving any configuration change creates a + release automatically and rolls it out to{' '} + {lowestEnvironment}. + {readiness.alertMessage && ( {readiness.alertMessage} @@ -292,11 +297,6 @@ export const SetupDetailPane = ({ )} - - Auto-deploy is on. Saving any configuration change creates a - release automatically and rolls it out to{' '} - {lowestEnvironment}. - @@ -313,6 +313,10 @@ export const SetupDetailPane = ({ {/* Story 1 — Create release (routes to workload page) */} Release + + Update your component's configuration and snapshot it as a + release. + {readiness.alertMessage && ( {readiness.alertMessage} From 9be2015f2e95e37ee922d69eee96ae9c97511ab6 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Wed, 13 May 2026 11:01:48 +0530 Subject: [PATCH 12/16] feat(deploy-canvas): differentiate setup tile from env tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add "Start" badge to the setup card to mark it as the pipeline entry - Shrink setup node dimensions to 180×96 (env tiles stay 240×140) so it reads as a smaller configuration node rather than another env Signed-off-by: Kavith Lokuhewage --- .../dag/pipelineLayoutUtils.ts | 4 ++-- .../Environments/components/SetupCard.tsx | 1 + .../src/components/Environments/styles.ts | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/plugins/openchoreo-react/src/components/PipelineFlowVisualization/dag/pipelineLayoutUtils.ts b/plugins/openchoreo-react/src/components/PipelineFlowVisualization/dag/pipelineLayoutUtils.ts index d5162cba0..de057f672 100644 --- a/plugins/openchoreo-react/src/components/PipelineFlowVisualization/dag/pipelineLayoutUtils.ts +++ b/plugins/openchoreo-react/src/components/PipelineFlowVisualization/dag/pipelineLayoutUtils.ts @@ -21,8 +21,8 @@ export const SETUP_NODE_HEIGHT = ENV_NODE_HEIGHT; // rendering inside the detail panel. export const MINI_ENV_NODE_WIDTH = 240; export const MINI_ENV_NODE_HEIGHT = 140; -export const MINI_SETUP_NODE_WIDTH = 240; -export const MINI_SETUP_NODE_HEIGHT = 140; +export const MINI_SETUP_NODE_WIDTH = 180; +export const MINI_SETUP_NODE_HEIGHT = 96; /** Minimal env shape used by buildEnvPipelineNodes */ export interface EnvPipelineInput { diff --git a/plugins/openchoreo/src/components/Environments/components/SetupCard.tsx b/plugins/openchoreo/src/components/Environments/components/SetupCard.tsx index 2387cba40..0e85b411c 100644 --- a/plugins/openchoreo/src/components/Environments/components/SetupCard.tsx +++ b/plugins/openchoreo/src/components/Environments/components/SetupCard.tsx @@ -23,6 +23,7 @@ export const SetupCard = ({ [classes.cardSelected]: selected, })} > + Start Set up diff --git a/plugins/openchoreo/src/components/Environments/styles.ts b/plugins/openchoreo/src/components/Environments/styles.ts index 8363d4545..caa213f92 100644 --- a/plugins/openchoreo/src/components/Environments/styles.ts +++ b/plugins/openchoreo/src/components/Environments/styles.ts @@ -83,6 +83,7 @@ export const useSetupCardStyles = makeStyles(theme => ({ */ export const useSetupCardCompactStyles = makeStyles(theme => ({ setupCard: { + position: 'relative', backgroundColor: theme.palette.background.paper, color: theme.palette.text.primary, padding: theme.spacing(2), @@ -102,6 +103,20 @@ export const useSetupCardCompactStyles = makeStyles(theme => ({ boxShadow: theme.shadows[3], }, }, + startBadge: { + position: 'absolute', + top: theme.spacing(1), + left: theme.spacing(1), + padding: '1px 6px', + borderRadius: 3, + backgroundColor: alpha(theme.palette.primary.main, 0.15), + color: theme.palette.primary.main, + fontSize: 9, + fontWeight: 700, + letterSpacing: '0.08em', + lineHeight: 1.4, + textTransform: 'uppercase', + }, cardSelected: { borderColor: theme.palette.primary.main, borderStyle: 'solid', From b8952f0a7bca7fdf45afee8823593949941e7aa2 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Wed, 13 May 2026 15:04:47 +0530 Subject: [PATCH 13/16] fix(release-picker): correct image path so search and meta render extractImage() read release.spec.workload.spec.container.image, but the API returns the image at release.spec.workload.container.image (no inner .spec wrapper). The "img: ..." line never appeared in rows and search by image silently matched nothing. Signed-off-by: Kavith Lokuhewage --- .../Environments/components/ReleaseBrowserDialog.tsx | 4 ++-- .../src/components/Environments/components/ReleasePicker.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.tsx b/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.tsx index 2ef933b86..2189c2dda 100644 --- a/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.tsx +++ b/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.tsx @@ -165,9 +165,9 @@ const formatAbsoluteTime = (iso?: string): string => { const extractImage = (release: ComponentRelease): string | undefined => { const workload = release.spec?.workload as - | { spec?: { container?: { image?: string } } } + | { container?: { image?: string } } | undefined; - return workload?.spec?.container?.image; + return workload?.container?.image; }; const shortenImage = (image: string): string => { diff --git a/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx b/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx index 1785e1bda..1213d1a46 100644 --- a/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx +++ b/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx @@ -92,9 +92,9 @@ const formatRelativeTime = (iso?: string): string => { const extractImage = (release: ComponentRelease): string | undefined => { const workload = release.spec?.workload as - | { spec?: { container?: { image?: string } } } + | { container?: { image?: string } } | undefined; - return workload?.spec?.container?.image; + return workload?.container?.image; }; const shortenImage = (image: string): string => { From ae446d9e99708d900aea631f88e5562e4026d3a0 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Wed, 13 May 2026 15:27:13 +0530 Subject: [PATCH 14/16] chore(deploy): unify auto-deploy copy and rename diff button - Auto-deploy tooltip now matches the confirmation dialog wording - Both reference "lowest environment" instead of "default environment" - Rename "View diff" to "View release diff" on the overrides page and env detail drift banner for clarity Signed-off-by: Kavith Lokuhewage --- .../components/AutoDeployConfirmationDialog.tsx | 2 +- .../Environments/components/EnvironmentDetailPanel.test.tsx | 6 ++++-- .../Environments/components/EnvironmentDetailPanel.tsx | 2 +- .../components/Environments/components/SetupDetailPane.tsx | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/plugins/openchoreo/src/components/Environments/components/AutoDeployConfirmationDialog.tsx b/plugins/openchoreo/src/components/Environments/components/AutoDeployConfirmationDialog.tsx index a8043fa55..8f1027921 100644 --- a/plugins/openchoreo/src/components/Environments/components/AutoDeployConfirmationDialog.tsx +++ b/plugins/openchoreo/src/components/Environments/components/AutoDeployConfirmationDialog.tsx @@ -28,7 +28,7 @@ export const AutoDeployConfirmationDialog: FC< {isEnabling - ? 'Enabling auto deploy will automatically deploy the component to the default environment when component configurations change.' + ? 'Enabling auto deploy will automatically deploy the component to the lowest environment when component configurations change.' : 'Disabling auto deploy will require manual deployment when component configurations change.'} diff --git a/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.test.tsx b/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.test.tsx index 695b27eb8..dffa7e8ea 100644 --- a/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.test.tsx +++ b/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.test.tsx @@ -460,7 +460,7 @@ describe('EnvironmentDetailPanel', () => { expect(screen.queryByText(/rel-7/)).toBeNull(); }); - it('opens the release diff dialog from the drift "View diff" button', async () => { + it('opens the release diff dialog from the drift "View release diff" button', async () => { const user = userEvent.setup(); renderPanel({ selection: { @@ -476,7 +476,9 @@ describe('EnvironmentDetailPanel', () => { aheadUpstreams: [{ envName: 'dev', releaseName: 'rel-7' }], }, }); - await user.click(screen.getByRole('button', { name: /view diff/i })); + await user.click( + screen.getByRole('button', { name: /view release diff/i }), + ); expect(await screen.findByTestId('diff-dialog')).toBeInTheDocument(); }); diff --git a/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.tsx b/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.tsx index 358dc5b01..bdc6bffea 100644 --- a/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.tsx +++ b/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.tsx @@ -358,7 +358,7 @@ export const EnvironmentDetailPanel = ({ startIcon={} onClick={() => setDiffOpen(true)} > - View diff + View release diff )} diff --git a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx index 2a8fc1c94..ca92af83d 100644 --- a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx +++ b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx @@ -252,7 +252,7 @@ export const SetupDetailPane = ({ label={Auto Deploy} /> From 2455b32736c1d06a62669442ca2bbd21235ba409 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Wed, 13 May 2026 15:59:23 +0530 Subject: [PATCH 15/16] feat(release-browser): add compare mode to diff two releases Adds a View/Compare toggle to the right pane. In Compare mode the user picks a reference release from a grouped Autocomplete (current-in-env bindings first, then any other release) and the right pane renders a side-by-side YamlDiffViewer of that reference vs the highlighted release. When opened with an environmentName, pre-fills the comparison target with that env's current release so the common "is this release different from what's deployed?" question resolves with zero clicks. Manifests are fetched on demand and cached, so toggling and switching targets are free. Existing view-mode behavior, search, and selection flow are unchanged. Signed-off-by: Kavith Lokuhewage --- .../components/ReleaseBrowserDialog.test.tsx | 83 ++++ .../components/ReleaseBrowserDialog.tsx | 360 ++++++++++++++++-- 2 files changed, 403 insertions(+), 40 deletions(-) diff --git a/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.test.tsx b/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.test.tsx index 733afac07..375390e00 100644 --- a/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.test.tsx +++ b/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.test.tsx @@ -22,6 +22,30 @@ jest.mock('@openchoreo/backstage-design-system', () => ({ ), })); +// The plugin-react entry pulls Backstage's TabbedLayout transitively which +// blows up under jest's isolated module env. Only YamlDiffViewer is used +// here; mock the package surface to that one component. +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + YamlDiffViewer: ({ + original, + modified, + originalLabel, + modifiedLabel, + }: { + original: string; + modified: string; + originalLabel?: string; + modifiedLabel?: string; + }) => ( +
+
{originalLabel}
+
{modifiedLabel}
+
{original}
+
{modified}
+
+ ), +})); + jest.mock('yaml', () => ({ stringify: (obj: unknown) => `name: ${ @@ -138,4 +162,63 @@ describe('ReleaseBrowserDialog', () => { expect(within(error).getByText(/boom/i)).toBeInTheDocument(); expect(screen.queryByTestId('yaml-viewer')).toBeNull(); }); + + it('renders the mode toggle, defaulting to View', async () => { + renderDialog(); + const viewBtn = await screen.findByRole('button', { + name: /view manifest/i, + }); + const compareBtn = screen.getByRole('button', { + name: /compare with another release/i, + }); + expect(viewBtn).toHaveAttribute('aria-pressed', 'true'); + expect(compareBtn).toHaveAttribute('aria-pressed', 'false'); + // YamlViewer renders in view mode; YamlDiffViewer does not. + expect(await screen.findByTestId('yaml-viewer')).toBeInTheDocument(); + expect(screen.queryByTestId('yaml-diff-viewer')).toBeNull(); + }); + + it('switching to Compare pre-fills the env-current release as compare target', async () => { + // `rel-middle` is highlighted (selectedReleaseName) and current in + // development. To make pre-select meaningful, highlight a *different* + // release so the env-current target isn't a self-diff. + const user = userEvent.setup(); + renderDialog({ selectedReleaseName: 'rel-newest' }); + + await user.click( + screen.getByRole('button', { name: /compare with another release/i }), + ); + + // The compare-with selector should now show the env-current release. + const selector = await screen.findByPlaceholderText( + /pick a release or environment/i, + ); + expect(selector).toHaveValue('Current in development'); + + // Both manifests resolve → diff viewer renders. + expect(await screen.findByTestId('yaml-diff-viewer')).toBeInTheDocument(); + expect(screen.getByTestId('diff-original-label')).toHaveTextContent( + /current in development \(rel-middle\)/i, + ); + expect(screen.getByTestId('diff-modified-label')).toHaveTextContent( + /rel-newest/, + ); + }); + + it('Compare with no pre-selectable target shows the empty hint', async () => { + const user = userEvent.setup(); + // No deployments → no env-current pre-select candidate. + renderDialog({ deployments: {} }); + + await user.click( + screen.getByRole('button', { name: /compare with another release/i }), + ); + + expect( + await screen.findByText( + /pick a release or environment to compare against/i, + ), + ).toBeInTheDocument(); + expect(screen.queryByTestId('yaml-diff-viewer')).toBeNull(); + }); }); diff --git a/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.tsx b/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.tsx index 2189c2dda..456305cf5 100644 --- a/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.tsx +++ b/plugins/openchoreo/src/components/Environments/components/ReleaseBrowserDialog.tsx @@ -18,12 +18,18 @@ import { Typography, } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; +import { + Autocomplete, + ToggleButton, + ToggleButtonGroup, +} from '@material-ui/lab'; import CloseIcon from '@material-ui/icons/Close'; import FileCopyOutlinedIcon from '@material-ui/icons/FileCopyOutlined'; import SearchIcon from '@material-ui/icons/Search'; import { useApi } from '@backstage/core-plugin-api'; import { useEntity } from '@backstage/plugin-catalog-react'; import { YamlViewer } from '@openchoreo/backstage-design-system'; +import { YamlDiffViewer } from '@openchoreo/backstage-plugin-react'; import YAML from 'yaml'; import type { ComponentRelease } from '@openchoreo/backstage-plugin-common'; import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; @@ -99,10 +105,41 @@ const useStyles = makeStyles(theme => ({ fontSize: 11, }, detailHeader: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: theme.spacing(1), + flexWrap: 'wrap', + }, + detailHeaderTitle: { display: 'flex', alignItems: 'baseline', gap: theme.spacing(1), flexWrap: 'wrap', + minWidth: 0, + }, + modeToggle: { + flexShrink: 0, + }, + modeButton: { + padding: theme.spacing(0.25, 1.25), + fontSize: 12, + lineHeight: 1.4, + textTransform: 'none', + }, + compareBar: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + }, + compareLabel: { + color: theme.palette.text.secondary, + fontSize: 12, + flexShrink: 0, + }, + compareSelector: { + flexGrow: 1, + minWidth: 0, }, metaGrid: { display: 'grid', @@ -217,6 +254,10 @@ export const ReleaseBrowserDialog = ({ const [query, setQuery] = useState(''); const [highlightedName, setHighlightedName] = useState(null); + const [mode, setMode] = useState<'view' | 'compare'>('view'); + const [compareTargetName, setCompareTargetName] = useState( + null, + ); const [manifestCache, setManifestCache] = useState< Record >({}); @@ -229,8 +270,45 @@ export const ReleaseBrowserDialog = ({ setQuery(''); const fallback = selectedReleaseName ?? releases[0]?.metadata?.name ?? null; setHighlightedName(fallback); + setMode('view'); + setCompareTargetName(null); }, [open, selectedReleaseName, releases]); + // Build env → releaseName map (inverse of `deployments`) so we can offer + // "Current in {env}" entries in the compare selector without an extra + // fetch. Same map also fuels the pre-selection default below. Keys use + // the original casing from `deployments` (e.g. "Development") so the + // option labels read naturally. + const envToRelease = useMemo(() => { + const map: Record = {}; + for (const [relName, envs] of Object.entries(deployments)) { + for (const env of envs) { + // First binding wins if a release somehow appears in multiple envs — + // doesn't actually happen with current semantics but defensive. + if (!map[env]) map[env] = relName; + } + } + return map; + }, [deployments]); + + // When entering Compare mode for the first time, default the compare + // target to the release currently in `environmentName` (the dialog's + // contextual env), provided it isn't the highlighted release itself. + // `deployments` may key envs with their original casing (e.g. + // "Development") while `environmentName` is lowercased upstream — match + // loosely. + useEffect(() => { + if (mode !== 'compare' || compareTargetName) return; + const target = environmentName.toLowerCase(); + const entry = Object.entries(envToRelease).find( + ([env]) => env.toLowerCase() === target, + ); + const candidate = entry?.[1]; + if (candidate && candidate !== highlightedName) { + setCompareTargetName(candidate); + } + }, [mode, compareTargetName, envToRelease, environmentName, highlightedName]); + const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return releases; @@ -288,6 +366,45 @@ export const ReleaseBrowserDialog = ({ }; }, [api, entity, open, highlightedName, manifestCache]); + // Mirror fetch for the compare target. Reuses the same manifestCache so + // switching back and forth between view and compare is free, and so the + // compare target's YAML is also available for inline display if needed. + useEffect(() => { + if (!open || mode !== 'compare' || !compareTargetName) return undefined; + if (manifestCache[compareTargetName]) return undefined; + let cancelled = false; + api + .fetchComponentRelease(entity, compareTargetName) + .then(response => { + if (cancelled) return; + if (!response?.success || !response.data) { + setManifestCache(prev => ({ + ...prev, + [compareTargetName]: { + error: 'Release manifest is not available.', + }, + })); + return; + } + setManifestCache(prev => ({ + ...prev, + [compareTargetName]: { yaml: YAML.stringify(response.data) }, + })); + }) + .catch(e => { + if (cancelled) return; + setManifestCache(prev => ({ + ...prev, + [compareTargetName]: { + error: e?.message ?? 'Failed to fetch release manifest', + }, + })); + }); + return () => { + cancelled = true; + }; + }, [api, entity, open, mode, compareTargetName, manifestCache]); + const handleConfirm = () => { if (!highlightedName) return; onConfirm(highlightedName); @@ -308,6 +425,49 @@ export const ReleaseBrowserDialog = ({ const currentManifest = highlightedName ? manifestCache[highlightedName] : undefined; + const compareManifest = compareTargetName + ? manifestCache[compareTargetName] + : undefined; + + // Options for the compare-with selector. Grouped by section: env current + // bindings first, then individual releases. The highlighted release is + // filtered out everywhere — diffing a release against itself is useless. + type CompareOption = { + releaseName: string; + group: 'Currently deployed' | 'Other releases'; + label: string; + sublabel?: string; + }; + const compareOptions = useMemo(() => { + const opts: CompareOption[] = []; + const seenInEnvGroup = new Set(); + for (const [env, relName] of Object.entries(envToRelease)) { + if (relName === highlightedName) continue; + opts.push({ + releaseName: relName, + group: 'Currently deployed', + label: `Current in ${env}`, + sublabel: relName, + }); + seenInEnvGroup.add(relName); + } + for (const r of releases) { + const name = r.metadata?.name; + if (!name || name === highlightedName) continue; + // Avoid duplicating a release that already appears in the env group — + // the env entry is more informative. + if (seenInEnvGroup.has(name)) continue; + opts.push({ + releaseName: name, + group: 'Other releases', + label: name, + sublabel: formatRelativeTime(r.metadata?.creationTimestamp), + }); + } + return opts; + }, [envToRelease, releases, highlightedName]); + const selectedCompareOption = + compareOptions.find(o => o.releaseName === compareTargetName) ?? null; return ( @@ -425,11 +585,13 @@ export const ReleaseBrowserDialog = ({ {highlighted ? ( <> - - {highlighted.metadata?.name} - - {(deployments[highlighted.metadata?.name ?? ''] ?? []).map( - env => ( + + + {highlighted.metadata?.name} + + {( + deployments[highlighted.metadata?.name ?? ''] ?? [] + ).map(env => ( - ), - )} + ))} + + { + if (next === 'view' || next === 'compare') + setMode(next); + }} + className={classes.modeToggle} + aria-label="Detail pane mode" + > + + View + + + Compare + + @@ -461,41 +649,133 @@ export const ReleaseBrowserDialog = ({ )} - - Manifest - - - + + + + {yamlLoading && !currentManifest && ( + + + + )} + {currentManifest?.error && ( + + {currentManifest.error} + + )} + {currentManifest?.yaml && ( + + )} + + ) : ( + <> + + + Compare with + + + className={classes.compareSelector} size="small" - startIcon={} - onClick={handleCopy} - disabled={!currentManifest?.yaml} + options={compareOptions} + value={selectedCompareOption} + getOptionLabel={o => o.label} + groupBy={o => o.group} + renderOption={o => ( + + {o.label} + {o.sublabel && ( + + {o.sublabel} + + )} + + )} + onChange={(_, next) => + setCompareTargetName(next?.releaseName ?? null) + } + renderInput={params => ( + + )} + /> + + {!compareTargetName && ( + + + Pick a release or environment to compare against. + + + )} + {compareTargetName && + (!currentManifest?.yaml || !compareManifest?.yaml) && + !currentManifest?.error && + !compareManifest?.error && ( + + + + )} + {(currentManifest?.error || compareManifest?.error) && ( + - Copy - - -
-
- {yamlLoading && !currentManifest && ( - - - - )} - {currentManifest?.error && ( - - {currentManifest.error} - - )} - {currentManifest?.yaml && ( - + {currentManifest?.error ?? compareManifest?.error} +
+ )} + {compareTargetName && + currentManifest?.yaml && + compareManifest?.yaml && ( + + )} + )} ) : ( From ee04b6cd97aa434b7076ecee9495f06715403038 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Thu, 21 May 2026 10:08:35 +0530 Subject: [PATCH 16/16] refactor(deploy): merge Release and Deploy into one section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the auto-deploy OFF branch of the Setup card from two stacked sections (Release + Deploy) into a single Deploy section anchored on the release picker. - Lift Create release / Select release out of the picker panel onto the Deploy section header so they read as actions on the section, not on the selection. - Pre-select the newest release when nothing is selected so the common "just created a release, want to ship it" flow finishes in one click. - Strip ReleasePicker down to a pure display component — actions, the browser dialog, and gating now live on DeployReleasePanel. - Drop the "SELECTED RELEASE" label and the redundant "No releases yet. Create one above" alert; the picker panel surfaces the empty state. Signed-off-by: Kavith Lokuhewage --- .../components/DeployReleasePanel.tsx | 75 ++++++++++-- .../Environments/components/ReleasePicker.tsx | 112 +++++------------- .../components/SetupDetailPane.test.tsx | 22 +++- .../components/SetupDetailPane.tsx | 46 ++----- 4 files changed, 119 insertions(+), 136 deletions(-) diff --git a/plugins/openchoreo/src/components/Environments/components/DeployReleasePanel.tsx b/plugins/openchoreo/src/components/Environments/components/DeployReleasePanel.tsx index e957600b4..048ec05b9 100644 --- a/plugins/openchoreo/src/components/Environments/components/DeployReleasePanel.tsx +++ b/plugins/openchoreo/src/components/Environments/components/DeployReleasePanel.tsx @@ -1,10 +1,12 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Box, Button, Tooltip, Typography } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import { Alert } from '@material-ui/lab'; +import AddIcon from '@material-ui/icons/Add'; import type { ComponentRelease } from '@openchoreo/backstage-plugin-common'; import { useEnvironmentRouting } from '../hooks/useEnvironmentRouting'; import { ReleasePicker, type ReleaseDeployments } from './ReleasePicker'; +import { ReleaseBrowserDialog } from './ReleaseBrowserDialog'; const useStyles = makeStyles(theme => ({ panel: { @@ -12,6 +14,18 @@ const useStyles = makeStyles(theme => ({ flexDirection: 'column', gap: theme.spacing(1.5), }, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: theme.spacing(1.5), + }, + headerActions: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.75), + flexShrink: 0, + }, actionsRow: { display: 'flex', justifyContent: 'flex-end', @@ -32,6 +46,10 @@ export interface DeployReleasePanelProps { firstEnvironmentName: string; disabled?: boolean; disabledReason?: string; + /** Forwarded to ReleasePicker to render an inline "+ Create release" button. */ + onCreateRelease?: () => void; + canCreateRelease?: boolean; + createDisabledReason?: string; } /** @@ -52,9 +70,13 @@ export const DeployReleasePanel = ({ firstEnvironmentName, disabled, disabledReason, + onCreateRelease, + canCreateRelease, + createDisabledReason, }: DeployReleasePanelProps) => { const classes = useStyles(); const { navigateToOverrides } = useEnvironmentRouting(); + const [browserOpen, setBrowserOpen] = useState(false); // Preselect the most recent release when nothing is selected yet. Only // react to the newest name changing so the user's explicit selection @@ -98,27 +120,47 @@ export const DeployReleasePanel = ({ return ( - Deploy + + Deploy + + {onCreateRelease && ( + + + + + + )} + {!(noReleases && onCreateRelease) && ( + + )} + + Pick a release and deploy it to {firstEnvironmentName}. {releasesError && {releasesError}} - {noReleases && !releasesError && ( - - No releases yet. Create one above to deploy it here. - - )} - @@ -136,6 +178,17 @@ export const DeployReleasePanel = ({ + + setBrowserOpen(false)} + releases={releases} + deployments={deployments} + selectedReleaseName={selectedReleaseName} + onConfirm={name => onSelectedReleaseChange(name)} + environmentName={firstEnvironmentName} + loading={releasesLoading} + /> ); }; diff --git a/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx b/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx index 1213d1a46..9bb662476 100644 --- a/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx +++ b/plugins/openchoreo/src/components/Environments/components/ReleasePicker.tsx @@ -1,24 +1,10 @@ -import { useMemo, useState } from 'react'; -import { Box, Button, Chip, Typography } from '@material-ui/core'; +import { useMemo } from 'react'; +import { Box, Chip, Typography } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import { Skeleton } from '@material-ui/lab'; import type { ComponentRelease } from '@openchoreo/backstage-plugin-common'; -import { ReleaseBrowserDialog } from './ReleaseBrowserDialog'; const useStyles = makeStyles(theme => ({ - wrapper: { - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(0.5), - }, - label: { - color: theme.palette.text.secondary, - fontWeight: 600, - fontSize: 11, - letterSpacing: '0.06em', - textTransform: 'uppercase', - marginBottom: theme.spacing(0.5), - }, summaryRow: { display: 'flex', flexDirection: 'column', @@ -27,18 +13,11 @@ const useStyles = makeStyles(theme => ({ backgroundColor: theme.palette.action.hover, borderRadius: 6, }, - topLine: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - gap: theme.spacing(1.5), - }, name: { fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', - flexGrow: 1, minWidth: 0, }, meta: { @@ -65,14 +44,9 @@ export type ReleaseDeployments = Record; export interface ReleasePickerProps { releases: ComponentRelease[]; selectedReleaseName: string | null; - onChange: (releaseName: string | null) => void; /** Environments where each release is currently deployed. Used for badges. */ deployments?: ReleaseDeployments; - /** Env name passed to the browser dialog for context. */ - environmentName: string; - disabled?: boolean; loading?: boolean; - label?: string; } const formatRelativeTime = (iso?: string): string => { @@ -105,15 +79,10 @@ const shortenImage = (image: string): string => { export const ReleasePicker = ({ releases, selectedReleaseName, - onChange, deployments = {}, - environmentName, - disabled, loading, - label = 'Selected release', }: ReleasePickerProps) => { const classes = useStyles(); - const [dialogOpen, setDialogOpen] = useState(false); const selected = useMemo( () => releases.find(r => r.metadata?.name === selectedReleaseName) ?? null, @@ -129,63 +98,36 @@ export const ReleasePicker = ({ ? deployments[selected.metadata?.name ?? ''] ?? [] : []; - return ( - - - {label} - + if (loading) { + return ; + } - {loading ? ( - + return ( + + {selected ? ( + + {selected.metadata?.name} + ) : ( - - - {selected ? ( - - {selected.metadata?.name} - - ) : ( - - {noReleases ? 'No releases yet' : 'No release selected'} - - )} - - - {selected && ( - - {created && {created}} - {image && img: {shortenImage(image)}} - {deployedIn.map(env => ( - - ))} - - )} + color="primary" + className={classes.chip} + /> + ))} )} - - setDialogOpen(false)} - releases={releases} - deployments={deployments} - selectedReleaseName={selectedReleaseName} - onConfirm={name => onChange(name)} - environmentName={environmentName} - loading={loading} - /> ); }; diff --git a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx index e739019b0..bb3e9d634 100644 --- a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx +++ b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx @@ -19,11 +19,25 @@ jest.mock('./LoadingSkeleton', () => ({ })); jest.mock('./DeployReleasePanel', () => ({ - DeployReleasePanel: ({ disabled }: any) => ( + DeployReleasePanel: ({ + disabled, + onCreateRelease, + canCreateRelease, + }: any) => (
+ > + {onCreateRelease && ( + + )} +
), })); @@ -154,13 +168,13 @@ beforeEach(() => { }); describe('SetupDetailPane', () => { - it('renders both stories: Create release button and the deploy panel', async () => { + it('renders the deploy panel with an inline Create release affordance', async () => { renderPane(); + expect(screen.getByTestId('deploy-release-panel')).toBeInTheDocument(); expect( await screen.findByRole('button', { name: /create release/i }), ).toBeEnabled(); - expect(screen.getByTestId('deploy-release-panel')).toBeInTheDocument(); }); it('Create release navigates to the workload page (onConfigureWorkload)', async () => { diff --git a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx index ca92af83d..cbf15934f 100644 --- a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx +++ b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx @@ -10,7 +10,6 @@ import { Typography, } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; -import AddIcon from '@material-ui/icons/Add'; import ChevronRightIcon from '@material-ui/icons/ChevronRight'; import CloseIcon from '@material-ui/icons/Close'; import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; @@ -310,41 +309,11 @@ export const SetupDetailPane = ({ ) : ( <> - {/* Story 1 — Create release (routes to workload page) */} - - Release - - Update your component's configuration and snapshot it as a - release. - - {readiness.alertMessage && ( - - {readiness.alertMessage} - - )} - {isWorkloadEditorSupported && ( - - - - - - - - )} - - - - - {/* Story 2 — Deploy */} + {readiness.alertMessage && ( + + {readiness.alertMessage} + + )} )}