diff --git a/decnet_web/src/components/Config/tabs/LimitsTab.test.tsx b/decnet_web/src/components/Config/tabs/LimitsTab.test.tsx new file mode 100644 index 00000000..83d179c5 --- /dev/null +++ b/decnet_web/src/components/Config/tabs/LimitsTab.test.tsx @@ -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( + ({ ok: true })} + />, + ); + expect(screen.getByText('42')).toBeInTheDocument(); + expect(screen.queryByText('SAVE')).not.toBeInTheDocument(); + }); + + it('renders preset buttons + SAVE for admins', () => { + render( + ({ 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(); + 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( + ({ ok: true })} + />, + ); + await user.click(screen.getByText('SAVE')); + expect(screen.getByText('DEPLOYMENT LIMIT UPDATED')).toBeInTheDocument(); + + rerender( + ({ ok: false, reason: 'too high' })} + />, + ); + await user.click(screen.getByText('SAVE')); + expect(screen.getByText('too high')).toBeInTheDocument(); + }); +}); diff --git a/decnet_web/src/components/Config/tabs/LimitsTab.tsx b/decnet_web/src/components/Config/tabs/LimitsTab.tsx new file mode 100644 index 00000000..f2177faa --- /dev/null +++ b/decnet_web/src/components/Config/tabs/LimitsTab.tsx @@ -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 = ({ isAdmin, initialValue, onSave }) => { + const [input, setInput] = useState(String(initialValue)); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState(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 ( +
+
+ MAXIMUM DECKIES PER DEPLOYMENT + {isAdmin ? ( + <> +
+ setInput(e.target.value)} + /> +
+ {PRESETS.map((v) => ( + + ))} +
+ +
+ {msg && ( + + {msg.text} + + )} + + ) : ( + {initialValue} + )} +
+
+ ); +};