Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ Parse Dashboard is a standalone dashboard for managing your [Parse Server](https
- [Prevent columns sorting](#prevent-columns-sorting)
- [Custom order in the filter popup](#custom-order-in-the-filter-popup)
- [Persistent Filters](#persistent-filters)
- [Keyboard Shortcuts](#keyboard-shortcuts)
- [Scripts](#scripts)
- [Resource Cache](#resource-cache)
- [Running as Express Middleware](#running-as-express-middleware)
Expand Down Expand Up @@ -530,6 +531,12 @@ For example:

You can conveniently create a filter definition without having to write it by hand by first saving a filter in the data browser, then exporting the filter definition under *App Settings > Export Class Preferences*.

### Keyboard Shortcuts

Configure custom keyboard shortcuts for dashboard actions in **App Settings > Keyboard Shortcuts**.

Delete a shortcut key to disable the shortcut.

### Scripts

You can specify scripts to execute Cloud Functions with the `scripts` option:
Expand Down
4 changes: 4 additions & 0 deletions src/components/TextInput/TextInput.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ class TextInput extends React.Component {
value={this.props.value}
onChange={this.changeValue.bind(this)}
onBlur={this.updateValue.bind(this)}
onFocus={this.props.onFocus}
maxLength={this.props.maxLength}
/>
);
}
Expand All @@ -87,11 +89,13 @@ TextInput.propTypes = {
'A function fired when the input is changed. It receives the new value as its only parameter.'
),
onBlur: PropTypes.func.describe('A function fired when the input is blurred.'),
onFocus: PropTypes.func.describe('A function fired when the input is focused.'),
placeholder: PropTypes.string.describe('A placeholder string, for when the input is empty'),
value: PropTypes.string.describe('The current value of the controlled input'),
height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).describe(
'The height of the field. Can be a string containing any CSS unit, or a number of pixels. Default is 80px.'
),
maxLength: PropTypes.number.describe('The maximum length of the input.'),
};

export default withForwardedRef(TextInput);
5 changes: 5 additions & 0 deletions src/components/TextInput/TextInput.scss
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
vertical-align: top;
resize: both;

@include placeholder {
color: #999;
opacity: 1;
}

&:disabled {
color: $mainTextColor;
}
Expand Down
2 changes: 2 additions & 0 deletions src/dashboard/Dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { Helmet } from 'react-helmet';
import Playground from './Data/Playground/Playground.react';
import DashboardSettings from './Settings/DashboardSettings/DashboardSettings.react';
import Security from './Settings/Security/Security.react';
import KeyboardShortcutsSettings from './Settings/KeyboardShortcutsSettings.react';
import semver from 'semver';
import packageInfo from '../../package.json';

Expand Down Expand Up @@ -235,6 +236,7 @@ export default class Dashboard extends React.Component {
<Route element={<SettingsData />}>
<Route path="dashboard" element={<DashboardSettings />} />
<Route path="security" element={<Security />} />
<Route path="keyboard-shortcuts" element={<KeyboardShortcutsSettings />} />
<Route path="general" element={<GeneralSettings />} />
<Route path="keys" element={<SecuritySettings />} />
<Route path="users" element={<UsersSettings />} />
Expand Down
4 changes: 4 additions & 0 deletions src/dashboard/DashboardView.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@ export default class DashboardView extends React.Component {
name: 'Dashboard',
link: '/settings/dashboard',
},
{
name: 'Keyboard Shortcuts',
link: '/settings/keyboard-shortcuts',
},
];

if (this.context.enableSecurityChecks) {
Expand Down
41 changes: 40 additions & 1 deletion src/dashboard/Data/Browser/DataBrowser.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import React from 'react';
import { ResizableBox } from 'react-resizable';
import ScriptConfirmationModal from '../../../components/ScriptConfirmationModal/ScriptConfirmationModal.react';
import styles from './Databrowser.scss';
import KeyboardShortcutsManager, { matchesShortcut } from 'lib/KeyboardShortcutsPreferences';

import AggregationPanel from '../../../components/AggregationPanel/AggregationPanel';

Expand Down Expand Up @@ -146,6 +147,7 @@ export default class DataBrowser extends React.Component {
multiPanelData: {}, // Object mapping objectId to panel data
_objectsToFetch: [], // Temporary field for async fetch handling
loadingObjectIds: new Set(),
keyboardShortcuts: null, // Keyboard shortcuts from server
showScriptConfirmationDialog: false,
selectedScript: null,
contextMenuX: null,
Expand Down Expand Up @@ -260,9 +262,18 @@ export default class DataBrowser extends React.Component {
this.checkClassNameChange(this.state.prevClassName, props.className);
}

componentDidMount() {
async componentDidMount() {
document.body.addEventListener('keydown', this.handleKey);
window.addEventListener('resize', this.updateMaxWidth);

// Load keyboard shortcuts from server
try {
const manager = new KeyboardShortcutsManager(this.props.app);
const shortcuts = await manager.getKeyboardShortcuts(this.props.app.applicationId);
this.setState({ keyboardShortcuts: shortcuts });
} catch (error) {
console.warn('Failed to load keyboard shortcuts:', error);
}
}

componentWillUnmount() {
Expand Down Expand Up @@ -849,6 +860,34 @@ export default class DataBrowser extends React.Component {
}
break;
}
default: {
// Handle custom keyboard shortcuts from server
const shortcuts = this.state.keyboardShortcuts;
if (!shortcuts) {
break;
}

// Reload data shortcut (only if enabled)
if (matchesShortcut(e, shortcuts.dataBrowserReloadData)) {
this.handleRefresh();
e.preventDefault();
break;
}

// Toggle panels shortcut (only if enabled and class has info panels configured)
if (matchesShortcut(e, shortcuts.dataBrowserToggleInfoPanels)) {
const hasAggregation =
this.props.classwiseCloudFunctions?.[
`${this.props.app.applicationId}${this.props.appName}`
]?.[this.props.className];
if (hasAggregation) {
this.togglePanelVisibility();
e.preventDefault();
}
break;
}
break;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ export default class DashboardSettings extends DashboardView {
{this.viewPreferencesManager && this.scriptManager && this.viewPreferencesManager.isServerConfigEnabled() && (
<Fieldset legend="Settings Storage">
<div style={{ marginBottom: '20px', color: '#666', fontSize: '14px', textAlign: 'center' }}>
Storing dashboard settings on the server rather than locally in the browser storage makes the settings available across devices and browsers. It also prevents them from getting lost when resetting the browser website data. Settings that can be stored on the server are currently Views and JS Console scripts.
Storing dashboard settings on the server rather than locally in the browser storage makes the settings available across devices and browsers. It also prevents them from getting lost when resetting the browser website data. Settings that can be stored on the server are currently Views, Keyboard Shortcuts and JS Console scripts.
</div>
<Field
label={
Expand Down
234 changes: 234 additions & 0 deletions src/dashboard/Settings/KeyboardShortcutsSettings.react.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/*
* Copyright (c) 2016-present, Parse, LLC
* All rights reserved.
*
* This source code is licensed under the license found in the LICENSE file in
* the root directory of this source tree.
*/
import DashboardView from 'dashboard/DashboardView.react';
import Field from 'components/Field/Field.react';
import Fieldset from 'components/Fieldset/Fieldset.react';
import FormButton from 'components/FormButton/FormButton.react';
import Label from 'components/Label/Label.react';
import React from 'react';
import TextInput from 'components/TextInput/TextInput.react';
import Toolbar from 'components/Toolbar/Toolbar.react';
import Notification from 'dashboard/Data/Browser/Notification.react';
import styles from 'dashboard/Settings/Settings.scss';
import KeyboardShortcutsManager, { DEFAULT_SHORTCUTS, isValidShortcut, createShortcut } from 'lib/KeyboardShortcutsPreferences';

export default class KeyboardShortcutsSettings extends DashboardView {
constructor() {
super();
this.section = 'App Settings';
this.subsection = 'Keyboard Shortcuts';

this.state = {
dataBrowserReloadData: '',
dataBrowserToggleInfoPanels: '',
hasChanges: false,
message: null,
loading: true,
};

this.manager = null;
}

componentDidMount() {
if (this.context) {
this.manager = new KeyboardShortcutsManager(this.context);
this.loadShortcuts();
}
}

async loadShortcuts() {
if (!this.context || !this.manager) {
return;
}

try {
const shortcuts = await this.manager.getKeyboardShortcuts(this.context.applicationId);
this.setState({
dataBrowserReloadData: shortcuts.dataBrowserReloadData?.key || '',
dataBrowserToggleInfoPanels: shortcuts.dataBrowserToggleInfoPanels?.key || '',
hasChanges: false,
loading: false,
});
} catch (error) {
console.error('Failed to load keyboard shortcuts:', error);
this.showNote('Failed to load keyboard shortcuts', true);
this.setState({ loading: false });
}
}

handleFieldChange(field, value) {
this.setState({
[field]: value,
hasChanges: true,
});
}

handleInputFocus(event) {
// Auto-select the text when focusing on the input
if (event.target.value) {
event.target.select();
}
}

async handleSave() {
if (!this.context || !this.manager) {
return;
}

// Create shortcut objects from the key strings
const shortcuts = {
dataBrowserReloadData: this.state.dataBrowserReloadData ? createShortcut(this.state.dataBrowserReloadData) : null,
dataBrowserToggleInfoPanels: this.state.dataBrowserToggleInfoPanels ? createShortcut(this.state.dataBrowserToggleInfoPanels) : null,
};

// Validate shortcuts (only if they are set)
if (shortcuts.dataBrowserReloadData && !isValidShortcut(shortcuts.dataBrowserReloadData)) {
this.showNote('Invalid key for "Reload Data". Please enter a valid key.', true);
return;
}

if (shortcuts.dataBrowserToggleInfoPanels && !isValidShortcut(shortcuts.dataBrowserToggleInfoPanels)) {
this.showNote('Invalid key for "Toggle Panels". Please enter a valid key.', true);
return;
}

// Check for duplicates (only if both are set)
if (shortcuts.dataBrowserReloadData && shortcuts.dataBrowserToggleInfoPanels &&
shortcuts.dataBrowserReloadData.key.toLowerCase() === shortcuts.dataBrowserToggleInfoPanels.key.toLowerCase()) {
this.showNote('Keyboard shortcuts must be unique. Please use different keys.', true);
return;
}

try {
await this.manager.saveKeyboardShortcuts(this.context.applicationId, shortcuts);
this.setState({ hasChanges: false });
this.showNote('Keyboard shortcuts saved successfully!', false);
} catch (error) {
console.error('Failed to save keyboard shortcuts:', error);
this.showNote('Failed to save keyboard shortcuts. Please try again.', true);
}
}

async handleReset() {
if (!this.context || !this.manager) {
return;
}

try {
await this.manager.resetKeyboardShortcuts(this.context.applicationId);
await this.loadShortcuts();
this.showNote('Keyboard shortcuts reset to defaults', false);
} catch (error) {
console.error('Failed to reset keyboard shortcuts:', error);
this.showNote('Failed to reset keyboard shortcuts. Please try again.', true);
}
}

showNote(message, isError = false) {
if (!message) {
return;
}

clearTimeout(this.noteTimeout);

this.setState({ message: { text: message, isError } });

this.noteTimeout = setTimeout(() => {
this.setState({ message: null });
}, 3500);
}

renderContent() {
if (this.state.loading) {
return (
<div>
<Toolbar section="Settings" subsection="Keyboard Shortcuts" />
<div className={styles.settings_page}>
<div>Loading keyboard shortcuts...</div>
</div>
</div>
);
}

if (this.manager && !this.manager.isServerConfigEnabled()) {
return (
<div>
<Toolbar section="Settings" subsection="Keyboard Shortcuts" />
<div className={styles.settings_page}>
<Notification
note="Server configuration is not enabled for this app. Please add a 'config' section to your app configuration to use keyboard shortcuts."
isErrorNote={true}
/>
</div>
</div>
);
}

return (
<div>
<Toolbar section="Settings" subsection="Keyboard Shortcuts" />
<Notification note={this.state.message?.text} isErrorNote={this.state.message?.isError} />
<div className={styles.settings_page}>
<Fieldset
legend="Data Browser"
description="Leave empty to disable a shortcut."
>
<Field
labelWidth={62}
label={<Label
text="Reload Data"
description={'Reloads the data browser table data.'}
/>
}
input={
<TextInput
placeholder={DEFAULT_SHORTCUTS.dataBrowserReloadData.key}
value={this.state.dataBrowserReloadData}
onChange={this.handleFieldChange.bind(this, 'dataBrowserReloadData')}
onFocus={this.handleInputFocus.bind(this)}
maxLength={1}
/>
}
/>
<Field
labelWidth={62}
label={
<Label
text="Toggle Info Panels"
description={'Shows/hides the info panels.'}
/>
}
input={
<TextInput
placeholder={DEFAULT_SHORTCUTS.dataBrowserToggleInfoPanels.key}
value={this.state.dataBrowserToggleInfoPanels}
onChange={this.handleFieldChange.bind(this, 'dataBrowserToggleInfoPanels')}
onFocus={this.handleInputFocus.bind(this)}
maxLength={1}
/>
}
/>
</Fieldset>

<div className={styles.form_buttons}>
<FormButton
value="Save Shortcuts"
disabled={!this.state.hasChanges}
onClick={this.handleSave.bind(this)}
/>
<FormButton
value="Reset to Defaults"
onClick={this.handleReset.bind(this)}
color="white"
/>
</div>
</div>
</div>
);
}
}
Loading
Loading