feat(ui): checkpoints table collapsible, default collapsed

This commit is contained in:
hsiegeln
2026-04-23 16:09:28 +02:00
parent 2c0cf7dc9c
commit f31975e0ef
3 changed files with 143 additions and 75 deletions

View File

@@ -400,3 +400,39 @@
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
/* Collapsible Checkpoints header */
.checkpointsSection {
display: flex;
flex-direction: column;
}
.checkpointsHeader {
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: none;
padding: 8px 0;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
cursor: pointer;
text-align: left;
}
.checkpointsHeader:hover {
color: var(--amber);
}
.checkpointsChevron {
color: var(--text-muted);
font-size: 11px;
width: 12px;
text-align: center;
}
.checkpointsCount {
color: var(--text-muted);
font-weight: 400;
}

View File

@@ -25,10 +25,23 @@ const stoppedDep: Deployment = {
deployedConfigSnapshot: { jarVersionId: 'v6id', agentConfig: null, containerConfig: {}, sensitiveKeys: null }, deployedConfigSnapshot: { jarVersionId: 'v6id', agentConfig: null, containerConfig: {}, sensitiveKeys: null },
}; };
function expand() {
fireEvent.click(screen.getByRole('button', { name: /checkpoints/i }));
}
describe('CheckpointsTable', () => { describe('CheckpointsTable', () => {
it('renders a row per checkpoint with version, jar, deployer', () => { it('defaults to collapsed — header visible, rows hidden', () => {
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]} wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />); currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
expect(screen.getByRole('button', { name: /checkpoints \(1\)/i })).toBeInTheDocument();
expect(screen.queryByText('v6')).toBeNull();
expect(screen.queryByText('my-app-1.2.3.jar')).toBeNull();
});
it('clicking header expands to show rows', () => {
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
expand();
expect(screen.getByText('v6')).toBeInTheDocument(); expect(screen.getByText('v6')).toBeInTheDocument();
expect(screen.getByText('my-app-1.2.3.jar')).toBeInTheDocument(); expect(screen.getByText('my-app-1.2.3.jar')).toBeInTheDocument();
expect(screen.getByText('alice')).toBeInTheDocument(); expect(screen.getByText('alice')).toBeInTheDocument();
@@ -38,7 +51,7 @@ describe('CheckpointsTable', () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]} wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={5} onSelect={onSelect} />); currentDeploymentId={null} jarRetentionCount={5} onSelect={onSelect} />);
// Use the version text as the row anchor (most stable selector) expand();
fireEvent.click(screen.getByText('v6').closest('tr')!); fireEvent.click(screen.getByText('v6').closest('tr')!);
expect(onSelect).toHaveBeenCalledWith('d1'); expect(onSelect).toHaveBeenCalledWith('d1');
}); });
@@ -47,6 +60,7 @@ describe('CheckpointsTable', () => {
const noActor = { ...stoppedDep, createdBy: null }; const noActor = { ...stoppedDep, createdBy: null };
wrap(<CheckpointsTable deployments={[noActor]} versions={[v6]} wrap(<CheckpointsTable deployments={[noActor]} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />); currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
expand();
expect(screen.getByText('—')).toBeInTheDocument(); expect(screen.getByText('—')).toBeInTheDocument();
}); });
@@ -54,14 +68,14 @@ describe('CheckpointsTable', () => {
const pruned = { ...stoppedDep, appVersionId: 'unknown' }; const pruned = { ...stoppedDep, appVersionId: 'unknown' };
wrap(<CheckpointsTable deployments={[pruned]} versions={[]} wrap(<CheckpointsTable deployments={[pruned]} versions={[]}
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />); currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
expand();
expect(screen.getByText(/archived/i)).toBeInTheDocument(); expect(screen.getByText(/archived/i)).toBeInTheDocument();
}); });
it('excludes the currently-running deployment', () => { it('renders nothing when there are no checkpoints', () => {
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]} const { container } = wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
currentDeploymentId="d1" jarRetentionCount={5} onSelect={() => {}} />); currentDeploymentId="d1" jarRetentionCount={5} onSelect={() => {}} />);
expect(screen.queryByText('v6')).toBeNull(); expect(container).toBeEmptyDOMElement();
expect(screen.getByText(/no past deployments/i)).toBeInTheDocument();
}); });
it('caps visible rows at jarRetentionCount and shows expander', () => { it('caps visible rows at jarRetentionCount and shows expander', () => {
@@ -70,7 +84,7 @@ describe('CheckpointsTable', () => {
})); }));
wrap(<CheckpointsTable deployments={many} versions={[v6]} wrap(<CheckpointsTable deployments={many} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={3} onSelect={() => {}} />); currentDeploymentId={null} jarRetentionCount={3} onSelect={() => {}} />);
// 3 visible rows + 1 header row = 4 rows max in the table expand();
expect(screen.getAllByRole('row').length).toBeLessThanOrEqual(4); expect(screen.getAllByRole('row').length).toBeLessThanOrEqual(4);
expect(screen.getByText(/show older \(7\)/i)).toBeInTheDocument(); expect(screen.getByText(/show older \(7\)/i)).toBeInTheDocument();
}); });
@@ -78,6 +92,7 @@ describe('CheckpointsTable', () => {
it('shows all rows when jarRetentionCount >= total', () => { it('shows all rows when jarRetentionCount >= total', () => {
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]} wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={10} onSelect={() => {}} />); currentDeploymentId={null} jarRetentionCount={10} onSelect={() => {}} />);
expand();
expect(screen.queryByText(/show older/i)).toBeNull(); expect(screen.queryByText(/show older/i)).toBeNull();
}); });
@@ -87,6 +102,7 @@ describe('CheckpointsTable', () => {
})); }));
wrap(<CheckpointsTable deployments={fifteen} versions={[v6]} wrap(<CheckpointsTable deployments={fifteen} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={null} onSelect={() => {}} />); currentDeploymentId={null} jarRetentionCount={null} onSelect={() => {}} />);
expand();
expect(screen.getByText(/show older \(5\)/i)).toBeInTheDocument(); expect(screen.getByText(/show older \(5\)/i)).toBeInTheDocument();
}); });
}); });

View File

@@ -22,6 +22,7 @@ export function CheckpointsTable({
onSelect, onSelect,
}: CheckpointsTableProps) { }: CheckpointsTableProps) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [open, setOpen] = useState(false);
const versionMap = new Map(versions.map((v) => [v.id, v])); const versionMap = new Map(versions.map((v) => [v.id, v]));
const checkpoints = deployments const checkpoints = deployments
@@ -37,6 +38,19 @@ export function CheckpointsTable({
const hidden = checkpoints.length - visible.length; const hidden = checkpoints.length - visible.length;
return ( return (
<div className={styles.checkpointsSection}>
<button
type="button"
className={styles.checkpointsHeader}
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
<span className={styles.checkpointsChevron}>{open ? '\u25BE' : '\u25B8'}</span>
<span>Checkpoints</span>
{' '}
<span className={styles.checkpointsCount}>({checkpoints.length})</span>
</button>
{open && (
<div className={styles.checkpointsTable}> <div className={styles.checkpointsTable}>
<table> <table>
<thead> <thead>
@@ -108,5 +122,7 @@ export function CheckpointsTable({
</button> </button>
)} )}
</div> </div>
)}
</div>
); );
} }