mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 02:28:30 +08:00
refactor(web): standardize hotkey ownership and typing (#38960)
This commit is contained in:
parent
f86bfb2a31
commit
e134c947c1
@ -18,6 +18,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
|
||||
| Should URL state enter Jotai? | Let Next.js route params and `nuqs` own URL state and updates. | Query atoms or shared derived atoms need a read-only bridge hydrated at the route/surface boundary. |
|
||||
| Should this query/mutation become an atom? | Use TanStack Query hooks at the lowest owner. | It reads atom state, feeds derived atoms, or participates in shared Jotai workflow orchestration. |
|
||||
| Should this be a helper/wrapper? | Prefer direct readable code at the use site. | The name captures a stable domain rule or the wrapper owns real behavior, validation, state, error handling, or semantics. |
|
||||
| Where should a hotkey live? | Keep a single-owner hotkey constant in its component. | Multiple production files share one command, or the feature owns a real command registry with shared metadata and behavior. |
|
||||
| Is an Effect needed? | No. Derive during render or handle the user action in the event handler. | It synchronizes with an external system such as browser APIs, subscriptions, timers, analytics, or imperative DOM/non-React widgets. |
|
||||
|
||||
## Core Defaults
|
||||
@ -77,6 +78,19 @@ Use this as the component decision guide for Dify web. Existing code is referenc
|
||||
- Name values by their domain role and backend API contract, especially persistent IDs and route params. Normalize framework or route params at the boundary.
|
||||
- Put fallback and invariant checks in the lowest component that already handles that state. Do not extract helpers whose only behavior is hiding missing display data.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
- Distinguish application commands from local keyboard semantics before choosing an API. Use `@tanstack/react-hotkeys` for application commands. Keep menu navigation, dialog Escape handling owned by a primitive, editor commands, and other widget-scoped ARIA interactions in their local component or primitive.
|
||||
- Use `useHotkey` or `useHotkeys` for registered commands. For a command intentionally owned by an existing `onKeyDown`, use `matchesKeyboardEvent` instead of hand-written `metaKey` / `ctrlKey` parsing or a second global listener.
|
||||
- Define a reusable string command with `satisfies Hotkey` and an object-form command with `satisfies RawHotkey`. Reserve `RegisterableHotkey` for API boundaries that intentionally accept either form. A one-time inline literal passed directly to TanStack is already type-checked; extract it when registration, display, metadata, or another production consumer needs the same source.
|
||||
- Keep registered hotkeys distinct from held keys and display-only accelerators. Use `IndividualKey` with `useKeyHold` for held-key interactions, and use an explicitly named `displayKey` for local widget accelerators that are not registered `Hotkey` values.
|
||||
- Keep registration and keycap/menu display derived from one canonical command. Do not maintain a hotkey string beside a separate `['Mod', ...]` display array.
|
||||
- Keep a single-owner command constant in its owning component. Create a feature-local `hotkeys.ts` only when multiple production files consume the same command. Keep a dedicated definitions/registry module when a feature owns a real command system with IDs, metadata, alternate bindings, and centralized registration. Tests do not count as another production owner, and file-name uniformity alone is not a reason to extract.
|
||||
- Make scope and availability explicit. Use `enabled` for business or surface lifecycle, `ignoreInputs` for whether input-like elements may trigger the command, and `target` when the command belongs to a concrete DOM subtree. Global application commands may use the document target; inline editors and composed overlays should prefer the actual editor or Base UI Popup ref when that owner is exposed.
|
||||
- Put a scoped ref on the real behavior owner. Do not add a wrapper DOM element solely to obtain a hotkey target. If a shared overlay convenience component hides the Popup ref, either rely on its modal lifecycle/focus boundary when that is sufficient or design the primitive API separately; do not create a fake owner at the call site.
|
||||
- Set `preventDefault` and `stopPropagation` according to the existing product behavior and browser interaction. Do not silently accept TanStack defaults when migrating from another listener if that changes typing, submission, or propagation semantics.
|
||||
- Test observable command behavior, disabled/input/target scope, and the shared registration/display contract at the owning feature boundary. Prefer partial mocks that retain TanStack formatting and matching behavior when a registration boundary must be isolated.
|
||||
|
||||
## Generated API And Nullable Data
|
||||
|
||||
- Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. For enterprise APIs, use `packages/contracts/generated/enterprise/*`.
|
||||
|
||||
@ -757,34 +757,8 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/create-app-modal/index.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/create-from-dsl-modal/index.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
},
|
||||
"eslint-react/set-state-in-effect": {
|
||||
"count": 2
|
||||
},
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 2
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/only-export-components": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
|
||||
@ -1,30 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
import AppDetailTop from '../app-detail-top'
|
||||
|
||||
vi.mock('../toggle-button', () => ({
|
||||
default: ({
|
||||
expand,
|
||||
handleToggle,
|
||||
icon,
|
||||
}: {
|
||||
expand: boolean
|
||||
handleToggle: () => void
|
||||
icon?: ReactNode
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="toggle-button"
|
||||
data-expand={expand}
|
||||
data-has-icon={Boolean(icon)}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
Toggle
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
import { AppDetailTop } from '../app-detail-top'
|
||||
|
||||
function TestGotoAnythingDialog() {
|
||||
return (
|
||||
@ -75,10 +52,8 @@ describe('AppDetailTop', () => {
|
||||
const onToggle = vi.fn()
|
||||
|
||||
render(<AppDetailTop onToggle={onToggle} />)
|
||||
fireEvent.click(screen.getByTestId('toggle-button'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }))
|
||||
|
||||
expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-expand', 'true')
|
||||
expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-has-icon', 'true')
|
||||
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,30 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
import DatasetDetailTop from '../dataset-detail-top'
|
||||
|
||||
vi.mock('../toggle-button', () => ({
|
||||
default: ({
|
||||
expand,
|
||||
handleToggle,
|
||||
icon,
|
||||
}: {
|
||||
expand: boolean
|
||||
handleToggle: () => void
|
||||
icon?: ReactNode
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="toggle-button"
|
||||
data-expand={expand}
|
||||
data-has-icon={Boolean(icon)}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
Toggle
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
import { DatasetDetailTop } from '../dataset-detail-top'
|
||||
|
||||
function TestGotoAnythingDialog() {
|
||||
return (
|
||||
@ -73,10 +50,8 @@ describe('DatasetDetailTop', () => {
|
||||
const onToggle = vi.fn()
|
||||
|
||||
render(<DatasetDetailTop expand={false} onToggle={onToggle} />)
|
||||
fireEvent.click(screen.getByTestId('toggle-button'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' }))
|
||||
|
||||
expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-expand', 'false')
|
||||
expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-has-icon', 'true')
|
||||
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import ToggleButton from '../toggle-button'
|
||||
|
||||
describe('ToggleButton', () => {
|
||||
it('should render collapse arrow when expanded', () => {
|
||||
render(<ToggleButton expand handleToggle={vi.fn()} />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render expand arrow when collapsed', () => {
|
||||
render(<ToggleButton expand={false} handleToggle={vi.fn()} />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call handleToggle when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleToggle = vi.fn()
|
||||
render(<ToggleButton expand handleToggle={handleToggle} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(handleToggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
render(<ToggleButton expand handleToggle={vi.fn()} className="custom-class" />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should have rounded-full style', () => {
|
||||
render(<ToggleButton expand handleToggle={vi.fn()} />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('rounded-full')
|
||||
})
|
||||
})
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
|
||||
import AppDetailSection from './app-detail-section'
|
||||
import AppDetailTop from './app-detail-top'
|
||||
import { AppDetailTop } from './app-detail-top'
|
||||
|
||||
export function AppDetailSidebar() {
|
||||
return (
|
||||
|
||||
@ -6,27 +6,26 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||
import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
|
||||
import Link from '@/next/link'
|
||||
import ToggleButton from './toggle-button'
|
||||
|
||||
type AppDetailTopProps = {
|
||||
expand?: boolean
|
||||
onToggle?: () => void
|
||||
}
|
||||
|
||||
const SEARCH_SHORTCUT = ['Mod', 'K']
|
||||
|
||||
const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
|
||||
export function AppDetailTop({ expand = true, onToggle }: AppDetailTopProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!expand) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
|
||||
{onToggle && (
|
||||
<ToggleButton
|
||||
<DetailSidebarToggleButton
|
||||
expand={expand}
|
||||
handleToggle={onToggle}
|
||||
onToggle={onToggle}
|
||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||
/>
|
||||
@ -82,7 +81,7 @@ const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
|
||||
>
|
||||
<span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span>
|
||||
<KbdGroup>
|
||||
{SEARCH_SHORTCUT.map((key) => (
|
||||
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
|
||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
@ -90,9 +89,9 @@ const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
|
||||
</Tooltip>
|
||||
)}
|
||||
{onToggle && (
|
||||
<ToggleButton
|
||||
<DetailSidebarToggleButton
|
||||
expand={expand}
|
||||
handleToggle={onToggle}
|
||||
onToggle={onToggle}
|
||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||
/>
|
||||
@ -100,5 +99,3 @@ const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppDetailTop
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
|
||||
import DatasetDetailSection from './dataset-detail-section'
|
||||
import DatasetDetailTop from './dataset-detail-top'
|
||||
import { DatasetDetailTop } from './dataset-detail-top'
|
||||
|
||||
export function DatasetDetailSidebar() {
|
||||
return (
|
||||
|
||||
@ -6,27 +6,26 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||
import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
|
||||
import Link from '@/next/link'
|
||||
import ToggleButton from './toggle-button'
|
||||
|
||||
type DatasetDetailTopProps = {
|
||||
expand?: boolean
|
||||
onToggle?: () => void
|
||||
}
|
||||
|
||||
const SEARCH_SHORTCUT = ['Mod', 'K']
|
||||
|
||||
const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) => {
|
||||
export function DatasetDetailTop({ expand = true, onToggle }: DatasetDetailTopProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!expand) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
|
||||
{onToggle && (
|
||||
<ToggleButton
|
||||
<DetailSidebarToggleButton
|
||||
expand={expand}
|
||||
handleToggle={onToggle}
|
||||
onToggle={onToggle}
|
||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||
/>
|
||||
@ -82,7 +81,7 @@ const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) =>
|
||||
>
|
||||
<span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span>
|
||||
<KbdGroup>
|
||||
{SEARCH_SHORTCUT.map((key) => (
|
||||
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
|
||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
@ -90,9 +89,9 @@ const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) =>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onToggle && (
|
||||
<ToggleButton
|
||||
<DetailSidebarToggleButton
|
||||
expand={expand}
|
||||
handleToggle={onToggle}
|
||||
onToggle={onToggle}
|
||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||
/>
|
||||
@ -100,5 +99,3 @@ const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) =>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DatasetDetailTop
|
||||
|
||||
@ -140,7 +140,7 @@ type MockCreateSnippetDialogProps = {
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
|
||||
default: ({
|
||||
CreateSnippetDialog: ({
|
||||
isOpen,
|
||||
title,
|
||||
confirmText,
|
||||
|
||||
@ -22,7 +22,7 @@ import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
|
||||
import { CreateSnippetDialog } from '@/app/components/snippets/create-snippet-dialog'
|
||||
import {
|
||||
canCreateAndModifySnippets,
|
||||
canManageSnippets,
|
||||
|
||||
@ -1,76 +0,0 @@
|
||||
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 { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type ToggleTooltipContentProps = {
|
||||
expand: boolean
|
||||
}
|
||||
|
||||
const TOGGLE_SHORTCUT = ['Mod', 'B']
|
||||
|
||||
const ToggleTooltipContent = ({ expand }: ToggleTooltipContentProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
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>
|
||||
<KbdGroup>
|
||||
{TOGGLE_SHORTCUT.map((key) => (
|
||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ToggleButtonProps = {
|
||||
expand: boolean
|
||||
handleToggle: () => void
|
||||
className?: string
|
||||
icon?: React.ReactNode
|
||||
iconClassName?: string
|
||||
}
|
||||
|
||||
const ToggleButton = ({
|
||||
expand,
|
||||
handleToggle,
|
||||
className,
|
||||
icon,
|
||||
iconClassName,
|
||||
}: ToggleButtonProps) => {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleToggle}
|
||||
className={cn('rounded-full px-1', className)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{icon ||
|
||||
(iconClassName ? (
|
||||
<span aria-hidden className={cn('size-4', iconClassName)} />
|
||||
) : expand ? (
|
||||
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
|
||||
) : (
|
||||
<span aria-hidden className="i-ri-arrow-right-s-line size-4" />
|
||||
))}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent placement="right" className="rounded-lg p-1.5">
|
||||
<ToggleTooltipContent expand={expand} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ToggleButton)
|
||||
@ -285,7 +285,6 @@ describe('AppPublisher', () => {
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
expect(sectionProps.summary?.publishShortcut).toEqual(['Mod', 'Shift', 'P'])
|
||||
expect(mockRefetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@ -75,7 +75,6 @@ describe('app-publisher sections', () => {
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={Date.now()}
|
||||
publishShortcut={['Mod', 'Shift', 'P']}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
@ -113,7 +112,6 @@ describe('app-publisher sections', () => {
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
publishShortcut={['Mod', 'Shift', 'P']}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
@ -137,7 +135,6 @@ describe('app-publisher sections', () => {
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
publishShortcut={['Mod', 'Shift', 'P']}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
@ -161,7 +158,6 @@ describe('app-publisher sections', () => {
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
publishShortcut={['Mod', 'Shift', 'P']}
|
||||
startNodeLimitExceeded
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
|
||||
3
web/app/components/app/app-publisher/hotkeys.ts
Normal file
3
web/app/components/app/app-publisher/hotkeys.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||
|
||||
export const APP_PUBLISH_HOTKEY = 'Mod+Shift+P' satisfies Hotkey
|
||||
@ -1,4 +1,3 @@
|
||||
import type { RegisterableHotkey } from '@tanstack/react-hotkeys'
|
||||
import type { FormEvent } from 'react'
|
||||
import type { ModelAndParameter } from '../configuration/debug/types'
|
||||
import type {
|
||||
@ -45,6 +44,7 @@ import { fetchPublishedWorkflow } from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import AccessControl from '../app-access-control'
|
||||
import { APP_PUBLISH_HOTKEY } from './hotkeys'
|
||||
import {
|
||||
PublisherAccessSection,
|
||||
PublisherActionsSection,
|
||||
@ -81,9 +81,6 @@ export type AppPublisherProps = {
|
||||
hasHumanInputNode?: boolean
|
||||
}
|
||||
|
||||
const PUBLISH_HOTKEY = 'Mod+Shift+P' satisfies RegisterableHotkey
|
||||
const PUBLISH_SHORTCUT = PUBLISH_HOTKEY.split('+')
|
||||
|
||||
export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams
|
||||
|
||||
type AppPublisherPublishHandler =
|
||||
@ -307,7 +304,7 @@ export function AppPublisher({
|
||||
}
|
||||
}
|
||||
|
||||
useHotkey(PUBLISH_HOTKEY, (e) => {
|
||||
useHotkey(APP_PUBLISH_HOTKEY, (e) => {
|
||||
e.preventDefault()
|
||||
if (publishDisabled || published) return
|
||||
handlePublish()
|
||||
@ -410,7 +407,6 @@ export function AppPublisher({
|
||||
publishDisabled={publishDisabled}
|
||||
published={published}
|
||||
publishedAt={publishedAt}
|
||||
publishShortcut={PUBLISH_SHORTCUT}
|
||||
startNodeLimitExceeded={startNodeLimitExceeded}
|
||||
upgradeHighlightStyle={upgradeHighlightStyle}
|
||||
/>
|
||||
|
||||
@ -12,6 +12,7 @@ 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 { APP_PUBLISH_HOTKEY } from './hotkeys'
|
||||
import PublishWithMultipleModel from './publish-with-multiple-model'
|
||||
import SuggestedAction from './suggested-action'
|
||||
import { ACCESS_MODE_MAP } from './utils'
|
||||
@ -30,7 +31,6 @@ type SummarySectionProps = Pick<
|
||||
handleRestore: () => Promise<void>
|
||||
isChatApp: boolean
|
||||
published: boolean
|
||||
publishShortcut: string[]
|
||||
upgradeHighlightStyle: CSSProperties
|
||||
}
|
||||
|
||||
@ -109,7 +109,6 @@ export const PublisherSummarySection = ({
|
||||
publishDisabled = false,
|
||||
published,
|
||||
publishedAt,
|
||||
publishShortcut,
|
||||
startNodeLimitExceeded = false,
|
||||
upgradeHighlightStyle,
|
||||
}: SummarySectionProps) => {
|
||||
@ -163,7 +162,7 @@ export const PublisherSummarySection = ({
|
||||
<div className="flex gap-1">
|
||||
<span>{t(($) => $['common.publishUpdate'], { ns: 'workflow' })}</span>
|
||||
<KbdGroup>
|
||||
{publishShortcut.map((key) => (
|
||||
{APP_PUBLISH_HOTKEY.split('+').map((key) => (
|
||||
<Kbd key={key} color="white">
|
||||
{formatForDisplay(key)}
|
||||
</Kbd>
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||
import type { AppIconSelection } from '../../base/app-icon-picker'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
@ -22,7 +24,6 @@ import {
|
||||
ListSparkle,
|
||||
Logic,
|
||||
} from '@/app/components/base/icons/src/vender/solid/communication'
|
||||
import Input from '@/app/components/base/input'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { userProfileIdAtom } from '@/context/account-state'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
@ -47,6 +48,8 @@ type CreateAppProps = {
|
||||
defaultAppMode?: AppModeEnum
|
||||
}
|
||||
|
||||
const CREATE_APP_HOTKEY = 'Mod+Enter' satisfies Hotkey
|
||||
|
||||
const shouldExpandBeginnerAppTypes = (appMode?: AppModeEnum) => {
|
||||
return (
|
||||
appMode === AppModeEnum.CHAT ||
|
||||
@ -152,7 +155,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
||||
|
||||
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
|
||||
useHotkey(
|
||||
'Mod+Enter',
|
||||
CREATE_APP_HOTKEY,
|
||||
() => {
|
||||
if (isAppsFull || !canCreateApp) return
|
||||
handleCreateApp()
|
||||
@ -348,7 +351,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
||||
>
|
||||
<span>{t(($) => $['newApp.Create'], { ns: 'app' })}</span>
|
||||
<KbdGroup>
|
||||
{['Mod', 'Enter'].map((key) => (
|
||||
{CREATE_APP_HOTKEY.split('+').map((key) => (
|
||||
<Kbd key={key} color="white">
|
||||
{formatForDisplay(key)}
|
||||
</Kbd>
|
||||
@ -434,9 +437,10 @@ type AppTypeCardProps = {
|
||||
}
|
||||
function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardProps) {
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
`relative box-content h-[84px] w-[191px] cursor-pointer rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-3 shadow-xs hover:shadow-md`,
|
||||
'relative box-content h-[84px] w-[191px] cursor-pointer rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-3 text-left shadow-xs outline-hidden hover:shadow-md focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
active
|
||||
? 'shadow-md outline-[1.5px] outline-components-option-card-option-selected-border outline-solid'
|
||||
: '',
|
||||
@ -448,7 +452,7 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
|
||||
<div className="line-clamp-2 system-xs-regular text-text-tertiary" title={description}>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,8 @@ import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-syste
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/app/components/apps/storage'
|
||||
import { DSLImportMode, DSLImportStatus } from '@/models/app'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import CreateFromDSLModal, { CreateFromDSLModalTab } from '../index'
|
||||
import CreateFromDSLModal from '../index'
|
||||
import { CreateFromDSLModalTab } from '../types'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
const mockImportDSL = vi.fn()
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||
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 { Input } from '@langgenius/dify-ui/input'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
||||
@ -13,7 +15,6 @@ import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSetNeedRefreshAppList } from '@/app/components/apps/storage'
|
||||
import Input from '@/app/components/base/input'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
|
||||
import { userProfileIdAtom } from '@/context/account-state'
|
||||
@ -26,21 +27,19 @@ import { importDSL, importDSLConfirm } from '@/service/apps'
|
||||
import { useInvalidateAppList } from '@/service/use-apps'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
import { trackCreateApp } from '@/utils/create-app-tracking'
|
||||
import { CreateFromDSLModalTab } from './types'
|
||||
import Uploader from './uploader'
|
||||
|
||||
type CreateFromDSLModalProps = {
|
||||
show: boolean
|
||||
onSuccess?: () => void
|
||||
onClose: () => void
|
||||
activeTab?: string
|
||||
activeTab?: CreateFromDSLModalTab
|
||||
dslUrl?: string
|
||||
droppedFile?: File
|
||||
}
|
||||
|
||||
export enum CreateFromDSLModalTab {
|
||||
FROM_FILE = 'from-file',
|
||||
FROM_URL = 'from-url',
|
||||
}
|
||||
const CREATE_FROM_DSL_HOTKEY = 'Mod+Enter' satisfies Hotkey
|
||||
|
||||
const CreateFromDSLModal = ({
|
||||
show,
|
||||
@ -91,8 +90,8 @@ const CreateFromDSLModal = ({
|
||||
const isCreatingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (droppedFile) handleFile(droppedFile)
|
||||
}, [droppedFile, handleFile])
|
||||
if (droppedFile) readFile(droppedFile)
|
||||
}, [droppedFile, readFile])
|
||||
|
||||
const onCreate = async (_e?: React.MouseEvent) => {
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) return
|
||||
@ -179,7 +178,7 @@ const CreateFromDSLModal = ({
|
||||
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
|
||||
|
||||
useHotkey(
|
||||
'Mod+Enter',
|
||||
CREATE_FROM_DSL_HOTKEY,
|
||||
() => {
|
||||
handleCreateApp(undefined)
|
||||
},
|
||||
@ -251,16 +250,23 @@ const CreateFromDSLModal = ({
|
||||
<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">
|
||||
{t(($) => $.importApp, { ns: 'app' })}
|
||||
<div className="flex size-8 cursor-pointer items-center" onClick={() => onClose()}>
|
||||
<span className="i-ri-close-line size-5 text-text-tertiary" />
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
aria-label={t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
className="size-8 p-0"
|
||||
onClick={onClose}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line size-5 text-text-tertiary" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 system-md-semibold text-text-tertiary">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
key={tab.key}
|
||||
className={cn(
|
||||
'relative flex h-full cursor-pointer items-center',
|
||||
'relative flex h-full cursor-pointer items-center outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
currentTab === tab.key && 'text-text-primary',
|
||||
)}
|
||||
onClick={() => setCurrentTab(tab.key)}
|
||||
@ -269,7 +275,7 @@ const CreateFromDSLModal = ({
|
||||
{currentTab === tab.key && (
|
||||
<div className="absolute bottom-0 h-[2px] w-full bg-util-colors-blue-brand-blue-brand-600"></div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
@ -304,7 +310,7 @@ const CreateFromDSLModal = ({
|
||||
>
|
||||
<span>{t(($) => $['newApp.Create'], { ns: 'app' })}</span>
|
||||
<KbdGroup>
|
||||
{['Mod', 'Enter'].map((key) => (
|
||||
{CREATE_FROM_DSL_HOTKEY.split('+').map((key) => (
|
||||
<Kbd key={key} color="white">
|
||||
{formatForDisplay(key)}
|
||||
</Kbd>
|
||||
|
||||
7
web/app/components/app/create-from-dsl-modal/types.ts
Normal file
7
web/app/components/app/create-from-dsl-modal/types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export const CreateFromDSLModalTab = {
|
||||
FROM_FILE: 'from-file',
|
||||
FROM_URL: 'from-url',
|
||||
} as const
|
||||
|
||||
export type CreateFromDSLModalTab =
|
||||
(typeof CreateFromDSLModalTab)[keyof typeof CreateFromDSLModalTab]
|
||||
@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal'
|
||||
import { CreateFromDSLModalTab } from '../../hooks/use-dsl-import'
|
||||
import Tab from '../index'
|
||||
|
||||
// Tab Component Tests
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal'
|
||||
import { CreateFromDSLModalTab } from '../hooks/use-dsl-import'
|
||||
import Item from './item'
|
||||
|
||||
type TabProps = {
|
||||
|
||||
@ -49,7 +49,7 @@ vi.mock('@/service/knowledge/use-segment', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../completed/common/action-buttons', () => ({
|
||||
default: ({
|
||||
ActionButtons: ({
|
||||
handleCancel,
|
||||
handleSave,
|
||||
loading,
|
||||
|
||||
@ -31,7 +31,7 @@ vi.mock('@/context/event-emitter', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../common/action-buttons', () => ({
|
||||
default: ({
|
||||
ActionButtons: ({
|
||||
handleCancel,
|
||||
handleSave,
|
||||
loading,
|
||||
|
||||
@ -45,7 +45,7 @@ vi.mock('@/service/knowledge/use-segment', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../common/action-buttons', () => ({
|
||||
default: ({
|
||||
ActionButtons: ({
|
||||
handleCancel,
|
||||
handleSave,
|
||||
loading,
|
||||
|
||||
@ -53,7 +53,7 @@ vi.mock('@/context/event-emitter', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../common/action-buttons', () => ({
|
||||
default: ({
|
||||
ActionButtons: ({
|
||||
handleCancel,
|
||||
handleSave,
|
||||
handleRegeneration,
|
||||
|
||||
@ -9,7 +9,7 @@ import Divider from '@/app/components/base/divider'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { formatTime } from '@/utils/time'
|
||||
import ActionButtons from './common/action-buttons'
|
||||
import { ActionButtons } from './common/action-buttons'
|
||||
import ChunkContent from './common/chunk-content'
|
||||
import Dot from './common/dot'
|
||||
import { SegmentIndexTag } from './common/segment-index-tag'
|
||||
|
||||
@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import { DocumentContext } from '../../../context'
|
||||
import ActionButtons from '../action-buttons'
|
||||
import { ActionButtons } from '../action-buttons'
|
||||
|
||||
const mockUseHotkey = vi.fn()
|
||||
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
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 { ChunkingMode } from '@/models/datasets'
|
||||
import { useDocumentContext } from '../../context'
|
||||
|
||||
type IActionButtonsProps = {
|
||||
const CANCEL_HOTKEY = 'Escape' satisfies Hotkey
|
||||
const SAVE_HOTKEY = 'Mod+S' satisfies Hotkey
|
||||
|
||||
type ActionButtonsProps = {
|
||||
handleCancel: () => void
|
||||
handleSave: () => void
|
||||
loading: boolean
|
||||
@ -18,7 +19,7 @@ type IActionButtonsProps = {
|
||||
showRegenerationButton?: boolean
|
||||
}
|
||||
|
||||
const ActionButtons: FC<IActionButtonsProps> = ({
|
||||
export function ActionButtons({
|
||||
handleCancel,
|
||||
handleSave,
|
||||
loading,
|
||||
@ -26,25 +27,24 @@ const ActionButtons: FC<IActionButtonsProps> = ({
|
||||
handleRegeneration,
|
||||
isChildChunk = false,
|
||||
showRegenerationButton = true,
|
||||
}) => {
|
||||
}: ActionButtonsProps) {
|
||||
const { t } = useTranslation()
|
||||
const docForm = useDocumentContext((s) => s.docForm)
|
||||
const parentMode = useDocumentContext((s) => s.parentMode)
|
||||
|
||||
useHotkey('Escape', (e) => {
|
||||
useHotkey(CANCEL_HOTKEY, (e) => {
|
||||
e.preventDefault()
|
||||
handleCancel()
|
||||
})
|
||||
|
||||
useHotkey('Mod+S', (e) => {
|
||||
useHotkey(SAVE_HOTKEY, (e) => {
|
||||
e.preventDefault()
|
||||
if (loading) return
|
||||
handleSave()
|
||||
})
|
||||
|
||||
const isParentChildParagraphMode = useMemo(() => {
|
||||
return docForm === ChunkingMode.parentChild && parentMode === 'paragraph'
|
||||
}, [docForm, parentMode])
|
||||
const isParentChildParagraphMode =
|
||||
docForm === ChunkingMode.parentChild && parentMode === 'paragraph'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-2">
|
||||
@ -53,7 +53,7 @@ const ActionButtons: FC<IActionButtonsProps> = ({
|
||||
<span className="system-sm-medium text-components-button-secondary-text">
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</span>
|
||||
<Kbd>{formatForDisplay('Escape')}</Kbd>
|
||||
<Kbd>{formatForDisplay(CANCEL_HOTKEY)}</Kbd>
|
||||
</div>
|
||||
</Button>
|
||||
{isParentChildParagraphMode &&
|
||||
@ -72,7 +72,7 @@ const ActionButtons: FC<IActionButtonsProps> = ({
|
||||
{t(($) => $['operation.save'], { ns: 'common' })}
|
||||
</span>
|
||||
<KbdGroup>
|
||||
{['Mod', 'S'].map((key) => (
|
||||
{SAVE_HOTKEY.split('+').map((key) => (
|
||||
<Kbd key={key} color="white">
|
||||
{formatForDisplay(key)}
|
||||
</Kbd>
|
||||
@ -83,7 +83,3 @@ const ActionButtons: FC<IActionButtonsProps> = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ActionButtons.displayName = 'ActionButtons'
|
||||
|
||||
export default React.memo(ActionButtons)
|
||||
|
||||
@ -11,7 +11,7 @@ import { useParams } from '@/next/navigation'
|
||||
import { useAddChildSegment } from '@/service/knowledge/use-segment'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { useDocumentContext } from '../context'
|
||||
import ActionButtons from './common/action-buttons'
|
||||
import { ActionButtons } from './common/action-buttons'
|
||||
import AddAnother from './common/add-another'
|
||||
import ChunkContent from './common/chunk-content'
|
||||
import Dot from './common/dot'
|
||||
|
||||
@ -13,7 +13,7 @@ import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { useDocumentContext } from '../context'
|
||||
import ActionButtons from './common/action-buttons'
|
||||
import { ActionButtons } from './common/action-buttons'
|
||||
import ChunkContent from './common/chunk-content'
|
||||
import Dot from './common/dot'
|
||||
import Keywords from './common/keywords'
|
||||
|
||||
@ -15,7 +15,7 @@ import { useAddSegment } from '@/service/knowledge/use-segment'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { IndexingType } from '../../create/step-two'
|
||||
import { useSegmentListContext } from './completed'
|
||||
import ActionButtons from './completed/common/action-buttons'
|
||||
import { ActionButtons } from './completed/common/action-buttons'
|
||||
import AddAnother from './completed/common/add-another'
|
||||
import ChunkContent from './completed/common/chunk-content'
|
||||
import Dot from './completed/common/dot'
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { DetailSidebarFrame } from '..'
|
||||
import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage'
|
||||
|
||||
@ -6,8 +6,8 @@ const { hotkeyRegistrations } = vi.hoisted(() => ({
|
||||
hotkeyRegistrations: new Map<
|
||||
string,
|
||||
{
|
||||
handler: (event: { preventDefault: () => void }) => void
|
||||
options?: { ignoreInputs?: boolean }
|
||||
handler: () => void
|
||||
options?: { ignoreInputs?: boolean; preventDefault?: boolean }
|
||||
}
|
||||
>(),
|
||||
}))
|
||||
@ -25,8 +25,8 @@ vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
||||
...actual,
|
||||
useHotkey: (
|
||||
hotkey: string,
|
||||
handler: (event: { preventDefault: () => void }) => void,
|
||||
options?: { ignoreInputs?: boolean },
|
||||
handler: () => void,
|
||||
options?: { ignoreInputs?: boolean; preventDefault?: boolean },
|
||||
) => {
|
||||
hotkeyRegistrations.set(hotkey, { handler, options })
|
||||
},
|
||||
@ -117,10 +117,19 @@ describe('DetailSidebarFrame', () => {
|
||||
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'true')
|
||||
expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'true')
|
||||
expect(hotkeyRegistrations.get('Mod+B')?.options).toEqual(
|
||||
expect.objectContaining({ ignoreInputs: false }),
|
||||
expect.objectContaining({ ignoreInputs: false, preventDefault: true }),
|
||||
)
|
||||
})
|
||||
|
||||
it('toggles detail content from the registered shortcut', () => {
|
||||
renderDetailSidebarFrame()
|
||||
|
||||
act(() => hotkeyRegistrations.get('Mod+B')?.handler())
|
||||
|
||||
expect(screen.getByRole('complementary')).toHaveClass('w-16')
|
||||
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'false')
|
||||
})
|
||||
|
||||
it('collapses detail content from the top toggle and hides environment metadata', () => {
|
||||
mockAppContextState.current = {
|
||||
langGeniusVersionInfo: {
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { DetailSidebarToggleButton } from '../toggle-button'
|
||||
|
||||
describe('DetailSidebarToggleButton', () => {
|
||||
it('labels the expanded sidebar action', () => {
|
||||
render(<DetailSidebarToggleButton expand onToggle={vi.fn()} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('labels the collapsed sidebar action', () => {
|
||||
render(<DetailSidebarToggleButton expand={false} onToggle={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles the sidebar when activated', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onToggle = vi.fn()
|
||||
render(<DetailSidebarToggleButton expand onToggle={onToggle} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }))
|
||||
|
||||
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
3
web/app/components/detail-sidebar/hotkeys.ts
Normal file
3
web/app/components/detail-sidebar/hotkeys.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||
|
||||
export const DETAIL_SIDEBAR_TOGGLE_HOTKEY = 'Mod+B' satisfies Hotkey
|
||||
@ -4,11 +4,12 @@ import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import EnvNav from '@/app/components/header/env-nav'
|
||||
import AccountSection from '@/app/components/main-nav/components/account-section'
|
||||
import HelpMenu from '@/app/components/main-nav/components/help-menu'
|
||||
import { langGeniusVersionInfoAtom } from '@/context/version-state'
|
||||
import { DETAIL_SIDEBAR_TOGGLE_HOTKEY } from './hotkeys'
|
||||
import { useDetailSidebarMode } from './storage'
|
||||
|
||||
type DetailSidebarRenderProps = {
|
||||
@ -56,7 +57,7 @@ export function DetailSidebarFrame({
|
||||
const currentEnv = langGeniusVersionInfo?.current_env
|
||||
const showEnvTag = currentEnv === 'TESTING' || currentEnv === 'DEVELOPMENT'
|
||||
|
||||
const handleToggleDetailNavigation = useCallback(() => {
|
||||
function handleToggleDetailNavigation() {
|
||||
if (isDetailNavigationHoverPreviewOpen) {
|
||||
if (detailNavigationTransitionTimerRef.current)
|
||||
clearTimeout(detailNavigationTransitionTimerRef.current)
|
||||
@ -73,25 +74,25 @@ export function DetailSidebarFrame({
|
||||
const nextMode = detailNavigationExpanded ? 'collapse' : 'expand'
|
||||
setDetailNavigationHoverPreviewOpen(false)
|
||||
setStoredDetailSidebarExpand(nextMode)
|
||||
}, [detailNavigationExpanded, isDetailNavigationHoverPreviewOpen, setStoredDetailSidebarExpand])
|
||||
}
|
||||
|
||||
const openDetailNavigationHoverPreview = useCallback(() => {
|
||||
function openDetailNavigationHoverPreview() {
|
||||
if (detailNavigationExpanded) return
|
||||
|
||||
if (closeDetailNavigationHoverPreviewTimerRef.current)
|
||||
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
|
||||
|
||||
setDetailNavigationHoverPreviewOpen(true)
|
||||
}, [detailNavigationExpanded])
|
||||
}
|
||||
|
||||
const closeDetailNavigationHoverPreview = useCallback(() => {
|
||||
function closeDetailNavigationHoverPreview() {
|
||||
if (closeDetailNavigationHoverPreviewTimerRef.current)
|
||||
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
|
||||
|
||||
closeDetailNavigationHoverPreviewTimerRef.current = setTimeout(() => {
|
||||
setDetailNavigationHoverPreviewOpen(false)
|
||||
}, 120)
|
||||
}, [])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@ -102,16 +103,10 @@ export function DetailSidebarFrame({
|
||||
}
|
||||
}, [])
|
||||
|
||||
useHotkey(
|
||||
'Mod+B',
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
handleToggleDetailNavigation()
|
||||
},
|
||||
{
|
||||
ignoreInputs: false,
|
||||
},
|
||||
)
|
||||
useHotkey(DETAIL_SIDEBAR_TOGGLE_HOTKEY, handleToggleDetailNavigation, {
|
||||
ignoreInputs: false,
|
||||
preventDefault: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<aside
|
||||
|
||||
62
web/app/components/detail-sidebar/toggle-button.tsx
Normal file
62
web/app/components/detail-sidebar/toggle-button.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import type { ReactNode } from 'react'
|
||||
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 { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DETAIL_SIDEBAR_TOGGLE_HOTKEY } from './hotkeys'
|
||||
|
||||
const detailSidebarToggleShortcutKeys = DETAIL_SIDEBAR_TOGGLE_HOTKEY.split('+')
|
||||
|
||||
type DetailSidebarToggleButtonProps = {
|
||||
expand: boolean
|
||||
onToggle: () => void
|
||||
className?: string
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
export function DetailSidebarToggleButton({
|
||||
expand,
|
||||
onToggle,
|
||||
className,
|
||||
icon,
|
||||
}: DetailSidebarToggleButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const label = expand
|
||||
? t(($) => $['sidebar.collapseSidebar'], { ns: 'layout' })
|
||||
: t(($) => $['sidebar.expandSidebar'], { ns: 'layout' })
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
aria-label={label}
|
||||
onClick={onToggle}
|
||||
className={cn('rounded-full px-1', className)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{icon ??
|
||||
(expand ? (
|
||||
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
|
||||
) : (
|
||||
<span aria-hidden className="i-ri-arrow-right-s-line size-4" />
|
||||
))}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent placement="right" className="rounded-lg p-1.5">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<span className="px-0.5 system-xs-medium text-text-secondary">{label}</span>
|
||||
<KbdGroup>
|
||||
{detailSidebarToggleShortcutKeys.map((key) => (
|
||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
'use client'
|
||||
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
@ -45,6 +46,8 @@ export type CreateAppModalProps = {
|
||||
|
||||
type CreateAppPayload = Parameters<CreateAppModalProps['onConfirm']>[0]
|
||||
|
||||
const SUBMIT_APP_HOTKEY = 'Mod+Enter' satisfies Hotkey
|
||||
|
||||
const CreateAppModal = ({
|
||||
show = false,
|
||||
isEditModal = false,
|
||||
@ -118,7 +121,7 @@ const CreateAppModal = ({
|
||||
const { run: handleSubmit } = useDebounceFn(submit, { wait: 300 })
|
||||
|
||||
useHotkey(
|
||||
'Mod+Enter',
|
||||
SUBMIT_APP_HOTKEY,
|
||||
() => {
|
||||
handleSubmit()
|
||||
},
|
||||
@ -237,7 +240,7 @@ const CreateAppModal = ({
|
||||
: t(($) => $['operation.save'], { ns: 'common' })}
|
||||
</span>
|
||||
<KbdGroup>
|
||||
{['Mod', 'Enter'].map((key) => (
|
||||
{SUBMIT_APP_HOTKEY.split('+').map((key) => (
|
||||
<Kbd key={key} color="white">
|
||||
{formatForDisplay(key)}
|
||||
</Kbd>
|
||||
|
||||
3
web/app/components/goto-anything/hotkeys.ts
Normal file
3
web/app/components/goto-anything/hotkeys.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||
|
||||
export const GOTO_ANYTHING_HOTKEY = 'Mod+K' satisfies Hotkey
|
||||
@ -51,12 +51,11 @@ import { addRecentItem, getRecentItems } from './actions/recent-store'
|
||||
import { EmptyState } from './components/empty-state'
|
||||
import { Footer } from './components/footer'
|
||||
import { gotoAnythingDialogHandle } from './dialog-handle'
|
||||
import { GOTO_ANYTHING_HOTKEY } from './hotkeys'
|
||||
|
||||
const appWorkflowPathPattern = /^\/app\/[^/]+\/workflow$/
|
||||
const sharedWorkflowPathPattern = /^\/workflow\/[^/]+$/
|
||||
const ragPipelinePathPattern = /^\/datasets\/[^/]+\/pipeline$/
|
||||
const searchHotkey = 'Mod+K'
|
||||
const searchShortcut = searchHotkey.split('+')
|
||||
|
||||
type CommandOption = {
|
||||
kind: 'command-option'
|
||||
@ -288,7 +287,7 @@ function GotoAnythingDialog() {
|
||||
}
|
||||
|
||||
useHotkey(
|
||||
searchHotkey,
|
||||
GOTO_ANYTHING_HOTKEY,
|
||||
(event) => {
|
||||
if (event.defaultPrevented) return
|
||||
if (!gotoAnythingDialogHandle.isOpen && isEditableShortcutTarget(event.target)) return
|
||||
@ -436,7 +435,7 @@ function GotoAnythingDialog() {
|
||||
)}
|
||||
</div>
|
||||
<KbdGroup>
|
||||
{searchShortcut.map((key) => (
|
||||
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
|
||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
|
||||
@ -5,8 +5,7 @@ import { Kbd } from '@langgenius/dify-ui/kbd'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
|
||||
const searchShortcut = ['Mod', 'K']
|
||||
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
|
||||
|
||||
export function MainNavSearchButton() {
|
||||
const { t } = useTranslation()
|
||||
@ -24,7 +23,7 @@ export function MainNavSearchButton() {
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-main-nav-quick-search h-4 w-4" />
|
||||
<Kbd className="h-4.5 min-w-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{searchShortcut.map((key) => (
|
||||
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
|
||||
<span key={key}>{formatForDisplay(key)}</span>
|
||||
))}
|
||||
</Kbd>
|
||||
|
||||
@ -5,8 +5,8 @@ import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import RagPipelineHeader from '../index'
|
||||
import InputFieldButton from '../input-field-button'
|
||||
import Publisher from '../publisher'
|
||||
import Popup from '../publisher/popup'
|
||||
import RunMode from '../run-mode'
|
||||
import { Popup } from '../publisher/popup'
|
||||
import { RunMode } from '../run-mode'
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
@ -1080,12 +1080,6 @@ describe('RunMode', () => {
|
||||
expect(wrapper)!.toHaveClass('items-center')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Memoization', () => {
|
||||
it('should be wrapped in React.memo', () => {
|
||||
expect((RunMode as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration', () => {
|
||||
|
||||
@ -1,11 +1,35 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import RunMode from '../run-mode'
|
||||
import { RunMode } from '../run-mode'
|
||||
|
||||
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
|
||||
const mockHandleStopRun = vi.fn()
|
||||
const mockSetIsPreparingDataSource = vi.fn()
|
||||
const mockSetShowDebugAndPreviewPanel = vi.fn()
|
||||
const hotkeyRegistrations = vi.hoisted(
|
||||
() =>
|
||||
new Map<
|
||||
string,
|
||||
{
|
||||
callback: () => void
|
||||
options?: { enabled?: boolean; ignoreInputs?: boolean; preventDefault?: boolean }
|
||||
}
|
||||
>(),
|
||||
)
|
||||
|
||||
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
|
||||
return {
|
||||
...actual,
|
||||
useHotkey: (
|
||||
hotkey: string,
|
||||
callback: () => void,
|
||||
options?: { enabled?: boolean; ignoreInputs?: boolean; preventDefault?: boolean },
|
||||
) => {
|
||||
hotkeyRegistrations.set(hotkey, { callback, options })
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
let mockWorkflowRunningData: { task_id: string; result: { status: string } } | undefined
|
||||
let mockIsPreparingDataSource = false
|
||||
@ -75,6 +99,7 @@ describe('RunMode', () => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkflowRunningData = undefined
|
||||
mockIsPreparingDataSource = false
|
||||
hotkeyRegistrations.clear()
|
||||
})
|
||||
|
||||
describe('Idle state', () => {
|
||||
@ -109,6 +134,20 @@ describe('RunMode', () => {
|
||||
|
||||
expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should run through the enabled application shortcut', () => {
|
||||
render(<RunMode />)
|
||||
|
||||
const registration = hotkeyRegistrations.get('Alt+R')
|
||||
registration?.callback()
|
||||
|
||||
expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalledOnce()
|
||||
expect(registration?.options).toEqual({
|
||||
enabled: true,
|
||||
ignoreInputs: true,
|
||||
preventDefault: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Running state', () => {
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||
|
||||
export const RAG_PIPELINE_RUN_HOTKEY = 'Alt+R' satisfies Hotkey
|
||||
export const RAG_PIPELINE_PUBLISH_HOTKEY = 'Mod+Shift+P' satisfies Hotkey
|
||||
@ -4,7 +4,7 @@ import Header from '@/app/components/workflow/header'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import InputFieldButton from './input-field-button'
|
||||
import Publisher from './publisher'
|
||||
import RunMode from './run-mode'
|
||||
import { RunMode } from './run-mode'
|
||||
|
||||
const RagPipelineHeader = () => {
|
||||
const pipelineId = useStore((s) => s.pipelineId)
|
||||
|
||||
@ -4,7 +4,7 @@ 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'
|
||||
import { Popup } from '../popup'
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
@ -971,13 +971,6 @@ describe('publisher', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
expect(Popup).toBeDefined()
|
||||
expect((Popup as unknown as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Prop Variations', () => {
|
||||
it('should display correct width when permission is allowed', () => {
|
||||
mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(true)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Popup from '../popup'
|
||||
import { Popup } from '../popup'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
|
||||
AlertDialog: ({
|
||||
|
||||
@ -17,7 +17,7 @@ import {
|
||||
usePublishAsCustomizedPipeline,
|
||||
} from '@/service/use-pipeline'
|
||||
import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline-modal'
|
||||
import Popup from './popup'
|
||||
import { Popup } from './popup'
|
||||
|
||||
const Publisher = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@ -16,7 +16,7 @@ import { RiArrowRightUpLine, RiPlayCircleLine, RiTerminalBoxLine } from '@remixi
|
||||
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
@ -42,8 +42,8 @@ import { useInvalid } from '@/service/use-base'
|
||||
import { publishedPipelineInfoQueryKeyPrefix } from '@/service/use-pipeline'
|
||||
import { usePublishWorkflow } from '@/service/use-workflow'
|
||||
import { getDatasetACLCapabilities } from '@/utils/permission'
|
||||
import { RAG_PIPELINE_PUBLISH_HOTKEY } from '../hotkeys'
|
||||
|
||||
const PUBLISH_SHORTCUT = ['Mod', 'Shift', 'P']
|
||||
type PopupProps = {
|
||||
onRequestClose?: () => void
|
||||
confirmVisible?: boolean
|
||||
@ -53,14 +53,14 @@ type PopupProps = {
|
||||
onShowPublishAsKnowledgePipelineModal?: () => void
|
||||
}
|
||||
|
||||
const Popup = ({
|
||||
export function Popup({
|
||||
onRequestClose,
|
||||
confirmVisible: controlledConfirmVisible,
|
||||
onShowConfirm,
|
||||
onHideConfirm,
|
||||
isPublishingAsCustomizedPipeline = false,
|
||||
onShowPublishAsKnowledgePipelineModal,
|
||||
}: PopupProps) => {
|
||||
}: PopupProps) {
|
||||
const { t } = useTranslation()
|
||||
const { datasetId } = useParams()
|
||||
const { push } = useRouter()
|
||||
@ -182,10 +182,10 @@ const Popup = ({
|
||||
handleHideConfirm,
|
||||
],
|
||||
)
|
||||
useHotkey('Mod+Shift+P', (e) => {
|
||||
e.preventDefault()
|
||||
if (published) return
|
||||
handlePublish()
|
||||
useHotkey(RAG_PIPELINE_PUBLISH_HOTKEY, () => void handlePublish(), {
|
||||
enabled: !published && !publishing,
|
||||
ignoreInputs: true,
|
||||
preventDefault: true,
|
||||
})
|
||||
const goToAddDocuments = useCallback(() => {
|
||||
if (isAddDocumentsDisabled) return
|
||||
@ -243,7 +243,7 @@ const Popup = ({
|
||||
<div className="flex gap-1">
|
||||
<span>{t(($) => $['common.publishUpdate'], { ns: 'workflow' })}</span>
|
||||
<KbdGroup>
|
||||
{PUBLISH_SHORTCUT.map((key) => (
|
||||
{RAG_PIPELINE_PUBLISH_HOTKEY.split('+').map((key) => (
|
||||
<Kbd key={key} color="white">
|
||||
{formatForDisplay(key)}
|
||||
</Kbd>
|
||||
@ -334,4 +334,3 @@ const Popup = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default memo(Popup)
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
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 { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
|
||||
@ -12,12 +11,13 @@ 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'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { RAG_PIPELINE_RUN_HOTKEY } from './hotkeys'
|
||||
|
||||
type RunModeProps = {
|
||||
text?: string
|
||||
}
|
||||
|
||||
const RunMode = ({ text }: RunModeProps) => {
|
||||
export function RunMode({ text }: RunModeProps) {
|
||||
const { t } = useTranslation()
|
||||
const { handleWorkflowStartRunInWorkflow } = useWorkflowStartRun()
|
||||
const { handleStopRun } = useWorkflowRun()
|
||||
@ -44,6 +44,17 @@ const RunMode = ({ text }: RunModeProps) => {
|
||||
if (v.type === EVENT_WORKFLOW_STOP) handleStop()
|
||||
})
|
||||
|
||||
function handleRun() {
|
||||
if (isDisabled) return
|
||||
handleWorkflowStartRunInWorkflow()
|
||||
}
|
||||
|
||||
useHotkey(RAG_PIPELINE_RUN_HOTKEY, handleRun, {
|
||||
enabled: !isDisabled,
|
||||
ignoreInputs: true,
|
||||
preventDefault: true,
|
||||
})
|
||||
|
||||
if (!canRun) return null
|
||||
|
||||
return (
|
||||
@ -55,9 +66,7 @@ const RunMode = ({ text }: RunModeProps) => {
|
||||
isDisabled && 'cursor-not-allowed bg-state-accent-hover',
|
||||
isDisabled ? 'rounded-l-md' : 'rounded-md',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (canRun) handleWorkflowStartRunInWorkflow()
|
||||
}}
|
||||
onClick={handleRun}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{!isDisabled && (
|
||||
@ -82,7 +91,7 @@ const RunMode = ({ text }: RunModeProps) => {
|
||||
)}
|
||||
{!isDisabled && (
|
||||
<KbdGroup>
|
||||
{['Alt', 'R'].map((key) => (
|
||||
{RAG_PIPELINE_RUN_HOTKEY.split('+').map((key) => (
|
||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
@ -113,5 +122,3 @@ const RunMode = ({ text }: RunModeProps) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(RunMode)
|
||||
|
||||
@ -184,7 +184,7 @@ vi.mock('@/features/tag-management/components/tag-filter', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
|
||||
default: () => null,
|
||||
CreateSnippetDialog: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/tag-management/components/tag-selector', () => ({
|
||||
|
||||
@ -22,7 +22,7 @@ import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
|
||||
import { CreateSnippetDialog } from '@/app/components/snippets/create-snippet-dialog'
|
||||
import {
|
||||
canCreateAndModifySnippets,
|
||||
canManageSnippets,
|
||||
|
||||
@ -4,7 +4,7 @@ import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
|
||||
import { CreateSnippetDialog } from '@/app/components/snippets/create-snippet-dialog'
|
||||
import { useCreateSnippet } from '@/app/components/snippets/hooks/use-create-snippet'
|
||||
import ImportSnippetDSLDialog from '@/app/components/snippets/import-snippet-dsl-dialog'
|
||||
|
||||
|
||||
@ -3,15 +3,43 @@ import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { expectLoadingButton } from '@/test/button'
|
||||
import CreateSnippetDialog from '../create-snippet-dialog'
|
||||
import { CreateSnippetDialog } from '../create-snippet-dialog'
|
||||
|
||||
let capturedKeyPressHandler: (() => void) | undefined
|
||||
let capturedHotkey: string | undefined
|
||||
let capturedHotkeyOptions:
|
||||
| {
|
||||
enabled?: boolean
|
||||
ignoreInputs?: boolean
|
||||
preventDefault?: boolean
|
||||
stopPropagation?: boolean
|
||||
target?: React.RefObject<HTMLElement | null>
|
||||
}
|
||||
| undefined
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useKeyPress: (_keys: string[], handler: () => void) => {
|
||||
capturedKeyPressHandler = 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
|
||||
ignoreInputs?: boolean
|
||||
preventDefault?: boolean
|
||||
stopPropagation?: boolean
|
||||
target?: React.RefObject<HTMLElement | null>
|
||||
},
|
||||
) => {
|
||||
capturedHotkey = hotkey
|
||||
capturedKeyPressHandler = () => {
|
||||
if (options?.enabled !== false) handler()
|
||||
}
|
||||
capturedHotkeyOptions = options
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const selectedGraph: SnippetCanvasData = {
|
||||
nodes: [],
|
||||
@ -32,6 +60,8 @@ describe('CreateSnippetDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedKeyPressHandler = undefined
|
||||
capturedHotkey = undefined
|
||||
capturedHotkeyOptions = undefined
|
||||
})
|
||||
|
||||
it('should submit trimmed snippet values with the selected graph and input fields', async () => {
|
||||
@ -178,6 +208,14 @@ describe('CreateSnippetDialog', () => {
|
||||
name: 'Keyboard snippet',
|
||||
}),
|
||||
)
|
||||
expect(capturedHotkeyOptions).toMatchObject({
|
||||
enabled: true,
|
||||
ignoreInputs: false,
|
||||
preventDefault: false,
|
||||
stopPropagation: false,
|
||||
})
|
||||
expect(capturedHotkeyOptions?.target?.current).toBe(screen.getByRole('dialog'))
|
||||
expect(capturedHotkey).toBe('Mod+Enter')
|
||||
})
|
||||
|
||||
it('should disable form controls while submitting', () => {
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
|
||||
import { SnippetDetailSection } from './snippet-detail-section'
|
||||
import SnippetDetailTop from './snippet-detail-top'
|
||||
import { SnippetDetailTop } from './snippet-detail-top'
|
||||
|
||||
export function SnippetDetailSidebar() {
|
||||
return (
|
||||
|
||||
@ -6,19 +6,18 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||
import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
|
||||
import Link from '@/next/link'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import ToggleButton from '../../app-sidebar/toggle-button'
|
||||
|
||||
type SnippetDetailTopProps = {
|
||||
expand?: boolean
|
||||
onToggle?: () => void
|
||||
}
|
||||
|
||||
const SEARCH_SHORTCUT = ['Mod', 'K']
|
||||
|
||||
const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) => {
|
||||
export function SnippetDetailTop({ expand = true, onToggle }: SnippetDetailTopProps) {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
|
||||
@ -26,9 +25,9 @@ const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) =>
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
|
||||
{onToggle && (
|
||||
<ToggleButton
|
||||
<DetailSidebarToggleButton
|
||||
expand={expand}
|
||||
handleToggle={onToggle}
|
||||
onToggle={onToggle}
|
||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||
/>
|
||||
@ -88,16 +87,16 @@ const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) =>
|
||||
>
|
||||
<span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span>
|
||||
<KbdGroup>
|
||||
{SEARCH_SHORTCUT.map((key) => (
|
||||
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
|
||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onToggle && (
|
||||
<ToggleButton
|
||||
<DetailSidebarToggleButton
|
||||
expand={expand}
|
||||
handleToggle={onToggle}
|
||||
onToggle={onToggle}
|
||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||
/>
|
||||
@ -105,5 +104,3 @@ const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) =>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetDetailTop
|
||||
|
||||
@ -4,8 +4,8 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TEST_RUN_MENU_HOTKEY } from '@/app/components/workflow/header/shortcuts.ts'
|
||||
import { useWorkflowRun, useWorkflowStartRun } from '@/app/components/workflow/hooks'
|
||||
import { TEST_RUN_MENU_HOTKEY } from '@/app/components/workflow/hotkeys'
|
||||
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
|
||||
@ -1,14 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||
import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogCloseButton,
|
||||
DialogPopup,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const CREATE_SNIPPET_HOTKEY = 'Mod+Enter' satisfies Hotkey
|
||||
|
||||
export type CreateSnippetDialogPayload = {
|
||||
name: string
|
||||
description: string
|
||||
@ -39,7 +49,7 @@ const defaultGraph: SnippetCanvasData = {
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
}
|
||||
|
||||
function CreateSnippetDialog({
|
||||
export function CreateSnippetDialog({
|
||||
isOpen,
|
||||
selectedGraph,
|
||||
inputFields,
|
||||
@ -51,20 +61,21 @@ function CreateSnippetDialog({
|
||||
initialValue,
|
||||
}: CreateSnippetDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const popupRef = useRef<HTMLDivElement>(null)
|
||||
const [name, setName] = useState(initialValue?.name ?? '')
|
||||
const [description, setDescription] = useState(initialValue?.description ?? '')
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
function resetForm() {
|
||||
setName('')
|
||||
setDescription('')
|
||||
}, [])
|
||||
}
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
function handleClose() {
|
||||
resetForm()
|
||||
onClose()
|
||||
}, [onClose, resetForm])
|
||||
}
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
function handleConfirm() {
|
||||
const trimmedName = name.trim()
|
||||
const trimmedDescription = description.trim()
|
||||
|
||||
@ -78,75 +89,79 @@ function CreateSnippetDialog({
|
||||
}
|
||||
|
||||
onConfirm(payload)
|
||||
}, [description, inputFields, name, onConfirm, selectedGraph])
|
||||
}
|
||||
|
||||
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
|
||||
if (!isOpen) return
|
||||
|
||||
if (isSubmitting) return
|
||||
|
||||
handleConfirm()
|
||||
useHotkey(CREATE_SNIPPET_HOTKEY, handleConfirm, {
|
||||
enabled: isOpen && !isSubmitting,
|
||||
ignoreInputs: false,
|
||||
preventDefault: false,
|
||||
stopPropagation: false,
|
||||
target: popupRef,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent className="w-120 max-w-120 p-0">
|
||||
<DialogCloseButton />
|
||||
<DialogPortal>
|
||||
<DialogBackdrop />
|
||||
<DialogPopup
|
||||
ref={popupRef}
|
||||
className="fixed top-1/2 left-1/2 max-h-[80dvh] w-120 max-w-120 -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain p-0"
|
||||
>
|
||||
<DialogCloseButton />
|
||||
|
||||
<div className="px-6 pt-6 pb-3">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{title || t(($) => $['snippet.createDialogTitle'], { ns: 'workflow' })}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 py-2">
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
|
||||
{t(($) => $['snippet.nameLabel'], { ns: 'workflow' })}
|
||||
</div>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t(($) => $['snippet.namePlaceholder'], { ns: 'workflow' }) || ''}
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="px-6 pt-6 pb-3">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{title || t(($) => $['snippet.createDialogTitle'], { ns: 'workflow' })}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
|
||||
{t(($) => $['snippet.descriptionLabel'], { ns: 'workflow' })}
|
||||
<div className="space-y-4 px-6 py-2">
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
|
||||
{t(($) => $['snippet.nameLabel'], { ns: 'workflow' })}
|
||||
</div>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t(($) => $['snippet.namePlaceholder'], { ns: 'workflow' }) || ''}
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
className="resize-none"
|
||||
value={description}
|
||||
onValueChange={(value) => setDescription(value)}
|
||||
placeholder={
|
||||
t(($) => $['snippet.descriptionPlaceholder'], { ns: 'workflow' }) || ''
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 pb-6">
|
||||
<Button disabled={isSubmitting} onClick={handleClose}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!name.trim() || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{confirmText || t(($) => $['snippet.confirm'], { ns: 'workflow' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
|
||||
{t(($) => $['snippet.descriptionLabel'], { ns: 'workflow' })}
|
||||
</div>
|
||||
<Textarea
|
||||
className="resize-none"
|
||||
value={description}
|
||||
onValueChange={(value) => setDescription(value)}
|
||||
placeholder={
|
||||
t(($) => $['snippet.descriptionPlaceholder'], { ns: 'workflow' }) || ''
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 pb-6">
|
||||
<Button disabled={isSubmitting} onClick={handleClose}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!name.trim() || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{confirmText || t(($) => $['snippet.confirm'], { ns: 'workflow' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogPopup>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateSnippetDialog
|
||||
|
||||
@ -2,7 +2,7 @@ import type { Edge, Node, ValueSelector } from '@/app/components/workflow/types'
|
||||
import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { getNodesBounds } from 'reactflow'
|
||||
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
|
||||
import { CreateSnippetDialog } from '@/app/components/snippets/create-snippet-dialog'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { useCreateSnippet } from './use-create-snippet'
|
||||
|
||||
|
||||
33
web/app/components/workflow/__tests__/hotkeys.spec.ts
Normal file
33
web/app/components/workflow/__tests__/hotkeys.spec.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { detectPlatform } from '@tanstack/react-hotkeys'
|
||||
import { shouldPreventWorkflowBrowserDefault } from '../hotkeys'
|
||||
|
||||
const primaryModifier = detectPlatform() === 'mac' ? { metaKey: true } : { ctrlKey: true }
|
||||
|
||||
function createKeyboardEvent(key: string, modifiers: KeyboardEventInit = {}) {
|
||||
return new KeyboardEvent('keydown', { key, ...modifiers })
|
||||
}
|
||||
|
||||
describe('workflow browser default hotkeys', () => {
|
||||
it.each(['d', 'z', 'y', 's'])('matches the exact Mod+%s browser guard', (key) => {
|
||||
expect(shouldPreventWorkflowBrowserDefault(createKeyboardEvent(key, primaryModifier))).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('matches the alternate redo hotkey', () => {
|
||||
expect(
|
||||
shouldPreventWorkflowBrowserDefault(
|
||||
createKeyboardEvent('z', { ...primaryModifier, shiftKey: true }),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match plain or over-modified keys', () => {
|
||||
expect(shouldPreventWorkflowBrowserDefault(createKeyboardEvent('s'))).toBe(false)
|
||||
expect(
|
||||
shouldPreventWorkflowBrowserDefault(
|
||||
createKeyboardEvent('s', { ...primaryModifier, shiftKey: true }),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -80,7 +80,7 @@ vi.mock('@/app/components/snippets/hooks/use-create-snippet', async () => {
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
|
||||
default: (props: {
|
||||
CreateSnippetDialog: (props: {
|
||||
isOpen: boolean
|
||||
selectedGraph?: {
|
||||
nodes: Node[]
|
||||
|
||||
@ -147,7 +147,7 @@ vi.mock('../run-and-history', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../version-history-button', () => ({
|
||||
default: ({ onClick }: { onClick: () => void }) => (
|
||||
VersionHistoryButton: ({ onClick }: { onClick: () => void }) => (
|
||||
<button type="button" onClick={onClick}>
|
||||
version-history
|
||||
</button>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import VersionHistoryButton from '../version-history-button'
|
||||
import { VersionHistoryButton } from '../version-history-button'
|
||||
|
||||
let mockTheme: 'light' | 'dark' = 'light'
|
||||
const hotkeyRegistrations = vi.hoisted(
|
||||
|
||||
@ -13,7 +13,7 @@ import GlobalVariableButton from './global-variable-button'
|
||||
import OnlineUsers from './online-users'
|
||||
import RunAndHistory from './run-and-history'
|
||||
import ScrollToSelectedNodeButton from './scroll-to-selected-node-button'
|
||||
import VersionHistoryButton from './version-history-button'
|
||||
import { VersionHistoryButton } from './version-history-button'
|
||||
|
||||
export type HeaderInNormalProps = {
|
||||
components?: {
|
||||
|
||||
@ -19,7 +19,7 @@ import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { useDynamicTestRunOptions } from '../hooks/use-dynamic-test-run-options'
|
||||
import { TEST_RUN_MENU_HOTKEY } from './shortcuts'
|
||||
import { TEST_RUN_MENU_HOTKEY } from '../hotkeys'
|
||||
import TestRunMenu, { TriggerType } from './test-run-menu'
|
||||
|
||||
type RunModeProps = {
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export const TEST_RUN_MENU_HOTKEY = 'Alt+R'
|
||||
@ -32,7 +32,9 @@ export const OptionRow = ({
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">{option.icon}</div>
|
||||
<span className="ml-2 truncate">{option.name}</span>
|
||||
</div>
|
||||
{shortcutKey && <ShortcutKbd hotkey={shortcutKey} className="ml-2" textColor="secondary" />}
|
||||
{shortcutKey && (
|
||||
<ShortcutKbd displayKey={shortcutKey} className="ml-2" textColor="secondary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,21 +1,17 @@
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useHotkey } from '@tanstack/react-hotkeys'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { VERSION_HISTORY_HOTKEY } from '../hotkeys'
|
||||
import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
|
||||
|
||||
const VERSION_HISTORY_HOTKEY = 'Mod+Shift+H'
|
||||
|
||||
type VersionHistoryButtonProps = {
|
||||
onClick: () => Promise<unknown> | unknown
|
||||
}
|
||||
|
||||
const PopupContent = React.memo(() => {
|
||||
function PopupContent() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex items-center gap-x-1">
|
||||
@ -25,20 +21,15 @@ const PopupContent = React.memo(() => {
|
||||
<ShortcutKbd hotkey={VERSION_HISTORY_HOTKEY} bgColor="gray" textColor="secondary" />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
PopupContent.displayName = 'PopupContent'
|
||||
|
||||
const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({ onClick }) => {
|
||||
export function VersionHistoryButton({ onClick }: VersionHistoryButtonProps) {
|
||||
const { theme } = useTheme()
|
||||
const handleViewVersionHistory = useCallback(async () => {
|
||||
await onClick?.()
|
||||
}, [onClick])
|
||||
|
||||
useHotkey(
|
||||
VERSION_HISTORY_HOTKEY,
|
||||
() => {
|
||||
void handleViewVersionHistory()
|
||||
void onClick()
|
||||
},
|
||||
{
|
||||
ignoreInputs: true,
|
||||
@ -54,7 +45,7 @@ const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({ onClick }) => {
|
||||
'rounded-lg border border-transparent p-2',
|
||||
theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-xs',
|
||||
)}
|
||||
onClick={handleViewVersionHistory}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="i-ri-history-line size-4 text-components-button-secondary-text" />
|
||||
</Button>
|
||||
@ -66,5 +57,3 @@ const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({ onClick }) => {
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default VersionHistoryButton
|
||||
|
||||
19
web/app/components/workflow/hotkeys.ts
Normal file
19
web/app/components/workflow/hotkeys.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||
import { matchesKeyboardEvent } from '@tanstack/react-hotkeys'
|
||||
import { WORKFLOW_CANVAS_SHORTCUTS } from './shortcuts/definitions'
|
||||
|
||||
export const TEST_RUN_MENU_HOTKEY = 'Alt+R' satisfies Hotkey
|
||||
export const VERSION_HISTORY_HOTKEY = 'Mod+Shift+H' satisfies Hotkey
|
||||
|
||||
const WORKFLOW_BROWSER_DEFAULT_GUARD_HOTKEYS = [
|
||||
...WORKFLOW_CANVAS_SHORTCUTS['workflow.duplicate'].hotkeys,
|
||||
...WORKFLOW_CANVAS_SHORTCUTS['workflow.undo'].hotkeys,
|
||||
...WORKFLOW_CANVAS_SHORTCUTS['workflow.redo'].hotkeys,
|
||||
'Mod+S',
|
||||
] satisfies readonly Hotkey[]
|
||||
|
||||
export function shouldPreventWorkflowBrowserDefault(event: KeyboardEvent) {
|
||||
return WORKFLOW_BROWSER_DEFAULT_GUARD_HOTKEYS.some((hotkey) =>
|
||||
matchesKeyboardEvent(event, hotkey),
|
||||
)
|
||||
}
|
||||
@ -72,6 +72,7 @@ import {
|
||||
import { HooksStoreContextProvider, useHooksStore } from './hooks-store'
|
||||
import { useWorkflowComment } from './hooks/use-workflow-comment'
|
||||
import { useWorkflowSearch } from './hooks/use-workflow-search'
|
||||
import { shouldPreventWorkflowBrowserDefault } from './hotkeys'
|
||||
import CustomNode from './nodes'
|
||||
import useMatchSchemaType from './nodes/_base/components/variable/use-match-schema-type'
|
||||
import CustomDataSourceEmptyNode from './nodes/data-source-empty'
|
||||
@ -446,10 +447,7 @@ export const Workflow: FC<WorkflowProps> = memo(
|
||||
}, [handleSyncWorkflowDraftWhenPageClose, handleBeforeUnload])
|
||||
|
||||
useEventListener('keydown', (e) => {
|
||||
if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey)) e.preventDefault()
|
||||
if ((e.key === 'z' || e.key === 'Z') && (e.ctrlKey || e.metaKey)) e.preventDefault()
|
||||
if ((e.key === 'y' || e.key === 'Y') && (e.ctrlKey || e.metaKey)) e.preventDefault()
|
||||
if ((e.key === 's' || e.key === 'S') && (e.ctrlKey || e.metaKey)) e.preventDefault()
|
||||
if (shouldPreventWorkflowBrowserDefault(e)) e.preventDefault()
|
||||
})
|
||||
useEventListener('mousemove', (e) => {
|
||||
const containerClientRect = workflowContainerRef.current?.getBoundingClientRect()
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import type { RegisterableHotkey } from '@tanstack/react-hotkeys'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { WorkflowCanvasShortcutId } from '@/app/components/workflow/shortcuts/definitions'
|
||||
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
||||
@ -9,17 +8,15 @@ export const NODE_ACTIONS_MENU_DELETE_ITEM_CLASS_NAME = `${NODE_ACTIONS_MENU_ITE
|
||||
|
||||
export function NodeActionsMenuItemContent({
|
||||
children,
|
||||
hotkey,
|
||||
shortcut,
|
||||
}: {
|
||||
children: ReactNode
|
||||
hotkey?: RegisterableHotkey | (string & {})
|
||||
shortcut?: WorkflowCanvasShortcutId
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<span className="min-w-0 truncate">{children}</span>
|
||||
{(shortcut || hotkey) && <ShortcutKbd shortcut={shortcut} hotkey={hotkey} />}
|
||||
{shortcut && <ShortcutKbd shortcut={shortcut} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||
import type { EditableOutputConfig, EditingState, OutputDraft } from './utils'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
|
||||
@ -18,7 +19,8 @@ import {
|
||||
OUTPUT_NAME_PATTERN_SOURCE,
|
||||
} from './utils'
|
||||
|
||||
const CONFIRM_HOTKEY = 'Mod+Enter'
|
||||
const CANCEL_HOTKEY = 'Escape' satisfies Hotkey
|
||||
const CONFIRM_HOTKEY = 'Mod+Enter' satisfies Hotkey
|
||||
|
||||
function ConfirmHotkeyHint() {
|
||||
const displayKeys = formatForDisplay(CONFIRM_HOTKEY, { separatorToken: ' ' })
|
||||
@ -70,8 +72,11 @@ export function OutputEditCard({
|
||||
if (confirmDisabled) return
|
||||
onConfirm(createOutputFromDraft(draft, { includeDefaultValue: allowDefaultValue }), state)
|
||||
}
|
||||
useHotkey(CONFIRM_HOTKEY, handleConfirm, { target: editorRef, ignoreInputs: false })
|
||||
useHotkey('Escape', onCancel, { target: editorRef, ignoreInputs: false })
|
||||
useHotkey(CONFIRM_HOTKEY, handleConfirm, {
|
||||
target: editorRef,
|
||||
ignoreInputs: false,
|
||||
})
|
||||
useHotkey(CANCEL_HOTKEY, onCancel, { target: editorRef, ignoreInputs: false })
|
||||
return (
|
||||
<div ref={editorRef}>
|
||||
<Form
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AdvancedActions } from '../advanced-actions'
|
||||
|
||||
const hotkeyRegistrations = vi.hoisted(
|
||||
() =>
|
||||
new Map<
|
||||
string,
|
||||
{
|
||||
callback: () => void
|
||||
options?: { enabled?: boolean; ignoreInputs?: boolean }
|
||||
}
|
||||
>(),
|
||||
)
|
||||
|
||||
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
|
||||
return {
|
||||
...actual,
|
||||
useHotkey: (
|
||||
hotkey: string,
|
||||
callback: () => void,
|
||||
options?: { enabled?: boolean; ignoreInputs?: boolean },
|
||||
) => {
|
||||
hotkeyRegistrations.set(hotkey, { callback, options })
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('AdvancedActions', () => {
|
||||
beforeEach(() => {
|
||||
hotkeyRegistrations.clear()
|
||||
})
|
||||
|
||||
it('runs the matching actions from the buttons', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCancel = vi.fn()
|
||||
const onConfirm = vi.fn()
|
||||
render(<AdvancedActions isConfirmDisabled={false} onCancel={onCancel} onConfirm={onConfirm} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
await user.click(screen.getByRole('button', { name: /^common\.operation\.confirm/ }))
|
||||
|
||||
expect(onCancel).toHaveBeenCalledOnce()
|
||||
expect(onConfirm).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('registers the confirm shortcut for input fields', () => {
|
||||
const onConfirm = vi.fn()
|
||||
render(<AdvancedActions isConfirmDisabled={false} onCancel={vi.fn()} onConfirm={onConfirm} />)
|
||||
|
||||
const registration = hotkeyRegistrations.get('Mod+Enter')
|
||||
registration?.callback()
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledOnce()
|
||||
expect(registration?.options).toEqual({ enabled: true, ignoreInputs: false })
|
||||
})
|
||||
|
||||
it('disables both confirmation paths when confirmation is unavailable', () => {
|
||||
render(<AdvancedActions isConfirmDisabled onCancel={vi.fn()} onConfirm={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /^common\.operation\.confirm/ })).toBeDisabled()
|
||||
expect(hotkeyRegistrations.get('Mod+Enter')?.options?.enabled).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -1,11 +1,10 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useHotkey } from '@tanstack/react-hotkeys'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
||||
|
||||
const JSON_SCHEMA_CONFIRM_HOTKEY = 'Mod+Enter'
|
||||
const JSON_SCHEMA_CONFIRM_HOTKEY = 'Mod+Enter' satisfies Hotkey
|
||||
|
||||
type AdvancedActionsProps = {
|
||||
isConfirmDisabled: boolean
|
||||
@ -13,7 +12,7 @@ type AdvancedActionsProps = {
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
const AdvancedActions: FC<AdvancedActionsProps> = ({ isConfirmDisabled, onCancel, onConfirm }) => {
|
||||
export function AdvancedActions({ isConfirmDisabled, onCancel, onConfirm }: AdvancedActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
useHotkey(
|
||||
@ -45,5 +44,3 @@ const AdvancedActions: FC<AdvancedActionsProps> = ({ isConfirmDisabled, onCancel
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AdvancedActions)
|
||||
|
||||
@ -13,7 +13,7 @@ import { ArrayType, Type } from '../../../../types'
|
||||
import { useMittContext } from '../context'
|
||||
import { useVisualEditorStore } from '../store'
|
||||
import Actions from './actions'
|
||||
import AdvancedActions from './advanced-actions'
|
||||
import { AdvancedActions } from './advanced-actions'
|
||||
import AdvancedOptions from './advanced-options'
|
||||
import AutoWidthInput from './auto-width-input'
|
||||
import RequiredSwitch from './required-switch'
|
||||
|
||||
@ -8,7 +8,6 @@ import {
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { TEST_RUN_MENU_HOTKEY } from './header/shortcuts'
|
||||
import {
|
||||
useDSL,
|
||||
useIsChatMode,
|
||||
@ -17,6 +16,7 @@ import {
|
||||
useWorkflowStartRun,
|
||||
} from './hooks'
|
||||
import { useHooksStore } from './hooks-store'
|
||||
import { TEST_RUN_MENU_HOTKEY } from './hotkeys'
|
||||
import { isSnippetCanvas } from './nodes/_base/hooks/snippet-input-field-vars'
|
||||
import AddBlock from './operator/add-block'
|
||||
import { useOperator } from './operator/hooks'
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { RegisterableHotkey } from '@tanstack/react-hotkeys'
|
||||
import type { Hotkey, IndividualKey } from '@tanstack/react-hotkeys'
|
||||
|
||||
export type WorkflowCanvasShortcutId =
|
||||
| 'workflow.delete'
|
||||
@ -26,18 +26,26 @@ export type WorkflowCanvasHotkeyMeta = {
|
||||
description: string
|
||||
}
|
||||
|
||||
export type WorkflowCanvasShortcutDefinition = {
|
||||
type WorkflowCanvasShortcutDefinitionBase = {
|
||||
id: WorkflowCanvasShortcutId
|
||||
hotkeys: readonly RegisterableHotkey[]
|
||||
displayHotkey?: RegisterableHotkey | (string & {})
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const WORKFLOW_CANVAS_SHORTCUTS: Record<
|
||||
WorkflowCanvasShortcutId,
|
||||
WorkflowCanvasShortcutDefinition
|
||||
> = {
|
||||
export type WorkflowCanvasHotkeyDefinition = WorkflowCanvasShortcutDefinitionBase & {
|
||||
hotkeys: readonly Hotkey[]
|
||||
displayHotkey?: Hotkey
|
||||
}
|
||||
|
||||
type WorkflowCanvasHoldKeyDefinition = WorkflowCanvasShortcutDefinitionBase & {
|
||||
holdKey: IndividualKey
|
||||
}
|
||||
|
||||
type WorkflowCanvasShortcutDefinition =
|
||||
| WorkflowCanvasHotkeyDefinition
|
||||
| WorkflowCanvasHoldKeyDefinition
|
||||
|
||||
export const WORKFLOW_CANVAS_SHORTCUTS = {
|
||||
'workflow.delete': {
|
||||
id: 'workflow.delete',
|
||||
hotkeys: ['Delete', 'Backspace'],
|
||||
@ -139,16 +147,19 @@ export const WORKFLOW_CANVAS_SHORTCUTS: Record<
|
||||
},
|
||||
'workflow.dim-other-nodes': {
|
||||
id: 'workflow.dim-other-nodes',
|
||||
hotkeys: [{ key: 'Shift', shift: true }],
|
||||
displayHotkey: 'Shift',
|
||||
holdKey: 'Shift',
|
||||
name: 'Dim other nodes',
|
||||
description: 'Dim nodes outside the current workflow selection',
|
||||
},
|
||||
}
|
||||
} as const satisfies Record<WorkflowCanvasShortcutId, WorkflowCanvasShortcutDefinition>
|
||||
|
||||
export const getWorkflowCanvasShortcutDisplayHotkey = (
|
||||
export const getWorkflowCanvasShortcutDisplayKey = (
|
||||
id: WorkflowCanvasShortcutId,
|
||||
): RegisterableHotkey | (string & {}) => {
|
||||
): Hotkey | IndividualKey => {
|
||||
const shortcut = WORKFLOW_CANVAS_SHORTCUTS[id]
|
||||
return shortcut.displayHotkey ?? shortcut.hotkeys[0]!
|
||||
|
||||
if ('displayHotkey' in shortcut && shortcut.displayHotkey) return shortcut.displayHotkey
|
||||
if ('hotkeys' in shortcut) return shortcut.hotkeys[0]
|
||||
|
||||
return shortcut.holdKey
|
||||
}
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
import type { KbdColor } from '@langgenius/dify-ui/kbd'
|
||||
import type { FormatDisplayOptions, RegisterableHotkey } from '@tanstack/react-hotkeys'
|
||||
import type { FormatDisplayOptions, Hotkey, IndividualKey } from '@tanstack/react-hotkeys'
|
||||
import type { WorkflowCanvasShortcutId } from './definitions'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { getWorkflowCanvasShortcutDisplayHotkey } from './definitions'
|
||||
import { getWorkflowCanvasShortcutDisplayKey } from './definitions'
|
||||
|
||||
type ShortcutKbdProps = {
|
||||
shortcut?: WorkflowCanvasShortcutId
|
||||
hotkey?: RegisterableHotkey | (string & {})
|
||||
type ShortcutKbdSource =
|
||||
| { shortcut: WorkflowCanvasShortcutId; hotkey?: never; displayKey?: never }
|
||||
| { shortcut?: never; hotkey: Hotkey; displayKey?: never }
|
||||
| { shortcut?: never; hotkey?: never; displayKey: string }
|
||||
|
||||
type ShortcutKbdProps = ShortcutKbdSource & {
|
||||
className?: string
|
||||
textColor?: 'default' | 'secondary'
|
||||
bgColor?: KbdColor
|
||||
@ -16,13 +19,11 @@ type ShortcutKbdProps = {
|
||||
}
|
||||
|
||||
const getDisplayKeys = (
|
||||
hotkey: RegisterableHotkey | (string & {}),
|
||||
hotkey: Hotkey | IndividualKey,
|
||||
platform?: FormatDisplayOptions['platform'],
|
||||
) => {
|
||||
const displayOptions = platform ? { platform } : undefined
|
||||
|
||||
if (typeof hotkey !== 'string') return [formatForDisplay(hotkey, displayOptions)]
|
||||
|
||||
return hotkey
|
||||
.split('+')
|
||||
.filter(Boolean)
|
||||
@ -32,17 +33,23 @@ const getDisplayKeys = (
|
||||
export const ShortcutKbd = ({
|
||||
shortcut,
|
||||
hotkey,
|
||||
displayKey,
|
||||
className,
|
||||
textColor = 'default',
|
||||
bgColor = 'gray',
|
||||
platform,
|
||||
}: ShortcutKbdProps) => {
|
||||
const displayHotkey =
|
||||
hotkey ?? (shortcut ? getWorkflowCanvasShortcutDisplayHotkey(shortcut) : undefined)
|
||||
const shortcutDisplayKey =
|
||||
hotkey ?? (shortcut ? getWorkflowCanvasShortcutDisplayKey(shortcut) : undefined)
|
||||
|
||||
if (!displayHotkey) return null
|
||||
const displayOptions = platform ? { platform } : undefined
|
||||
const displayKeys = displayKey
|
||||
? [formatForDisplay(displayKey, displayOptions)]
|
||||
: shortcutDisplayKey
|
||||
? getDisplayKeys(shortcutDisplayKey, platform)
|
||||
: []
|
||||
|
||||
const displayKeys = getDisplayKeys(displayHotkey, platform)
|
||||
if (!displayKeys.length) return null
|
||||
|
||||
return (
|
||||
<KbdGroup className={cn(className)}>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { HotkeyCallback, UseHotkeyDefinition, UseHotkeyOptions } from '@tanstack/react-hotkeys'
|
||||
import type { WorkflowCanvasHotkeyMeta, WorkflowCanvasShortcutDefinition } from './definitions'
|
||||
import type { WorkflowCanvasHotkeyDefinition, WorkflowCanvasHotkeyMeta } from './definitions'
|
||||
import { useHotkeys, useKeyHold } from '@tanstack/react-hotkeys'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
@ -29,7 +29,7 @@ const isInputLikeElement = (element: Element | null) => {
|
||||
}
|
||||
|
||||
const toHotkeyDefinitions = (
|
||||
shortcut: WorkflowCanvasShortcutDefinition,
|
||||
shortcut: WorkflowCanvasHotkeyDefinition,
|
||||
callback: HotkeyCallback,
|
||||
options?: UseHotkeyOptions,
|
||||
): UseHotkeyDefinition[] => {
|
||||
@ -68,7 +68,7 @@ export const useWorkflowHotkeys = (): void => {
|
||||
const { handleLayout } = useWorkflowOrganize()
|
||||
|
||||
const { zoomTo, getZoom, fitView, getNodes } = useReactFlow()
|
||||
const isShiftHeld = useKeyHold('Shift')
|
||||
const isShiftHeld = useKeyHold(WORKFLOW_CANVAS_SHORTCUTS['workflow.dim-other-nodes'].holdKey)
|
||||
const shiftDimmedRef = useRef(false)
|
||||
const undimAllNodesRef = useRef(undimAllNodes)
|
||||
undimAllNodesRef.current = undimAllNodes
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { WorkflowGenerateErrorResponse } from '@dify/contracts/api/console/workflow-generate/types.gen'
|
||||
import type { GenerateWorkflowStreamCallbacks } from '@/service/workflow-generator'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import WorkflowGeneratorModal from '../index'
|
||||
import { useWorkflowGeneratorStore } from '../store'
|
||||
@ -93,6 +93,19 @@ describe('WorkflowGeneratorModal', () => {
|
||||
|
||||
expect(instruction).toHaveValue('Summarize a URL')
|
||||
})
|
||||
|
||||
it('should generate from the instruction shortcut', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<WorkflowGeneratorModal />)
|
||||
|
||||
const instruction = screen.getByRole('textbox', {
|
||||
name: /workflowGenerator\.instruction/i,
|
||||
})
|
||||
await user.type(instruction, 'Summarize a URL')
|
||||
fireEvent.keyDown(instruction, { key: 'Enter', ctrlKey: true })
|
||||
|
||||
await waitFor(() => expect(mockGenerateWorkflowStream).toHaveBeenCalledOnce())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Generation errors', () => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
import type { WorkflowGenerateErrorResponse } from '@dify/contracts/api/console/workflow-generate/types.gen'
|
||||
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||
import type { SelectorParam, TFunction } from 'i18next'
|
||||
import type { GeneratedGraph } from './types'
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
@ -23,6 +24,7 @@ import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@langgeni
|
||||
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { matchesKeyboardEvent } from '@tanstack/react-hotkeys'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
@ -63,6 +65,7 @@ const FE_TIMEOUT_MS = 90_000
|
||||
// Mirrors the backend's instruction/ideal-output cap on /workflow-generate —
|
||||
// keeping the limit client-side turns an opaque 400 into a visible input stop.
|
||||
const MAX_INSTRUCTION_LENGTH = 10_000
|
||||
const WORKFLOW_GENERATOR_SUBMIT_HOTKEY = 'Mod+Enter' satisfies Hotkey
|
||||
|
||||
// A single structured generation error. Mirrors the backend ``errors[]`` entry
|
||||
// (stable ``code`` + human ``detail`` + optional ``node_id``) so the error panel
|
||||
@ -615,9 +618,7 @@ function WorkflowGeneratorModal() {
|
||||
value={instruction}
|
||||
onValueChange={setInstruction}
|
||||
onKeyDown={(e) => {
|
||||
// ⌘/Ctrl+Enter generates — the journey starts keyboard-first in
|
||||
// the palette, so let it finish without reaching for the mouse.
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
if (matchesKeyboardEvent(e.nativeEvent, WORKFLOW_GENERATOR_SUBMIT_HOTKEY)) {
|
||||
e.preventDefault()
|
||||
if (!isLoading) onGenerate()
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import type {
|
||||
AgentReferencingWorkflowResponse,
|
||||
AgentReferencingWorkflowsResponse,
|
||||
} from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { RegisterableHotkey } from '@tanstack/react-hotkeys'
|
||||
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Collapsible, CollapsiblePanel } from '@langgenius/dify-ui/collapsible'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
@ -25,7 +25,7 @@ import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { AgentPublishImpactDetails } from './publish-impact-details'
|
||||
|
||||
const PUBLISH_AGENT_HOTKEY = 'Mod+Shift+P' satisfies RegisterableHotkey
|
||||
const PUBLISH_AGENT_HOTKEY = 'Mod+Shift+P' satisfies Hotkey
|
||||
|
||||
type AgentConfigurePublishState = 'draft' | 'publishing' | 'published' | 'unpublished'
|
||||
|
||||
|
||||
@ -12,11 +12,12 @@ import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import NavLink from '@/app/components/app-sidebar/nav-link'
|
||||
import ToggleButton from '@/app/components/app-sidebar/toggle-button'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||
import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
|
||||
import Link from '@/next/link'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
@ -39,8 +40,6 @@ type AgentDetailNavItem = {
|
||||
activeIcon: NavIcon
|
||||
}
|
||||
|
||||
const SEARCH_SHORTCUT = ['Mod', 'K']
|
||||
|
||||
const createAgentNavIcon = (iconClassName: string) => {
|
||||
function AgentNavIcon({ className }: ComponentProps<'svg'>) {
|
||||
return <span aria-hidden className={cn(iconClassName, className)} />
|
||||
@ -92,9 +91,9 @@ export function AgentDetailTop({ expand = true, onToggle }: AgentDetailTopProps)
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
|
||||
{onToggle && (
|
||||
<ToggleButton
|
||||
<DetailSidebarToggleButton
|
||||
expand={expand}
|
||||
handleToggle={onToggle}
|
||||
onToggle={onToggle}
|
||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||
/>
|
||||
@ -145,16 +144,16 @@ export function AgentDetailTop({ expand = true, onToggle }: AgentDetailTopProps)
|
||||
>
|
||||
<span className="px-0.5">{tApp(($) => $['gotoAnything.quickAction'])}</span>
|
||||
<KbdGroup>
|
||||
{SEARCH_SHORTCUT.map((key) => (
|
||||
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
|
||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onToggle && (
|
||||
<ToggleButton
|
||||
<DetailSidebarToggleButton
|
||||
expand={expand}
|
||||
handleToggle={onToggle}
|
||||
onToggle={onToggle}
|
||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||
/>
|
||||
|
||||
@ -11,11 +11,12 @@ import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import NavLink from '@/app/components/app-sidebar/nav-link'
|
||||
import ToggleButton from '@/app/components/app-sidebar/toggle-button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
|
||||
import Link from '@/next/link'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { DeploymentActionsMenu } from '../deployment-actions'
|
||||
@ -78,8 +79,6 @@ const DEPLOYMENT_TABS: TabDef[] = [
|
||||
{ key: 'api-tokens', icon: ApiIcon, selectedIcon: ApiSelectedIcon },
|
||||
]
|
||||
|
||||
const SEARCH_SHORTCUT = ['Mod', 'K']
|
||||
|
||||
function DeploymentIcon({ expand }: { expand: boolean }) {
|
||||
return (
|
||||
<div
|
||||
@ -198,9 +197,9 @@ export function DeploymentDetailTop({
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
|
||||
{onToggle && (
|
||||
<ToggleButton
|
||||
<DetailSidebarToggleButton
|
||||
expand={expand}
|
||||
handleToggle={onToggle}
|
||||
onToggle={onToggle}
|
||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||
/>
|
||||
@ -251,16 +250,16 @@ export function DeploymentDetailTop({
|
||||
>
|
||||
<span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span>
|
||||
<KbdGroup>
|
||||
{SEARCH_SHORTCUT.map((key) => (
|
||||
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
|
||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onToggle && (
|
||||
<ToggleButton
|
||||
<DetailSidebarToggleButton
|
||||
expand={expand}
|
||||
handleToggle={onToggle}
|
||||
onToggle={onToggle}
|
||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||
/>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user