Skip to content

Commit a69e4dc

Browse files
Copilothotlong
andcommitted
test: add tests for system pages, view designer save, and report dynamic fields
- 19 new tests across 3 test files all passing - Update ROADMAP_CONSOLE.md with API integration status for G10-G13 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 2e385e0 commit a69e4dc

4 files changed

Lines changed: 369 additions & 3 deletions

File tree

ROADMAP_CONSOLE.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,10 @@ The Console is the **canonical proof** that ObjectUI's Server-Driven UI (SDUI) e
161161

162162
**Pages & Dashboards:**
163163
- ✅ Dashboard renderer with chart widgets
164-
- ✅ Report viewer with builder mode
164+
- ✅ Report viewer with builder mode (dynamic fields from object schema)
165165
- ✅ Custom page renderer (SchemaRenderer)
166166
- ✅ Record detail page
167-
- ✅ View designer page
167+
- ✅ View designer page (save persisted via API)
168168
- ✅ Search results page
169169

170170
**Expression Engine:**
@@ -225,6 +225,7 @@ The Console is the **canonical proof** that ObjectUI's Server-Driven UI (SDUI) e
225225
-`AuthGuard` + `ConditionalAuthWrapper`
226226
- ✅ Login, Register, Forgot Password pages
227227
- ✅ System admin pages (users, orgs, roles, audit log, profile)
228+
- ✅ System admin pages CRUD via `dataSource.find/create/delete` (API integration)
228229

229230
### Resolved Gaps
230231

@@ -239,6 +240,10 @@ The Console is the **canonical proof** that ObjectUI's Server-Driven UI (SDUI) e
239240
| G7 | No offline support / PWA | ⚠️ | `MobileProvider` with PWA manifest; background sync queue simulated only (no real server sync) |
240241
| G8 | Bundle size 200KB+ || Code splitting (15+ manual chunks), compression, preloading |
241242
| G9 | NavigationConfig incomplete || All 8 view plugins support NavigationConfig with 7 modes |
243+
| G10 | System admin pages stub-only || All 4 system pages wired to `dataSource.find/create/delete` via `useAdapter()` |
244+
| G11 | Collaboration data hardcoded || Presence/activity/comments now fetched from API; fallback to defaults when API unavailable |
245+
| G12 | ReportBuilder uses mock fields || `availableFields` derived from object schema via `useMetadata().objects` |
246+
| G13 | ViewDesigner save not persisted || `handleSave` calls `dataSource.create/update('sys_view', config)` |
242247

243248
---
244249

@@ -814,7 +819,7 @@ These were the initial tasks to bring the console prototype to production-qualit
814819

815820
**Goal:** Add record-level comments, @mention notifications, activity feed, and threaded discussions.
816821

817-
**Status:** ✅ L2 Complete — `CommentThread` from `@object-ui/collaboration` integrated into console `RecordDetailView` with thread resolution (resolve/reopen), emoji reactions, and sorting. `ActivityFeed` sidebar with notification preference filters (toggle by activity type). Demo activity data wired into AppHeader.
822+
**Status:** ✅ L2 Complete — `CommentThread` from `@object-ui/collaboration` integrated into console `RecordDetailView` with thread resolution (resolve/reopen), emoji reactions, and sorting. `ActivityFeed` sidebar with notification preference filters (toggle by activity type). Presence and activity data now fetched from API (`sys_presence`, `sys_activity`). Comments persisted via `sys_comment` resource. AppHeader falls back to defaults when API is unavailable.
818823

819824
#### 17.1: Record-Level Comments
820825

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* ReportView Dynamic Fields Unit Tests
3+
*
4+
* Tests the field derivation logic that replaces hardcoded MOCK_FIELDS
5+
* with fields from object schema. Uses a lightweight approach to avoid
6+
* OOM issues from heavy plugin-report imports.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
11+
/**
12+
* Port of the availableFields derivation logic from ReportView.tsx.
13+
* This tests the pure logic without importing the full component tree.
14+
*/
15+
function deriveAvailableFields(
16+
reportData: any,
17+
objects: any[],
18+
fallbackFields: any[]
19+
): any[] {
20+
const objName = reportData?.objectName || reportData?.dataSource?.object || reportData?.dataSource?.resource;
21+
if (objName && objects?.length) {
22+
const objDef = objects.find((o: any) => o.name === objName);
23+
if (objDef?.fields) {
24+
const fields = objDef.fields;
25+
if (Array.isArray(fields)) {
26+
return fields.map((f: any) =>
27+
typeof f === 'string'
28+
? { name: f, label: f, type: 'text' }
29+
: { name: f.name, label: f.label || f.name, type: f.type || 'text' },
30+
);
31+
}
32+
return Object.entries(fields).map(([name, def]: [string, any]) => ({
33+
name,
34+
label: def.label || name,
35+
type: def.type || 'text',
36+
}));
37+
}
38+
}
39+
return fallbackFields;
40+
}
41+
42+
const FALLBACK = [
43+
{ name: 'month', label: 'Month', type: 'string' },
44+
{ name: 'revenue', label: 'Revenue', type: 'number' },
45+
];
46+
47+
const OBJECTS = [
48+
{
49+
name: 'opportunity',
50+
label: 'Opportunity',
51+
fields: [
52+
{ name: 'name', label: 'Deal Name', type: 'text' },
53+
{ name: 'amount', label: 'Amount', type: 'currency' },
54+
{ name: 'stage', label: 'Stage', type: 'select' },
55+
],
56+
},
57+
{
58+
name: 'contact',
59+
label: 'Contact',
60+
fields: {
61+
first_name: { label: 'First Name', type: 'text' },
62+
last_name: { label: 'Last Name', type: 'text' },
63+
email: { label: 'Email', type: 'email' },
64+
},
65+
},
66+
];
67+
68+
describe('ReportView Dynamic Fields Logic', () => {
69+
it('should derive fields from array-style object schema', () => {
70+
const fields = deriveAvailableFields({ objectName: 'opportunity' }, OBJECTS, FALLBACK);
71+
expect(fields).toHaveLength(3);
72+
expect(fields[0]).toEqual({ name: 'name', label: 'Deal Name', type: 'text' });
73+
expect(fields[1]).toEqual({ name: 'amount', label: 'Amount', type: 'currency' });
74+
expect(fields[2]).toEqual({ name: 'stage', label: 'Stage', type: 'select' });
75+
});
76+
77+
it('should derive fields from object-map-style schema', () => {
78+
const fields = deriveAvailableFields({ objectName: 'contact' }, OBJECTS, FALLBACK);
79+
expect(fields).toHaveLength(3);
80+
expect(fields).toEqual(
81+
expect.arrayContaining([
82+
expect.objectContaining({ name: 'first_name', label: 'First Name', type: 'text' }),
83+
expect.objectContaining({ name: 'email', label: 'Email', type: 'email' }),
84+
])
85+
);
86+
});
87+
88+
it('should use dataSource.object for lookup', () => {
89+
const fields = deriveAvailableFields(
90+
{ dataSource: { object: 'opportunity' } },
91+
OBJECTS,
92+
FALLBACK
93+
);
94+
expect(fields).toHaveLength(3);
95+
expect(fields[0]).toEqual({ name: 'name', label: 'Deal Name', type: 'text' });
96+
});
97+
98+
it('should use dataSource.resource for lookup', () => {
99+
const fields = deriveAvailableFields(
100+
{ dataSource: { resource: 'contact' } },
101+
OBJECTS,
102+
FALLBACK
103+
);
104+
expect(fields).toHaveLength(3);
105+
expect(fields[0]).toEqual(expect.objectContaining({ name: 'first_name' }));
106+
});
107+
108+
it('should fall back to defaults when no matching object found', () => {
109+
const fields = deriveAvailableFields({ objectName: 'nonexistent' }, OBJECTS, FALLBACK);
110+
expect(fields).toBe(FALLBACK);
111+
});
112+
113+
it('should fall back to defaults when reportData has no objectName or dataSource', () => {
114+
const fields = deriveAvailableFields({}, OBJECTS, FALLBACK);
115+
expect(fields).toBe(FALLBACK);
116+
});
117+
118+
it('should fall back to defaults when objects list is empty', () => {
119+
const fields = deriveAvailableFields({ objectName: 'opportunity' }, [], FALLBACK);
120+
expect(fields).toBe(FALLBACK);
121+
});
122+
123+
it('should handle string-only fields', () => {
124+
const objects = [
125+
{ name: 'simple', fields: ['id', 'name', 'email'] },
126+
];
127+
const fields = deriveAvailableFields({ objectName: 'simple' }, objects, FALLBACK);
128+
expect(fields).toHaveLength(3);
129+
expect(fields[0]).toEqual({ name: 'id', label: 'id', type: 'text' });
130+
});
131+
});
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* System Admin Pages Integration Tests
3+
*
4+
* Tests that system pages (User, Org, Role, AuditLog) fetch data
5+
* via useAdapter() and render records from the API.
6+
*/
7+
8+
import { describe, it, expect, vi, beforeEach } from 'vitest';
9+
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
10+
import '@testing-library/jest-dom';
11+
import { MemoryRouter } from 'react-router-dom';
12+
13+
// --- Shared mock adapter ---
14+
const mockFind = vi.fn().mockResolvedValue({ data: [], total: 0 });
15+
const mockCreate = vi.fn().mockResolvedValue({ id: 'new-1' });
16+
const mockDelete = vi.fn().mockResolvedValue({});
17+
18+
vi.mock('../context/AdapterProvider', () => ({
19+
useAdapter: () => ({
20+
find: mockFind,
21+
create: mockCreate,
22+
delete: mockDelete,
23+
update: vi.fn(),
24+
findOne: vi.fn(),
25+
}),
26+
}));
27+
28+
vi.mock('@object-ui/auth', () => ({
29+
useAuth: () => ({ user: { id: 'u1', name: 'Admin', role: 'admin' } }),
30+
}));
31+
32+
vi.mock('sonner', () => ({
33+
toast: { success: vi.fn(), error: vi.fn() },
34+
}));
35+
36+
// Import after mocks
37+
import { UserManagementPage } from '../pages/system/UserManagementPage';
38+
import { OrgManagementPage } from '../pages/system/OrgManagementPage';
39+
import { RoleManagementPage } from '../pages/system/RoleManagementPage';
40+
import { AuditLogPage } from '../pages/system/AuditLogPage';
41+
42+
function wrap(ui: React.ReactElement) {
43+
return render(<MemoryRouter>{ui}</MemoryRouter>);
44+
}
45+
46+
beforeEach(() => {
47+
vi.clearAllMocks();
48+
});
49+
50+
describe('UserManagementPage', () => {
51+
it('should call dataSource.find("sys_user") on mount', async () => {
52+
mockFind.mockResolvedValueOnce({
53+
data: [{ id: '1', name: 'Alice', email: 'alice@test.com', role: 'admin', status: 'active', lastLoginAt: '' }],
54+
});
55+
wrap(<UserManagementPage />);
56+
await waitFor(() => {
57+
expect(mockFind).toHaveBeenCalledWith('sys_user');
58+
});
59+
expect(screen.getByText('Alice')).toBeInTheDocument();
60+
});
61+
62+
it('should show empty state when no users', async () => {
63+
mockFind.mockResolvedValueOnce({ data: [] });
64+
wrap(<UserManagementPage />);
65+
await waitFor(() => {
66+
expect(screen.getByText('No users found.')).toBeInTheDocument();
67+
});
68+
});
69+
70+
it('should call create when Add User is clicked', async () => {
71+
mockFind.mockResolvedValue({ data: [] });
72+
mockCreate.mockResolvedValueOnce({ id: 'new-user' });
73+
wrap(<UserManagementPage />);
74+
await waitFor(() => {
75+
expect(screen.getByText('No users found.')).toBeInTheDocument();
76+
});
77+
fireEvent.click(screen.getByText('Add User'));
78+
await waitFor(() => {
79+
expect(mockCreate).toHaveBeenCalledWith('sys_user', expect.objectContaining({ name: 'New User' }));
80+
});
81+
});
82+
});
83+
84+
describe('OrgManagementPage', () => {
85+
it('should call dataSource.find("sys_org") on mount', async () => {
86+
mockFind.mockResolvedValueOnce({
87+
data: [{ id: '1', name: 'Acme', slug: 'acme', plan: 'pro', status: 'active', memberCount: 5 }],
88+
});
89+
wrap(<OrgManagementPage />);
90+
await waitFor(() => {
91+
expect(mockFind).toHaveBeenCalledWith('sys_org');
92+
});
93+
expect(screen.getByText('Acme')).toBeInTheDocument();
94+
});
95+
96+
it('should show empty state when no organizations', async () => {
97+
mockFind.mockResolvedValueOnce({ data: [] });
98+
wrap(<OrgManagementPage />);
99+
await waitFor(() => {
100+
expect(screen.getByText('No organizations found.')).toBeInTheDocument();
101+
});
102+
});
103+
});
104+
105+
describe('RoleManagementPage', () => {
106+
it('should call dataSource.find("sys_role") on mount', async () => {
107+
mockFind.mockResolvedValueOnce({
108+
data: [{ id: '1', name: 'Admin', description: 'Full access', isSystem: true, userCount: 3 }],
109+
});
110+
wrap(<RoleManagementPage />);
111+
await waitFor(() => {
112+
expect(mockFind).toHaveBeenCalledWith('sys_role');
113+
});
114+
expect(screen.getByText('Admin')).toBeInTheDocument();
115+
});
116+
117+
it('should show empty state when no roles', async () => {
118+
mockFind.mockResolvedValueOnce({ data: [] });
119+
wrap(<RoleManagementPage />);
120+
await waitFor(() => {
121+
expect(screen.getByText('No roles found.')).toBeInTheDocument();
122+
});
123+
});
124+
});
125+
126+
describe('AuditLogPage', () => {
127+
it('should call dataSource.find("sys_audit_log") on mount', async () => {
128+
mockFind.mockResolvedValueOnce({
129+
data: [{ id: '1', action: 'create', resource: 'user', userName: 'Admin', ipAddress: '127.0.0.1', createdAt: '2026-01-01' }],
130+
});
131+
wrap(<AuditLogPage />);
132+
await waitFor(() => {
133+
expect(mockFind).toHaveBeenCalledWith('sys_audit_log', expect.objectContaining({ $orderby: 'createdAt desc' }));
134+
});
135+
expect(screen.getByText('create')).toBeInTheDocument();
136+
});
137+
138+
it('should show empty state when no logs', async () => {
139+
mockFind.mockResolvedValueOnce({ data: [] });
140+
wrap(<AuditLogPage />);
141+
await waitFor(() => {
142+
expect(screen.getByText('No audit logs found.')).toBeInTheDocument();
143+
});
144+
});
145+
});

0 commit comments

Comments
 (0)