mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 10:38:32 +08:00
245 lines
8.8 KiB
TypeScript
245 lines
8.8 KiB
TypeScript
'use client'
|
|
import type { SourceAppPickerValue } from '../state'
|
|
import type { App } from '@/types/app'
|
|
import { cn } from '@langgenius/dify-ui/cn'
|
|
import {
|
|
Combobox,
|
|
ComboboxContent,
|
|
ComboboxEmpty,
|
|
ComboboxInput,
|
|
ComboboxInputGroup,
|
|
ComboboxItem,
|
|
ComboboxItemText,
|
|
ComboboxList,
|
|
ComboboxTrigger,
|
|
} from '@langgenius/dify-ui/combobox'
|
|
import { useAtomValue, useSetAtom } from 'jotai'
|
|
import { useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import AppIcon from '@/app/components/base/app-icon'
|
|
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
|
import { useInfiniteScroll } from '@/features/deployments/shared/hooks/use-infinite-scroll'
|
|
import { TitleTooltip } from '../../shared/components/title-tooltip'
|
|
import {
|
|
createReleaseSourceAppsAtom,
|
|
createReleaseSourceAppSearchTextAtom,
|
|
createReleaseSourceAppsErrorAtom,
|
|
createReleaseSourceAppsFetchNextPageAtom,
|
|
createReleaseSourceAppsHasNextPageAtom,
|
|
createReleaseSourceAppsIsFetchingAtom,
|
|
createReleaseSourceAppsIsFetchingNextPageAtom,
|
|
createReleaseSourceAppsIsLoadingAtom,
|
|
} from '../state'
|
|
|
|
const SOURCE_APP_PICKER_SKELETON_KEYS = ['first-source-app', 'second-source-app', 'third-source-app']
|
|
|
|
function sourceAppSearchText(app: App) {
|
|
return `${app.name} ${app.id}`.toLowerCase()
|
|
}
|
|
|
|
function SourceAppTrigger({ app }: {
|
|
app?: SourceAppPickerValue
|
|
}) {
|
|
const { t } = useTranslation('deployments')
|
|
|
|
return (
|
|
<span
|
|
className={cn(
|
|
'flex h-10 items-center gap-2 rounded-lg border border-transparent bg-components-input-bg-normal px-3 text-left',
|
|
'cursor-pointer hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
|
|
'group-data-disabled/combobox-trigger:cursor-not-allowed group-data-disabled/combobox-trigger:text-components-input-text-disabled group-data-disabled/combobox-trigger:hover:border-transparent group-data-disabled/combobox-trigger:hover:bg-components-input-bg-normal',
|
|
'group-data-popup-open/combobox-trigger:border-components-input-border-active group-data-popup-open/combobox-trigger:bg-components-input-bg-active group-data-popup-open/combobox-trigger:shadow-xs',
|
|
app && 'pl-2',
|
|
)}
|
|
>
|
|
{app && (
|
|
<AppIcon
|
|
className="shrink-0"
|
|
size="xs"
|
|
iconType={app.icon_type}
|
|
icon={app.icon}
|
|
background={app.icon_background}
|
|
imageUrl={app.icon_url}
|
|
/>
|
|
)}
|
|
<TitleTooltip content={app?.name}>
|
|
<span
|
|
className={cn(
|
|
'min-w-0 grow truncate',
|
|
app
|
|
? 'system-sm-medium text-components-input-text-filled'
|
|
: 'system-sm-regular text-components-input-text-placeholder',
|
|
)}
|
|
>
|
|
{app?.name ?? t('createModal.appPickerPlaceholder')}
|
|
</span>
|
|
</TitleTooltip>
|
|
<span
|
|
className={cn(
|
|
'i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary group-hover/combobox-trigger:text-text-secondary',
|
|
'group-data-disabled/combobox-trigger:text-text-quaternary group-data-disabled/combobox-trigger:opacity-50',
|
|
'group-data-popup-open/combobox-trigger:text-text-secondary',
|
|
)}
|
|
aria-hidden="true"
|
|
/>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function SourceAppOption({ app }: {
|
|
app: App
|
|
}) {
|
|
return (
|
|
<ComboboxItem
|
|
value={app}
|
|
className="mx-0 grid-cols-[minmax(0,1fr)] gap-3 py-1 pr-3 pl-2"
|
|
>
|
|
<ComboboxItemText className="flex min-w-0 items-center gap-3 px-0">
|
|
<AppIcon
|
|
className="shrink-0"
|
|
size="xs"
|
|
iconType={app.icon_type}
|
|
icon={app.icon}
|
|
background={app.icon_background}
|
|
imageUrl={app.icon_url}
|
|
/>
|
|
<TitleTooltip content={`${app.name} (${app.id})`}>
|
|
<span className="flex min-w-0 grow items-center gap-1 truncate system-sm-medium text-components-input-text-filled">
|
|
<span className="truncate">{app.name}</span>
|
|
<span className="shrink-0 text-text-tertiary">
|
|
(
|
|
{app.id.slice(0, 8)}
|
|
)
|
|
</span>
|
|
</span>
|
|
</TitleTooltip>
|
|
</ComboboxItemText>
|
|
</ComboboxItem>
|
|
)
|
|
}
|
|
|
|
function SourceAppPickerSkeleton() {
|
|
return (
|
|
<div className="flex flex-col gap-2 px-3 py-3">
|
|
{SOURCE_APP_PICKER_SKELETON_KEYS.map(key => (
|
|
<SkeletonRow key={key} className="h-7 gap-3">
|
|
<SkeletonRectangle className="my-0 size-5 animate-pulse rounded-md" />
|
|
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
|
|
</SkeletonRow>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function SourceAppPicker({ value, onChange, disabled = false }: {
|
|
value?: SourceAppPickerValue
|
|
onChange: (app: App) => void
|
|
disabled?: boolean
|
|
}) {
|
|
const { t } = useTranslation('deployments')
|
|
const [isShow, setIsShow] = useState(false)
|
|
const searchText = useAtomValue(createReleaseSourceAppSearchTextAtom)
|
|
const setSearchText = useSetAtom(createReleaseSourceAppSearchTextAtom)
|
|
const apps = useAtomValue(createReleaseSourceAppsAtom)
|
|
const sourceAppsError = useAtomValue(createReleaseSourceAppsErrorAtom)
|
|
const sourceAppsFetchNextPage = useAtomValue(createReleaseSourceAppsFetchNextPageAtom)
|
|
const sourceAppsHasNextPage = useAtomValue(createReleaseSourceAppsHasNextPageAtom)
|
|
const sourceAppsIsFetching = useAtomValue(createReleaseSourceAppsIsFetchingAtom)
|
|
const sourceAppsIsFetchingNextPage = useAtomValue(createReleaseSourceAppsIsFetchingNextPageAtom)
|
|
const sourceAppsIsLoading = useAtomValue(createReleaseSourceAppsIsLoadingAtom)
|
|
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>({
|
|
error: sourceAppsError,
|
|
fetchNextPage: sourceAppsFetchNextPage,
|
|
hasNextPage: sourceAppsHasNextPage,
|
|
isFetching: sourceAppsIsFetching,
|
|
isFetchingNextPage: sourceAppsIsFetchingNextPage,
|
|
isLoading: sourceAppsIsLoading,
|
|
}, {
|
|
enabled: isShow && !disabled,
|
|
rootMargin: '0px 0px 160px 0px',
|
|
threshold: 0.1,
|
|
})
|
|
|
|
return (
|
|
<Combobox<App>
|
|
items={apps}
|
|
open={!disabled && isShow}
|
|
inputValue={searchText}
|
|
onOpenChange={(open) => {
|
|
setIsShow(disabled ? false : open)
|
|
}}
|
|
onInputValueChange={(value) => {
|
|
if (!disabled)
|
|
setSearchText(value)
|
|
}}
|
|
onValueChange={(app) => {
|
|
if (disabled)
|
|
return
|
|
if (!app)
|
|
return
|
|
onChange(app)
|
|
setIsShow(false)
|
|
}}
|
|
itemToStringLabel={(app) => {
|
|
if (!app)
|
|
return ''
|
|
|
|
return app.name
|
|
}}
|
|
itemToStringValue={(app) => {
|
|
if (!app)
|
|
return ''
|
|
|
|
return app.id
|
|
}}
|
|
filter={(app, query) => sourceAppSearchText(app).includes(query.toLowerCase())}
|
|
disabled={disabled}
|
|
>
|
|
<ComboboxTrigger
|
|
aria-label={t('versions.sourceAppOption')}
|
|
icon={false}
|
|
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-open:bg-transparent"
|
|
>
|
|
<SourceAppTrigger app={value} />
|
|
</ComboboxTrigger>
|
|
<ComboboxContent
|
|
placement="bottom-start"
|
|
sideOffset={4}
|
|
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
|
>
|
|
<div className="relative flex max-h-100 min-h-20 w-89 flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
|
<div className="p-2 pb-1">
|
|
<ComboboxInputGroup className="h-8 min-h-8 px-2">
|
|
<span className="i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
|
<ComboboxInput
|
|
aria-label={t('createModal.appSearchPlaceholder')}
|
|
placeholder={t('createModal.appSearchPlaceholder')}
|
|
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
|
|
/>
|
|
</ComboboxInputGroup>
|
|
</div>
|
|
<div ref={rootRef} className="min-h-0 flex-1 overflow-y-auto p-1">
|
|
{(sourceAppsIsLoading || sourceAppsIsFetchingNextPage) && apps.length === 0 && <SourceAppPickerSkeleton />}
|
|
<ComboboxList className="max-h-none p-0">
|
|
{(app: App) => (
|
|
<SourceAppOption key={app.id} app={app} />
|
|
)}
|
|
</ComboboxList>
|
|
{!(sourceAppsIsLoading || sourceAppsIsFetchingNextPage) && (
|
|
<ComboboxEmpty>
|
|
{t('createModal.appSearchEmpty')}
|
|
</ComboboxEmpty>
|
|
)}
|
|
{sourceAppsIsFetchingNextPage && apps.length > 0 && (
|
|
<div className="px-3 py-2 text-center system-xs-regular text-text-tertiary">
|
|
{t('createModal.loadingApps')}
|
|
</div>
|
|
)}
|
|
{sourceAppsHasNextPage && <div ref={sentinelRef} aria-hidden="true" className="h-px" />}
|
|
</div>
|
|
</div>
|
|
</ComboboxContent>
|
|
</Combobox>
|
|
)
|
|
}
|