mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 01:18:05 +08:00
refactor(web): redesign workflow checklist panel with grouped tree view and Popover primitive
Migrate checklist from flat card list using deprecated PortalToFollowElem to grouped tree view using base-ui Popover. Split into checklist/ directory with separate components: plugin group with batch install, per-node groups with sub-items and "Go to fix" hover action, and tree-line SVG indicators.
This commit is contained in:
150
web/app/components/workflow/header/checklist/index.tsx
Normal file
150
web/app/components/workflow/header/checklist/index.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import type { ChecklistItem } from '../../hooks/use-checklist'
|
||||
import type {
|
||||
CommonEdgeType,
|
||||
} from '../../types'
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useEdges,
|
||||
} from 'reactflow'
|
||||
import {
|
||||
Popover,
|
||||
PopoverClose,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/app/components/base/ui/popover'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import {
|
||||
useChecklist,
|
||||
useNodesInteractions,
|
||||
} from '../../hooks'
|
||||
import { ChecklistNodeGroup } from './node-group'
|
||||
import { ChecklistPluginGroup } from './plugin-group'
|
||||
|
||||
type WorkflowChecklistProps = {
|
||||
disabled: boolean
|
||||
showGoTo?: boolean
|
||||
onItemClick?: (item: ChecklistItem) => void
|
||||
}
|
||||
|
||||
const WorkflowChecklist = ({
|
||||
disabled,
|
||||
showGoTo = true,
|
||||
onItemClick,
|
||||
}: WorkflowChecklistProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const edges = useEdges<CommonEdgeType>()
|
||||
const nodes = useNodes()
|
||||
const needWarningNodes = useChecklist(nodes, edges)
|
||||
const { handleNodeSelect } = useNodesInteractions()
|
||||
|
||||
const { pluginItems, nodeItems } = useMemo(() => {
|
||||
const plugins: ChecklistItem[] = []
|
||||
const regular: ChecklistItem[] = []
|
||||
for (const item of needWarningNodes) {
|
||||
if (item.isPluginMissing)
|
||||
plugins.push(item)
|
||||
else
|
||||
regular.push(item)
|
||||
}
|
||||
return { pluginItems: plugins, nodeItems: regular }
|
||||
}, [needWarningNodes])
|
||||
|
||||
const handleItemClick = (item: ChecklistItem) => {
|
||||
if (onItemClick)
|
||||
onItemClick(item)
|
||||
else
|
||||
handleNodeSelect(item.id)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={newOpen => !disabled && setOpen(newOpen)}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div
|
||||
className={cn(
|
||||
'relative ml-0.5 flex h-7 w-7 items-center justify-center rounded-md',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
aria-disabled={disabled || undefined}
|
||||
>
|
||||
<div
|
||||
className={cn('group flex h-full w-full cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
|
||||
>
|
||||
<span
|
||||
className={cn('i-ri-list-check-3 h-4 w-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')}
|
||||
/>
|
||||
</div>
|
||||
{!!needWarningNodes.length && (
|
||||
<div className="absolute -right-1.5 -top-1.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full border border-gray-100 bg-text-warning-secondary text-[11px] font-semibold text-white">
|
||||
{needWarningNodes.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={12}
|
||||
alignOffset={-30}
|
||||
popupClassName="w-[420px] rounded-2xl bg-background-default-subtle"
|
||||
>
|
||||
<div
|
||||
className="overflow-y-auto"
|
||||
style={{ maxHeight: 'calc(2 / 3 * 100vh)' }}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 px-3 pb-1 pt-3.5">
|
||||
<div className="flex items-start px-1">
|
||||
<div className="min-w-0 grow pr-8">
|
||||
<h2 className="text-base font-semibold leading-6 text-text-primary">
|
||||
{t('panel.checklist', { ns: 'workflow' })}
|
||||
{needWarningNodes.length > 0 && `(${needWarningNodes.length})`}
|
||||
</h2>
|
||||
</div>
|
||||
<PopoverClose className="-mr-0.5 -mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg">
|
||||
<span className="i-ri-close-line size-4 text-text-tertiary" />
|
||||
</PopoverClose>
|
||||
</div>
|
||||
{needWarningNodes.length > 0 && (
|
||||
<p className="px-1 text-xs leading-4 text-text-tertiary">
|
||||
{t('panel.checklistDescription', { ns: 'workflow' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{needWarningNodes.length > 0
|
||||
? (
|
||||
<div className="flex flex-col gap-1 px-4 pb-4 pt-1">
|
||||
{pluginItems.length > 0 && (
|
||||
<ChecklistPluginGroup items={pluginItems} />
|
||||
)}
|
||||
{nodeItems.map(item => (
|
||||
<ChecklistNodeGroup
|
||||
key={item.id}
|
||||
item={item}
|
||||
showGoTo={showGoTo}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="mx-4 mb-3 rounded-lg py-4 text-center text-xs text-text-tertiary">
|
||||
<span className="i-custom-vender-line-general-checklist-square mx-auto mb-[5px] block h-8 w-8 text-text-quaternary" />
|
||||
{t('panel.checklistResolved', { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(WorkflowChecklist)
|
||||
@ -0,0 +1,21 @@
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type ItemIndicatorProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ItemIndicator = ({ className }: ItemIndicatorProps) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="24"
|
||||
viewBox="0 0 20 24"
|
||||
fill="none"
|
||||
className={cn('shrink-0', className)}
|
||||
>
|
||||
<path d="M9.5 0H10.5V24H9.5V0Z" fill="currentColor" className="text-divider-regular" />
|
||||
<circle cx="10" cy="12" r="3.25" fill="#F79009" stroke="white" strokeWidth="1.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
75
web/app/components/workflow/header/checklist/node-group.tsx
Normal file
75
web/app/components/workflow/header/checklist/node-group.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import type { ChecklistItem } from '../../hooks/use-checklist'
|
||||
import type { BlockEnum } from '../../types'
|
||||
import { memo, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import BlockIcon from '../../block-icon'
|
||||
import { ItemIndicator } from './item-indicator'
|
||||
|
||||
type ChecklistSubItem = {
|
||||
key: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export const ChecklistNodeGroup = memo(({
|
||||
item,
|
||||
showGoTo,
|
||||
onItemClick,
|
||||
}: {
|
||||
item: ChecklistItem
|
||||
showGoTo: boolean
|
||||
onItemClick: (item: ChecklistItem) => void
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const goToEnabled = showGoTo && item.canNavigate && !item.disableGoTo
|
||||
|
||||
const subItems = useMemo(() => {
|
||||
const items: ChecklistSubItem[] = []
|
||||
if (item.errorMessage)
|
||||
items.push({ key: 'error', message: item.errorMessage })
|
||||
if (item.unConnected)
|
||||
items.push({ key: 'unconnected', message: t('common.needConnectTip', { ns: 'workflow' }) })
|
||||
return items
|
||||
}, [item.errorMessage, item.unConnected, t])
|
||||
|
||||
return (
|
||||
<div className="overflow-clip rounded-[10px] bg-components-panel-on-panel-item-bg">
|
||||
<div className="flex items-center gap-2 px-2 pt-2">
|
||||
<BlockIcon
|
||||
type={item.type as BlockEnum}
|
||||
size="sm"
|
||||
toolIcon={item.toolIcon}
|
||||
/>
|
||||
<span className="min-w-0 grow truncate text-sm font-medium leading-5 text-text-primary">
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-1">
|
||||
{subItems.map(sub => (
|
||||
<div
|
||||
key={sub.key}
|
||||
className={cn(
|
||||
'group/item flex items-center gap-2 rounded-lg px-1',
|
||||
goToEnabled && 'cursor-pointer hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => goToEnabled && onItemClick(item)}
|
||||
>
|
||||
<ItemIndicator />
|
||||
<span className="min-w-0 grow truncate text-xs leading-4 text-text-warning">
|
||||
{sub.message}
|
||||
</span>
|
||||
{goToEnabled && (
|
||||
<div className="flex shrink-0 items-center gap-0.5 pr-0.5 opacity-0 transition-opacity duration-150 group-hover/item:opacity-100">
|
||||
<span className="whitespace-nowrap text-xs font-medium leading-4 text-text-accent">
|
||||
{t('panel.goToFix', { ns: 'workflow' })}
|
||||
</span>
|
||||
<span className="i-ri-arrow-right-line size-3.5 text-text-accent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
ChecklistNodeGroup.displayName = 'ChecklistNodeGroup'
|
||||
@ -0,0 +1,95 @@
|
||||
import type { MouseEventHandler } from 'react'
|
||||
import type { ChecklistItem } from '../../hooks/use-checklist'
|
||||
import type { BlockEnum } from '../../types'
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
|
||||
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
|
||||
import BlockIcon from '../../block-icon'
|
||||
import { ItemIndicator } from './item-indicator'
|
||||
|
||||
export const ChecklistPluginGroup = memo(({
|
||||
items,
|
||||
}: {
|
||||
items: ChecklistItem[]
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const install = useInstallPackageFromMarketPlace()
|
||||
const [installing, setInstalling] = useState(false)
|
||||
|
||||
const identifiers = useMemo(
|
||||
() => items
|
||||
.map(i => i.pluginUniqueIdentifier)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
[items],
|
||||
)
|
||||
|
||||
const handleInstallAll: MouseEventHandler = async (e) => {
|
||||
e.stopPropagation()
|
||||
if (installing || identifiers.length === 0)
|
||||
return
|
||||
setInstalling(true)
|
||||
for (const id of identifiers) {
|
||||
try {
|
||||
const response = await install.mutateAsync(id)
|
||||
if (response?.task_id) {
|
||||
const { check } = checkTaskStatus()
|
||||
await check({ taskId: response.task_id, pluginUniqueIdentifier: id })
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// continue installing remaining plugins
|
||||
}
|
||||
}
|
||||
setInstalling(false)
|
||||
install.reset()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-clip rounded-[10px] bg-components-panel-on-panel-item-bg">
|
||||
<div className="flex items-center gap-2 px-2 pt-2">
|
||||
<div className="flex size-5 shrink-0 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-icon-bg-midnight-solid shadow-xs">
|
||||
<span className="i-ri-download-line size-3.5 text-white" />
|
||||
</div>
|
||||
<span className="min-w-0 grow truncate text-sm font-medium leading-5 text-text-primary">
|
||||
{t('nodes.common.pluginsNotInstalled', { ns: 'workflow', count: items.length })}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={handleInstallAll}
|
||||
disabled={installing || identifiers.length === 0}
|
||||
>
|
||||
{installing
|
||||
? (
|
||||
<>
|
||||
{t('nodes.agent.pluginInstaller.installing', { ns: 'workflow' })}
|
||||
<span className="i-ri-loader-2-line ml-1 size-3 animate-spin" />
|
||||
</>
|
||||
)
|
||||
: t('nodes.agent.pluginInstaller.install', { ns: 'workflow' })}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-1">
|
||||
{items.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-2 rounded-lg px-1"
|
||||
>
|
||||
<ItemIndicator />
|
||||
<BlockIcon
|
||||
type={item.type as BlockEnum}
|
||||
size="xs"
|
||||
toolIcon={item.toolIcon}
|
||||
/>
|
||||
<span className="min-w-0 grow truncate text-xs leading-4 text-text-warning">
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
ChecklistPluginGroup.displayName = 'ChecklistPluginGroup'
|
||||
Reference in New Issue
Block a user