@mongoosejs/studio 0.3.3 → 0.3.5

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