diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css b/ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css index 707d3418..0726b183 100644 --- a/ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css +++ b/ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css @@ -374,3 +374,29 @@ background: var(--bg-inset); color: var(--text-primary); } + +/* Primary-button upload progress overlay + * Wraps the DS Button so the inner Button can retain its own background + * while we overlay a tinted progress fill and label on top. */ +.uploadBtnWrap { + display: inline-block; +} + +.uploadBtnWrap button { + position: relative; + overflow: hidden; +} + +.uploadBtnFill { + position: absolute; + inset: 0 auto 0 0; + background: color-mix(in srgb, var(--primary) 35%, transparent); + transition: width 120ms linear; + pointer-events: none; + z-index: 0; +} + +.uploadBtnLabel { + position: relative; + z-index: 1; +} diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.test.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.test.tsx new file mode 100644 index 00000000..192f1af3 --- /dev/null +++ b/ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.test.tsx @@ -0,0 +1,55 @@ +import type { ReactElement } from 'react'; +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '@cameleer/design-system'; +import { PrimaryActionButton, computeMode } from './PrimaryActionButton'; + +function wrap(ui: ReactElement) { + return render({ui}); +} + +describe('PrimaryActionButton', () => { + it('renders Save in save mode', () => { + wrap( {}} />); + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); + }); + + it('renders Redeploy in redeploy mode', () => { + wrap( {}} />); + expect(screen.getByRole('button', { name: /redeploy/i })).toBeInTheDocument(); + }); + + it('renders Deploying… disabled in deploying mode', () => { + wrap( {}} />); + const btn = screen.getByRole('button', { name: /deploying/i }); + expect(btn).toBeDisabled(); + }); + + it('renders Uploading… NN% with progress overlay in uploading mode', () => { + wrap( {}} />); + const btn = screen.getByRole('button', { name: /uploading/i }); + expect(btn).toBeDisabled(); + expect(btn).toHaveTextContent('42%'); + const fill = btn.querySelector('[data-upload-fill]') as HTMLElement | null; + expect(fill).not.toBeNull(); + expect(fill!.style.width).toBe('42%'); + }); +}); + +describe('computeMode', () => { + it('returns uploading when uploading flag set, even if deploymentInProgress is false', () => { + expect(computeMode({ deploymentInProgress: false, uploading: true, hasLocalEdits: true, serverDirtyAgainstDeploy: false })).toBe('uploading'); + }); + + it('returns deploying when deploymentInProgress even if uploading flag set', () => { + expect(computeMode({ deploymentInProgress: true, uploading: true, hasLocalEdits: false, serverDirtyAgainstDeploy: false })).toBe('deploying'); + }); + + it('returns save when local edits without upload or deploy', () => { + expect(computeMode({ deploymentInProgress: false, uploading: false, hasLocalEdits: true, serverDirtyAgainstDeploy: false })).toBe('save'); + }); + + it('returns redeploy when server dirty and no local edits', () => { + expect(computeMode({ deploymentInProgress: false, uploading: false, hasLocalEdits: false, serverDirtyAgainstDeploy: true })).toBe('redeploy'); + }); +}); diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx index ef4e9ac7..c563a3aa 100644 --- a/ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx +++ b/ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx @@ -1,14 +1,32 @@ import { Button } from '@cameleer/design-system'; +import styles from './AppDeploymentPage.module.css'; -export type PrimaryActionMode = 'save' | 'redeploy' | 'deploying'; +export type PrimaryActionMode = 'save' | 'redeploy' | 'uploading' | 'deploying'; interface Props { mode: PrimaryActionMode; enabled: boolean; onClick: () => void; + /** Upload percentage 0–100. Only meaningful when mode === 'uploading'. */ + progress?: number; } -export function PrimaryActionButton({ mode, enabled, onClick }: Props) { +export function PrimaryActionButton({ mode, enabled, onClick, progress }: Props) { + if (mode === 'uploading') { + const pct = Math.max(0, Math.min(100, Math.round(progress ?? 0))); + return ( + + + + ); + } if (mode === 'deploying') { return ; } @@ -20,14 +38,17 @@ export function PrimaryActionButton({ mode, enabled, onClick }: Props) { export function computeMode({ deploymentInProgress, + uploading, hasLocalEdits, serverDirtyAgainstDeploy, }: { deploymentInProgress: boolean; + uploading: boolean; hasLocalEdits: boolean; serverDirtyAgainstDeploy: boolean; }): PrimaryActionMode { if (deploymentInProgress) return 'deploying'; + if (uploading) return 'uploading'; if (hasLocalEdits) return 'save'; if (serverDirtyAgainstDeploy) return 'redeploy'; return 'save';