mirror of
https://github.com/langgenius/dify.git
synced 2026-05-31 19:00:22 -04:00
feat(ui): add kbd primitive (#36729)
This commit is contained in:
@@ -32,6 +32,7 @@ import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog
|
||||
import { Drawer, DrawerPopup, DrawerTrigger } from '@langgenius/dify-ui/drawer'
|
||||
import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
@@ -46,6 +47,7 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ |
|
||||
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
|
||||
| Controls | `./segmented-control` | SegmentedControl for mode, filter, and view selection. |
|
||||
| Display | `./kbd` | Keyboard input and shortcut keycap primitives. |
|
||||
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
|
||||
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./textarea`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
|
||||
@@ -69,6 +69,10 @@
|
||||
"types": "./src/input/index.tsx",
|
||||
"import": "./src/input/index.tsx"
|
||||
},
|
||||
"./kbd": {
|
||||
"types": "./src/kbd/index.tsx",
|
||||
"import": "./src/kbd/index.tsx"
|
||||
},
|
||||
"./meter": {
|
||||
"types": "./src/meter/index.tsx",
|
||||
"import": "./src/meter/index.tsx"
|
||||
@@ -171,6 +175,7 @@
|
||||
"@storybook/addon-themes": "catalog:",
|
||||
"@storybook/react-vite": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tanstack/react-hotkeys": "catalog:",
|
||||
"@tanstack/react-virtual": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
useAutocompleteFilteredItems,
|
||||
} from '.'
|
||||
import { cn } from '../cn'
|
||||
import { Kbd } from '../kbd'
|
||||
|
||||
type Suggestion = {
|
||||
value: string
|
||||
@@ -309,9 +310,9 @@ const CommandPaletteList = () => {
|
||||
<span className="block truncate system-xs-regular text-text-tertiary">{item.description}</span>
|
||||
</span>
|
||||
</span>
|
||||
<kbd className="rounded-md border border-divider-subtle bg-components-badge-bg-dimm px-1.5 py-0.5 text-text-quaternary system-2xs-medium">
|
||||
<Kbd className="text-text-quaternary">
|
||||
Enter
|
||||
</kbd>
|
||||
</Kbd>
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteCollection>
|
||||
|
||||
59
packages/dify-ui/src/kbd/__tests__/index.spec.tsx
Normal file
59
packages/dify-ui/src/kbd/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { Kbd, KbdGroup } from '../index'
|
||||
|
||||
describe('Kbd', () => {
|
||||
it('renders a native kbd element with the default gray variant', async () => {
|
||||
const screen = await render(<Kbd>⌘</Kbd>)
|
||||
const key = screen.getByText('⌘').element()
|
||||
|
||||
expect(key.tagName).toBe('KBD')
|
||||
await expect.element(screen.getByText('⌘')).toHaveClass(
|
||||
'h-4',
|
||||
'min-w-4',
|
||||
'px-px',
|
||||
'rounded-sm',
|
||||
'bg-components-kbd-bg-gray',
|
||||
'text-text-tertiary',
|
||||
'system-kbd',
|
||||
)
|
||||
})
|
||||
|
||||
it('applies the white variant for elevated or inverse surfaces', async () => {
|
||||
const screen = await render(<Kbd color="white">↵</Kbd>)
|
||||
|
||||
await expect.element(screen.getByText('↵')).toHaveClass(
|
||||
'bg-components-kbd-bg-white',
|
||||
'text-text-primary-on-surface',
|
||||
)
|
||||
})
|
||||
|
||||
it('marks disabled keycaps visually without adding widget semantics', async () => {
|
||||
const screen = await render(<Kbd disabled>⌘</Kbd>)
|
||||
|
||||
await expect.element(screen.getByText('⌘')).toHaveAttribute('data-disabled')
|
||||
await expect.element(screen.getByText('⌘')).toHaveClass('opacity-30')
|
||||
await expect.element(screen.getByText('⌘')).not.toHaveAttribute('aria-disabled')
|
||||
})
|
||||
|
||||
it('merges custom classes with the design-system recipe', async () => {
|
||||
const screen = await render(<Kbd className="custom-key h-5">K</Kbd>)
|
||||
|
||||
await expect.element(screen.getByText('K')).toHaveClass('custom-key', 'h-5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('KbdGroup', () => {
|
||||
it('groups keycaps without replacing individual kbd semantics', async () => {
|
||||
const screen = await render(
|
||||
<KbdGroup aria-label="Command Shift K">
|
||||
<Kbd>⌘</Kbd>
|
||||
<Kbd>⇧</Kbd>
|
||||
<Kbd>K</Kbd>
|
||||
</KbdGroup>,
|
||||
)
|
||||
|
||||
const group = screen.getByLabelText('Command Shift K').element()
|
||||
expect(group.tagName).toBe('SPAN')
|
||||
expect(group.querySelectorAll('kbd')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
230
packages/dify-ui/src/kbd/index.stories.tsx
Normal file
230
packages/dify-ui/src/kbd/index.stories.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { FormatDisplayOptions, RegisterableHotkey } from '@tanstack/react-hotkeys'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { Kbd, KbdGroup } from '.'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from '../context-menu'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '../tooltip'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/Kbd',
|
||||
component: Kbd,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Keyboard input primitives aligned with the Dify Key Set design. '
|
||||
+ '`Kbd` renders a native `<kbd>` element for a single key or key-like token. '
|
||||
+ '`KbdGroup` only groups multiple keycaps; it does not replace the individual `<kbd>` semantics.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
color: {
|
||||
control: 'select',
|
||||
options: ['gray', 'white'],
|
||||
},
|
||||
disabled: { control: 'boolean' },
|
||||
},
|
||||
args: {
|
||||
children: 'K',
|
||||
color: 'gray',
|
||||
},
|
||||
} satisfies Meta<typeof Kbd>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const displayKeys = (
|
||||
hotkey: RegisterableHotkey | (string & {}),
|
||||
platform: FormatDisplayOptions['platform'] = 'mac',
|
||||
) => {
|
||||
if (typeof hotkey !== 'string')
|
||||
return [formatForDisplay(hotkey, { platform })]
|
||||
|
||||
return hotkey
|
||||
.split('+')
|
||||
.filter(Boolean)
|
||||
.map(key => formatForDisplay(key, { platform }))
|
||||
}
|
||||
|
||||
const HotkeyKbdGroup = ({
|
||||
hotkey,
|
||||
color = 'gray',
|
||||
platform = 'mac',
|
||||
}: {
|
||||
hotkey: RegisterableHotkey | (string & {})
|
||||
color?: 'gray' | 'white'
|
||||
platform?: FormatDisplayOptions['platform']
|
||||
}) => (
|
||||
<KbdGroup>
|
||||
{displayKeys(hotkey, platform).map((key, index) => (
|
||||
<Kbd key={`${key}-${index}`} color={color}>
|
||||
{key}
|
||||
</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
)
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => <HotkeyKbdGroup hotkey="Mod+K" />,
|
||||
}
|
||||
|
||||
export const KeySet: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Figma Key Set variants: gray and white, each with a disabled state. Disabled is visual only because `<kbd>` is not an interactive widget.',
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<div className="grid grid-cols-[auto_auto_auto] items-center gap-x-4 gap-y-3 rounded-xl bg-components-panel-bg p-5">
|
||||
<span className="system-xs-medium text-text-tertiary">Gray</span>
|
||||
<KbdGroup>
|
||||
<Kbd>⌘</Kbd>
|
||||
<Kbd>⇧</Kbd>
|
||||
</KbdGroup>
|
||||
<KbdGroup>
|
||||
<Kbd disabled>⌘</Kbd>
|
||||
<Kbd disabled>⇧</Kbd>
|
||||
</KbdGroup>
|
||||
|
||||
<span className="system-xs-medium text-text-tertiary">White</span>
|
||||
<div className="rounded-lg bg-gray-900 p-2">
|
||||
<KbdGroup>
|
||||
<Kbd color="white">⌘</Kbd>
|
||||
<Kbd color="white">⇧</Kbd>
|
||||
</KbdGroup>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-900 p-2">
|
||||
<KbdGroup>
|
||||
<Kbd color="white" disabled>⌘</Kbd>
|
||||
<Kbd color="white" disabled>⇧</Kbd>
|
||||
</KbdGroup>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const FormattedShortcuts: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: '`Kbd` does not parse hotkeys. Compose it with a formatter at the feature layer; this story uses TanStack Hotkeys `formatForDisplay` for platform-aware labels.',
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<div className="grid grid-cols-[auto_auto_auto] items-center gap-x-5 gap-y-3 rounded-xl bg-components-panel-bg p-5">
|
||||
<span className="system-xs-medium text-text-tertiary">Action</span>
|
||||
<span className="system-xs-medium text-text-tertiary">macOS</span>
|
||||
<span className="system-xs-medium text-text-tertiary">Windows</span>
|
||||
|
||||
<span className="system-sm-regular text-text-secondary">Search</span>
|
||||
<HotkeyKbdGroup hotkey="Mod+K" platform="mac" />
|
||||
<HotkeyKbdGroup hotkey="Mod+K" platform="windows" />
|
||||
|
||||
<span className="system-sm-regular text-text-secondary">Save</span>
|
||||
<HotkeyKbdGroup hotkey="Mod+S" platform="mac" />
|
||||
<HotkeyKbdGroup hotkey="Mod+S" platform="windows" />
|
||||
|
||||
<span className="system-sm-regular text-text-secondary">Redo</span>
|
||||
<HotkeyKbdGroup hotkey="Mod+Shift+Z" platform="mac" />
|
||||
<HotkeyKbdGroup hotkey="Mod+Shift+Z" platform="windows" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const InTooltip: Story = {
|
||||
decorators: [
|
||||
Story => (
|
||||
<TooltipProvider delay={0}>
|
||||
<Story />
|
||||
</TooltipProvider>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Shortcut keycaps can be composed inside short tooltip content. The trigger keeps its own accessible name; the tooltip is only a visual hint.',
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<Tooltip open>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Collapse sidebar"
|
||||
className="inline-flex size-8 items-center justify-center rounded-lg border border-divider-subtle bg-components-button-secondary-bg text-text-secondary shadow-xs"
|
||||
>
|
||||
<span aria-hidden className="i-ri-sidebar-fold-line size-4" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent className="flex items-center gap-1">
|
||||
<span>Collapse sidebar</span>
|
||||
<HotkeyKbdGroup hotkey="Mod+B" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
}
|
||||
|
||||
const MENU_ITEMS = [
|
||||
{ label: 'Copy', icon: 'i-ri-file-copy-line', hotkey: 'Mod+C' },
|
||||
{ label: 'Duplicate', icon: 'i-ri-stack-line', hotkey: 'Mod+D' },
|
||||
{ label: 'Paste', icon: 'i-ri-clipboard-line', hotkey: 'Mod+V' },
|
||||
] as const
|
||||
|
||||
export const InContextMenu: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A compact context-menu composition based on the Dify Design Kit context menu example. The menu is intentionally small here because the story focuses on shortcut keycaps.',
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-28 w-60 items-center justify-center rounded-xl border border-divider-subtle bg-background-default-subtle px-6 text-center system-sm-regular text-text-tertiary"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
Context menu trigger
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent popupClassName="w-60">
|
||||
{MENU_ITEMS.map(({ label, icon, hotkey }) => (
|
||||
<ContextMenuItem key={label} className="justify-between gap-4">
|
||||
<span aria-hidden className={`${icon} size-4 shrink-0 text-text-tertiary`} />
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
<HotkeyKbdGroup hotkey={hotkey} />
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem variant="destructive" className="justify-between gap-4">
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">Delete</span>
|
||||
<HotkeyKbdGroup hotkey="Delete" />
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
),
|
||||
}
|
||||
61
packages/dify-ui/src/kbd/index.tsx
Normal file
61
packages/dify-ui/src/kbd/index.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { cn } from '../cn'
|
||||
|
||||
const kbdVariants = cva(
|
||||
'pointer-events-none inline-flex h-4 min-w-4 select-none items-center justify-center rounded-sm px-px font-sans system-kbd capitalize not-italic',
|
||||
{
|
||||
variants: {
|
||||
color: {
|
||||
gray: 'bg-components-kbd-bg-gray text-text-tertiary',
|
||||
white: 'bg-components-kbd-bg-white text-text-primary-on-surface',
|
||||
},
|
||||
disabled: {
|
||||
true: 'opacity-30',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
color: 'gray',
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type KbdColor = NonNullable<VariantProps<typeof kbdVariants>['color']>
|
||||
|
||||
export type KbdProps
|
||||
= Omit<ComponentProps<'kbd'>, 'color'>
|
||||
& VariantProps<typeof kbdVariants>
|
||||
|
||||
export function Kbd({
|
||||
className,
|
||||
color,
|
||||
disabled,
|
||||
...props
|
||||
}: KbdProps) {
|
||||
return (
|
||||
<kbd
|
||||
data-disabled={disabled ? '' : undefined}
|
||||
className={cn(kbdVariants({ color, disabled, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type KbdGroupProps = ComponentProps<'span'>
|
||||
|
||||
export function KbdGroup({
|
||||
className,
|
||||
...props
|
||||
}: KbdGroupProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex items-center gap-0.5 align-middle', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from '.'
|
||||
import { Kbd, KbdGroup } from '../kbd'
|
||||
|
||||
const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover'
|
||||
|
||||
@@ -46,11 +47,10 @@ export const Default: Story = {
|
||||
<PopoverDescription className="text-xs text-text-secondary">
|
||||
Press
|
||||
{' '}
|
||||
<kbd className="rounded bg-background-default-subtle px-1 py-0.5 font-mono text-[11px]">⌘</kbd>
|
||||
{' '}
|
||||
+
|
||||
{' '}
|
||||
<kbd className="rounded bg-background-default-subtle px-1 py-0.5 font-mono text-[11px]">K</kbd>
|
||||
<KbdGroup>
|
||||
<Kbd>⌘</Kbd>
|
||||
<Kbd>K</Kbd>
|
||||
</KbdGroup>
|
||||
{' '}
|
||||
to open the command palette anywhere in the app.
|
||||
</PopoverDescription>
|
||||
|
||||
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@@ -477,9 +477,6 @@ catalogs:
|
||||
react-easy-crop:
|
||||
specifier: 5.5.7
|
||||
version: 5.5.7
|
||||
react-hotkeys-hook:
|
||||
specifier: 5.3.2
|
||||
version: 5.3.2
|
||||
react-i18next:
|
||||
specifier: 16.6.6
|
||||
version: 16.6.6
|
||||
@@ -855,6 +852,9 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: 'catalog:'
|
||||
version: 4.3.0(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))
|
||||
'@tanstack/react-hotkeys':
|
||||
specifier: 'catalog:'
|
||||
version: 0.10.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: 'catalog:'
|
||||
version: 3.13.25(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -1212,9 +1212,6 @@ importers:
|
||||
react-easy-crop:
|
||||
specifier: 'catalog:'
|
||||
version: 5.5.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-hotkeys-hook:
|
||||
specifier: 'catalog:'
|
||||
version: 5.3.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-i18next:
|
||||
specifier: 'catalog:'
|
||||
version: 16.6.6(i18next@26.2.0(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)
|
||||
@@ -8096,12 +8093,6 @@ packages:
|
||||
react-fast-compare@3.2.2:
|
||||
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
|
||||
|
||||
react-hotkeys-hook@5.3.2:
|
||||
resolution: {integrity: sha512-DDDy9xK6mbTQ6aPlQvIl0dA/a90T/AWml4Rm21JXFDLlRHalIg4/Rv3equUQYs5xPTWq+oEl6RD7mi/nBpU3Uw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
react-i18next@16.6.6:
|
||||
resolution: {integrity: sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==}
|
||||
peerDependencies:
|
||||
@@ -16253,11 +16244,6 @@ snapshots:
|
||||
|
||||
react-fast-compare@3.2.2: {}
|
||||
|
||||
react-hotkeys-hook@5.3.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
|
||||
react-i18next@16.6.6(i18next@26.2.0(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
@@ -17760,7 +17746,6 @@ time:
|
||||
qs@6.15.2: '2026-05-16T23:19:19.539Z'
|
||||
react-dom@19.2.6: '2026-05-06T16:16:56.080Z'
|
||||
react-easy-crop@5.5.7: '2026-03-24T09:41:01.114Z'
|
||||
react-hotkeys-hook@5.3.2: '2026-05-05T13:01:00.987Z'
|
||||
react-i18next@16.6.6: '2026-03-24T15:35:20.832Z'
|
||||
react-multi-email@1.0.25: '2024-07-18T04:31:06.176Z'
|
||||
react-papaparse@4.4.0: '2023-10-13T10:27:07.978Z'
|
||||
|
||||
@@ -202,7 +202,6 @@ catalog:
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6
|
||||
react-easy-crop: 5.5.7
|
||||
react-hotkeys-hook: 5.3.2
|
||||
react-i18next: 16.6.6
|
||||
react-multi-email: 1.0.25
|
||||
react-papaparse: 4.4.0
|
||||
|
||||
@@ -10,7 +10,7 @@ let mockAppSidebarExpand = 'expand'
|
||||
let mockPathname = '/app/app-1/logs'
|
||||
let mockSelectedSegment = 'logs'
|
||||
let mockIsHovering = true
|
||||
let keyPressHandler: ((event: { preventDefault: () => void }) => void) | null = null
|
||||
let hotkeyHandler: ((event: { preventDefault: () => void }) => void) | null = null
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
@@ -63,11 +63,18 @@ vi.mock('@/next/link', () => ({
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useHover: () => mockIsHovering,
|
||||
useKeyPress: (_key: string, handler: (event: { preventDefault: () => void }) => void) => {
|
||||
keyPressHandler = handler
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
|
||||
return {
|
||||
...actual,
|
||||
useHotkey: (_hotkey: string, handler: (event: { preventDefault: () => void }) => void) => {
|
||||
hotkeyHandler = handler
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: () => 'desktop',
|
||||
MediaType: {
|
||||
@@ -90,11 +97,6 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', () => ({
|
||||
getKeyboardKeyCodeBySystem: () => 'ctrl',
|
||||
getKeyboardKeyNameBySystem: (key: string) => key,
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/dropdown-menu', () => import('@/__mocks__/base-ui-dropdown-menu'))
|
||||
vi.mock('@langgenius/dify-ui/tooltip', () => import('@/__mocks__/base-ui-tooltip'))
|
||||
|
||||
@@ -131,7 +133,7 @@ describe('App Sidebar Shell Flow', () => {
|
||||
mockPathname = '/app/app-1/logs'
|
||||
mockSelectedSegment = 'logs'
|
||||
mockIsHovering = true
|
||||
keyPressHandler = null
|
||||
hotkeyHandler = null
|
||||
})
|
||||
|
||||
it('renders the expanded sidebar, marks the active nav item, and toggles collapse by click and shortcut', () => {
|
||||
@@ -146,7 +148,7 @@ describe('App Sidebar Shell Flow', () => {
|
||||
expect(mockSetAppSidebarExpand).toHaveBeenCalledWith('collapse')
|
||||
|
||||
const preventDefault = vi.fn()
|
||||
keyPressHandler?.({ preventDefault })
|
||||
hotkeyHandler?.({ preventDefault })
|
||||
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
expect(mockSetAppSidebarExpand).toHaveBeenCalledWith('collapse')
|
||||
|
||||
@@ -43,10 +43,6 @@ vi.mock('react-i18next', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useKeyPress: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
|
||||
appDetail: mockAppDetail,
|
||||
@@ -124,11 +120,6 @@ vi.mock('@/app/components/app/app-access-control', () => ({
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', () => ({
|
||||
getKeyboardKeyCodeBySystem: () => 'ctrl',
|
||||
getKeyboardKeyNameBySystem: (key: string) => key,
|
||||
}))
|
||||
|
||||
describe('App Publisher Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -28,7 +28,10 @@ let mockKeyPressCallback: ((e: { preventDefault: () => void }) => void) | null =
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useHover: () => mockIsHovering,
|
||||
useKeyPress: (_key: string, cb: (e: { preventDefault: () => void }) => void) => {
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-hotkeys', () => ({
|
||||
useHotkey: (_hotkey: string, cb: (e: { preventDefault: () => void }) => void) => {
|
||||
mockKeyPressCallback = cb
|
||||
},
|
||||
}))
|
||||
@@ -52,10 +55,6 @@ vi.mock('../../base/divider', () => ({
|
||||
default: ({ className }: { className?: string }) => <hr data-testid="divider" className={className} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', () => ({
|
||||
getKeyboardKeyCodeBySystem: () => 'ctrl',
|
||||
}))
|
||||
|
||||
vi.mock('../app-info', () => ({
|
||||
default: ({ expand }: { expand: boolean }) => (
|
||||
<div data-testid="app-info" data-expand={expand} />
|
||||
@@ -110,6 +109,7 @@ describe('AppDetailNav', () => {
|
||||
mockAppSidebarExpand = 'expand'
|
||||
mockPathname = '/app/123/overview'
|
||||
mockIsHovering = true
|
||||
mockKeyPressCallback = null
|
||||
})
|
||||
|
||||
describe('Normal sidebar mode', () => {
|
||||
@@ -279,7 +279,7 @@ describe('AppDetailNav', () => {
|
||||
})
|
||||
|
||||
describe('Keyboard shortcut', () => {
|
||||
it('should toggle sidebar on ctrl+b', () => {
|
||||
it('should toggle sidebar on Mod+B', () => {
|
||||
render(<AppDetailNav navigation={navigation} />)
|
||||
|
||||
const cb = mockKeyPressCallback
|
||||
|
||||
@@ -3,12 +3,6 @@ import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import ToggleButton from '../toggle-button'
|
||||
|
||||
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
|
||||
default: ({ keys }: { keys: string[] }) => (
|
||||
<span data-testid="shortcuts">{keys.join('+')}</span>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ToggleButton', () => {
|
||||
it('should render collapse arrow when expanded', () => {
|
||||
render(<ToggleButton expand handleToggle={vi.fn()} />)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { AppInfoActions } from './app-info/use-app-info-actions'
|
||||
import type { NavIcon } from './nav-link'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useHover, useKeyPress } from 'ahooks'
|
||||
import { useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useHover } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
@@ -10,7 +11,6 @@ import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import Divider from '../base/divider'
|
||||
import { getKeyboardKeyCodeBySystem } from '../workflow/utils'
|
||||
import AppInfo, { AppInfoView } from './app-info'
|
||||
import AppSidebarDropdown from './app-sidebar-dropdown'
|
||||
import DatasetInfo from './dataset-info'
|
||||
@@ -18,15 +18,6 @@ import DatasetSidebarDropdown from './dataset-sidebar-dropdown'
|
||||
import NavLink from './nav-link'
|
||||
import ToggleButton from './toggle-button'
|
||||
|
||||
const isShortcutFromInputArea = (target: EventTarget | null) => {
|
||||
if (!(target instanceof HTMLElement))
|
||||
return false
|
||||
|
||||
return target.tagName === 'INPUT'
|
||||
|| target.tagName === 'TEXTAREA'
|
||||
|| target.isContentEditable
|
||||
}
|
||||
|
||||
type IAppDetailNavProps = {
|
||||
iconType?: 'app' | 'dataset'
|
||||
navigation: Array<{
|
||||
@@ -81,13 +72,12 @@ const AppDetailNav = ({
|
||||
}
|
||||
}, [appSidebarExpand, setAppSidebarExpand])
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => {
|
||||
if (isShortcutFromInputArea(e.target))
|
||||
return
|
||||
|
||||
useHotkey('Mod+B', (e) => {
|
||||
e.preventDefault()
|
||||
handleToggle()
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
}, {
|
||||
ignoreInputs: true,
|
||||
})
|
||||
|
||||
if (inWorkflowCanvas && hideHeader) {
|
||||
return (
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ShortcutsName from '../workflow/shortcuts-name'
|
||||
|
||||
type ToggleTooltipContentProps = {
|
||||
expand: boolean
|
||||
}
|
||||
|
||||
const TOGGLE_SHORTCUT = ['ctrl', 'B']
|
||||
const TOGGLE_SHORTCUT = ['Mod', 'B']
|
||||
|
||||
const ToggleTooltipContent = ({
|
||||
expand,
|
||||
@@ -20,7 +21,11 @@ const ToggleTooltipContent = ({
|
||||
return (
|
||||
<div className="flex items-center gap-x-1">
|
||||
<span className="px-0.5 system-xs-medium text-text-secondary">{expand ? t('sidebar.collapseSidebar', { ns: 'layout' }) : t('sidebar.expandSidebar', { ns: 'layout' })}</span>
|
||||
<ShortcutsName keys={TOGGLE_SHORTCUT} textColor="secondary" />
|
||||
<KbdGroup>
|
||||
{TOGGLE_SHORTCUT.map(key => (
|
||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ const sectionProps = vi.hoisted(() => ({
|
||||
access: null as null | Record<string, any>,
|
||||
actions: null as null | Record<string, any>,
|
||||
}))
|
||||
const ahooksMocks = vi.hoisted(() => ({
|
||||
keyPressHandlers: [] as Array<(event: { preventDefault: () => void }) => void>,
|
||||
const hotkeyMocks = vi.hoisted(() => ({
|
||||
handlers: [] as Array<(event: { preventDefault: () => void }) => void>,
|
||||
}))
|
||||
|
||||
let mockAppDetail: Record<string, any> | null = null
|
||||
@@ -41,13 +41,11 @@ vi.mock('react-i18next', () => ({
|
||||
Trans: ({ i18nKey }: { i18nKey?: string }) => i18nKey ?? null,
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', async () => {
|
||||
return {
|
||||
useKeyPress: (_keys: unknown, handler: (event: { preventDefault: () => void }) => void) => {
|
||||
ahooksMocks.keyPressHandlers.push(handler)
|
||||
},
|
||||
}
|
||||
})
|
||||
vi.mock('@tanstack/react-hotkeys', () => ({
|
||||
useHotkey: (_hotkey: string, handler: (event: { preventDefault: () => void }) => void) => {
|
||||
hotkeyMocks.handlers.push(handler)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { appDetail: Record<string, any> | null, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({
|
||||
@@ -184,7 +182,7 @@ vi.mock('../sections', () => ({
|
||||
describe('AppPublisher', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
ahooksMocks.keyPressHandlers.length = 0
|
||||
hotkeyMocks.handlers.length = 0
|
||||
sectionProps.summary = null
|
||||
sectionProps.access = null
|
||||
sectionProps.actions = null
|
||||
@@ -443,7 +441,7 @@ describe('AppPublisher', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
ahooksMocks.keyPressHandlers[0]!({ preventDefault })
|
||||
hotkeyMocks.handlers[0]!({ preventDefault })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
@@ -472,7 +470,7 @@ describe('AppPublisher', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
ahooksMocks.keyPressHandlers[0]!({ preventDefault })
|
||||
hotkeyMocks.handlers[0]!({ preventDefault })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('app-publisher sections', () => {
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={Date.now()}
|
||||
publishShortcut={['ctrl', '⇧', 'P']}
|
||||
publishShortcut={['Mod', 'Shift', 'P']}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
@@ -110,7 +110,7 @@ describe('app-publisher sections', () => {
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
publishShortcut={['ctrl', '⇧', 'P']}
|
||||
publishShortcut={['Mod', 'Shift', 'P']}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
@@ -134,7 +134,7 @@ describe('app-publisher sections', () => {
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
publishShortcut={['ctrl', '⇧', 'P']}
|
||||
publishShortcut={['Mod', 'Shift', 'P']}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
@@ -158,7 +158,7 @@ describe('app-publisher sections', () => {
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
publishShortcut={['ctrl', '⇧', 'P']}
|
||||
publishShortcut={['Mod', 'Shift', 'P']}
|
||||
startNodeLimitExceeded
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
|
||||
@@ -7,8 +7,8 @@ import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import {
|
||||
|
||||
memo,
|
||||
@@ -46,7 +46,6 @@ import { useInvalidateAppWorkflow } from '@/service/use-workflow'
|
||||
import { fetchPublishedWorkflow } from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
|
||||
import AccessControl from '../app-access-control'
|
||||
import {
|
||||
PublisherAccessSection,
|
||||
@@ -84,7 +83,7 @@ export type AppPublisherProps = {
|
||||
hasHumanInputNode?: boolean
|
||||
}
|
||||
|
||||
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
||||
const PUBLISH_SHORTCUT = ['Mod', 'Shift', 'P']
|
||||
|
||||
type AppPublisherPublishHandler
|
||||
= | ((params?: ModelAndParameter | PublishWorkflowParams) => Promise<unknown> | unknown)
|
||||
@@ -302,12 +301,12 @@ const AppPublisher = ({
|
||||
}
|
||||
}, [appDetail?.id, publishingToMarketplace, t])
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
|
||||
useHotkey('Mod+Shift+P', (e) => {
|
||||
e.preventDefault()
|
||||
if (publishDisabled || published)
|
||||
return
|
||||
handlePublish()
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const appId = appDetail?.id
|
||||
|
||||
@@ -3,19 +3,20 @@ import type { ModelAndParameter } from '../configuration/debug/types'
|
||||
import type { AppPublisherProps } from './index'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@langgenius/dify-ui/tooltip'
|
||||
import { RiSettings2Line } from '@remixicon/react'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import ShortcutsName from '../../workflow/shortcuts-name'
|
||||
import PublishWithMultipleModel from './publish-with-multiple-model'
|
||||
import SuggestedAction from './suggested-action'
|
||||
import { ACCESS_MODE_MAP } from './utils'
|
||||
@@ -163,7 +164,11 @@ export const PublisherSummarySection = ({
|
||||
: (
|
||||
<div className="flex gap-1">
|
||||
<span>{t('common.publishUpdate', { ns: 'workflow' })}</span>
|
||||
<ShortcutsName keys={publishShortcut} bgColor="white" />
|
||||
<KbdGroup>
|
||||
{publishShortcut.map(key => (
|
||||
<Kbd key={key} color="white">{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { FC } from 'react'
|
||||
import type { GeneratorType } from './types'
|
||||
import type { Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Kbd } from '@langgenius/dify-ui/kbd'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
@@ -111,7 +112,7 @@ const InstructionEditor: FC<Props> = ({
|
||||
/>
|
||||
<div className="absolute bottom-0 left-4 flex h-8 items-center space-x-0.5 system-xs-regular text-components-input-text-placeholder">
|
||||
<span>{t('generate.press', { ns: 'appDebug' })}</span>
|
||||
<span className="flex h-4 w-3.5 items-center justify-center rounded-sm bg-components-kbd-bg-gray system-kbd text-text-placeholder">/</span>
|
||||
<Kbd className="text-text-placeholder">/</Kbd>
|
||||
<span>{t('generate.to', { ns: 'appDebug' })}</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -13,8 +13,8 @@ import { getRedirection } from '@/utils/app-redirection'
|
||||
import { trackCreateApp } from '@/utils/create-app-tracking'
|
||||
import CreateAppModal from '../index'
|
||||
|
||||
const ahooksMocks = vi.hoisted(() => ({
|
||||
keyPressHandlers: [] as Array<() => void>,
|
||||
const hotkeyMocks = vi.hoisted(() => ({
|
||||
handlers: new Map<string, () => void>(),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
@@ -24,11 +24,18 @@ vi.mock('ahooks', () => ({
|
||||
const flush = vi.fn()
|
||||
return { run, cancel, flush }
|
||||
},
|
||||
useKeyPress: (_keys: unknown, handler: () => void) => {
|
||||
ahooksMocks.keyPressHandlers.push(handler)
|
||||
},
|
||||
useHover: () => false,
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
|
||||
return {
|
||||
...actual,
|
||||
useHotkey: (hotkey: string, handler: () => void) => {
|
||||
hotkeyMocks.handlers.set(hotkey, handler)
|
||||
},
|
||||
}
|
||||
})
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: vi.fn(),
|
||||
useParams: () => ({}),
|
||||
@@ -113,7 +120,7 @@ describe('CreateAppModal', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
ahooksMocks.keyPressHandlers.length = 0
|
||||
hotkeyMocks.handlers.clear()
|
||||
mockUseRouter.mockReturnValue({ push: mockPush } as unknown as ReturnType<typeof useRouter>)
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
plan: {
|
||||
@@ -205,6 +212,13 @@ describe('CreateAppModal', () => {
|
||||
expect(onCreateFromTemplate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the create shortcut with kbd primitives', () => {
|
||||
renderModal()
|
||||
|
||||
const createButton = screen.getByRole('button', { name: /app\.newApp\.Create/ })
|
||||
expect(createButton.querySelectorAll('kbd')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('creates a beginner chat app with the keyboard shortcut and selected icon style', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCreateApp.mockResolvedValue({ id: 'chat-app', mode: AppModeEnum.CHAT } as App)
|
||||
@@ -228,7 +242,7 @@ describe('CreateAppModal', () => {
|
||||
target: { value: 'Created from shortcut' },
|
||||
})
|
||||
|
||||
ahooksMocks.keyPressHandlers.at(-1)?.()
|
||||
hotkeyMocks.handlers.get('Mod+Enter')?.()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateApp).toHaveBeenCalledWith({
|
||||
@@ -245,7 +259,7 @@ describe('CreateAppModal', () => {
|
||||
it('shows validation feedback when the keyboard shortcut runs without a name', () => {
|
||||
renderModal()
|
||||
|
||||
ahooksMocks.keyPressHandlers.at(-1)?.()
|
||||
hotkeyMocks.handlers.get('Mod+Enter')?.()
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith('app.newApp.nameNotEmpty')
|
||||
expect(mockCreateApp).not.toHaveBeenCalled()
|
||||
@@ -276,7 +290,7 @@ describe('CreateAppModal', () => {
|
||||
|
||||
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
|
||||
|
||||
ahooksMocks.keyPressHandlers.at(-1)?.()
|
||||
hotkeyMocks.handlers.get('Mod+Enter')?.()
|
||||
|
||||
expect(mockCreateApp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -4,10 +4,12 @@ import type { AppIconSelection } from '../../base/app-icon-picker'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react'
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
@@ -26,7 +28,6 @@ import { getRedirection } from '@/utils/app-redirection'
|
||||
import { trackCreateApp } from '@/utils/create-app-tracking'
|
||||
import { basePath } from '@/utils/var'
|
||||
import AppIconPicker from '../../base/app-icon-picker'
|
||||
import ShortcutsName from '../../workflow/shortcuts-name'
|
||||
import { CreateAppDialogShell } from '../create-app-dialog-shell'
|
||||
|
||||
type CreateAppProps = {
|
||||
@@ -94,10 +95,12 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
||||
}, [name, t, appMode, appIcon, description, onSuccess, onClose, push, isCurrentWorkspaceEditor])
|
||||
|
||||
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
|
||||
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
|
||||
useHotkey('Mod+Enter', () => {
|
||||
if (isAppsFull)
|
||||
return
|
||||
handleCreateApp()
|
||||
}, {
|
||||
ignoreInputs: false,
|
||||
})
|
||||
return (
|
||||
<>
|
||||
@@ -265,7 +268,11 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
||||
<Button onClick={onClose}>{t('newApp.Cancel', { ns: 'app' })}</Button>
|
||||
<Button disabled={isAppsFull || !name} className="gap-1" variant="primary" onClick={handleCreateApp}>
|
||||
<span>{t('newApp.Create', { ns: 'app' })}</span>
|
||||
<ShortcutsName keys={['ctrl', '↵']} bgColor="white" />
|
||||
<KbdGroup>
|
||||
{['Mod', 'Enter'].map(key => (
|
||||
<Kbd key={key} color="white">{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,8 +17,8 @@ const toastMocks = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
}))
|
||||
const ahooksMocks = vi.hoisted(() => ({
|
||||
handlers: [] as Array<{ keys: unknown, handler: () => void }>,
|
||||
const hotkeyMocks = vi.hoisted(() => ({
|
||||
handlers: new Map<string, { handler: () => void, options?: { enabled?: boolean } }>(),
|
||||
}))
|
||||
let mockPlanUsage = 0
|
||||
let mockPlanTotal = 10
|
||||
@@ -33,11 +33,25 @@ vi.mock('ahooks', () => ({
|
||||
useDebounceFn: (fn: (...args: any[]) => any) => ({
|
||||
run: fn,
|
||||
}),
|
||||
useKeyPress: (keys: unknown, handler: () => void) => {
|
||||
ahooksMocks.handlers.push({ keys, handler })
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
|
||||
return {
|
||||
...actual,
|
||||
useHotkey: (hotkey: string, handler: () => void, options?: { enabled?: boolean }) => {
|
||||
hotkeyMocks.handlers.set(hotkey, { handler, options })
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const triggerHotkey = (hotkey: string) => {
|
||||
const registration = hotkeyMocks.handlers.get(hotkey)
|
||||
if (registration?.options?.enabled === false)
|
||||
return
|
||||
registration?.handler()
|
||||
}
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
@@ -98,14 +112,10 @@ vi.mock('@/app/components/billing/apps-full-in-dialog', () => ({
|
||||
default: () => <div>apps-full</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../../workflow/shortcuts-name', () => ({
|
||||
default: ({ keys }: { keys: string[] }) => <span>{keys.join('+')}</span>,
|
||||
}))
|
||||
|
||||
describe('CreateFromDSLModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
ahooksMocks.handlers.length = 0
|
||||
hotkeyMocks.handlers.clear()
|
||||
mockPlanUsage = 0
|
||||
mockPlanTotal = 10
|
||||
localStorage.clear()
|
||||
@@ -153,7 +163,7 @@ describe('CreateFromDSLModal', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
ahooksMocks.handlers.find(item => Array.isArray(item.keys))?.handler()
|
||||
triggerHotkey('Mod+Enter')
|
||||
expect(mockImportDSL).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
@@ -166,6 +176,20 @@ describe('CreateFromDSLModal', () => {
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render the import shortcut with kbd primitives', () => {
|
||||
render(
|
||||
<CreateFromDSLModal
|
||||
show
|
||||
onClose={vi.fn()}
|
||||
activeTab={CreateFromDSLModalTab.FROM_URL}
|
||||
dslUrl="https://example.com/app.yml"
|
||||
/>,
|
||||
)
|
||||
|
||||
const createButton = getCreateButton()
|
||||
expect(createButton.querySelectorAll('kbd')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should import from a URL and redirect after a successful import', async () => {
|
||||
const handleClose = vi.fn()
|
||||
const handleSuccess = vi.fn()
|
||||
@@ -258,8 +282,7 @@ describe('CreateFromDSLModal', () => {
|
||||
expect(getCreateButton())!.toBeDisabled()
|
||||
})
|
||||
|
||||
const latestHandlerAfterRemove = [...ahooksMocks.handlers].reverse().find(item => Array.isArray(item.keys))
|
||||
latestHandlerAfterRemove?.handler()
|
||||
triggerHotkey('Mod+Enter')
|
||||
expect(mockImportDSL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -418,7 +441,7 @@ describe('CreateFromDSLModal', () => {
|
||||
expect(toastMocks.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle keyboard shortcuts, quota guard, and escape close', async () => {
|
||||
it('should handle keyboard shortcut and quota guard', async () => {
|
||||
const handleClose = vi.fn()
|
||||
mockImportDSL.mockResolvedValue({
|
||||
id: 'import-shortcut',
|
||||
@@ -436,7 +459,7 @@ describe('CreateFromDSLModal', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
ahooksMocks.handlers.find(item => Array.isArray(item.keys))?.handler()
|
||||
triggerHotkey('Mod+Enter')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockImportDSL).toHaveBeenCalledWith({
|
||||
@@ -445,9 +468,6 @@ describe('CreateFromDSLModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
ahooksMocks.handlers.find(item => item.keys === 'esc')?.handler()
|
||||
expect(handleClose).toHaveBeenCalled()
|
||||
|
||||
mockPlanUsage = 1
|
||||
mockPlanTotal = 1
|
||||
render(
|
||||
@@ -460,8 +480,7 @@ describe('CreateFromDSLModal', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByText('apps-full'))!.toBeInTheDocument()
|
||||
const latestPlanLimitHandler = [...ahooksMocks.handlers].reverse().find(item => Array.isArray(item.keys))
|
||||
latestPlanLimitHandler?.handler()
|
||||
triggerHotkey('Mod+Enter')
|
||||
expect(mockImportDSL).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import type { MouseEventHandler } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
@@ -25,7 +27,6 @@ import {
|
||||
} from '@/service/apps'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
import { trackCreateApp } from '@/utils/create-app-tracking'
|
||||
import ShortcutsName from '../../workflow/shortcuts-name'
|
||||
import Uploader from './uploader'
|
||||
|
||||
type CreateFromDSLModalProps = {
|
||||
@@ -150,14 +151,11 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
|
||||
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
|
||||
|
||||
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
|
||||
if (show && !isAppsFull && ((currentTab === CreateFromDSLModalTab.FROM_FILE && currentFile) || (currentTab === CreateFromDSLModalTab.FROM_URL && dslUrlValue)))
|
||||
handleCreateApp(undefined)
|
||||
})
|
||||
|
||||
useKeyPress('esc', () => {
|
||||
if (show && !showErrorModal)
|
||||
onClose()
|
||||
useHotkey('Mod+Enter', () => {
|
||||
handleCreateApp(undefined)
|
||||
}, {
|
||||
enabled: show && !isAppsFull && ((currentTab === CreateFromDSLModalTab.FROM_FILE && !!currentFile) || (currentTab === CreateFromDSLModalTab.FROM_URL && !!dslUrlValue)),
|
||||
ignoreInputs: false,
|
||||
})
|
||||
|
||||
const onDSLConfirm: MouseEventHandler = async () => {
|
||||
@@ -215,7 +213,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={show}>
|
||||
<Dialog open={show} onOpenChange={open => !open && !showErrorModal && onClose()}>
|
||||
<DialogContent className="w-full max-w-[480px]! overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-xl">
|
||||
|
||||
<div className="flex items-center justify-between pt-6 pr-5 pb-3 pl-6 title-2xl-semi-bold text-text-primary">
|
||||
@@ -285,7 +283,11 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
className="gap-1"
|
||||
>
|
||||
<span>{t('newApp.Create', { ns: 'app' })}</span>
|
||||
<ShortcutsName keys={['ctrl', '↵']} bgColor="white" />
|
||||
<KbdGroup>
|
||||
{['Mod', 'Enter'].map(key => (
|
||||
<Kbd key={key} color="white">{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { FC } from 'react'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiCloseLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
|
||||
import { useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
@@ -42,8 +42,8 @@ const PdfPreview: FC<PdfPreviewProps> = ({
|
||||
})
|
||||
}
|
||||
|
||||
useHotkeys('up', zoomIn)
|
||||
useHotkeys('down', zoomOut)
|
||||
useHotkey('ArrowUp', zoomIn)
|
||||
useHotkey('ArrowDown', zoomOut)
|
||||
|
||||
const zoomOutLabel = t('operation.zoomOut', { ns: 'common' })
|
||||
const zoomInLabel = t('operation.zoomIn', { ns: 'common' })
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Kbd } from '@langgenius/dify-ui/kbd'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $insertNodes, FOCUS_COMMAND } from 'lexical'
|
||||
import { useCallback } from 'react'
|
||||
@@ -25,7 +26,7 @@ const Placeholder = () => {
|
||||
>
|
||||
<div className="flex grow items-center">
|
||||
Type or press
|
||||
<div className="mx-0.5 flex size-4 items-center justify-center rounded-sm bg-components-kbd-bg-gray system-kbd text-text-placeholder">/</div>
|
||||
<Kbd className="mx-0.5 text-text-placeholder">/</Kbd>
|
||||
<div
|
||||
className="cursor-pointer system-sm-regular text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary"
|
||||
onClick={((e) => {
|
||||
|
||||
@@ -2,10 +2,10 @@ import type { FC } from 'react'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
|
||||
@@ -168,10 +168,10 @@ const ImagePreview: FC<ImagePreviewProps> = ({
|
||||
}
|
||||
}, [handleMouseUp])
|
||||
|
||||
useHotkeys('up', zoomIn)
|
||||
useHotkeys('down', zoomOut)
|
||||
useHotkeys('left', onPrev || noop)
|
||||
useHotkeys('right', onNext || noop)
|
||||
useHotkey('ArrowUp', zoomIn)
|
||||
useHotkey('ArrowDown', zoomOut)
|
||||
useHotkey('ArrowLeft', onPrev || noop)
|
||||
useHotkey('ArrowRight', onNext || noop)
|
||||
|
||||
const copyImageLabel = t('operation.copyImage', { ns: 'common' })
|
||||
const zoomOutLabel = t('operation.zoomOut', { ns: 'common' })
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { FormInputItem, FormInputItemDefault } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { ValueSelector } from '@/app/components/workflow/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
|
||||
import PrePopulate from './pre-populate'
|
||||
|
||||
const i18nPrefix = 'nodes.humanInput.insertInputField'
|
||||
@@ -142,8 +143,11 @@ const InputField: React.FC<InputFieldProps> = ({
|
||||
onClick={handleSave}
|
||||
>
|
||||
<span className="mr-1">{t(`${i18nPrefix}.insert`, { ns: 'workflow' })}</span>
|
||||
<span className="mr-0.5 flex h-4 items-center rounded-sm bg-components-kbd-bg-white px-1 system-kbd">{getKeyboardKeyNameBySystem('ctrl')}</span>
|
||||
<span className="flex h-4 items-center rounded-sm bg-components-kbd-bg-white px-1 system-kbd">↩︎</span>
|
||||
<KbdGroup>
|
||||
{['Mod', 'Enter'].map(key => (
|
||||
<Kbd key={key} color="white">{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { Kbd } from '@langgenius/dify-ui/kbd'
|
||||
import { useState } from 'react'
|
||||
import SearchInput from '.'
|
||||
|
||||
@@ -355,9 +356,9 @@ const CommandPaletteDemo = () => {
|
||||
<span className="text-xl">{cmd.icon}</span>
|
||||
<span className="text-sm">{cmd.name}</span>
|
||||
</div>
|
||||
<kbd className="rounded-sm bg-gray-200 px-2 py-1 font-mono text-xs">
|
||||
<Kbd>
|
||||
{cmd.shortcut}
|
||||
</kbd>
|
||||
</Kbd>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Kbd } from '@langgenius/dify-ui/kbd'
|
||||
import { RiArrowLeftLine, RiArrowRightLine, RiCloseLine, RiRefreshLine } from '@remixicon/react'
|
||||
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
|
||||
@@ -145,8 +146,8 @@ const ImagePreviewer = ({
|
||||
fetchImage(image)
|
||||
}, [fetchImage])
|
||||
|
||||
useHotkeys('left', prevImage)
|
||||
useHotkeys('right', nextImage)
|
||||
useHotkey('ArrowLeft', prevImage)
|
||||
useHotkey('ArrowRight', nextImage)
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -170,9 +171,7 @@ const ImagePreviewer = ({
|
||||
>
|
||||
<RiCloseLine className="size-5" />
|
||||
</Button>
|
||||
<span className="system-2xs-medium-uppercase text-text-tertiary">
|
||||
Esc
|
||||
</span>
|
||||
<Kbd>{formatForDisplay('Escape')}</Kbd>
|
||||
</div>
|
||||
{cachedImages[currentImage!.url]!.status === 'loading' && (
|
||||
<Loading type="app" />
|
||||
|
||||
@@ -563,8 +563,6 @@ describe('CreateFromDSLModal', () => {
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Trigger ESC key event - ahooks useKeyPress listens for 'esc' which maps to Escape key
|
||||
// Need to dispatch on window/document with the correct event properties
|
||||
const escEvent = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
code: 'Escape',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import DSLConfirmModal from './dsl-confirm-modal'
|
||||
@@ -50,14 +49,9 @@ const CreateFromDSLModal = ({
|
||||
onClose,
|
||||
})
|
||||
|
||||
useKeyPress('esc', () => {
|
||||
if (show && !showConfirmModal)
|
||||
onClose()
|
||||
}, { target: () => document })
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={show}>
|
||||
<Dialog open={show} onOpenChange={open => !open && !showConfirmModal && onClose()}>
|
||||
<DialogContent className="w-full max-w-[480px]! overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-xl">
|
||||
|
||||
<Header onClose={onClose} />
|
||||
|
||||
@@ -4,14 +4,16 @@ import { ChunkingMode } from '@/models/datasets'
|
||||
import { DocumentContext } from '../../../context'
|
||||
import ActionButtons from '../action-buttons'
|
||||
|
||||
// Mock useKeyPress: required because tests capture registered callbacks
|
||||
// via mockUseKeyPress to verify ESC and Ctrl+S keyboard shortcut behavior.
|
||||
const mockUseKeyPress = vi.fn()
|
||||
vi.mock('ahooks', () => ({
|
||||
useKeyPress: (keys: string | string[], callback: (e: KeyboardEvent) => void, options?: object) => {
|
||||
mockUseKeyPress(keys, callback, options)
|
||||
},
|
||||
}))
|
||||
const mockUseHotkey = vi.fn()
|
||||
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
|
||||
return {
|
||||
...actual,
|
||||
useHotkey: (hotkey: string, callback: (e: KeyboardEvent) => void, options?: object) => {
|
||||
mockUseHotkey(hotkey, callback, options)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Create wrapper component for providing context
|
||||
const createWrapper = (contextValue: {
|
||||
@@ -25,31 +27,20 @@ const createWrapper = (contextValue: {
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to get captured callbacks from useKeyPress mock
|
||||
const getEscCallback = (): ((e: KeyboardEvent) => void) | undefined => {
|
||||
const escCall = mockUseKeyPress.mock.calls.find(
|
||||
(call) => {
|
||||
const keys = call[0]
|
||||
return Array.isArray(keys) && keys.includes('esc')
|
||||
},
|
||||
)
|
||||
const escCall = mockUseHotkey.mock.calls.find(call => call[0] === 'Escape')
|
||||
return escCall?.[1]
|
||||
}
|
||||
|
||||
const getCtrlSCallback = (): ((e: KeyboardEvent) => void) | undefined => {
|
||||
const ctrlSCall = mockUseKeyPress.mock.calls.find(
|
||||
(call) => {
|
||||
const keys = call[0]
|
||||
return typeof keys === 'string' && keys.includes('.s')
|
||||
},
|
||||
)
|
||||
const ctrlSCall = mockUseHotkey.mock.calls.find(call => call[0] === 'Mod+S')
|
||||
return ctrlSCall?.[1]
|
||||
}
|
||||
|
||||
describe('ActionButtons', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseKeyPress.mockClear()
|
||||
mockUseHotkey.mockClear()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@@ -102,7 +93,7 @@ describe('ActionButtons', () => {
|
||||
{ wrapper: createWrapper({}) },
|
||||
)
|
||||
|
||||
expect(screen.getByText('ESC'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Esc'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render S keyboard hint on save button', () => {
|
||||
@@ -381,7 +372,6 @@ describe('ActionButtons', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Keyboard shortcuts tests via useKeyPress callbacks
|
||||
describe('Keyboard Shortcuts', () => {
|
||||
it('should display ctrl key hint on save button', () => {
|
||||
render(
|
||||
@@ -393,8 +383,7 @@ describe('ActionButtons', () => {
|
||||
{ wrapper: createWrapper({}) },
|
||||
)
|
||||
|
||||
// Assert - check for ctrl key hint (Ctrl or Cmd depending on system)
|
||||
const kbdElements = document.querySelectorAll('.system-kbd')
|
||||
const kbdElements = document.querySelectorAll('kbd')
|
||||
expect(kbdElements.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
@@ -461,7 +450,7 @@ describe('ActionButtons', () => {
|
||||
expect(mockHandleSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should register useKeyPress with correct options for Ctrl+S', () => {
|
||||
it('should register the Mod+S hotkey', () => {
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
@@ -471,12 +460,8 @@ describe('ActionButtons', () => {
|
||||
{ wrapper: createWrapper({}) },
|
||||
)
|
||||
|
||||
// Assert - verify useKeyPress was called with correct options
|
||||
const ctrlSCall = mockUseKeyPress.mock.calls.find(
|
||||
call => typeof call[0] === 'string' && call[0].includes('.s'),
|
||||
)
|
||||
const ctrlSCall = mockUseHotkey.mock.calls.find(call => call[0] === 'Mod+S')
|
||||
expect(ctrlSCall).toBeDefined()
|
||||
expect(ctrlSCall![2]).toEqual({ exactMatch: true, useCapture: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import { useDocumentContext } from '../../context'
|
||||
|
||||
@@ -32,17 +31,17 @@ const ActionButtons: FC<IActionButtonsProps> = ({
|
||||
const docForm = useDocumentContext(s => s.docForm)
|
||||
const parentMode = useDocumentContext(s => s.parentMode)
|
||||
|
||||
useKeyPress(['esc'], (e) => {
|
||||
useHotkey('Escape', (e) => {
|
||||
e.preventDefault()
|
||||
handleCancel()
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.s`, (e) => {
|
||||
useHotkey('Mod+S', (e) => {
|
||||
e.preventDefault()
|
||||
if (loading)
|
||||
return
|
||||
handleSave()
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
})
|
||||
|
||||
const isParentChildParagraphMode = useMemo(() => {
|
||||
return docForm === ChunkingMode.parentChild && parentMode === 'paragraph'
|
||||
@@ -55,7 +54,7 @@ const ActionButtons: FC<IActionButtonsProps> = ({
|
||||
>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<span className="system-sm-medium text-components-button-secondary-text">{t('operation.cancel', { ns: 'common' })}</span>
|
||||
<ShortcutsName keys={['ESC']} textColor="secondary" />
|
||||
<Kbd>{formatForDisplay('Escape')}</Kbd>
|
||||
</div>
|
||||
</Button>
|
||||
{(isParentChildParagraphMode && actionType === 'edit' && !isChildChunk && showRegenerationButton)
|
||||
@@ -77,7 +76,11 @@ const ActionButtons: FC<IActionButtonsProps> = ({
|
||||
>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<span className="text-components-button-primary-text">{t('operation.save', { ns: 'common' })}</span>
|
||||
<ShortcutsName keys={['ctrl', 'S']} bgColor="white" />
|
||||
<KbdGroup>
|
||||
{['Mod', 'S'].map(key => (
|
||||
<Kbd key={key} color="white">{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,27 @@ import { Plan } from '@/app/components/billing/type'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import CreateAppModal from '../index'
|
||||
|
||||
const hotkeyMocks = vi.hoisted(() => ({
|
||||
handlers: new Map<string, { handler: () => void, options?: { enabled?: boolean } }>(),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
|
||||
return {
|
||||
...actual,
|
||||
useHotkey: (hotkey: string, handler: () => void, options?: { enabled?: boolean }) => {
|
||||
hotkeyMocks.handlers.set(hotkey, { handler, options })
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const triggerHotkey = (hotkey: string) => {
|
||||
const registration = hotkeyMocks.handlers.get(hotkey)
|
||||
if (registration?.options?.enabled === false)
|
||||
return
|
||||
registration?.handler()
|
||||
}
|
||||
|
||||
vi.mock('emoji-mart', () => ({
|
||||
init: vi.fn(),
|
||||
SearchIndex: { search: vi.fn().mockResolvedValue([]) },
|
||||
@@ -100,6 +121,7 @@ describe('CreateAppModal', () => {
|
||||
mockPlanType = Plan.team
|
||||
mockUsagePlanInfo = createPlanInfo(1)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
hotkeyMocks.handlers.clear()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@@ -111,6 +133,13 @@ describe('CreateAppModal', () => {
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the submit shortcut with kbd primitives', async () => {
|
||||
await setup()
|
||||
|
||||
const createButton = screen.getByRole('button', { name: /common\.operation\.create/ })
|
||||
expect(createButton.querySelectorAll('kbd')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should render edit-only fields when editing a chat app', async () => {
|
||||
await setup({ isEditModal: true, appMode: AppModeEnum.CHAT, max_active_requests: 5 })
|
||||
|
||||
@@ -214,13 +243,10 @@ describe('CreateAppModal', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['meta+enter', { metaKey: true }],
|
||||
['ctrl+enter', { ctrlKey: true }],
|
||||
])('should submit when %s is pressed while visible', async (_, modifier) => {
|
||||
it('should submit when Mod+Enter is pressed while visible', async () => {
|
||||
const { onConfirm, onHide } = await setup()
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, ...modifier })
|
||||
triggerHotkey('Mod+Enter')
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
@@ -232,7 +258,7 @@ describe('CreateAppModal', () => {
|
||||
it('should not submit when modal is hidden', async () => {
|
||||
const { onConfirm, onHide } = await setup({ show: false })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
|
||||
triggerHotkey('Mod+Enter')
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
@@ -249,7 +275,7 @@ describe('CreateAppModal', () => {
|
||||
|
||||
const { onConfirm, onHide } = await setup({ isEditModal: false })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
|
||||
triggerHotkey('Mod+Enter')
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
@@ -266,7 +292,7 @@ describe('CreateAppModal', () => {
|
||||
|
||||
const { onConfirm, onHide } = await setup({ isEditModal: true })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
|
||||
triggerHotkey('Mod+Enter')
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
@@ -278,7 +304,7 @@ describe('CreateAppModal', () => {
|
||||
it('should not submit when name is empty', async () => {
|
||||
const { onConfirm, onHide } = await setup({ appName: ' ' })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
|
||||
triggerHotkey('Mod+Enter')
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -15,7 +17,6 @@ import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppIconPicker from '../../base/app-icon-picker'
|
||||
import ShortcutsName from '../../workflow/shortcuts-name'
|
||||
|
||||
export type CreateAppModalProps = {
|
||||
show: boolean
|
||||
@@ -103,9 +104,11 @@ const CreateAppModal = ({
|
||||
|
||||
const { run: handleSubmit } = useDebounceFn(submit, { wait: 300 })
|
||||
|
||||
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
|
||||
if (show && !(!isEditModal && isAppsFull) && name.trim())
|
||||
handleSubmit()
|
||||
useHotkey('Mod+Enter', () => {
|
||||
handleSubmit()
|
||||
}, {
|
||||
enabled: show && !(!isEditModal && isAppsFull) && !!name.trim(),
|
||||
ignoreInputs: false,
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -191,7 +194,11 @@ const CreateAppModal = ({
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
<span>{!isEditModal ? t('operation.create', { ns: 'common' }) : t('operation.save', { ns: 'common' })}</span>
|
||||
<ShortcutsName keys={['ctrl', '↵']} bgColor="white" />
|
||||
<KbdGroup>
|
||||
{['Mod', 'Enter'].map(key => (
|
||||
<Kbd key={key} color="white">{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</Button>
|
||||
<Button className="w-24" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
|
||||
@@ -23,22 +23,41 @@ type KeyPressEvent = {
|
||||
target?: EventTarget
|
||||
}
|
||||
|
||||
const keyPressHandlers: Record<string, (event: KeyPressEvent) => void> = {}
|
||||
type HotkeyRegistration = {
|
||||
handler: (event: KeyPressEvent) => void
|
||||
options?: { enabled?: boolean }
|
||||
}
|
||||
|
||||
const hotkeyHandlers: Record<string, HotkeyRegistration> = {}
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounce: <T,>(value: T) => value,
|
||||
useKeyPress: (keys: string | string[], handler: (event: KeyPressEvent) => void) => {
|
||||
const keyList = Array.isArray(keys) ? keys : [keys]
|
||||
keyList.forEach((key) => {
|
||||
keyPressHandlers[key] = handler
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
|
||||
return {
|
||||
...actual,
|
||||
useHotkey: (
|
||||
hotkey: string,
|
||||
handler: (event: KeyPressEvent) => void,
|
||||
options?: HotkeyRegistration['options'],
|
||||
) => {
|
||||
hotkeyHandlers[hotkey] = { handler, options }
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const HOTKEY_ALIAS: Record<string, string> = {
|
||||
'ctrl.k': 'Mod+K',
|
||||
'esc': 'Escape',
|
||||
}
|
||||
|
||||
const triggerKeyPress = (combo: string) => {
|
||||
const handler = keyPressHandlers[combo]
|
||||
if (handler) {
|
||||
const hotkey = HOTKEY_ALIAS[combo] ?? combo
|
||||
const registration = hotkeyHandlers[hotkey]
|
||||
if (registration && registration.options?.enabled !== false) {
|
||||
act(() => {
|
||||
handler({ preventDefault: vi.fn(), target: document.body })
|
||||
registration.handler({ preventDefault: vi.fn(), target: document.body })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -58,10 +77,6 @@ vi.mock('../context', () => ({
|
||||
GotoAnythingProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', () => ({
|
||||
getKeyboardKeyNameBySystem: (key: string) => key,
|
||||
}))
|
||||
|
||||
const createActionItem = (key: ActionItem['key'], shortcut: string): ActionItem => ({
|
||||
key,
|
||||
shortcut,
|
||||
@@ -106,13 +121,6 @@ vi.mock('../actions/commands/registry', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils/common', () => ({
|
||||
getKeyboardKeyCodeBySystem: () => 'ctrl',
|
||||
getKeyboardKeyNameBySystem: (key: string) => key,
|
||||
isEventTargetInputArea: () => false,
|
||||
isMac: () => false,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils/node-navigation', () => ({
|
||||
selectWorkflowNode: vi.fn(),
|
||||
}))
|
||||
@@ -130,7 +138,7 @@ vi.mock('../../plugins/install-plugin/install-from-marketplace', () => ({
|
||||
describe('GotoAnything', () => {
|
||||
beforeEach(() => {
|
||||
routerPush.mockClear()
|
||||
Object.keys(keyPressHandlers).forEach(key => delete keyPressHandlers[key])
|
||||
Object.keys(hotkeyHandlers).forEach(key => delete hotkeyHandlers[key])
|
||||
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
|
||||
matchActionMock.mockReset()
|
||||
searchAnythingMock.mockClear()
|
||||
|
||||
@@ -8,14 +8,6 @@ vi.mock('@remixicon/react', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
|
||||
default: ({ keys, textColor }: { keys: string[], textColor: string }) => (
|
||||
<div data-testid="shortcuts-name" data-keys={keys.join(',')} data-color={textColor}>
|
||||
{keys.join('+')}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/input', async () => {
|
||||
const { forwardRef } = await import('react')
|
||||
|
||||
@@ -74,13 +66,12 @@ describe('SearchInput', () => {
|
||||
expect(screen.getByTestId('search-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render shortcuts name', () => {
|
||||
render(<SearchInput {...defaultProps} />)
|
||||
it('should render shortcut keycaps', () => {
|
||||
const { container } = render(<SearchInput {...defaultProps} />)
|
||||
|
||||
const shortcuts = screen.getByTestId('shortcuts-name')
|
||||
expect(shortcuts).toBeInTheDocument()
|
||||
expect(shortcuts).toHaveAttribute('data-keys', 'ctrl,K')
|
||||
expect(shortcuts).toHaveAttribute('data-color', 'secondary')
|
||||
const keycaps = container.querySelectorAll('kbd')
|
||||
expect(keycaps).toHaveLength(2)
|
||||
expect(keycaps[1]).toHaveTextContent('K')
|
||||
})
|
||||
|
||||
it('should use provided placeholder', () => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import type { FC, KeyboardEvent, RefObject } from 'react'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { RiSearchLine } from '@remixicon/react'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
|
||||
type SearchInputProps = {
|
||||
inputRef: RefObject<HTMLInputElement | null>
|
||||
@@ -54,7 +55,11 @@ const SearchInput: FC<SearchInputProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ShortcutsName keys={['ctrl', 'K']} textColor="secondary" />
|
||||
<KbdGroup>
|
||||
{['Mod', 'K'].map(key => (
|
||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,27 +6,34 @@ type KeyPressEvent = {
|
||||
target?: EventTarget
|
||||
}
|
||||
|
||||
const keyPressHandlers: Record<string, (event: KeyPressEvent) => void> = {}
|
||||
let mockIsEventTargetInputArea = false
|
||||
type HotkeyRegistration = {
|
||||
handler: (event: KeyPressEvent) => void
|
||||
options?: { enabled?: boolean, ignoreInputs?: boolean }
|
||||
}
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useKeyPress: (keys: string | string[], handler: (event: KeyPressEvent) => void) => {
|
||||
const keyList = Array.isArray(keys) ? keys : [keys]
|
||||
keyList.forEach((key) => {
|
||||
keyPressHandlers[key] = handler
|
||||
})
|
||||
const hotkeyHandlers: Record<string, HotkeyRegistration> = {}
|
||||
|
||||
vi.mock('@tanstack/react-hotkeys', () => ({
|
||||
useHotkey: (
|
||||
hotkey: string,
|
||||
handler: (event: KeyPressEvent) => void,
|
||||
options?: HotkeyRegistration['options'],
|
||||
) => {
|
||||
hotkeyHandlers[hotkey] = { handler, options }
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils/common', () => ({
|
||||
getKeyboardKeyCodeBySystem: () => 'ctrl',
|
||||
isEventTargetInputArea: () => mockIsEventTargetInputArea,
|
||||
}))
|
||||
const triggerHotkey = (hotkey: string, event: KeyPressEvent) => {
|
||||
const registration = hotkeyHandlers[hotkey]
|
||||
if (registration?.options?.enabled === false)
|
||||
return
|
||||
|
||||
registration?.handler(event)
|
||||
}
|
||||
|
||||
describe('useGotoAnythingModal', () => {
|
||||
beforeEach(() => {
|
||||
Object.keys(keyPressHandlers).forEach(key => delete keyPressHandlers[key])
|
||||
mockIsEventTargetInputArea = false
|
||||
Object.keys(hotkeyHandlers).forEach(key => delete hotkeyHandlers[key])
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
@@ -58,43 +65,36 @@ describe('useGotoAnythingModal', () => {
|
||||
})
|
||||
|
||||
describe('keyboard shortcuts', () => {
|
||||
it('should toggle show state when Ctrl+K is triggered', () => {
|
||||
it('should toggle show state when Mod+K is triggered', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
|
||||
expect(result.current.show).toBe(false)
|
||||
|
||||
act(() => {
|
||||
keyPressHandlers['ctrl.k']?.({ preventDefault: vi.fn(), target: document.body })
|
||||
triggerHotkey('Mod+K', { preventDefault: vi.fn(), target: document.body })
|
||||
})
|
||||
|
||||
expect(result.current.show).toBe(true)
|
||||
})
|
||||
|
||||
it('should toggle back to closed when Ctrl+K is triggered twice', () => {
|
||||
it('should toggle back to closed when Mod+K is triggered twice', () => {
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
|
||||
act(() => {
|
||||
keyPressHandlers['ctrl.k']?.({ preventDefault: vi.fn(), target: document.body })
|
||||
triggerHotkey('Mod+K', { preventDefault: vi.fn(), target: document.body })
|
||||
})
|
||||
expect(result.current.show).toBe(true)
|
||||
|
||||
act(() => {
|
||||
keyPressHandlers['ctrl.k']?.({ preventDefault: vi.fn(), target: document.body })
|
||||
triggerHotkey('Mod+K', { preventDefault: vi.fn(), target: document.body })
|
||||
})
|
||||
expect(result.current.show).toBe(false)
|
||||
})
|
||||
|
||||
it('should NOT toggle when focus is in input area and modal is closed', () => {
|
||||
mockIsEventTargetInputArea = true
|
||||
const { result } = renderHook(() => useGotoAnythingModal())
|
||||
it('should let the hotkey library ignore inputs when the modal is closed', () => {
|
||||
renderHook(() => useGotoAnythingModal())
|
||||
|
||||
expect(result.current.show).toBe(false)
|
||||
|
||||
act(() => {
|
||||
keyPressHandlers['ctrl.k']?.({ preventDefault: vi.fn(), target: document.body })
|
||||
})
|
||||
|
||||
expect(result.current.show).toBe(false)
|
||||
expect(hotkeyHandlers['Mod+K']?.options?.ignoreInputs).toBe(true)
|
||||
})
|
||||
|
||||
it('should close modal when escape is pressed and modal is open', () => {
|
||||
@@ -106,7 +106,7 @@ describe('useGotoAnythingModal', () => {
|
||||
expect(result.current.show).toBe(true)
|
||||
|
||||
act(() => {
|
||||
keyPressHandlers.esc?.({ preventDefault: vi.fn() })
|
||||
triggerHotkey('Escape', { preventDefault: vi.fn() })
|
||||
})
|
||||
|
||||
expect(result.current.show).toBe(false)
|
||||
@@ -119,19 +119,19 @@ describe('useGotoAnythingModal', () => {
|
||||
|
||||
const preventDefaultMock = vi.fn()
|
||||
act(() => {
|
||||
keyPressHandlers.esc?.({ preventDefault: preventDefaultMock })
|
||||
triggerHotkey('Escape', { preventDefault: preventDefaultMock })
|
||||
})
|
||||
|
||||
expect(result.current.show).toBe(false)
|
||||
expect(preventDefaultMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call preventDefault when Ctrl+K is triggered', () => {
|
||||
it('should call preventDefault when Mod+K is triggered', () => {
|
||||
renderHook(() => useGotoAnythingModal())
|
||||
|
||||
const preventDefaultMock = vi.fn()
|
||||
act(() => {
|
||||
keyPressHandlers['ctrl.k']?.({ preventDefault: preventDefaultMock, target: document.body })
|
||||
triggerHotkey('Mod+K', { preventDefault: preventDefaultMock, target: document.body })
|
||||
})
|
||||
|
||||
expect(preventDefaultMock).toHaveBeenCalled()
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import { useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { getKeyboardKeyCodeBySystem, isEventTargetInputArea } from '@/app/components/workflow/utils/common'
|
||||
|
||||
type UseGotoAnythingModalReturn = {
|
||||
show: boolean
|
||||
@@ -18,23 +17,20 @@ export const useGotoAnythingModal = (): UseGotoAnythingModalReturn => {
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleToggleModal = useCallback((e: KeyboardEvent) => {
|
||||
// Allow closing when modal is open, even if focus is in the search input
|
||||
if (!show && isEventTargetInputArea(e.target as HTMLElement))
|
||||
return
|
||||
e.preventDefault()
|
||||
setShow(prev => !prev)
|
||||
}, [show])
|
||||
}, [])
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.k`, handleToggleModal, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
useHotkey('Mod+K', handleToggleModal, {
|
||||
ignoreInputs: !show,
|
||||
})
|
||||
|
||||
useKeyPress(['esc'], (e) => {
|
||||
if (show) {
|
||||
e.preventDefault()
|
||||
setShow(false)
|
||||
}
|
||||
useHotkey('Escape', (e) => {
|
||||
e.preventDefault()
|
||||
setShow(false)
|
||||
}, {
|
||||
enabled: show,
|
||||
ignoreInputs: false,
|
||||
})
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
|
||||
@@ -278,8 +278,6 @@ vi.mock('@/app/components/workflow/constants', () => ({
|
||||
vi.mock('@/app/components/workflow/utils', () => ({
|
||||
initialNodes: vi.fn(nodes => nodes),
|
||||
initialEdges: vi.fn(edges => edges),
|
||||
getKeyboardKeyCodeBySystem: (key: string) => key,
|
||||
getKeyboardKeyNameBySystem: (key: string) => key,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({
|
||||
|
||||
@@ -172,11 +172,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
promise: toastMocks.promise,
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/app/components/workflow/utils', () => ({
|
||||
getKeyboardKeyCodeBySystem: (key: string) => key,
|
||||
getKeyboardKeyNameBySystem: (key: string) => key,
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useBoolean: (initial: boolean) => {
|
||||
let value = initial
|
||||
@@ -189,7 +184,6 @@ vi.mock('ahooks', () => ({
|
||||
},
|
||||
]
|
||||
},
|
||||
useKeyPress: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({
|
||||
@@ -408,10 +402,11 @@ describe('Popup', () => {
|
||||
})
|
||||
|
||||
it('should render keyboard shortcuts', () => {
|
||||
render(<Popup />)
|
||||
const { container } = render(<Popup />)
|
||||
|
||||
expect(screen.getByText('ctrl'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('⇧'))!.toBeInTheDocument()
|
||||
expect(container.querySelectorAll('kbd')).toHaveLength(3)
|
||||
expect(screen.getByText('Ctrl'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Shift'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('P'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -566,9 +561,10 @@ describe('RunMode', () => {
|
||||
})
|
||||
|
||||
it('should render keyboard shortcuts when not disabled', () => {
|
||||
render(<RunMode />)
|
||||
const { container } = render(<RunMode />)
|
||||
|
||||
expect(screen.getByText('alt'))!.toBeInTheDocument()
|
||||
expect(container.querySelectorAll('kbd')).toHaveLength(2)
|
||||
expect(screen.getByText('Alt'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('R'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1077,9 +1073,10 @@ describe('Edge Cases', () => {
|
||||
mockStoreState.workflowRunningData = null
|
||||
mockStoreState.isPreparingDataSource = false
|
||||
|
||||
render(<RunMode />)
|
||||
const { container } = render(<RunMode />)
|
||||
|
||||
expect(screen.getByText('alt'))!.toBeInTheDocument()
|
||||
expect(container.querySelectorAll('kbd')).toHaveLength(2)
|
||||
expect(screen.getByText('Alt'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('R'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -19,10 +19,6 @@ vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
|
||||
default: ({ keys }: { keys: string[] }) => <span data-testid="shortcuts">{keys.join('+')}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
@@ -95,9 +91,9 @@ describe('RunMode', () => {
|
||||
})
|
||||
|
||||
it('should render keyboard shortcuts', () => {
|
||||
render(<RunMode />)
|
||||
const { container } = render(<RunMode />)
|
||||
|
||||
expect(screen.getByTestId('shortcuts')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('kbd')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should call start run when button clicked', () => {
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
import type { IconInfo } from '@/models/datasets'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Publisher from '../index'
|
||||
import Popup from '../popup'
|
||||
|
||||
const hotkeyHandlers = vi.hoisted(() => new Map<string, (event: KeyboardEvent) => void>())
|
||||
|
||||
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
|
||||
return {
|
||||
...actual,
|
||||
useHotkey: (hotkey: string, handler: (event: KeyboardEvent) => void) => {
|
||||
hotkeyHandlers.set(hotkey, handler)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const triggerHotkey = (hotkey: string) => {
|
||||
const handler = hotkeyHandlers.get(hotkey)
|
||||
if (!handler)
|
||||
return
|
||||
|
||||
act(() => {
|
||||
handler({ preventDefault: vi.fn() } as unknown as KeyboardEvent)
|
||||
})
|
||||
}
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
|
||||
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||
Button: ({ children, onClick, disabled, variant, className }: Record<string, unknown>) => (
|
||||
@@ -170,11 +192,6 @@ vi.mock('@/service/use-workflow', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', () => ({
|
||||
getKeyboardKeyCodeBySystem: (key: string) => key,
|
||||
getKeyboardKeyNameBySystem: (key: string) => key === 'ctrl' ? '⌘' : key,
|
||||
}))
|
||||
|
||||
vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({
|
||||
default: ({ confirmDisabled, onConfirm, onCancel }: {
|
||||
confirmDisabled: boolean
|
||||
@@ -215,6 +232,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
describe('publisher', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hotkeyHandlers.clear()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockPublishedAt.mockReturnValue(null)
|
||||
mockDraftUpdatedAt.mockReturnValue(1700000000)
|
||||
@@ -872,7 +890,7 @@ describe('publisher', () => {
|
||||
mockPublishWorkflow.mockResolvedValue({ created_at: 1700100000 })
|
||||
renderWithQueryClient(<Popup />)
|
||||
|
||||
fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true })
|
||||
triggerHotkey('Mod+Shift+P')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPublishWorkflow).toHaveBeenCalled()
|
||||
@@ -893,7 +911,7 @@ describe('publisher', () => {
|
||||
|
||||
vi.clearAllMocks()
|
||||
|
||||
fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true })
|
||||
triggerHotkey('Mod+Shift+P')
|
||||
|
||||
expect(mockPublishWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -902,7 +920,7 @@ describe('publisher', () => {
|
||||
mockPublishedAt.mockReturnValue(null)
|
||||
renderWithQueryClient(<Popup />)
|
||||
|
||||
fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true })
|
||||
triggerHotkey('Mod+Shift+P')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
|
||||
@@ -917,14 +935,14 @@ describe('publisher', () => {
|
||||
}))
|
||||
renderWithQueryClient(<Popup />)
|
||||
|
||||
fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true })
|
||||
triggerHotkey('Mod+Shift+P')
|
||||
|
||||
await waitFor(() => {
|
||||
const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i })
|
||||
expect(publishButton).toBeDisabled()
|
||||
})
|
||||
|
||||
fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true })
|
||||
triggerHotkey('Mod+Shift+P')
|
||||
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledTimes(1)
|
||||
|
||||
|
||||
@@ -62,7 +62,6 @@ let mockDraftUpdatedAt: string | undefined = '2024-06-01T00:00:00Z'
|
||||
let mockPipelineId: string | undefined = 'pipeline-123'
|
||||
let mockIsAllowPublishAsCustom = true
|
||||
const mockUseBoolean = vi.hoisted(() => vi.fn())
|
||||
const mockUseKeyPress = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useParams: () => ({ datasetId: 'ds-123' }),
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
@@ -76,9 +75,16 @@ vi.mock('@/next/link', () => ({
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useBoolean: (initial: boolean) => mockUseBoolean(initial),
|
||||
useKeyPress: (...args: unknown[]) => mockUseKeyPress(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
|
||||
return {
|
||||
...actual,
|
||||
useHotkey: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
@@ -130,14 +136,6 @@ vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
|
||||
default: ({ keys }: { keys: string[] }) => <span data-testid="shortcuts">{keys.join('+')}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', () => ({
|
||||
getKeyboardKeyCodeBySystem: () => 'ctrl',
|
||||
}))
|
||||
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContextWithSelector: () => mockMutateDatasetRes,
|
||||
}))
|
||||
@@ -220,7 +218,6 @@ describe('Popup', () => {
|
||||
setFalse: vi.fn(),
|
||||
setTrue: vi.fn(),
|
||||
}])
|
||||
mockUseKeyPress.mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -244,10 +241,10 @@ describe('Popup', () => {
|
||||
})
|
||||
|
||||
it('should render publish button with shortcuts', () => {
|
||||
render(<Popup />)
|
||||
const { container } = render(<Popup />)
|
||||
|
||||
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('shortcuts')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('kbd')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should render "Go to Add Documents" button', () => {
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiArrowRightUpLine, RiHammerLine, RiPlayCircleLine, RiTerminalBoxLine } from '@remixicon/react'
|
||||
import { useBoolean, useKeyPress } from 'ahooks'
|
||||
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
@@ -20,9 +22,7 @@ import Divider from '@/app/components/base/divider'
|
||||
import { SparklesSoft } from '@/app/components/base/icons/src/public/common'
|
||||
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import { useChecklistBeforePublish } from '@/app/components/workflow/hooks'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
@@ -35,7 +35,7 @@ import { useInvalid } from '@/service/use-base'
|
||||
import { publishedPipelineInfoQueryKeyPrefix } from '@/service/use-pipeline'
|
||||
import { usePublishWorkflow } from '@/service/use-workflow'
|
||||
|
||||
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
||||
const PUBLISH_SHORTCUT = ['Mod', 'Shift', 'P']
|
||||
type PopupProps = {
|
||||
onRequestClose?: () => void
|
||||
confirmVisible?: boolean
|
||||
@@ -133,12 +133,12 @@ const Popup = ({
|
||||
handleHideConfirm()
|
||||
}
|
||||
}, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, handleHideConfirm])
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
|
||||
useHotkey('Mod+Shift+P', (e) => {
|
||||
e.preventDefault()
|
||||
if (published)
|
||||
return
|
||||
handlePublish()
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
})
|
||||
const goToAddDocuments = useCallback(() => {
|
||||
push(`/datasets/${datasetId}/documents/create-from-pipeline`)
|
||||
}, [datasetId, push])
|
||||
@@ -181,7 +181,11 @@ const Popup = ({
|
||||
: (
|
||||
<div className="flex gap-1">
|
||||
<span>{t('common.publishUpdate', { ns: 'workflow' })}</span>
|
||||
<ShortcutsName keys={PUBLISH_SHORTCUT} bgColor="white" />
|
||||
<KbdGroup>
|
||||
{PUBLISH_SHORTCUT.map(key => (
|
||||
<Kbd key={key} color="white">{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { RiCloseLine, RiDatabase2Line, RiLoader2Line, RiPlayLargeLine } from '@remixicon/react'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
|
||||
import { useWorkflowRun, useWorkflowStartRun } from '@/app/components/workflow/hooks'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
|
||||
@@ -78,7 +79,11 @@ const RunMode = ({
|
||||
)}
|
||||
{
|
||||
!isDisabled && (
|
||||
<ShortcutsName keys={['alt', 'R']} textColor="secondary" />
|
||||
<KbdGroup>
|
||||
{['Alt', 'R'].map(key => (
|
||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
)
|
||||
}
|
||||
</button>
|
||||
|
||||
@@ -104,7 +104,6 @@ export function createReactFlowModuleMock() {
|
||||
useNodes: vi.fn(() => rfState.nodes),
|
||||
useEdges: vi.fn(() => rfState.edges),
|
||||
useViewport: vi.fn(() => ({ x: 0, y: 0, zoom: 1 })),
|
||||
useKeyPress: vi.fn(() => false),
|
||||
useOnSelectionChange: vi.fn(),
|
||||
useOnViewportChange: vi.fn(),
|
||||
useUpdateNodeInternals: vi.fn(() => vi.fn()),
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ShortcutsName from '../shortcuts-name'
|
||||
|
||||
describe('ShortcutsName', () => {
|
||||
const originalNavigator = globalThis.navigator
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: originalNavigator,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders mac-friendly key labels and style variants', () => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Macintosh' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<ShortcutsName
|
||||
keys={['ctrl', 'shift', 's']}
|
||||
bgColor="white"
|
||||
textColor="secondary"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('⌘')).toBeInTheDocument()
|
||||
expect(screen.getByText('⇧')).toBeInTheDocument()
|
||||
expect(screen.getByText('s')).toBeInTheDocument()
|
||||
expect(container.querySelector('.system-kbd')).toHaveClass(
|
||||
'bg-components-kbd-bg-white',
|
||||
'text-text-tertiary',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps raw key names on non-mac systems', () => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Windows NT' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
render(<ShortcutsName keys={['ctrl', 'alt']} />)
|
||||
|
||||
expect(screen.getByText('ctrl')).toBeInTheDocument()
|
||||
expect(screen.getByText('alt')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -71,10 +71,6 @@ vi.mock('@/context/event-emitter', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
|
||||
default: () => <span data-testid="shortcuts-name">Shortcut</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({
|
||||
StopCircle: () => <span data-testid="stop-circle" />,
|
||||
}))
|
||||
|
||||
@@ -37,10 +37,6 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../shortcuts-name', () => ({
|
||||
default: ({ keys }: { keys: string[] }) => <span>{keys.join('+')}</span>,
|
||||
}))
|
||||
|
||||
const createOption = (overrides: Partial<TriggerOption> = {}): TriggerOption => ({
|
||||
id: 'user-input',
|
||||
type: TriggerType.UserInput,
|
||||
|
||||
@@ -67,10 +67,6 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../shortcuts-name', () => ({
|
||||
default: ({ keys }: { keys: string[] }) => <span>{keys.join('+')}</span>,
|
||||
}))
|
||||
|
||||
const createOption = (overrides: Partial<TriggerOption> = {}): TriggerOption => ({
|
||||
id: 'user-input',
|
||||
type: TriggerType.UserInput,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Kbd } from '@langgenius/dify-ui/kbd'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $insertNodes, FOCUS_COMMAND } from 'lexical'
|
||||
import { useCallback } from 'react'
|
||||
@@ -27,7 +28,7 @@ const Placeholder = () => {
|
||||
>
|
||||
<div className="flex grow items-center">
|
||||
{t('nodes.tool.insertPlaceholder1', { ns: 'workflow' })}
|
||||
<div className="mx-0.5 flex size-4 items-center justify-center rounded-sm bg-components-kbd-bg-gray system-kbd text-text-placeholder">/</div>
|
||||
<Kbd className="mx-0.5 text-text-placeholder">/</Kbd>
|
||||
<div
|
||||
className="cursor-pointer system-sm-regular text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary"
|
||||
onMouseDown={((e) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import FormContent from '../form-content'
|
||||
|
||||
const mockUseTranslation = vi.hoisted(() => vi.fn())
|
||||
const mockUseWorkflowVariableType = vi.hoisted(() => vi.fn())
|
||||
const mockIsMac = vi.hoisted(() => vi.fn())
|
||||
const mockFormatForDisplay = vi.hoisted(() => vi.fn((hotkey: string) => hotkey))
|
||||
const mockPromptEditor = vi.hoisted(() => vi.fn())
|
||||
const mockAddInputField = vi.hoisted(() => vi.fn())
|
||||
const mockOnInsert = vi.hoisted(() => vi.fn())
|
||||
@@ -30,9 +30,13 @@ vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useWorkflowVariableType: () => mockUseWorkflowVariableType(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', () => ({
|
||||
isMac: () => mockIsMac(),
|
||||
}))
|
||||
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
|
||||
return {
|
||||
...actual,
|
||||
formatForDisplay: (hotkey: string) => mockFormatForDisplay(hotkey),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor', () => ({
|
||||
__esModule: true,
|
||||
@@ -114,7 +118,7 @@ describe('FormContent', () => {
|
||||
t: (key: string) => key,
|
||||
})
|
||||
mockUseWorkflowVariableType.mockReturnValue(() => 'string')
|
||||
mockIsMac.mockReturnValue(false)
|
||||
mockFormatForDisplay.mockImplementation((hotkey: string) => hotkey)
|
||||
})
|
||||
|
||||
it('should build workflow node maps, show the hotkey tip on focus, and defer form-input sync until value changes', async () => {
|
||||
@@ -232,7 +236,7 @@ describe('FormContent', () => {
|
||||
})
|
||||
|
||||
it('should render the mac hotkey hint when focused on macOS', () => {
|
||||
mockIsMac.mockReturnValue(true)
|
||||
mockFormatForDisplay.mockReturnValue('⌘')
|
||||
|
||||
render(
|
||||
<FormContent
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { FC } from 'react'
|
||||
import type { FormInputItem } from '../types'
|
||||
import type { Node, NodeOutPutVar } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Kbd } from '@langgenius/dify-ui/kbd'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -12,7 +14,6 @@ import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import { INSERT_HITL_INPUT_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/hitl-input-block'
|
||||
import { useWorkflowVariableType } from '../../../hooks'
|
||||
import { BlockEnum } from '../../../types'
|
||||
import { isMac } from '../../../utils'
|
||||
import AddInputField from './add-input-field'
|
||||
|
||||
type FormContentProps = {
|
||||
@@ -30,14 +31,6 @@ type FormContentProps = {
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const Key: FC<{ children: React.ReactNode, className?: string }> = ({ children, className }) => {
|
||||
return <span className={cn('mx-0.5 inline-flex size-4 items-center justify-center rounded-sm bg-components-kbd-bg-gray system-kbd text-text-placeholder', className)}>{children}</span>
|
||||
}
|
||||
|
||||
const CtrlKey: FC = () => {
|
||||
return <Key className={cn('mr-0', !isMac() && 'w-7')}>{isMac() ? '⌘' : 'Ctrl'}</Key>
|
||||
}
|
||||
|
||||
const FormContent: FC<FormContentProps> = ({
|
||||
nodeId,
|
||||
value,
|
||||
@@ -162,8 +155,8 @@ const FormContent: FC<FormContentProps> = ({
|
||||
ns="workflow"
|
||||
components={
|
||||
{
|
||||
Key: <Key>/</Key>,
|
||||
CtrlKey: <CtrlKey />,
|
||||
Key: <Kbd className="mx-0.5 text-text-placeholder">/</Kbd>,
|
||||
CtrlKey: <Kbd className="mx-0.5 text-text-placeholder">{formatForDisplay('Mod')}</Kbd>,
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Kbd } from '@langgenius/dify-ui/kbd'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $insertNodes, FOCUS_COMMAND } from 'lexical'
|
||||
import { useCallback } from 'react'
|
||||
@@ -38,7 +39,7 @@ const Placeholder = ({ disableVariableInsertion = false, hideBadge = false }: Pl
|
||||
{t('nodes.tool.insertPlaceholder1', { ns: 'workflow' })}
|
||||
{(!disableVariableInsertion) && (
|
||||
<>
|
||||
<div className="mx-0.5 flex size-4 items-center justify-center rounded-sm bg-components-kbd-bg-gray system-kbd text-text-placeholder">/</div>
|
||||
<Kbd className="mx-0.5 text-text-placeholder">/</Kbd>
|
||||
<div
|
||||
className="cursor-pointer system-sm-regular text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary"
|
||||
onMouseDown={((e) => {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { memo } from 'react'
|
||||
import { getKeyboardKeyNameBySystem } from './utils'
|
||||
|
||||
type ShortcutsNameProps = {
|
||||
keys: string[]
|
||||
className?: string
|
||||
textColor?: 'default' | 'secondary'
|
||||
bgColor?: 'gray' | 'white'
|
||||
}
|
||||
const ShortcutsName = ({
|
||||
keys,
|
||||
className,
|
||||
textColor = 'default',
|
||||
bgColor = 'gray',
|
||||
}: ShortcutsNameProps) => {
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex items-center gap-0.5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{
|
||||
keys.map(key => (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
'flex h-4 min-w-4 items-center justify-center rounded-sm px-1 system-kbd capitalize',
|
||||
bgColor === 'gray' && 'bg-components-kbd-bg-gray',
|
||||
bgColor === 'white' && 'bg-components-kbd-bg-white text-text-primary-on-surface',
|
||||
textColor === 'secondary' && 'text-text-tertiary',
|
||||
)}
|
||||
>
|
||||
{getKeyboardKeyNameBySystem(key)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ShortcutsName)
|
||||
@@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'
|
||||
import { ShortcutKbd } from '../shortcut-kbd'
|
||||
|
||||
describe('ShortcutKbd', () => {
|
||||
it('renders shortcut chords as separate keycaps with the legacy visual classes', () => {
|
||||
it('renders shortcut chords as separate design-system keycaps', () => {
|
||||
const { container } = render(
|
||||
<ShortcutKbd
|
||||
shortcut="workflow.copy"
|
||||
@@ -14,7 +14,7 @@ describe('ShortcutKbd', () => {
|
||||
)
|
||||
|
||||
const wrapper = container.firstElementChild
|
||||
expect(wrapper).toHaveClass('flex', 'items-center', 'gap-0.5', 'ml-2')
|
||||
expect(wrapper).toHaveClass('inline-flex', 'items-center', 'gap-0.5', 'ml-2')
|
||||
|
||||
const keys = container.querySelectorAll('kbd')
|
||||
expect(keys).toHaveLength(2)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { KbdColor } from '@langgenius/dify-ui/kbd'
|
||||
import type { FormatDisplayOptions, RegisterableHotkey } from '@tanstack/react-hotkeys'
|
||||
import type { WorkflowShortcutId } from './definitions'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { getWorkflowShortcutDisplayHotkey } from './definitions'
|
||||
|
||||
@@ -9,7 +11,7 @@ type ShortcutKbdProps = {
|
||||
hotkey?: RegisterableHotkey | (string & {})
|
||||
className?: string
|
||||
textColor?: 'default' | 'secondary'
|
||||
bgColor?: 'gray' | 'white'
|
||||
bgColor?: KbdColor
|
||||
platform?: FormatDisplayOptions['platform']
|
||||
}
|
||||
|
||||
@@ -44,27 +46,22 @@ export const ShortcutKbd = ({
|
||||
const displayKeys = getDisplayKeys(displayHotkey, platform)
|
||||
|
||||
return (
|
||||
<span
|
||||
<KbdGroup
|
||||
className={cn(
|
||||
'flex items-center gap-0.5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{
|
||||
displayKeys.map((key, index) => (
|
||||
<kbd
|
||||
<Kbd
|
||||
key={`${key}-${index}`}
|
||||
className={cn(
|
||||
'flex h-4 min-w-4 items-center justify-center rounded-sm px-1 font-sans system-kbd capitalize not-italic',
|
||||
bgColor === 'gray' && 'bg-components-kbd-bg-gray',
|
||||
bgColor === 'white' && 'bg-components-kbd-bg-white text-text-primary-on-surface',
|
||||
textColor === 'secondary' && 'text-text-tertiary',
|
||||
)}
|
||||
color={bgColor}
|
||||
className={cn(textColor === 'secondary' && 'text-text-tertiary')}
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
</Kbd>
|
||||
))
|
||||
}
|
||||
</span>
|
||||
</KbdGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useWorkflowCanvasMaximize } from '../hooks/use-workflow-canvas-maximize
|
||||
import { useWorkflowOrganize } from '../hooks/use-workflow-organize'
|
||||
import { useWorkflowMoveMode } from '../hooks/use-workflow-panel-interactions'
|
||||
import { useStore } from '../store/workflow'
|
||||
import { isEventTargetInputArea } from '../utils'
|
||||
import {
|
||||
subscribeWorkflowCommand,
|
||||
WorkflowCommand,
|
||||
@@ -27,6 +26,16 @@ const workflowHotkeyOptions = {
|
||||
conflictBehavior: 'warn',
|
||||
} satisfies UseHotkeyOptions
|
||||
|
||||
const isInputLikeElement = (element: Element | null) => {
|
||||
if (!element)
|
||||
return false
|
||||
|
||||
return element instanceof HTMLInputElement
|
||||
|| element instanceof HTMLTextAreaElement
|
||||
|| element instanceof HTMLSelectElement
|
||||
|| (element instanceof HTMLElement && element.isContentEditable)
|
||||
}
|
||||
|
||||
const toHotkeyDefinitions = (
|
||||
shortcut: WorkflowShortcutDefinition,
|
||||
callback: HotkeyCallback,
|
||||
@@ -227,7 +236,7 @@ export const useWorkflowHotkeys = (): void => {
|
||||
if (shiftDimmedRef.current)
|
||||
return
|
||||
|
||||
if (isEventTargetInputArea(document.activeElement as HTMLElement))
|
||||
if (isInputLikeElement(document.activeElement))
|
||||
return
|
||||
|
||||
shiftDimmedRef.current = true
|
||||
|
||||
@@ -1,163 +1,7 @@
|
||||
import {
|
||||
formatWorkflowRunIdentifier,
|
||||
getKeyboardKeyCodeBySystem,
|
||||
getKeyboardKeyNameBySystem,
|
||||
isEventTargetInputArea,
|
||||
isMac,
|
||||
} from '../common'
|
||||
|
||||
describe('isMac', () => {
|
||||
const originalNavigator = globalThis.navigator
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: originalNavigator,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return true when userAgent contains MAC', () => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
expect(isMac()).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when userAgent does not contain MAC', () => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
expect(isMac()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getKeyboardKeyNameBySystem', () => {
|
||||
const originalNavigator = globalThis.navigator
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: originalNavigator,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
function setMac() {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Macintosh' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
function setWindows() {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Windows NT' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
it('should map ctrl to ⌘ on Mac', () => {
|
||||
setMac()
|
||||
expect(getKeyboardKeyNameBySystem('ctrl')).toBe('⌘')
|
||||
})
|
||||
|
||||
it('should map alt to ⌥ on Mac', () => {
|
||||
setMac()
|
||||
expect(getKeyboardKeyNameBySystem('alt')).toBe('⌥')
|
||||
})
|
||||
|
||||
it('should map shift to ⇧ on Mac', () => {
|
||||
setMac()
|
||||
expect(getKeyboardKeyNameBySystem('shift')).toBe('⇧')
|
||||
})
|
||||
|
||||
it('should return the original key for unmapped keys on Mac', () => {
|
||||
setMac()
|
||||
expect(getKeyboardKeyNameBySystem('enter')).toBe('enter')
|
||||
})
|
||||
|
||||
it('should return the original key on non-Mac', () => {
|
||||
setWindows()
|
||||
expect(getKeyboardKeyNameBySystem('ctrl')).toBe('ctrl')
|
||||
expect(getKeyboardKeyNameBySystem('alt')).toBe('alt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getKeyboardKeyCodeBySystem', () => {
|
||||
const originalNavigator = globalThis.navigator
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: originalNavigator,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should map ctrl to meta on Mac', () => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Macintosh' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
expect(getKeyboardKeyCodeBySystem('ctrl')).toBe('meta')
|
||||
})
|
||||
|
||||
it('should return the original key on non-Mac', () => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Windows NT' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
expect(getKeyboardKeyCodeBySystem('ctrl')).toBe('ctrl')
|
||||
})
|
||||
|
||||
it('should return the original key for unmapped keys on Mac', () => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'Macintosh' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
expect(getKeyboardKeyCodeBySystem('alt')).toBe('alt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEventTargetInputArea', () => {
|
||||
it('should return true for INPUT elements', () => {
|
||||
const el = document.createElement('input')
|
||||
expect(isEventTargetInputArea(el)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for TEXTAREA elements', () => {
|
||||
const el = document.createElement('textarea')
|
||||
expect(isEventTargetInputArea(el)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for contentEditable elements', () => {
|
||||
const el = document.createElement('div')
|
||||
el.contentEditable = 'true'
|
||||
expect(isEventTargetInputArea(el)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return undefined for non-input elements', () => {
|
||||
const el = document.createElement('div')
|
||||
expect(isEventTargetInputArea(el)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for contentEditable=false elements', () => {
|
||||
const el = document.createElement('div')
|
||||
el.contentEditable = 'false'
|
||||
expect(isEventTargetInputArea(el)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatWorkflowRunIdentifier', () => {
|
||||
it('should return fallback text when finishedAt is undefined', () => {
|
||||
expect(formatWorkflowRunIdentifier()).toBe(' (Running)')
|
||||
|
||||
@@ -1,39 +1,3 @@
|
||||
export const isMac = () => {
|
||||
return navigator.userAgent.toUpperCase().includes('MAC')
|
||||
}
|
||||
|
||||
const specialKeysNameMap: Record<string, string | undefined> = {
|
||||
ctrl: '⌘',
|
||||
alt: '⌥',
|
||||
shift: '⇧',
|
||||
}
|
||||
|
||||
export const getKeyboardKeyNameBySystem = (key: string) => {
|
||||
if (isMac())
|
||||
return specialKeysNameMap[key] || key
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
const specialKeysCodeMap: Record<string, string | undefined> = {
|
||||
ctrl: 'meta',
|
||||
}
|
||||
|
||||
export const getKeyboardKeyCodeBySystem = (key: string) => {
|
||||
if (isMac())
|
||||
return specialKeysCodeMap[key] || key
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
export const isEventTargetInputArea = (target: HTMLElement) => {
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')
|
||||
return true
|
||||
|
||||
if (target.contentEditable === 'true')
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Format workflow run identifier using finished_at timestamp
|
||||
* @param finishedAt - Unix timestamp in seconds
|
||||
|
||||
@@ -125,7 +125,6 @@
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-easy-crop": "catalog:",
|
||||
"react-hotkeys-hook": "catalog:",
|
||||
"react-i18next": "catalog:",
|
||||
"react-multi-email": "catalog:",
|
||||
"react-papaparse": "catalog:",
|
||||
|
||||
Reference in New Issue
Block a user