Skip to content

Commit b1679db

Browse files
authored
feat: Add custom data views with aggregation query (#2888)
1 parent 3335696 commit b1679db

15 files changed

+1029
-22
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ Parse Dashboard is a standalone dashboard for managing your [Parse Server](https
7575
- [Change Pointer Key](#change-pointer-key)
7676
- [Limitations](#limitations)
7777
- [CSV Export](#csv-export)
78+
- [Views](#views)
7879
- [Contributing](#contributing)
7980

8081
# Getting Started
@@ -1189,6 +1190,12 @@ This feature allows you to change how a pointer is represented in the browser. B
11891190
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.
11901191

11911192
> ⚠️ 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.
1193+
## Views
1194+
1195+
▶️ *Core > Views*
1196+
1197+
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.
1198+
11921199

11931200
# Contributing
11941201

src/components/BrowserMenu/BrowserMenu.react.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,10 @@ export default class BrowserMenu extends React.Component {
8383
BrowserMenu.propTypes = {
8484
icon: PropTypes.string.isRequired.describe('The name of the icon to place in the menu.'),
8585
title: PropTypes.string.isRequired.describe('The title text of the menu.'),
86-
children: PropTypes.arrayOf(PropTypes.node).describe(
86+
children: PropTypes.oneOfType([
87+
PropTypes.arrayOf(PropTypes.node),
88+
PropTypes.node,
89+
]).describe(
8790
'The contents of the menu when open. It should be a set of MenuItem and Separator components.'
8891
),
8992
};

src/dashboard/Dashboard.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import SlowQueries from './Analytics/SlowQueries/SlowQueries.react';
4444
import styles from 'dashboard/Apps/AppsIndex.scss';
4545
import UsersSettings from './Settings/UsersSettings.react';
4646
import Webhooks from './Data/Webhooks/Webhooks.react';
47+
import Views from './Data/Views/Views.react';
4748
import { AsyncStatus } from 'lib/Constants';
4849
import baseStyles from 'stylesheets/base.scss';
4950
import { get } from 'lib/AJAX';
@@ -270,6 +271,8 @@ export default class Dashboard extends React.Component {
270271

271272
<Route path="cloud_code" element={<CloudCode />} />
272273
<Route path="cloud_code/*" element={<CloudCode />} />
274+
<Route path="views/:name" element={<Views />} />
275+
<Route path="views" element={<Views />} />
273276
<Route path="webhooks" element={<Webhooks />} />
274277

275278
<Route path="jobs">{JobsRoute}</Route>

src/dashboard/DashboardView.react.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ export default class DashboardView extends React.Component {
7676
});
7777
}
7878

79+
coreSubsections.push({
80+
name: 'Views',
81+
link: '/views',
82+
});
83+
7984
//webhooks requires removal of heroku link code, then it should work.
8085
if (
8186
features.hooks &&

src/dashboard/Data/Browser/Browser.react.js

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,7 @@ import { withRouter } from 'lib/withRouter';
4444
import { get } from 'lib/AJAX';
4545
import BrowserFooter from './BrowserFooter.react';
4646

47-
const SELECTED_ROWS_MESSAGE =
48-
'There are selected rows. Are you sure you want to leave this page?';
47+
const SELECTED_ROWS_MESSAGE = 'There are selected rows. Are you sure you want to leave this page?';
4948

5049
function SelectedRowsNavigationPrompt({ when }) {
5150
const message = SELECTED_ROWS_MESSAGE;
@@ -119,7 +118,7 @@ function SelectedRowsNavigationPrompt({ when }) {
119118
}
120119

121120
// The initial and max amount of rows fetched by lazy loading
122-
const BROWSER_LAST_LOCATION = 'brower_last_location';
121+
const BROWSER_LAST_LOCATION = 'browser_last_location';
123122

124123
@subscribeTo('Schema', 'schema')
125124
@withRouter
@@ -386,6 +385,13 @@ class Browser extends DashboardView {
386385
}
387386
addLocation(appId) {
388387
if (window.localStorage) {
388+
const currentSearch = this.props.location?.search;
389+
if (currentSearch) {
390+
const params = new URLSearchParams(currentSearch);
391+
if (params.has('filters')) {
392+
return;
393+
}
394+
}
389395
let pathname = null;
390396
const newLastLocations = [];
391397

@@ -1505,22 +1511,17 @@ class Browser extends DashboardView {
15051511

15061512
if (error.code === Parse.Error.AGGREGATE_ERROR) {
15071513
if (error.errors.length == 1) {
1508-
errorDeletingNote =
1509-
`Error deleting ${className} with id '${error.errors[0].object.id}'`;
1514+
errorDeletingNote = `Error deleting ${className} with id '${error.errors[0].object.id}'`;
15101515
} else if (error.errors.length < toDeleteObjectIds.length) {
1511-
errorDeletingNote =
1512-
`Error deleting ${error.errors.length} out of ${toDeleteObjectIds.length} ${className} objects`;
1516+
errorDeletingNote = `Error deleting ${error.errors.length} out of ${toDeleteObjectIds.length} ${className} objects`;
15131517
} else {
1514-
errorDeletingNote =
1515-
`Error deleting all ${error.errors.length} ${className} objects`;
1518+
errorDeletingNote = `Error deleting all ${error.errors.length} ${className} objects`;
15161519
}
15171520
} else {
15181521
if (toDeleteObjectIds.length == 1) {
1519-
errorDeletingNote =
1520-
`Error deleting ${className} with id '${toDeleteObjectIds[0]}'`;
1522+
errorDeletingNote = `Error deleting ${className} with id '${toDeleteObjectIds[0]}'`;
15211523
} else {
1522-
errorDeletingNote =
1523-
`Error deleting ${toDeleteObjectIds.length} ${className} objects`;
1524+
errorDeletingNote = `Error deleting ${toDeleteObjectIds.length} ${className} objects`;
15241525
}
15251526
}
15261527

@@ -2526,9 +2527,7 @@ class Browser extends DashboardView {
25262527
<Helmet>
25272528
<title>{pageTitle}</title>
25282529
</Helmet>
2529-
<SelectedRowsNavigationPrompt
2530-
when={Object.keys(this.state.selection).length > 0}
2531-
/>
2530+
<SelectedRowsNavigationPrompt when={Object.keys(this.state.selection).length > 0} />
25322531
{browser}
25332532
{notification}
25342533
{extras}

src/dashboard/Data/Browser/BrowserTable.react.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,7 @@ export default class BrowserTable extends React.Component {
574574
id="browser-table"
575575
style={{
576576
right: rightValue,
577-
'overflow-x': this.props.isResizing ? 'hidden' : 'auto',
577+
overflowX: this.props.isResizing ? 'hidden' : 'auto',
578578
}}
579579
>
580580
<DataBrowserHeaderBar

src/dashboard/Data/Config/Config.scss

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
align-items: center;
66
justify-content: center;
77
vertical-align: middle;
8-
width: 25px;
9-
height: 25px;
8+
width: 20px;
9+
height: 20px;
1010
cursor: pointer;
1111
svg {
1212
fill: currentColor;
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import Dropdown from 'components/Dropdown/Dropdown.react';
2+
import Field from 'components/Field/Field.react';
3+
import Label from 'components/Label/Label.react';
4+
import Modal from 'components/Modal/Modal.react';
5+
import Option from 'components/Dropdown/Option.react';
6+
import React from 'react';
7+
import TextInput from 'components/TextInput/TextInput.react';
8+
import Checkbox from 'components/Checkbox/Checkbox.react';
9+
10+
function isValidJSON(value) {
11+
try {
12+
JSON.parse(value);
13+
return true;
14+
} catch {
15+
return false;
16+
}
17+
}
18+
19+
export default class CreateViewDialog extends React.Component {
20+
constructor() {
21+
super();
22+
this.state = {
23+
name: '',
24+
className: '',
25+
query: '[]',
26+
showCounter: false,
27+
};
28+
}
29+
30+
valid() {
31+
return (
32+
this.state.name.length > 0 &&
33+
this.state.className.length > 0 &&
34+
isValidJSON(this.state.query)
35+
);
36+
}
37+
38+
render() {
39+
const { classes, onConfirm, onCancel } = this.props;
40+
return (
41+
<Modal
42+
type={Modal.Types.INFO}
43+
icon="plus"
44+
iconSize={40}
45+
title="Create a new view?"
46+
subtitle="Define a custom query to display data."
47+
confirmText="Create"
48+
cancelText="Cancel"
49+
disabled={!this.valid()}
50+
onCancel={onCancel}
51+
onConfirm={() =>
52+
onConfirm({
53+
name: this.state.name,
54+
className: this.state.className,
55+
query: JSON.parse(this.state.query),
56+
showCounter: this.state.showCounter,
57+
})
58+
}
59+
>
60+
<Field
61+
label={<Label text="Name" />}
62+
input={
63+
<TextInput
64+
value={this.state.name}
65+
onChange={name => this.setState({ name })}
66+
/>
67+
}
68+
/>
69+
<Field
70+
label={<Label text="Class" />}
71+
input={
72+
<Dropdown
73+
value={this.state.className}
74+
onChange={className => this.setState({ className })}
75+
>
76+
{classes.map(c => (
77+
<Option key={c} value={c}>
78+
{c}
79+
</Option>
80+
))}
81+
</Dropdown>
82+
}
83+
/>
84+
<Field
85+
label={
86+
<Label
87+
text="Query"
88+
description="An aggregation pipeline that returns an array of items."
89+
/>
90+
}
91+
input={
92+
<TextInput
93+
multiline={true}
94+
value={this.state.query}
95+
onChange={query => this.setState({ query })}
96+
/>
97+
}
98+
/>
99+
<Field
100+
label={<Label text="Show object counter" />}
101+
input={
102+
<Checkbox
103+
checked={this.state.showCounter}
104+
onChange={showCounter => this.setState({ showCounter })}
105+
/>
106+
}
107+
/>
108+
</Modal>
109+
);
110+
}
111+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import Field from 'components/Field/Field.react';
2+
import Label from 'components/Label/Label.react';
3+
import Modal from 'components/Modal/Modal.react';
4+
import React from 'react';
5+
import TextInput from 'components/TextInput/TextInput.react';
6+
7+
export default class DeleteViewDialog extends React.Component {
8+
constructor() {
9+
super();
10+
this.state = {
11+
confirmation: '',
12+
};
13+
}
14+
15+
valid() {
16+
return this.state.confirmation === this.props.name;
17+
}
18+
19+
render() {
20+
return (
21+
<Modal
22+
type={Modal.Types.DANGER}
23+
icon="warn-outline"
24+
title="Delete view?"
25+
subtitle="This action cannot be undone!"
26+
disabled={!this.valid()}
27+
confirmText="Delete"
28+
cancelText="Cancel"
29+
onCancel={this.props.onCancel}
30+
onConfirm={this.props.onConfirm}
31+
>
32+
<Field
33+
label={<Label text="Confirm this action" description="Enter the view name to continue." />}
34+
input={
35+
<TextInput
36+
placeholder="View name"
37+
value={this.state.confirmation}
38+
onChange={confirmation => this.setState({ confirmation })}
39+
/>
40+
}
41+
/>
42+
</Modal>
43+
);
44+
}
45+
}

0 commit comments

Comments
 (0)