diff --git a/backend/actions/Dashboard/createDashboard.js b/backend/actions/Dashboard/createDashboard.js index dd5d0dab..6aa32045 100644 --- a/backend/actions/Dashboard/createDashboard.js +++ b/backend/actions/Dashboard/createDashboard.js @@ -2,6 +2,7 @@ const Archetype = require('archetype'); const authorize = require('../../authorize'); +const mongoose = require('mongoose'); const CreateDashboardParams = new Archetype({ title: { @@ -12,18 +13,29 @@ const CreateDashboardParams = new Archetype({ $type: 'string', $required: true }, + initiatedById: { + $type: mongoose.Types.ObjectId, + $required: false + }, roles: { $type: ['string'] } }).compile('CreateDashboardParams'); module.exports = ({ studioConnection }) => async function createDashboard(params) { - const { title, code, roles } = new CreateDashboardParams(params); + const { title, code, initiatedById, roles } = new CreateDashboardParams(params); const Dashboard = studioConnection.model('__Studio_Dashboard'); await authorize('Dashboard.createDashboard', roles); - const dashboard = await Dashboard.create({ title, code }); + console.log('Test', params) + + const dashboard = await Dashboard.create({ + title, + code, + createdById: initiatedById, + createdBy: params.initiatedBy + }); return { dashboard }; }; diff --git a/backend/actions/Dashboard/getDashboard.js b/backend/actions/Dashboard/getDashboard.js index 1257d6f6..b9531bca 100644 --- a/backend/actions/Dashboard/getDashboard.js +++ b/backend/actions/Dashboard/getDashboard.js @@ -51,6 +51,7 @@ module.exports = ({ db, studioConnection, options }) => async function getDashbo return {}; } return completeDashboardEvaluate( + Dashboard, DashboardResult, dashboardResult._id, null, @@ -73,6 +74,7 @@ module.exports = ({ db, studioConnection, options }) => async function getDashbo return {}; } return completeDashboardEvaluate( + Dashboard, DashboardResult, dashboardResult._id, result, @@ -97,13 +99,15 @@ module.exports = ({ db, studioConnection, options }) => async function getDashbo } }; -async function completeDashboardEvaluate(DashboardResult, dashboardResultId, result, error, status) { +async function completeDashboardEvaluate(Dashboard, DashboardResult, dashboardResultId, result, error, status) { const dashboardResult = await DashboardResult.findById(dashboardResultId).orFail(); - dashboardResult.finishedEvaluatingAt = new Date(); + const finishedEvaluatingAt = new Date(); + dashboardResult.finishedEvaluatingAt = finishedEvaluatingAt; dashboardResult.result = result; dashboardResult.error = error; dashboardResult.status = status; await dashboardResult.save(); + await Dashboard.updateOne({ _id: dashboardResult.dashboardId }, { $set: { lastEvaluatedAt: finishedEvaluatingAt } }); return { dashboardResult }; } diff --git a/backend/actions/Dashboard/getDashboards.js b/backend/actions/Dashboard/getDashboards.js index f7e942a6..db40cff5 100644 --- a/backend/actions/Dashboard/getDashboards.js +++ b/backend/actions/Dashboard/getDashboards.js @@ -15,7 +15,7 @@ module.exports = ({ studioConnection }) => async function getDashboards(params) await authorize('Dashboard.getDashboards', roles); - const dashboards = await Dashboard.find(); + const dashboards = await Dashboard.find().sort({ createdAt: -1, _id: -1 }).lean(); return { dashboards }; }; diff --git a/backend/db/dashboardSchema.js b/backend/db/dashboardSchema.js index 229674de..80e495c2 100644 --- a/backend/db/dashboardSchema.js +++ b/backend/db/dashboardSchema.js @@ -13,6 +13,30 @@ const dashboardSchema = new mongoose.Schema({ }, description: { type: String + }, + createdById: { + type: mongoose.Schema.Types.ObjectId + }, + createdBy: { + name: String, + email: String + }, + lastEvaluatedAt: { + type: Date + } +}, { timestamps: true }); + +dashboardSchema.post(['find', 'findOne'], async function(docs) { + const dashboards = Array.isArray(docs) ? docs : [docs]; + for (const dashboard of dashboards) { + if (dashboard != null && dashboard.createdAt == null && dashboard._id?.getTimestamp) { + dashboard.createdAt = dashboard._id.getTimestamp(); + await this.model.db.model('__Studio_Dashboard').updateOne( + { _id: dashboard._id }, + { createdAt: dashboard._id.getTimestamp() }, + { overwriteImmutable: true } + ); + } } }); diff --git a/backend/netlify.js b/backend/netlify.js index 7993071c..742b386e 100644 --- a/backend/netlify.js +++ b/backend/netlify.js @@ -63,6 +63,7 @@ module.exports = function netlify(conn, options) { params.roles = roles; params.userId = user._id; params.initiatedById = user._id; + params.initiatedBy = user; } if (typeof actionName !== 'string') { diff --git a/backend/next.js b/backend/next.js index 9a4b77fb..39833179 100644 --- a/backend/next.js +++ b/backend/next.js @@ -64,6 +64,7 @@ module.exports = function next(conn, options) { params.roles = roles; params.userId = user._id; params.initiatedById = user._id; + params.initiatedBy = user; } if (typeof actionName !== 'string') { diff --git a/express.js b/express.js index 506c069f..2d742082 100644 --- a/express.js +++ b/express.js @@ -87,6 +87,7 @@ module.exports = async function mongooseStudioExpressApp(apiUrl, conn, options) req._internals.initiatedById = user._id; req._internals.roles = roles; req._internals.$workspaceId = workspace._id; + req._internals.initiatedBy = user; next(); }) diff --git a/frontend/src/dashboards/dashboards.html b/frontend/src/dashboards/dashboards.html index a7433f1f..cfffcdb9 100644 --- a/frontend/src/dashboards/dashboards.html +++ b/frontend/src/dashboards/dashboards.html @@ -10,6 +10,23 @@

Dashboards

+
+ +
+
+ + + +
+ +
+
+
No dashboards yet
+
+

No matching dashboards

+

Try a different title or description.

+
+
- {{ dashboard.title }} + -

{{ dashboard.description }}

+

+
+
+
Created At
+
{{ formatDate(dashboard.createdAt) }}
+
+
+
Last Evaluated At
+
{{ formatDate(dashboard.lastEvaluatedAt) }}
+
+
+
Created By
+
{{ dashboard.createdBy?.name }}
+
+
app.component('dashboards', { data: () => ({ status: 'loading', dashboards: [], + searchText: '', showCreateDashboardModal: false, showDeleteDashboardModal: null, openMenuId: null }), + computed: { + filteredDashboards() { + const searchText = this.searchText.trim().toLowerCase(); + if (!searchText) { + return this.dashboards; + } + + return this.dashboards.filter(dashboard => { + return (dashboard.title || '').toLowerCase().includes(searchText) || + (dashboard.description || '').toLowerCase().includes(searchText); + }); + } + }, methods: { + highlightTitle(value) { + return this.highlightSearchText(value, 'underline decoration-primary underline-offset-2'); + }, + highlightDescription(value) { + return this.highlightSearchText(value, 'font-semibold text-content-secondary'); + }, + highlightSearchText(value, className) { + const text = value || ''; + const searchText = this.searchText.trim(); + if (!searchText) { + return escapeHtml(text); + } + + const pattern = new RegExp(escapeRegExp(searchText), 'ig'); + let html = ''; + let lastIndex = 0; + let match = pattern.exec(text); + while (match != null) { + html += escapeHtml(text.slice(lastIndex, match.index)); + html += `${escapeHtml(match[0])}`; + lastIndex = match.index + match[0].length; + match = pattern.exec(text); + } + html += escapeHtml(text.slice(lastIndex)); + return html; + }, + formatDate(value) { + if (!value) { + return '-'; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return '-'; + } + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short' + }).format(date); + }, + formatUser(dashboard) { + return dashboard.createdBy?.name || + dashboard.createdBy?.email || + (dashboard.createdById ? dashboard.createdById.toString() : '-'); + }, toggleMenu(id) { this.openMenuId = this.openMenuId === id ? null : id; }, @@ -33,7 +91,7 @@ module.exports = app => app.component('dashboards', { this.$toast.success('Dashboard deleted!'); }, insertNewDashboard(dashboard) { - this.dashboards.push(dashboard); + this.dashboards.unshift(dashboard); this.showCreateDashboardModal = false; } }, @@ -58,3 +116,16 @@ module.exports = app => app.component('dashboards', { this.status = 'loaded'; } }); + +function escapeHtml(value) { + return value.toString(). + replace(/&/g, '&'). + replace(//g, '>'). + replace(/"/g, '"'). + replace(/'/g, '''); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/test/Dashboard.getDashboard.results.test.js b/test/Dashboard.getDashboard.results.test.js index a8b8bfce..39eb0a8b 100644 --- a/test/Dashboard.getDashboard.results.test.js +++ b/test/Dashboard.getDashboard.results.test.js @@ -37,6 +37,12 @@ describe('Dashboard.getDashboard() results', function () { assert.ok(persisted); assert.strictEqual(persisted.status, 'completed'); assert.deepStrictEqual(persisted.result, { total: 42 }); + + const persistedDashboard = await Dashboard.findById(dashboard._id).lean(); + assert.strictEqual( + persistedDashboard.lastEvaluatedAt.toISOString(), + persisted.finishedEvaluatingAt.toISOString() + ); }); it('returns persisted results from studioConnection', async function () { diff --git a/test/Dashboard.getDashboards.test.js b/test/Dashboard.getDashboards.test.js new file mode 100644 index 00000000..e0797047 --- /dev/null +++ b/test/Dashboard.getDashboards.test.js @@ -0,0 +1,79 @@ +'use strict'; + +const assert = require('assert'); +const mongoose = require('mongoose'); +const { actions, studioConnection } = require('./setup.test'); +const dashboardSchema = require('../backend/db/dashboardSchema'); + +const Dashboard = studioConnection.model('__Studio_Dashboard', dashboardSchema, 'studio__dashboards'); + +describe('Dashboard.getDashboards()', function() { + afterEach(async function() { + await Dashboard.deleteMany(); + }); + + it('returns newest dashboards first with stored evaluation time', async function() { + const older = await Dashboard.create({ + title: 'Older', + description: 'First dashboard', + code: 'return 1;', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z') + }); + const newer = await Dashboard.create({ + title: 'Newer', + description: 'Second dashboard', + code: 'return 2;', + createdAt: new Date('2026-01-02T00:00:00.000Z'), + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + lastEvaluatedAt: new Date('2026-01-04T00:00:01.000Z') + }); + + const res = await actions.Dashboard.getDashboards({ roles: ['dashboards'] }); + + assert.deepStrictEqual(res.dashboards.map(dashboard => dashboard.title), ['Newer', 'Older']); + assert.strictEqual( + new Date(res.dashboards[0].lastEvaluatedAt).toISOString(), + '2026-01-04T00:00:01.000Z' + ); + assert.strictEqual(res.dashboards[1].lastEvaluatedAt, undefined); + assert.strictEqual(older.title, 'Older'); + }); + + it('derives createdAt from _id for legacy dashboards', async function() { + const dashboard = await Dashboard.create({ + title: 'Legacy', + code: 'return 1;' + }); + await Dashboard.collection.updateOne({ _id: dashboard._id }, { $unset: { createdAt: 1 } }); + + const res = await actions.Dashboard.getDashboards({ roles: ['dashboards'] }); + + assert.strictEqual(res.dashboards.length, 1); + assert.strictEqual( + new Date(res.dashboards[0].createdAt).toISOString(), + dashboard._id.getTimestamp().toISOString() + ); + }); + + it('stores the creating user id when creating a dashboard', async function() { + const userId = new mongoose.Types.ObjectId(); + + const res = await actions.Dashboard.createDashboard({ + title: 'Created By Test', + code: 'return 1;', + initiatedById: userId, + initiatedBy: { + name: 'Jane Doe', + email: 'jane@example.com' + }, + roles: ['member'] + }); + + assert.strictEqual(res.dashboard.createdById.toString(), userId.toString()); + assert.deepStrictEqual(res.dashboard.createdBy.toObject(), { + name: 'Jane Doe', + email: 'jane@example.com' + }); + }); +});