@@ -332,6 +351,14 @@ class Views extends TableView {
this.props.navigate(path);
}
+ syncHeaderPosition() {
+ if (!this.headerRef.current || !this.contentRef.current) {
+ return;
+ }
+ const left = this.contentRef.current.getBoundingClientRect().left;
+ this.headerRef.current.style.left = `${left}px`;
+ }
+
showNote(message, isError) {
if (!message) {
return;
From 1708ad9450da7559c27026e7d8240e1ac67a4527 Mon Sep 17 00:00:00 2001
From: Manuel <5673677+mtrezza@users.noreply.github.com>
Date: Fri, 11 Jul 2025 01:46:49 +0200
Subject: [PATCH 14/26] fix: allow route for views page
---
src/dashboard/DashboardView.react.js | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/dashboard/DashboardView.react.js b/src/dashboard/DashboardView.react.js
index c48e5ac461..bf9aa12dc3 100644
--- a/src/dashboard/DashboardView.react.js
+++ b/src/dashboard/DashboardView.react.js
@@ -306,9 +306,10 @@ export default class DashboardView extends React.Component {
);
let content =
;
- const canRoute = [...coreSubsections, ...pushSubsections, ...settingsSections]
- .map(({ link }) => link.split('/')[1])
- .includes(this.state.route);
+ const allowedRoutes = [...coreSubsections, ...pushSubsections, ...settingsSections]
+ .map(({ link }) => link.split('/')[1]);
+ const canRoute =
+ allowedRoutes.includes(this.state.route) || this.state.route === 'views';
if (!canRoute) {
content = (
From d4902604224be74fbc44b019d43105d6904f95e5 Mon Sep 17 00:00:00 2001
From: Manuel <5673677+mtrezza@users.noreply.github.com>
Date: Fri, 11 Jul 2025 02:01:07 +0200
Subject: [PATCH 15/26] fix: parse route query string
---
src/dashboard/DashboardView.react.js | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/dashboard/DashboardView.react.js b/src/dashboard/DashboardView.react.js
index bf9aa12dc3..2dc7cca57b 100644
--- a/src/dashboard/DashboardView.react.js
+++ b/src/dashboard/DashboardView.react.js
@@ -33,7 +33,10 @@ export default class DashboardView extends React.Component {
onRouteChanged() {
const path = this.props.location?.pathname ?? window.location.pathname;
- const route = path.split('apps')[1].split('/')[2];
+ const route = path
+ .split('apps')[1]
+ .split('/')[2]
+ .split('?')[0];
if (route !== this.state.route) {
this.setState({ route });
}
From 7c6230ac82afac68c68bbec6ee5b0efff18eb012 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Fri, 11 Jul 2025 02:02:45 +0200
Subject: [PATCH 16/26] Revert "fix: sync view headers with scroll"
This reverts commit 0404b3ed57db2809492d9002b9f04d7adc9c26e0.
---
src/dashboard/Data/Views/Views.react.js | 29 +------------------------
1 file changed, 1 insertion(+), 28 deletions(-)
diff --git a/src/dashboard/Data/Views/Views.react.js b/src/dashboard/Data/Views/Views.react.js
index 059c6a9273..f8626929d6 100644
--- a/src/dashboard/Data/Views/Views.react.js
+++ b/src/dashboard/Data/Views/Views.react.js
@@ -26,9 +26,6 @@ class Views extends TableView {
super();
this.section = 'Core';
this.subsection = 'Views';
- this.contentRef = React.createRef();
- this.headerRef = React.createRef();
- this.syncHeaderPosition = this.syncHeaderPosition.bind(this);
this.state = {
views: [],
counts: {},
@@ -52,21 +49,6 @@ class Views extends TableView {
.then(() => this.loadViews(this.context));
}
- componentDidMount() {
- this.syncHeaderPosition();
- window.addEventListener('scroll', this.syncHeaderPosition);
- window.addEventListener('resize', this.syncHeaderPosition);
- }
-
- componentDidUpdate() {
- this.syncHeaderPosition();
- }
-
- componentWillUnmount() {
- window.removeEventListener('scroll', this.syncHeaderPosition);
- window.removeEventListener('resize', this.syncHeaderPosition);
- }
-
componentWillReceiveProps(nextProps, nextContext) {
if (this.context !== nextContext) {
this.props.schema
@@ -192,13 +174,12 @@ class Views extends TableView {
return (
- {content}
+ {content}
{toolbar}
{headers}
@@ -351,14 +332,6 @@ class Views extends TableView {
this.props.navigate(path);
}
- syncHeaderPosition() {
- if (!this.headerRef.current || !this.contentRef.current) {
- return;
- }
- const left = this.contentRef.current.getBoundingClientRect().left;
- this.headerRef.current.style.left = `${left}px`;
- }
-
showNote(message, isError) {
if (!message) {
return;
From 510dea272b6a779932181e5820aa64796e0c2ffe Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Fri, 11 Jul 2025 02:02:48 +0200
Subject: [PATCH 17/26] Revert "fix: allow route for views page"
This reverts commit 1708ad9450da7559c27026e7d8240e1ac67a4527.
---
src/dashboard/DashboardView.react.js | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/src/dashboard/DashboardView.react.js b/src/dashboard/DashboardView.react.js
index 2dc7cca57b..61987c4797 100644
--- a/src/dashboard/DashboardView.react.js
+++ b/src/dashboard/DashboardView.react.js
@@ -309,10 +309,9 @@ export default class DashboardView extends React.Component {
);
let content =
{this.renderContent()}
;
- const allowedRoutes = [...coreSubsections, ...pushSubsections, ...settingsSections]
- .map(({ link }) => link.split('/')[1]);
- const canRoute =
- allowedRoutes.includes(this.state.route) || this.state.route === 'views';
+ const canRoute = [...coreSubsections, ...pushSubsections, ...settingsSections]
+ .map(({ link }) => link.split('/')[1])
+ .includes(this.state.route);
if (!canRoute) {
content = (
From d18cb6a054813d8e104b5cf04e90cf13b92b2d07 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Fri, 11 Jul 2025 02:02:49 +0200
Subject: [PATCH 18/26] Revert "fix: parse route query string"
This reverts commit d4902604224be74fbc44b019d43105d6904f95e5.
---
src/dashboard/DashboardView.react.js | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/src/dashboard/DashboardView.react.js b/src/dashboard/DashboardView.react.js
index 61987c4797..c48e5ac461 100644
--- a/src/dashboard/DashboardView.react.js
+++ b/src/dashboard/DashboardView.react.js
@@ -33,10 +33,7 @@ export default class DashboardView extends React.Component {
onRouteChanged() {
const path = this.props.location?.pathname ?? window.location.pathname;
- const route = path
- .split('apps')[1]
- .split('/')[2]
- .split('?')[0];
+ const route = path.split('apps')[1].split('/')[2];
if (route !== this.state.route) {
this.setState({ route });
}
From 80ef8c1808b0514d3efa78e1ca31a4e50090c52b Mon Sep 17 00:00:00 2001
From: Manuel <5673677+mtrezza@users.noreply.github.com>
Date: Sat, 12 Jul 2025 01:45:45 +0200
Subject: [PATCH 19/26] feat: Allow editing views (#2889)
---
.../Data/Views/DeleteViewDialog.react.js | 45 ++++
.../Data/Views/EditViewDialog.react.js | 112 ++++++++++
src/dashboard/Data/Views/Views.react.js | 204 ++++++++++++++++--
src/dashboard/Data/Views/Views.scss | 14 +-
4 files changed, 343 insertions(+), 32 deletions(-)
create mode 100644 src/dashboard/Data/Views/DeleteViewDialog.react.js
create mode 100644 src/dashboard/Data/Views/EditViewDialog.react.js
diff --git a/src/dashboard/Data/Views/DeleteViewDialog.react.js b/src/dashboard/Data/Views/DeleteViewDialog.react.js
new file mode 100644
index 0000000000..eae2ed039d
--- /dev/null
+++ b/src/dashboard/Data/Views/DeleteViewDialog.react.js
@@ -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 (
+
+ }
+ input={
+ this.setState({ confirmation })}
+ />
+ }
+ />
+
+ );
+ }
+}
diff --git a/src/dashboard/Data/Views/EditViewDialog.react.js b/src/dashboard/Data/Views/EditViewDialog.react.js
new file mode 100644
index 0000000000..50e3bbb57f
--- /dev/null
+++ b/src/dashboard/Data/Views/EditViewDialog.react.js
@@ -0,0 +1,112 @@
+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 EditViewDialog extends React.Component {
+ constructor(props) {
+ super();
+ const view = props.view || {};
+ this.state = {
+ name: view.name || '',
+ className: view.className || '',
+ query: JSON.stringify(view.query || [], null, 2),
+ showCounter: !!view.showCounter,
+ };
+ }
+
+ valid() {
+ return (
+ this.state.name.length > 0 &&
+ this.state.className.length > 0 &&
+ isValidJSON(this.state.query)
+ );
+ }
+
+ render() {
+ const { classes, onConfirm, onCancel } = this.props;
+ return (
+
+ onConfirm({
+ name: this.state.name,
+ className: this.state.className,
+ query: JSON.parse(this.state.query),
+ showCounter: this.state.showCounter,
+ })
+ }
+ >
+ }
+ input={
+ this.setState({ name })}
+ />
+ }
+ />
+ }
+ input={
+ this.setState({ className })}
+ >
+ {classes.map(c => (
+
+ ))}
+
+ }
+ />
+
+ }
+ input={
+ this.setState({ query })}
+ />
+ }
+ />
+ }
+ input={
+ this.setState({ showCounter })}
+ />
+ }
+ />
+
+ );
+ }
+}
diff --git a/src/dashboard/Data/Views/Views.react.js b/src/dashboard/Data/Views/Views.react.js
index f8626929d6..5029ed6ca8 100644
--- a/src/dashboard/Data/Views/Views.react.js
+++ b/src/dashboard/Data/Views/Views.react.js
@@ -6,9 +6,14 @@ import LoaderContainer from 'components/LoaderContainer/LoaderContainer.react';
import Parse from 'parse';
import React from 'react';
import Notification from 'dashboard/Data/Browser/Notification.react';
-import Icon from 'components/Icon/Icon.react';
+import Pill from 'components/Pill/Pill.react';
import DragHandle from 'components/DragHandle/DragHandle.react';
import CreateViewDialog from './CreateViewDialog.react';
+import EditViewDialog from './EditViewDialog.react';
+import DeleteViewDialog from './DeleteViewDialog.react';
+import BrowserMenu from 'components/BrowserMenu/BrowserMenu.react';
+import MenuItem from 'components/BrowserMenu/MenuItem.react';
+import Separator from 'components/BrowserMenu/Separator.react';
import * as ViewPreferences from 'lib/ViewPreferences';
import generatePath from 'lib/generatePath';
import { withRouter } from 'lib/withRouter';
@@ -34,9 +39,13 @@ class Views extends TableView {
columns: {},
tableWidth: 0,
showCreate: false,
+ editView: null,
+ editIndex: null,
+ deleteIndex: null,
lastError: null,
lastNote: null,
};
+ this.headersRef = React.createRef();
this.noteTimeout = null;
this.action = new SidebarAction('Create a view', () =>
this.setState({ showCreate: true })
@@ -49,6 +58,10 @@ class Views extends TableView {
.then(() => this.loadViews(this.context));
}
+ componentWillUnmount() {
+ clearTimeout(this.noteTimeout);
+ }
+
componentWillReceiveProps(nextProps, nextContext) {
if (this.context !== nextContext) {
this.props.schema
@@ -98,7 +111,22 @@ class Views extends TableView {
.aggregate(view.query, { useMasterKey: true })
.then(results => {
const columns = {};
- const computeWidth = str => Math.max((String(str).length + 2) * 8, 40);
+ const computeWidth = str => {
+ const text =
+ typeof str === 'object' && str !== null
+ ? JSON.stringify(str)
+ : String(str);
+ if (typeof document !== 'undefined') {
+ const canvas =
+ computeWidth._canvas ||
+ (computeWidth._canvas = document.createElement('canvas'));
+ const context = canvas.getContext('2d');
+ context.font = '12px "Source Code Pro", "Courier New", monospace';
+ const width = context.measureText(text).width + 32;
+ return Math.max(width, 40);
+ }
+ return Math.max((text.length + 2) * 12, 40);
+ };
results.forEach(item => {
Object.keys(item).forEach(key => {
const val = item[key];
@@ -121,7 +149,11 @@ class Views extends TableView {
}
}
if (!columns[key]) {
- columns[key] = { type, width: computeWidth(key) };
+ columns[key] = { type, width: Math.min(computeWidth(key), 200) };
+ }
+ const width = computeWidth(val);
+ if (width > columns[key].width && columns[key].width < 200) {
+ columns[key].width = Math.min(width, 200);
}
});
});
@@ -154,7 +186,11 @@ class Views extends TableView {
console.warn('tableData() needs to return an array of objects');
} else {
if (data.length === 0) {
- content =
{this.renderEmpty()}
;
+ content = (
+
+ {this.renderEmpty()}
+
+ );
} else {
content = (
@@ -174,15 +210,29 @@ class Views extends TableView {
return (
- {content}
+
+
+
+ {headers}
+
+ {content}
+
+
{toolbar}
-
- {headers}
-
{extras}
);
@@ -191,21 +241,37 @@ class Views extends TableView {
renderRow(row) {
return (
- {this.state.order.map(({ name, width }) => {
+ {this.state.order.map(({ name }) => {
const value = row[name];
- const type = this.state.columns[name]?.type;
+ let type = 'String';
+ if (typeof value === 'number') {
+ type = 'Number';
+ } else if (typeof value === 'boolean') {
+ type = 'Boolean';
+ } else if (value && typeof value === 'object') {
+ if (value.__type === 'Date') {
+ type = 'Date';
+ } else if (value.__type === 'Pointer') {
+ type = 'Pointer';
+ } else if (value.__type === 'File') {
+ type = 'File';
+ } else if (value.__type === 'GeoPoint') {
+ type = 'GeoPoint';
+ } else {
+ type = 'Object';
+ }
+ }
let content = '';
if (type === 'Pointer' && value && value.className && value.objectId) {
const id = value.objectId;
const className = value.className;
content = (
- this.handlePointerClick({ className, id })}
- >
- {id}
-
-
+ followClick
+ shrinkablePill
+ />
);
} else if (type === 'Object') {
content = JSON.stringify(value);
@@ -244,6 +310,7 @@ class Views extends TableView {
});
}
+
renderHeaders() {
return this.state.order.map(({ name, width }, i) => (
@@ -267,7 +334,9 @@ class Views extends TableView {
return (
{}}
categories={categories}
/>
);
@@ -275,7 +344,44 @@ class Views extends TableView {
renderToolbar() {
const subsection = this.props.params.name || '';
- return ;
+ let editMenu = null;
+ if (this.props.params.name) {
+ editMenu = (
+ {}}>
+
+ );
+ }
+ return (
+
+ {editMenu}
+
+ );
}
renderExtras() {
@@ -306,6 +412,64 @@ class Views extends TableView {
}}
/>
);
+ } else if (this.state.editView) {
+ let classNames = [];
+ if (this.props.schema?.data) {
+ const classes = this.props.schema.data.get('classes');
+ if (classes) {
+ classNames = Object.keys(classes.toObject());
+ }
+ }
+ extras = (
+ this.setState({ editView: null, editIndex: null })}
+ onConfirm={view => {
+ this.setState(
+ state => {
+ const newViews = [...state.views];
+ newViews[state.editIndex] = view;
+ return { editView: null, editIndex: null, views: newViews };
+ },
+ () => {
+ ViewPreferences.saveViews(
+ this.context.applicationId,
+ this.state.views
+ );
+ this.loadViews(this.context);
+ }
+ );
+ }}
+ />
+ );
+ } else if (this.state.deleteIndex !== null) {
+ const name = this.state.views[this.state.deleteIndex]?.name || '';
+ extras = (
+ this.setState({ deleteIndex: null })}
+ onConfirm={() => {
+ this.setState(
+ state => {
+ const newViews = state.views.filter((_, i) => i !== state.deleteIndex);
+ return { deleteIndex: null, views: newViews };
+ },
+ () => {
+ ViewPreferences.saveViews(
+ this.context.applicationId,
+ this.state.views
+ );
+ if (this.props.params.name === name) {
+ const path = generatePath(this.context, 'views');
+ this.props.navigate(path);
+ }
+ this.loadViews(this.context);
+ }
+ );
+ }}
+ />
+ );
}
let notification = null;
if (this.state.lastError) {
diff --git a/src/dashboard/Data/Views/Views.scss b/src/dashboard/Data/Views/Views.scss
index 35a2c6d438..028832be21 100644
--- a/src/dashboard/Data/Views/Views.scss
+++ b/src/dashboard/Data/Views/Views.scss
@@ -40,8 +40,8 @@
}
.cell {
- line-height: 30px;
- padding: 10px 16px;
+ line-height: 20px;
+ padding: 5px 16px;
border-right: 1px solid #e3e3ea;
overflow: hidden;
text-overflow: ellipsis;
@@ -49,13 +49,3 @@
max-width: none;
}
-.pointerLink {
- display: inline-flex;
- align-items: center;
- cursor: pointer;
-
- svg {
- margin-left: 4px;
- transform: rotate(316deg);
- }
-}
From eef72bd53a5b9fc68f019442cb988a30c6860d97 Mon Sep 17 00:00:00 2001
From: Manuel <5673677+mtrezza@users.noreply.github.com>
Date: Sat, 12 Jul 2025 03:10:40 +0200
Subject: [PATCH 20/26] fix: Prevent stale pointer navigation (#2890)
---
.../BrowserMenu/BrowserMenu.react.js | 5 +-
src/dashboard/Data/Browser/Browser.react.js | 31 ++--
.../Data/Browser/BrowserTable.react.js | 2 +-
src/dashboard/Data/Views/Views.react.js | 158 +++++++++++-------
4 files changed, 118 insertions(+), 78 deletions(-)
diff --git a/src/components/BrowserMenu/BrowserMenu.react.js b/src/components/BrowserMenu/BrowserMenu.react.js
index 2eb60e50ec..ee5f9c0b41 100644
--- a/src/components/BrowserMenu/BrowserMenu.react.js
+++ b/src/components/BrowserMenu/BrowserMenu.react.js
@@ -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.'
),
};
diff --git a/src/dashboard/Data/Browser/Browser.react.js b/src/dashboard/Data/Browser/Browser.react.js
index b60df5be4a..c524607f01 100644
--- a/src/dashboard/Data/Browser/Browser.react.js
+++ b/src/dashboard/Data/Browser/Browser.react.js
@@ -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;
@@ -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
@@ -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 = [];
@@ -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`;
}
}
@@ -2526,9 +2527,7 @@ class Browser extends DashboardView {
{pageTitle}
- 0}
- />
+ 0} />
{browser}
{notification}
{extras}
diff --git a/src/dashboard/Data/Browser/BrowserTable.react.js b/src/dashboard/Data/Browser/BrowserTable.react.js
index 1d8001816a..378d162ff9 100644
--- a/src/dashboard/Data/Browser/BrowserTable.react.js
+++ b/src/dashboard/Data/Browser/BrowserTable.react.js
@@ -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',
}}
>
- this.setState({ showCreate: true })
- );
+ this.action = new SidebarAction('Create a view', () => this.setState({ showCreate: true }));
+ }
+
+ componentDidMount() {
+ this._isMounted = true;
}
componentWillMount() {
- this.props.schema
- .dispatch(SchemaActionTypes.FETCH)
- .then(() => this.loadViews(this.context));
+ this.props.schema.dispatch(SchemaActionTypes.FETCH).then(() => this.loadViews(this.context));
}
componentWillUnmount() {
+ this._isMounted = false;
clearTimeout(this.noteTimeout);
}
componentWillReceiveProps(nextProps, nextContext) {
if (this.context !== nextContext) {
- this.props.schema
- .dispatch(SchemaActionTypes.FETCH)
- .then(() => this.loadViews(nextContext));
+ this.props.schema.dispatch(SchemaActionTypes.FETCH).then(() => this.loadViews(nextContext));
}
if (this.props.params.name !== nextProps.params.name || this.context !== nextContext) {
+ window.scrollTo({ top: 0 });
this.loadData(nextProps.params.name);
}
}
@@ -81,30 +85,40 @@ class Views extends TableView {
new Parse.Query(view.className)
.aggregate(view.query, { useMasterKey: true })
.then(res => {
- this.setState(({ counts }) => ({
- counts: { ...counts, [view.name]: res.length },
- }));
+ if (this._isMounted) {
+ this.setState(({ counts }) => ({
+ counts: { ...counts, [view.name]: res.length },
+ }));
+ }
})
.catch(error => {
- this.showNote(
- `Request failed: ${error.message || 'Unknown error occurred'}`,
- true
- );
+ if (this._isMounted) {
+ this.showNote(`Request failed: ${error.message || 'Unknown error occurred'}`, true);
+ }
});
}
});
- this.loadData(this.props.params.name);
+ if (this._isMounted) {
+ this.loadData(this.props.params.name);
+ }
});
}
loadData(name) {
+ if (this._isMounted) {
+ this.setState({ loading: true });
+ }
if (!name) {
- this.setState({ data: [], order: [], columns: {} });
+ if (this._isMounted) {
+ this.setState({ data: [], order: [], columns: {}, loading: false });
+ }
return;
}
const view = (this.state.views || []).find(v => v.name === name);
if (!view) {
- this.setState({ data: [], order: [], columns: {} });
+ if (this._isMounted) {
+ this.setState({ data: [], order: [], columns: {}, loading: false });
+ }
return;
}
new Parse.Query(view.className)
@@ -112,14 +126,10 @@ class Views extends TableView {
.then(results => {
const columns = {};
const computeWidth = str => {
- const text =
- typeof str === 'object' && str !== null
- ? JSON.stringify(str)
- : String(str);
+ const text = typeof str === 'object' && str !== null ? JSON.stringify(str) : String(str);
if (typeof document !== 'undefined') {
const canvas =
- computeWidth._canvas ||
- (computeWidth._canvas = document.createElement('canvas'));
+ computeWidth._canvas || (computeWidth._canvas = document.createElement('canvas'));
const context = canvas.getContext('2d');
context.font = '12px "Source Code Pro", "Courier New", monospace';
const width = context.measureText(text).width + 32;
@@ -160,17 +170,22 @@ class Views extends TableView {
const colNames = Object.keys(columns);
const order = colNames.map(name => ({ name, width: columns[name].width }));
const tableWidth = order.reduce((sum, col) => sum + col.width, 0);
- this.setState({ data: results, order, columns, tableWidth });
+ if (this._isMounted) {
+ this.setState({ data: results, order, columns, tableWidth, loading: false });
+ }
})
.catch(error => {
- this.showNote(
- `Request failed: ${error.message || 'Unknown error occurred'}`,
- true
- );
- this.setState({ data: [], order: [], columns: {} });
+ if (this._isMounted) {
+ this.showNote(`Request failed: ${error.message || 'Unknown error occurred'}`, true);
+ this.setState({ data: [], order: [], columns: {}, loading: false });
+ }
});
}
+ onRefresh() {
+ this.loadData(this.props.params.name);
+ }
+
tableData() {
return this.state.data;
}
@@ -209,11 +224,8 @@ class Views extends TableView {
const loading = this.state ? this.state.loading : false;
return (
-
-
+
+
(
@@ -321,6 +332,34 @@ class Views extends TableView {
}
renderEmpty() {
+ if (!this.props.params.name) {
+ if (this.state.views.length > 0) {
+ return (
+
+ );
+ }
+ return (
+
+ Use views to display aggregated data from your classes.{' '}
+
+ Learn more
+
+ .
+
+ }
+ cta="Create a view"
+ action={() => this.setState({ showCreate: true })}
+ />
+ );
+ }
return No data available
;
}
@@ -336,7 +375,9 @@ class Views extends TableView {
current={current}
params={this.props.location?.search}
linkPrefix={'views/'}
- classClicked={() => {}}
+ classClicked={() => {
+ window.scrollTo({ top: 0 });
+ }}
categories={categories}
/>
);
@@ -345,15 +386,14 @@ class Views extends TableView {
renderToolbar() {
const subsection = this.props.params.name || '';
let editMenu = null;
+ let refreshButton = null;
if (this.props.params.name) {
editMenu = (
{}}>
{
- const index = this.state.views.findIndex(
- v => v.name === this.props.params.name
- );
+ const index = this.state.views.findIndex(v => v.name === this.props.params.name);
if (index >= 0) {
this.setState({
editView: this.state.views[index],
@@ -366,9 +406,7 @@ class Views extends TableView {
{
- const index = this.state.views.findIndex(
- v => v.name === this.props.params.name
- );
+ const index = this.state.views.findIndex(v => v.name === this.props.params.name);
if (index >= 0) {
this.setState({ deleteIndex: index });
}
@@ -376,9 +414,20 @@ class Views extends TableView {
/>
);
+ refreshButton = (
+ <>
+
+
+ Refresh
+
+
+ >
+ );
}
+
return (
+ {refreshButton}
{editMenu}
);
@@ -402,10 +451,7 @@ class Views extends TableView {
this.setState(
state => ({ showCreate: false, views: [...state.views, view] }),
() => {
- ViewPreferences.saveViews(
- this.context.applicationId,
- this.state.views
- );
+ ViewPreferences.saveViews(this.context.applicationId, this.state.views);
this.loadViews(this.context);
}
);
@@ -433,10 +479,7 @@ class Views extends TableView {
return { editView: null, editIndex: null, views: newViews };
},
() => {
- ViewPreferences.saveViews(
- this.context.applicationId,
- this.state.views
- );
+ ViewPreferences.saveViews(this.context.applicationId, this.state.views);
this.loadViews(this.context);
}
);
@@ -456,10 +499,7 @@ class Views extends TableView {
return { deleteIndex: null, views: newViews };
},
() => {
- ViewPreferences.saveViews(
- this.context.applicationId,
- this.state.views
- );
+ ViewPreferences.saveViews(this.context.applicationId, this.state.views);
if (this.props.params.name === name) {
const path = generatePath(this.context, 'views');
this.props.navigate(path);
@@ -486,9 +526,7 @@ class Views extends TableView {
}
handlePointerClick({ className, id, field = 'objectId' }) {
- const filters = JSON.stringify([
- { field, constraint: 'eq', compareTo: id },
- ]);
+ const filters = JSON.stringify([{ field, constraint: 'eq', compareTo: id }]);
const path = generatePath(
this.context,
`browser/${className}?filters=${encodeURIComponent(filters)}`
From 146ec5debee2a7aa0291e5dce9ccf4cd2b5b7e8d Mon Sep 17 00:00:00 2001
From: Manuel <5673677+mtrezza@users.noreply.github.com>
Date: Sat, 12 Jul 2025 03:50:23 +0200
Subject: [PATCH 21/26] fix: Handle invalid pointers in Views results (#2891)
---
.../Data/Views/ViewValueDialog.react.js | 26 ++++++++++++++
src/dashboard/Data/Views/Views.react.js | 35 ++++++++++++++++---
2 files changed, 57 insertions(+), 4 deletions(-)
create mode 100644 src/dashboard/Data/Views/ViewValueDialog.react.js
diff --git a/src/dashboard/Data/Views/ViewValueDialog.react.js b/src/dashboard/Data/Views/ViewValueDialog.react.js
new file mode 100644
index 0000000000..86ffa3ef61
--- /dev/null
+++ b/src/dashboard/Data/Views/ViewValueDialog.react.js
@@ -0,0 +1,26 @@
+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 function ViewValueDialog({ value, onClose }) {
+ let stringValue;
+ if (typeof value === 'object' && value !== null) {
+ stringValue = JSON.stringify(value, null, 2);
+ } else {
+ stringValue = String(value);
+ }
+ return (
+
+ } input={} />
+
+ );
+}
diff --git a/src/dashboard/Data/Views/Views.react.js b/src/dashboard/Data/Views/Views.react.js
index 36b1c75eec..e267a2dbe5 100644
--- a/src/dashboard/Data/Views/Views.react.js
+++ b/src/dashboard/Data/Views/Views.react.js
@@ -12,6 +12,7 @@ import DragHandle from 'components/DragHandle/DragHandle.react';
import CreateViewDialog from './CreateViewDialog.react';
import EditViewDialog from './EditViewDialog.react';
import DeleteViewDialog from './DeleteViewDialog.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';
@@ -48,6 +49,7 @@ class Views extends TableView {
lastError: null,
lastNote: null,
loading: false,
+ viewValue: null,
};
this.headersRef = React.createRef();
this.noteTimeout = null;
@@ -149,7 +151,11 @@ class Views extends TableView {
if (val.__type === 'Date') {
type = 'Date';
} else if (val.__type === 'Pointer') {
- type = 'Pointer';
+ if (val.className && val.objectId) {
+ type = 'Pointer';
+ } else {
+ type = 'Object';
+ }
} else if (val.__type === 'File') {
type = 'File';
} else if (val.__type === 'GeoPoint') {
@@ -264,7 +270,11 @@ class Views extends TableView {
if (value.__type === 'Date') {
type = 'Date';
} else if (value.__type === 'Pointer') {
- type = 'Pointer';
+ if (value.className && value.objectId) {
+ type = 'Pointer';
+ } else {
+ type = 'Object';
+ }
} else if (value.__type === 'File') {
type = 'File';
} else if (value.__type === 'GeoPoint') {
@@ -290,8 +300,14 @@ class Views extends TableView {
} else {
content = String(value);
}
+ const isViewable = ['String', 'Number', 'Object'].includes(type);
+ const cellProps = {};
+ if (isViewable) {
+ cellProps.onClick = () => this.handleValueClick(value);
+ cellProps.style = { cursor: 'pointer' };
+ }
return (
-
+ |
{content}
|
);
@@ -435,7 +451,14 @@ class Views extends TableView {
renderExtras() {
let extras = null;
- if (this.state.showCreate) {
+ if (this.state.viewValue !== null) {
+ extras = (
+ this.setState({ viewValue: null })}
+ />
+ );
+ } else if (this.state.showCreate) {
let classNames = [];
if (this.props.schema?.data) {
const classes = this.props.schema.data.get('classes');
@@ -534,6 +557,10 @@ class Views extends TableView {
this.props.navigate(path);
}
+ handleValueClick(value) {
+ this.setState({ viewValue: value });
+ }
+
showNote(message, isError) {
if (!message) {
return;
From 5b96beaa4311f2bc05cb4072f480520362fc6bda Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Sat, 12 Jul 2025 10:32:37 +0200
Subject: [PATCH 22/26] Update TableView.scss
---
src/dashboard/TableView.scss | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/dashboard/TableView.scss b/src/dashboard/TableView.scss
index 5027e6a89f..08c24d98ca 100644
--- a/src/dashboard/TableView.scss
+++ b/src/dashboard/TableView.scss
@@ -54,8 +54,8 @@ body:global(.expanded) {
}
td {
- line-height: 30px;
- padding: 10px 16px;
+ line-height: normal;
+ padding: 8px 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
From f9514dd90ed5e38331f52a5378b5b38c3647e5aa Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Sat, 12 Jul 2025 11:45:55 +0200
Subject: [PATCH 23/26] Cell height fix
---
src/dashboard/Data/Config/Config.scss | 4 ++--
src/dashboard/TableView.scss | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/dashboard/Data/Config/Config.scss b/src/dashboard/Data/Config/Config.scss
index ce22903024..32ae6d0615 100644
--- a/src/dashboard/Data/Config/Config.scss
+++ b/src/dashboard/Data/Config/Config.scss
@@ -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;
diff --git a/src/dashboard/TableView.scss b/src/dashboard/TableView.scss
index 08c24d98ca..6ed24afc12 100644
--- a/src/dashboard/TableView.scss
+++ b/src/dashboard/TableView.scss
@@ -54,7 +54,7 @@ body:global(.expanded) {
}
td {
- line-height: normal;
+ line-height: 20px;
padding: 8px 16px;
overflow: hidden;
text-overflow: ellipsis;
From dc366308399ffea4e052b3f400b01e3c93afce1d Mon Sep 17 00:00:00 2001
From: Manuel <5673677+mtrezza@users.noreply.github.com>
Date: Sat, 12 Jul 2025 13:04:32 +0200
Subject: [PATCH 24/26] fix: Center pill in Views table cells (#2895)
---
.../Data/Views/ViewValueDialog.react.js | 13 ++++++++++-
src/dashboard/Data/Views/Views.react.js | 23 ++++++++++++++-----
src/dashboard/Data/Views/Views.scss | 8 +++++++
3 files changed, 37 insertions(+), 7 deletions(-)
diff --git a/src/dashboard/Data/Views/ViewValueDialog.react.js b/src/dashboard/Data/Views/ViewValueDialog.react.js
index 86ffa3ef61..31c7c6794b 100644
--- a/src/dashboard/Data/Views/ViewValueDialog.react.js
+++ b/src/dashboard/Data/Views/ViewValueDialog.react.js
@@ -20,7 +20,18 @@ export default function ViewValueDialog({ value, onClose }) {
showCancel={false}
onConfirm={onClose}
>
- } input={} />
+ }
+ input={
+ {}}
+ />
+ }
+ />
);
}
diff --git a/src/dashboard/Data/Views/Views.react.js b/src/dashboard/Data/Views/Views.react.js
index e267a2dbe5..3ee6f5a332 100644
--- a/src/dashboard/Data/Views/Views.react.js
+++ b/src/dashboard/Data/Views/Views.react.js
@@ -284,7 +284,8 @@ class Views extends TableView {
}
}
let content = '';
- if (type === 'Pointer' && value && value.className && value.objectId) {
+ const hasPill = type === 'Pointer' && value && value.className && value.objectId;
+ if (hasPill) {
const id = value.objectId;
const className = value.className;
content = (
@@ -301,14 +302,24 @@ class Views extends TableView {
content = String(value);
}
const isViewable = ['String', 'Number', 'Object'].includes(type);
- const cellProps = {};
+ const classes = [styles.cell];
+ if (hasPill) {
+ classes.push(styles.pillCell);
+ }
+ let cellContent = content;
if (isViewable) {
- cellProps.onClick = () => this.handleValueClick(value);
- cellProps.style = { cursor: 'pointer' };
+ cellContent = (
+ this.handleValueClick(value)}
+ >
+ {content}
+
+ );
}
return (
-
- {content}
+ |
+ {cellContent}
|
);
})}
diff --git a/src/dashboard/Data/Views/Views.scss b/src/dashboard/Data/Views/Views.scss
index 028832be21..03c6794437 100644
--- a/src/dashboard/Data/Views/Views.scss
+++ b/src/dashboard/Data/Views/Views.scss
@@ -49,3 +49,11 @@
max-width: none;
}
+.tableRow td.pillCell {
+ line-height: 8px;
+}
+
+.clickableText {
+ cursor: pointer;
+}
+
From d74b4f8ddb206439b1b61740714fe7eef79ec2ae Mon Sep 17 00:00:00 2001
From: Manuel <5673677+mtrezza@users.noreply.github.com>
Date: Sat, 12 Jul 2025 13:16:32 +0200
Subject: [PATCH 25/26] docs: Add Views feature (#2896)
---
README.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/README.md b/README.md
index 37667813be..0d4381b291 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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
From d657be13c7abb084dc30fb6cb4464343e4c09463 Mon Sep 17 00:00:00 2001
From: Manuel <5673677+mtrezza@users.noreply.github.com>
Date: Sun, 13 Jul 2025 03:07:56 +0200
Subject: [PATCH 26/26] fix: Display ISO string for date objects in views
(#2897)
---
src/dashboard/Data/Views/Views.react.js | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/src/dashboard/Data/Views/Views.react.js b/src/dashboard/Data/Views/Views.react.js
index 3ee6f5a332..9aa2e1cdfa 100644
--- a/src/dashboard/Data/Views/Views.react.js
+++ b/src/dashboard/Data/Views/Views.react.js
@@ -128,7 +128,13 @@ class Views extends TableView {
.then(results => {
const columns = {};
const computeWidth = str => {
- const text = typeof str === 'object' && str !== null ? JSON.stringify(str) : String(str);
+ let text = str;
+ if (text === undefined) {
+ text = '';
+ } else if (text && typeof text === 'object') {
+ text = text.__type === 'Date' && text.iso ? text.iso : JSON.stringify(text);
+ }
+ text = String(text);
if (typeof document !== 'undefined') {
const canvas =
computeWidth._canvas || (computeWidth._canvas = document.createElement('canvas'));
@@ -298,6 +304,10 @@ class Views extends TableView {
);
} else if (type === 'Object') {
content = JSON.stringify(value);
+ } else if (type === 'Date') {
+ content = value && value.iso ? value.iso : String(value);
+ } else if (value === undefined) {
+ content = '';
} else {
content = String(value);
}