Skip to content
Merged
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
131 changes: 131 additions & 0 deletions src/components/EntityDetailPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Copyright 2026 bburda
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { EntityDetailPanel } from './EntityDetailPanel';

// Mock heavy child components - we only care about the top-level routing
// (header + tab bar + tab content selection) for these tests.
vi.mock('@/components/DataPanel', () => ({ DataPanel: () => <div data-testid="data-panel" /> }));
vi.mock('@/components/ConfigurationPanel', () => ({ ConfigurationPanel: () => <div data-testid="config-panel" /> }));
vi.mock('@/components/OperationsPanel', () => ({ OperationsPanel: () => <div data-testid="ops-panel" /> }));
vi.mock('@/components/AreasPanel', () => ({ AreasPanel: () => <div data-testid="areas-panel" /> }));
vi.mock('@/components/AppsPanel', () => ({ AppsPanel: () => <div data-testid="apps-panel" /> }));
vi.mock('@/components/FunctionsPanel', () => ({ FunctionsPanel: () => <div data-testid="functions-panel" /> }));
vi.mock('@/components/ServerInfoPanel', () => ({ ServerInfoPanel: () => <div data-testid="server-panel" /> }));
vi.mock('@/components/FaultsDashboard', () => ({ FaultsDashboard: () => <div data-testid="faults-dashboard" /> }));
vi.mock('@/components/UpdatesDashboard', () => ({ UpdatesDashboard: () => <div data-testid="updates-dashboard" /> }));
vi.mock('@/components/EmptyState', () => ({ EmptyState: () => <div data-testid="empty-state" /> }));
vi.mock('@/components/EntityDetailSkeleton', () => ({ EntityDetailSkeleton: () => <div data-testid="skeleton" /> }));
vi.mock('@/components/ResourceTabs', async () => {
const actual = await vi.importActual<typeof import('./ResourceTabs')>('@/components/ResourceTabs');
return {
...actual,
renderResourceTabContent: (tab: string) => <div data-testid={`tab-content-${tab}`} />,
};
});

const mockPrefetchResourceCounts = vi.fn();
const mockFetchEntityData = vi.fn();
const mockSelectEntity = vi.fn();
const mockRefreshSelectedEntity = vi.fn();

let storeState: Record<string, unknown> = {};

vi.mock('@/lib/store', () => ({
useAppStore: vi.fn((selector: (s: Record<string, unknown>) => unknown) => selector(storeState)),
// The breadcrumb builder resolves segment types via findNode; these tests
// don't load a tree, so it always falls back to position-based inference.
findNode: () => null,
}));

function setStore(overrides: Record<string, unknown>) {
storeState = {
selectedPath: null,
selectedEntity: null,
rootEntities: [],
isLoadingDetails: false,
isRefreshing: false,
isConnected: true,
selectEntity: mockSelectEntity,
refreshSelectedEntity: mockRefreshSelectedEntity,
prefetchResourceCounts: mockPrefetchResourceCounts,
fetchEntityData: mockFetchEntityData,
...overrides,
};
}

describe('EntityDetailPanel - nested entity types', () => {
beforeEach(() => {
vi.clearAllMocks();
mockPrefetchResourceCounts.mockResolvedValue({ data: 0, operations: 0, configurations: 0, faults: 0, logs: 0 });
mockFetchEntityData.mockResolvedValue([]);
});

it('renders resource tabs and fetches counts as components for subcomponent entity', async () => {
setStore({
selectedPath: '/server/area1/component1/planning-ecu',
selectedEntity: {
id: 'planning-ecu',
name: 'planning-ecu',
type: 'subcomponent',
},
});

render(<EntityDetailPanel onConnectClick={() => {}} />);

// Bug repro: subcomponent should fetch resource counts using the
// 'components' entity type (gateway routes subcomponents through
// /api/v1/components/{id}/...).
await waitFor(() => {
expect(mockPrefetchResourceCounts).toHaveBeenCalledWith('components', 'planning-ecu', expect.anything());
});

// Bug repro: subcomponent should render the resource tab bar
// (Data / Operations / Config / Faults / Logs) just like a component.
expect(screen.getByRole('button', { name: /Data/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Operations/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Config/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Faults/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Logs/ })).toBeInTheDocument();

// The fallback "No detailed information available" must not appear.
expect(screen.queryByText(/No detailed information available/i)).not.toBeInTheDocument();
});

it('renders the area panel and fetches counts as areas for subarea entity', async () => {
setStore({
selectedPath: '/server/area1/zone-a',
selectedEntity: {
id: 'zone-a',
name: 'zone-a',
type: 'subarea',
},
});

render(<EntityDetailPanel onConnectClick={() => {}} />);

// Bug repro: subarea should fetch resource counts using the 'areas'
// entity type (gateway routes subareas through /api/v1/areas/{id}/...).
await waitFor(() => {
expect(mockPrefetchResourceCounts).toHaveBeenCalledWith('areas', 'zone-a', expect.anything());
});

// Bug repro: subarea should render the area panel just like an area,
// not the "No detailed information available" fallback.
expect(screen.getByTestId('areas-panel')).toBeInTheDocument();
expect(screen.queryByText(/No detailed information available/i)).not.toBeInTheDocument();
});
});
41 changes: 29 additions & 12 deletions src/components/EntityDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { FunctionsPanel } from '@/components/FunctionsPanel';
import { ServerInfoPanel } from '@/components/ServerInfoPanel';
import { FaultsDashboard } from '@/components/FaultsDashboard';
import { UpdatesDashboard } from '@/components/UpdatesDashboard';
import { useAppStore, type AppState } from '@/lib/store';
import { useAppStore, findNode, type AppState } from '@/lib/store';
import type { ComponentTopic, Parameter, SovdResourceEntityType } from '@/lib/types';

type ComponentTab = ResourceTabId;
Expand All @@ -53,10 +53,12 @@ function getEntityTypeForApi(entityType: string | undefined): SovdResourceEntity
case 'app':
return 'apps';
case 'component':
case 'subcomponent':
Comment thread
bburda marked this conversation as resolved.
return 'components';
case 'function':
return 'functions';
case 'area':
case 'subarea':
return 'areas';
default:
return 'components'; // default fallback
Expand All @@ -71,8 +73,10 @@ function getBreadcrumbIcon(type: string) {
case 'server':
return <Server className="w-3 h-3" />;
case 'area':
case 'subarea':
return <Layers className="w-3 h-3" />;
case 'component':
case 'subcomponent':
return <Box className="w-3 h-3" />;
case 'app':
return <Cpu className="w-3 h-3" />;
Expand Down Expand Up @@ -382,6 +386,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
const {
selectedPath,
selectedEntity,
rootEntities,
isLoadingDetails,
isRefreshing,
isConnected,
Expand All @@ -393,6 +398,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
useShallow((state: AppState) => ({
selectedPath: state.selectedPath,
selectedEntity: state.selectedEntity,
rootEntities: state.rootEntities,
isLoadingDetails: state.isLoadingDetails,
isRefreshing: state.isRefreshing,
isConnected: state.isConnected,
Expand Down Expand Up @@ -444,9 +450,9 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
}

const entityId = selectedEntity.id;
const isComponent = selectedEntity.type === 'component';
const isComponent = selectedEntity.type === 'component' || selectedEntity.type === 'subcomponent';
Comment thread
bburda marked this conversation as resolved.
const isApp = selectedEntity.type === 'app';
const isArea = selectedEntity.type === 'area';
const isArea = selectedEntity.type === 'area' || selectedEntity.type === 'subarea';
const isFunction = selectedEntity.type === 'function';

// Only fetch counts for entity types that have resources
Expand Down Expand Up @@ -554,8 +560,8 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
// Entity detail view
if (selectedEntity) {
const isTopic = selectedEntity.type === 'topic';
const isComponent = selectedEntity.type === 'component';
const isArea = selectedEntity.type === 'area';
const isComponent = selectedEntity.type === 'component' || selectedEntity.type === 'subcomponent';
const isArea = selectedEntity.type === 'area' || selectedEntity.type === 'subarea';
const isApp = selectedEntity.type === 'app';
const isFunction = selectedEntity.type === 'function';
const isServer = selectedEntity.type === 'server';
Expand Down Expand Up @@ -626,8 +632,10 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
case 'server':
return <Server className="w-6 h-6 text-primary" />;
case 'area':
case 'subarea':
return <Layers className="w-6 h-6 text-cyan-500" />;
case 'component':
case 'subcomponent':
Comment thread
bburda marked this conversation as resolved.
return <Box className="w-6 h-6 text-indigo-500" />;
Comment thread
bburda marked this conversation as resolved.
case 'app':
return <Cpu className="w-6 h-6 text-emerald-500" />;
Expand All @@ -644,8 +652,10 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
case 'server':
return 'bg-primary/10';
case 'area':
case 'subarea':
return 'bg-cyan-100 dark:bg-cyan-900';
case 'component':
case 'subcomponent':
return 'bg-indigo-100 dark:bg-indigo-900';
case 'app':
return 'bg-emerald-100 dark:bg-emerald-900';
Expand All @@ -656,23 +666,30 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
}
};

// Build breadcrumb from path with type inference
// Build breadcrumb from path. Prefer the real entity type from the
// loaded tree so nested types (subarea / subcomponent) and deeper
// hierarchies get the correct icon; fall back to position-based
// inference for segments not present in the tree.
const breadcrumbs = pathParts.map((part, index) => {
const breadcrumbPath = '/' + pathParts.slice(0, index + 1).join('/');
// Decode URL-encoded parts for display
const decodedPart = decodeURIComponent(part);
// Infer type from path position: server -> area -> component -> app/folder
let type: string;
if (part === 'server') {
type = 'server';
} else if (index === 1) {
type = 'area';
} else if (index === 2) {
type = 'component';
} else if (['data', 'operations', 'configurations', 'faults', 'resources'].includes(part)) {
type = 'folder';
} else {
type = 'app';
const node = findNode(rootEntities, breadcrumbPath);
if (node) {
type = node.type;
} else if (index === 1) {
type = 'area';
} else if (index === 2) {
type = 'component';
} else {
type = 'app';
}
}
return {
label: decodedPart,
Expand Down
6 changes: 5 additions & 1 deletion src/components/SearchCommand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@ function flattenTree(nodes: EntityTreeNode[]): EntityTreeNode[] {
function getEntityIcon(type: string) {
switch (type) {
case 'area':
case 'subarea':
return Layers;
case 'component':
case 'subcomponent':
return Box;
case 'app':
return Cpu;
Expand All @@ -57,8 +59,10 @@ function getEntityIcon(type: string) {
function getEntityColorClass(type: string): string {
switch (type) {
case 'area':
case 'subarea':
return 'text-cyan-500';
case 'component':
case 'subcomponent':
return 'text-indigo-500';
case 'app':
return 'text-emerald-500';
Expand Down Expand Up @@ -154,7 +158,7 @@ export function SearchCommand({ open, onOpenChange }: SearchCommandProps) {
<CommandEmpty>No entities found.</CommandEmpty>

{/* Group by entity type */}
{['area', 'component', 'app', 'function'].map((type) => {
{['area', 'subarea', 'component', 'subcomponent', 'app', 'function'].map((type) => {
const typeEntities = filteredEntities.filter((e) => e.type === type);
if (typeEntities.length === 0) return null;

Expand Down
Loading