+
{props.title}
- {props.message}
+
+ {typeof props.description === 'string' ? props.description : ''}
+
),
}));
diff --git a/plugins/openchoreo/src/components/Environments/PipelineDAG/PipelineCanvas.tsx b/plugins/openchoreo/src/components/Environments/PipelineDAG/PipelineCanvas.tsx
index 5d30aa9ef..9de161d54 100644
--- a/plugins/openchoreo/src/components/Environments/PipelineDAG/PipelineCanvas.tsx
+++ b/plugins/openchoreo/src/components/Environments/PipelineDAG/PipelineCanvas.tsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, type FC } from 'react';
import { Box } from '@material-ui/core';
import { Skeleton } from '@material-ui/lab';
import { useEntity } from '@backstage/plugin-catalog-react';
+import { EmptyState } from '@backstage/core-components';
import { useItemActionTracker, useNotification } from '../../../hooks';
import {
@@ -18,17 +19,14 @@ import { EnvironmentDetailPanel, NotificationBanner } from '../components';
import { useEnvironmentsContext, type Selection } from '../EnvironmentsContext';
import { useIncidentsSummary } from '../hooks/useIncidentsSummary';
import { isForbiddenError, getErrorMessage } from '../../../utils/errorUtils';
-import {
- EmptyState,
- ErrorState,
- ForbiddenState,
-} from '@openchoreo/backstage-plugin-react';
+import { ForbiddenState } from '@openchoreo/backstage-plugin-react';
import { Card, useChoreoTokens } from '@openchoreo/backstage-design-system';
import {
useDeployFlowCanvasStyles,
useEnvironmentDetailPanelStyles,
} from '../styles';
import { DeployFlowCanvas } from './DeployFlowCanvas';
+import { NoEnvironmentsEmptyState } from '../components/NoEnvironmentsEmptyState';
/** Placeholder canvas tiles while initial env data loads. */
const CanvasSkeleton: FC = () => {
@@ -406,27 +404,17 @@ export const PipelineCanvas: FC = () => {
environments.length === 0 &&
canViewEnvironments &&
error && (
-
-
-
+
)}
{!loading &&
!environmentReadPermissionLoading &&
!error &&
environments.length === 0 &&
- canViewEnvironments && (
-
-
-
- )}
+ canViewEnvironments &&
}
{showSkeleton && (
diff --git a/plugins/openchoreo/src/components/Environments/components/NoEnvironmentsEmptyState.test.tsx b/plugins/openchoreo/src/components/Environments/components/NoEnvironmentsEmptyState.test.tsx
new file mode 100644
index 000000000..ac8ad5d3d
--- /dev/null
+++ b/plugins/openchoreo/src/components/Environments/components/NoEnvironmentsEmptyState.test.tsx
@@ -0,0 +1,96 @@
+import { screen } from '@testing-library/react';
+import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
+import { EntityProvider } from '@backstage/plugin-catalog-react';
+import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common';
+import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi';
+import { NoEnvironmentsEmptyState } from './NoEnvironmentsEmptyState';
+
+function makeEntity(annotations: Record, name = 'api-service') {
+ return {
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'Component',
+ metadata: { name, namespace: 'default', annotations },
+ };
+}
+
+function renderWith(entity: any, fetchDeploymentPipeline: jest.Mock) {
+ return renderInTestApp(
+
+
+
+
+ ,
+ );
+}
+
+describe('NoEnvironmentsEmptyState', () => {
+ it('links to the resolved deployment pipeline entity (project from annotation)', async () => {
+ const fetchDeploymentPipeline = jest
+ .fn()
+ .mockResolvedValue({ name: 'prod-pipeline' });
+
+ await renderWith(
+ makeEntity({
+ [CHOREO_ANNOTATIONS.PROJECT]: 'my-project',
+ [CHOREO_ANNOTATIONS.NAMESPACE]: 'dev-ns',
+ }),
+ fetchDeploymentPipeline,
+ );
+
+ expect(
+ screen.getByText('No environments available to deploy'),
+ ).toBeInTheDocument();
+
+ const link = await screen.findByRole('link', {
+ name: 'pipeline configuration',
+ });
+ expect(link).toHaveAttribute(
+ 'href',
+ '/catalog/default/deploymentpipeline/prod-pipeline',
+ );
+ expect(fetchDeploymentPipeline).toHaveBeenCalledWith(
+ 'my-project',
+ 'dev-ns',
+ );
+ });
+
+ it('falls back to the entity name as the project when no PROJECT annotation (Project entity)', async () => {
+ const fetchDeploymentPipeline = jest
+ .fn()
+ .mockResolvedValue({ name: 'proj-pipeline' });
+
+ await renderWith(
+ makeEntity({ [CHOREO_ANNOTATIONS.NAMESPACE]: 'dev-ns' }, 'my-project'),
+ fetchDeploymentPipeline,
+ );
+
+ await screen.findByRole('link', { name: 'pipeline configuration' });
+ expect(fetchDeploymentPipeline).toHaveBeenCalledWith(
+ 'my-project',
+ 'dev-ns',
+ );
+ });
+
+ it('degrades to plain guidance when the pipeline cannot be resolved', async () => {
+ const fetchDeploymentPipeline = jest
+ .fn()
+ .mockRejectedValue(new Error('boom'));
+
+ await renderWith(
+ makeEntity({
+ [CHOREO_ANNOTATIONS.PROJECT]: 'my-project',
+ [CHOREO_ANNOTATIONS.NAMESPACE]: 'dev-ns',
+ }),
+ fetchDeploymentPipeline,
+ );
+
+ expect(
+ await screen.findByText(/Contact your administrator to configure it/),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('link', { name: 'pipeline configuration' }),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/plugins/openchoreo/src/components/Environments/components/NoEnvironmentsEmptyState.tsx b/plugins/openchoreo/src/components/Environments/components/NoEnvironmentsEmptyState.tsx
new file mode 100644
index 000000000..d7eba1cbe
--- /dev/null
+++ b/plugins/openchoreo/src/components/Environments/components/NoEnvironmentsEmptyState.tsx
@@ -0,0 +1,65 @@
+import { useEffect, useState } from 'react';
+import { EmptyState, Link } from '@backstage/core-components';
+import { useApi } from '@backstage/core-plugin-api';
+import { useEntity } from '@backstage/plugin-catalog-react';
+import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common';
+import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi';
+
+/**
+ * Deploy empty state shown when a project's deployment pipeline has no
+ * environments configured. Resolves the project's pipeline so users can
+ * jump straight to its entity page to review/configure it, and
+ * degrades to plain guidance if the pipeline can't be resolved.
+ */
+export const NoEnvironmentsEmptyState = () => {
+ const { entity } = useEntity();
+ const client = useApi(openChoreoClientApiRef);
+ const [pipelineUrl, setPipelineUrl] = useState();
+
+ useEffect(() => {
+ let cancelled = false;
+ const projectName =
+ entity.metadata.annotations?.[CHOREO_ANNOTATIONS.PROJECT] ??
+ entity.metadata.name;
+ const namespaceName =
+ entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE];
+ if (!projectName || !namespaceName) return undefined;
+
+ client
+ .fetchDeploymentPipeline(projectName, namespaceName)
+ .then((pipeline: { name?: string }) => {
+ if (cancelled || !pipeline?.name) return;
+ const catalogNamespace = entity.metadata.namespace || 'default';
+ setPipelineUrl(
+ `/catalog/${catalogNamespace}/deploymentpipeline/${pipeline.name}`,
+ );
+ })
+ .catch(() => {
+ // Leave the link out if the pipeline ref can't be resolved.
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [entity, client]);
+
+ return (
+
+ This project's deployment pipeline has no environments configured.{' '}
+ {pipelineUrl ? (
+ <>
+ Review the pipeline configuration or
+ contact your administrator.
+ >
+ ) : (
+ <>Contact your administrator to configure it.>
+ )}
+ >
+ }
+ />
+ );
+};
diff --git a/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.test.tsx b/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.test.tsx
index 581ac2f8f..5e842723b 100644
--- a/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.test.tsx
+++ b/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.test.tsx
@@ -29,28 +29,18 @@ jest.mock('react-router-dom', () => ({
jest.mock('@backstage/core-components', () => ({
Progress: () => loading
,
+ EmptyState: ({ missing, title, description }: any) => (
+
+ {title}
+ {typeof description === 'string' ? description : ''}
+
+ ),
}));
jest.mock('@openchoreo/backstage-plugin-react', () => ({
ForbiddenState: ({ message }: any) => (
{message}
),
- EmptyState: ({ title, description }: any) => (
-
- {title}
- {description}
-
- ),
- ErrorState: ({ title, message }: any) => (
-
- {title}
- {message}
-
- ),
-}));
-
-jest.mock('@openchoreo/backstage-design-system', () => ({
- Card: ({ children }: any) => {children}
,
}));
jest.mock('../../utils/errorUtils', () => ({
@@ -70,6 +60,14 @@ jest.mock('../Environments/components', () => ({
NotificationBanner: () => null,
}));
+jest.mock('../Environments/components/NoEnvironmentsEmptyState', () => ({
+ NoEnvironmentsEmptyState: () => (
+
+ This project's deployment pipeline has no environments configured.
+
+ ),
+}));
+
jest.mock('../Environments/hooks', () => ({
useEnvironmentPolling: () => {},
}));
diff --git a/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.tsx b/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.tsx
index 736d9df91..6e5252224 100644
--- a/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.tsx
+++ b/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.tsx
@@ -1,15 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box } from '@material-ui/core';
import { useNavigate } from 'react-router-dom';
-import { Progress } from '@backstage/core-components';
+import { Progress, EmptyState } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { useEntity } from '@backstage/plugin-catalog-react';
-import {
- EmptyState,
- ErrorState,
- ForbiddenState,
-} from '@openchoreo/backstage-plugin-react';
-import { Card } from '@openchoreo/backstage-design-system';
+import { ForbiddenState } from '@openchoreo/backstage-plugin-react';
import {
openChoreoClientApiRef,
type ProjectEnvironment,
@@ -17,6 +12,7 @@ import {
import { isForbiddenError, getErrorMessage } from '../../utils/errorUtils';
import { useNotification } from '../../hooks';
import { NotificationBanner } from '../Environments/components';
+import { NoEnvironmentsEmptyState } from '../Environments/components/NoEnvironmentsEmptyState';
import { useEnvironmentPolling } from '../Environments/hooks';
import { ProjectDeployFlowCanvas } from './ProjectDeployFlowCanvas';
import { ProjectEnvironmentDetailPanel } from './ProjectEnvironmentDetailPanel';
@@ -183,29 +179,16 @@ export const ProjectEnvironmentsList = () => {
if (error) {
return (
-
- fetchEnvs({ showProgress: true })}
- />
-
+
);
}
if (envs.length === 0) {
- return (
-
- fetchEnvs({ showProgress: true }),
- }}
- />
-
- );
+ return ;
}
return (
diff --git a/plugins/openchoreo/src/components/ResourceEnvironments/ResourceEnvironments.test.tsx b/plugins/openchoreo/src/components/ResourceEnvironments/ResourceEnvironments.test.tsx
index edf58ed17..e866fd7c8 100644
--- a/plugins/openchoreo/src/components/ResourceEnvironments/ResourceEnvironments.test.tsx
+++ b/plugins/openchoreo/src/components/ResourceEnvironments/ResourceEnvironments.test.tsx
@@ -9,6 +9,12 @@ import { ResourceEnvironmentsList } from './ResourceEnvironmentsList';
jest.mock('@backstage/core-components', () => ({
Progress: () => ,
+ EmptyState: ({ missing, title, description }: any) => (
+
+ {title}
+ {typeof description === 'string' ? description : ''}
+
+ ),
}));
jest.mock('@openchoreo/backstage-design-system', () => ({
@@ -100,6 +106,14 @@ jest.mock('../Environments/components', () => ({
) : null,
}));
+jest.mock('../Environments/components/NoEnvironmentsEmptyState', () => ({
+ NoEnvironmentsEmptyState: () => (
+
+ This project's deployment pipeline has no environments configured.
+
+ ),
+}));
+
function makeEntity(): Entity {
return {
apiVersion: 'backstage.io/v1alpha1',
diff --git a/plugins/openchoreo/src/components/ResourceEnvironments/ResourceEnvironmentsList.tsx b/plugins/openchoreo/src/components/ResourceEnvironments/ResourceEnvironmentsList.tsx
index 99cba679f..596c85abf 100644
--- a/plugins/openchoreo/src/components/ResourceEnvironments/ResourceEnvironmentsList.tsx
+++ b/plugins/openchoreo/src/components/ResourceEnvironments/ResourceEnvironmentsList.tsx
@@ -1,15 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box } from '@material-ui/core';
import { useNavigate } from 'react-router-dom';
-import { Progress } from '@backstage/core-components';
+import { Progress, EmptyState } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { useEntity } from '@backstage/plugin-catalog-react';
-import {
- EmptyState,
- ErrorState,
- ForbiddenState,
-} from '@openchoreo/backstage-plugin-react';
-import { Card } from '@openchoreo/backstage-design-system';
+import { ForbiddenState } from '@openchoreo/backstage-plugin-react';
import {
openChoreoClientApiRef,
type ResourceEnvironment,
@@ -17,6 +12,7 @@ import {
import { isForbiddenError, getErrorMessage } from '../../utils/errorUtils';
import { useNotification } from '../../hooks';
import { NotificationBanner } from '../Environments/components';
+import { NoEnvironmentsEmptyState } from '../Environments/components/NoEnvironmentsEmptyState';
import { useEnvironmentPolling } from '../Environments/hooks';
import { ResourceDeployFlowCanvas } from './ResourceDeployFlowCanvas';
import { ResourceEnvironmentDetailPanel } from './ResourceEnvironmentDetailPanel';
@@ -314,29 +310,16 @@ export const ResourceEnvironmentsList = () => {
if (error) {
return (
-
- fetchEnvs({ showProgress: true })}
- />
-
+
);
}
if (envs.length === 0) {
- return (
-
- fetchEnvs({ showProgress: true }),
- }}
- />
-
- );
+ return ;
}
return (