@mongoosejs/studio 0.2.6 → 0.2.8
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/backend/actions/ChatMessage/executeScript.js +1 -1
- package/backend/actions/Model/createChatMessage.js +54 -0
- package/backend/actions/Model/index.js +2 -0
- package/backend/actions/Model/streamChatMessage.js +58 -0
- package/backend/authorize.js +1 -0
- package/backend/helpers/getModelDescriptions.js +8 -0
- package/frontend/public/app.js +2151 -8
- package/frontend/public/tw.css +23 -0
- package/frontend/src/api.js +60 -0
- package/frontend/src/create-document/create-document.html +36 -1
- package/frontend/src/create-document/create-document.js +51 -1
- package/frontend/src/dashboard/dashboard.js +1 -1
- package/frontend/src/models/models.html +41 -3
- package/frontend/src/models/models.js +252 -3
- package/package.json +3 -2
package/frontend/public/tw.css
CHANGED
|
@@ -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
|
}
|
package/frontend/src/api.js
CHANGED
|
@@ -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
|
-
<
|
|
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
|
+
}
|
|
58
|
+
}
|
|
59
|
+
this.editor.setValue(this.aiSuggestion);
|
|
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 {
|
|
@@ -127,7 +127,7 @@ module.exports = {
|
|
|
127
127
|
this.code = this.dashboard.code;
|
|
128
128
|
this.title = this.dashboard.title;
|
|
129
129
|
this.description = this.dashboard.description ?? '';
|
|
130
|
-
this.dashboardResults = dashboardResults;
|
|
130
|
+
this.dashboardResults = Array.isArray(dashboardResults) ? dashboardResults : [];
|
|
131
131
|
if (this.shouldEvaluateDashboard()) {
|
|
132
132
|
await this.evaluateDashboard();
|
|
133
133
|
return;
|
|
@@ -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-
|
|
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
|
|
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: '© 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.
|
|
3
|
+
"version": "0.2.8",
|
|
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"
|