Skip to content

Commit eca01df

Browse files
committed
Add tests for ObjectGridTable component and setup testing environment
- Created a new test file for the ObjectGridTable component to verify rendering and basic functionality. - Added a mock configuration and data for testing purposes. - Implemented a setup file for Jest to include necessary configurations and mock the ResizeObserver. - Configured Vitest to use jsdom environment and specified setup files for testing.
1 parent e668ac1 commit eca01df

File tree

12 files changed

+871
-58
lines changed

12 files changed

+871
-58
lines changed

apps/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
"dependencies": {
1515
"@objectos/ui": "workspace:*",
1616
"@objectql/types": "^1.6.1",
17+
"ag-grid-community": "^35.0.0",
18+
"ag-grid-react": "^35.0.0",
1719
"better-auth": "^1.4.10",
1820
"clsx": "^2.1.0",
1921
"lucide-react": "^0.344.0",

apps/web/src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ function AppContent() {
4848
<Route path={paths.APP_ROOT} element={<AppDashboard />} />
4949

5050
{/* Object Routes */}
51-
<Route path={paths.APP_OBJECT_LIST} element={<ObjectListRoute />} />
51+
<Route path={paths.APP_OBJECT_LIST} element={<ObjectListRoute isCreating={false} />} />
52+
<Route path={paths.APP_OBJECT_NEW} element={<ObjectListRoute isCreating={true} />} />
5253
<Route path={paths.APP_OBJECT_DETAIL} element={<ObjectDetailRoute />} />
5354
{/* Legacy/Compat routes support */}
5455
<Route path="/app/:appName/object/:objectName/:recordId" element={<ObjectDetailRoute />} />

apps/web/src/components/dashboard/ObjectListView.tsx

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,19 @@ export function ObjectListView({ objectName, user, isCreating, navigate, objectS
4141

4242
const params = new URLSearchParams();
4343
if (searchTerm) {
44-
const textFields = objectSchema?.fields ?
45-
Object.entries(objectSchema.fields)
46-
.filter(([_, field]: [string, any]) => !field.type || field.type === 'string')
47-
.map(([key]) => key)
48-
: ['name', 'title', 'description', 'email'];
44+
let textFields: string[] = [];
45+
46+
if (objectSchema?.fields) {
47+
const fieldsArr = Array.isArray(objectSchema.fields)
48+
? objectSchema.fields
49+
: Object.values(objectSchema.fields);
50+
51+
textFields = fieldsArr
52+
.filter((field: any) => !field.type || field.type === 'string')
53+
.map((field: any) => field.name);
54+
} else {
55+
textFields = ['name', 'title', 'description', 'email'];
56+
}
4957

5058
if (textFields.length > 0) {
5159
const searchFilters: any[] = [];
@@ -67,7 +75,7 @@ export function ObjectListView({ objectName, user, isCreating, navigate, objectS
6775
return res.json();
6876
})
6977
.then(result => {
70-
const items = Array.isArray(result) ? result : (result.list || []);
78+
const items = Array.isArray(result) ? result : (result.list || result.data || result.value || []);
7179
setData(items);
7280
})
7381
.catch(err => {
@@ -92,19 +100,19 @@ export function ObjectListView({ objectName, user, isCreating, navigate, objectS
92100
return res.json();
93101
})
94102
.then(() => {
95-
navigate(`/object/${objectName}`);
103+
navigate('..');
96104
fetchData();
97105
})
98106
.catch(err => alert(err.message));
99107
}
100108

101109
const onRowClick = (event: any) => {
102110
const id = event.data?.id || event.data?._id;
103-
if (id) navigate(`/object/${objectName}/${id}`);
111+
if (id) navigate(`${id}`);
104112
};
105113

106114
return (
107-
<div className="h-full flex flex-col space-y-4 p-4 overflow-hidden">
115+
<div className="h-full flex flex-col space-y-4 overflow-hidden">
108116
<div className="flex items-center justify-between shrink-0">
109117
<div className="flex items-center space-x-2">
110118
<h1 className="text-2xl font-bold tracking-tight">{label}</h1>
@@ -127,7 +135,7 @@ export function ObjectListView({ objectName, user, isCreating, navigate, objectS
127135
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
128136
Refresh
129137
</Button>
130-
<Button onClick={() => navigate(`/object/${objectName}/new`)} size="sm">
138+
<Button onClick={() => navigate('new')} size="sm">
131139
<Plus className="h-4 w-4 mr-2" />
132140
New
133141
</Button>
@@ -153,7 +161,7 @@ export function ObjectListView({ objectName, user, isCreating, navigate, objectS
153161

154162
<Dialog
155163
open={isCreating}
156-
onOpenChange={(open) => !open && navigate(`/object/${objectName}`)}
164+
onOpenChange={(open) => !open && navigate('..')}
157165
>
158166
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
159167
<DialogHeader>
@@ -164,7 +172,7 @@ export function ObjectListView({ objectName, user, isCreating, navigate, objectS
164172
initialValues={{}}
165173
headers={getHeaders()}
166174
onSubmit={handleCreate}
167-
onCancel={() => navigate(`/object/${objectName}`)}
175+
onCancel={() => navigate('..')}
168176
/>
169177
</DialogContent>
170178
</Dialog>

apps/web/src/hooks/useObjectSchema.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,22 @@ export function useObjectSchema(objectName: string) {
3535
})
3636
.then(data => {
3737
if (data) {
38+
// Normalize fields from Array to Record if needed
39+
if (data.fields && Array.isArray(data.fields)) {
40+
const fieldRecord: Record<string, any> = {};
41+
data.fields.forEach((f: any) => {
42+
if (f.name) fieldRecord[f.name] = f;
43+
});
44+
data.fields = fieldRecord;
45+
}
46+
3847
schemaCache[objectName] = data;
3948
setSchema(data);
49+
setLoading(false);
4050
} else {
41-
// Try bulk fetch fallback if 404/error on single?
42-
// Assuming 404 meant endponit doesn't exist, not object.
43-
// But actually, if object doesn't exist, we want null.
44-
setError(new Error('Object not found'));
51+
// Trigger fallback
52+
throw new Error('Not found');
4553
}
46-
setLoading(false);
4754
})
4855
.catch(() => {
4956
// Fallback: Fetch all

apps/web/src/main.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ import React from 'react'
22
import ReactDOM from 'react-dom/client'
33
import { BrowserRouter } from 'react-router-dom'
44
import App from './App'
5+
import { ModuleRegistry, AllCommunityModule } from 'ag-grid-community';
6+
7+
// Register AG Grid Modules
8+
ModuleRegistry.registerModules([ AllCommunityModule ]);
9+
10+
import 'ag-grid-community/styles/ag-grid.css'
11+
import 'ag-grid-community/styles/ag-theme-alpine.css'
512
import '@objectos/ui/dist/index.css'
613
import './index.css'
714

apps/web/src/pages/objects/ObjectListRoute.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { ObjectNotFound } from '../../components/dashboard/ObjectNotFound';
66
import { Spinner } from '@objectos/ui';
77
import { useRouter } from '../../hooks/useRouter';
88

9-
export function ObjectListRoute() {
9+
export function ObjectListRoute({ isCreating = false }: { isCreating?: boolean }) {
1010
const { objectName, appName } = useParams();
1111
const { schema, loading, error } = useObjectSchema(objectName || '');
1212
const { user } = useAuth();
@@ -21,7 +21,7 @@ export function ObjectListRoute() {
2121
<ObjectListView
2222
objectName={objectName!}
2323
user={user}
24-
isCreating={false}
24+
isCreating={isCreating}
2525
navigate={navigate}
2626
objectSchema={schema}
2727
/>

packages/ui/package.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,28 @@
1212
"build": "tsup",
1313
"dev": "tsup --watch",
1414
"lint": "eslint src/**",
15-
"test": "echo \"No tests specified\" && exit 0"
15+
"test": "vitest",
16+
"test:ui": "vitest --ui",
17+
"test:coverage": "vitest run --coverage"
1618
},
1719
"peerDependencies": {
1820
"react": ">=18",
1921
"react-dom": ">=18"
2022
},
2123
"devDependencies": {
24+
"@testing-library/jest-dom": "^6.9.1",
25+
"@testing-library/react": "^16.3.1",
2226
"@types/react": "^18.3.27",
2327
"@types/react-dom": "^18.3.7",
2428
"autoprefixer": "^10.4.0",
29+
"jsdom": "^27.4.0",
2530
"postcss": "^8.4.0",
2631
"react": "^18.2.0",
2732
"react-dom": "^18.2.0",
2833
"tailwindcss": "^3.4.0",
2934
"tsup": "^8.0.0",
30-
"typescript": "^5.0.0"
35+
"typescript": "^5.0.0",
36+
"vitest": "^4.0.16"
3137
},
3238
"dependencies": {
3339
"@dnd-kit/core": "^6.3.1",
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { render, screen, waitFor } from '@testing-library/react'
2+
import { ObjectGridTable } from '../object-grid-table'
3+
import { ObjectConfig } from '@objectql/types'
4+
import { describe, it, expect } from 'vitest'
5+
6+
const mockConfig: ObjectConfig = {
7+
name: 'testObject',
8+
fields: {
9+
name: { type: 'string', label: 'Name' },
10+
age: { type: 'integer', label: 'Age' }
11+
}
12+
}
13+
14+
const mockData = [
15+
{ id: '1', name: 'Alice', age: 30 },
16+
{ id: '2', name: 'Bob', age: 25 }
17+
]
18+
19+
describe('ObjectGridTable', () => {
20+
it('renders the grid container', () => {
21+
const { container } = render(
22+
<div style={{ height: 500, width: 800 }}>
23+
<ObjectGridTable objectConfig={mockConfig} data={mockData} />
24+
</div>
25+
)
26+
// Check for the theme class which we apply to the wrapper
27+
expect(container.querySelector('.ag-theme-alpine')).toBeInTheDocument()
28+
})
29+
30+
// Testing AG Grid internals in JSDOM is notoriously flaky because it virtualizes everything
31+
// and relies on real layout calculation. We mainly check if the component mounts.
32+
})

packages/ui/src/components/object-grid-table.tsx

Lines changed: 43 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import * as React from "react"
22
import { AgGridReact } from "ag-grid-react"
3-
import type {
4-
ColDef,
5-
GridReadyEvent,
6-
CellClickedEvent,
7-
GridApi,
8-
ICellRendererParams
3+
import {
4+
ModuleRegistry,
5+
AllCommunityModule,
6+
type ColDef,
7+
type GridReadyEvent,
8+
type CellClickedEvent,
9+
type GridApi,
10+
type ICellRendererParams
911
} from "ag-grid-community"
12+
13+
// Register AG Grid Modules
14+
ModuleRegistry.registerModules([ AllCommunityModule ]);
15+
1016
import "ag-grid-community/styles/ag-grid.css"
1117
import "ag-grid-community/styles/ag-theme-alpine.css"
1218
import { format } from "date-fns"
@@ -79,25 +85,19 @@ const BooleanCellRenderer = (props: ICellRendererParams) => {
7985
/**
8086
* Cell renderer for date/datetime fields
8187
*/
82-
const DateCellRenderer = (props: ICellRendererParams) => {
83-
const { value, colDef } = props
88+
const DateCellRenderer = (props: ICellRendererParams & { fieldType?: FieldType }) => {
89+
const { value, fieldType } = props
8490

8591
if (!value) {
8692
return <span className="text-muted-foreground">-</span>
8793
}
8894

8995
try {
90-
const date = value instanceof Date ? value : new Date(value)
91-
92-
// Check if date is valid
96+
const date = new Date(value)
9397
if (isNaN(date.getTime())) {
9498
return <span className="text-muted-foreground">{String(value)}</span>
9599
}
96100

97-
// Format based on field type from colDef
98-
const extendedColDef = colDef as ExtendedColDef
99-
const fieldType = extendedColDef.fieldType
100-
101101
if (fieldType === 'datetime') {
102102
return (
103103
<div className="flex items-center gap-1.5">
@@ -121,15 +121,13 @@ const DateCellRenderer = (props: ICellRendererParams) => {
121121
/**
122122
* Cell renderer for number fields (including currency and percent)
123123
*/
124-
const NumberCellRenderer = (props: ICellRendererParams) => {
125-
const { value, colDef } = props
124+
const NumberCellRenderer = (props: ICellRendererParams & { fieldType?: FieldType }) => {
125+
const { value, fieldType } = props
126126

127127
if (value === null || value === undefined) {
128128
return <span className="text-muted-foreground">-</span>
129129
}
130130

131-
const extendedColDef = colDef as ExtendedColDef
132-
const fieldType = extendedColDef.fieldType
133131
const num = Number(value)
134132

135133
if (isNaN(num)) {
@@ -158,10 +156,9 @@ const NumberCellRenderer = (props: ICellRendererParams) => {
158156
/**
159157
* Cell renderer for select fields with options
160158
*/
161-
const SelectCellRenderer = (props: ICellRendererParams) => {
162-
const { value, colDef } = props
163-
const extendedColDef = colDef as ExtendedColDef
164-
const options = extendedColDef.fieldOptions || []
159+
const SelectCellRenderer = (props: ICellRendererParams & { fieldOptions?: any[] }) => {
160+
const { value, fieldOptions } = props
161+
const options = fieldOptions || []
165162

166163
if (!value) {
167164
return <span className="text-muted-foreground">-</span>
@@ -337,26 +334,32 @@ function getCellRendererForFieldType(fieldType: FieldType): any {
337334
/**
338335
* Generate AG Grid column definitions from ObjectQL object metadata
339336
*/
340-
function generateColumnDefs(objectConfig: ObjectConfig): ExtendedColDef[] {
341-
const columnDefs: ExtendedColDef[] = []
337+
function generateColumnDefs(objectConfig: ObjectConfig): ColDef[] {
338+
const columnDefs: ColDef[] = []
342339

343340
const fields = objectConfig.fields || {}
344341

345-
Object.entries(fields).forEach(([fieldName, fieldConfig]: [string, FieldConfig]) => {
342+
const entries: [string, FieldConfig][] = Array.isArray(fields)
343+
? fields.map((f: any) => [f.name, f])
344+
: Object.entries(fields);
345+
346+
entries.forEach(([fieldName, fieldConfig]: [string, FieldConfig]) => {
346347
// Skip hidden fields
347348
if (fieldConfig.hidden) {
348349
return
349350
}
350351

351-
const colDef: ExtendedColDef = {
352+
const colDef: ColDef = {
352353
field: fieldName,
353354
headerName: fieldConfig.label || fieldName,
354355
sortable: true,
355356
filter: true,
356357
resizable: true,
357358
// Store field type and options for cell renderers
358-
fieldType: fieldConfig.type,
359-
fieldOptions: fieldConfig.options,
359+
cellRendererParams: {
360+
fieldType: fieldConfig.type,
361+
fieldOptions: fieldConfig.options,
362+
}
360363
}
361364

362365
// Set appropriate cell renderer based on field type
@@ -435,24 +438,33 @@ export function ObjectGridTable({
435438
onSelectionChanged(selectedRows)
436439
}, [gridApi, onSelectionChanged])
437440

441+
const selection = React.useMemo(() => {
442+
if (!rowSelection) return undefined;
443+
return {
444+
mode: rowSelection === true || rowSelection === 'multiple' ? 'multiRow' : 'singleRow',
445+
enableClickSelection: false,
446+
} as const;
447+
}, [rowSelection]);
448+
438449
return (
439450
<div
440451
className="ag-theme-alpine dark:ag-theme-alpine-dark overflow-hidden rounded-lg border"
441452
style={{ height: typeof height === 'number' ? `${height}px` : height, width: '100%' }}
442453
>
443454
<AgGridReact
455+
theme="legacy"
444456
ref={gridRef}
445457
rowData={data}
446458
columnDefs={columnDefs}
447459
defaultColDef={defaultColDef}
448460
onGridReady={handleGridReady}
449461
onCellClicked={onCellClicked}
450462
onSelectionChanged={handleSelectionChanged}
451-
rowSelection={rowSelection === true ? 'multiple' : rowSelection || undefined}
452-
suppressRowClickSelection={true}
463+
rowSelection={selection}
453464
animateRows={true}
454465
pagination={pagination}
455466
paginationPageSize={pageSize}
467+
paginationPageSizeSelector={[10, 20, 50, 100]}
456468
getRowId={(params) => params.data._id || params.data.id}
457469
/>
458470
</div>

packages/ui/test/setup.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import '@testing-library/jest-dom'
2+
3+
// Mock ResizeObserver
4+
global.ResizeObserver = class ResizeObserver {
5+
observe() {}
6+
unobserve() {}
7+
disconnect() {}
8+
}

0 commit comments

Comments
 (0)