@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.
- package/backend/actions/ChatMessage/executeScript.js +2 -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/backend/integrations/callLLM.js +1 -1
- package/backend/integrations/streamLLM.js +1 -1
- package/docs/user_stories.md +13 -0
- package/frontend/public/app.js +20149 -16871
- package/frontend/public/tw.css +95 -0
- package/frontend/src/api.js +60 -0
- package/frontend/src/chat/chat.js +6 -2
- package/frontend/src/create-document/create-document.html +36 -1
- package/frontend/src/create-document/create-document.js +51 -1
- package/frontend/src/detail-default/detail-default.html +15 -2
- package/frontend/src/detail-default/detail-default.js +1066 -2
- package/frontend/src/document-details/document-property/document-property.html +71 -3
- package/frontend/src/document-details/document-property/document-property.js +67 -1
- package/frontend/src/models/models.html +41 -3
- package/frontend/src/models/models.js +252 -3
- package/package.json +3 -2
|
@@ -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
|
|
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
|
|
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-
|
|
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.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"
|