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 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/Dashboard.js b/src/dashboard/Dashboard.js index 96be0559be..b1e8a800bd 100644 --- a/src/dashboard/Dashboard.js +++ b/src/dashboard/Dashboard.js @@ -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'; @@ -270,6 +271,8 @@ export default class Dashboard extends React.Component { } /> } /> + } /> + } /> } /> {JobsRoute} diff --git a/src/dashboard/DashboardView.react.js b/src/dashboard/DashboardView.react.js index 061bcbf1de..c48e5ac461 100644 --- a/src/dashboard/DashboardView.react.js +++ b/src/dashboard/DashboardView.react.js @@ -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 && 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', }} > 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 = ( + {}}> + { + const index = this.state.views.findIndex(v => v.name === this.props.params.name); + if (index >= 0) { + this.setState({ + editView: this.state.views[index], + editIndex: index, + }); + } + }} + /> + + { + const index = this.state.views.findIndex(v => v.name === this.props.params.name); + if (index >= 0) { + this.setState({ deleteIndex: index }); + } + }} + /> + + ); + 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`; +}