) => {
- 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..bb3e9d634
--- /dev/null
+++ b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx
@@ -0,0 +1,283 @@
+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 }) => (
+
+ ),
+}));
+
+jest.mock('./DeployReleasePanel', () => ({
+ DeployReleasePanel: ({
+ disabled,
+ onCreateRelease,
+ canCreateRelease,
+ }: any) => (
+
+ {onCreateRelease && (
+
+ )}
+
+ ),
+}));
+
+jest.mock('./ReleaseBrowserDialog', () => ({
+ ReleaseBrowserDialog: ({ open, readOnly }: any) =>
+ open ? (
+
+ ) : null,
+}));
+
+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(),
+ }),
+}));
+
+const mockRefetchAutoDeploy = jest.fn();
+let contextOverride: Partial<{
+ autoDeploy: boolean;
+ autoDeployLoading: boolean;
+}> = {};
+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,
+ autoDeploy: false,
+ autoDeployLoading: false,
+ refetchAutoDeploy: mockRefetchAutoDeploy,
+ selection: null,
+ setSelection: jest.fn(),
+ ...contextOverride,
+ }),
+}));
+
+// ---- Helpers ----
+
+const mockClient = createMockOpenChoreoClient();
+const testEntity = mockComponentEntity();
+
+const renderPane = (
+ props: Partial> = {},
+) =>
+ render(
+
+
+
+
+
+
+ ,
+ );
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ readinessOverride = null;
+ permissionOverride = null;
+ contextOverride = {};
+ mockClient.getComponentDetails.mockResolvedValue({ autoDeploy: false });
+});
+
+describe('SetupDetailPane', () => {
+ 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();
+ });
+
+ 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 () => {
+ 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('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('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,
+ loading: false,
+ deniedTooltip: 'You do not have permission to deploy.',
+ };
+
+ renderPane();
+
+ expect(
+ await screen.findByRole('button', { name: /create release/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..cbf15934f 100644
--- a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx
+++ b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx
@@ -1,24 +1,111 @@
-import { useState, useEffect, useCallback } from 'react';
+import { useCallback, 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 ChevronRightIcon from '@material-ui/icons/ChevronRight';
import CloseIcon from '@material-ui/icons/Close';
-import { useApi } from '@backstage/core-plugin-api';
+import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined';
+import SettingsOutlinedIcon from '@material-ui/icons/SettingsOutlined';
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 { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi';
+import { DeployReleasePanel } from './DeployReleasePanel';
+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';
+import { useEnvironmentsContext } from '../EnvironmentsContext';
+import { useConfigureAndDeployPermission } from '@openchoreo/backstage-plugin-react';
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;
@@ -29,9 +116,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,
@@ -42,71 +133,81 @@ export const SetupDetailPane = ({
}: SetupDetailPaneProps) => {
const classes = useEnvironmentDetailPanelStyles();
const { entity } = useEntity();
- const client = useApi(openChoreoClientApiRef);
const notification = useNotification();
+ const {
+ environments,
+ lowestEnvironment,
+ autoDeploy,
+ autoDeployLoading,
+ refetchAutoDeploy,
+ } = useEnvironmentsContext();
const { updateAutoDeploy, isUpdating: autoDeployUpdating } =
useAutoDeployUpdate(entity);
+ const {
+ canConfigureAndDeploy,
+ loading: permissionLoading,
+ deniedTooltip,
+ } = useConfigureAndDeployPermission();
+ const readiness = useReleaseReadiness(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 {
- // Transient fetch failure — leave autoDeploy undefined and let
- // the toggle render with the default. Don't block "loaded".
- } finally {
- if (!cancelled) {
- setAutoDeployLoaded(true);
- }
- }
- };
+ const {
+ releases,
+ loading: releasesLoading,
+ error: releasesError,
+ } = useReleases(entity);
- fetchComponentData();
- return () => {
- cancelled = true;
- };
- }, [entity, client]);
+ const [showAutoDeployConfirm, setShowAutoDeployConfirm] = useState(false);
+ const [pendingAutoDeployValue, setPendingAutoDeployValue] = useState(false);
+ const [selectedReleaseName, setSelectedReleaseName] = useState(
+ null,
+ );
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) {
+ refetchAutoDeploy();
notification.showSuccess(
- `Auto deploy ${newAutoDeploy ? 'enabled' : 'disabled'} successfully`,
+ `Auto deploy ${next ? 'enabled' : 'disabled'} successfully`,
);
} else {
notification.showError('Failed to update auto deploy setting');
}
},
- [updateAutoDeploy, notification],
+ [updateAutoDeploy, refetchAutoDeploy, notification],
);
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 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,25 +217,24 @@ export const SetupDetailPane = ({
Set up
-
-
-
-
-
+
+
+