Files
dify/web/app/components/workflow/skill/start-tab/create-blank-skill-modal.tsx
yyh 4338632a78 fix(skill): use Dialog initialFocus to focus input on modal open
Expose initialFocus prop on Modal component (passthrough to Headless
UI Dialog) so the create blank skill modal reliably focuses the name
input when opened, replacing the ineffective autoFocus attribute.
2026-01-30 16:10:19 +08:00

166 lines
5.2 KiB
TypeScript

'use client'
import type { BatchUploadNodeInput } from '@/types/app-asset'
import { memo, useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import Toast from '@/app/components/base/toast'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { useBatchUpload } from '@/service/use-app-asset'
import { useExistingSkillNames } from '../hooks/use-skill-asset-tree'
import { useSkillTreeUpdateEmitter } from '../hooks/use-skill-tree-collaboration'
import { prepareSkillUploadFile } from '../utils/skill-upload-utils'
const SKILL_MD_TEMPLATE = (name: string) => `---
name: ${name}
description:
---
# ${name}
`
type CreateBlankSkillModalProps = {
isOpen: boolean
onClose: () => void
}
const CreateBlankSkillModal = ({ isOpen, onClose }: CreateBlankSkillModalProps) => {
const { t } = useTranslation()
const [skillName, setSkillName] = useState('')
const [isCreating, setIsCreating] = useState(false)
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id || ''
const storeApi = useWorkflowStore()
const batchUpload = useBatchUpload()
const batchUploadRef = useRef(batchUpload)
batchUploadRef.current = batchUpload
const emitTreeUpdate = useSkillTreeUpdateEmitter()
const emitTreeUpdateRef = useRef(emitTreeUpdate)
emitTreeUpdateRef.current = emitTreeUpdate
const { data: existingNames } = useExistingSkillNames()
const inputRef = useRef<HTMLInputElement>(null)
const trimmedName = skillName.trim()
const isDuplicate = !!trimmedName && (existingNames?.has(trimmedName) ?? false)
const canCreate = !!trimmedName && !isDuplicate && !isCreating
const handleClose = useCallback(() => {
if (isCreating)
return
setSkillName('')
onClose()
}, [isCreating, onClose])
const handleCreate = useCallback(async () => {
if (!canCreate || !appId)
return
setIsCreating(true)
storeApi.getState().setUploadStatus('uploading')
storeApi.getState().setUploadProgress({ uploaded: 0, total: 1, failed: 0 })
try {
const content = SKILL_MD_TEMPLATE(trimmedName)
const rawFile = new File([content], 'SKILL.md', { type: 'text/markdown' })
const preparedFile = await prepareSkillUploadFile(rawFile)
const tree: BatchUploadNodeInput[] = [{
name: trimmedName,
node_type: 'folder',
children: [{ name: 'SKILL.md', node_type: 'file', size: preparedFile.size }],
}]
const files = new Map<string, File>()
files.set(`${trimmedName}/SKILL.md`, preparedFile)
const createdNodes = await batchUploadRef.current.mutateAsync({
appId,
tree,
files,
parentId: null,
onProgress: (uploaded, total) => {
storeApi.getState().setUploadProgress({ uploaded, total, failed: 0 })
},
})
storeApi.getState().setUploadStatus('success')
emitTreeUpdateRef.current()
const skillMdId = createdNodes?.[0]?.children?.[0]?.id
if (skillMdId)
storeApi.getState().openTab(skillMdId, { pinned: true })
Toast.notify({ type: 'success', message: t('skill.startTab.createSuccess', { ns: 'workflow', name: trimmedName }) })
onClose()
}
catch {
storeApi.getState().setUploadStatus('partial_error')
Toast.notify({ type: 'error', message: t('skill.startTab.createError', { ns: 'workflow' }) })
}
finally {
setIsCreating(false)
setSkillName('')
}
}, [canCreate, appId, trimmedName, storeApi, onClose, t])
return (
<Modal
isShow={isOpen}
onClose={handleClose}
title={t('skill.startTab.createModal.title', { ns: 'workflow' })}
closable={!isCreating}
clickOutsideNotClose={isCreating}
initialFocus={inputRef}
>
<div className="mt-6 flex flex-col gap-1">
<label className="system-sm-semibold text-text-secondary">
{t('skill.startTab.createModal.nameLabel', { ns: 'workflow' })}
</label>
<Input
ref={inputRef}
value={skillName}
onChange={e => setSkillName(e.target.value)}
placeholder={t('skill.startTab.createModal.namePlaceholder', { ns: 'workflow' }) || ''}
destructive={isDuplicate}
disabled={isCreating}
onKeyDown={(e) => {
if (e.key === 'Enter' && canCreate)
handleCreate()
}}
/>
{isDuplicate && (
<p className="system-xs-regular text-text-destructive">
{t('skill.startTab.createModal.nameDuplicate', { ns: 'workflow' })}
</p>
)}
</div>
<div className="mt-6 flex justify-end gap-2">
<Button
onClick={handleClose}
disabled={isCreating}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
onClick={handleCreate}
disabled={!canCreate}
loading={isCreating}
>
{t('operation.create', { ns: 'common' })}
</Button>
</div>
</Modal>
)
}
export default memo(CreateBlankSkillModal)