@mongoosejs/studio 0.2.6 → 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.
@@ -1038,6 +1038,10 @@ video {
1038
1038
  min-height: 200px;
1039
1039
  }
1040
1040
 
1041
+ .min-h-\[400px\] {
1042
+ min-height: 400px;
1043
+ }
1044
+
1041
1045
  .\!w-0 {
1042
1046
  width: 0px !important;
1043
1047
  }
@@ -1652,6 +1656,11 @@ video {
1652
1656
  background-color: rgb(37 99 235 / var(--tw-bg-opacity));
1653
1657
  }
1654
1658
 
1659
+ .bg-emerald-600 {
1660
+ --tw-bg-opacity: 1;
1661
+ background-color: rgb(5 150 105 / var(--tw-bg-opacity));
1662
+ }
1663
+
1655
1664
  .bg-forest-green-600 {
1656
1665
  --tw-bg-opacity: 1;
1657
1666
  background-color: rgb(0 202 44 / var(--tw-bg-opacity));
@@ -2503,6 +2512,11 @@ video {
2503
2512
  background-color: rgb(29 78 216 / var(--tw-bg-opacity));
2504
2513
  }
2505
2514
 
2515
+ .hover\:bg-emerald-500:hover {
2516
+ --tw-bg-opacity: 1;
2517
+ background-color: rgb(16 185 129 / var(--tw-bg-opacity));
2518
+ }
2519
+
2506
2520
  .hover\:bg-forest-green-500:hover {
2507
2521
  --tw-bg-opacity: 1;
2508
2522
  background-color: rgb(0 242 58 / var(--tw-bg-opacity));
@@ -2692,6 +2706,11 @@ video {
2692
2706
  border-color: transparent;
2693
2707
  }
2694
2708
 
2709
+ .focus\:border-ultramarine-500:focus {
2710
+ --tw-border-opacity: 1;
2711
+ border-color: rgb(63 83 255 / var(--tw-border-opacity));
2712
+ }
2713
+
2695
2714
  .focus\:opacity-100:focus {
2696
2715
  opacity: 1;
2697
2716
  }
@@ -2943,6 +2962,10 @@ video {
2943
2962
  flex: none;
2944
2963
  }
2945
2964
 
2965
+ .sm\:flex-row {
2966
+ flex-direction: row;
2967
+ }
2968
+
2946
2969
  .sm\:flex-col {
2947
2970
  flex-direction: column;
2948
2971
  }
@@ -147,6 +147,14 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
147
147
  updateDocument: function updateDocument(params) {
148
148
  return client.post('', { action: 'Model.updateDocument', ...params }).then(res => res.data);
149
149
  },
150
+ createChatMessage(params) {
151
+ return client.post('', { action: 'Model.createChatMessage', ...params }).then(res => res.data);
152
+ },
153
+ streamChatMessage: async function* streamChatMessage(params) {
154
+ // Don't stream on Next.js or Netlify for now.
155
+ const data = await client.post('', { action: 'Model.createChatMessage', ...params }).then(res => res.data);
156
+ yield { textPart: data.text };
157
+ },
150
158
  updateDocuments: function updateDocuments(params) {
151
159
  return client.post('', { action: 'Model.updateDocuments', ...params }).then(res => res.data);
152
160
  }
@@ -250,6 +258,9 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
250
258
  createChart: function(params) {
251
259
  return client.post('/Model/createChart', params).then(res => res.data);
252
260
  },
261
+ createChatMessage: function(params) {
262
+ return client.post('/Model/createChatMessage', params).then(res => res.data);
263
+ },
253
264
  createDocument: function(params) {
254
265
  return client.post('/Model/createDocument', params).then(res => res.data);
255
266
  },
@@ -356,6 +367,55 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
356
367
  updateDocument: function updateDocument(params) {
357
368
  return client.post('/Model/updateDocument', params).then(res => res.data);
358
369
  },
370
+ streamChatMessage: async function* streamChatMessage(params) {
371
+ const accessToken = window.localStorage.getItem('_mongooseStudioAccessToken') || null;
372
+ const url = window.MONGOOSE_STUDIO_CONFIG.baseURL + '/Model/streamChatMessage?' + new URLSearchParams(params).toString();
373
+
374
+ const response = await fetch(url, {
375
+ method: 'GET',
376
+ headers: {
377
+ Authorization: `${accessToken}`,
378
+ Accept: 'text/event-stream'
379
+ }
380
+ });
381
+
382
+ if (!response.ok) {
383
+ throw new Error(`HTTP error! Status: ${response.status}`);
384
+ }
385
+
386
+ const reader = response.body.getReader();
387
+ const decoder = new TextDecoder('utf-8');
388
+ let buffer = '';
389
+
390
+ while (true) {
391
+ const { done, value } = await reader.read();
392
+ if (done) break;
393
+ buffer += decoder.decode(value, { stream: true });
394
+
395
+ let eventEnd;
396
+ while ((eventEnd = buffer.indexOf('\n\n')) !== -1) {
397
+ const eventStr = buffer.slice(0, eventEnd);
398
+ buffer = buffer.slice(eventEnd + 2);
399
+
400
+ // Parse SSE event
401
+ const lines = eventStr.split('\n');
402
+ let data = '';
403
+ for (const line of lines) {
404
+ if (line.startsWith('data:')) {
405
+ data += line.slice(5).trim();
406
+ }
407
+ }
408
+ if (data) {
409
+ try {
410
+ yield JSON.parse(data);
411
+ } catch (err) {
412
+ // If not JSON, yield as string
413
+ yield data;
414
+ }
415
+ }
416
+ }
417
+ }
418
+ },
359
419
  updateDocuments: function updateDocument(params) {
360
420
  return client.post('/Model/updateDocuments', params).then(res => res.data);
361
421
  }
@@ -1,6 +1,41 @@
1
1
  <div>
2
+ <div class="mb-4">
3
+ <label class="block text-sm font-bold text-gray-900">AI Mode</label>
4
+ <div class="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center">
5
+ <input
6
+ v-model="aiPrompt"
7
+ type="text"
8
+ placeholder="Describe the document you'd like to create..."
9
+ @keydown.enter.prevent="requestAiSuggestion()"
10
+ class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-ultramarine-500 focus:outline-none focus:ring-1 focus:ring-ultramarine-500"
11
+ />
12
+ <button
13
+ @click="requestAiSuggestion()"
14
+ :disabled="aiStreaming || !aiPrompt.trim()"
15
+ class="inline-flex items-center justify-center rounded-md bg-ultramarine-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-ultramarine-500 disabled:cursor-not-allowed disabled:opacity-50"
16
+ >
17
+ {{ aiStreaming ? 'Generating...' : 'Generate' }}
18
+ </button>
19
+ </div>
20
+ <p class="mt-2 text-xs text-gray-500">Use AI to draft the document. You can accept or reject the suggestion once it finishes.</p>
21
+ <div v-if="aiSuggestionReady" class="mt-3 flex flex-wrap gap-2">
22
+ <button
23
+ @click="acceptAiSuggestion()"
24
+ class="rounded-md bg-emerald-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-emerald-500"
25
+ >
26
+ Accept suggestion
27
+ </button>
28
+ <button
29
+ @click="rejectAiSuggestion()"
30
+ class="rounded-md bg-gray-100 px-2.5 py-1.5 text-sm font-semibold text-gray-700 shadow-sm hover:bg-gray-200"
31
+ >
32
+ Reject suggestion
33
+ </button>
34
+ </div>
35
+ </div>
2
36
  <div class="mb-2">
3
- <textarea class="border border-gray-200 p-2 h-[300px] w-full" ref="codeEditor"></textarea>
37
+ <label class="block text-sm font-bold text-gray-900">Document to Create</label>
38
+ <textarea class="border border-gray-200 p-2 h-[300px] w-full mt-2" ref="codeEditor"></textarea>
4
39
  </div>
5
40
  <button @click="createDocument()" class="rounded-md bg-ultramarine-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-ultramarine-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-600">Submit</button>
6
41
  <div v-if="errors.length > 0" class="rounded-md bg-red-50 p-4 mt-1">
@@ -23,10 +23,60 @@ module.exports = app => app.component('create-document', {
23
23
  return {
24
24
  documentData: '',
25
25
  editor: null,
26
- errors: []
26
+ errors: [],
27
+ aiPrompt: '',
28
+ aiSuggestion: '',
29
+ aiOriginalDocument: '',
30
+ aiStreaming: false,
31
+ aiSuggestionReady: false
27
32
  };
28
33
  },
29
34
  methods: {
35
+ async requestAiSuggestion() {
36
+ if (this.aiStreaming) {
37
+ return;
38
+ }
39
+ const prompt = this.aiPrompt.trim();
40
+ if (!prompt) {
41
+ return;
42
+ }
43
+
44
+ this.aiOriginalDocument = this.editor.getValue();
45
+ this.aiSuggestion = '';
46
+ this.aiSuggestionReady = false;
47
+ this.aiStreaming = true;
48
+
49
+ try {
50
+ for await (const event of api.Model.streamChatMessage({
51
+ model: this.currentModel,
52
+ content: prompt,
53
+ documentData: this.aiOriginalDocument
54
+ })) {
55
+ if (event?.textPart) {
56
+ this.aiSuggestion += event.textPart;
57
+ this.editor.setValue(this.aiSuggestion);
58
+ }
59
+ }
60
+ this.aiSuggestionReady = true;
61
+ } catch (err) {
62
+ this.editor.setValue(this.aiOriginalDocument);
63
+ this.$toast.error('Failed to generate a document suggestion.');
64
+ throw err;
65
+ } finally {
66
+ this.aiStreaming = false;
67
+ }
68
+ },
69
+ acceptAiSuggestion() {
70
+ this.aiSuggestionReady = false;
71
+ this.aiSuggestion = '';
72
+ this.aiOriginalDocument = '';
73
+ },
74
+ rejectAiSuggestion() {
75
+ this.editor.setValue(this.aiOriginalDocument);
76
+ this.aiSuggestionReady = false;
77
+ this.aiSuggestion = '';
78
+ this.aiOriginalDocument = '';
79
+ },
30
80
  async createDocument() {
31
81
  const data = EJSON.serialize(eval(`(${this.editor.getValue()})`));
32
82
  try {
@@ -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.6",
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"