@mongoosejs/studio 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -62,11 +62,14 @@ Then, add `pages/api/studio.js` to your Next.js project to host the Mongoose Stu
62
62
  import db from '../../src/db';
63
63
  import studio from '@mongoosejs/studio/backend/next';
64
64
 
65
- const handler = studio({
66
- apiKey: process.env.MONGOOSE_STUDIO_API_KEY, // optional
67
- connection: db, // Optional: Connection or Mongoose global. If omitted, will use `import mongoose`
68
- connectToDB: async () => { /* connection logic here */ }, // Optional: if you need to call a function to connect to the database put it here
69
- });
65
+ const handler = studio(
66
+ db, // Mongoose connection or Mongoose global. Or null to use `import mongoose`.
67
+ {
68
+ apiKey: process.env.MONGOOSE_STUDIO_API_KEY, // optional
69
+ connection: db, // Optional: Connection or Mongoose global. If omitted, will use `import mongoose`
70
+ connectToDB: async () => { /* connection logic here */ }, // Optional: if you need to call a function to connect to the database put it here
71
+ }
72
+ );
70
73
 
71
74
  export default handler;
72
75
  ```
@@ -37,7 +37,9 @@ module.exports = ({ db }) => async function getDocument(params) {
37
37
  schemaPaths[path] = {
38
38
  instance: Model.schema.paths[path].instance,
39
39
  path,
40
- ref: Model.schema.paths[path].options?.ref
40
+ ref: Model.schema.paths[path].options?.ref,
41
+ required: Model.schema.paths[path].options?.required,
42
+ enum: Model.schema.paths[path].options?.enum
41
43
  };
42
44
  }
43
45
  removeSpecifiedPaths(schemaPaths, '.$*');
@@ -77,7 +77,8 @@ module.exports = ({ db }) => async function getDocuments(params) {
77
77
  instance: Model.schema.paths[path].instance,
78
78
  path,
79
79
  ref: Model.schema.paths[path].options?.ref,
80
- required: Model.schema.paths[path].options?.required
80
+ required: Model.schema.paths[path].options?.required,
81
+ enum: Model.schema.paths[path].options?.enum
81
82
  };
82
83
  }
83
84
  removeSpecifiedPaths(schemaPaths, '.$*');
@@ -67,7 +67,8 @@ module.exports = ({ db }) => async function* getDocumentsStream(params) {
67
67
  instance: Model.schema.paths[path].instance,
68
68
  path,
69
69
  ref: Model.schema.paths[path].options?.ref,
70
- required: Model.schema.paths[path].options?.required
70
+ required: Model.schema.paths[path].options?.required,
71
+ enum: Model.schema.paths[path].options?.enum
71
72
  };
72
73
  }
73
74
  removeSpecifiedPaths(schemaPaths, '.$*');
@@ -106,6 +106,9 @@ var map = {
106
106
  "./edit-number/edit-number": "./frontend/src/edit-number/edit-number.js",
107
107
  "./edit-number/edit-number.html": "./frontend/src/edit-number/edit-number.html",
108
108
  "./edit-number/edit-number.js": "./frontend/src/edit-number/edit-number.js",
109
+ "./edit-string/edit-string": "./frontend/src/edit-string/edit-string.js",
110
+ "./edit-string/edit-string.html": "./frontend/src/edit-string/edit-string.html",
111
+ "./edit-string/edit-string.js": "./frontend/src/edit-string/edit-string.js",
109
112
  "./edit-subdocument/edit-subdocument": "./frontend/src/edit-subdocument/edit-subdocument.js",
110
113
  "./edit-subdocument/edit-subdocument.html": "./frontend/src/edit-subdocument/edit-subdocument.html",
111
114
  "./edit-subdocument/edit-subdocument.js": "./frontend/src/edit-subdocument/edit-subdocument.js",
@@ -2517,7 +2520,7 @@ module.exports = ".document-details {\n width: 100%;\n }\n \n .document-de
2517
2520
  /***/ ((module) => {
2518
2521
 
2519
2522
  "use strict";
2520
- module.exports = "<div class=\"border border-gray-200 rounded-lg mb-2\">\n <!-- Collapsible Header -->\n <div\n @click=\"toggleCollapse\"\n class=\"p-3 hover:bg-gray-100 cursor-pointer flex items-center justify-between border-b border-gray-200 transition-colors duration-200 ease-in-out\"\n :class=\"{ 'bg-amber-100': highlight, 'bg-gray-50': !highlight }\"\n >\n <div class=\"flex items-center\" >\n <svg\n :class=\"isCollapsed ? 'rotate-0' : 'rotate-90'\"\n class=\"w-4 h-4 text-gray-500 mr-2 transition-transform duration-200\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 5l7 7-7 7\"></path>\n </svg>\n <span class=\"font-medium text-gray-900\">{{path.path}}</span>\n <span class=\"ml-2 text-sm text-gray-500\">({{(path.instance || 'unknown').toLowerCase()}})</span>\n </div>\n <div class=\"flex items-center gap-2\">\n <router-link\n v-if=\"path.ref && getValueForPath(path.path)\"\n :to=\"`/model/${path.ref}/document/${getValueForPath(path.path)}`\"\n class=\"bg-ultramarine-600 hover:bg-ultramarine-500 text-white px-2 py-1 text-sm rounded-md\"\n @click.stop\n >View Document\n </router-link>\n </div>\n </div>\n\n <!-- Collapsible Content -->\n <div v-if=\"!isCollapsed\" class=\"p-3\">\n <!-- Date Type Selector (when editing dates) -->\n <div v-if=\"editting && path.instance === 'Date'\" class=\"mb-3 flex gap-1.5\">\n <div\n @click=\"dateType = 'picker'\"\n :class=\"dateType === 'picker' ? 'bg-teal-600' : ''\"\n class=\"self-stretch px-2 py-1 rounded-sm justify-center items-center gap-1.5 flex cursor-pointer\">\n <div\n :class=\"dateType === 'picker' ? 'text-white' : ''\"\n class=\"text-xs font-medium font-['Lato'] capitalize leading-tight\">\n Date Picker\n </div>\n </div>\n <div\n @click=\"dateType = 'iso'\"\n :class=\"dateType === 'iso' ? 'bg-teal-600' : ''\"\n class=\"self-stretch px-2 py-1 rounded-sm justify-center items-center gap-1.5 flex cursor-pointer\">\n <div\n :class=\"dateType === 'iso' ? 'text-white' : ''\"\n class=\"text-xs font-medium font-['Lato'] capitalize leading-tight\">\n ISO String\n </div>\n </div>\n </div>\n\n <!-- Field Content -->\n <div v-if=\"editting && path.path !== '_id'\">\n <component\n :is=\"getEditComponentForPath(path)\"\n :value=\"getEditValueForPath(path)\"\n :format=\"dateType\"\n @input=\"changes[path.path] = $event; delete invalid[path.path];\"\n @error=\"invalid[path.path] = $event;\"\n >\n </component>\n </div>\n <div v-else>\n <!-- Show truncated or full value based on needsTruncation and isValueExpanded -->\n <div v-if=\"needsTruncation && !isValueExpanded\" class=\"relative\">\n <div class=\"text-gray-700 whitespace-pre-wrap break-words font-mono text-sm\">{{truncatedString}}</div>\n <button\n @click=\"toggleValueExpansion\"\n class=\"mt-2 text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center gap-1 transform transition-all duration-200 ease-in-out hover:translate-x-0.5\"\n >\n <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\"></path>\n </svg>\n Show more ({{valueAsString.length}} characters)\n </button>\n </div>\n <div v-else-if=\"needsTruncation && isValueExpanded\" class=\"relative\">\n <component :is=\"getComponentForPath(path)\" :value=\"getValueForPath(path.path)\"></component>\n <button\n @click=\"toggleValueExpansion\"\n class=\"mt-2 text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center gap-1 transform transition-all duration-200 ease-in-out hover:translate-x-0.5\"\n >\n <svg class=\"w-4 h-4 rotate-180\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\"></path>\n </svg>\n Show less\n </button>\n </div>\n <div v-else>\n <component :is=\"getComponentForPath(path)\" :value=\"getValueForPath(path.path)\"></component>\n </div>\n </div>\n </div>\n</div>\n";
2523
+ module.exports = "<div class=\"border border-gray-200 rounded-lg mb-2\">\n <!-- Collapsible Header -->\n <div\n @click=\"toggleCollapse\"\n class=\"p-3 hover:bg-gray-100 cursor-pointer flex items-center justify-between border-b border-gray-200 transition-colors duration-200 ease-in-out\"\n :class=\"{ 'bg-amber-100': highlight, 'bg-gray-50': !highlight }\"\n >\n <div class=\"flex items-center\" >\n <svg\n :class=\"isCollapsed ? 'rotate-0' : 'rotate-90'\"\n class=\"w-4 h-4 text-gray-500 mr-2 transition-transform duration-200\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 5l7 7-7 7\"></path>\n </svg>\n <span class=\"font-medium text-gray-900\">{{path.path}}</span>\n <span class=\"ml-2 text-sm text-gray-500\">({{(path.instance || 'unknown').toLowerCase()}})</span>\n </div>\n <div class=\"flex items-center gap-2\">\n <button\n type=\"button\"\n class=\"flex items-center gap-1 text-sm text-gray-600 hover:text-gray-800 px-2 py-1 rounded-md border border-transparent hover:border-gray-300 bg-white\"\n @click.stop.prevent=\"copyPropertyValue\"\n title=\"Copy value\"\n aria-label=\"Copy property value\"\n >\n <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 7h8m-8 4h8m-8 4h5m-7-9a2 2 0 012-2h7a2 2 0 012 2v10a2 2 0 01-2 2H8l-4-4V7a2 2 0 012-2z\" />\n </svg>\n {{copyButtonLabel}}\n </button>\n <router-link\n v-if=\"path.ref && getValueForPath(path.path)\"\n :to=\"`/model/${path.ref}/document/${getValueForPath(path.path)}`\"\n class=\"bg-ultramarine-600 hover:bg-ultramarine-500 text-white px-2 py-1 text-sm rounded-md\"\n @click.stop\n >View Document\n </router-link>\n </div>\n </div>\n\n <!-- Collapsible Content -->\n <div v-if=\"!isCollapsed\" class=\"p-3\">\n <!-- Date Type Selector (when editing dates) -->\n <div v-if=\"editting && path.instance === 'Date'\" class=\"mb-3 flex gap-1.5\">\n <div\n @click=\"dateType = 'picker'\"\n :class=\"dateType === 'picker' ? 'bg-teal-600' : ''\"\n class=\"self-stretch px-2 py-1 rounded-sm justify-center items-center gap-1.5 flex cursor-pointer\">\n <div\n :class=\"dateType === 'picker' ? 'text-white' : ''\"\n class=\"text-xs font-medium font-['Lato'] capitalize leading-tight\">\n Date Picker\n </div>\n </div>\n <div\n @click=\"dateType = 'iso'\"\n :class=\"dateType === 'iso' ? 'bg-teal-600' : ''\"\n class=\"self-stretch px-2 py-1 rounded-sm justify-center items-center gap-1.5 flex cursor-pointer\">\n <div\n :class=\"dateType === 'iso' ? 'text-white' : ''\"\n class=\"text-xs font-medium font-['Lato'] capitalize leading-tight\">\n ISO String\n </div>\n </div>\n </div>\n\n <!-- Field Content -->\n <div v-if=\"editting && path.path !== '_id'\">\n <component\n :is=\"getEditComponentForPath(path)\"\n :value=\"getEditValueForPath(path)\"\n :format=\"dateType\"\n v-bind=\"getEditComponentProps(path)\"\n @input=\"changes[path.path] = $event; delete invalid[path.path];\"\n @error=\"invalid[path.path] = $event;\"\n >\n </component>\n </div>\n <div v-else>\n <!-- Show truncated or full value based on needsTruncation and isValueExpanded -->\n <div v-if=\"needsTruncation && !isValueExpanded\" class=\"relative\">\n <div class=\"text-gray-700 whitespace-pre-wrap break-words font-mono text-sm\">{{truncatedString}}</div>\n <button\n @click=\"toggleValueExpansion\"\n class=\"mt-2 text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center gap-1 transform transition-all duration-200 ease-in-out hover:translate-x-0.5\"\n >\n <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\"></path>\n </svg>\n Show more ({{valueAsString.length}} characters)\n </button>\n </div>\n <div v-else-if=\"needsTruncation && isValueExpanded\" class=\"relative\">\n <component :is=\"getComponentForPath(path)\" :value=\"getValueForPath(path.path)\"></component>\n <button\n @click=\"toggleValueExpansion\"\n class=\"mt-2 text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center gap-1 transform transition-all duration-200 ease-in-out hover:translate-x-0.5\"\n >\n <svg class=\"w-4 h-4 rotate-180\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\"></path>\n </svg>\n Show less\n </button>\n </div>\n <div v-else>\n <component :is=\"getComponentForPath(path)\" :value=\"getValueForPath(path.path)\"></component>\n </div>\n </div>\n </div>\n</div>\n";
2521
2524
 
2522
2525
  /***/ }),
2523
2526
 
@@ -2528,6 +2531,8 @@ module.exports = "<div class=\"border border-gray-200 rounded-lg mb-2\">\n <!--
2528
2531
  /***/ ((module, __unused_webpack_exports, __webpack_require__) => {
2529
2532
 
2530
2533
  "use strict";
2534
+ /* global clearTimeout setTimeout */
2535
+
2531
2536
 
2532
2537
 
2533
2538
  const mpath = __webpack_require__(/*! mpath */ "./node_modules/mpath/index.js");
@@ -2543,9 +2548,17 @@ module.exports = app => app.component('document-property', {
2543
2548
  return {
2544
2549
  dateType: 'picker', // picker, iso
2545
2550
  isCollapsed: false, // Start uncollapsed by default
2546
- isValueExpanded: false // Track if the value is expanded
2551
+ isValueExpanded: false, // Track if the value is expanded
2552
+ copyButtonLabel: 'Copy',
2553
+ copyResetTimeoutId: null
2547
2554
  };
2548
2555
  },
2556
+ beforeDestroy() {
2557
+ if (this.copyResetTimeoutId) {
2558
+ clearTimeout(this.copyResetTimeoutId);
2559
+ this.copyResetTimeoutId = null;
2560
+ }
2561
+ },
2549
2562
  props: ['path', 'document', 'schemaPaths', 'editting', 'changes', 'invalid', 'highlight'],
2550
2563
  computed: {
2551
2564
  valueAsString() {
@@ -2584,6 +2597,9 @@ module.exports = app => app.component('document-property', {
2584
2597
  return 'detail-default';
2585
2598
  },
2586
2599
  getEditComponentForPath(path) {
2600
+ if (path.instance === 'String') {
2601
+ return 'edit-string';
2602
+ }
2587
2603
  if (path.instance == 'Date') {
2588
2604
  return 'edit-date';
2589
2605
  }
@@ -2601,6 +2617,15 @@ module.exports = app => app.component('document-property', {
2601
2617
  }
2602
2618
  return 'edit-default';
2603
2619
  },
2620
+ getEditComponentProps(path) {
2621
+ const props = {};
2622
+ if (path.instance === 'String') {
2623
+ if (path.enum?.length > 0) {
2624
+ props.enumValues = path.enum;
2625
+ }
2626
+ }
2627
+ return props;
2628
+ },
2604
2629
  getValueForPath(path) {
2605
2630
  if (this.document == null) {
2606
2631
  return undefined;
@@ -2622,6 +2647,53 @@ module.exports = app => app.component('document-property', {
2622
2647
  },
2623
2648
  toggleValueExpansion() {
2624
2649
  this.isValueExpanded = !this.isValueExpanded;
2650
+ },
2651
+ setCopyFeedback() {
2652
+ this.copyButtonLabel = 'Copied';
2653
+ if (this.copyResetTimeoutId) {
2654
+ clearTimeout(this.copyResetTimeoutId);
2655
+ }
2656
+ this.copyResetTimeoutId = setTimeout(() => {
2657
+ this.copyButtonLabel = 'Copy';
2658
+ this.copyResetTimeoutId = null;
2659
+ }, 5000);
2660
+ },
2661
+ copyPropertyValue() {
2662
+ const textToCopy = this.valueAsString;
2663
+ if (textToCopy == null) {
2664
+ return;
2665
+ }
2666
+
2667
+ const fallbackCopy = () => {
2668
+ if (typeof document === 'undefined') {
2669
+ return;
2670
+ }
2671
+ const textArea = document.createElement('textarea');
2672
+ textArea.value = textToCopy;
2673
+ textArea.setAttribute('readonly', '');
2674
+ textArea.style.position = 'absolute';
2675
+ textArea.style.left = '-9999px';
2676
+ document.body.appendChild(textArea);
2677
+ textArea.select();
2678
+ try {
2679
+ document.execCommand('copy');
2680
+ } finally {
2681
+ document.body.removeChild(textArea);
2682
+ }
2683
+ this.setCopyFeedback();
2684
+ };
2685
+
2686
+ if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
2687
+ navigator.clipboard.writeText(textToCopy)
2688
+ .then(() => {
2689
+ this.setCopyFeedback();
2690
+ })
2691
+ .catch(() => {
2692
+ fallbackCopy();
2693
+ });
2694
+ } else {
2695
+ fallbackCopy();
2696
+ }
2625
2697
  }
2626
2698
  }
2627
2699
  });
@@ -3186,6 +3258,176 @@ module.exports = app => app.component('edit-number', {
3186
3258
  }
3187
3259
  });
3188
3260
 
3261
+ /***/ }),
3262
+
3263
+ /***/ "./frontend/src/edit-string/edit-string.html":
3264
+ /*!***************************************************!*\
3265
+ !*** ./frontend/src/edit-string/edit-string.html ***!
3266
+ \***************************************************/
3267
+ /***/ ((module) => {
3268
+
3269
+ "use strict";
3270
+ module.exports = "<div>\n <div v-if=\"hasEnumValues\" class=\"space-y-2\">\n <select\n class=\"w-full px-3 py-2 border border-gray-300 bg-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent\"\n :value=\"selectedOption\"\n @change=\"onSelectChange\"\n >\n <option v-for=\"option in normalizedEnums\" :key=\"`enum-${option}`\" :value=\"option\">\n {{ option }}\n </option>\n <option :value=\"'__null'\">null</option>\n <option :value=\"'__other'\">Other</option>\n </select>\n <input\n v-if=\"selectedOption === '__other'\"\n type=\"text\"\n class=\"w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent\"\n :value=\"otherValue\"\n @input=\"onOtherInput\"\n placeholder=\"Enter a value\"\n />\n </div>\n <div v-else>\n <input\n type=\"text\"\n class=\"w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent\"\n :value=\"value != null ? value : ''\"\n @input=\"onTextInput\"\n placeholder=\"Enter a value\"\n />\n </div>\n</div>\n";
3271
+
3272
+ /***/ }),
3273
+
3274
+ /***/ "./frontend/src/edit-string/edit-string.js":
3275
+ /*!*************************************************!*\
3276
+ !*** ./frontend/src/edit-string/edit-string.js ***!
3277
+ \*************************************************/
3278
+ /***/ ((module, __unused_webpack_exports, __webpack_require__) => {
3279
+
3280
+ "use strict";
3281
+
3282
+
3283
+ const template = __webpack_require__(/*! ./edit-string.html */ "./frontend/src/edit-string/edit-string.html");
3284
+
3285
+ const OTHER_OPTION = '__other';
3286
+ const NULL_OPTION = '__null';
3287
+
3288
+ function normalizeEnumValues(enumValues) {
3289
+ if (!Array.isArray(enumValues)) {
3290
+ return [];
3291
+ }
3292
+
3293
+ const deduped = [];
3294
+ enumValues.forEach(value => {
3295
+ if (value == null) {
3296
+ return;
3297
+ }
3298
+ if (deduped.indexOf(value) === -1) {
3299
+ deduped.push(value);
3300
+ }
3301
+ });
3302
+
3303
+ return deduped;
3304
+ }
3305
+
3306
+ function getInitialSelection(value, normalizedEnumValues) {
3307
+ if (value == null) {
3308
+ return NULL_OPTION;
3309
+ }
3310
+ if (normalizedEnumValues.indexOf(value) !== -1) {
3311
+ return value;
3312
+ }
3313
+ if (typeof value === 'string' && value === '') {
3314
+ return OTHER_OPTION;
3315
+ }
3316
+ // For any other non-null, non-enum, non-empty string value, return OTHER_OPTION
3317
+ return OTHER_OPTION;
3318
+ }
3319
+
3320
+ module.exports = app => app.component('edit-string', {
3321
+ template,
3322
+ props: {
3323
+ value: {
3324
+ type: null,
3325
+ default: undefined
3326
+ },
3327
+ enumValues: {
3328
+ type: Array,
3329
+ default: () => []
3330
+ }
3331
+ },
3332
+ emits: ['input'],
3333
+ data() {
3334
+ const normalizedEnums = normalizeEnumValues(this.enumValues);
3335
+ const initialSelection = normalizedEnums.length > 0 ? getInitialSelection(this.value, normalizedEnums) : null;
3336
+
3337
+ return {
3338
+ normalizedEnums,
3339
+ selectedOption: initialSelection,
3340
+ otherValue: initialSelection === OTHER_OPTION && typeof this.value === 'string' ? this.value : ''
3341
+ };
3342
+ },
3343
+ computed: {
3344
+ hasEnumValues() {
3345
+ return this.normalizedEnums.length > 0;
3346
+ }
3347
+ },
3348
+ watch: {
3349
+ value(newVal) {
3350
+ if (!this.hasEnumValues) {
3351
+ return;
3352
+ }
3353
+ const newSelection = getInitialSelection(newVal, this.normalizedEnums);
3354
+ const selectionChanged = newSelection !== this.selectedOption;
3355
+
3356
+ if (selectionChanged) {
3357
+ this.selectedOption = newSelection;
3358
+ }
3359
+
3360
+ if (newSelection === OTHER_OPTION) {
3361
+ const nextOtherValue = typeof newVal === 'string' ? newVal : '';
3362
+ if (this.otherValue !== nextOtherValue) {
3363
+ this.otherValue = nextOtherValue;
3364
+ }
3365
+ } else if (selectionChanged && this.otherValue !== '') {
3366
+ this.otherValue = '';
3367
+ }
3368
+ },
3369
+ enumValues(newValues) {
3370
+ const normalized = normalizeEnumValues(newValues);
3371
+ this.normalizedEnums = normalized;
3372
+
3373
+ if (!normalized.length) {
3374
+ this.selectedOption = null;
3375
+ this.otherValue = '';
3376
+ return;
3377
+ }
3378
+
3379
+ const newSelection = getInitialSelection(this.value, normalized);
3380
+ const selectionChanged = newSelection !== this.selectedOption;
3381
+
3382
+ if (selectionChanged) {
3383
+ this.selectedOption = newSelection;
3384
+ }
3385
+
3386
+ if (newSelection === OTHER_OPTION) {
3387
+ const sourceValue = typeof this.value === 'string' ? this.value : '';
3388
+ if (this.otherValue !== sourceValue) {
3389
+ this.otherValue = sourceValue;
3390
+ }
3391
+ } else if (this.otherValue !== '') {
3392
+ this.otherValue = '';
3393
+ }
3394
+ }
3395
+ },
3396
+ methods: {
3397
+ onSelectChange(event) {
3398
+ if (!this.hasEnumValues) {
3399
+ return;
3400
+ }
3401
+
3402
+ const selected = event.target.value;
3403
+ this.selectedOption = selected;
3404
+
3405
+ if (selected === NULL_OPTION) {
3406
+ this.otherValue = '';
3407
+ this.$emit('input', null);
3408
+ return;
3409
+ }
3410
+ if (selected === OTHER_OPTION) {
3411
+ if (this.otherValue === '' && typeof this.value === 'string' && this.normalizedEnums.indexOf(this.value) === -1) {
3412
+ this.otherValue = this.value;
3413
+ }
3414
+ return;
3415
+ }
3416
+
3417
+ this.otherValue = '';
3418
+ this.$emit('input', selected);
3419
+ },
3420
+ onOtherInput(event) {
3421
+ this.otherValue = event.target.value;
3422
+ this.$emit('input', this.otherValue);
3423
+ },
3424
+ onTextInput(event) {
3425
+ this.$emit('input', event.target.value);
3426
+ }
3427
+ }
3428
+ });
3429
+
3430
+
3189
3431
  /***/ }),
3190
3432
 
3191
3433
  /***/ "./frontend/src/edit-subdocument/edit-subdocument.html":
@@ -16367,7 +16609,7 @@ module.exports = function stringToParts(str) {
16367
16609
  /***/ ((module) => {
16368
16610
 
16369
16611
  "use strict";
16370
- module.exports = /*#__PURE__*/JSON.parse('{"name":"@mongoosejs/studio","version":"0.1.2","description":"A sleek, powerful MongoDB UI with built-in dashboarding and auth, seamlessly integrated with your Express, Vercel, or Netlify app.","homepage":"https://studio.mongoosejs.io/","repository":{"type":"git","url":"https://github.com/mongoosejs/studio"},"license":"Apache-2.0","dependencies":{"archetype":"0.13.1","csv-stringify":"6.3.0","ejson":"^2.2.3","extrovert":"^0.1.0","marked":"15.0.12","node-inspect-extracted":"3.x","tailwindcss":"3.4.0","vanillatoasts":"^1.6.0","vue":"3.x","webpack":"5.x"},"peerDependencies":{"bson":"^5.5.1 || 6.x","express":"4.x","mongoose":"7.x || 8.x"},"devDependencies":{"@masteringjs/eslint-config":"0.1.1","axios":"1.2.2","dedent":"^1.6.0","eslint":"9.30.0","express":"4.x","mocha":"10.2.0","mongoose":"8.x"},"scripts":{"lint":"eslint .","tailwind":"tailwindcss -o ./frontend/public/tw.css","tailwind:watch":"tailwindcss -o ./frontend/public/tw.css --watch","test":"mocha test/*.test.js"}}');
16612
+ module.exports = /*#__PURE__*/JSON.parse('{"name":"@mongoosejs/studio","version":"0.1.4","description":"A sleek, powerful MongoDB UI with built-in dashboarding and auth, seamlessly integrated with your Express, Vercel, or Netlify app.","homepage":"https://studio.mongoosejs.io/","repository":{"type":"git","url":"https://github.com/mongoosejs/studio"},"license":"Apache-2.0","dependencies":{"archetype":"0.13.1","csv-stringify":"6.3.0","ejson":"^2.2.3","extrovert":"^0.1.0","marked":"15.0.12","node-inspect-extracted":"3.x","tailwindcss":"3.4.0","vanillatoasts":"^1.6.0","vue":"3.x","webpack":"5.x"},"peerDependencies":{"bson":"^5.5.1 || 6.x","express":"4.x","mongoose":"7.x || 8.x"},"devDependencies":{"@masteringjs/eslint-config":"0.1.1","axios":"1.2.2","dedent":"^1.6.0","eslint":"9.30.0","express":"4.x","mocha":"10.2.0","mongoose":"8.x"},"scripts":{"lint":"eslint .","tailwind":"tailwindcss -o ./frontend/public/tw.css","tailwind:watch":"tailwindcss -o ./frontend/public/tw.css --watch","test":"mocha test/*.test.js"}}');
16371
16613
 
16372
16614
  /***/ })
16373
16615
 
@@ -2446,6 +2446,11 @@ video {
2446
2446
  color: rgb(55 65 81 / var(--tw-text-opacity));
2447
2447
  }
2448
2448
 
2449
+ .hover\:text-gray-800:hover {
2450
+ --tw-text-opacity: 1;
2451
+ color: rgb(31 41 55 / var(--tw-text-opacity));
2452
+ }
2453
+
2449
2454
  .hover\:text-slate-700:hover {
2450
2455
  --tw-text-opacity: 1;
2451
2456
  color: rgb(51 65 85 / var(--tw-text-opacity));
@@ -19,6 +19,18 @@
19
19
  <span class="ml-2 text-sm text-gray-500">({{(path.instance || 'unknown').toLowerCase()}})</span>
20
20
  </div>
21
21
  <div class="flex items-center gap-2">
22
+ <button
23
+ type="button"
24
+ class="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-800 px-2 py-1 rounded-md border border-transparent hover:border-gray-300 bg-white"
25
+ @click.stop.prevent="copyPropertyValue"
26
+ title="Copy value"
27
+ aria-label="Copy property value"
28
+ >
29
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
30
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h8m-8 4h8m-8 4h5m-7-9a2 2 0 012-2h7a2 2 0 012 2v10a2 2 0 01-2 2H8l-4-4V7a2 2 0 012-2z" />
31
+ </svg>
32
+ {{copyButtonLabel}}
33
+ </button>
22
34
  <router-link
23
35
  v-if="path.ref && getValueForPath(path.path)"
24
36
  :to="`/model/${path.ref}/document/${getValueForPath(path.path)}`"
@@ -61,6 +73,7 @@
61
73
  :is="getEditComponentForPath(path)"
62
74
  :value="getEditValueForPath(path)"
63
75
  :format="dateType"
76
+ v-bind="getEditComponentProps(path)"
64
77
  @input="changes[path.path] = $event; delete invalid[path.path];"
65
78
  @error="invalid[path.path] = $event;"
66
79
  >
@@ -1,3 +1,5 @@
1
+ /* global clearTimeout setTimeout */
2
+
1
3
  'use strict';
2
4
 
3
5
  const mpath = require('mpath');
@@ -13,9 +15,17 @@ module.exports = app => app.component('document-property', {
13
15
  return {
14
16
  dateType: 'picker', // picker, iso
15
17
  isCollapsed: false, // Start uncollapsed by default
16
- isValueExpanded: false // Track if the value is expanded
18
+ isValueExpanded: false, // Track if the value is expanded
19
+ copyButtonLabel: 'Copy',
20
+ copyResetTimeoutId: null
17
21
  };
18
22
  },
23
+ beforeDestroy() {
24
+ if (this.copyResetTimeoutId) {
25
+ clearTimeout(this.copyResetTimeoutId);
26
+ this.copyResetTimeoutId = null;
27
+ }
28
+ },
19
29
  props: ['path', 'document', 'schemaPaths', 'editting', 'changes', 'invalid', 'highlight'],
20
30
  computed: {
21
31
  valueAsString() {
@@ -54,6 +64,9 @@ module.exports = app => app.component('document-property', {
54
64
  return 'detail-default';
55
65
  },
56
66
  getEditComponentForPath(path) {
67
+ if (path.instance === 'String') {
68
+ return 'edit-string';
69
+ }
57
70
  if (path.instance == 'Date') {
58
71
  return 'edit-date';
59
72
  }
@@ -71,6 +84,15 @@ module.exports = app => app.component('document-property', {
71
84
  }
72
85
  return 'edit-default';
73
86
  },
87
+ getEditComponentProps(path) {
88
+ const props = {};
89
+ if (path.instance === 'String') {
90
+ if (path.enum?.length > 0) {
91
+ props.enumValues = path.enum;
92
+ }
93
+ }
94
+ return props;
95
+ },
74
96
  getValueForPath(path) {
75
97
  if (this.document == null) {
76
98
  return undefined;
@@ -92,6 +114,53 @@ module.exports = app => app.component('document-property', {
92
114
  },
93
115
  toggleValueExpansion() {
94
116
  this.isValueExpanded = !this.isValueExpanded;
117
+ },
118
+ setCopyFeedback() {
119
+ this.copyButtonLabel = 'Copied';
120
+ if (this.copyResetTimeoutId) {
121
+ clearTimeout(this.copyResetTimeoutId);
122
+ }
123
+ this.copyResetTimeoutId = setTimeout(() => {
124
+ this.copyButtonLabel = 'Copy';
125
+ this.copyResetTimeoutId = null;
126
+ }, 5000);
127
+ },
128
+ copyPropertyValue() {
129
+ const textToCopy = this.valueAsString;
130
+ if (textToCopy == null) {
131
+ return;
132
+ }
133
+
134
+ const fallbackCopy = () => {
135
+ if (typeof document === 'undefined') {
136
+ return;
137
+ }
138
+ const textArea = document.createElement('textarea');
139
+ textArea.value = textToCopy;
140
+ textArea.setAttribute('readonly', '');
141
+ textArea.style.position = 'absolute';
142
+ textArea.style.left = '-9999px';
143
+ document.body.appendChild(textArea);
144
+ textArea.select();
145
+ try {
146
+ document.execCommand('copy');
147
+ } finally {
148
+ document.body.removeChild(textArea);
149
+ }
150
+ this.setCopyFeedback();
151
+ };
152
+
153
+ if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
154
+ navigator.clipboard.writeText(textToCopy)
155
+ .then(() => {
156
+ this.setCopyFeedback();
157
+ })
158
+ .catch(() => {
159
+ fallbackCopy();
160
+ });
161
+ } else {
162
+ fallbackCopy();
163
+ }
95
164
  }
96
165
  }
97
166
  });
@@ -0,0 +1,32 @@
1
+ <div>
2
+ <div v-if="hasEnumValues" class="space-y-2">
3
+ <select
4
+ class="w-full px-3 py-2 border border-gray-300 bg-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
5
+ :value="selectedOption"
6
+ @change="onSelectChange"
7
+ >
8
+ <option v-for="option in normalizedEnums" :key="`enum-${option}`" :value="option">
9
+ {{ option }}
10
+ </option>
11
+ <option :value="'__null'">null</option>
12
+ <option :value="'__other'">Other</option>
13
+ </select>
14
+ <input
15
+ v-if="selectedOption === '__other'"
16
+ type="text"
17
+ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
18
+ :value="otherValue"
19
+ @input="onOtherInput"
20
+ placeholder="Enter a value"
21
+ />
22
+ </div>
23
+ <div v-else>
24
+ <input
25
+ type="text"
26
+ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
27
+ :value="value != null ? value : ''"
28
+ @input="onTextInput"
29
+ placeholder="Enter a value"
30
+ />
31
+ </div>
32
+ </div>
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ const template = require('./edit-string.html');
4
+
5
+ const OTHER_OPTION = '__other';
6
+ const NULL_OPTION = '__null';
7
+
8
+ function normalizeEnumValues(enumValues) {
9
+ if (!Array.isArray(enumValues)) {
10
+ return [];
11
+ }
12
+
13
+ const deduped = [];
14
+ enumValues.forEach(value => {
15
+ if (value == null) {
16
+ return;
17
+ }
18
+ if (deduped.indexOf(value) === -1) {
19
+ deduped.push(value);
20
+ }
21
+ });
22
+
23
+ return deduped;
24
+ }
25
+
26
+ function getInitialSelection(value, normalizedEnumValues) {
27
+ if (value == null) {
28
+ return NULL_OPTION;
29
+ }
30
+ if (normalizedEnumValues.indexOf(value) !== -1) {
31
+ return value;
32
+ }
33
+ if (typeof value === 'string' && value === '') {
34
+ return OTHER_OPTION;
35
+ }
36
+ // For any other non-null, non-enum, non-empty string value, return OTHER_OPTION
37
+ return OTHER_OPTION;
38
+ }
39
+
40
+ module.exports = app => app.component('edit-string', {
41
+ template,
42
+ props: {
43
+ value: {
44
+ type: null,
45
+ default: undefined
46
+ },
47
+ enumValues: {
48
+ type: Array,
49
+ default: () => []
50
+ }
51
+ },
52
+ emits: ['input'],
53
+ data() {
54
+ const normalizedEnums = normalizeEnumValues(this.enumValues);
55
+ const initialSelection = normalizedEnums.length > 0 ? getInitialSelection(this.value, normalizedEnums) : null;
56
+
57
+ return {
58
+ normalizedEnums,
59
+ selectedOption: initialSelection,
60
+ otherValue: initialSelection === OTHER_OPTION && typeof this.value === 'string' ? this.value : ''
61
+ };
62
+ },
63
+ computed: {
64
+ hasEnumValues() {
65
+ return this.normalizedEnums.length > 0;
66
+ }
67
+ },
68
+ watch: {
69
+ value(newVal) {
70
+ if (!this.hasEnumValues) {
71
+ return;
72
+ }
73
+ const newSelection = getInitialSelection(newVal, this.normalizedEnums);
74
+ const selectionChanged = newSelection !== this.selectedOption;
75
+
76
+ if (selectionChanged) {
77
+ this.selectedOption = newSelection;
78
+ }
79
+
80
+ if (newSelection === OTHER_OPTION) {
81
+ const nextOtherValue = typeof newVal === 'string' ? newVal : '';
82
+ if (this.otherValue !== nextOtherValue) {
83
+ this.otherValue = nextOtherValue;
84
+ }
85
+ } else if (selectionChanged && this.otherValue !== '') {
86
+ this.otherValue = '';
87
+ }
88
+ },
89
+ enumValues(newValues) {
90
+ const normalized = normalizeEnumValues(newValues);
91
+ this.normalizedEnums = normalized;
92
+
93
+ if (!normalized.length) {
94
+ this.selectedOption = null;
95
+ this.otherValue = '';
96
+ return;
97
+ }
98
+
99
+ const newSelection = getInitialSelection(this.value, normalized);
100
+ const selectionChanged = newSelection !== this.selectedOption;
101
+
102
+ if (selectionChanged) {
103
+ this.selectedOption = newSelection;
104
+ }
105
+
106
+ if (newSelection === OTHER_OPTION) {
107
+ const sourceValue = typeof this.value === 'string' ? this.value : '';
108
+ if (this.otherValue !== sourceValue) {
109
+ this.otherValue = sourceValue;
110
+ }
111
+ } else if (this.otherValue !== '') {
112
+ this.otherValue = '';
113
+ }
114
+ }
115
+ },
116
+ methods: {
117
+ onSelectChange(event) {
118
+ if (!this.hasEnumValues) {
119
+ return;
120
+ }
121
+
122
+ const selected = event.target.value;
123
+ this.selectedOption = selected;
124
+
125
+ if (selected === NULL_OPTION) {
126
+ this.otherValue = '';
127
+ this.$emit('input', null);
128
+ return;
129
+ }
130
+ if (selected === OTHER_OPTION) {
131
+ if (this.otherValue === '' && typeof this.value === 'string' && this.normalizedEnums.indexOf(this.value) === -1) {
132
+ this.otherValue = this.value;
133
+ }
134
+ return;
135
+ }
136
+
137
+ this.otherValue = '';
138
+ this.$emit('input', selected);
139
+ },
140
+ onOtherInput(event) {
141
+ this.otherValue = event.target.value;
142
+ this.$emit('input', this.otherValue);
143
+ },
144
+ onTextInput(event) {
145
+ this.$emit('input', event.target.value);
146
+ }
147
+ }
148
+ });
package/next.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
+ const frontend = require('./frontend');
4
5
  const path = require('path');
5
6
 
6
7
  module.exports = withMongooseStudio;
@@ -15,6 +16,15 @@ module.exports = withMongooseStudio;
15
16
  function withMongooseStudio(nextConfig = {}) {
16
17
  const studioPath = normalizeBasePath(nextConfig.studioPath || '/studio');
17
18
 
19
+ try {
20
+ copyStudioFrontend(studioPath);
21
+ frontend('/api/studio', true)
22
+ .then(() => console.log(`✅ Mongoose Studio: copied frontend+config to public${studioPath}`))
23
+ .catch(err => console.error(`❌ Mongoose Studio: failed to copy frontend`, err));
24
+ } catch (err) {
25
+ console.error('❌ Mongoose Studio: failed to copy frontend', err);
26
+ }
27
+
18
28
  return {
19
29
  ...nextConfig,
20
30
 
@@ -32,24 +42,7 @@ function withMongooseStudio(nextConfig = {}) {
32
42
  };
33
43
 
34
44
  return [...userRedirects, studioRedirect];
35
- },
36
-
37
- webpack(config, { isServer }) {
38
- if (isServer) {
39
- try {
40
- copyStudioFrontend(studioPath);
41
- console.log(`✅ Mongoose Studio: copied frontend to public${studioPath}`);
42
- } catch (err) {
43
- console.error('❌ Mongoose Studio: failed to copy frontend', err);
44
- }
45
- }
46
-
47
- // Preserve user’s webpack config
48
- if (typeof nextConfig.webpack === 'function') {
49
- return nextConfig.webpack(config, { isServer });
50
- }
51
- return config;
52
- },
45
+ }
53
46
  };
54
47
  }
55
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mongoosejs/studio",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "A sleek, powerful MongoDB UI with built-in dashboarding and auth, seamlessly integrated with your Express, Vercel, or Netlify app.",
5
5
  "homepage": "https://studio.mongoosejs.io/",
6
6
  "repository": {