@object-ui/plugin-map 0.3.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +19 -0
- package/CHANGELOG.md +14 -0
- package/dist/index.css +1 -0
- package/dist/index.js +3759 -326
- package/dist/index.umd.cjs +847 -2
- package/dist/maplibre-gl-CNsW26De.js +24161 -0
- package/dist/src/ObjectMap.d.ts +1 -0
- package/dist/src/ObjectMap.d.ts.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +11 -7
- package/src/ObjectMap.test.tsx +110 -0
- package/src/ObjectMap.tsx +175 -98
- package/src/index.test.tsx +27 -0
- package/src/index.tsx +17 -3
- package/vite.config.ts +4 -0
- package/vitest.config.ts +13 -0
- package/vitest.setup.ts +78 -0
package/dist/src/ObjectMap.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ObjectMap.d.ts","sourceRoot":"","sources":["../../src/ObjectMap.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH
|
|
1
|
+
{"version":3,"file":"ObjectMap.d.ts","sourceRoot":"","sources":["../../src/ObjectMap.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAuC,MAAM,OAAO,CAAC;AAC5D,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAY,MAAM,kBAAkB,CAAC;AAI/E,OAAO,kCAAkC,CAAC;AAY1C,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,gBAAgB,CAAC;IACzB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,IAAI,CAAC;IACtC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,IAAI,CAAC;IAC/B,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,IAAI,CAAC;CAClC;AAsKD,eAAO,MAAM,SAAS,EAAE,KAAK,CAAC,EAAE,CAAC,cAAc,CAsQ9C,CAAC;AAEF,eAAe,SAAS,CAAC"}
|
package/dist/src/index.d.ts
CHANGED
package/dist/src/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,MAAM,OAAO,CAAC;AAG1B,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD,OAAO,EAAE,SAAS,EAAE,CAAC;AACrB,YAAY,EAAE,cAAc,EAAE,CAAC;AAG/B,eAAO,MAAM,iBAAiB,EAAE,KAAK,CAAC,EAAE,CAAC,GAAG,CAG3C,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/plugin-map",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Map visualization plugin for Object UI",
|
|
@@ -24,20 +24,24 @@
|
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
+
"@objectstack/spec": "^2.0.7",
|
|
27
28
|
"lucide-react": "^0.563.0",
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"@object-ui/
|
|
29
|
+
"maplibre-gl": "^5.17.0",
|
|
30
|
+
"react-map-gl": "^8.1.0",
|
|
31
|
+
"zod": "^4.3.6",
|
|
32
|
+
"@object-ui/components": "2.0.0",
|
|
33
|
+
"@object-ui/core": "2.0.0",
|
|
34
|
+
"@object-ui/react": "2.0.0",
|
|
35
|
+
"@object-ui/types": "2.0.0"
|
|
32
36
|
},
|
|
33
37
|
"peerDependencies": {
|
|
34
38
|
"react": "^18.0.0 || ^19.0.0",
|
|
35
39
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
36
40
|
},
|
|
37
41
|
"devDependencies": {
|
|
38
|
-
"@types/react": "^19.2.
|
|
42
|
+
"@types/react": "^19.2.13",
|
|
39
43
|
"@types/react-dom": "^19.2.3",
|
|
40
|
-
"@vitejs/plugin-react": "^
|
|
44
|
+
"@vitejs/plugin-react": "^5.1.3",
|
|
41
45
|
"typescript": "^5.9.3",
|
|
42
46
|
"vite": "^7.3.1",
|
|
43
47
|
"vite-plugin-dts": "^4.5.4"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
|
3
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
4
|
+
import { ObjectMap } from './ObjectMap';
|
|
5
|
+
import { DataSource } from '@object-ui/types';
|
|
6
|
+
|
|
7
|
+
// Mock react-map-gl/maplibre components to check they are receiving the right props/children
|
|
8
|
+
// without relying on real WebGL canvas rendering.
|
|
9
|
+
vi.mock('react-map-gl/maplibre', () => ({
|
|
10
|
+
default: ({ children }: any) => <div aria-label="Map">{children}</div>,
|
|
11
|
+
Map: ({ children }: any) => <div aria-label="Map">{children}</div>,
|
|
12
|
+
NavigationControl: () => <div data-testid="nav-control" />,
|
|
13
|
+
Marker: ({ children, longitude, latitude, onClick }: any) => (
|
|
14
|
+
<div
|
|
15
|
+
data-testid="map-marker"
|
|
16
|
+
data-lat={latitude}
|
|
17
|
+
data-lng={longitude}
|
|
18
|
+
onClick={onClick}
|
|
19
|
+
>
|
|
20
|
+
{children}
|
|
21
|
+
</div>
|
|
22
|
+
),
|
|
23
|
+
Popup: ({ children }: any) => <div data-testid="map-popup">{children}</div>,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const mockData = [
|
|
27
|
+
{ id: '1', name: 'Loc 1', latitude: 40, longitude: -74 },
|
|
28
|
+
{ id: '2', name: 'Loc 2', latitude: 41, longitude: -75 },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const mockDataSource: DataSource = {
|
|
32
|
+
find: vi.fn(),
|
|
33
|
+
findOne: vi.fn(),
|
|
34
|
+
create: vi.fn(),
|
|
35
|
+
update: vi.fn(),
|
|
36
|
+
delete: vi.fn(),
|
|
37
|
+
getObjectSchema: vi.fn(),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe('ObjectMap', () => {
|
|
41
|
+
it('renders with static value provider', async () => {
|
|
42
|
+
const schema: any = {
|
|
43
|
+
type: 'map',
|
|
44
|
+
map: {
|
|
45
|
+
latitudeField: 'latitude',
|
|
46
|
+
longitudeField: 'longitude',
|
|
47
|
+
titleField: 'name'
|
|
48
|
+
},
|
|
49
|
+
data: {
|
|
50
|
+
provider: 'value',
|
|
51
|
+
items: mockData
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
render(<ObjectMap schema={schema} />);
|
|
56
|
+
|
|
57
|
+
// Wait for loading to finish
|
|
58
|
+
await waitFor(() => {
|
|
59
|
+
expect(screen.queryByText('Loading map...')).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Check if map is rendered
|
|
63
|
+
expect(screen.getByLabelText('Map')).toBeDefined();
|
|
64
|
+
|
|
65
|
+
// Check if markers are rendered using testid from our mock
|
|
66
|
+
await waitFor(() => {
|
|
67
|
+
const markers = screen.getAllByTestId('map-marker');
|
|
68
|
+
expect(markers).toHaveLength(2);
|
|
69
|
+
// Verify coordinates of first marker
|
|
70
|
+
expect(markers[0]).toHaveAttribute('data-lat', '40');
|
|
71
|
+
expect(markers[0]).toHaveAttribute('data-lng', '-74');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Check content inside marker (the emoji)
|
|
75
|
+
expect(screen.getAllByText('📍')).toHaveLength(2);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('fetches data with object provider', async () => {
|
|
79
|
+
(mockDataSource.find as any).mockResolvedValue({ data: mockData });
|
|
80
|
+
|
|
81
|
+
const schema: any = {
|
|
82
|
+
type: 'map',
|
|
83
|
+
map: {
|
|
84
|
+
latitudeField: 'latitude',
|
|
85
|
+
longitudeField: 'longitude',
|
|
86
|
+
titleField: 'name'
|
|
87
|
+
},
|
|
88
|
+
data: {
|
|
89
|
+
provider: 'object',
|
|
90
|
+
object: 'locations'
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
render(<ObjectMap schema={schema} dataSource={mockDataSource} />);
|
|
95
|
+
|
|
96
|
+
// Wait for loading to finish
|
|
97
|
+
await waitFor(() => {
|
|
98
|
+
expect(screen.queryByText('Loading map...')).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Verify dataSource.find was called
|
|
102
|
+
expect(mockDataSource.find).toHaveBeenCalledWith('locations', expect.anything());
|
|
103
|
+
|
|
104
|
+
// Check markers
|
|
105
|
+
await waitFor(() => {
|
|
106
|
+
const markers = screen.getAllByTestId('map-marker');
|
|
107
|
+
expect(markers).toHaveLength(2);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
package/src/ObjectMap.tsx
CHANGED
|
@@ -16,16 +16,26 @@
|
|
|
16
16
|
* Features:
|
|
17
17
|
* - Interactive map with markers
|
|
18
18
|
* - Location-based data visualization
|
|
19
|
-
* - Marker clustering (when many points)
|
|
20
19
|
* - Popup/tooltip on marker click
|
|
21
20
|
* - Works with object/api/value data providers
|
|
22
|
-
*
|
|
23
|
-
* Note: This is a basic implementation. For production use, integrate with a
|
|
24
|
-
* proper mapping library like Mapbox, Leaflet, or Google Maps.
|
|
25
21
|
*/
|
|
26
22
|
|
|
27
23
|
import React, { useEffect, useState, useMemo } from 'react';
|
|
28
24
|
import type { ObjectGridSchema, DataSource, ViewData } from '@object-ui/types';
|
|
25
|
+
import { z } from 'zod';
|
|
26
|
+
import Map, { NavigationControl, Marker, Popup } from 'react-map-gl/maplibre';
|
|
27
|
+
import maplibregl from 'maplibre-gl';
|
|
28
|
+
import 'maplibre-gl/dist/maplibre-gl.css';
|
|
29
|
+
|
|
30
|
+
const MapConfigSchema = z.object({
|
|
31
|
+
latitudeField: z.string().optional(),
|
|
32
|
+
longitudeField: z.string().optional(),
|
|
33
|
+
locationField: z.string().optional(),
|
|
34
|
+
titleField: z.string().optional(),
|
|
35
|
+
descriptionField: z.string().optional(),
|
|
36
|
+
zoom: z.number().optional(),
|
|
37
|
+
center: z.tuple([z.number(), z.number()]).optional(),
|
|
38
|
+
});
|
|
29
39
|
|
|
30
40
|
export interface ObjectMapProps {
|
|
31
41
|
schema: ObjectGridSchema;
|
|
@@ -108,15 +118,37 @@ function convertSortToQueryParams(sort: string | any[] | undefined): Record<stri
|
|
|
108
118
|
/**
|
|
109
119
|
* Helper to get map configuration from schema
|
|
110
120
|
*/
|
|
111
|
-
function getMapConfig(schema: ObjectGridSchema): MapConfig {
|
|
121
|
+
function getMapConfig(schema: ObjectGridSchema | any): MapConfig {
|
|
122
|
+
// 1. Check top-level properties (ObjectMapSchema style)
|
|
123
|
+
if (schema.locationField || schema.latitudeField) {
|
|
124
|
+
return {
|
|
125
|
+
locationField: schema.locationField,
|
|
126
|
+
latitudeField: schema.latitudeField,
|
|
127
|
+
longitudeField: schema.longitudeField,
|
|
128
|
+
titleField: schema.titleField || 'name',
|
|
129
|
+
descriptionField: schema.descriptionField,
|
|
130
|
+
zoom: schema.zoom,
|
|
131
|
+
center: schema.center
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let config: MapConfig | null = null;
|
|
112
136
|
// Check if schema has map configuration
|
|
113
137
|
if (schema.filter && typeof schema.filter === 'object' && 'map' in schema.filter) {
|
|
114
|
-
|
|
138
|
+
config = (schema.filter as any).map as MapConfig;
|
|
115
139
|
}
|
|
116
140
|
|
|
117
141
|
// For backward compatibility, check if schema has map config at root
|
|
118
|
-
if ((schema as any).map) {
|
|
119
|
-
|
|
142
|
+
else if ((schema as any).map) {
|
|
143
|
+
config = (schema as any).map as MapConfig;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (config) {
|
|
147
|
+
const result = MapConfigSchema.safeParse(config);
|
|
148
|
+
if (!result.success) {
|
|
149
|
+
console.warn(`[ObjectMap] Invalid map configuration:`, result.error.format());
|
|
150
|
+
}
|
|
151
|
+
return config;
|
|
120
152
|
}
|
|
121
153
|
|
|
122
154
|
// Default configuration
|
|
@@ -183,14 +215,22 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
|
|
|
183
215
|
dataSource,
|
|
184
216
|
className,
|
|
185
217
|
onMarkerClick,
|
|
218
|
+
onEdit,
|
|
219
|
+
onDelete,
|
|
220
|
+
...rest
|
|
186
221
|
}) => {
|
|
187
222
|
const [data, setData] = useState<any[]>([]);
|
|
188
223
|
const [loading, setLoading] = useState(true);
|
|
189
224
|
const [error, setError] = useState<Error | null>(null);
|
|
190
225
|
const [objectSchema, setObjectSchema] = useState<any>(null);
|
|
191
|
-
const [
|
|
226
|
+
const [selectedMarkerId, setSelectedMarkerId] = useState<string | null>(null);
|
|
192
227
|
|
|
193
|
-
const
|
|
228
|
+
const rawDataConfig = getDataConfig(schema);
|
|
229
|
+
// Memoize dataConfig using deep comparison to prevent infinite loops
|
|
230
|
+
const dataConfig = useMemo(() => {
|
|
231
|
+
return rawDataConfig;
|
|
232
|
+
}, [JSON.stringify(rawDataConfig)]);
|
|
233
|
+
|
|
194
234
|
const mapConfig = getMapConfig(schema);
|
|
195
235
|
const hasInlineData = dataConfig?.provider === 'value';
|
|
196
236
|
|
|
@@ -200,6 +240,26 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
|
|
|
200
240
|
try {
|
|
201
241
|
setLoading(true);
|
|
202
242
|
|
|
243
|
+
// Prioritize data passed via props (from ListView)
|
|
244
|
+
if ((rest as any).data) { // Check props.data directly first
|
|
245
|
+
const passed = (rest as any).data;
|
|
246
|
+
if (Array.isArray(passed)) {
|
|
247
|
+
setData(passed);
|
|
248
|
+
setLoading(false);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Check schema.data next
|
|
254
|
+
if ((schema as any).data) {
|
|
255
|
+
const passed = (schema as any).data;
|
|
256
|
+
if (Array.isArray(passed)) {
|
|
257
|
+
setData(passed);
|
|
258
|
+
setLoading(false);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
203
263
|
if (hasInlineData && dataConfig?.provider === 'value') {
|
|
204
264
|
setData(dataConfig.items as any[]);
|
|
205
265
|
setLoading(false);
|
|
@@ -216,7 +276,18 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
|
|
|
216
276
|
$filter: schema.filter,
|
|
217
277
|
$orderby: convertSortToQueryParams(schema.sort),
|
|
218
278
|
});
|
|
219
|
-
|
|
279
|
+
|
|
280
|
+
let items: any[] = [];
|
|
281
|
+
if (Array.isArray(result)) {
|
|
282
|
+
items = result;
|
|
283
|
+
} else if (result && typeof result === 'object') {
|
|
284
|
+
if (Array.isArray((result as any).data)) {
|
|
285
|
+
items = (result as any).data;
|
|
286
|
+
} else if (Array.isArray((result as any).value)) {
|
|
287
|
+
items = (result as any).value;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
setData(items);
|
|
220
291
|
} else if (dataConfig?.provider === 'api') {
|
|
221
292
|
console.warn('API provider not yet implemented for ObjectMap');
|
|
222
293
|
setData([]);
|
|
@@ -257,57 +328,73 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
|
|
|
257
328
|
}, [schema.objectName, dataSource, hasInlineData, dataConfig]);
|
|
258
329
|
|
|
259
330
|
// Transform data to map markers
|
|
260
|
-
const markers = useMemo(() => {
|
|
261
|
-
|
|
331
|
+
const { markers, invalidCount } = useMemo(() => {
|
|
332
|
+
let invalid = 0;
|
|
333
|
+
const validMarkers = data
|
|
262
334
|
.map((record, index) => {
|
|
263
335
|
const coordinates = extractCoordinates(record, mapConfig);
|
|
264
|
-
if (!coordinates)
|
|
336
|
+
if (!coordinates) {
|
|
337
|
+
invalid++;
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
265
340
|
|
|
266
341
|
const title = mapConfig.titleField ? record[mapConfig.titleField] : 'Marker';
|
|
267
342
|
const description = mapConfig.descriptionField ? record[mapConfig.descriptionField] : undefined;
|
|
268
343
|
|
|
344
|
+
// Ensure lat/lng are within valid ranges
|
|
345
|
+
const [lat, lng] = coordinates;
|
|
346
|
+
if (!isFinite(lat) || !isFinite(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
|
347
|
+
invalid++;
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
|
|
269
351
|
return {
|
|
270
352
|
id: record.id || record._id || `marker-${index}`,
|
|
271
353
|
title,
|
|
272
354
|
description,
|
|
273
|
-
coordinates,
|
|
355
|
+
coordinates: [lng, lat] as [number, number], // maplibre uses [lng, lat]
|
|
274
356
|
data: record,
|
|
275
357
|
};
|
|
276
358
|
})
|
|
277
359
|
.filter((marker): marker is NonNullable<typeof marker> => marker !== null);
|
|
360
|
+
|
|
361
|
+
return { markers: validMarkers, invalidCount: invalid };
|
|
278
362
|
}, [data, mapConfig]);
|
|
279
363
|
|
|
364
|
+
const selectedMarker = useMemo(() =>
|
|
365
|
+
markers.find(m => m.id === selectedMarkerId),
|
|
366
|
+
[markers, selectedMarkerId]);
|
|
367
|
+
|
|
280
368
|
// Calculate map bounds
|
|
281
|
-
const
|
|
369
|
+
const initialViewState = useMemo(() => {
|
|
282
370
|
if (!markers.length) {
|
|
283
371
|
return {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
minLng: (mapConfig.center?.[1] || 0) - 0.1,
|
|
288
|
-
maxLng: (mapConfig.center?.[1] || 0) + 0.1,
|
|
372
|
+
longitude: mapConfig.center?.[1] || 0,
|
|
373
|
+
latitude: mapConfig.center?.[0] || 0,
|
|
374
|
+
zoom: mapConfig.zoom || 2
|
|
289
375
|
};
|
|
290
376
|
}
|
|
291
377
|
|
|
292
|
-
|
|
293
|
-
const lngs = markers.map(m => m.coordinates[
|
|
378
|
+
// Simple bounds calculation
|
|
379
|
+
const lngs = markers.map(m => m.coordinates[0]);
|
|
380
|
+
const lats = markers.map(m => m.coordinates[1]);
|
|
381
|
+
|
|
382
|
+
const minLng = Math.min(...lngs);
|
|
383
|
+
const maxLng = Math.max(...lngs);
|
|
384
|
+
const minLat = Math.min(...lats);
|
|
385
|
+
const maxLat = Math.max(...lats);
|
|
294
386
|
|
|
295
387
|
return {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
] as [number, number],
|
|
300
|
-
minLat: Math.min(...lats),
|
|
301
|
-
maxLat: Math.max(...lats),
|
|
302
|
-
minLng: Math.min(...lngs),
|
|
303
|
-
maxLng: Math.max(...lngs),
|
|
388
|
+
longitude: (minLng + maxLng) / 2,
|
|
389
|
+
latitude: (minLat + maxLat) / 2,
|
|
390
|
+
zoom: mapConfig.zoom || 3, // Auto-zoom logic could be improved here
|
|
304
391
|
};
|
|
305
|
-
}, [markers, mapConfig
|
|
392
|
+
}, [markers, mapConfig]);
|
|
306
393
|
|
|
307
394
|
if (loading) {
|
|
308
395
|
return (
|
|
309
396
|
<div className={className}>
|
|
310
|
-
<div className="flex items-center justify-center h-96 bg-muted rounded-lg">
|
|
397
|
+
<div className="flex items-center justify-center h-96 bg-muted rounded-lg border">
|
|
311
398
|
<div className="text-muted-foreground">Loading map...</div>
|
|
312
399
|
</div>
|
|
313
400
|
</div>
|
|
@@ -317,7 +404,7 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
|
|
|
317
404
|
if (error) {
|
|
318
405
|
return (
|
|
319
406
|
<div className={className}>
|
|
320
|
-
<div className="flex items-center justify-center h-96 bg-muted rounded-lg">
|
|
407
|
+
<div className="flex items-center justify-center h-96 bg-muted rounded-lg border">
|
|
321
408
|
<div className="text-destructive">Error: {error.message}</div>
|
|
322
409
|
</div>
|
|
323
410
|
</div>
|
|
@@ -326,75 +413,65 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
|
|
|
326
413
|
|
|
327
414
|
return (
|
|
328
415
|
<div className={className}>
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
<div className="text-center">
|
|
333
|
-
<div className="text-4xl mb-2">🗺️</div>
|
|
334
|
-
<div className="text-sm text-muted-foreground mb-4">
|
|
335
|
-
Map Visualization (Placeholder)
|
|
336
|
-
</div>
|
|
337
|
-
<div className="text-xs text-muted-foreground max-w-md mx-auto">
|
|
338
|
-
This is a basic map placeholder. For production, integrate with Mapbox, Leaflet, or Google Maps.
|
|
339
|
-
<br />
|
|
340
|
-
<br />
|
|
341
|
-
<strong>Map Info:</strong>
|
|
342
|
-
<br />
|
|
343
|
-
Center: [{bounds.center[0].toFixed(4)}, {bounds.center[1].toFixed(4)}]
|
|
344
|
-
<br />
|
|
345
|
-
Markers: {markers.length}
|
|
346
|
-
</div>
|
|
347
|
-
</div>
|
|
416
|
+
{invalidCount > 0 && (
|
|
417
|
+
<div className="mb-2 p-2 text-sm text-yellow-800 bg-yellow-50 border border-yellow-200 rounded">
|
|
418
|
+
{`${invalidCount} record${invalidCount !== 1 ? 's' : ''} with missing or invalid coordinates excluded from the map.`}
|
|
348
419
|
</div>
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
<
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
onMarkerClick?.(marker.data);
|
|
370
|
-
}}
|
|
420
|
+
)}
|
|
421
|
+
<div className="relative border rounded-lg overflow-hidden bg-muted" style={{ height: '600px', width: '100%' }}>
|
|
422
|
+
<Map
|
|
423
|
+
initialViewState={initialViewState}
|
|
424
|
+
style={{ width: '100%', height: '100%' }}
|
|
425
|
+
mapStyle="https://demotiles.maplibre.org/style.json"
|
|
426
|
+
>
|
|
427
|
+
<NavigationControl position="top-right" />
|
|
428
|
+
|
|
429
|
+
{markers.map(marker => (
|
|
430
|
+
<Marker
|
|
431
|
+
key={marker.id}
|
|
432
|
+
longitude={marker.coordinates[0]}
|
|
433
|
+
latitude={marker.coordinates[1]}
|
|
434
|
+
anchor="bottom"
|
|
435
|
+
onClick={(e) => {
|
|
436
|
+
e.originalEvent.stopPropagation();
|
|
437
|
+
setSelectedMarkerId(marker.id);
|
|
438
|
+
onMarkerClick?.(marker.data);
|
|
439
|
+
}}
|
|
371
440
|
>
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
|
375
|
-
{marker.description}
|
|
441
|
+
<div className="text-2xl cursor-pointer hover:scale-110 transition-transform">
|
|
442
|
+
📍
|
|
376
443
|
</div>
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
📍 {marker.coordinates[0].toFixed(4)}, {marker.coordinates[1].toFixed(4)}
|
|
380
|
-
</div>
|
|
381
|
-
</div>
|
|
382
|
-
))}
|
|
383
|
-
</div>
|
|
384
|
-
)}
|
|
385
|
-
</div>
|
|
444
|
+
</Marker>
|
|
445
|
+
))}
|
|
386
446
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
447
|
+
{selectedMarker && (
|
|
448
|
+
<Popup
|
|
449
|
+
longitude={selectedMarker.coordinates[0]}
|
|
450
|
+
latitude={selectedMarker.coordinates[1]}
|
|
451
|
+
anchor="top"
|
|
452
|
+
onClose={() => setSelectedMarkerId(null)}
|
|
453
|
+
closeOnClick={false}
|
|
454
|
+
>
|
|
455
|
+
<div className="p-2 min-w-[200px]">
|
|
456
|
+
<h3 className="font-bold text-sm mb-1">{selectedMarker.title}</h3>
|
|
457
|
+
{selectedMarker.description && (
|
|
458
|
+
<p className="text-xs text-muted-foreground">{selectedMarker.description}</p>
|
|
459
|
+
)}
|
|
460
|
+
<div className="mt-2 text-xs flex gap-2">
|
|
461
|
+
{onEdit && (
|
|
462
|
+
<button className="text-blue-500 hover:underline" onClick={() => onEdit(selectedMarker.data)}>Edit</button>
|
|
463
|
+
)}
|
|
464
|
+
{onDelete && (
|
|
465
|
+
<button className="text-red-500 hover:underline" onClick={() => onDelete(selectedMarker.data)}>Delete</button>
|
|
466
|
+
)}
|
|
467
|
+
</div>
|
|
468
|
+
</div>
|
|
469
|
+
</Popup>
|
|
470
|
+
)}
|
|
471
|
+
</Map>
|
|
397
472
|
</div>
|
|
398
473
|
</div>
|
|
399
474
|
);
|
|
400
475
|
};
|
|
476
|
+
|
|
477
|
+
export default ObjectMap;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
|
4
|
+
// import { ComponentRegistry } from '@object-ui/core';
|
|
5
|
+
// Use relative path to force same instance as verified by alias
|
|
6
|
+
import { ObjectMapRenderer } from './index';
|
|
7
|
+
// import { ComponentRegistry } from '@object-ui/core';
|
|
8
|
+
|
|
9
|
+
// Mock dependencies
|
|
10
|
+
vi.mock('@object-ui/react', () => ({
|
|
11
|
+
useSchemaContext: vi.fn(() => ({ dataSource: { type: 'mock-datasource' } })),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock('./ObjectMap', () => ({
|
|
15
|
+
ObjectMap: ({ dataSource }: any) => (
|
|
16
|
+
<div data-testid="map-mock">
|
|
17
|
+
{dataSource ? `DataSource: ${dataSource.type}` : 'No DataSource'}
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
describe('Plugin Map Registration', () => {
|
|
23
|
+
it('renderer passes dataSource from context', () => {
|
|
24
|
+
render(<ObjectMapRenderer schema={{}} />);
|
|
25
|
+
expect(screen.getByTestId('map-mock')).toHaveTextContent('DataSource: mock-datasource');
|
|
26
|
+
});
|
|
27
|
+
});
|
package/src/index.tsx
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import React from 'react';
|
|
10
10
|
import { ComponentRegistry } from '@object-ui/core';
|
|
11
|
+
import { useSchemaContext } from '@object-ui/react';
|
|
11
12
|
import { ObjectMap } from './ObjectMap';
|
|
12
13
|
import type { ObjectMapProps } from './ObjectMap';
|
|
13
14
|
|
|
@@ -15,13 +16,26 @@ export { ObjectMap };
|
|
|
15
16
|
export type { ObjectMapProps };
|
|
16
17
|
|
|
17
18
|
// Register component
|
|
18
|
-
const ObjectMapRenderer: React.FC<
|
|
19
|
-
|
|
19
|
+
export const ObjectMapRenderer: React.FC<any> = ({ schema, ...props }) => {
|
|
20
|
+
const { dataSource } = useSchemaContext();
|
|
21
|
+
return <ObjectMap schema={schema} dataSource={dataSource} {...props} />;
|
|
20
22
|
};
|
|
21
23
|
|
|
24
|
+
console.log('Registering object-map...');
|
|
22
25
|
ComponentRegistry.register('object-map', ObjectMapRenderer, {
|
|
26
|
+
namespace: 'plugin-map',
|
|
23
27
|
label: 'Object Map',
|
|
24
|
-
category: '
|
|
28
|
+
category: 'view',
|
|
29
|
+
inputs: [
|
|
30
|
+
{ name: 'objectName', type: 'string', label: 'Object Name', required: true },
|
|
31
|
+
{ name: 'map', type: 'object', label: 'Map Config', description: 'latitudeField, longitudeField, titleField' },
|
|
32
|
+
],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
ComponentRegistry.register('map', ObjectMapRenderer, {
|
|
36
|
+
namespace: 'view',
|
|
37
|
+
label: 'Map View',
|
|
38
|
+
category: 'view',
|
|
25
39
|
inputs: [
|
|
26
40
|
{ name: 'objectName', type: 'string', label: 'Object Name', required: true },
|
|
27
41
|
{ name: 'map', type: 'object', label: 'Map Config', description: 'latitudeField, longitudeField, titleField' },
|
package/vite.config.ts
CHANGED
|
@@ -24,6 +24,7 @@ export default defineConfig({
|
|
|
24
24
|
resolve: {
|
|
25
25
|
alias: {
|
|
26
26
|
'@': resolve(__dirname, './src'),
|
|
27
|
+
'@object-ui/core': resolve(__dirname, '../core/src/index.ts'),
|
|
27
28
|
},
|
|
28
29
|
},
|
|
29
30
|
build: {
|
|
@@ -47,4 +48,7 @@ export default defineConfig({
|
|
|
47
48
|
},
|
|
48
49
|
},
|
|
49
50
|
},
|
|
51
|
+
test: {
|
|
52
|
+
passWithNoTests: true,
|
|
53
|
+
},
|
|
50
54
|
});
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/// <reference types="vitest" />
|
|
2
|
+
import { defineConfig } from 'vite';
|
|
3
|
+
import react from '@vitejs/plugin-react';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
plugins: [react()],
|
|
8
|
+
test: {
|
|
9
|
+
environment: 'happy-dom',
|
|
10
|
+
globals: true,
|
|
11
|
+
setupFiles: ['./vitest.setup.ts'],
|
|
12
|
+
},
|
|
13
|
+
});
|