Commit 2a9367fd authored by Paul Slaughter's avatar Paul Slaughter

Merge branch 'nfriend-make-keybindings-tree-shakeable' into 'master'

Make keybindings.js tree-shakeable

See merge request gitlab-org/gitlab!56173
parents 5dd14a02 96ae08d2
import { flatten } from 'lodash'; import { memoize } from 'lodash';
import AccessorUtilities from '~/lib/utils/accessor'; import AccessorUtilities from '~/lib/utils/accessor';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { shouldDisableShortcuts } from './shortcuts_toggle';
export const LOCAL_STORAGE_KEY = 'gl-keyboard-shortcuts-customizations'; export const LOCAL_STORAGE_KEY = 'gl-keyboard-shortcuts-customizations';
let parsedCustomizations = {}; /**
const localStorageIsSafe = AccessorUtilities.isLocalStorageAccessSafe(); * @returns { Object.<string, string[]> } A map of command ID => keys of all
* keyboard shortcuts that have been customized by the user. These
* customizations are fetched from `localStorage`. This function is memoized,
* so its return value will not reflect changes made to the `localStorage` data
* after it has been called once.
*
* @example
* { "globalShortcuts.togglePerformanceBar": ["p e r f"] }
*/
export const getCustomizations = memoize(() => {
let parsedCustomizations = {};
const localStorageIsSafe = AccessorUtilities.isLocalStorageAccessSafe();
if (localStorageIsSafe) { if (localStorageIsSafe) {
try { try {
parsedCustomizations = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || '{}'); parsedCustomizations = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || '{}');
} catch (e) { } catch (e) {
/* do nothing */ /* do nothing */
} }
} }
/** return parsedCustomizations;
* A map of command => keys of all keyboard shortcuts });
* that have been customized by the user.
*
* @example
* { "globalShortcuts.togglePerformanceBar": ["p e r f"] }
*
* @type { Object.<string, string[]> }
*/
export const customizations = parsedCustomizations;
// All available commands // All available commands
export const TOGGLE_PERFORMANCE_BAR = 'globalShortcuts.togglePerformanceBar'; export const TOGGLE_PERFORMANCE_BAR = {
export const TOGGLE_CANARY = 'globalShortcuts.toggleCanary'; id: 'globalShortcuts.togglePerformanceBar',
/** All keybindings, grouped and ordered with descriptions */
export const keybindingGroups = [
{
groupId: 'globalShortcuts',
name: s__('KeyboardShortcuts|Global Shortcuts'),
keybindings: [
{
description: s__('KeyboardShortcuts|Toggle the Performance Bar'), description: s__('KeyboardShortcuts|Toggle the Performance Bar'),
command: TOGGLE_PERFORMANCE_BAR,
// eslint-disable-next-line @gitlab/require-i18n-strings // eslint-disable-next-line @gitlab/require-i18n-strings
defaultKeys: ['p b'], defaultKeys: ['p b'],
}, };
{
export const TOGGLE_CANARY = {
id: 'globalShortcuts.toggleCanary',
description: s__('KeyboardShortcuts|Toggle GitLab Next'), description: s__('KeyboardShortcuts|Toggle GitLab Next'),
command: TOGGLE_CANARY,
// eslint-disable-next-line @gitlab/require-i18n-strings // eslint-disable-next-line @gitlab/require-i18n-strings
defaultKeys: ['g x'], defaultKeys: ['g x'],
}, };
],
},
]
// For each keybinding object, add a `customKeys` property populated with the export const WEB_IDE_COMMIT = {
// user's custom keybindings (if the command has been customized). id: 'webIDE.commit',
// `customKeys` will be `undefined` if the command hasn't been customized. description: s__('KeyboardShortcuts|Commit (when editing commit message)'),
.map((group) => { defaultKeys: ['mod+enter'],
return { customizable: false,
...group, };
keybindings: group.keybindings.map((binding) => ({
...binding,
customKeys: customizations[binding.command],
})),
};
});
/** // All keybinding groups
* A simple map of command => keys. All user customizations are included in this map. export const GLOBAL_SHORTCUTS_GROUP = {
* This mapping is used to simplify `keysFor` below. id: 'globalShortcuts',
* name: s__('KeyboardShortcuts|Global Shortcuts'),
* @example keybindings: [TOGGLE_PERFORMANCE_BAR, TOGGLE_CANARY],
* { "globalShortcuts.togglePerformanceBar": ["p e r f"] } };
*/
const commandToKeys = flatten(keybindingGroups.map((group) => group.keybindings)).reduce( export const WEB_IDE_GROUP = {
(acc, binding) => { id: 'webIDE',
acc[binding.command] = binding.customKeys || binding.defaultKeys; name: s__('KeyboardShortcuts|Web IDE'),
return acc; keybindings: [WEB_IDE_COMMIT],
}, };
{},
); /** All keybindings, grouped and ordered with descriptions */
export const keybindingGroups = [GLOBAL_SHORTCUTS_GROUP, WEB_IDE_GROUP];
/** /**
* Gets keyboard shortcuts associated with a command * Gets keyboard shortcuts associated with a command
* *
* @param {string} command The command string. All command * @param {string} command The command object. All command
* strings are available as imports from this file. * objects are available as imports from this file.
* *
* @returns {string[]} An array of keyboard shortcut strings bound to the command * @returns {string[]} An array of keyboard shortcut strings bound to the command
* *
...@@ -95,9 +81,11 @@ const commandToKeys = flatten(keybindingGroups.map((group) => group.keybindings) ...@@ -95,9 +81,11 @@ const commandToKeys = flatten(keybindingGroups.map((group) => group.keybindings)
* Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), handler); * Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), handler);
*/ */
export const keysFor = (command) => { export const keysFor = (command) => {
if (shouldDisableShortcuts()) { if (command.customizable === false) {
return []; // if the command is defined with `customizable: false`,
// don't allow this command to be customized.
return command.defaultKeys;
} }
return commandToKeys[command]; return getCustomizations()[command.id] || command.defaultKeys;
}; };
...@@ -33,9 +33,10 @@ Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), togglePerformanceBar); ...@@ -33,9 +33,10 @@ Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), togglePerformanceBar);
## Shortcut customization ## Shortcut customization
`keybindings.js` stores keyboard shortcut customizations as a JSON string in `keybindings.js` stores keyboard shortcut customizations as a JSON string in
`localStorage`. When `keybindings.js` is first imported, it fetches any `localStorage`. When `keysFor` is called, it uses the provided command object's
customizations from `localStorage` and merges these customizations into the `id` to lookup any customizations found in `localStorage` and returns the custom
default set of keybindings. There is no UI to edit these customizations. keybindings, or the default keybindings if the command has not been customized.
There is no UI to edit these customizations.
## Adding new shortcuts ## Adding new shortcuts
...@@ -44,27 +45,33 @@ developers are encouraged to build _lots_ of keyboard shortcuts into GitLab. ...@@ -44,27 +45,33 @@ developers are encouraged to build _lots_ of keyboard shortcuts into GitLab.
Shortcuts that are less likely to be used should be Shortcuts that are less likely to be used should be
[disabled](#disabling-shortcuts) by default. [disabled](#disabling-shortcuts) by default.
To add a new shortcut, define and export a new command string in To add a new shortcut, define and export a new command object in
`keybindings.js`: `keybindings.js`:
```javascript ```javascript
export const MAKE_COFFEE = 'foodAndBeverage.makeCoffee'; export const MAKE_COFFEE = {
id: 'foodAndBeverage.makeCoffee',
description: s__('KeyboardShortcuts|Make coffee'),
defaultKeys: ['mod+shift+c'],
};
``` ```
Next, add a new command definition under the appropriate group in the Next, add a new command to the appropriate keybinding group object:
`keybindingGroups` array:
```javascript ```javascript
{ const COFFEE_GROUP = {
description: s__('KeyboardShortcuts|Make coffee'), id: 'foodAndBeverage',
command: MAKE_COFFEE, name: s__('KeyboardShortcuts|Food and Beverage'),
defaultKeys: ['mod+shift+c'], keybindings: [
customKeys: customizations[MAKE_COFFEE], MAKE_ESPRESSO,
MAKE_LATTE,
MAKE_COFFEE
];
} }
``` ```
Finally, in the application code, import the `keysFor` function and the new Finally, in the application code, import the `keysFor` function and the new
command and bind the shortcut to the handler using Mousetrap: command object and bind the shortcut to the handler using Mousetrap:
```javascript ```javascript
import { keysFor, MAKE_COFFEE } from '~/behaviors/shortcuts/keybindings' import { keysFor, MAKE_COFFEE } from '~/behaviors/shortcuts/keybindings'
...@@ -81,16 +88,34 @@ shortcut to an empty array `[]`. For example, to introduce a new shortcut that ...@@ -81,16 +88,34 @@ shortcut to an empty array `[]`. For example, to introduce a new shortcut that
is disabled by default, a command can be defined like this: is disabled by default, a command can be defined like this:
```javascript ```javascript
export const MAKE_MOCHA = 'foodAndBeverage.makeMocha'; export const MAKE_MOCHA = {
id: 'foodAndBeverage.makeMocha',
{
description: s__('KeyboardShortcuts|Make a mocha'), description: s__('KeyboardShortcuts|Make a mocha'),
command: MAKE_MOCHA,
defaultKeys: [], defaultKeys: [],
customKeys: customizations[MAKE_MOCHA], };
}
``` ```
## Making shortcuts non-customizable
Occasionally, it's important that a keyboard shortcut _not_ be customizable
(although this should be a rare occurrence).
In this case, a shortcut can be defined with `customizable: false`, which
disables customization of the keybinding:
```javascript
export const MAKE_AMERICANO = {
id: 'foodAndBeverage.makeAmericano',
description: s__('KeyboardShortcuts|Make an Americano'),
defaultKeys: ['mod+shift+a'],
// this disables customization of this shortcut
customizable: false
};
```
This shortcut will always be bound to its `defaultKeys`.
## Make cross-platform shortcuts ## Make cross-platform shortcuts
It's difficult to make shortcuts that work well in all platforms and browsers. It's difficult to make shortcuts that work well in all platforms and browsers.
......
...@@ -17544,6 +17544,9 @@ msgstr "" ...@@ -17544,6 +17544,9 @@ msgstr ""
msgid "KeyboardKey|Ctrl+" msgid "KeyboardKey|Ctrl+"
msgstr "" msgstr ""
msgid "KeyboardShortcuts|Commit (when editing commit message)"
msgstr ""
msgid "KeyboardShortcuts|Global Shortcuts" msgid "KeyboardShortcuts|Global Shortcuts"
msgstr "" msgstr ""
...@@ -17553,6 +17556,9 @@ msgstr "" ...@@ -17553,6 +17556,9 @@ msgstr ""
msgid "KeyboardShortcuts|Toggle the Performance Bar" msgid "KeyboardShortcuts|Toggle the Performance Bar"
msgstr "" msgstr ""
msgid "KeyboardShortcuts|Web IDE"
msgstr ""
msgid "Keys" msgid "Keys"
msgstr "" msgstr ""
......
import { flatten } from 'lodash';
import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import {
keysFor,
getCustomizations,
keybindingGroups,
TOGGLE_PERFORMANCE_BAR,
LOCAL_STORAGE_KEY,
WEB_IDE_COMMIT,
} from '~/behaviors/shortcuts/keybindings';
describe('~/behaviors/shortcuts/keybindings.js', () => { describe('~/behaviors/shortcuts/keybindings', () => {
let keysFor;
let TOGGLE_PERFORMANCE_BAR;
let LOCAL_STORAGE_KEY;
beforeAll(() => { beforeAll(() => {
useLocalStorageSpy(); useLocalStorageSpy();
}); });
const setupCustomizations = async (customizationsAsString) => { const setupCustomizations = (customizationsAsString) => {
localStorage.clear(); localStorage.clear();
if (customizationsAsString) { if (customizationsAsString) {
localStorage.setItem(LOCAL_STORAGE_KEY, customizationsAsString); localStorage.setItem(LOCAL_STORAGE_KEY, customizationsAsString);
} }
jest.resetModules(); getCustomizations.cache.clear();
({ keysFor, TOGGLE_PERFORMANCE_BAR, LOCAL_STORAGE_KEY } = await import(
'~/behaviors/shortcuts/keybindings'
));
}; };
describe('keybinding definition errors', () => {
beforeEach(() => {
setupCustomizations();
});
it('has no duplicate group IDs', () => {
const allGroupIds = keybindingGroups.map((group) => group.id);
expect(allGroupIds).toHaveLength(new Set(allGroupIds).size);
});
it('has no duplicate commands IDs', () => {
const allCommandIds = flatten(
keybindingGroups.map((group) => group.keybindings.map((kb) => kb.id)),
);
expect(allCommandIds).toHaveLength(new Set(allCommandIds).size);
});
});
describe('when a command has not been customized', () => { describe('when a command has not been customized', () => {
beforeEach(async () => { beforeEach(() => {
await setupCustomizations('{}'); setupCustomizations('{}');
}); });
it('returns the default keybinding for the command', () => { it('returns the default keybindings for the command', () => {
expect(keysFor(TOGGLE_PERFORMANCE_BAR)).toEqual(['p b']); expect(keysFor(TOGGLE_PERFORMANCE_BAR)).toEqual(['p b']);
}); });
}); });
...@@ -35,18 +55,30 @@ describe('~/behaviors/shortcuts/keybindings.js', () => { ...@@ -35,18 +55,30 @@ describe('~/behaviors/shortcuts/keybindings.js', () => {
describe('when a command has been customized', () => { describe('when a command has been customized', () => {
const customization = ['p b a r']; const customization = ['p b a r'];
beforeEach(async () => { beforeEach(() => {
await setupCustomizations(JSON.stringify({ [TOGGLE_PERFORMANCE_BAR]: customization })); setupCustomizations(JSON.stringify({ [TOGGLE_PERFORMANCE_BAR.id]: customization }));
}); });
it('returns the default keybinding for the command', () => { it('returns the custom keybindings for the command', () => {
expect(keysFor(TOGGLE_PERFORMANCE_BAR)).toEqual(customization); expect(keysFor(TOGGLE_PERFORMANCE_BAR)).toEqual(customization);
}); });
}); });
describe('when a command is marked as non-customizable', () => {
const customization = ['mod+shift+c'];
beforeEach(() => {
setupCustomizations(JSON.stringify({ [WEB_IDE_COMMIT.id]: customization }));
});
it('returns the default keybinding for the command', () => {
expect(keysFor(WEB_IDE_COMMIT)).toEqual(['mod+enter']);
});
});
describe("when the localStorage entry isn't valid JSON", () => { describe("when the localStorage entry isn't valid JSON", () => {
beforeEach(async () => { beforeEach(() => {
await setupCustomizations('{'); setupCustomizations('{');
}); });
it('returns the default keybinding for the command', () => { it('returns the default keybinding for the command', () => {
...@@ -55,8 +87,8 @@ describe('~/behaviors/shortcuts/keybindings.js', () => { ...@@ -55,8 +87,8 @@ describe('~/behaviors/shortcuts/keybindings.js', () => {
}); });
describe(`when localStorage doesn't contain the ${LOCAL_STORAGE_KEY} key`, () => { describe(`when localStorage doesn't contain the ${LOCAL_STORAGE_KEY} key`, () => {
beforeEach(async () => { beforeEach(() => {
await setupCustomizations(); setupCustomizations();
}); });
it('returns the default keybinding for the command', () => { it('returns the default keybinding for the command', () => {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment