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:
65
decnet_web/src/components/Config/tabs/LimitsTab.test.tsx
Normal file
65
decnet_web/src/components/Config/tabs/LimitsTab.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
78
decnet_web/src/components/Config/tabs/LimitsTab.tsx
Normal file
78
decnet_web/src/components/Config/tabs/LimitsTab.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user