feat(ui): PrimaryActionButton gains uploading mode + progress overlay
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<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') {
|
||||
return <Button size="sm" variant="primary" loading disabled>Deploying…</Button>;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user