refactor: migrate base/select to dify-ui/select (#35487)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Coding On Star 2026-04-22 17:35:57 +08:00 committed by fatelei
parent 32d75fe08c
commit c7d96badf4
No known key found for this signature in database
GPG Key ID: 2F91DA05646F4EED
71 changed files with 2748 additions and 1078 deletions

View File

@ -111,16 +111,6 @@
"count": 1
}
},
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/svg-attribute-error-reproduction.spec.tsx": {
"no-console": {
"count": 19
@ -534,11 +524,6 @@
"count": 1
}
},
"web/app/components/app/configuration/debug/chat-user-input.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx": {
"ts/no-explicit-any": {
"count": 6
@ -584,7 +569,7 @@
},
"web/app/components/app/configuration/prompt-value-panel/index.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"web/app/components/app/configuration/prompt-value-panel/utils.ts": {
@ -681,7 +666,7 @@
},
"web/app/components/app/overview/settings/index.tsx": {
"no-restricted-imports": {
"count": 3
"count": 2
},
"react/set-state-in-effect": {
"count": 3
@ -920,9 +905,6 @@
}
},
"web/app/components/base/chat/chat-with-history/inputs-form/content.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
@ -1036,9 +1018,6 @@
}
},
"web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
@ -1175,11 +1154,6 @@
"count": 5
}
},
"web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/base/features/new-feature-panel/moderation/index.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -1195,7 +1169,7 @@
},
"web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"web/app/components/base/features/types.ts": {
@ -2438,11 +2412,6 @@
"count": 4
}
},
"web/app/components/datasets/documents/components/documents-header.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/components/operations.tsx": {
"no-restricted-imports": {
"count": 1
@ -2576,11 +2545,6 @@
"count": 3
}
},
"web/app/components/datasets/documents/detail/completed/components/menu-bar.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx": {
"ts/no-non-null-asserted-optional-chain": {
"count": 1
@ -2596,11 +2560,6 @@
"count": 5
}
},
"web/app/components/datasets/documents/detail/completed/hooks/use-search-filter.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/completed/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 2
@ -2617,11 +2576,6 @@
"count": 1
}
},
"web/app/components/datasets/documents/detail/completed/status-item.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/context.ts": {
"ts/no-explicit-any": {
"count": 1
@ -2642,11 +2596,6 @@
"count": 1
}
},
"web/app/components/datasets/documents/detail/metadata/components/field-info.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.tsx": {
"ts/no-non-null-asserted-optional-chain": {
"count": 1
@ -3034,11 +2983,6 @@
"count": 1
}
},
"web/app/components/header/account-setting/language-page/index.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/components/header/account-setting/members-page/invite-modal/index.tsx": {
"react/set-state-in-effect": {
"count": 3
@ -3121,7 +3065,7 @@
},
"web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"ts/no-explicit-any": {
"count": 6
@ -3273,16 +3217,13 @@
},
"web/app/components/plugins/install-plugin/install-from-github/index.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
},
"web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.tsx": {
"no-restricted-imports": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
@ -3386,9 +3327,6 @@
}
},
"web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-form.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 8
}
@ -3492,7 +3430,7 @@
"count": 3
},
"no-restricted-imports": {
"count": 3
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx": {
@ -3561,11 +3499,6 @@
"count": 7
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/schema-modal.tsx": {
"no-restricted-imports": {
"count": 1
@ -3867,9 +3800,6 @@
}
},
"web/app/components/share/text-generation/run-once/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 1
},
@ -4289,9 +4219,6 @@
}
},
"web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 11
}
@ -4371,14 +4298,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/form-input-item.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 4
}
},
"web/app/components/workflow/nodes/_base/components/form-input-type-switch.tsx": {
"no-restricted-imports": {
"count": 1
@ -4476,11 +4395,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/variable/constant-field.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/variable/match-schema-type.ts": {
"ts/no-explicit-any": {
"count": 8
@ -4890,11 +4804,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/if-else/components/condition-list/condition-operator.tsx": {
"no-restricted-imports": {
"count": 1
@ -4905,11 +4814,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/if-else/components/condition-wrap.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/if-else/default.ts": {
"ts/no-explicit-any": {
"count": 1
@ -4940,16 +4844,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/iteration/panel.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/iteration/use-config.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/iteration/use-single-run-form-params.ts": {
"ts/no-explicit-any": {
"count": 6
@ -5052,17 +4946,6 @@
}
},
"web/app/components/workflow/nodes/list-operator/components/filter-condition.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/nodes/list-operator/components/sub-variable-picker.tsx": {
"no-restricted-imports": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
@ -5202,11 +5085,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/condition-list/condition-item.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx": {
"no-restricted-imports": {
"count": 1
@ -5217,31 +5095,16 @@
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/condition-wrap.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/loop-variables/form-item.tsx": {
"ts/no-explicit-any": {
"count": 3
}
},
"web/app/components/workflow/nodes/loop/components/loop-variables/input-mode-selec.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/loop-variables/item.tsx": {
"ts/no-explicit-any": {
"count": 4
}
},
"web/app/components/workflow/nodes/loop/components/loop-variables/variable-type-select.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/loop/default.ts": {
"ts/no-explicit-any": {
"count": 1
@ -5277,7 +5140,7 @@
},
"web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"ts/no-explicit-any": {
"count": 1
@ -5494,11 +5357,6 @@
"count": 7
}
},
"web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx": {
"no-restricted-imports": {
"count": 1
@ -5512,11 +5370,6 @@
"count": 10
}
},
"web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx": {
"ts/no-non-null-asserted-optional-chain": {
"count": 1
@ -5529,7 +5382,7 @@
},
"web/app/components/workflow/nodes/trigger-webhook/panel.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"web/app/components/workflow/nodes/utils.ts": {
@ -6028,11 +5881,6 @@
"count": 1
}
},
"web/app/signin/invite-settings/page.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/signin/layout.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -6040,7 +5888,7 @@
},
"web/app/signin/one-more-step.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"ts/no-explicit-any": {
"count": 1

View File

@ -1,14 +1,17 @@
'use client'
import type { FC } from 'react'
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
import type { Item } from '@/app/components/base/select'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import dayjs from 'dayjs'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { SimpleSelect } from '@/app/components/base/select'
type TimePeriodName = I18nKeysByPrefix<'appLog', 'filter.period.'>
type TimePeriodOption = {
value: string
name: string
}
type Props = {
periodMapping: { [key: string]: { value: number, name: TimePeriodName } }
@ -24,8 +27,18 @@ const LongTimeRangePicker: FC<Props> = ({
queryDateFormat,
}) => {
const { t } = useTranslation()
const items = React.useMemo<TimePeriodOption[]>(() => {
return Object.entries(periodMapping).map(([key, period]) => ({
value: key,
name: t(`filter.period.${period.name}`, { ns: 'appLog' }),
}))
}, [periodMapping, t])
const [value, setValue] = React.useState('2')
const selectedItem = React.useMemo(() => {
return items.find(item => item.value === value) ?? null
}, [items, value])
const handleSelect = React.useCallback((item: Item) => {
const handleSelect = React.useCallback((item: TimePeriodOption) => {
const id = item.value
const value = periodMapping[id]?.value ?? '-1'
const name = item.name || t('filter.period.allTime', { ns: 'appLog' })
@ -55,13 +68,30 @@ const LongTimeRangePicker: FC<Props> = ({
}, [onSelect, periodMapping, queryDateFormat, t])
return (
<SimpleSelect
items={Object.entries(periodMapping).map(([k, v]) => ({ value: k, name: t(`filter.period.${v.name}`, { ns: 'appLog' }) }))}
className="mt-0 w-40!"
notClearable={true}
onSelect={handleSelect}
defaultValue="2"
/>
<Select
value={selectedItem?.value ?? null}
onValueChange={(nextValue) => {
if (!nextValue)
return
const nextItem = items.find(item => item.value === nextValue)
if (!nextItem)
return
setValue(nextValue)
handleSelect(nextItem)
}}
>
<SelectTrigger className="mt-0 w-fit max-w-none">
{selectedItem?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>
<SelectContent>
{items.map(item => (
<SelectItem key={item.value} value={item.value}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
export default React.memo(LongTimeRangePicker)

View File

@ -1,19 +1,22 @@
'use client'
import type { FC } from 'react'
import type { PeriodParamsWithTimeRange, TimeRange } from '@/app/components/app/overview/app-chart'
import type { Item } from '@/app/components/base/select'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { RiArrowDownSLine } from '@remixicon/react'
import dayjs from 'dayjs'
import * as React from 'react'
import { useCallback } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SimpleSelect } from '@/app/components/base/select'
const today = dayjs()
type TimePeriodName = I18nKeysByPrefix<'appLog', 'filter.period.'>
type TimePeriodOption = {
value: number
name: string
}
type Props = {
isCustomRange: boolean
@ -27,8 +30,19 @@ const RangeSelector: FC<Props> = ({
onSelect,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const items = useMemo<TimePeriodOption[]>(() => {
return ranges.map(range => ({
...range,
name: t(`filter.period.${range.name}`, { ns: 'appLog' }),
}))
}, [ranges, t])
const [value, setValue] = useState('0')
const selectedItem = useMemo(() => {
return items.find(item => String(item.value) === value) ?? null
}, [items, value])
const handleSelectRange = useCallback((item: Item) => {
const handleSelectRange = useCallback((item: TimePeriodOption) => {
const { name, value } = item
let period: TimeRange | null = null
if (value === 0) {
@ -42,44 +56,38 @@ const RangeSelector: FC<Props> = ({
onSelect({ query: period!, name })
}, [onSelect])
const renderTrigger = useCallback((item: Item | null, isOpen: boolean) => {
return (
<div className={cn('flex h-8 cursor-pointer items-center space-x-1.5 rounded-lg bg-components-input-bg-normal pr-2 pl-3', isOpen && 'bg-state-base-hover-alt')}>
<div className="system-sm-regular text-components-input-text-filled">{isCustomRange ? t('filter.period.custom', { ns: 'appLog' }) : item?.name}</div>
<RiArrowDownSLine className={cn('size-4 text-text-quaternary', isOpen && 'text-text-secondary')} />
</div>
)
}, [isCustomRange])
const renderOption = useCallback(({ item, selected }: { item: Item, selected: boolean }) => {
return (
<>
{selected && (
<span
className={cn(
'absolute top-[9px] left-2 flex items-center text-text-accent',
)}
>
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
</span>
)}
<span className={cn('block truncate system-md-regular')}>{item.name}</span>
</>
)
}, [])
return (
<SimpleSelect
items={ranges.map(v => ({ ...v, name: t(`filter.period.${v.name}`, { ns: 'appLog' }) }))}
className="mt-0 w-40!"
notClearable={true}
onSelect={handleSelectRange}
defaultValue={0}
wrapperClassName="h-8"
optionWrapClassName="w-[200px] translate-x-[-24px]"
renderTrigger={renderTrigger}
optionClassName="flex items-center py-0 pl-7 pr-2 h-8"
renderOption={renderOption}
/>
<Select
value={selectedItem ? String(selectedItem.value) : null}
open={open}
onOpenChange={setOpen}
onValueChange={(nextValue) => {
if (!nextValue)
return
const nextItem = items.find(item => String(item.value) === nextValue)
if (!nextItem)
return
setValue(nextValue)
handleSelectRange(nextItem)
}}
>
<SelectTrigger
className="h-auto w-fit max-w-none border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden"
>
<div className={cn('flex h-8 cursor-pointer items-center space-x-1.5 rounded-lg bg-components-input-bg-normal pr-2 pl-3', open && 'bg-state-base-hover-alt')}>
<div className="system-sm-regular text-components-input-text-filled">{isCustomRange ? t('filter.period.custom', { ns: 'appLog' }) : selectedItem?.name}</div>
<RiArrowDownSLine className={cn('size-4 text-text-quaternary', open && 'text-text-secondary')} />
</div>
</SelectTrigger>
<SelectContent className="translate-x-[-24px]" popupClassName="w-[200px]" listClassName="p-1">
{items.map(item => (
<SelectItem key={item.value} value={String(item.value)} className="h-8 py-0 pr-2 pl-7 system-md-regular">
<SelectItemText className="px-0">{item.name}</SelectItemText>
<SelectItemIndicator className="absolute top-[8px] left-2 ml-0" />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
export default React.memo(RangeSelector)

View File

@ -5,16 +5,31 @@ import { InputVarType } from '@/app/components/workflow/types'
import ConfigModalFormFields from '../form-fields'
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({ onChange }: { onChange: (files: Array<Record<string, unknown>>) => void }) => (
<button
type="button"
onClick={() => onChange([
{ fileId: 'file-1', type: 'local_file', url: 'https://example.com/file.png' },
{ fileId: 'file-2', type: 'remote_url', url: 'https://example.com/file-2.png' },
])}
>
upload-file
</button>
FileUploaderInAttachmentWrapper: ({
onChange,
value,
fileConfig,
}: {
onChange: (files?: Array<Record<string, unknown>>) => void
value: Array<Record<string, unknown>>
fileConfig: Record<string, unknown>
}) => (
<div>
<span data-testid="file-uploader-value">{JSON.stringify(value)}</span>
<span data-testid="file-uploader-config">{JSON.stringify(fileConfig)}</span>
<button
type="button"
onClick={() => onChange([
{ fileId: 'file-1', type: 'local_file', url: 'https://example.com/file.png' },
{ fileId: 'file-2', type: 'remote_url', url: 'https://example.com/file-2.png' },
])}
>
upload-file
</button>
<button type="button" data-testid="upload-empty-file" onClick={() => onChange(undefined)}>
upload-empty-file
</button>
</div>
),
}))
@ -38,12 +53,6 @@ vi.mock('@/app/components/base/checkbox', () => ({
),
}))
vi.mock('@/app/components/base/select', () => ({
default: ({ onSelect }: { onSelect: (item: { value: string }) => void }) => (
<button type="button" onClick={() => onSelect({ value: 'beta' })}>legacy-select</button>
),
}))
vi.mock('@langgenius/dify-ui/select', async (importOriginal) => {
const actual = await importOriginal<typeof import('@langgenius/dify-ui/select')>()
@ -52,6 +61,7 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => {
Select: ({ value, onValueChange, children }: { value: string, onValueChange: (value: string) => void, children: ReactNode }) => (
<div>
<button type="button" onClick={() => onValueChange(value === 'true' ? 'false' : 'beta')}>{`ui-select:${value}`}</button>
<button type="button" onClick={() => onValueChange('__empty__')}>ui-select-empty</button>
{children}
</div>
),
@ -86,8 +96,8 @@ vi.mock('../../config-select', () => ({
}))
vi.mock('../../config-string', () => ({
default: ({ onChange }: { onChange: (value: number) => void }) => (
<button type="button" onClick={() => onChange(64)}>config-string</button>
default: ({ onChange, maxLength }: { onChange: (value: number) => void, maxLength: number }) => (
<button type="button" data-max-length={String(maxLength)} onClick={() => onChange(64)}>config-string</button>
),
}))
@ -211,4 +221,150 @@ describe('ConfigModalFormFields', () => {
fireEvent.click(screen.getByText('json-editor'))
expect(jsonProps.onJSONSchemaChange).toHaveBeenCalledWith('{\n "type": "object"\n}')
})
it('should update text input metadata and clear empty defaults for string inputs', () => {
const textProps = createBaseProps()
textProps.isStringInput = true
textProps.tempPayload = {
...textProps.tempPayload,
type: InputVarType.textInput,
default: 'hello',
}
render(<ConfigModalFormFields {...textProps} />)
const variableInput = screen.getByDisplayValue('question')
fireEvent.click(screen.getByText('type-selector'))
fireEvent.change(variableInput, { target: { value: 'prompt' } })
fireEvent.blur(variableInput)
fireEvent.change(screen.getByDisplayValue('Question'), { target: { value: 'Prompt Label' } })
fireEvent.click(screen.getByText('config-string'))
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: '' } })
expect(textProps.onTypeChange).toHaveBeenCalledWith({ value: InputVarType.select })
expect(textProps.onVarNameChange).toHaveBeenCalled()
expect(textProps.onVarKeyBlur).toHaveBeenCalled()
expect(textProps.payloadChangeHandlers.label).toHaveBeenCalledWith('Prompt Label')
expect(textProps.payloadChangeHandlers.max_length).toHaveBeenCalledWith(64)
expect(textProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
})
it('should clear select defaults and apply uploader fallback values', () => {
const selectProps = createBaseProps()
selectProps.tempPayload = { ...selectProps.tempPayload, type: InputVarType.select, default: 'alpha' }
selectProps.options = ['alpha', ' ', 'beta']
render(<ConfigModalFormFields {...selectProps} />)
fireEvent.click(screen.getByText('ui-select-empty'))
expect(selectProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
const singleFallbackProps = createBaseProps()
singleFallbackProps.tempPayload = {
...singleFallbackProps.tempPayload,
type: InputVarType.singleFile,
default: undefined,
}
render(<ConfigModalFormFields {...singleFallbackProps} />)
expect(screen.getAllByTestId('file-uploader-value')[0]).toHaveTextContent('[]')
expect(screen.getAllByTestId('file-uploader-config')[0]).toHaveTextContent('"allowed_file_types":["document"]')
expect(screen.getAllByTestId('file-uploader-config')[0]).toHaveTextContent('"allowed_file_upload_methods":["remote_url"]')
expect(screen.getAllByTestId('file-uploader-config')[0]).toHaveTextContent('"number_limits":1')
fireEvent.click(screen.getAllByTestId('upload-empty-file')[0]!)
expect(singleFallbackProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
const multiFallbackProps = createBaseProps()
multiFallbackProps.tempPayload = {
...multiFallbackProps.tempPayload,
type: InputVarType.multiFiles,
default: undefined,
max_length: undefined,
}
render(<ConfigModalFormFields {...multiFallbackProps} />)
expect(screen.getAllByTestId('file-uploader-value')[1]).toHaveTextContent('[]')
expect(screen.getAllByTestId('file-uploader-config')[1]).toHaveTextContent('"number_limits":5')
fireEvent.click(screen.getAllByTestId('upload-empty-file')[1]!)
expect(multiFallbackProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
})
it('should clear number defaults and skip rendering the default selector when options are missing', () => {
const numberProps = createBaseProps()
numberProps.tempPayload = { ...numberProps.tempPayload, type: InputVarType.number, default: '9' }
render(<ConfigModalFormFields {...numberProps} />)
fireEvent.change(screen.getByDisplayValue('9'), { target: { value: '' } })
expect(numberProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
const selectWithoutOptionsProps = createBaseProps()
selectWithoutOptionsProps.tempPayload = { ...selectWithoutOptionsProps.tempPayload, type: InputVarType.select }
selectWithoutOptionsProps.options = undefined
render(<ConfigModalFormFields {...selectWithoutOptionsProps} />)
expect(screen.getAllByText('config-select')).toHaveLength(1)
expect(screen.queryByText('ui-select:__empty__')).not.toBeInTheDocument()
})
it('should preserve existing select and file defaults when present', () => {
const selectProps = createBaseProps()
selectProps.tempPayload = { ...selectProps.tempPayload, type: InputVarType.select, default: undefined }
selectProps.options = ['alpha', 'beta']
render(<ConfigModalFormFields {...selectProps} />)
expect(screen.getByText('ui-select:__empty__')).toBeInTheDocument()
const existingFile = { fileId: 'existing-file', type: 'local_file', url: 'https://example.com/existing.png' }
const singleFileProps = createBaseProps()
singleFileProps.tempPayload = {
...singleFileProps.tempPayload,
type: InputVarType.singleFile,
default: existingFile,
}
render(<ConfigModalFormFields {...singleFileProps} />)
expect(screen.getAllByTestId('file-uploader-value')[0]).toHaveTextContent('"fileId":"existing-file"')
const existingFiles = [
{ fileId: 'file-1', type: 'local_file', url: 'https://example.com/1.png' },
{ fileId: 'file-2', type: 'remote_url', url: 'https://example.com/2.png' },
]
const multiFileProps = createBaseProps()
multiFileProps.tempPayload = {
...multiFileProps.tempPayload,
type: InputVarType.multiFiles,
default: existingFiles,
max_length: 2,
}
render(<ConfigModalFormFields {...multiFileProps} />)
expect(screen.getAllByTestId('file-uploader-value')[1]).toHaveTextContent('"fileId":"file-1"')
expect(screen.getAllByTestId('file-uploader-config')[1]).toHaveTextContent('"number_limits":2')
})
it('should render empty fallback values for text, paragraph, and number defaults', () => {
const textProps = createBaseProps()
textProps.isStringInput = true
textProps.tempPayload = { ...textProps.tempPayload, type: InputVarType.textInput, default: undefined }
const textView = render(<ConfigModalFormFields {...textProps} />)
expect(screen.getAllByPlaceholderText('variableConfig.inputPlaceholder')[2]).toHaveValue('')
expect(screen.getByText('config-string')).toHaveAttribute('data-max-length', '256')
textView.unmount()
const paragraphProps = createBaseProps()
paragraphProps.isStringInput = true
paragraphProps.tempPayload = { ...paragraphProps.tempPayload, type: InputVarType.paragraph, default: undefined }
const paragraphView = render(<ConfigModalFormFields {...paragraphProps} />)
expect(screen.getByText('config-string')).toHaveAttribute('data-max-length', 'Infinity')
expect(paragraphView.container.querySelector('textarea')).toHaveValue('')
paragraphView.unmount()
const numberProps = createBaseProps()
numberProps.tempPayload = { ...numberProps.tempPayload, type: InputVarType.number, default: undefined }
render(<ConfigModalFormFields {...numberProps} />)
expect(screen.getByRole('spinbutton')).toHaveValue(null)
})
})

View File

@ -40,28 +40,49 @@ vi.mock('@/app/components/base/input', () => ({
),
}))
vi.mock('@/app/components/base/select', () => ({
default: ({ defaultValue, onSelect, items, disabled, className }: {
defaultValue: string
onSelect: (item: { value: string }) => void
items: { name: string, value: string }[]
allowSearch?: boolean
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
disabled?: boolean
className?: string
}) => (
<select
data-testid="select-input"
value={defaultValue}
onChange={e => onSelect({ value: e.target.value })}
disabled={disabled}
className={className}
>
{items.map(item => (
<option key={item.value} value={item.value}>{item.name}</option>
))}
</select>
),
}))
onValueChange?: (value: string) => void
}>({})
return {
Select: ({ children, disabled, onValueChange }: {
children: React.ReactNode
disabled?: boolean
onValueChange?: (value: string) => void
}) => (
<SelectContext.Provider value={{ disabled, onValueChange }}>
<div>{children}</div>
</SelectContext.Provider>
),
SelectTrigger: ({ children, className }: { children: React.ReactNode, className?: string }) => {
const context = React.useContext(SelectContext)
return (
<div>
<button data-testid="select-input" type="button" disabled={context.disabled} className={className}>
{children}
</button>
<button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}>
empty select value
</button>
</div>
)
},
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
const context = React.useContext(SelectContext)
return (
<button data-testid={`select-${value}`} type="button" role="option" onClick={() => context.onValueChange?.(value)}>
{children}
</button>
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
vi.mock('@/app/components/base/textarea', () => ({
default: ({ value, onChange, placeholder, readOnly, className }: {
@ -410,11 +431,24 @@ describe('ChatUserInput', () => {
}))
render(<ChatUserInput inputs={{ choice: 'A' }} />)
fireEvent.change(screen.getByTestId('select-input'), { target: { value: 'B' } })
fireEvent.click(screen.getByTestId('select-B'))
expect(mockSetInputs).toHaveBeenCalledWith({ choice: 'B' })
})
it('should ignore empty select updates', () => {
mockUseContext.mockReturnValue(createContextValue({
modelConfig: createModelConfig([
createPromptVariable({ key: 'choice', name: 'Choice', type: 'select', options: ['A', 'B', 'C'] }),
]),
}))
render(<ChatUserInput inputs={{}} />)
fireEvent.click(screen.getByTestId('select-empty'))
expect(mockSetInputs).not.toHaveBeenCalled()
})
it('should call setInputs when number input changes', () => {
mockUseContext.mockReturnValue(createContextValue({
modelConfig: createModelConfig([
@ -443,20 +477,30 @@ describe('ChatUserInput', () => {
})
it('should not call setInputs for unknown keys', () => {
const filteredPromptVariables = {
length: 1,
forEach: vi.fn(),
map: (callback: (value: ExtendedPromptVariable, index: number) => unknown) => [
callback(createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), 0),
],
}
mockUseContext.mockReturnValue(createContextValue({
modelConfig: createModelConfig([
createPromptVariable({ key: 'name', name: 'Name', type: 'string' }),
]),
modelConfig: {
...createModelConfig(),
configs: {
prompt_template: '',
prompt_variables: {
filter: () => filteredPromptVariables,
} as unknown as PromptVariable[],
},
},
}))
render(<ChatUserInput inputs={{}} />)
// The component filters by promptVariableObj, so unknown keys won't trigger updates
// This is tested indirectly - only valid keys should trigger setInputs
fireEvent.change(screen.getByTestId('input-Name'), { target: { value: 'Valid' } })
expect(mockSetInputs).toHaveBeenCalledTimes(1)
expect(mockSetInputs).toHaveBeenCalledWith({ name: 'Valid' })
expect(mockSetInputs).not.toHaveBeenCalled()
})
})
@ -652,7 +696,7 @@ describe('ChatUserInput', () => {
render(<ChatUserInput inputs={{}} />)
const select = screen.getByTestId('select-input')
expect(select).toBeInTheDocument()
expect(select.children).toHaveLength(0)
expect(screen.queryAllByRole('option')).toHaveLength(0)
})
it('should handle select with undefined options', () => {

View File

@ -1,11 +1,11 @@
import type { Inputs } from '@/models/debug'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import * as React from 'react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Input from '@/app/components/base/input'
import Select from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import ConfigContext from '@/context/debug-configuration'
@ -102,13 +102,26 @@ const ChatUserInput = ({
)}
{type === 'select' && (
<Select
className="w-full"
defaultValue={inputs[key] as string}
onSelect={(i) => { handleInputValueChange(key, i.value as string) }}
items={(options || []).map(i => ({ name: i, value: i }))}
allowSearch={false}
value={inputs[key] ? String(inputs[key]) : null}
disabled={readonly}
/>
onValueChange={(nextValue) => {
if (!nextValue)
return
handleInputValueChange(key, nextValue)
}}
>
<SelectTrigger className="w-full">
{String(inputs[key] || t('placeholder.select', { ns: 'common' }))}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{(options || []).map(option => (
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)}
{type === 'number' && (
<Input

View File

@ -9,6 +9,29 @@ import PromptValuePanel from '../index'
const mockSetShowAppConfigureFeaturesModal = vi.fn()
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({
children,
onClick,
disabled,
className,
}: {
children: React.ReactNode
onClick?: () => void
disabled?: boolean
className?: string
}) => (
<button
type="button"
data-disabled={disabled ? 'true' : 'false'}
className={className}
onClick={() => onClick?.()}
>
{children}
</button>
),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { setShowAppConfigureFeaturesModal: typeof mockSetShowAppConfigureFeaturesModal }) => unknown) => selector({
setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal,
@ -24,15 +47,51 @@ vi.mock('@/app/components/base/features/new-feature-panel/feature-bar', () => ({
),
}))
vi.mock('@/app/components/base/select', () => ({
default: ({ onSelect }: { onSelect: (item: { value: string }) => void }) => (
<button type="button" onClick={() => onSelect({ value: 'selected-option' })}>select-input</button>
),
}))
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
onValueChange?: (value: string) => void
}>({})
return {
Select: ({ children, onValueChange }: {
children: React.ReactNode
onValueChange?: (value: string) => void
}) => (
<SelectContext.Provider value={{ onValueChange }}>
<div>{children}</div>
</SelectContext.Provider>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => {
const context = React.useContext(SelectContext)
return (
<div>
<button type="button">{children}</button>
<button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}>
empty select value
</button>
</div>
)
},
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
const context = React.useContext(SelectContext)
return (
<button type="button" onClick={() => context.onValueChange?.(value)}>
{children}
</button>
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-input', () => ({
default: ({ onChange }: { onChange: (value: boolean) => void }) => (
<button type="button" onClick={() => onChange(true)}>bool-input</button>
default: ({ name, onChange }: { name: string, onChange: (value: boolean) => void }) => (
<button type="button" data-testid={`bool-input-${name}`} onClick={() => onChange(true)}>
bool-input
</button>
),
}))
@ -121,7 +180,7 @@ describe('PromptValuePanel', () => {
})
const runButton = screen.getByRole('button', { name: 'appDebug.inputs.run' })
expect(runButton).not.toBeDisabled()
expect(runButton).toHaveAttribute('data-disabled', 'false')
fireEvent.click(runButton)
await waitFor(() => expect(mockOnSend).toHaveBeenCalledTimes(1))
})
@ -137,9 +196,22 @@ describe('PromptValuePanel', () => {
})
const runButton = screen.getByRole('button', { name: 'appDebug.inputs.run' })
expect(runButton).toBeDisabled()
fireEvent.click(runButton)
expect(mockOnSend).not.toHaveBeenCalled()
expect(runButton).toHaveAttribute('data-disabled', 'true')
})
it('invokes the tooltip-branch run handler when the click callback is triggered', () => {
renderPanel({
context: {
mode: AppModeEnum.CHAT,
},
props: {
appType: AppModeEnum.CHAT,
},
})
fireEvent.click(screen.getByRole('button', { name: 'appDebug.inputs.run' }))
expect(mockOnSend).toHaveBeenCalledTimes(1)
})
it('hydrates default values, supports advanced prompt gating, and toggles the feature panel', () => {
@ -163,12 +235,33 @@ describe('PromptValuePanel', () => {
})
expect(mockSetInputs).toHaveBeenCalledWith({ textVar: 'default text' })
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute('data-disabled', 'true')
fireEvent.click(screen.getByText('feature bar'))
expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalled()
})
it('disables run for advanced completion mode when the completion prompt is empty', () => {
renderPanel({
context: {
isAdvancedMode: true,
modelModeType: ModelModeType.completion,
completionPromptConfig: {
prompt: { text: '' },
conversation_histories_role: { user_prefix: 'user', assistant_prefix: 'assistant' },
},
modelConfig: {
configs: {
prompt_template: '',
prompt_variables: [],
},
},
},
})
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute('data-disabled', 'true')
})
it('renders paragraph, select, number, checkbox, and vision inputs', () => {
const onVisionFilesChange = vi.fn()
renderPanel({
@ -203,13 +296,13 @@ describe('PromptValuePanel', () => {
})
fireEvent.change(screen.getByPlaceholderText('Paragraph Var'), { target: { value: 'updated paragraph' } })
fireEvent.click(screen.getByText('select-input'))
fireEvent.click(screen.getByText('b'))
fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '2' } })
fireEvent.click(screen.getByText('bool-input'))
fireEvent.click(screen.getByText('image-uploader'))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ paragraphVar: 'updated paragraph' }))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ selectVar: 'selected-option' }))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ selectVar: 'b' }))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ numberVar: '2' }))
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ boolVar: true }))
expect(onVisionFilesChange).toHaveBeenCalledWith([
@ -222,6 +315,127 @@ describe('PromptValuePanel', () => {
])
})
it('ignores empty select values when choosing prompt options', () => {
renderPanel({
context: {
modelConfig: {
configs: {
prompt_template: 'prompt template',
prompt_variables: [
{ key: 'selectVar', name: 'Select Var', type: 'select', options: ['a', 'b'], required: false },
],
},
},
},
props: {
inputs: {
selectVar: 'a',
},
},
})
fireEvent.click(screen.getByTestId('select-empty'))
expect(mockSetInputs).not.toHaveBeenCalled()
})
it('ignores updates when the rendered field is not tracked in the prompt variable lookup', () => {
const filteredPromptVariables = {
length: 1,
forEach: vi.fn(),
map: (callback: (value: { key: string, name: string, type: string, required: boolean }, index: number) => unknown) => [
callback({ key: 'textVar', name: 'Text Var', type: 'string', required: true }, 0),
],
}
renderPanel({
context: {
modelConfig: {
configs: {
prompt_template: 'prompt template',
prompt_variables: {
filter: () => filteredPromptVariables,
},
},
},
},
props: {
inputs: { textVar: '' },
},
})
fireEvent.change(screen.getByPlaceholderText('Text Var'), { target: { value: 'ignored' } })
expect(mockSetInputs).not.toHaveBeenCalled()
})
it('renders empty select and number placeholders when no value is provided', () => {
renderPanel({
context: {
modelConfig: {
configs: {
prompt_template: 'prompt template',
prompt_variables: [
{ key: 'selectVar', name: 'Select Var', type: 'select', required: false },
{ key: 'numberVar', name: 'Number Var', type: 'number', required: true },
],
},
},
},
props: {
inputs: {
selectVar: '',
numberVar: '',
},
},
})
expect(screen.getByText('common.placeholder.select')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Number Var')).toHaveValue(null)
expect(screen.queryAllByRole('option')).toHaveLength(0)
})
it('falls back to the checkbox key when the label is missing from the rendered collection', () => {
const filteredPromptVariables = {
length: 1,
forEach: vi.fn(),
map: (callback: (value: { key: string, name: string, type: string, required: boolean }, index: number) => unknown) => [
callback({ key: 'boolVar', name: '', type: 'checkbox', required: false }, 0),
],
}
renderPanel({
context: {
modelConfig: {
configs: {
prompt_template: 'prompt template',
prompt_variables: {
filter: () => filteredPromptVariables,
},
},
},
},
props: {
inputs: {
boolVar: false,
},
},
})
expect(screen.getByTestId('bool-input-boolVar')).toBeInTheDocument()
})
it('marks actions as disabled when readonly even if the prompt is runnable', () => {
renderPanel({
context: {
readonly: true,
},
})
expect(screen.getByRole('button', { name: 'common.operation.clear' })).toHaveAttribute('data-disabled', 'true')
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute('data-disabled', 'true')
})
it('collapses the user input panel and hides the clear and run actions', () => {
renderPanel()

View File

@ -4,6 +4,7 @@ import type { Inputs } from '@/models/debug'
import type { VisionFile, VisionSettings } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import {
RiArrowDownSLine,
RiArrowRightSLine,
@ -17,7 +18,6 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import FeatureBar from '@/app/components/base/features/new-feature-panel/feature-bar'
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
import Input from '@/app/components/base/input'
import Select from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import Tooltip from '@/app/components/base/tooltip'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
@ -156,14 +156,26 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
)}
{type === 'select' && (
<Select
className="w-full"
defaultValue={inputs[key] as string}
onSelect={(i) => { handleInputValueChange(key, i.value as string) }}
items={(options || []).map(i => ({ name: i, value: i }))}
allowSearch={false}
bgClassName="bg-gray-50"
value={inputs[key] ? String(inputs[key]) : null}
disabled={readonly}
/>
onValueChange={(nextValue) => {
if (!nextValue)
return
handleInputValueChange(key, nextValue)
}}
>
<SelectTrigger className="w-full bg-gray-50">
{String(inputs[key] || t('placeholder.select', { ns: 'common' }))}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{(options || []).map(option => (
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)}
{type === 'number' && (
<Input

View File

@ -5,6 +5,7 @@ import type { AppDetailResponse } from '@/models/app'
import type { AppIconType, AppSSO, Language } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import { RiArrowRightSLine, RiCloseLine } from '@remixicon/react'
@ -19,7 +20,6 @@ import { SparklesSoft } from '@/app/components/base/icons/src/public/common'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import PremiumBadge from '@/app/components/base/premium-badge'
import { SimpleSelect } from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import Tooltip from '@/app/components/base/tooltip'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
@ -57,6 +57,10 @@ export type ConfigParams = {
}
const prefixSettings = 'overview.appInfo.settings'
type SelectOption = {
value: string
name: string
}
const SettingsModal: FC<ISettingsModalProps> = ({
isChat,
@ -110,6 +114,8 @@ const SettingsModal: FC<ISettingsModalProps> = ({
const { enableBilling, plan, webappCopyrightEnabled } = useProviderContext()
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
const isFreePlan = plan.type === 'sandbox'
const languageOptions: SelectOption[] = languages.filter(item => item.supported)
const selectedLanguage = languageOptions.find(item => item.value === language)
const handlePlanClick = useCallback(() => {
if (isFreePlan)
setShowPricingModal()
@ -303,13 +309,26 @@ const SettingsModal: FC<ISettingsModalProps> = ({
{/* language */}
<div className="flex items-center">
<div className={cn('grow py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.language`, { ns: 'appOverview' })}</div>
<SimpleSelect
wrapperClassName="w-[200px]"
items={languages.filter(item => item.supported)}
defaultValue={language}
onSelect={item => setLanguage(item.value as Language)}
notClearable
/>
<Select
value={selectedLanguage?.value ?? null}
onValueChange={(nextValue) => {
if (!nextValue)
return
setLanguage(nextValue as Language)
}}
>
<SelectTrigger size="large" className="w-[200px]">
{selectedLanguage?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{languageOptions.map(item => (
<SelectItem key={item.value} value={item.value}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* theme color */}
{isChat && (

View File

@ -270,7 +270,7 @@ describe('InputsFormContent', () => {
renderWithContext(<InputsFormContent />, context)
const selNodes = screen.getAllByText('Sel')
expect(selNodes.length).toBeGreaterThan(0)
expect(screen.queryByText('existing')).toBeNull()
expect(screen.getByText('existing')).toBeInTheDocument()
})
it('handles select input empty branches (no current value -> show placeholder)', () => {

View File

@ -1,9 +1,9 @@
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import * as React from 'react'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
import { PortalSelect } from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
@ -85,13 +85,22 @@ const InputsFormContent = ({ showTip }: Props) => {
/>
)}
{form.type === InputVarType.select && (
<PortalSelect
popupClassName="z-[60] w-[200px]"
value={inputsFormValue?.[form.variable] ?? form.default ?? ''}
items={form.options.map((option: string) => ({ value: option, name: option }))}
onSelect={item => handleFormChange(form.variable, item.value as string)}
placeholder={form.label}
/>
<Select
value={(inputsFormValue?.[form.variable] ?? form.default ?? '') || null}
onValueChange={value => value && handleFormChange(form.variable, value)}
>
<SelectTrigger className="w-full">
{String(inputsFormValue?.[form.variable] ?? form.default ?? form.label)}
</SelectTrigger>
<SelectContent popupClassName="z-[60] w-(--anchor-width)">
{form.options.map((option: string) => (
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)}
{form.type === InputVarType.singleFile && (
<FileUploaderInAttachmentWrapper

View File

@ -1,9 +1,9 @@
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import * as React from 'react'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
import { PortalSelect } from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
@ -85,13 +85,22 @@ const InputsFormContent = ({ showTip }: Props) => {
/>
)}
{form.type === InputVarType.select && (
<PortalSelect
popupClassName="z-[60] w-[200px]"
value={inputsFormValue?.[form.variable] ?? form.default ?? ''}
items={form.options.map((option: string) => ({ value: option, name: option }))}
onSelect={item => handleFormChange(form.variable, item.value as string)}
placeholder={form.label}
/>
<Select
value={(inputsFormValue?.[form.variable] ?? form.default ?? '') || null}
onValueChange={value => value && handleFormChange(form.variable, value)}
>
<SelectTrigger className="w-full">
{String(inputsFormValue?.[form.variable] ?? form.default ?? form.label)}
</SelectTrigger>
<SelectContent popupClassName="z-[60] w-(--anchor-width)">
{form.options.map((option: string) => (
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)}
{form.type === InputVarType.singleFile && (
<FileUploaderInAttachmentWrapper

View File

@ -206,7 +206,7 @@ const TimePicker = ({
>
<PopoverTrigger
nativeButton={false}
className={triggerFullWidth ? 'block! w-full' : undefined}
className={triggerFullWidth ? 'flex! w-full' : undefined}
render={renderTrigger
? renderTrigger({
inputElem,

View File

@ -132,8 +132,8 @@ describe('FormGeneration', () => {
})
render(<FormGeneration forms={[form]} value={{}} onChange={onChange} />)
fireEvent.click(screen.getByText(/placeholder\.select/))
fireEvent.click(screen.getByText('GPT-4'))
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByRole('option', { name: 'GPT-4' }))
expect(onChange).toHaveBeenCalledWith({ model: 'gpt-4' })
})
@ -152,7 +152,7 @@ describe('FormGeneration', () => {
render(<FormGeneration forms={[form]} value={{}} onChange={vi.fn()} />)
expect(screen.getByText('模型')).toBeInTheDocument()
fireEvent.click(screen.getByText(/placeholder\.select/))
expect(screen.getByText('智谱-4')).toBeInTheDocument()
fireEvent.click(screen.getByRole('combobox'))
expect(screen.getByRole('option', { name: '智谱-4' })).toBeInTheDocument()
})
})

View File

@ -1,7 +1,7 @@
import type { FC } from 'react'
import type { CodeBasedExtensionForm } from '@/models/common'
import type { ModerationConfig } from '@/models/debug'
import { PortalSelect } from '@/app/components/base/select'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import Textarea from '@/app/components/base/textarea'
import { useLocale } from '@/context/i18n'
@ -24,53 +24,65 @@ const FormGeneration: FC<FormGenerationProps> = ({
return (
<>
{
forms.map((form, index) => (
<div
key={index}
className="py-2"
>
<div className="flex h-9 items-center text-sm font-medium text-text-primary">
{locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']}
</div>
{
form.type === 'text-input' && (
<input
value={value?.[form.variable] || ''}
className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden"
placeholder={form.placeholder}
onChange={e => handleFormChange(form.variable, e.target.value)}
/>
)
}
{
form.type === 'paragraph' && (
<div className="relative">
<Textarea
className="resize-none"
forms.map((form, index) => {
const selectOptions = form.type === 'select'
? form.options.map(option => ({
name: option.label[locale === 'zh-Hans' ? 'zh-Hans' : 'en-US'],
value: option.value,
}))
: []
const selectedOption = selectOptions.find(option => option.value === value?.[form.variable]) ?? null
return (
<div
key={index}
className="py-2"
>
<div className="flex h-9 items-center text-sm font-medium text-text-primary">
{locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']}
</div>
{
form.type === 'text-input' && (
<input
value={value?.[form.variable] || ''}
className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden"
placeholder={form.placeholder}
onChange={e => handleFormChange(form.variable, e.target.value)}
/>
</div>
)
}
{
form.type === 'select' && (
<PortalSelect
value={value?.[form.variable]}
items={form.options.map((option) => {
return {
name: option.label[locale === 'zh-Hans' ? 'zh-Hans' : 'en-US'],
value: option.value,
}
})}
onSelect={item => handleFormChange(form.variable, item.value as string)}
popupClassName="w-[576px] z-102!"
/>
)
}
</div>
))
)
}
{
form.type === 'paragraph' && (
<div className="relative">
<Textarea
className="resize-none"
value={value?.[form.variable] || ''}
placeholder={form.placeholder}
onChange={e => handleFormChange(form.variable, e.target.value)}
/>
</div>
)
}
{
form.type === 'select' && (
<Select value={selectedOption?.value ?? null} onValueChange={nextValue => nextValue && handleFormChange(form.variable, nextValue)}>
<SelectTrigger className="w-full">
{selectedOption?.name ?? form.placeholder}
</SelectTrigger>
<SelectContent popupClassName="z-102 w-(--anchor-width)">
{selectOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
</div>
)
})
}
</>
)

View File

@ -1,6 +1,5 @@
'use client'
import type { OnFeaturesChange } from '@/app/components/base/features/types'
import type { Item } from '@/app/components/base/select'
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react'
import { cn } from '@langgenius/dify-ui/cn'
import { Switch } from '@langgenius/dify-ui/switch'
@ -17,6 +16,11 @@ import { usePathname } from '@/next/navigation'
import { useAppVoices } from '@/service/use-apps'
import { TtsAutoPlay } from '@/types/app'
type SelectOption = {
value: string | number
name: string
}
type VoiceParamConfigProps = {
onClose: () => void
onChange?: OnFeaturesChange
@ -99,7 +103,7 @@ const VoiceParamConfig = ({
</div>
<Listbox
value={languageItem}
onChange={(value: Item) => {
onChange={(value: SelectOption) => {
handleChange({
language: String(value.value),
})
@ -166,7 +170,7 @@ const VoiceParamConfig = ({
<Listbox
value={voiceItem}
disabled={!languageItem}
onChange={(value: Item) => {
onChange={(value: SelectOption) => {
handleChange({
voice: String(value.value),
})
@ -195,7 +199,7 @@ const VoiceParamConfig = ({
<ListboxOptions
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg px-1 py-1 text-base shadow-lg focus:outline-hidden sm:text-sm"
>
{voiceItems?.map((item: Item) => (
{voiceItems?.map((item: SelectOption) => (
<ListboxOption
key={item.value}
className="relative cursor-pointer rounded-lg py-2 pr-9 pl-3 text-text-secondary select-none hover:bg-state-base-hover data-active:bg-state-base-active"

View File

@ -1,6 +1,5 @@
'use client'
import type { FC } from 'react'
import type { Item } from '@/app/components/base/select'
import type { BuiltInMetadataItem, MetadataItemWithValueLength } from '@/app/components/datasets/metadata/types'
import type { SortType } from '@/service/datasets'
import { PlusIcon } from '@heroicons/react/24/solid'
@ -19,6 +18,11 @@ import { useDocLink } from '@/context/i18n'
import { DataSourceType } from '@/models/datasets'
import { useIndexStatus } from '../status-item/hooks'
type SelectOption = {
value: string | number
name: string
}
type DocumentsHeaderProps = {
// Dataset info
datasetId: string
@ -82,7 +86,7 @@ const DocumentsHeader: FC<DocumentsHeaderProps> = ({
const isDataSourceNotion = dataSourceType === DataSourceType.NOTION
const isDataSourceWeb = dataSourceType === DataSourceType.WEB
const statusFilterItems: Item[] = useMemo(() => [
const statusFilterItems: SelectOption[] = useMemo(() => [
{ value: 'all', name: t('list.index.all', { ns: 'datasetDocuments' }) as string },
{ value: 'queuing', name: DOC_INDEX_STATUS_MAP.queuing.text },
{ value: 'indexing', name: DOC_INDEX_STATUS_MAP.indexing.text },
@ -94,7 +98,7 @@ const DocumentsHeader: FC<DocumentsHeaderProps> = ({
{ value: 'archived', name: DOC_INDEX_STATUS_MAP.archived.text },
], [DOC_INDEX_STATUS_MAP, t])
const sortItems: Item[] = useMemo(() => [
const sortItems: SelectOption[] = useMemo(() => [
{ value: 'created_at', name: t('list.sort.uploadTime', { ns: 'datasetDocuments' }) as string },
{ value: 'hit_count', name: t('list.sort.hitCount', { ns: 'datasetDocuments' }) as string },
], [t])

View File

@ -82,7 +82,7 @@ describe('MenuBar', () => {
it('should call renderOption for each item when dropdown is opened', async () => {
render(<MenuBar {...defaultProps} />)
const selectButton = screen.getByRole('button', { name: /All/i })
const selectButton = screen.getByRole('combobox')
fireEvent.click(selectButton)
// After opening, renderOption is called for each item, rendering the mocked StatusItem

View File

@ -1,14 +1,19 @@
'use client'
import type { FC } from 'react'
import type { Item } from '@/app/components/base/select'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import Checkbox from '@/app/components/base/checkbox'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import { SimpleSelect } from '@/app/components/base/select'
import DisplayToggle from '../display-toggle'
import StatusItem from '../status-item'
import s from '../style.module.css'
type Item = {
value: number | string
name: string
} & Record<string, unknown>
type MenuBarProps = {
isAllSelected: boolean
isSomeSelected: boolean
@ -38,6 +43,8 @@ const MenuBar: FC<MenuBarProps> = ({
isCollapsed,
toggleCollapsed,
}) => {
const selectedStatus = statusList.find(item => item.value === selectDefaultValue) ?? null
return (
<div className={s.docSearchWrapper}>
<Checkbox
@ -48,17 +55,29 @@ const MenuBar: FC<MenuBarProps> = ({
disabled={isLoading}
/>
<div className="flex-1 pl-5 system-sm-semibold-uppercase text-text-secondary">{totalText}</div>
<SimpleSelect
onSelect={onChangeStatus}
items={statusList}
defaultValue={selectDefaultValue}
className={s.select}
wrapperClassName="h-fit mr-2"
optionWrapClassName="w-[160px]"
optionClassName="p-0"
renderOption={({ item, selected }) => <StatusItem item={item} selected={selected} />}
notClearable
/>
<Select
value={selectedStatus ? String(selectedStatus.value) : null}
onValueChange={(nextValue) => {
if (!nextValue)
return
const nextItem = statusList.find(item => String(item.value) === nextValue)
if (nextItem)
onChangeStatus(nextItem)
}}
>
<SelectTrigger className={cn(s.select, 'mr-2 h-fit')}>
{selectedStatus?.name ?? ''}
</SelectTrigger>
<SelectContent popupClassName="w-[160px]">
{statusList.map(item => (
<SelectItem key={item.value} value={String(item.value)} className="h-auto p-0">
<SelectItemText className="sr-only m-0 p-0">{item.name}</SelectItemText>
<StatusItem item={item} selected={item.value === selectDefaultValue} />
{item.value === selectDefaultValue && <SelectItemIndicator className="hidden" />}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
showLeftIcon
showClearIcon

View File

@ -1,16 +1,20 @@
import type { Item } from '@/app/components/base/select'
import { useDebounceFn } from 'ahooks'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
type SelectOption = {
value: string | number
name: string
}
type UseSearchFilterReturn = {
inputValue: string
searchValue: string
selectedStatus: boolean | 'all'
statusList: Item[]
statusList: SelectOption[]
selectDefaultValue: 'all' | 0 | 1
handleInputChange: (value: string) => void
onChangeStatus: (item: Item) => void
onChangeStatus: (item: SelectOption) => void
onClearFilter: () => void
resetPage: () => void
}
@ -27,7 +31,7 @@ export const useSearchFilter = (options: UseSearchFilterOptions): UseSearchFilte
const [searchValue, setSearchValue] = useState<string>('')
const [selectedStatus, setSelectedStatus] = useState<boolean | 'all'>('all')
const statusList = useRef<Item[]>([
const statusList = useRef<SelectOption[]>([
{ value: 'all', name: t('list.index.all', { ns: 'datasetDocuments' }) },
{ value: 0, name: t('list.status.disabled', { ns: 'datasetDocuments' }) },
{ value: 1, name: t('list.status.enabled', { ns: 'datasetDocuments' }) },
@ -43,7 +47,7 @@ export const useSearchFilter = (options: UseSearchFilterOptions): UseSearchFilte
handleSearch()
}, [handleSearch])
const onChangeStatus = useCallback(({ value }: Item) => {
const onChangeStatus = useCallback(({ value }: SelectOption) => {
setSelectedStatus(value === 'all' ? 'all' : !!value)
onPageChange(1)
}, [onPageChange])

View File

@ -1,10 +1,14 @@
import type { FC } from 'react'
import type { Item } from '@/app/components/base/select'
import { RiCheckLine } from '@remixicon/react'
import * as React from 'react'
type StatusOption = {
value: string | number
name: string
}
type IStatusItemProps = {
item: Item
item: StatusOption
selected: boolean
}

View File

@ -480,7 +480,7 @@ describe('FieldInfo', () => {
// Assert - SimpleSelect should be rendered
// Assert - SimpleSelect should be rendered
expect(screen.getByRole('button'))!.toBeInTheDocument()
expect(screen.getByRole('combobox'))!.toBeInTheDocument()
})
it('should render textarea when showEdit is true and inputType is textarea', () => {

View File

@ -2,10 +2,10 @@
import type { FC, ReactNode } from 'react'
import type { inputType } from '@/hooks/use-metadata'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { useTranslation } from 'react-i18next'
import AutoHeightTextarea from '@/app/components/base/auto-height-textarea'
import Input from '@/app/components/base/input'
import { SimpleSelect } from '@/app/components/base/select'
import { getTextWidthWithCanvas } from '@/utils'
import s from '../style.module.css'
@ -36,6 +36,7 @@ const FieldInfo: FC<FieldInfoProps> = ({
const textNeedWrap = getTextWidthWithCanvas(displayedValue) > 190
const editAlignTop = showEdit && inputType === 'textarea'
const readAlignTop = !showEdit && textNeedWrap
const selectedOption = selectOptions.find(option => option.value === value)
const renderContent = () => {
if (!showEdit)
@ -43,14 +44,26 @@ const FieldInfo: FC<FieldInfoProps> = ({
if (inputType === 'select') {
return (
<SimpleSelect
onSelect={({ value }) => onUpdate?.(value as string)}
items={selectOptions}
defaultValue={value}
className={s.select}
wrapperClassName={s.selectWrapper}
placeholder={`${t('metadata.placeholder.select', { ns: 'datasetDocuments' })}${label}`}
/>
<Select
value={selectedOption?.value ?? null}
onValueChange={(nextValue) => {
if (!nextValue)
return
onUpdate?.(nextValue)
}}
>
<SelectTrigger className={cn(s.select, s.selectWrapper)}>
{selectedOption?.name ?? `${t('metadata.placeholder.select', { ns: 'datasetDocuments' })}${label}`}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{selectOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View File

@ -11,53 +11,56 @@ const mockMutateUserProfile = vi.fn()
let mockLocale: string | undefined = 'en-US'
let mockUserProfile: UserProfileResponse
vi.mock('@/app/components/base/select', async () => {
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
disabled?: boolean
onValueChange?: (value: string) => void
}>({})
return {
SimpleSelect: ({
items = [],
defaultValue,
onSelect,
Select: ({
children,
disabled,
onValueChange,
}: {
items?: Array<{ value: string | number, name: string }>
defaultValue?: string | number
onSelect: (item: { value: string | number, name: string }) => void
children: React.ReactNode
disabled?: boolean
onValueChange?: (value: string) => void
}) => {
const [open, setOpen] = React.useState(false)
const [selectedValue, setSelectedValue] = React.useState<string | number | undefined>(defaultValue)
const selected = items.find(item => item.value === selectedValue)
?? items.find(item => item.value === defaultValue)
?? null
return (
<SelectContext.Provider value={{ disabled, onValueChange }}>
<div>{children}</div>
</SelectContext.Provider>
)
},
SelectTrigger: ({ children }: { children: React.ReactNode }) => {
const context = React.useContext(SelectContext)
return (
<div>
<button type="button" disabled={disabled} onClick={() => setOpen(prev => !prev)}>
{selected?.name ?? ''}
<button type="button" disabled={context.disabled}>
{children}
</button>
<button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}>
empty value
</button>
<button data-testid="select-invalid" type="button" onClick={() => context.onValueChange?.('__missing__')}>
invalid value
</button>
{open && (
<div>
{items.map(item => (
<button
key={item.value}
type="button"
role="option"
onClick={() => {
setSelectedValue(item.value)
onSelect(item)
setOpen(false)
}}
>
{item.name}
</button>
))}
</div>
)}
</div>
)
},
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
const context = React.useContext(SelectContext)
return (
<button type="button" role="option" onClick={() => context.onValueChange?.(value)}>
{children}
</button>
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
@ -118,7 +121,7 @@ const getSectionByLabel = (sectionLabel: string) => {
const selectOption = async (sectionLabel: string, optionName: string) => {
const section = getSectionByLabel(sectionLabel)
await act(async () => {
fireEvent.click(within(section).getByRole('button'))
fireEvent.click(within(section).getAllByRole('button')[0]!)
})
await act(async () => {
fireEvent.click(await within(section).findByRole('option', { name: optionName }))
@ -164,6 +167,18 @@ describe('LanguagePage - Rendering', () => {
expect(screen.getByRole('button', { name: english.name })).toBeInTheDocument()
expect(screen.getByRole('button', { name: niueTimezone.name })).toBeInTheDocument()
})
it('should render placeholders when the current locale or timezone is unsupported', () => {
mockLocale = 'unsupported-locale'
mockUserProfile = createUserProfile({
interface_language: 'unsupported-locale',
timezone: 'Unsupported/Timezone',
})
renderPage()
expect(screen.getAllByRole('button', { name: 'common.placeholder.select' })).toHaveLength(2)
})
})
// Interactions
@ -206,7 +221,12 @@ describe('LanguagePage - Interactions', () => {
await selectOption('common.language.timezone', midwayTimezone.name)
expect(await screen.findByText('common.actionMsg.modifiedSuccessfully')).toBeInTheDocument()
expect(screen.getByRole('button', { name: midwayTimezone.name })).toBeInTheDocument()
await waitFor(() => {
expect(updateUserProfileMock).toHaveBeenCalledWith({
url: '/account/timezone',
body: { timezone: midwayTimezone.value },
})
})
}, 15000)
it('should show error toast when timezone update fails', async () => {
@ -219,4 +239,30 @@ describe('LanguagePage - Interactions', () => {
expect(await screen.findByText('Timezone failed')).toBeInTheDocument()
}, 15000)
it('should ignore empty and unknown language selections', async () => {
renderPage()
const section = getSectionByLabel('common.language.displayLanguage')
await act(async () => {
fireEvent.click(within(section).getByTestId('select-empty'))
fireEvent.click(within(section).getByTestId('select-invalid'))
})
expect(updateUserProfileMock).not.toHaveBeenCalled()
})
it('should ignore empty and unknown timezone selections', async () => {
renderPage()
const section = getSectionByLabel('common.language.timezone')
await act(async () => {
fireEvent.click(within(section).getByTestId('select-empty'))
fireEvent.click(within(section).getByTestId('select-invalid'))
})
expect(updateUserProfileMock).not.toHaveBeenCalled()
})
})

View File

@ -1,10 +1,9 @@
'use client'
import type { Item } from '@/app/components/base/select'
import type { Locale } from '@/i18n-config'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SimpleSelect } from '@/app/components/base/select'
import { useAppContext } from '@/context/app-context'
import { useLocale } from '@/context/i18n'
import { setLocaleOnClient } from '@/i18n-config'
@ -13,6 +12,16 @@ import { useRouter } from '@/next/navigation'
import { updateUserProfile } from '@/service/common'
import { timezones } from '@/utils/timezone'
type SelectOption = {
value: string
name: string
}
type TimezoneOption = {
value: string | number
name: string
}
const titleClassName = `
mb-2 system-sm-semibold text-text-secondary
`
@ -22,7 +31,10 @@ export default function LanguagePage() {
const [editing, setEditing] = useState(false)
const { t } = useTranslation()
const router = useRouter()
const handleSelectLanguage = async (item: Item) => {
const languageOptions: SelectOption[] = languages.filter(item => item.supported)
const selectedLanguage = languageOptions.find(item => item.value === (locale || userProfile.interface_language))
const selectedTimezone = timezones.find(item => item.value === userProfile.timezone)
const handleSelectLanguage = async (item: SelectOption) => {
const url = '/account/interface-language'
const bodyKey = 'interface_language'
setEditing(true)
@ -39,7 +51,7 @@ export default function LanguagePage() {
setEditing(false)
}
}
const handleSelectTimezone = async (item: Item) => {
const handleSelectTimezone = async (item: TimezoneOption) => {
const url = '/account/timezone'
const bodyKey = 'timezone'
setEditing(true)
@ -59,11 +71,55 @@ export default function LanguagePage() {
<>
<div className="mb-8">
<div className={titleClassName}>{t('language.displayLanguage', { ns: 'common' })}</div>
<SimpleSelect defaultValue={locale || userProfile.interface_language} items={languages.filter(item => item.supported)} onSelect={item => handleSelectLanguage(item)} disabled={editing} notClearable={true} />
<Select
value={selectedLanguage?.value ?? null}
disabled={editing}
onValueChange={(nextValue) => {
if (!nextValue)
return
const nextItem = languageOptions.find(item => item.value === nextValue)
if (nextItem)
handleSelectLanguage(nextItem)
}}
>
<SelectTrigger size="large">
{selectedLanguage?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{languageOptions.map(item => (
<SelectItem key={item.value} value={item.value}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mb-8">
<div className={titleClassName}>{t('language.timezone', { ns: 'common' })}</div>
<SimpleSelect defaultValue={userProfile.timezone} items={timezones} onSelect={item => handleSelectTimezone(item)} disabled={editing} notClearable={true} />
<Select
value={selectedTimezone ? String(selectedTimezone.value) : null}
disabled={editing}
onValueChange={(nextValue) => {
if (!nextValue)
return
const nextItem = timezones.find(item => String(item.value) === nextValue)
if (nextItem)
handleSelectTimezone(nextItem)
}}
>
<SelectTrigger size="large">
{selectedTimezone?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{timezones.map(item => (
<SelectItem key={item.value} value={String(item.value)}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)

View File

@ -13,10 +13,10 @@ import type {
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { useCallback, useState } from 'react'
import Radio from '@/app/components/base/radio'
import RadioE from '@/app/components/base/radio/ui'
import { SimpleSelect } from '@/app/components/base/select'
import Tooltip from '@/app/components/base/tooltip'
import AppSelector from '@/app/components/plugins/plugin-detail-panel/app-selector'
import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector'
@ -253,6 +253,17 @@ function Form<
if (show_on.length && !show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value))
return null
const filteredOptions = options.filter((option) => {
if (option.show_on.length)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return true
}).map(option => ({ value: option.value, name: option.label[language] || option.label.en_US }))
const currentValue = (isShowDefaultValue && ((value[variable] as string) === '' || value[variable] === undefined || value[variable] === null))
? formSchema.default
: value[variable]
const selectedOption = filteredOptions.find(option => option.value === currentValue)
return (
<div key={variable} className={cn(itemClassName, 'py-3')}>
<div className={cn(fieldLabelClassName, 'flex items-center py-2 system-sm-semibold text-text-secondary')}>
@ -263,20 +274,27 @@ function Form<
)}
{tooltipContent}
</div>
<SimpleSelect
wrapperClassName="h-8"
className={cn(inputClassName)}
<Select
disabled={readonly}
defaultValue={(isShowDefaultValue && ((value[variable] as string) === '' || value[variable] === undefined || value[variable] === null)) ? formSchema.default : value[variable]}
items={options.filter((option) => {
if (option.show_on.length)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return true
}).map(option => ({ value: option.value, name: option.label[language] || option.label.en_US }))}
onSelect={item => handleFormChange(variable, item.value as string)}
placeholder={placeholder?.[language] || placeholder?.en_US}
/>
value={selectedOption?.value ?? null}
onValueChange={(nextValue) => {
if (!nextValue)
return
handleFormChange(variable, nextValue)
}}
>
<SelectTrigger size="medium" className={cn(inputClassName)}>
{selectedOption?.name ?? placeholder?.[language] ?? placeholder?.en_US}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{filteredOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
{fieldMoreInfo?.(formSchema)}
{validating && changeKey === variable && <ValidatingTip />}
</div>

View File

@ -10,6 +10,7 @@ import type {
} from '../../declarations'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { FormTypeEnum } from '../../declarations'
import Form from '../Form'
@ -288,7 +289,8 @@ describe('Form', () => {
expect(onChange).toHaveBeenCalledTimes(1)
})
it('should render select and checkbox fields and update checkbox value', () => {
it('should render select and checkbox fields and update checkbox value', async () => {
const user = userEvent.setup()
const formSchemas: AnyFormSchema[] = [
createSelectSchema({
variable: 'model',
@ -339,10 +341,10 @@ describe('Form', () => {
)
expect(screen.getByText('Select A'))!.toBeInTheDocument()
fireEvent.click(screen.getByText('Select A'))
fireEvent.click(screen.getByText('Select B'))
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('option', { name: 'Select B' }))
fireEvent.click(screen.getByText('True'))
await user.click(screen.getByText('True'))
expect(onChange).toHaveBeenCalledWith({ model: 'b', agree: false, toggle: 'on' })
expect(onChange).toHaveBeenCalledWith({ model: 'a', agree: true, toggle: 'on' })
@ -989,9 +991,8 @@ describe('Form', () => {
/>,
)
const selectTrigger = screen.getByRole('button', { name: 'Select A' })
fireEvent.click(selectTrigger)
expect(screen.queryByText('Select B')).not.toBeInTheDocument()
const selectTrigger = screen.getByRole('combobox')
expect(selectTrigger).toBeDisabled()
})
// isShowDefaultValue=false: value used even if empty
@ -1899,7 +1900,8 @@ describe('Form', () => {
expect(screen.getByText('Select Tools'))!.toBeInTheDocument()
})
it('should show ValidatingTip for select field being validated', () => {
it('should show ValidatingTip for select field being validated', async () => {
const user = userEvent.setup()
// Arrange: value 'a' is pre-selected so 'Select A' text appears in the trigger button
const formSchemas: AnyFormSchema[] = [
createSelectSchema({
@ -1923,14 +1925,14 @@ describe('Form', () => {
/>,
)
// First click opens the dropdown (Select A is the trigger button text)
fireEvent.click(screen.getByText('Select A'))
// Then click on 'Select B' option in the open dropdown
fireEvent.click(screen.getByText('Select B'))
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('option', { name: 'Select B' }))
// Assert: ValidatingTip shows for the select field
// Assert: ValidatingTip shows for the select field
expect(screen.getByText('Validating...'))!.toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText('Validating...'))!.toBeInTheDocument()
})
})
it('should show ValidatingTip for toolSelector field being validated', () => {

View File

@ -1,7 +1,6 @@
'use client'
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../types'
import type { Item } from '@/app/components/base/select'
import type { InstallState } from '@/app/components/plugins/types'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
@ -22,6 +21,11 @@ import SetURL from './steps/setURL'
const i18nPrefix = 'installFromGitHub'
type SelectOption = {
value: string | number
name: string
}
type InstallFromGitHubProps = {
updatePayload?: UpdateFromGitHubPayload
onClose: () => void
@ -53,12 +57,12 @@ const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ updatePayload, on
const [manifest, setManifest] = useState<PluginDeclaration | null>(null)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const versions: Item[] = state.releases.map(release => ({
const versions: SelectOption[] = state.releases.map(release => ({
value: release.tag_name,
name: release.tag_name,
}))
const packages: Item[] = state.selectedVersion
const packages: SelectOption[] = state.selectedVersion
? (state.releases
.find(release => release.tag_name === state.selectedVersion)
?.assets
@ -198,10 +202,10 @@ const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ updatePayload, on
repoUrl={state.repoUrl}
selectedVersion={state.selectedVersion}
versions={versions}
onSelectVersion={item => setState(prevState => ({ ...prevState, selectedVersion: item.value as string }))}
onSelectVersion={item => setState(prevState => ({ ...prevState, selectedVersion: String(item.value) }))}
selectedPackage={state.selectedPackage}
packages={packages}
onSelectPackage={item => setState(prevState => ({ ...prevState, selectedPackage: item.value as string }))}
onSelectPackage={item => setState(prevState => ({ ...prevState, selectedPackage: String(item.value) }))}
onUploaded={handleUploaded}
onFailed={handleUploadFail}
onBack={handleBack}

View File

@ -1,10 +1,14 @@
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../../types'
import type { Item } from '@/app/components/base/select'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '../../../../types'
import SelectPackage from '../selectPackage'
type SelectOption = {
value: string | number
name: string
}
// Mock upload helper from hooks module
const { mockHandleUpload } = vi.hoisted(() => ({
mockHandleUpload: vi.fn(),
@ -17,6 +21,53 @@ vi.mock('../../../hooks', async (importOriginal) => {
}
})
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
readOnly?: boolean
onValueChange?: (value: string) => void
}>({})
return {
Select: ({ children, readOnly, onValueChange }: {
children: React.ReactNode
readOnly?: boolean
onValueChange?: (value: string) => void
}) => (
<SelectContext.Provider value={{ readOnly, onValueChange }}>
<div>{children}</div>
</SelectContext.Provider>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => {
const context = React.useContext(SelectContext)
return (
<div>
<div data-testid="select-trigger" className={context.readOnly ? 'cursor-not-allowed' : 'cursor-pointer'}>
{children}
</div>
<button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}>
empty select value
</button>
<button data-testid="select-invalid" type="button" onClick={() => context.onValueChange?.('__missing__')}>
invalid select value
</button>
</div>
)
},
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
const context = React.useContext(SelectContext)
return (
<button type="button" onClick={() => context.onValueChange?.(value)}>
{children}
</button>
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
// Factory functions
const createMockManifest = (): PluginDeclaration => ({
plugin_unique_identifier: 'test-uid',
@ -39,12 +90,12 @@ const createMockManifest = (): PluginDeclaration => ({
trigger: {} as PluginDeclaration['trigger'],
})
const createVersions = (): Item[] => [
const createVersions = (): SelectOption[] => [
{ value: 'v1.0.0', name: 'v1.0.0' },
{ value: 'v0.9.0', name: 'v0.9.0' },
]
const createPackages = (): Item[] => [
const createPackages = (): SelectOption[] => [
{ value: 'plugin.zip', name: 'plugin.zip' },
{ value: 'plugin.tar.gz', name: 'plugin.tar.gz' },
]
@ -64,11 +115,11 @@ type TestProps = {
updatePayload?: UpdateFromGitHubPayload
repoUrl?: string
selectedVersion?: string
versions?: Item[]
onSelectVersion?: (item: Item) => void
versions?: SelectOption[]
onSelectVersion?: (item: SelectOption) => void
selectedPackage?: string
packages?: Item[]
onSelectPackage?: (item: Item) => void
packages?: SelectOption[]
onSelectPackage?: (item: SelectOption) => void
onUploaded?: (result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void
onFailed?: (errorMsg: string) => void
onBack?: () => void
@ -80,10 +131,10 @@ describe('SelectPackage', () => {
repoUrl: 'https://github.com/owner/repo',
selectedVersion: '',
versions: createVersions(),
onSelectVersion: vi.fn() as (item: Item) => void,
onSelectVersion: vi.fn() as (item: SelectOption) => void,
selectedPackage: '',
packages: createPackages(),
onSelectPackage: vi.fn() as (item: Item) => void,
onSelectPackage: vi.fn() as (item: SelectOption) => void,
onUploaded: vi.fn() as (result: { uniqueIdentifier: string, manifest: PluginDeclaration }) => void,
onFailed: vi.fn() as (errorMsg: string) => void,
onBack: vi.fn() as () => void,
@ -96,6 +147,14 @@ describe('SelectPackage', () => {
return render(<SelectPackage {...(props as Parameters<typeof SelectPackage>[0])} />)
}
const getSection = (label: string): HTMLElement => {
const labelElement = screen.getByText(label)
const section = labelElement.closest('label')?.nextElementSibling
if (!(section instanceof HTMLElement))
throw new Error(`Missing section for ${label}`)
return section
}
beforeEach(() => {
vi.clearAllMocks()
mockHandleUpload.mockReset()
@ -144,13 +203,13 @@ describe('SelectPackage', () => {
renderSelectPackage({ selectedVersion: 'v1.0.0' })
// PortalSelect should display the selected version
expect(screen.getByText('v1.0.0')).toBeInTheDocument()
expect(screen.getAllByText('v1.0.0').length).toBeGreaterThan(0)
})
it('should pass selectedPackage to PortalSelect', () => {
renderSelectPackage({ selectedPackage: 'plugin.zip' })
expect(screen.getByText('plugin.zip')).toBeInTheDocument()
expect(screen.getAllByText('plugin.zip').length).toBeGreaterThan(0)
})
it('should show installed version badge when updatePayload version differs', () => {
@ -231,6 +290,54 @@ describe('SelectPackage', () => {
expect(mockHandleUpload).not.toHaveBeenCalled()
})
it('should ignore empty and unknown version selections', () => {
const onSelectVersion = vi.fn()
renderSelectPackage({ onSelectVersion })
const section = getSection('plugin.installFromGitHub.selectVersion')
fireEvent.click(within(section).getByTestId('select-empty'))
fireEvent.click(within(section).getByTestId('select-invalid'))
expect(onSelectVersion).not.toHaveBeenCalled()
})
it('should select a valid version option', () => {
const onSelectVersion = vi.fn()
renderSelectPackage({ onSelectVersion })
const section = getSection('plugin.installFromGitHub.selectVersion')
fireEvent.click(within(section).getByRole('button', { name: 'v0.9.0' }))
expect(onSelectVersion).toHaveBeenCalledWith({ value: 'v0.9.0', name: 'v0.9.0' })
})
it('should ignore empty and unknown package selections', () => {
const onSelectPackage = vi.fn()
renderSelectPackage({
selectedVersion: 'v1.0.0',
onSelectPackage,
})
const section = getSection('plugin.installFromGitHub.selectPackage')
fireEvent.click(within(section).getByTestId('select-empty'))
fireEvent.click(within(section).getByTestId('select-invalid'))
expect(onSelectPackage).not.toHaveBeenCalled()
})
it('should select a valid package option', () => {
const onSelectPackage = vi.fn()
renderSelectPackage({
selectedVersion: 'v1.0.0',
onSelectPackage,
})
const section = getSection('plugin.installFromGitHub.selectPackage')
fireEvent.click(within(section).getByRole('button', { name: 'plugin.tar.gz' }))
expect(onSelectPackage).toHaveBeenCalledWith({ value: 'plugin.tar.gz', name: 'plugin.tar.gz' })
})
})
// ================================
@ -424,8 +531,7 @@ describe('SelectPackage', () => {
renderSelectPackage({ selectedVersion: '' })
// When no version is selected, package select should be readonly
// This is tested by verifying the component renders correctly
const trigger = screen.getByText('plugin.installFromGitHub.selectPackagePlaceholder').closest('div')
const trigger = screen.getAllByTestId('select-trigger')[1]
expect(trigger).toHaveClass('cursor-not-allowed')
})
@ -433,7 +539,7 @@ describe('SelectPackage', () => {
renderSelectPackage({ selectedVersion: 'v1.0.0' })
// When version is selected, package select should be active
const trigger = screen.getByText('plugin.installFromGitHub.selectPackagePlaceholder').closest('div')
const trigger = screen.getAllByTestId('select-trigger')[1]
expect(trigger).toHaveClass('cursor-pointer')
})
})

View File

@ -1,24 +1,29 @@
'use client'
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../types'
import type { Item } from '@/app/components/base/select'
import { Button } from '@langgenius/dify-ui/button'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { PortalSelect } from '@/app/components/base/select'
import Badge from '@/app/components/base/badge'
import { handleUpload } from '../../hooks'
const i18nPrefix = 'installFromGitHub'
type SelectOption = {
value: string | number
name: string
}
type SelectPackageProps = {
updatePayload: UpdateFromGitHubPayload
repoUrl: string
selectedVersion: string
versions: Item[]
onSelectVersion: (item: Item) => void
versions: SelectOption[]
onSelectVersion: (item: SelectOption) => void
selectedPackage: string
packages: Item[]
onSelectPackage: (item: Item) => void
packages: SelectOption[]
onSelectPackage: (item: SelectOption) => void
onUploaded: (result: {
uniqueIdentifier: string
manifest: PluginDeclaration
@ -43,6 +48,8 @@ const SelectPackage: React.FC<SelectPackageProps> = ({
const { t } = useTranslation()
const isEdit = Boolean(updatePayload)
const [isUploading, setIsUploading] = React.useState(false)
const selectedVersionOption = versions.find(item => String(item.value) === selectedVersion) ?? null
const selectedPackageOption = packages.find(item => String(item.value) === selectedPackage) ?? null
const handleUploadPackage = async () => {
if (isUploading)
@ -76,30 +83,73 @@ const SelectPackage: React.FC<SelectPackageProps> = ({
>
<span className="system-sm-semibold">{t(`${i18nPrefix}.selectVersion`, { ns: 'plugin' })}</span>
</label>
<PortalSelect
value={selectedVersion}
onSelect={onSelectVersion}
items={versions}
installedValue={updatePayload?.originalPackageInfo.version}
placeholder={t(`${i18nPrefix}.selectVersionPlaceholder`, { ns: 'plugin' }) || ''}
popupClassName="w-[512px] z-1001"
triggerClassName="text-components-input-text-filled"
/>
<Select
value={selectedVersionOption ? String(selectedVersionOption.value) : null}
onValueChange={(value) => {
if (!value)
return
const selectedItem = versions.find(item => String(item.value) === value)
if (selectedItem)
onSelectVersion(selectedItem)
}}
>
<SelectTrigger className="h-9 text-components-input-text-filled">
<div className="flex items-center justify-between gap-2">
<span className="truncate">
{selectedVersionOption?.name ?? t(`${i18nPrefix}.selectVersionPlaceholder`, { ns: 'plugin' }) ?? ''}
</span>
{!!(updatePayload?.originalPackageInfo.version && selectedVersionOption && selectedVersionOption.value !== updatePayload.originalPackageInfo.version) && (
<Badge>
{updatePayload.originalPackageInfo.version}
{' '}
{'->'}
{' '}
{selectedVersionOption.value}
</Badge>
)}
</div>
</SelectTrigger>
<SelectContent popupClassName="z-1001 w-[512px]">
{versions.map(item => (
<SelectItem key={item.value} value={String(item.value)}>
<SelectItemText>{item.name}</SelectItemText>
{item.value === updatePayload?.originalPackageInfo.version && (
<Badge uppercase={true} className="ml-1 shrink-0">INSTALLED</Badge>
)}
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
<label
htmlFor="package"
className="flex flex-col items-start justify-center self-stretch text-text-secondary"
>
<span className="system-sm-semibold">{t(`${i18nPrefix}.selectPackage`, { ns: 'plugin' })}</span>
</label>
<PortalSelect
value={selectedPackage}
onSelect={onSelectPackage}
items={packages}
readonly={!selectedVersion}
placeholder={t(`${i18nPrefix}.selectPackagePlaceholder`, { ns: 'plugin' }) || ''}
popupClassName="w-[512px] z-1001"
triggerClassName="text-components-input-text-filled"
/>
<Select
value={selectedPackageOption ? String(selectedPackageOption.value) : null}
readOnly={!selectedVersion}
onValueChange={(value) => {
if (!value)
return
const selectedItem = packages.find(item => String(item.value) === value)
if (selectedItem)
onSelectPackage(selectedItem)
}}
>
<SelectTrigger className="h-9 text-components-input-text-filled">
{selectedPackageOption?.name ?? t(`${i18nPrefix}.selectPackagePlaceholder`, { ns: 'plugin' }) ?? ''}
</SelectTrigger>
<SelectContent popupClassName="z-1001 w-[512px]">
{packages.map(item => (
<SelectItem key={item.value} value={String(item.value)}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
<div className="mt-4 flex items-center justify-end gap-2 self-stretch">
{!isEdit
&& (

View File

@ -6,38 +6,82 @@ import AppInputsForm from '../app-inputs-form'
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({
onChange,
value,
}: {
onChange: (files: Array<Record<string, unknown>>) => void
}) => (
<button data-testid="file-uploader" onClick={() => onChange([{ id: 'file-1', name: 'demo.png' }])}>
Upload
</button>
),
}))
vi.mock('@/app/components/base/select', () => ({
PortalSelect: ({
items,
onSelect,
}: {
items: Array<{ value: string, name: string }>
onSelect: (item: { value: string }) => void
value: Array<Record<string, unknown>>
}) => (
<div>
{items.map(item => (
<button key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect(item)}>
{item.name}
</button>
))}
<span data-testid="file-uploader-value">{JSON.stringify(value)}</span>
<button data-testid="file-uploader" onClick={() => onChange([{ id: 'file-1', name: 'demo.png' }])}>
Upload
</button>
<button data-testid="file-uploader-empty" onClick={() => onChange([])}>
Upload Empty
</button>
</div>
),
}))
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
onValueChange?: (value: string) => void
}>({})
return {
Select: ({ children, onValueChange }: {
children: React.ReactNode
onValueChange?: (value: string) => void
}) => (
<SelectContext.Provider value={{ onValueChange }}>
<div>{children}</div>
</SelectContext.Provider>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => {
const context = React.useContext(SelectContext)
return (
<div>
<button type="button">{children}</button>
<button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}>
Empty Select
</button>
</div>
)
},
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
const context = React.useContext(SelectContext)
return (
<button key={value} data-testid={`select-${value}`} type="button" onClick={() => context.onValueChange?.(value)}>
{children}
</button>
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
describe('AppInputsForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return null when no form items are provided', () => {
const { container } = render(
<AppInputsForm
inputsForms={[]}
inputs={{}}
inputsRef={{ current: {} }}
onFormChange={vi.fn()}
/>,
)
expect(container.firstChild).toBeNull()
})
it('should update text input values', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { question: '' } }
@ -58,6 +102,26 @@ describe('AppInputsForm', () => {
expect(onFormChange).toHaveBeenCalledWith({ question: 'hello' })
})
it('should update number input values', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { count: '' } }
render(
<AppInputsForm
inputsForms={[{ variable: 'count', label: 'Count', type: InputVarType.number, required: false }]}
inputs={{ count: '' }}
inputsRef={inputsRef}
onFormChange={onFormChange}
/>,
)
fireEvent.change(screen.getByPlaceholderText('Count'), {
target: { value: '42' },
})
expect(onFormChange).toHaveBeenCalledWith({ count: '42' })
})
it('should update select values', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { tone: '' } }
@ -76,6 +140,25 @@ describe('AppInputsForm', () => {
expect(onFormChange).toHaveBeenCalledWith({ tone: 'formal' })
})
it('should ignore empty select values and render the placeholder when there is no current selection', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { tone: '' } }
render(
<AppInputsForm
inputsForms={[{ variable: 'tone', label: 'Tone', type: InputVarType.select, options: ['friendly', 'formal'], required: false }]}
inputs={{ tone: '' }}
inputsRef={inputsRef}
onFormChange={onFormChange}
/>,
)
expect(screen.getAllByText('Tone').length).toBeGreaterThan(0)
fireEvent.click(screen.getByTestId('select-empty'))
expect(onFormChange).not.toHaveBeenCalled()
})
it('should update uploaded single file values', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { attachment: null } }
@ -103,4 +186,83 @@ describe('AppInputsForm', () => {
attachment: { id: 'file-1', name: 'demo.png' },
})
})
it('should update paragraph fields and preserve sibling input values', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { description: 'old', topic: 'existing' } }
render(
<AppInputsForm
inputsForms={[{ variable: 'description', label: 'Description', type: InputVarType.paragraph, required: false }]}
inputs={{ description: '' }}
inputsRef={inputsRef}
onFormChange={onFormChange}
/>,
)
fireEvent.change(screen.getByPlaceholderText('Description'), {
target: { value: 'updated paragraph' },
})
expect(onFormChange).toHaveBeenCalledWith({
description: 'updated paragraph',
topic: 'existing',
})
})
it('should keep multi-file values and forward empty multi-file uploads', () => {
const onFormChange = vi.fn()
const existingFiles = [{ id: 'existing-file', name: 'existing.png' }]
render(
<AppInputsForm
inputsForms={[{
variable: 'files',
label: 'Files',
type: InputVarType.multiFiles,
required: true,
max_length: 3,
allowed_file_types: ['image'],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: ['local_file'],
}]}
inputs={{ files: existingFiles }}
inputsRef={{ current: { files: existingFiles } }}
onFormChange={onFormChange}
/>,
)
expect(screen.getByTestId('file-uploader-value')).toHaveTextContent('"existing-file"')
expect(screen.queryByText('workflow.panel.optional')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('file-uploader-empty'))
expect(onFormChange).toHaveBeenCalledWith({ files: [] })
})
it('should preserve existing single-file values and forward empty single-file uploads as undefined', () => {
const onFormChange = vi.fn()
const existingFile = { id: 'existing-file', name: 'existing.png' }
render(
<AppInputsForm
inputsForms={[{
variable: 'attachment',
label: 'Attachment',
type: InputVarType.singleFile,
required: false,
allowed_file_types: ['image'],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: ['local_file'],
}]}
inputs={{ attachment: existingFile }}
inputsRef={{ current: { attachment: existingFile } }}
onFormChange={onFormChange}
/>,
)
expect(screen.getByTestId('file-uploader-value')).toHaveTextContent('"existing-file"')
fireEvent.click(screen.getByTestId('file-uploader-empty'))
expect(onFormChange).toHaveBeenCalledWith({ attachment: undefined })
})
})

View File

@ -244,28 +244,42 @@ vi.mock('@/app/components/base/file-uploader', () => ({
),
}))
// Mock PortalSelect for testing select field interactions
vi.mock('@/app/components/base/select', () => ({
PortalSelect: ({ onSelect, value, placeholder, items }: {
onSelect: (item: { value: string }) => void
value: string
placeholder: string
items: Array<{ value: string, name: string }>
}) => (
<div data-testid="portal-select">
<span data-testid="select-value">{value || placeholder}</span>
{items?.map((item: { value: string, name: string }) => (
// Mock Select for testing select field interactions
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
onValueChange?: (value: string) => void
}>({})
return {
Select: ({ children, onValueChange }: {
children: React.ReactNode
onValueChange?: (value: string) => void
}) => (
<SelectContext.Provider value={{ onValueChange }}>
<div data-testid="portal-select">{children}</div>
</SelectContext.Provider>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<span data-testid="select-value">{children}</span>
),
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
const context = React.useContext(SelectContext)
return (
<button
key={item.value}
data-testid={`select-option-${item.value}`}
onClick={() => onSelect(item)}
key={value}
data-testid={`select-option-${value}`}
onClick={() => context.onValueChange?.(value)}
>
{item.name}
{children}
</button>
))}
</div>
),
}))
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
// Mock Input component with onClear support
vi.mock('@/app/components/base/input', () => ({

View File

@ -1,8 +1,8 @@
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
import { PortalSelect } from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import { InputVarType } from '@/app/components/workflow/types'
@ -62,14 +62,23 @@ const AppInputsForm = ({
)
}
if (form.type === InputVarType.select) {
const selectOptions: Array<{ value: string, name: string }> = options.map((option: string) => ({ value: option, name: option }))
const selectedOption = selectOptions.find(option => option.value === (inputs[variable] || '')) ?? null
return (
<PortalSelect
popupClassName="w-[356px] z-1050"
value={inputs[variable] || ''}
items={options.map((option: string) => ({ value: option, name: option }))}
onSelect={item => handleFormChange(variable, item.value as string)}
placeholder={label}
/>
<Select value={selectedOption?.value ?? null} onValueChange={value => value && handleFormChange(variable, value)}>
<SelectTrigger className="w-full">
{selectedOption?.name ?? label}
</SelectTrigger>
<SelectContent popupClassName="z-1050 w-(--anchor-width)">
{selectOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
if (form.type === InputVarType.singleFile) {

View File

@ -126,36 +126,77 @@ vi.mock('../oauth-client', () => ({
),
}))
vi.mock('@/app/components/base/select/custom', () => ({
default: ({ options, value, onChange, CustomTrigger, CustomOption, containerProps }: {
options: Array<{ value: string, label: string, show: boolean, extra?: React.ReactNode, tag?: React.ReactNode }>
value: string
onChange: (value: string) => void
CustomTrigger: () => React.ReactNode
CustomOption: (option: { label: string, tag?: React.ReactNode, extra?: React.ReactNode }) => React.ReactNode
containerProps?: { open?: boolean }
}) => (
<div
data-testid="custom-select"
data-value={value}
data-options-count={options?.length || 0}
data-container-open={containerProps?.open}
>
<div data-testid="custom-trigger">{CustomTrigger()}</div>
<div data-testid="options-container">
{options?.map(option => (
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
onValueChange?: (value: string) => void
}>({})
const countOptions = (children: React.ReactNode): number => {
return React.Children.toArray(children).reduce<number>((count, child) => {
if (!React.isValidElement<{ children?: React.ReactNode }>(child))
return count
return count + React.Children.toArray(child.props.children).filter((nestedChild) => {
return React.isValidElement<{ value?: string }>(nestedChild) && 'value' in nestedChild.props
}).length
}, 0)
}
return {
Select: ({
children,
value,
open,
onValueChange,
}: {
children: React.ReactNode
value: string | null
open?: boolean
onValueChange?: (value: string) => void
}) => {
const currentValue = value ?? DEFAULT_METHOD
const optionsCount = countOptions(children)
const containerOpen
= currentValue === DEFAULT_METHOD || (currentValue === SupportedCreationMethods.OAUTH && optionsCount === 1)
? undefined
: String(open ?? false)
return (
<SelectContext.Provider value={{ onValueChange }}>
<div
key={option.value}
data-testid={`option-${option.value}`}
onClick={() => onChange(option.value)}
data-testid="custom-select"
data-value={currentValue}
data-options-count={optionsCount}
data-container-open={containerOpen}
>
{CustomOption(option)}
{children}
</div>
))}
</div>
</div>
),
}))
</SelectContext.Provider>
)
},
SelectTrigger: ({ children, className }: { children: React.ReactNode, render?: React.ReactNode, className?: string }) => {
return <div data-testid="custom-trigger" className={className}>{children}</div>
},
SelectContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="options-container">{children}</div>
),
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
const context = React.useContext(SelectContext)
return (
<div
data-testid={`option-${value}`}
onClick={() => context.onValueChange?.(value)}
>
{children}
</div>
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
const createProviderInfo = (overrides: Partial<TriggerProviderApiEntity> = {}): TriggerProviderApiEntity => ({
author: 'test-author',

View File

@ -1,7 +1,7 @@
import type { Option } from '@/app/components/base/select/custom'
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectTrigger } from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { RiAddLine, RiEqualizer2Line } from '@remixicon/react'
import { useBoolean } from 'ahooks'
@ -9,7 +9,6 @@ import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ActionButton, ActionButtonState } from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge'
import CustomSelect from '@/app/components/base/select/custom'
import Tooltip from '@/app/components/base/tooltip'
import { openOAuthPopup } from '@/hooks/use-oauth'
import { useInitiateTriggerOAuth, useTriggerOAuthConfig, useTriggerProviderInfo } from '@/service/use-triggers'
@ -28,6 +27,14 @@ type Props = {
const MAX_COUNT = 10
type CreateTypeOption = {
value: SupportedCreationMethods
label: string
show: boolean
extra?: React.ReactNode
tag?: React.ReactNode
}
export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BUTTON, shape = 'square' }: Props) => {
const { t } = useTranslation()
const { subscriptions } = useSubscriptionList()
@ -35,6 +42,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
const [selectedCreateInfo, setSelectedCreateInfo] = useState<{ type: SupportedCreationMethods, builder?: TriggerSubscriptionBuilder } | null>(null)
const detail = usePluginStore(state => state.detail)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const { data: providerInfo } = useTriggerProviderInfo(detail?.provider || '')
const supportedMethods = useMemo(() => providerInfo?.supported_creation_methods || [], [providerInfo?.supported_creation_methods])
@ -63,7 +71,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
showClientSettingsModal()
}, [showClientSettingsModal])
const allOptions = useMemo(() => {
const allOptions = useMemo<CreateTypeOption[]>(() => {
const showCustomBadge = oauthConfig?.custom_enabled && oauthConfig?.custom_configured
return [
@ -99,6 +107,10 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
},
]
}, [t, oauthConfig, supportedMethods, methodType, onClickClientSettings])
const visibleOptions = useMemo(() => {
return allOptions.filter(option => option.show)
}, [allOptions])
const shouldAllowSelect = methodType === DEFAULT_METHOD || (methodType === SupportedCreationMethods.OAUTH && supportedMethods.length === 1)
const onChooseCreateType = async (type: SupportedCreationMethods) => {
if (type === SupportedCreationMethods.OAUTH) {
@ -145,24 +157,23 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
return (
<>
<CustomSelect<Option & { show: boolean, extra?: React.ReactNode, tag?: React.ReactNode }>
options={allOptions.filter(option => option.show)}
value={methodType}
onChange={value => onChooseCreateType(value as SupportedCreationMethods)}
containerProps={{
open: (methodType === DEFAULT_METHOD || (methodType === SupportedCreationMethods.OAUTH && supportedMethods.length === 1)) ? undefined : false,
placement: 'bottom-start',
offset: 4,
triggerPopupSameWidth: buttonType === CreateButtonType.FULL_BUTTON,
<Select
value={methodType === DEFAULT_METHOD ? null : methodType}
open={shouldAllowSelect ? isMenuOpen : false}
onOpenChange={setIsMenuOpen}
onValueChange={(value) => {
if (!value)
return
setIsMenuOpen(false)
void onChooseCreateType(value as SupportedCreationMethods)
}}
triggerProps={{
className: cn('h-8 bg-transparent px-0 hover:bg-transparent', methodType !== DEFAULT_METHOD && supportedMethods.length > 1 && 'pointer-events-none', buttonType === CreateButtonType.FULL_BUTTON && 'grow'),
}}
popupProps={{
wrapperClassName: 'z-1000',
}}
CustomTrigger={() => {
return buttonType === CreateButtonType.FULL_BUTTON
>
<SelectTrigger
render={<div />}
nativeButton={false}
className={cn('h-8 border-0 bg-transparent px-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden', buttonType === CreateButtonType.FULL_BUTTON && 'grow')}
>
{buttonType === CreateButtonType.FULL_BUTTON
? (
<Button
variant="primary"
@ -210,18 +221,21 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
<RiAddLine className="size-4" />
</ActionButton>
</Tooltip>
)
}}
CustomOption={option => (
<>
<div className="mr-8 flex grow items-center gap-1 truncate px-1">
{option.label}
{option.tag}
</div>
{option.extra}
</>
)}
/>
)}
</SelectTrigger>
<SelectContent placement="bottom-start" sideOffset={4} popupClassName={cn('z-1000', buttonType === CreateButtonType.FULL_BUTTON && 'min-w-(--anchor-width)')}>
{visibleOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
<div className="mr-8 flex grow items-center gap-1 truncate px-1">
{option.label}
{option.tag}
</div>
{option.extra}
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
{selectedCreateInfo && (
<CommonCreateModal
createType={selectedCreateInfo.type}

View File

@ -153,8 +153,8 @@ vi.mock('@/app/components/plugins/plugin-auth', () => ({
}))
// Portal components need mocking for controlled positioning in tests
vi.mock('@langgenius/dify-ui/popover', () => ({
Popover: ({
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({
children,
open,
}: {
@ -165,7 +165,7 @@ vi.mock('@langgenius/dify-ui/popover', () => ({
{children}
</div>
),
PopoverTrigger: ({
PortalToFollowElemTrigger: ({
children,
render,
onClick,
@ -178,7 +178,7 @@ vi.mock('@langgenius/dify-ui/popover', () => ({
{render ?? children}
</div>
),
PopoverContent: ({ children }: { children: ReactNode }) => (
PortalToFollowElemContent: ({ children }: { children: ReactNode }) => (
<div data-testid="portal-content">{children}</div>
),
}))

View File

@ -1,3 +1,4 @@
import type { ReactNode } from 'react'
import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -7,28 +8,42 @@ import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/typ
import ReasoningConfigForm from '../reasoning-config-form'
vi.mock('@/app/components/base/input', () => ({
default: ({ value, onChange }: { value?: string, onChange: (e: { target: { value: string } }) => void }) => (
<input data-testid="number-input" value={value} onChange={e => onChange({ target: { value: e.target.value } })} />
default: ({ value, onChange, placeholder }: { value?: string, onChange: (e: { target: { value: string } }) => void, placeholder?: string }) => (
<input data-testid="number-input" placeholder={placeholder} value={value} onChange={e => onChange({ target: { value: e.target.value } })} />
),
}))
vi.mock('@/app/components/base/select', () => ({
SimpleSelect: ({
items,
onSelect,
}: {
items: Array<{ value: string, name: string }>
onSelect: (item: { value: string }) => void
}) => (
<div>
{items.map(item => (
<button key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect({ value: item.value })}>
{item.name}
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
onValueChange?: (value: string) => void
}>({})
return {
Select: ({ children, onValueChange }: {
children: React.ReactNode
onValueChange?: (value: string) => void
}) => (
<SelectContext.Provider value={{ onValueChange }}>
<div>{children}</div>
</SelectContext.Provider>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
),
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
const context = React.useContext(SelectContext)
return (
<button key={value} data-testid={`select-${value}`} type="button" onClick={() => context.onValueChange?.(value)}>
{children}
</button>
))}
</div>
),
}))
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
vi.mock('@langgenius/dify-ui/switch', () => ({
Switch: ({ checked, onCheckedChange }: { checked: boolean, onCheckedChange: (checked: boolean) => void }) => (
@ -47,9 +62,10 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({
default: ({ onSelect }: { onSelect: (value: Record<string, unknown>) => void }) => (
default: ({ onSelect, scope }: { onSelect: (value: Record<string, unknown>) => void, scope?: string }) => (
<button
data-testid="app-selector"
data-scope={scope}
onClick={() => onSelect({ app_id: 'app-1', inputs: { topic: 'hello' } })}
>
Select App
@ -66,10 +82,13 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ onChange }: { onChange: (value: string) => void }) => (
<button data-testid="code-editor" onClick={() => onChange('{"foo":"bar"}')}>
Update JSON
</button>
default: ({ onChange, placeholder }: { onChange: (value: string) => void, placeholder?: ReactNode }) => (
<div>
<div data-testid="code-editor-placeholder">{placeholder}</div>
<button data-testid="code-editor" onClick={() => onChange('{"foo":"bar"}')}>
Update JSON
</button>
</div>
),
}))
@ -90,8 +109,8 @@ vi.mock('@/app/components/workflow/nodes/_base/components/form-input-type-switch
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ onChange }: { onChange: (value: string) => void }) => (
<button data-testid="var-picker" onClick={() => onChange(['node', 'field'] as unknown as string)}>
default: ({ onChange, value }: { onChange: (value: string) => void, value: string | string[] }) => (
<button data-testid="var-picker" data-value={JSON.stringify(value)} onClick={() => onChange(['node', 'field'] as unknown as string)}>
Pick Variable
</button>
),
@ -337,4 +356,198 @@ describe('ReasoningConfigForm', () => {
},
})
})
it('should update number, boolean, and select fields', () => {
const onChange = vi.fn()
render(
<ReasoningConfigForm
value={{
count: {
auto: 0,
value: { type: VarKindType.constant, value: '' },
},
enabled: {
auto: 0,
value: { type: VarKindType.constant, value: false },
},
choice: {
auto: 0,
value: { type: VarKindType.constant, value: '' },
},
}}
onChange={onChange}
schemas={[
createSchema({
variable: 'count',
type: FormTypeEnum.textNumber,
label: { en_US: 'Count', zh_Hans: '数量' },
placeholder: { en_US: 'Enter count', zh_Hans: '输入数量' },
}),
createSchema({
variable: 'enabled',
type: FormTypeEnum.checkbox,
label: { en_US: 'Enabled', zh_Hans: '启用' },
}),
createSchema({
variable: 'choice',
type: FormTypeEnum.select,
label: { en_US: 'Choice', zh_Hans: '选择' },
placeholder: { en_US: 'Pick one', zh_Hans: '选择一个' },
options: [
{
value: 'alpha',
label: { en_US: 'Alpha', zh_Hans: 'Alpha' },
show_on: [],
},
{
value: 'beta',
label: { en_US: 'Beta', zh_Hans: 'Beta' },
show_on: [],
},
],
}),
]}
nodeOutputVars={[]}
availableNodes={[]}
nodeId="node-1"
/>,
)
expect(screen.getByText('Pick one')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Enter count')).toBeInTheDocument()
fireEvent.change(screen.getByTestId('number-input'), { target: { value: '7' } })
fireEvent.click(screen.getByTestId('boolean-input'))
fireEvent.click(screen.getByTestId('select-beta'))
expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({
count: {
auto: 0,
value: { type: VarKindType.constant, value: '7' },
},
}))
expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({
enabled: {
auto: 0,
value: { type: VarKindType.constant, value: true },
},
}))
expect(onChange).toHaveBeenNthCalledWith(3, expect.objectContaining({
choice: {
auto: 0,
value: { type: VarKindType.constant, value: 'beta' },
},
}))
})
it('should render selected select values and update object json fields', () => {
const onChange = vi.fn()
render(
<ReasoningConfigForm
value={{
config: {
auto: 0,
value: { type: VarKindType.constant, value: '{}' },
},
choice: {
auto: 0,
value: { type: VarKindType.constant, value: 'alpha' },
},
}}
onChange={onChange}
schemas={[
createSchema({
variable: 'config',
type: FormTypeEnum.object,
input_schema: { type: Type.object, properties: {}, additionalProperties: false },
placeholder: { en_US: '{\n "foo": "bar"\n}', zh_Hans: '{\n "foo": "bar"\n}' },
}),
createSchema({
variable: 'choice',
type: FormTypeEnum.select,
placeholder: { en_US: 'Pick one', zh_Hans: '选择一个' },
options: [
{
value: 'alpha',
label: { en_US: 'Alpha', zh_Hans: 'Alpha' },
show_on: [],
},
{
value: 'beta',
label: { en_US: 'Beta', zh_Hans: 'Beta' },
show_on: [],
},
],
}),
]}
nodeOutputVars={[]}
availableNodes={[]}
nodeId="node-1"
/>,
)
expect(screen.getAllByText('Alpha').length).toBeGreaterThan(0)
expect(screen.getByTestId('code-editor-placeholder')).toHaveTextContent('"foo": "bar"')
fireEvent.click(screen.getByTestId('code-editor'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
config: {
auto: 0,
value: { type: VarKindType.constant, value: '{"foo":"bar"}' },
},
}))
})
it('should render json placeholders, default app scope, variable links, and helper urls', () => {
const onChange = vi.fn()
render(
<ReasoningConfigForm
value={{
config: {
auto: 0,
value: { type: VarKindType.constant, value: '{}' },
},
app: {
auto: 0,
value: { type: VarKindType.constant, value: null },
},
files: {
auto: 0,
value: { type: VarKindType.variable, value: '' },
},
}}
onChange={onChange}
schemas={[
createSchema({
variable: 'config',
type: FormTypeEnum.object,
input_schema: { type: Type.object, properties: {}, additionalProperties: false },
placeholder: { en_US: '{\n "foo": "bar"\n}', zh_Hans: '{\n "foo": "bar"\n}' },
}),
createSchema({
variable: 'app',
type: FormTypeEnum.appSelector,
scope: '' as never,
}),
createSchema({
variable: 'files',
type: FormTypeEnum.files,
url: 'https://example.com/help',
}),
]}
nodeOutputVars={[]}
availableNodes={[]}
nodeId="node-1"
/>,
)
expect(screen.getByTestId('code-editor-placeholder')).toHaveTextContent('"foo": "bar"')
expect(screen.getByTestId('app-selector')).toHaveAttribute('data-scope', 'all')
expect(screen.getByTestId('var-picker')).toHaveAttribute('data-value', '[]')
expect(screen.getByRole('link', { name: 'tools.howToGet' })).toHaveAttribute('href', 'https://example.com/help')
})
})

View File

@ -7,6 +7,7 @@ import type {
ValueSelector,
} from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Switch } from '@langgenius/dify-ui/switch'
import {
RiArrowRightUpLine,
@ -16,7 +17,7 @@ import { useBoolean } from 'ahooks'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { SimpleSelect } from '@/app/components/base/select'
// eslint-disable-next-line no-restricted-imports -- legacy tooltip migration is handled separately from this change
import Tooltip from '@/app/components/base/tooltip'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
@ -156,6 +157,9 @@ const ReasoningConfigForm: React.FC<Props> = ({
language,
schema,
})
const selectedOption = isSelect && options
? pickerProps.selectItems.find(item => item.value === (varInput?.value as string | number | undefined)) ?? null
: null
return (
<div key={variable} className="space-y-0.5">
@ -225,13 +229,19 @@ const ReasoningConfigForm: React.FC<Props> = ({
/>
)}
{isSelect && options && (
<SimpleSelect
wrapperClassName="h-8 grow"
defaultValue={varInput?.value as string | number | undefined}
items={pickerProps.selectItems}
onSelect={item => handleValueChange(variable, type)(item.value as string)}
placeholder={placeholder?.[language] || placeholder?.en_US}
/>
<Select value={selectedOption ? String(selectedOption.value) : null} onValueChange={value => value && handleValueChange(variable, type)(value)}>
<SelectTrigger className="h-8 grow">
{selectedOption?.name ?? placeholder?.[language] ?? placeholder?.en_US}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{pickerProps.selectItems.map(item => (
<SelectItem key={item.value} value={String(item.value)}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)}
{isShowJSONEditor && isConstant && (
<div className="mt-1 w-full">

View File

@ -8,13 +8,14 @@ import type { Node } from 'reactflow'
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
// eslint-disable-next-line no-restricted-imports -- legacy overlay migration is handled separately from this change
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { CollectionType } from '@/app/components/tools/types'
import Link from '@/next/link'
import {
@ -102,21 +103,15 @@ const ToolSelector: FC<Props> = ({
getSettingsValue,
} = state
const handleTriggerClick = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault()
const handleTriggerClick = () => {
if (disabled)
return
if (!currentProvider || !currentTool)
return
setIsShow(true)
}
// Determine portal open state based on controlled vs uncontrolled mode
const portalOpen = trigger ? controlledState : isShow
const onPortalOpenChange = trigger ? onControlledStateChange : setIsShow
const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
// Build error tooltip content
const renderErrorTip = () => (
@ -140,58 +135,57 @@ const ToolSelector: FC<Props> = ({
)
return (
<Popover
<PortalToFollowElem
placement={placement}
offset={offset}
open={portalOpen}
onOpenChange={onPortalOpenChange}
>
<PopoverTrigger
render={(
<div className="w-full">
{trigger}
{/* Default trigger - no value */}
{!trigger && !value?.provider_name && (
<ToolTrigger
isConfigure
open={isShow}
value={value}
provider={currentProvider}
/>
)}
{/* Default trigger - with value */}
{!trigger && value?.provider_name && (
<ToolItem
open={isShow}
icon={currentProvider?.icon || manifestIcon}
isMCPTool={currentProvider?.type === CollectionType.mcp}
providerName={value.provider_name}
providerShowName={value.provider_show_name}
toolLabel={value.tool_label || value.tool_name}
showSwitch={supportEnableSwitch}
switchValue={value.enabled}
onSwitchChange={handleEnabledChange}
onDelete={onDelete}
noAuth={currentProvider && currentTool && !currentProvider.is_team_authorization}
uninstalled={!currentProvider && inMarketPlace}
versionMismatch={currentProvider && inMarketPlace && !currentTool}
installInfo={manifest?.latest_package_identifier}
onInstall={handleInstall}
isError={(!currentProvider || !currentTool) && !inMarketPlace}
errorTip={renderErrorTip()}
/>
)}
</div>
)}
onClick={handleTriggerClick}
/>
<PopoverContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
<PortalToFollowElemTrigger
className="w-full"
onClick={() => {
if (!currentProvider || !currentTool)
return
handleTriggerClick()
}}
>
{trigger}
{/* Default trigger - no value */}
{!trigger && !value?.provider_name && (
<ToolTrigger
isConfigure
open={isShow}
value={value}
provider={currentProvider}
/>
)}
{/* Default trigger - with value */}
{!trigger && value?.provider_name && (
<ToolItem
open={isShow}
icon={currentProvider?.icon || manifestIcon}
isMCPTool={currentProvider?.type === CollectionType.mcp}
providerName={value.provider_name}
providerShowName={value.provider_show_name}
toolLabel={value.tool_label || value.tool_name}
showSwitch={supportEnableSwitch}
switchValue={value.enabled}
onSwitchChange={handleEnabledChange}
onDelete={onDelete}
noAuth={currentProvider && currentTool && !currentProvider.is_team_authorization}
uninstalled={!currentProvider && inMarketPlace}
versionMismatch={currentProvider && inMarketPlace && !currentTool}
installInfo={manifest?.latest_package_identifier}
onInstall={handleInstall}
isError={(!currentProvider || !currentTool) && !inMarketPlace}
errorTip={renderErrorTip()}
/>
)}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
<div className={cn(
'relative max-h-[642px] min-h-20 w-[361px] rounded-xl',
'border-[0.5px] border-components-panel-border bg-components-panel-bg-blur',
@ -246,8 +240,8 @@ const ToolSelector: FC<Props> = ({
onParamsFormChange={handleParamsFormChange}
/>
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@ -6,6 +6,7 @@ import type { SiteInfo } from '@/models/share'
import type { VisionFile, VisionSettings } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import {
RiLoader2Line,
RiPlayLargeLine,
@ -17,7 +18,6 @@ import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uplo
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
import Input from '@/app/components/base/input'
import Select from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
@ -128,12 +128,25 @@ const RunOnce: FC<IRunOnceProps> = ({
<div className="mt-1">
{item.type === 'select' && (
<Select
className="w-full"
defaultValue={inputs[item.key] as (string | number | undefined)}
onSelect={(i) => { handleInputsChange({ ...inputsRef.current, [item.key]: i.value }) }}
items={(item.options || []).map(i => ({ name: i, value: i }))}
allowSearch={false}
/>
value={inputs[item.key] ? String(inputs[item.key]) : null}
onValueChange={(nextValue) => {
if (!nextValue)
return
handleInputsChange({ ...inputsRef.current, [item.key]: nextValue })
}}
>
<SelectTrigger className="w-full">
{String(inputs[item.key] || item.default || t('placeholder.select', { ns: 'common' }))}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{(item.options || []).map(option => (
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)}
{item.type === 'string' && (
<Input

View File

@ -8,17 +8,18 @@ import type { ToolDefaultValue, ToolValue } from './types'
import type { CustomCollectionBackend } from '@/app/components/tools/types'
import type { BlockEnum, OnSelectBlock } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
// eslint-disable-next-line no-restricted-imports -- legacy overlay migration is handled separately from this change
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import SearchBox from '@/app/components/plugins/marketplace/search-box'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
import AllTools from '@/app/components/workflow/block-selector/all-tools'
@ -43,7 +44,7 @@ type Props = {
disabled: boolean
trigger: React.ReactNode
placement?: Placement
offset?: OffsetOptions | number
offset?: OffsetOptions
isShow: boolean
onShowChange: (isShow: boolean) => void
onSelect: (tool: ToolDefaultValue) => void
@ -120,6 +121,12 @@ const ToolPicker: FC<Props> = ({
const handleAddedCustomTool = invalidateCustomTools
const handleTriggerClick = () => {
if (disabled)
return
onShowChange(true)
}
const handleSelect = (_type: BlockEnum, tool?: ToolDefaultValue) => {
onSelect(tool!)
}
@ -133,11 +140,6 @@ const ToolPicker: FC<Props> = ({
setTrue: showEditCustomCollectionModal,
}] = useBoolean(false)
const handleShowAddCustomCollectionModal = useCallback(() => {
onShowChange(false)
showEditCustomCollectionModal()
}, [onShowChange, showEditCustomCollectionModal])
const doCreateCustomToolCollection = async (data: CustomCollectionBackend) => {
await createCustomCollection(data)
toast.success(t('api.actionSuccess', { ns: 'common' }))
@ -156,35 +158,20 @@ const ToolPicker: FC<Props> = ({
)
}
const resolvedTrigger = React.isValidElement(trigger) ? trigger : <div>{trigger}</div>
const resolvedOffset = typeof offset === 'object' && offset !== null
? offset as { mainAxis?: number, crossAxis?: number, alignmentAxis?: number | null }
: undefined
const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
return (
<Popover
<PortalToFollowElem
placement={placement}
offset={offset}
open={isShow}
onOpenChange={(nextOpen) => {
if (disabled && nextOpen)
return
onShowChange(nextOpen)
}}
onOpenChange={onShowChange}
>
<PopoverTrigger
render={resolvedTrigger}
onClick={(e) => {
if (disabled)
e.preventDefault()
}}
/>
<PopoverContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
<PortalToFollowElemTrigger
onClick={handleTriggerClick}
>
{trigger}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1002">
<div className={cn('relative min-h-20 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs', panelClassName)}>
<div className="p-2 pb-1">
<SearchBox
@ -195,7 +182,7 @@ const ToolPicker: FC<Props> = ({
placeholder={t('searchTools', { ns: 'plugin' })!}
supportAddCustomTool={supportAddCustomTool}
onAddedCustomTool={handleAddedCustomTool}
onShowAddCustomCollectionModal={handleShowAddCustomCollectionModal}
onShowAddCustomCollectionModal={showEditCustomCollectionModal}
inputClassName="grow"
/>
</div>
@ -223,8 +210,8 @@ const ToolPicker: FC<Props> = ({
}}
/>
</div>
</PopoverContent>
</Popover>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@ -194,7 +194,7 @@ describe('FormInputItem branches', () => {
},
})
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByRole('combobox'))
expect(document.querySelector('img[src="/basic.svg"]')).toBeInTheDocument()
fireEvent.click(screen.getByText('basic'))
@ -261,7 +261,10 @@ describe('FormInputItem branches', () => {
expect(mockFetchDynamicOptions).toHaveBeenCalledTimes(1)
})
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByRole('combobox')).not.toBeDisabled()
})
fireEvent.click(screen.getByRole('combobox'))
expect(document.querySelector('img[src="/remote.svg"]')).toBeInTheDocument()
fireEvent.click(screen.getByText('remote'))

View File

@ -3,6 +3,7 @@ import type { FC } from 'react'
import type { InputVar } from '../../../../types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import {
RiDeleteBinLine,
} from '@remixicon/react'
@ -17,7 +18,6 @@ import { Variable02 } from '@/app/components/base/icons/src/vender/solid/develop
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
import Input from '@/app/components/base/input'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import Select from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import { VarBlockIcon } from '@/app/components/workflow/block-icon'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
@ -181,12 +181,25 @@ const FormItem: FC<Props> = ({
{
type === InputVarType.select && (
<Select
className="w-full"
defaultValue={value || payload.default || ''}
items={payload.options?.map(option => ({ name: option, value: option })) || []}
onSelect={i => onChange(i.value)}
allowSearch={false}
/>
value={value || payload.default || null}
onValueChange={(nextValue) => {
if (!nextValue)
return
onChange(nextValue)
}}
>
<SelectTrigger className="w-full">
{String(value || payload.default || t('placeholder.select', { ns: 'common' }))}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{(payload.options || []).map(option => (
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View File

@ -6,10 +6,10 @@ import type { Event, Tool } from '@/app/components/tools/types'
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import type { ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { useEffect, useMemo, useState } from 'react'
import CheckboxList from '@/app/components/base/checkbox-list'
import Input from '@/app/components/base/input'
import { SimpleSelect } from '@/app/components/base/select'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import AppSelector from '@/app/components/plugins/plugin-detail-panel/app-selector'
import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector'
@ -32,7 +32,6 @@ import {
getSelectedLabels,
getTargetVarType,
getVarKindType,
hasOptionIcon,
mapSelectItems,
normalizeVariableSelectorValue,
} from './form-input-item.helpers'
@ -47,17 +46,19 @@ type Props = {
nodeId: string
schema: CredentialFormSchema
value: ResourceVarInputs
onChange: (value: any) => void
onChange: (value: ResourceVarInputs) => void
inPanel?: boolean
currentTool?: Tool | Event
currentProvider?: ToolWithProvider | TriggerWithProvider
showManageInputField?: boolean
onManageInputField?: () => void
extraParams?: Record<string, any>
extraParams?: Record<string, unknown>
providerType?: string
disableVariableInsertion?: boolean
}
type FormInputValue = string | number | boolean | string[] | Record<string, unknown> | null | undefined
const FormInputItem: FC<Props> = ({
readOnly,
nodeId,
@ -195,22 +196,25 @@ const FormInputItem: FC<Props> = ({
}
}
const handleValueChange = (newValue: any) => {
const handleValueChange = (newValue: FormInputValue) => {
const nextType = getVarKindType(formState) ?? varInput?.type ?? VarKindType.constant
onChange({
...value,
[variable]: {
...varInput,
type: getVarKindType(formState),
value: isNumber ? Number.parseFloat(newValue) : newValue,
type: nextType,
value: isNumber ? Number.parseFloat(String(newValue ?? '')) : newValue,
},
})
}
const handleAppOrModelSelect = (newValue: any) => {
const handleAppOrModelSelect = (newValue: Record<string, unknown>) => {
const nextType = getVarKindType(formState) ?? varInput?.type ?? VarKindType.constant
onChange({
...value,
[variable]: {
...varInput,
type: nextType,
value: newValue,
},
})
@ -271,6 +275,8 @@ const FormInputItem: FC<Props> = ({
},
})
}
const selectedStaticOption = staticSelectItems.find(item => item.value === (varInput?.value as string | undefined)) ?? null
const selectedDynamicOption = dynamicSelectItems.find(item => item.value === (varInput?.value as string | undefined)) ?? null
return (
<div className={cn('gap-1', !(isShowJSONEditor && isConstant) && 'flex')}>
@ -315,24 +321,26 @@ const FormInputItem: FC<Props> = ({
/>
)}
{isSelect && isConstant && !isMultipleSelect && (
<SimpleSelect
wrapperClassName="h-8 grow"
<Select
value={selectedStaticOption?.value ?? null}
disabled={readOnly}
defaultValue={varInput?.value as string | undefined}
items={staticSelectItems}
onSelect={item => handleValueChange(item.value as string)}
placeholder={placeholder?.[language] || placeholder?.en_US}
renderOption={hasOptionIcon(visibleSelectOptions)
? ({ item }) => (
<div className="flex items-center">
{item.icon && (
<img src={item.icon} alt="" className="mr-2 h-4 w-4" />
)}
<span>{item.name}</span>
</div>
)
: undefined}
/>
onValueChange={value => value && handleValueChange(value)}
>
<SelectTrigger className="h-8 grow">
{selectedStaticOption?.name ?? placeholder?.[language] ?? placeholder?.en_US}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{staticSelectItems.map(item => (
<SelectItem key={item.value} value={item.value}>
{item.icon && (
<img src={item.icon} alt="" className="mr-2 h-4 w-4 shrink-0" />
)}
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)}
{isSelect && isConstant && isMultipleSelect && (
<MultiSelectField
@ -345,22 +353,26 @@ const FormInputItem: FC<Props> = ({
/>
)}
{isDynamicSelect && !isMultipleSelect && (
<SimpleSelect
wrapperClassName="h-8 grow"
<Select
value={selectedDynamicOption?.value ?? null}
disabled={readOnly || isLoadingOptions}
defaultValue={varInput?.value as string | undefined}
items={dynamicSelectItems}
onSelect={item => handleValueChange(item.value as string)}
placeholder={isLoadingOptions ? 'Loading...' : (placeholder?.[language] || placeholder?.en_US)}
renderOption={({ item }) => (
<div className="flex items-center">
{item.icon && (
<img src={item.icon} alt="" className="mr-2 h-4 w-4" />
)}
<span>{item.name}</span>
</div>
)}
/>
onValueChange={value => value && handleValueChange(value)}
>
<SelectTrigger className="h-8 grow">
{selectedDynamicOption?.name ?? (isLoadingOptions ? 'Loading...' : (placeholder?.[language] ?? placeholder?.en_US))}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{dynamicSelectItems.map(item => (
<SelectItem key={item.value} value={item.value}>
{item.icon && (
<img src={item.icon} alt="" className="mr-2 h-4 w-4 shrink-0" />
)}
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)}
{isDynamicSelect && isMultipleSelect && (
<MultiSelectField

View File

@ -2,9 +2,9 @@
import type { FC } from 'react'
import type { CredentialFormSchema, CredentialFormSchemaNumberInput, CredentialFormSchemaSelect } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Var } from '@/app/components/workflow/types'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import * as React from 'react'
import { useCallback } from 'react'
import { SimpleSelect } from '@/app/components/base/select'
import { useCallback, useMemo } from 'react'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
@ -30,6 +30,18 @@ const ConstantField: FC<Props> = ({
}) => {
const language = useLanguage()
const placeholder = (schema as CredentialFormSchemaSelect).placeholder
const selectOptions = useMemo(() => {
if (schema.type !== FormTypeEnum.select && schema.type !== FormTypeEnum.dynamicSelect)
return []
return (schema as CredentialFormSchemaSelect).options.map(option => ({
value: String(option.value),
name: option.label[language] || option.label.en_US,
}))
}, [language, schema])
const selectedOption = useMemo(() => {
return selectOptions.find(option => option.value === String(value)) ?? null
}, [selectOptions, value])
const handleStaticChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value === '' ? '' : Number.parseFloat(e.target.value)
onChange(value, VarKindType.constant)
@ -42,17 +54,27 @@ const ConstantField: FC<Props> = ({
return (
<>
{(schema.type === FormTypeEnum.select || schema.type === FormTypeEnum.dynamicSelect) && (
<SimpleSelect
wrapperClassName="w-full h-8!"
className="flex items-center"
disabled={readonly}
defaultValue={value}
items={(schema as CredentialFormSchemaSelect).options.map(option => ({ value: option.value, name: option.label[language] || option.label.en_US }))}
onSelect={item => handleSelectChange(item.value)}
placeholder={placeholder?.[language] || placeholder?.en_US}
<Select
value={selectedOption?.value ?? null}
disabled={readonly || isLoading}
onValueChange={nextValue => nextValue && handleSelectChange(nextValue)}
onOpenChange={onOpenChange}
isLoading={isLoading}
/>
>
<SelectTrigger
className="h-8 w-full"
disabled={readonly || isLoading}
>
{selectedOption?.name ?? placeholder?.[language] ?? placeholder?.en_US}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{selectOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)}
{schema.type === FormTypeEnum.textNumber && (
<input

View File

@ -15,6 +15,7 @@ import type {
Var,
} from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { RiDeleteBinLine } from '@remixicon/react'
import { produce } from 'immer'
import {
@ -24,7 +25,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { SimpleSelect as Select } from '@/app/components/base/select'
import { useIsChatMode } from '@/app/components/workflow/hooks/use-workflow'
import { getVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import BoolValue from '@/app/components/workflow/panel/chat-variable-panel/components/bool-value'
@ -164,7 +164,7 @@ const ConditionItem = ({
}, [condition, doUpdateCondition, isArrayValue])
const isSelect = condition.comparison_operator && [ComparisonOperator.in, ComparisonOperator.notIn].includes(condition.comparison_operator)
const selectOptions = useMemo(() => {
const selectOptions = useMemo<Array<{ name: string, value: string }>>(() => {
if (isSelect) {
if (fileAttr?.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) {
return FILE_TYPE_OPTIONS.map(item => ({
@ -190,6 +190,7 @@ const ConditionItem = ({
name: item,
value: item,
}))
const selectedSubVarOption = subVarOptions.find(item => item.value === condition.key) ?? null
const handleSubVarKeyChange = useCallback((key: string) => {
const newCondition = produce(condition, (draft) => {
@ -257,6 +258,9 @@ const ConditionItem = ({
return true
return false
}, [condition])
const selectedSelectValue = isArrayValue ? (condition.value as string[])?.[0] : (condition.value as string)
const selectedSelectOption = selectOptions.find(item => item.value === selectedSelectValue) ?? null
return (
<div className={cn('mb-1 flex last-of-type:mb-0', className)}>
<div className={cn(
@ -269,26 +273,38 @@ const ConditionItem = ({
{isSubVarSelect
? (
<Select
wrapperClassName="h-6"
className="pl-0 text-xs"
optionWrapClassName="w-[165px] max-h-none"
defaultValue={condition.key}
items={subVarOptions}
onSelect={item => handleSubVarKeyChange(item.value as string)}
renderTrigger={item => (
item
value={selectedSubVarOption?.value ?? null}
onValueChange={value => value && handleSubVarKeyChange(value)}
>
<SelectTrigger
render={<div />}
nativeButton={false}
className="h-6 border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden"
>
{selectedSubVarOption
? (
<div className="flex cursor-pointer justify-start">
<div className="inline-flex h-6 max-w-full items-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark px-1.5 text-text-accent shadow-xs">
<Variable02 className="h-3.5 w-3.5 shrink-0 text-text-accent" />
<div className="ml-0.5 truncate system-xs-medium">{item?.name}</div>
<div className="ml-0.5 truncate system-xs-medium">{selectedSubVarOption.name}</div>
</div>
</div>
)
: <div className="text-left system-sm-regular text-components-input-text-placeholder">{t('placeholder.select', { ns: 'common' })}</div>
)}
hideChecked
/>
: <div className="text-left system-sm-regular text-components-input-text-placeholder">{t('placeholder.select', { ns: 'common' })}</div>}
</SelectTrigger>
<SelectContent popupClassName="w-[165px]" listClassName="max-h-none p-1">
{subVarOptions.map(option => (
<SelectItem key={option.value} value={option.value} className="h-8 py-0 pr-5 pl-1">
<div className="flex h-6 items-center justify-between">
<div className="flex h-full items-center">
<Variable02 className="mr-[5px] h-3.5 w-3.5 text-text-accent" />
<SelectItemText className="mr-0 px-0 system-sm-medium text-text-secondary">{option.name}</SelectItemText>
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)
: (
<ConditionVarSelector
@ -353,15 +369,18 @@ const ConditionItem = ({
{
!comparisonOperatorNotRequireValue(condition.comparison_operator) && isSelect && (
<div className="border-t border-t-divider-subtle">
<Select
wrapperClassName="h-8"
className="rounded-t-none px-2 text-xs"
defaultValue={isArrayValue ? (condition.value as string[])?.[0] : (condition.value as string)}
items={selectOptions}
onSelect={item => handleUpdateConditionValue(item.value as string)}
hideChecked
notClearable
/>
<Select value={selectedSelectOption?.value ?? null} onValueChange={value => value && handleUpdateConditionValue(value)}>
<SelectTrigger className="h-8 rounded-t-none border-0 px-2 text-xs hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal">
{selectedSelectOption?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{selectOptions.map(option => (
<SelectItem key={option.value} value={option.value} className="text-xs">
<SelectItemText>{option.name}</SelectItemText>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View File

@ -4,6 +4,7 @@ import type { Node, NodeOutPutVar, Var } from '../../../types'
import type { CaseItem, HandleAddCondition, HandleAddSubVariableCondition, HandleRemoveCondition, handleRemoveSubVariableCondition, HandleToggleConditionLogicalOperator, HandleToggleSubVariableConditionLogicalOperator, HandleUpdateCondition, HandleUpdateSubVariableCondition } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import {
RiAddLine,
RiDeleteBinLine,
@ -14,7 +15,6 @@ import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ReactSortable } from 'react-sortablejs'
import { PortalSelect as Select } from '@/app/components/base/select'
import { VarType } from '../../../types'
import { SUB_VARIABLES } from '../../constants'
import { useGetAvailableVars } from '../../variable-assigner/hooks'
@ -167,11 +167,15 @@ const ConditionWrap: FC<Props> = ({
{isSubVariable
? (
<Select
popupInnerClassName="w-[165px] max-h-none"
onSelect={value => handleAddSubVariableCondition?.(caseId!, conditionId!, value.value as string)}
items={subVarOptions}
value=""
renderTrigger={() => (
value={null}
disabled={readOnly}
onValueChange={value => value && handleAddSubVariableCondition?.(caseId!, conditionId!, value)}
>
<SelectTrigger
render={<div />}
nativeButton={false}
className="border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden"
>
<Button
size="small"
disabled={readOnly}
@ -179,9 +183,15 @@ const ConditionWrap: FC<Props> = ({
<RiAddLine className="mr-1 h-3.5 w-3.5" />
{t('nodes.ifElse.addSubVariable', { ns: 'workflow' })}
</Button>
)}
hideChecked
/>
</SelectTrigger>
<SelectContent popupClassName="w-[165px]" listClassName="max-h-none p-1">
{subVarOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.name}</SelectItemText>
</SelectItem>
))}
</SelectContent>
</Select>
)
: (
<ConditionAdd

View File

@ -237,8 +237,8 @@ describe('iteration path', () => {
await user.click(screen.getByRole('button', { name: 'pick-output-var' }))
await user.click(screen.getAllByRole('switch')[0]!)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '7' } })
await user.click(screen.getByRole('button', { name: /workflow.nodes.iteration.ErrorMethod.operationTerminated/i }))
await user.click(screen.getByText('workflow.nodes.iteration.ErrorMethod.continueOnError'))
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('option', { name: 'workflow.nodes.iteration.ErrorMethod.continueOnError' }))
await user.click(screen.getAllByRole('switch')[1]!)
expect(handleInputChange).toHaveBeenCalledWith(['node-1', 'items'], 'variable', { type: VarType.arrayString })

View File

@ -1,5 +1,4 @@
import type { IterationNodeType } from '../types'
import type { Item } from '@/app/components/base/select'
import type { Var } from '@/app/components/workflow/types'
import { act, renderHook } from '@testing-library/react'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
@ -73,6 +72,11 @@ const createVar = (type: VarType, variable = 'test.variable'): Var => ({
type,
})
type SelectOption = {
name: string
value: string | number
}
describe('iteration/use-config', () => {
const mockSetInputs = vi.fn()
const mockDeleteNodeInspectorVars = vi.fn()
@ -148,7 +152,7 @@ describe('iteration/use-config', () => {
it('should update parallel, error-mode, and flatten options', () => {
const { result } = renderHook(() => useConfig('iteration-node', currentInputs))
const item: Item = { name: 'Continue', value: ErrorHandleMode.ContinueOnError }
const item: SelectOption = { name: 'Continue', value: ErrorHandleMode.ContinueOnError }
act(() => {
result.current.changeParallel(true)
@ -170,4 +174,52 @@ describe('iteration/use-config', () => {
flatten_output: true,
}))
})
it('should fall back to empty selectors and empty plugin lists when metadata is missing', () => {
mockUseStore.mockReturnValue(undefined)
mockUseAllBuiltInTools.mockReturnValue({ data: undefined })
mockUseAllCustomTools.mockReturnValue({ data: undefined })
mockUseAllWorkflowTools.mockReturnValue({ data: undefined })
mockUseAllMCPTools.mockReturnValue({ data: undefined })
const { result } = renderHook(() => useConfig('iteration-node', currentInputs))
expect(mockToNodeOutputVars).toHaveBeenCalledWith(
[{ id: 'child-node' }],
false,
undefined,
[],
[],
[],
{
buildInTools: [],
customTools: [],
workflowTools: [],
mcpTools: [],
dataSourceList: [],
},
)
act(() => {
result.current.handleInputChange('', VarKindType.variable)
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
iterator_selector: [],
iterator_input_type: VarType.arrayString,
}))
mockSetInputs.mockClear()
mockDeleteNodeInspectorVars.mockClear()
act(() => {
result.current.handleOutputVarChange('', VarKindType.variable, createVar(VarType.boolean, 'child.flag'))
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
output_selector: [],
output_type: VarType.arrayString,
}))
expect(mockDeleteNodeInspectorVars).toHaveBeenCalledWith('iteration-node')
})
})

View File

@ -1,12 +1,12 @@
import type { FC } from 'react'
import type { IterationNodeType } from './types'
import type { NodePanelProps } from '@/app/components/workflow/types'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Slider } from '@langgenius/dify-ui/slider'
import { Switch } from '@langgenius/dify-ui/switch'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Select from '@/app/components/base/select'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import { ErrorHandleMode } from '@/app/components/workflow/types'
import { MAX_PARALLEL_LIMIT } from '@/config'
@ -49,6 +49,7 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({
changeParallelNums,
changeFlattenOutput,
} = useConfig(id, data)
const selectedResponseMethod = responseMethod.find(item => item.value === inputs.error_handle_mode)
return (
<div className="pt-2 pb-2">
@ -119,7 +120,28 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({
<div className="px-4 py-2">
<Field title={t(`${i18nPrefix}.errorResponseMethod`, { ns: 'workflow' })}>
<Select items={responseMethod} defaultValue={inputs.error_handle_mode} onSelect={changeErrorResponseMode} allowSearch={false} />
<Select
value={selectedResponseMethod ? String(selectedResponseMethod.value) : null}
onValueChange={(nextValue) => {
if (!nextValue)
return
const nextItem = responseMethod.find(item => String(item.value) === nextValue)
if (nextItem)
changeErrorResponseMode(nextItem)
}}
>
<SelectTrigger className="w-full">
{selectedResponseMethod?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{responseMethod.map(item => (
<SelectItem key={item.value} value={String(item.value)}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>

View File

@ -1,6 +1,5 @@
import type { ErrorHandleMode, ValueSelector, Var } from '../../types'
import type { IterationNodeType } from './types'
import type { Item } from '@/app/components/base/select'
import type { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
import { isEqual } from 'es-toolkit/predicate'
import { produce } from 'immer'
@ -22,6 +21,11 @@ import { VarType } from '../../types'
import { toNodeOutputVars } from '../_base/components/variable/utils'
import useNodeCrud from '../_base/hooks/use-node-crud'
type SelectOption = {
value: string | number
name: string
}
const useConfig = (id: string, payload: IterationNodeType) => {
const {
deleteNodeInspectorVars,
@ -92,7 +96,7 @@ const useConfig = (id: string, payload: IterationNodeType) => {
setInputs(newInputs)
}, [inputs, setInputs])
const changeErrorResponseMode = useCallback((item: Item) => {
const changeErrorResponseMode = useCallback((item: SelectOption) => {
const newInputs = produce(inputs, (draft) => {
draft.error_handle_mode = item.value as ErrorHandleMode
})

View File

@ -1,7 +1,7 @@
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
import type { ListFilterNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
@ -160,6 +160,7 @@ describe('list-operator path', () => {
})
it('should change the selected sub variable', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { unmount } = render(
<SubVariablePicker
@ -168,16 +169,8 @@ describe('list-operator path', () => {
/>,
)
const trigger = screen.getByRole('button')
await act(async () => {
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
})
const option = await screen.findByText('name')
await act(async () => {
fireEvent.click(option)
})
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('option', { name: 'name' }))
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith('name')

View File

@ -112,7 +112,7 @@ describe('FilterCondition', () => {
expect(screen.getByText(/operator:/)).toBeInTheDocument()
expect(screen.getByText(/sub-variable:/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'workflow.nodes.ifElse.optionName.doc' }))
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByText('workflow.nodes.ifElse.optionName.image'))
expect(onChange).toHaveBeenCalledWith({
@ -282,7 +282,7 @@ describe('FilterCondition', () => {
/>,
)
await user.click(screen.getByRole('button', { name: 'workflow.nodes.ifElse.optionName.localUpload' }))
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByText('workflow.nodes.ifElse.optionName.url'))
expect(onChange).toHaveBeenCalledWith({
key: 'transfer_method',
@ -305,6 +305,6 @@ describe('FilterCondition', () => {
/>,
)
expect(screen.getByRole('button', { name: 'Select value' })).toBeInTheDocument()
expect(screen.getByText('Select value')).toBeInTheDocument()
})
})

View File

@ -20,7 +20,7 @@ describe('list-operator/sub-variable-picker', () => {
expect(screen.getByText('common.placeholder.select')).toBeInTheDocument()
await user.click(screen.getByRole('button'))
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('option', { name: 'name' }))
expect(handleChange).toHaveBeenCalledWith('name')
@ -41,7 +41,7 @@ describe('list-operator/sub-variable-picker', () => {
expect(container.firstChild).toHaveClass('custom-sub-variable')
expect(screen.getByText('size')).toBeInTheDocument()
await user.click(screen.getByRole('button'))
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByRole('option', { name: 'type' }))
expect(handleChange).toHaveBeenCalledWith('type')

View File

@ -2,10 +2,10 @@
import type { FC } from 'react'
import type { Condition } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SimpleSelect as Select } from '@/app/components/base/select'
import Input from '@/app/components/workflow/nodes/_base/components/input-support-select-var'
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
import { FILE_TYPE_OPTIONS, TRANSFER_METHOD } from '@/app/components/workflow/nodes/constants'
@ -126,15 +126,23 @@ const ValueInput = ({
return null
if (isSelect) {
const selectedValue = isArrayValue ? (condition.value as string[])?.[0] : condition.value as string
const selectedOption = selectOptions.find(option => option.value === selectedValue) ?? null
return (
<Select
items={selectOptions}
defaultValue={isArrayValue ? (condition.value as string[])[0] : condition.value as string}
onSelect={item => onChange(item.value)}
className="text-[13px]!"
wrapperClassName="grow h-8"
placeholder="Select value"
/>
<Select value={selectedOption?.value ?? null} disabled={readOnly} onValueChange={value => value && onChange(value)}>
<SelectTrigger className="h-8 grow text-[13px]">
{selectedOption?.name ?? 'Select value'}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{selectOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View File

@ -1,12 +1,11 @@
'use client'
import type { FC } from 'react'
import type { Item } from '@/app/components/base/select'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import * as React from 'react'
import { useCallback } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { SimpleSelect as Select } from '@/app/components/base/select'
import { SUB_VARIABLES } from '../../constants'
type Props = {
@ -15,51 +14,36 @@ type Props = {
className?: string
}
type SubVariableOption = {
value: string
name: string
}
const SubVariablePicker: FC<Props> = ({
value,
onChange,
className,
}) => {
const { t } = useTranslation()
const subVarOptions = SUB_VARIABLES.map(item => ({
const subVarOptions = useMemo<SubVariableOption[]>(() => SUB_VARIABLES.map(item => ({
value: item,
name: item,
}))
const renderOption = ({ item }: { item: Record<string, any> }) => {
return (
<div className="flex h-6 items-center justify-between">
<div className="flex h-full items-center">
<Variable02 className="mr-[5px] h-3.5 w-3.5 text-text-accent" />
<span className="system-sm-medium text-text-secondary">{item.name}</span>
</div>
<span className="system-xs-regular text-text-tertiary">{item.type}</span>
</div>
)
}
const handleChange = useCallback(({ value }: Item) => {
onChange(value as string)
}, [onChange])
})), [])
const selectedOption = useMemo(() => {
return subVarOptions.find(option => option.value === value) ?? null
}, [subVarOptions, value])
return (
<div className={cn(className)}>
<Select
items={subVarOptions}
defaultValue={value}
onSelect={handleChange}
className="text-[13px]!"
placeholder={t('nodes.listFilter.selectVariableKeyPlaceholder', { ns: 'workflow' })!}
optionClassName="pl-1 pr-5 py-0"
renderOption={renderOption}
renderTrigger={item => (
<Select value={selectedOption?.value ?? null} onValueChange={nextValue => nextValue && onChange(nextValue)}>
<SelectTrigger className="h-8 border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden">
<div className="group/sub-variable-picker flex h-8 items-center rounded-lg bg-components-input-bg-normal pl-1 hover:bg-state-base-hover-alt">
{item
{selectedOption
? (
<div className="flex cursor-pointer justify-start">
<div className="inline-flex h-6 max-w-full items-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark px-1.5 text-text-accent shadow-xs">
<Variable02 className="h-3.5 w-3.5 shrink-0 text-text-accent" />
<div className="ml-0.5 truncate system-xs-medium">{item?.name}</div>
<div className="ml-0.5 truncate system-xs-medium">{selectedOption.name}</div>
</div>
</div>
)
@ -70,8 +54,20 @@ const SubVariablePicker: FC<Props> = ({
</div>
)}
</div>
)}
/>
</SelectTrigger>
<SelectContent popupClassName="w-[165px]" listClassName="max-h-none p-1">
{subVarOptions.map(option => (
<SelectItem key={option.value} value={option.value} className="h-8 py-0 pr-5 pl-1">
<div className="flex h-6 items-center justify-between">
<div className="flex h-full items-center">
<Variable02 className="mr-[5px] h-3.5 w-3.5 text-text-accent" />
<SelectItemText className="mr-0 px-0 system-sm-medium text-text-secondary">{option.name}</SelectItemText>
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View File

@ -15,6 +15,7 @@ import type {
Var,
} from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { RiDeleteBinLine } from '@remixicon/react'
import { produce } from 'immer'
import {
@ -24,7 +25,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { SimpleSelect as Select } from '@/app/components/base/select'
import BoolValue from '@/app/components/workflow/panel/chat-variable-panel/components/bool-value'
import { VarType } from '@/app/components/workflow/types'
import {
@ -141,7 +141,7 @@ const ConditionItem = ({
}, [condition, doUpdateCondition, isArrayValue])
const isSelect = condition.comparison_operator && [ComparisonOperator.in, ComparisonOperator.notIn].includes(condition.comparison_operator)
const selectOptions = useMemo(() => {
const selectOptions = useMemo<Array<{ name: string, value: string }>>(() => {
if (isSelect) {
if (fileAttr?.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) {
return FILE_TYPE_OPTIONS.map(item => ({
@ -167,6 +167,7 @@ const ConditionItem = ({
name: item,
value: item,
}))
const selectedSubVarOption = subVarOptions.find(item => item.value === condition.key) ?? null
const handleSubVarKeyChange = useCallback((key: string) => {
const newCondition = produce(condition, (draft) => {
@ -203,6 +204,8 @@ const ConditionItem = ({
doUpdateCondition(newCondition)
setOpen(false)
}, [condition, doUpdateCondition])
const selectedSelectValue = isArrayValue ? (condition.value as string[])?.[0] : (condition.value as string)
const selectedSelectOption = selectOptions.find(item => item.value === selectedSelectValue) ?? null
return (
<div className={cn('mb-1 flex last-of-type:mb-0', className)}>
@ -216,26 +219,38 @@ const ConditionItem = ({
{isSubVarSelect
? (
<Select
wrapperClassName="h-6"
className="pl-0 text-xs"
optionWrapClassName="w-[165px] max-h-none"
defaultValue={condition.key}
items={subVarOptions}
onSelect={item => handleSubVarKeyChange(item.value as string)}
renderTrigger={item => (
item
value={selectedSubVarOption?.value ?? null}
onValueChange={value => value && handleSubVarKeyChange(value)}
>
<SelectTrigger
render={<div />}
nativeButton={false}
className="h-6 border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden"
>
{selectedSubVarOption
? (
<div className="flex cursor-pointer justify-start">
<div className="inline-flex h-6 max-w-full items-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark px-1.5 text-text-accent shadow-xs">
<Variable02 className="h-3.5 w-3.5 shrink-0 text-text-accent" />
<div className="ml-0.5 truncate system-xs-medium">{item?.name}</div>
<div className="ml-0.5 truncate system-xs-medium">{selectedSubVarOption.name}</div>
</div>
</div>
)
: <div className="text-left system-sm-regular text-components-input-text-placeholder">{t('placeholder.select', { ns: 'common' })}</div>
)}
hideChecked
/>
: <div className="text-left system-sm-regular text-components-input-text-placeholder">{t('placeholder.select', { ns: 'common' })}</div>}
</SelectTrigger>
<SelectContent popupClassName="w-[165px]" listClassName="max-h-none p-1">
{subVarOptions.map(option => (
<SelectItem key={option.value} value={option.value} className="h-8 py-0 pr-5 pl-1">
<div className="flex h-6 items-center justify-between">
<div className="flex h-full items-center">
<Variable02 className="mr-[5px] h-3.5 w-3.5 text-text-accent" />
<SelectItemText className="mr-0 px-0 system-sm-medium text-text-secondary">{option.name}</SelectItemText>
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)
: (
<ConditionVarSelector
@ -298,15 +313,18 @@ const ConditionItem = ({
{
!comparisonOperatorNotRequireValue(condition.comparison_operator) && isSelect && (
<div className="border-t border-t-divider-subtle">
<Select
wrapperClassName="h-8"
className="rounded-t-none px-2 text-xs"
defaultValue={isArrayValue ? (condition.value as string[])?.[0] : (condition.value as string)}
items={selectOptions}
onSelect={item => handleUpdateConditionValue(item.value as string)}
hideChecked
notClearable
/>
<Select value={selectedSelectOption?.value ?? null} onValueChange={value => value && handleUpdateConditionValue(value)}>
<SelectTrigger className="h-8 rounded-t-none border-0 px-2 text-xs hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal">
{selectedSelectOption?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{selectOptions.map(option => (
<SelectItem key={option.value} value={option.value} className="text-xs">
<SelectItemText>{option.name}</SelectItemText>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View File

@ -4,13 +4,13 @@ import type { Node, NodeOutPutVar, Var } from '../../../types'
import type { Condition, HandleAddCondition, HandleAddSubVariableCondition, HandleRemoveCondition, handleRemoveSubVariableCondition, HandleToggleConditionLogicalOperator, HandleToggleSubVariableConditionLogicalOperator, HandleUpdateCondition, HandleUpdateSubVariableCondition, LogicalOperator } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import {
RiAddLine,
} from '@remixicon/react'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { PortalSelect as Select } from '@/app/components/base/select'
import { VarType } from '../../../types'
import { useGetAvailableVars } from '../../variable-assigner/hooks'
import { SUB_VARIABLES } from './../default'
@ -115,11 +115,15 @@ const ConditionWrap: FC<Props> = ({
{isSubVariable
? (
<Select
popupInnerClassName="w-[165px] max-h-none"
onSelect={value => handleAddSubVariableCondition?.(conditionId!, value.value as string)}
items={subVarOptions}
value=""
renderTrigger={() => (
value={null}
disabled={readOnly}
onValueChange={value => value && handleAddSubVariableCondition?.(conditionId!, value)}
>
<SelectTrigger
render={<div />}
nativeButton={false}
className="border-0 bg-transparent p-0 hover:bg-transparent focus-visible:bg-transparent [&>*:last-child]:hidden"
>
<Button
size="small"
disabled={readOnly}
@ -127,9 +131,15 @@ const ConditionWrap: FC<Props> = ({
<RiAddLine className="mr-1 h-3.5 w-3.5" />
{t('nodes.ifElse.addSubVariable', { ns: 'workflow' })}
</Button>
)}
hideChecked
/>
</SelectTrigger>
<SelectContent popupClassName="w-[165px]" listClassName="max-h-none p-1">
{subVarOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.name}</SelectItemText>
</SelectItem>
))}
</SelectContent>
</Select>
)
: (
<ConditionAdd

View File

@ -1,5 +1,5 @@
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { useTranslation } from 'react-i18next'
import PureSelect from '@/app/components/base/select/pure'
type InputModeSelectProps = {
value?: string
@ -20,17 +20,22 @@ const InputModeSelect = ({
value: 'constant',
},
]
const selectedOption = options.find(option => option.value === value) ?? null
return (
<PureSelect
options={options}
value={value}
onChange={onChange}
popupProps={{
title: t('nodes.loop.inputMode', { ns: 'workflow' }),
className: 'w-[132px]',
}}
/>
<Select value={selectedOption?.value ?? null} onValueChange={nextValue => nextValue && onChange(nextValue)}>
<SelectTrigger className="w-full">
{selectedOption?.label ?? t('nodes.loop.inputMode', { ns: 'workflow' })}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{options.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.label}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View File

@ -1,4 +1,4 @@
import PureSelect from '@/app/components/base/select/pure'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { VarType } from '@/app/components/workflow/types'
type VariableTypeSelectProps = {
@ -43,16 +43,22 @@ const VariableTypeSelect = ({
value: VarType.arrayBoolean,
},
]
const selectedOption = options.find(option => option.value === value) ?? null
return (
<PureSelect
options={options}
value={value}
onChange={onChange}
popupProps={{
className: 'w-[132px]',
}}
/>
<Select value={selectedOption?.value ?? null} onValueChange={nextValue => nextValue && onChange(nextValue)}>
<SelectTrigger className="w-full">
{selectedOption?.label}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{options.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.label}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View File

@ -3,6 +3,7 @@ import type { FC } from 'react'
import type { Param } from '../../types'
import type { MoreInfo } from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import { useBoolean } from 'ahooks'
@ -13,7 +14,6 @@ import Field from '@/app/components/app/configuration/config-var/config-modal/fi
import ConfigSelect from '@/app/components/app/configuration/config-var/config-select'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import Select from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import { ChangeType } from '@/app/components/workflow/types'
import { checkKeys } from '@/utils/var'
@ -141,18 +141,21 @@ const AddExtractParameter: FC<Props> = ({
</Field>
<Field title={t(`${i18nPrefix}.addExtractParameterContent.type`, { ns: 'workflow' })}>
<Select
defaultValue={param.type}
allowSearch={false}
// bgClassName='bg-gray-100'
onSelect={v => handleParamChange('type')(v.value)}
optionClassName="capitalize"
items={
TYPES.map(type => ({
value: type,
name: type,
}))
}
/>
value={param.type}
onValueChange={value => value && handleParamChange('type')(value)}
>
<SelectTrigger className="w-full capitalize">
{param.type}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{TYPES.map(type => (
<SelectItem key={type} value={type} className="capitalize">
<SelectItemText className="capitalize">{type}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{param.type === ParamType.select && (
<Field title={t('variableConfig.options', { ns: 'appDebug' })}>

View File

@ -14,7 +14,7 @@ describe('trigger-schedule/frequency-selector', () => {
/>,
)
const trigger = screen.getByRole('button', { name: 'workflow.nodes.triggerSchedule.frequency.daily' })
const trigger = screen.getByRole('combobox')
await user.click(trigger)
await waitFor(() => {

View File

@ -43,7 +43,7 @@ describe('trigger-schedule components', () => {
/>,
)
const trigger = screen.getByRole('button', { name: 'workflow.nodes.triggerSchedule.frequency.daily' })
const trigger = screen.getByRole('combobox')
await user.click(trigger)
await user.keyboard('{ArrowDown}')
await user.keyboard('{Enter}')

View File

@ -1,8 +1,22 @@
import type { ScheduleFrequency } from '../types'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectLabel,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { SimpleSelect } from '@/app/components/base/select'
type FrequencyOption = {
value: ScheduleFrequency
name: string
}
type FrequencySelectorProps = {
frequency: ScheduleFrequency
@ -11,28 +25,37 @@ type FrequencySelectorProps = {
const FrequencySelector = ({ frequency, onChange }: FrequencySelectorProps) => {
const { t } = useTranslation()
const groupLabel = t('nodes.triggerSchedule.frequency.label', { ns: 'workflow' })
const frequencies = useMemo(() => [
{ value: 'frequency-header', name: t('nodes.triggerSchedule.frequency.label', { ns: 'workflow' }), isGroup: true },
const frequencies = useMemo<FrequencyOption[]>(() => [
{ value: 'hourly', name: t('nodes.triggerSchedule.frequency.hourly', { ns: 'workflow' }) },
{ value: 'daily', name: t('nodes.triggerSchedule.frequency.daily', { ns: 'workflow' }) },
{ value: 'weekly', name: t('nodes.triggerSchedule.frequency.weekly', { ns: 'workflow' }) },
{ value: 'monthly', name: t('nodes.triggerSchedule.frequency.monthly', { ns: 'workflow' }) },
], [t])
const selectedFrequency = frequencies.find(item => item.value === frequency)
return (
<SimpleSelect
key={`${frequency}-${frequencies[0]?.name}`} // Include translation in key to force re-render
items={frequencies}
defaultValue={frequency}
onSelect={item => onChange(item.value as ScheduleFrequency)}
placeholder={t('nodes.triggerSchedule.selectFrequency', { ns: 'workflow' })}
className="w-full py-2"
wrapperClassName="h-auto"
optionWrapClassName="min-w-40"
notClearable={true}
allowSearch={false}
/>
<Select
key={`${frequency}-${groupLabel}`}
value={frequency}
onValueChange={value => value && onChange(value as ScheduleFrequency)}
>
<SelectTrigger className="w-full py-2">
{selectedFrequency?.name ?? t('nodes.triggerSchedule.selectFrequency', { ns: 'workflow' })}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
<SelectGroup>
<SelectLabel>{groupLabel}</SelectLabel>
{frequencies.map(item => (
<SelectItem key={item.value} value={item.value}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}

View File

@ -14,6 +14,9 @@ const {
mockHandleParamsChange,
mockHandleBodyChange,
mockHandleResponseBodyChange,
mockToastSuccess,
mockCopy,
mockIsPrivateOrLocalAddress,
} = vi.hoisted(() => ({
mockHandleStatusCodeChange: vi.fn(),
mockGenerateWebhookUrl: vi.fn(),
@ -23,6 +26,137 @@ const {
mockHandleParamsChange: vi.fn(),
mockHandleBodyChange: vi.fn(),
mockHandleResponseBodyChange: vi.fn(),
mockToastSuccess: vi.fn(),
mockCopy: vi.fn(),
mockIsPrivateOrLocalAddress: vi.fn((_url: string) => false),
}))
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
disabled?: boolean
onValueChange?: (value: string) => void
}>({})
return {
Select: ({ children, disabled, onValueChange }: {
children: React.ReactNode
disabled?: boolean
onValueChange?: (value: string) => void
}) => (
<SelectContext.Provider value={{ disabled, onValueChange }}>
<div>{children}</div>
</SelectContext.Provider>
),
SelectTrigger: ({ children, className }: { children: React.ReactNode, className?: string }) => {
const context = React.useContext(SelectContext)
return (
<div>
<button data-testid="select-trigger" type="button" disabled={context.disabled} className={className}>
{children}
</button>
<button data-testid="select-empty" type="button" onClick={() => context.onValueChange?.('')}>
empty select value
</button>
</div>
)
},
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => {
const context = React.useContext(SelectContext)
return (
<button data-testid={`select-${value}`} type="button" onClick={() => context.onValueChange?.(value)}>
{children}
</button>
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
success: mockToastSuccess,
},
}))
vi.mock('copy-to-clipboard', () => ({
default: mockCopy,
}))
vi.mock('@/app/components/base/input-with-copy', () => ({
default: ({ value, placeholder, onCopy }: { value: string, placeholder: string, onCopy: () => void }) => (
<div>
<input value={value} placeholder={placeholder} readOnly />
<button data-testid="copy-input" type="button" onClick={onCopy}>Copy</button>
</div>
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: ({ title, children }: { title: string, children: React.ReactNode }) => (
<div>
<span>{title}</span>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
default: ({ children, onCollapse, collapsed }: { children: React.ReactNode, onCollapse: (value: boolean) => void, collapsed: boolean }) => (
<div>
<button data-testid="toggle-output-vars" type="button" onClick={() => onCollapse(!collapsed)}>toggle output vars</button>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
default: () => <div data-testid="split" />,
}))
vi.mock('../components/header-table', () => ({
default: ({ onChange }: { onChange: (value: Array<Record<string, string>>) => void }) => (
<button data-testid="header-table" type="button" onClick={() => onChange([{ key: 'Authorization', value: 'Bearer token' }])}>
header table
</button>
),
}))
vi.mock('../components/parameter-table', () => ({
default: ({ title, onChange, placeholder, contentType }: {
title: string
onChange: (value: Array<Record<string, string>>) => void
placeholder?: string
contentType?: string
}) => (
<div>
<span>{placeholder}</span>
<span>{contentType}</span>
<button data-testid={`parameter-${title}`} type="button" onClick={() => onChange([{ key: title, value: 'value' }])}>
{title}
</button>
</div>
),
}))
vi.mock('../components/paragraph-input', () => ({
default: ({ value, onChange, placeholder }: { value: string, onChange: (value: string) => void, placeholder: string }) => (
<textarea value={value} placeholder={placeholder} onChange={e => onChange(e.target.value)} />
),
}))
vi.mock('../utils/render-output-vars', () => ({
OutputVariablesContent: ({ variables }: { variables: unknown[] }) => <div data-testid="output-variables">{variables.length}</div>,
}))
vi.mock('@/utils/urlValidation', () => ({
isPrivateOrLocalAddress: (url: string) => mockIsPrivateOrLocalAddress(url),
}))
const mockConfigState = {
@ -106,11 +240,19 @@ describe('WebhookTriggerPanel', () => {
render(<Panel {...panelProps} />)
expect(screen.getByDisplayValue('https://example.com/webhook')).toBeInTheDocument()
expect(screen.getByText('application/json')).toBeInTheDocument()
expect(screen.getAllByText('application/json')[0]).toBeInTheDocument()
expect(screen.getByDisplayValue('ok')).toBeInTheDocument()
expect(mockGenerateWebhookUrl).not.toHaveBeenCalled()
})
it('should keep the content type selector aligned with the webhook url row width', () => {
render(<Panel {...panelProps} />)
const contentTypeTrigger = screen.getAllByTestId('select-trigger')[1]
expect(contentTypeTrigger).toHaveClass('w-full')
})
it('should request a webhook url when the node is writable and missing one', async () => {
mockConfigState.inputs = {
...mockConfigState.inputs,
@ -147,4 +289,56 @@ describe('WebhookTriggerPanel', () => {
expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(200)
})
})
describe('Interactions', () => {
it('should handle method, content type, table, response, and copy actions', () => {
render(<Panel {...panelProps} />)
fireEvent.click(screen.getByTestId('copy-input'))
fireEvent.click(screen.getByTestId('select-GET'))
fireEvent.click(screen.getByTestId('select-text/plain'))
fireEvent.click(screen.getByTestId('parameter-Query Parameters'))
fireEvent.click(screen.getByTestId('header-table'))
fireEvent.click(screen.getByTestId('parameter-Request Body Parameters'))
fireEvent.change(screen.getByDisplayValue('ok'), { target: { value: 'updated body' } })
fireEvent.click(screen.getByTestId('toggle-output-vars'))
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.nodes.triggerWebhook.urlCopied')
expect(mockHandleMethodChange).toHaveBeenCalledWith('GET')
expect(mockHandleContentTypeChange).toHaveBeenCalledWith('text/plain')
expect(mockHandleParamsChange).toHaveBeenCalledWith([{ key: 'Query Parameters', value: 'value' }])
expect(mockHandleHeadersChange).toHaveBeenCalledWith([{ key: 'Authorization', value: 'Bearer token' }])
expect(mockHandleBodyChange).toHaveBeenCalledWith([{ key: 'Request Body Parameters', value: 'value' }])
expect(mockHandleResponseBodyChange).toHaveBeenCalledWith('updated body')
})
it('should render the debug url card, copy it, and show the private-address warning', () => {
vi.useFakeTimers()
mockIsPrivateOrLocalAddress.mockReturnValue(true)
mockConfigState.inputs = {
...mockConfigState.inputs,
webhook_debug_url: 'http://127.0.0.1:8000/debug',
}
render(<Panel {...panelProps} />)
fireEvent.click(screen.getByText('http://127.0.0.1:8000/debug'))
expect(mockCopy).toHaveBeenCalledWith('http://127.0.0.1:8000/debug')
expect(screen.getByText('workflow.nodes.triggerWebhook.debugUrlPrivateAddressWarning')).toBeInTheDocument()
vi.runAllTimers()
vi.useRealTimers()
})
it('should ignore empty method and content-type selections', () => {
render(<Panel {...panelProps} />)
fireEvent.click(screen.getAllByTestId('select-empty')[0]!)
fireEvent.click(screen.getAllByTestId('select-empty')[1]!)
expect(mockHandleMethodChange).not.toHaveBeenCalled()
expect(mockHandleContentTypeChange).not.toHaveBeenCalled()
})
})
})

View File

@ -55,7 +55,8 @@ describe('GenericTable', () => {
const selectOption = async (triggerName: string, optionName: string) => {
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: triggerName }))
expect(screen.getByText(triggerName)).toBeInTheDocument()
fireEvent.click(screen.getByRole('combobox'))
})
await act(async () => {
@ -158,7 +159,7 @@ describe('GenericTable', () => {
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith([{ method: 'post', preview: '' }])
expect(screen.getByRole('button', { name: 'POST' }))!.toBeInTheDocument()
expect(screen.getAllByRole('combobox')[0])!.toHaveTextContent('POST')
})
onChange.mockClear()

View File

@ -17,7 +17,8 @@ const selectOption = async ({
if (!(row instanceof HTMLElement))
throw new Error('Failed to locate parameter table row')
const selectButton = within(row).getByRole('button', { name: triggerName })
expect(within(row).getByText(triggerName)).toBeInTheDocument()
const selectButton = within(row).getByRole('combobox')
await user.click(selectButton)
await user.keyboard('{ArrowDown}')
await user.keyboard('{Enter}')

View File

@ -1,12 +1,12 @@
'use client'
import type { FC, ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { RiDeleteBinLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import Checkbox from '@/app/components/base/checkbox'
import Input from '@/app/components/base/input'
import { SimpleSelect } from '@/app/components/base/select'
import { replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
// Tiny utility to judge whether a cell value is effectively present
@ -129,22 +129,33 @@ const renderSelectCell = (
readonly: boolean,
handleChange: (value: unknown) => void,
) => {
const options = column.options || []
const selectedOption = options.find(option => option.value === value) ?? null
return (
<SimpleSelect
items={column.options || []}
defaultValue={value as string | undefined}
onSelect={item => handleChange(item.value)}
<Select
value={selectedOption?.value ?? null}
onValueChange={nextValue => nextValue && handleChange(nextValue)}
disabled={readonly}
placeholder={column.placeholder}
hideChecked={false}
notClearable={true}
wrapperClassName="h-6 w-full min-w-0"
className={cn(
'h-6 rounded-none bg-transparent pr-6 pl-0 text-text-secondary',
'group-hover/simple-select:bg-transparent hover:bg-transparent focus-visible:bg-transparent',
)}
optionWrapClassName="w-26 min-w-26 z-60 -ml-3"
/>
>
<SelectTrigger
size="small"
className={cn(
'h-6 w-full min-w-0 rounded-none bg-transparent py-0 pr-6 pl-0 text-text-secondary',
'hover:bg-transparent focus-visible:bg-transparent',
)}
>
{selectedOption?.name ?? column.placeholder}
</SelectTrigger>
<SelectContent className="-translate-x-3" popupClassName="z-60 w-26 min-w-26">
{options.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View File

@ -9,14 +9,13 @@ import {
NumberFieldIncrement,
NumberFieldInput,
} from '@langgenius/dify-ui/number-field'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import copy from 'copy-to-clipboard'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import InputWithCopy from '@/app/components/base/input-with-copy'
import { SimpleSelect } from '@/app/components/base/select'
import Tooltip from '@/app/components/base/tooltip'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import OutputVars from '@/app/components/workflow/nodes/_base/components/output-vars'
@ -76,6 +75,9 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
}
}, [readOnly, inputs.webhook_url, generateWebhookUrl])
const selectedMethod = HTTP_METHODS.find(item => item.value === inputs.method) ?? null
const selectedContentType = CONTENT_TYPES.find(item => item.value === inputs.content_type) ?? null
return (
<div className="mt-2">
<div className="space-y-4 px-4 pt-2 pb-3">
@ -84,18 +86,24 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
<div className="space-y-1">
<div className="flex gap-1" style={{ height: '32px' }}>
<div className="w-26 shrink-0">
<SimpleSelect
<Select
key={`${id}-method-${inputs.method}`}
items={HTTP_METHODS}
defaultValue={inputs.method}
onSelect={item => handleMethodChange(item.value as HttpMethod)}
value={selectedMethod?.value ?? null}
disabled={readOnly}
className="h-8 pr-8 text-sm"
wrapperClassName="h-8"
optionWrapClassName="w-26 min-w-26 z-5"
allowSearch={false}
notClearable={true}
/>
onValueChange={value => value && handleMethodChange(value as HttpMethod)}
>
<SelectTrigger className="h-8 pr-8 text-sm">
{selectedMethod?.name}
</SelectTrigger>
<SelectContent popupClassName="z-5 w-26 min-w-26">
{HTTP_METHODS.map(item => (
<SelectItem key={item.value} value={item.value}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1" style={{ width: '284px' }}>
<InputWithCopy
@ -149,19 +157,25 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
{/* Content Type */}
<Field title={t(`${i18nPrefix}.contentType`, { ns: 'workflow' })}>
<div className="w-full">
<SimpleSelect
<div className="w-full max-w-[392px]">
<Select
key={`${id}-content-type-${inputs.content_type}`}
items={CONTENT_TYPES}
defaultValue={inputs.content_type}
onSelect={item => handleContentTypeChange(item.value as string)}
value={selectedContentType?.value ?? null}
disabled={readOnly}
className="h-8 text-sm"
wrapperClassName="h-8"
optionWrapClassName="min-w-48 z-5"
allowSearch={false}
notClearable={true}
/>
onValueChange={value => value && handleContentTypeChange(value)}
>
<SelectTrigger className="h-8 w-full text-sm">
{selectedContentType?.name}
</SelectTrigger>
<SelectContent popupClassName="z-5">
{CONTENT_TYPES.map(item => (
<SelectItem key={item.value} value={item.value}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</Field>

View File

@ -1,6 +1,7 @@
'use client'
import type { Locale } from '@/i18n-config'
import { Button } from '@langgenius/dify-ui/button'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { RiAccountCircleLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
@ -9,7 +10,6 @@ import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Loading from '@/app/components/base/loading'
import { SimpleSelect } from '@/app/components/base/select'
import { LICENSE_LINK } from '@/constants/link'
import { setLocaleOnClient } from '@/i18n-config'
import { languages, LanguagesSupported } from '@/i18n-config/language'
@ -21,6 +21,11 @@ import { useInvitationCheck } from '@/service/use-common'
import { timezones } from '@/utils/timezone'
import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
type SelectOption = {
value: string
name: string
}
export default function InviteSettingsPage() {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
@ -30,6 +35,9 @@ export default function InviteSettingsPage() {
const [name, setName] = useState('')
const [language, setLanguage] = useState(LanguagesSupported[0])
const [timezone, setTimezone] = useState(() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'America/Los_Angeles')
const languageOptions: SelectOption[] = languages.filter(item => item.supported)
const selectedLanguage = languageOptions.find(item => item.value === language)
const selectedTimezone = timezones.find(item => item.value === timezone)
const checkParams = {
url: '/activate/check',
@ -119,13 +127,26 @@ export default function InviteSettingsPage() {
{t('interfaceLanguage', { ns: 'login' })}
</label>
<div className="mt-1">
<SimpleSelect
defaultValue={LanguagesSupported[0]}
items={languages.filter(item => item.supported)}
onSelect={(item) => {
setLanguage(item.value as Locale)
<Select
value={selectedLanguage?.value ?? null}
onValueChange={(nextValue) => {
if (!nextValue)
return
setLanguage(nextValue as Locale)
}}
/>
>
<SelectTrigger size="large">
{selectedLanguage?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{languageOptions.map(item => (
<SelectItem key={item.value} value={item.value}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* timezone */}
@ -134,13 +155,26 @@ export default function InviteSettingsPage() {
{t('timezone', { ns: 'login' })}
</label>
<div className="mt-1">
<SimpleSelect
defaultValue={timezone}
items={timezones}
onSelect={(item) => {
setTimezone(item.value as string)
<Select
value={selectedTimezone ? String(selectedTimezone.value) : null}
onValueChange={(nextValue) => {
if (!nextValue)
return
setTimezone(nextValue as string)
}}
/>
>
<SelectTrigger size="large">
{selectedTimezone?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>
<SelectContent>
{timezones.map(item => (
<SelectItem key={item.value} value={String(item.value)}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>

View File

@ -1,13 +1,14 @@
'use client'
import type { Reducer } from 'react'
import type { LanguagesSupported } from '@/i18n-config/language'
import { Button } from '@langgenius/dify-ui/button'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { useReducer } from 'react'
import { useTranslation } from 'react-i18next'
import { SimpleSelect } from '@/app/components/base/select'
import Tooltip from '@/app/components/base/tooltip'
import { LICENSE_LINK } from '@/constants/link'
import { languages, LanguagesSupported } from '@/i18n-config/language'
import { languages } from '@/i18n-config/language'
import Link from '@/next/link'
import { useRouter, useSearchParams } from '@/next/navigation'
import { useOneMoreStep } from '@/service/use-common'
@ -45,6 +46,11 @@ const reducer: Reducer<IState, IAction> = (state: IState, action: IAction) => {
}
}
type SelectOption = {
value: string
name: string
}
const OneMoreStep = () => {
const { t } = useTranslation()
const router = useRouter()
@ -56,6 +62,9 @@ const OneMoreStep = () => {
timezone: 'Asia/Shanghai',
})
const { mutateAsync: submitOneMoreStep, isPending } = useOneMoreStep()
const languageOptions: SelectOption[] = languages.filter(item => item.supported)
const selectedLanguage = languageOptions.find(item => item.value === state.interface_language)
const selectedTimezone = timezones.find(item => item.value === state.timezone)
const handleSubmit = async () => {
if (isPending)
@ -117,13 +126,26 @@ const OneMoreStep = () => {
{t('interfaceLanguage', { ns: 'login' })}
</label>
<div className="mt-1">
<SimpleSelect
defaultValue={LanguagesSupported[0]}
items={languages.filter(item => item.supported)}
onSelect={(item) => {
dispatch({ type: 'interface_language', value: item.value as typeof LanguagesSupported[number] })
<Select
value={selectedLanguage?.value ?? null}
onValueChange={(nextValue) => {
if (!nextValue)
return
dispatch({ type: 'interface_language', value: nextValue as typeof LanguagesSupported[number] })
}}
/>
>
<SelectTrigger size="large">
{selectedLanguage?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{languageOptions.map(item => (
<SelectItem key={item.value} value={item.value}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mb-4">
@ -131,13 +153,26 @@ const OneMoreStep = () => {
{t('timezone', { ns: 'login' })}
</label>
<div className="mt-1">
<SimpleSelect
defaultValue={state.timezone}
items={timezones}
onSelect={(item) => {
dispatch({ type: 'timezone', value: item.value as typeof state.timezone })
<Select
value={selectedTimezone ? String(selectedTimezone.value) : null}
onValueChange={(nextValue) => {
if (!nextValue)
return
dispatch({ type: 'timezone', value: nextValue as typeof state.timezone })
}}
/>
>
<SelectTrigger size="large">
{selectedTimezone?.name ?? t('placeholder.select', { ns: 'common' })}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{timezones.map(item => (
<SelectItem key={item.value} value={String(item.value)}>
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>