From 7153959e52ca8b6a0b4602bc4df61627e7966354 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Thu, 4 Jun 2026 12:51:48 +0200 Subject: [PATCH 1/3] feat: UI / UX refactor and cleanup --- e2e/helpers/index.ts | 2 +- e2e/helpers/sidebar.ts | 9 +- e2e/helpers/stack.ts | 20 +- e2e/navigation.spec.ts | 66 ++ frontend/src/components/app-routes.tsx | 6 +- frontend/src/components/branch-selector.tsx | 48 +- .../src/components/layout/breadcrumbs.tsx | 4 +- .../layout/sidebar-stack-item.spec.tsx | 80 +++ .../components/layout/sidebar-stack-item.tsx | 164 +++++ frontend/src/components/layout/sidebar.tsx | 158 ++--- .../src/components/service-table.spec.tsx | 83 +++ frontend/src/components/service-table.tsx | 94 +-- .../src/components/stack-redirect.spec.tsx | 42 ++ frontend/src/components/stack-redirect.tsx | 37 ++ frontend/src/pages/dashboard/index.tsx | 15 +- frontend/src/pages/dashboard/service-row.tsx | 134 ----- .../src/pages/dashboard/stack-card.spec.tsx | 121 ++++ frontend/src/pages/dashboard/stack-card.tsx | 270 +++++++++ .../src/pages/dashboard/stack-dashboard.tsx | 562 ------------------ .../pages/dashboard/stack-list-dashboard.tsx | 133 +---- .../src/pages/import-export/import-stack.tsx | 7 +- .../services/services-empty-state.spec.tsx | 39 ++ .../pages/services/services-empty-state.tsx | 121 ++++ frontend/src/pages/services/services-list.tsx | 52 +- .../services/stack-actions-menu.spec.tsx | 43 ++ .../src/pages/services/stack-actions-menu.tsx | 84 +++ frontend/src/pages/stacks/create-stack.tsx | 2 +- frontend/src/pages/stacks/stack-setup.tsx | 294 --------- .../create-service-wizard/setup-step.tsx | 18 +- frontend/src/utils/stack-status.spec.ts | 94 +++ frontend/src/utils/stack-status.ts | 56 ++ 31 files changed, 1478 insertions(+), 1380 deletions(-) create mode 100644 e2e/navigation.spec.ts create mode 100644 frontend/src/components/layout/sidebar-stack-item.spec.tsx create mode 100644 frontend/src/components/layout/sidebar-stack-item.tsx create mode 100644 frontend/src/components/service-table.spec.tsx create mode 100644 frontend/src/components/stack-redirect.spec.tsx create mode 100644 frontend/src/components/stack-redirect.tsx delete mode 100644 frontend/src/pages/dashboard/service-row.tsx create mode 100644 frontend/src/pages/dashboard/stack-card.spec.tsx create mode 100644 frontend/src/pages/dashboard/stack-card.tsx delete mode 100644 frontend/src/pages/dashboard/stack-dashboard.tsx create mode 100644 frontend/src/pages/services/services-empty-state.spec.tsx create mode 100644 frontend/src/pages/services/services-empty-state.tsx create mode 100644 frontend/src/pages/services/stack-actions-menu.spec.tsx create mode 100644 frontend/src/pages/services/stack-actions-menu.tsx delete mode 100644 frontend/src/pages/stacks/stack-setup.tsx create mode 100644 frontend/src/utils/stack-status.spec.ts create mode 100644 frontend/src/utils/stack-status.ts diff --git a/e2e/helpers/index.ts b/e2e/helpers/index.ts index 0ab391a..16b1448 100644 --- a/e2e/helpers/index.ts +++ b/e2e/helpers/index.ts @@ -5,4 +5,4 @@ export { addPrerequisite, type PrerequisiteParams } from './prerequisite.js' export { addRepository, type AddRepositoryParams } from './repository.js' export { fillServiceForm, navigateToCreateService, submitServiceForm, type CreateServiceParams } from './service.js' export { navigateViaSidebar } from './sidebar.js' -export { createStack, deleteStack, type CreateStackParams } from './stack.js' +export { createStack, deleteStack, openStackActionsMenu, type CreateStackParams } from './stack.js' diff --git a/e2e/helpers/sidebar.ts b/e2e/helpers/sidebar.ts index 83ce5b3..b8dfd0b 100644 --- a/e2e/helpers/sidebar.ts +++ b/e2e/helpers/sidebar.ts @@ -1,13 +1,10 @@ import type { Page } from '@playwright/test' -export const navigateViaSidebar = async ( - page: Page, - stackDisplayName: string, - link: 'Overview' | 'Services' | 'Repositories' | 'Prerequisites', -) => { +export type StackSidebarLink = 'Services' | 'Repositories' | 'Prerequisites' + +export const navigateViaSidebar = async (page: Page, stackDisplayName: string, link: StackSidebarLink) => { const stackSidebar = page.locator('shade-accordion-item').filter({ hasText: stackDisplayName }) - // Expand the accordion if it's collapsed const isExpanded = await stackSidebar.getAttribute('data-expanded') if (isExpanded === null) { await stackSidebar.locator('.accordion-header').click() diff --git a/e2e/helpers/stack.ts b/e2e/helpers/stack.ts index 6ac4a11..933f996 100644 --- a/e2e/helpers/stack.ts +++ b/e2e/helpers/stack.ts @@ -21,26 +21,28 @@ export const createStack = async (page: Page, params: CreateStackParams) => { await page.locator('button', { hasText: 'Create' }).click() await expectNotification(page, `Stack "${params.displayName}" was created successfully.`) - await expect(page.locator('shade-dashboard')).toBeVisible() - await expect(page.getByTestId('page-header-title')).toContainText(params.displayName) + await expect(page.locator('shade-services-list')).toBeVisible() + await expect(page.getByTestId('page-header-title')).toContainText('Services (0)') +} + +export const openStackActionsMenu = async (page: Page) => { + await page.getByRole('button', { name: 'Stack actions' }).click() } export const deleteStack = async (page: Page, displayName: string) => { - // Navigate to the main dashboard via the sidebar "Dashboard" link (always visible, no accordion) await page.locator('shade-sidebar-item a', { hasText: 'Dashboard' }).click() await expect(page.locator('stack-list-dashboard')).toBeVisible() - // Click on the stack card to open its overview - await page.locator('stack-list-dashboard shade-card', { hasText: displayName }).click() - await expect(page.getByTestId('page-header-title')).toContainText(displayName) + await page.locator('stack-list-dashboard stack-card', { hasText: displayName }).click() + await expect(page.locator('shade-services-list')).toBeVisible() - // Navigate to Edit Stack, then delete - await page.locator('a', { hasText: 'Edit Stack' }).click() + await openStackActionsMenu(page) + await page.getByRole('menuitem', { name: 'Edit Stack' }).click() await expect(page.locator('shade-edit-stack')).toBeVisible() await page.locator('button', { hasText: 'Delete Stack' }).click() await page.locator('shade-dialog .dialog-confirm-btn').click() await expectNotification(page, `"${displayName}" was deleted.`) - await expect(page.locator('shade-dashboard')).toBeVisible({ timeout: 10_000 }) + await expect(page.locator('stack-list-dashboard')).toBeVisible({ timeout: 10_000 }) } diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts new file mode 100644 index 0000000..488022e --- /dev/null +++ b/e2e/navigation.spec.ts @@ -0,0 +1,66 @@ +import { tmpdir } from 'os' +import { join } from 'path' + +import { expect, test } from '@playwright/test' + +import { createStack, deleteStack, login, openStackActionsMenu } from './helpers/index.js' + +test('Navigation restructure', async ({ page, browserName }) => { + const uuid = crypto.randomUUID() + const stackName = `e2e-nav-${uuid}` + const displayName = `Nav Stack - ${browserName} - ${uuid}` + + await page.goto('/') + await login(page) + + try { + await test.step('Create stack lands on Services', async () => { + await createStack(page, { + name: stackName, + displayName, + description: 'Navigation restructure E2E', + mainDirectory: join(tmpdir(), `e2e-nav-${uuid}`), + }) + await expect(page).toHaveURL(new RegExp(`/stacks/${stackName}/services$`)) + }) + + await test.step('Legacy routes redirect to Services', async () => { + await page.goto(`/stacks/${stackName}`) + await expect(page.locator('shade-services-list')).toBeVisible() + await expect(page).toHaveURL(new RegExp(`/stacks/${stackName}/services$`)) + + await page.goto(`/stacks/${stackName}/setup`) + await expect(page.locator('shade-services-list')).toBeVisible() + await expect(page).toHaveURL(new RegExp(`/stacks/${stackName}/services$`)) + }) + + await test.step('Stack actions menu reaches Edit and Export', async () => { + await openStackActionsMenu(page) + await page.getByRole('menuitem', { name: 'Edit Stack' }).click() + await expect(page.locator('shade-edit-stack')).toBeVisible() + + await page.goto(`/stacks/${stackName}/services`) + await openStackActionsMenu(page) + await page.getByRole('menuitem', { name: 'Export Stack' }).click() + await expect(page.locator('shade-export-stack')).toBeVisible() + }) + + await test.step('Dashboard card actions do not navigate away', async () => { + await page.locator('shade-sidebar-item a', { hasText: 'Dashboard' }).click() + await expect(page.locator('stack-list-dashboard')).toBeVisible() + + const card = page.locator('stack-card', { hasText: displayName }) + await card.getByRole('button', { name: 'Start All' }).click() + await expect(page.locator('stack-list-dashboard')).toBeVisible() + await expect(page).toHaveURL('/') + }) + + await test.step('Sidebar shows stack status dot', async () => { + await expect(page.locator('shade-sidebar-stack-item [data-testid="stack-status-dot"]').first()).toBeVisible() + }) + } finally { + await test.step('Clean up stack', async () => { + await deleteStack(page, displayName) + }) + } +}) diff --git a/frontend/src/components/app-routes.tsx b/frontend/src/components/app-routes.tsx index 9e7ad0c..ae04dbe 100644 --- a/frontend/src/components/app-routes.tsx +++ b/frontend/src/components/app-routes.tsx @@ -18,8 +18,8 @@ import { ServicesList } from '../pages/services/services-list.js' import { UserSettings } from '../pages/settings/user-settings.js' import { CreateStack } from '../pages/stacks/create-stack.js' import { EditStack } from '../pages/stacks/edit-stack.js' -import { StackSetup } from '../pages/stacks/stack-setup.js' import { CreateServiceWizard } from '../pages/wizards/create-service-wizard/index.js' +import { StackRedirect } from './stack-redirect.js' export const appRoutes = { '/': { @@ -36,7 +36,7 @@ export const appRoutes = { }, '/stacks/:stackName': { component: ({ match }: { match: MatchResult<{ stackName: string }> }) => ( - + ), }, '/stacks/:stackName/edit': { @@ -51,7 +51,7 @@ export const appRoutes = { }, '/stacks/:stackName/setup': { component: ({ match }: { match: MatchResult<{ stackName: string }> }) => ( - + ), }, '/stacks/:stackName/services': { diff --git a/frontend/src/components/branch-selector.tsx b/frontend/src/components/branch-selector.tsx index 60ce33a..81ed79b 100644 --- a/frontend/src/components/branch-selector.tsx +++ b/frontend/src/components/branch-selector.tsx @@ -10,6 +10,7 @@ type BranchSelectorProps = { cloneStatus: CloneStatus upstreamStatus?: UpstreamStatus lastPullError?: string + commitsBehind?: number } /** @@ -205,6 +206,16 @@ export const BranchSelector = Shade({ opacity: '0.5', flexShrink: '0', }, + '& .commits-behind-badge': { + fontSize: '10px', + fontWeight: '600', + padding: '1px 5px', + borderRadius: '8px', + backgroundColor: 'var(--shades-theme-palette-primary-main)', + color: 'var(--shades-theme-palette-primary-mainContrast)', + flexShrink: '0', + lineHeight: '1.2', + }, '& .branch-spinner': { display: 'inline-block', width: '10px', @@ -218,7 +229,7 @@ export const BranchSelector = Shade({ }, render: ({ props, injector, useState, useDisposable, useRef }) => { const triggerRef = useRef('triggerRef') - const { serviceId, currentBranch, cloneStatus, upstreamStatus, lastPullError } = props + const { serviceId, currentBranch, cloneStatus, upstreamStatus, lastPullError, commitsBehind } = props const [branches, setBranches] = useState<{ local: string[]; remote: string[] } | null>('branches', null) const [isLoading, setIsLoading] = useState('isLoading', false) @@ -332,20 +343,27 @@ export const BranchSelector = Shade({ return ( <> - + + + {commitsBehind ? ( + + {commitsBehind} behind + + ) : null} +