diff --git a/README.md b/README.md index 1e30c18ac1..dc14442d6c 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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: diff --git a/src/components/TextInput/TextInput.react.js b/src/components/TextInput/TextInput.react.js index 4aa72f3b76..c210f019f5 100644 --- a/src/components/TextInput/TextInput.react.js +++ b/src/components/TextInput/TextInput.react.js @@ -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} /> ); } @@ -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); diff --git a/src/components/TextInput/TextInput.scss b/src/components/TextInput/TextInput.scss index 670992e39b..08e0ee9b68 100644 --- a/src/components/TextInput/TextInput.scss +++ b/src/components/TextInput/TextInput.scss @@ -21,6 +21,11 @@ vertical-align: top; resize: both; + @include placeholder { + color: #999; + opacity: 1; + } + &:disabled { color: $mainTextColor; } diff --git a/src/dashboard/Dashboard.js b/src/dashboard/Dashboard.js index bedf0e1d78..743f715671 100644 --- a/src/dashboard/Dashboard.js +++ b/src/dashboard/Dashboard.js @@ -40,7 +40,6 @@ import RestConsole from './Data/ApiConsole/RestConsole.react'; import Retention from './Analytics/Retention/Retention.react'; import SchemaOverview from './Data/Browser/SchemaOverview.react'; import SecuritySettings from './Settings/SecuritySettings.react'; -import SettingsData from './Settings/SettingsData.react'; import SlowQueries from './Analytics/SlowQueries/SlowQueries.react'; import styles from 'dashboard/Apps/AppsIndex.scss'; import UsersSettings from './Settings/UsersSettings.react'; @@ -55,6 +54,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'; @@ -232,16 +232,17 @@ export default class Dashboard extends React.Component { ); const SettingsRoute = ( - }> + <> } /> } /> + } /> } /> } /> } /> } /> } /> } /> - + ); const JobsRoute = ( diff --git a/src/dashboard/DashboardView.react.js b/src/dashboard/DashboardView.react.js index f438ca3a75..0b37634360 100644 --- a/src/dashboard/DashboardView.react.js +++ b/src/dashboard/DashboardView.react.js @@ -33,13 +33,22 @@ 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] || ''; if (route !== this.state.route) { this.setState({ route }); } } + getCurrentRoute() { + // If state.route is set, use it; otherwise extract from current location + if (this.state.route) { + return this.state.route; + } + const path = this.props.location?.pathname ?? window.location.pathname; + return path.split('apps')[1]?.split('/')[2] || ''; + } + render() { let sidebarChildren = null; if (typeof this.renderSidebar === 'function') { @@ -212,6 +221,10 @@ export default class DashboardView extends React.Component { name: 'Dashboard', link: '/settings/dashboard', }, + { + name: 'Keyboard Shortcuts', + link: '/settings/keyboard-shortcuts', + }, ]; if (this.context.enableSecurityChecks) { @@ -313,9 +326,10 @@ export default class DashboardView extends React.Component { ); let content =
{this.renderContent()}
; - const canRoute = [...coreSubsections, ...pushSubsections, ...settingsSections] - .map(({ link }) => link.split('/')[1]) - .includes(this.state.route); + const allSections = [...coreSubsections, ...pushSubsections, ...settingsSections]; + const validRoutes = allSections.map(({ link }) => link.split('/')[1]); + const currentRoute = this.getCurrentRoute(); + const canRoute = validRoutes.includes(currentRoute); if (!canRoute) { content = ( diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index ebaad2729a..54c42ec564 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -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'; @@ -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, @@ -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() { @@ -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; + } } } diff --git a/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js b/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js index c7d8f11aaf..52001e00af 100644 --- a/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js +++ b/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js @@ -474,7 +474,7 @@ export default class DashboardSettings extends DashboardView { {this.viewPreferencesManager && this.scriptManager && this.viewPreferencesManager.isServerConfigEnabled() && (
- 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.
{ + this.setState({ message: undefined }); + }, 3500); + } + + renderContent() { + // Show error if server config is not enabled + const serverConfigError = this.manager && !this.manager.isServerConfigEnabled() + ? 'Server configuration is not enabled for this app. Please add a \'config\' section to your app configuration to use keyboard shortcuts.' + : null; + + // Show either server config error or user message + const message = this.state.message; + const notificationMessage = serverConfigError || (message && message.text); + const isError = serverConfigError ? true : (message && message.isError); + + return ( +
+ + +
+
+ + } + input={ + + } + /> + + } + input={ + + } + /> +
+ +
+ + +
+
+
+ ); + } +} diff --git a/src/dashboard/Settings/Settings.scss b/src/dashboard/Settings/Settings.scss index 26ac51dfd5..2a9c30e282 100644 --- a/src/dashboard/Settings/Settings.scss +++ b/src/dashboard/Settings/Settings.scss @@ -8,3 +8,17 @@ .settings_page { padding: 120px 0 80px 0; } + +.form_buttons { + max-width: 50%; + margin: 20px auto 0; + display: flex; + justify-content: center; + gap: 5px; + + & > div { + background: transparent !important; + padding: 0 !important; + height: auto !important; + } +} diff --git a/src/dashboard/Settings/SettingsData.react.js b/src/dashboard/Settings/SettingsData.react.js deleted file mode 100644 index ce7e7bba7e..0000000000 --- a/src/dashboard/Settings/SettingsData.react.js +++ /dev/null @@ -1,56 +0,0 @@ -/* - * 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 React from 'react'; -import { CurrentApp } from 'context/currentApp'; -import { Outlet } from 'react-router-dom'; - -export default class SettingsData extends React.Component { - static contextType = CurrentApp; - constructor() { - super(); - - this.state = { - fields: undefined, - }; - } - - componentDidMount() { - this.context.fetchSettingsFields().then(({ fields }) => { - this.setState({ fields }); - }); - } - - componentWillReceiveProps(props, context) { - if (this.context !== context) { - this.setState({ fields: undefined }); - context.fetchSettingsFields().then(({ fields }) => { - this.setState({ fields }); - }); - } - } - - saveChanges(changes) { - const promise = this.context.saveSettingsFields(changes); - promise.then(({ successes }) => { - const newFields = { ...this.state.fields, ...successes }; - this.setState({ fields: newFields }); - }); - return promise; - } - - render() { - return ( - - ); - } -} diff --git a/src/lib/KeyboardShortcutsPreferences.js b/src/lib/KeyboardShortcutsPreferences.js new file mode 100644 index 0000000000..593008454e --- /dev/null +++ b/src/lib/KeyboardShortcutsPreferences.js @@ -0,0 +1,190 @@ +/* + * 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 ServerConfigStorage from './ServerConfigStorage'; + +/** + * Default keyboard shortcuts + */ +export const DEFAULT_SHORTCUTS = { + dataBrowserReloadData: { key: 'r', ctrl: false, shift: false, alt: false }, + dataBrowserToggleInfoPanels: { key: 'p', ctrl: false, shift: false, alt: false }, +}; + +/** + * Keyboard shortcuts manager with server-side storage support + */ +export default class KeyboardShortcutsManager { + constructor(app) { + this.app = app; + this.serverStorage = new ServerConfigStorage(app); + } + + /** + * Gets keyboard shortcuts from server storage + * @param {string} appId - The application ID + * @returns {Promise} The keyboard shortcuts configuration + */ + async getKeyboardShortcuts(appId) { + if (!this.serverStorage.isServerConfigEnabled()) { + return DEFAULT_SHORTCUTS; + } + + try { + const configs = await this.serverStorage.getConfigsByPrefix('settings.keyboard.binding', appId); + + if (!configs || Object.keys(configs).length === 0) { + return DEFAULT_SHORTCUTS; + } + + // Extract shortcuts from the config keys + const shortcuts = {}; + for (const [key, value] of Object.entries(configs)) { + // Extract the shortcut name from keys like "settings.keyboard.binding.dataBrowserReloadData" + const shortcutName = key.replace('settings.keyboard.binding.', ''); + // Validate the shortcut structure before storing + if (isValidShortcut(value)) { + shortcuts[shortcutName] = value; + } else { + console.warn(`Invalid shortcut for ${shortcutName}, using default`); + } + } + + // Merge with defaults, but preserve null values (which mean disabled) + const result = {}; + for (const shortcutName of Object.keys(DEFAULT_SHORTCUTS)) { + result[shortcutName] = shortcuts.hasOwnProperty(shortcutName) + ? shortcuts[shortcutName] + : DEFAULT_SHORTCUTS[shortcutName]; + } + return result; + } catch (error) { + console.warn('Failed to get keyboard shortcuts from server:', error); + return DEFAULT_SHORTCUTS; + } + } + + /** + * Saves keyboard shortcuts to server storage + * @param {string} appId - The application ID + * @param {Object} shortcuts - The keyboard shortcuts configuration + * @returns {Promise} + */ + async saveKeyboardShortcuts(appId, shortcuts) { + if (!this.serverStorage.isServerConfigEnabled()) { + throw new Error('Server configuration is not enabled for this app'); + } + + // Validate all shortcuts before saving + for (const [shortcutName, value] of Object.entries(shortcuts)) { + if (!isValidShortcut(value)) { + throw new Error(`Invalid shortcut for ${shortcutName}`); + } + } + + try { + // Save each shortcut as a separate config entry + const promises = []; + for (const [shortcutName, value] of Object.entries(shortcuts)) { + const key = `settings.keyboard.binding.${shortcutName}`; + promises.push(this.serverStorage.setConfig(key, value, appId)); + } + + await Promise.all(promises); + } catch (error) { + console.error('Failed to save keyboard shortcuts to server:', error); + throw error; + } + } + + /** + * Resets keyboard shortcuts to defaults + * @param {string} appId - The application ID + * @returns {Promise} + */ + async resetKeyboardShortcuts(appId) { + return this.saveKeyboardShortcuts(appId, DEFAULT_SHORTCUTS); + } + + /** + * Checks if server configuration is enabled + * @returns {boolean} + */ + isServerConfigEnabled() { + return this.serverStorage.isServerConfigEnabled(); + } +} + +/** + * Validates that a shortcut object has the correct structure + * Note: Currently only single-character keys are supported (a-z, 0-9). + * Special keys like "Enter", "Escape", "Tab" are not supported. + * @param {Object} shortcut - The shortcut object to validate + * @returns {boolean} True if valid + */ +export function isValidShortcut(shortcut) { + if (shortcut === null) { + return true; + } + + if (typeof shortcut !== 'object') { + return false; + } + + // Must have a key property that is a single character + if (!shortcut.key || typeof shortcut.key !== 'string' || shortcut.key.length !== 1) { + return false; + } + + // Modifier keys are optional booleans + // Note: Meta/Cmd key support could be added in the future + if (shortcut.ctrl !== undefined && typeof shortcut.ctrl !== 'boolean') { + return false; + } + if (shortcut.shift !== undefined && typeof shortcut.shift !== 'boolean') { + return false; + } + if (shortcut.alt !== undefined && typeof shortcut.alt !== 'boolean') { + return false; + } + + return true; +} + +/** + * Creates a shortcut object from a key string + * @param {string} key - The key character + * @returns {Object} Shortcut object + */ +export function createShortcut(key) { + if (!key || key.length !== 1) { + return null; + } + return { key, ctrl: false, shift: false, alt: false }; +} + +/** + * Checks if a keyboard event matches a shortcut + * Note: Consumers should check event.target to prevent triggering + * shortcuts when typing in input fields (INPUT, TEXTAREA, contentEditable). + * @param {KeyboardEvent} event - The keyboard event + * @param {Object} shortcut - The shortcut object + * @returns {boolean} True if matches + */ +export function matchesShortcut(event, shortcut) { + if (!shortcut || !shortcut.key) { + return false; + } + + const keyMatches = event.key.toLowerCase() === shortcut.key.toLowerCase(); + const ctrlMatches = !!event.ctrlKey === !!shortcut.ctrl; + const shiftMatches = !!event.shiftKey === !!shortcut.shift; + const altMatches = !!event.altKey === !!shortcut.alt; + + return keyMatches && ctrlMatches && shiftMatches && altMatches; +} diff --git a/src/lib/ParseApp.js b/src/lib/ParseApp.js index 318c07fe87..6d8173671a 100644 --- a/src/lib/ParseApp.js +++ b/src/lib/ParseApp.js @@ -370,35 +370,6 @@ export default class ParseApp { return AJAX.put(path, data); } - saveSettingsFields(fields) { - const path = '/apps/' + this.slug; - const appFields = {}; - for (const f in fields) { - appFields['parse_app[' + f + ']'] = fields[f]; - } - const promise = AJAX.put(path, appFields); - promise.then(({ successes }) => { - for (const f in fields) { - this.settings.fields[f] = successes[f]; - } - }); - return promise; - } - - fetchSettingsFields() { - // Cache it for a minute - if (new Date() - this.settings.lastFetched < 60000) { - return Promise.resolve(this.settings.fields); - } - const path = '/apps/' + this.slug + '/dashboard_ajax/settings'; - return AJAX.get(path).then(fields => { - for (const f in fields) { - this.settings.fields[f] = fields[f]; - this.settings.lastFetched = new Date(); - } - return Promise.resolve(fields); - }); - } cleanUpFiles() { const path = '/apps/' + this.slug + '/cleanup_files';