@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
@@ -1,7 +1,6 @@
1
1
  'use strict';
2
2
 
3
3
  const { inspect } = require('node-inspect-extracted');
4
- const deepEqual = require('./_util/deepEqual');
5
4
 
6
5
  /**
7
6
  * Format a value for display in array views
@@ -59,9 +58,68 @@ function formatItemValue(item, key) {
59
58
  return String(value);
60
59
  }
61
60
 
61
+ /**
62
+ * True when every element is a plain object (empty array counts as eligible).
63
+ */
64
+ function isArrayOfObjects(arr) {
65
+ return Array.isArray(arr) && arr.every(item => isObjectItem(item));
66
+ }
67
+
68
+ /**
69
+ * Sorted union of keys across plain object elements.
70
+ */
71
+ function unionKeysForArrayOfObjects(arr) {
72
+ if (!Array.isArray(arr)) {
73
+ return [];
74
+ }
75
+ const set = new Set();
76
+ for (const item of arr) {
77
+ if (!isObjectItem(item)) {
78
+ continue;
79
+ }
80
+ for (const k of Object.keys(item)) {
81
+ set.add(k);
82
+ }
83
+ }
84
+ return Array.from(set).sort((a, b) => a.localeCompare(b));
85
+ }
86
+
87
+ /**
88
+ * Flat text for substring search over one array element (field names + values, or whole item).
89
+ * @param {*} item
90
+ * @returns {string}
91
+ */
92
+ function searchTextForArrayItem(item) {
93
+ if (item == null) {
94
+ return String(item);
95
+ }
96
+ if (isObjectItem(item)) {
97
+ return getItemKeys(item)
98
+ .map(k => `${k} ${formatItemValue(item, k)}`)
99
+ .join(' \n ');
100
+ }
101
+ return formatValue(item);
102
+ }
103
+
104
+ /**
105
+ * Case-insensitive substring match against an array element.
106
+ * @param {*} item
107
+ * @param {string} normalizedQuery lowercased trimmed query; empty matches all
108
+ */
109
+ function arrayItemMatchesSearch(item, normalizedQuery) {
110
+ if (normalizedQuery == null || normalizedQuery === '') {
111
+ return true;
112
+ }
113
+ return searchTextForArrayItem(item).toLowerCase().includes(normalizedQuery);
114
+ }
115
+
62
116
  module.exports = {
63
117
  formatValue,
64
118
  isObjectItem,
65
119
  getItemKeys,
66
- formatItemValue
120
+ formatItemValue,
121
+ isArrayOfObjects,
122
+ unionKeysForArrayOfObjects,
123
+ searchTextForArrayItem,
124
+ arrayItemMatchesSearch
67
125
  };
@@ -10,6 +10,23 @@
10
10
  </button>
11
11
  </div>
12
12
 
13
+ <div v-if="status !== 'loading'" class="mb-5">
14
+ <label for="dashboard-search" class="sr-only">Search dashboards</label>
15
+ <div class="relative max-w-lg">
16
+ <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-content-tertiary">
17
+ <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
18
+ <path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
19
+ </svg>
20
+ </div>
21
+ <input
22
+ id="dashboard-search"
23
+ v-model="searchText"
24
+ type="search"
25
+ class="block w-full rounded-md border-0 bg-surface py-2 pl-9 pr-3 text-sm text-content shadow-sm ring-1 ring-inset ring-edge placeholder:text-content-tertiary focus:ring-2 focus:ring-inset focus:ring-primary"
26
+ placeholder="Search by title or description">
27
+ </div>
28
+ </div>
29
+
13
30
  <div v-if="status === 'loading'" class="text-center py-12">
14
31
  <svg
15
32
  class="inline w-8 h-8 animate-spin text-gray-400"
@@ -38,21 +55,52 @@
38
55
  </div>
39
56
  </div>
40
57
 
58
+ <div v-else-if="filteredDashboards.length === 0" class="text-center py-12">
59
+ <h3 class="text-sm font-semibold text-content">No matching dashboards</h3>
60
+ <p class="mt-1 text-sm text-content-tertiary">Try a different title or description.</p>
61
+ </div>
62
+
41
63
  <div v-else class="space-y-3">
42
64
  <div
43
- v-for="dashboard in dashboards"
65
+ v-for="dashboard in filteredDashboards"
44
66
  :key="dashboard._id"
45
- class="bg-surface rounded-lg border border-edge px-5 py-4 flex items-center justify-between hover:border-edge-strong transition-colors"
67
+ class="bg-surface rounded-lg border border-edge px-5 py-4 flex items-start justify-between hover:border-edge-strong transition-colors"
46
68
  >
47
69
  <div class="min-w-0 flex-1 mr-4">
48
70
  <router-link
49
71
  :to="'/dashboard/' + dashboard._id"
50
72
  class="text-sm font-semibold text-content hover:text-primary block truncate">
51
- {{ dashboard.title }}
73
+ <span v-html="highlightTitle(dashboard.title)"></span>
52
74
  </router-link>
53
- <p v-if="dashboard.description" class="mt-0.5 text-sm text-content-tertiary truncate">{{ dashboard.description }}</p>
75
+ <p
76
+ v-if="dashboard.description"
77
+ class="mt-0.5 text-sm text-content-tertiary truncate"
78
+ v-html="highlightDescription(dashboard.description)"></p>
79
+ <dl class="mt-3 grid grid-cols-1 gap-x-4 gap-y-1 text-xs text-content-tertiary sm:grid-cols-3">
80
+ <div>
81
+ <dt class="font-medium text-content-secondary">Created At</dt>
82
+ <dd>{{ formatDate(dashboard.createdAt) }}</dd>
83
+ </div>
84
+ <div>
85
+ <dt class="font-medium text-content-secondary">Last Evaluated At</dt>
86
+ <dd>{{ formatDate(dashboard.lastEvaluatedAt) }}</dd>
87
+ </div>
88
+ <div v-show="dashboard.createdBy">
89
+ <dt class="font-medium text-content-secondary">Created By</dt>
90
+ <dd class="truncate">{{ dashboard.createdBy?.name }}</dd>
91
+ </div>
92
+ </dl>
54
93
  </div>
55
94
  <div class="flex items-center gap-2 shrink-0">
95
+ <button
96
+ type="button"
97
+ @click="togglePin(dashboard)"
98
+ :title="dashboard.isPinned ? 'Unpin dashboard' : 'Pin dashboard'"
99
+ :aria-label="dashboard.isPinned ? 'Unpin dashboard' : 'Pin dashboard'"
100
+ class="rounded-md bg-surface px-2 py-1.5 text-content-tertiary ring-1 ring-inset ring-gray-300 hover:bg-page"
101
+ :class="dashboard.isPinned ? 'text-primary ring-primary/40' : ''">
102
+ <img :src="dashboard.isPinned ? 'images/pin-off.svg' : 'images/pin.svg'" class="w-4 h-4" alt="" />
103
+ </button>
56
104
  <router-link
57
105
  :to="'/dashboard/' + dashboard._id"
58
106
  class="rounded-md bg-primary px-3 py-1.5 text-sm font-semibold text-primary-text shadow-sm hover:bg-primary-hover">
@@ -9,11 +9,73 @@ module.exports = app => app.component('dashboards', {
9
9
  data: () => ({
10
10
  status: 'loading',
11
11
  dashboards: [],
12
+ searchText: '',
12
13
  showCreateDashboardModal: false,
13
14
  showDeleteDashboardModal: null,
14
15
  openMenuId: null
15
16
  }),
17
+ computed: {
18
+ filteredDashboards() {
19
+ const searchWords = getSearchWords(this.searchText);
20
+ let dashboards = this.dashboards;
21
+ if (searchWords.length === 0) {
22
+ return sortPinnedFirst(dashboards);
23
+ }
24
+ const searchPatterns = searchWords.map(word => new RegExp(escapeRegExp(word), 'i'));
25
+
26
+ dashboards = dashboards.filter(dashboard => {
27
+ const searchableText = `${dashboard.title || ''} ${dashboard.description || ''}`;
28
+ return searchPatterns.every(pattern => pattern.test(searchableText));
29
+ });
30
+
31
+ return sortPinnedFirst(dashboards);
32
+ }
33
+ },
16
34
  methods: {
35
+ highlightTitle(value) {
36
+ return this.highlightSearchText(value, 'underline decoration-primary underline-offset-2');
37
+ },
38
+ highlightDescription(value) {
39
+ return this.highlightSearchText(value, 'font-semibold text-content-secondary');
40
+ },
41
+ highlightSearchText(value, className) {
42
+ const text = value || '';
43
+ const searchWords = getSearchWords(this.searchText);
44
+ if (searchWords.length === 0) {
45
+ return escapeHtml(text);
46
+ }
47
+ const searchPattern = new RegExp(searchWords.map(escapeRegExp).join('|'), 'ig');
48
+
49
+ let html = '';
50
+ let lastIndex = 0;
51
+ let match = searchPattern.exec(text);
52
+ while (match != null) {
53
+ html += escapeHtml(text.slice(lastIndex, match.index));
54
+ html += `<span class="${className}">${escapeHtml(match[0])}</span>`;
55
+ lastIndex = match.index + match[0].length;
56
+ match = searchPattern.exec(text);
57
+ }
58
+ html += escapeHtml(text.slice(lastIndex));
59
+ return html;
60
+ },
61
+ formatDate(value) {
62
+ if (!value) {
63
+ return '-';
64
+ }
65
+ const date = new Date(value);
66
+ if (Number.isNaN(date.getTime())) {
67
+ return '-';
68
+ }
69
+ return new Intl.DateTimeFormat(undefined, {
70
+ dateStyle: 'medium',
71
+ timeStyle: 'short'
72
+ }).format(date);
73
+ },
74
+ formatUser(dashboard) {
75
+ return dashboard.createdBy?.name ||
76
+ dashboard.createdBy?.email ||
77
+ (dashboard.createdById ? dashboard.createdById.toString() : '-');
78
+ },
17
79
  toggleMenu(id) {
18
80
  this.openMenuId = this.openMenuId === id ? null : id;
19
81
  },
@@ -33,8 +95,19 @@ module.exports = app => app.component('dashboards', {
33
95
  this.$toast.success('Dashboard deleted!');
34
96
  },
35
97
  insertNewDashboard(dashboard) {
36
- this.dashboards.push(dashboard);
98
+ this.dashboards.unshift(dashboard);
37
99
  this.showCreateDashboardModal = false;
100
+ },
101
+ async togglePin(dashboard) {
102
+ if (!dashboard) {
103
+ return;
104
+ }
105
+
106
+ const { doc } = await api.Dashboard.updateDashboard({
107
+ dashboardId: dashboard._id,
108
+ isPinned: !dashboard.isPinned
109
+ });
110
+ dashboard.isPinned = doc.isPinned;
38
111
  }
39
112
  },
40
113
  directives: {
@@ -58,3 +131,25 @@ module.exports = app => app.component('dashboards', {
58
131
  this.status = 'loaded';
59
132
  }
60
133
  });
134
+
135
+ function escapeHtml(value) {
136
+ return value.toString().
137
+ replace(/&/g, '&amp;').
138
+ replace(/</g, '&lt;').
139
+ replace(/>/g, '&gt;').
140
+ replace(/"/g, '&quot;').
141
+ replace(/'/g, '&#39;');
142
+ }
143
+
144
+ function escapeRegExp(value) {
145
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
146
+ }
147
+
148
+ function getSearchWords(searchText) {
149
+ return Array.from(new Set(searchText.trim().split(/\s+/).filter(Boolean))).
150
+ sort((a, b) => b.length - a.length);
151
+ }
152
+
153
+ function sortPinnedFirst(dashboards) {
154
+ return dashboards.slice().sort((a, b) => Number(Boolean(b.isPinned)) - Number(Boolean(a.isPinned)));
155
+ }
@@ -3,23 +3,107 @@
3
3
  Empty array
4
4
  </div>
5
5
 
6
- <div v-else class="mt-2">
6
+ <div v-else>
7
+ <div class="mb-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
8
+ <div class="relative min-w-0 flex-1">
9
+ <input
10
+ v-model="arraySearchQuery"
11
+ type="text"
12
+ autocomplete="off"
13
+ aria-label="Search in this array"
14
+ placeholder="Search in this array…"
15
+ class="w-full rounded-md border border-edge bg-surface py-1.5 pl-2 pr-8 text-xs text-content placeholder:text-content-tertiary focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
16
+ />
17
+ <button
18
+ v-if="arraySearchNormalized"
19
+ type="button"
20
+ 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-100 hover:text-content"
21
+ title="Clear search"
22
+ @click="arraySearchQuery = ''">
23
+ Clear
24
+ </button>
25
+ </div>
26
+ <span v-if="arraySearchNormalized" class="shrink-0 text-xs text-content-tertiary">
27
+ {{ filteredArrayRows.length }} of {{ arrayValue.length }} shown
28
+ </span>
29
+ </div>
30
+
7
31
  <div
8
- v-for="(item, index) in arrayValue"
9
- :key="index"
10
- :title="'Index: ' + index"
11
- class="mb-1.5 last:mb-0 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 hover:shadow-lg hover:-translate-y-0.5">
12
- <div class="absolute -left-3 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 >= 1000 ? '1k+' : index }}</div>
13
- <div v-if="arrayUtils.isObjectItem(item)" class="flex flex-col gap-1 mt-1 px-2">
14
- <div
15
- v-for="key in arrayUtils.getItemKeys(item)"
16
- :key="key"
17
- class="flex items-start gap-2 text-xs font-mono">
18
- <span class="font-semibold text-gray-600 flex-shrink-0 min-w-[80px]">{{ key }}:</span>
19
- <span class="text-gray-800 break-words whitespace-pre-wrap flex-1">{{ arrayUtils.formatItemValue(item, key) }}</span>
32
+ v-if="filteredArrayRows.length === 0"
33
+ class="rounded-md border border-dashed border-edge py-6 text-center text-xs text-content-tertiary">
34
+ No items match "{{ arraySearchQuery.trim() }}".
35
+ </div>
36
+
37
+ <div v-else-if="showAsTable" class="mt-2 overflow-x-auto rounded-md border border-edge">
38
+ <table class="min-w-full text-left text-xs font-mono text-content">
39
+ <thead class="bg-slate-100 text-content-secondary border-b border-edge">
40
+ <tr>
41
+ <th scope="col" class="sticky left-0 z-[1] bg-slate-100 px-2 py-2 font-semibold border-r border-edge w-10 text-center">#</th>
42
+ <th
43
+ v-for="key in tableColumnKeys"
44
+ :key="key"
45
+ scope="col"
46
+ class="px-3 py-2 font-semibold whitespace-nowrap border-r border-edge last:border-r-0">
47
+ {{ key }}
48
+ </th>
49
+ </tr>
50
+ </thead>
51
+ <tbody class="divide-y divide-edge bg-surface">
52
+ <tr v-for="row in displayedArrayRows" :key="row.index" class="hover:bg-slate-50/80">
53
+ <td class="sticky left-0 z-[1] bg-inherit px-2 py-2 text-center text-content-tertiary border-r border-edge">{{ row.index }}</td>
54
+ <td
55
+ v-for="key in tableColumnKeys"
56
+ :key="key"
57
+ class="px-3 py-2 align-top break-words whitespace-pre-wrap max-w-md border-r border-edge last:border-r-0">
58
+ {{ Object.prototype.hasOwnProperty.call(row.item, key) ? arrayUtils.formatItemValue(row.item, key) : '' }}
59
+ </td>
60
+ </tr>
61
+ </tbody>
62
+ </table>
63
+ </div>
64
+
65
+ <div v-else class="mt-2">
66
+ <div
67
+ v-for="row in displayedArrayRows"
68
+ :key="row.index"
69
+ :title="'Index: ' + row.index"
70
+ class="mb-1.5 last:mb-0 py-2.5 px-3 pl-4 bg-transparent border-l-[3px] border-l-slate-100 rounded-none transition-all duration-200 cursor-pointer relative hover:bg-slate-50 hover:border-l-slate-100 hover:shadow-lg hover:-translate-y-0.5">
71
+ <div class="absolute -left-3 top-1/2 -translate-y-1/2 w-5 h-5 bg-slate-100 text-content rounded-full flex items-center justify-center text-[10px] font-semibold font-mono z-10 hover:bg-slate-100">{{ row.index >= 1000 ? '1k+' : row.index }}</div>
72
+ <div v-if="arrayUtils.isObjectItem(row.item)" class="flex flex-col gap-1 mt-1 px-2">
73
+ <div
74
+ v-for="key in arrayUtils.getItemKeys(row.item)"
75
+ :key="key"
76
+ class="flex items-start gap-2 text-xs font-mono">
77
+ <span class="font-semibold text-gray-600 flex-shrink-0 min-w-[80px]">{{ key }}:</span>
78
+ <span class="text-gray-800 break-words whitespace-pre-wrap flex-1">{{ arrayUtils.formatItemValue(row.item, key) }}</span>
79
+ </div>
20
80
  </div>
81
+ <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(row.item) }}</div>
82
+ </div>
83
+ </div>
84
+
85
+ <div
86
+ v-if="remainingArrayCount > 0"
87
+ class="mb-1.5 py-2.5 px-3 pl-4 bg-transparent border-none border-l-[3px] border-l-slate-100 rounded-none transition-all duration-200 cursor-pointer relative opacity-70 hover:opacity-100">
88
+ <div class="text-xs py-1.5 px-2 font-mono text-content-tertiary italic break-words whitespace-pre-wrap mt-1">
89
+ ... and {{ remainingArrayCount }} more item{{ remainingArrayCount !== 1 ? 's' : '' }}
21
90
  </div>
22
- <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>
23
91
  </div>
92
+
93
+ <button
94
+ v-if="canToggleRows"
95
+ @click="toggleExpansion"
96
+ 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"
97
+ >
98
+ <svg
99
+ class="w-4 h-4"
100
+ :class="{ 'rotate-180': isExpanded }"
101
+ fill="none"
102
+ stroke="currentColor"
103
+ viewBox="0 0 24 24">
104
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
105
+ </svg>
106
+ {{ expansionToggleLabel }}
107
+ </button>
24
108
  </div>
25
109
  </div>
@@ -1,16 +1,83 @@
1
1
  'use strict';
2
2
 
3
3
  const template = require('./detail-array.html');
4
+ const { isArrayOfObjects, unionKeysForArrayOfObjects, arrayItemMatchesSearch } = require('../array-utils');
4
5
 
5
6
  module.exports = app => app.component('detail-array', {
6
7
  template: template,
7
- props: ['value'],
8
+ props: {
9
+ value: {},
10
+ /** 'list' | 'table' — table applies to arrays of plain objects */
11
+ viewMode: {
12
+ type: String,
13
+ default: 'list'
14
+ },
15
+ truncate: {
16
+ type: Boolean,
17
+ default: false
18
+ },
19
+ initialLimit: {
20
+ type: Number,
21
+ default: 2
22
+ }
23
+ },
8
24
  data() {
9
25
  return {
10
- arrayValue: []
26
+ arrayValue: [],
27
+ arraySearchQuery: '',
28
+ isExpanded: false
11
29
  };
12
30
  },
31
+ computed: {
32
+ effectiveViewMode() {
33
+ return this.viewMode === 'table' ? 'table' : 'list';
34
+ },
35
+ showAsTable() {
36
+ return this.effectiveViewMode === 'table' && isArrayOfObjects(this.arrayValue);
37
+ },
38
+ tableColumnKeys() {
39
+ return unionKeysForArrayOfObjects(this.arrayValue);
40
+ },
41
+ arraySearchNormalized() {
42
+ return (this.arraySearchQuery || '').trim().toLowerCase();
43
+ },
44
+ filteredArrayRows() {
45
+ if (!Array.isArray(this.arrayValue)) {
46
+ return [];
47
+ }
48
+ return this.arrayValue
49
+ .map((item, index) => ({ item, index }))
50
+ .filter(({ item }) => arrayItemMatchesSearch(item, this.arraySearchNormalized));
51
+ },
52
+ displayedArrayRows() {
53
+ if (!this.shouldLimitRows) {
54
+ return this.filteredArrayRows;
55
+ }
56
+ return this.filteredArrayRows.slice(0, this.initialLimit);
57
+ },
58
+ shouldLimitRows() {
59
+ return this.truncate
60
+ && !this.isExpanded
61
+ && !this.arraySearchNormalized
62
+ && this.filteredArrayRows.length > this.initialLimit;
63
+ },
64
+ canToggleRows() {
65
+ return this.truncate && !this.arraySearchNormalized && this.arrayValue.length > this.initialLimit;
66
+ },
67
+ expansionToggleLabel() {
68
+ return this.isExpanded ? 'Show less' : `Show all ${this.arrayValue.length} items`;
69
+ },
70
+ remainingArrayCount() {
71
+ if (!this.shouldLimitRows) {
72
+ return 0;
73
+ }
74
+ return this.filteredArrayRows.length - this.displayedArrayRows.length;
75
+ }
76
+ },
13
77
  methods: {
78
+ toggleExpansion() {
79
+ this.isExpanded = !this.isExpanded;
80
+ },
14
81
  initializeArray() {
15
82
  if (this.value == null) {
16
83
  this.arrayValue = [];
@@ -28,9 +95,12 @@ module.exports = app => app.component('detail-array', {
28
95
  value: {
29
96
  handler(newValue) {
30
97
  this.initializeArray();
98
+ if (this.arrayValue.length <= this.initialLimit) {
99
+ this.isExpanded = false;
100
+ }
31
101
  },
32
102
  deep: true,
33
103
  immediate: true
34
104
  }
35
105
  }
36
- });
106
+ });
@@ -120,6 +120,13 @@
120
120
  >
121
121
  Copy Document
122
122
  </button>
123
+ <button
124
+ @click="openValidationModal()"
125
+ type="button"
126
+ class="flex items-center px-4 py-2 text-sm text-content-secondary hover:bg-green-100"
127
+ >
128
+ Validate
129
+ </button>
123
130
  <button
124
131
  @click="openScriptDrawer()"
125
132
  type="button"
@@ -222,6 +229,13 @@
222
229
  >
223
230
  Copy Document
224
231
  </button>
232
+ <button
233
+ @click="openValidationModal()"
234
+ type="button"
235
+ class="flex items-center px-4 py-2 text-sm text-content-secondary hover:bg-green-100"
236
+ >
237
+ Validate
238
+ </button>
225
239
  <button
226
240
  @click="openScriptDrawer()"
227
241
  type="button"
@@ -280,6 +294,43 @@
280
294
  <clone-document :currentModel="model" :doc="document" :schemaPaths="schemaPaths" @close="showClonedDocument"></clone-document>
281
295
  </template>
282
296
  </modal>
297
+ <modal v-if="shouldShowValidationModal">
298
+ <template v-slot:body>
299
+ <div class="modal-exit" @click="shouldShowValidationModal = false;">&times;</div>
300
+ <div class="text-xl font-bold mb-2">Validate Document</div>
301
+ <div v-if="!validationResult" class="space-y-3">
302
+ <div class="h-4 w-32 animate-pulse rounded bg-muted"></div>
303
+ <div class="h-20 w-full animate-pulse rounded bg-muted"></div>
304
+ </div>
305
+ <div v-else class="space-y-3">
306
+ <div
307
+ class="rounded px-3 py-2 text-sm font-semibold"
308
+ :class="validationResult.valid ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'"
309
+ >
310
+ {{ validationResult.valid ? 'Document is valid' : 'Document is invalid' }}
311
+ </div>
312
+ <div v-if="!validationResult.valid" class="rounded border border-edge bg-muted p-3 text-sm text-content-secondary">
313
+ {{ validationResult.error }}
314
+ </div>
315
+ <div v-if="validationResult?.errors && Object.keys(validationResult.errors).length > 0" class="rounded border border-edge">
316
+ <div class="border-b border-edge px-3 py-2 text-sm font-semibold text-content-secondary">Invalid Paths</div>
317
+ <div class="max-h-72 overflow-auto">
318
+ <div
319
+ v-for="[path, pathError] in Object.entries(validationResult.errors)"
320
+ :key="path"
321
+ class="grid gap-2 border-b border-edge px-3 py-2 text-sm last:border-b-0 md:grid-cols-[minmax(160px,220px)_1fr]"
322
+ >
323
+ <div class="font-mono text-content">{{ path }}</div>
324
+ <div class="text-content-secondary">
325
+ <div>{{ pathError.message }}</div>
326
+ <div v-if="pathError.kind" class="mt-1 text-xs text-content-tertiary">Kind: {{ pathError.kind }}</div>
327
+ </div>
328
+ </div>
329
+ </div>
330
+ </div>
331
+ </div>
332
+ </template>
333
+ </modal>
283
334
  </div>
284
335
  <execute-script
285
336
  :visible="scriptDrawerOpen"
@@ -26,6 +26,8 @@ module.exports = app => app.component('document', {
26
26
  shouldShowConfirmModal: false,
27
27
  shouldShowDeleteModal: false,
28
28
  shouldShowCloneModal: false,
29
+ shouldShowValidationModal: false,
30
+ validationResult: null,
29
31
  previousQuery: null,
30
32
  lastUpdatedAt: null,
31
33
  isRefreshing: false,
@@ -351,6 +353,17 @@ module.exports = app => app.component('document', {
351
353
  this.desktopMenuOpen = false;
352
354
  this.mobileMenuOpen = false;
353
355
  },
356
+ async openValidationModal() {
357
+ this.desktopMenuOpen = false;
358
+ this.mobileMenuOpen = false;
359
+ this.shouldShowValidationModal = true;
360
+ this.validationResult = null;
361
+ const { result } = await api.Model.validateDocument({
362
+ model: this.model,
363
+ documentId: this.documentId
364
+ });
365
+ this.validationResult = result;
366
+ },
354
367
  closeScriptDrawer() {
355
368
  this.scriptDrawerOpen = false;
356
369
  },
@@ -55,6 +55,7 @@
55
55
  :editting="editting"
56
56
  :changes="changes"
57
57
  :highlight="true"
58
+ :searchQuery="searchQuery"
58
59
  :invalid="invalid"></document-property>
59
60
  </div>
60
61
 
@@ -67,7 +68,7 @@
67
68
  <!-- Virtual Field Header (Always Visible) -->
68
69
  <div
69
70
  @click="toggleVirtualField(path.name)"
70
- class="p-3 bg-amber-100 hover:bg-amber-200 cursor-pointer flex items-center justify-between border-b border-edge transition-colors duration-200 ease-in-out"
71
+ class="p-3 bg-slate-100 hover:bg-muted cursor-pointer flex items-center justify-between border-b border-edge transition-colors duration-200 ease-in-out"
71
72
  >
72
73
  <div class="flex items-center">
73
74
  <svg
@@ -79,7 +80,12 @@
79
80
  >
80
81
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
81
82
  </svg>
82
- <span class="font-medium text-content">{{path.name}}</span>
83
+ <span class="font-medium text-content">
84
+ <span
85
+ v-for="(part, index) in getSearchMatchParts(path.name)"
86
+ :key="index"
87
+ :class="{ 'font-bold': part.matched }">{{ part.text }}</span>
88
+ </span>
83
89
  <span v-if="path.isVirtual" class="ml-2 text-sm text-purple-600">(virtual - {{getVirtualFieldType(path)}})</span>
84
90
  <span v-else class="ml-2 text-sm text-blue-600">(user-added - {{getVirtualFieldType(path)}})</span>
85
91
  </div>
@@ -109,6 +115,7 @@
109
115
  :editting="editting"
110
116
  :changes="changes"
111
117
  :highlight="false"
118
+ :searchQuery="searchQuery"
112
119
  :invalid="invalid"></document-property>
113
120
  </div>
114
121
 
@@ -121,7 +128,7 @@
121
128
  <!-- Virtual Field Header (Always Visible) -->
122
129
  <div
123
130
  @click="toggleVirtualField(path.name)"
124
- class="p-3 bg-page hover:bg-muted cursor-pointer flex items-center justify-between border-b border-edge transition-colors duration-200 ease-in-out"
131
+ class="p-3 bg-slate-100 hover:bg-muted cursor-pointer flex items-center justify-between border-b border-edge transition-colors duration-200 ease-in-out"
125
132
  >
126
133
  <div class="flex items-center">
127
134
  <svg