@mongoosejs/studio 0.3.2 → 0.3.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.
@@ -11,11 +11,66 @@ const appendCSS = require('../appendCSS');
11
11
  appendCSS(require('./models.css'));
12
12
 
13
13
  const limit = 20;
14
+ const DEFAULT_FIRST_N_FIELDS = 6;
14
15
  const OUTPUT_TYPE_STORAGE_KEY = 'studio:model-output-type';
15
16
  const SELECTED_GEO_FIELD_STORAGE_KEY = 'studio:model-selected-geo-field';
17
+ const PROJECTION_STORAGE_KEY_PREFIX = 'studio:model-projection:';
18
+ const SHOW_ROW_NUMBERS_STORAGE_KEY = 'studio:model-show-row-numbers';
19
+ const PROJECTION_MODE_QUERY_KEY = 'projectionMode';
16
20
  const RECENTLY_VIEWED_MODELS_KEY = 'studio:recently-viewed-models';
17
21
  const MAX_RECENT_MODELS = 4;
18
22
 
23
+ /** Parse `fields` from the route (JSON array or inclusion projection object only). */
24
+ function parseFieldsQueryParam(fields) {
25
+ if (fields == null || fields === '') {
26
+ return [];
27
+ }
28
+ const s = typeof fields === 'string' ? fields : String(fields);
29
+ const trimmed = s.trim();
30
+ if (!trimmed) {
31
+ return [];
32
+ }
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(trimmed);
36
+ } catch (e) {
37
+ return [];
38
+ }
39
+ if (Array.isArray(parsed)) {
40
+ return parsed.map(x => String(x).trim()).filter(Boolean);
41
+ }
42
+ if (parsed != null && typeof parsed === 'object') {
43
+ return Object.keys(parsed).filter(k =>
44
+ Object.prototype.hasOwnProperty.call(parsed, k) && parsed[k]
45
+ );
46
+ }
47
+ return [];
48
+ }
49
+
50
+ /** Pass through a valid JSON `fields` string for Model.getDocuments / getDocumentsStream. */
51
+ function normalizeFieldsParamForApi(fieldsStr) {
52
+ if (fieldsStr == null || fieldsStr === '') {
53
+ return null;
54
+ }
55
+ const s = typeof fieldsStr === 'string' ? fieldsStr : String(fieldsStr);
56
+ const trimmed = s.trim();
57
+ if (!trimmed) {
58
+ return null;
59
+ }
60
+ try {
61
+ const parsed = JSON.parse(trimmed);
62
+ if (Array.isArray(parsed)) {
63
+ return trimmed;
64
+ }
65
+ if (parsed != null && typeof parsed === 'object' && !Array.isArray(parsed)) {
66
+ return trimmed;
67
+ }
68
+ } catch (e) {
69
+ return null;
70
+ }
71
+ return null;
72
+ }
73
+
19
74
  module.exports = app => app.component('models', {
20
75
  template: template,
21
76
  props: ['model', 'user', 'roles'],
@@ -31,6 +86,7 @@ module.exports = app => app.component('models', {
31
86
  mongoDBIndexes: [],
32
87
  schemaIndexes: [],
33
88
  status: 'loading',
89
+ loadingMore: false,
34
90
  loadedAllDocs: false,
35
91
  edittingDoc: null,
36
92
  docEdits: null,
@@ -39,7 +95,10 @@ module.exports = app => app.component('models', {
39
95
  searchText: '',
40
96
  shouldShowExportModal: false,
41
97
  shouldShowCreateModal: false,
42
- shouldShowFieldModal: false,
98
+ projectionText: '',
99
+ isProjectionMenuSelected: false,
100
+ addFieldFilterText: '',
101
+ showAddFieldDropdown: false,
43
102
  shouldShowIndexModal: false,
44
103
  shouldShowCollectionInfoModal: false,
45
104
  shouldShowUpdateMultipleModal: false,
@@ -61,27 +120,44 @@ module.exports = app => app.component('models', {
61
120
  collectionInfo: null,
62
121
  modelSearch: '',
63
122
  recentlyViewedModels: [],
64
- showModelSwitcher: false
123
+ showModelSwitcher: false,
124
+ showRowNumbers: true,
125
+ suppressScrollCheck: false,
126
+ scrollTopToRestore: null
65
127
  }),
66
128
  created() {
67
129
  this.currentModel = this.model;
68
130
  this.setSearchTextFromRoute();
69
131
  this.loadOutputPreference();
70
132
  this.loadSelectedGeoField();
133
+ this.loadShowRowNumbersPreference();
71
134
  this.loadRecentlyViewedModels();
135
+ this.isProjectionMenuSelected = this.$route?.query?.[PROJECTION_MODE_QUERY_KEY] === '1';
72
136
  },
73
137
  beforeDestroy() {
74
- document.removeEventListener('scroll', this.onScroll, true);
75
138
  window.removeEventListener('popstate', this.onPopState, true);
76
139
  document.removeEventListener('click', this.onOutsideActionsMenuClick, true);
140
+ document.removeEventListener('click', this.onOutsideAddFieldDropdownClick, true);
77
141
  document.documentElement.removeEventListener('studio-theme-changed', this.onStudioThemeChanged);
78
142
  document.removeEventListener('keydown', this.onCtrlP, true);
79
143
  this.destroyMap();
80
144
  },
81
145
  async mounted() {
146
+ // Persist scroll restoration across remounts.
147
+ // This component is keyed by `$route.fullPath`, so query changes (e.g. projection updates)
148
+ // recreate the component and reset scroll position.
149
+ if (typeof window !== 'undefined') {
150
+ if (typeof window.__studioModelsScrollTopToRestore === 'number') {
151
+ this.scrollTopToRestore = window.__studioModelsScrollTopToRestore;
152
+ }
153
+ if (window.__studioModelsSuppressScrollCheck === true) {
154
+ this.suppressScrollCheck = true;
155
+ }
156
+ delete window.__studioModelsScrollTopToRestore;
157
+ delete window.__studioModelsSuppressScrollCheck;
158
+ }
159
+
82
160
  window.pageState = this;
83
- this.onScroll = () => this.checkIfScrolledToBottom();
84
- document.addEventListener('scroll', this.onScroll, true);
85
161
  this.onPopState = () => this.initSearchFromUrl();
86
162
  window.addEventListener('popstate', this.onPopState, true);
87
163
  this.onOutsideActionsMenuClick = event => {
@@ -93,7 +169,18 @@ module.exports = app => app.component('models', {
93
169
  this.closeActionsMenu();
94
170
  }
95
171
  };
172
+ this.onOutsideAddFieldDropdownClick = event => {
173
+ if (!this.showAddFieldDropdown) {
174
+ return;
175
+ }
176
+ const container = this.$refs.addFieldContainer;
177
+ if (container && !container.contains(event.target)) {
178
+ this.showAddFieldDropdown = false;
179
+ this.addFieldFilterText = '';
180
+ }
181
+ };
96
182
  document.addEventListener('click', this.onOutsideActionsMenuClick, true);
183
+ document.addEventListener('click', this.onOutsideAddFieldDropdownClick, true);
97
184
  this.onStudioThemeChanged = () => this.updateMapTileLayer();
98
185
  document.documentElement.addEventListener('studio-theme-changed', this.onStudioThemeChanged);
99
186
  this.onCtrlP = (event) => {
@@ -104,6 +191,8 @@ module.exports = app => app.component('models', {
104
191
  };
105
192
  document.addEventListener('keydown', this.onCtrlP, true);
106
193
  this.query = Object.assign({}, this.$route.query);
194
+ // Keep UI mode in sync with the URL on remounts.
195
+ this.isProjectionMenuSelected = this.$route?.query?.[PROJECTION_MODE_QUERY_KEY] === '1';
107
196
  const { models, readyState } = await api.Model.listModels();
108
197
  this.models = models;
109
198
  await this.loadModelCounts();
@@ -119,8 +208,27 @@ module.exports = app => app.component('models', {
119
208
  }
120
209
 
121
210
  await this.initSearchFromUrl();
211
+ if (this.isProjectionMenuSelected && this.outputType === 'map') {
212
+ // Projection input is not rendered in map view.
213
+ this.setOutputType('json');
214
+ }
215
+ this.$nextTick(() => {
216
+ if (!this.isProjectionMenuSelected) return;
217
+ const input = this.$refs.projectionInput;
218
+ if (input && typeof input.focus === 'function') {
219
+ input.focus();
220
+ }
221
+ });
122
222
  },
123
223
  watch: {
224
+ model(newModel) {
225
+ if (newModel !== this.currentModel) {
226
+ this.currentModel = newModel;
227
+ if (this.currentModel != null) {
228
+ this.initSearchFromUrl();
229
+ }
230
+ }
231
+ },
124
232
  documents: {
125
233
  handler() {
126
234
  if (this.outputType === 'map' && this.mapInstance) {
@@ -225,6 +333,19 @@ module.exports = app => app.component('models', {
225
333
  }
226
334
 
227
335
  return geoFields;
336
+ },
337
+ availablePathsToAdd() {
338
+ const currentPaths = new Set(this.filteredPaths.map(p => p.path));
339
+ return this.schemaPaths.filter(p => !currentPaths.has(p.path));
340
+ },
341
+ filteredPathsToAdd() {
342
+ const available = this.availablePathsToAdd;
343
+ const query = (this.addFieldFilterText || '').trim().toLowerCase();
344
+ if (!query) return available;
345
+ return available.filter(p => p.path.toLowerCase().includes(query));
346
+ },
347
+ tableDisplayPaths() {
348
+ return this.filteredPaths.length > 0 ? this.filteredPaths : this.schemaPaths;
228
349
  }
229
350
  },
230
351
  methods: {
@@ -289,6 +410,24 @@ module.exports = app => app.component('models', {
289
410
  this.selectedGeoField = storedField;
290
411
  }
291
412
  },
413
+ loadShowRowNumbersPreference() {
414
+ if (typeof window === 'undefined' || !window.localStorage) {
415
+ return;
416
+ }
417
+ const stored = window.localStorage.getItem(SHOW_ROW_NUMBERS_STORAGE_KEY);
418
+ if (stored === '0') {
419
+ this.showRowNumbers = false;
420
+ } else if (stored === '1') {
421
+ this.showRowNumbers = true;
422
+ }
423
+ },
424
+ toggleRowNumbers() {
425
+ this.showRowNumbers = !this.showRowNumbers;
426
+ if (typeof window !== 'undefined' && window.localStorage) {
427
+ window.localStorage.setItem(SHOW_ROW_NUMBERS_STORAGE_KEY, this.showRowNumbers ? '1' : '0');
428
+ }
429
+ this.showActionsMenu = false;
430
+ },
292
431
  setOutputType(type) {
293
432
  if (type !== 'json' && type !== 'table' && type !== 'map') {
294
433
  return;
@@ -484,6 +623,22 @@ module.exports = app => app.component('models', {
484
623
  params.searchText = this.searchText;
485
624
  }
486
625
 
626
+ // Prefer explicit URL projection (`query.fields`) so the first fetch after
627
+ // mount/remount respects deep-linked projections before `filteredPaths`
628
+ // is rehydrated from schema paths.
629
+ let fieldsParam = normalizeFieldsParamForApi(this.query?.fields);
630
+ if (!fieldsParam) {
631
+ const fieldPaths = this.filteredPaths && this.filteredPaths.length > 0
632
+ ? this.filteredPaths.map(p => p.path).filter(Boolean)
633
+ : null;
634
+ if (fieldPaths && fieldPaths.length > 0) {
635
+ fieldsParam = JSON.stringify(fieldPaths);
636
+ }
637
+ }
638
+ if (fieldsParam) {
639
+ params.fields = fieldsParam;
640
+ }
641
+
487
642
  return params;
488
643
  },
489
644
  setSearchTextFromRoute() {
@@ -497,20 +652,35 @@ module.exports = app => app.component('models', {
497
652
  this.status = 'loading';
498
653
  this.query = Object.assign({}, this.$route.query); // important that this is here before the if statements
499
654
  this.setSearchTextFromRoute();
500
- if (this.$route.query?.sort) {
501
- const sort = eval(`(${this.$route.query.sort})`);
502
- const path = Object.keys(sort)[0];
503
- const num = Object.values(sort)[0];
504
- this.sortDocs(num, path);
505
- }
506
-
655
+ // Avoid eval() on user-controlled query params.
656
+ // Use explicit sortKey + sortDirection query params.
657
+ const sortKey = this.$route.query?.sortKey;
658
+ const sortDirectionRaw = this.$route.query?.sortDirection;
659
+ const sortDirection = typeof sortDirectionRaw === 'string' ? Number(sortDirectionRaw) : sortDirectionRaw;
507
660
 
661
+ if (typeof sortKey === 'string' && sortKey.trim().length > 0 &&
662
+ (sortDirection === 1 || sortDirection === -1)) {
663
+ for (const key in this.sortBy) {
664
+ delete this.sortBy[key];
665
+ }
666
+ this.sortBy[sortKey] = sortDirection;
667
+ // Normalize to new params and remove legacy key if present.
668
+ this.query.sortKey = sortKey;
669
+ this.query.sortDirection = sortDirection;
670
+ delete this.query.sort;
671
+ }
508
672
  if (this.currentModel != null) {
509
673
  await this.getDocuments();
510
674
  }
511
675
  if (this.$route.query?.fields) {
512
- const filter = this.$route.query.fields.split(',');
513
- this.filteredPaths = this.filteredPaths.filter(x => filter.includes(x.path));
676
+ const urlPaths = parseFieldsQueryParam(this.$route.query.fields);
677
+ if (urlPaths.length > 0) {
678
+ this.filteredPaths = urlPaths.map(path => this.schemaPaths.find(p => p.path === path)).filter(Boolean);
679
+ if (this.filteredPaths.length > 0) {
680
+ this.syncProjectionFromPaths();
681
+ this.saveProjectionPreference();
682
+ }
683
+ }
514
684
  }
515
685
  this.status = 'loaded';
516
686
 
@@ -530,15 +700,22 @@ module.exports = app => app.component('models', {
530
700
  this.shouldShowCreateModal = false;
531
701
  await this.getDocuments();
532
702
  },
533
- initializeDocumentData() {
534
- this.shouldShowCreateModal = true;
535
- },
536
703
  filterDocument(doc) {
704
+ if (this.filteredPaths.length === 0) return doc;
537
705
  const filteredDoc = {};
538
706
  for (let i = 0; i < this.filteredPaths.length; i++) {
539
707
  const path = this.filteredPaths[i].path;
540
708
  const value = mpath.get(path, doc);
541
- mpath.set(path, value, filteredDoc);
709
+ mpath.set(path, value, filteredDoc, function(cur, path, val) {
710
+ if (arguments.length === 2) {
711
+ if (cur[path] == null) {
712
+ cur[path] = {};
713
+ }
714
+ return cur[path];
715
+ }
716
+ cur[path] = val;
717
+ return val;
718
+ });
542
719
  }
543
720
  return filteredDoc;
544
721
  },
@@ -546,23 +723,47 @@ module.exports = app => app.component('models', {
546
723
  if (this.status === 'loading' || this.loadedAllDocs) {
547
724
  return;
548
725
  }
549
- const container = this.$refs.documentsList;
550
- if (container.scrollHeight - container.clientHeight - 100 < container.scrollTop) {
551
- this.status = 'loading';
552
- const params = this.buildDocumentFetchParams({ skip: this.documents.length });
726
+ // Infinite scroll only applies to table/json views.
727
+ if (this.outputType !== 'table' && this.outputType !== 'json') {
728
+ return;
729
+ }
730
+ if (this.documents.length === 0) {
731
+ return;
732
+ }
733
+ const container = this.outputType === 'table'
734
+ ? this.$refs.documentsScrollContainer
735
+ : this.$refs.documentsContainerScroll;
736
+ if (!container || container.scrollHeight <= 0) {
737
+ return;
738
+ }
739
+ const threshold = 150;
740
+ const nearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - threshold;
741
+ if (!nearBottom) {
742
+ return;
743
+ }
744
+ this.loadingMore = true;
745
+ this.status = 'loading';
746
+ try {
747
+ const skip = this.documents.length;
748
+ const params = this.buildDocumentFetchParams({ skip });
553
749
  const { docs } = await api.Model.getDocuments(params);
554
750
  if (docs.length < limit) {
555
751
  this.loadedAllDocs = true;
556
752
  }
557
753
  this.documents.push(...docs);
754
+ } finally {
755
+ this.loadingMore = false;
558
756
  this.status = 'loaded';
559
757
  }
758
+ this.$nextTick(() => this.checkIfScrolledToBottom());
560
759
  },
561
760
  async sortDocs(num, path) {
562
761
  let sorted = false;
563
762
  if (this.sortBy[path] == num) {
564
763
  sorted = true;
565
764
  delete this.query.sort;
765
+ delete this.query.sortKey;
766
+ delete this.query.sortDirection;
566
767
  this.$router.push({ query: this.query });
567
768
  }
568
769
  for (const key in this.sortBy) {
@@ -570,9 +771,13 @@ module.exports = app => app.component('models', {
570
771
  }
571
772
  if (!sorted) {
572
773
  this.sortBy[path] = num;
573
- this.query.sort = `{${path}:${num}}`;
774
+ this.query.sortKey = path;
775
+ this.query.sortDirection = num;
776
+ delete this.query.sort;
574
777
  this.$router.push({ query: this.query });
575
778
  }
779
+ this.documents = [];
780
+ this.loadedAllDocs = false;
576
781
  await this.loadMoreDocuments();
577
782
  },
578
783
  async search(searchText) {
@@ -609,6 +814,23 @@ module.exports = app => app.component('models', {
609
814
  closeActionsMenu() {
610
815
  this.showActionsMenu = false;
611
816
  },
817
+ toggleProjectionMenu() {
818
+ const next = !this.isProjectionMenuSelected;
819
+ this.isProjectionMenuSelected = next;
820
+
821
+ // Because the route-view is keyed on `$route.fullPath`, query changes remount this component.
822
+ // Persist projection UI state in the URL so Reset/Suggest don't turn the mode off.
823
+ if (next) {
824
+ this.query[PROJECTION_MODE_QUERY_KEY] = '1';
825
+ if (this.outputType === 'map') {
826
+ this.setOutputType('json');
827
+ }
828
+ } else {
829
+ delete this.query[PROJECTION_MODE_QUERY_KEY];
830
+ }
831
+
832
+ this.$router.push({ query: this.query });
833
+ },
612
834
  async openCollectionInfo() {
613
835
  this.closeActionsMenu();
614
836
  this.shouldShowCollectionInfoModal = true;
@@ -709,160 +931,401 @@ module.exports = app => app.component('models', {
709
931
  }
710
932
  return formatValue(value / 1000000000, 'B');
711
933
  },
712
- checkIndexLocation(indexName) {
713
- if (this.schemaIndexes.find(x => x.name == indexName) && this.mongoDBIndexes.find(x => x.name == indexName)) {
714
- return 'text-gray-500';
715
- } else if (this.schemaIndexes.find(x => x.name == indexName)) {
716
- return 'text-forest-green-500';
717
- } else {
718
- return 'text-valencia-500';
719
- }
720
- },
721
934
  async getDocuments() {
722
- // Track recently viewed model
723
- this.trackRecentModel(this.currentModel);
935
+ this.loadingMore = false;
936
+ this.status = 'loading';
937
+ try {
938
+ // Track recently viewed model
939
+ this.trackRecentModel(this.currentModel);
724
940
 
725
- // Clear previous data
726
- this.documents = [];
727
- this.schemaPaths = [];
728
- this.numDocuments = null;
729
- this.loadedAllDocs = false;
730
- this.lastSelectedIndex = null;
941
+ // Clear previous data
942
+ this.documents = [];
943
+ this.schemaPaths = [];
944
+ this.numDocuments = null;
945
+ this.loadedAllDocs = false;
946
+ this.lastSelectedIndex = null;
731
947
 
732
- let docsCount = 0;
733
- let schemaPathsReceived = false;
948
+ let docsCount = 0;
949
+ let schemaPathsReceived = false;
734
950
 
735
- // Use async generator to stream SSEs
736
- const params = this.buildDocumentFetchParams();
737
- for await (const event of api.Model.getDocumentsStream(params)) {
738
- if (event.schemaPaths && !schemaPathsReceived) {
951
+ // Use async generator to stream SSEs
952
+ const params = this.buildDocumentFetchParams();
953
+ for await (const event of api.Model.getDocumentsStream(params)) {
954
+ if (event.schemaPaths && !schemaPathsReceived) {
739
955
  // Sort schemaPaths with _id first
740
- this.schemaPaths = Object.keys(event.schemaPaths).sort((k1, k2) => {
741
- if (k1 === '_id' && k2 !== '_id') {
742
- return -1;
956
+ this.schemaPaths = Object.keys(event.schemaPaths).sort((k1, k2) => {
957
+ if (k1 === '_id' && k2 !== '_id') {
958
+ return -1;
959
+ }
960
+ if (k1 !== '_id' && k2 === '_id') {
961
+ return 1;
962
+ }
963
+ return 0;
964
+ }).map(key => event.schemaPaths[key]);
965
+ this.shouldExport = {};
966
+ for (const { path } of this.schemaPaths) {
967
+ this.shouldExport[path] = true;
743
968
  }
744
- if (k1 !== '_id' && k2 === '_id') {
745
- return 1;
969
+ const shouldUseSavedProjection = this.isProjectionMenuSelected === true;
970
+ const savedPaths = shouldUseSavedProjection ? this.loadProjectionPreference() : null;
971
+ if (savedPaths === null) {
972
+ this.applyDefaultProjection(event.suggestedFields);
973
+ if (shouldUseSavedProjection) {
974
+ this.saveProjectionPreference();
975
+ }
976
+ } else if (Array.isArray(savedPaths) && savedPaths.length === 0) {
977
+ this.filteredPaths = [];
978
+ this.projectionText = '';
979
+ if (shouldUseSavedProjection) {
980
+ this.saveProjectionPreference();
981
+ }
982
+ } else if (savedPaths && savedPaths.length > 0) {
983
+ this.filteredPaths = savedPaths
984
+ .map(path => this.schemaPaths.find(p => p.path === path))
985
+ .filter(Boolean);
986
+ if (this.filteredPaths.length === 0) {
987
+ this.applyDefaultProjection(event.suggestedFields);
988
+ if (shouldUseSavedProjection) {
989
+ this.saveProjectionPreference();
990
+ }
991
+ }
992
+ } else {
993
+ this.applyDefaultProjection(event.suggestedFields);
994
+ if (shouldUseSavedProjection) {
995
+ this.saveProjectionPreference();
996
+ }
746
997
  }
747
- return 0;
748
- }).map(key => event.schemaPaths[key]);
749
- this.shouldExport = {};
750
- for (const { path } of this.schemaPaths) {
751
- this.shouldExport[path] = true;
998
+ this.selectedPaths = [...this.filteredPaths];
999
+ this.syncProjectionFromPaths();
1000
+ schemaPathsReceived = true;
1001
+ }
1002
+ if (event.numDocs !== undefined) {
1003
+ this.numDocuments = event.numDocs;
1004
+ }
1005
+ if (event.document) {
1006
+ this.documents.push(event.document);
1007
+ docsCount++;
1008
+ }
1009
+ if (event.message) {
1010
+ throw new Error(event.message);
752
1011
  }
753
- this.filteredPaths = [...this.schemaPaths];
754
- this.selectedPaths = [...this.schemaPaths];
755
- schemaPathsReceived = true;
756
- }
757
- if (event.numDocs !== undefined) {
758
- this.numDocuments = event.numDocs;
759
- }
760
- if (event.document) {
761
- this.documents.push(event.document);
762
- docsCount++;
763
- }
764
- if (event.message) {
765
- this.status = 'loaded';
766
- throw new Error(event.message);
767
1012
  }
768
- }
769
1013
 
770
- if (docsCount < limit) {
771
- this.loadedAllDocs = true;
1014
+ if (docsCount < limit) {
1015
+ this.loadedAllDocs = true;
1016
+ }
1017
+ } finally {
1018
+ this.status = 'loaded';
772
1019
  }
1020
+ this.$nextTick(() => {
1021
+ this.restoreScrollPosition();
1022
+ if (!this.suppressScrollCheck) {
1023
+ this.checkIfScrolledToBottom();
1024
+ }
1025
+ this.suppressScrollCheck = false;
1026
+ });
773
1027
  },
774
1028
  async loadMoreDocuments() {
775
- let docsCount = 0;
776
- let numDocsReceived = false;
1029
+ const isLoadingMore = this.documents.length > 0;
1030
+ if (isLoadingMore) {
1031
+ this.loadingMore = true;
1032
+ }
1033
+ this.status = 'loading';
1034
+ try {
1035
+ let docsCount = 0;
1036
+ let numDocsReceived = false;
777
1037
 
778
- // Use async generator to stream SSEs
779
- const params = this.buildDocumentFetchParams({ skip: this.documents.length });
780
- for await (const event of api.Model.getDocumentsStream(params)) {
781
- if (event.numDocs !== undefined && !numDocsReceived) {
782
- this.numDocuments = event.numDocs;
783
- numDocsReceived = true;
1038
+ // Use async generator to stream SSEs
1039
+ const params = this.buildDocumentFetchParams({ skip: this.documents.length });
1040
+ for await (const event of api.Model.getDocumentsStream(params)) {
1041
+ if (event.numDocs !== undefined && !numDocsReceived) {
1042
+ this.numDocuments = event.numDocs;
1043
+ numDocsReceived = true;
1044
+ }
1045
+ if (event.document) {
1046
+ this.documents.push(event.document);
1047
+ docsCount++;
1048
+ }
1049
+ if (event.message) {
1050
+ throw new Error(event.message);
1051
+ }
784
1052
  }
785
- if (event.document) {
786
- this.documents.push(event.document);
787
- docsCount++;
1053
+
1054
+ if (docsCount < limit) {
1055
+ this.loadedAllDocs = true;
788
1056
  }
789
- if (event.message) {
790
- this.status = 'loaded';
791
- throw new Error(event.message);
1057
+ } finally {
1058
+ this.loadingMore = false;
1059
+ this.status = 'loaded';
1060
+ }
1061
+ this.$nextTick(() => this.checkIfScrolledToBottom());
1062
+ },
1063
+ applyDefaultProjection(suggestedFields) {
1064
+ if (Array.isArray(suggestedFields) && suggestedFields.length > 0) {
1065
+ this.filteredPaths = suggestedFields
1066
+ .map(path => this.schemaPaths.find(p => p.path === path))
1067
+ .filter(Boolean);
1068
+ }
1069
+ if (!this.filteredPaths || this.filteredPaths.length === 0) {
1070
+ this.filteredPaths = this.schemaPaths.slice(0, DEFAULT_FIRST_N_FIELDS);
1071
+ }
1072
+ if (this.filteredPaths.length === 0) {
1073
+ this.filteredPaths = this.schemaPaths.filter(p => p.path === '_id');
1074
+ }
1075
+ },
1076
+ loadProjectionPreference() {
1077
+ if (typeof window === 'undefined' || !window.localStorage || !this.currentModel) {
1078
+ return null;
1079
+ }
1080
+ const key = PROJECTION_STORAGE_KEY_PREFIX + this.currentModel;
1081
+ const stored = window.localStorage.getItem(key);
1082
+ if (stored === null || stored === undefined) {
1083
+ return null;
1084
+ }
1085
+ if (stored === '') {
1086
+ return [];
1087
+ }
1088
+ try {
1089
+ const parsed = JSON.parse(stored);
1090
+ if (Array.isArray(parsed)) {
1091
+ return parsed.map(x => String(x).trim()).filter(Boolean);
792
1092
  }
1093
+ } catch (e) {
1094
+ return null;
793
1095
  }
794
-
795
- if (docsCount < limit) {
796
- this.loadedAllDocs = true;
1096
+ return null;
1097
+ },
1098
+ saveProjectionPreference() {
1099
+ if (typeof window === 'undefined' || !window.localStorage || !this.currentModel) {
1100
+ return;
797
1101
  }
1102
+ const key = PROJECTION_STORAGE_KEY_PREFIX + this.currentModel;
1103
+ const paths = this.filteredPaths.map(p => p.path);
1104
+ window.localStorage.setItem(key, JSON.stringify(paths));
798
1105
  },
799
- addOrRemove(path) {
800
- const exists = this.selectedPaths.findIndex(x => x.path == path.path);
801
- if (exists > 0) { // remove
802
- this.selectedPaths.splice(exists, 1);
803
- } else { // add
804
- this.selectedPaths.push(path);
805
- this.selectedPaths = Object.keys(this.selectedPaths).sort((k1, k2) => {
806
- if (k1 === '_id' && k2 !== '_id') {
807
- return -1;
808
- }
809
- if (k1 !== '_id' && k2 === '_id') {
810
- return 1;
1106
+ clearProjection() {
1107
+ // Keep current filter input in sync with the URL so projection reset
1108
+ // does not unintentionally wipe the filter on remount.
1109
+ this.syncFilterToQuery();
1110
+ this.filteredPaths = [];
1111
+ this.selectedPaths = [];
1112
+ this.projectionText = '';
1113
+ this.updateProjectionQuery();
1114
+ this.saveProjectionPreference();
1115
+ },
1116
+ resetFilter() {
1117
+ // Reuse the existing "apply filter + update URL" flow.
1118
+ this.search('');
1119
+ },
1120
+ syncFilterToQuery() {
1121
+ if (typeof this.searchText === 'string' && this.searchText.trim().length > 0) {
1122
+ this.query.search = this.searchText;
1123
+ } else {
1124
+ delete this.query.search;
1125
+ }
1126
+ },
1127
+ applyDefaultProjectionColumns() {
1128
+ if (!this.schemaPaths || this.schemaPaths.length === 0) return;
1129
+ const pathNames = this.schemaPaths.map(p => p.path);
1130
+ this.applyDefaultProjection(pathNames.slice(0, DEFAULT_FIRST_N_FIELDS));
1131
+ this.selectedPaths = [...this.filteredPaths];
1132
+ this.syncProjectionFromPaths();
1133
+ this.updateProjectionQuery();
1134
+ this.saveProjectionPreference();
1135
+ },
1136
+ initProjection(ev) {
1137
+ if (!this.projectionText || !this.projectionText.trim()) {
1138
+ this.projectionText = '';
1139
+ this.$nextTick(() => {
1140
+ if (ev && ev.target) {
1141
+ ev.target.setSelectionRange(0, 0);
811
1142
  }
812
- return 0;
813
- }).map(key => this.selectedPaths[key]);
1143
+ });
814
1144
  }
815
1145
  },
816
- openFieldSelection() {
817
- if (this.$route.query?.fields) {
818
- this.selectedPaths.length = 0;
819
- console.log('there are fields in play', this.$route.query.fields);
820
- const fields = this.$route.query.fields.split(',');
821
- for (let i = 0; i < fields.length; i++) {
822
- this.selectedPaths.push({ path: fields[i] });
1146
+ syncProjectionFromPaths() {
1147
+ if (this.filteredPaths.length === 0) {
1148
+ this.projectionText = '';
1149
+ return;
1150
+ }
1151
+ // String-only projection syntax: `field1 field2` and `-field` for exclusions.
1152
+ // Since `filteredPaths` represents the final include set, we serialize as space-separated fields.
1153
+ this.projectionText = this.filteredPaths.map(p => p.path).join(' ');
1154
+ },
1155
+ parseProjectionInput(text) {
1156
+ if (!text || typeof text !== 'string') {
1157
+ return [];
1158
+ }
1159
+ const trimmed = text.trim();
1160
+ if (!trimmed) {
1161
+ return [];
1162
+ }
1163
+ const normalizeKey = (key) => String(key).trim();
1164
+
1165
+ // String-only projection syntax:
1166
+ // name email
1167
+ // -password (exclusion-only)
1168
+ // +email (inclusion-only)
1169
+ //
1170
+ // Brace/object syntax is intentionally NOT supported.
1171
+ if (trimmed.startsWith('{') || trimmed.endsWith('}')) {
1172
+ return null;
1173
+ }
1174
+
1175
+ const tokens = trimmed.split(/[,\s]+/).filter(Boolean);
1176
+ if (tokens.length === 0) return [];
1177
+
1178
+ const includeKeys = [];
1179
+ const excludeKeys = [];
1180
+
1181
+ for (const rawToken of tokens) {
1182
+ const token = rawToken.trim();
1183
+ if (!token) continue;
1184
+
1185
+ const prefix = token[0];
1186
+ if (prefix === '-') {
1187
+ const path = token.slice(1).trim();
1188
+ if (!path) return null;
1189
+ excludeKeys.push(path);
1190
+ } else if (prefix === '+') {
1191
+ const path = token.slice(1).trim();
1192
+ if (!path) return null;
1193
+ includeKeys.push(path);
1194
+ } else {
1195
+ includeKeys.push(token);
1196
+ }
1197
+ }
1198
+
1199
+ if (includeKeys.length > 0 && excludeKeys.length > 0) {
1200
+ // Support subtractive edits on an existing projection string, e.g.
1201
+ // `name email createdAt -email` -> `name createdAt`.
1202
+ const includeSet = new Set(includeKeys.map(normalizeKey));
1203
+ for (const path of excludeKeys) {
1204
+ includeSet.delete(normalizeKey(path));
1205
+ }
1206
+ return Array.from(includeSet);
1207
+ }
1208
+
1209
+ if (excludeKeys.length > 0) {
1210
+ const excludeSet = new Set(excludeKeys.map(normalizeKey));
1211
+ return this.schemaPaths.map(p => p.path).filter(p => !excludeSet.has(p));
1212
+ }
1213
+
1214
+ return includeKeys.map(normalizeKey);
1215
+ },
1216
+ applyProjectionFromInput() {
1217
+ const paths = this.parseProjectionInput(this.projectionText);
1218
+ if (paths === null) {
1219
+ this.syncProjectionFromPaths();
1220
+ return;
1221
+ }
1222
+ if (paths.length === 0) {
1223
+ this.filteredPaths = this.schemaPaths.filter(p => p.path === '_id');
1224
+ if (this.filteredPaths.length === 0 && this.schemaPaths.length > 0) {
1225
+ const idPath = this.schemaPaths.find(p => p.path === '_id');
1226
+ this.filteredPaths = idPath ? [idPath] : [this.schemaPaths[0]];
823
1227
  }
824
1228
  } else {
825
- this.selectedPaths = [{ path: '_id' }];
1229
+ this.filteredPaths = paths.map(path => this.schemaPaths.find(p => p.path === path)).filter(Boolean);
1230
+ const validPaths = new Set(this.schemaPaths.map(p => p.path));
1231
+ for (const path of paths) {
1232
+ if (validPaths.has(path) && !this.filteredPaths.find(p => p.path === path)) {
1233
+ this.filteredPaths.push(this.schemaPaths.find(p => p.path === path));
1234
+ }
1235
+ }
1236
+ if (this.filteredPaths.length === 0) {
1237
+ this.filteredPaths = this.schemaPaths.filter(p => p.path === '_id');
1238
+ }
826
1239
  }
827
- this.shouldShowFieldModal = true;
1240
+ this.selectedPaths = [...this.filteredPaths];
1241
+ this.syncProjectionFromPaths();
1242
+ this.updateProjectionQuery();
1243
+ this.saveProjectionPreference();
828
1244
  },
829
- filterDocuments() {
830
- if (this.selectedPaths.length > 0) {
831
- this.filteredPaths = [...this.selectedPaths];
1245
+ updateProjectionQuery() {
1246
+ const paths = this.filteredPaths.map(x => x.path).filter(Boolean);
1247
+ if (paths.length > 0) {
1248
+ this.query.fields = JSON.stringify(paths);
832
1249
  } else {
833
- this.filteredPaths.length = 0;
1250
+ delete this.query.fields;
834
1251
  }
835
- this.shouldShowFieldModal = false;
836
- const selectedParams = this.filteredPaths.map(x => x.path).join(',');
837
- this.query.fields = selectedParams;
838
1252
  this.$router.push({ query: this.query });
839
1253
  },
840
- resetDocuments() {
841
- this.selectedPaths = [...this.filteredPaths];
842
- this.query.fields = {};
843
- this.$router.push({ query: this.query });
844
- this.shouldShowFieldModal = false;
1254
+ removeField(schemaPath) {
1255
+ if (this.outputType === 'table' && this.$refs.documentsScrollContainer) {
1256
+ this.scrollTopToRestore = this.$refs.documentsScrollContainer.scrollTop;
1257
+ this.suppressScrollCheck = true;
1258
+ // Persist for remount caused by query changes.
1259
+ if (typeof window !== 'undefined') {
1260
+ window.__studioModelsScrollTopToRestore = this.scrollTopToRestore;
1261
+ window.__studioModelsSuppressScrollCheck = true;
1262
+ }
1263
+ }
1264
+ const index = this.filteredPaths.findIndex(p => p.path === schemaPath.path);
1265
+ if (index !== -1) {
1266
+ this.filteredPaths.splice(index, 1);
1267
+ if (this.filteredPaths.length === 0) {
1268
+ const idPath = this.schemaPaths.find(p => p.path === '_id');
1269
+ this.filteredPaths = idPath ? [idPath] : [];
1270
+ }
1271
+ this.syncProjectionFromPaths();
1272
+ this.updateProjectionQuery();
1273
+ this.saveProjectionPreference();
1274
+ }
845
1275
  },
846
- deselectAll() {
847
- this.selectedPaths = [];
1276
+ addField(schemaPath) {
1277
+ if (!this.filteredPaths.find(p => p.path === schemaPath.path)) {
1278
+ if (this.outputType === 'table' && this.$refs.documentsScrollContainer) {
1279
+ this.scrollTopToRestore = this.$refs.documentsScrollContainer.scrollTop;
1280
+ this.suppressScrollCheck = true;
1281
+ // Persist for remount caused by query changes.
1282
+ if (typeof window !== 'undefined') {
1283
+ window.__studioModelsScrollTopToRestore = this.scrollTopToRestore;
1284
+ window.__studioModelsSuppressScrollCheck = true;
1285
+ }
1286
+ }
1287
+ this.filteredPaths.push(schemaPath);
1288
+ this.filteredPaths.sort((a, b) => {
1289
+ if (a.path === '_id') return -1;
1290
+ if (b.path === '_id') return 1;
1291
+ return 0;
1292
+ });
1293
+ this.syncProjectionFromPaths();
1294
+ this.updateProjectionQuery();
1295
+ this.saveProjectionPreference();
1296
+ this.showAddFieldDropdown = false;
1297
+ this.addFieldFilterText = '';
1298
+ }
848
1299
  },
849
- selectAll() {
850
- this.selectedPaths = [...this.schemaPaths];
1300
+ restoreScrollPosition() {
1301
+ if (this.outputType !== 'table') return;
1302
+ if (this.scrollTopToRestore == null) return;
1303
+ const container = this.$refs.documentsScrollContainer;
1304
+ if (!container) return;
1305
+ container.scrollTop = this.scrollTopToRestore;
1306
+ this.scrollTopToRestore = null;
851
1307
  },
852
- isSelected(path) {
853
- return this.selectedPaths.find(x => x.path == path);
1308
+ toggleAddFieldDropdown() {
1309
+ this.showAddFieldDropdown = !this.showAddFieldDropdown;
1310
+ if (this.showAddFieldDropdown) {
1311
+ this.addFieldFilterText = '';
1312
+ this.$nextTick(() => this.$refs.addFieldFilterInput?.focus());
1313
+ }
854
1314
  },
855
1315
  getComponentForPath(schemaPath) {
1316
+ if (!schemaPath || typeof schemaPath !== 'object') {
1317
+ return 'list-mixed';
1318
+ }
856
1319
  if (schemaPath.instance === 'Array') {
857
1320
  return 'list-array';
858
1321
  }
859
1322
  if (schemaPath.instance === 'String') {
860
1323
  return 'list-string';
861
1324
  }
862
- if (schemaPath.instance == 'Embedded') {
1325
+ if (schemaPath.instance === 'Embedded') {
863
1326
  return 'list-subdocument';
864
1327
  }
865
- if (schemaPath.instance == 'Mixed') {
1328
+ if (schemaPath.instance === 'Mixed') {
866
1329
  return 'list-mixed';
867
1330
  }
868
1331
  return 'list-default';
@@ -887,6 +1350,31 @@ module.exports = app => app.component('models', {
887
1350
  this.edittingDoc = null;
888
1351
  this.$toast.success('Document updated!');
889
1352
  },
1353
+ copyCellValue(value) {
1354
+ const text = value == null ? '' : (typeof value === 'object' ? JSON.stringify(value) : String(value));
1355
+ if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
1356
+ navigator.clipboard.writeText(text).then(() => {
1357
+ this.$toast.success('Copied to clipboard');
1358
+ }).catch(() => {
1359
+ this.fallbackCopyText(text);
1360
+ });
1361
+ } else {
1362
+ this.fallbackCopyText(text);
1363
+ }
1364
+ },
1365
+ fallbackCopyText(text) {
1366
+ try {
1367
+ const el = document.createElement('textarea');
1368
+ el.value = text;
1369
+ document.body.appendChild(el);
1370
+ el.select();
1371
+ document.execCommand('copy');
1372
+ document.body.removeChild(el);
1373
+ this.$toast.success('Copied to clipboard');
1374
+ } catch (err) {
1375
+ this.$toast.error('Copy failed');
1376
+ }
1377
+ },
890
1378
  handleDocumentClick(document, event) {
891
1379
  if (this.selectMultiple) {
892
1380
  this.handleDocumentSelection(document, event);