@mongoosejs/studio 0.4.0 → 0.4.1

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.
@@ -16,58 +16,126 @@ const OUTPUT_TYPE_STORAGE_KEY = 'studio:model-output-type';
16
16
  const SELECTED_GEO_FIELD_STORAGE_KEY = 'studio:model-selected-geo-field';
17
17
  const SHOW_ROW_NUMBERS_STORAGE_KEY = 'studio:model-show-row-numbers';
18
18
  const PROJECTION_MODE_QUERY_KEY = 'projectionMode';
19
+ const PROJECTION_INPUT_QUERY_KEY = 'projectionInput';
19
20
  const RECENTLY_VIEWED_MODELS_KEY = 'studio:recently-viewed-models';
20
21
  const MAX_RECENT_MODELS = 4;
22
+ const FOCUS_AFTER_MODELS_REMOUNT_KEY = '__studioModelsFocusAfterRemount';
21
23
 
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 [];
24
+ function setModelsRemountFocusIntent(target) {
25
+ if (typeof window === 'undefined') {
26
+ return;
31
27
  }
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);
28
+ window[FOCUS_AFTER_MODELS_REMOUNT_KEY] = target;
29
+ }
30
+
31
+ function takeModelsRemountFocusIntent() {
32
+ if (typeof window === 'undefined') {
33
+ return null;
40
34
  }
41
- if (parsed != null && typeof parsed === 'object') {
42
- return Object.keys(parsed).filter(k =>
43
- Object.prototype.hasOwnProperty.call(parsed, k) && parsed[k]
44
- );
35
+ const v = window[FOCUS_AFTER_MODELS_REMOUNT_KEY] || null;
36
+ delete window[FOCUS_AFTER_MODELS_REMOUNT_KEY];
37
+ return v;
38
+ }
39
+
40
+ /**
41
+ * Split projection list tokens on commas/whitespace, merging a lone `-` or `+` with the next
42
+ * chunk so `- password` and `-password` both exclude `password`.
43
+ * @returns {string[]|null} null when a dangling `+`/`-` has no following field name.
44
+ */
45
+ function normalizeProjectionTokens(trimmed) {
46
+ const rawTokens = trimmed.split(/[,\s]+/).filter(Boolean);
47
+ const tokens = [];
48
+ for (let i = 0; i < rawTokens.length; i++) {
49
+ const t = rawTokens[i];
50
+ if (t === '-' || t === '+') {
51
+ if (i + 1 >= rawTokens.length) {
52
+ return null;
53
+ }
54
+ tokens.push(t + rawTokens[++i]);
55
+ } else {
56
+ tokens.push(t);
57
+ }
45
58
  }
46
- return [];
59
+ return tokens;
47
60
  }
48
61
 
49
- /** Pass through a valid JSON `fields` string for Model.getDocuments / getDocumentsStream. */
50
- function normalizeFieldsParamForApi(fieldsStr) {
51
- if (fieldsStr == null || fieldsStr === '') {
62
+ /**
63
+ * Parse projection object syntax like:
64
+ * { deletedAt: 1 }
65
+ * { "deletedAt": true, email: 1 }
66
+ * { password: 0 }
67
+ *
68
+ * Supported values are 1/0/true/false (quoted or unquoted).
69
+ * Returns include paths, exclude-derived include paths, or null when invalid.
70
+ */
71
+ function parseProjectionObjectNotation(trimmed, schemaPaths) {
72
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
52
73
  return null;
53
74
  }
54
- const s = typeof fieldsStr === 'string' ? fieldsStr : String(fieldsStr);
55
- const trimmed = s.trim();
56
- if (!trimmed) {
57
- return null;
75
+ const body = trimmed.slice(1, -1).trim();
76
+ if (!body) {
77
+ return [];
58
78
  }
59
- try {
60
- const parsed = JSON.parse(trimmed);
61
- if (Array.isArray(parsed)) {
62
- return trimmed;
79
+
80
+ const pairRe = /(?:^|,)\s*(?:"([^"]+)"|'([^']+)'|([a-zA-Z_$][\w.$]*))\s*:\s*("[^"]*"|'[^']*'|[^,]+?)\s*(?=,|$)/g;
81
+ const includeKeys = [];
82
+ const excludeKeys = [];
83
+ let matchedChars = '';
84
+ let match;
85
+
86
+ while ((match = pairRe.exec(body)) !== null) {
87
+ matchedChars += match[0];
88
+ const key = (match[1] || match[2] || match[3] || '').trim();
89
+ const rawValue = (match[4] || '').trim();
90
+ if (!key || !rawValue) {
91
+ return null;
63
92
  }
64
- if (parsed != null && typeof parsed === 'object' && !Array.isArray(parsed)) {
65
- return trimmed;
93
+
94
+ const valueLower = rawValue.replace(/^['"]|['"]$/g, '').trim().toLowerCase();
95
+ const isInclude = valueLower === '1' || valueLower === 'true';
96
+ const isExclude = valueLower === '0' || valueLower === 'false';
97
+ if (!isInclude && !isExclude) {
98
+ return null;
66
99
  }
67
- } catch (e) {
100
+ if (isInclude) {
101
+ includeKeys.push(key);
102
+ } else {
103
+ excludeKeys.push(key);
104
+ }
105
+ }
106
+
107
+ // Reject malformed object strings where regex skipped segments.
108
+ const normalizedBody = body.replace(/\s+/g, '');
109
+ const normalizedMatched = matchedChars.replace(/\s+/g, '').replace(/^,/, '');
110
+ if (!normalizedMatched || normalizedMatched !== normalizedBody) {
68
111
  return null;
69
112
  }
70
- return null;
113
+
114
+ const normalizeKey = (key) => String(key).trim();
115
+ if (includeKeys.length > 0 && excludeKeys.length > 0) {
116
+ const includeSet = new Set(includeKeys.map(normalizeKey));
117
+ for (const path of excludeKeys) {
118
+ const ex = normalizeKey(path);
119
+ for (const k of Array.from(includeSet)) {
120
+ if (k.toLowerCase() === ex.toLowerCase()) {
121
+ includeSet.delete(k);
122
+ }
123
+ }
124
+ }
125
+ if (includeSet.size === 0) {
126
+ return null;
127
+ }
128
+ return Array.from(includeSet);
129
+ }
130
+
131
+ if (excludeKeys.length > 0) {
132
+ const excludeNorm = excludeKeys.map(normalizeKey);
133
+ return schemaPaths
134
+ .map(p => p.path)
135
+ .filter(p => !excludeNorm.some(ex => ex === p || p.toLowerCase() === ex.toLowerCase()));
136
+ }
137
+
138
+ return includeKeys.map(normalizeKey);
71
139
  }
72
140
 
73
141
  module.exports = app => app.component('models', {
@@ -212,10 +280,19 @@ module.exports = app => app.component('models', {
212
280
  this.setOutputType('json');
213
281
  }
214
282
  this.$nextTick(() => {
215
- if (!this.isProjectionMenuSelected) return;
216
- const input = this.$refs.projectionInput;
217
- if (input && typeof input.focus === 'function') {
218
- input.focus();
283
+ const focusIntent = takeModelsRemountFocusIntent();
284
+ if (focusIntent === 'documentSearch') {
285
+ const ds = this.$refs.documentSearch;
286
+ if (ds && typeof ds.focusInput === 'function') {
287
+ ds.focusInput();
288
+ }
289
+ return;
290
+ }
291
+ if (focusIntent === 'projection' && this.isProjectionMenuSelected) {
292
+ const projectionSearch = this.$refs.projectionSearch;
293
+ if (projectionSearch && typeof projectionSearch.focusInput === 'function') {
294
+ projectionSearch.focusInput();
295
+ }
219
296
  }
220
297
  });
221
298
  },
@@ -622,21 +699,21 @@ module.exports = app => app.component('models', {
622
699
  params.searchText = this.searchText;
623
700
  }
624
701
 
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) {
702
+ const projectionInput = typeof this.query?.[PROJECTION_INPUT_QUERY_KEY] === 'string' ?
703
+ this.query[PROJECTION_INPUT_QUERY_KEY].trim() :
704
+ '';
705
+ if (projectionInput) {
706
+ params.projectionInput = projectionInput;
707
+ }
708
+
709
+ if (!projectionInput && this.isProjectionMenuSelected === true) {
630
710
  const fieldPaths = this.filteredPaths && this.filteredPaths.length > 0
631
711
  ? this.filteredPaths.map(p => p.path).filter(Boolean)
632
712
  : null;
633
713
  if (fieldPaths && fieldPaths.length > 0) {
634
- fieldsParam = JSON.stringify(fieldPaths);
714
+ params.projectionInput = fieldPaths.join(' ');
635
715
  }
636
716
  }
637
- if (fieldsParam) {
638
- params.fields = fieldsParam;
639
- }
640
717
 
641
718
  return params;
642
719
  },
@@ -671,13 +748,16 @@ module.exports = app => app.component('models', {
671
748
  if (this.currentModel != null) {
672
749
  await this.getDocuments();
673
750
  }
674
- if (this.$route.query?.fields) {
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
- }
751
+ const routeProjectionInput = this.$route.query?.[PROJECTION_INPUT_QUERY_KEY];
752
+ if (typeof routeProjectionInput === 'string' && routeProjectionInput.trim().length > 0) {
753
+ const urlPaths = this.normalizeProjectionPathsForDisplay(routeProjectionInput);
754
+ if (urlPaths !== null) {
755
+ const nextFiltered = urlPaths
756
+ .map(path => this.schemaPaths.find(p => p.path === path))
757
+ .filter(Boolean);
758
+ this.filteredPaths = nextFiltered;
759
+ this.selectedPaths = [...this.filteredPaths];
760
+ this.syncProjectionInputFromQueryOrPaths();
681
761
  }
682
762
  }
683
763
  this.status = 'loaded';
@@ -786,6 +866,7 @@ module.exports = app => app.component('models', {
786
866
  } else {
787
867
  delete this.query.search;
788
868
  }
869
+ setModelsRemountFocusIntent('documentSearch');
789
870
  const query = this.query;
790
871
  this.$router.push({ query });
791
872
  this.documents = [];
@@ -820,11 +901,13 @@ module.exports = app => app.component('models', {
820
901
  // Persist projection UI state in the URL so Reset/Suggest don't turn the mode off.
821
902
  if (next) {
822
903
  this.query[PROJECTION_MODE_QUERY_KEY] = '1';
904
+ setModelsRemountFocusIntent('projection');
823
905
  if (this.outputType === 'map') {
824
906
  this.setOutputType('json');
825
907
  }
826
908
  } else {
827
909
  delete this.query[PROJECTION_MODE_QUERY_KEY];
910
+ delete this.query[PROJECTION_INPUT_QUERY_KEY];
828
911
  delete this.query.fields;
829
912
  this.filteredPaths = [];
830
913
  this.selectedPaths = [];
@@ -970,14 +1053,25 @@ module.exports = app => app.component('models', {
970
1053
  }
971
1054
  const isProjectionModeOn = this.isProjectionMenuSelected === true;
972
1055
  if (isProjectionModeOn) {
973
- this.applyDefaultProjection(event.suggestedFields);
1056
+ const routeProjectionInput = this.$route.query?.[PROJECTION_INPUT_QUERY_KEY];
1057
+ if (typeof routeProjectionInput === 'string' && routeProjectionInput.trim().length > 0) {
1058
+ const projectionPaths = this.normalizeProjectionPathsForDisplay(routeProjectionInput);
1059
+ const fromProjectionInput = Array.isArray(projectionPaths) ?
1060
+ projectionPaths
1061
+ .map(path => this.schemaPaths.find(p => p.path === path))
1062
+ .filter(Boolean) :
1063
+ [];
1064
+ this.filteredPaths = fromProjectionInput;
1065
+ } else {
1066
+ this.filteredPaths = [];
1067
+ }
974
1068
  } else {
975
1069
  this.filteredPaths = [];
976
1070
  this.selectedPaths = [];
977
1071
  this.projectionText = '';
978
1072
  }
979
1073
  this.selectedPaths = [...this.filteredPaths];
980
- this.syncProjectionFromPaths();
1074
+ this.syncProjectionInputFromQueryOrPaths();
981
1075
  schemaPathsReceived = true;
982
1076
  }
983
1077
  if (event.numDocs !== undefined) {
@@ -1058,6 +1152,10 @@ module.exports = app => app.component('models', {
1058
1152
  // Keep current filter input in sync with the URL so projection reset
1059
1153
  // does not unintentionally wipe the filter on remount.
1060
1154
  this.syncFilterToQuery();
1155
+ if (this.$route.query?.fields) {
1156
+ delete this.query.fields;
1157
+ }
1158
+ delete this.query[PROJECTION_INPUT_QUERY_KEY];
1061
1159
  this.filteredPaths = [];
1062
1160
  this.selectedPaths = [];
1063
1161
  this.projectionText = '';
@@ -1080,18 +1178,9 @@ module.exports = app => app.component('models', {
1080
1178
  this.applyDefaultProjection(pathNames.slice(0, DEFAULT_FIRST_N_FIELDS));
1081
1179
  this.selectedPaths = [...this.filteredPaths];
1082
1180
  this.syncProjectionFromPaths();
1181
+ setModelsRemountFocusIntent('projection');
1083
1182
  this.updateProjectionQuery();
1084
1183
  },
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
1184
  syncProjectionFromPaths() {
1096
1185
  if (this.filteredPaths.length === 0) {
1097
1186
  this.projectionText = '';
@@ -1101,6 +1190,29 @@ module.exports = app => app.component('models', {
1101
1190
  // Since `filteredPaths` represents the final include set, we serialize as space-separated fields.
1102
1191
  this.projectionText = this.filteredPaths.map(p => p.path).join(' ');
1103
1192
  },
1193
+ syncProjectionInputFromQueryOrPaths() {
1194
+ const fromQuery = this.$route.query?.[PROJECTION_INPUT_QUERY_KEY];
1195
+ if (typeof fromQuery === 'string' && fromQuery.trim().length > 0) {
1196
+ this.projectionText = fromQuery;
1197
+ return;
1198
+ }
1199
+ this.syncProjectionFromPaths();
1200
+ },
1201
+ normalizeProjectionPathsForDisplay(text) {
1202
+ const paths = this.parseProjectionInput(text);
1203
+ if (paths == null) {
1204
+ return null;
1205
+ }
1206
+ const excludesId = this.projectionExplicitlyExcludesId(text);
1207
+ const hasIdSchemaPath = this.schemaPaths.some(p => p.path === '_id');
1208
+ if (hasIdSchemaPath && !excludesId) {
1209
+ const hasIdAlready = paths.some(p => String(p).toLowerCase() === '_id');
1210
+ if (!hasIdAlready) {
1211
+ paths.unshift('_id');
1212
+ }
1213
+ }
1214
+ return paths;
1215
+ },
1104
1216
  parseProjectionInput(text) {
1105
1217
  if (!text || typeof text !== 'string') {
1106
1218
  return [];
@@ -1111,17 +1223,21 @@ module.exports = app => app.component('models', {
1111
1223
  }
1112
1224
  const normalizeKey = (key) => String(key).trim();
1113
1225
 
1114
- // String-only projection syntax:
1226
+ // String projection syntax:
1115
1227
  // name email
1116
1228
  // -password (exclusion-only)
1117
1229
  // +email (inclusion-only)
1230
+ // { deletedAt: 1 } (object notation)
1118
1231
  //
1119
- // Brace/object syntax is intentionally NOT supported.
1120
- if (trimmed.startsWith('{') || trimmed.endsWith('}')) {
1121
- return null;
1232
+ // For object notation, return include paths based on 1/0/true/false values.
1233
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
1234
+ return parseProjectionObjectNotation(trimmed, this.schemaPaths);
1122
1235
  }
1123
1236
 
1124
- const tokens = trimmed.split(/[,\s]+/).filter(Boolean);
1237
+ const tokens = normalizeProjectionTokens(trimmed);
1238
+ if (tokens === null) {
1239
+ return null;
1240
+ }
1125
1241
  if (tokens.length === 0) return [];
1126
1242
 
1127
1243
  const includeKeys = [];
@@ -1150,22 +1266,78 @@ module.exports = app => app.component('models', {
1150
1266
  // `name email createdAt -email` -> `name createdAt`.
1151
1267
  const includeSet = new Set(includeKeys.map(normalizeKey));
1152
1268
  for (const path of excludeKeys) {
1153
- includeSet.delete(normalizeKey(path));
1269
+ const ex = normalizeKey(path);
1270
+ for (const k of Array.from(includeSet)) {
1271
+ if (k.toLowerCase() === ex.toLowerCase()) {
1272
+ includeSet.delete(k);
1273
+ }
1274
+ }
1275
+ }
1276
+ if (includeSet.size === 0) {
1277
+ return null;
1154
1278
  }
1155
1279
  return Array.from(includeSet);
1156
1280
  }
1157
1281
 
1158
1282
  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));
1283
+ const excludeNorm = excludeKeys.map(normalizeKey);
1284
+ return this.schemaPaths
1285
+ .map(p => p.path)
1286
+ .filter(p => !excludeNorm.some(ex => ex === p || p.toLowerCase() === ex.toLowerCase()));
1161
1287
  }
1162
1288
 
1163
1289
  return includeKeys.map(normalizeKey);
1164
1290
  },
1291
+ projectionExplicitlyExcludesId(text) {
1292
+ if (!text || typeof text !== 'string') {
1293
+ return false;
1294
+ }
1295
+ const trimmed = text.trim();
1296
+ if (!trimmed) {
1297
+ return false;
1298
+ }
1299
+
1300
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
1301
+ const body = trimmed.slice(1, -1).trim();
1302
+ if (!body) {
1303
+ return false;
1304
+ }
1305
+ const pairRe = /(?:^|,)\s*(?:"([^"]+)"|'([^']+)'|([a-zA-Z_$][\w.$]*))\s*:\s*("[^"]*"|'[^']*'|[^,]+?)\s*(?=,|$)/g;
1306
+ let match;
1307
+ while ((match = pairRe.exec(body)) !== null) {
1308
+ const key = (match[1] || match[2] || match[3] || '').trim();
1309
+ if (!key || key.toLowerCase() !== '_id') {
1310
+ continue;
1311
+ }
1312
+ const rawValue = (match[4] || '').trim();
1313
+ const valueLower = rawValue.replace(/^['"]|['"]$/g, '').trim().toLowerCase();
1314
+ if (valueLower === '0' || valueLower === 'false') {
1315
+ return true;
1316
+ }
1317
+ }
1318
+ return false;
1319
+ }
1320
+
1321
+ const tokens = normalizeProjectionTokens(trimmed);
1322
+ if (!tokens) {
1323
+ return false;
1324
+ }
1325
+ return tokens.some((rawToken) => {
1326
+ const token = rawToken.trim();
1327
+ return token.toLowerCase() === '-_id';
1328
+ });
1329
+ },
1165
1330
  applyProjectionFromInput() {
1166
- const paths = this.parseProjectionInput(this.projectionText);
1167
- if (paths === null) {
1168
- this.syncProjectionFromPaths();
1331
+ if (typeof this.projectionText === 'string' && this.projectionText.trim().length === 0) {
1332
+ this.filteredPaths = [];
1333
+ this.selectedPaths = [];
1334
+ setModelsRemountFocusIntent('projection');
1335
+ this.updateProjectionQuery();
1336
+ return;
1337
+ }
1338
+
1339
+ const paths = this.normalizeProjectionPathsForDisplay(this.projectionText);
1340
+ if (paths == null) {
1169
1341
  return;
1170
1342
  }
1171
1343
  if (paths.length === 0) {
@@ -1187,16 +1359,16 @@ module.exports = app => app.component('models', {
1187
1359
  }
1188
1360
  }
1189
1361
  this.selectedPaths = [...this.filteredPaths];
1190
- this.syncProjectionFromPaths();
1362
+ setModelsRemountFocusIntent('projection');
1191
1363
  this.updateProjectionQuery();
1192
1364
  },
1193
1365
  updateProjectionQuery() {
1194
- const paths = this.filteredPaths.map(x => x.path).filter(Boolean);
1195
- if (paths.length > 0) {
1196
- this.query.fields = JSON.stringify(paths);
1366
+ if (typeof this.projectionText === 'string' && this.projectionText.trim().length > 0) {
1367
+ this.query[PROJECTION_INPUT_QUERY_KEY] = this.projectionText;
1197
1368
  } else {
1198
- delete this.query.fields;
1369
+ delete this.query[PROJECTION_INPUT_QUERY_KEY];
1199
1370
  }
1371
+ delete this.query.fields;
1200
1372
  this.$router.push({ query: this.query });
1201
1373
  },
1202
1374
  removeField(schemaPath) {
@@ -0,0 +1,27 @@
1
+ <div class="relative flex-grow m-0">
2
+ <input
3
+ ref="projectionInput"
4
+ class="w-full min-w-0 rounded border border-edge bg-surface px-2 py-1.5 text-sm font-mono placeholder:text-content-tertiary focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
5
+ type="text"
6
+ placeholder="Projection: name email, or -password"
7
+ v-model="projectionText"
8
+ @focus="initProjection"
9
+ @input="handleInput"
10
+ @keydown="handleKeyDown"
11
+ @blur="hideAutocomplete"
12
+ />
13
+ <ul
14
+ v-if="autocompleteSuggestions.length"
15
+ class="absolute left-0 right-0 top-full z-[9999] mt-1 max-h-40 overflow-y-auto rounded border border-edge bg-surface shadow"
16
+ >
17
+ <li
18
+ v-for="(suggestion, index) in autocompleteSuggestions"
19
+ :key="suggestion"
20
+ class="cursor-pointer px-2 py-1 text-sm font-mono"
21
+ :class="{ 'bg-primary-subtle': index === autocompleteIndex }"
22
+ @mousedown.prevent="applySuggestion(index)"
23
+ >
24
+ {{ suggestion }}
25
+ </li>
26
+ </ul>
27
+ </div>