mirror of https://github.com/langgenius/dify.git
user actions
This commit is contained in:
parent
e257455c9c
commit
b02199145e
|
|
@ -0,0 +1,108 @@
|
|||
import type { FC } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiFontSize,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { UserActionButtonType } from '../types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
const i18nPrefix = 'workflow.nodes.humanInput'
|
||||
|
||||
type Props = {
|
||||
text: string
|
||||
data: UserActionButtonType
|
||||
onChange: (state: UserActionButtonType) => void
|
||||
}
|
||||
|
||||
const ButtonStyleDropdown: FC<Props> = ({
|
||||
text = 'Button Text',
|
||||
data,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const currentStyle = useMemo(() => {
|
||||
switch (data) {
|
||||
case UserActionButtonType.Primary:
|
||||
return 'primary'
|
||||
case UserActionButtonType.Default:
|
||||
return 'secondary'
|
||||
case UserActionButtonType.Accent:
|
||||
return 'secondary-accent'
|
||||
default:
|
||||
return 'ghost'
|
||||
}
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 44,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<div className={cn('flex cursor-pointer items-center justify-center rounded-lg bg-components-button-tertiary-bg p-1 hover:bg-components-button-tertiary-bg-hover', open && 'bg-components-button-tertiary-bg-hover')}>
|
||||
<Button size='small' className='pointer-events-none px-1' variant={currentStyle}>
|
||||
<RiFontSize className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
|
||||
<div className='rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-4 shadow-lg backdrop-blur-sm'>
|
||||
<div className='system-md-medium text-text-primary'>{t(`${i18nPrefix}.userActions.chooseStyle`)}</div>
|
||||
<div className='mt-2 flex w-[324px] flex-wrap gap-1'>
|
||||
<div
|
||||
className={cn(
|
||||
'box-border flex h-[80px] w-[160px] cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-transparent bg-background-section hover:bg-background-section-burn',
|
||||
data === UserActionButtonType.Primary && 'border-components-option-card-option-selected-border',
|
||||
)}
|
||||
onClick={() => onChange(UserActionButtonType.Primary)}
|
||||
>
|
||||
<Button variant='primary' className='pointer-events-none'>{text}</Button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'box-border flex h-[80px] w-[160px] cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-transparent bg-background-section hover:bg-background-section-burn',
|
||||
data === UserActionButtonType.Default && 'border-components-option-card-option-selected-border',
|
||||
)}
|
||||
onClick={() => onChange(UserActionButtonType.Default)}
|
||||
>
|
||||
<Button variant='secondary' className='pointer-events-none'>{text}</Button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'box-border flex h-[80px] w-[160px] cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-transparent bg-background-section hover:bg-background-section-burn',
|
||||
data === UserActionButtonType.Accent && 'border-components-option-card-option-selected-border',
|
||||
)}
|
||||
onClick={() => onChange(UserActionButtonType.Accent)}
|
||||
>
|
||||
<Button variant='secondary-accent' className='pointer-events-none'>{text}</Button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'box-border flex h-[80px] w-[160px] cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-transparent bg-background-section hover:bg-background-section-burn',
|
||||
data === UserActionButtonType.Ghost && 'border-components-option-card-option-selected-border',
|
||||
)}
|
||||
onClick={() => onChange(UserActionButtonType.Ghost)}
|
||||
>
|
||||
<Button variant='ghost' className='pointer-events-none'>{text}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default ButtonStyleDropdown
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
} from '@remixicon/react'
|
||||
import type { UserAction } from '../types'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ButtonStyleDropdown from './button-style-dropdown'
|
||||
|
||||
const i18nPrefix = 'workflow.nodes.humanInput'
|
||||
|
||||
type Props = {
|
||||
data: UserAction
|
||||
onChange: (state: UserAction) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
const UserActionItem: FC<Props> = ({
|
||||
data,
|
||||
onChange,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='shrink-0'>
|
||||
<Input
|
||||
wrapperClassName='w-[120px]'
|
||||
value={data.name}
|
||||
placeholder={t(`${i18nPrefix}.userActions.actionNamePlaceholder`)}
|
||||
onChange={e => onChange({ ...data, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className='grow'>
|
||||
<Input
|
||||
value={data.text}
|
||||
placeholder={t(`${i18nPrefix}.userActions.buttonTextPlaceholder`)}
|
||||
onChange={e => onChange({ ...data, text: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyleDropdown
|
||||
text={data.text}
|
||||
data={data.type}
|
||||
onChange={type => onChange({ ...data, type })}
|
||||
/>
|
||||
<Button
|
||||
className='px-2'
|
||||
variant='tertiary'
|
||||
onClick={() => onDelete(data.id)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserActionItem
|
||||
|
|
@ -17,21 +17,25 @@ const nodeDefault: NodeDefault<HumanInputNodeType> = {
|
|||
],
|
||||
userActions: [
|
||||
{
|
||||
id: 'approve-action',
|
||||
name: 'approve',
|
||||
text: 'Post to X',
|
||||
type: UserActionButtonType.Primary,
|
||||
},
|
||||
{
|
||||
id: 'regenerate-action',
|
||||
name: 'regenerate',
|
||||
text: 'regenerate',
|
||||
type: UserActionButtonType.Default,
|
||||
},
|
||||
{
|
||||
id: 'thinking-action',
|
||||
name: 'thinking',
|
||||
text: 'think more',
|
||||
type: UserActionButtonType.Accent,
|
||||
},
|
||||
{
|
||||
id: 'cancel-action',
|
||||
name: 'cancel',
|
||||
text: 'cancel',
|
||||
type: UserActionButtonType.Ghost,
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ const Node: FC<NodeProps<HumanInputNodeType>> = (props) => {
|
|||
{userActions.length > 0 && (
|
||||
<div className='space-y-0.5 py-1'>
|
||||
{userActions.map(userAction => (
|
||||
<div key={userAction.name} className='relative flex flex-row-reverse items-center px-4 py-1'>
|
||||
<div key={userAction.id} className='relative flex flex-row-reverse items-center px-4 py-1'>
|
||||
<span className='system-xs-semibold-uppercase truncate text-text-secondary'>{userAction.name}</span>
|
||||
<NodeSourceHandle
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import {
|
||||
RiAddLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useConfig from './use-config'
|
||||
import type { HumanInputNodeType } from './types'
|
||||
import { UserActionButtonType } from './types'
|
||||
import type { NodePanelProps } from '@/app/components/workflow/types'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import UserActionItem from './components/user-action'
|
||||
import TimeoutInput from './components/timeout'
|
||||
import { v4 as uuid4 } from 'uuid'
|
||||
|
||||
const i18nPrefix = 'workflow.nodes.humanInput'
|
||||
|
||||
|
|
@ -16,10 +24,51 @@ const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({
|
|||
const { t } = useTranslation()
|
||||
const {
|
||||
inputs,
|
||||
handleUserActionChange,
|
||||
handleUserActionDelete,
|
||||
handleTimeoutChange,
|
||||
} = useConfig(id, data)
|
||||
return (
|
||||
<div className='py-2'>
|
||||
<div className='px-4 py-2'>
|
||||
<div className='mb-1 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-0.5'>
|
||||
<div className='system-sm-semibold-uppercase text-text-secondary'>{t(`${i18nPrefix}.userActions.title`)}</div>
|
||||
<Tooltip
|
||||
popupContent={t(`${i18nPrefix}.userActions.tooltip`)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center px-1'>
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
inputs.userActions.push({
|
||||
id: uuid4(),
|
||||
name: 'Action',
|
||||
text: 'Button Text',
|
||||
type: UserActionButtonType.Default,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<RiAddLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
{!inputs.userActions.length && (
|
||||
<div className='system-xs-regular flex items-center justify-center rounded-[10px] bg-background-section p-3 text-text-tertiary'>{t(`${i18nPrefix}.userActions.emptyTip`)}</div>
|
||||
)}
|
||||
{inputs.userActions.length > 0 && (
|
||||
<div className='space-y-2'>
|
||||
{inputs.userActions.map(action => (
|
||||
<UserActionItem
|
||||
key={action.id}
|
||||
data={action}
|
||||
onChange={handleUserActionChange}
|
||||
onDelete={handleUserActionDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='px-4 py-2'>
|
||||
<Divider className='!my-0 !h-px !bg-divider-subtle' />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export enum UserActionButtonType {
|
|||
}
|
||||
|
||||
export type UserAction = {
|
||||
id: string
|
||||
name: string
|
||||
text: string
|
||||
type: UserActionButtonType
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { HumanInputNodeType, Timeout } from './types'
|
||||
import produce from 'immer'
|
||||
import type { HumanInputNodeType, Timeout, UserAction } from './types'
|
||||
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
||||
import {
|
||||
useNodesReadOnly,
|
||||
|
|
@ -7,6 +8,26 @@ const useConfig = (id: string, payload: HumanInputNodeType) => {
|
|||
const { nodesReadOnly: readOnly } = useNodesReadOnly()
|
||||
const { inputs, setInputs } = useNodeCrud<HumanInputNodeType>(id, payload)
|
||||
|
||||
const handleUserActionChange = (updatedAction: UserAction) => {
|
||||
const newActions = produce(inputs.userActions, (draft) => {
|
||||
const index = draft.findIndex(a => a.id === updatedAction.id)
|
||||
if (index !== -1)
|
||||
draft[index] = updatedAction
|
||||
})
|
||||
setInputs({
|
||||
...inputs,
|
||||
userActions: newActions,
|
||||
})
|
||||
}
|
||||
|
||||
const handleUserActionDelete = (actionId: string) => {
|
||||
const newActions = inputs.userActions.filter(action => action.id !== actionId)
|
||||
setInputs({
|
||||
...inputs,
|
||||
userActions: newActions,
|
||||
})
|
||||
}
|
||||
|
||||
const handleTimeoutChange = (timeout: Timeout) => {
|
||||
setInputs({
|
||||
...inputs,
|
||||
|
|
@ -17,6 +38,8 @@ const useConfig = (id: string, payload: HumanInputNodeType) => {
|
|||
return {
|
||||
readOnly,
|
||||
inputs,
|
||||
handleUserActionChange,
|
||||
handleUserActionDelete,
|
||||
handleTimeoutChange,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -909,7 +909,14 @@ const translation = {
|
|||
title: 'Delivery Method',
|
||||
},
|
||||
formContent: 'form content',
|
||||
userActions: 'user actions',
|
||||
userActions: {
|
||||
title: 'User Actions',
|
||||
tooltip: 'Define buttons that users can click to respond to this form. Each button can trigger different workflow paths.',
|
||||
emptyTip: 'Click the \'+\' button to add user actions',
|
||||
actionNamePlaceholder: 'Action Name',
|
||||
buttonTextPlaceholder: 'Button display Text',
|
||||
chooseStyle: 'Choose a button style',
|
||||
},
|
||||
timeout: {
|
||||
title: 'Timeout',
|
||||
hours: 'Hours',
|
||||
|
|
|
|||
|
|
@ -910,7 +910,14 @@ const translation = {
|
|||
title: '提交方式',
|
||||
},
|
||||
formContent: '表单内容',
|
||||
userActions: '用户操作',
|
||||
userActions: {
|
||||
title: '用户操作',
|
||||
tooltip: '定义用户可以点击以响应此表单的按钮。每个按钮都可以触发不同的工作流路径。',
|
||||
emptyTip: '点击 \'+\' 按钮添加用户操作',
|
||||
actionNamePlaceholder: '操作名称',
|
||||
buttonTextPlaceholder: '按钮显示文本',
|
||||
chooseStyle: '选择按钮样式',
|
||||
},
|
||||
timeout: {
|
||||
title: '超时设置',
|
||||
hours: '小时',
|
||||
|
|
|
|||
Loading…
Reference in New Issue