dify/web/app/components/plugins/plugin-page/empty/index.tsx
Stephen Zhou a84c2d36a3
style: format with vp fmt (#38803)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-12 15:57:46 +00:00

317 lines
12 KiB
TypeScript

'use client'
import type { PluginPageContentInset } from '../content-inset'
import type { PluginCategoryEnum } from '@/app/components/plugins/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Group } from '@/app/components/base/icons/src/vender/other'
import { FileZip } from '@/app/components/base/icons/src/vender/solid/files'
import { Github } from '@/app/components/base/icons/src/vender/solid/general'
import { MagicBox } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import InstallFromGitHub from '@/app/components/plugins/install-plugin/install-from-github'
import InstallFromLocalPackage from '@/app/components/plugins/install-plugin/install-from-local-package'
import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useInstalledPluginList } from '@/service/use-plugins'
import Line from '../../marketplace/empty/line'
import {
pluginPageContentFrameClassNames,
pluginPageContentInsetClassNames,
} from '../content-inset'
import { usePluginPageContext } from '../context'
import {
DropHintInstallSourceIcon,
GithubInstallSourceIcon,
LocalPackageInstallSourceIcon,
MarketplaceInstallSourceIcon,
} from '../install-source-icons'
type InstallMethod = {
icon: React.ComponentType<{ className?: string }>
integrationIcon: React.ComponentType
text: string
action: string
}
const TriggerEmptyIcon = () => (
<span aria-hidden className="i-custom-vender-integrations-trigger-active size-6 shrink-0" />
)
const AgentStrategyEmptyIcon = () => (
<span
aria-hidden
className="i-custom-vender-integrations-agent-strategy-active size-6 shrink-0"
/>
)
const ExtensionEmptyIcon = () => (
<span aria-hidden className="i-custom-vender-integrations-extension-active size-6 shrink-0" />
)
type EmptyProps = {
canInstall?: boolean
contentInset?: PluginPageContentInset
installContextCategory?: PluginCategoryEnum
onSwitchToMarketplace?: () => void
variant?:
| 'default'
| 'integrationsAgentStrategy'
| 'integrationsExtension'
| 'integrationsTrigger'
}
const Empty = ({
canInstall = true,
contentInset = 'default',
installContextCategory,
onSwitchToMarketplace,
variant = 'default',
}: EmptyProps) => {
const { t } = useTranslation()
const fileInputRef = useRef<HTMLInputElement>(null)
const [selectedAction, setSelectedAction] = useState<string | null>(null)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const { data: enable_marketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: (s) => s.enable_marketplace,
})
const { data: plugin_installation_permission } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: (s) => s.plugin_installation_permission,
})
const setActiveTab = usePluginPageContext((v) => v.setActiveTab)
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (!canInstall) {
setSelectedFile(null)
setSelectedAction(null)
return
}
const file = event.target.files?.[0]
if (file) {
setSelectedFile(file)
setSelectedAction('local')
}
}
const filters = usePluginPageContext((v) => v.filters)
const { data: pluginList } = useInstalledPluginList()
const text = useMemo(() => {
if (pluginList?.plugins.length === 0) return t(($) => $['list.noInstalled'], { ns: 'plugin' })
if (filters.categories.length > 0 || filters.tags.length > 0 || filters.searchQuery)
return t(($) => $['list.notFound'], { ns: 'plugin' })
}, [
pluginList?.plugins.length,
t,
filters.categories.length,
filters.tags.length,
filters.searchQuery,
])
const installMethods = useMemo<InstallMethod[]>(() => {
if (!canInstall) return []
const methods: InstallMethod[] = []
if (enable_marketplace)
methods.push({
icon: MagicBox,
integrationIcon: MarketplaceInstallSourceIcon,
text: t(($) => $['source.marketplace'], { ns: 'plugin' }),
action: 'marketplace',
})
if (plugin_installation_permission.restrict_to_marketplace_only) return methods
methods.push({
icon: Github,
integrationIcon: GithubInstallSourceIcon,
text: t(($) => $['source.github'], { ns: 'plugin' }),
action: 'github',
})
methods.push({
icon: FileZip,
integrationIcon: LocalPackageInstallSourceIcon,
text: t(($) => $['source.local'], { ns: 'plugin' }),
action: 'local',
})
return methods
}, [canInstall, plugin_installation_permission, enable_marketplace, t])
const contentPaddingClassName = pluginPageContentInsetClassNames[contentInset]
const canInstallLocalPackage =
canInstall && !plugin_installation_permission.restrict_to_marketplace_only
const isIntegrationsTrigger = variant === 'integrationsTrigger'
const isIntegrationsAgentStrategy = variant === 'integrationsAgentStrategy'
const isIntegrationsExtension = variant === 'integrationsExtension'
const isIntegrationsCategory =
isIntegrationsTrigger || isIntegrationsAgentStrategy || isIntegrationsExtension
const supportsDropInstall = isIntegrationsCategory
const showDropInstallTip = supportsDropInstall && canInstallLocalPackage
const contentFrameClassName = cn(
pluginPageContentFrameClassNames[contentInset],
contentPaddingClassName,
)
const emptyText = isIntegrationsTrigger
? t(($) => $['list.noTriggerFound'], { ns: 'plugin' })
: isIntegrationsAgentStrategy
? t(($) => $['list.noAgentStrategyFound'], { ns: 'plugin' })
: isIntegrationsExtension
? t(($) => $['list.noExtensionFound'], { ns: 'plugin' })
: text
const placeholderItemCount = isIntegrationsCategory ? 14 : 20
return (
<div className="relative z-0 w-full grow bg-components-panel-bg">
<div
aria-hidden
className={cn(
'pointer-events-none absolute top-0 left-1/2 z-10 grid h-full -translate-x-1/2 grid-cols-2 content-start gap-2 overflow-hidden',
contentFrameClassName,
)}
>
{Array.from({ length: placeholderItemCount }, (_, i) => (
<div
key={i}
className={cn(
isIntegrationsCategory
? 'h-24 rounded-lg bg-background-section-burn/30'
: 'h-24 rounded-xl bg-components-card-bg',
)}
/>
))}
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-20 bg-linear-to-b from-components-panel-bg-transparent to-components-panel-bg"
/>
<div
className={cn(
'relative z-30 flex h-full',
showDropInstallTip ? 'flex-col' : 'items-center justify-center',
)}
>
<div
className={cn(
'flex items-center justify-center',
showDropInstallTip ? 'min-h-0 flex-1' : 'h-full w-full',
)}
>
<div
className={cn(
'flex flex-col items-center',
isIntegrationsCategory ? 'gap-y-6' : 'gap-y-3',
)}
>
<div className="flex flex-col items-center gap-y-3">
<div
className={cn(
'relative -z-10 flex items-center justify-center border-dashed bg-components-card-bg backdrop-blur-md',
isIntegrationsCategory
? 'size-14 rounded-xl border border-divider-regular'
: 'size-14 rounded-xl border border-divider-deep shadow-xl shadow-shadow-shadow-5',
)}
>
{isIntegrationsCategory ? (
<span className="text-text-tertiary">
{isIntegrationsAgentStrategy ? (
<AgentStrategyEmptyIcon />
) : isIntegrationsExtension ? (
<ExtensionEmptyIcon />
) : (
<TriggerEmptyIcon />
)}
</span>
) : (
<Group className="size-5 text-text-tertiary" />
)}
{!isIntegrationsCategory && (
<>
<Line className="absolute top-1/2 -right-px -translate-y-1/2" />
<Line className="absolute top-1/2 -left-px -translate-y-1/2" />
<Line className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 rotate-90" />
<Line className="absolute top-full left-1/2 -translate-x-1/2 -translate-y-1/2 rotate-90" />
</>
)}
</div>
<div
className={cn(
isIntegrationsCategory
? 'system-sm-regular text-text-tertiary'
: 'system-md-regular text-text-tertiary',
)}
>
{emptyText}
</div>
</div>
<div className="flex w-[236px] flex-col">
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileChange}
accept={SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS}
/>
<div className="flex w-full flex-col gap-y-1">
{installMethods.map(
({ icon: Icon, integrationIcon: IntegrationIcon, text, action }) => (
<Button
key={action}
variant="secondary"
title={text}
className="h-8 w-full justify-start gap-x-0.5 px-3 py-2 system-sm-medium"
onClick={() => {
if (action === 'local') fileInputRef.current?.click()
else if (action === 'marketplace')
onSwitchToMarketplace ? onSwitchToMarketplace() : setActiveTab('discover')
else setSelectedAction(action)
}}
>
{isIntegrationsCategory ? (
<IntegrationIcon />
) : (
<Icon className="size-4 text-components-button-secondary-text" />
)}
<span className="min-w-0 flex-1 truncate px-0.5 text-left">{text}</span>
</Button>
),
)}
</div>
</div>
</div>
</div>
{showDropInstallTip && (
<div className="flex shrink-0 items-center justify-center gap-2 px-6 py-4 text-text-quaternary">
<DropHintInstallSourceIcon />
<span className="system-xs-regular">
{t(($) => $['installModal.dropIntegrationToInstall'], { ns: 'plugin' })}
</span>
</div>
)}
{selectedAction === 'github' && (
<InstallFromGitHub
installContextCategory={installContextCategory}
onSuccess={noop}
onClose={() => setSelectedAction(null)}
/>
)}
{selectedAction === 'local' && selectedFile && (
<InstallFromLocalPackage
file={selectedFile}
installContextCategory={installContextCategory}
onClose={() => setSelectedAction(null)}
onSuccess={noop}
/>
)}
</div>
</div>
)
}
Empty.displayName = 'Empty'
export default React.memo(Empty)