diff --git a/backend/actions/Model/getDocuments.js b/backend/actions/Model/getDocuments.js
index f045bf3a..53873b60 100644
--- a/backend/actions/Model/getDocuments.js
+++ b/backend/actions/Model/getDocuments.js
@@ -4,6 +4,8 @@ const Archetype = require('archetype');
const removeSpecifiedPaths = require('../../helpers/removeSpecifiedPaths');
const evaluateFilter = require('../../helpers/evaluateFilter');
const getRefFromSchemaType = require('../../helpers/getRefFromSchemaType');
+const getSuggestedProjection = require('../../helpers/getSuggestedProjection');
+const parseFieldsParam = require('../../helpers/parseFieldsParam');
const authorize = require('../../authorize');
const GetDocumentsParams = new Archetype({
@@ -30,6 +32,9 @@ const GetDocumentsParams = new Archetype({
sortDirection: {
$type: 'number'
},
+ fields: {
+ $type: 'string'
+ },
roles: {
$type: ['string']
}
@@ -40,7 +45,7 @@ module.exports = ({ db }) => async function getDocuments(params) {
const { roles } = params;
await authorize('Model.getDocuments', roles);
- const { model, limit, skip, sortKey, sortDirection, searchText } = params;
+ const { model, limit, skip, sortKey, sortDirection, searchText, fields } = params;
const Model = db.models[model];
if (Model == null) {
@@ -61,12 +66,13 @@ module.exports = ({ db }) => async function getDocuments(params) {
if (!sortObj.hasOwnProperty('_id')) {
sortObj._id = -1;
}
- const cursor = await Model.
- find(filter).
- limit(limit).
- skip(skip).
- sort(sortObj).
- cursor();
+
+ let query = Model.find(filter).limit(limit).skip(skip).sort(sortObj);
+ const projection = parseFieldsParam(fields);
+ if (projection != null) {
+ query = query.select(projection);
+ }
+ const cursor = await query.cursor();
const docs = [];
for (let doc = await cursor.next(); doc != null; doc = await cursor.next()) {
docs.push(doc);
@@ -101,9 +107,12 @@ module.exports = ({ db }) => async function getDocuments(params) {
await Model.estimatedDocumentCount() :
await Model.countDocuments(filter);
+ const suggestedFields = getSuggestedProjection(Model);
+
return {
docs: docs.map(doc => doc.toJSON({ virtuals: false, getters: false, transform: false })),
schemaPaths,
+ suggestedFields,
numDocs: numDocuments
};
};
diff --git a/backend/actions/Model/getDocumentsStream.js b/backend/actions/Model/getDocumentsStream.js
index ac483bc5..6cc4d2fd 100644
--- a/backend/actions/Model/getDocumentsStream.js
+++ b/backend/actions/Model/getDocumentsStream.js
@@ -4,6 +4,8 @@ const Archetype = require('archetype');
const removeSpecifiedPaths = require('../../helpers/removeSpecifiedPaths');
const evaluateFilter = require('../../helpers/evaluateFilter');
const getRefFromSchemaType = require('../../helpers/getRefFromSchemaType');
+const getSuggestedProjection = require('../../helpers/getSuggestedProjection');
+const parseFieldsParam = require('../../helpers/parseFieldsParam');
const authorize = require('../../authorize');
const GetDocumentsParams = new Archetype({
@@ -30,6 +32,9 @@ const GetDocumentsParams = new Archetype({
sortDirection: {
$type: 'number'
},
+ fields: {
+ $type: 'string'
+ },
roles: {
$type: ['string']
}
@@ -40,7 +45,7 @@ module.exports = ({ db }) => async function* getDocumentsStream(params) {
const { roles } = params;
await authorize('Model.getDocumentsStream', roles);
- const { model, limit, skip, sortKey, sortDirection, searchText } = params;
+ const { model, limit, skip, sortKey, sortDirection, searchText, fields } = params;
const Model = db.models[model];
if (Model == null) {
@@ -62,6 +67,12 @@ module.exports = ({ db }) => async function* getDocumentsStream(params) {
sortObj._id = -1;
}
+ let query = Model.find(filter).limit(limit).skip(skip).sort(sortObj).batchSize(1);
+ const projection = parseFieldsParam(fields);
+ if (projection != null) {
+ query = query.select(projection);
+ }
+
const schemaPaths = {};
for (const path of Object.keys(Model.schema.paths)) {
const schemaType = Model.schema.paths[path];
@@ -87,20 +98,16 @@ module.exports = ({ db }) => async function* getDocumentsStream(params) {
}
removeSpecifiedPaths(schemaPaths, '.$*');
- yield { schemaPaths };
+ const suggestedFields = getSuggestedProjection(Model);
+
+ yield { schemaPaths, suggestedFields };
// Start counting documents in parallel with streaming documents
const numDocsPromise = (parsedFilter == null)
? Model.estimatedDocumentCount().exec()
: Model.countDocuments(filter).exec();
- const cursor = await Model.
- find(filter).
- limit(limit).
- skip(skip).
- sort(sortObj).
- batchSize(1).
- cursor();
+ const cursor = await query.cursor();
let numDocsYielded = false;
let numDocumentsPromiseResolved = false;
diff --git a/backend/actions/Model/getSuggestedProjection.js b/backend/actions/Model/getSuggestedProjection.js
new file mode 100644
index 00000000..f1c96f7e
--- /dev/null
+++ b/backend/actions/Model/getSuggestedProjection.js
@@ -0,0 +1,33 @@
+'use strict';
+
+const Archetype = require('archetype');
+const getSuggestedProjection = require('../../helpers/getSuggestedProjection');
+const authorize = require('../../authorize');
+
+const GetSuggestedProjectionParams = new Archetype({
+ model: {
+ $type: 'string',
+ $required: true
+ },
+ roles: {
+ $type: ['string']
+ }
+}).compile('GetSuggestedProjectionParams');
+
+module.exports = ({ db }) => async function getSuggestedProjectionAction(params) {
+ params = new GetSuggestedProjectionParams(params);
+ const { roles } = params;
+ await authorize('Model.getSuggestedProjection', roles);
+
+ const { model } = params;
+
+ const Model = db.models[model];
+ if (Model == null) {
+ throw new Error(`Model ${model} not found`);
+ }
+
+ // Default columns: first N schema paths (no scoring).
+ const suggestedFields = getSuggestedProjection(Model);
+
+ return { suggestedFields };
+};
diff --git a/backend/actions/Model/index.js b/backend/actions/Model/index.js
index 17abdb8e..aacf041c 100644
--- a/backend/actions/Model/index.js
+++ b/backend/actions/Model/index.js
@@ -11,6 +11,7 @@ exports.exportQueryResults = require('./exportQueryResults');
exports.getDocument = require('./getDocument');
exports.getDocuments = require('./getDocuments');
exports.getDocumentsStream = require('./getDocumentsStream');
+exports.getSuggestedProjection = require('./getSuggestedProjection');
exports.getCollectionInfo = require('./getCollectionInfo');
exports.getIndexes = require('./getIndexes');
exports.getEstimatedDocumentCounts = require('./getEstimatedDocumentCounts');
diff --git a/backend/authorize.js b/backend/authorize.js
index c04190cc..a4e0a9bd 100644
--- a/backend/authorize.js
+++ b/backend/authorize.js
@@ -22,6 +22,7 @@ const actionsToRequiredRoles = {
'Model.getDocument': ['owner', 'admin', 'member', 'readonly'],
'Model.getDocuments': ['owner', 'admin', 'member', 'readonly'],
'Model.getDocumentsStream': ['owner', 'admin', 'member', 'readonly'],
+ 'Model.getSuggestedProjection': ['owner', 'admin', 'member', 'readonly'],
'Model.getEstimatedDocumentCounts': ['owner', 'admin', 'member', 'readonly'],
'Model.getIndexes': ['owner', 'admin', 'member', 'readonly'],
'Model.listModels': ['owner', 'admin', 'member', 'readonly'],
diff --git a/backend/helpers/getSuggestedProjection.js b/backend/helpers/getSuggestedProjection.js
new file mode 100644
index 00000000..dab7d549
--- /dev/null
+++ b/backend/helpers/getSuggestedProjection.js
@@ -0,0 +1,37 @@
+'use strict';
+
+/** Max number of paths to use for the default table projection. */
+const DEFAULT_SUGGESTED_LIMIT = 6;
+
+/**
+ * Default projection for the models table: the first N schema paths (definition order),
+ * excluding Mongoose internals. No scoring — stable and predictable.
+ *
+ * @param {import('mongoose').Model} Model - Mongoose model
+ * @param {{ limit?: number }} options - max paths returned
+ * @returns {string[]} Path names in schema order
+ */
+function getSuggestedProjection(Model, options = {}) {
+ const limit = typeof options.limit === 'number' && options.limit > 0
+ ? options.limit
+ : DEFAULT_SUGGESTED_LIMIT;
+
+ const pathNames = Object.keys(Model.schema.paths).filter(key =>
+ !key.includes('.$*') &&
+ key !== '__v'
+ );
+
+ pathNames.sort((k1, k2) => {
+ if (k1 === '_id' && k2 !== '_id') {
+ return -1;
+ }
+ if (k1 !== '_id' && k2 === '_id') {
+ return 1;
+ }
+ return 0;
+ });
+
+ return pathNames.slice(0, limit);
+}
+
+module.exports = getSuggestedProjection;
diff --git a/backend/helpers/parseFieldsParam.js b/backend/helpers/parseFieldsParam.js
new file mode 100644
index 00000000..ce22acc3
--- /dev/null
+++ b/backend/helpers/parseFieldsParam.js
@@ -0,0 +1,36 @@
+'use strict';
+
+/**
+ * Parse the `fields` request param for Model.getDocuments / getDocumentsStream.
+ * Expects JSON: either `["a","b"]` (inclusion list) or `{"a":1,"b":1}` (Mongo projection).
+ *
+ * @param {string|undefined} fields
+ * @returns {string|object|null} Argument suitable for Query.select(), or null when unset/invalid.
+ */
+function parseFieldsParam(fields) {
+ if (fields == null || typeof fields !== 'string') {
+ return null;
+ }
+ const trimmed = fields.trim();
+ if (!trimmed) {
+ return null;
+ }
+
+ let parsed;
+ try {
+ parsed = JSON.parse(trimmed);
+ } catch (e) {
+ return null;
+ }
+
+ if (Array.isArray(parsed)) {
+ const list = parsed.map(x => String(x).trim()).filter(Boolean);
+ return list.length > 0 ? list.join(' ') : null;
+ }
+ if (parsed != null && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ return Object.keys(parsed).length > 0 ? parsed : null;
+ }
+ return null;
+}
+
+module.exports = parseFieldsParam;
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 911922da..4c398838 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -127,9 +127,12 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
getDocuments: function getDocuments(params) {
return client.post('', { action: 'Model.getDocuments', ...params }).then(res => res.data);
},
+ getSuggestedProjection: function getSuggestedProjection(params) {
+ return client.post('', { action: 'Model.getSuggestedProjection', ...params }).then(res => res.data);
+ },
getDocumentsStream: async function* getDocumentsStream(params) {
const data = await client.post('', { action: 'Model.getDocuments', ...params }).then(res => res.data);
- yield { schemaPaths: data.schemaPaths };
+ yield { schemaPaths: data.schemaPaths, suggestedFields: data.suggestedFields };
yield { numDocs: data.numDocs };
for (const doc of data.docs) {
yield { document: doc };
@@ -342,6 +345,9 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
getDocuments: function getDocuments(params) {
return client.post('/Model/getDocuments', params).then(res => res.data);
},
+ getSuggestedProjection: function getSuggestedProjection(params) {
+ return client.post('/Model/getSuggestedProjection', params).then(res => res.data);
+ },
getDocumentsStream: async function* getDocumentsStream(params) {
const accessToken = window.localStorage.getItem('_mongooseStudioAccessToken') || null;
const url = window.MONGOOSE_STUDIO_CONFIG.baseURL + '/Model/getDocumentsStream?' + new URLSearchParams(params).toString();
diff --git a/frontend/src/dashboard/dashboard.js b/frontend/src/dashboard/dashboard.js
index 2a5df636..75f86836 100644
--- a/frontend/src/dashboard/dashboard.js
+++ b/frontend/src/dashboard/dashboard.js
@@ -157,7 +157,7 @@ module.exports = {
return this.dashboardResults.length > 0 ? this.dashboardResults[0] : null;
}
},
- mounted: async function () {
+ mounted: async function() {
window.pageState = this;
document.addEventListener('click', this.handleDocumentClick);
diff --git a/frontend/src/list-mixed/list-mixed.html b/frontend/src/list-mixed/list-mixed.html
index e87af1cb..735553f5 100644
--- a/frontend/src/list-mixed/list-mixed.html
+++ b/frontend/src/list-mixed/list-mixed.html
@@ -1,7 +1,6 @@
\ No newline at end of file
diff --git a/frontend/src/list-string/list-string.html b/frontend/src/list-string/list-string.html
index b1952773..a294c14a 100644
--- a/frontend/src/list-string/list-string.html
+++ b/frontend/src/list-string/list-string.html
@@ -1,4 +1,3 @@
{{displayValue}}
- copy 📋
\ No newline at end of file
diff --git a/frontend/src/models/models.css b/frontend/src/models/models.css
index 6cb86c12..ac594a8f 100644
--- a/frontend/src/models/models.css
+++ b/frontend/src/models/models.css
@@ -5,105 +5,30 @@
min-height: calc(100% - 56px);
}
-.models button.gray {
- color: black;
- background-color: #eee;
-}
-
-.models .model-selector {
- flex-grow: 0;
- padding: 15px;
- padding-top: 0px;
-}
-
-.models h1 {
- margin-top: 0px;
-}
-
.models .documents {
flex-grow: 1;
- overflow: scroll;
+ overflow: visible;
max-height: calc(100vh - 56px);
+ display: flex;
+ flex-direction: column;
}
-.models .documents table {
- /* max-width: -moz-fit-content;
- max-width: fit-content; */
- width: 100%;
- table-layout: auto;
- font-size: small;
- padding: 0;
- margin-right: 1em;
- white-space: nowrap;
- z-index: -1;
- border-collapse: collapse;
- line-height: 1.5em;
-}
-
-.models .documents table th {
- position: sticky;
- top: 42px;
- z-index: 1;
-}
-
-.models .documents table th:after {
- content: "";
- position: absolute;
- left: 0;
- width: 100%;
- bottom: -1px;
- border-bottom: thin solid rgba(0, 0, 0, 0.12);
-}
-
-.models .documents table tr {
- color: black;
- border-spacing: 0px 0px;
-}
-
-.models .documents table th {
- border-bottom: thin solid rgba(0, 0, 0, 0.12);
- text-align: left;
- height: 48px;
-}
-
-.models .documents table td {
- border-bottom: thin solid rgba(0, 0, 0, 0.12);
- text-align: left;
-}
-
-.models textarea {
- font-size: 1.2em;
-}
-
-.models .path-type {
- color: rgba(0, 0, 0, 0.36);
- font-size: 0.8em;
+.models .documents-container {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
}
.models .documents-menu {
position: sticky;
top: 0;
- z-index: 2;
+ z-index: 30;
padding: 4px;
display: flex;
-}
-
-.models .documents-menu .search-input {
- flex-grow: 1;
- align-items: center;
-}
-
-.models .search-input input {
- padding: 0.25em 0.5em;
- font-size: 1.1em;
- border: 1px solid #ddd;
- border-radius: 3px;
- width: calc(100% - 1em);
-}
-
-.models .sort-arrow {
- padding-left: 10px;
- padding-right: 10px;
+ flex-direction: column;
+ flex-shrink: 0;
}
.models .loader {
@@ -115,8 +40,18 @@
height: 4em;
}
-.models .documents .buttons {
- display: inline-flex;
- justify-content: space-around;
+.models .loader-overlay {
+ position: absolute;
+ inset: 0;
+ z-index: 20;
+ display: flex;
align-items: center;
+ justify-content: center;
+ background: var(--color-page);
+ opacity: 0.9;
+}
+
+/* Table cell: copy icon only copies; rest of cell opens document */
+.models .table-cell-copy {
+ cursor: pointer;
}
diff --git a/frontend/src/models/models.html b/frontend/src/models/models.html
index d33378ea..96f83ad8 100644
--- a/frontend/src/models/models.html
+++ b/frontend/src/models/models.html
@@ -116,8 +116,11 @@
{{ selectMultiple ? 'Cancel' : 'Select' }}
@@ -149,7 +152,7 @@
Create
-
- Projection
-
Find oldest document
+
+ {{ showRowNumbers ? 'Hide row numbers' : 'Show row numbers' }}
+
+
+ Reset Filter
+
+
+ {{ isProjectionMenuSelected ? 'Projection (On)' : 'Projection' }}
+
+
+ Reset Projection
+
+
+ Default
+
@@ -228,37 +262,176 @@
+
+
+
-
+
+
-
-
-
- {{path.path}}
-
- ({{(path.instance || 'unknown')}})
-
- {{sortBy[path.path] == 1 ? 'X' : '↑'}}
- {{sortBy[path.path] == -1 ? 'X' : '↓'}}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ #
+
+
+
+
+ {{ path.path }}
+
+
({{ path.instance || 'unknown' }})
+
+
+ ↑
+
+
+ ↓
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ p.path }}
+
+
+ {{ addFieldFilterText.trim() ? 'No matching fields' : 'All fields added' }}
+
+
+
+
+
+
+
+
+
+ {{ docIndex + 1 }}
+
+
+
+
+
+
+
+
+
+
+
+
Loading documents…
+
+
+ No documents to show. Use Projection in the menu to choose columns.
+
+
+
+
+
Loading documents…
+
-
-
+
+
@@ -385,23 +562,6 @@
-
-
- ×
-
-
- Filter Selection
- Select All
- Deselect All
- Cancel
-
-
-
×
diff --git a/frontend/src/models/models.js b/frontend/src/models/models.js
index b5511bc8..c0509c99 100644
--- a/frontend/src/models/models.js
+++ b/frontend/src/models/models.js
@@ -11,11 +11,66 @@ const appendCSS = require('../appendCSS');
appendCSS(require('./models.css'));
const limit = 20;
+const DEFAULT_FIRST_N_FIELDS = 6;
const OUTPUT_TYPE_STORAGE_KEY = 'studio:model-output-type';
const SELECTED_GEO_FIELD_STORAGE_KEY = 'studio:model-selected-geo-field';
+const PROJECTION_STORAGE_KEY_PREFIX = 'studio:model-projection:';
+const SHOW_ROW_NUMBERS_STORAGE_KEY = 'studio:model-show-row-numbers';
+const PROJECTION_MODE_QUERY_KEY = 'projectionMode';
const RECENTLY_VIEWED_MODELS_KEY = 'studio:recently-viewed-models';
const MAX_RECENT_MODELS = 4;
+/** Parse `fields` from the route (JSON array or inclusion projection object only). */
+function parseFieldsQueryParam(fields) {
+ if (fields == null || fields === '') {
+ return [];
+ }
+ const s = typeof fields === 'string' ? fields : String(fields);
+ const trimmed = s.trim();
+ if (!trimmed) {
+ return [];
+ }
+ let parsed;
+ try {
+ parsed = JSON.parse(trimmed);
+ } catch (e) {
+ return [];
+ }
+ if (Array.isArray(parsed)) {
+ return parsed.map(x => String(x).trim()).filter(Boolean);
+ }
+ if (parsed != null && typeof parsed === 'object') {
+ return Object.keys(parsed).filter(k =>
+ Object.prototype.hasOwnProperty.call(parsed, k) && parsed[k]
+ );
+ }
+ return [];
+}
+
+/** Pass through a valid JSON `fields` string for Model.getDocuments / getDocumentsStream. */
+function normalizeFieldsParamForApi(fieldsStr) {
+ if (fieldsStr == null || fieldsStr === '') {
+ return null;
+ }
+ const s = typeof fieldsStr === 'string' ? fieldsStr : String(fieldsStr);
+ const trimmed = s.trim();
+ if (!trimmed) {
+ return null;
+ }
+ try {
+ const parsed = JSON.parse(trimmed);
+ if (Array.isArray(parsed)) {
+ return trimmed;
+ }
+ if (parsed != null && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ return trimmed;
+ }
+ } catch (e) {
+ return null;
+ }
+ return null;
+}
+
module.exports = app => app.component('models', {
template: template,
props: ['model', 'user', 'roles'],
@@ -31,6 +86,7 @@ module.exports = app => app.component('models', {
mongoDBIndexes: [],
schemaIndexes: [],
status: 'loading',
+ loadingMore: false,
loadedAllDocs: false,
edittingDoc: null,
docEdits: null,
@@ -39,7 +95,10 @@ module.exports = app => app.component('models', {
searchText: '',
shouldShowExportModal: false,
shouldShowCreateModal: false,
- shouldShowFieldModal: false,
+ projectionText: '',
+ isProjectionMenuSelected: false,
+ addFieldFilterText: '',
+ showAddFieldDropdown: false,
shouldShowIndexModal: false,
shouldShowCollectionInfoModal: false,
shouldShowUpdateMultipleModal: false,
@@ -61,27 +120,44 @@ module.exports = app => app.component('models', {
collectionInfo: null,
modelSearch: '',
recentlyViewedModels: [],
- showModelSwitcher: false
+ showModelSwitcher: false,
+ showRowNumbers: true,
+ suppressScrollCheck: false,
+ scrollTopToRestore: null
}),
created() {
this.currentModel = this.model;
this.setSearchTextFromRoute();
this.loadOutputPreference();
this.loadSelectedGeoField();
+ this.loadShowRowNumbersPreference();
this.loadRecentlyViewedModels();
+ this.isProjectionMenuSelected = this.$route?.query?.[PROJECTION_MODE_QUERY_KEY] === '1';
},
beforeDestroy() {
- document.removeEventListener('scroll', this.onScroll, true);
window.removeEventListener('popstate', this.onPopState, true);
document.removeEventListener('click', this.onOutsideActionsMenuClick, true);
+ document.removeEventListener('click', this.onOutsideAddFieldDropdownClick, true);
document.documentElement.removeEventListener('studio-theme-changed', this.onStudioThemeChanged);
document.removeEventListener('keydown', this.onCtrlP, true);
this.destroyMap();
},
async mounted() {
+ // Persist scroll restoration across remounts.
+ // This component is keyed by `$route.fullPath`, so query changes (e.g. projection updates)
+ // recreate the component and reset scroll position.
+ if (typeof window !== 'undefined') {
+ if (typeof window.__studioModelsScrollTopToRestore === 'number') {
+ this.scrollTopToRestore = window.__studioModelsScrollTopToRestore;
+ }
+ if (window.__studioModelsSuppressScrollCheck === true) {
+ this.suppressScrollCheck = true;
+ }
+ delete window.__studioModelsScrollTopToRestore;
+ delete window.__studioModelsSuppressScrollCheck;
+ }
+
window.pageState = this;
- this.onScroll = () => this.checkIfScrolledToBottom();
- document.addEventListener('scroll', this.onScroll, true);
this.onPopState = () => this.initSearchFromUrl();
window.addEventListener('popstate', this.onPopState, true);
this.onOutsideActionsMenuClick = event => {
@@ -93,7 +169,18 @@ module.exports = app => app.component('models', {
this.closeActionsMenu();
}
};
+ this.onOutsideAddFieldDropdownClick = event => {
+ if (!this.showAddFieldDropdown) {
+ return;
+ }
+ const container = this.$refs.addFieldContainer;
+ if (container && !container.contains(event.target)) {
+ this.showAddFieldDropdown = false;
+ this.addFieldFilterText = '';
+ }
+ };
document.addEventListener('click', this.onOutsideActionsMenuClick, true);
+ document.addEventListener('click', this.onOutsideAddFieldDropdownClick, true);
this.onStudioThemeChanged = () => this.updateMapTileLayer();
document.documentElement.addEventListener('studio-theme-changed', this.onStudioThemeChanged);
this.onCtrlP = (event) => {
@@ -104,6 +191,8 @@ module.exports = app => app.component('models', {
};
document.addEventListener('keydown', this.onCtrlP, true);
this.query = Object.assign({}, this.$route.query);
+ // Keep UI mode in sync with the URL on remounts.
+ this.isProjectionMenuSelected = this.$route?.query?.[PROJECTION_MODE_QUERY_KEY] === '1';
const { models, readyState } = await api.Model.listModels();
this.models = models;
await this.loadModelCounts();
@@ -119,8 +208,27 @@ module.exports = app => app.component('models', {
}
await this.initSearchFromUrl();
+ if (this.isProjectionMenuSelected && this.outputType === 'map') {
+ // Projection input is not rendered in map view.
+ this.setOutputType('json');
+ }
+ this.$nextTick(() => {
+ if (!this.isProjectionMenuSelected) return;
+ const input = this.$refs.projectionInput;
+ if (input && typeof input.focus === 'function') {
+ input.focus();
+ }
+ });
},
watch: {
+ model(newModel) {
+ if (newModel !== this.currentModel) {
+ this.currentModel = newModel;
+ if (this.currentModel != null) {
+ this.initSearchFromUrl();
+ }
+ }
+ },
documents: {
handler() {
if (this.outputType === 'map' && this.mapInstance) {
@@ -225,6 +333,19 @@ module.exports = app => app.component('models', {
}
return geoFields;
+ },
+ availablePathsToAdd() {
+ const currentPaths = new Set(this.filteredPaths.map(p => p.path));
+ return this.schemaPaths.filter(p => !currentPaths.has(p.path));
+ },
+ filteredPathsToAdd() {
+ const available = this.availablePathsToAdd;
+ const query = (this.addFieldFilterText || '').trim().toLowerCase();
+ if (!query) return available;
+ return available.filter(p => p.path.toLowerCase().includes(query));
+ },
+ tableDisplayPaths() {
+ return this.filteredPaths.length > 0 ? this.filteredPaths : this.schemaPaths;
}
},
methods: {
@@ -289,6 +410,24 @@ module.exports = app => app.component('models', {
this.selectedGeoField = storedField;
}
},
+ loadShowRowNumbersPreference() {
+ if (typeof window === 'undefined' || !window.localStorage) {
+ return;
+ }
+ const stored = window.localStorage.getItem(SHOW_ROW_NUMBERS_STORAGE_KEY);
+ if (stored === '0') {
+ this.showRowNumbers = false;
+ } else if (stored === '1') {
+ this.showRowNumbers = true;
+ }
+ },
+ toggleRowNumbers() {
+ this.showRowNumbers = !this.showRowNumbers;
+ if (typeof window !== 'undefined' && window.localStorage) {
+ window.localStorage.setItem(SHOW_ROW_NUMBERS_STORAGE_KEY, this.showRowNumbers ? '1' : '0');
+ }
+ this.showActionsMenu = false;
+ },
setOutputType(type) {
if (type !== 'json' && type !== 'table' && type !== 'map') {
return;
@@ -484,6 +623,22 @@ module.exports = app => app.component('models', {
params.searchText = this.searchText;
}
+ // Prefer explicit URL projection (`query.fields`) so the first fetch after
+ // mount/remount respects deep-linked projections before `filteredPaths`
+ // is rehydrated from schema paths.
+ let fieldsParam = normalizeFieldsParamForApi(this.query?.fields);
+ if (!fieldsParam) {
+ const fieldPaths = this.filteredPaths && this.filteredPaths.length > 0
+ ? this.filteredPaths.map(p => p.path).filter(Boolean)
+ : null;
+ if (fieldPaths && fieldPaths.length > 0) {
+ fieldsParam = JSON.stringify(fieldPaths);
+ }
+ }
+ if (fieldsParam) {
+ params.fields = fieldsParam;
+ }
+
return params;
},
setSearchTextFromRoute() {
@@ -497,20 +652,35 @@ module.exports = app => app.component('models', {
this.status = 'loading';
this.query = Object.assign({}, this.$route.query); // important that this is here before the if statements
this.setSearchTextFromRoute();
- if (this.$route.query?.sort) {
- const sort = eval(`(${this.$route.query.sort})`);
- const path = Object.keys(sort)[0];
- const num = Object.values(sort)[0];
- this.sortDocs(num, path);
- }
-
+ // Avoid eval() on user-controlled query params.
+ // Use explicit sortKey + sortDirection query params.
+ const sortKey = this.$route.query?.sortKey;
+ const sortDirectionRaw = this.$route.query?.sortDirection;
+ const sortDirection = typeof sortDirectionRaw === 'string' ? Number(sortDirectionRaw) : sortDirectionRaw;
+ if (typeof sortKey === 'string' && sortKey.trim().length > 0 &&
+ (sortDirection === 1 || sortDirection === -1)) {
+ for (const key in this.sortBy) {
+ delete this.sortBy[key];
+ }
+ this.sortBy[sortKey] = sortDirection;
+ // Normalize to new params and remove legacy key if present.
+ this.query.sortKey = sortKey;
+ this.query.sortDirection = sortDirection;
+ delete this.query.sort;
+ }
if (this.currentModel != null) {
await this.getDocuments();
}
if (this.$route.query?.fields) {
- const filter = this.$route.query.fields.split(',');
- this.filteredPaths = this.filteredPaths.filter(x => filter.includes(x.path));
+ const urlPaths = parseFieldsQueryParam(this.$route.query.fields);
+ if (urlPaths.length > 0) {
+ this.filteredPaths = urlPaths.map(path => this.schemaPaths.find(p => p.path === path)).filter(Boolean);
+ if (this.filteredPaths.length > 0) {
+ this.syncProjectionFromPaths();
+ this.saveProjectionPreference();
+ }
+ }
}
this.status = 'loaded';
@@ -530,10 +700,8 @@ module.exports = app => app.component('models', {
this.shouldShowCreateModal = false;
await this.getDocuments();
},
- initializeDocumentData() {
- this.shouldShowCreateModal = true;
- },
filterDocument(doc) {
+ if (this.filteredPaths.length === 0) return doc;
const filteredDoc = {};
for (let i = 0; i < this.filteredPaths.length; i++) {
const path = this.filteredPaths[i].path;
@@ -546,23 +714,47 @@ module.exports = app => app.component('models', {
if (this.status === 'loading' || this.loadedAllDocs) {
return;
}
- const container = this.$refs.documentsList;
- if (container.scrollHeight - container.clientHeight - 100 < container.scrollTop) {
- this.status = 'loading';
- const params = this.buildDocumentFetchParams({ skip: this.documents.length });
+ // Infinite scroll only applies to table/json views.
+ if (this.outputType !== 'table' && this.outputType !== 'json') {
+ return;
+ }
+ if (this.documents.length === 0) {
+ return;
+ }
+ const container = this.outputType === 'table'
+ ? this.$refs.documentsScrollContainer
+ : this.$refs.documentsContainerScroll;
+ if (!container || container.scrollHeight <= 0) {
+ return;
+ }
+ const threshold = 150;
+ const nearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - threshold;
+ if (!nearBottom) {
+ return;
+ }
+ this.loadingMore = true;
+ this.status = 'loading';
+ try {
+ const skip = this.documents.length;
+ const params = this.buildDocumentFetchParams({ skip });
const { docs } = await api.Model.getDocuments(params);
if (docs.length < limit) {
this.loadedAllDocs = true;
}
this.documents.push(...docs);
+ } finally {
+ this.loadingMore = false;
this.status = 'loaded';
}
+ this.$nextTick(() => this.checkIfScrolledToBottom());
},
async sortDocs(num, path) {
let sorted = false;
if (this.sortBy[path] == num) {
sorted = true;
delete this.query.sort;
+ delete this.query.sortKey;
+ delete this.query.sortDirection;
this.$router.push({ query: this.query });
}
for (const key in this.sortBy) {
@@ -570,9 +762,13 @@ module.exports = app => app.component('models', {
}
if (!sorted) {
this.sortBy[path] = num;
- this.query.sort = `{${path}:${num}}`;
+ this.query.sortKey = path;
+ this.query.sortDirection = num;
+ delete this.query.sort;
this.$router.push({ query: this.query });
}
+ this.documents = [];
+ this.loadedAllDocs = false;
await this.loadMoreDocuments();
},
async search(searchText) {
@@ -609,6 +805,23 @@ module.exports = app => app.component('models', {
closeActionsMenu() {
this.showActionsMenu = false;
},
+ toggleProjectionMenu() {
+ const next = !this.isProjectionMenuSelected;
+ this.isProjectionMenuSelected = next;
+
+ // Because the route-view is keyed on `$route.fullPath`, query changes remount this component.
+ // Persist projection UI state in the URL so Reset/Suggest don't turn the mode off.
+ if (next) {
+ this.query[PROJECTION_MODE_QUERY_KEY] = '1';
+ if (this.outputType === 'map') {
+ this.setOutputType('json');
+ }
+ } else {
+ delete this.query[PROJECTION_MODE_QUERY_KEY];
+ }
+
+ this.$router.push({ query: this.query });
+ },
async openCollectionInfo() {
this.closeActionsMenu();
this.shouldShowCollectionInfoModal = true;
@@ -709,160 +922,401 @@ module.exports = app => app.component('models', {
}
return formatValue(value / 1000000000, 'B');
},
- checkIndexLocation(indexName) {
- if (this.schemaIndexes.find(x => x.name == indexName) && this.mongoDBIndexes.find(x => x.name == indexName)) {
- return 'text-gray-500';
- } else if (this.schemaIndexes.find(x => x.name == indexName)) {
- return 'text-forest-green-500';
- } else {
- return 'text-valencia-500';
- }
- },
async getDocuments() {
- // Track recently viewed model
- this.trackRecentModel(this.currentModel);
+ this.loadingMore = false;
+ this.status = 'loading';
+ try {
+ // Track recently viewed model
+ this.trackRecentModel(this.currentModel);
- // Clear previous data
- this.documents = [];
- this.schemaPaths = [];
- this.numDocuments = null;
- this.loadedAllDocs = false;
- this.lastSelectedIndex = null;
+ // Clear previous data
+ this.documents = [];
+ this.schemaPaths = [];
+ this.numDocuments = null;
+ this.loadedAllDocs = false;
+ this.lastSelectedIndex = null;
- let docsCount = 0;
- let schemaPathsReceived = false;
+ let docsCount = 0;
+ let schemaPathsReceived = false;
- // Use async generator to stream SSEs
- const params = this.buildDocumentFetchParams();
- for await (const event of api.Model.getDocumentsStream(params)) {
- if (event.schemaPaths && !schemaPathsReceived) {
+ // Use async generator to stream SSEs
+ const params = this.buildDocumentFetchParams();
+ for await (const event of api.Model.getDocumentsStream(params)) {
+ if (event.schemaPaths && !schemaPathsReceived) {
// Sort schemaPaths with _id first
- this.schemaPaths = Object.keys(event.schemaPaths).sort((k1, k2) => {
- if (k1 === '_id' && k2 !== '_id') {
- return -1;
+ this.schemaPaths = Object.keys(event.schemaPaths).sort((k1, k2) => {
+ if (k1 === '_id' && k2 !== '_id') {
+ return -1;
+ }
+ if (k1 !== '_id' && k2 === '_id') {
+ return 1;
+ }
+ return 0;
+ }).map(key => event.schemaPaths[key]);
+ this.shouldExport = {};
+ for (const { path } of this.schemaPaths) {
+ this.shouldExport[path] = true;
}
- if (k1 !== '_id' && k2 === '_id') {
- return 1;
+ const shouldUseSavedProjection = this.isProjectionMenuSelected === true;
+ const savedPaths = shouldUseSavedProjection ? this.loadProjectionPreference() : null;
+ if (savedPaths === null) {
+ this.applyDefaultProjection(event.suggestedFields);
+ if (shouldUseSavedProjection) {
+ this.saveProjectionPreference();
+ }
+ } else if (Array.isArray(savedPaths) && savedPaths.length === 0) {
+ this.filteredPaths = [];
+ this.projectionText = '';
+ if (shouldUseSavedProjection) {
+ this.saveProjectionPreference();
+ }
+ } else if (savedPaths && savedPaths.length > 0) {
+ this.filteredPaths = savedPaths
+ .map(path => this.schemaPaths.find(p => p.path === path))
+ .filter(Boolean);
+ if (this.filteredPaths.length === 0) {
+ this.applyDefaultProjection(event.suggestedFields);
+ if (shouldUseSavedProjection) {
+ this.saveProjectionPreference();
+ }
+ }
+ } else {
+ this.applyDefaultProjection(event.suggestedFields);
+ if (shouldUseSavedProjection) {
+ this.saveProjectionPreference();
+ }
}
- return 0;
- }).map(key => event.schemaPaths[key]);
- this.shouldExport = {};
- for (const { path } of this.schemaPaths) {
- this.shouldExport[path] = true;
+ this.selectedPaths = [...this.filteredPaths];
+ this.syncProjectionFromPaths();
+ schemaPathsReceived = true;
+ }
+ if (event.numDocs !== undefined) {
+ this.numDocuments = event.numDocs;
+ }
+ if (event.document) {
+ this.documents.push(event.document);
+ docsCount++;
+ }
+ if (event.message) {
+ throw new Error(event.message);
}
- this.filteredPaths = [...this.schemaPaths];
- this.selectedPaths = [...this.schemaPaths];
- schemaPathsReceived = true;
- }
- if (event.numDocs !== undefined) {
- this.numDocuments = event.numDocs;
- }
- if (event.document) {
- this.documents.push(event.document);
- docsCount++;
- }
- if (event.message) {
- this.status = 'loaded';
- throw new Error(event.message);
}
- }
- if (docsCount < limit) {
- this.loadedAllDocs = true;
+ if (docsCount < limit) {
+ this.loadedAllDocs = true;
+ }
+ } finally {
+ this.status = 'loaded';
}
+ this.$nextTick(() => {
+ this.restoreScrollPosition();
+ if (!this.suppressScrollCheck) {
+ this.checkIfScrolledToBottom();
+ }
+ this.suppressScrollCheck = false;
+ });
},
async loadMoreDocuments() {
- let docsCount = 0;
- let numDocsReceived = false;
+ const isLoadingMore = this.documents.length > 0;
+ if (isLoadingMore) {
+ this.loadingMore = true;
+ }
+ this.status = 'loading';
+ try {
+ let docsCount = 0;
+ let numDocsReceived = false;
- // Use async generator to stream SSEs
- const params = this.buildDocumentFetchParams({ skip: this.documents.length });
- for await (const event of api.Model.getDocumentsStream(params)) {
- if (event.numDocs !== undefined && !numDocsReceived) {
- this.numDocuments = event.numDocs;
- numDocsReceived = true;
+ // Use async generator to stream SSEs
+ const params = this.buildDocumentFetchParams({ skip: this.documents.length });
+ for await (const event of api.Model.getDocumentsStream(params)) {
+ if (event.numDocs !== undefined && !numDocsReceived) {
+ this.numDocuments = event.numDocs;
+ numDocsReceived = true;
+ }
+ if (event.document) {
+ this.documents.push(event.document);
+ docsCount++;
+ }
+ if (event.message) {
+ throw new Error(event.message);
+ }
}
- if (event.document) {
- this.documents.push(event.document);
- docsCount++;
+
+ if (docsCount < limit) {
+ this.loadedAllDocs = true;
}
- if (event.message) {
- this.status = 'loaded';
- throw new Error(event.message);
+ } finally {
+ this.loadingMore = false;
+ this.status = 'loaded';
+ }
+ this.$nextTick(() => this.checkIfScrolledToBottom());
+ },
+ applyDefaultProjection(suggestedFields) {
+ if (Array.isArray(suggestedFields) && suggestedFields.length > 0) {
+ this.filteredPaths = suggestedFields
+ .map(path => this.schemaPaths.find(p => p.path === path))
+ .filter(Boolean);
+ }
+ if (!this.filteredPaths || this.filteredPaths.length === 0) {
+ this.filteredPaths = this.schemaPaths.slice(0, DEFAULT_FIRST_N_FIELDS);
+ }
+ if (this.filteredPaths.length === 0) {
+ this.filteredPaths = this.schemaPaths.filter(p => p.path === '_id');
+ }
+ },
+ loadProjectionPreference() {
+ if (typeof window === 'undefined' || !window.localStorage || !this.currentModel) {
+ return null;
+ }
+ const key = PROJECTION_STORAGE_KEY_PREFIX + this.currentModel;
+ const stored = window.localStorage.getItem(key);
+ if (stored === null || stored === undefined) {
+ return null;
+ }
+ if (stored === '') {
+ return [];
+ }
+ try {
+ const parsed = JSON.parse(stored);
+ if (Array.isArray(parsed)) {
+ return parsed.map(x => String(x).trim()).filter(Boolean);
}
+ } catch (e) {
+ return null;
}
-
- if (docsCount < limit) {
- this.loadedAllDocs = true;
+ return null;
+ },
+ saveProjectionPreference() {
+ if (typeof window === 'undefined' || !window.localStorage || !this.currentModel) {
+ return;
}
+ const key = PROJECTION_STORAGE_KEY_PREFIX + this.currentModel;
+ const paths = this.filteredPaths.map(p => p.path);
+ window.localStorage.setItem(key, JSON.stringify(paths));
},
- addOrRemove(path) {
- const exists = this.selectedPaths.findIndex(x => x.path == path.path);
- if (exists > 0) { // remove
- this.selectedPaths.splice(exists, 1);
- } else { // add
- this.selectedPaths.push(path);
- this.selectedPaths = Object.keys(this.selectedPaths).sort((k1, k2) => {
- if (k1 === '_id' && k2 !== '_id') {
- return -1;
- }
- if (k1 !== '_id' && k2 === '_id') {
- return 1;
+ clearProjection() {
+ // Keep current filter input in sync with the URL so projection reset
+ // does not unintentionally wipe the filter on remount.
+ this.syncFilterToQuery();
+ this.filteredPaths = [];
+ this.selectedPaths = [];
+ this.projectionText = '';
+ this.updateProjectionQuery();
+ this.saveProjectionPreference();
+ },
+ resetFilter() {
+ // Reuse the existing "apply filter + update URL" flow.
+ this.search('');
+ },
+ syncFilterToQuery() {
+ if (typeof this.searchText === 'string' && this.searchText.trim().length > 0) {
+ this.query.search = this.searchText;
+ } else {
+ delete this.query.search;
+ }
+ },
+ applyDefaultProjectionColumns() {
+ if (!this.schemaPaths || this.schemaPaths.length === 0) return;
+ const pathNames = this.schemaPaths.map(p => p.path);
+ this.applyDefaultProjection(pathNames.slice(0, DEFAULT_FIRST_N_FIELDS));
+ this.selectedPaths = [...this.filteredPaths];
+ this.syncProjectionFromPaths();
+ this.updateProjectionQuery();
+ this.saveProjectionPreference();
+ },
+ initProjection(ev) {
+ if (!this.projectionText || !this.projectionText.trim()) {
+ this.projectionText = '';
+ this.$nextTick(() => {
+ if (ev && ev.target) {
+ ev.target.setSelectionRange(0, 0);
}
- return 0;
- }).map(key => this.selectedPaths[key]);
+ });
}
},
- openFieldSelection() {
- if (this.$route.query?.fields) {
- this.selectedPaths.length = 0;
- console.log('there are fields in play', this.$route.query.fields);
- const fields = this.$route.query.fields.split(',');
- for (let i = 0; i < fields.length; i++) {
- this.selectedPaths.push({ path: fields[i] });
+ syncProjectionFromPaths() {
+ if (this.filteredPaths.length === 0) {
+ this.projectionText = '';
+ return;
+ }
+ // String-only projection syntax: `field1 field2` and `-field` for exclusions.
+ // Since `filteredPaths` represents the final include set, we serialize as space-separated fields.
+ this.projectionText = this.filteredPaths.map(p => p.path).join(' ');
+ },
+ parseProjectionInput(text) {
+ if (!text || typeof text !== 'string') {
+ return [];
+ }
+ const trimmed = text.trim();
+ if (!trimmed) {
+ return [];
+ }
+ const normalizeKey = (key) => String(key).trim();
+
+ // String-only projection syntax:
+ // name email
+ // -password (exclusion-only)
+ // +email (inclusion-only)
+ //
+ // Brace/object syntax is intentionally NOT supported.
+ if (trimmed.startsWith('{') || trimmed.endsWith('}')) {
+ return null;
+ }
+
+ const tokens = trimmed.split(/[,\s]+/).filter(Boolean);
+ if (tokens.length === 0) return [];
+
+ const includeKeys = [];
+ const excludeKeys = [];
+
+ for (const rawToken of tokens) {
+ const token = rawToken.trim();
+ if (!token) continue;
+
+ const prefix = token[0];
+ if (prefix === '-') {
+ const path = token.slice(1).trim();
+ if (!path) return null;
+ excludeKeys.push(path);
+ } else if (prefix === '+') {
+ const path = token.slice(1).trim();
+ if (!path) return null;
+ includeKeys.push(path);
+ } else {
+ includeKeys.push(token);
+ }
+ }
+
+ if (includeKeys.length > 0 && excludeKeys.length > 0) {
+ // Support subtractive edits on an existing projection string, e.g.
+ // `name email createdAt -email` -> `name createdAt`.
+ const includeSet = new Set(includeKeys.map(normalizeKey));
+ for (const path of excludeKeys) {
+ includeSet.delete(normalizeKey(path));
+ }
+ return Array.from(includeSet);
+ }
+
+ if (excludeKeys.length > 0) {
+ const excludeSet = new Set(excludeKeys.map(normalizeKey));
+ return this.schemaPaths.map(p => p.path).filter(p => !excludeSet.has(p));
+ }
+
+ return includeKeys.map(normalizeKey);
+ },
+ applyProjectionFromInput() {
+ const paths = this.parseProjectionInput(this.projectionText);
+ if (paths === null) {
+ this.syncProjectionFromPaths();
+ return;
+ }
+ if (paths.length === 0) {
+ this.filteredPaths = this.schemaPaths.filter(p => p.path === '_id');
+ if (this.filteredPaths.length === 0 && this.schemaPaths.length > 0) {
+ const idPath = this.schemaPaths.find(p => p.path === '_id');
+ this.filteredPaths = idPath ? [idPath] : [this.schemaPaths[0]];
}
} else {
- this.selectedPaths = [{ path: '_id' }];
+ this.filteredPaths = paths.map(path => this.schemaPaths.find(p => p.path === path)).filter(Boolean);
+ const validPaths = new Set(this.schemaPaths.map(p => p.path));
+ for (const path of paths) {
+ if (validPaths.has(path) && !this.filteredPaths.find(p => p.path === path)) {
+ this.filteredPaths.push(this.schemaPaths.find(p => p.path === path));
+ }
+ }
+ if (this.filteredPaths.length === 0) {
+ this.filteredPaths = this.schemaPaths.filter(p => p.path === '_id');
+ }
}
- this.shouldShowFieldModal = true;
+ this.selectedPaths = [...this.filteredPaths];
+ this.syncProjectionFromPaths();
+ this.updateProjectionQuery();
+ this.saveProjectionPreference();
},
- filterDocuments() {
- if (this.selectedPaths.length > 0) {
- this.filteredPaths = [...this.selectedPaths];
+ updateProjectionQuery() {
+ const paths = this.filteredPaths.map(x => x.path).filter(Boolean);
+ if (paths.length > 0) {
+ this.query.fields = JSON.stringify(paths);
} else {
- this.filteredPaths.length = 0;
+ delete this.query.fields;
}
- this.shouldShowFieldModal = false;
- const selectedParams = this.filteredPaths.map(x => x.path).join(',');
- this.query.fields = selectedParams;
this.$router.push({ query: this.query });
},
- resetDocuments() {
- this.selectedPaths = [...this.filteredPaths];
- this.query.fields = {};
- this.$router.push({ query: this.query });
- this.shouldShowFieldModal = false;
+ removeField(schemaPath) {
+ if (this.outputType === 'table' && this.$refs.documentsScrollContainer) {
+ this.scrollTopToRestore = this.$refs.documentsScrollContainer.scrollTop;
+ this.suppressScrollCheck = true;
+ // Persist for remount caused by query changes.
+ if (typeof window !== 'undefined') {
+ window.__studioModelsScrollTopToRestore = this.scrollTopToRestore;
+ window.__studioModelsSuppressScrollCheck = true;
+ }
+ }
+ const index = this.filteredPaths.findIndex(p => p.path === schemaPath.path);
+ if (index !== -1) {
+ this.filteredPaths.splice(index, 1);
+ if (this.filteredPaths.length === 0) {
+ const idPath = this.schemaPaths.find(p => p.path === '_id');
+ this.filteredPaths = idPath ? [idPath] : [];
+ }
+ this.syncProjectionFromPaths();
+ this.updateProjectionQuery();
+ this.saveProjectionPreference();
+ }
},
- deselectAll() {
- this.selectedPaths = [];
+ addField(schemaPath) {
+ if (!this.filteredPaths.find(p => p.path === schemaPath.path)) {
+ if (this.outputType === 'table' && this.$refs.documentsScrollContainer) {
+ this.scrollTopToRestore = this.$refs.documentsScrollContainer.scrollTop;
+ this.suppressScrollCheck = true;
+ // Persist for remount caused by query changes.
+ if (typeof window !== 'undefined') {
+ window.__studioModelsScrollTopToRestore = this.scrollTopToRestore;
+ window.__studioModelsSuppressScrollCheck = true;
+ }
+ }
+ this.filteredPaths.push(schemaPath);
+ this.filteredPaths.sort((a, b) => {
+ if (a.path === '_id') return -1;
+ if (b.path === '_id') return 1;
+ return 0;
+ });
+ this.syncProjectionFromPaths();
+ this.updateProjectionQuery();
+ this.saveProjectionPreference();
+ this.showAddFieldDropdown = false;
+ this.addFieldFilterText = '';
+ }
},
- selectAll() {
- this.selectedPaths = [...this.schemaPaths];
+ restoreScrollPosition() {
+ if (this.outputType !== 'table') return;
+ if (this.scrollTopToRestore == null) return;
+ const container = this.$refs.documentsScrollContainer;
+ if (!container) return;
+ container.scrollTop = this.scrollTopToRestore;
+ this.scrollTopToRestore = null;
},
- isSelected(path) {
- return this.selectedPaths.find(x => x.path == path);
+ toggleAddFieldDropdown() {
+ this.showAddFieldDropdown = !this.showAddFieldDropdown;
+ if (this.showAddFieldDropdown) {
+ this.addFieldFilterText = '';
+ this.$nextTick(() => this.$refs.addFieldFilterInput?.focus());
+ }
},
getComponentForPath(schemaPath) {
+ if (!schemaPath || typeof schemaPath !== 'object') {
+ return 'list-mixed';
+ }
if (schemaPath.instance === 'Array') {
return 'list-array';
}
if (schemaPath.instance === 'String') {
return 'list-string';
}
- if (schemaPath.instance == 'Embedded') {
+ if (schemaPath.instance === 'Embedded') {
return 'list-subdocument';
}
- if (schemaPath.instance == 'Mixed') {
+ if (schemaPath.instance === 'Mixed') {
return 'list-mixed';
}
return 'list-default';
@@ -887,6 +1341,31 @@ module.exports = app => app.component('models', {
this.edittingDoc = null;
this.$toast.success('Document updated!');
},
+ copyCellValue(value) {
+ const text = value == null ? '' : (typeof value === 'object' ? JSON.stringify(value) : String(value));
+ if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(text).then(() => {
+ this.$toast.success('Copied to clipboard');
+ }).catch(() => {
+ this.fallbackCopyText(text);
+ });
+ } else {
+ this.fallbackCopyText(text);
+ }
+ },
+ fallbackCopyText(text) {
+ try {
+ const el = document.createElement('textarea');
+ el.value = text;
+ document.body.appendChild(el);
+ el.select();
+ document.execCommand('copy');
+ document.body.removeChild(el);
+ this.$toast.success('Copied to clipboard');
+ } catch (err) {
+ this.$toast.error('Copy failed');
+ }
+ },
handleDocumentClick(document, event) {
if (this.selectMultiple) {
this.handleDocumentSelection(document, event);
diff --git a/frontend/src/navbar/navbar.js b/frontend/src/navbar/navbar.js
index e29eeed9..cb5f688a 100644
--- a/frontend/src/navbar/navbar.js
+++ b/frontend/src/navbar/navbar.js
@@ -17,7 +17,7 @@ module.exports = app => app.component('navbar', {
showFlyout: false,
darkMode: typeof localStorage !== 'undefined' && localStorage.getItem('studio-theme') === 'dark'
}),
- mounted: function () {
+ mounted: function() {
window.navbar = this;
const mobileMenuMask = document.querySelector('#mobile-menu-mask');
const mobileMenu = document.querySelector('#mobile-menu');
diff --git a/test/Model.getDocuments.projection.test.js b/test/Model.getDocuments.projection.test.js
new file mode 100644
index 00000000..5e63ec70
--- /dev/null
+++ b/test/Model.getDocuments.projection.test.js
@@ -0,0 +1,67 @@
+'use strict';
+
+const assert = require('assert');
+const mongoose = require('mongoose');
+const { actions, connection } = require('./setup.test');
+
+describe('Model.getDocuments() projection (fields)', function() {
+ const ProjectionFieldsTest = connection.model(
+ 'ProjectionFieldsTest',
+ new mongoose.Schema({
+ name: String,
+ email: String,
+ value: Number,
+ createdAt: Date
+ })
+ );
+
+ afterEach(async function() {
+ await ProjectionFieldsTest.deleteMany();
+ });
+
+ it('returns only selected fields when fields is provided', async function() {
+ const doc = await ProjectionFieldsTest.create({
+ name: 'Alice',
+ email: 'alice@example.com',
+ value: 123,
+ createdAt: new Date('2020-01-01')
+ });
+
+ const res = await actions.Model.getDocuments({
+ model: 'ProjectionFieldsTest',
+ fields: JSON.stringify(['name', 'email']),
+ roles: ['admin']
+ });
+
+ assert.strictEqual(res.docs.length, 1);
+ assert.strictEqual(res.docs[0]._id.toString(), doc._id.toString());
+ assert.strictEqual(res.docs[0].name, 'Alice');
+ assert.strictEqual(res.docs[0].email, 'alice@example.com');
+
+ // Should not include unselected fields
+ assert.strictEqual(res.docs[0].value, undefined);
+ assert.strictEqual(res.docs[0].createdAt, undefined);
+ });
+
+ it('includes all fields when fields is omitted', async function() {
+ const doc = await ProjectionFieldsTest.create({
+ name: 'Bob',
+ email: 'bob@example.com',
+ value: 456,
+ createdAt: new Date('2021-01-01')
+ });
+
+ const res = await actions.Model.getDocuments({
+ model: 'ProjectionFieldsTest',
+ roles: ['admin']
+ });
+
+ assert.strictEqual(res.docs.length, 1);
+ assert.strictEqual(res.docs[0]._id.toString(), doc._id.toString());
+ assert.strictEqual(res.docs[0].name, 'Bob');
+ assert.strictEqual(res.docs[0].email, 'bob@example.com');
+ assert.strictEqual(res.docs[0].value, 456);
+ assert.strictEqual(new Date(res.docs[0].createdAt).toISOString(), doc.createdAt.toISOString());
+ });
+});
+
diff --git a/test/Model.getSuggestedProjection.test.js b/test/Model.getSuggestedProjection.test.js
new file mode 100644
index 00000000..11fb2d0e
--- /dev/null
+++ b/test/Model.getSuggestedProjection.test.js
@@ -0,0 +1,40 @@
+'use strict';
+
+const assert = require('assert');
+const mongoose = require('mongoose');
+const { actions, connection } = require('./setup.test');
+
+describe('Model.getSuggestedProjection()', function() {
+ const SuggestedProjectionTest = connection.model(
+ 'SuggestedProjectionTest',
+ new mongoose.Schema({
+ name: { type: String, required: true },
+ email: { type: String, unique: true, default: 'example@example.com' },
+ value: Number,
+ createdAt: Date,
+ active: Boolean,
+ userId: mongoose.Schema.Types.ObjectId
+ }, { _id: false })
+ );
+
+ afterEach(async function() {
+ await SuggestedProjectionTest.deleteMany();
+ });
+
+ it('returns the first N schema paths in definition order', async function() {
+ const res = await actions.Model.getSuggestedProjection({
+ model: 'SuggestedProjectionTest',
+ roles: ['admin']
+ });
+
+ assert.deepStrictEqual(res.suggestedFields, [
+ 'name',
+ 'email',
+ 'value',
+ 'createdAt',
+ 'active',
+ 'userId'
+ ]);
+ });
+});
+