From 96498597a97655289b7c90e782d2cffc3ef0e8a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 03:36:13 +0000 Subject: [PATCH 1/5] Initial plan From f91c312488b4cb51918264def2b3941ad31e3c41 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 03:45:34 +0000 Subject: [PATCH 2/5] feat: add metadata CRUD, form dialog, detail page, and registry extensions - Add MetadataFormDialog component for create/edit with registry-driven form fields - Add Create button and Edit button to MetadataManagerPage - Add MetadataDetailPage at /system/metadata/:metadataType/:itemName - Extend MetadataTypeConfig with formFields, detailComponent - Add formFields to dashboard, page, report registry entries - Add DEFAULT_FORM_FIELDS constant for fallback - Add routes for detail page in all three route blocks - Add 30 new tests (MetadataFormDialog, MetadataDetailPage, create/edit flows) - Update CHANGELOG.md Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/c72f47f2-b9dd-4364-b613-535fb3b9d405 Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com> --- CHANGELOG.md | 8 + apps/console/src/App.tsx | 4 + .../src/__tests__/MetadataDetailPage.test.tsx | 154 +++++++++++ .../src/__tests__/MetadataFormDialog.test.tsx | 186 ++++++++++++++ .../__tests__/MetadataManagerPage.test.tsx | 108 +++++++- .../src/components/MetadataFormDialog.tsx | 198 ++++++++++++++ .../src/config/metadataTypeRegistry.ts | 61 +++++ .../src/pages/system/MetadataDetailPage.tsx | 241 ++++++++++++++++++ .../src/pages/system/MetadataManagerPage.tsx | 91 ++++++- 9 files changed, 1048 insertions(+), 3 deletions(-) create mode 100644 apps/console/src/__tests__/MetadataDetailPage.test.tsx create mode 100644 apps/console/src/__tests__/MetadataFormDialog.test.tsx create mode 100644 apps/console/src/components/MetadataFormDialog.tsx create mode 100644 apps/console/src/pages/system/MetadataDetailPage.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 4171a4919..8b27ce185 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Metadata Create & Edit via MetadataFormDialog** (`@object-ui/console`): New generic `MetadataFormDialog` component (`components/MetadataFormDialog.tsx`) provides a registry-driven create/edit dialog for any metadata type. Form fields are determined by the `formFields` configuration in the metadata type registry, with fallback defaults (`name`, `label`, `description`). Supports required validation, `disabledOnEdit` for immutable keys (e.g. `name`), textarea and select field types, and loading state during submission. + +- **MetadataManagerPage CRUD enhancements** (`@object-ui/console`): Extended the generic MetadataManagerPage with "New" button in the header (opens create dialog), per-item edit buttons (opens pre-filled edit dialog), and click-to-navigate to the detail page. All mutations use `MetadataService.saveMetadataItem()` with toast feedback, loading state, and automatic list refresh. + +- **MetadataDetailPage** (`@object-ui/console`): New detail page component (`pages/system/MetadataDetailPage.tsx`) for viewing a single metadata item at `/system/metadata/:metadataType/:itemName`. Displays item fields from the registry's `formFields` config, supports editing via MetadataFormDialog, and allows custom detail renderers via the registry's `detailComponent` property. + +- **Registry extensions** (`@object-ui/console`): Extended `MetadataTypeConfig` interface with `formFields` (standardized create/edit form structure), `detailComponent` (custom detail renderers), and `MetadataFormFieldDef` type. Added `formFields` entries for dashboard, page, and report types. Added `DEFAULT_FORM_FIELDS` constant for types without explicit form configuration. + - **Unified metadata management architecture** (`@object-ui/console`): New centralized metadata type registry (`config/metadataTypeRegistry.ts`) that defines all manageable metadata categories (app, object, dashboard, page, report) as configuration entries. Registry-driven approach eliminates code duplication — adding a new metadata type requires only a single config entry. Includes `getMetadataTypeConfig()`, `getGenericMetadataTypes()`, and `getHubMetadataTypes()` helpers. - **Generic MetadataManagerPage** (`@object-ui/console`): New reusable page component (`pages/system/MetadataManagerPage.tsx`) for listing and managing metadata items of any registered type. Driven by the `:metadataType` URL parameter, it fetches items via `MetadataService.getItems()`, supports search filtering, soft-delete with confirm pattern, and displays items in a responsive card grid. Routes: `/system/metadata/:metadataType`. diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index a4ee20c6e..15e4249b1 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -49,6 +49,7 @@ const SystemHubPage = lazy(() => import('./pages/system/SystemHubPage').then(m = const AppManagementPage = lazy(() => import('./pages/system/AppManagementPage').then(m => ({ default: m.AppManagementPage }))); const ObjectManagerPage = lazy(() => import('./pages/system/ObjectManagerPage').then(m => ({ default: m.ObjectManagerPage }))); const MetadataManagerPage = lazy(() => import('./pages/system/MetadataManagerPage').then(m => ({ default: m.MetadataManagerPage }))); +const MetadataDetailPage = lazy(() => import('./pages/system/MetadataDetailPage').then(m => ({ default: m.MetadataDetailPage }))); const UserManagementPage = lazy(() => import('./pages/system/UserManagementPage').then(m => ({ default: m.UserManagementPage }))); const OrgManagementPage = lazy(() => import('./pages/system/OrgManagementPage').then(m => ({ default: m.OrgManagementPage }))); const RoleManagementPage = lazy(() => import('./pages/system/RoleManagementPage').then(m => ({ default: m.RoleManagementPage }))); @@ -302,6 +303,7 @@ export function AppContent() { } /> } /> } /> + } /> ); @@ -397,6 +399,7 @@ export function AppContent() { } /> } /> } /> + } /> @@ -492,6 +495,7 @@ function SystemRoutes() { } /> } /> } /> + } /> ); diff --git a/apps/console/src/__tests__/MetadataDetailPage.test.tsx b/apps/console/src/__tests__/MetadataDetailPage.test.tsx new file mode 100644 index 000000000..271ec06d4 --- /dev/null +++ b/apps/console/src/__tests__/MetadataDetailPage.test.tsx @@ -0,0 +1,154 @@ +/** + * MetadataDetailPage Tests + * + * Tests for the generic, registry-driven metadata detail page that shows + * a single metadata item and supports editing via the MetadataFormDialog. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +// --- Mock MetadataService --- +const mockGetItems = vi.fn().mockResolvedValue([]); +const mockSaveMetadataItem = vi.fn().mockResolvedValue(undefined); + +vi.mock('../hooks/useMetadataService', () => ({ + useMetadataService: () => ({ + getItems: mockGetItems, + saveMetadataItem: mockSaveMetadataItem, + }), +})); + +const mockRefresh = vi.fn().mockResolvedValue(undefined); +vi.mock('../context/MetadataProvider', () => ({ + useMetadata: () => ({ + apps: [], + objects: [], + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: mockRefresh, + }), +})); + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +// Import after mocks +import { MetadataDetailPage } from '../pages/system/MetadataDetailPage'; +import { toast } from 'sonner'; + +function renderWithRoute(metadataType: string, itemName: string) { + return render( + + + } + /> + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockGetItems.mockResolvedValue([]); +}); + +describe('MetadataDetailPage', () => { + describe('with known type and existing item', () => { + beforeEach(() => { + mockGetItems.mockResolvedValue([ + { name: 'sales_dash', label: 'Sales Dashboard', description: 'Sales KPIs' }, + { name: 'ops_dash', label: 'Operations', description: 'Ops overview' }, + ]); + }); + + it('should render item details', async () => { + renderWithRoute('dashboard', 'sales_dash'); + await waitFor(() => { + expect(screen.getByTestId('detail-card')).toBeInTheDocument(); + }); + // "Sales Dashboard" appears in both heading and detail card + expect(screen.getAllByText('Sales Dashboard').length).toBeGreaterThanOrEqual(1); + }); + + it('should show edit button', async () => { + renderWithRoute('dashboard', 'sales_dash'); + await waitFor(() => { + expect(screen.getByTestId('detail-edit-btn')).toBeInTheDocument(); + }); + }); + + it('should show back button that navigates to list', async () => { + renderWithRoute('dashboard', 'sales_dash'); + await waitFor(() => { + expect(screen.getByTestId('back-to-list-btn')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('back-to-list-btn')); + expect(mockNavigate).toHaveBeenCalledWith('/system/metadata/dashboard'); + }); + + it('should open edit dialog when edit button clicked', async () => { + renderWithRoute('dashboard', 'sales_dash'); + await waitFor(() => { + expect(screen.getByTestId('detail-edit-btn')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('detail-edit-btn')); + await waitFor(() => { + expect(screen.getByTestId('metadata-form-dialog')).toBeInTheDocument(); + }); + }); + + it('should display field values from the item', async () => { + renderWithRoute('dashboard', 'sales_dash'); + await waitFor(() => { + expect(screen.getByTestId('detail-card')).toBeInTheDocument(); + }); + // "sales_dash" appears in both the heading subtitle and the detail card + expect(screen.getAllByText('sales_dash').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Sales Dashboard').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('Sales KPIs')).toBeInTheDocument(); + }); + }); + + describe('with item not found', () => { + it('should show not found message', async () => { + mockGetItems.mockResolvedValue([]); + renderWithRoute('dashboard', 'nonexistent'); + await waitFor(() => { + expect(screen.getByTestId('detail-not-found')).toBeInTheDocument(); + }); + }); + }); + + describe('with unknown metadata type', () => { + it('should show unknown type message', () => { + renderWithRoute('nonexistent_type', 'some_item'); + expect(screen.getByText(/Unknown metadata type: nonexistent_type/)).toBeInTheDocument(); + }); + }); + + describe('loading state', () => { + it('should show loading indicator while fetching', () => { + mockGetItems.mockReturnValue(new Promise(() => {})); // never resolves + renderWithRoute('dashboard', 'sales_dash'); + expect(screen.getByTestId('detail-loading')).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/console/src/__tests__/MetadataFormDialog.test.tsx b/apps/console/src/__tests__/MetadataFormDialog.test.tsx new file mode 100644 index 000000000..263e9767c --- /dev/null +++ b/apps/console/src/__tests__/MetadataFormDialog.test.tsx @@ -0,0 +1,186 @@ +/** + * MetadataFormDialog Tests + * + * Tests for the generic create/edit dialog driven by the metadata type + * registry's `formFields` configuration. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MetadataFormDialog } from '../components/MetadataFormDialog'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('MetadataFormDialog', () => { + const defaultProps = { + open: true, + onOpenChange: vi.fn(), + mode: 'create' as const, + typeLabel: 'Dashboard', + onSubmit: vi.fn().mockResolvedValue(undefined), + }; + + describe('create mode', () => { + it('should render dialog with create title', () => { + render(); + expect(screen.getByText('New Dashboard')).toBeInTheDocument(); + }); + + it('should render default form fields when no formFields provided', () => { + render(); + expect(screen.getByTestId('metadata-field-name')).toBeInTheDocument(); + expect(screen.getByTestId('metadata-field-label')).toBeInTheDocument(); + expect(screen.getByTestId('metadata-field-description')).toBeInTheDocument(); + }); + + it('should render custom form fields from formFields prop', () => { + const formFields = [ + { key: 'name', label: 'Name', required: true }, + { key: 'title', label: 'Title', required: false }, + ]; + render(); + expect(screen.getByTestId('metadata-field-name')).toBeInTheDocument(); + expect(screen.getByTestId('metadata-field-title')).toBeInTheDocument(); + expect(screen.queryByTestId('metadata-field-description')).not.toBeInTheDocument(); + }); + + it('should show Create button text', () => { + render(); + expect(screen.getByTestId('metadata-form-submit-btn')).toHaveTextContent('Create'); + }); + + it('should disable submit when required fields are empty', () => { + render(); + expect(screen.getByTestId('metadata-form-submit-btn')).toBeDisabled(); + }); + + it('should enable submit when required fields are filled', () => { + render(); + fireEvent.change(screen.getByTestId('metadata-field-name'), { + target: { value: 'my_dash' }, + }); + fireEvent.change(screen.getByTestId('metadata-field-label'), { + target: { value: 'My Dash' }, + }); + expect(screen.getByTestId('metadata-form-submit-btn')).not.toBeDisabled(); + }); + + it('should call onSubmit with form values when submitted', async () => { + const mockSubmit = vi.fn().mockResolvedValue(undefined); + render(); + + fireEvent.change(screen.getByTestId('metadata-field-name'), { + target: { value: 'my_dash' }, + }); + fireEvent.change(screen.getByTestId('metadata-field-label'), { + target: { value: 'My Dashboard' }, + }); + fireEvent.click(screen.getByTestId('metadata-form-submit-btn')); + + await waitFor(() => { + expect(mockSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'my_dash', + label: 'My Dashboard', + }), + ); + }); + }); + + it('should call onOpenChange(false) after successful submit', async () => { + const mockOpenChange = vi.fn(); + render( + , + ); + + fireEvent.change(screen.getByTestId('metadata-field-name'), { + target: { value: 'test' }, + }); + fireEvent.change(screen.getByTestId('metadata-field-label'), { + target: { value: 'Test' }, + }); + fireEvent.click(screen.getByTestId('metadata-form-submit-btn')); + + await waitFor(() => { + expect(mockOpenChange).toHaveBeenCalledWith(false); + }); + }); + + it('should close dialog when Cancel is clicked', () => { + const mockOpenChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId('metadata-form-cancel-btn')); + expect(mockOpenChange).toHaveBeenCalledWith(false); + }); + }); + + describe('edit mode', () => { + const editProps = { + ...defaultProps, + mode: 'edit' as const, + initialValues: { name: 'existing_dash', label: 'Existing Dashboard', description: 'Old desc' }, + }; + + it('should render dialog with edit title', () => { + render(); + expect(screen.getByText('Edit Dashboard')).toBeInTheDocument(); + }); + + it('should show Save button text', () => { + render(); + expect(screen.getByTestId('metadata-form-submit-btn')).toHaveTextContent('Save'); + }); + + it('should pre-fill form fields with initial values', () => { + render(); + expect(screen.getByTestId('metadata-field-name')).toHaveValue('existing_dash'); + expect(screen.getByTestId('metadata-field-label')).toHaveValue('Existing Dashboard'); + expect(screen.getByTestId('metadata-field-description')).toHaveValue('Old desc'); + }); + + it('should disable fields with disabledOnEdit in edit mode', () => { + const formFields = [ + { key: 'name', label: 'Name', required: true, disabledOnEdit: true }, + { key: 'label', label: 'Label', required: true }, + ]; + render( + , + ); + expect(screen.getByTestId('metadata-field-name')).toBeDisabled(); + expect(screen.getByTestId('metadata-field-label')).not.toBeDisabled(); + }); + }); + + describe('textarea fields', () => { + it('should render textarea for fields with type textarea', () => { + const formFields = [ + { key: 'name', label: 'Name', required: true }, + { key: 'description', label: 'Description', type: 'textarea' as const }, + ]; + render(); + const desc = screen.getByTestId('metadata-field-description'); + expect(desc.tagName).toBe('TEXTAREA'); + }); + }); + + describe('when dialog is closed', () => { + it('should not render dialog content when open is false', () => { + render(); + expect(screen.queryByTestId('metadata-form-dialog')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/console/src/__tests__/MetadataManagerPage.test.tsx b/apps/console/src/__tests__/MetadataManagerPage.test.tsx index 7a1dbb6ca..f89206663 100644 --- a/apps/console/src/__tests__/MetadataManagerPage.test.tsx +++ b/apps/console/src/__tests__/MetadataManagerPage.test.tsx @@ -13,12 +13,13 @@ import { MemoryRouter, Routes, Route } from 'react-router-dom'; // --- Mock MetadataService --- const mockGetItems = vi.fn().mockResolvedValue([]); const mockDeleteMetadataItem = vi.fn().mockResolvedValue(undefined); +const mockSaveMetadataItem = vi.fn().mockResolvedValue(undefined); vi.mock('../hooks/useMetadataService', () => ({ useMetadataService: () => ({ getItems: mockGetItems, deleteMetadataItem: mockDeleteMetadataItem, - saveMetadataItem: vi.fn(), + saveMetadataItem: mockSaveMetadataItem, }), })); @@ -222,4 +223,109 @@ describe('MetadataManagerPage', () => { }); }); }); + + describe('create functionality', () => { + it('should show create button for editable types', async () => { + renderWithRoute('dashboard'); + await waitFor(() => { + expect(screen.getByTestId('create-metadata-btn')).toBeInTheDocument(); + }); + }); + + it('should open form dialog when create button is clicked', async () => { + renderWithRoute('dashboard'); + await waitFor(() => { + expect(screen.getByTestId('create-metadata-btn')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('create-metadata-btn')); + await waitFor(() => { + expect(screen.getByTestId('metadata-form-dialog')).toBeInTheDocument(); + }); + }); + + it('should call saveMetadataItem on create form submit', async () => { + renderWithRoute('dashboard'); + await waitFor(() => { + expect(screen.getByTestId('create-metadata-btn')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('create-metadata-btn')); + await waitFor(() => { + expect(screen.getByTestId('metadata-form-dialog')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('metadata-field-name'), { + target: { value: 'new_dash' }, + }); + fireEvent.change(screen.getByTestId('metadata-field-label'), { + target: { value: 'New Dash' }, + }); + fireEvent.click(screen.getByTestId('metadata-form-submit-btn')); + + await waitFor(() => { + expect(mockSaveMetadataItem).toHaveBeenCalledWith( + 'dashboard', + 'new_dash', + expect.objectContaining({ name: 'new_dash', label: 'New Dash' }), + ); + expect(toast.success).toHaveBeenCalledWith('Dashboard "new_dash" created'); + }); + }); + }); + + describe('edit functionality', () => { + it('should show edit button for each item', async () => { + mockGetItems.mockResolvedValue([ + { name: 'test_dash', label: 'Test Dashboard' }, + ]); + renderWithRoute('dashboard'); + await waitFor(() => { + expect(screen.getByTestId('edit-test_dash-btn')).toBeInTheDocument(); + }); + }); + + it('should open edit form dialog when edit button is clicked', async () => { + mockGetItems.mockResolvedValue([ + { name: 'test_dash', label: 'Test Dashboard', description: 'Desc' }, + ]); + renderWithRoute('dashboard'); + await waitFor(() => { + expect(screen.getByTestId('edit-test_dash-btn')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('edit-test_dash-btn')); + await waitFor(() => { + expect(screen.getByTestId('metadata-form-dialog')).toBeInTheDocument(); + expect(screen.getByText('Edit Dashboard')).toBeInTheDocument(); + }); + }); + + it('should pre-fill edit form with item values', async () => { + mockGetItems.mockResolvedValue([ + { name: 'test_dash', label: 'Test Dashboard', description: 'Some desc' }, + ]); + renderWithRoute('dashboard'); + await waitFor(() => { + expect(screen.getByTestId('edit-test_dash-btn')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('edit-test_dash-btn')); + await waitFor(() => { + expect(screen.getByTestId('metadata-field-name')).toHaveValue('test_dash'); + expect(screen.getByTestId('metadata-field-label')).toHaveValue('Test Dashboard'); + expect(screen.getByTestId('metadata-field-description')).toHaveValue('Some desc'); + }); + }); + }); + + describe('item navigation to detail', () => { + it('should navigate to detail page when item card is clicked', async () => { + mockGetItems.mockResolvedValue([ + { name: 'sales_dash', label: 'Sales Dashboard' }, + ]); + renderWithRoute('dashboard'); + await waitFor(() => { + expect(screen.getByTestId('metadata-item-sales_dash')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('metadata-item-sales_dash')); + expect(mockNavigate).toHaveBeenCalledWith('/system/metadata/dashboard/sales_dash'); + }); + }); }); diff --git a/apps/console/src/components/MetadataFormDialog.tsx b/apps/console/src/components/MetadataFormDialog.tsx new file mode 100644 index 000000000..293267a64 --- /dev/null +++ b/apps/console/src/components/MetadataFormDialog.tsx @@ -0,0 +1,198 @@ +/** + * MetadataFormDialog + * + * Generic, registry-driven dialog for creating and editing metadata items. + * Form fields are determined by the `formFields` configuration in the metadata + * type registry, falling back to the default `name`, `label`, `description` + * fields when not specified. + * + * @module components/MetadataFormDialog + */ + +import { useState, useCallback, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + Button, + Input, + Label, + Textarea, +} from '@object-ui/components'; +import { Loader2 } from 'lucide-react'; +import { + DEFAULT_FORM_FIELDS, + type MetadataFormFieldDef, +} from '../config/metadataTypeRegistry'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface MetadataFormDialogProps { + /** Whether the dialog is open. */ + open: boolean; + /** Callback when the dialog open state changes. */ + onOpenChange: (open: boolean) => void; + /** Dialog mode — create or edit. */ + mode: 'create' | 'edit'; + /** Human-readable type label (e.g. `'Dashboard'`). */ + typeLabel: string; + /** Form field definitions from the registry (or defaults). */ + formFields?: MetadataFormFieldDef[]; + /** Initial values when editing an existing item. */ + initialValues?: Record; + /** Called when the user submits the form. Returns a promise. */ + onSubmit: (values: Record) => Promise; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function MetadataFormDialog({ + open, + onOpenChange, + mode, + typeLabel, + formFields, + initialValues, + onSubmit, +}: MetadataFormDialogProps) { + const fields = formFields ?? DEFAULT_FORM_FIELDS; + const isEdit = mode === 'edit'; + + // Form state + const [values, setValues] = useState>({}); + const [submitting, setSubmitting] = useState(false); + + // Reset form values when dialog opens or initialValues change + useEffect(() => { + if (open) { + const initial: Record = {}; + for (const f of fields) { + initial[f.key] = String(initialValues?.[f.key] ?? ''); + } + setValues(initial); + } + }, [open, initialValues, fields]); + + const handleChange = useCallback((key: string, value: string) => { + setValues((prev) => ({ ...prev, [key]: value })); + }, []); + + const handleSubmit = useCallback(async () => { + setSubmitting(true); + try { + await onSubmit(values); + onOpenChange(false); + } catch { + // Error handling is done by the caller (toast, etc.) + } finally { + setSubmitting(false); + } + }, [values, onSubmit, onOpenChange]); + + // Validate required fields + const isValid = fields + .filter((f) => f.required) + .every((f) => (values[f.key] ?? '').trim().length > 0); + + return ( + + + + + {isEdit ? `Edit ${typeLabel}` : `New ${typeLabel}`} + + + {isEdit + ? `Update the ${typeLabel.toLowerCase()} details below.` + : `Fill in the details to create a new ${typeLabel.toLowerCase()}.`} + + + +
+ {fields.map((field) => { + const disabled = submitting || (isEdit && field.disabledOnEdit === true); + const value = values[field.key] ?? ''; + const inputId = `metadata-field-${field.key}`; + + return ( +
+ + + {field.type === 'textarea' ? ( +