feat(dashboard): Add global commands (#7782)

* add global commands

* update lock

* shorten keybinds

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Kasper Fabricius Kristensen
2024-06-24 13:00:52 +02:00
committed by GitHub
co-authored by Oli Juhl
parent 27bb93c5b5
commit aee75f6ba0
9 changed files with 710 additions and 177 deletions
@@ -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<string[]>([])
// 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
}
@@ -0,0 +1,3 @@
export { useRegisterShortcut } from "./hooks"
export * from "./keybind-provider"
export type { Shortcut, ShortcutType } from "./types"
@@ -0,0 +1,4 @@
import { createContext } from "react"
import { KeybindContextState } from "./types"
export const KeybindContext = createContext<KeybindContextState | null>(null)
@@ -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<KeybindContextState>(
() => ({
shortcuts: storeShortcuts,
registerShortcut,
getKeysByPlatform,
}),
[storeShortcuts, registerShortcut, getKeysByPlatform]
)
return (
<KeybindContext.Provider value={commandsContext}>
{children}
</KeybindContext.Provider>
)
}
@@ -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
}
@@ -0,0 +1,92 @@
import { Keys, Platform, Shortcut } from "./types"
export const findFirstPlatformMatch = (keys: Keys) => {
const match =
Object.entries(keys as Record<any, any>).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,
}
}