refactor(decnet_web/Config): extract LimitsTab

DEPLOYMENT LIMITS panel into its own tab file. Owns the input
state, preset-button shortcuts, and the inline FormMsg chip; the
hook mutation is passed in via prop so this component is fully
reusable as a presentation-only piece.

- New Config/tabs/LimitsTab.tsx
- LimitsTab.test.tsx covers viewer-vs-admin rendering, the
  1-500 validation message, and success/error chip display.
This commit is contained in:
2026-05-09 05:23:42 -04:00
parent f2fd314dd6
commit 8807da218b
2 changed files with 143 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LimitsTab } from './LimitsTab';
describe('LimitsTab', () => {
it('renders the static value for viewers', () => {
render(
<LimitsTab
isAdmin={false}
initialValue={42}
onSave={async () => ({ ok: true })}
/>,
);
expect(screen.getByText('42')).toBeInTheDocument();
expect(screen.queryByText('SAVE')).not.toBeInTheDocument();
});
it('renders preset buttons + SAVE for admins', () => {
render(
<LimitsTab
isAdmin
initialValue={50}
onSave={async () => ({ ok: true })}
/>,
);
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('SAVE')).toBeInTheDocument();
});
it('rejects values outside 1-500 with an inline error', async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<LimitsTab isAdmin initialValue={50} onSave={onSave} />);
const input = screen.getByRole('spinbutton');
await user.clear(input);
await user.type(input, '999');
await user.click(screen.getByText('SAVE'));
expect(screen.getByText('VALUE MUST BE 1-500')).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
});
it('shows success chip on ok and error chip with the reason on failure', async () => {
const user = userEvent.setup();
const { rerender } = render(
<LimitsTab
isAdmin
initialValue={50}
onSave={async () => ({ ok: true })}
/>,
);
await user.click(screen.getByText('SAVE'));
expect(screen.getByText('DEPLOYMENT LIMIT UPDATED')).toBeInTheDocument();
rerender(
<LimitsTab
isAdmin
initialValue={50}
onSave={async () => ({ ok: false, reason: 'too high' })}
/>,
);
await user.click(screen.getByText('SAVE'));
expect(screen.getByText('too high')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,78 @@
import React, { useState } from 'react';
import { Save } from '../../../icons';
import type { FormMsg } from '../types';
interface Props {
isAdmin: boolean;
initialValue: number;
onSave: (n: number) => Promise<{ ok: true } | { ok: false; reason: string }>;
}
const PRESETS = [10, 50, 100, 200] as const;
/** DEPLOYMENT LIMITS tab — admins edit + save; viewers see the
* current value as static text. The inline FormMsg chip renders
* the success/error result from the hook mutation. */
export const LimitsTab: React.FC<Props> = ({ isAdmin, initialValue, onSave }) => {
const [input, setInput] = useState(String(initialValue));
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<FormMsg | null>(null);
const handleSave = async () => {
const val = parseInt(input);
if (isNaN(val) || val < 1 || val > 500) {
setMsg({ type: 'error', text: 'VALUE MUST BE 1-500' });
return;
}
setSaving(true);
setMsg(null);
const r = await onSave(val);
setMsg(r.ok
? { type: 'success', text: 'DEPLOYMENT LIMIT UPDATED' }
: { type: 'error', text: r.reason });
setSaving(false);
};
return (
<div className="config-panel">
<div className="config-field">
<span className="config-label">MAXIMUM DECKIES PER DEPLOYMENT</span>
{isAdmin ? (
<>
<div className="config-input-row">
<input
type="number"
min={1}
max={500}
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<div className="preset-buttons">
{PRESETS.map((v) => (
<button
key={v}
className={`preset-btn ${input === String(v) ? 'active' : ''}`}
onClick={() => setInput(String(v))}
>
{v}
</button>
))}
</div>
<button className="save-btn" onClick={handleSave} disabled={saving}>
<Save size={14} />
{saving ? 'SAVING...' : 'SAVE'}
</button>
</div>
{msg && (
<span className={msg.type === 'success' ? 'config-success' : 'config-error'}>
{msg.text}
</span>
)}
</>
) : (
<span className="config-value">{initialValue}</span>
)}
</div>
</div>
);
};