Skip to content

Commit 65d9761

Browse files
authored
refactor(web): use Select for the Studio app type filter (#44070)
1 parent 7c6b8fa commit 65d9761

3 files changed

Lines changed: 61 additions & 69 deletions

File tree

‎web/app/components/apps/__tests__/list.spec.tsx‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -526,7 +526,7 @@ type AppStarredListQueryOptions = {
526526
}
527527

528528
const openAppTypeSelect = async (user = userEvent.setup()) => {
529-
await user.click(screen.getByRole('button', { name: /^(Types|app\.types\.)/ }))
529+
await user.click(screen.getByRole('combobox', { name: 'Types' }))
530530
return user
531531
}
532532

@@ -869,7 +869,7 @@ describe('List', () => {
869869
expect(screen.getByText('app.firstEmpty.title'))!.toBeInTheDocument()
870870
expect(screen.getByText('app.firstEmpty.learnDifyTitle'))!.toBeInTheDocument()
871871
expect(screen.getByText('app.firstEmpty.or'))!.toBeInTheDocument()
872-
expect(screen.getByRole('button', { name: 'Types' }))!.toBeInTheDocument()
872+
expect(screen.getByRole('combobox', { name: 'Types' }))!.toBeInTheDocument()
873873
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
874874
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
875875
expect(
@@ -911,7 +911,7 @@ describe('List', () => {
911911
renderList()
912912

913913
expect(screen.queryByText('app.firstEmpty.title')).not.toBeInTheDocument()
914-
expect(screen.getByRole('button', { name: 'Types' }))!.toBeInTheDocument()
914+
expect(screen.getByRole('combobox', { name: 'Types' }))!.toBeInTheDocument()
915915
})
916916

917917
it('should not render first empty state from placeholder data during a filter transition', () => {
@@ -936,7 +936,7 @@ describe('List', () => {
936936
'aria-busy',
937937
'false',
938938
)
939-
expect(screen.getByRole('button', { name: 'Types' }))!.toBeInTheDocument()
939+
expect(screen.getByRole('combobox', { name: 'Types' }))!.toBeInTheDocument()
940940
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
941941
expect(screen.queryByText('app.firstEmpty.title')).not.toBeInTheDocument()
942942
})
@@ -1033,13 +1033,15 @@ describe('List', () => {
10331033
it('should render selected category in the trigger', () => {
10341034
renderList('?category=workflow')
10351035

1036-
expect(screen.getByRole('button', { name: 'app.types.workflow' }))!.toBeInTheDocument()
1036+
expect(screen.getByRole('combobox', { name: 'Types' })).toHaveTextContent(
1037+
'app.types.workflow',
1038+
)
10371039
})
10381040

10391041
it('should reject API modes that the Studio category control does not support', () => {
10401042
renderList('?category=channel')
10411043

1042-
expect(screen.getByRole('button', { name: 'Types' })).toBeInTheDocument()
1044+
expect(screen.getByRole('combobox', { name: 'Types' })).toBeInTheDocument()
10431045
const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions
10441046
expect(options.input(1).query).not.toHaveProperty('mode')
10451047
})
@@ -1049,7 +1051,7 @@ describe('List', () => {
10491051
const { onUrlUpdate } = renderList()
10501052
await openAppTypeSelect(user)
10511053

1052-
await user.click(await screen.findByRole('menuitemradio', { name: 'app.types.workflow' }))
1054+
await user.click(await screen.findByRole('option', { name: 'app.types.workflow' }))
10531055

10541056
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
10551057
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('category')).toBe(
@@ -1062,10 +1064,15 @@ describe('List', () => {
10621064
const { onUrlUpdate } = renderList('?category=workflow')
10631065
await openAppTypeSelect(user)
10641066

1065-
await user.click(await screen.findByRole('menuitemradio', { name: 'All' }))
1067+
expect(await screen.findByRole('option', { name: 'app.types.workflow' })).toHaveAttribute(
1068+
'aria-selected',
1069+
'true',
1070+
)
1071+
await user.click(screen.getByRole('option', { name: 'All' }))
10661072

10671073
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
10681074
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.has('category')).toBe(false)
1075+
expect(screen.getByRole('combobox', { name: 'Types' })).toHaveTextContent('Types')
10691076
})
10701077
})
10711078

@@ -1237,7 +1244,7 @@ describe('List', () => {
12371244
const user = userEvent.setup()
12381245
const { onUrlUpdate, unmount } = renderList()
12391246
await openAppTypeSelect(user)
1240-
await user.click(await screen.findByRole('menuitemradio', { name: text }))
1247+
await user.click(await screen.findByRole('option', { name: text }))
12411248
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
12421249
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('category')).toBe(mode)
12431250
unmount()

‎web/app/components/apps/app-list-header.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export function AppListHeader({
8787
>
8888
<div className="flex flex-wrap items-start justify-between gap-2">
8989
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
90-
<AppTypeFilter value={category} onChange={onCategoryChange} />
90+
<AppTypeFilter value={category} onValueChange={onCategoryChange} />
9191
<TagFilter
9292
type="app"
9393
value={tagIDs}

‎web/app/components/apps/app-type-filter.tsx‎

Lines changed: 44 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -3,105 +3,90 @@
33
import type { AppListUrlQuery } from './query-params'
44
import { cn } from '@langgenius/dify-ui/cn'
55
import {
6-
DropdownMenu,
7-
DropdownMenuContent,
8-
DropdownMenuRadioGroup,
9-
DropdownMenuRadioItem,
10-
DropdownMenuRadioItemIndicator,
11-
DropdownMenuTrigger,
12-
} from '@langgenius/dify-ui/dropdown-menu'
6+
Select,
7+
SelectContent,
8+
SelectItem,
9+
SelectItemIndicator,
10+
SelectItemText,
11+
SelectTrigger,
12+
SelectValue,
13+
} from '@langgenius/dify-ui/select'
1314
import { useMemo } from 'react'
1415
import { useTranslation } from 'react-i18next'
1516
import { studioAppListCategories } from './query-params'
1617

1718
type AppListCategory = AppListUrlQuery['category']
1819

19-
const chipClassName =
20-
'flex h-8 items-center whitespace-nowrap rounded-lg border-[0.5px] px-2 text-[13px] leading-4 transition-colors'
21-
2220
type AppTypeFilterProps = {
2321
value: AppListCategory
24-
onChange: (value: AppListCategory) => void
22+
onValueChange: (value: AppListCategory) => void
2523
}
2624

27-
export function AppTypeFilter({ value, onChange }: AppTypeFilterProps) {
25+
export function AppTypeFilter({ value, onValueChange }: AppTypeFilterProps) {
2826
const { t } = useTranslation(['app'])
2927

3028
const options = useMemo(() => {
3129
const optionsByCategory = {
3230
all: {
33-
text: t(($) => $['types.all'], { ns: 'app' }),
31+
label: t(($) => $['types.all'], { ns: 'app' }),
3432
iconClassName: 'i-ri-apps-2-line',
3533
},
3634
workflow: {
37-
text: t(($) => $['types.workflow'], { ns: 'app' }),
35+
label: t(($) => $['types.workflow'], { ns: 'app' }),
3836
iconClassName: 'i-ri-exchange-2-line',
3937
},
4038
'advanced-chat': {
41-
text: t(($) => $['types.advanced'], { ns: 'app' }),
39+
label: t(($) => $['types.advanced'], { ns: 'app' }),
4240
iconClassName: 'i-ri-message-3-line',
4341
},
4442
chat: {
45-
text: t(($) => $['types.chatbot'], { ns: 'app' }),
43+
label: t(($) => $['types.chatbot'], { ns: 'app' }),
4644
iconClassName: 'i-ri-message-3-line',
4745
},
4846
'agent-chat': {
49-
text: t(($) => $['types.agent'], { ns: 'app' }),
47+
label: t(($) => $['types.agent'], { ns: 'app' }),
5048
iconClassName: 'i-ri-robot-3-line',
5149
},
5250
completion: {
53-
text: t(($) => $['newApp.completeApp'], { ns: 'app' }),
51+
label: t(($) => $['newApp.completeApp'], { ns: 'app' }),
5452
iconClassName: 'i-ri-file-4-line',
5553
},
56-
} satisfies Record<AppListCategory, { text: string; iconClassName: string }>
54+
} satisfies Record<AppListCategory, { label: string; iconClassName: string }>
5755

5856
return studioAppListCategories.map((value) => ({
5957
value,
6058
...optionsByCategory[value],
6159
}))
6260
}, [t])
6361

64-
const activeOption = options.find((option) => option.value === value)
65-
const isSelected = value !== 'all'
66-
const triggerLabel = isSelected
67-
? activeOption?.text
68-
: t(($) => $['studio.filters.types'], { ns: 'app' })
62+
const isFiltered = value !== 'all'
63+
const typesLabel = t(($) => $['studio.filters.types'], { ns: 'app' })
6964

7065
return (
71-
<DropdownMenu>
72-
<DropdownMenuTrigger
73-
render={
74-
<button
75-
type="button"
76-
className={cn(
77-
chipClassName,
78-
isSelected
79-
? 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-state-base-hover'
80-
: 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover',
81-
)}
82-
/>
83-
}
84-
>
85-
<span className="px-1 text-text-tertiary">{triggerLabel}</span>
86-
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
87-
</DropdownMenuTrigger>
88-
<DropdownMenuContent placement="bottom-start" className="w-55">
89-
<DropdownMenuRadioGroup<AppListCategory>
90-
value={value}
91-
onValueChange={(nextValue) => onChange(nextValue)}
92-
>
93-
{options.map((option) => (
94-
<DropdownMenuRadioItem<AppListCategory> key={option.value} value={option.value}>
95-
<span
96-
aria-hidden
97-
className={cn('h-4 w-4 shrink-0 text-text-tertiary', option.iconClassName)}
98-
/>
99-
<span>{option.text}</span>
100-
<DropdownMenuRadioItemIndicator />
101-
</DropdownMenuRadioItem>
102-
))}
103-
</DropdownMenuRadioGroup>
104-
</DropdownMenuContent>
105-
</DropdownMenu>
66+
<Select<AppListCategory>
67+
items={options}
68+
value={value}
69+
onValueChange={(nextValue) => {
70+
if (nextValue !== null) onValueChange(nextValue)
71+
}}
72+
>
73+
<SelectTrigger aria-label={typesLabel} className="w-auto">
74+
<SelectValue className="text-text-tertiary">
75+
{isFiltered ? undefined : typesLabel}
76+
</SelectValue>
77+
</SelectTrigger>
78+
<SelectContent className="w-55">
79+
{options.map((option) => (
80+
<SelectItem<AppListCategory> key={option.value} value={option.value} className="gap-1">
81+
<span
82+
aria-hidden
83+
className={cn('h-4 w-4 shrink-0 text-text-tertiary', option.iconClassName)}
84+
/>
85+
<SelectItemText>{option.label}</SelectItemText>
86+
<SelectItemIndicator />
87+
</SelectItem>
88+
))}
89+
</SelectContent>
90+
</Select>
10691
)
10792
}

0 commit comments

Comments
 (0)