Compare commits
1 Commits
main
...
codex/sele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d7d0a4fda |
@@ -37,6 +37,15 @@ Import the complete family from its single public subpath:
|
||||
import { Radio, RadioControl, RadioGroup, RadioItem } from '@langgenius/dify-ui/radio-group'
|
||||
```
|
||||
|
||||
## Read-only and disabled selection
|
||||
|
||||
Follow the [`Select`] and [`Autocomplete`] contracts: `readOnly` locks the value while allowing
|
||||
popup browsing; `disabled` makes the control unavailable.
|
||||
|
||||
Dify UI triggers show a decorative lock alongside their popup indicator when read-only.
|
||||
Disabled appearance takes precedence when both states apply. `AutocompleteClear` is hidden while
|
||||
read-only. Keep these state indicators in Dify UI rather than duplicating them in consumers.
|
||||
|
||||
## Typed values
|
||||
|
||||
Do not widen domain values to `string`. Use `Select<Value, Multiple>`, `RadioGroup<Value>`,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { Virtualizer } from '@tanstack/react-virtual'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import * as React from 'react'
|
||||
import { expect, waitFor, within } from 'storybook/test'
|
||||
import {
|
||||
Autocomplete,
|
||||
AutocompleteClear,
|
||||
@@ -960,6 +961,7 @@ export const DisabledAndReadOnly: Story = {
|
||||
defaultValue="feature"
|
||||
mode="list"
|
||||
disabled
|
||||
readOnly
|
||||
>
|
||||
<AutocompleteInputGroup>
|
||||
<AutocompleteInput aria-label="Disabled tag autocomplete" />
|
||||
@@ -976,28 +978,69 @@ export const DisabledAndReadOnly: Story = {
|
||||
</AutocompletePositioner>
|
||||
</AutocompletePortal>
|
||||
</Autocomplete>
|
||||
<Autocomplete
|
||||
items={promptCompletions}
|
||||
itemToStringValue={getSuggestionLabel}
|
||||
defaultValue="summarize this conversation"
|
||||
mode="both"
|
||||
readOnly
|
||||
>
|
||||
<AutocompleteInputGroup>
|
||||
<AutocompleteInput aria-label="Read-only prompt autocomplete" />
|
||||
<AutocompleteClear />
|
||||
<AutocompleteTrigger />
|
||||
</AutocompleteInputGroup>
|
||||
<AutocompletePortal>
|
||||
<AutocompletePositioner>
|
||||
<AutocompletePopup>
|
||||
<AutocompleteList<Suggestion>>
|
||||
{(item) => <SuggestionItem key={item.value} item={item} />}
|
||||
</AutocompleteList>
|
||||
</AutocompletePopup>
|
||||
</AutocompletePositioner>
|
||||
</AutocompletePortal>
|
||||
</Autocomplete>
|
||||
{(['small', 'medium', 'large'] as const).map((size) => (
|
||||
<Autocomplete
|
||||
key={size}
|
||||
items={promptCompletions}
|
||||
itemToStringValue={getSuggestionLabel}
|
||||
defaultValue="summarize this"
|
||||
mode="both"
|
||||
readOnly
|
||||
>
|
||||
<AutocompleteInputGroup size={size}>
|
||||
<AutocompleteInput size={size} aria-label={`Read-only ${size} prompt autocomplete`} />
|
||||
<AutocompleteClear size={size} aria-label={`Clear read-only ${size} prompt`} />
|
||||
<AutocompleteTrigger
|
||||
size={size}
|
||||
aria-label={`Browse read-only ${size} prompt suggestions`}
|
||||
/>
|
||||
</AutocompleteInputGroup>
|
||||
<AutocompletePortal>
|
||||
<AutocompletePositioner>
|
||||
<AutocompletePopup>
|
||||
<AutocompleteList<Suggestion>>
|
||||
{(item) => <SuggestionItem key={item.value} item={item} />}
|
||||
</AutocompleteList>
|
||||
</AutocompletePopup>
|
||||
</AutocompletePositioner>
|
||||
</AutocompletePortal>
|
||||
</Autocomplete>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
play: async ({ canvas, canvasElement, userEvent }) => {
|
||||
const input = canvas.getByRole('combobox', { name: 'Read-only medium prompt autocomplete' })
|
||||
const body = within(canvasElement.ownerDocument.body)
|
||||
await expect(canvas.getByRole('combobox', { name: 'Disabled tag autocomplete' })).toBeDisabled()
|
||||
await expect(input).toHaveAttribute('readonly')
|
||||
await expect(
|
||||
canvas.queryByRole('button', { name: 'Clear read-only medium prompt' }),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
const trigger = canvas.getByRole('button', {
|
||||
name: 'Browse read-only medium prompt suggestions',
|
||||
})
|
||||
await expect(trigger).toBeVisible()
|
||||
await userEvent.click(trigger)
|
||||
const list = await body.findByRole('listbox')
|
||||
await expect(list).toHaveAttribute('aria-readonly', 'true')
|
||||
await expect(input).toHaveValue('summarize this')
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}')
|
||||
const firstHighlightedId = input.getAttribute('aria-activedescendant')
|
||||
await expect(firstHighlightedId).toBeTruthy()
|
||||
await expect(input).toHaveValue('summarize this')
|
||||
await userEvent.keyboard('{ArrowDown}')
|
||||
await expect(input).not.toHaveAttribute('aria-activedescendant', firstHighlightedId)
|
||||
await expect(input).toHaveValue('summarize this')
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await expect(input).toHaveValue('summarize this')
|
||||
await userEvent.click(
|
||||
within(list).getByRole('option', { name: 'summarize this dataset with citations' }),
|
||||
)
|
||||
await expect(input).toHaveValue('summarize this')
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await waitFor(() => expect(body.queryByRole('listbox')).not.toBeInTheDocument())
|
||||
await waitFor(() => expect(input).toHaveFocus())
|
||||
},
|
||||
}
|
||||
|
||||
@@ -166,7 +166,6 @@ const autocompleteControlVariants = cva(
|
||||
'focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid',
|
||||
'disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-text-tertiary disabled:focus-visible:bg-transparent disabled:focus-visible:ring-0',
|
||||
'group-data-disabled/autocomplete:cursor-not-allowed group-data-disabled/autocomplete:hover:bg-transparent group-data-disabled/autocomplete:focus-visible:bg-transparent group-data-disabled/autocomplete:focus-visible:ring-0',
|
||||
'group-data-readonly/autocomplete:hidden',
|
||||
'motion-reduce:transition-none',
|
||||
],
|
||||
{
|
||||
@@ -200,9 +199,17 @@ function AutocompleteTrigger({
|
||||
props['aria-label'] ??
|
||||
(props['aria-labelledby'] ? undefined : 'Open autocomplete suggestions')
|
||||
}
|
||||
className={cn(autocompleteControlVariants({ size }), className)}
|
||||
className={cn(
|
||||
autocompleteControlVariants({ size }),
|
||||
'group/autocomplete-trigger [&[data-readonly]:not([data-disabled])]:w-auto [&[data-readonly]:not([data-disabled])]:gap-1 [&[data-readonly]:not([data-disabled])]:px-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="i-ri-lock-line hidden size-4 shrink-0 group-[[data-readonly]:not([data-disabled])]/autocomplete-trigger:block"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{children ?? <span className="i-ri-arrow-down-s-line size-4" aria-hidden="true" />}
|
||||
</BaseAutocomplete.Trigger>
|
||||
)
|
||||
@@ -226,6 +233,7 @@ function AutocompleteClear({
|
||||
}
|
||||
className={cn(
|
||||
autocompleteControlVariants({ size }),
|
||||
'group-data-readonly/autocomplete:hidden',
|
||||
'data-ending-style:opacity-0 data-starting-style:opacity-0',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -257,8 +257,8 @@ export const WithDisabledItem: Story = {
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<div className={triggerWidth}>
|
||||
<Select defaultValue="seattle">
|
||||
<SelectTrigger aria-label="City" disabled>
|
||||
<Select defaultValue="seattle" disabled>
|
||||
<SelectTrigger aria-label="City">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -296,6 +296,121 @@ export const ReadOnly: Story = {
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
play: async ({ canvas, canvasElement, userEvent }) => {
|
||||
const trigger = canvas.getByRole('combobox', { name: 'City' })
|
||||
const body = within(canvasElement.ownerDocument.body)
|
||||
|
||||
await expect(trigger).toHaveAttribute('aria-readonly', 'true')
|
||||
await userEvent.click(trigger)
|
||||
const list = await body.findByRole('listbox')
|
||||
await expect(list).toHaveAttribute('aria-readonly', 'true')
|
||||
const newYork = within(list).getByRole('option', { name: 'New York' })
|
||||
await userEvent.click(newYork)
|
||||
await expect(trigger).toHaveTextContent('Seattle')
|
||||
await expect(newYork).toHaveAttribute('aria-selected', 'false')
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
await userEvent.keyboard('{End}{Enter}')
|
||||
await expect(newYork).toHaveFocus()
|
||||
await expect(trigger).toHaveTextContent('Seattle')
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await waitFor(() => expect(body.queryByRole('listbox')).not.toBeInTheDocument())
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
|
||||
await userEvent.keyboard('n')
|
||||
await expect(trigger).toHaveTextContent('Seattle')
|
||||
await userEvent.keyboard('{ArrowDown}')
|
||||
await waitFor(() => expect(body.getByRole('listbox')).toBeVisible())
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
},
|
||||
}
|
||||
|
||||
export const InteractionStates: Story = {
|
||||
render: () => (
|
||||
<div className="group flex flex-wrap gap-6">
|
||||
{[
|
||||
{ label: 'Editable', readOnly: false, disabled: false },
|
||||
{ label: 'Read-only', readOnly: true, disabled: false },
|
||||
{ label: 'Disabled', readOnly: false, disabled: true },
|
||||
{ label: 'Read-only and disabled', readOnly: true, disabled: true },
|
||||
].map(({ label, readOnly, disabled }) => (
|
||||
<div key={label} className="flex w-52 flex-col gap-3">
|
||||
<span className="system-xs-medium text-text-tertiary">{label}</span>
|
||||
{(['small', 'medium', 'large'] as const).map((size) => (
|
||||
<Select
|
||||
key={size}
|
||||
items={cityItems}
|
||||
defaultValue="seattle"
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger size={size} aria-label={`${label} ${size} city`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cityItems.map(({ label: city, value }) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
<SelectItemText>{city}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const ReadOnlyMultiple: Story = {
|
||||
render: () => (
|
||||
<div className={triggerWidth}>
|
||||
<Select<string, true> multiple defaultValue={['seattle', 'tokyo']} items={cityItems} readOnly>
|
||||
<SelectTrigger aria-label="Cities">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cityItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<SelectItemText>{item.label}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
play: async ({ canvas, canvasElement, userEvent }) => {
|
||||
const trigger = canvas.getByRole('combobox', { name: 'Cities' })
|
||||
const body = within(canvasElement.ownerDocument.body)
|
||||
|
||||
trigger.focus()
|
||||
await userEvent.keyboard('{ArrowDown}')
|
||||
const list = await body.findByRole('listbox')
|
||||
const options = within(list)
|
||||
await expect(list).toHaveAttribute('aria-multiselectable', 'true')
|
||||
await expect(list).toHaveAttribute('aria-readonly', 'true')
|
||||
await userEvent.click(options.getByRole('option', { name: 'Seattle' }))
|
||||
await userEvent.click(options.getByRole('option', { name: 'New York' }))
|
||||
await userEvent.keyboard('{End}{Enter}')
|
||||
|
||||
await expect(options.getAllByRole('option', { selected: true })).toHaveLength(2)
|
||||
await expect(options.getByRole('option', { name: 'Seattle' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
)
|
||||
await expect(options.getByRole('option', { name: 'Tokyo' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
)
|
||||
await expect(options.getByRole('option', { name: 'Paris' })).toHaveFocus()
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await waitFor(() => expect(body.queryByRole('listbox')).not.toBeInTheDocument())
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
},
|
||||
}
|
||||
|
||||
const ControlledDemo = () => {
|
||||
|
||||
@@ -62,11 +62,10 @@ type SelectGroupProps = BaseSelect.Group.Props
|
||||
|
||||
const selectTriggerVariants = cva(
|
||||
[
|
||||
'group flex w-full items-center border-0 bg-components-input-bg-normal text-start text-components-input-text-filled outline-hidden',
|
||||
'group/select-trigger flex w-full items-center border-0 bg-components-input-bg-normal text-start text-components-input-text-filled outline-hidden',
|
||||
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt',
|
||||
'focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
'data-placeholder:text-components-input-text-placeholder',
|
||||
'data-readonly:cursor-default data-readonly:bg-components-input-bg-normal data-readonly:hover:bg-components-input-bg-normal',
|
||||
'data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled data-disabled:hover:bg-components-input-bg-disabled',
|
||||
'data-disabled:data-placeholder:text-components-input-text-disabled',
|
||||
],
|
||||
@@ -94,7 +93,11 @@ function SelectTrigger({ className, children, size, ...props }: SelectTriggerPro
|
||||
return (
|
||||
<BaseSelect.Trigger className={cn(selectTriggerVariants({ size, className }))} {...props}>
|
||||
<span className="min-w-0 grow truncate">{children}</span>
|
||||
<BaseSelect.Icon className="shrink-0 text-text-quaternary transition-colors group-hover:text-text-secondary group-data-readonly:hidden data-popup-open:text-text-secondary">
|
||||
<span
|
||||
className="me-1 i-ri-lock-line hidden size-4 shrink-0 text-text-tertiary group-[[data-readonly]:not([data-disabled])]/select-trigger:block"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<BaseSelect.Icon className="shrink-0 text-text-quaternary transition-colors group-[:hover:not([data-disabled])]/select-trigger:text-text-secondary data-popup-open:text-text-secondary">
|
||||
<span className="i-ri-arrow-down-s-line h-4 w-4" aria-hidden="true" />
|
||||
</BaseSelect.Icon>
|
||||
</BaseSelect.Trigger>
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
SelectPortal,
|
||||
SelectPositioner,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import dayjs from 'dayjs'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
@@ -35,7 +35,6 @@ type Props = Readonly<{
|
||||
|
||||
const RangeSelector: FC<Props> = ({ isCustomRange, ranges, onSelect }) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const items = useMemo<TimePeriodOption[]>(() => {
|
||||
return ranges.map((range) => ({
|
||||
...range,
|
||||
@@ -69,8 +68,6 @@ const RangeSelector: FC<Props> = ({ isCustomRange, ranges, onSelect }) => {
|
||||
return (
|
||||
<Select<number>
|
||||
value={selectedItem?.value ?? null}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onValueChange={(nextValue) => {
|
||||
if (nextValue == null) return
|
||||
const nextItem = items.find((item) => item.value === nextValue)
|
||||
@@ -79,15 +76,12 @@ const RangeSelector: FC<Props> = ({ isCustomRange, ranges, onSelect }) => {
|
||||
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="flex h-8 cursor-pointer items-center space-x-1.5 rounded-lg bg-components-input-bg-normal pr-2 pl-3 group-data-popup-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="size-4 text-text-quaternary group-data-popup-open:text-text-secondary" />
|
||||
</div>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue>
|
||||
{isCustomRange
|
||||
? t(($) => $['filter.period.custom'], { ns: 'appLog' })
|
||||
: selectedItem?.name}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectPositioner className="-translate-x-6">
|
||||
|
||||
@@ -262,16 +262,28 @@ describe('SelectPackage', () => {
|
||||
expect(onSelectVersion).toHaveBeenCalledWith({ value: 'v0.9.0', name: 'v0.9.0' })
|
||||
})
|
||||
|
||||
it('should select a valid package option', async () => {
|
||||
it('should only allow package selection after a version is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelectPackage = vi.fn()
|
||||
renderSelectPackage({
|
||||
selectedVersion: 'v1.0.0',
|
||||
const props = {
|
||||
...createDefaultProps(),
|
||||
updatePayload: createUpdatePayload(),
|
||||
onSelectPackage,
|
||||
})
|
||||
}
|
||||
const { rerender } = render(<SelectPackage {...props} packages={[]} />)
|
||||
|
||||
const section = getSection('plugin.installFromGitHub.selectPackage')
|
||||
await user.click(within(section).getByRole('combobox'))
|
||||
const packageTrigger = screen.getByRole('combobox', {
|
||||
name: 'plugin.installFromGitHub.selectPackage',
|
||||
})
|
||||
expect(packageTrigger).toBeDisabled()
|
||||
await user.click(packageTrigger)
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
expect(onSelectPackage).not.toHaveBeenCalled()
|
||||
|
||||
rerender(<SelectPackage {...props} selectedVersion="v1.0.0" />)
|
||||
|
||||
expect(packageTrigger).toBeEnabled()
|
||||
await user.click(packageTrigger)
|
||||
await user.click(await screen.findByRole('option', { name: 'plugin.tar.gz' }))
|
||||
|
||||
expect(onSelectPackage).toHaveBeenCalledWith({
|
||||
@@ -472,27 +484,6 @@ describe('SelectPackage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// PortalSelect Readonly State Tests
|
||||
// ================================
|
||||
describe('PortalSelect Readonly State', () => {
|
||||
it('should make package select readonly when no version selected', () => {
|
||||
renderSelectPackage({ selectedVersion: '' })
|
||||
|
||||
// When no version is selected, package select should be readonly
|
||||
const trigger = screen.getAllByRole('combobox')[1]
|
||||
expect(trigger).toHaveAttribute('aria-readonly', 'true')
|
||||
})
|
||||
|
||||
it('should make package select active when version is selected', () => {
|
||||
renderSelectPackage({ selectedVersion: 'v1.0.0' })
|
||||
|
||||
// When version is selected, package select should be active
|
||||
const trigger = screen.getAllByRole('combobox')[1]
|
||||
expect(trigger).not.toHaveAttribute('aria-readonly', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// installedValue Props Tests
|
||||
// ================================
|
||||
|
||||
@@ -128,7 +128,7 @@ const SelectPackage: React.FC<SelectPackageProps> = ({
|
||||
<Field name="package" className="gap-4 self-stretch">
|
||||
<Select
|
||||
value={selectedPackageOption?.value ?? null}
|
||||
readOnly={!selectedVersion}
|
||||
disabled={!selectedVersion}
|
||||
onValueChange={(value) => {
|
||||
if (value == null) return
|
||||
const selectedItem = packages.find((item) => item.value === value)
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
FormOption,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { AppSelectorValue } from '@/app/components/plugins/plugin-detail-panel/app-selector'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
@@ -292,6 +292,108 @@ describe('FormInputItem branches', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ mode: 'single', multiple: false },
|
||||
{ mode: 'multiple', multiple: true },
|
||||
])(
|
||||
'should let users inspect read-only static $mode selections without changing them',
|
||||
async ({ multiple }) => {
|
||||
const user = userEvent.setup()
|
||||
const { onChange } = renderFormInputItem({
|
||||
readOnly: true,
|
||||
schema: createSchema({
|
||||
multiple,
|
||||
type: FormTypeEnum.select,
|
||||
options: ['alpha', 'beta', 'gamma', 'delta'].map((value) => createOption(value)),
|
||||
}),
|
||||
value: {
|
||||
field: {
|
||||
type: VarKindType.constant,
|
||||
value: multiple ? ['alpha', 'beta', 'gamma'] : 'alpha',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const trigger = screen.getByRole('combobox')
|
||||
expect(trigger).toHaveTextContent(multiple ? '3 selected' : 'alpha')
|
||||
await user.click(trigger)
|
||||
|
||||
expect(await screen.findByRole('listbox')).toHaveAttribute('aria-readonly', 'true')
|
||||
for (const name of multiple ? ['alpha', 'beta', 'gamma'] : ['alpha'])
|
||||
expect(screen.getByRole('option', { name })).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
await user.click(screen.getByRole('option', { name: 'alpha' }))
|
||||
await user.click(screen.getByRole('option', { name: 'delta' }))
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('option', { name: 'alpha' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('option', { name: 'delta' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
)
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument())
|
||||
expect(trigger).toHaveTextContent(multiple ? '3 selected' : 'alpha')
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ mode: 'single', multiple: false },
|
||||
{ mode: 'multiple', multiple: true },
|
||||
])(
|
||||
'should disable loading dynamic $mode selections then allow read-only inspection',
|
||||
async ({ multiple }) => {
|
||||
const user = userEvent.setup()
|
||||
let resolveOptions!: (result: { options: FormOption[] }) => void
|
||||
mockFetchDynamicOptions.mockReturnValueOnce(
|
||||
new Promise<{ options: FormOption[] }>((resolve) => {
|
||||
resolveOptions = resolve
|
||||
}),
|
||||
)
|
||||
const { onChange } = renderFormInputItem({
|
||||
readOnly: true,
|
||||
schema: createSchema({ multiple, type: FormTypeEnum.dynamicSelect }),
|
||||
currentProvider: { plugin_id: 'provider-1', name: 'provider-1' } as never,
|
||||
currentTool: { name: 'tool-1' } as never,
|
||||
providerType: PluginCategoryEnum.tool,
|
||||
value: {
|
||||
field: {
|
||||
type: VarKindType.constant,
|
||||
value: multiple ? ['alpha', 'beta', 'gamma'] : 'alpha',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const trigger = screen.getByRole('combobox')
|
||||
expect(trigger).toBeDisabled()
|
||||
await user.click(trigger)
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
resolveOptions({
|
||||
options: ['alpha', 'beta', 'gamma', 'delta'].map((value) => createOption(value)),
|
||||
})
|
||||
})
|
||||
|
||||
expect(trigger).not.toBeDisabled()
|
||||
expect(trigger).toHaveTextContent(multiple ? '3 selected' : 'alpha')
|
||||
await user.click(trigger)
|
||||
expect(await screen.findByRole('listbox')).toHaveAttribute('aria-readonly', 'true')
|
||||
for (const name of multiple ? ['alpha', 'beta', 'gamma'] : ['alpha'])
|
||||
expect(screen.getByRole('option', { name })).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
await user.click(screen.getByRole('option', { name: 'alpha' }))
|
||||
await user.click(screen.getByRole('option', { name: 'delta' }))
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('option', { name: 'alpha' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('option', { name: 'delta' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it('should recover when fetching dynamic tool options fails', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockFetchDynamicOptions.mockRejectedValueOnce(new Error('network'))
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('form-input-item sections', () => {
|
||||
it('should render a loading multi-select label', () => {
|
||||
renderWorkflowComponent(
|
||||
<MultiSelectField
|
||||
disabled={false}
|
||||
readOnly={false}
|
||||
isLoading
|
||||
items={[{ name: 'Alpha', value: 'alpha' }]}
|
||||
onChange={vi.fn()}
|
||||
@@ -37,7 +37,7 @@ describe('form-input-item sections', () => {
|
||||
|
||||
renderWorkflowComponent(
|
||||
<MultiSelectField
|
||||
disabled={false}
|
||||
readOnly={false}
|
||||
items={[
|
||||
{ name: 'Alpha', value: 'alpha', icon: '/alpha.svg' },
|
||||
{ name: 'Beta', value: 'beta' },
|
||||
|
||||
@@ -19,7 +19,7 @@ import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
|
||||
type MultiSelectFieldProps = {
|
||||
disabled: boolean
|
||||
readOnly: boolean
|
||||
isLoading?: boolean
|
||||
items: SelectItem[]
|
||||
onChange: (value: string[]) => void
|
||||
@@ -36,7 +36,7 @@ const LoadingIndicator = () => (
|
||||
)
|
||||
|
||||
export const MultiSelectField: FC<MultiSelectFieldProps> = ({
|
||||
disabled,
|
||||
readOnly,
|
||||
isLoading = false,
|
||||
items,
|
||||
onChange,
|
||||
@@ -60,7 +60,13 @@ export const MultiSelectField: FC<MultiSelectFieldProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Select multiple value={value} onValueChange={onChange} disabled={disabled || isLoading}>
|
||||
<Select
|
||||
multiple
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
readOnly={readOnly}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<div className="min-w-0 grow">
|
||||
<SelectTrigger aria-label={placeholder || selectedLabel || 'Options'}>
|
||||
<span className={cn('flex min-w-0 items-center', textClassName)}>
|
||||
|
||||
@@ -392,7 +392,7 @@ const FormInputItem: FC<Props> = ({
|
||||
{isSelect && isConstant && !isMultipleSelect && (
|
||||
<Select
|
||||
value={selectedStaticOption?.value ?? null}
|
||||
disabled={readOnly}
|
||||
readOnly={readOnly}
|
||||
onValueChange={(value) => value && handleValueChange(value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 min-w-0 grow">
|
||||
@@ -411,7 +411,7 @@ const FormInputItem: FC<Props> = ({
|
||||
)}
|
||||
{isSelect && isConstant && isMultipleSelect && (
|
||||
<MultiSelectField
|
||||
disabled={readOnly}
|
||||
readOnly={readOnly}
|
||||
value={(varInput?.value as string[] | undefined) || []}
|
||||
items={staticSelectItems}
|
||||
onChange={handleValueChange}
|
||||
@@ -422,7 +422,8 @@ const FormInputItem: FC<Props> = ({
|
||||
{isDynamicSelect && !isMultipleSelect && (
|
||||
<Select
|
||||
value={selectedDynamicOption?.value ?? null}
|
||||
disabled={readOnly || isLoadingOptions}
|
||||
readOnly={readOnly}
|
||||
disabled={isLoadingOptions}
|
||||
onValueChange={(value) => value && handleValueChange(value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 min-w-0 grow">
|
||||
@@ -442,7 +443,7 @@ const FormInputItem: FC<Props> = ({
|
||||
)}
|
||||
{isDynamicSelect && isMultipleSelect && (
|
||||
<MultiSelectField
|
||||
disabled={readOnly || isLoadingOptions}
|
||||
readOnly={readOnly}
|
||||
isLoading={isLoadingOptions}
|
||||
value={(varInput?.value as string[] | undefined) || []}
|
||||
items={dynamicSelectItems}
|
||||
|
||||
Reference in New Issue
Block a user