@mongoosejs/studio 0.2.1 → 0.2.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.
@@ -1877,6 +1877,10 @@ video {
1877
1877
  padding-bottom: 2rem;
1878
1878
  }
1879
1879
 
1880
+ .pb-16 {
1881
+ padding-bottom: 4rem;
1882
+ }
1883
+
1880
1884
  .pb-2 {
1881
1885
  padding-bottom: 0.5rem;
1882
1886
  }
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ destroyed() {
5
+ this.$parent.$options.$children = this.$parent.$options.$children.filter(
6
+ (el) => el !== this
7
+ );
8
+ },
9
+ created() {
10
+ this.$parent.$options.$children = this.$parent.$options.$children || [];
11
+ this.$parent.$options.$children.push(this);
12
+ },
13
+ mounted() {
14
+ this.isMounted = true;
15
+ },
16
+ unmounted() {
17
+ this.isMounted = false;
18
+ }
19
+ };
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Deep equality check for values (handles primitives, arrays, objects, dates, etc.)
5
+ * @param {*} a - First value to compare
6
+ * @param {*} b - Second value to compare
7
+ * @returns {boolean} - True if values are deeply equal
8
+ */
9
+ function deepEqual(a, b) {
10
+ // Handle primitives and same reference
11
+ if (a === b) return true;
12
+
13
+ // Handle null and undefined
14
+ if (a == null || b == null) return a === b;
15
+
16
+ // Handle different types
17
+ if (typeof a !== typeof b) return false;
18
+
19
+ // Handle dates
20
+ if (a instanceof Date && b instanceof Date) {
21
+ return a.getTime() === b.getTime();
22
+ }
23
+
24
+ // Handle arrays - must both be arrays
25
+ if (Array.isArray(a) || Array.isArray(b)) {
26
+ if (!Array.isArray(a) || !Array.isArray(b)) return false;
27
+ if (a.length !== b.length) return false;
28
+ for (let i = 0; i < a.length; i++) {
29
+ if (!deepEqual(a[i], b[i])) return false;
30
+ }
31
+ return true;
32
+ }
33
+
34
+ // Handle objects (non-arrays)
35
+ if (typeof a === 'object' && typeof b === 'object') {
36
+ const keysA = Object.keys(a);
37
+ const keysB = Object.keys(b);
38
+
39
+ if (keysA.length !== keysB.length) return false;
40
+
41
+ for (const key of keysA) {
42
+ if (!keysB.includes(key)) return false;
43
+ if (!deepEqual(a[key], b[key])) return false;
44
+ }
45
+
46
+ return true;
47
+ }
48
+
49
+ // Fallback for primitives (strings, numbers, booleans)
50
+ return false;
51
+ }
52
+
53
+ module.exports = deepEqual;
@@ -82,9 +82,9 @@ function getAutocompleteContext(searchText, cursorPos) {
82
82
 
83
83
  // Check if we're in a value context (after a colon)
84
84
  // Match the last colon followed by optional whitespace and capture everything after
85
- const valueMatch = before.match(/:\s*([^\s,\}\]:]*)$/);
85
+ const valueMatch = before.match(/:\s*(\{?\s*)([^\s,\}\]:]*)$/);
86
86
  if (valueMatch) {
87
- const token = valueMatch[1];
87
+ const token = valueMatch[2];
88
88
  return {
89
89
  token,
90
90
  role: 'value',
@@ -159,9 +159,9 @@ function applySuggestion(searchText, cursorPos, suggestion) {
159
159
  const after = searchText.slice(cursorPos);
160
160
 
161
161
  // Check if we're in a value context
162
- const valueMatch = before.match(/:\s*([^\s,\}\]:]*)$/);
162
+ const valueMatch = before.match(/:\s*(\{?\s*)([^\s,\}\]:]*)$/);
163
163
  if (valueMatch) {
164
- const token = valueMatch[1];
164
+ const token = valueMatch[2];
165
165
  const start = cursorPos - token.length;
166
166
  let replacement = suggestion;
167
167
  let cursorOffset = replacement.length;
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { inspect } = require('node-inspect-extracted');
4
+ const deepEqual = require('./_util/deepEqual');
4
5
 
5
6
  /**
6
7
  * Format a value for display in array views
@@ -1,10 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  const api = require('../api');
4
+ const baseComponent = require('../_util/baseComponent');
4
5
  const template = require('./dashboard.html');
5
6
 
6
- module.exports = app => app.component('dashboard', {
7
+ module.exports = {
7
8
  template: template,
9
+ extends: baseComponent,
8
10
  props: ['dashboardId'],
9
11
  data: function() {
10
12
  return {
@@ -48,7 +50,7 @@ module.exports = app => app.component('dashboard', {
48
50
  }
49
51
  },
50
52
  shouldEvaluateDashboard() {
51
- if (this.dashboardResults.length === 0) {
53
+ if (!this.dashboardResults || this.dashboardResults.length === 0) {
52
54
  return true;
53
55
  }
54
56
 
@@ -115,6 +117,22 @@ module.exports = app => app.component('dashboard', {
115
117
  this.startingChat = false;
116
118
  this.showActionsMenu = false;
117
119
  }
120
+ },
121
+ async loadInitial() {
122
+ const { dashboard, dashboardResults, error } = await api.Dashboard.getDashboard({ dashboardId: this.dashboardId, evaluate: false });
123
+ if (!dashboard) {
124
+ return;
125
+ }
126
+ this.dashboard = dashboard;
127
+ this.code = this.dashboard.code;
128
+ this.title = this.dashboard.title;
129
+ this.description = this.dashboard.description ?? '';
130
+ this.dashboardResults = dashboardResults;
131
+ if (this.shouldEvaluateDashboard()) {
132
+ await this.evaluateDashboard();
133
+ return;
134
+ }
135
+ this.status = 'loaded';
118
136
  }
119
137
  },
120
138
  computed: {
@@ -125,22 +143,9 @@ module.exports = app => app.component('dashboard', {
125
143
  mounted: async function() {
126
144
  document.addEventListener('click', this.handleDocumentClick);
127
145
  this.showEditor = this.$route.query.edit;
128
- const { dashboard, dashboardResults, error } = await api.Dashboard.getDashboard({ dashboardId: this.dashboardId, evaluate: false });
129
- if (!dashboard) {
130
- return;
131
- }
132
- this.dashboard = dashboard;
133
- this.code = this.dashboard.code;
134
- this.title = this.dashboard.title;
135
- this.description = this.dashboard.description ?? '';
136
- this.dashboardResults = dashboardResults;
137
- if (this.shouldEvaluateDashboard()) {
138
- await this.evaluateDashboard();
139
- return;
140
- }
141
- this.status = 'loaded';
146
+ await this.loadInitial();
142
147
  },
143
148
  beforeDestroy() {
144
149
  document.removeEventListener('click', this.handleDocumentClick);
145
150
  }
146
- });
151
+ };
@@ -7,6 +7,16 @@ module.exports = app => app.component('confirm-changes', {
7
7
  props: ['value'],
8
8
  computed: {
9
9
  displayValue() {
10
+ const hasUnsetFields = Object.keys(this.value).some(key => this.value[key] === undefined);
11
+ if (hasUnsetFields) {
12
+ const unsetFields = Object.keys(this.value)
13
+ .filter(key => this.value[key] === undefined)
14
+ .reduce((obj, key) => Object.assign(obj, { [key]: 1 }), {});
15
+ const setFields = Object.keys(this.value)
16
+ .filter(key => this.value[key] !== undefined)
17
+ .reduce((obj, key) => Object.assign(obj, { [key]: this.value[key] }), {});
18
+ return JSON.stringify({ $set: setFields, $unset: unsetFields }, null, ' ').trim();
19
+ }
10
20
  return JSON.stringify(this.value, null, ' ').trim();
11
21
  }
12
22
  },
@@ -21,4 +31,4 @@ module.exports = app => app.component('confirm-changes', {
21
31
  mounted() {
22
32
  Prism.highlightElement(this.$refs.code);
23
33
  }
24
- });
34
+ });
@@ -1,4 +1,4 @@
1
- <div class="document px-1 pt-4 md:px-0 bg-slate-50 w-full">
1
+ <div class="document px-1 pt-4 pb-16 md:px-0 bg-slate-50 w-full">
2
2
  <div class="max-w-7xl mx-auto">
3
3
  <div class="flex gap-4 items-center sticky top-0 z-50 bg-white p-4 border-b border-gray-200 shadow-sm">
4
4
  <div class="font-bold overflow-hidden text-ellipsis">{{model}}: {{documentId}}</div>
@@ -74,10 +74,25 @@ module.exports = app => app.component('document', {
74
74
  if (Object.keys(this.invalid).length > 0) {
75
75
  throw new Error('Invalid paths: ' + Object.keys(this.invalid).join(', '));
76
76
  }
77
+
78
+ let update = this.changes;
79
+ let unset = {};
80
+ const hasUnsetFields = Object.keys(this.changes)
81
+ .some(key => this.changes[key] === undefined);
82
+ if (hasUnsetFields) {
83
+ unset = Object.keys(this.changes)
84
+ .filter(key => this.changes[key] === undefined)
85
+ .reduce((obj, key) => Object.assign(obj, { [key]: 1 }), {});
86
+ update = Object.keys(this.changes)
87
+ .filter(key => this.changes[key] !== undefined)
88
+ .reduce((obj, key) => Object.assign(obj, { [key]: this.changes[key] }), {});
89
+ }
90
+
77
91
  const { doc } = await api.Model.updateDocument({
78
92
  model: this.model,
79
93
  _id: this.document._id,
80
- update: this.changes
94
+ update,
95
+ unset
81
96
  });
82
97
  this.document = doc;
83
98
  this.changes = {};
@@ -74,7 +74,7 @@
74
74
  :value="getEditValueForPath(path)"
75
75
  :format="dateType"
76
76
  v-bind="getEditComponentProps(path)"
77
- @input="changes[path.path] = $event; delete invalid[path.path];"
77
+ @input="handleInputChange($event)"
78
78
  @error="invalid[path.path] = $event;"
79
79
  >
80
80
  </component>
@@ -3,7 +3,7 @@
3
3
  'use strict';
4
4
 
5
5
  const mpath = require('mpath');
6
- const { inspect } = require('node-inspect-extracted');
6
+ const deepEqual = require('../../_util/deepEqual');
7
7
  const template = require('./document-property.html');
8
8
 
9
9
  const appendCSS = require('../../appendCSS');
@@ -95,6 +95,20 @@ module.exports = app => app.component('document-property', {
95
95
  }
96
96
  },
97
97
  methods: {
98
+ handleInputChange(newValue) {
99
+ const currentValue = this.getValueForPath(this.path.path);
100
+
101
+ // Only record as a change if the value is actually different
102
+ if (!deepEqual(currentValue, newValue)) {
103
+ this.changes[this.path.path] = newValue;
104
+ } else {
105
+ // If the value is the same as the original, remove it from changes
106
+ delete this.changes[this.path.path];
107
+ }
108
+
109
+ // Always clear invalid state on input
110
+ delete this.invalid[this.path.path];
111
+ },
98
112
  getComponentForPath(schemaPath) {
99
113
  if (schemaPath.instance === 'Array') {
100
114
  return 'detail-array';
@@ -19,20 +19,12 @@ module.exports = app => app.component('edit-boolean', {
19
19
  this.selectedValue = newValue;
20
20
  },
21
21
  selectedValue(newValue) {
22
- // Convert null/undefined to strings for proper backend serialization
23
- const emitValue = this.convertValueToString(newValue);
24
- this.$emit('input', emitValue);
22
+ this.$emit('input', newValue);
25
23
  }
26
24
  },
27
25
  methods: {
28
26
  selectValue(value) {
29
27
  this.selectedValue = value;
30
- },
31
- convertValueToString(value) {
32
- // Convert null/undefined to strings for proper backend serialization
33
- if (value === null) return 'null';
34
- if (typeof value === 'undefined') return 'undefined';
35
- return value;
36
28
  }
37
29
  }
38
30
  });
@@ -44,7 +44,11 @@ requireComponents.keys().forEach((filePath) => {
44
44
  // Check if the file name matches the directory name
45
45
  if (directoryName === fileName) {
46
46
  components[directoryName] = requireComponents(filePath);
47
- components[directoryName](app);
47
+ if (typeof components[directoryName] === 'function') {
48
+ components[directoryName](app);
49
+ } else {
50
+ app.component(directoryName, components[directoryName]);
51
+ }
48
52
  }
49
53
  });
50
54
 
@@ -102,18 +106,21 @@ app.component('app-component', {
102
106
  return;
103
107
  }
104
108
 
109
+ window.localStorage.setItem('_mongooseStudioAccessToken', accessToken._id);
110
+
105
111
  try {
106
112
  const { nodeEnv } = await api.status();
107
113
  this.nodeEnv = nodeEnv;
108
114
  } catch (err) {
109
115
  this.authError = 'Error connecting to Mongoose Studio API: ' + err.response?.data?.message ?? err.message;
110
116
  this.status = 'loaded';
117
+ window.localStorage.setItem('_mongooseStudioAccessToken', '');
111
118
  return;
112
119
  }
113
120
 
114
121
  this.user = user;
115
122
  this.roles = roles;
116
- window.localStorage.setItem('_mongooseStudioAccessToken', accessToken._id);
123
+
117
124
  setTimeout(() => {
118
125
  this.$router.replace(this.$router.currentRoute.value.path);
119
126
  }, 0);
@@ -18,12 +18,21 @@ client.interceptors.request.use(req => {
18
18
  return req;
19
19
  });
20
20
 
21
+ function sanitizedReturnUrl() {
22
+ const url = new URL(window.location.href);
23
+ if (url.hash && url.hash.includes('?')) {
24
+ const [hashPath, hashSearch] = url.hash.split('?', 2);
25
+ url.hash = hashPath;
26
+ }
27
+ return url.toString();
28
+ }
29
+
21
30
  exports.githubLogin = function githubLogin() {
22
- return client.post('/githubLogin', { state: window.location.href }).then(res => res.data);
31
+ return client.post('/githubLogin', { state: sanitizedReturnUrl() }).then(res => res.data);
23
32
  };
24
33
 
25
34
  exports.googleLogin = function googleLogin() {
26
- return client.post('/googleLogin', { state: window.location.href }).then(res => res.data);
35
+ return client.post('/googleLogin', { state: sanitizedReturnUrl() }).then(res => res.data);
27
36
  };
28
37
 
29
38
  exports.getWorkspaceTeam = function getWorkspaceTeam() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mongoosejs/studio",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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": {
@@ -34,12 +34,14 @@
34
34
  "eslint": "9.30.0",
35
35
  "express": "4.x",
36
36
  "mocha": "10.2.0",
37
- "mongoose": "9.x"
37
+ "mongoose": "9.x",
38
+ "sinon": "^21.0.1"
38
39
  },
39
40
  "scripts": {
40
41
  "lint": "eslint .",
41
42
  "tailwind": "tailwindcss -o ./frontend/public/tw.css",
42
43
  "tailwind:watch": "tailwindcss -o ./frontend/public/tw.css --watch",
43
- "test": "mocha test/*.test.js"
44
+ "test": "mocha test/*.test.js",
45
+ "test:frontend": "mocha test/frontend/*.test.js"
44
46
  }
45
47
  }