feat(ui): PrimaryActionButton gains uploading mode + progress overlay
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { 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 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') {
|
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';
|
||||||
|
|||||||
Reference in New Issue
Block a user