Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rendered-release-events-spec-tabs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@openchoreo/backstage-plugin': minor
---

Show **Events** and **Spec** tabs on the release details page when a rendered release is selected in the resource tree. The Events tab surfaces release-level Kubernetes events (reusing the existing events table and API endpoint), and the Spec tab renders the full rendered release spec as YAML.
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ReleaseDetailTabs } from './ReleaseDetailTabs';
import type { LayoutNode } from './treeTypes';

// Mock design-system YamlViewer
jest.mock('@openchoreo/backstage-design-system', () => ({
YamlViewer: ({ value }: { value: string }) => (
<pre data-testid="yaml-viewer">{value}</pre>
),
}));

// Mock the events table to isolate the tab container logic
jest.mock('./ResourceEventsTable', () => ({
ResourceEventsTable: ({
node,
refreshKey,
}: {
node: { name: string };
refreshKey?: number;
}) => (
<div data-testid="resource-events-table">
{node.name}:{refreshKey ?? 0}
</div>
),
}));

function makeReleaseNode(overrides: Partial<LayoutNode> = {}): LayoutNode {
return {
id: '__release__my-release',
kind: 'RenderedRelease',
name: 'my-release',
group: 'openchoreo.dev',
version: 'v1alpha1',
targetPlane: 'dataplane',
parentIds: ['__release_binding__'],
specObject: {
apiVersion: 'openchoreo.dev/v1alpha1',
kind: 'RenderedRelease',
},
x: 0,
y: 0,
width: 200,
height: 50,
...overrides,
};
}

describe('ReleaseDetailTabs', () => {
const defaultProps = {
namespaceName: 'default-ns',
releaseBindingName: 'rb-name',
};

it('shows the events table on the default (Events) tab', () => {
render(<ReleaseDetailTabs {...defaultProps} node={makeReleaseNode()} />);

expect(screen.getByTestId('resource-events-table')).toHaveTextContent(
'my-release:0',
);
expect(screen.queryByTestId('yaml-viewer')).not.toBeInTheDocument();
});

it('renders the release spec as YAML on the Spec tab', async () => {
render(<ReleaseDetailTabs {...defaultProps} node={makeReleaseNode()} />);

await userEvent.click(screen.getByRole('tab', { name: 'Spec' }));

const viewer = screen.getByTestId('yaml-viewer');
expect(viewer).toHaveTextContent('kind: RenderedRelease');
expect(
screen.queryByTestId('resource-events-table'),
).not.toBeInTheDocument();
});

it('shows an empty state on the Spec tab when no spec is available', async () => {
render(
<ReleaseDetailTabs
{...defaultProps}
node={makeReleaseNode({ specObject: undefined })}
/>,
);

await userEvent.click(screen.getByRole('tab', { name: 'Spec' }));

expect(screen.getByText('No release spec available')).toBeInTheDocument();
expect(screen.queryByTestId('yaml-viewer')).not.toBeInTheDocument();
});

it('surfaces the target plane as a chip when present', () => {
render(<ReleaseDetailTabs {...defaultProps} node={makeReleaseNode()} />);

expect(screen.getByText('Target: dataplane')).toBeInTheDocument();
});

it('omits the target plane chip when the node has none', () => {
render(
<ReleaseDetailTabs
{...defaultProps}
node={makeReleaseNode({ targetPlane: undefined })}
/>,
);

expect(screen.queryByText(/^Target:/)).not.toBeInTheDocument();
});

it('bumps the events refresh key when the refresh button is clicked', async () => {
render(<ReleaseDetailTabs {...defaultProps} node={makeReleaseNode()} />);

expect(screen.getByTestId('resource-events-table')).toHaveTextContent(
'my-release:0',
);

// The only role="button" in the Events tab is the refresh icon button
// (tabs use role="tab").
await userEvent.click(screen.getByRole('button'));

expect(screen.getByTestId('resource-events-table')).toHaveTextContent(
'my-release:1',
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { useState, useEffect, useCallback, type FC } from 'react';
import {
Box,
Tabs,
Tab,
Typography,
IconButton,
Tooltip,
Chip,
} from '@material-ui/core';
import RefreshIcon from '@material-ui/icons/Refresh';
import YAML from 'yaml';
import { YamlViewer } from '@openchoreo/backstage-design-system';
import { useTreeStyles } from './treeStyles';
import { ResourceEventsTable } from './ResourceEventsTable';
import type { LayoutNode } from './treeTypes';

const TABS = [
{ id: 'events', label: 'Events' },
{ id: 'spec', label: 'Spec' },
] as const;

interface ReleaseDetailTabsProps {
node: LayoutNode;
namespaceName: string;
releaseBindingName: string;
}

/**
* Detail tabs shown when a rendered release node is selected in the resource
* tree. Surfaces the release's own Kubernetes events and full spec (YAML),
* reusing the same components used for individual resources.
*/
export const ReleaseDetailTabs: FC<ReleaseDetailTabsProps> = ({
node,
namespaceName,
releaseBindingName,
}) => {
const classes = useTreeStyles();
const [activeTab, setActiveTab] = useState(0);
const [refreshKey, setRefreshKey] = useState(0);

// Reset tab when switching to a different node
useEffect(() => {
setActiveTab(0);
}, [node.id]);

const currentTab = TABS[activeTab]?.id;

const handleRefresh = useCallback(() => {
setRefreshKey(prev => prev + 1);
}, []);

return (
<>
<Box display="flex" alignItems="center">
<Tabs
value={activeTab}
onChange={(_, newValue) => setActiveTab(newValue)}
indicatorColor="primary"
textColor="primary"
className={classes.drawerTabs}
style={{ flex: 1 }}
>
{TABS.map(tab => (
<Tab key={tab.id} label={tab.label} />
))}
</Tabs>
{node.targetPlane && (
<Chip
label={`Target: ${node.targetPlane}`}
size="small"
variant="outlined"
style={{ marginRight: 8 }}
/>
)}
{currentTab === 'events' && (
<Tooltip title="Refresh">
<IconButton size="small" onClick={handleRefresh}>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>

<Box className={classes.drawerTabContent}>
{currentTab === 'events' && (
<ResourceEventsTable
node={node}
namespaceName={namespaceName}
releaseBindingName={releaseBindingName}
refreshKey={refreshKey}
/>
)}

{currentTab === 'spec' && (
<>
{node.specObject ? (
<YamlViewer
value={YAML.stringify(node.specObject)}
maxHeight="auto"
/>
) : (
<Box className={classes.drawerEmptyState}>
<Typography variant="body2" color="textSecondary">
No release spec available
</Typography>
</Box>
)}
</>
)}
</Box>
</>
);
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ jest.mock('./ResourceDetailTabs', () => ({
),
}));

jest.mock('./ReleaseDetailTabs', () => ({
ReleaseDetailTabs: ({ node }: { node: { kind: string; name: string } }) => (
<div data-testid="release-detail-tabs">
{node.kind}/{node.name}
</div>
),
}));

jest.mock('./ResourceKindIcon', () => ({
ResourceKindIcon: ({ kind }: { kind: string }) => (
<span data-testid="kind-icon">{kind}</span>
Expand Down Expand Up @@ -145,24 +153,24 @@ describe('ResourceDetailPanel', () => {
).not.toBeInTheDocument();
});

it('renders release name and target plane for RenderedRelease node', () => {
it('renders ReleaseDetailTabs for RenderedRelease node', () => {
const node = makeLayoutNode({
kind: 'RenderedRelease',
name: 'my-release',
version: 'dataplane-1',
group: 'openchoreo.dev',
version: 'v1alpha1',
targetPlane: 'dataplane-1',
isRoot: false,
});

render(<ResourceDetailPanel {...defaultProps} node={node} />);

expect(screen.getByText('Release Name')).toBeInTheDocument();
// "my-release" appears in header and body
expect(screen.getAllByText('my-release')).toHaveLength(2);
expect(screen.getByText('Target Plane')).toBeInTheDocument();
// "dataplane-1" appears in metadata chip (version) and in body text
expect(screen.getAllByText('dataplane-1').length).toBeGreaterThanOrEqual(
1,
expect(screen.getByTestId('release-detail-tabs')).toHaveTextContent(
'RenderedRelease/my-release',
);
expect(
screen.queryByTestId('resource-detail-tabs'),
).not.toBeInTheDocument();
});

it('renders ResourceDetailTabs for regular resource nodes', () => {
Expand Down Expand Up @@ -202,8 +210,9 @@ describe('ResourceDetailPanel', () => {

render(<ResourceDetailPanel {...defaultProps} node={node} />);

expect(screen.queryByText('Release Name')).not.toBeInTheDocument();
expect(screen.queryByText('Target Plane')).not.toBeInTheDocument();
expect(
screen.queryByTestId('release-detail-tabs'),
).not.toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import CloseIcon from '@material-ui/icons/Close';
import { ResourceKindIcon } from './ResourceKindIcon';
import { ResourceDetailTabs } from './ResourceDetailTabs';
import { ReleaseDetailTabs } from './ReleaseDetailTabs';
import { ReleaseBindingDetailTabs } from './ReleaseBindingDetailTabs';
import { useTreeStyles } from './treeStyles';
import type { LayoutNode } from './treeTypes';
Expand Down Expand Up @@ -86,22 +87,11 @@ export const ResourceDetailPanel: FC<ResourceDetailPanelProps> = ({
<ReleaseBindingDetailTabs releaseBindingData={releaseBindingData} />
)}
{!node.isRoot && node.kind === 'RenderedRelease' && (
<Box padding={2}>
<Typography variant="subtitle2" gutterBottom>
Release Name
</Typography>
<Typography variant="body2" gutterBottom>
{node.name}
</Typography>
<Typography
variant="subtitle2"
gutterBottom
style={{ marginTop: 16 }}
>
Target Plane
</Typography>
<Typography variant="body2">{node.version}</Typography>
</Box>
<ReleaseDetailTabs
node={node}
namespaceName={namespaceName}
releaseBindingName={releaseBindingName}
/>
)}
{!node.isRoot && node.kind !== 'RenderedRelease' && (
<ResourceDetailTabs
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { render, screen } from '@testing-library/react';
import { ResourceTreeNode } from './ResourceTreeNode';
import type { LayoutNode } from './treeTypes';

function makeNode(overrides: Partial<LayoutNode> = {}): LayoutNode {
return {
id: 'node-1',
kind: 'RenderedRelease',
name: 'my-release',
targetPlane: 'dataplane',
parentIds: [],
x: 0,
y: 0,
width: 200,
height: 50,
...overrides,
};
}

describe('ResourceTreeNode', () => {
const defaultProps = {
isSelected: false,
onClick: jest.fn(),
};

it('shows the target plane subtitle for a RenderedRelease node', () => {
render(<ResourceTreeNode {...defaultProps} node={makeNode()} />);

expect(screen.getByText('Target plane: dataplane')).toBeInTheDocument();
});

it('omits the target plane subtitle when targetPlane is absent', () => {
render(
<ResourceTreeNode
{...defaultProps}
node={makeNode({ targetPlane: undefined })}
/>,
);

expect(screen.queryByText(/^Target plane:/)).not.toBeInTheDocument();
});

it('does not show the target plane subtitle for non-release kinds', () => {
render(
<ResourceTreeNode
{...defaultProps}
node={makeNode({ kind: 'Deployment' })}
/>,
);

expect(screen.queryByText(/^Target plane:/)).not.toBeInTheDocument();
});
});
Loading
Loading