@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.
@@ -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,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
- 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
- }
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({
@@ -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 api.status();
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 api.status();
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.8",
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": {