@mongoosejs/studio 0.3.3 → 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,10 +700,8 @@ 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;
@@ -555,23 +723,47 @@ module.exports = app => app.component('models', {
555
723
  if (this.status === 'loading' || this.loadedAllDocs) {
556
724
  return;
557
725
  }
558
- const container = this.$refs.documentsList;
559
- if (container.scrollHeight - container.clientHeight - 100 < container.scrollTop) {
560
- this.status = 'loading';
561
- 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 });
562
749
  const { docs } = await api.Model.getDocuments(params);
563
750
  if (docs.length < limit) {
564
751
  this.loadedAllDocs = true;
565
752
  }
566
753
  this.documents.push(...docs);
754
+ } finally {
755
+ this.loadingMore = false;
567
756
  this.status = 'loaded';
568
757
  }
758
+ this.$nextTick(() => this.checkIfScrolledToBottom());
569
759
  },
570
760
  async sortDocs(num, path) {
571
761
  let sorted = false;
572
762
  if (this.sortBy[path] == num) {
573
763
  sorted = true;
574
764
  delete this.query.sort;
765
+ delete this.query.sortKey;
766
+ delete this.query.sortDirection;
575
767
  this.$router.push({ query: this.query });
576
768
  }
577
769
  for (const key in this.sortBy) {
@@ -579,9 +771,13 @@ module.exports = app => app.component('models', {
579
771
  }
580
772
  if (!sorted) {
581
773
  this.sortBy[path] = num;
582
- this.query.sort = `{${path}:${num}}`;
774
+ this.query.sortKey = path;
775
+ this.query.sortDirection = num;
776
+ delete this.query.sort;
583
777
  this.$router.push({ query: this.query });
584
778
  }
779
+ this.documents = [];
780
+ this.loadedAllDocs = false;
585
781
  await this.loadMoreDocuments();
586
782
  },
587
783
  async search(searchText) {
@@ -618,6 +814,23 @@ module.exports = app => app.component('models', {
618
814
  closeActionsMenu() {
619
815
  this.showActionsMenu = false;
620
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
+ },
621
834
  async openCollectionInfo() {
622
835
  this.closeActionsMenu();
623
836
  this.shouldShowCollectionInfoModal = true;
@@ -718,160 +931,401 @@ module.exports = app => app.component('models', {
718
931
  }
719
932
  return formatValue(value / 1000000000, 'B');
720
933
  },
721
- checkIndexLocation(indexName) {
722
- if (this.schemaIndexes.find(x => x.name == indexName) && this.mongoDBIndexes.find(x => x.name == indexName)) {
723
- return 'text-gray-500';
724
- } else if (this.schemaIndexes.find(x => x.name == indexName)) {
725
- return 'text-forest-green-500';
726
- } else {
727
- return 'text-valencia-500';
728
- }
729
- },
730
934
  async getDocuments() {
731
- // Track recently viewed model
732
- this.trackRecentModel(this.currentModel);
935
+ this.loadingMore = false;
936
+ this.status = 'loading';
937
+ try {
938
+ // Track recently viewed model
939
+ this.trackRecentModel(this.currentModel);
733
940
 
734
- // Clear previous data
735
- this.documents = [];
736
- this.schemaPaths = [];
737
- this.numDocuments = null;
738
- this.loadedAllDocs = false;
739
- 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;
740
947
 
741
- let docsCount = 0;
742
- let schemaPathsReceived = false;
948
+ let docsCount = 0;
949
+ let schemaPathsReceived = false;
743
950
 
744
- // Use async generator to stream SSEs
745
- const params = this.buildDocumentFetchParams();
746
- for await (const event of api.Model.getDocumentsStream(params)) {
747
- 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) {
748
955
  // Sort schemaPaths with _id first
749
- this.schemaPaths = Object.keys(event.schemaPaths).sort((k1, k2) => {
750
- if (k1 === '_id' && k2 !== '_id') {
751
- 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;
752
968
  }
753
- if (k1 !== '_id' && k2 === '_id') {
754
- 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
+ }
755
997
  }
756
- return 0;
757
- }).map(key => event.schemaPaths[key]);
758
- this.shouldExport = {};
759
- for (const { path } of this.schemaPaths) {
760
- 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);
761
1011
  }
762
- this.filteredPaths = [...this.schemaPaths];
763
- this.selectedPaths = [...this.schemaPaths];
764
- schemaPathsReceived = true;
765
- }
766
- if (event.numDocs !== undefined) {
767
- this.numDocuments = event.numDocs;
768
- }
769
- if (event.document) {
770
- this.documents.push(event.document);
771
- docsCount++;
772
- }
773
- if (event.message) {
774
- this.status = 'loaded';
775
- throw new Error(event.message);
776
1012
  }
777
- }
778
1013
 
779
- if (docsCount < limit) {
780
- this.loadedAllDocs = true;
1014
+ if (docsCount < limit) {
1015
+ this.loadedAllDocs = true;
1016
+ }
1017
+ } finally {
1018
+ this.status = 'loaded';
781
1019
  }
1020
+ this.$nextTick(() => {
1021
+ this.restoreScrollPosition();
1022
+ if (!this.suppressScrollCheck) {
1023
+ this.checkIfScrolledToBottom();
1024
+ }
1025
+ this.suppressScrollCheck = false;
1026
+ });
782
1027
  },
783
1028
  async loadMoreDocuments() {
784
- let docsCount = 0;
785
- 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;
786
1037
 
787
- // Use async generator to stream SSEs
788
- const params = this.buildDocumentFetchParams({ skip: this.documents.length });
789
- for await (const event of api.Model.getDocumentsStream(params)) {
790
- if (event.numDocs !== undefined && !numDocsReceived) {
791
- this.numDocuments = event.numDocs;
792
- 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
+ }
793
1052
  }
794
- if (event.document) {
795
- this.documents.push(event.document);
796
- docsCount++;
1053
+
1054
+ if (docsCount < limit) {
1055
+ this.loadedAllDocs = true;
797
1056
  }
798
- if (event.message) {
799
- this.status = 'loaded';
800
- 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);
801
1092
  }
1093
+ } catch (e) {
1094
+ return null;
802
1095
  }
803
-
804
- if (docsCount < limit) {
805
- this.loadedAllDocs = true;
1096
+ return null;
1097
+ },
1098
+ saveProjectionPreference() {
1099
+ if (typeof window === 'undefined' || !window.localStorage || !this.currentModel) {
1100
+ return;
806
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));
807
1105
  },
808
- addOrRemove(path) {
809
- const exists = this.selectedPaths.findIndex(x => x.path == path.path);
810
- if (exists > 0) { // remove
811
- this.selectedPaths.splice(exists, 1);
812
- } else { // add
813
- this.selectedPaths.push(path);
814
- this.selectedPaths = Object.keys(this.selectedPaths).sort((k1, k2) => {
815
- if (k1 === '_id' && k2 !== '_id') {
816
- return -1;
817
- }
818
- if (k1 !== '_id' && k2 === '_id') {
819
- 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);
820
1142
  }
821
- return 0;
822
- }).map(key => this.selectedPaths[key]);
1143
+ });
823
1144
  }
824
1145
  },
825
- openFieldSelection() {
826
- if (this.$route.query?.fields) {
827
- this.selectedPaths.length = 0;
828
- console.log('there are fields in play', this.$route.query.fields);
829
- const fields = this.$route.query.fields.split(',');
830
- for (let i = 0; i < fields.length; i++) {
831
- 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]];
832
1227
  }
833
1228
  } else {
834
- 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
+ }
835
1239
  }
836
- this.shouldShowFieldModal = true;
1240
+ this.selectedPaths = [...this.filteredPaths];
1241
+ this.syncProjectionFromPaths();
1242
+ this.updateProjectionQuery();
1243
+ this.saveProjectionPreference();
837
1244
  },
838
- filterDocuments() {
839
- if (this.selectedPaths.length > 0) {
840
- 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);
841
1249
  } else {
842
- this.filteredPaths.length = 0;
1250
+ delete this.query.fields;
843
1251
  }
844
- this.shouldShowFieldModal = false;
845
- const selectedParams = this.filteredPaths.map(x => x.path).join(',');
846
- this.query.fields = selectedParams;
847
1252
  this.$router.push({ query: this.query });
848
1253
  },
849
- resetDocuments() {
850
- this.selectedPaths = [...this.filteredPaths];
851
- this.query.fields = {};
852
- this.$router.push({ query: this.query });
853
- 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
+ }
854
1275
  },
855
- deselectAll() {
856
- 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
+ }
857
1299
  },
858
- selectAll() {
859
- 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;
860
1307
  },
861
- isSelected(path) {
862
- 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
+ }
863
1314
  },
864
1315
  getComponentForPath(schemaPath) {
1316
+ if (!schemaPath || typeof schemaPath !== 'object') {
1317
+ return 'list-mixed';
1318
+ }
865
1319
  if (schemaPath.instance === 'Array') {
866
1320
  return 'list-array';
867
1321
  }
868
1322
  if (schemaPath.instance === 'String') {
869
1323
  return 'list-string';
870
1324
  }
871
- if (schemaPath.instance == 'Embedded') {
1325
+ if (schemaPath.instance === 'Embedded') {
872
1326
  return 'list-subdocument';
873
1327
  }
874
- if (schemaPath.instance == 'Mixed') {
1328
+ if (schemaPath.instance === 'Mixed') {
875
1329
  return 'list-mixed';
876
1330
  }
877
1331
  return 'list-default';
@@ -896,6 +1350,31 @@ module.exports = app => app.component('models', {
896
1350
  this.edittingDoc = null;
897
1351
  this.$toast.success('Document updated!');
898
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
+ },
899
1378
  handleDocumentClick(document, event) {
900
1379
  if (this.selectMultiple) {
901
1380
  this.handleDocumentSelection(document, event);