From 07bbe000595a9f88ce2efb90508e69e112f4bd94 Mon Sep 17 00:00:00 2001
From: Robert Dyer <rdyer@unl.edu>
Date: Wed, 17 Jul 2024 15:41:50 -0500
Subject: [PATCH] Add additional hotkeys (#3061)

* Add account page hotkeys

* add release note

* fix linter

* change shortcut

* change hotkey

* fix lint

* add budget shortcuts

* update help page

* fix linter

* add privacy filter hotkey p

* update help modal

* fix deps

* slash the zero

* bound the month picker

* change privacy shortcut to ctrl+p

* remap keys to ctrl

* add select all hotkey

* fix linter

* change add hotkey to T

* update help modal

* resize help modal

* fix linter

* shrink modal size more

* change budget reset behavior

* change privacy to shift+ctrl+p

* move shift to front
---
 .../src/components/Titlebar.tsx               | 12 +++
 .../src/components/accounts/Header.jsx        | 27 +++++++
 .../components/budget/BudgetPageHeader.tsx    | 16 +---
 .../components/budget/DynamicBudgetTable.tsx  | 61 +++++++++++++-
 .../src/components/filters/FiltersMenu.jsx    |  4 +
 .../modals/KeyboardShortcutModal.tsx          | 79 +++++++++++++++----
 .../transactions/TransactionsTable.jsx        | 11 +++
 upcoming-release-notes/3061.md                |  6 ++
 8 files changed, 183 insertions(+), 33 deletions(-)
 create mode 100644 upcoming-release-notes/3061.md

diff --git a/packages/desktop-client/src/components/Titlebar.tsx b/packages/desktop-client/src/components/Titlebar.tsx
index 0a1e76dfb..4336ef4e0 100644
--- a/packages/desktop-client/src/components/Titlebar.tsx
+++ b/packages/desktop-client/src/components/Titlebar.tsx
@@ -64,6 +64,18 @@ function PrivacyButton({ style }: PrivacyButtonProps) {
 
   const privacyIconStyle = { width: 15, height: 15 };
 
+  useHotkeys(
+    'shift+ctrl+p, shift+cmd+p, shift+meta+p',
+    () => {
+      setPrivacyEnabledPref(!isPrivacyEnabled);
+    },
+    {
+      preventDefault: true,
+      scopes: ['app'],
+    },
+    [setPrivacyEnabledPref, isPrivacyEnabled],
+  );
+
   return (
     <Button
       type="bare"
diff --git a/packages/desktop-client/src/components/accounts/Header.jsx b/packages/desktop-client/src/components/accounts/Header.jsx
index 00e247473..d5591ccbf 100644
--- a/packages/desktop-client/src/components/accounts/Header.jsx
+++ b/packages/desktop-client/src/components/accounts/Header.jsx
@@ -130,6 +130,33 @@ export function AccountHeader({
     },
     [searchInput],
   );
+  useHotkeys(
+    't',
+    () => onAddTransaction(),
+    {
+      preventDefault: true,
+      scopes: ['app'],
+    },
+    [onAddTransaction],
+  );
+  useHotkeys(
+    'ctrl+i, cmd+i, meta+i',
+    () => onImport(),
+    {
+      scopes: ['app'],
+    },
+    [onImport],
+  );
+  useHotkeys(
+    'ctrl+b, cmd+b, meta+b',
+    () => onSync(),
+    {
+      enabled: canSync && !isServerOffline,
+      preventDefault: true,
+      scopes: ['app'],
+    },
+    [onSync],
+  );
 
   return (
     <>
diff --git a/packages/desktop-client/src/components/budget/BudgetPageHeader.tsx b/packages/desktop-client/src/components/budget/BudgetPageHeader.tsx
index 4db9767f5..bd6e61060 100644
--- a/packages/desktop-client/src/components/budget/BudgetPageHeader.tsx
+++ b/packages/desktop-client/src/components/budget/BudgetPageHeader.tsx
@@ -1,8 +1,6 @@
 // @ts-strict-ignore
 import React, { type ComponentProps, memo } from 'react';
 
-import * as monthUtils from 'loot-core/src/shared/months';
-
 import { View } from '../common/View';
 
 import { MonthPicker } from './MonthPicker';
@@ -17,18 +15,6 @@ type BudgetPageHeaderProps = {
 
 export const BudgetPageHeader = memo<BudgetPageHeaderProps>(
   ({ startMonth, onMonthSelect, numMonths, monthBounds }) => {
-    function getValidMonth(month) {
-      const start = monthBounds.start;
-      const end = monthUtils.subMonths(monthBounds.end, numMonths - 1);
-
-      if (month < start) {
-        return start;
-      } else if (month > end) {
-        return end;
-      }
-      return month;
-    }
-
     return (
       <View style={{ marginLeft: 200 + 5, flexShrink: 0 }}>
         <View style={{ marginRight: 5 + getScrollbarWidth() }}>
@@ -37,7 +23,7 @@ export const BudgetPageHeader = memo<BudgetPageHeaderProps>(
             numDisplayed={numMonths}
             monthBounds={monthBounds}
             style={{ paddingTop: 5 }}
-            onSelect={month => onMonthSelect(getValidMonth(month))}
+            onSelect={month => onMonthSelect(month)}
           />
         </View>
       </View>
diff --git a/packages/desktop-client/src/components/budget/DynamicBudgetTable.tsx b/packages/desktop-client/src/components/budget/DynamicBudgetTable.tsx
index 60e97052b..7229a0e14 100644
--- a/packages/desktop-client/src/components/budget/DynamicBudgetTable.tsx
+++ b/packages/desktop-client/src/components/budget/DynamicBudgetTable.tsx
@@ -1,7 +1,10 @@
 // @ts-strict-ignore
 import React, { useEffect, type ComponentProps } from 'react';
+import { useHotkeys } from 'react-hotkeys-hook';
 import AutoSizer from 'react-virtualized-auto-sizer';
 
+import * as monthUtils from 'loot-core/src/shared/months';
+
 import { View } from '../common/View';
 
 import { useBudgetMonthCount } from './BudgetMonthCountContext';
@@ -32,6 +35,7 @@ type DynamicBudgetTableInnerProps = {
 } & DynamicBudgetTableProps;
 
 const DynamicBudgetTableInner = ({
+  type,
   width,
   height,
   prewarmStartMonth,
@@ -51,10 +55,65 @@ const DynamicBudgetTableInner = ({
     setDisplayMax(numPossible);
   }, [numPossible]);
 
+  function getValidMonth(month) {
+    const start = monthBounds.start;
+    const end = monthUtils.subMonths(monthBounds.end, numMonths - 1);
+
+    if (month < start) {
+      return start;
+    } else if (month > end) {
+      return end;
+    }
+    return month;
+  }
+
   function _onMonthSelect(month) {
-    onMonthSelect(month, numMonths);
+    onMonthSelect(getValidMonth(month), numMonths);
   }
 
+  useHotkeys(
+    'left',
+    () => {
+      _onMonthSelect(monthUtils.prevMonth(startMonth));
+    },
+    {
+      preventDefault: true,
+      scopes: ['app'],
+    },
+    [_onMonthSelect, startMonth],
+  );
+  useHotkeys(
+    'right',
+    () => {
+      _onMonthSelect(monthUtils.nextMonth(startMonth));
+    },
+    {
+      preventDefault: true,
+      scopes: ['app'],
+    },
+    [_onMonthSelect, startMonth],
+  );
+  useHotkeys(
+    '0',
+    () => {
+      _onMonthSelect(
+        monthUtils.subMonths(
+          monthUtils.currentMonth(),
+          type === 'rollover'
+            ? Math.floor((numMonths - 1) / 2)
+            : numMonths === 2
+              ? 1
+              : Math.max(numMonths - 2, 0),
+        ),
+      );
+    },
+    {
+      preventDefault: true,
+      scopes: ['app'],
+    },
+    [_onMonthSelect, startMonth, numMonths],
+  );
+
   return (
     <View
       style={{
diff --git a/packages/desktop-client/src/components/filters/FiltersMenu.jsx b/packages/desktop-client/src/components/filters/FiltersMenu.jsx
index 4f9b16cd0..fc971664e 100644
--- a/packages/desktop-client/src/components/filters/FiltersMenu.jsx
+++ b/packages/desktop-client/src/components/filters/FiltersMenu.jsx
@@ -1,4 +1,5 @@
 import React, { useState, useRef, useEffect, useReducer } from 'react';
+import { useHotkeys } from 'react-hotkeys-hook';
 
 import { FocusScope } from '@react-aria/focus';
 import {
@@ -315,6 +316,9 @@ export function FilterButton({ onApply, compact, hover, exclude }) {
       dispatch({ type: 'close' });
     }
   }
+  useHotkeys('f', () => dispatch({ type: 'select-field' }), {
+    scopes: ['app'],
+  });
 
   return (
     <View>
diff --git a/packages/desktop-client/src/components/modals/KeyboardShortcutModal.tsx b/packages/desktop-client/src/components/modals/KeyboardShortcutModal.tsx
index 0f018ac98..5a4a3a0b1 100644
--- a/packages/desktop-client/src/components/modals/KeyboardShortcutModal.tsx
+++ b/packages/desktop-client/src/components/modals/KeyboardShortcutModal.tsx
@@ -2,6 +2,7 @@ import { useLocation } from 'react-router-dom';
 
 import * as Platform from 'loot-core/src/client/platform';
 
+import { type CSSProperties } from '../../style';
 import { Modal, type ModalProps } from '../common/Modal';
 import { Text } from '../common/Text';
 import { View } from '../common/View';
@@ -12,6 +13,7 @@ type KeyboardShortcutsModalProps = {
 
 type KeyIconProps = {
   shortcut: string;
+  style?: CSSProperties;
 };
 
 type GroupHeadingProps = {
@@ -23,9 +25,10 @@ type ShortcutProps = {
   description: string;
   meta?: string;
   shift?: boolean;
+  style?: CSSProperties;
 };
 
-function KeyIcon({ shortcut }: KeyIconProps) {
+function KeyIcon({ shortcut, style }: KeyIconProps) {
   return (
     <div
       style={{
@@ -37,10 +40,11 @@ function KeyIcon({ shortcut }: KeyIconProps) {
         color: '#000',
         border: '1px solid #000',
         borderRadius: 8,
-        minWidth: 35,
-        minHeight: 35,
+        minWidth: 30,
+        minHeight: 30,
         filter: 'drop-shadow(1px 1px)',
         padding: 5,
+        ...style,
       }}
     >
       {shortcut}
@@ -63,12 +67,18 @@ function GroupHeading({ group }: GroupHeadingProps) {
   );
 }
 
-function Shortcut({ shortcut, description, meta, shift }: ShortcutProps) {
+function Shortcut({
+  shortcut,
+  description,
+  meta,
+  shift,
+  style,
+}: ShortcutProps) {
   return (
     <div
       style={{
         display: 'flex',
-        marginBottom: 10,
+        marginBottom: 5,
         marginLeft: 20,
       }}
     >
@@ -85,9 +95,9 @@ function Shortcut({ shortcut, description, meta, shift }: ShortcutProps) {
             marginRight: 10,
           }}
         >
-          {meta && (
+          {shift && (
             <>
-              <KeyIcon shortcut={meta} />
+              <KeyIcon shortcut="Shift" />
               <Text
                 style={{
                   display: 'flex',
@@ -102,9 +112,9 @@ function Shortcut({ shortcut, description, meta, shift }: ShortcutProps) {
               </Text>
             </>
           )}
-          {shift && (
+          {meta && (
             <>
-              <KeyIcon shortcut="Shift" />
+              <KeyIcon shortcut={meta} />
               <Text
                 style={{
                   display: 'flex',
@@ -119,7 +129,7 @@ function Shortcut({ shortcut, description, meta, shift }: ShortcutProps) {
               </Text>
             </>
           )}
-          <KeyIcon shortcut={shortcut} />
+          <KeyIcon shortcut={shortcut} style={style} />
         </div>
         <div
           style={{
@@ -146,6 +156,7 @@ export function KeyboardShortcutModal({
   modalProps,
 }: KeyboardShortcutsModalProps) {
   const location = useLocation();
+  const onBudget = location.pathname.startsWith('/budget');
   const onAccounts = location.pathname.startsWith('/accounts');
   const ctrl = Platform.OS === 'mac' ? '⌘' : 'Ctrl';
   return (
@@ -153,19 +164,45 @@ export function KeyboardShortcutModal({
       <View
         style={{
           flexDirection: 'row',
+          fontSize: 13,
         }}
       >
         <View>
+          <Shortcut shortcut="?" description="Show this help dialog" />
           <Shortcut
             shortcut="O"
             description="Close the current budget and open another"
             meta={ctrl}
           />
-          <Shortcut shortcut="?" description="Show this help dialog" />
+          <Shortcut
+            shortcut="P"
+            description="Toggle the privacy filter"
+            meta={ctrl}
+            shift={true}
+          />
+          {onBudget && (
+            <Shortcut
+              shortcut="0"
+              description="View current month"
+              style={{
+                fontVariantNumeric: 'slashed-zero',
+              }}
+            />
+          )}
           {onAccounts && (
             <>
               <Shortcut shortcut="Enter" description="Move down when editing" />
-              <Shortcut shortcut="Tab" description="Move right when editing" />
+              <Shortcut
+                shortcut="Enter"
+                description="Move up when editing"
+                shift={true}
+              />
+              <Shortcut
+                shortcut="I"
+                description="Import transactions"
+                meta={ctrl}
+              />
+              <Shortcut shortcut="B" description="Bank sync" meta={ctrl} />
               <GroupHeading group="Select a transaction, then" />
               <Shortcut
                 shortcut="J"
@@ -197,8 +234,7 @@ export function KeyboardShortcutModal({
         </View>
         <View
           style={{
-            marginLeft: 20,
-            marginRight: 20,
+            marginRight: 15,
           }}
         >
           <Shortcut
@@ -212,18 +248,27 @@ export function KeyboardShortcutModal({
             shift={true}
             meta={ctrl}
           />
+          {onBudget && (
+            <>
+              <Shortcut shortcut="←" description="View previous month" />
+              <Shortcut shortcut="→" description="View next month" />
+            </>
+          )}
           {onAccounts && (
             <>
               <Shortcut
-                shortcut="Enter"
-                description="Move up when editing"
-                shift={true}
+                shortcut="A"
+                description="Select all transactions"
+                meta={ctrl}
               />
+              <Shortcut shortcut="Tab" description="Move right when editing" />
               <Shortcut
                 shortcut="Tab"
                 description="Move left when editing"
                 shift={true}
               />
+              <Shortcut shortcut="T" description="Add a new transaction" />
+              <Shortcut shortcut="F" description="Filter transactions" />
               <GroupHeading group="With transaction(s) selected" />
               <Shortcut
                 shortcut="F"
diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
index 0f57c59f9..5de3b0abc 100644
--- a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
+++ b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
@@ -10,6 +10,7 @@ import React, {
   useLayoutEffect,
   useEffect,
 } from 'react';
+import { useHotkeys } from 'react-hotkeys-hook';
 import { useDispatch } from 'react-redux';
 
 import {
@@ -174,6 +175,16 @@ const TransactionHeader = memo(
   }) => {
     const dispatchSelected = useSelectedDispatch();
 
+    useHotkeys(
+      'ctrl+a, cmd+a, meta+a',
+      e => dispatchSelected({ type: 'select-all', event: e }),
+      {
+        preventDefault: true,
+        scopes: ['app'],
+      },
+      [dispatchSelected],
+    );
+
     return (
       <Row
         style={{
diff --git a/upcoming-release-notes/3061.md b/upcoming-release-notes/3061.md
new file mode 100644
index 000000000..cd8ea967b
--- /dev/null
+++ b/upcoming-release-notes/3061.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [psybers]
+---
+
+Add additional keyboard hotkeys.
\ No newline at end of file
-- 
GitLab