(core) Add Command API to Grist Plugin API

Summary:
The new Command API provides limited access to Grist Commands from within cusotm
widgets. This includes the ability to perform undo and redo, which is bound to
the same keyboard shortcut as Grist by default.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Subscribers: paulfitz, jarek

Differential Revision: https://phab.getgrist.com/D4050
This commit is contained in:
George Gevoian 2023-09-26 16:37:27 -04:00
parent 9b36fb4dab
commit f38df564a9
8 changed files with 69 additions and 1 deletions

View File

@ -3,6 +3,7 @@ import * as commands from 'app/client/components/commands';
import {Cursor} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc';
import {
CommandAPI,
ConfigNotifier,
CustomSectionAPIImpl,
GristDocAPIImpl,
@ -240,6 +241,10 @@ export class CustomView extends Disposable {
access,
this._promptAccess.bind(this)),
new MinimumLevel(AccessLevel.none));
frame.exposeAPI(
"CommandAPI",
new CommandAPI(access),
new MinimumLevel(AccessLevel.none));
frame.useEvents(RecordNotifier.create(frame, view), new MinimumLevel(AccessLevel.read_table));
frame.useEvents(TableNotifier.create(frame, view), new MinimumLevel(AccessLevel.read_table));
frame.exposeAPI(

View File

@ -1,4 +1,6 @@
import BaseView from 'app/client/components/BaseView';
import {CommandName} from 'app/client/components/commandList';
import * as commands from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import {hooks} from 'app/client/Hooks';
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
@ -484,6 +486,26 @@ export class WidgetAPIImpl implements WidgetAPI {
}
}
const COMMAND_MINIMUM_ACCESS_LEVELS: Map<CommandName, AccessLevel> = new Map([
['undo', AccessLevel.full],
['redo', AccessLevel.full],
]);
export class CommandAPI {
constructor(private _currentAccess: AccessLevel) {}
public async run(commandName: CommandName): Promise<unknown> {
const minimumAccess = COMMAND_MINIMUM_ACCESS_LEVELS.get(commandName);
if (minimumAccess === undefined || !isSatisfied(this._currentAccess, minimumAccess)) {
// If the command name is unrecognized, or the current access level doesn't meet the
// command's minimum access level, do nothing.
return;
}
return await commands.allCommands[commandName].run();
}
}
/************************
* Events that are sent to the CustomWidget.
*

View File

@ -65,6 +65,8 @@ export const widgetApi = rpc.getStub<WidgetAPI>('WidgetAPI', checkers.WidgetAPI)
*/
export const sectionApi = rpc.getStub<CustomSectionAPI>('CustomSectionAPI', checkers.CustomSectionAPI);
export const commandApi = rpc.getStub<any>('CommandAPI');
/**
* Shortcut for [[GristView.allowSelectBy]].
*/
@ -437,6 +439,12 @@ export async function addImporter(name: string, path: string, mode: 'fullscreen'
});
}
export function enableKeyboardShortcuts() {
const Mousetrap = require('mousetrap');
Mousetrap.bind('mod+z', () => commandApi.run('undo'));
Mousetrap.bind(['mod+shift+z', 'ctrl+y'], () => commandApi.run('redo'));
}
/**
* Options when initializing connection to Grist.
*/

View File

@ -66,6 +66,7 @@
"@types/minio": "7.0.15",
"@types/mocha": "5.2.5",
"@types/moment-timezone": "0.5.9",
"@types/mousetrap": "1.6.2",
"@types/node": "^14",
"@types/node-fetch": "2.6.2",
"@types/pidusage": "2.0.1",

View File

@ -7,6 +7,7 @@
<script src="/grist-plugin-api.js"></script>
<script>
grist.ready();
grist.enableKeyboardShortcuts();
</script>
<style>
body {

View File

@ -32,6 +32,7 @@ function setup() {
grist.onNewRecord(function(rec) {
document.getElementById('record').innerHTML = 'new';
});
grist.enableKeyboardShortcuts();
}
window.onload = setup;

View File

@ -236,6 +236,31 @@ describe('CustomView', function() {
await gu.undo();
});
const undoTestTitle = access === 'full'
? 'allows undo/redo via keyboard'
: 'does not allow undo/redo via keyboard';
it (undoTestTitle, async function() {
const iframe = gu.getSection('Friends custom').find('iframe');
await driver.switchTo().frame(iframe);
await driver.find('body').click();
await gu.sendKeys(Key.chord(Key.CONTROL, 'y'));
const expected = access === 'full'
? withAccess(['Rabbit', 'Tom', 'Sydney', 'Bill', 'Evan', 'Mary'], undefined)
: withAccess(['Roger', 'Tom', 'Sydney', 'Bill', 'Evan', 'Mary'], undefined);
await gu.waitToPass(async () => {
assert.deepEqual(readJson(await driver.find('#placeholder').getText())?.Name, expected);
}, 1000);
await gu.sendKeys(Key.chord(await gu.modKey(), 'z'));
await gu.waitToPass(async () => {
assert.deepEqual(readJson(await driver.find('#placeholder').getText())?.Name,
withAccess(['Roger', 'Tom', 'Sydney', 'Bill', 'Evan', 'Mary'], undefined));
}, 1000);
await driver.switchTo().defaultContent();
});
it('allows switching to custom section by clicking inside it', async function() {
await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click();
assert.equal(await gu.getActiveSectionTitle(), 'FRIENDS');
@ -245,7 +270,7 @@ describe('CustomView', function() {
await driver.switchTo().frame(iframe);
await driver.find('body').click();
// Check that the right secton is active, and its settings visible in the side panel.
// Check that the right section is active, and its settings visible in the side panel.
await driver.switchTo().defaultContent();
assert.equal(await gu.getActiveSectionTitle(), 'FRIENDS Custom');
assert.equal(await driver.find('.test-config-widget-url').isPresent(), true);

View File

@ -843,6 +843,11 @@
dependencies:
moment ">=2.14.0"
"@types/mousetrap@1.6.2":
version "1.6.2"
resolved "https://registry.yarnpkg.com/@types/mousetrap/-/mousetrap-1.6.2.tgz#5f48bdbd28c8a447167263ae436488f1d8be072d"
integrity sha512-m67PUdzoHJGmYJ6Fno4iXocl+azw9cuz8tdUG21M04UzMRUT1YfxPIIIt3lQkXLMGwlGSKXYNGxCOMxKlGegIw==
"@types/node-fetch@2.6.2":
version "2.6.2"
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.2.tgz#d1a9c5fd049d9415dce61571557104dec3ec81da"