Skip to content

feat: Add custom data views with aggregation query #2888

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 26 commits into from
Jul 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1e707d5
feat: add custom views page
mtrezza Jul 10, 2025
5f61237
chore: address lint issues
mtrezza Jul 10, 2025
eab8cf3
fix: populate classes in Views dialog
mtrezza Jul 10, 2025
d485379
fix: handle view query errors
mtrezza Jul 10, 2025
8825239
fix: align view table headers
mtrezza Jul 10, 2025
49cd9fc
feat: link pointers in views
mtrezza Jul 10, 2025
6ed37ba
fix: preserve filter URL for pointer links
mtrezza Jul 10, 2025
bb8eabd
fix: improve views table usability
mtrezza Jul 10, 2025
e97fbd9
fix: sync views column widths
mtrezza Jul 10, 2025
1de7b4c
fix: align resizable view columns
mtrezza Jul 10, 2025
5781c2b
fix: align view headers with columns
mtrezza Jul 10, 2025
c0ca3e2
fix: stabilize column resize
mtrezza Jul 10, 2025
0404b3e
fix: sync view headers with scroll
mtrezza Jul 10, 2025
1708ad9
fix: allow route for views page
mtrezza Jul 10, 2025
d490260
fix: parse route query string
mtrezza Jul 11, 2025
7c6230a
Revert "fix: sync view headers with scroll"
mtrezza Jul 11, 2025
510dea2
Revert "fix: allow route for views page"
mtrezza Jul 11, 2025
d18cb6a
Revert "fix: parse route query string"
mtrezza Jul 11, 2025
80ef8c1
feat: Allow editing views (#2889)
mtrezza Jul 11, 2025
eef72bd
fix: Prevent stale pointer navigation (#2890)
mtrezza Jul 12, 2025
146ec5d
fix: Handle invalid pointers in Views results (#2891)
mtrezza Jul 12, 2025
5b96bea
Update TableView.scss
mtrezza Jul 12, 2025
f9514dd
Cell height fix
mtrezza Jul 12, 2025
dc36630
fix: Center pill in Views table cells (#2895)
mtrezza Jul 12, 2025
d74b4f8
docs: Add Views feature (#2896)
mtrezza Jul 12, 2025
d657be1
fix: Display ISO string for date objects in views (#2897)
mtrezza Jul 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ Parse Dashboard is a standalone dashboard for managing your [Parse Server](https
- [Change Pointer Key](#change-pointer-key)
- [Limitations](#limitations)
- [CSV Export](#csv-export)
- [Views](#views)
- [Contributing](#contributing)

# Getting Started
Expand Down Expand Up @@ -1189,6 +1190,12 @@ This feature allows you to change how a pointer is represented in the browser. B
This feature will take either selected rows or all rows of an individual class and saves them to a CSV file, which is then downloaded. CSV headers are added to the top of the file matching the column names.

> ⚠️ There is currently a 10,000 row limit when exporting all data. If more than 10,000 rows are present in the class, the CSV file will only contain 10,000 rows.
## Views

▶️ *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.


# Contributing

Expand Down
5 changes: 4 additions & 1 deletion src/components/BrowserMenu/BrowserMenu.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ export default class BrowserMenu extends React.Component {
BrowserMenu.propTypes = {
icon: PropTypes.string.isRequired.describe('The name of the icon to place in the menu.'),
title: PropTypes.string.isRequired.describe('The title text of the menu.'),
children: PropTypes.arrayOf(PropTypes.node).describe(
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).describe(
'The contents of the menu when open. It should be a set of MenuItem and Separator components.'
),
};
3 changes: 3 additions & 0 deletions src/dashboard/Dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import SlowQueries from './Analytics/SlowQueries/SlowQueries.react';
import styles from 'dashboard/Apps/AppsIndex.scss';
import UsersSettings from './Settings/UsersSettings.react';
import Webhooks from './Data/Webhooks/Webhooks.react';
import Views from './Data/Views/Views.react';
import { AsyncStatus } from 'lib/Constants';
import baseStyles from 'stylesheets/base.scss';
import { get } from 'lib/AJAX';
Expand Down Expand Up @@ -270,6 +271,8 @@ export default class Dashboard extends React.Component {

<Route path="cloud_code" element={<CloudCode />} />
<Route path="cloud_code/*" element={<CloudCode />} />
<Route path="views/:name" element={<Views />} />
<Route path="views" element={<Views />} />
<Route path="webhooks" element={<Webhooks />} />

<Route path="jobs">{JobsRoute}</Route>
Expand Down
5 changes: 5 additions & 0 deletions src/dashboard/DashboardView.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ export default class DashboardView extends React.Component {
});
}

coreSubsections.push({
name: 'Views',
link: '/views',
});

//webhooks requires removal of heroku link code, then it should work.
if (
features.hooks &&
Expand Down
31 changes: 15 additions & 16 deletions src/dashboard/Data/Browser/Browser.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@ import { withRouter } from 'lib/withRouter';
import { get } from 'lib/AJAX';
import BrowserFooter from './BrowserFooter.react';

const SELECTED_ROWS_MESSAGE =
'There are selected rows. Are you sure you want to leave this page?';
const SELECTED_ROWS_MESSAGE = 'There are selected rows. Are you sure you want to leave this page?';

function SelectedRowsNavigationPrompt({ when }) {
const message = SELECTED_ROWS_MESSAGE;
Expand Down Expand Up @@ -119,7 +118,7 @@ function SelectedRowsNavigationPrompt({ when }) {
}

// The initial and max amount of rows fetched by lazy loading
const BROWSER_LAST_LOCATION = 'brower_last_location';
const BROWSER_LAST_LOCATION = 'browser_last_location';

@subscribeTo('Schema', 'schema')
@withRouter
Expand Down Expand Up @@ -386,6 +385,13 @@ class Browser extends DashboardView {
}
addLocation(appId) {
if (window.localStorage) {
const currentSearch = this.props.location?.search;
if (currentSearch) {
const params = new URLSearchParams(currentSearch);
if (params.has('filters')) {
return;
}
}
let pathname = null;
const newLastLocations = [];

Expand Down Expand Up @@ -1505,22 +1511,17 @@ class Browser extends DashboardView {

if (error.code === Parse.Error.AGGREGATE_ERROR) {
if (error.errors.length == 1) {
errorDeletingNote =
`Error deleting ${className} with id '${error.errors[0].object.id}'`;
errorDeletingNote = `Error deleting ${className} with id '${error.errors[0].object.id}'`;
} else if (error.errors.length < toDeleteObjectIds.length) {
errorDeletingNote =
`Error deleting ${error.errors.length} out of ${toDeleteObjectIds.length} ${className} objects`;
errorDeletingNote = `Error deleting ${error.errors.length} out of ${toDeleteObjectIds.length} ${className} objects`;
} else {
errorDeletingNote =
`Error deleting all ${error.errors.length} ${className} objects`;
errorDeletingNote = `Error deleting all ${error.errors.length} ${className} objects`;
}
} else {
if (toDeleteObjectIds.length == 1) {
errorDeletingNote =
`Error deleting ${className} with id '${toDeleteObjectIds[0]}'`;
errorDeletingNote = `Error deleting ${className} with id '${toDeleteObjectIds[0]}'`;
} else {
errorDeletingNote =
`Error deleting ${toDeleteObjectIds.length} ${className} objects`;
errorDeletingNote = `Error deleting ${toDeleteObjectIds.length} ${className} objects`;
}
}

Expand Down Expand Up @@ -2526,9 +2527,7 @@ class Browser extends DashboardView {
<Helmet>
<title>{pageTitle}</title>
</Helmet>
<SelectedRowsNavigationPrompt
when={Object.keys(this.state.selection).length > 0}
/>
<SelectedRowsNavigationPrompt when={Object.keys(this.state.selection).length > 0} />
{browser}
{notification}
{extras}
Expand Down
2 changes: 1 addition & 1 deletion src/dashboard/Data/Browser/BrowserTable.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,7 @@ export default class BrowserTable extends React.Component {
id="browser-table"
style={{
right: rightValue,
'overflow-x': this.props.isResizing ? 'hidden' : 'auto',
overflowX: this.props.isResizing ? 'hidden' : 'auto',
}}
>
<DataBrowserHeaderBar
Expand Down
4 changes: 2 additions & 2 deletions src/dashboard/Data/Config/Config.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
align-items: center;
justify-content: center;
vertical-align: middle;
width: 25px;
height: 25px;
width: 20px;
height: 20px;
cursor: pointer;
svg {
fill: currentColor;
Expand Down
111 changes: 111 additions & 0 deletions src/dashboard/Data/Views/CreateViewDialog.react.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import Dropdown from 'components/Dropdown/Dropdown.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';

function isValidJSON(value) {
try {
JSON.parse(value);
return true;
} catch {
return false;
}
}

export default class CreateViewDialog extends React.Component {
constructor() {
super();
this.state = {
name: '',
className: '',
query: '[]',
showCounter: false,
};
}

valid() {
return (
this.state.name.length > 0 &&
this.state.className.length > 0 &&
isValidJSON(this.state.query)
);
}

render() {
const { classes, onConfirm, onCancel } = this.props;
return (
<Modal
type={Modal.Types.INFO}
icon="plus"
iconSize={40}
title="Create a new view?"
subtitle="Define a custom query to display data."
confirmText="Create"
cancelText="Cancel"
disabled={!this.valid()}
onCancel={onCancel}
onConfirm={() =>
onConfirm({
name: this.state.name,
className: this.state.className,
query: JSON.parse(this.state.query),
showCounter: this.state.showCounter,
})
}
>
<Field
label={<Label text="Name" />}
input={
<TextInput
value={this.state.name}
onChange={name => this.setState({ name })}
/>
}
/>
<Field
label={<Label text="Class" />}
input={
<Dropdown
value={this.state.className}
onChange={className => this.setState({ className })}
>
{classes.map(c => (
<Option key={c} value={c}>
{c}
</Option>
))}
</Dropdown>
}
/>
<Field
label={
<Label
text="Query"
description="An aggregation pipeline that returns an array of items."
/>
}
input={
<TextInput
multiline={true}
value={this.state.query}
onChange={query => this.setState({ query })}
/>
}
/>
<Field
label={<Label text="Show object counter" />}
input={
<Checkbox
checked={this.state.showCounter}
onChange={showCounter => this.setState({ showCounter })}
/>
}
/>
</Modal>
);
}
}
45 changes: 45 additions & 0 deletions src/dashboard/Data/Views/DeleteViewDialog.react.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Field from 'components/Field/Field.react';
import Label from 'components/Label/Label.react';
import Modal from 'components/Modal/Modal.react';
import React from 'react';
import TextInput from 'components/TextInput/TextInput.react';

export default class DeleteViewDialog extends React.Component {
constructor() {
super();
this.state = {
confirmation: '',
};
}

valid() {
return this.state.confirmation === this.props.name;
}

render() {
return (
<Modal
type={Modal.Types.DANGER}
icon="warn-outline"
title="Delete view?"
subtitle="This action cannot be undone!"
disabled={!this.valid()}
confirmText="Delete"
cancelText="Cancel"
onCancel={this.props.onCancel}
onConfirm={this.props.onConfirm}
>
<Field
label={<Label text="Confirm this action" description="Enter the view name to continue." />}
input={
<TextInput
placeholder="View name"
value={this.state.confirmation}
onChange={confirmation => this.setState({ confirmation })}
/>
}
/>
</Modal>
);
}
}
Comment on lines +7 to +45
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add prop validation for required props.

The component expects name, onCancel, and onConfirm props but doesn't validate them. Consider adding PropTypes validation to ensure proper usage.

Add prop validation after the component class:

DeleteViewDialog.propTypes = {
  name: PropTypes.string.isRequired,
  onCancel: PropTypes.func.isRequired,
  onConfirm: PropTypes.func.isRequired,
};

And import PropTypes at the top:

+import PropTypes from 'prop-types';
import Field from 'components/Field/Field.react';
🤖 Prompt for AI Agents
In src/dashboard/Data/Views/DeleteViewDialog.react.js from lines 7 to 45, the
component uses props name, onCancel, and onConfirm but lacks prop validation. To
fix this, import PropTypes at the top of the file and add a static propTypes
object or assign DeleteViewDialog.propTypes after the class definition,
specifying name as a required string and onCancel and onConfirm as required
functions to ensure proper prop usage and catch errors early.

Loading
Loading