@mongoosejs/studio 0.5.4 → 0.5.5
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/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/helpers/validateDocumentWithTimeout.js +39 -0
- package/frontend/public/app.js +639 -99
- package/frontend/public/tw.css +221 -28
- package/frontend/src/api.js +12 -0
- package/frontend/src/array-utils.js +60 -2
- 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
|
@@ -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() {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
<div>
|
|
2
|
+
<div class="modal-exit" @click="close">×</div>
|
|
3
|
+
<div class="text-xl font-bold mb-2">Analyze Schema</div>
|
|
4
|
+
<div class="space-y-4">
|
|
5
|
+
<div class="grid grid-cols-2 gap-3 text-sm md:grid-cols-4">
|
|
6
|
+
<div>
|
|
7
|
+
<div class="font-semibold text-content-secondary">Documents</div>
|
|
8
|
+
<div v-if="schemaAnalysis" class="text-content">{{ formatNumber(schemaAnalysis.documentCount) }}</div>
|
|
9
|
+
<div v-else class="mt-1 h-4 w-20 animate-pulse rounded bg-muted"></div>
|
|
10
|
+
</div>
|
|
11
|
+
<div>
|
|
12
|
+
<div class="font-semibold text-content-secondary">Analyzed</div>
|
|
13
|
+
<div v-if="schemaAnalysis" class="text-content">{{ formatNumber(schemaAnalysis.sampleSize) }}{{ schemaAnalysis.sampled ? ' sampled' : '' }}</div>
|
|
14
|
+
<div v-else class="mt-1 h-4 w-24 animate-pulse rounded bg-muted"></div>
|
|
15
|
+
</div>
|
|
16
|
+
<div>
|
|
17
|
+
<div class="font-semibold text-content-secondary">Valid</div>
|
|
18
|
+
<div v-if="schemaAnalysis" class="text-content">
|
|
19
|
+
{{ formatNumber(schemaAnalysis.validDocumentCount) }} ({{ formatPercent(schemaAnalysis.validDocumentCount, schemaAnalysis.sampleSize) }})
|
|
20
|
+
<router-link
|
|
21
|
+
v-if="schemaAnalysis.firstValidDocumentId"
|
|
22
|
+
:to="documentRoute(schemaAnalysis.firstValidDocumentId)"
|
|
23
|
+
class="ml-1 text-xs text-primary underline"
|
|
24
|
+
>[Example]</router-link>
|
|
25
|
+
</div>
|
|
26
|
+
<div v-else class="mt-1 h-4 w-28 animate-pulse rounded bg-muted"></div>
|
|
27
|
+
</div>
|
|
28
|
+
<div>
|
|
29
|
+
<div class="flex items-center gap-2 font-semibold text-content-secondary">
|
|
30
|
+
<span>Invalid</span>
|
|
31
|
+
<button
|
|
32
|
+
v-if="schemaAnalysis && schemaAnalysis.invalidDocuments.length > 0"
|
|
33
|
+
type="button"
|
|
34
|
+
class="text-xs font-semibold text-primary underline"
|
|
35
|
+
@click="showInvalidDocuments = !showInvalidDocuments"
|
|
36
|
+
>{{ showInvalidDocuments ? 'Hide' : 'Show' }}</button>
|
|
37
|
+
</div>
|
|
38
|
+
<div v-if="schemaAnalysis" class="text-content">
|
|
39
|
+
{{ formatNumber(schemaAnalysis.invalidDocumentCount) }} ({{ formatPercent(schemaAnalysis.invalidDocumentCount, schemaAnalysis.sampleSize) }})
|
|
40
|
+
<router-link
|
|
41
|
+
v-if="schemaAnalysis.firstInvalidDocumentId"
|
|
42
|
+
:to="documentRoute(schemaAnalysis.firstInvalidDocumentId)"
|
|
43
|
+
class="ml-1 text-xs text-primary underline"
|
|
44
|
+
>[Example]</router-link>
|
|
45
|
+
</div>
|
|
46
|
+
<div v-else class="mt-1 h-4 w-28 animate-pulse rounded bg-muted"></div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
<div v-if="schemaAnalysis && showInvalidDocuments && schemaAnalysis.invalidDocuments.length > 0" class="rounded border border-edge">
|
|
50
|
+
<div class="max-h-48 overflow-auto">
|
|
51
|
+
<div
|
|
52
|
+
v-for="invalidDocument in schemaAnalysis.invalidDocuments"
|
|
53
|
+
:key="invalidDocument.documentId"
|
|
54
|
+
class="grid gap-2 border-b border-edge px-3 py-2 text-sm last:border-b-0 md:grid-cols-[minmax(180px,240px)_1fr]"
|
|
55
|
+
>
|
|
56
|
+
<router-link
|
|
57
|
+
:to="documentRoute(invalidDocument.documentId)"
|
|
58
|
+
class="font-mono text-primary underline"
|
|
59
|
+
>{{ invalidDocument.documentId }}</router-link>
|
|
60
|
+
<div class="text-content-secondary">{{ invalidDocument.error }}</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="px-px py-px">
|
|
65
|
+
<input
|
|
66
|
+
ref="fieldSearch"
|
|
67
|
+
v-model="fieldSearch"
|
|
68
|
+
type="text"
|
|
69
|
+
:disabled="!schemaAnalysis"
|
|
70
|
+
placeholder="Filter fields..."
|
|
71
|
+
class="w-full rounded border border-edge bg-surface px-3 py-2 text-sm text-content placeholder:text-content-tertiary focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-wait disabled:bg-muted disabled:text-content-tertiary"
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="max-h-[55vh] overflow-auto rounded border border-edge">
|
|
75
|
+
<table class="min-w-full table-fixed border-collapse text-sm">
|
|
76
|
+
<colgroup>
|
|
77
|
+
<col class="w-[40%]" />
|
|
78
|
+
<col class="w-[60%]" />
|
|
79
|
+
</colgroup>
|
|
80
|
+
<thead class="sticky top-0 bg-slate-100 border-b border-edge">
|
|
81
|
+
<tr>
|
|
82
|
+
<th class="px-3 py-2 text-left font-medium text-content border-r border-edge">Property</th>
|
|
83
|
+
<th class="px-3 py-2 text-left font-medium text-content">
|
|
84
|
+
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
85
|
+
<span>Types</span>
|
|
86
|
+
<div class="isolate inline-flex shrink-0 rounded-md shadow-sm">
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
class="relative inline-flex items-center rounded-l-md px-2.5 py-1 text-xs font-semibold ring-1 ring-inset ring-edge-strong focus:z-10"
|
|
90
|
+
:class="typeDisplayMode === 'badges' ? 'bg-merlot-700 text-white' : 'bg-surface text-content-secondary hover:bg-muted'"
|
|
91
|
+
@click="typeDisplayMode = 'badges'"
|
|
92
|
+
>
|
|
93
|
+
Badges
|
|
94
|
+
</button>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
class="relative -ml-px inline-flex items-center rounded-r-md px-2.5 py-1 text-xs font-semibold ring-1 ring-inset ring-edge-strong focus:z-10"
|
|
98
|
+
:class="typeDisplayMode === 'bars' ? 'bg-merlot-700 text-white' : 'bg-surface text-content-secondary hover:bg-muted'"
|
|
99
|
+
@click="typeDisplayMode = 'bars'"
|
|
100
|
+
>
|
|
101
|
+
Bars
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</th>
|
|
106
|
+
</tr>
|
|
107
|
+
</thead>
|
|
108
|
+
<tbody class="bg-surface">
|
|
109
|
+
<template v-if="!schemaAnalysis">
|
|
110
|
+
<tr v-for="index in 6" :key="'skeleton-' + index" class="border-b border-edge last:border-b-0">
|
|
111
|
+
<td class="px-3 py-3 align-top border-r border-edge">
|
|
112
|
+
<div class="h-4 w-44 animate-pulse rounded bg-muted"></div>
|
|
113
|
+
</td>
|
|
114
|
+
<td class="px-3 py-3 align-top">
|
|
115
|
+
<div class="flex flex-wrap gap-1.5">
|
|
116
|
+
<div class="h-6 w-24 animate-pulse rounded bg-muted"></div>
|
|
117
|
+
<div class="h-6 w-20 animate-pulse rounded bg-muted"></div>
|
|
118
|
+
<div class="h-6 w-28 animate-pulse rounded bg-muted"></div>
|
|
119
|
+
</div>
|
|
120
|
+
</td>
|
|
121
|
+
</tr>
|
|
122
|
+
</template>
|
|
123
|
+
<tr v-for="path in filteredPaths" :key="path.path" class="border-b border-edge last:border-b-0">
|
|
124
|
+
<td class="px-3 py-2 align-top border-r border-edge text-xs text-content">
|
|
125
|
+
<div class="flex flex-wrap items-center gap-1.5">
|
|
126
|
+
<span class="font-mono" v-html="highlightPath(path.path)"></span>
|
|
127
|
+
<span
|
|
128
|
+
v-if="primaryType(path)"
|
|
129
|
+
class="rounded px-1.5 py-0.5 text-[11px] font-semibold"
|
|
130
|
+
:style="typeBadgeStyle(primaryType(path).type)"
|
|
131
|
+
>{{ primaryType(path).type }}</span>
|
|
132
|
+
<span v-if="path.required" class="rounded bg-red-50 px-1.5 py-0.5 text-[11px] font-semibold text-red-700">required</span>
|
|
133
|
+
</div>
|
|
134
|
+
</td>
|
|
135
|
+
<td class="px-3 py-2 align-top">
|
|
136
|
+
<div v-if="typeDisplayMode === 'badges'" class="flex flex-wrap gap-1.5">
|
|
137
|
+
<span
|
|
138
|
+
v-for="typeInfo in path.types"
|
|
139
|
+
:key="typeInfo.type"
|
|
140
|
+
class="rounded px-2 py-1 text-xs font-semibold"
|
|
141
|
+
:style="typeBadgeStyle(typeInfo.type)"
|
|
142
|
+
>
|
|
143
|
+
<span class="font-mono">{{ typeInfo.type }}</span>:
|
|
144
|
+
{{ formatPercent(typeInfo.count, pathValueCount(path)) }}
|
|
145
|
+
</span>
|
|
146
|
+
</div>
|
|
147
|
+
<div v-else class="min-w-[260px]">
|
|
148
|
+
<div class="flex h-7 overflow-hidden rounded-lg border border-edge bg-surface">
|
|
149
|
+
<div
|
|
150
|
+
v-for="typeInfo in path.types"
|
|
151
|
+
:key="typeInfo.type"
|
|
152
|
+
:title="`${typeInfo.type}: ${formatPercent(typeInfo.count, pathValueCount(path))}`"
|
|
153
|
+
class="flex h-full items-center overflow-hidden px-2 text-xs font-semibold"
|
|
154
|
+
:style="typeSegmentStyle(typeInfo.type, typeInfo.count, pathValueCount(path))"
|
|
155
|
+
>
|
|
156
|
+
<span class="truncate">{{ typeInfo.type }}</span>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</td>
|
|
161
|
+
</tr>
|
|
162
|
+
<tr v-if="schemaAnalysis && filteredPaths.length === 0">
|
|
163
|
+
<td colspan="2" class="px-3 py-4 text-sm text-content-tertiary">No fields match "{{ fieldSearch }}".</td>
|
|
164
|
+
</tr>
|
|
165
|
+
</tbody>
|
|
166
|
+
</table>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|