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:
committed by
GitHub
parent
27bb93c5b5
commit
aee75f6ba0
@@ -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 (
|
||||
<div className="flex h-screen flex-col items-start overflow-hidden lg:flex-row">
|
||||
<div>
|
||||
<MobileSidebarContainer>{children}</MobileSidebarContainer>
|
||||
<DesktopSidebarContainer>{children}</DesktopSidebarContainer>
|
||||
<KeybindProvider shortcuts={globalShortcuts}>
|
||||
<div className="flex h-screen flex-col items-start overflow-hidden lg:flex-row">
|
||||
<div>
|
||||
<MobileSidebarContainer>{children}</MobileSidebarContainer>
|
||||
<DesktopSidebarContainer>{children}</DesktopSidebarContainer>
|
||||
</div>
|
||||
<div className="flex h-screen w-full flex-col overflow-auto">
|
||||
<Topbar />
|
||||
<main className="flex h-full w-full flex-col items-center overflow-y-auto">
|
||||
<Gutter>
|
||||
<Outlet />
|
||||
</Gutter>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-screen w-full flex-col overflow-auto">
|
||||
<Topbar />
|
||||
<main className="flex h-full w-full flex-col items-center overflow-y-auto">
|
||||
<Gutter>
|
||||
<Outlet />
|
||||
</Gutter>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</KeybindProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<DropdownMenu.Item onClick={handleLayout}>
|
||||
<DropdownMenu.Item onClick={handleLogout}>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<ArrowRightOnRectangle className="text-ui-fg-subtle" />
|
||||
<span>Logout</span>
|
||||
@@ -241,35 +257,95 @@ const Profile = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const LoggedInUser = () => {
|
||||
const GlobalKeybindsModal = (props: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const globalShortcuts = useGlobalShortcuts()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<UserBadge />
|
||||
<DropdownMenu.Content align="center">
|
||||
<Profile />
|
||||
<DropdownMenu.Separator />
|
||||
<Link
|
||||
// TODO change link once docs are public
|
||||
to="https://medusa-docs-v2-git-docs-v2-medusajs.vercel.app/"
|
||||
target="_blank"
|
||||
>
|
||||
<DropdownMenu.Item>
|
||||
<BookOpen className="text-ui-fg-subtle mr-2" />
|
||||
Documentation
|
||||
<Dialog.Root {...props}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="bg-ui-bg-overlay fixed inset-0" />
|
||||
<Dialog.Content className="bg-ui-bg-subtle shadow-elevation-modal fixed left-[50%] top-[50%] flex h-full max-h-[612px] w-full max-w-[560px] translate-x-[-50%] translate-y-[-50%] flex-col divide-y overflow-hidden rounded-lg">
|
||||
<div className="px-6 py-4">
|
||||
<Dialog.Title asChild>
|
||||
<Heading>Keyboard Shortcuts</Heading>
|
||||
</Dialog.Title>
|
||||
</div>
|
||||
<div className="flex flex-col divide-y overflow-y-auto">
|
||||
{globalShortcuts.map((shortcut, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="text-ui-fg-subtle flex items-center justify-between px-6 py-3"
|
||||
>
|
||||
<Text size="small">{shortcut.label}</Text>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{shortcut.keys.Mac?.map((key, index) => {
|
||||
return <Kbd key={index}>{key}</Kbd>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-end border-b px-6 py-4">
|
||||
<Dialog.Close asChild>
|
||||
<Button size="small">{t("actions.close")}</Button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const LoggedInUser = () => {
|
||||
const [openMenu, setOpenMenu] = useState(false)
|
||||
const [openModal, setOpenModal] = useState(false)
|
||||
|
||||
const toggleModal = () => {
|
||||
setOpenMenu(false)
|
||||
setOpenModal(!openModal)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DropdownMenu open={openMenu} onOpenChange={setOpenMenu}>
|
||||
<UserBadge />
|
||||
<DropdownMenu.Content align="center">
|
||||
<Profile />
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item asChild>
|
||||
<Link
|
||||
// TODO change link once docs are public
|
||||
to="https://medusa-docs-v2-git-docs-v2-medusajs.vercel.app/"
|
||||
target="_blank"
|
||||
>
|
||||
<BookOpen className="text-ui-fg-subtle mr-2" />
|
||||
Documentation
|
||||
</Link>
|
||||
</DropdownMenu.Item>
|
||||
</Link>
|
||||
<Link to="https://medusajs.com/changelog/" target="_blank">
|
||||
<DropdownMenu.Item>
|
||||
<Calendar className="text-ui-fg-subtle mr-2" />
|
||||
Changelog
|
||||
<DropdownMenu.Item asChild>
|
||||
<Link to="https://medusajs.com/changelog/" target="_blank">
|
||||
<Calendar className="text-ui-fg-subtle mr-2" />
|
||||
Changelog
|
||||
</Link>
|
||||
</DropdownMenu.Item>
|
||||
</Link>
|
||||
<DropdownMenu.Separator />
|
||||
<ThemeToggle />
|
||||
<DropdownMenu.Separator />
|
||||
<Logout />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu.Item onClick={toggleModal}>
|
||||
<Keyboard className="text-ui-fg-subtle mr-2" />
|
||||
Keyboard shortcuts
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<ThemeToggle />
|
||||
<DropdownMenu.Separator />
|
||||
<Logout />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
<GlobalKeybindsModal open={openModal} onOpenChange={setOpenModal} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -306,6 +382,7 @@ const ToggleNotifications = () => {
|
||||
}
|
||||
|
||||
const Searchbar = () => {
|
||||
const { t } = useTranslation()
|
||||
const { toggleSearch } = useSearch()
|
||||
|
||||
return (
|
||||
@@ -316,7 +393,7 @@ const Searchbar = () => {
|
||||
<MagnifyingGlass />
|
||||
<div className="flex-1 text-left">
|
||||
<Text size="small" leading="compact">
|
||||
Jump to or search
|
||||
{t("app.search.placeholder")}
|
||||
</Text>
|
||||
</div>
|
||||
<Kbd className="rounded-full">⌘K</Kbd>
|
||||
|
||||
@@ -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<ShortcutType, Shortcut[]>()
|
||||
|
||||
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 (
|
||||
<CommandDialog open={open} onOpenChange={onOpenChange}>
|
||||
<CommandInput placeholder="Search" />
|
||||
<CommandInput placeholder={t("app.search.placeholder")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandEmpty>{t("general.noResultsTitle")}</CommandEmpty>
|
||||
{links.map((group) => {
|
||||
return (
|
||||
<CommandGroup key={group.title} heading={group.title}>
|
||||
<CommandGroup
|
||||
key={group.title}
|
||||
heading={t(`app.keyboardShortcuts.${group.title}`)}
|
||||
>
|
||||
{group.items.map((item) => {
|
||||
return (
|
||||
<CommandItem key={item.label}>
|
||||
<CommandItem
|
||||
key={item.label}
|
||||
onSelect={() => handleSelect(item.callback)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{item.keys.Mac?.map((key, index) => {
|
||||
return <Kbd key={index}>{key}</Kbd>
|
||||
})}
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
@@ -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<typeof Command>,
|
||||
ComponentPropsWithoutRef<typeof Command>
|
||||
@@ -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 (
|
||||
<Dialog.Root {...props}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="bg-ui-bg-overlay fixed inset-0" />
|
||||
<Dialog.Content className="bg-ui-bg-base shadow-elevation-modal fixed left-[50%] top-[50%] w-full max-w-2xl translate-x-[-50%] translate-y-[-50%] overflow-hidden rounded-xl p-0">
|
||||
<CommandPalette className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
<CommandPalette className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input]]:h-[52px]">
|
||||
{children}
|
||||
</CommandPalette>
|
||||
<div className="flex items-center justify-between border-t px-4 pb-4 pt-3">
|
||||
<div></div>
|
||||
<div className="bg-ui-bg-field text-ui-fg-subtle flex items-center justify-end border-t px-4 py-3">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Text size="xsmall" leading="compact">
|
||||
Navigation
|
||||
{t("app.search.navigation")}
|
||||
</Text>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<Kbd>↓</Kbd>
|
||||
<Kbd>↑</Kbd>
|
||||
<Kbd className="bg-ui-bg-field-component">↓</Kbd>
|
||||
<Kbd className="bg-ui-bg-field-component">↑</Kbd>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-ui-border-strong h-3 w-px" />
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Text size="xsmall" leading="compact">
|
||||
Open Result
|
||||
{t("app.search.openResult")}
|
||||
</Text>
|
||||
<Kbd>↵</Kbd>
|
||||
<Kbd className="bg-ui-bg-field-component">↵</Kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,19 +137,26 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
const CommandInput = forwardRef<
|
||||
ElementRef<typeof Command.Input>,
|
||||
ComponentPropsWithoutRef<typeof Command.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<MagnifyingGlass className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<Command.Input
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"placeholder:text-ui-fg-muted flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border-b">
|
||||
<div className="px-4 pt-4">
|
||||
{/* TODO: Add filter once we have search engine */}
|
||||
<Badge size="2xsmall">{t("app.search.allAreas")}</Badge>
|
||||
</div>
|
||||
<Command.Input
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"placeholder:text-ui-fg-muted flex h-10 w-full rounded-md bg-transparent p-4 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
CommandInput.displayName = Command.Input.displayName
|
||||
|
||||
@@ -198,7 +167,7 @@ const CommandList = forwardRef<
|
||||
<Command.List
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"max-h-[300px] overflow-y-auto overflow-x-hidden pb-4",
|
||||
"max-h-[300px] overflow-y-auto overflow-x-hidden px-2 pb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -223,7 +192,7 @@ const CommandGroup = forwardRef<
|
||||
<Command.Group
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"text-ui-fg-base [&_[cmdk-group-heading]]:text-ui-fg-muted [&_[cmdk-group-heading]]:txt-compact-xsmall-plus overflow-hidden px-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:pt-4 [&_[cmdk-item]]:py-2",
|
||||
"text-ui-fg-base [&_[cmdk-group-heading]]:text-ui-fg-muted [&_[cmdk-group-heading]]:txt-compact-xsmall-plus overflow-hidden [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:pt-4 [&_[cmdk-item]]:py-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -251,7 +220,7 @@ const CommandItem = forwardRef<
|
||||
<Command.Item
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"aria-selected:bg-ui-bg-base-hover hover:bg-ui-bg-base-hover focus-visible:bg-ui-bg-base-hover txt-compact-small relative flex cursor-default select-none items-center rounded-md p-2 outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"aria-selected:bg-ui-bg-base-hover focus-visible:bg-ui-bg-base-hover txt-compact-small [&>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<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={clx(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user