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/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/ViewValueDialog.react.js b/src/dashboard/Data/Views/ViewValueDialog.react.js
new file mode 100644
index 0000000000..31c7c6794b
--- /dev/null
+++ b/src/dashboard/Data/Views/ViewValueDialog.react.js
@@ -0,0 +1,37 @@
+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
new file mode 100644
index 0000000000..9aa2e1cdfa
--- /dev/null
+++ b/src/dashboard/Data/Views/Views.react.js
@@ -0,0 +1,599 @@
+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 Icon from 'components/Icon/Icon.react';
+import LoaderContainer from 'components/LoaderContainer/LoaderContainer.react';
+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 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';
+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')
+@withRouter
+class Views extends TableView {
+ constructor() {
+ super();
+ this.section = 'Core';
+ this.subsection = 'Views';
+ this._isMounted = false;
+ this.state = {
+ views: [],
+ counts: {},
+ data: [],
+ order: [],
+ columns: {},
+ tableWidth: 0,
+ showCreate: false,
+ editView: null,
+ editIndex: null,
+ deleteIndex: null,
+ lastError: null,
+ lastNote: null,
+ loading: false,
+ viewValue: null,
+ };
+ this.headersRef = React.createRef();
+ this.noteTimeout = null;
+ 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));
+ }
+
+ componentWillUnmount() {
+ this._isMounted = false;
+ clearTimeout(this.noteTimeout);
+ }
+
+ 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) {
+ window.scrollTo({ top: 0 });
+ 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 => {
+ 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) {
+ this.loadData(this.props.params.name);
+ }
+ });
+ }
+
+ loadData(name) {
+ if (this._isMounted) {
+ this.setState({ loading: true });
+ }
+ if (!name) {
+ if (this._isMounted) {
+ this.setState({ data: [], order: [], columns: {}, loading: false });
+ }
+ return;
+ }
+ const view = (this.state.views || []).find(v => v.name === name);
+ if (!view) {
+ if (this._isMounted) {
+ this.setState({ data: [], order: [], columns: {}, loading: false });
+ }
+ return;
+ }
+ new Parse.Query(view.className)
+ .aggregate(view.query, { useMasterKey: true })
+ .then(results => {
+ const columns = {};
+ const computeWidth = 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'));
+ 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];
+ 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') {
+ if (val.className && val.objectId) {
+ type = 'Pointer';
+ } else {
+ type = 'Object';
+ }
+ } else if (val.__type === 'File') {
+ type = 'File';
+ } else if (val.__type === 'GeoPoint') {
+ type = 'GeoPoint';
+ } else {
+ type = 'Object';
+ }
+ }
+ if (!columns[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);
+ }
+ });
+ });
+ 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);
+ if (this._isMounted) {
+ this.setState({ data: results, order, columns, tableWidth, loading: false });
+ }
+ })
+ .catch(error => {
+ 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;
+ }
+
+ renderContent() {
+ const toolbar = this.renderToolbar();
+ const data = this.tableData();
+ const footer = this.renderFooter();
+ let content = null;
+ let headers = null;
+ if (data !== undefined) {
+ if (!Array.isArray(data)) {
+ console.warn('tableData() needs to return an array of objects');
+ } else {
+ if (data.length === 0) {
+ content = (
+
+ {this.renderEmpty()}
+
+ );
+ } else {
+ content = (
+
+
+ {this.renderColGroup()}
+ {data.map(row => this.renderRow(row))}
+
+ {footer}
+
+ );
+ headers = this.renderHeaders();
+ }
+ }
+ }
+ const extras = this.renderExtras ? this.renderExtras() : null;
+ const loading = this.state ? this.state.loading : false;
+ return (
+
+
+
+
+
+ {headers}
+
+ {content}
+
+
+
+ {toolbar}
+ {extras}
+
+ );
+ }
+
+ renderRow(row) {
+ return (
+
+ {this.state.order.map(({ name }) => {
+ const value = row[name];
+ 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') {
+ if (value.className && value.objectId) {
+ type = 'Pointer';
+ } else {
+ type = 'Object';
+ }
+ } else if (value.__type === 'File') {
+ type = 'File';
+ } else if (value.__type === 'GeoPoint') {
+ type = 'GeoPoint';
+ } else {
+ type = 'Object';
+ }
+ }
+ let content = '';
+ const hasPill = type === 'Pointer' && value && value.className && value.objectId;
+ if (hasPill) {
+ const id = value.objectId;
+ const className = value.className;
+ content = (
+ this.handlePointerClick({ className, id })}
+ followClick
+ shrinkablePill
+ />
+ );
+ } 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);
+ }
+ const isViewable = ['String', 'Number', 'Object'].includes(type);
+ const classes = [styles.cell];
+ if (hasPill) {
+ classes.push(styles.pillCell);
+ }
+ let cellContent = content;
+ if (isViewable) {
+ cellContent = (
+ this.handleValueClick(value)}
+ >
+ {content}
+
+ );
+ }
+ return (
+
+ {cellContent}
+ |
+ );
+ })}
+
+ );
+ }
+
+ renderColGroup() {
+ return (
+
+ {this.state.order.map(({ width }, i) => (
+
+ ))}
+
+ );
+ }
+
+ handleResize(index, delta) {
+ this.setState(({ order }) => {
+ const newOrder = [...order];
+ newOrder[index] = {
+ ...newOrder[index],
+ width: Math.max(40, newOrder[index].width + delta),
+ };
+ const tableWidth = newOrder.reduce((sum, col) => sum + col.width, 0);
+ return { order: newOrder, tableWidth };
+ });
+ }
+
+ renderHeaders() {
+ return this.state.order.map(({ name, width }, i) => (
+
+ {name}
+ this.handleResize(i, delta)} />
+
+ ));
+ }
+
+ 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
;
+ }
+
+ 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 (
+ {
+ window.scrollTo({ top: 0 });
+ }}
+ categories={categories}
+ />
+ );
+ }
+
+ renderToolbar() {
+ const subsection = this.props.params.name || '';
+ let editMenu = null;
+ let refreshButton = null;
+ if (this.props.params.name) {
+ editMenu = (
+ {}}>
+
+ );
+ refreshButton = (
+ <>
+
+
+ Refresh
+
+
+ >
+ );
+ }
+
+ return (
+
+ {refreshButton}
+ {editMenu}
+
+ );
+ }
+
+ renderExtras() {
+ let extras = null;
+ 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');
+ if (classes) {
+ classNames = Object.keys(classes.toObject());
+ }
+ }
+ extras = (
+ 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);
+ }
+ );
+ }}
+ />
+ );
+ } 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) {
+ notification = ;
+ } else if (this.state.lastNote) {
+ notification = ;
+ }
+ return (
+ <>
+ {extras}
+ {notification}
+ >
+ );
+ }
+
+ handlePointerClick({ className, id, field = 'objectId' }) {
+ const filters = JSON.stringify([{ field, constraint: 'eq', compareTo: id }]);
+ const path = generatePath(
+ this.context,
+ `browser/${className}?filters=${encodeURIComponent(filters)}`
+ );
+ this.props.navigate(path);
+ }
+
+ handleValueClick(value) {
+ this.setState({ viewValue: value });
+ }
+
+ 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);
+ }
+}
diff --git a/src/dashboard/Data/Views/Views.scss b/src/dashboard/Data/Views/Views.scss
new file mode 100644
index 0000000000..03c6794437
--- /dev/null
+++ b/src/dashboard/Data/Views/Views.scss
@@ -0,0 +1,59 @@
+@import 'stylesheets/globals.scss';
+
+.headers {
+ right: auto;
+}
+
+.headerWrap {
+ display: inline-block;
+ vertical-align: top;
+ background: #66637A;
+ color: white;
+ line-height: 30px;
+ padding: 0 16px;
+ border-right: 1px solid #e3e3ea;
+ position: relative;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.handle {
+ position: absolute;
+ top: 0;
+ right: -4px;
+ width: 8px;
+ height: 30px;
+ cursor: ew-resize;
+}
+
+.tableRow {
+ @include MonospaceFont;
+ font-size: 12px;
+ white-space: nowrap;
+ height: 30px;
+ border-bottom: 1px solid #e3e3ea;
+}
+
+.tableRow:nth-child(odd) {
+ background: #f4f5f7;
+}
+
+.cell {
+ line-height: 20px;
+ padding: 5px 16px;
+ border-right: 1px solid #e3e3ea;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: none;
+}
+
+.tableRow td.pillCell {
+ line-height: 8px;
+}
+
+.clickableText {
+ cursor: pointer;
+}
+
diff --git a/src/dashboard/TableView.scss b/src/dashboard/TableView.scss
index 5027e6a89f..6ed24afc12 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: 20px;
+ padding: 8px 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
diff --git a/src/lib/ViewPreferences.js b/src/lib/ViewPreferences.js
new file mode 100644
index 0000000000..90ebc03c2e
--- /dev/null
+++ b/src/lib/ViewPreferences.js
@@ -0,0 +1,27 @@
+const VERSION = 1;
+
+export function getViews(appId) {
+ let entry;
+ try {
+ entry = localStorage.getItem(path(appId)) || '[]';
+ } catch {
+ entry = '[]';
+ }
+ try {
+ return JSON.parse(entry);
+ } catch {
+ return [];
+ }
+}
+
+export function saveViews(appId, views) {
+ try {
+ localStorage.setItem(path(appId), JSON.stringify(views));
+ } catch {
+ // ignore write errors
+ }
+}
+
+function path(appId) {
+ return `ParseDashboard:${VERSION}:${appId}:Views`;
+}