diff --git a/spring-boot-admin-server-ui/src/main/frontend/components/sba-icon-button.vue b/spring-boot-admin-server-ui/src/main/frontend/components/sba-icon-button.vue index c18f7917b2b..2c76685730f 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/components/sba-icon-button.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/components/sba-icon-button.vue @@ -26,10 +26,13 @@ - diff --git a/spring-boot-admin-server-ui/src/main/frontend/test-utils.ts b/spring-boot-admin-server-ui/src/main/frontend/test-utils.ts index dfa598da467..ffcf1d48a89 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/test-utils.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/test-utils.ts @@ -18,7 +18,8 @@ const modules: Record = import.meta.glob('@/**/i18n.en.json', { eager: true, }); for (const modulesKey in modules) { - terms = { ...terms, ...modules[modulesKey] }; + const moduleContent = modules[modulesKey].default || modules[modulesKey]; + terms = { ...terms, ...moduleContent }; } export let router; createViewRegistry(); diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/__snapshots__/health-details.spec.ts.snap b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/__snapshots__/health-details.spec.ts.snap index 0ad277d1391..673cf2a8713 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/__snapshots__/health-details.spec.ts.snap +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/__snapshots__/health-details.spec.ts.snap @@ -2,8 +2,11 @@ exports[`HealthDetails > Health .details > should format object details correctly 1`] = ` - + - + - : - + : + + - + diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/health-details.spec.ts b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/health-details.spec.ts index 9b37ddd4b61..9cbeb2d279d 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/health-details.spec.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/health-details.spec.ts @@ -1,12 +1,38 @@ +import userEvent from '@testing-library/user-event'; import { screen, within } from '@testing-library/vue'; import { beforeEach, describe, expect, it } from 'vitest'; +import Instance from '@/services/instance'; import { render } from '@/test-utils'; import HealthDetails from '@/views/instances/details/health-details.vue'; describe('HealthDetails', () => { + const mockInstance = { + id: 'test-instance-123', + } as Instance; + describe('Health .details', () => { beforeEach(() => { + // Clear localStorage and set all details to expanded for these tests + localStorage.clear(); + // These tests expect details to be visible by default + // We need to set localStorage for all health components that will be rendered + const componentsToExpand = [ + 'clientConfigServer', + 'db', + 'discoveryComposite', + 'discoveryClient', + 'diskSpace', + 'diskSpace2', + 'ssl', + ]; + componentsToExpand.forEach((name) => { + localStorage.setItem( + `de.codecentric.spring-boot-admin.health-details.${name}.test-instance-123.collapsed`, + 'false', + ); + }); + const healthMock = { status: 'UP', details: { @@ -64,6 +90,7 @@ describe('HealthDetails', () => { props: { name: 'Name', health: healthMock, + instance: mockInstance, }, }); }); @@ -77,7 +104,7 @@ describe('HealthDetails', () => { `( 'should display health components status', async ({ componentId, status }) => { - const clientConfigServer = await screen.findByRole('definition', { + const clientConfigServer = await screen.findByRole('group', { name: componentId, }); expect( @@ -87,7 +114,7 @@ describe('HealthDetails', () => { ); it('should format diskSpace details correctly', async () => { - const diskSpaceInfo = await screen.findByRole('definition', { + const diskSpaceInfo = await screen.findByRole('group', { name: 'diskSpace2', }); @@ -116,7 +143,7 @@ describe('HealthDetails', () => { it('should format object details correctly', async () => { const sslInfo = await screen.findByRole('definition', { - name: 'validChains', + name: 'validChains', // inner within ssl group }); expect(sslInfo).toMatchSnapshot(); }); @@ -124,6 +151,22 @@ describe('HealthDetails', () => { describe('Health .components', () => { beforeEach(() => { + // Clear localStorage and set all components to expanded for these tests + localStorage.clear(); + // These tests expect components to be visible by default + const componentsToExpand = [ + 'clientConfigServer', + 'discoveryComposite', + 'discoveryClient', + 'diskSpace', + ]; + componentsToExpand.forEach((name) => { + localStorage.setItem( + `de.codecentric.spring-boot-admin.health-details.${name}.test-instance-123.collapsed`, + 'false', + ); + }); + const healthMock = { status: 'UP', components: { @@ -156,13 +199,15 @@ describe('HealthDetails', () => { render(HealthDetails, { props: { health: healthMock, + name: 'root', + instance: mockInstance, }, }); }); it('should display health status', async () => { expect( - screen.getByRole('definition', { name: 'clientConfigServer' }), + screen.getByRole('group', { name: 'clientConfigServer' }), ).toBeInTheDocument(); }); @@ -175,7 +220,7 @@ describe('HealthDetails', () => { `( 'should display health components status', async ({ componentId, status }) => { - const clientConfigServer = await screen.findByRole('definition', { + const clientConfigServer = await screen.findByRole('group', { name: componentId, }); expect( @@ -184,4 +229,299 @@ describe('HealthDetails', () => { }, ); }); + + describe('Collapsible details functionality', () => { + beforeEach(() => { + // Clear localStorage before each test + localStorage.clear(); + }); + + it('should show toggle button when details exist', async () => { + const healthMock = { + status: 'UP', + details: { + database: 'HSQL Database Engine', + validationQuery: 'isValid()', + }, + }; + + render(HealthDetails, { + props: { + name: 'db', + health: healthMock, + instance: mockInstance, + }, + }); + + const toggleButton = await screen.findByRole('button'); + expect(toggleButton).toBeInTheDocument(); + expect(toggleButton).toHaveAttribute('title'); + // Title should be the i18n key for toggle_details + const title = toggleButton.getAttribute('title'); + expect(title).toContain('toggle_details'); + }); + + it('should have proper ARIA attributes on toggle button', async () => { + const healthMock = { + status: 'UP', + details: { + database: 'HSQL Database Engine', + }, + }; + + render(HealthDetails, { + props: { + name: 'db', + health: healthMock, + instance: mockInstance, + }, + }); + + const toggleButton = await screen.findByRole('button'); + + // Should have title with toggle_details i18n key + expect(toggleButton).toHaveAttribute('title'); + const title = toggleButton.getAttribute('title'); + expect(title).toContain('toggle_details'); + + // Should have aria-expanded set to false initially (collapsed) + expect(toggleButton).toHaveAttribute('aria-expanded', 'false'); + + // Should have aria-controls pointing to the details element + expect(toggleButton).toHaveAttribute('aria-controls'); + const controlsId = toggleButton.getAttribute('aria-controls'); + expect(controlsId).toContain('health-details'); + expect(controlsId).toContain('db'); + }); + + it('should update aria-expanded when toggled', async () => { + const user = userEvent.setup(); + const healthMock = { + status: 'UP', + details: { + database: 'HSQL Database Engine', + }, + }; + + render(HealthDetails, { + props: { + name: 'db', + health: healthMock, + instance: mockInstance, + }, + }); + + const toggleButton = await screen.findByRole('button'); + + // Initially collapsed + expect(toggleButton).toHaveAttribute('aria-expanded', 'false'); + + // After clicking - expanded + await user.click(toggleButton); + expect(toggleButton).toHaveAttribute('aria-expanded', 'true'); + + // After clicking again - collapsed + await user.click(toggleButton); + expect(toggleButton).toHaveAttribute('aria-expanded', 'false'); + }); + + it('should not show toggle button when no details exist', async () => { + const healthMock = { + status: 'UP', + }; + + render(HealthDetails, { + props: { + name: 'simple', + health: healthMock, + instance: mockInstance, + }, + }); + + const toggleButton = screen.queryByRole('button'); + expect(toggleButton).not.toBeInTheDocument(); + }); + + it('should start collapsed by default', async () => { + const healthMock = { + status: 'UP', + details: { + database: 'HSQL Database Engine', + validationQuery: 'isValid()', + }, + }; + + render(HealthDetails, { + props: { + name: 'db', + health: healthMock, + instance: mockInstance, + }, + }); + + // Details should not be visible initially + expect( + screen.queryByText('HSQL Database Engine'), + ).not.toBeInTheDocument(); + }); + + it('should expand details when toggle button is clicked', async () => { + const user = userEvent.setup(); + const healthMock = { + status: 'UP', + details: { + database: 'HSQL Database Engine', + validationQuery: 'isValid()', + }, + }; + + render(HealthDetails, { + props: { + name: 'db', + health: healthMock, + instance: mockInstance, + }, + }); + + const toggleButton = await screen.findByRole('button'); + await user.click(toggleButton); + + // Details should now be visible + expect( + await screen.findByText('HSQL Database Engine'), + ).toBeInTheDocument(); + expect(screen.getByText('isValid()')).toBeInTheDocument(); + }); + + it('should collapse details when toggle button is clicked twice', async () => { + const user = userEvent.setup(); + const healthMock = { + status: 'UP', + details: { + database: 'HSQL Database Engine', + }, + }; + + render(HealthDetails, { + props: { + name: 'db', + health: healthMock, + instance: mockInstance, + }, + }); + + const toggleButton = await screen.findByRole('button'); + + // First click - expand + await user.click(toggleButton); + expect( + await screen.findByText('HSQL Database Engine'), + ).toBeInTheDocument(); + + // Second click - collapse + await user.click(toggleButton); + expect( + screen.queryByText('HSQL Database Engine'), + ).not.toBeInTheDocument(); + }); + + it('should persist collapsed state in localStorage', async () => { + const user = userEvent.setup(); + const healthMock = { + status: 'UP', + details: { + database: 'HSQL Database Engine', + }, + }; + + render(HealthDetails, { + props: { + name: 'db', + health: healthMock, + instance: mockInstance, + }, + }); + + const toggleButton = await screen.findByRole('button'); + await user.click(toggleButton); + + const storageKey = `de.codecentric.spring-boot-admin.health-details.db.${mockInstance.id}.collapsed`; + expect(localStorage.getItem(storageKey)).toBe('false'); + }); + + it('should restore collapsed state from localStorage', async () => { + const healthMock = { + status: 'UP', + details: { + database: 'HSQL Database Engine', + }, + }; + + const storageKey = `de.codecentric.spring-boot-admin.health-details.db.${mockInstance.id}.collapsed`; + localStorage.setItem(storageKey, 'false'); + + render(HealthDetails, { + props: { + name: 'db', + health: healthMock, + instance: mockInstance, + }, + }); + + // Details should be visible because we set collapsed to false in localStorage + expect( + await screen.findByText('HSQL Database Engine'), + ).toBeInTheDocument(); + }); + + it('should handle child health components correctly', async () => { + const healthMock = { + status: 'UP', + components: { + db: { + status: 'UP', + details: { + database: 'HSQL Database Engine', + }, + }, + }, + }; + + render(HealthDetails, { + props: { + name: 'parent', + health: healthMock, + instance: mockInstance, + }, + }); + + // Child component should be rendered + const dbComponent = await screen.findByRole('group', { name: 'db' }); + expect(dbComponent).toBeInTheDocument(); + }); + + it('should not show toggle button when details only contain child health components', async () => { + const healthMock = { + status: 'UP', + details: { + childComponent: { + status: 'UP', + details: {}, + }, + }, + }; + + render(HealthDetails, { + props: { + name: 'parent', + health: healthMock, + instance: mockInstance, + }, + }); + + // No toggle button because all details are child health components + const toggleButton = screen.queryByRole('button'); + expect(toggleButton).not.toBeInTheDocument(); + }); + }); }); diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/health-details.vue b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/health-details.vue index d60486b2114..b6ba00b2e97 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/health-details.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/health-details.vue @@ -16,23 +16,57 @@ - - {{ name }} + + + {{ name }} + + + + + + toggleCollapsed()" + > + + + + - - - - + + - - + + +