diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 0e0970f90d..f1a5e6ce87 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx index b5da0e4ca5..002f8f3bf1 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/long-time-range-picker.tsx @@ -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 = ({ queryDateFormat, }) => { const { t } = useTranslation() + const items = React.useMemo(() => { + 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 = ({ }, [onSelect, periodMapping, queryDateFormat, t]) return ( - ({ value: k, name: t(`filter.period.${v.name}`, { ns: 'appLog' }) }))} - className="mt-0 w-40!" - notClearable={true} - onSelect={handleSelect} - defaultValue="2" - /> + ) } export default React.memo(LongTimeRangePicker) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx index a89b77e9e3..c028a184ed 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx @@ -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 = ({ onSelect, }) => { const { t } = useTranslation() + const [open, setOpen] = useState(false) + const items = useMemo(() => { + 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 = ({ onSelect({ query: period!, name }) }, [onSelect]) - const renderTrigger = useCallback((item: Item | null, isOpen: boolean) => { - return ( -
-
{isCustomRange ? t('filter.period.custom', { ns: 'appLog' }) : item?.name}
- -
- ) - }, [isCustomRange]) - - const renderOption = useCallback(({ item, selected }: { item: Item, selected: boolean }) => { - return ( - <> - {selected && ( - - - )} - {item.name} - - ) - }, []) return ( - ({ ...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} - /> + ) } export default React.memo(RangeSelector) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx index 5f0f4129d6..7a63df3350 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx @@ -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>) => void }) => ( - + FileUploaderInAttachmentWrapper: ({ + onChange, + value, + fileConfig, + }: { + onChange: (files?: Array>) => void + value: Array> + fileConfig: Record + }) => ( +
+ {JSON.stringify(value)} + {JSON.stringify(fileConfig)} + + +
), })) @@ -38,12 +53,6 @@ vi.mock('@/app/components/base/checkbox', () => ({ ), })) -vi.mock('@/app/components/base/select', () => ({ - default: ({ onSelect }: { onSelect: (item: { value: string }) => void }) => ( - - ), -})) - vi.mock('@langgenius/dify-ui/select', async (importOriginal) => { const actual = await importOriginal() @@ -52,6 +61,7 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => { Select: ({ value, onValueChange, children }: { value: string, onValueChange: (value: string) => void, children: ReactNode }) => (
+ {children}
), @@ -86,8 +96,8 @@ vi.mock('../../config-select', () => ({ })) vi.mock('../../config-string', () => ({ - default: ({ onChange }: { onChange: (value: number) => void }) => ( - + default: ({ onChange, maxLength }: { onChange: (value: number) => void, maxLength: number }) => ( + ), })) @@ -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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + expect(screen.getByRole('spinbutton')).toHaveValue(null) + }) }) diff --git a/web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx b/web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx index 39db0da1ec..9fb79076d2 100644 --- a/web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx +++ b/web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx @@ -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 - }) => ( - - ), -})) + onValueChange?: (value: string) => void + }>({}) + + return { + Select: ({ children, disabled, onValueChange }: { + children: React.ReactNode + disabled?: boolean + onValueChange?: (value: string) => void + }) => ( + +
{children}
+
+ ), + SelectTrigger: ({ children, className }: { children: React.ReactNode, className?: string }) => { + const context = React.useContext(SelectContext) + return ( +
+ + +
+ ) + }, + SelectContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => { + const context = React.useContext(SelectContext) + return ( + + ) + }, + 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() - 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() + 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() - // 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() 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', () => { diff --git a/web/app/components/app/configuration/debug/chat-user-input.tsx b/web/app/components/app/configuration/debug/chat-user-input.tsx index b1285b712c..2eff7ac3ca 100644 --- a/web/app/components/app/configuration/debug/chat-user-input.tsx +++ b/web/app/components/app/configuration/debug/chat-user-input.tsx @@ -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' && ( )} {type === 'number' && ( ({ + Button: ({ + children, + onClick, + disabled, + className, + }: { + children: React.ReactNode + onClick?: () => void + disabled?: boolean + className?: string + }) => ( + + ), +})) + 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 }) => ( - - ), -})) +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 + }) => ( + +
{children}
+
+ ), + SelectTrigger: ({ children }: { children: React.ReactNode }) => { + const context = React.useContext(SelectContext) + return ( +
+ + +
+ ) + }, + SelectContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectItem: ({ children, value }: { children: React.ReactNode, value: string }) => { + const context = React.useContext(SelectContext) + return ( + + ) + }, + 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 }) => ( - + default: ({ name, onChange }: { name: string, onChange: (value: boolean) => void }) => ( + ), })) @@ -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() diff --git a/web/app/components/app/configuration/prompt-value-panel/index.tsx b/web/app/components/app/configuration/prompt-value-panel/index.tsx index 94bc48da29..c3ba69bf34 100644 --- a/web/app/components/app/configuration/prompt-value-panel/index.tsx +++ b/web/app/components/app/configuration/prompt-value-panel/index.tsx @@ -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 = ({ )} {type === 'select' && ( )} {type === 'number' && ( = ({ isChat, @@ -110,6 +114,8 @@ const SettingsModal: FC = ({ 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 = ({ {/* language */}
{t(`${prefixSettings}.language`, { ns: 'appOverview' })}
- item.supported)} - defaultValue={language} - onSelect={item => setLanguage(item.value as Language)} - notClearable - /> +
{/* theme color */} {isChat && ( diff --git a/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx b/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx index 6081024490..126ee77ae5 100644 --- a/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/inputs-form/__tests__/content.spec.tsx @@ -270,7 +270,7 @@ describe('InputsFormContent', () => { renderWithContext(, 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)', () => { diff --git a/web/app/components/base/chat/chat-with-history/inputs-form/content.tsx b/web/app/components/base/chat/chat-with-history/inputs-form/content.tsx index 4baa46744d..380b914492 100644 --- a/web/app/components/base/chat/chat-with-history/inputs-form/content.tsx +++ b/web/app/components/base/chat/chat-with-history/inputs-form/content.tsx @@ -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 && ( - ({ value: option, name: option }))} - onSelect={item => handleFormChange(form.variable, item.value as string)} - placeholder={form.label} - /> + )} {form.type === InputVarType.singleFile && ( { /> )} {form.type === InputVarType.select && ( - ({ value: option, name: option }))} - onSelect={item => handleFormChange(form.variable, item.value as string)} - placeholder={form.label} - /> + )} {form.type === InputVarType.singleFile && ( { }) render() - 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() 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() }) }) diff --git a/web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx b/web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx index 57b579b431..0392aa2b55 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx @@ -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 = ({ return ( <> { - forms.map((form, index) => ( -
-
- {locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']} -
- { - form.type === 'text-input' && ( - handleFormChange(form.variable, e.target.value)} - /> - ) - } - { - form.type === 'paragraph' && ( -
-