mirror of
https://github.com/langgenius/dify.git
synced 2026-04-21 11:17:38 +08:00
feat(web): snippet info operations
This commit is contained in:
198
web/app/components/app-sidebar/snippet-info/dropdown.tsx
Normal file
198
web/app/components/app-sidebar/snippet-info/dropdown.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
'use client'
|
||||
|
||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@/app/components/base/ui/alert-dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import CreateSnippetDialog from '@/app/components/workflow/create-snippet-dialog'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
|
||||
type SnippetInfoDropdownProps = {
|
||||
snippet: SnippetDetail
|
||||
}
|
||||
|
||||
const FALLBACK_ICON: AppIconSelection = {
|
||||
type: 'emoji',
|
||||
icon: '🤖',
|
||||
background: '#FFEAD5',
|
||||
}
|
||||
|
||||
const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const { replace } = useRouter()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false)
|
||||
const updateSnippetMutation = useUpdateSnippetMutation()
|
||||
const exportSnippetMutation = useExportSnippetMutation()
|
||||
const deleteSnippetMutation = useDeleteSnippetMutation()
|
||||
|
||||
const initialValue = React.useMemo(() => ({
|
||||
name: snippet.name,
|
||||
description: snippet.description,
|
||||
icon: snippet.icon
|
||||
? {
|
||||
type: 'emoji' as const,
|
||||
icon: snippet.icon,
|
||||
background: snippet.iconBackground || FALLBACK_ICON.background,
|
||||
}
|
||||
: FALLBACK_ICON,
|
||||
}), [snippet.description, snippet.icon, snippet.iconBackground, snippet.name])
|
||||
|
||||
const handleOpenEditDialog = React.useCallback(() => {
|
||||
setOpen(false)
|
||||
setIsEditDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleExportSnippet = React.useCallback(async () => {
|
||||
setOpen(false)
|
||||
try {
|
||||
const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id })
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
downloadBlob({ data: file, fileName: `${snippet.name}.yml` })
|
||||
}
|
||||
catch {
|
||||
toast.error(t('exportFailed'))
|
||||
}
|
||||
}, [exportSnippetMutation, snippet.id, snippet.name, t])
|
||||
|
||||
const handleEditSnippet = React.useCallback(async ({ name, description, icon }: {
|
||||
name: string
|
||||
description: string
|
||||
icon: AppIconSelection
|
||||
}) => {
|
||||
updateSnippetMutation.mutate({
|
||||
params: { snippetId: snippet.id },
|
||||
body: {
|
||||
name,
|
||||
description: description || undefined,
|
||||
icon_info: {
|
||||
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
|
||||
icon_type: icon.type,
|
||||
icon_background: icon.type === 'emoji' ? icon.background : undefined,
|
||||
icon_url: icon.type === 'image' ? icon.url : undefined,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('editDone'))
|
||||
setIsEditDialogOpen(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('editFailed'))
|
||||
},
|
||||
})
|
||||
}, [snippet.id, t, updateSnippetMutation])
|
||||
|
||||
const handleDeleteSnippet = React.useCallback(() => {
|
||||
deleteSnippetMutation.mutate({
|
||||
params: { snippetId: snippet.id },
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('deleted'))
|
||||
setIsDeleteDialogOpen(false)
|
||||
replace('/snippets')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
|
||||
},
|
||||
})
|
||||
}, [deleteSnippetMutation, replace, snippet.id, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className={cn('action-btn action-btn-m size-6 rounded-md text-text-tertiary', open && 'bg-state-base-hover text-text-secondary')}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[180px] p-1"
|
||||
>
|
||||
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
|
||||
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('menu.editInfo')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}>
|
||||
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('menu.exportSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="!my-1 bg-divider-subtle" />
|
||||
<DropdownMenuItem
|
||||
className="mx-0 gap-2"
|
||||
destructive
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<span className="grow">{t('menu.deleteSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{isEditDialogOpen && (
|
||||
<CreateSnippetDialog
|
||||
isOpen={isEditDialogOpen}
|
||||
selectedNodeIds={[]}
|
||||
initialValue={initialValue}
|
||||
title={t('editDialogTitle')}
|
||||
confirmText={t('operation.save', { ns: 'common' })}
|
||||
isSubmitting={updateSnippetMutation.isPending}
|
||||
onClose={() => setIsEditDialogOpen(false)}
|
||||
onConfirm={handleEditSnippet}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent className="w-[400px]">
|
||||
<div className="space-y-2 p-6">
|
||||
<AlertDialogTitle className="text-text-primary title-lg-semi-bold">
|
||||
{t('deleteConfirmTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-text-tertiary system-sm-regular">
|
||||
{t('deleteConfirmContent')}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions className="pt-0">
|
||||
<AlertDialogCancelButton>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
loading={deleteSnippetMutation.isPending}
|
||||
onClick={handleDeleteSnippet}
|
||||
>
|
||||
{t('menu.deleteSnippet')}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SnippetInfoDropdown)
|
||||
@ -2,9 +2,10 @@
|
||||
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import SnippetInfoDropdown from './dropdown'
|
||||
|
||||
type SnippetInfoProps = {
|
||||
expand: boolean
|
||||
@ -15,11 +16,13 @@ const SnippetInfo = ({
|
||||
expand,
|
||||
snippet,
|
||||
}: SnippetInfoProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col', expand ? '' : 'p-1')}>
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(!expand && 'ml-1')}>
|
||||
<div className={cn('flex flex-col', expand ? 'px-2 pb-1 pt-2' : 'p-1')}>
|
||||
<div className={cn('flex flex-col', expand ? 'gap-2 rounded-xl p-2' : '')}>
|
||||
<div className={cn('flex', expand ? 'items-center justify-between' : 'items-start gap-3')}>
|
||||
<div className={cn('shrink-0', !expand && 'ml-1')}>
|
||||
<AppIcon
|
||||
size={expand ? 'large' : 'small'}
|
||||
iconType="emoji"
|
||||
@ -27,21 +30,20 @@ const SnippetInfo = ({
|
||||
background={snippet.iconBackground}
|
||||
/>
|
||||
</div>
|
||||
{expand && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-text-secondary system-md-semibold">
|
||||
{snippet.name}
|
||||
</div>
|
||||
{snippet.status && (
|
||||
<div className="pt-1">
|
||||
<Badge>{snippet.status}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{expand && <SnippetInfoDropdown snippet={snippet} />}
|
||||
</div>
|
||||
{expand && (
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-text-secondary system-md-semibold">
|
||||
{snippet.name}
|
||||
</div>
|
||||
<div className="pt-1 text-text-tertiary system-2xs-medium-uppercase">
|
||||
{t('typeLabel')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{expand && snippet.description && (
|
||||
<p className="line-clamp-3 text-text-tertiary system-xs-regular">
|
||||
<p className="line-clamp-3 break-words text-text-tertiary system-xs-regular">
|
||||
{snippet.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -6,8 +6,8 @@ import type { SnippetDetailPayload, SnippetInputField, SnippetSection } from '@/
|
||||
import {
|
||||
RiFlaskFill,
|
||||
RiFlaskLine,
|
||||
RiGitBranchFill,
|
||||
RiGitBranchLine,
|
||||
RiTerminalWindowFill,
|
||||
RiTerminalWindowLine,
|
||||
} from '@remixicon/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -16,7 +16,7 @@ import AppSideBar from '@/app/components/app-sidebar'
|
||||
import NavLink from '@/app/components/app-sidebar/nav-link'
|
||||
import SnippetInfo from '@/app/components/app-sidebar/snippet-info'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import Evaluation from '@/app/components/evaluation'
|
||||
import { WorkflowWithInnerContext } from '@/app/components/workflow'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
@ -30,8 +30,8 @@ type SnippetMainProps = {
|
||||
} & Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport'>
|
||||
|
||||
const ORCHESTRATE_ICONS: { normal: NavIcon, selected: NavIcon } = {
|
||||
normal: RiGitBranchLine,
|
||||
selected: RiGitBranchFill,
|
||||
normal: RiTerminalWindowLine,
|
||||
selected: RiTerminalWindowFill,
|
||||
}
|
||||
|
||||
const EVALUATION_ICONS: { normal: NavIcon, selected: NavIcon } = {
|
||||
@ -107,10 +107,7 @@ const SnippetMain = ({
|
||||
const duplicated = fields.some(item => item.variable === field.variable && item.variable !== originalVariable)
|
||||
|
||||
if (duplicated) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('inputFieldPanel.error.variableDuplicate', { ns: 'datasetPipeline' }),
|
||||
})
|
||||
toast.error(t('inputFieldPanel.error.variableDuplicate', { ns: 'datasetPipeline' }))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useSnippetDetail } from '@/service/use-snippets'
|
||||
// import { useSnippetDetail } from '@/service/use-snippets'
|
||||
import { useSnippetDetail } from '@/service/use-snippets.mock'
|
||||
|
||||
export const useSnippetInit = (snippetId: string) => {
|
||||
return useSnippetDetail(snippetId)
|
||||
|
||||
@ -20,12 +20,21 @@ export type CreateSnippetDialogPayload = {
|
||||
selectedNodeIds: string[]
|
||||
}
|
||||
|
||||
export type CreateSnippetDialogInitialValue = {
|
||||
name?: string
|
||||
description?: string
|
||||
icon?: AppIconSelection
|
||||
}
|
||||
|
||||
type CreateSnippetDialogProps = {
|
||||
isOpen: boolean
|
||||
selectedNodeIds: string[]
|
||||
onClose: () => void
|
||||
onConfirm: (payload: CreateSnippetDialogPayload) => void
|
||||
isSubmitting?: boolean
|
||||
title?: string
|
||||
confirmText?: string
|
||||
initialValue?: CreateSnippetDialogInitialValue
|
||||
}
|
||||
|
||||
const defaultIcon: AppIconSelection = {
|
||||
@ -40,11 +49,14 @@ const CreateSnippetDialog: FC<CreateSnippetDialogProps> = ({
|
||||
onClose,
|
||||
onConfirm,
|
||||
isSubmitting = false,
|
||||
title,
|
||||
confirmText,
|
||||
initialValue,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [icon, setIcon] = useState<AppIconSelection>(defaultIcon)
|
||||
const [name, setName] = useState(initialValue?.name ?? '')
|
||||
const [description, setDescription] = useState(initialValue?.description ?? '')
|
||||
const [icon, setIcon] = useState<AppIconSelection>(initialValue?.icon ?? defaultIcon)
|
||||
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
@ -94,7 +106,7 @@ const CreateSnippetDialog: FC<CreateSnippetDialogProps> = ({
|
||||
|
||||
<div className="px-6 pb-3 pt-6">
|
||||
<DialogTitle className="text-text-primary title-2xl-semi-bold">
|
||||
{t('snippet.createDialogTitle', { ns: 'workflow' })}
|
||||
{title || t('snippet.createDialogTitle', { ns: 'workflow' })}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
|
||||
@ -148,7 +160,7 @@ const CreateSnippetDialog: FC<CreateSnippetDialogProps> = ({
|
||||
loading={isSubmitting}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{t('snippet.confirm', { ns: 'workflow' })}
|
||||
{confirmText || t('snippet.confirm', { ns: 'workflow' })}
|
||||
<ShortcutsName className="ml-1" keys={['ctrl', 'enter']} bgColor="white" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -439,14 +439,16 @@ const SelectionContextmenu = () => {
|
||||
))}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
<CreateSnippetDialog
|
||||
isOpen={isCreateSnippetDialogOpen}
|
||||
selectedNodeIds={selectedNodeIdsSnapshot}
|
||||
onClose={handleCloseCreateSnippetDialog}
|
||||
onConfirm={(payload) => {
|
||||
void payload
|
||||
}}
|
||||
/>
|
||||
{isCreateSnippetDialogOpen && (
|
||||
<CreateSnippetDialog
|
||||
isOpen={isCreateSnippetDialogOpen}
|
||||
selectedNodeIds={selectedNodeIdsSnapshot}
|
||||
onClose={handleCloseCreateSnippetDialog}
|
||||
onConfirm={(payload) => {
|
||||
void payload
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,9 +3,20 @@
|
||||
"createFailed": "Failed to create snippet",
|
||||
"createFromBlank": "Create from blank",
|
||||
"defaultName": "Untitled Snippet",
|
||||
"deleteConfirmContent": "Deleting this snippet is irreversible. Its draft workflow and published content will no longer be available.",
|
||||
"deleteConfirmTitle": "Delete Snippet?",
|
||||
"deleteFailed": "Failed to delete snippet",
|
||||
"deleted": "Snippet deleted",
|
||||
"editDialogTitle": "Edit Snippet Info",
|
||||
"editDone": "Snippet info updated",
|
||||
"editFailed": "Failed to update snippet info",
|
||||
"exportFailed": "Export snippet failed.",
|
||||
"importFailed": "Failed to import snippet DSL",
|
||||
"importSuccess": "Snippet imported",
|
||||
"inputFieldButton": "Input Field",
|
||||
"menu.deleteSnippet": "Delete",
|
||||
"menu.editInfo": "Edit Info",
|
||||
"menu.exportSnippet": "Export Snippet",
|
||||
"notFoundDescription": "The requested snippet mock was not found.",
|
||||
"notFoundTitle": "Snippet not found",
|
||||
"panelDescription": "Defines the input fields that allow the snippet to receive data from other nodes.",
|
||||
@ -14,9 +25,11 @@
|
||||
"panelTitle": "Input Field",
|
||||
"publishButton": "Publish",
|
||||
"publishMenuCurrentDraft": "Current draft unpublished",
|
||||
"save": "Save",
|
||||
"sectionEvaluation": "Evaluation",
|
||||
"sectionOrchestrate": "Orchestrate",
|
||||
"testRunButton": "Test run",
|
||||
"typeLabel": "Snippet",
|
||||
"usageCount": "Used {{count}} times",
|
||||
"variableInspect": "Variable Inspect"
|
||||
}
|
||||
|
||||
@ -3,9 +3,20 @@
|
||||
"createFailed": "创建 Snippet 失败",
|
||||
"createFromBlank": "创建空白 Snippet",
|
||||
"defaultName": "未命名 Snippet",
|
||||
"deleteConfirmContent": "删除后不可恢复,草稿工作流和已发布内容都将无法继续使用。",
|
||||
"deleteConfirmTitle": "删除 Snippet?",
|
||||
"deleteFailed": "删除 Snippet 失败",
|
||||
"deleted": "Snippet 已删除",
|
||||
"editDialogTitle": "编辑 Snippet 信息",
|
||||
"editDone": "Snippet 信息已更新",
|
||||
"editFailed": "更新 Snippet 信息失败",
|
||||
"exportFailed": "导出 Snippet 失败。",
|
||||
"importFailed": "导入 Snippet DSL 失败",
|
||||
"importSuccess": "Snippet 导入成功",
|
||||
"inputFieldButton": "输入字段",
|
||||
"menu.deleteSnippet": "删除",
|
||||
"menu.editInfo": "编辑信息",
|
||||
"menu.exportSnippet": "导出 Snippet",
|
||||
"notFoundDescription": "未找到对应的 snippet 静态数据。",
|
||||
"notFoundTitle": "未找到 Snippet",
|
||||
"panelDescription": "定义允许 snippet 从其他节点接收数据的输入字段。",
|
||||
@ -14,9 +25,11 @@
|
||||
"panelTitle": "输入字段",
|
||||
"publishButton": "发布",
|
||||
"publishMenuCurrentDraft": "当前草稿未发布",
|
||||
"save": "保存",
|
||||
"sectionEvaluation": "评测",
|
||||
"sectionOrchestrate": "编排",
|
||||
"testRunButton": "测试运行",
|
||||
"typeLabel": "Snippet",
|
||||
"usageCount": "已使用 {{count}} 次",
|
||||
"variableInspect": "变量查看"
|
||||
}
|
||||
|
||||
@ -263,6 +263,17 @@ export const useDeleteSnippetMutation = () => {
|
||||
})
|
||||
}
|
||||
|
||||
export const useExportSnippetMutation = () => {
|
||||
return useMutation<string, Error, { snippetId: string, include?: boolean }>({
|
||||
mutationFn: ({ snippetId, include = false }) => {
|
||||
return consoleClient.snippets.export({
|
||||
params: { snippetId },
|
||||
query: { include_secret: include ? 'true' : 'false' },
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const usePublishSnippetMutation = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user