@mongoosejs/studio 0.2.8 → 0.2.10
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/getDashboard.js +1 -1
- package/backend/actions/Model/index.js +1 -0
- package/backend/actions/Model/listModels.js +36 -1
- package/backend/actions/Model/streamDocumentChanges.js +123 -0
- package/backend/authorize.js +1 -0
- package/backend/index.js +7 -1
- package/backend/next.js +63 -3
- package/eslint.config.js +3 -1
- package/express.js +3 -2
- package/frontend/public/app.js +282 -39
- package/frontend/public/tw.css +46 -0
- package/frontend/src/api.js +60 -0
- package/frontend/src/dashboard-result/dashboard-document/dashboard-document.html +4 -5
- package/frontend/src/dashboard-result/dashboard-document/dashboard-document.js +13 -14
- package/frontend/src/document/document.html +58 -0
- package/frontend/src/document/document.js +194 -19
- package/frontend/src/index.js +12 -3
- package/package.json +1 -1
|
@@ -7,23 +7,22 @@ const template = require('./dashboard-document.html');
|
|
|
7
7
|
module.exports = app => app.component('dashboard-document', {
|
|
8
8
|
template: template,
|
|
9
9
|
props: ['value'],
|
|
10
|
+
inject: ['state'],
|
|
10
11
|
computed: {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
references() {
|
|
13
|
+
const model = this.value.$document?.model;
|
|
14
|
+
if (typeof model === 'string' && this.state.modelSchemaPaths?.[model]) {
|
|
15
|
+
const map = {};
|
|
16
|
+
for (const path of Object.keys(this.state.modelSchemaPaths[model])) {
|
|
17
|
+
const definition = this.state.modelSchemaPaths[model][path];
|
|
18
|
+
if (definition?.ref) {
|
|
19
|
+
map[path] = definition.ref;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return map;
|
|
14
23
|
}
|
|
24
|
+
|
|
15
25
|
return null;
|
|
16
|
-
},
|
|
17
|
-
schemaPaths() {
|
|
18
|
-
return Object.keys(this.value?.$document?.schemaPaths || {}).sort((k1, k2) => {
|
|
19
|
-
if (k1 === '_id' && k2 !== '_id') {
|
|
20
|
-
return -1;
|
|
21
|
-
}
|
|
22
|
-
if (k1 !== '_id' && k2 === '_id') {
|
|
23
|
-
return 1;
|
|
24
|
-
}
|
|
25
|
-
return 0;
|
|
26
|
-
}).map(key => this.value?.$document.schemaPaths[key]);
|
|
27
26
|
}
|
|
28
27
|
}
|
|
29
28
|
});
|
|
@@ -29,6 +29,18 @@
|
|
|
29
29
|
</button>
|
|
30
30
|
</div>
|
|
31
31
|
|
|
32
|
+
<div class="hidden md:flex items-center gap-3 text-sm text-slate-600">
|
|
33
|
+
<div class="text-right leading-tight">
|
|
34
|
+
<div class="text-xs uppercase tracking-wide text-slate-400 flex items-center gap-2 justify-end">
|
|
35
|
+
<span>Loaded at</span>
|
|
36
|
+
<span class="inline-flex items-center gap-1 text-[10px] font-semibold" :class="autoRefreshEnabled ? 'text-forest-green-600' : 'text-slate-400'">
|
|
37
|
+
<span class="inline-block h-1.5 w-1.5 rounded-full" :class="autoRefreshEnabled ? 'bg-forest-green-500' : 'bg-slate-300'"></span>
|
|
38
|
+
</span>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="font-medium text-slate-700">{{lastUpdatedLabel}}</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
32
44
|
<div class="gap-2 hidden md:flex items-center">
|
|
33
45
|
<button
|
|
34
46
|
v-if="!editting"
|
|
@@ -77,6 +89,23 @@
|
|
|
77
89
|
class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-50"
|
|
78
90
|
>
|
|
79
91
|
<div class="py-1 flex flex-col">
|
|
92
|
+
<button
|
|
93
|
+
@click="refreshDocument({ force: true, source: 'manual' }); desktopMenuOpen = false"
|
|
94
|
+
:disabled="isRefreshing"
|
|
95
|
+
:class="['flex items-center px-4 py-2 text-sm text-gray-700', isRefreshing ? 'cursor-not-allowed opacity-50' : 'hover:bg-slate-100']"
|
|
96
|
+
type="button"
|
|
97
|
+
>
|
|
98
|
+
{{isRefreshing ? 'Refreshing...' : 'Refresh'}}
|
|
99
|
+
</button>
|
|
100
|
+
<button
|
|
101
|
+
@click="toggleAutoRefresh(); desktopMenuOpen = false"
|
|
102
|
+
:disabled="isLambda"
|
|
103
|
+
type="button"
|
|
104
|
+
:class="['flex items-center px-4 py-2 text-sm', isLambda ? 'text-gray-400 cursor-not-allowed' : 'text-gray-700 hover:bg-slate-100']"
|
|
105
|
+
:title="isLambda ? 'Auto-refresh only available on Express deployments' : ''"
|
|
106
|
+
>
|
|
107
|
+
{{autoRefreshEnabled ? 'Disable Auto-Refresh' : 'Enable Auto-Refresh'}}
|
|
108
|
+
</button>
|
|
80
109
|
<button
|
|
81
110
|
@click="addField(); desktopMenuOpen = false"
|
|
82
111
|
type="button"
|
|
@@ -84,6 +113,13 @@
|
|
|
84
113
|
>
|
|
85
114
|
Add Field
|
|
86
115
|
</button>
|
|
116
|
+
<button
|
|
117
|
+
@click="copyDocument(); desktopMenuOpen = false"
|
|
118
|
+
type="button"
|
|
119
|
+
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-blue-100"
|
|
120
|
+
>
|
|
121
|
+
Copy Document
|
|
122
|
+
</button>
|
|
87
123
|
<button
|
|
88
124
|
@click="shouldShowDeleteModal=true; desktopMenuOpen = false"
|
|
89
125
|
:disabled="!canManipulate"
|
|
@@ -150,6 +186,21 @@
|
|
|
150
186
|
>
|
|
151
187
|
Save
|
|
152
188
|
</button>
|
|
189
|
+
<button
|
|
190
|
+
@click="refreshDocument({ force: true, source: 'manual' }); mobileMenuOpen = false"
|
|
191
|
+
:disabled="isRefreshing"
|
|
192
|
+
:class="['flex items-center px-4 py-2 text-sm text-gray-700', isRefreshing ? 'cursor-not-allowed opacity-50' : 'hover:bg-slate-100']"
|
|
193
|
+
type="button"
|
|
194
|
+
>
|
|
195
|
+
{{isRefreshing ? 'Refreshing...' : 'Refresh'}}
|
|
196
|
+
</button>
|
|
197
|
+
<button
|
|
198
|
+
@click="toggleAutoRefresh(); mobileMenuOpen = false"
|
|
199
|
+
type="button"
|
|
200
|
+
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-slate-100"
|
|
201
|
+
>
|
|
202
|
+
Auto-Refresh {{autoRefreshEnabled ? 'ON' : 'OFF'}}
|
|
203
|
+
</button>
|
|
153
204
|
<button
|
|
154
205
|
@click="addField(); mobileMenuOpen = false"
|
|
155
206
|
type="button"
|
|
@@ -157,6 +208,13 @@
|
|
|
157
208
|
>
|
|
158
209
|
Add Field
|
|
159
210
|
</button>
|
|
211
|
+
<button
|
|
212
|
+
@click="copyDocument(); mobileMenuOpen = false"
|
|
213
|
+
type="button"
|
|
214
|
+
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-blue-100"
|
|
215
|
+
>
|
|
216
|
+
Copy Document
|
|
217
|
+
</button>
|
|
160
218
|
<button
|
|
161
219
|
@click="shouldShowDeleteModal=true; mobileMenuOpen = false"
|
|
162
220
|
:disabled="!canManipulate"
|
|
@@ -26,30 +26,25 @@ module.exports = app => app.component('document', {
|
|
|
26
26
|
shouldShowConfirmModal: false,
|
|
27
27
|
shouldShowDeleteModal: false,
|
|
28
28
|
shouldShowCloneModal: false,
|
|
29
|
-
previousQuery: null
|
|
29
|
+
previousQuery: null,
|
|
30
|
+
lastUpdatedAt: null,
|
|
31
|
+
isRefreshing: false,
|
|
32
|
+
autoRefreshEnabled: false,
|
|
33
|
+
autoRefreshConnecting: false,
|
|
34
|
+
autoRefreshLoopRunning: false,
|
|
35
|
+
autoRefreshError: null,
|
|
36
|
+
autoRefreshAbortController: null,
|
|
37
|
+
autoRefreshRetryTimer: null,
|
|
38
|
+
pendingRefresh: false
|
|
30
39
|
}),
|
|
31
40
|
async mounted() {
|
|
32
41
|
window.pageState = this;
|
|
33
42
|
// Store query parameters from the route (preserved from models page)
|
|
34
43
|
this.previousQuery = Object.assign({}, this.$route.query);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
this.schemaPaths = Object.keys(schemaPaths).sort((k1, k2) => {
|
|
40
|
-
if (k1 === '_id' && k2 !== '_id') {
|
|
41
|
-
return -1;
|
|
42
|
-
}
|
|
43
|
-
if (k1 !== '_id' && k2 === '_id') {
|
|
44
|
-
return 1;
|
|
45
|
-
}
|
|
46
|
-
return 0;
|
|
47
|
-
}).map(key => schemaPaths[key]);
|
|
48
|
-
this.virtualPaths = virtualPaths || [];
|
|
49
|
-
this.status = 'loaded';
|
|
50
|
-
} finally {
|
|
51
|
-
this.status = 'loaded';
|
|
52
|
-
}
|
|
44
|
+
await this.refreshDocument({ force: true, source: 'initial' });
|
|
45
|
+
},
|
|
46
|
+
beforeDestroy() {
|
|
47
|
+
this.stopAutoRefresh();
|
|
53
48
|
},
|
|
54
49
|
computed: {
|
|
55
50
|
canManipulate() {
|
|
@@ -61,8 +56,32 @@ module.exports = app => app.component('document', {
|
|
|
61
56
|
}
|
|
62
57
|
return !this.roles.includes('readonly');
|
|
63
58
|
},
|
|
59
|
+
lastUpdatedLabel() {
|
|
60
|
+
if (!this.lastUpdatedAt) {
|
|
61
|
+
return '—';
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
return new Date(this.lastUpdatedAt).toLocaleTimeString([], {
|
|
65
|
+
hour: '2-digit',
|
|
66
|
+
minute: '2-digit',
|
|
67
|
+
second: '2-digit'
|
|
68
|
+
});
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return '—';
|
|
71
|
+
}
|
|
72
|
+
},
|
|
64
73
|
canEdit() {
|
|
65
74
|
return this.canManipulate && this.viewMode === 'fields';
|
|
75
|
+
},
|
|
76
|
+
isLambda() {
|
|
77
|
+
return !!window?.MONGOOSE_STUDIO_CONFIG?.isLambda;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
watch: {
|
|
81
|
+
editting(nextValue) {
|
|
82
|
+
if (!nextValue && this.pendingRefresh) {
|
|
83
|
+
this.refreshDocument({ source: 'pending' });
|
|
84
|
+
}
|
|
66
85
|
}
|
|
67
86
|
},
|
|
68
87
|
methods: {
|
|
@@ -70,6 +89,125 @@ module.exports = app => app.component('document', {
|
|
|
70
89
|
this.changes = {};
|
|
71
90
|
this.editting = false;
|
|
72
91
|
},
|
|
92
|
+
async refreshDocument(options = {}) {
|
|
93
|
+
const { force = false, source = 'manual' } = options;
|
|
94
|
+
if (this.editting && !force) {
|
|
95
|
+
this.pendingRefresh = true;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (this.isRefreshing) {
|
|
99
|
+
this.pendingRefresh = true;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const isInitial = this.status === 'init';
|
|
104
|
+
if (isInitial) {
|
|
105
|
+
this.status = 'loading';
|
|
106
|
+
}
|
|
107
|
+
this.isRefreshing = true;
|
|
108
|
+
this.autoRefreshError = null;
|
|
109
|
+
try {
|
|
110
|
+
const { doc, schemaPaths, virtualPaths } = await api.Model.getDocument({
|
|
111
|
+
model: this.model,
|
|
112
|
+
documentId: this.documentId
|
|
113
|
+
});
|
|
114
|
+
window.doc = doc;
|
|
115
|
+
this.document = doc;
|
|
116
|
+
this.schemaPaths = Object.keys(schemaPaths).sort((k1, k2) => {
|
|
117
|
+
if (k1 === '_id' && k2 !== '_id') {
|
|
118
|
+
return -1;
|
|
119
|
+
}
|
|
120
|
+
if (k1 !== '_id' && k2 === '_id') {
|
|
121
|
+
return 1;
|
|
122
|
+
}
|
|
123
|
+
return 0;
|
|
124
|
+
}).map(key => schemaPaths[key]);
|
|
125
|
+
this.virtualPaths = virtualPaths || [];
|
|
126
|
+
this.lastUpdatedAt = new Date();
|
|
127
|
+
this.pendingRefresh = false;
|
|
128
|
+
} catch (err) {
|
|
129
|
+
console.error('Error refreshing document:', err);
|
|
130
|
+
if (this.$toast && source !== 'initial') {
|
|
131
|
+
this.$toast.error('Failed to refresh document');
|
|
132
|
+
}
|
|
133
|
+
} finally {
|
|
134
|
+
this.status = 'loaded';
|
|
135
|
+
this.isRefreshing = false;
|
|
136
|
+
if (this.pendingRefresh && !this.editting) {
|
|
137
|
+
this.pendingRefresh = false;
|
|
138
|
+
this.refreshDocument({ source: 'pending' });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
toggleAutoRefresh() {
|
|
143
|
+
if (this.autoRefreshEnabled) {
|
|
144
|
+
this.stopAutoRefresh();
|
|
145
|
+
} else {
|
|
146
|
+
this.startAutoRefresh();
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
startAutoRefresh() {
|
|
150
|
+
if (this.autoRefreshEnabled) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
this.autoRefreshEnabled = true;
|
|
154
|
+
this.autoRefreshError = null;
|
|
155
|
+
this.runAutoRefreshLoop();
|
|
156
|
+
},
|
|
157
|
+
stopAutoRefresh() {
|
|
158
|
+
this.autoRefreshEnabled = false;
|
|
159
|
+
this.autoRefreshConnecting = false;
|
|
160
|
+
if (this.autoRefreshAbortController) {
|
|
161
|
+
this.autoRefreshAbortController.abort();
|
|
162
|
+
this.autoRefreshAbortController = null;
|
|
163
|
+
}
|
|
164
|
+
if (this.autoRefreshRetryTimer) {
|
|
165
|
+
clearTimeout(this.autoRefreshRetryTimer);
|
|
166
|
+
this.autoRefreshRetryTimer = null;
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
async runAutoRefreshLoop() {
|
|
170
|
+
if (this.autoRefreshLoopRunning) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
this.autoRefreshLoopRunning = true;
|
|
174
|
+
this.autoRefreshAbortController = new AbortController();
|
|
175
|
+
let retryDelay = 1500;
|
|
176
|
+
|
|
177
|
+
while (this.autoRefreshEnabled && !this.autoRefreshAbortController.signal.aborted) {
|
|
178
|
+
try {
|
|
179
|
+
this.autoRefreshConnecting = true;
|
|
180
|
+
for await (const event of api.Model.streamDocumentChanges({
|
|
181
|
+
model: this.model,
|
|
182
|
+
documentId: this.documentId
|
|
183
|
+
}, { signal: this.autoRefreshAbortController.signal })) {
|
|
184
|
+
this.autoRefreshConnecting = false;
|
|
185
|
+
if (!event || event.type === 'heartbeat') {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
await this.refreshDocument({ source: 'auto' });
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
if (this.autoRefreshAbortController.signal.aborted) {
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
this.autoRefreshError = err?.message || String(err);
|
|
195
|
+
} finally {
|
|
196
|
+
this.autoRefreshConnecting = false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!this.autoRefreshEnabled || this.autoRefreshAbortController.signal.aborted) {
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
await new Promise(resolve => {
|
|
204
|
+
this.autoRefreshRetryTimer = setTimeout(resolve, retryDelay);
|
|
205
|
+
});
|
|
206
|
+
retryDelay = Math.min(retryDelay * 2, 15000);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
this.autoRefreshLoopRunning = false;
|
|
210
|
+
},
|
|
73
211
|
async save() {
|
|
74
212
|
if (Object.keys(this.invalid).length > 0) {
|
|
75
213
|
throw new Error('Invalid paths: ' + Object.keys(this.invalid).join(', '));
|
|
@@ -137,6 +275,43 @@ module.exports = app => app.component('document', {
|
|
|
137
275
|
this.changes = {};
|
|
138
276
|
}
|
|
139
277
|
},
|
|
278
|
+
copyDocument() {
|
|
279
|
+
if (!this.document) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const textToCopy = JSON.stringify(this.document, null, 2);
|
|
284
|
+
const fallbackCopy = () => {
|
|
285
|
+
if (typeof document === 'undefined') {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const textArea = document.createElement('textarea');
|
|
289
|
+
textArea.value = textToCopy;
|
|
290
|
+
textArea.setAttribute('readonly', '');
|
|
291
|
+
textArea.style.position = 'absolute';
|
|
292
|
+
textArea.style.left = '-9999px';
|
|
293
|
+
document.body.appendChild(textArea);
|
|
294
|
+
textArea.select();
|
|
295
|
+
try {
|
|
296
|
+
document.execCommand('copy');
|
|
297
|
+
} finally {
|
|
298
|
+
document.body.removeChild(textArea);
|
|
299
|
+
}
|
|
300
|
+
this.$toast.success('Document copied!');
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
|
|
304
|
+
navigator.clipboard.writeText(textToCopy)
|
|
305
|
+
.then(() => {
|
|
306
|
+
this.$toast.success('Document copied!');
|
|
307
|
+
})
|
|
308
|
+
.catch(() => {
|
|
309
|
+
fallbackCopy();
|
|
310
|
+
});
|
|
311
|
+
} else {
|
|
312
|
+
fallbackCopy();
|
|
313
|
+
}
|
|
314
|
+
},
|
|
140
315
|
goBack() {
|
|
141
316
|
// Preserve query parameters when going back to models page
|
|
142
317
|
this.$router.push({
|
package/frontend/src/index.js
CHANGED
|
@@ -130,8 +130,12 @@ app.component('app-component', {
|
|
|
130
130
|
const { user, roles } = await mothership.me();
|
|
131
131
|
|
|
132
132
|
try {
|
|
133
|
-
const { nodeEnv } = await
|
|
133
|
+
const [{ nodeEnv }, { modelSchemaPaths }] = await Promise.all([
|
|
134
|
+
api.status(),
|
|
135
|
+
api.Model.listModels()
|
|
136
|
+
]);
|
|
134
137
|
this.nodeEnv = nodeEnv;
|
|
138
|
+
this.modelSchemaPaths = modelSchemaPaths;
|
|
135
139
|
} catch (err) {
|
|
136
140
|
this.authError = 'Error connecting to Mongoose Studio API: ' + (err.response?.data?.message ?? err.message);
|
|
137
141
|
this.status = 'loaded';
|
|
@@ -144,8 +148,12 @@ app.component('app-component', {
|
|
|
144
148
|
}
|
|
145
149
|
} else {
|
|
146
150
|
try {
|
|
147
|
-
const { nodeEnv } = await
|
|
151
|
+
const [{ nodeEnv }, { modelSchemaPaths }] = await Promise.all([
|
|
152
|
+
api.status(),
|
|
153
|
+
api.Model.listModels()
|
|
154
|
+
]);
|
|
148
155
|
this.nodeEnv = nodeEnv;
|
|
156
|
+
this.modelSchemaPaths = modelSchemaPaths;
|
|
149
157
|
} catch (err) {
|
|
150
158
|
this.authError = 'Error connecting to Mongoose Studio API: ' + (err.response?.data?.message ?? err.message);
|
|
151
159
|
}
|
|
@@ -159,8 +167,9 @@ app.component('app-component', {
|
|
|
159
167
|
const status = Vue.ref('init');
|
|
160
168
|
const nodeEnv = Vue.ref(null);
|
|
161
169
|
const authError = Vue.ref(null);
|
|
170
|
+
const modelSchemaPaths = Vue.ref(null);
|
|
162
171
|
|
|
163
|
-
const state = Vue.reactive({ user, roles, status, nodeEnv, authError });
|
|
172
|
+
const state = Vue.reactive({ user, roles, status, nodeEnv, authError, modelSchemaPaths });
|
|
164
173
|
Vue.provide('state', state);
|
|
165
174
|
|
|
166
175
|
return state;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mongoosejs/studio",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"description": "A Mongoose-native MongoDB UI with schema-aware autocomplete, AI-assisted queries, and dashboards that understand your models - not just your data.",
|
|
5
5
|
"homepage": "https://mongoosestudio.app/",
|
|
6
6
|
"repository": {
|