From aee75f6ba0e18484b5b80faf547a19ceb9faaace Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Mon, 24 Jun 2024 13:00:52 +0200 Subject: [PATCH] feat(dashboard): Add global commands (#7782) * add global commands * update lock * shorten keybinds --------- Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> --- .../src/components/layout/shell/shell.tsx | 169 ++++++++--- .../src/components/search/search.tsx | 213 +++++-------- .../dashboard/src/i18n/translations/en.json | 37 ++- .../src/providers/keybind-provider/hooks.tsx | 285 ++++++++++++++++++ .../src/providers/keybind-provider/index.ts | 3 + .../keybind-provider/keybind-context.tsx | 4 + .../keybind-provider/keybind-provider.tsx | 64 ++++ .../src/providers/keybind-provider/types.ts | 20 ++ .../src/providers/keybind-provider/utils.ts | 92 ++++++ 9 files changed, 710 insertions(+), 177 deletions(-) create mode 100644 packages/admin-next/dashboard/src/providers/keybind-provider/hooks.tsx create mode 100644 packages/admin-next/dashboard/src/providers/keybind-provider/index.ts create mode 100644 packages/admin-next/dashboard/src/providers/keybind-provider/keybind-context.tsx create mode 100644 packages/admin-next/dashboard/src/providers/keybind-provider/keybind-provider.tsx create mode 100644 packages/admin-next/dashboard/src/providers/keybind-provider/types.ts create mode 100644 packages/admin-next/dashboard/src/providers/keybind-provider/utils.ts diff --git a/packages/admin-next/dashboard/src/components/layout/shell/shell.tsx b/packages/admin-next/dashboard/src/components/layout/shell/shell.tsx index 21ddb5194a..e05442dfd8 100644 --- a/packages/admin-next/dashboard/src/components/layout/shell/shell.tsx +++ b/packages/admin-next/dashboard/src/components/layout/shell/shell.tsx @@ -7,12 +7,22 @@ import { Calendar, CircleHalfSolid, CogSixTooth, + Keyboard, MagnifyingGlass, SidebarLeft, User as UserIcon, } from "@medusajs/icons" -import { Avatar, DropdownMenu, IconButton, Kbd, Text, clx } from "@medusajs/ui" -import { PropsWithChildren } from "react" +import { + Avatar, + Button, + DropdownMenu, + Heading, + IconButton, + Kbd, + Text, + clx, +} from "@medusajs/ui" +import { PropsWithChildren, useState } from "react" import { Link, Outlet, @@ -22,31 +32,37 @@ import { useNavigate, } from "react-router-dom" -import { Skeleton } from "../../common/skeleton" - +import { useTranslation } from "react-i18next" import { useLogout } from "../../../hooks/api/auth" import { useMe } from "../../../hooks/api/users" import { queryClient } from "../../../lib/query-client" +import { KeybindProvider } from "../../../providers/keybind-provider" +import { useGlobalShortcuts } from "../../../providers/keybind-provider/hooks" import { useSearch } from "../../../providers/search-provider" import { useSidebar } from "../../../providers/sidebar-provider" import { useTheme } from "../../../providers/theme-provider" +import { Skeleton } from "../../common/skeleton" export const Shell = ({ children }: PropsWithChildren) => { + const globalShortcuts = useGlobalShortcuts() + return ( -
-
- {children} - {children} + +
+
+ {children} + {children} +
+
+ +
+ + + +
+
-
- -
- - - -
-
-
+ ) } @@ -206,7 +222,7 @@ const Logout = () => { const navigate = useNavigate() const { mutateAsync: logoutMutation } = useLogout() - const handleLayout = async () => { + const handleLogout = async () => { await logoutMutation(undefined, { onSuccess: () => { /** @@ -219,7 +235,7 @@ const Logout = () => { } return ( - +
Logout @@ -241,35 +257,95 @@ const Profile = () => { ) } -const LoggedInUser = () => { +const GlobalKeybindsModal = (props: { + open: boolean + onOpenChange: (open: boolean) => void +}) => { + const { t } = useTranslation() + const globalShortcuts = useGlobalShortcuts() + return ( - - - - - - - - - Documentation + + + + +
+ + Keyboard Shortcuts + +
+
+ {globalShortcuts.map((shortcut, index) => { + return ( +
+ {shortcut.label} +
+ {shortcut.keys.Mac?.map((key, index) => { + return {key} + })} +
+
+ ) + })} +
+
+ + + +
+
+
+
+ ) +} + +const LoggedInUser = () => { + const [openMenu, setOpenMenu] = useState(false) + const [openModal, setOpenModal] = useState(false) + + const toggleModal = () => { + setOpenMenu(false) + setOpenModal(!openModal) + } + + return ( +
+ + + + + + + + + Documentation + - - - - - Changelog + + + + Changelog + - - - - - - - + + + Keyboard shortcuts + + + + + + + + +
) } @@ -306,6 +382,7 @@ const ToggleNotifications = () => { } const Searchbar = () => { + const { t } = useTranslation() const { toggleSearch } = useSearch() return ( @@ -316,7 +393,7 @@ const Searchbar = () => {
- Jump to or search + {t("app.search.placeholder")}
⌘K diff --git a/packages/admin-next/dashboard/src/components/search/search.tsx b/packages/admin-next/dashboard/src/components/search/search.tsx index cb78cf7727..04cf9c5e7f 100644 --- a/packages/admin-next/dashboard/src/components/search/search.tsx +++ b/packages/admin-next/dashboard/src/components/search/search.tsx @@ -1,34 +1,74 @@ -import { MagnifyingGlass } from "@medusajs/icons" -import { Kbd, Text, clx } from "@medusajs/ui" +import { Badge, Kbd, Text, clx } from "@medusajs/ui" import * as Dialog from "@radix-ui/react-dialog" import { Command } from "cmdk" import { ComponentPropsWithoutRef, ElementRef, - HTMLAttributes, forwardRef, + useEffect, useMemo, } from "react" -import { useTranslation } from "react-i18next" +import { useTranslation } from "react-i18next" +import { useLocation } from "react-router-dom" +import { Shortcut, ShortcutType } from "../../providers/keybind-provider" +import { useGlobalShortcuts } from "../../providers/keybind-provider/hooks" import { useSearch } from "../../providers/search-provider" export const Search = () => { const { open, onOpenChange } = useSearch() - const links = useLinks() + const globalCommands = useGlobalShortcuts() + const location = useLocation() + const { t } = useTranslation() + + useEffect(() => { + onOpenChange(false) + }, [location.pathname, onOpenChange]) + + const links = useMemo(() => { + const groups = new Map() + + globalCommands.forEach((command) => { + const group = groups.get(command.type) || [] + group.push(command) + groups.set(command.type, group) + }) + + return Array.from(groups).map(([title, items]) => ({ + title, + items, + })) + }, [globalCommands]) + + const handleSelect = (callback: () => void) => { + callback() + onOpenChange(false) + } return ( - + - No results found. + {t("general.noResultsTitle")} {links.map((group) => { return ( - + {group.items.map((item) => { return ( - + handleSelect(item.callback)} + className="flex items-center justify-between" + > {item.label} +
+ {item.keys.Mac?.map((key, index) => { + return {key} + })} +
) })} @@ -40,86 +80,6 @@ export const Search = () => { ) } -type CommandItemProps = { - label: string -} - -type CommandGroupProps = { - title: string - items: CommandItemProps[] -} - -const useLinks = (): CommandGroupProps[] => { - const { t } = useTranslation() - - return useMemo( - () => [ - { - title: "Pages", - items: [ - { - label: t("products.domain"), - }, - { - label: t("categories.domain"), - }, - { - label: t("collections.domain"), - }, - { - label: t("giftCards.domain"), - }, - { - label: t("orders.domain"), - }, - { - label: t("draftOrders.domain"), - }, - { - label: t("customers.domain"), - }, - { - label: t("customerGroups.domain"), - }, - { - label: t("discounts.domain"), - }, - { - label: t("pricing.domain"), - }, - ], - }, - { - title: "Settings", - items: [ - { - label: t("profile.domain"), - }, - { - label: t("store.domain"), - }, - { - label: t("users.domain"), - }, - { - label: t("regions.domain"), - }, - { - label: t("locations.domain"), - }, - { - label: t("salesChannels.domain"), - }, - { - label: t("apiKeyManagement.domainTitle"), - }, - ], - }, - ], - [t] - ) -} - const CommandPalette = forwardRef< ElementRef, ComponentPropsWithoutRef @@ -133,36 +93,38 @@ const CommandPalette = forwardRef< {...props} /> )) -Command.displayName = Command.displayName +CommandPalette.displayName = Command.displayName interface CommandDialogProps extends Dialog.DialogProps {} const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + const { t } = useTranslation() + return ( - + {children} -
-
+
- Navigation + {t("app.search.navigation")}
- - + +
+
- Open Result + {t("app.search.openResult")} - +
@@ -175,19 +137,26 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => { const CommandInput = forwardRef< ElementRef, ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( -
- - -
-)) +>(({ className, ...props }, ref) => { + const { t } = useTranslation() + + return ( +
+
+ {/* TODO: Add filter once we have search engine */} + {t("app.search.allAreas")} +
+ +
+ ) +}) CommandInput.displayName = Command.Input.displayName @@ -198,7 +167,7 @@ const CommandList = forwardRef< svg]:text-ui-fg-subtle relative flex cursor-pointer select-none items-center gap-x-3 rounded-md p-2 outline-none data-[disabled]:pointer-events-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50", className )} {...props} @@ -259,19 +228,3 @@ const CommandItem = forwardRef< )) CommandItem.displayName = Command.Item.displayName - -const CommandShortcut = ({ - className, - ...props -}: HTMLAttributes) => { - return ( - - ) -} -CommandShortcut.displayName = "CommandShortcut" diff --git a/packages/admin-next/dashboard/src/i18n/translations/en.json b/packages/admin-next/dashboard/src/i18n/translations/en.json index ee25945326..29e0e42fc1 100644 --- a/packages/admin-next/dashboard/src/i18n/translations/en.json +++ b/packages/admin-next/dashboard/src/i18n/translations/en.json @@ -85,7 +85,42 @@ "clearAll": "Clear all", "apply": "Apply", "add": "Add", - "select": "Select" + "select": "Select", + "logout": "Logout" + }, + "app": { + "search": { + "allAreas": "All areas", + "navigation": "Navigation", + "openResult": "Open result", + "placeholder": "Jump to or find anything..." + }, + "keyboardShortcuts": { + "pageShortcut": "Jump to", + "settingShortcut": "Settings", + "commandShortcut": "Commands", + "goToOrders": "Go to orders", + "goToProducts": "Go to products", + "goToCollections": "Go to collections", + "goToCategories": "Go to categories", + "goToCustomers": "Go to customers", + "goToCustomerGroups": "Go to customer groups", + "goToInventory": "Go to inventory", + "goToReservations": "Go to reservations", + "goToPriceLists": "Go to price lists", + "goToPromotions": "Go to promotions", + "goToCampaigns": "Go to campaigns", + "goToStore": "Go to store", + "goToUsers": "Go to users", + "goToRegions": "Go to regions", + "goToTaxRegions": "Go to tax region", + "goToSalesChannels": "Go to sales channels", + "goToProductTypes": "Go to product types", + "goToLocations": "Go to locations", + "goToPublishableApiKeys": "Go to publishable API keys", + "goToSecretApiKeys": "Go to secret API keys", + "goToWorkflows": "Go to workflows" + } }, "filters": { "date": { diff --git a/packages/admin-next/dashboard/src/providers/keybind-provider/hooks.tsx b/packages/admin-next/dashboard/src/providers/keybind-provider/hooks.tsx new file mode 100644 index 0000000000..0f7ebb1ad1 --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/keybind-provider/hooks.tsx @@ -0,0 +1,285 @@ +import debounceFn from "lodash/debounce" +import { useCallback, useContext, useEffect, useState } from "react" +import { useTranslation } from "react-i18next" +import { useNavigate } from "react-router-dom" + +import { useLogout } from "../../hooks/api/auth" +import { queryClient } from "../../lib/query-client" +import { KeybindContext } from "./keybind-context" +import { Shortcut } from "./types" +import { findShortcut } from "./utils" + +export const useKeybind = () => { + const context = useContext(KeybindContext) + + if (!context) { + throw new Error("useKeybind must be used within a KeybindProvider") + } + + return context +} + +export const useRegisterShortcut = () => {} + +export const useShortcuts = ({ + shortcuts = [], + debounce, +}: { + shortcuts?: Shortcut[] + debounce: number +}) => { + const [keys, setKeys] = useState([]) + + // eslint-disable-next-line react-hooks/exhaustive-deps + const removeKeys = useCallback( + debounceFn(() => setKeys([]), debounce), + [] + ) + + // eslint-disable-next-line react-hooks/exhaustive-deps + const invokeShortcut = useCallback( + debounceFn((shortcut: Shortcut | null) => { + if (shortcut && shortcut.callback) { + shortcut.callback() + setKeys([]) + } + }, debounce / 2), + [] + ) + + useEffect(() => { + if (keys.length > 0 && shortcuts.length > 0) { + const shortcut = findShortcut(shortcuts, keys) + invokeShortcut(shortcut) + } + + return () => invokeShortcut.cancel() + }, [keys, shortcuts, invokeShortcut]) + + useEffect(() => { + const listener = (event: KeyboardEvent) => { + const target = event.target as HTMLElement + + /** + * Ignore key events from input, textarea and contenteditable elements + */ + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.contentEditable === "true" + ) { + removeKeys() + return + } + + setKeys((oldKeys) => [...oldKeys, event.key]) + removeKeys() + } + + window.addEventListener("keydown", listener) + + return () => { + window.removeEventListener("keydown", listener) + } + }, [removeKeys]) +} + +export const useGlobalShortcuts = () => { + const { t } = useTranslation() + const navigate = useNavigate() + + const { mutateAsync } = useLogout() + + const handleLogout = async () => { + await mutateAsync(undefined, { + onSuccess: () => { + queryClient.clear() + navigate("/login") + }, + }) + } + + const globalShortcuts: Shortcut[] = [ + // Pages + { + keys: { + Mac: ["G", "O"], + }, + label: t("app.keyboardShortcuts.goToOrders"), + type: "pageShortcut", + callback: () => navigate("/orders"), + }, + { + keys: { + Mac: ["G", "P"], + }, + label: t("app.keyboardShortcuts.goToProducts"), + type: "pageShortcut", + callback: () => navigate("/products"), + }, + { + keys: { + Mac: ["G", "P", "C"], + }, + label: t("app.keyboardShortcuts.goToCollections"), + type: "pageShortcut", + callback: () => navigate("/collections"), + }, + { + keys: { + Mac: ["G", "P", "A"], + }, + label: t("app.keyboardShortcuts.goToCategories"), + type: "pageShortcut", + callback: () => navigate("/categories"), + }, + { + keys: { + Mac: ["G", "C"], + }, + label: t("app.keyboardShortcuts.goToCustomers"), + type: "pageShortcut", + callback: () => navigate("/customers"), + }, + { + keys: { + Mac: ["G", "C", "G"], + }, + label: t("app.keyboardShortcuts.goToCustomerGroups"), + type: "pageShortcut", + callback: () => navigate("/customer-groups"), + }, + { + keys: { + Mac: ["G", "I"], + }, + label: t("app.keyboardShortcuts.goToInventory"), + type: "pageShortcut", + callback: () => navigate("/inventory"), + }, + { + keys: { + Mac: ["G", "I", "R"], + }, + label: t("app.keyboardShortcuts.goToReservations"), + type: "pageShortcut", + callback: () => navigate("/reservations"), + }, + { + keys: { + Mac: ["G", "L"], + }, + label: t("app.keyboardShortcuts.goToPriceLists"), + type: "pageShortcut", + callback: () => navigate("/pricing"), + }, + { + keys: { + Mac: ["G", "R"], + }, + label: t("app.keyboardShortcuts.goToPromotions"), + type: "pageShortcut", + callback: () => navigate("/promotions"), + }, + { + keys: { + Mac: ["G", "R", "C"], + }, + label: t("app.keyboardShortcuts.goToCampaigns"), + type: "pageShortcut", + callback: () => navigate("/campaigns"), + }, + // + { + keys: { + Mac: ["G", "S", "S"], + }, + label: t("app.keyboardShortcuts.goToStore"), + type: "settingShortcut", + callback: () => navigate("/settings/store"), + }, + { + keys: { + Mac: ["G", "S", "U"], + }, + label: t("app.keyboardShortcuts.goToUsers"), + type: "settingShortcut", + callback: () => navigate("/settings/users"), + }, + { + keys: { + Mac: ["G", "S", "R"], + }, + label: t("app.keyboardShortcuts.goToRegions"), + type: "settingShortcut", + callback: () => navigate("/settings/regions"), + }, + { + keys: { + Mac: ["G", "S", "T"], + }, + label: t("app.keyboardShortcuts.goToTaxRegions"), + type: "settingShortcut", + callback: () => navigate("/settings/taxes"), + }, + { + keys: { + Mac: ["G", "S", "A"], + }, + label: t("app.keyboardShortcuts.goToSalesChannels"), + type: "settingShortcut", + callback: () => navigate("/settings/sales-channels"), + }, + { + keys: { + Mac: ["G", "S", "P"], + }, + label: t("app.keyboardShortcuts.goToProductTypes"), + type: "settingShortcut", + callback: () => navigate("/settings/product-types"), + }, + { + keys: { + Mac: ["G", "S", "L"], + }, + label: t("app.keyboardShortcuts.goToLocations"), + type: "settingShortcut", + callback: () => navigate("/settings/locations"), + }, + { + keys: { + Mac: ["G", "S", "J"], + }, + label: t("app.keyboardShortcuts.goToPublishableApiKeys"), + type: "settingShortcut", + callback: () => navigate("/settings/publishable-api-keys"), + }, + { + keys: { + Mac: ["G", "S", "K"], + }, + label: t("app.keyboardShortcuts.goToSecretApiKeys"), + type: "settingShortcut", + callback: () => navigate("/settings/secret-api-keys"), + }, + { + keys: { + Mac: ["G", "S", "W"], + }, + label: t("app.keyboardShortcuts.goToWorkflows"), + type: "settingShortcut", + callback: () => navigate("/settings/workflows"), + }, + // Commands + { + keys: { + Mac: ["B", "Y", "E"], + }, + label: t("actions.logout"), + type: "commandShortcut", + callback: () => handleLogout(), + }, + ] + + return globalShortcuts +} diff --git a/packages/admin-next/dashboard/src/providers/keybind-provider/index.ts b/packages/admin-next/dashboard/src/providers/keybind-provider/index.ts new file mode 100644 index 0000000000..d821142bcf --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/keybind-provider/index.ts @@ -0,0 +1,3 @@ +export { useRegisterShortcut } from "./hooks" +export * from "./keybind-provider" +export type { Shortcut, ShortcutType } from "./types" diff --git a/packages/admin-next/dashboard/src/providers/keybind-provider/keybind-context.tsx b/packages/admin-next/dashboard/src/providers/keybind-provider/keybind-context.tsx new file mode 100644 index 0000000000..7d5f95a6c0 --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/keybind-provider/keybind-context.tsx @@ -0,0 +1,4 @@ +import { createContext } from "react" +import { KeybindContextState } from "./types" + +export const KeybindContext = createContext(null) diff --git a/packages/admin-next/dashboard/src/providers/keybind-provider/keybind-provider.tsx b/packages/admin-next/dashboard/src/providers/keybind-provider/keybind-provider.tsx new file mode 100644 index 0000000000..302ec37040 --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/keybind-provider/keybind-provider.tsx @@ -0,0 +1,64 @@ +import { PropsWithChildren, useCallback, useMemo, useState } from "react" + +import { useShortcuts } from "./hooks" +import { KeybindContext } from "./keybind-context" +import { KeybindContextState, Shortcut } from "./types" +import { + findFirstPlatformMatch, + findShortcutIndex, + getShortcutKeys, + getShortcutWithDefaultValues, +} from "./utils" + +type KeybindProviderProps = PropsWithChildren<{ + shortcuts: Shortcut[] + debounce?: number +}> + +export const KeybindProvider = ({ + shortcuts, + debounce = 500, + children, +}: KeybindProviderProps) => { + const [storeShortcuts, setStoreCommands] = useState( + shortcuts.map((shr) => getShortcutWithDefaultValues(shr)) + ) + const registerShortcut = useCallback( + (shortcut: Shortcut) => { + setStoreCommands((prevShortcuts) => { + const idx = findShortcutIndex(shortcuts, getShortcutKeys(shortcut)) + + const newShortcuts = [...prevShortcuts] + + if (idx > -1) { + newShortcuts[idx] = getShortcutWithDefaultValues(shortcut) + return prevShortcuts + } + + return [...prevShortcuts, getShortcutWithDefaultValues(shortcut)] + }) + }, + [shortcuts] + ) + + const getKeysByPlatform = useCallback((command: Shortcut) => { + return findFirstPlatformMatch(command.keys) + }, []) + + useShortcuts({ shortcuts: storeShortcuts, debounce }) + + const commandsContext = useMemo( + () => ({ + shortcuts: storeShortcuts, + registerShortcut, + getKeysByPlatform, + }), + [storeShortcuts, registerShortcut, getKeysByPlatform] + ) + + return ( + + {children} + + ) +} diff --git a/packages/admin-next/dashboard/src/providers/keybind-provider/types.ts b/packages/admin-next/dashboard/src/providers/keybind-provider/types.ts new file mode 100644 index 0000000000..e4a9dde9ee --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/keybind-provider/types.ts @@ -0,0 +1,20 @@ +export type KeybindContextState = {} + +export type Platform = "Mac" | "Windows" | "Linux" + +export type Keys = { + [key in Platform]?: string[] +} + +export type ShortcutType = + | "pageShortcut" + | "settingShortcut" + | "commandShortcut" + +export type Shortcut = { + keys: Keys + type: ShortcutType + label: string + callback: () => void + _defaultKeys?: Keys +} diff --git a/packages/admin-next/dashboard/src/providers/keybind-provider/utils.ts b/packages/admin-next/dashboard/src/providers/keybind-provider/utils.ts new file mode 100644 index 0000000000..08187bd897 --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/keybind-provider/utils.ts @@ -0,0 +1,92 @@ +import { Keys, Platform, Shortcut } from "./types" + +export const findFirstPlatformMatch = (keys: Keys) => { + const match = + Object.entries(keys as Record).filter( + ([, value]) => value.length > 0 + )[0] ?? [] + + return match.length + ? { + platform: match[0] as Platform, + keys: match[1] as string[], + } + : null +} + +export const getShortcutKeys = (shortcut: Shortcut) => { + const platform: Platform = "Mac" + + const keys: string[] | undefined = shortcut.keys[platform] + + if (!keys) { + const defaultPlatform = findFirstPlatformMatch(shortcut.keys) + + console.warn( + `No keys found for platform "${platform}" in "${shortcut.label}" ${ + defaultPlatform + ? `using keys for platform "${defaultPlatform.platform}"` + : "" + }` + ) + + return defaultPlatform ? defaultPlatform.keys : [] + } + + return keys +} + +const keysMatch = (keys1: string[], keys2: string[]) => { + return ( + keys1.length === keys2.length && + keys1.every( + (key, index) => key.toLowerCase() === keys2[index].toLowerCase() + ) + ) +} + +export const findShortcutIndex = (shortcuts: Shortcut[], keys: string[]) => { + if (!keys.length) { + return -1 + } + + let index = 0 + for (const shortcut of shortcuts) { + const shortcutKeys = getShortcutKeys(shortcut) + + if (keysMatch(shortcutKeys, keys)) { + return index + } + + index++ + } + + return -1 +} + +export const findShortcut = (shortcuts: Shortcut[], keys: string[]) => { + const shortcutIndex = findShortcutIndex(shortcuts, keys) + return shortcutIndex > -1 ? shortcuts[shortcutIndex] : null +} + +export const getShortcutWithDefaultValues = ( + shortcut: Shortcut, + platform: Platform = "Mac" +): Shortcut => { + const platforms: Platform[] = ["Mac", "Windows", "Linux"] + + const defaultKeys = Object.values(shortcut.keys)[0] ?? shortcut.keys[platform] + + const keys = platforms.reduce((acc, curr) => { + return { + ...acc, + [curr]: shortcut.keys[curr] ?? defaultKeys, + } + }, {}) + + return { + ...shortcut, + keys, + _defaultKeys: shortcut.keys, + } +}