@mongoosejs/studio 0.5.1 → 0.5.3

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 (42) hide show
  1. package/backend/actions/ChatThread/streamChatMessage.js +12 -12
  2. package/backend/actions/Dashboard/getDashboard.js +29 -2
  3. package/backend/actions/Model/dropCollection.js +29 -0
  4. package/backend/actions/Model/index.js +1 -0
  5. package/backend/authorize.js +1 -0
  6. package/backend/chatAgent/agentSystemPrompt.js +22 -10
  7. package/backend/chatAgent/getAgentTools.js +19 -8
  8. package/backend/chatAgent/runChatAgent.js +17 -0
  9. package/backend/db/dashboardSchema.js +0 -32
  10. package/backend/index.js +4 -1
  11. package/backend/integrations/callLLM.js +5 -20
  12. package/backend/integrations/streamLLM.js +5 -24
  13. package/backend/sandbox/createSandbox.js +6 -0
  14. package/backend/vercel.js +3 -0
  15. package/debugging.js +11 -0
  16. package/eval/agent/cases.js +168 -0
  17. package/eval/agent/fixtures/carsharing.js +147 -0
  18. package/eval/agent/fixtures/ecommerce.js +152 -0
  19. package/eval/agent/run.js +323 -0
  20. package/frontend/index.js +2 -1
  21. package/frontend/public/app.js +581 -203
  22. package/frontend/public/tw.css +81 -0
  23. package/frontend/src/_util/recent-pages-history.js +116 -0
  24. package/frontend/src/api.js +96 -164
  25. package/frontend/src/chat/chat-message-script/chat-message-script.html +1 -6
  26. package/frontend/src/chat/chat.html +1 -1
  27. package/frontend/src/document/confirm-changes/confirm-changes.js +20 -0
  28. package/frontend/src/document/document.html +2 -2
  29. package/frontend/src/index.js +148 -1
  30. package/frontend/src/json-node/json-node.html +41 -39
  31. package/frontend/src/json-node/json-node.js +38 -0
  32. package/frontend/src/list-json/list-json.html +3 -2
  33. package/frontend/src/list-json/list-json.js +5 -0
  34. package/frontend/src/modal/modal.css +26 -1
  35. package/frontend/src/modal/modal.html +2 -2
  36. package/frontend/src/modal/modal.js +5 -1
  37. package/frontend/src/models/models.html +45 -5
  38. package/frontend/src/models/models.js +26 -0
  39. package/frontend/src/navbar/navbar.html +92 -5
  40. package/frontend/src/navbar/navbar.js +69 -1
  41. package/index.js +2 -1
  42. package/package.json +3 -2
@@ -6,7 +6,7 @@
6
6
  <button
7
7
  @click="viewMode = 'fields'"
8
8
  :class="viewMode === 'fields'
9
- ? 'bg-blue-600 text-white z-10'
9
+ ? 'bg-primary text-white z-10'
10
10
  : 'bg-gray-200 text-content-secondary hover:bg-gray-300'"
11
11
  class="px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 flex items-center gap-2 border border-edge-strong border-r-0 rounded-l-lg rounded-r-none"
12
12
  >
@@ -18,7 +18,7 @@
18
18
  <button
19
19
  @click="viewMode = 'json'"
20
20
  :class="viewMode === 'json'
21
- ? 'bg-blue-600 text-white z-10'
21
+ ? 'bg-primary text-white z-10'
22
22
  : 'bg-gray-200 text-content-secondary hover:bg-gray-300'"
23
23
  class="px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 flex items-center gap-2 border border-edge-strong rounded-r-lg rounded-l-none"
24
24
  >
@@ -15,12 +15,143 @@ const { routes, hasAccess } = require('./routes');
15
15
  const Toast = require('vue-toastification').default;
16
16
  const { useToast } = require('vue-toastification');
17
17
  const appendCSS = require('./appendCSS');
18
+ const {
19
+ safeReadRecentPages,
20
+ saveRecentPages,
21
+ MAX_RECENT_PAGES,
22
+ normalizeTrackedPath,
23
+ withRecentPagesStorageLock
24
+ } = require('./_util/recent-pages-history');
18
25
  appendCSS(require('vue-toastification/dist/index.css'));
19
26
 
27
+ const TRACKED_RECENT_PAGE_ROUTE_NAMES = new Set(['model', 'document', 'dashboard', 'chat']);
28
+
29
+ function formatHistoryLabel(route) {
30
+ if (!route || typeof route.path !== 'string') {
31
+ return 'Unknown page';
32
+ }
33
+ if (route.name === 'model') {
34
+ return route.params?.model ? `Model: ${route.params.model}` : 'Model';
35
+ }
36
+ if (route.name === 'document') {
37
+ const model = route.params?.model ? `${route.params.model} ` : '';
38
+ const documentId = route.params?.documentId ? String(route.params.documentId) : '';
39
+ const shortDocumentId = documentId ? documentId.slice(0, 24) : '';
40
+ return `Document: ${model}${shortDocumentId}`.trim();
41
+ }
42
+ if (route.name === 'dashboard') {
43
+ return route.params?.dashboardId ? `Dashboard: ${route.params.dashboardId}` : 'Dashboard';
44
+ }
45
+ if (route.name === 'chat') {
46
+ return route.params?.threadId ? `Chat: ${route.params.threadId}` : 'Chat';
47
+ }
48
+ const normalizedPath = route.path.replace(/^\//, '');
49
+ return normalizedPath || 'Home';
50
+ }
51
+
52
+ function trackRecentPage(route) {
53
+ if (!route || typeof route.path !== 'string') {
54
+ return;
55
+ }
56
+ if (!TRACKED_RECENT_PAGE_ROUTE_NAMES.has(route.name)) {
57
+ return;
58
+ }
59
+ if (typeof route.path === 'string' && route.path.includes('code=')) {
60
+ return;
61
+ }
62
+ const path = normalizeTrackedPath(route.fullPath || route.path || '/');
63
+ const label = formatHistoryLabel(route);
64
+ const visitedAt = Date.now();
65
+
66
+ void withRecentPagesStorageLock(() => {
67
+ const existing = safeReadRecentPages();
68
+ const deduped = existing.filter(entry => normalizeTrackedPath(entry.path) !== path);
69
+ const next = [...deduped, { path, label, visitedAt }]
70
+ .sort((a, b) => b.visitedAt - a.visitedAt)
71
+ .slice(0, MAX_RECENT_PAGES);
72
+ saveRecentPages(next);
73
+ });
74
+ }
75
+
20
76
  const app = Vue.createApp({
21
77
  template: '<app-component />'
22
78
  });
23
79
 
80
+ window.drawArrowTo = function drawArrowTo(elementOrSelector) {
81
+ const element = typeof elementOrSelector === 'string' ?
82
+ document.querySelector(elementOrSelector) :
83
+ elementOrSelector;
84
+
85
+ if (!element || typeof element.getBoundingClientRect !== 'function') {
86
+ throw new Error('window.drawArrowTo() requires a DOM element or selector');
87
+ }
88
+
89
+ const existingArrow = document.getElementById('mongoose-studio-screenshot-arrow');
90
+ if (existingArrow) {
91
+ existingArrow.remove();
92
+ }
93
+
94
+ const rect = element.getBoundingClientRect();
95
+ const arrowLength = 140;
96
+ const arrowRise = 80;
97
+ const centerX = rect.left + rect.width / 2;
98
+ const centerY = rect.top + rect.height / 2;
99
+ const startX = Math.max(24, Math.min(window.innerWidth - 24, centerX - arrowLength));
100
+ const startY = Math.max(24, Math.min(window.innerHeight - 24, centerY - arrowRise));
101
+ const targetX = Math.max(rect.left, Math.min(rect.right, startX));
102
+ const targetY = Math.max(rect.top, Math.min(rect.bottom, startY));
103
+ const controlX = startX + (targetX - startX) * 0.6;
104
+ const controlY = startY + (targetY - startY) * 0.15;
105
+ const svgNS = 'http://www.w3.org/2000/svg';
106
+ const svg = document.createElementNS(svgNS, 'svg');
107
+ const defs = document.createElementNS(svgNS, 'defs');
108
+ const marker = document.createElementNS(svgNS, 'marker');
109
+ const markerPath = document.createElementNS(svgNS, 'path');
110
+ const path = document.createElementNS(svgNS, 'path');
111
+
112
+ svg.setAttribute('id', 'mongoose-studio-screenshot-arrow');
113
+ svg.setAttribute('viewBox', `0 0 ${window.innerWidth} ${window.innerHeight}`);
114
+ svg.setAttribute('width', '100vw');
115
+ svg.setAttribute('height', '100vh');
116
+ svg.style.position = 'fixed';
117
+ svg.style.left = '0';
118
+ svg.style.top = '0';
119
+ svg.style.zIndex = '2147483647';
120
+ svg.style.pointerEvents = 'none';
121
+
122
+ marker.setAttribute('id', 'mongoose-studio-screenshot-arrow-head');
123
+ marker.setAttribute('markerWidth', '14');
124
+ marker.setAttribute('markerHeight', '14');
125
+ marker.setAttribute('refX', '12');
126
+ marker.setAttribute('refY', '7');
127
+ marker.setAttribute('orient', 'auto');
128
+ marker.setAttribute('markerUnits', 'userSpaceOnUse');
129
+
130
+ markerPath.setAttribute('d', 'M 0 0 L 14 7 L 0 14 z');
131
+ markerPath.setAttribute('fill', '#dc2626');
132
+
133
+ path.setAttribute('d', `M ${startX} ${startY} Q ${controlX} ${controlY} ${targetX} ${targetY}`);
134
+ path.setAttribute('fill', 'none');
135
+ path.setAttribute('stroke', '#dc2626');
136
+ path.setAttribute('stroke-width', '5');
137
+ path.setAttribute('stroke-linecap', 'round');
138
+ path.setAttribute('stroke-linejoin', 'round');
139
+ path.setAttribute('marker-end', 'url(#mongoose-studio-screenshot-arrow-head)');
140
+
141
+ marker.appendChild(markerPath);
142
+ defs.appendChild(marker);
143
+ svg.appendChild(defs);
144
+ svg.appendChild(path);
145
+ document.body.appendChild(svg);
146
+
147
+ return {
148
+ element: svg,
149
+ remove() {
150
+ svg.remove();
151
+ }
152
+ };
153
+ };
154
+
24
155
  // https://github.com/Maronato/vue-toastification/tree/main?tab=readme-ov-file#toast-types
25
156
  app.use(Toast, { position: 'bottom-right', timeout: 3000 });
26
157
 
@@ -169,7 +300,14 @@ app.component('app-component', {
169
300
  const authError = Vue.ref(null);
170
301
  const modelSchemaPaths = Vue.ref(null);
171
302
 
172
- const state = Vue.reactive({ user, roles, status, nodeEnv, authError, modelSchemaPaths });
303
+ const state = Vue.reactive({
304
+ user,
305
+ roles,
306
+ status,
307
+ nodeEnv,
308
+ authError,
309
+ modelSchemaPaths
310
+ });
173
311
  Vue.provide('state', state);
174
312
 
175
313
  return state;
@@ -221,6 +359,11 @@ router.beforeEach((to, from, next) => {
221
359
  });
222
360
 
223
361
  router.beforeEach((to, from, next) => {
362
+ if (['chat index', 'chat'].includes(to.name) && !window.MONGOOSE_STUDIO_CONFIG.hasLLMAPIKey) {
363
+ next({ name: 'root' });
364
+ return;
365
+ }
366
+
224
367
  if (to.name === 'root' && window.state.roles && window.state.roles[0] === 'dashboards') {
225
368
  return next({ name: 'dashboards' });
226
369
  } else {
@@ -228,6 +371,10 @@ router.beforeEach((to, from, next) => {
228
371
  }
229
372
  });
230
373
 
374
+ router.afterEach((to) => {
375
+ trackRecentPage(to);
376
+ });
377
+
231
378
  app.config.globalProperties = { format, arrayUtils, $toast: toast };
232
379
  app.use(router);
233
380
 
@@ -1,5 +1,5 @@
1
- <div>
2
- <div class="flex items-baseline whitespace-pre" :style="indentStyle">
1
+ <div class="min-w-0 max-w-full">
2
+ <div class="flex min-w-0 max-w-full items-baseline whitespace-pre-wrap break-words" :style="indentStyle">
3
3
  <button
4
4
  v-if="showToggle"
5
5
  type="button"
@@ -23,36 +23,32 @@
23
23
  </template>
24
24
  </template>
25
25
  <template v-else>
26
- <!--
27
- If value is a string and overflows its container (i.e. goes over one line), show an ellipsis.
28
- This is done via CSS ellipsis strategy.
29
- -->
30
26
  <span
31
27
  v-if="shouldShowReferenceLink"
32
- class="inline-flex items-baseline group"
28
+ class="group inline-flex min-w-0 max-w-full items-baseline flex-wrap gap-x-1.5"
33
29
  >
34
- <span
35
- :class="[...valueClasses, 'underline', 'decoration-dotted', 'underline-offset-2']"
36
- :style="typeof value === 'string'
37
- ? {
38
- display: 'inline-block',
39
- maxWidth: '100%',
40
- overflow: 'hidden',
41
- textOverflow: 'ellipsis',
42
- whiteSpace: 'nowrap',
43
- verticalAlign: 'bottom'
44
- }
45
- : {}"
46
- :title="typeof value === 'string' && $el && $el.scrollWidth > $el.clientWidth ? value : undefined"
47
- >
48
- {{ formattedValue }}
49
- </span>
50
- <span>
51
- {{ comma }}
30
+ <span class="min-w-0 max-w-full">
31
+ <span
32
+ :class="[...valueClasses, ...stringWrapperClasses, 'underline', 'decoration-dotted', 'underline-offset-2']"
33
+ :style="isStringValue ? { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' } : {}"
34
+ :title="shouldTruncateString && !isStringExpanded ? value : undefined"
35
+ >
36
+ {{ displayedValue }}
37
+ </span><template v-if="!(shouldTruncateString && !isStringExpanded)">{{ comma }}</template>
52
38
  </span>
39
+ <button
40
+ v-if="shouldTruncateString"
41
+ type="button"
42
+ class="inline-flex shrink-0 items-center gap-1 rounded-full border border-edge bg-surface px-2 py-0.5 text-[11px] font-medium text-content-secondary shadow-sm transition-colors hover:bg-muted hover:text-content focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
43
+ :title="stringToggleLabel"
44
+ :aria-expanded="isStringExpanded"
45
+ @click.stop="toggleStringExpansion"
46
+ >
47
+ <span class="leading-none">{{ stringToggleLabel }}</span>
48
+ </button>
53
49
  <a
54
50
  href="#"
55
- class="ml-1 text-sm text-primary opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
51
+ class="ml-1 shrink-0 text-sm text-primary opacity-0 transition-opacity group-hover:opacity-100 focus:opacity-100"
56
52
  @click.stop.prevent="goToReference()"
57
53
  >
58
54
  View Document
@@ -60,20 +56,25 @@
60
56
  </span>
61
57
  <span
62
58
  v-else
63
- :class="valueClasses"
64
- :style="typeof value === 'string'
65
- ? {
66
- display: 'inline-block',
67
- maxWidth: '100%',
68
- overflow: 'hidden',
69
- textOverflow: 'ellipsis',
70
- whiteSpace: 'nowrap',
71
- verticalAlign: 'bottom'
72
- }
73
- : {}"
74
- :title="typeof value === 'string' && $el && $el.scrollWidth > $el.clientWidth ? value : undefined"
59
+ class="inline-flex min-w-0 max-w-full items-baseline flex-wrap gap-x-1.5"
75
60
  >
76
- {{ formattedValue }}{{ comma }}
61
+ <span
62
+ :class="[...valueClasses, ...stringWrapperClasses]"
63
+ :style="isStringValue ? { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' } : {}"
64
+ :title="shouldTruncateString && !isStringExpanded ? value : undefined"
65
+ >
66
+ {{ displayedValue }}<template v-if="!(shouldTruncateString && !isStringExpanded)">{{ comma }}</template>
67
+ </span>
68
+ <button
69
+ v-if="shouldTruncateString"
70
+ type="button"
71
+ class="inline-flex shrink-0 items-center gap-1 rounded-full border border-edge bg-surface px-2 py-0.5 text-[11px] font-medium text-content-secondary shadow-sm transition-colors hover:bg-muted hover:text-content focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
72
+ :title="stringToggleLabel"
73
+ :aria-expanded="isStringExpanded"
74
+ @click.stop="toggleStringExpansion"
75
+ >
76
+ <span class="leading-none">{{ stringToggleLabel }}</span>
77
+ </button>
77
78
  </span>
78
79
  </template>
79
80
  </div>
@@ -94,6 +95,7 @@
94
95
  :top-level-expanded="topLevelExpanded"
95
96
  :expand-top-level="expandTopLevel"
96
97
  :references="references"
98
+ :max-string-length="maxStringLength"
97
99
  ></json-node>
98
100
  <div
99
101
  v-if="hasHiddenRootChildren"
@@ -56,8 +56,17 @@ module.exports = app => app.component('json-node', {
56
56
  references: {
57
57
  type: Object,
58
58
  default: () => ({})
59
+ },
60
+ maxStringLength: {
61
+ type: Number,
62
+ default: 200
59
63
  }
60
64
  },
65
+ data() {
66
+ return {
67
+ isStringExpanded: false
68
+ };
69
+ },
61
70
  computed: {
62
71
  hasKey() {
63
72
  return this.nodeKey !== null && this.nodeKey !== undefined;
@@ -151,6 +160,29 @@ module.exports = app => app.component('json-node', {
151
160
  }
152
161
  return stringified;
153
162
  },
163
+ isStringValue() {
164
+ return typeof this.value === 'string';
165
+ },
166
+ shouldTruncateString() {
167
+ return this.isStringValue && this.formattedValue.length > this.maxStringLength;
168
+ },
169
+ displayedValue() {
170
+ if (!this.shouldTruncateString || this.isStringExpanded) {
171
+ return this.formattedValue;
172
+ }
173
+ const keep = Math.max(0, this.maxStringLength - 1);
174
+ return `${this.formattedValue.slice(0, keep)}…`;
175
+ },
176
+ stringWrapperClasses() {
177
+ const classes = ['min-w-0', 'max-w-full', 'break-words'];
178
+ if (this.shouldTruncateString && this.isStringExpanded) {
179
+ classes.push('rounded-md', 'border', 'border-edge', 'bg-muted/60', 'px-2', 'py-1');
180
+ }
181
+ return classes;
182
+ },
183
+ stringToggleLabel() {
184
+ return this.isStringExpanded ? 'Show less' : 'Show more';
185
+ },
154
186
  valueClasses() {
155
187
  const classes = ['text-slate-700'];
156
188
  if (this.value === null) {
@@ -267,6 +299,12 @@ module.exports = app => app.component('json-node', {
267
299
  return;
268
300
  }
269
301
  this.$router.push({ path: `/model/${this.referenceModel}/document/${id}` });
302
+ },
303
+ toggleStringExpansion() {
304
+ if (!this.shouldTruncateString) {
305
+ return;
306
+ }
307
+ this.isStringExpanded = !this.isStringExpanded;
270
308
  }
271
309
  }
272
310
  });
@@ -1,5 +1,5 @@
1
- <div class="tooltip w-full font-mono text-sm py-1 text-slate-800">
2
- <div class="w-full">
1
+ <div class="tooltip w-full min-w-0 max-w-full font-mono text-sm py-1 text-slate-800">
2
+ <div class="w-full min-w-0 max-w-full">
3
3
  <json-node
4
4
  :node-key="null"
5
5
  :value="value"
@@ -14,6 +14,7 @@
14
14
  :top-level-expanded="topLevelExpanded"
15
15
  :expand-top-level="expandTopLevel"
16
16
  :references="references"
17
+ :max-string-length="maxStringLength"
17
18
  ></json-node>
18
19
  </div>
19
20
  </div>
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const template = require('./list-json.html');
4
+ const DEFAULT_MAX_STRING_LENGTH = 200;
4
5
 
5
6
  module.exports = app => app.component('list-json', {
6
7
  template: template,
@@ -19,6 +20,10 @@ module.exports = app => app.component('list-json', {
19
20
  expandedFields: {
20
21
  type: Array,
21
22
  default: () => []
23
+ },
24
+ maxStringLength: {
25
+ type: Number,
26
+ default: DEFAULT_MAX_STRING_LENGTH
22
27
  }
23
28
  },
24
29
  data() {
@@ -2,7 +2,7 @@
2
2
 
3
3
  .modal-mask {
4
4
  position: fixed;
5
- z-index: 9998;
5
+ z-index: 10050;
6
6
  top: 0;
7
7
  left: 0;
8
8
  width: 100%;
@@ -30,6 +30,11 @@
30
30
  position: relative;
31
31
  }
32
32
 
33
+ .modal-container.modal-container--flush {
34
+ padding: 0;
35
+ padding-bottom: 0;
36
+ }
37
+
33
38
  .modal-header {
34
39
  margin-top: 0;
35
40
  font-size: 18px;
@@ -50,6 +55,25 @@
50
55
  overflow: auto;
51
56
  }
52
57
 
58
+ /* Recent pages: one scroll region — inner .modal-recent-pages-scroll only (avoids double scrollbars with .modal-body overflow). */
59
+ .modal-body.modal-recent-pages-body {
60
+ box-sizing: border-box;
61
+ display: flex;
62
+ flex-direction: column;
63
+ margin: 0;
64
+ height: min(60vh, 22.5rem);
65
+ max-height: min(60vh, 22.5rem);
66
+ overflow: hidden;
67
+ }
68
+
69
+ .modal-body.modal-recent-pages-body .modal-recent-pages-scroll {
70
+ flex: 1 1 0;
71
+ min-height: 0;
72
+ overflow-x: hidden;
73
+ overflow-y: auto;
74
+ -webkit-overflow-scrolling: touch;
75
+ }
76
+
53
77
  .modal__button--default {
54
78
  float: right;
55
79
  }
@@ -79,6 +103,7 @@
79
103
 
80
104
  .modal-container .modal-exit {
81
105
  position: absolute;
106
+ z-index: 2;
82
107
  right: 0.25em;
83
108
  top: 0.25em;
84
109
  cursor: pointer;
@@ -1,8 +1,8 @@
1
1
  <transition name="modal">
2
2
  <div class="modal-mask">
3
3
  <div class="modal-wrapper">
4
- <div class="modal-container" :class="containerClass">
5
- <div class="modal-body">
4
+ <div class="modal-container" :class="[containerClass, { 'modal-container--flush': flushContainer }]">
5
+ <div class="modal-body" :class="bodyClass">
6
6
  <slot name="body">
7
7
  </slot>
8
8
  </div>
@@ -7,7 +7,11 @@ appendCSS(require('./modal.css'));
7
7
 
8
8
  module.exports = app => app.component('modal', {
9
9
  template,
10
- props: ['containerClass'],
10
+ props: {
11
+ containerClass: String,
12
+ bodyClass: String,
13
+ flushContainer: Boolean
14
+ },
11
15
  mounted() {
12
16
  window.addEventListener('keydown', this.onEscape);
13
17
  },
@@ -117,7 +117,7 @@
117
117
  @click="stagingSelect"
118
118
  type="button"
119
119
  :class="{
120
- 'bg-gray-700 ring-inset ring-2 ring-gray-300 hover:bg-gray-200 text-content-secondary': selectMultiple,
120
+ 'bg-gray-700 ring-inset ring-2 ring-gray-300 text-white hover:bg-gray-600': selectMultiple,
121
121
  'bg-primary hover:bg-primary-hover text-primary-text': !selectMultiple
122
122
  }"
123
123
  class="rounded px-2 py-2 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
@@ -190,6 +190,13 @@
190
190
  >
191
191
  Find oldest document
192
192
  </button>
193
+ <button
194
+ @click="openDropCollectionModal"
195
+ type="button"
196
+ class="block w-full px-4 py-2 text-left text-sm text-red-700 hover:bg-red-50"
197
+ >
198
+ Drop Collection
199
+ </button>
193
200
  <button
194
201
  @click="toggleRowNumbers()"
195
202
  type="button"
@@ -404,8 +411,10 @@
404
411
  v-for="(document, docIndex) in documents"
405
412
  :key="document._id"
406
413
  @click="handleDocumentClick(document, $event)"
407
- class="border-b border-edge cursor-pointer transition-colors hover:bg-muted/60"
408
- :class="{ 'bg-primary-subtle/50 hover:bg-primary-subtle/70': selectedDocuments.some(x => x._id.toString() === document._id.toString()) }"
414
+ class="border-b border-edge cursor-pointer transition-colors"
415
+ :class="selectedDocuments.some(x => x._id.toString() === document._id.toString())
416
+ ? 'bg-blue-200'
417
+ : 'hover:bg-muted'"
409
418
  >
410
419
  <td v-if="showRowNumbers" class="px-3 py-2 border-r border-edge align-top text-content-tertiary whitespace-nowrap">
411
420
  {{ docIndex + 1 }}
@@ -448,13 +457,13 @@
448
457
  No documents to show. Use Projection in the menu to choose columns.
449
458
  </p>
450
459
  </div>
451
- <div v-else-if="outputType === 'json'" class="flex flex-col space-y-2 p-1 mt-1">
460
+ <div v-else-if="outputType === 'json'" class="flex min-w-0 flex-col space-y-2 p-1 mt-1">
452
461
  <div
453
462
  v-for="document in documents"
454
463
  :key="document._id"
455
464
  @click="handleDocumentContainerClick(document, $event)"
456
465
  :class="[
457
- 'group relative transition-colors rounded-md border border-slate-100',
466
+ 'group relative min-w-0 max-w-full transition-colors rounded-md border border-slate-100',
458
467
  selectedDocuments.some(x => x._id.toString() === document._id.toString()) ? 'bg-blue-200' : 'hover:shadow-sm hover:border-slate-300 bg-surface'
459
468
  ]"
460
469
  >
@@ -609,6 +618,37 @@
609
618
  </div>
610
619
  </template>
611
620
  </modal>
621
+ <modal v-if="shouldShowDropCollectionModal">
622
+ <template v-slot:body>
623
+ <div class="modal-exit" @click="shouldShowDropCollectionModal = false">&times;</div>
624
+ <h2 class="text-xl font-bold mb-3">Are you sure?</h2>
625
+ <p class="text-content-secondary mb-3">
626
+ This will permanently drop the <span class="font-semibold">{{ currentModel }}</span> collection and all its documents.
627
+ </p>
628
+ <div v-if="requiresDropCollectionNameConfirmation()" class="rounded border border-red-300 bg-red-50 px-3 py-2 mb-4">
629
+ <p class="text-sm font-semibold text-red-700">Danger zone</p>
630
+ <p class="text-sm text-red-700 mb-2">Type <span class="font-mono font-semibold">{{ currentModel }}</span> to confirm.</p>
631
+ <input
632
+ v-model="dropCollectionConfirmName"
633
+ type="text"
634
+ :placeholder="currentModel"
635
+ class="w-full rounded border border-red-300 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-red-200"
636
+ />
637
+ </div>
638
+ <div class="flex gap-4">
639
+ <async-button
640
+ @click="dropCollection"
641
+ :disabled="requiresDropCollectionNameConfirmation() && dropCollectionConfirmName !== currentModel"
642
+ class="rounded bg-red-600 px-2 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 disabled:opacity-50 disabled:cursor-not-allowed"
643
+ >
644
+ Drop Collection
645
+ </async-button>
646
+ <button @click="shouldShowDropCollectionModal = false" class="rounded bg-gray-400 px-2 py-2 text-sm font-semibold text-white shadow-sm hover:bg-page0">
647
+ Cancel
648
+ </button>
649
+ </div>
650
+ </template>
651
+ </modal>
612
652
  <model-switcher
613
653
  :show="showModelSwitcher"
614
654
  :models="models"
@@ -168,6 +168,8 @@ module.exports = app => app.component('models', {
168
168
  showAddFieldDropdown: false,
169
169
  shouldShowIndexModal: false,
170
170
  shouldShowCollectionInfoModal: false,
171
+ shouldShowDropCollectionModal: false,
172
+ dropCollectionConfirmName: '',
171
173
  shouldShowUpdateMultipleModal: false,
172
174
  shouldShowDeleteMultipleModal: false,
173
175
  shouldExport: {},
@@ -923,6 +925,30 @@ module.exports = app => app.component('models', {
923
925
  const { info } = await api.Model.getCollectionInfo({ model: this.currentModel });
924
926
  this.collectionInfo = info;
925
927
  },
928
+ openDropCollectionModal() {
929
+ this.closeActionsMenu();
930
+ this.dropCollectionConfirmName = '';
931
+ this.shouldShowDropCollectionModal = true;
932
+ },
933
+ requiresDropCollectionNameConfirmation() {
934
+ const nodeEnv = window?.state?.nodeEnv;
935
+ return nodeEnv !== 'local';
936
+ },
937
+ async dropCollection() {
938
+ if (this.requiresDropCollectionNameConfirmation() && this.dropCollectionConfirmName !== this.currentModel) {
939
+ this.$toast.error('Please type the collection name exactly to confirm.');
940
+ return;
941
+ }
942
+
943
+ await api.Model.dropCollection({ model: this.currentModel });
944
+ this.shouldShowDropCollectionModal = false;
945
+ this.dropCollectionConfirmName = '';
946
+ this.documents = [];
947
+ this.numDocuments = 0;
948
+ this.selectedDocuments = [];
949
+ this.$toast.success(`Collection ${this.currentModel} dropped.`);
950
+ await this.getDocuments();
951
+ },
926
952
  async findOldestDocument() {
927
953
  this.closeActionsMenu();
928
954
  const { docs } = await api.Model.getDocuments({