mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 12:59:18 +08:00
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:
parent
32d75fe08c
commit
c7d96badf4
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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)', () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
})
|
||||
}
|
||||
</>
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
&& (
|
||||
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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'))
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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' })}>
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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}')
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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}')
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user