@mongoosejs/studio 0.5.4 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/actions/Dashboard/createDashboard.js +12 -2
- package/backend/actions/Dashboard/getDashboard.js +6 -2
- package/backend/actions/Dashboard/getDashboards.js +3 -1
- package/backend/actions/Dashboard/updateDashboard.js +19 -6
- package/backend/actions/Model/analyzeSchema.js +202 -0
- package/backend/actions/Model/index.js +2 -0
- package/backend/actions/Model/validateDocument.js +35 -0
- package/backend/authorize.js +2 -0
- package/backend/db/dashboardSchema.js +31 -0
- package/backend/helpers/validateDocumentWithTimeout.js +39 -0
- package/backend/netlify.js +1 -0
- package/backend/next.js +1 -0
- package/express.js +1 -0
- package/frontend/public/app.js +859 -102
- package/frontend/public/images/pin-off.svg +1 -0
- package/frontend/public/images/pin.svg +1 -0
- package/frontend/public/tw.css +249 -28
- package/frontend/src/api.js +12 -0
- package/frontend/src/array-utils.js +60 -2
- package/frontend/src/dashboards/dashboards.html +52 -4
- package/frontend/src/dashboards/dashboards.js +96 -1
- package/frontend/src/detail-array/detail-array.html +98 -14
- package/frontend/src/detail-array/detail-array.js +73 -3
- package/frontend/src/document/document.html +51 -0
- package/frontend/src/document/document.js +13 -0
- package/frontend/src/document-details/document-details.html +10 -3
- package/frontend/src/document-details/document-details.js +28 -0
- package/frontend/src/document-details/document-property/document-property.html +36 -44
- package/frontend/src/document-details/document-property/document-property.js +78 -14
- package/frontend/src/edit-array/edit-array.html +143 -8
- package/frontend/src/edit-array/edit-array.js +104 -2
- package/frontend/src/models/analyze-schema-modal/analyze-schema-modal.html +169 -0
- package/frontend/src/models/analyze-schema-modal/analyze-schema-modal.js +149 -0
- package/frontend/src/models/models.html +25 -13
- package/frontend/src/models/models.js +5 -13
- package/package.json +2 -1
|
@@ -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
|
|
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-
|
|
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
|
-
|
|
73
|
+
<span v-html="highlightTitle(dashboard.title)"></span>
|
|
52
74
|
</router-link>
|
|
53
|
-
<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.
|
|
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, '&').
|
|
138
|
+
replace(/</g, '<').
|
|
139
|
+
replace(/>/g, '>').
|
|
140
|
+
replace(/"/g, '"').
|
|
141
|
+
replace(/'/g, ''');
|
|
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
|
|
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-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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:
|
|
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;">×</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-
|
|
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">
|
|
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-
|
|
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
|