@mongoosejs/studio 0.2.9 → 0.2.11

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 (35) hide show
  1. package/backend/actions/Model/executeDocumentScript.js +61 -0
  2. package/backend/actions/Model/index.js +2 -0
  3. package/backend/actions/Model/listModels.js +36 -1
  4. package/backend/actions/Model/streamDocumentChanges.js +123 -0
  5. package/backend/actions/Task/cancelTask.js +24 -0
  6. package/backend/actions/Task/createTask.js +33 -0
  7. package/backend/actions/Task/getTasks.js +62 -0
  8. package/backend/actions/Task/index.js +7 -0
  9. package/backend/actions/Task/rescheduleTask.js +39 -0
  10. package/backend/actions/Task/runTask.js +25 -0
  11. package/backend/actions/index.js +1 -0
  12. package/backend/authorize.js +2 -0
  13. package/backend/index.js +7 -1
  14. package/eslint.config.js +4 -1
  15. package/express.js +4 -2
  16. package/frontend/public/app.js +14590 -13420
  17. package/frontend/public/tw.css +357 -4
  18. package/frontend/src/api.js +100 -0
  19. package/frontend/src/dashboard-result/dashboard-document/dashboard-document.html +4 -5
  20. package/frontend/src/dashboard-result/dashboard-document/dashboard-document.js +13 -14
  21. package/frontend/src/document/document.html +80 -0
  22. package/frontend/src/document/document.js +206 -19
  23. package/frontend/src/document/execute-script/execute-script.css +35 -0
  24. package/frontend/src/document/execute-script/execute-script.html +67 -0
  25. package/frontend/src/document/execute-script/execute-script.js +142 -0
  26. package/frontend/src/index.js +48 -4
  27. package/frontend/src/navbar/navbar.html +15 -2
  28. package/frontend/src/navbar/navbar.js +11 -0
  29. package/frontend/src/routes.js +13 -5
  30. package/frontend/src/tasks/task-details/task-details.html +284 -0
  31. package/frontend/src/tasks/task-details/task-details.js +182 -0
  32. package/frontend/src/tasks/tasks.css +0 -0
  33. package/frontend/src/tasks/tasks.html +220 -0
  34. package/frontend/src/tasks/tasks.js +372 -0
  35. package/package.json +4 -1
@@ -91,6 +91,9 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
91
91
  deleteDocuments(params) {
92
92
  return client.post('', { action: 'Model.deleteDocuments', ...params }).then(res => res.data);
93
93
  },
94
+ executeDocumentScript(params) {
95
+ return client.post('', { action: 'Model.executeDocumentScript', ...params }).then(res => res.data);
96
+ },
94
97
  exportQueryResults(params) {
95
98
  const accessToken = window.localStorage.getItem('_mongooseStudioAccessToken') || null;
96
99
 
@@ -132,6 +135,16 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
132
135
  yield { document: doc };
133
136
  }
134
137
  },
138
+ streamDocumentChanges: async function* streamDocumentChanges(params, options = {}) {
139
+ const pollIntervalMs = 5000;
140
+ while (!options.signal?.aborted) {
141
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
142
+ if (options.signal?.aborted) {
143
+ return;
144
+ }
145
+ yield { type: 'poll', model: params.model, documentId: params.documentId };
146
+ }
147
+ },
135
148
  getCollectionInfo: function getCollectionInfo(params) {
136
149
  return client.post('', { action: 'Model.getCollectionInfo', ...params }).then(res => res.data);
137
150
  },
@@ -159,6 +172,23 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
159
172
  return client.post('', { action: 'Model.updateDocuments', ...params }).then(res => res.data);
160
173
  }
161
174
  };
175
+ exports.Task = {
176
+ cancelTask: function cancelTask(params) {
177
+ return client.post('', { action: 'Task.cancelTask', ...params }).then(res => res.data);
178
+ },
179
+ createTask: function createTask(params) {
180
+ return client.post('', { action: 'Task.createTask', ...params }).then(res => res.data);
181
+ },
182
+ getTasks: function getTasks(params) {
183
+ return client.post('', { action: 'Task.getTasks', ...params }).then(res => res.data);
184
+ },
185
+ rescheduleTask: function rescheduleTask(params) {
186
+ return client.post('', { action: 'Task.rescheduleTask', ...params }).then(res => res.data);
187
+ },
188
+ runTask: function runTask(params) {
189
+ return client.post('', { action: 'Task.runTask', ...params }).then(res => res.data);
190
+ }
191
+ };
162
192
  } else {
163
193
  exports.status = function status() {
164
194
  return client.get('/status').then(res => res.data);
@@ -270,6 +300,9 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
270
300
  deleteDocuments: function(params) {
271
301
  return client.post('/Model/deleteDocuments', params).then(res => res.data);
272
302
  },
303
+ executeDocumentScript: function(params) {
304
+ return client.post('/Model/executeDocumentScript', params).then(res => res.data);
305
+ },
273
306
  exportQueryResults(params) {
274
307
  const accessToken = window.localStorage.getItem('_mongooseStudioAccessToken') || null;
275
308
 
@@ -352,6 +385,56 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
352
385
  }
353
386
  }
354
387
  },
388
+ streamDocumentChanges: async function* streamDocumentChanges(params, options = {}) {
389
+ const accessToken = window.localStorage.getItem('_mongooseStudioAccessToken') || null;
390
+ const url = window.MONGOOSE_STUDIO_CONFIG.baseURL + '/Model/streamDocumentChanges?' + new URLSearchParams(params).toString();
391
+
392
+ const response = await fetch(url, {
393
+ method: 'GET',
394
+ headers: {
395
+ Authorization: `${accessToken}`,
396
+ Accept: 'text/event-stream'
397
+ },
398
+ signal: options.signal
399
+ });
400
+
401
+ if (!response.ok) {
402
+ throw new Error(`HTTP error! Status: ${response.status}`);
403
+ }
404
+
405
+ const reader = response.body.getReader();
406
+ const decoder = new TextDecoder('utf-8');
407
+ let buffer = '';
408
+
409
+ while (true) {
410
+ const { done, value } = await reader.read();
411
+ if (done) break;
412
+ buffer += decoder.decode(value, { stream: true });
413
+
414
+ let eventEnd;
415
+ while ((eventEnd = buffer.indexOf('\n\n')) !== -1) {
416
+ const eventStr = buffer.slice(0, eventEnd);
417
+ buffer = buffer.slice(eventEnd + 2);
418
+
419
+ // Parse SSE event
420
+ const lines = eventStr.split('\n');
421
+ let data = '';
422
+ for (const line of lines) {
423
+ if (line.startsWith('data:')) {
424
+ data += line.slice(5).trim();
425
+ }
426
+ }
427
+ if (data) {
428
+ try {
429
+ yield JSON.parse(data);
430
+ } catch (err) {
431
+ // If not JSON, yield as string
432
+ yield data;
433
+ }
434
+ }
435
+ }
436
+ }
437
+ },
355
438
  getCollectionInfo: function getCollectionInfo(params) {
356
439
  return client.post('/Model/getCollectionInfo', params).then(res => res.data);
357
440
  },
@@ -420,4 +503,21 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
420
503
  return client.post('/Model/updateDocuments', params).then(res => res.data);
421
504
  }
422
505
  };
506
+ exports.Task = {
507
+ cancelTask: function cancelTask(params) {
508
+ return client.post('/Task/cancelTask', params).then(res => res.data);
509
+ },
510
+ createTask: function createTask(params) {
511
+ return client.post('/Task/createTask', params).then(res => res.data);
512
+ },
513
+ getTasks: function getTasks(params) {
514
+ return client.post('/Task/getTasks', params).then(res => res.data);
515
+ },
516
+ rescheduleTask: function rescheduleTask(params) {
517
+ return client.post('/Task/rescheduleTask', params).then(res => res.data);
518
+ },
519
+ runTask: function runTask(params) {
520
+ return client.post('/Task/runTask', params).then(res => res.data);
521
+ }
522
+ };
423
523
  }
@@ -1,8 +1,7 @@
1
1
  <div class="py-2">
2
- <div v-if="header" class="border-b border-gray-100 px-2 pb-2 text-xl font-bold">
3
- {{header}}
4
- </div>
5
2
  <div class="text-xl pb-2">
6
- <document-details :document="value.$document.document" :schemaPaths="schemaPaths" />
3
+ <list-json
4
+ :value="value.$document.document"
5
+ :references="references" />
7
6
  </div>
8
- </div>
7
+ </div>
@@ -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
- header() {
12
- if (this.value != null && this.value.$document.header) {
13
- return this.value.$document.header;
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,20 @@
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>
123
+ <button
124
+ @click="openScriptDrawer()"
125
+ type="button"
126
+ class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-indigo-100"
127
+ >
128
+ Run Script
129
+ </button>
87
130
  <button
88
131
  @click="shouldShowDeleteModal=true; desktopMenuOpen = false"
89
132
  :disabled="!canManipulate"
@@ -150,6 +193,21 @@
150
193
  >
151
194
  Save
152
195
  </button>
196
+ <button
197
+ @click="refreshDocument({ force: true, source: 'manual' }); mobileMenuOpen = false"
198
+ :disabled="isRefreshing"
199
+ :class="['flex items-center px-4 py-2 text-sm text-gray-700', isRefreshing ? 'cursor-not-allowed opacity-50' : 'hover:bg-slate-100']"
200
+ type="button"
201
+ >
202
+ {{isRefreshing ? 'Refreshing...' : 'Refresh'}}
203
+ </button>
204
+ <button
205
+ @click="toggleAutoRefresh(); mobileMenuOpen = false"
206
+ type="button"
207
+ class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-slate-100"
208
+ >
209
+ Auto-Refresh {{autoRefreshEnabled ? 'ON' : 'OFF'}}
210
+ </button>
153
211
  <button
154
212
  @click="addField(); mobileMenuOpen = false"
155
213
  type="button"
@@ -157,6 +215,20 @@
157
215
  >
158
216
  Add Field
159
217
  </button>
218
+ <button
219
+ @click="copyDocument(); mobileMenuOpen = false"
220
+ type="button"
221
+ class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-blue-100"
222
+ >
223
+ Copy Document
224
+ </button>
225
+ <button
226
+ @click="openScriptDrawer()"
227
+ type="button"
228
+ class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-indigo-100"
229
+ >
230
+ Run Script
231
+ </button>
160
232
  <button
161
233
  @click="shouldShowDeleteModal=true; mobileMenuOpen = false"
162
234
  :disabled="!canManipulate"
@@ -209,5 +281,13 @@
209
281
  </template>
210
282
  </modal>
211
283
  </div>
284
+ <execute-script
285
+ :visible="scriptDrawerOpen"
286
+ :model="model"
287
+ :document-id="documentId"
288
+ :editting="editting"
289
+ @close="closeScriptDrawer"
290
+ @refresh="handleScriptRefresh"
291
+ ></execute-script>
212
292
  </div>
213
293
  </div>
@@ -26,30 +26,26 @@ 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,
39
+ scriptDrawerOpen: false
30
40
  }),
31
41
  async mounted() {
32
42
  window.pageState = this;
33
43
  // Store query parameters from the route (preserved from models page)
34
44
  this.previousQuery = Object.assign({}, this.$route.query);
35
- try {
36
- const { doc, schemaPaths, virtualPaths } = await api.Model.getDocument({ model: this.model, documentId: this.documentId });
37
- window.doc = doc;
38
- this.document = doc;
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
- }
45
+ await this.refreshDocument({ force: true, source: 'initial' });
46
+ },
47
+ beforeDestroy() {
48
+ this.stopAutoRefresh();
53
49
  },
54
50
  computed: {
55
51
  canManipulate() {
@@ -61,8 +57,32 @@ module.exports = app => app.component('document', {
61
57
  }
62
58
  return !this.roles.includes('readonly');
63
59
  },
60
+ lastUpdatedLabel() {
61
+ if (!this.lastUpdatedAt) {
62
+ return '—';
63
+ }
64
+ try {
65
+ return new Date(this.lastUpdatedAt).toLocaleTimeString([], {
66
+ hour: '2-digit',
67
+ minute: '2-digit',
68
+ second: '2-digit'
69
+ });
70
+ } catch (err) {
71
+ return '—';
72
+ }
73
+ },
64
74
  canEdit() {
65
75
  return this.canManipulate && this.viewMode === 'fields';
76
+ },
77
+ isLambda() {
78
+ return !!window?.MONGOOSE_STUDIO_CONFIG?.isLambda;
79
+ }
80
+ },
81
+ watch: {
82
+ editting(nextValue) {
83
+ if (!nextValue && this.pendingRefresh) {
84
+ this.refreshDocument({ source: 'pending' });
85
+ }
66
86
  }
67
87
  },
68
88
  methods: {
@@ -70,6 +90,125 @@ module.exports = app => app.component('document', {
70
90
  this.changes = {};
71
91
  this.editting = false;
72
92
  },
93
+ async refreshDocument(options = {}) {
94
+ const { force = false, source = 'manual' } = options;
95
+ if (this.editting && !force) {
96
+ this.pendingRefresh = true;
97
+ return;
98
+ }
99
+ if (this.isRefreshing) {
100
+ this.pendingRefresh = true;
101
+ return;
102
+ }
103
+
104
+ const isInitial = this.status === 'init';
105
+ if (isInitial) {
106
+ this.status = 'loading';
107
+ }
108
+ this.isRefreshing = true;
109
+ this.autoRefreshError = null;
110
+ try {
111
+ const { doc, schemaPaths, virtualPaths } = await api.Model.getDocument({
112
+ model: this.model,
113
+ documentId: this.documentId
114
+ });
115
+ window.doc = doc;
116
+ this.document = doc;
117
+ this.schemaPaths = Object.keys(schemaPaths).sort((k1, k2) => {
118
+ if (k1 === '_id' && k2 !== '_id') {
119
+ return -1;
120
+ }
121
+ if (k1 !== '_id' && k2 === '_id') {
122
+ return 1;
123
+ }
124
+ return 0;
125
+ }).map(key => schemaPaths[key]);
126
+ this.virtualPaths = virtualPaths || [];
127
+ this.lastUpdatedAt = new Date();
128
+ this.pendingRefresh = false;
129
+ } catch (err) {
130
+ console.error('Error refreshing document:', err);
131
+ if (this.$toast && source !== 'initial') {
132
+ this.$toast.error('Failed to refresh document');
133
+ }
134
+ } finally {
135
+ this.status = 'loaded';
136
+ this.isRefreshing = false;
137
+ if (this.pendingRefresh && !this.editting) {
138
+ this.pendingRefresh = false;
139
+ this.refreshDocument({ source: 'pending' });
140
+ }
141
+ }
142
+ },
143
+ toggleAutoRefresh() {
144
+ if (this.autoRefreshEnabled) {
145
+ this.stopAutoRefresh();
146
+ } else {
147
+ this.startAutoRefresh();
148
+ }
149
+ },
150
+ startAutoRefresh() {
151
+ if (this.autoRefreshEnabled) {
152
+ return;
153
+ }
154
+ this.autoRefreshEnabled = true;
155
+ this.autoRefreshError = null;
156
+ this.runAutoRefreshLoop();
157
+ },
158
+ stopAutoRefresh() {
159
+ this.autoRefreshEnabled = false;
160
+ this.autoRefreshConnecting = false;
161
+ if (this.autoRefreshAbortController) {
162
+ this.autoRefreshAbortController.abort();
163
+ this.autoRefreshAbortController = null;
164
+ }
165
+ if (this.autoRefreshRetryTimer) {
166
+ clearTimeout(this.autoRefreshRetryTimer);
167
+ this.autoRefreshRetryTimer = null;
168
+ }
169
+ },
170
+ async runAutoRefreshLoop() {
171
+ if (this.autoRefreshLoopRunning) {
172
+ return;
173
+ }
174
+ this.autoRefreshLoopRunning = true;
175
+ this.autoRefreshAbortController = new AbortController();
176
+ let retryDelay = 1500;
177
+
178
+ while (this.autoRefreshEnabled && !this.autoRefreshAbortController.signal.aborted) {
179
+ try {
180
+ this.autoRefreshConnecting = true;
181
+ for await (const event of api.Model.streamDocumentChanges({
182
+ model: this.model,
183
+ documentId: this.documentId
184
+ }, { signal: this.autoRefreshAbortController.signal })) {
185
+ this.autoRefreshConnecting = false;
186
+ if (!event || event.type === 'heartbeat') {
187
+ continue;
188
+ }
189
+ await this.refreshDocument({ source: 'auto' });
190
+ }
191
+ } catch (err) {
192
+ if (this.autoRefreshAbortController.signal.aborted) {
193
+ break;
194
+ }
195
+ this.autoRefreshError = err?.message || String(err);
196
+ } finally {
197
+ this.autoRefreshConnecting = false;
198
+ }
199
+
200
+ if (!this.autoRefreshEnabled || this.autoRefreshAbortController.signal.aborted) {
201
+ break;
202
+ }
203
+
204
+ await new Promise(resolve => {
205
+ this.autoRefreshRetryTimer = setTimeout(resolve, retryDelay);
206
+ });
207
+ retryDelay = Math.min(retryDelay * 2, 15000);
208
+ }
209
+
210
+ this.autoRefreshLoopRunning = false;
211
+ },
73
212
  async save() {
74
213
  if (Object.keys(this.invalid).length > 0) {
75
214
  throw new Error('Invalid paths: ' + Object.keys(this.invalid).join(', '));
@@ -137,12 +276,60 @@ module.exports = app => app.component('document', {
137
276
  this.changes = {};
138
277
  }
139
278
  },
279
+ copyDocument() {
280
+ if (!this.document) {
281
+ return;
282
+ }
283
+
284
+ const textToCopy = JSON.stringify(this.document, null, 2);
285
+ const fallbackCopy = () => {
286
+ if (typeof document === 'undefined') {
287
+ return;
288
+ }
289
+ const textArea = document.createElement('textarea');
290
+ textArea.value = textToCopy;
291
+ textArea.setAttribute('readonly', '');
292
+ textArea.style.position = 'absolute';
293
+ textArea.style.left = '-9999px';
294
+ document.body.appendChild(textArea);
295
+ textArea.select();
296
+ try {
297
+ document.execCommand('copy');
298
+ } finally {
299
+ document.body.removeChild(textArea);
300
+ }
301
+ this.$toast.success('Document copied!');
302
+ };
303
+
304
+ if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
305
+ navigator.clipboard.writeText(textToCopy)
306
+ .then(() => {
307
+ this.$toast.success('Document copied!');
308
+ })
309
+ .catch(() => {
310
+ fallbackCopy();
311
+ });
312
+ } else {
313
+ fallbackCopy();
314
+ }
315
+ },
140
316
  goBack() {
141
317
  // Preserve query parameters when going back to models page
142
318
  this.$router.push({
143
319
  path: '/model/' + this.model,
144
320
  query: this.previousQuery || {}
145
321
  });
322
+ },
323
+ openScriptDrawer() {
324
+ this.scriptDrawerOpen = true;
325
+ this.desktopMenuOpen = false;
326
+ this.mobileMenuOpen = false;
327
+ },
328
+ closeScriptDrawer() {
329
+ this.scriptDrawerOpen = false;
330
+ },
331
+ handleScriptRefresh() {
332
+ this.refreshDocument({ force: true, source: 'script' });
146
333
  }
147
334
  }
148
335
  });
@@ -0,0 +1,35 @@
1
+ .execute-script-backdrop {
2
+ animation: execute-script-fade-in 200ms ease forwards;
3
+ }
4
+
5
+ .execute-script-panel {
6
+ animation: execute-script-slide-in 200ms ease forwards;
7
+ }
8
+
9
+ .execute-script-drawer.is-closing .execute-script-backdrop {
10
+ animation: execute-script-fade-out 200ms ease forwards;
11
+ }
12
+
13
+ .execute-script-drawer.is-closing .execute-script-panel {
14
+ animation: execute-script-slide-out 200ms ease forwards;
15
+ }
16
+
17
+ @keyframes execute-script-fade-in {
18
+ from { opacity: 0; }
19
+ to { opacity: 1; }
20
+ }
21
+
22
+ @keyframes execute-script-fade-out {
23
+ from { opacity: 1; }
24
+ to { opacity: 0; }
25
+ }
26
+
27
+ @keyframes execute-script-slide-in {
28
+ from { transform: translateX(100%); }
29
+ to { transform: translateX(0); }
30
+ }
31
+
32
+ @keyframes execute-script-slide-out {
33
+ from { transform: translateX(0); }
34
+ to { transform: translateX(100%); }
35
+ }
@@ -0,0 +1,67 @@
1
+ <div v-if="isOpen" class="fixed inset-0 z-50 execute-script-drawer" :class="{ 'is-closing': isClosing }">
2
+ <div class="execute-script-backdrop absolute inset-0 bg-black/40" @click="close"></div>
3
+ <div class="execute-script-panel absolute right-0 top-0 h-full w-full max-w-xl bg-white shadow-xl flex flex-col">
4
+ <div class="flex items-center justify-between px-5 py-4 border-b border-gray-200">
5
+ <div>
6
+ <h2 class="text-lg font-semibold text-gray-900">Run Script</h2>
7
+ <p class="text-xs text-gray-500">Use <code class="bg-gray-100 px-1 rounded">doc</code> for this document.</p>
8
+ </div>
9
+ <button
10
+ type="button"
11
+ @click="close"
12
+ class="text-gray-400 hover:text-gray-600 text-2xl leading-none"
13
+ aria-label="Close drawer"
14
+ >
15
+ &times;
16
+ </button>
17
+ </div>
18
+ <div class="flex-1 overflow-auto p-5 space-y-4">
19
+ <div class="space-y-2">
20
+ <label class="text-sm font-medium text-gray-700">Script</label>
21
+ <textarea
22
+ ref="scriptEditor"
23
+ :value="scriptText"
24
+ @input="updateScriptText"
25
+ rows="8"
26
+ placeholder="await doc.updateName('new name')&#10;return doc"
27
+ class="w-full rounded-md border border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 font-mono text-sm"
28
+ ></textarea>
29
+ <p class="text-xs text-gray-500">Return a value to see it in the output panel.</p>
30
+ </div>
31
+ <div class="flex items-center gap-3">
32
+ <button
33
+ type="button"
34
+ @click="runDocumentScript"
35
+ :disabled="scriptRunning"
36
+ class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:opacity-50 disabled:cursor-not-allowed"
37
+ >
38
+ <span v-if="scriptRunning">Running...</span>
39
+ <span v-else>Run script</span>
40
+ </button>
41
+ <button
42
+ type="button"
43
+ @click="clearScript"
44
+ class="text-sm text-gray-500 hover:text-gray-700"
45
+ >
46
+ Clear
47
+ </button>
48
+ </div>
49
+ <div class="space-y-3">
50
+ <div v-if="scriptError" class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700">
51
+ {{scriptError}}
52
+ </div>
53
+ <div v-if="scriptHasRun" class="rounded-md border border-gray-200 bg-gray-50 p-3">
54
+ <div class="text-xs uppercase tracking-wide text-gray-500 mb-2">Output</div>
55
+ <pre class="whitespace-pre-wrap text-sm text-gray-800">{{formatScriptOutput(scriptResult)}}</pre>
56
+ </div>
57
+ <div v-if="scriptLogs" class="rounded-md border border-gray-200 bg-white p-3">
58
+ <div class="text-xs uppercase tracking-wide text-gray-500 mb-2">Logs</div>
59
+ <pre class="whitespace-pre-wrap text-xs text-gray-700">{{scriptLogs}}</pre>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ <div class="border-t border-gray-200 px-5 py-3 text-xs text-gray-500">
64
+ Scripts run on the server with access to the Mongoose document instance.
65
+ </div>
66
+ </div>
67
+ </div>