@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.
@@ -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() {
@@ -0,0 +1,169 @@
1
+ <div>
2
+ <div class="modal-exit" @click="close">&times;</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>