Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
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
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>
);
}
}
239 changes: 239 additions & 0 deletions src/dashboard/Data/Views/Views.react.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import CategoryList from 'components/CategoryList/CategoryList.react';
import SidebarAction from 'components/Sidebar/SidebarAction';
import TableHeader from 'components/Table/TableHeader.react';
import TableView from 'dashboard/TableView.react';
import Toolbar from 'components/Toolbar/Toolbar.react';
import Parse from 'parse';
import React from 'react';
import Notification from 'dashboard/Data/Browser/Notification.react';
import CreateViewDialog from './CreateViewDialog.react';
import * as ViewPreferences from 'lib/ViewPreferences';
import { withRouter } from 'lib/withRouter';
import subscribeTo from 'lib/subscribeTo';
import { ActionTypes as SchemaActionTypes } from 'lib/stores/SchemaStore';

export default
@subscribeTo('Schema', 'schema')
@withRouter
class Views extends TableView {
constructor() {
super();
this.section = 'Core';
this.subsection = 'Views';
this.state = {
views: [],
counts: {},
data: [],
order: [],
columns: {},
showCreate: false,
lastError: null,
lastNote: null,
};
this.noteTimeout = null;
this.action = new SidebarAction('Create a view', () =>
this.setState({ showCreate: true })
);
}

componentWillMount() {
this.props.schema
.dispatch(SchemaActionTypes.FETCH)
.then(() => this.loadViews(this.context));
}

componentWillReceiveProps(nextProps, nextContext) {
if (this.context !== nextContext) {
this.props.schema
.dispatch(SchemaActionTypes.FETCH)
.then(() => this.loadViews(nextContext));
}
if (this.props.params.name !== nextProps.params.name || this.context !== nextContext) {
this.loadData(nextProps.params.name);
}
}

loadViews(app) {
const views = ViewPreferences.getViews(app.applicationId);
this.setState({ views, counts: {} }, () => {
views.forEach(view => {
if (view.showCounter) {
new Parse.Query(view.className)
.aggregate(view.query, { useMasterKey: true })
.then(res => {
this.setState(({ counts }) => ({
counts: { ...counts, [view.name]: res.length },
}));
})
.catch(error => {
this.showNote(
`Request failed: ${error.message || 'Unknown error occurred'}`,
true
);
});
}
});
this.loadData(this.props.params.name);
});
}

loadData(name) {
if (!name) {
this.setState({ data: [], order: [], columns: {} });
return;
}
const view = (this.state.views || []).find(v => v.name === name);
if (!view) {
this.setState({ data: [], order: [], columns: {} });
return;
}
new Parse.Query(view.className)
.aggregate(view.query, { useMasterKey: true })
.then(results => {
const columns = {};
results.forEach(item => {
Object.keys(item).forEach(key => {
if (columns[key]) {
return;
}
const val = item[key];
let type = 'String';
if (typeof val === 'number') {
type = 'Number';
} else if (typeof val === 'boolean') {
type = 'Boolean';
} else if (val && typeof val === 'object') {
if (val.__type === 'Date') {
type = 'Date';
} else if (val.__type === 'Pointer') {
type = 'Pointer';
} else if (val.__type === 'File') {
type = 'File';
} else if (val.__type === 'GeoPoint') {
type = 'GeoPoint';
} else {
type = 'Object';
}
}
columns[key] = { type };

Check failure on line 118 in src/dashboard/Data/Views/Views.react.js

View workflow job for this annotation

GitHub Actions / Lint

Expected indentation of 12 spaces but found 10
});

Check failure on line 119 in src/dashboard/Data/Views/Views.react.js

View workflow job for this annotation

GitHub Actions / Lint

Expected indentation of 10 spaces but found 8
});

Check failure on line 120 in src/dashboard/Data/Views/Views.react.js

View workflow job for this annotation

GitHub Actions / Lint

Expected indentation of 8 spaces but found 6
const colNames = Object.keys(columns);

Check failure on line 121 in src/dashboard/Data/Views/Views.react.js

View workflow job for this annotation

GitHub Actions / Lint

Expected indentation of 8 spaces but found 6
const width = colNames.length > 0 ? 100 / colNames.length : 0;

Check failure on line 122 in src/dashboard/Data/Views/Views.react.js

View workflow job for this annotation

GitHub Actions / Lint

Expected indentation of 8 spaces but found 6
const order = colNames.map(name => ({ name, width }));

Check failure on line 123 in src/dashboard/Data/Views/Views.react.js

View workflow job for this annotation

GitHub Actions / Lint

Expected indentation of 8 spaces but found 6
this.setState({ data: results, order, columns });

Check failure on line 124 in src/dashboard/Data/Views/Views.react.js

View workflow job for this annotation

GitHub Actions / Lint

Expected indentation of 8 spaces but found 6
})
.catch(error => {
this.showNote(
`Request failed: ${error.message || 'Unknown error occurred'}`,
true
);
this.setState({ data: [], order: [], columns: {} });
});
}

tableData() {
return this.state.data;
}

renderRow(row) {
return (
<tr key={JSON.stringify(row)}>
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Use a more efficient key for table rows.

Using JSON.stringify(row) as a React key is inefficient and potentially problematic for large datasets. Consider using a unique identifier from the data.

-      <tr key={JSON.stringify(row)}>
+      <tr key={row.objectId || row.id || JSON.stringify(row)}>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<tr key={JSON.stringify(row)}>
<tr key={row.objectId || row.id || JSON.stringify(row)}>
🤖 Prompt for AI Agents
In src/dashboard/Data/Views/Views.react.js at line 121, replace the use of
JSON.stringify(row) as the key for the table row with a unique and stable
identifier from the row data, such as an ID property. This change improves
rendering performance and avoids potential issues with key collisions or
inefficiency when handling large datasets.

{this.state.order.map(({ name, width }) => (
<td key={name} style={{ width: width + '%' }}>{String(row[name])}</td>
))}
</tr>
);
}

renderHeaders() {
return this.state.order.map(({ name, width }) => (
<TableHeader key={name} width={width}>
{name}
</TableHeader>
));
}

renderEmpty() {
return <div>No data available</div>;
}

renderSidebar() {
const categories = this.state.views.map(view => ({
name: view.name,
id: view.name,
count: this.state.counts[view.name],
}));
const current = this.props.params.name || '';
return (
<CategoryList
current={current}
linkPrefix={'views/'}
categories={categories}
/>
);
}

renderToolbar() {
const subsection = this.props.params.name || '';
return <Toolbar section="Views" subsection={subsection} />;
}

renderExtras() {
let extras = null;
if (this.state.showCreate) {
let classNames = [];
if (this.props.schema?.data) {
const classes = this.props.schema.data.get('classes');
if (classes) {
classNames = Object.keys(classes.toObject());
}
}
extras = (
<CreateViewDialog
classes={classNames}
onCancel={() => this.setState({ showCreate: false })}
onConfirm={view => {
this.setState(
state => ({ showCreate: false, views: [...state.views, view] }),
() => {
ViewPreferences.saveViews(
this.context.applicationId,
this.state.views
);
this.loadViews(this.context);
}
);
}}
/>
);
}
let notification = null;
if (this.state.lastError) {
notification = <Notification note={this.state.lastError} isErrorNote={true} />;
} else if (this.state.lastNote) {
notification = <Notification note={this.state.lastNote} isErrorNote={false} />;
}
return (
<>
{extras}
{notification}
</>
);
}

showNote(message, isError) {
if (!message) {
return;
}
clearTimeout(this.noteTimeout);
if (isError) {
this.setState({ lastError: message, lastNote: null });
} else {
this.setState({ lastNote: message, lastError: null });
}
this.noteTimeout = setTimeout(() => {
this.setState({ lastError: null, lastNote: null });
}, 3500);
}
}
Loading
Loading