refactor(web): align Base UI composition contracts (#39820)

This commit is contained in:
yyh 2026-07-30 21:37:04 +08:00 committed by GitHub
parent 69a16d8319
commit 5e6154fdd1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 239 additions and 99 deletions

View File

@ -46,6 +46,7 @@ vi.mock('@langgenius/dify-ui/select', async () => {
const SelectContext = React.createContext<{
disabled?: boolean
onValueChange?: (value: string) => void
value?: string | null
}>({})
return {
@ -53,15 +54,21 @@ vi.mock('@langgenius/dify-ui/select', async () => {
children,
disabled,
onValueChange,
value,
}: {
children: React.ReactNode
disabled?: boolean
onValueChange?: (value: string) => void
value?: string | null
}) => (
<SelectContext.Provider value={{ disabled, onValueChange }}>
<SelectContext.Provider value={{ disabled, onValueChange, value }}>
<div>{children}</div>
</SelectContext.Provider>
),
SelectValue: ({ placeholder }: { placeholder?: React.ReactNode }) => {
const context = React.use(SelectContext)
return <>{context.value || placeholder}</>
},
SelectTrigger: ({ children, className }: { children: React.ReactNode; className?: string }) => {
const context = React.useContext(SelectContext)
return (

View File

@ -7,6 +7,7 @@ import {
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
@ -130,9 +131,9 @@ const ChatUserInput = ({ inputs }: Props) => {
}}
>
<SelectTrigger className="w-full">
{typeof inputs[key] === 'string' && inputs[key] !== ''
? inputs[key]
: t(($) => $['placeholder.select'], { ns: 'common' })}
<SelectValue
placeholder={t(($) => $['placeholder.select'], { ns: 'common' })}
/>
</SelectTrigger>
<SelectContent>
{(options || []).map((option) => (

View File

@ -69,20 +69,27 @@ vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
onValueChange?: (value: string) => void
value?: string | null
}>({})
return {
Select: ({
children,
onValueChange,
value,
}: {
children: React.ReactNode
onValueChange?: (value: string) => void
value?: string | null
}) => (
<SelectContext.Provider value={{ onValueChange }}>
<SelectContext.Provider value={{ onValueChange, value }}>
<div>{children}</div>
</SelectContext.Provider>
),
SelectValue: ({ placeholder }: { placeholder?: React.ReactNode }) => {
const context = React.use(SelectContext)
return <>{context.value || placeholder}</>
},
SelectTrigger: ({ children }: { children: React.ReactNode }) => {
const context = React.useContext(SelectContext)
return (

View File

@ -11,6 +11,7 @@ import {
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
@ -208,9 +209,9 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
}}
>
<SelectTrigger className="w-full bg-gray-50">
{typeof inputs[key] === 'string' && inputs[key] !== ''
? inputs[key]
: t(($) => $['placeholder.select'], { ns: 'common' })}
<SelectValue
placeholder={t(($) => $['placeholder.select'], { ns: 'common' })}
/>
</SelectTrigger>
<SelectContent>
{(options || []).map((option) => (

View File

@ -5,6 +5,7 @@ import {
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
@ -108,7 +109,7 @@ const InputsFormContent = ({ showTip }: Props) => {
onValueChange={(value) => value && handleFormChange(form.variable, value)}
>
<SelectTrigger className="w-full">
{String(inputsFormValue?.[form.variable] ?? form.default ?? form.label)}
<SelectValue placeholder={form.label} />
</SelectTrigger>
<SelectContent>
{form.options.map((option: string) => (

View File

@ -32,42 +32,52 @@ vi.mock('@langgenius/dify-ui/textarea', () => ({
Textarea: MockTextarea,
}))
vi.mock('@langgenius/dify-ui/select', () => ({
Select: ({
children,
onValueChange,
}: {
children: React.ReactNode
onValueChange: (value: string | null) => void
}) => (
<div>
<button
type="button"
data-testid="content-item-select-root"
onClick={() => onValueChange('alice')}
>
select alice
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectValueContext = React.createContext<string | null>(null)
return {
Select: ({
children,
onValueChange,
value,
}: {
children: React.ReactNode
onValueChange: (value: string | null) => void
value: string | null
}) => (
<SelectValueContext value={value}>
<div>
<button
type="button"
data-testid="content-item-select-root"
onClick={() => onValueChange('alice')}
>
select alice
</button>
<button
type="button"
data-testid="content-item-select-null"
onClick={() => onValueChange(null)}
>
select null
</button>
{children}
</div>
</SelectValueContext>
),
SelectValue: () => <>{React.use(SelectValueContext)}</>,
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<button type="button" data-testid="content-item-select">
{children}
</button>
<button
type="button"
data-testid="content-item-select-null"
onClick={() => onValueChange(null)}
>
select null
</button>
{children}
</div>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<button type="button" data-testid="content-item-select">
{children}
</button>
),
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItemText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
SelectItemIndicator: () => <span>selected</span>,
}))
),
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItemText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
SelectItemIndicator: () => <span>selected</span>,
}
})
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({

View File

@ -7,6 +7,7 @@ import {
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
@ -54,7 +55,7 @@ const HumanInputFieldRenderer = ({ field, value, onChange }: Props) => {
}}
>
<SelectTrigger size="large" className="w-full" aria-label={field.output_variable_name}>
{typeof value === 'string' ? value : ''}
<SelectValue />
</SelectTrigger>
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
{options.map((option) => (

View File

@ -7,6 +7,7 @@ import {
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import * as React from 'react'
import { FileList } from '@/app/components/base/file-uploader'
@ -68,7 +69,7 @@ const SubmittedContentItem = ({ content, formInputFields, values }: SubmittedCon
aria-label={field.output_variable_name}
disabled
>
{selectedValue}
<SelectValue />
</SelectTrigger>
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
{field.option_source.value.map((option) => (

View File

@ -5,6 +5,7 @@ import {
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
@ -112,7 +113,7 @@ const InputsFormContent = ({ showTip }: Props) => {
onValueChange={(value) => value && handleFormChange(form.variable, value)}
>
<SelectTrigger className="w-full">
{String(inputsFormValue?.[form.variable] ?? form.default ?? form.label)}
<SelectValue placeholder={form.label} />
</SelectTrigger>
<SelectContent>
{form.options.map((option: string) => (

View File

@ -128,6 +128,7 @@ describe('LanguageSelect', () => {
{children}
</button>
),
SelectValue: ({ placeholder }: { placeholder?: React.ReactNode }) => <>{placeholder}</>,
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItemText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,

View File

@ -8,6 +8,7 @@ import {
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import * as React from 'react'
import { languages } from '@/i18n-config/language'
@ -39,7 +40,7 @@ const LanguageSelect: FC<ILanguageSelectProps> = ({ currentLanguage, onSelect, d
'cursor-not-allowed bg-components-button-tertiary-bg-disabled text-components-button-tertiary-text-disabled hover:bg-components-button-tertiary-bg-disabled',
)}
>
{currentLanguage || <span>&nbsp;</span>}
<SelectValue placeholder={<span>&nbsp;</span>} />
</SelectTrigger>
<SelectContent placement="bottom-start" sideOffset={4} popupClassName="w-max">
{supportedLanguages.map(({ prompt_name }) => (

View File

@ -89,10 +89,7 @@ function Popup({
)
const { theme } = useTheme()
const language = useLanguage()
const previewCardHandle = useMemo(
() => createPreviewCardHandle<ModelSelectorPreviewPayload>(),
[],
)
const [previewCardHandle] = useState(() => createPreviewCardHandle<ModelSelectorPreviewPayload>())
const [marketplaceCollapsed, setMarketplaceCollapsed] = useState(false)
const [showIncompatibleModels, setShowIncompatibleModels] = useState(false)
const { modelProviders } = useProviderContext()

View File

@ -282,6 +282,7 @@ const WorkspaceRoleCheckboxList = ({
</ul>
) : (
<RadioGroup
aria-label={t(($) => $['role.workspaceRoles.title'], { ns: 'permission' })}
value={selectedRoleIds[0] ?? ''}
onValueChange={handleRadioValueChange}
className="flex-col items-stretch gap-0.5 pb-2"

View File

@ -13,6 +13,7 @@ import {
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { RiLoader2Line, RiPlayLargeLine } from '@remixicon/react'
@ -143,16 +144,16 @@ const RunOnce: FC<IRunOnceProps> = ({
<div className="mt-1">
{item.type === 'select' && (
<Select<string>
value={selectValue}
value={selectValue ?? defaultSelectValue}
onValueChange={(nextValue) => {
if (nextValue == null || nextValue === '') return
handleInputsChange({ ...inputsRef.current, [item.key]: nextValue })
}}
>
<SelectTrigger className="w-full">
{selectValue ??
defaultSelectValue ??
t(($) => $['placeholder.select'], { ns: 'common' })}
<SelectValue
placeholder={t(($) => $['placeholder.select'], { ns: 'common' })}
/>
</SelectTrigger>
<SelectContent>
{(item.options || []).map((option) => (

View File

@ -6,7 +6,7 @@ import {
PreviewCardTrigger,
} from '@langgenius/dify-ui/preview-card'
import { groupBy } from 'es-toolkit/compat'
import { Fragment, memo, useCallback, useId, useMemo } from 'react'
import { Fragment, memo, useCallback, useId, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStoreApi } from 'reactflow'
import Badge from '@/app/components/base/badge'
@ -36,7 +36,7 @@ const Blocks = ({
const { t } = useTranslation()
const store = useStoreApi()
const blocksFromHooks = useBlocks()
const previewCardHandle = useMemo(() => createPreviewCardHandle<BlockPreviewPayload>(), [])
const [previewCardHandle] = useState(() => createPreviewCardHandle<BlockPreviewPayload>())
const previewDescriptionBaseId = useId()
// Use external blocks if provided, otherwise fallback to hook-based blocks

View File

@ -60,7 +60,7 @@ const FeaturedTools = ({
}: FeaturedToolsProps) => {
const { t } = useTranslation()
const language = useGetLanguage()
const previewCardHandle = useMemo(() => createPreviewCardHandle<FeaturedToolPreviewPayload>(), [])
const [previewCardHandle] = useState(() => createPreviewCardHandle<FeaturedToolPreviewPayload>())
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT)
const [visibleCountPlugins, setVisibleCountPlugins] = useState(plugins)
const [isCollapsed, setIsCollapsed] = useFeaturedToolsCollapsed()

View File

@ -58,13 +58,11 @@ const FeaturedTriggers = ({
}: FeaturedTriggersProps) => {
const { t } = useTranslation()
const language = useGetLanguage()
const previewCardHandle = useMemo(
() => createPreviewCardHandle<FeaturedTriggerPreviewPayload>(),
[],
const [previewCardHandle] = useState(() =>
createPreviewCardHandle<FeaturedTriggerPreviewPayload>(),
)
const triggerActionPreviewCardHandle = useMemo(
() => createPreviewCardHandle<TriggerPluginActionPreviewPayload>(),
[],
const [triggerActionPreviewCardHandle] = useState(() =>
createPreviewCardHandle<TriggerPluginActionPreviewPayload>(),
)
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT)
const [visibleCountPlugins, setVisibleCountPlugins] = useState(plugins)

View File

@ -5,7 +5,7 @@ import type { Plugin } from '@/app/components/plugins/types'
import type { OnSelectBlock } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { createPreviewCardHandle, PreviewCard } from '@langgenius/dify-ui/preview-card'
import { useCallback, useMemo, useRef } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useGetLanguage } from '@/context/i18n'
import { createToolListData } from '../tool-list-data'
import { ToolActionPreviewCard } from '../tool/action-item'
@ -24,7 +24,7 @@ type ListProps = {
const List = ({ onSelect, tools, viewType, unInstalledPlugins, className }: ListProps) => {
const language = useGetLanguage()
const previewCardHandle = useMemo(() => createPreviewCardHandle<ToolActionPreviewPayload>(), [])
const [previewCardHandle] = useState(() => createPreviewCardHandle<ToolActionPreviewPayload>())
const isFlatView = viewType === ViewType.flat
const { letters, flatTools, treeGroups } = useMemo(

View File

@ -68,7 +68,7 @@ const Snippets = ({ searchText, onSearchTextChange, insertPayload, onInserted }:
const deferredSearchText = useDeferredValue(searchText)
const viewportRef = useRef<HTMLDivElement>(null)
const [tagIds, setTagIds] = useState<string[]>([])
const previewCardHandle = useMemo(() => createPreviewCardHandle<SnippetListItemData>(), [])
const [previewCardHandle] = useState(() => createPreviewCardHandle<SnippetListItemData>())
const keyword = deferredSearchText.trim() || undefined

View File

@ -7,7 +7,7 @@ import {
PreviewCardTrigger,
} from '@langgenius/dify-ui/preview-card'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { Fragment, memo, useCallback, useEffect, useId, useMemo } from 'react'
import { Fragment, memo, useCallback, useEffect, useId, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import BlockIcon from '../block-icon'
@ -46,7 +46,7 @@ const StartBlocks = ({
}: StartBlocksProps) => {
const { t } = useTranslation()
const nodes = useNodes()
const previewCardHandle = useMemo(() => createPreviewCardHandle<StartBlockPreviewPayload>(), [])
const [previewCardHandle] = useState(() => createPreviewCardHandle<StartBlockPreviewPayload>())
const previewDescriptionBaseId = useId()
const filteredBlocks = useMemo(() => {

View File

@ -3,7 +3,7 @@ import type { ToolActionPreviewPayload } from './tool/action-item'
import type { ToolDefaultValue, ToolType, ToolValue } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { createPreviewCardHandle, PreviewCard } from '@langgenius/dify-ui/preview-card'
import { memo, useMemo, useRef } from 'react'
import { memo, useMemo, useRef, useState } from 'react'
import Empty from '@/app/components/tools/provider/empty'
import { useGetLanguage } from '@/context/i18n'
import { IndexBar } from './index-bar'
@ -40,7 +40,7 @@ const Tools = ({
selectedTools,
}: ToolsProps) => {
const language = useGetLanguage()
const previewCardHandle = useMemo(() => createPreviewCardHandle<ToolActionPreviewPayload>(), [])
const [previewCardHandle] = useState(() => createPreviewCardHandle<ToolActionPreviewPayload>())
const isFlatView = viewType === ViewType.flat
const isShowLetterIndex = isFlatView && tools.length > 10

View File

@ -3,7 +3,7 @@ import type { BlockEnum } from '../../types'
import type { TriggerDefaultValue, TriggerWithProvider } from '../types'
import type { TriggerPluginActionPreviewPayload } from './action-item'
import { createPreviewCardHandle, PreviewCard } from '@langgenius/dify-ui/preview-card'
import { memo, useEffect, useMemo } from 'react'
import { memo, useEffect, useMemo, useState } from 'react'
import { useGetLanguage } from '@/context/i18n'
import { useAllTriggerPlugins } from '@/service/use-triggers'
import { TriggerPluginActionPreviewCard } from './action-item'
@ -25,9 +25,8 @@ const TriggerPluginList = ({
}: TriggerPluginListProps) => {
const { data: triggerPluginsData } = useAllTriggerPlugins()
const language = useGetLanguage()
const previewCardHandle = useMemo(
() => createPreviewCardHandle<TriggerPluginActionPreviewPayload>(),
[],
const [previewCardHandle] = useState(() =>
createPreviewCardHandle<TriggerPluginActionPreviewPayload>(),
)
const normalizedSearch = searchText.trim().toLowerCase()

View File

@ -10,6 +10,7 @@ import {
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { RiDeleteBinLine } from '@remixicon/react'
@ -198,9 +199,7 @@ const FormItem: FC<Props> = ({
}}
>
<SelectTrigger className="w-full">
{String(
value || payload.default || t(($) => $['placeholder.select'], { ns: 'common' }),
)}
<SelectValue placeholder={t(($) => $['placeholder.select'], { ns: 'common' })} />
</SelectTrigger>
<SelectContent>
{(payload.options || []).map((option) => (

View File

@ -76,10 +76,10 @@ const OutputVarList: FC<Props> = ({ readonly, outputs, outputKeyOrders, onChange
const handleVarTypeChange = useCallback(
(index: number) => {
return (value: string) => {
return (value: VarType) => {
const key = list[index]!.variable
const newOutputs = produce(outputs, (draft) => {
draft[key]!.type = value as VarType
draft[key]!.type = value
})
onChange(newOutputs)
}

View File

@ -8,6 +8,7 @@ import {
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import * as React from 'react'
import { VarType } from '@/app/components/workflow/types'
@ -15,8 +16,8 @@ import { VarType } from '@/app/components/workflow/types'
type Props = Readonly<{
className?: string
readonly: boolean
value: string
onChange: (value: string) => void
value: VarType
onChange: (value: VarType) => void
}>
const TYPES = [
@ -32,7 +33,7 @@ const TYPES = [
const VarReferencePicker: FC<Props> = ({ readonly, className, value, onChange }) => {
return (
<div className={cn(className, !readonly && 'cursor-pointer select-none')}>
<Select
<Select<VarType>
value={value}
readOnly={readonly}
onValueChange={(type) => {
@ -43,7 +44,7 @@ const VarReferencePicker: FC<Props> = ({ readonly, className, value, onChange })
className="h-8 w-30 cursor-pointer rounded-lg px-2.5 text-[13px] text-text-primary"
title={value}
>
<span className="capitalize">{value}</span>
<SelectValue className="capitalize" />
</SelectTrigger>
<SelectContent
sideOffset={4}
@ -51,7 +52,7 @@ const VarReferencePicker: FC<Props> = ({ readonly, className, value, onChange })
listClassName="p-0"
>
{TYPES.map((type) => (
<SelectItem
<SelectItem<VarType>
key={type}
value={type}
className="h-7.5 rounded-lg pr-2 pl-3 text-[13px] text-text-primary"

View File

@ -9,6 +9,7 @@ import {
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
@ -156,7 +157,7 @@ const SelectPreview: React.FC<{ label: string; options: string[] }> = ({ label,
className="w-full rounded-[10px]"
aria-label="human-input-note-select"
>
{value}
<SelectValue />
</SelectTrigger>
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
{options.map((option) => (

View File

@ -8,6 +8,7 @@ import {
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import { RiCheckLine } from '@remixicon/react'
import { useState } from 'react'
@ -43,7 +44,7 @@ const TypeSelector: FC<TypeSelectorProps> = ({ items, currentValue, onSelect, po
open && 'bg-state-base-hover',
)}
>
<span className="system-xs-medium text-text-tertiary">{currentValue}</span>
<SelectValue className="system-xs-medium text-text-tertiary" />
</SelectTrigger>
<SelectContent
sideOffset={4}
@ -54,7 +55,7 @@ const TypeSelector: FC<TypeSelectorProps> = ({ items, currentValue, onSelect, po
{items.map((item) => {
const isSelected = item.value === currentValue
return (
<SelectItem
<SelectItem<Type | ArrayType>
key={item.value}
value={item.value}
className="gap-x-1 rounded-lg px-2 py-1"

View File

@ -11,6 +11,7 @@ import {
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import { Switch } from '@langgenius/dify-ui/switch'
import { Textarea } from '@langgenius/dify-ui/textarea'
@ -189,14 +190,16 @@ const AddExtractParameter: FC<Props> = ({ type, payload, onSave, onCancel }) =>
ns: 'workflow',
})}
>
<Select
<Select<ParamType>
value={param.type}
onValueChange={(value) => value && handleParamChange('type')(value)}
>
<SelectTrigger className="w-full capitalize">{param.type}</SelectTrigger>
<SelectTrigger className="w-full capitalize">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TYPES.map((type) => (
<SelectItem key={type} value={type} className="capitalize">
<SelectItem<ParamType> key={type} value={type} className="capitalize">
<SelectItemText className="capitalize">{type}</SelectItemText>
<SelectItemIndicator />
</SelectItem>

View File

@ -0,0 +1,77 @@
import type {
AgentLogSourceGroupResponse,
AgentLogSourceResponse,
} from '@dify/contracts/api/console/agent/types.gen'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AgentLogSourcePicker } from '../components/source-picker'
const sources = {
webapp: {
app_id: 'webapp-app-id',
app_name: 'Book Translation',
id: 'webapp:webapp-app-id',
type: 'webapp',
},
workflow: {
app_id: 'workflow-app-id',
app_name: 'SVG Logo Design',
id: 'workflow:workflow-app-id:workflow-id:v3:agent-node-id',
node_id: 'agent-node-id',
type: 'workflow',
workflow_id: 'workflow-id',
workflow_version: 'v3',
},
} satisfies Record<string, AgentLogSourceResponse>
const groups: AgentLogSourceGroupResponse[] = [
{
label: 'Webapp',
type: 'webapp',
sources: [sources.webapp],
},
{
label: 'Workflow',
type: 'workflow',
sources: [sources.workflow],
},
]
describe('AgentLogSourcePicker', () => {
it('should filter sources across groups and only show empty when no source matches', async () => {
const user = userEvent.setup()
render(
<AgentLogSourcePicker
value={[]}
groups={groups}
isLoading={false}
isError={false}
onRetry={vi.fn()}
onChange={vi.fn()}
/>,
)
await user.click(
screen.getByRole('combobox', {
name: 'agentV2.agentDetail.logs.filters.source.label',
}),
)
const searchInput = screen.getByRole('combobox', {
name: 'agentV2.agentDetail.logs.filters.source.searchLabel',
})
await user.type(searchInput, 'Book')
expect(screen.getByRole('option', { name: /Book Translation/ })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: /SVG Logo Design/ })).not.toBeInTheDocument()
expect(
screen.queryByText('agentV2.agentDetail.logs.filters.source.empty'),
).not.toBeInTheDocument()
await user.clear(searchInput)
await user.type(searchInput, 'Missing source')
expect(screen.queryByRole('option')).not.toBeInTheDocument()
expect(screen.getByText('agentV2.agentDetail.logs.filters.source.empty')).toBeInTheDocument()
})
})

View File

@ -21,7 +21,7 @@ import {
ComboboxTrigger,
ComboboxValue,
} from '@langgenius/dify-ui/combobox'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LogSourceIcon } from './source-icon'
@ -35,6 +35,10 @@ const getSourceGroupLabel = (group: AgentLogSourceGroupResponse, t: TFunction<'a
const getSourceLabel = (source: AgentLogSourceResponse) => source.app_name
type AgentLogSourceComboboxGroup = Omit<AgentLogSourceGroupResponse, 'sources'> & {
items: AgentLogSourceResponse[]
}
export function AgentLogSourcePicker({
value,
groups,
@ -53,13 +57,17 @@ export function AgentLogSourcePicker({
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const [inputValue, setInputValue] = useState('')
const sources = groups.flatMap((group) => group.sources ?? [])
const sourceGroups = useMemo<AgentLogSourceComboboxGroup[]>(
() => groups.map(({ sources, ...group }) => ({ ...group, items: sources ?? [] })),
[groups],
)
const sources = sourceGroups.flatMap((group) => group.items)
const selectedSources = sources.filter((source) => value.includes(source.id))
return (
<Combobox<AgentLogSourceResponse, true>
multiple
items={groups}
items={sourceGroups}
value={selectedSources}
itemToStringLabel={getSourceLabel}
onValueChange={(nextSources) => {
@ -112,9 +120,9 @@ export function AgentLogSourcePicker({
)}
{!isLoading && !isError && (
<>
<ComboboxList className="max-h-69 p-2 pt-1">
{groups.map((group) => (
<ComboboxGroup key={group.type} items={group.sources ?? []}>
<ComboboxList<AgentLogSourceComboboxGroup> className="max-h-69 p-2 pt-1">
{(group) => (
<ComboboxGroup key={group.type} items={group.items}>
<ComboboxGroupLabel className="px-1 pt-2 pb-1">
{getSourceGroupLabel(group, t)}
</ComboboxGroupLabel>
@ -134,7 +142,7 @@ export function AgentLogSourcePicker({
)}
</ComboboxCollection>
</ComboboxGroup>
))}
)}
</ComboboxList>
<ComboboxEmpty className="px-3 py-3 text-center system-xs-regular">
{t(($) => $['agentDetail.logs.filters.source.empty'])}

View File

@ -77,6 +77,7 @@ function SourceMethodSection() {
hideHeader
>
<RadioGroup<GuideMethod>
aria-label={t(($) => $['createGuide.steps.method'])}
value={method}
onValueChange={selectMethod}
className="flex flex-col items-stretch gap-2 sm:flex-row"

View File

@ -90,6 +90,7 @@ function TargetEnvironmentSection() {
</div>
{hasEnvironmentOptions ? (
<RadioGroup<string>
aria-label={t(($) => $['createGuide.target.environment'])}
value={effectiveSelectedEnvironmentId}
onValueChange={selectEnvironment}
className="grid grid-cols-1 items-stretch gap-3 lg:grid-cols-2"

View File

@ -155,4 +155,17 @@ describe('SourceAppPicker', () => {
screen.queryByRole('button', { name: /createModal\.loadMoreApps/ }),
).not.toBeInTheDocument()
})
it('should restore the selected app by business identity', async () => {
const user = userEvent.setup()
renderSourceAppPicker(false)
await user.click(screen.getByRole('combobox', { name: 'deployments.versions.sourceAppOption' }))
expect(screen.getByRole('option', { name: /Workflow App/ })).toHaveAttribute(
'aria-selected',
'true',
)
})
})

View File

@ -41,6 +41,10 @@ function sourceAppSearchText(app: App) {
return `${app.name} ${app.id}`.toLowerCase()
}
function isSameApp(app: App, selectedApp: App) {
return app.id === selectedApp.id
}
function SourceAppTrigger({ app }: { app?: SourceAppPickerValue }) {
const { t } = useTranslation('deployments')
@ -144,6 +148,7 @@ export function SourceAppPicker({
const sourceAppsIsFetching = useAtomValue(createReleaseSourceAppsIsFetchingAtom)
const sourceAppsIsFetchingNextPage = useAtomValue(createReleaseSourceAppsIsFetchingNextPageAtom)
const sourceAppsIsLoading = useAtomValue(createReleaseSourceAppsIsLoadingAtom)
const selectedApp = apps.find((app) => app.id === value?.id) ?? null
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>(
{
error: sourceAppsError,
@ -163,8 +168,10 @@ export function SourceAppPicker({
return (
<Combobox<App>
items={apps}
value={selectedApp}
open={!disabled && isShow}
inputValue={searchText}
isItemEqualToValue={isSameApp}
onOpenChange={(open) => {
setIsShow(disabled ? false : open)
}}