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 @@
     {{shortenValue}}
-    copy 📋
   
\ 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 @@ @@ -149,7 +152,7 @@
- + + + + + + 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' }}) + + + + + +
+
+
+ +
+ + +

+ {{ 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… +
@@ -304,8 +481,8 @@
-
- +
+ Loading
@@ -385,23 +562,6 @@
- - -