From 9e5f28a4f456bcb8a0c22e404c9a0eb01707ecdd Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 3 May 2026 13:16:16 +0200 Subject: [PATCH] fix: render SOVD resources for subcomponent entity type Subcomponent entities served via /api/v1/components/{id} expose the full resource set (data, operations, configurations, faults, logs) but EntityDetailPanel and refreshSelectedEntity only branched on type === 'component', leaving subcomponents with a bare header and the "No detailed information available" fallback. Treat 'subcomponent' the same as 'component' for resource fetching, tab rendering, type-icon / background, and refresh routing. --- src/components/EntityDetailPanel.test.tsx | 103 ++++++++++++++++++++++ src/components/EntityDetailPanel.tsx | 7 +- src/lib/store.ts | 1 + 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 src/components/EntityDetailPanel.test.tsx diff --git a/src/components/EntityDetailPanel.test.tsx b/src/components/EntityDetailPanel.test.tsx new file mode 100644 index 0000000..b1993a4 --- /dev/null +++ b/src/components/EntityDetailPanel.test.tsx @@ -0,0 +1,103 @@ +// 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: () =>
})); +vi.mock('@/components/ConfigurationPanel', () => ({ ConfigurationPanel: () =>
})); +vi.mock('@/components/OperationsPanel', () => ({ OperationsPanel: () =>
})); +vi.mock('@/components/AreasPanel', () => ({ AreasPanel: () =>
})); +vi.mock('@/components/AppsPanel', () => ({ AppsPanel: () =>
})); +vi.mock('@/components/FunctionsPanel', () => ({ FunctionsPanel: () =>
})); +vi.mock('@/components/ServerInfoPanel', () => ({ ServerInfoPanel: () =>
})); +vi.mock('@/components/FaultsDashboard', () => ({ FaultsDashboard: () =>
})); +vi.mock('@/components/UpdatesDashboard', () => ({ UpdatesDashboard: () =>
})); +vi.mock('@/components/EmptyState', () => ({ EmptyState: () =>
})); +vi.mock('@/components/EntityDetailSkeleton', () => ({ EntityDetailSkeleton: () =>
})); +vi.mock('@/components/ResourceTabs', async () => { + const actual = await vi.importActual('@/components/ResourceTabs'); + return { + ...actual, + renderResourceTabContent: (tab: string) =>
, + }; +}); + +const mockPrefetchResourceCounts = vi.fn(); +const mockFetchEntityData = vi.fn(); +const mockSelectEntity = vi.fn(); +const mockRefreshSelectedEntity = vi.fn(); + +let storeState: Record = {}; + +vi.mock('@/lib/store', () => ({ + useAppStore: vi.fn((selector: (s: Record) => unknown) => selector(storeState)), +})); + +function setStore(overrides: Record) { + storeState = { + selectedPath: null, + selectedEntity: null, + isLoadingDetails: false, + isRefreshing: false, + isConnected: true, + selectEntity: mockSelectEntity, + refreshSelectedEntity: mockRefreshSelectedEntity, + prefetchResourceCounts: mockPrefetchResourceCounts, + fetchEntityData: mockFetchEntityData, + ...overrides, + }; +} + +describe('EntityDetailPanel - subcomponent entity type', () => { + 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( {}} />); + + // 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(); + }); +}); diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx index 12baed2..fc4a74b 100644 --- a/src/components/EntityDetailPanel.tsx +++ b/src/components/EntityDetailPanel.tsx @@ -53,6 +53,7 @@ function getEntityTypeForApi(entityType: string | undefined): SovdResourceEntity case 'app': return 'apps'; case 'component': + case 'subcomponent': return 'components'; case 'function': return 'functions'; @@ -444,7 +445,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit } const entityId = selectedEntity.id; - const isComponent = selectedEntity.type === 'component'; + const isComponent = selectedEntity.type === 'component' || selectedEntity.type === 'subcomponent'; const isApp = selectedEntity.type === 'app'; const isArea = selectedEntity.type === 'area'; const isFunction = selectedEntity.type === 'function'; @@ -554,7 +555,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit // Entity detail view if (selectedEntity) { const isTopic = selectedEntity.type === 'topic'; - const isComponent = selectedEntity.type === 'component'; + const isComponent = selectedEntity.type === 'component' || selectedEntity.type === 'subcomponent'; const isArea = selectedEntity.type === 'area'; const isApp = selectedEntity.type === 'app'; const isFunction = selectedEntity.type === 'function'; @@ -628,6 +629,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit case 'area': return ; case 'component': + case 'subcomponent': return ; case 'app': return ; @@ -646,6 +648,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit case 'area': 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'; diff --git a/src/lib/store.ts b/src/lib/store.ts index 37fcd0a..3cfef93 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -1326,6 +1326,7 @@ export const useAppStore = create()( const typeMap: Record = { area: 'areas', component: 'components', + subcomponent: 'components', app: 'apps', function: 'functions', };