From 33e7ab72c66e400d8d8a80163730fc4796720671 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Wed, 15 Apr 2026 07:50:48 +0200 Subject: [PATCH 1/6] feat: generic RegisterUpdateDialog for SOVD POST /updates Add RegisterUpdateDialog component with id, name, automated, and JSON metadata fields. Includes client-side validation (required id, JSON parse check) and merges metadata extras into the request body. Add registerUpdate store action and install shadcn label/checkbox primitives. --- src/components/RegisterUpdateDialog.test.tsx | 51 ++++++++ src/components/RegisterUpdateDialog.tsx | 122 +++++++++++++++++++ src/components/ui/checkbox.tsx | 29 +++++ src/components/ui/label.tsx | 19 +++ src/lib/store.ts | 11 ++ 5 files changed, 232 insertions(+) create mode 100644 src/components/RegisterUpdateDialog.test.tsx create mode 100644 src/components/RegisterUpdateDialog.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/label.tsx diff --git a/src/components/RegisterUpdateDialog.test.tsx b/src/components/RegisterUpdateDialog.test.tsx new file mode 100644 index 0000000..59e8de8 --- /dev/null +++ b/src/components/RegisterUpdateDialog.test.tsx @@ -0,0 +1,51 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { RegisterUpdateDialog } from './RegisterUpdateDialog'; + +describe('RegisterUpdateDialog', () => { + it('renders the four fields when open', () => { + render( {}} onSubmit={async () => {}} />); + expect(screen.getByLabelText(/^id$/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/^name$/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/^automated$/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/additional metadata/i)).toBeInTheDocument(); + }); + + it('rejects empty id', async () => { + const onSubmit = vi.fn(); + render( {}} onSubmit={onSubmit} />); + fireEvent.click(screen.getByRole('button', { name: /^register$/i })); + expect(await screen.findByText(/id is required/i)).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('rejects malformed JSON metadata', async () => { + const onSubmit = vi.fn(); + render( {}} onSubmit={onSubmit} />); + fireEvent.change(screen.getByLabelText(/^id$/i), { target: { value: 'x' } }); + fireEvent.change(screen.getByLabelText(/additional metadata/i), { target: { value: '{not json' } }); + fireEvent.click(screen.getByRole('button', { name: /^register$/i })); + expect(await screen.findByText(/invalid json/i)).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('submits merged body on valid input', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( {}} onSubmit={onSubmit} />); + fireEvent.change(screen.getByLabelText(/^id$/i), { target: { value: 'pkg-1' } }); + fireEvent.change(screen.getByLabelText(/^name$/i), { target: { value: 'Pkg One' } }); + fireEvent.click(screen.getByLabelText(/^automated$/i)); + fireEvent.change(screen.getByLabelText(/additional metadata/i), { + target: { value: '{"origins":["a"]}' }, + }); + fireEvent.click(screen.getByRole('button', { name: /^register$/i })); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + id: 'pkg-1', + update_name: 'Pkg One', + automated: true, + origins: ['a'], + }) + ); + }); +}); diff --git a/src/components/RegisterUpdateDialog.tsx b/src/components/RegisterUpdateDialog.tsx new file mode 100644 index 0000000..1f78fc6 --- /dev/null +++ b/src/components/RegisterUpdateDialog.tsx @@ -0,0 +1,122 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; + +export interface RegisterUpdateBody { + id: string; + update_name?: string; + automated?: boolean; + [key: string]: unknown; +} + +interface Props { + open: boolean; + onClose: () => void; + onSubmit: (body: RegisterUpdateBody) => Promise; +} + +export function RegisterUpdateDialog({ open, onClose, onSubmit }: Props) { + const [id, setId] = useState(''); + const [name, setName] = useState(''); + const [automated, setAutomated] = useState(false); + const [metadata, setMetadata] = useState('{}'); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async () => { + setError(null); + if (!id.trim()) { + setError('id is required'); + return; + } + let extras: Record = {}; + if (metadata.trim()) { + try { + const parsed = JSON.parse(metadata); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('not an object'); + } + extras = parsed as Record; + } catch { + setError('invalid JSON in additional metadata'); + return; + } + } + const body: RegisterUpdateBody = { id: id.trim(), ...extras }; + if (name.trim()) body.update_name = name.trim(); + if (automated) body.automated = true; + setSubmitting(true); + try { + await onSubmit(body); + setId(''); + setName(''); + setAutomated(false); + setMetadata('{}'); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSubmitting(false); + } + }; + + return ( + !o && onClose()}> + + + Register Update + +
+
+ + setId(e.target.value)} /> +
+
+ + setName(e.target.value)} /> +
+
+ setAutomated(v === true)} /> + +
+
+ +