diff --git a/README.md b/README.md
index 309c0b927..8d1aa2338 100644
--- a/README.md
+++ b/README.md
@@ -79,6 +79,9 @@ Parse Dashboard is a standalone dashboard for managing your [Parse Server](https
- [Limitations](#limitations)
- [CSV Export](#csv-export)
- [Views](#views)
+ - [Data Sources](#data-sources)
+ - [Aggregation Pipeline](#aggregation-pipeline)
+ - [Cloud Function](#cloud-function)
- [View Table](#view-table)
- [Pointer](#pointer)
- [Link](#link)
@@ -1260,11 +1263,38 @@ This feature will take either selected rows or all rows of an individual class a
▶️ *Core > Views*
-Views are saved queries that display aggregated data from your classes. Create a view by providing a name, selecting a class and defining an aggregation pipeline. Optionally enable the object counter to show how many items match the view. Saved views appear in the sidebar, where you can select, edit, or delete them.
+Views are saved queries that display data in a table format. Saved views appear in the sidebar, where you can select, edit, or delete them. Optionally you can enable the object counter to show in the sidebar how many items match the view.
> [!Caution]
> Values are generally rendered without sanitization in the resulting data table. If rendered values come from user input or untrusted data, make sure to remove potentially dangerous HTML or JavaScript, to prevent an attacker from injecting malicious code, to exploit vulnerabilities like Cross-Site Scripting (XSS).
+### Data Sources
+
+Views can pull their data from the following data sources.
+
+#### Aggregation Pipeline
+
+Display aggregated data from your classes using a MongoDB aggregation pipeline. Create a view by selecting a class and defining an aggregation pipeline.
+
+#### Cloud Function
+
+Display data returned by a Parse Cloud Function. Create a view specifying a Cloud Function that returns an array of objects. Cloud Functions enable custom business logic, computed fields, and complex data transformations.
+
+Cloud Function views can prompt users for text input and/or file upload when opened. Enable "Require text input" or "Require file upload" checkboxes when creating the view. The user provided data will then be available in the Cloud Function as parameters.
+
+Cloud Function example:
+
+```js
+Parse.Cloud.define("myViewFunction", request => {
+ const text = request.params.text;
+ const fileData = request.params.fileData;
+ return processDataWithTextAndFile(text, fileData);
+});
+```
+
+> [!Note]
+> Text and file data are ephemeral and only available to the Cloud Function during that request. Files are base64 encoded, increasing the data during transfer by ~33%.
+
### View Table
When designing the aggregation pipeline, consider that some values are rendered specially in the output table.
diff --git a/src/dashboard/Data/Views/CloudFunctionInputDialog.react.js b/src/dashboard/Data/Views/CloudFunctionInputDialog.react.js
new file mode 100644
index 000000000..e550d58e7
--- /dev/null
+++ b/src/dashboard/Data/Views/CloudFunctionInputDialog.react.js
@@ -0,0 +1,99 @@
+import Field from 'components/Field/Field.react';
+import FileInput from 'components/FileInput/FileInput.react';
+import Label from 'components/Label/Label.react';
+import Modal from 'components/Modal/Modal.react';
+import TextInput from 'components/TextInput/TextInput.react';
+import React from 'react';
+
+export default class CloudFunctionInputDialog extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ textInput: '',
+ uploadedFile: null,
+ };
+ }
+
+ handleFileChange = (file) => {
+ this.setState({ uploadedFile: file });
+ };
+
+ handleConfirm = () => {
+ const { requireTextInput, requireFileUpload } = this.props;
+ const params = {};
+
+ if (requireTextInput) {
+ params.text = this.state.textInput;
+ }
+
+ if (requireFileUpload && this.state.uploadedFile) {
+ // For file uploads, we'll pass the raw file data
+ // The cloud function will receive this as base64 encoded data
+ const file = this.state.uploadedFile;
+ const reader = new FileReader();
+ reader.onload = () => {
+ if (reader.result && typeof reader.result === 'string') {
+ params.fileData = {
+ name: file.name,
+ type: file.type,
+ size: file.size,
+ data: reader.result.split(',')[1], // Remove the data URL prefix
+ };
+ }
+ this.props.onConfirm(params);
+ };
+ reader.readAsDataURL(file);
+ } else {
+ this.props.onConfirm(params);
+ }
+ };
+
+ render() {
+ const { requireTextInput, requireFileUpload, onCancel } = this.props;
+
+ // Check if we have all required inputs
+ const hasRequiredText = !requireTextInput || this.state.textInput.trim().length > 0;
+ const hasRequiredFile = !requireFileUpload || this.state.uploadedFile !== null;
+ const isValid = hasRequiredText && hasRequiredFile;
+
+ return (
+
+ {requireTextInput && (
+ }
+ input={
+ this.setState({ textInput })}
+ placeholder="Enter text here..."
+ />
+ }
+ />
+ )}
+ {requireFileUpload && (
+ }
+ input={
+
+ }
+ />
+ )}
+
+ );
+ }
+}
diff --git a/src/dashboard/Data/Views/CreateViewDialog.react.js b/src/dashboard/Data/Views/CreateViewDialog.react.js
index 745762c31..530cad2fc 100644
--- a/src/dashboard/Data/Views/CreateViewDialog.react.js
+++ b/src/dashboard/Data/Views/CreateViewDialog.react.js
@@ -1,11 +1,22 @@
+import Checkbox from 'components/Checkbox/Checkbox.react';
import Dropdown from 'components/Dropdown/Dropdown.react';
+import Option from 'components/Dropdown/Option.react';
import Field from 'components/Field/Field.react';
import Label from 'components/Label/Label.react';
import Modal from 'components/Modal/Modal.react';
-import Option from 'components/Dropdown/Option.react';
-import React from 'react';
import TextInput from 'components/TextInput/TextInput.react';
-import Checkbox from 'components/Checkbox/Checkbox.react';
+import React from 'react';
+
+/**
+ * The data source types available for views.
+ *
+ * @param {string} query An aggregation pipeline query data source.
+ * @param {string} cloudFunction A Cloud Function data source.
+ */
+const DataSourceTypes = {
+ query: 'query',
+ cloudFunction: 'cloudFunction'
+};
function isValidJSON(value) {
try {
@@ -22,17 +33,30 @@ export default class CreateViewDialog extends React.Component {
this.state = {
name: '',
className: '',
+ dataSourceType: DataSourceTypes.query,
query: '[]',
+ cloudFunction: '',
showCounter: false,
+ requireTextInput: false,
+ requireFileUpload: false,
};
}
valid() {
- return (
- this.state.name.length > 0 &&
- this.state.className.length > 0 &&
- isValidJSON(this.state.query)
- );
+ if (this.state.dataSourceType === DataSourceTypes.query) {
+ return (
+ this.state.name.length > 0 &&
+ this.state.className.length > 0 &&
+ this.state.query.trim() !== '' &&
+ this.state.query !== '[]' &&
+ isValidJSON(this.state.query)
+ );
+ } else {
+ return (
+ this.state.name.length > 0 &&
+ this.state.cloudFunction.trim() !== ''
+ );
+ }
}
render() {
@@ -43,7 +67,7 @@ export default class CreateViewDialog extends React.Component {
icon="plus"
iconSize={40}
title="Create a new view?"
- subtitle="Define a custom query to display data."
+ subtitle="Define a data source to display data."
confirmText="Create"
cancelText="Cancel"
disabled={!this.valid()}
@@ -51,9 +75,12 @@ export default class CreateViewDialog extends React.Component {
onConfirm={() =>
onConfirm({
name: this.state.name,
- className: this.state.className,
- query: JSON.parse(this.state.query),
+ className: this.state.dataSourceType === DataSourceTypes.query ? this.state.className : null,
+ query: this.state.dataSourceType === DataSourceTypes.query ? JSON.parse(this.state.query) : null,
+ cloudFunction: this.state.dataSourceType === DataSourceTypes.cloudFunction ? this.state.cloudFunction : null,
showCounter: this.state.showCounter,
+ requireTextInput: this.state.dataSourceType === DataSourceTypes.cloudFunction ? this.state.requireTextInput : false,
+ requireFileUpload: this.state.dataSourceType === DataSourceTypes.cloudFunction ? this.state.requireFileUpload : false,
})
}
>
@@ -67,32 +94,56 @@ export default class CreateViewDialog extends React.Component {
}
/>
}
+ label={}
input={
this.setState({ className })}
+ value={this.state.dataSourceType}
+ onChange={dataSourceType => this.setState({ dataSourceType })}
>
- {classes.map(c => (
-
- ))}
+
+
}
/>
+ {this.state.dataSourceType === DataSourceTypes.query && (
+ }
+ input={
+ this.setState({ className })}
+ >
+ {classes.map(c => (
+
+ ))}
+
+ }
+ />
+ )}
}
input={
this.setState({ query })}
+ multiline={this.state.dataSourceType === DataSourceTypes.query}
+ value={this.state.dataSourceType === DataSourceTypes.query ? this.state.query : this.state.cloudFunction}
+ onChange={value =>
+ this.setState(
+ this.state.dataSourceType === DataSourceTypes.query
+ ? { query: value }
+ : { cloudFunction: value }
+ )
+ }
/>
}
/>
@@ -105,6 +156,28 @@ export default class CreateViewDialog extends React.Component {
/>
}
/>
+ {this.state.dataSourceType === DataSourceTypes.cloudFunction && (
+ <>
+ }
+ input={
+ this.setState({ requireTextInput })}
+ />
+ }
+ />
+ }
+ input={
+ this.setState({ requireFileUpload })}
+ />
+ }
+ />
+ >
+ )}
);
}
diff --git a/src/dashboard/Data/Views/EditViewDialog.react.js b/src/dashboard/Data/Views/EditViewDialog.react.js
index 50e3bbb57..84f3fe317 100644
--- a/src/dashboard/Data/Views/EditViewDialog.react.js
+++ b/src/dashboard/Data/Views/EditViewDialog.react.js
@@ -1,11 +1,11 @@
+import Checkbox from 'components/Checkbox/Checkbox.react';
import Dropdown from 'components/Dropdown/Dropdown.react';
+import Option from 'components/Dropdown/Option.react';
import Field from 'components/Field/Field.react';
import Label from 'components/Label/Label.react';
import Modal from 'components/Modal/Modal.react';
-import Option from 'components/Dropdown/Option.react';
-import React from 'react';
import TextInput from 'components/TextInput/TextInput.react';
-import Checkbox from 'components/Checkbox/Checkbox.react';
+import React from 'react';
function isValidJSON(value) {
try {
@@ -20,20 +20,42 @@ export default class EditViewDialog extends React.Component {
constructor(props) {
super();
const view = props.view || {};
+
+ // Determine data source type based on existing view properties
+ let dataSourceType = 'query'; // default
+ if (view.cloudFunction) {
+ dataSourceType = 'cloudFunction';
+ } else if (view.query && Array.isArray(view.query) && view.query.length > 0) {
+ dataSourceType = 'query';
+ }
+
this.state = {
name: view.name || '',
className: view.className || '',
- query: JSON.stringify(view.query || [], null, 2),
+ dataSourceType,
+ query: view.query ? JSON.stringify(view.query, null, 2) : '[]',
+ cloudFunction: view.cloudFunction || '',
showCounter: !!view.showCounter,
+ requireTextInput: !!view.requireTextInput,
+ requireFileUpload: !!view.requireFileUpload,
};
}
valid() {
- return (
- this.state.name.length > 0 &&
- this.state.className.length > 0 &&
- isValidJSON(this.state.query)
- );
+ if (this.state.dataSourceType === 'query') {
+ return (
+ this.state.name.length > 0 &&
+ this.state.className.length > 0 &&
+ this.state.query.trim() !== '' &&
+ this.state.query !== '[]' &&
+ isValidJSON(this.state.query)
+ );
+ } else {
+ return (
+ this.state.name.length > 0 &&
+ this.state.cloudFunction.trim() !== ''
+ );
+ }
}
render() {
@@ -44,7 +66,7 @@ export default class EditViewDialog extends React.Component {
icon="edit-solid"
iconSize={40}
title="Edit view?"
- subtitle="Update the custom query."
+ subtitle="Update the data source configuration."
confirmText="Save"
cancelText="Cancel"
disabled={!this.valid()}
@@ -52,9 +74,12 @@ export default class EditViewDialog extends React.Component {
onConfirm={() =>
onConfirm({
name: this.state.name,
- className: this.state.className,
- query: JSON.parse(this.state.query),
+ className: this.state.dataSourceType === 'query' ? this.state.className : null,
+ query: this.state.dataSourceType === 'query' ? JSON.parse(this.state.query) : null,
+ cloudFunction: this.state.dataSourceType === 'cloudFunction' ? this.state.cloudFunction : null,
showCounter: this.state.showCounter,
+ requireTextInput: this.state.dataSourceType === 'cloudFunction' ? this.state.requireTextInput : false,
+ requireFileUpload: this.state.dataSourceType === 'cloudFunction' ? this.state.requireFileUpload : false,
})
}
>
@@ -68,32 +93,56 @@ export default class EditViewDialog extends React.Component {
}
/>
}
+ label={}
input={
this.setState({ className })}
+ value={this.state.dataSourceType}
+ onChange={dataSourceType => this.setState({ dataSourceType })}
>
- {classes.map(c => (
-
- ))}
+
+
}
/>
+ {this.state.dataSourceType === 'query' && (
+ }
+ input={
+ this.setState({ className })}
+ >
+ {classes.map(c => (
+
+ ))}
+
+ }
+ />
+ )}
}
input={
this.setState({ query })}
+ multiline={this.state.dataSourceType === 'query'}
+ value={this.state.dataSourceType === 'query' ? this.state.query : this.state.cloudFunction}
+ onChange={value =>
+ this.setState(
+ this.state.dataSourceType === 'query'
+ ? { query: value }
+ : { cloudFunction: value }
+ )
+ }
/>
}
/>
@@ -106,6 +155,28 @@ export default class EditViewDialog extends React.Component {
/>
}
/>
+ {this.state.dataSourceType === 'cloudFunction' && (
+ <>
+ }
+ input={
+ this.setState({ requireTextInput })}
+ />
+ }
+ />
+ }
+ input={
+ this.setState({ requireFileUpload })}
+ />
+ }
+ />
+ >
+ )}
);
}
diff --git a/src/dashboard/Data/Views/Views.react.js b/src/dashboard/Data/Views/Views.react.js
index 739976e21..eacf5c633 100644
--- a/src/dashboard/Data/Views/Views.react.js
+++ b/src/dashboard/Data/Views/Views.react.js
@@ -1,30 +1,31 @@
+import BrowserMenu from 'components/BrowserMenu/BrowserMenu.react';
+import MenuItem from 'components/BrowserMenu/MenuItem.react';
+import Separator from 'components/BrowserMenu/Separator.react';
import CategoryList from 'components/CategoryList/CategoryList.react';
-import SidebarAction from 'components/Sidebar/SidebarAction';
-import TableView from 'dashboard/TableView.react';
-import Toolbar from 'components/Toolbar/Toolbar.react';
+import DragHandle from 'components/DragHandle/DragHandle.react';
+import EmptyState from 'components/EmptyState/EmptyState.react';
import Icon from 'components/Icon/Icon.react';
import LoaderContainer from 'components/LoaderContainer/LoaderContainer.react';
+import Pill from 'components/Pill/Pill.react';
+import SidebarAction from 'components/Sidebar/SidebarAction';
+import Toolbar from 'components/Toolbar/Toolbar.react';
+import browserStyles from 'dashboard/Data/Browser/Browser.scss';
+import Notification from 'dashboard/Data/Browser/Notification.react';
+import TableView from 'dashboard/TableView.react';
+import tableStyles from 'dashboard/TableView.scss';
+import * as ViewPreferences from 'lib/ViewPreferences';
+import generatePath from 'lib/generatePath';
+import { ActionTypes as SchemaActionTypes } from 'lib/stores/SchemaStore';
+import subscribeTo from 'lib/subscribeTo';
+import { withRouter } from 'lib/withRouter';
import Parse from 'parse';
import React from 'react';
-import Notification from 'dashboard/Data/Browser/Notification.react';
-import Pill from 'components/Pill/Pill.react';
-import DragHandle from 'components/DragHandle/DragHandle.react';
+import CloudFunctionInputDialog from './CloudFunctionInputDialog.react';
import CreateViewDialog from './CreateViewDialog.react';
-import EditViewDialog from './EditViewDialog.react';
import DeleteViewDialog from './DeleteViewDialog.react';
+import EditViewDialog from './EditViewDialog.react';
import ViewValueDialog from './ViewValueDialog.react';
-import BrowserMenu from 'components/BrowserMenu/BrowserMenu.react';
-import MenuItem from 'components/BrowserMenu/MenuItem.react';
-import Separator from 'components/BrowserMenu/Separator.react';
-import EmptyState from 'components/EmptyState/EmptyState.react';
-import * as ViewPreferences from 'lib/ViewPreferences';
-import generatePath from 'lib/generatePath';
-import { withRouter } from 'lib/withRouter';
-import subscribeTo from 'lib/subscribeTo';
-import { ActionTypes as SchemaActionTypes } from 'lib/stores/SchemaStore';
import styles from './Views.scss';
-import tableStyles from 'dashboard/TableView.scss';
-import browserStyles from 'dashboard/Data/Browser/Browser.scss';
export default
@subscribeTo('Schema', 'schema')
@@ -50,6 +51,8 @@ class Views extends TableView {
lastNote: null,
loading: false,
viewValue: null,
+ showCloudFunctionInput: false,
+ cloudFunctionInputConfig: null,
};
this.headersRef = React.createRef();
this.noteTimeout = null;
@@ -84,20 +87,38 @@ class Views extends TableView {
this.setState({ views, counts: {} }, () => {
views.forEach(view => {
if (view.showCounter) {
- new Parse.Query(view.className)
- .aggregate(view.query, { useMasterKey: true })
- .then(res => {
- if (this._isMounted) {
- this.setState(({ counts }) => ({
- counts: { ...counts, [view.name]: res.length },
- }));
- }
- })
- .catch(error => {
- if (this._isMounted) {
- this.showNote(`Request failed: ${error.message || 'Unknown error occurred'}`, true);
- }
- });
+ if (view.cloudFunction) {
+ // For Cloud Function views, call the function to get count
+ Parse.Cloud.run(view.cloudFunction, {}, { useMasterKey: true })
+ .then(res => {
+ if (this._isMounted) {
+ this.setState(({ counts }) => ({
+ counts: { ...counts, [view.name]: Array.isArray(res) ? res.length : 0 },
+ }));
+ }
+ })
+ .catch(error => {
+ if (this._isMounted) {
+ this.showNote(`Request failed: ${error.message || 'Unknown error occurred'}`, true);
+ }
+ });
+ } else if (view.query && Array.isArray(view.query)) {
+ // For aggregation pipeline views, use existing logic
+ new Parse.Query(view.className)
+ .aggregate(view.query, { useMasterKey: true })
+ .then(res => {
+ if (this._isMounted) {
+ this.setState(({ counts }) => ({
+ counts: { ...counts, [view.name]: res.length },
+ }));
+ }
+ })
+ .catch(error => {
+ if (this._isMounted) {
+ this.showNote(`Request failed: ${error.message || 'Unknown error occurred'}`, true);
+ }
+ });
+ }
}
});
if (this._isMounted) {
@@ -123,9 +144,64 @@ class Views extends TableView {
}
return;
}
- new Parse.Query(view.className)
- .aggregate(view.query, { useMasterKey: true })
+
+ // Check if cloud function view requires input
+ if (view.cloudFunction && (view.requireTextInput || view.requireFileUpload)) {
+ if (this._isMounted) {
+ this.setState({
+ loading: false,
+ showCloudFunctionInput: true,
+ cloudFunctionInputConfig: {
+ view,
+ requireTextInput: view.requireTextInput,
+ requireFileUpload: view.requireFileUpload,
+ },
+ });
+ }
+ return;
+ }
+
+ this.executeCloudFunctionOrQuery(view);
+ }
+
+ executeCloudFunctionOrQuery(view, params = {}) {
+ // Choose data source: Cloud Function or Aggregation Pipeline
+ const dataPromise = view.cloudFunction
+ ? Parse.Cloud.run(view.cloudFunction, params, { useMasterKey: true })
+ : new Parse.Query(view.className).aggregate(view.query || [], { useMasterKey: true });
+
+ dataPromise
.then(results => {
+ // Normalize Parse.Object instances to raw JSON for consistent rendering as pointer
+ const normalizeValue = val => {
+ if (val && typeof val === 'object' && val instanceof Parse.Object) {
+ return {
+ __type: 'Pointer',
+ className: val.className,
+ objectId: val.id
+ };
+ }
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
+ const normalized = {};
+ Object.keys(val).forEach(key => {
+ normalized[key] = normalizeValue(val[key]);
+ });
+ return normalized;
+ }
+ if (Array.isArray(val)) {
+ return val.map(normalizeValue);
+ }
+ return val;
+ };
+
+ const normalizedResults = results.map(item => {
+ const normalized = {};
+ Object.keys(item).forEach(key => {
+ normalized[key] = normalizeValue(item[key]);
+ });
+ return normalized;
+ });
+
const columns = {};
const computeWidth = str => {
let text = str;
@@ -151,7 +227,7 @@ class Views extends TableView {
}
return Math.max((text.length + 2) * 12, 40);
};
- results.forEach(item => {
+ normalizedResults.forEach(item => {
Object.keys(item).forEach(key => {
const val = item[key];
let type = 'String';
@@ -191,7 +267,7 @@ class Views extends TableView {
const order = colNames.map(name => ({ name, width: columns[name].width }));
const tableWidth = order.reduce((sum, col) => sum + col.width, 0);
if (this._isMounted) {
- this.setState({ data: results, order, columns, tableWidth, loading: false });
+ this.setState({ data: normalizedResults, order, columns, tableWidth, loading: false });
}
})
.catch(error => {
@@ -203,6 +279,13 @@ class Views extends TableView {
}
onRefresh() {
+ // Clear any existing cloud function input modal first
+ if (this.state.showCloudFunctionInput) {
+ this.setState({
+ showCloudFunctionInput: false,
+ cloudFunctionInputConfig: null,
+ });
+ }
this.loadData(this.props.params.name);
}
@@ -606,6 +689,27 @@ class Views extends TableView {
}}
/>
);
+ } else if (this.state.showCloudFunctionInput && this.state.cloudFunctionInputConfig) {
+ const config = this.state.cloudFunctionInputConfig;
+ extras = (
+ this.setState({
+ showCloudFunctionInput: false,
+ cloudFunctionInputConfig: null,
+ loading: false,
+ })}
+ onConfirm={(params) => {
+ this.setState({
+ showCloudFunctionInput: false,
+ cloudFunctionInputConfig: null,
+ loading: true,
+ });
+ this.executeCloudFunctionOrQuery(config.view, params);
+ }}
+ />
+ );
}
let notification = null;
if (this.state.lastError) {