@mongoosejs/studio 0.2.5 → 0.2.7

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.
@@ -1,9 +1,10 @@
1
- <div class="border border-gray-200 bg-white rounded-lg mb-2">
1
+ <div class="border border-gray-200 bg-white rounded-lg mb-2" style="overflow: visible;">
2
2
  <!-- Collapsible Header -->
3
3
  <div
4
4
  @click="toggleCollapse"
5
5
  class="p-1 cursor-pointer flex items-center justify-between border-b border-gray-200 transition-colors duration-200 ease-in-out"
6
6
  :class="{ 'bg-amber-100 hover:bg-amber-200': highlight, 'bg-slate-100 hover:bg-gray-100': !highlight }"
7
+ style="overflow: visible; position: relative;"
7
8
  >
8
9
  <div class="flex items-center" >
9
10
  <svg
@@ -17,6 +18,56 @@
17
18
  </svg>
18
19
  <span class="font-medium text-gray-900">{{path.path}}</span>
19
20
  <span class="ml-2 text-sm text-gray-500">({{(path.instance || 'unknown').toLowerCase()}})</span>
21
+ <div v-if="isGeoJsonGeometry" class="ml-3 inline-flex items-center gap-2">
22
+ <div class="inline-flex items-center rounded-full bg-gray-200 p-0.5 text-xs font-semibold">
23
+ <button
24
+ type="button"
25
+ class="rounded-full px-2.5 py-0.5 transition"
26
+ :class="detailViewMode === 'text' ? 'bg-blue-600 text-white shadow' : 'text-gray-700 hover:text-gray-900'"
27
+ :style="detailViewMode === 'text' ? 'color: white !important; background-color: #2563eb !important;' : ''"
28
+ @click.stop="setDetailViewMode('text')">
29
+ Text
30
+ </button>
31
+ <button
32
+ type="button"
33
+ class="rounded-full px-2.5 py-0.5 transition"
34
+ :class="detailViewMode === 'map' ? 'bg-blue-600 text-white shadow' : 'text-gray-700 hover:text-gray-900'"
35
+ :style="detailViewMode === 'map' ? 'color: white !important; background-color: #2563eb !important;' : ''"
36
+ @click.stop="setDetailViewMode('map')">
37
+ Map
38
+ </button>
39
+ </div>
40
+ <!-- Info icon with tooltip -->
41
+ <div v-if="editting" class="relative inline-block" style="z-index: 10002;" @mouseenter="showTooltip = true" @mouseleave="showTooltip = false" @click.stop>
42
+ <svg
43
+ ref="infoIcon"
44
+ class="w-6 h-6 text-gray-400 hover:text-gray-600 cursor-help"
45
+ fill="none"
46
+ stroke="currentColor"
47
+ viewBox="0 0 24 24"
48
+ >
49
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
50
+ </svg>
51
+ <div
52
+ v-show="showTooltip"
53
+ ref="tooltip"
54
+ class="absolute left-full top-0 ml-2 w-64 p-3 text-white text-xs rounded-lg shadow-xl"
55
+ style="z-index: 99999; pointer-events: none; white-space: normal; position: fixed; background-color: #111827;"
56
+ :style="getTooltipStyle()"
57
+ >
58
+ <div class="font-semibold mb-2">Map Controls:</div>
59
+ <div v-if="isGeoJsonPoint" class="space-y-1">
60
+ <div>• Drag pin to move location</div>
61
+ </div>
62
+ <div v-else-if="isGeoJsonPolygon" class="space-y-1">
63
+ <div>• Drag vertices to reshape polygon</div>
64
+ <div v-if="isMultiPolygon">• Right-click edge to add new vertex</div>
65
+ <div>• Right-click vertex to delete</div>
66
+ </div>
67
+ <div class="absolute top-2 -left-1 w-0 h-0 border-t-4 border-b-4 border-r-4 border-transparent border-r-gray-900"></div>
68
+ </div>
69
+ </div>
70
+ </div>
20
71
  </div>
21
72
  <div class="flex items-center gap-2">
22
73
  <button
@@ -69,7 +120,18 @@
69
120
 
70
121
  <!-- Field Content -->
71
122
  <div v-if="editting && path.path !== '_id'">
123
+ <!-- Use detail-default with map editing for GeoJSON geometries -->
124
+ <component
125
+ v-if="isGeoJsonGeometry"
126
+ :is="getComponentForPath(path)"
127
+ :value="getEditValueForPath(path)"
128
+ :view-mode="detailViewMode"
129
+ :on-change="handleInputChange"
130
+ >
131
+ </component>
132
+ <!-- Use standard edit components for other types -->
72
133
  <component
134
+ v-else
73
135
  :is="getEditComponentForPath(path)"
74
136
  :value="getEditValueForPath(path)"
75
137
  :format="dateType"
@@ -131,7 +193,10 @@
131
193
  </div>
132
194
  <!-- Expanded view -->
133
195
  <div v-else-if="needsTruncation && isValueExpanded" class="relative">
134
- <component :is="getComponentForPath(path)" :value="getValueForPath(path.path)"></component>
196
+ <component
197
+ :is="getComponentForPath(path)"
198
+ :value="getValueForPath(path.path)"
199
+ :view-mode="detailViewMode"></component>
135
200
  <button
136
201
  @click="toggleValueExpansion"
137
202
  class="mt-2 text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center gap-1 transform transition-all duration-200 ease-in-out hover:translate-x-0.5"
@@ -144,7 +209,10 @@
144
209
  </div>
145
210
  <!-- Full view (no truncation needed) -->
146
211
  <div v-else>
147
- <component :is="getComponentForPath(path)" :value="getValueForPath(path.path)"></component>
212
+ <component
213
+ :is="getComponentForPath(path)"
214
+ :value="getValueForPath(path.path)"
215
+ :view-mode="detailViewMode"></component>
148
216
  </div>
149
217
  </div>
150
218
  </div>
@@ -17,8 +17,10 @@ module.exports = app => app.component('document-property', {
17
17
  dateType: 'picker', // picker, iso
18
18
  isCollapsed: false, // Start uncollapsed by default
19
19
  isValueExpanded: false, // Track if the value is expanded
20
+ detailViewMode: 'text',
20
21
  copyButtonLabel: 'Copy',
21
- copyResetTimeoutId: null
22
+ copyResetTimeoutId: null,
23
+ showTooltip: false
22
24
  };
23
25
  },
24
26
  beforeDestroy() {
@@ -92,9 +94,58 @@ module.exports = app => app.component('document-property', {
92
94
  return this.arrayValue.length - 2;
93
95
  }
94
96
  return 0;
97
+ },
98
+ isGeoJsonGeometry() {
99
+ const value = this.getValueForPath(this.path.path);
100
+ return value != null
101
+ && typeof value === 'object'
102
+ && !Array.isArray(value)
103
+ && Object.prototype.hasOwnProperty.call(value, 'type')
104
+ && Object.prototype.hasOwnProperty.call(value, 'coordinates');
105
+ },
106
+ isGeoJsonPoint() {
107
+ const value = this.getValueForPath(this.path.path);
108
+ return this.isGeoJsonGeometry && value.type === 'Point';
109
+ },
110
+ isGeoJsonPolygon() {
111
+ const value = this.getValueForPath(this.path.path);
112
+ return this.isGeoJsonGeometry && (value.type === 'Polygon' || value.type === 'MultiPolygon');
113
+ },
114
+ isMultiPolygon() {
115
+ const value = this.getValueForPath(this.path.path);
116
+ return this.isGeoJsonGeometry && value.type === 'MultiPolygon';
117
+ }
118
+ },
119
+ watch: {
120
+ isGeoJsonGeometry(newValue) {
121
+ if (!newValue) {
122
+ this.detailViewMode = 'text';
123
+ } else if (this.editting) {
124
+ // Default to map view when editing GeoJSON
125
+ this.detailViewMode = 'map';
126
+ }
127
+ },
128
+ editting(newValue) {
129
+ // When entering edit mode for GeoJSON, default to map view
130
+ if (newValue && this.isGeoJsonGeometry) {
131
+ this.detailViewMode = 'map';
132
+ }
95
133
  }
96
134
  },
97
135
  methods: {
136
+ setDetailViewMode(mode) {
137
+ this.detailViewMode = mode;
138
+
139
+ // When switching to map view, expand the container and value so the map is visible
140
+ if (mode === 'map' && this.isGeoJsonGeometry) {
141
+ if (this.isCollapsed) {
142
+ this.isCollapsed = false;
143
+ }
144
+ if (this.needsTruncation && !this.isValueExpanded) {
145
+ this.isValueExpanded = true;
146
+ }
147
+ }
148
+ },
98
149
  handleInputChange(newValue) {
99
150
  const currentValue = this.getValueForPath(this.path.path);
100
151
 
@@ -165,6 +216,11 @@ module.exports = app => app.component('document-property', {
165
216
  if (!this.document) {
166
217
  return;
167
218
  }
219
+ // If there are unsaved changes for this path, use the changed value
220
+ if (Object.prototype.hasOwnProperty.call(this.changes, path)) {
221
+ return this.changes[path];
222
+ }
223
+ // Otherwise, use the document value
168
224
  const documentValue = mpath.get(path, this.document);
169
225
  return documentValue;
170
226
  },
@@ -184,6 +240,16 @@ module.exports = app => app.component('document-property', {
184
240
  this.copyResetTimeoutId = null;
185
241
  }, 5000);
186
242
  },
243
+ getTooltipStyle() {
244
+ if (!this.$refs.infoIcon || !this.showTooltip) {
245
+ return {};
246
+ }
247
+ const rect = this.$refs.infoIcon.getBoundingClientRect();
248
+ return {
249
+ left: (rect.right + 8) + 'px',
250
+ top: rect.top + 'px'
251
+ };
252
+ },
187
253
  copyPropertyValue() {
188
254
  const textToCopy = this.valueAsString;
189
255
  if (textToCopy == null) {
@@ -34,7 +34,7 @@
34
34
  </nav>
35
35
  </aside>
36
36
  <div class="documents bg-slate-50" ref="documentsList">
37
- <div class="relative h-[42px] z-10">
37
+ <div class="relative h-[42px]" style="z-index: 1000">
38
38
  <div class="documents-menu bg-slate-50">
39
39
  <div class="flex flex-row items-center w-full gap-2">
40
40
  <document-search
@@ -46,7 +46,7 @@
46
46
  </document-search>
47
47
  <div>
48
48
  <span v-if="numDocuments == null">Loading ...</span>
49
- <span v-else-if="typeof numDocuments === 'number'">{{numDocuments === 1 ? numDocuments+ ' document' : numDocuments + ' documents'}}</span>
49
+ <span v-else-if="typeof numDocuments === 'number'">{{documents.length}}/{{numDocuments === 1 ? numDocuments + ' document' : numDocuments + ' documents'}}</span>
50
50
  </div>
51
51
  <button
52
52
  @click="stagingSelect"
@@ -143,10 +143,24 @@
143
143
  <button
144
144
  @click="setOutputType('json')"
145
145
  type="button"
146
- class="relative -ml-px inline-flex items-center rounded-none rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10"
146
+ class="relative -ml-px inline-flex items-center px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10"
147
147
  :class="outputType === 'json' ? 'bg-gray-200' : 'bg-white'">
148
148
  <img class="h-5 w-5" src="images/json.svg">
149
149
  </button>
150
+ <button
151
+ @click="setOutputType('map')"
152
+ :disabled="geoJsonFields.length === 0"
153
+ type="button"
154
+ :title="geoJsonFields.length > 0 ? 'Map view' : 'No GeoJSON fields detected'"
155
+ class="relative -ml-px inline-flex items-center rounded-none rounded-r-md px-2 py-2 ring-1 ring-inset ring-gray-300 focus:z-10"
156
+ :class="[
157
+ geoJsonFields.length === 0 ? 'text-gray-300 cursor-not-allowed bg-gray-100' : 'text-gray-400 hover:bg-gray-50',
158
+ outputType === 'map' ? 'bg-gray-200' : (geoJsonFields.length > 0 ? 'bg-white' : '')
159
+ ]">
160
+ <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
161
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 6.75V15m6-6v8.25m.503 3.498 4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 0 0-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0Z" />
162
+ </svg>
163
+ </button>
150
164
  </span>
151
165
  </div>
152
166
  </div>
@@ -202,6 +216,30 @@
202
216
  </list-json>
203
217
  </div>
204
218
  </div>
219
+ <div v-else-if="outputType === 'map'" class="flex flex-col h-full">
220
+ <div class="p-2 bg-white border-b flex items-center gap-2">
221
+ <label class="text-sm font-medium text-gray-700">GeoJSON Field:</label>
222
+ <select
223
+ :value="selectedGeoField"
224
+ @change="setSelectedGeoField($event.target.value)"
225
+ class="rounded-md border border-gray-300 py-1 px-2 text-sm focus:border-ultramarine-500 focus:ring-ultramarine-500"
226
+ >
227
+ <option v-for="field in geoJsonFields" :key="field.path" :value="field.path">
228
+ {{ field.label }}
229
+ </option>
230
+ </select>
231
+ <async-button
232
+ @click="loadMoreDocuments"
233
+ :disabled="loadedAllDocs"
234
+ type="button"
235
+ class="rounded px-2 py-1 text-xs font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ultramarine-600"
236
+ :class="loadedAllDocs ? 'bg-gray-400 cursor-not-allowed' : 'bg-ultramarine-600 hover:bg-ultramarine-500'"
237
+ >
238
+ Load more
239
+ </async-button>
240
+ </div>
241
+ <div class="flex-1 min-h-[400px]" ref="modelsMap"></div>
242
+ </div>
205
243
  <div v-if="status === 'loading'" class="loader">
206
244
  <img src="images/loader.gif">
207
245
  </div>
@@ -1,14 +1,18 @@
1
1
  'use strict';
2
2
 
3
+ /* global L */
4
+
3
5
  const api = require('../api');
4
6
  const template = require('./models.html');
5
7
  const mpath = require('mpath');
8
+ const xss = require('xss');
6
9
 
7
10
  const appendCSS = require('../appendCSS');
8
11
  appendCSS(require('./models.css'));
9
12
 
10
13
  const limit = 20;
11
14
  const OUTPUT_TYPE_STORAGE_KEY = 'studio:model-output-type';
15
+ const SELECTED_GEO_FIELD_STORAGE_KEY = 'studio:model-selected-geo-field';
12
16
 
13
17
  module.exports = app => app.component('models', {
14
18
  template: template,
@@ -42,7 +46,10 @@ module.exports = app => app.component('models', {
42
46
  query: {},
43
47
  scrollHeight: 0,
44
48
  interval: null,
45
- outputType: 'table', // json, table
49
+ outputType: 'table', // json, table, map
50
+ selectedGeoField: null,
51
+ mapInstance: null,
52
+ mapLayer: null,
46
53
  hideSidebar: null,
47
54
  lastSelectedIndex: null,
48
55
  error: null,
@@ -52,11 +59,13 @@ module.exports = app => app.component('models', {
52
59
  created() {
53
60
  this.currentModel = this.model;
54
61
  this.loadOutputPreference();
62
+ this.loadSelectedGeoField();
55
63
  },
56
64
  beforeDestroy() {
57
65
  document.removeEventListener('scroll', this.onScroll, true);
58
66
  window.removeEventListener('popstate', this.onPopState, true);
59
67
  document.removeEventListener('click', this.onOutsideActionsMenuClick, true);
68
+ this.destroyMap();
60
69
  },
61
70
  async mounted() {
62
71
  this.onScroll = () => this.checkIfScrolledToBottom();
@@ -88,6 +97,37 @@ module.exports = app => app.component('models', {
88
97
 
89
98
  await this.initSearchFromUrl();
90
99
  },
100
+ watch: {
101
+ documents: {
102
+ handler() {
103
+ if (this.outputType === 'map' && this.mapInstance) {
104
+ this.$nextTick(() => {
105
+ this.updateMapFeatures();
106
+ });
107
+ }
108
+ },
109
+ deep: true
110
+ },
111
+ geoJsonFields: {
112
+ handler(newFields) {
113
+ // Switch off map view if map is selected but no GeoJSON fields available
114
+ if (this.outputType === 'map' && newFields.length === 0) {
115
+ this.setOutputType('json');
116
+ return;
117
+ }
118
+ // Auto-select first field if current selection is not valid
119
+ if (this.outputType === 'map' && newFields.length > 0) {
120
+ const isCurrentValid = newFields.some(f => f.path === this.selectedGeoField);
121
+ if (!isCurrentValid) {
122
+ this.selectedGeoField = newFields[0].path;
123
+ this.$nextTick(() => {
124
+ this.updateMapFeatures();
125
+ });
126
+ }
127
+ }
128
+ }
129
+ }
130
+ },
91
131
  computed: {
92
132
  referenceMap() {
93
133
  const map = {};
@@ -97,6 +137,56 @@ module.exports = app => app.component('models', {
97
137
  }
98
138
  }
99
139
  return map;
140
+ },
141
+ geoJsonFields() {
142
+ // Find schema paths that look like GeoJSON fields
143
+ // GeoJSON fields have nested 'type' and 'coordinates' properties
144
+ const geoFields = [];
145
+ const pathsByPrefix = {};
146
+
147
+ // Group paths by their parent prefix
148
+ for (const schemaPath of this.schemaPaths) {
149
+ const path = schemaPath.path;
150
+ const parts = path.split('.');
151
+ if (parts.length >= 2) {
152
+ const parent = parts.slice(0, -1).join('.');
153
+ const child = parts[parts.length - 1];
154
+ if (!pathsByPrefix[parent]) {
155
+ pathsByPrefix[parent] = {};
156
+ }
157
+ pathsByPrefix[parent][child] = schemaPath;
158
+ }
159
+ }
160
+
161
+ // Check which parents have both 'type' and 'coordinates' children
162
+ for (const [parent, children] of Object.entries(pathsByPrefix)) {
163
+ if (children.type && children.coordinates) {
164
+ geoFields.push({
165
+ path: parent,
166
+ label: parent
167
+ });
168
+ }
169
+ }
170
+
171
+ // Also check for Embedded/Mixed fields that might contain GeoJSON
172
+ // by looking at actual document data
173
+ for (const schemaPath of this.schemaPaths) {
174
+ if (schemaPath.instance === 'Embedded' || schemaPath.instance === 'Mixed') {
175
+ // Check if any document has this field with GeoJSON structure
176
+ const hasGeoJsonData = this.documents.some(doc => {
177
+ const value = mpath.get(schemaPath.path, doc);
178
+ return this.isGeoJsonValue(value);
179
+ });
180
+ if (hasGeoJsonData && !geoFields.find(f => f.path === schemaPath.path)) {
181
+ geoFields.push({
182
+ path: schemaPath.path,
183
+ label: schemaPath.path
184
+ });
185
+ }
186
+ }
187
+ }
188
+
189
+ return geoFields;
100
190
  }
101
191
  },
102
192
  methods: {
@@ -105,18 +195,170 @@ module.exports = app => app.component('models', {
105
195
  return;
106
196
  }
107
197
  const storedPreference = window.localStorage.getItem(OUTPUT_TYPE_STORAGE_KEY);
108
- if (storedPreference === 'json' || storedPreference === 'table') {
198
+ if (storedPreference === 'json' || storedPreference === 'table' || storedPreference === 'map') {
109
199
  this.outputType = storedPreference;
110
200
  }
111
201
  },
202
+ loadSelectedGeoField() {
203
+ if (typeof window === 'undefined' || !window.localStorage) {
204
+ return;
205
+ }
206
+ const storedField = window.localStorage.getItem(SELECTED_GEO_FIELD_STORAGE_KEY);
207
+ if (storedField) {
208
+ this.selectedGeoField = storedField;
209
+ }
210
+ },
112
211
  setOutputType(type) {
113
- if (type !== 'json' && type !== 'table') {
212
+ if (type !== 'json' && type !== 'table' && type !== 'map') {
114
213
  return;
115
214
  }
116
215
  this.outputType = type;
117
216
  if (typeof window !== 'undefined' && window.localStorage) {
118
217
  window.localStorage.setItem(OUTPUT_TYPE_STORAGE_KEY, type);
119
218
  }
219
+ if (type === 'map') {
220
+ this.$nextTick(() => {
221
+ this.initMap();
222
+ });
223
+ } else {
224
+ this.destroyMap();
225
+ }
226
+ },
227
+ setSelectedGeoField(field) {
228
+ this.selectedGeoField = field;
229
+ if (typeof window !== 'undefined' && window.localStorage) {
230
+ window.localStorage.setItem(SELECTED_GEO_FIELD_STORAGE_KEY, field);
231
+ }
232
+ if (this.outputType === 'map') {
233
+ this.$nextTick(() => {
234
+ this.updateMapFeatures();
235
+ });
236
+ }
237
+ },
238
+ isGeoJsonValue(value) {
239
+ return value != null &&
240
+ typeof value === 'object' &&
241
+ !Array.isArray(value) &&
242
+ Object.prototype.hasOwnProperty.call(value, 'type') &&
243
+ typeof value.type === 'string' &&
244
+ Object.prototype.hasOwnProperty.call(value, 'coordinates') &&
245
+ Array.isArray(value.coordinates);
246
+ },
247
+ initMap() {
248
+ if (typeof L === 'undefined') {
249
+ console.error('Leaflet (L) is not defined');
250
+ return;
251
+ }
252
+ if (!this.$refs.modelsMap) {
253
+ return;
254
+ }
255
+ if (this.mapInstance) {
256
+ this.updateMapFeatures();
257
+ return;
258
+ }
259
+
260
+ const mapElement = this.$refs.modelsMap;
261
+ mapElement.style.setProperty('height', '100%', 'important');
262
+ mapElement.style.setProperty('min-height', '400px', 'important');
263
+ mapElement.style.setProperty('width', '100%', 'important');
264
+
265
+ this.mapInstance = L.map(this.$refs.modelsMap).setView([0, 0], 2);
266
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
267
+ attribution: '&copy; OpenStreetMap contributors'
268
+ }).addTo(this.mapInstance);
269
+
270
+ this.$nextTick(() => {
271
+ if (this.mapInstance) {
272
+ this.mapInstance.invalidateSize();
273
+ this.updateMapFeatures();
274
+ }
275
+ });
276
+ },
277
+ destroyMap() {
278
+ if (this.mapLayer) {
279
+ this.mapLayer.remove();
280
+ this.mapLayer = null;
281
+ }
282
+ if (this.mapInstance) {
283
+ this.mapInstance.remove();
284
+ this.mapInstance = null;
285
+ }
286
+ },
287
+ updateMapFeatures() {
288
+ if (!this.mapInstance) {
289
+ return;
290
+ }
291
+
292
+ // Remove existing layer
293
+ if (this.mapLayer) {
294
+ this.mapLayer.remove();
295
+ this.mapLayer = null;
296
+ }
297
+
298
+ // Auto-select first geoJSON field if none selected
299
+ if (!this.selectedGeoField && this.geoJsonFields.length > 0) {
300
+ this.selectedGeoField = this.geoJsonFields[0].path;
301
+ }
302
+
303
+ if (!this.selectedGeoField) {
304
+ return;
305
+ }
306
+
307
+ // Build GeoJSON FeatureCollection from documents
308
+ const features = [];
309
+ for (const doc of this.documents) {
310
+ const geoValue = mpath.get(this.selectedGeoField, doc);
311
+ if (this.isGeoJsonValue(geoValue)) {
312
+ features.push({
313
+ type: 'Feature',
314
+ geometry: geoValue,
315
+ properties: {
316
+ _id: doc._id,
317
+ documentId: doc._id
318
+ }
319
+ });
320
+ }
321
+ }
322
+
323
+ if (features.length === 0) {
324
+ return;
325
+ }
326
+
327
+ const featureCollection = {
328
+ type: 'FeatureCollection',
329
+ features: features
330
+ };
331
+
332
+ // Add layer with click handler for popups
333
+ this.mapLayer = L.geoJSON(featureCollection, {
334
+ style: {
335
+ color: '#3388ff',
336
+ weight: 2,
337
+ opacity: 0.8,
338
+ fillOpacity: 0.3
339
+ },
340
+ pointToLayer: (feature, latlng) => {
341
+ return L.marker(latlng);
342
+ },
343
+ onEachFeature: (feature, layer) => {
344
+ const docId = feature.properties._id;
345
+ const docUrl = `#/model/${this.currentModel}/document/${xss(docId)}`;
346
+ const popupContent = `
347
+ <div style="min-width: 150px;">
348
+ <div style="font-weight: bold; margin-bottom: 8px;">Document</div>
349
+ <div style="font-family: monospace; font-size: 12px; word-break: break-all; margin-bottom: 8px;">${docId}</div>
350
+ <a href="${docUrl}" style="color: #3388ff; text-decoration: underline;">Open Document</a>
351
+ </div>
352
+ `;
353
+ layer.bindPopup(popupContent);
354
+ }
355
+ }).addTo(this.mapInstance);
356
+
357
+ // Fit bounds to show all features
358
+ const bounds = this.mapLayer.getBounds();
359
+ if (bounds.isValid()) {
360
+ this.mapInstance.fitBounds(bounds, { padding: [20, 20], maxZoom: 16 });
361
+ }
120
362
  },
121
363
  buildDocumentFetchParams(options = {}) {
122
364
  const params = {
@@ -170,6 +412,13 @@ module.exports = app => app.component('models', {
170
412
  this.filteredPaths = this.filteredPaths.filter(x => filter.includes(x.path));
171
413
  }
172
414
  this.status = 'loaded';
415
+
416
+ // Initialize map if output type is map
417
+ if (this.outputType === 'map') {
418
+ this.$nextTick(() => {
419
+ this.initMap();
420
+ });
421
+ }
173
422
  },
174
423
  async dropIndex(name) {
175
424
  const { mongoDBIndexes } = await api.Model.dropIndex({ model: this.currentModel, name });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mongoosejs/studio",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "A Mongoose-native MongoDB UI with schema-aware autocomplete, AI-assisted queries, and dashboards that understand your models - not just your data.",
5
5
  "homepage": "https://mongoosestudio.app/",
6
6
  "repository": {
@@ -22,7 +22,8 @@
22
22
  "tailwindcss": "3.4.0",
23
23
  "vue": "3.x",
24
24
  "vue-toastification": "^2.0.0-rc.5",
25
- "webpack": "5.x"
25
+ "webpack": "5.x",
26
+ "xss": "^1.0.15"
26
27
  },
27
28
  "peerDependencies": {
28
29
  "mongoose": "7.x || 8.x || ^9.0.0"