feat(ui): PrimaryActionButton gains uploading mode + progress overlay

This commit is contained in:
hsiegeln
2026-04-23 15:49:27 +02:00
parent a208f2eec7
commit 427988bcc8
3 changed files with 104 additions and 2 deletions

View File

@@ -374,3 +374,29 @@
background: var(--bg-inset); background: var(--bg-inset);
color: var(--text-primary); 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;
}

View File

@@ -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(<ThemeProvider>{ui}</ThemeProvider>);
}
describe('PrimaryActionButton', () => {
it('renders Save in save mode', () => {
wrap(<PrimaryActionButton mode="save" enabled onClick={() => {}} />);
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('renders Redeploy in redeploy mode', () => {
wrap(<PrimaryActionButton mode="redeploy" enabled onClick={() => {}} />);
expect(screen.getByRole('button', { name: /redeploy/i })).toBeInTheDocument();
});
it('renders Deploying… disabled in deploying mode', () => {
wrap(<PrimaryActionButton mode="deploying" enabled={false} onClick={() => {}} />);
const btn = screen.getByRole('button', { name: /deploying/i });
expect(btn).toBeDisabled();
});
it('renders Uploading… NN% with progress overlay in uploading mode', () => {
wrap(<PrimaryActionButton mode="uploading" enabled={false} progress={42} onClick={() => {}} />);
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');
});
});

View File

@@ -1,14 +1,32 @@
import { Button } from '@cameleer/design-system'; 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 { interface Props {
mode: PrimaryActionMode; mode: PrimaryActionMode;
enabled: boolean; enabled: boolean;
onClick: () => void; onClick: () => void;
/** Upload percentage 0100. 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 (
<span className={styles.uploadBtnWrap}>
<Button size="sm" variant="primary" disabled>
<span
data-upload-fill
className={styles.uploadBtnFill}
style={{ width: `${pct}%` }}
/>
<span className={styles.uploadBtnLabel}>Uploading {pct}%</span>
</Button>
</span>
);
}
if (mode === 'deploying') { if (mode === 'deploying') {
return <Button size="sm" variant="primary" loading disabled>Deploying</Button>; return <Button size="sm" variant="primary" loading disabled>Deploying</Button>;
} }
@@ -20,14 +38,17 @@ export function PrimaryActionButton({ mode, enabled, onClick }: Props) {
export function computeMode({ export function computeMode({
deploymentInProgress, deploymentInProgress,
uploading,
hasLocalEdits, hasLocalEdits,
serverDirtyAgainstDeploy, serverDirtyAgainstDeploy,
}: { }: {
deploymentInProgress: boolean; deploymentInProgress: boolean;
uploading: boolean;
hasLocalEdits: boolean; hasLocalEdits: boolean;
serverDirtyAgainstDeploy: boolean; serverDirtyAgainstDeploy: boolean;
}): PrimaryActionMode { }): PrimaryActionMode {
if (deploymentInProgress) return 'deploying'; if (deploymentInProgress) return 'deploying';
if (uploading) return 'uploading';
if (hasLocalEdits) return 'save'; if (hasLocalEdits) return 'save';
if (serverDirtyAgainstDeploy) return 'redeploy'; if (serverDirtyAgainstDeploy) return 'redeploy';
return 'save'; return 'save';