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';