a system of contributors?

This commit is contained in:
Ashley Stanton-Nurse 2024-04-15 15:45:22 -07:00
parent e441b75325
commit 2089d8ca9e
No known key found for this signature in database
6 changed files with 189 additions and 148 deletions

View File

@ -1,6 +1,8 @@
/** /**
* React component for Command button component. * React component for Command button component.
*/ */
import { KeyboardShortcutAction } from "KeyboardShortcuts";
import { ExtendedKeyboardEvent } from "mousetrap";
import * as React from "react"; import * as React from "react";
import CollapseChevronDownIcon from "../../../../images/QueryBuilder/CollapseChevronDown_16x.png"; import CollapseChevronDownIcon from "../../../../images/QueryBuilder/CollapseChevronDown_16x.png";
import { KeyCodes } from "../../../Common/Constants"; import { KeyCodes } from "../../../Common/Constants";
@ -30,7 +32,7 @@ export interface CommandButtonComponentProps {
/** /**
* Click handler for command button click * Click handler for command button click
*/ */
onCommandClick: (e: React.SyntheticEvent) => void; onCommandClick: (e: React.SyntheticEvent | ExtendedKeyboardEvent) => void;
/** /**
* Label for the button * Label for the button
@ -107,10 +109,13 @@ export interface CommandButtonComponentProps {
* Vertical bar to divide buttons * Vertical bar to divide buttons
*/ */
isDivider?: boolean; isDivider?: boolean;
/** /**
* Aria-label for the button * Aria-label for the button
*/ */
ariaLabel: string; ariaLabel: string;
keyboardShortcut?: KeyboardShortcutAction;
} }
export class CommandButtonComponent extends React.Component<CommandButtonComponentProps> { export class CommandButtonComponent extends React.Component<CommandButtonComponentProps> {

View File

@ -5,6 +5,7 @@
*/ */
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react"; import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
import { useNotebook } from "Explorer/Notebook/useNotebook"; import { useNotebook } from "Explorer/Notebook/useNotebook";
import { KeyboardShortcutAction, KeyboardShortcutContributor, KeyboardShortcutHandler, useKeyboardShortcutContributor } from "KeyboardShortcuts";
import { userContext } from "UserContext"; import { userContext } from "UserContext";
import * as React from "react"; import * as React from "react";
import create, { UseStore } from "zustand"; import create, { UseStore } from "zustand";
@ -40,6 +41,7 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
const buttons = useCommandBar((state) => state.contextButtons); const buttons = useCommandBar((state) => state.contextButtons);
const isHidden = useCommandBar((state) => state.isHidden); const isHidden = useCommandBar((state) => state.isHidden);
const backgroundColor = StyleConstants.BaseLight; const backgroundColor = StyleConstants.BaseLight;
const setKeyboardShortcutHandlers = useKeyboardShortcutContributor(KeyboardShortcutContributor.COMMAND_BAR);
if (userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo") { if (userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo") {
const buttons = const buttons =
@ -105,6 +107,18 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
}, },
}; };
const allButtons = staticButtons.concat(contextButtons).concat(controlButtons);
const handlers: Partial<Record<KeyboardShortcutAction, KeyboardShortcutHandler>> = {};
allButtons.forEach((button) => {
if(button.keyboardShortcut) {
handlers[button.keyboardShortcut] = (e) => {
button.onCommandClick(e);
return false;
}
}
});
setKeyboardShortcutHandlers(handlers);
return ( return (
<div className="commandBarContainer" style={{ display: isHidden ? "none" : "initial" }}> <div className="commandBarContainer" style={{ display: isHidden ? "none" : "initial" }}>
<FluentCommandBar <FluentCommandBar

View File

@ -1,3 +1,4 @@
import { KeyboardShortcutAction } from "KeyboardShortcuts";
import { ReactTabKind, useTabs } from "hooks/useTabs"; import { ReactTabKind, useTabs } from "hooks/useTabs";
import * as React from "react"; import * as React from "react";
import AddCollectionIcon from "../../../../images/AddCollection.svg"; import AddCollectionIcon from "../../../../images/AddCollection.svg";
@ -297,6 +298,7 @@ function createNewSQLQueryButton(selectedNodeState: SelectedNodeState): CommandB
id: "newQueryBtn", id: "newQueryBtn",
iconSrc: AddSqlQueryIcon, iconSrc: AddSqlQueryIcon,
iconAlt: label, iconAlt: label,
keyboardShortcut: KeyboardShortcutAction.NEW_QUERY,
onCommandClick: () => { onCommandClick: () => {
const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection(); const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection();
selectedCollection && selectedCollection.onNewQueryClick(selectedCollection); selectedCollection && selectedCollection.onNewQueryClick(selectedCollection);
@ -312,6 +314,7 @@ function createNewSQLQueryButton(selectedNodeState: SelectedNodeState): CommandB
id: "newQueryBtn", id: "newQueryBtn",
iconSrc: AddSqlQueryIcon, iconSrc: AddSqlQueryIcon,
iconAlt: label, iconAlt: label,
keyboardShortcut: KeyboardShortcutAction.NEW_QUERY,
onCommandClick: () => { onCommandClick: () => {
const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection(); const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection();
selectedCollection && selectedCollection.onNewMongoQueryClick(selectedCollection); selectedCollection && selectedCollection.onNewMongoQueryClick(selectedCollection);

View File

@ -10,6 +10,7 @@ import { OnExecuteQueryClick, QueryDocumentsPerPage } from "Explorer/QueryCopilo
import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar"; import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar";
import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection"; import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection";
import { useSelectedNode } from "Explorer/useSelectedNode"; import { useSelectedNode } from "Explorer/useSelectedNode";
import { KeyboardShortcutAction } from "KeyboardShortcuts";
import { QueryConstants } from "Shared/Constants"; import { QueryConstants } from "Shared/Constants";
import { LocalStorageUtility, StorageKey, getRUThreshold, ruThresholdEnabled } from "Shared/StorageUtility"; import { LocalStorageUtility, StorageKey, getRUThreshold, ruThresholdEnabled } from "Shared/StorageUtility";
import { Action } from "Shared/Telemetry/TelemetryConstants"; import { Action } from "Shared/Telemetry/TelemetryConstants";
@ -393,6 +394,7 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
buttons.push({ buttons.push({
iconSrc: ExecuteQueryIcon, iconSrc: ExecuteQueryIcon,
iconAlt: label, iconAlt: label,
keyboardShortcut: KeyboardShortcutAction.EXECUTE_ITEM,
onCommandClick: this.props.isSampleCopilotActive onCommandClick: this.props.isSampleCopilotActive
? () => OnExecuteQueryClick(this.props.copilotStore) ? () => OnExecuteQueryClick(this.props.copilotStore)
: this.onExecuteQueryClick, : this.onExecuteQueryClick,

View File

@ -1,115 +1,135 @@
import { useSelectedNode } from "Explorer/useSelectedNode";
import { userContext } from "UserContext";
import Mousetrap, { ExtendedKeyboardEvent } from "mousetrap"; import Mousetrap, { ExtendedKeyboardEvent } from "mousetrap";
import * as React from "react"; import * as React from "react";
import * as ViewModels from "../Contracts/ViewModels"; import create, { UseStore } from "zustand";
type KeyboardShortcutRootProps = React.PropsWithChildren<unknown>; // Provides a system of Keyboard Shortcuts that can be contributed to by different parts of the application.
type KeyboardShortcutHandler = (e: ExtendedKeyboardEvent, combo: string) => boolean | void; //
// The goals of this system are:
// * Shortcuts can be contributed from different parts of the application (e.g. the command bar, specified editor tabs, etc.)
// * Contributors may only provide some of their shortcuts, others may be out-of-scope for the current context.
// * Contributors don't have to add/remove handlers individually, they can use a declarative pattern to set all their handlers at once.
//
// So, in order to do that, we store handlers in a two-level hierarchy:
// 1. We store a mapping of contributors to their Contributions.
// 2. Each Contribution is a mapping of actions to their handlers.
//
// Thus, a Contributor sets all its handlers at once, replacing all handlers previously contributed by that Contributor.
// The system then merges all Contributions into a single set of handlers, with duplicate handlers being handled in the order that the Contributors are processed.
export interface KeyboardShortcutBinding {
/** export type KeyboardShortcutHandler = (e: ExtendedKeyboardEvent, combo: string) => boolean | void;
* The keyboard shortcut to bind to. This can be a single string or an array of strings.
* Any combination supported by Mousetrap (https://craig.is/killing/mice#api.bind) is valid here.
*/
keys: string | string[],
/** /**
* The handler to run when the keyboard shortcut is pressed. * Lists all the possible contributors to keyboard shortcut handlers.
* @param e The keyboard event that triggered the shortcut. *
* @param combo The specific keyboard combination that was matched (in case a single handler is used for multiple shortcuts). * A "Contributor" is a part of the application that can contribute keyboard shortcut handlers.
* @returns If the handler returns `false`, the default action for the keyboard shortcut will be prevented AND propagation of the event will be stopped. * The contributor must set all it's keyboard shortcut handlers at once.
* This allows the contributor to easily replace all it's keyboard shortcuts at once.
*
* For example, the command bar adds/removes keyboard shortcut handlers based on the current context, using the existing logic that determines which buttons are shown.
* Isolating contributors like this allow the command bar to easily replace all it's keyboard shortcuts when the context changes without breaking keyboard shortcuts contributed by other parts of the application.
*/ */
handler: KeyboardShortcutHandler, export enum KeyboardShortcutContributor {
COMMAND_BAR = "COMMAND_BAR",
/**
* The event to bind the keyboard shortcut to (keydown, keyup, etc.).
* The default is 'keydown'
*/
action?: string,
} }
/** /**
* Wraps the provided keyboard shortcut handler in one that only runs if a collection is selected. * The order in which contributors are processed.
* @param callback The callback to run if a collection is selected. * This is important because the last contributor to set a handler for a given action will be the one that is used.
* @returns If the handler returns `false`, the default action for the keyboard shortcut will be prevented AND propagation of the event will be stopped.
*/ */
function withSelectedCollection(callback: (selectedCollection: ViewModels.Collection, e: ExtendedKeyboardEvent, combo: string) => boolean | void): KeyboardShortcutHandler { const contributorOrder: KeyboardShortcutContributor[] = [
return (e, combo) => { KeyboardShortcutContributor.COMMAND_BAR,
const state = useSelectedNode.getState(); ];
if (!state.selectedNode) {
return; /**
* The possible actions that can be triggered by keyboard shortcuts.
*/
export enum KeyboardShortcutAction {
NEW_QUERY = "NEW_QUERY",
EXECUTE_ITEM = "EXECUTE_ITEM",
} }
const selectedCollection = state.findSelectedCollection(); /**
if (selectedCollection) { * The default keyboard shortcuts for the application.
return callback(selectedCollection, e, combo); * This record maps each action to the keyboard shortcuts that trigger the action.
} * Even if an action is specified here though, it will not be triggered unless a handler is set for it.
*/
const bindings: Record<KeyboardShortcutAction, string[]> = {
[KeyboardShortcutAction.NEW_QUERY]: ["ctrl+j"],
[KeyboardShortcutAction.EXECUTE_ITEM]: ["shift+enter"],
}; };
/**
* Represents all the handlers provided by a contributor.
*/
export type KeyboardShortcutContribution = Partial<Record<KeyboardShortcutAction, KeyboardShortcutHandler>>;
interface KeyboardShortcutState {
/**
* Collects all the contributions from different contributors.
*/
contributions: Partial<Record<KeyboardShortcutContributor, KeyboardShortcutContribution>>;
/**
* A merged set of all the handlers from all contributors.
*/
allHandlers: KeyboardShortcutContribution;
/**
* Sets the keyboard shortcut handlers for a given contributor.
*/
setHandlers: (contributor: KeyboardShortcutContributor, handlers: Partial<Record<KeyboardShortcutAction, KeyboardShortcutHandler>>) => void;
} }
const bindings: KeyboardShortcutBinding[] = [ /**
{ * Gets the setHandlers function for a given contributor.
keys: ["ctrl+j"], * @param contributor The contributor to get the setHandlers function for.
handler: withSelectedCollection((selectedCollection) => { * @returns A function that sets the keyboard shortcut handlers for the given contributor.
if (userContext.apiType === "SQL" || userContext.apiType === "Gremlin") { */
selectedCollection.onNewQueryClick(selectedCollection); export const useKeyboardShortcutContributor = (contributor: KeyboardShortcutContributor) => {
return false; const setHandlers = useKeyboardShortcutHandlers.getState().setHandlers;
} else if (userContext.apiType === "Mongo") { return (handlers: Partial<Record<KeyboardShortcutAction, KeyboardShortcutHandler>>) => {
selectedCollection.onNewMongoQueryClick(selectedCollection); setHandlers(contributor, handlers);
return false; };
} }
return true;
}),
},
{
keys: ["shift+enter"],
handler: () => {
alert("TODO: Execute Item");
return false;
},
},
{
keys: ["esc"],
handler: () => {
alert("TODO: Cancel Query");
return false;
},
},
{
keys: ["mod+s"],
handler: () => {
alert("TODO: Save Query");
return false;
},
},
{
keys: ["mod+o"],
handler: () => {
alert("TODO: Open Query");
return false;
},
},
{
keys: ["mod+shift+o"],
handler: () => {
alert("TODO: Open Query from Disk");
return false;
},
},
{
keys: ["mod+s"],
handler: () => {
alert("TODO: Save");
return false;
},
},
]
export function KeyboardShortcutRoot({ children }: KeyboardShortcutRootProps) { const useKeyboardShortcutHandlers: UseStore<KeyboardShortcutState> = create((set, get) => ({
contributions: {},
allHandlers: {},
setHandlers: (contributor: KeyboardShortcutContributor, handlers: Partial<Record<KeyboardShortcutAction, KeyboardShortcutHandler>>) => {
const current = get();
// Update the list of contributions.
const newContributions = { ...current.contributions, [contributor]: handlers };
// Merge all the contributions into a single set of handlers.
const allHandlers: KeyboardShortcutContribution = {};
contributorOrder.forEach((contributor) => {
const contribution = newContributions[contributor];
if (contribution) {
(Object.keys(contribution) as KeyboardShortcutAction[]).forEach((action) => {
allHandlers[action] = contribution[action];
});
}
});
set({ contributions: newContributions, allHandlers })
}
}));
function createHandler(action: KeyboardShortcutAction): KeyboardShortcutHandler {
return (e, combo) => {
const handlers = useKeyboardShortcutHandlers.getState().allHandlers;
const handler = handlers[action];
if (handler) {
return handler(e, combo);
}
};
}
export function KeyboardShortcutRoot(props: React.HTMLProps<HTMLDivElement>) {
const ref = React.useRef<HTMLDivElement>(null);
React.useEffect(() => { React.useEffect(() => {
const m = new Mousetrap(document.body); const m = new Mousetrap(ref.current);
const existingStopCallback = m.stopCallback; const existingStopCallback = m.stopCallback;
m.stopCallback = (e, element, combo) => { m.stopCallback = (e, element, combo) => {
// Don't block mousetrap callback in the Monaco editor. // Don't block mousetrap callback in the Monaco editor.
@ -120,12 +140,11 @@ export function KeyboardShortcutRoot({ children }: KeyboardShortcutRootProps) {
return existingStopCallback(e, element, combo); return existingStopCallback(e, element, combo);
}; };
bindings.forEach(b => { (Object.keys(bindings) as KeyboardShortcutAction[]).forEach((action) => {
m.bind(b.keys, b.handler, b.action); m.bind(bindings[action], createHandler(action));
}); });
}, []); // Using an empty dependency array means React will only run this _once_ when the component is mounted. }, [ref]); // We only need to re-render the component when the ref changes.
return <> return <div ref={ref} {...props}>
{children} </div>;
</>;
} }

View File

@ -92,8 +92,7 @@ const App: React.FunctionComponent = () => {
} }
return ( return (
<KeyboardShortcutRoot> <KeyboardShortcutRoot className="flexContainer" aria-hidden="false">
<div className="flexContainer" aria-hidden="false">
<div id="divExplorer" className="flexContainer hideOverflows"> <div id="divExplorer" className="flexContainer hideOverflows">
<div id="freeTierTeachingBubble"> </div> <div id="freeTierTeachingBubble"> </div>
{/* Main Command Bar - Start */} {/* Main Command Bar - Start */}
@ -138,7 +137,6 @@ const App: React.FunctionComponent = () => {
{<SQLQuickstartTutorial />} {<SQLQuickstartTutorial />}
{<MongoQuickstartTutorial />} {<MongoQuickstartTutorial />}
{<QueryCopilotCarousel isOpen={isCopilotCarouselOpen} explorer={explorer} />} {<QueryCopilotCarousel isOpen={isCopilotCarouselOpen} explorer={explorer} />}
</div>
</KeyboardShortcutRoot> </KeyboardShortcutRoot>
); );
}; };