Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ 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), `actions` (custom page-level and row-level action buttons via `MetadataActionDef`), and `MetadataFormFieldDef` type. Added `formFields` entries for dashboard, page, and report types. Added `DEFAULT_FORM_FIELDS` constant for types without explicit form configuration.

- **Permission integration** (`@object-ui/console`): MetadataManagerPage and MetadataDetailPage now check the current user's role via `useAuth()`. Create, edit, and delete buttons are only shown for admin users (`user.role === 'admin'`), matching the pattern used by UserManagementPage, RoleManagementPage, and other system pages.

- **Custom actions support** (`@object-ui/console`): MetadataManagerPage renders page-level and row-level custom action buttons from the registry's `actions` configuration. Page-level actions appear in the header alongside the create button; row-level actions appear on each item card alongside edit/delete.

- **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`.
Expand Down
4 changes: 4 additions & 0 deletions apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })));
Expand Down Expand Up @@ -302,6 +303,7 @@ export function AppContent() {
<Route path="system/audit-log" element={<AuditLogPage />} />
<Route path="system/profile" element={<ProfilePage />} />
<Route path="system/metadata/:metadataType" element={<MetadataManagerPage />} />
<Route path="system/metadata/:metadataType/:itemName" element={<MetadataDetailPage />} />
</Routes>
</Suspense>
);
Expand Down Expand Up @@ -397,6 +399,7 @@ export function AppContent() {
<Route path="system/audit-log" element={<AuditLogPage />} />
<Route path="system/profile" element={<ProfilePage />} />
<Route path="system/metadata/:metadataType" element={<MetadataManagerPage />} />
<Route path="system/metadata/:metadataType/:itemName" element={<MetadataDetailPage />} />
</Routes>
</Suspense>
</ErrorBoundary>
Expand Down Expand Up @@ -492,6 +495,7 @@ function SystemRoutes() {
<Route path="audit-log" element={<AuditLogPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="metadata/:metadataType" element={<MetadataManagerPage />} />
<Route path="metadata/:metadataType/:itemName" element={<MetadataDetailPage />} />
</Routes>
</Suspense>
);
Expand Down
158 changes: 158 additions & 0 deletions apps/console/src/__tests__/MetadataDetailPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* 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() },
}));

vi.mock('@object-ui/auth', () => ({
useAuth: () => ({ user: { id: 'u1', name: 'Admin', role: 'admin' } }),
}));

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(
<MemoryRouter initialEntries={[`/system/metadata/${metadataType}/${itemName}`]}>
<Routes>
<Route
path="/system/metadata/:metadataType/:itemName"
element={<MetadataDetailPage />}
/>
</Routes>
</MemoryRouter>,
);
}

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();
});
});
});
186 changes: 186 additions & 0 deletions apps/console/src/__tests__/MetadataFormDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<MetadataFormDialog {...defaultProps} />);
expect(screen.getByText('New Dashboard')).toBeInTheDocument();
});

it('should render default form fields when no formFields provided', () => {
render(<MetadataFormDialog {...defaultProps} />);
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(<MetadataFormDialog {...defaultProps} formFields={formFields} />);
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(<MetadataFormDialog {...defaultProps} />);
expect(screen.getByTestId('metadata-form-submit-btn')).toHaveTextContent('Create');
});

it('should disable submit when required fields are empty', () => {
render(<MetadataFormDialog {...defaultProps} />);
expect(screen.getByTestId('metadata-form-submit-btn')).toBeDisabled();
});

it('should enable submit when required fields are filled', () => {
render(<MetadataFormDialog {...defaultProps} />);
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(<MetadataFormDialog {...defaultProps} onSubmit={mockSubmit} />);

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(
<MetadataFormDialog
{...defaultProps}
onOpenChange={mockOpenChange}
/>,
);

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(
<MetadataFormDialog
{...defaultProps}
onOpenChange={mockOpenChange}
/>,
);
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(<MetadataFormDialog {...editProps} />);
expect(screen.getByText('Edit Dashboard')).toBeInTheDocument();
});

it('should show Save button text', () => {
render(<MetadataFormDialog {...editProps} />);
expect(screen.getByTestId('metadata-form-submit-btn')).toHaveTextContent('Save');
});

it('should pre-fill form fields with initial values', () => {
render(<MetadataFormDialog {...editProps} />);
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(
<MetadataFormDialog
{...editProps}
formFields={formFields}
/>,
);
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(<MetadataFormDialog {...defaultProps} formFields={formFields} />);
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(<MetadataFormDialog {...defaultProps} open={false} />);
expect(screen.queryByTestId('metadata-form-dialog')).not.toBeInTheDocument();
});
});
});
Loading