@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.
Files changed (36) hide show
  1. package/backend/actions/Dashboard/createDashboard.js +12 -2
  2. package/backend/actions/Dashboard/getDashboard.js +6 -2
  3. package/backend/actions/Dashboard/getDashboards.js +3 -1
  4. package/backend/actions/Dashboard/updateDashboard.js +19 -6
  5. package/backend/actions/Model/analyzeSchema.js +202 -0
  6. package/backend/actions/Model/index.js +2 -0
  7. package/backend/actions/Model/validateDocument.js +35 -0
  8. package/backend/authorize.js +2 -0
  9. package/backend/db/dashboardSchema.js +31 -0
  10. package/backend/helpers/validateDocumentWithTimeout.js +39 -0
  11. package/backend/netlify.js +1 -0
  12. package/backend/next.js +1 -0
  13. package/express.js +1 -0
  14. package/frontend/public/app.js +859 -102
  15. package/frontend/public/images/pin-off.svg +1 -0
  16. package/frontend/public/images/pin.svg +1 -0
  17. package/frontend/public/tw.css +249 -28
  18. package/frontend/src/api.js +12 -0
  19. package/frontend/src/array-utils.js +60 -2
  20. package/frontend/src/dashboards/dashboards.html +52 -4
  21. package/frontend/src/dashboards/dashboards.js +96 -1
  22. package/frontend/src/detail-array/detail-array.html +98 -14
  23. package/frontend/src/detail-array/detail-array.js +73 -3
  24. package/frontend/src/document/document.html +51 -0
  25. package/frontend/src/document/document.js +13 -0
  26. package/frontend/src/document-details/document-details.html +10 -3
  27. package/frontend/src/document-details/document-details.js +28 -0
  28. package/frontend/src/document-details/document-property/document-property.html +36 -44
  29. package/frontend/src/document-details/document-property/document-property.js +78 -14
  30. package/frontend/src/edit-array/edit-array.html +143 -8
  31. package/frontend/src/edit-array/edit-array.js +104 -2
  32. package/frontend/src/models/analyze-schema-modal/analyze-schema-modal.html +169 -0
  33. package/frontend/src/models/analyze-schema-modal/analyze-schema-modal.js +149 -0
  34. package/frontend/src/models/models.html +25 -13
  35. package/frontend/src/models/models.js +5 -13
  36. 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">{{path.path}}</span>
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
- <button
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
- <!-- Show truncated or full value based on needsTruncation and isValueExpanded -->
157
- <!-- Special handling for truncated arrays -->
158
- <div v-if="isArray && shouldShowTruncated" class="w-full">
159
- <div class="mt-2">
160
- <div
161
- v-for="(item, index) in truncatedArrayItems"
162
- :key="index"
163
- class="mb-1.5 py-2.5 px-3 pl-4 bg-transparent border-l-[3px] border-l-blue-500 rounded-none transition-all duration-200 cursor-pointer relative hover:bg-slate-50 hover:border-l-blue-600">
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-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"
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="isDatePath ? dateViewMode : (isObjectIdPath ? objectIdViewMode : detailViewMode)"
202
+ :viewMode="propertyDetailViewMode"
211
203
  @updated="renderedValue = $event"></component>
212
204
  <button
213
205
  @click="toggleValueExpansion"
214
- 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"
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="isDatePath ? dateViewMode : (isObjectIdPath ? objectIdViewMode : detailViewMode)"
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
- <ace-editor
3
- :value="arrayStr"
4
- @input="onEditorInput"
5
- mode="json"
6
- :line-numbers="true"
7
- class="min-h-[300px]"
8
- />
9
- </div>
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: ['value'],
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() {