@mongoosejs/studio 0.5.4 → 0.5.6
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/Dashboard/createDashboard.js +12 -2
- package/backend/actions/Dashboard/getDashboard.js +6 -2
- package/backend/actions/Dashboard/getDashboards.js +3 -1
- package/backend/actions/Dashboard/updateDashboard.js +19 -6
- package/backend/actions/Model/analyzeSchema.js +202 -0
- package/backend/actions/Model/index.js +2 -0
- package/backend/actions/Model/validateDocument.js +35 -0
- package/backend/authorize.js +2 -0
- package/backend/db/dashboardSchema.js +31 -0
- package/backend/helpers/validateDocumentWithTimeout.js +39 -0
- package/backend/netlify.js +1 -0
- package/backend/next.js +1 -0
- package/express.js +1 -0
- package/frontend/public/app.js +859 -102
- package/frontend/public/images/pin-off.svg +1 -0
- package/frontend/public/images/pin.svg +1 -0
- package/frontend/public/tw.css +249 -28
- package/frontend/src/api.js +12 -0
- package/frontend/src/array-utils.js +60 -2
- package/frontend/src/dashboards/dashboards.html +52 -4
- package/frontend/src/dashboards/dashboards.js +96 -1
- package/frontend/src/detail-array/detail-array.html +98 -14
- package/frontend/src/detail-array/detail-array.js +73 -3
- package/frontend/src/document/document.html +51 -0
- package/frontend/src/document/document.js +13 -0
- package/frontend/src/document-details/document-details.html +10 -3
- package/frontend/src/document-details/document-details.js +28 -0
- package/frontend/src/document-details/document-property/document-property.html +36 -44
- package/frontend/src/document-details/document-property/document-property.js +78 -14
- package/frontend/src/edit-array/edit-array.html +143 -8
- package/frontend/src/edit-array/edit-array.js +104 -2
- package/frontend/src/models/analyze-schema-modal/analyze-schema-modal.html +169 -0
- package/frontend/src/models/analyze-schema-modal/analyze-schema-modal.js +149 -0
- package/frontend/src/models/models.html +25 -13
- package/frontend/src/models/models.js +5 -13
- package/package.json +2 -1
|
@@ -286,6 +286,34 @@ module.exports = app => app.component('document-details', {
|
|
|
286
286
|
|
|
287
287
|
return this.virtualSearchMatchSet.has(virtual.name);
|
|
288
288
|
},
|
|
289
|
+
getSearchMatchParts(text) {
|
|
290
|
+
const normalizedQuery = typeof this.searchQuery === 'string' ? this.searchQuery.trim() : '';
|
|
291
|
+
if (!normalizedQuery) {
|
|
292
|
+
return [{ text, matched: false }];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const lowerText = String(text).toLowerCase();
|
|
296
|
+
const lowerQuery = normalizedQuery.toLowerCase();
|
|
297
|
+
const parts = [];
|
|
298
|
+
let position = 0;
|
|
299
|
+
let matchIndex = lowerText.indexOf(lowerQuery, position);
|
|
300
|
+
|
|
301
|
+
while (matchIndex !== -1) {
|
|
302
|
+
if (matchIndex > position) {
|
|
303
|
+
parts.push({ text: text.slice(position, matchIndex), matched: false });
|
|
304
|
+
}
|
|
305
|
+
const matchEnd = matchIndex + normalizedQuery.length;
|
|
306
|
+
parts.push({ text: text.slice(matchIndex, matchEnd), matched: true });
|
|
307
|
+
position = matchEnd;
|
|
308
|
+
matchIndex = lowerText.indexOf(lowerQuery, position);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (position < text.length) {
|
|
312
|
+
parts.push({ text: text.slice(position), matched: false });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return parts.length > 0 ? parts : [{ text, matched: false }];
|
|
316
|
+
},
|
|
289
317
|
isVirtualFieldCollapsed(fieldName) {
|
|
290
318
|
return this.collapsedVirtuals.has(fieldName);
|
|
291
319
|
},
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
<!-- Collapsible Header -->
|
|
3
3
|
<div
|
|
4
4
|
@click="toggleCollapse"
|
|
5
|
-
class="p-1 cursor-pointer flex items-center justify-between border-b border-edge transition-colors duration-200 ease-in-out"
|
|
6
|
-
:class="{ 'bg-amber-100 hover:bg-amber-200': highlight, 'bg-slate-100 hover:bg-muted': !highlight }"
|
|
5
|
+
class="p-1 cursor-pointer flex items-center justify-between border-b border-edge bg-slate-100 hover:bg-muted transition-colors duration-200 ease-in-out"
|
|
7
6
|
style="overflow: visible; position: relative;"
|
|
8
7
|
>
|
|
9
8
|
<div class="flex items-center" >
|
|
@@ -16,11 +15,32 @@
|
|
|
16
15
|
>
|
|
17
16
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
|
18
17
|
</svg>
|
|
19
|
-
<span class="font-medium text-content">
|
|
18
|
+
<span class="font-medium text-content">
|
|
19
|
+
<span
|
|
20
|
+
v-for="(part, index) in pathNameParts"
|
|
21
|
+
:key="index"
|
|
22
|
+
:class="{ 'font-bold': part.matched }">{{ part.text }}</span>
|
|
23
|
+
</span>
|
|
20
24
|
<span class="ml-2 text-sm text-content-tertiary">({{(path.instance || 'unknown').toLowerCase()}})</span>
|
|
25
|
+
<div v-if="path.instance === 'Array' && canUseArrayTableView" class="ml-3 inline-flex items-center rounded-full bg-gray-200 p-0.5 text-xs font-semibold">
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
class="rounded-full px-2.5 py-0.5 transition"
|
|
29
|
+
:class="arrayDetailViewMode === 'list' ? 'bg-merlot-700 text-white shadow' : 'text-content-secondary hover:text-content'"
|
|
30
|
+
@click.stop="setArrayDetailViewMode('list')">
|
|
31
|
+
List
|
|
32
|
+
</button>
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
class="rounded-full px-2.5 py-0.5 transition"
|
|
36
|
+
:class="arrayDetailViewMode === 'table' ? 'bg-merlot-700 text-white shadow' : 'text-content-secondary hover:text-content'"
|
|
37
|
+
@click.stop="setArrayDetailViewMode('table')">
|
|
38
|
+
Table
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
21
41
|
<div v-if="isGeoJsonGeometry" class="ml-3 inline-flex items-center gap-2">
|
|
22
42
|
<div class="inline-flex items-center rounded-full bg-gray-200 p-0.5 text-xs font-semibold">
|
|
23
|
-
|
|
43
|
+
<button
|
|
24
44
|
type="button"
|
|
25
45
|
class="rounded-full px-2.5 py-0.5 transition"
|
|
26
46
|
:class="detailViewMode === 'text' ? 'bg-blue-600 text-white shadow' : 'text-content-secondary hover:text-content'"
|
|
@@ -153,48 +173,20 @@
|
|
|
153
173
|
</component>
|
|
154
174
|
</div>
|
|
155
175
|
<div v-else>
|
|
156
|
-
<!--
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
<div class="absolute -left-2 top-1/2 -translate-y-1/2 w-5 h-5 bg-blue-500 text-white rounded-full flex items-center justify-center text-[10px] font-semibold font-mono z-10 hover:bg-blue-600">{{ index }}</div>
|
|
165
|
-
<div v-if="arrayUtils.isObjectItem(item)" class="flex flex-col gap-1 mt-1 px-2">
|
|
166
|
-
<div
|
|
167
|
-
v-for="key in arrayUtils.getItemKeys(item)"
|
|
168
|
-
:key="key"
|
|
169
|
-
class="flex items-start gap-2 text-xs font-mono">
|
|
170
|
-
<span class="font-semibold text-gray-600 flex-shrink-0 min-w-[80px]">{{ key }}:</span>
|
|
171
|
-
<span class="text-gray-800 break-words whitespace-pre-wrap flex-1">{{ arrayUtils.formatItemValue(item, key) }}</span>
|
|
172
|
-
</div>
|
|
173
|
-
</div>
|
|
174
|
-
<div v-else class="text-xs py-1.5 px-2 font-mono text-gray-800 break-words whitespace-pre-wrap mt-1">{{ arrayUtils.formatValue(item) }}</div>
|
|
175
|
-
</div>
|
|
176
|
-
<div class="mb-1.5 py-2.5 px-3 pl-4 bg-transparent border-none border-l-[3px] border-l-blue-500 rounded-none transition-all duration-200 cursor-pointer relative opacity-70 hover:opacity-100">
|
|
177
|
-
<div class="text-xs py-1.5 px-2 font-mono text-content-tertiary italic break-words whitespace-pre-wrap mt-1">
|
|
178
|
-
... and {{ remainingArrayCount }} more item{{ remainingArrayCount !== 1 ? 's' : '' }}
|
|
179
|
-
</div>
|
|
180
|
-
</div>
|
|
181
|
-
</div>
|
|
182
|
-
<button
|
|
183
|
-
@click="toggleValueExpansion"
|
|
184
|
-
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"
|
|
185
|
-
>
|
|
186
|
-
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
187
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
188
|
-
</svg>
|
|
189
|
-
Show all {{ arrayValue.length }} items
|
|
190
|
-
</button>
|
|
191
|
-
</div>
|
|
176
|
+
<!-- Arrays own their truncation and array search behavior in detail-array. -->
|
|
177
|
+
<component
|
|
178
|
+
v-if="isArray"
|
|
179
|
+
:is="getComponentForPath(path)"
|
|
180
|
+
:value="rawValue"
|
|
181
|
+
:viewMode="propertyDetailViewMode"
|
|
182
|
+
:truncate="needsTruncation"
|
|
183
|
+
@updated="renderedValue = $event"></component>
|
|
192
184
|
<!-- Non-array truncated view -->
|
|
193
185
|
<div v-else-if="shouldShowTruncated && !isArray" class="relative">
|
|
194
186
|
<div class="text-content-secondary whitespace-pre-wrap break-words font-mono text-sm">{{truncatedString}}</div>
|
|
195
187
|
<button
|
|
196
188
|
@click="toggleValueExpansion"
|
|
197
|
-
class="mt-2 text-
|
|
189
|
+
class="mt-2 text-merlot-700 hover:text-merlot-900 text-sm font-medium flex items-center gap-1 transform transition-all duration-200 ease-in-out hover:translate-x-0.5"
|
|
198
190
|
>
|
|
199
191
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
200
192
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
@@ -207,11 +199,11 @@
|
|
|
207
199
|
<component
|
|
208
200
|
:is="getComponentForPath(path)"
|
|
209
201
|
:value="rawValue"
|
|
210
|
-
:viewMode="
|
|
202
|
+
:viewMode="propertyDetailViewMode"
|
|
211
203
|
@updated="renderedValue = $event"></component>
|
|
212
204
|
<button
|
|
213
205
|
@click="toggleValueExpansion"
|
|
214
|
-
class="mt-2 text-
|
|
206
|
+
class="mt-2 text-merlot-700 hover:text-merlot-900 text-sm font-medium flex items-center gap-1 transform transition-all duration-200 ease-in-out hover:translate-x-0.5"
|
|
215
207
|
>
|
|
216
208
|
<svg class="w-4 h-4 rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
217
209
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
@@ -224,7 +216,7 @@
|
|
|
224
216
|
<component
|
|
225
217
|
:is="getComponentForPath(path)"
|
|
226
218
|
:value="rawValue"
|
|
227
|
-
:viewMode="
|
|
219
|
+
:viewMode="propertyDetailViewMode"
|
|
228
220
|
@updated="renderedValue = $event"></component>
|
|
229
221
|
</div>
|
|
230
222
|
</div>
|
|
@@ -11,6 +11,7 @@ const appendCSS = require('../../appendCSS');
|
|
|
11
11
|
appendCSS(require('./document-property.css'));
|
|
12
12
|
|
|
13
13
|
const UNSET = Symbol('unset');
|
|
14
|
+
const { isArrayOfObjects } = require('../../array-utils');
|
|
14
15
|
|
|
15
16
|
module.exports = app => app.component('document-property', {
|
|
16
17
|
template,
|
|
@@ -22,6 +23,8 @@ module.exports = app => app.component('document-property', {
|
|
|
22
23
|
renderedValue: UNSET,
|
|
23
24
|
isCollapsed: false, // Start uncollapsed by default
|
|
24
25
|
isValueExpanded: false, // Track if the value is expanded
|
|
26
|
+
/** 'list' | 'table' for array-of-objects display and editing */
|
|
27
|
+
arrayDetailViewMode: 'list',
|
|
25
28
|
detailViewMode: 'text',
|
|
26
29
|
copyButtonLabel: 'Copy',
|
|
27
30
|
copyResetTimeoutId: null,
|
|
@@ -34,7 +37,7 @@ module.exports = app => app.component('document-property', {
|
|
|
34
37
|
this.copyResetTimeoutId = null;
|
|
35
38
|
}
|
|
36
39
|
},
|
|
37
|
-
props: ['path', 'document', 'schemaPaths', 'editting', 'changes', 'invalid', 'highlight'],
|
|
40
|
+
props: ['path', 'document', 'schemaPaths', 'editting', 'changes', 'invalid', 'highlight', 'searchQuery'],
|
|
38
41
|
computed: {
|
|
39
42
|
isDatePath() {
|
|
40
43
|
return this.path?.instance === 'Date';
|
|
@@ -97,18 +100,6 @@ module.exports = app => app.component('document-property', {
|
|
|
97
100
|
}
|
|
98
101
|
return this.valueAsString;
|
|
99
102
|
},
|
|
100
|
-
truncatedArrayItems() {
|
|
101
|
-
if (this.isArray && this.needsTruncation && !this.isValueExpanded) {
|
|
102
|
-
return this.arrayValue.slice(0, 2);
|
|
103
|
-
}
|
|
104
|
-
return [];
|
|
105
|
-
},
|
|
106
|
-
remainingArrayCount() {
|
|
107
|
-
if (this.isArray && this.needsTruncation && !this.isValueExpanded) {
|
|
108
|
-
return this.arrayValue.length - 2;
|
|
109
|
-
}
|
|
110
|
-
return 0;
|
|
111
|
-
},
|
|
112
103
|
isGeoJsonGeometry() {
|
|
113
104
|
const value = this.getValueForPath(this.path.path);
|
|
114
105
|
return value != null
|
|
@@ -128,6 +119,34 @@ module.exports = app => app.component('document-property', {
|
|
|
128
119
|
isMultiPolygon() {
|
|
129
120
|
const value = this.getValueForPath(this.path.path);
|
|
130
121
|
return this.isGeoJsonGeometry && value.type === 'MultiPolygon';
|
|
122
|
+
},
|
|
123
|
+
arrayPathCurrentValue() {
|
|
124
|
+
if (!this.path || this.path.instance !== 'Array') {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
if (this.editting) {
|
|
128
|
+
return this.getEditValueForPath(this.path);
|
|
129
|
+
}
|
|
130
|
+
return this.getValueForPath(this.path.path);
|
|
131
|
+
},
|
|
132
|
+
canUseArrayTableView() {
|
|
133
|
+
const v = this.arrayPathCurrentValue;
|
|
134
|
+
return Array.isArray(v) && isArrayOfObjects(v);
|
|
135
|
+
},
|
|
136
|
+
propertyDetailViewMode() {
|
|
137
|
+
if (this.isDatePath) {
|
|
138
|
+
return this.dateViewMode;
|
|
139
|
+
}
|
|
140
|
+
if (this.isObjectIdPath) {
|
|
141
|
+
return this.objectIdViewMode;
|
|
142
|
+
}
|
|
143
|
+
if (this.path?.instance === 'Array') {
|
|
144
|
+
return this.arrayDetailViewMode;
|
|
145
|
+
}
|
|
146
|
+
return this.detailViewMode;
|
|
147
|
+
},
|
|
148
|
+
pathNameParts() {
|
|
149
|
+
return this.getSearchMatchParts(this.path?.path || '', this.searchQuery);
|
|
131
150
|
}
|
|
132
151
|
},
|
|
133
152
|
watch: {
|
|
@@ -144,12 +163,56 @@ module.exports = app => app.component('document-property', {
|
|
|
144
163
|
if (newValue && this.isGeoJsonGeometry) {
|
|
145
164
|
this.detailViewMode = 'map';
|
|
146
165
|
}
|
|
166
|
+
},
|
|
167
|
+
canUseArrayTableView(can) {
|
|
168
|
+
if (!can && this.arrayDetailViewMode === 'table') {
|
|
169
|
+
this.arrayDetailViewMode = 'list';
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
'path.path'() {
|
|
173
|
+
this.arrayDetailViewMode = 'list';
|
|
147
174
|
}
|
|
148
175
|
},
|
|
149
176
|
methods: {
|
|
177
|
+
getSearchMatchParts(text, query) {
|
|
178
|
+
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
|
|
179
|
+
if (!normalizedQuery) {
|
|
180
|
+
return [{ text, matched: false }];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const lowerText = String(text).toLowerCase();
|
|
184
|
+
const lowerQuery = normalizedQuery.toLowerCase();
|
|
185
|
+
const parts = [];
|
|
186
|
+
let position = 0;
|
|
187
|
+
let matchIndex = lowerText.indexOf(lowerQuery, position);
|
|
188
|
+
|
|
189
|
+
while (matchIndex !== -1) {
|
|
190
|
+
if (matchIndex > position) {
|
|
191
|
+
parts.push({ text: text.slice(position, matchIndex), matched: false });
|
|
192
|
+
}
|
|
193
|
+
const matchEnd = matchIndex + normalizedQuery.length;
|
|
194
|
+
parts.push({ text: text.slice(matchIndex, matchEnd), matched: true });
|
|
195
|
+
position = matchEnd;
|
|
196
|
+
matchIndex = lowerText.indexOf(lowerQuery, position);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (position < text.length) {
|
|
200
|
+
parts.push({ text: text.slice(position), matched: false });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return parts.length > 0 ? parts : [{ text, matched: false }];
|
|
204
|
+
},
|
|
205
|
+
setArrayDetailViewMode(mode) {
|
|
206
|
+
this.arrayDetailViewMode = mode;
|
|
207
|
+
if (mode === 'table') {
|
|
208
|
+
if (this.isCollapsed) {
|
|
209
|
+
this.isCollapsed = false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
},
|
|
150
213
|
setDetailViewMode(mode) {
|
|
151
214
|
this.detailViewMode = mode;
|
|
152
|
-
|
|
215
|
+
|
|
153
216
|
// When switching to map view, expand the container and value so the map is visible
|
|
154
217
|
if (mode === 'map' && this.isGeoJsonGeometry) {
|
|
155
218
|
if (this.isCollapsed) {
|
|
@@ -220,6 +283,7 @@ module.exports = app => app.component('document-property', {
|
|
|
220
283
|
if (path.instance === 'Array') {
|
|
221
284
|
props.path = path;
|
|
222
285
|
props.schemaPaths = this.schemaPaths;
|
|
286
|
+
props.viewMode = this.arrayDetailViewMode === 'table' ? 'table' : 'json';
|
|
223
287
|
}
|
|
224
288
|
return props;
|
|
225
289
|
},
|
|
@@ -1,9 +1,144 @@
|
|
|
1
1
|
<div class="w-full">
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
<div v-if="!useTableEditor">
|
|
3
|
+
<ace-editor
|
|
4
|
+
:value="arrayStr"
|
|
5
|
+
@input="onEditorInput"
|
|
6
|
+
mode="json"
|
|
7
|
+
:line-numbers="true"
|
|
8
|
+
class="min-h-[300px]"
|
|
9
|
+
/>
|
|
10
|
+
</div>
|
|
11
|
+
<div v-else class="overflow-hidden rounded-lg border border-edge bg-surface">
|
|
12
|
+
<div class="border-b border-edge bg-slate-50/90 px-3 py-2.5">
|
|
13
|
+
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:gap-4">
|
|
14
|
+
<div class="flex flex-wrap items-stretch gap-2">
|
|
15
|
+
<button
|
|
16
|
+
type="button"
|
|
17
|
+
class="rounded-md bg-primary px-3 py-1.5 text-xs font-semibold text-primary-text shadow-sm hover:bg-primary-hover"
|
|
18
|
+
@click="addRow">
|
|
19
|
+
+ Row
|
|
20
|
+
</button>
|
|
21
|
+
<div class="flex min-w-0 max-w-full sm:max-w-md">
|
|
22
|
+
<input
|
|
23
|
+
id="edit-array-new-column"
|
|
24
|
+
v-model="newColumnKeyInput"
|
|
25
|
+
type="text"
|
|
26
|
+
class="min-w-0 flex-1 rounded-l-md border border-r-0 border-edge bg-surface px-2.5 py-1.5 text-xs font-mono text-content placeholder:text-content-tertiary focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
|
27
|
+
placeholder="New field name"
|
|
28
|
+
autocomplete="off"
|
|
29
|
+
@keyup.enter="addColumn"
|
|
30
|
+
/>
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
class="shrink-0 rounded-r-md border border-edge bg-slate-100 px-3 py-1.5 text-xs font-semibold text-content hover:bg-slate-200"
|
|
34
|
+
@click="addColumn">
|
|
35
|
+
Add field
|
|
36
|
+
</button>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
<p class="text-[11px] leading-snug text-content-tertiary lg:ml-auto lg:max-w-[18rem] lg:text-right">
|
|
40
|
+
Each cell is JSON. Clear a cell to remove that field from the row.
|
|
41
|
+
</p>
|
|
42
|
+
</div>
|
|
43
|
+
<p v-if="newColumnKeyError" class="mt-2 text-xs font-medium text-red-600">{{ newColumnKeyError }}</p>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div v-if="arrayValue.length > 0" class="border-b border-edge bg-slate-50/50 px-3 py-2">
|
|
47
|
+
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
|
48
|
+
<div class="relative min-w-0 flex-1 sm:max-w-md">
|
|
49
|
+
<input
|
|
50
|
+
v-model="arraySearchQuery"
|
|
51
|
+
type="search"
|
|
52
|
+
autocomplete="off"
|
|
53
|
+
aria-label="Search rows in this array"
|
|
54
|
+
placeholder="Search rows…"
|
|
55
|
+
class="w-full rounded-md border border-edge bg-surface py-1.5 pl-2 pr-16 text-xs text-content placeholder:text-content-tertiary focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
|
56
|
+
/>
|
|
57
|
+
<button
|
|
58
|
+
v-if="arraySearchNormalized"
|
|
59
|
+
type="button"
|
|
60
|
+
class="absolute right-1 top-1/2 -translate-y-1/2 rounded px-1.5 py-0.5 text-[11px] text-content-tertiary hover:bg-slate-200 hover:text-content"
|
|
61
|
+
title="Clear search"
|
|
62
|
+
@click="arraySearchQuery = ''">
|
|
63
|
+
Clear
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
<span v-if="arraySearchNormalized" class="shrink-0 text-xs text-content-tertiary">
|
|
67
|
+
{{ filteredTableRows.length }} of {{ arrayValue.length }} shown
|
|
68
|
+
</span>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div v-if="arrayValue.length === 0" class="px-4 py-10 text-center text-sm text-content-secondary">
|
|
73
|
+
No rows yet. Use <span class="font-medium text-content">+ Row</span> or enter a field name and choose <span class="font-medium text-content">Add field</span>.
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div
|
|
77
|
+
v-else-if="filteredTableRows.length === 0"
|
|
78
|
+
class="px-4 py-8 text-center text-sm text-content-secondary">
|
|
79
|
+
No rows match "{{ arraySearchQuery.trim() }}".
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div v-else class="overflow-x-auto">
|
|
83
|
+
<table class="min-w-full text-left text-xs font-mono">
|
|
84
|
+
<thead class="border-b border-edge bg-slate-100 text-content-secondary">
|
|
85
|
+
<tr>
|
|
86
|
+
<th scope="col" class="w-24 border-r border-edge px-2 py-2 text-left text-[11px] font-semibold uppercase tracking-wide">
|
|
87
|
+
Actions
|
|
88
|
+
</th>
|
|
89
|
+
<th
|
|
90
|
+
v-for="key in tableColumnKeys"
|
|
91
|
+
:key="key"
|
|
92
|
+
scope="col"
|
|
93
|
+
class="border-r border-edge px-3 py-2 text-left text-xs font-semibold last:border-r-0 min-w-[8rem] whitespace-nowrap">
|
|
94
|
+
{{ key }}
|
|
95
|
+
</th>
|
|
96
|
+
</tr>
|
|
97
|
+
</thead>
|
|
98
|
+
<tbody class="divide-y divide-edge bg-surface">
|
|
99
|
+
<tr v-for="entry in filteredTableRows" :key="entry.index" class="hover:bg-slate-50/60">
|
|
100
|
+
<td class="align-middle border-r border-edge p-2">
|
|
101
|
+
<div class="flex flex-col gap-1">
|
|
102
|
+
<span class="text-center text-[10px] font-medium text-content-tertiary">#{{ entry.index }}</span>
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
class="rounded border border-edge bg-surface px-2 py-1 text-[11px] font-medium hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-40"
|
|
106
|
+
:disabled="entry.index === 0"
|
|
107
|
+
title="Move row up"
|
|
108
|
+
@click="moveRow(entry.index, -1)">
|
|
109
|
+
Up
|
|
110
|
+
</button>
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
class="rounded border border-edge bg-surface px-2 py-1 text-[11px] font-medium hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-40"
|
|
114
|
+
:disabled="entry.index === arrayValue.length - 1"
|
|
115
|
+
title="Move row down"
|
|
116
|
+
@click="moveRow(entry.index, 1)">
|
|
117
|
+
Down
|
|
118
|
+
</button>
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
class="rounded border border-red-200 bg-surface px-2 py-1 text-[11px] font-medium text-red-700 hover:bg-red-50"
|
|
122
|
+
title="Remove this row"
|
|
123
|
+
@click="removeRow(entry.index)">
|
|
124
|
+
Remove
|
|
125
|
+
</button>
|
|
126
|
+
</div>
|
|
127
|
+
</td>
|
|
128
|
+
<td
|
|
129
|
+
v-for="key in tableColumnKeys"
|
|
130
|
+
:key="key"
|
|
131
|
+
class="max-w-md min-w-[8rem] border-r border-edge p-1.5 align-top last:border-r-0">
|
|
132
|
+
<textarea
|
|
133
|
+
rows="2"
|
|
134
|
+
class="w-full resize-y rounded border border-edge bg-surface px-2 py-1.5 text-xs text-content focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
|
135
|
+
:value="serializeCell(entry.item[key])"
|
|
136
|
+
@blur="onCellChange(entry.index, key, $event.target.value)"
|
|
137
|
+
></textarea>
|
|
138
|
+
</td>
|
|
139
|
+
</tr>
|
|
140
|
+
</tbody>
|
|
141
|
+
</table>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const template = require('./edit-array.html');
|
|
4
4
|
const { BSON } = require('mongodb/lib/bson');
|
|
5
|
+
const { isArrayOfObjects, unionKeysForArrayOfObjects, arrayItemMatchesSearch } = require('../array-utils');
|
|
5
6
|
|
|
6
7
|
const ObjectId = new Proxy(BSON.ObjectId, {
|
|
7
8
|
apply(target, thisArg, argumentsList) {
|
|
@@ -12,15 +13,44 @@ const ObjectId = new Proxy(BSON.ObjectId, {
|
|
|
12
13
|
|
|
13
14
|
module.exports = app => app.component('edit-array', {
|
|
14
15
|
template: template,
|
|
15
|
-
props:
|
|
16
|
+
props: {
|
|
17
|
+
value: {},
|
|
18
|
+
path: {},
|
|
19
|
+
schemaPaths: {},
|
|
20
|
+
/** 'json' (ACE) or 'table' for arrays of plain objects */
|
|
21
|
+
viewMode: {
|
|
22
|
+
type: String,
|
|
23
|
+
default: 'json'
|
|
24
|
+
}
|
|
25
|
+
},
|
|
16
26
|
data() {
|
|
17
27
|
return {
|
|
18
|
-
arrayValue: []
|
|
28
|
+
arrayValue: [],
|
|
29
|
+
newColumnKeyInput: '',
|
|
30
|
+
newColumnKeyError: '',
|
|
31
|
+
arraySearchQuery: ''
|
|
19
32
|
};
|
|
20
33
|
},
|
|
21
34
|
computed: {
|
|
22
35
|
arrayStr() {
|
|
23
36
|
return JSON.stringify(this.arrayValue, null, 2);
|
|
37
|
+
},
|
|
38
|
+
useTableEditor() {
|
|
39
|
+
return this.viewMode === 'table' && isArrayOfObjects(this.arrayValue);
|
|
40
|
+
},
|
|
41
|
+
tableColumnKeys() {
|
|
42
|
+
return unionKeysForArrayOfObjects(this.arrayValue);
|
|
43
|
+
},
|
|
44
|
+
arraySearchNormalized() {
|
|
45
|
+
return (this.arraySearchQuery || '').trim().toLowerCase();
|
|
46
|
+
},
|
|
47
|
+
filteredTableRows() {
|
|
48
|
+
if (!Array.isArray(this.arrayValue)) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
return this.arrayValue
|
|
52
|
+
.map((item, index) => ({ item, index }))
|
|
53
|
+
.filter(({ item }) => arrayItemMatchesSearch(item, this.arraySearchNormalized));
|
|
24
54
|
}
|
|
25
55
|
},
|
|
26
56
|
methods: {
|
|
@@ -51,6 +81,78 @@ module.exports = app => app.component('edit-array', {
|
|
|
51
81
|
} catch (err) {
|
|
52
82
|
this.$emit('error', err);
|
|
53
83
|
}
|
|
84
|
+
},
|
|
85
|
+
serializeCell(value) {
|
|
86
|
+
if (value === undefined) {
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
return JSON.stringify(value);
|
|
90
|
+
},
|
|
91
|
+
onCellChange(rowIndex, key, raw) {
|
|
92
|
+
const row = this.arrayValue[rowIndex];
|
|
93
|
+
if (row == null || typeof row !== 'object') {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const trimmed = raw.trim();
|
|
97
|
+
if (trimmed === '') {
|
|
98
|
+
delete row[key];
|
|
99
|
+
this.emitUpdate();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
row[key] = JSON.parse(trimmed);
|
|
104
|
+
this.emitUpdate();
|
|
105
|
+
} catch (err) {
|
|
106
|
+
this.$emit('error', err);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
addRow() {
|
|
110
|
+
const row = {};
|
|
111
|
+
for (const k of this.tableColumnKeys) {
|
|
112
|
+
row[k] = null;
|
|
113
|
+
}
|
|
114
|
+
this.arrayValue.push(row);
|
|
115
|
+
this.emitUpdate();
|
|
116
|
+
},
|
|
117
|
+
addColumn() {
|
|
118
|
+
const key = (this.newColumnKeyInput || '').trim();
|
|
119
|
+
this.newColumnKeyError = '';
|
|
120
|
+
if (!key) {
|
|
121
|
+
this.newColumnKeyError = 'Enter a column name.';
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (this.tableColumnKeys.includes(key)) {
|
|
125
|
+
this.newColumnKeyError = 'That column already exists.';
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!Array.isArray(this.arrayValue)) {
|
|
129
|
+
this.arrayValue = [];
|
|
130
|
+
}
|
|
131
|
+
if (this.arrayValue.length === 0) {
|
|
132
|
+
this.arrayValue.push({ [key]: null });
|
|
133
|
+
} else {
|
|
134
|
+
for (const row of this.arrayValue) {
|
|
135
|
+
if (row != null && typeof row === 'object' && !Array.isArray(row) && row.constructor === Object) {
|
|
136
|
+
row[key] = null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
this.newColumnKeyInput = '';
|
|
141
|
+
this.emitUpdate();
|
|
142
|
+
},
|
|
143
|
+
removeRow(index) {
|
|
144
|
+
this.arrayValue.splice(index, 1);
|
|
145
|
+
this.emitUpdate();
|
|
146
|
+
},
|
|
147
|
+
moveRow(index, delta) {
|
|
148
|
+
const j = index + delta;
|
|
149
|
+
if (j < 0 || j >= this.arrayValue.length) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const copy = this.arrayValue[index];
|
|
153
|
+
this.arrayValue.splice(index, 1);
|
|
154
|
+
this.arrayValue.splice(j, 0, copy);
|
|
155
|
+
this.emitUpdate();
|
|
54
156
|
}
|
|
55
157
|
},
|
|
56
158
|
mounted() {
|