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

This commit is contained in:
yyh
2026-07-30 13:37:04 +00:00
committed by GitHub
parent 69a16d8319
commit 5e6154fdd1
34 changed files with 239 additions and 99 deletions
@@ -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()
})
})
@@ -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'])}
@@ -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"
@@ -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"
@@ -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',
)
})
})
@@ -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)
}}