mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 07:52:50 +08:00
feat(ui): migrate radio to Base UI and update web callsites (#36451)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
7d0d9019d8
commit
9f9cb4d17e
@ -1574,26 +1574,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/radio-card/index.stories.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/radio/component/group/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/radio/context/index.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/radio/index.stories.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/search-input/index.stories.tsx": {
|
||||
"no-console": {
|
||||
"count": 3
|
||||
|
||||
@ -73,6 +73,14 @@
|
||||
"types": "./src/number-field/index.tsx",
|
||||
"import": "./src/number-field/index.tsx"
|
||||
},
|
||||
"./radio": {
|
||||
"types": "./src/radio/index.tsx",
|
||||
"import": "./src/radio/index.tsx"
|
||||
},
|
||||
"./radio-group": {
|
||||
"types": "./src/radio-group/index.tsx",
|
||||
"import": "./src/radio-group/index.tsx"
|
||||
},
|
||||
"./popover": {
|
||||
"types": "./src/popover/index.tsx",
|
||||
"import": "./src/popover/index.tsx"
|
||||
|
||||
82
packages/dify-ui/src/radio-group/__tests__/index.spec.tsx
Normal file
82
packages/dify-ui/src/radio-group/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { useState } from 'react'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '../../field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '../../fieldset'
|
||||
import { Radio } from '../../radio'
|
||||
import { RadioGroup } from '../index'
|
||||
|
||||
const clickElement = (element: HTMLElement | SVGElement) => {
|
||||
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
}
|
||||
|
||||
describe('RadioGroup', () => {
|
||||
it('should manage a controlled single selection', async () => {
|
||||
function StorageDemo() {
|
||||
const [value, setValue] = useState('ssd')
|
||||
|
||||
return (
|
||||
<FieldRoot name="storageType">
|
||||
<FieldsetRoot render={<RadioGroup value={value} onValueChange={setValue} />}>
|
||||
<FieldsetLegend>Storage type</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<FieldLabel>
|
||||
<Radio value="ssd" />
|
||||
SSD
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel>
|
||||
<Radio value="hdd" />
|
||||
HDD
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
const screen = await render(<StorageDemo />)
|
||||
|
||||
await expect.element(screen.getByRole('radio', { name: 'SSD' })).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
clickElement(screen.getByRole('radio', { name: 'HDD' }).element())
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(screen.getByRole('radio', { name: 'SSD' })).toHaveAttribute('aria-checked', 'false')
|
||||
await expect.element(screen.getByRole('radio', { name: 'HDD' })).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
it('should compose with Dify UI Field and Fieldset without losing labels', async () => {
|
||||
const onValueChange = vi.fn()
|
||||
const screen = await render(
|
||||
<FieldRoot name="storageType">
|
||||
<FieldsetRoot render={<RadioGroup value="ssd" onValueChange={onValueChange} />}>
|
||||
<FieldsetLegend>Storage type</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<FieldLabel>
|
||||
<Radio value="ssd" />
|
||||
SSD
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel>
|
||||
<Radio value="hdd" />
|
||||
HDD
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('radiogroup', { name: 'Storage type' })).toBeInTheDocument()
|
||||
|
||||
const hdd = screen.getByRole('radio', { name: 'HDD' })
|
||||
await expect.element(hdd).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
clickElement(hdd.element())
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledTimes(1)
|
||||
expect(onValueChange.mock.calls[0]?.[0]).toBe('hdd')
|
||||
})
|
||||
})
|
||||
217
packages/dify-ui/src/radio-group/index.stories.tsx
Normal file
217
packages/dify-ui/src/radio-group/index.stories.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { useState } from 'react'
|
||||
import { RadioGroup } from '.'
|
||||
import {
|
||||
FieldDescription,
|
||||
FieldItem,
|
||||
FieldLabel,
|
||||
FieldRoot,
|
||||
} from '../field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '../fieldset'
|
||||
import { Radio, RadioControl, RadioRoot } from '../radio'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Form/RadioGroup',
|
||||
component: RadioGroup,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'RadioGroup primitive built on Base UI. For normal form rows, compose FieldRoot, FieldsetRoot, FieldLabel, RadioGroup, and Radio. For option cards, make the card itself a RadioRoot with variant="unstyled" and render RadioControl inside it.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof RadioGroup>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function StandardFormRowsDemo() {
|
||||
const [value, setValue] = useState('vector')
|
||||
|
||||
return (
|
||||
<FieldRoot name="retrievalIndex" className="w-80">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup value={value} onValueChange={setValue} className="flex-col items-start gap-3" />
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend>Retrieval index</FieldsetLegend>
|
||||
{[
|
||||
{ value: 'vector', label: 'Vector storage' },
|
||||
{ value: 'keyword', label: 'Keyword index' },
|
||||
{ value: 'hybrid', label: 'Hybrid retrieval' },
|
||||
].map(option => (
|
||||
<FieldItem key={option.value}>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Radio value={option.value} />
|
||||
{option.label}
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
))}
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export const StandardFormRows: Story = {
|
||||
render: () => <StandardFormRowsDemo />,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Default form composition. Most product code should use this shape: RadioGroup owns value, FieldsetLegend names the group, and FieldLabel makes each row clickable.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function BooleanInlineDemo() {
|
||||
const [value, setValue] = useState(true)
|
||||
|
||||
return (
|
||||
<FieldRoot name="streaming" className="w-80">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup<boolean> value={value} onValueChange={setValue} className="gap-3" />
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend>Streaming output</FieldsetLegend>
|
||||
<div className="flex items-center gap-3">
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
|
||||
<Radio value={true} />
|
||||
True
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
|
||||
<Radio value={false} />
|
||||
False
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</div>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export const BooleanInline: Story = {
|
||||
render: () => <BooleanInlineDemo />,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Compact boolean radio fields. This is the pattern used by model parameters and dynamic boolean schema fields.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function OptionCardsDemo() {
|
||||
const [value, setValue] = useState('default')
|
||||
|
||||
return (
|
||||
<FieldRoot name="promptMode" className="w-100">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup value={value} onValueChange={setValue} className="flex-col items-stretch gap-3" />
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend>Prompt mode</FieldsetLegend>
|
||||
{[
|
||||
{
|
||||
value: 'default',
|
||||
title: 'Default prompt',
|
||||
description: 'Use the built-in prompt for consistent output.',
|
||||
},
|
||||
{
|
||||
value: 'custom',
|
||||
title: 'Custom prompt',
|
||||
description: 'Write a prompt for this app and keep full control.',
|
||||
},
|
||||
].map(option => (
|
||||
<RadioRoot
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
variant="unstyled"
|
||||
nativeButton
|
||||
render={<button type="button" />}
|
||||
className="w-full rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg p-4 text-left transition-colors hover:bg-state-base-hover data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="system-sm-semibold text-text-primary">
|
||||
{option.title}
|
||||
</div>
|
||||
<div className="mt-1 system-xs-regular text-text-tertiary">
|
||||
{option.description}
|
||||
</div>
|
||||
</div>
|
||||
<RadioControl aria-hidden="true" />
|
||||
</div>
|
||||
</RadioRoot>
|
||||
))}
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export const OptionCards: Story = {
|
||||
render: () => <OptionCardsDemo />,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Use RadioRoot with variant="unstyled" when the entire option card is the radio. RadioControl renders the visual dot inside the card.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function DynamicFormFieldDemo() {
|
||||
const options = [
|
||||
{ value: 'automatic', label: 'Automatic' },
|
||||
{ value: 'high_quality', label: 'High quality' },
|
||||
{ value: 'economy', label: 'Economy' },
|
||||
]
|
||||
const [selected, setSelected] = useState('automatic')
|
||||
|
||||
return (
|
||||
<FieldRoot name="generation_mode" className="flex w-80 flex-col gap-2">
|
||||
<FieldDescription className="body-xs-regular text-text-tertiary">
|
||||
This mirrors Dify dynamic form fields where radio options are controlled by schema and persisted as a single value.
|
||||
</FieldDescription>
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup
|
||||
value={selected}
|
||||
onValueChange={setSelected}
|
||||
className="flex-col items-start gap-2 rounded-lg border border-components-panel-border bg-components-panel-bg p-3"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend className="system-sm-medium text-text-secondary">
|
||||
Generation mode
|
||||
</FieldsetLegend>
|
||||
{options.map(option => (
|
||||
<FieldItem key={option.value}>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Radio value={option.value} />
|
||||
{option.label}
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
))}
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export const DynamicFormField: Story = {
|
||||
render: () => <DynamicFormFieldDemo />,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Matches Dify form composition: Field and Fieldset provide group labeling while RadioGroup owns controlled single-selection state.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
23
packages/dify-ui/src/radio-group/index.tsx
Normal file
23
packages/dify-ui/src/radio-group/index.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import type { RadioGroup as BaseRadioGroupNS } from '@base-ui/react/radio-group'
|
||||
import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group'
|
||||
import { cn } from '../cn'
|
||||
|
||||
export type RadioGroupProps<Value = string>
|
||||
= Omit<BaseRadioGroupNS.Props<Value>, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function RadioGroup<Value = string>({
|
||||
className,
|
||||
...props
|
||||
}: RadioGroupProps<Value>) {
|
||||
return (
|
||||
<BaseRadioGroup
|
||||
className={cn('flex items-center gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
178
packages/dify-ui/src/radio/__tests__/index.spec.tsx
Normal file
178
packages/dify-ui/src/radio/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '../../field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '../../fieldset'
|
||||
import { RadioGroup } from '../../radio-group'
|
||||
import {
|
||||
Radio,
|
||||
RadioControl,
|
||||
RadioIndicator,
|
||||
RadioRoot,
|
||||
RadioSkeleton,
|
||||
} from '../index'
|
||||
|
||||
const clickElement = (element: HTMLElement | SVGElement) => {
|
||||
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
}
|
||||
|
||||
type TestRadioGroupProps = ComponentProps<typeof RadioGroup> & {
|
||||
children: ReactNode
|
||||
label: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
function TestRadioGroup({
|
||||
children,
|
||||
label,
|
||||
name = 'radioField',
|
||||
...props
|
||||
}: TestRadioGroupProps) {
|
||||
return (
|
||||
<FieldRoot name={name}>
|
||||
<FieldsetRoot render={<RadioGroup {...props} />}>
|
||||
<FieldsetLegend>{label}</FieldsetLegend>
|
||||
{children}
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
type TestRadioOptionProps = ComponentProps<typeof Radio> & {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function TestRadioOption({
|
||||
children,
|
||||
...props
|
||||
}: TestRadioOptionProps) {
|
||||
return (
|
||||
<FieldItem>
|
||||
<FieldLabel>
|
||||
<Radio {...props} />
|
||||
{children}
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Radio', () => {
|
||||
it('should render unchecked and checked radios with Base UI semantics', async () => {
|
||||
const screen = await render(
|
||||
<TestRadioGroup defaultValue="ssd" label="Storage type">
|
||||
<TestRadioOption value="ssd">SSD</TestRadioOption>
|
||||
<TestRadioOption value="hdd">HDD</TestRadioOption>
|
||||
</TestRadioGroup>,
|
||||
)
|
||||
|
||||
const ssd = screen.getByRole('radio', { name: 'SSD' })
|
||||
const hdd = screen.getByRole('radio', { name: 'HDD' })
|
||||
|
||||
await expect.element(ssd).toHaveAttribute('aria-checked', 'true')
|
||||
await expect.element(ssd).toHaveAttribute('data-checked', '')
|
||||
await expect.element(ssd).toHaveClass('data-checked:border-components-radio-border-checked')
|
||||
await expect.element(hdd).toHaveAttribute('aria-checked', 'false')
|
||||
await expect.element(hdd).toHaveAttribute('data-unchecked', '')
|
||||
})
|
||||
|
||||
it('should call onValueChange and update uncontrolled state when selected', async () => {
|
||||
const onValueChange = vi.fn()
|
||||
const screen = await render(
|
||||
<TestRadioGroup defaultValue="ssd" label="Storage type" onValueChange={onValueChange}>
|
||||
<TestRadioOption value="ssd">SSD</TestRadioOption>
|
||||
<TestRadioOption value="hdd">HDD</TestRadioOption>
|
||||
</TestRadioGroup>,
|
||||
)
|
||||
|
||||
clickElement(screen.getByRole('radio', { name: 'HDD' }).element())
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledTimes(1)
|
||||
expect(onValueChange.mock.calls[0]?.[0]).toBe('hdd')
|
||||
await expect.element(screen.getByRole('radio', { name: 'HDD' })).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('should ignore interaction when disabled', async () => {
|
||||
const onValueChange = vi.fn()
|
||||
const screen = await render(
|
||||
<TestRadioGroup defaultValue="ssd" label="Storage type" onValueChange={onValueChange}>
|
||||
<TestRadioOption value="ssd">SSD</TestRadioOption>
|
||||
<TestRadioOption value="hdd" disabled>HDD</TestRadioOption>
|
||||
</TestRadioGroup>,
|
||||
)
|
||||
|
||||
const hdd = screen.getByRole('radio', { name: 'HDD' })
|
||||
await expect.element(hdd).toHaveAttribute('data-disabled', '')
|
||||
await expect.element(hdd).toHaveClass('data-disabled:cursor-not-allowed')
|
||||
|
||||
clickElement(hdd.element())
|
||||
|
||||
expect(onValueChange).not.toHaveBeenCalled()
|
||||
await expect.element(hdd).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('should submit the selected group value through the hidden input', async () => {
|
||||
const screen = await render(
|
||||
<form>
|
||||
<TestRadioGroup defaultValue="ssd" label="Storage type" name="storageType">
|
||||
<TestRadioOption value="ssd">SSD</TestRadioOption>
|
||||
<TestRadioOption value="hdd">HDD</TestRadioOption>
|
||||
</TestRadioGroup>
|
||||
</form>,
|
||||
)
|
||||
const form = screen.container.querySelector<HTMLFormElement>('form')
|
||||
expect(form).not.toBeNull()
|
||||
if (!form)
|
||||
return
|
||||
|
||||
const data = new FormData(form)
|
||||
|
||||
expect(data.get('storageType')).toBe('ssd')
|
||||
})
|
||||
|
||||
it('should support custom compound composition with RadioRoot and RadioIndicator', async () => {
|
||||
const screen = await render(
|
||||
<TestRadioGroup defaultValue="custom" label="Custom">
|
||||
<FieldItem>
|
||||
<FieldLabel>
|
||||
<RadioRoot value="custom" className="custom-root">
|
||||
<RadioIndicator className="custom-indicator" keepMounted />
|
||||
</RadioRoot>
|
||||
Custom
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</TestRadioGroup>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('radio', { name: 'Custom' })).toHaveClass('custom-root')
|
||||
expect(screen.container.querySelector('.custom-indicator')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should support unstyled roots with a visual RadioControl for option cards', async () => {
|
||||
const screen = await render(
|
||||
<RadioGroup defaultValue="card" aria-label="Card choice">
|
||||
<RadioRoot
|
||||
value="card"
|
||||
variant="unstyled"
|
||||
nativeButton
|
||||
render={<button type="button" className="custom-card" />}
|
||||
>
|
||||
<span>Card option</span>
|
||||
<RadioControl className="custom-control" />
|
||||
</RadioRoot>
|
||||
</RadioGroup>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('radio', { name: 'Card option' })).toHaveClass('custom-card')
|
||||
expect(screen.container.querySelector('.custom-control')).toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('radio', { name: 'Card option' })).toHaveAttribute('data-checked', '')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RadioSkeleton', () => {
|
||||
it('should render a visual placeholder without radio semantics', async () => {
|
||||
const screen = await render(<RadioSkeleton />)
|
||||
const skeleton = screen.container.querySelector<HTMLElement>('.rounded-full')
|
||||
|
||||
expect(screen.container.querySelector('[role="radio"]')).not.toBeInTheDocument()
|
||||
await expect.element(skeleton).toHaveClass('rounded-full', 'opacity-20')
|
||||
})
|
||||
})
|
||||
147
packages/dify-ui/src/radio/index.stories.tsx
Normal file
147
packages/dify-ui/src/radio/index.stories.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Radio,
|
||||
RadioSkeleton,
|
||||
} from '.'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '../field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '../fieldset'
|
||||
import { RadioGroup } from '../radio-group'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Form/Radio',
|
||||
component: Radio,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Radio primitive built on Base UI. It preserves RadioGroup selection, hidden input, disabled, and form semantics while applying the Dify 16px radio design from Figma. Import from `@langgenius/dify-ui/radio` and place radios inside `RadioGroup` from `@langgenius/dify-ui/radio-group`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
disabled: false,
|
||||
value: 'ssd',
|
||||
},
|
||||
argTypes: {
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disables user interaction and exposes Base UI disabled state attributes.',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Radio>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function RadioDemo(args: Partial<ComponentProps<typeof Radio>>) {
|
||||
const [value, setValue] = useState('ssd')
|
||||
|
||||
return (
|
||||
<FieldRoot name="storageType">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup value={value} onValueChange={setValue} className="flex-col items-start gap-3" />
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend>Storage type</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Radio {...args} value="ssd" />
|
||||
SSD
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Radio {...args} value="hdd" />
|
||||
HDD
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: args => <RadioDemo {...args} />,
|
||||
args: {
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
value: 'checked',
|
||||
},
|
||||
render: () => (
|
||||
<FieldRoot name="disabledStates">
|
||||
<FieldsetRoot render={<RadioGroup value="checked" className="flex-col items-start gap-3" />}>
|
||||
<FieldsetLegend>Disabled states</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Radio value="unchecked" disabled />
|
||||
Disabled unchecked
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Radio value="checked" disabled />
|
||||
Disabled checked
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
),
|
||||
}
|
||||
|
||||
export const StateMatrix: Story = {
|
||||
args: {
|
||||
value: 'checked',
|
||||
},
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-3">
|
||||
<FieldRoot name="radioStates">
|
||||
<FieldsetRoot render={<RadioGroup value="checked" className="flex-col items-start gap-3" />}>
|
||||
<FieldsetLegend>Radio states</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Radio value="unchecked" />
|
||||
Unchecked
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Radio value="checked" />
|
||||
Checked
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Radio value="disabled-unchecked" disabled />
|
||||
Disabled unchecked
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<Radio value="checked" disabled />
|
||||
Disabled checked
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
<div className="flex items-center gap-2 system-sm-medium text-text-secondary">
|
||||
<RadioSkeleton aria-hidden="true" />
|
||||
Skeleton
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'The full visual matrix for Dify radio states. State styling comes from Base UI data attributes such as data-checked and data-disabled.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
105
packages/dify-ui/src/radio/index.tsx
Normal file
105
packages/dify-ui/src/radio/index.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import type { Radio as BaseRadioNS } from '@base-ui/react/radio'
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import { Radio as BaseRadio } from '@base-ui/react/radio'
|
||||
import { cn } from '../cn'
|
||||
|
||||
const radioRootClassName = cn(
|
||||
'inline-flex size-4 shrink-0 touch-manipulation items-center justify-center rounded-full p-0 transition-colors motion-reduce:transition-none',
|
||||
'border border-components-radio-border bg-components-radio-bg shadow-xs shadow-shadow-shadow-3',
|
||||
'hover:border-components-radio-border-hover hover:bg-components-radio-bg-hover',
|
||||
'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-components-input-border-hover focus-visible:ring-offset-0',
|
||||
'data-checked:border-[5px] data-checked:border-components-radio-border-checked data-checked:hover:border-components-radio-border-checked-hover',
|
||||
'data-disabled:cursor-not-allowed data-disabled:border-components-radio-border-disabled data-disabled:bg-components-radio-bg-disabled',
|
||||
'data-disabled:hover:border-components-radio-border-disabled data-disabled:hover:bg-components-radio-bg-disabled',
|
||||
'data-disabled:data-checked:border-[5px] data-disabled:data-checked:border-components-radio-border-checked-disabled',
|
||||
'data-disabled:data-checked:hover:border-components-radio-border-checked-disabled',
|
||||
)
|
||||
|
||||
const radioIndicatorClassName = 'flex items-center justify-center data-unchecked:hidden before:size-1.5 before:rounded-full before:bg-current'
|
||||
|
||||
const radioControlClassName = radioRootClassName
|
||||
|
||||
const radioSkeletonClassName = 'size-4 shrink-0 rounded-full bg-text-quaternary opacity-20'
|
||||
|
||||
export type RadioRootProps<Value = string>
|
||||
= Omit<BaseRadioNS.Root.Props<Value>, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
variant?: 'control' | 'unstyled'
|
||||
}
|
||||
|
||||
export function RadioRoot<Value = string>({
|
||||
className,
|
||||
variant = 'control',
|
||||
...props
|
||||
}: RadioRootProps<Value>) {
|
||||
return (
|
||||
<BaseRadio.Root
|
||||
className={cn(variant === 'control' && radioRootClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type RadioIndicatorProps
|
||||
= Omit<BaseRadioNS.Indicator.Props, 'className' | 'children'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function RadioIndicator({
|
||||
className,
|
||||
...props
|
||||
}: RadioIndicatorProps) {
|
||||
return (
|
||||
<BaseRadio.Indicator
|
||||
className={cn(radioIndicatorClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type RadioControlProps
|
||||
= Omit<RadioIndicatorProps, 'keepMounted'>
|
||||
|
||||
export function RadioControl({
|
||||
className,
|
||||
...props
|
||||
}: RadioControlProps) {
|
||||
return (
|
||||
<BaseRadio.Indicator
|
||||
keepMounted
|
||||
className={cn(radioControlClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type RadioProps<Value = string>
|
||||
= Omit<RadioRootProps<Value>, 'children'>
|
||||
|
||||
export function Radio<Value = string>({
|
||||
...props
|
||||
}: RadioProps<Value>) {
|
||||
return <RadioRoot {...props} />
|
||||
}
|
||||
|
||||
export type RadioSkeletonProps
|
||||
= Omit<HTMLAttributes<HTMLDivElement>, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function RadioSkeleton({
|
||||
className,
|
||||
...props
|
||||
}: RadioSkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(radioSkeletonClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -167,7 +167,7 @@ The Dify community can be found on [Discord community], where you can ask questi
|
||||
[Storybook]: https://storybook.js.org
|
||||
[Vite+]: https://viteplus.dev
|
||||
[Vitest]: https://vitest.dev
|
||||
[index.spec.tsx]: ./app/components/base/radio/__tests__/index.spec.tsx
|
||||
[index.spec.tsx]: ./app/components/base/action-button/__tests__/index.spec.tsx
|
||||
[pnpm]: https://pnpm.io
|
||||
[vinext]: https://github.com/cloudflare/vinext
|
||||
[web/docs/test.md]: ./docs/test.md
|
||||
|
||||
@ -2,11 +2,15 @@
|
||||
import type { FC } from 'react'
|
||||
import type { AgentConfig } from '@/models/debug'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { Radio } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
@ -15,7 +19,6 @@ import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows
|
||||
import { Settings04 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { CuteRobot } from '@/app/components/base/icons/src/vender/solid/communication'
|
||||
import { BubbleText } from '@/app/components/base/icons/src/vender/solid/education'
|
||||
import Radio from '@/app/components/base/radio/ui'
|
||||
import AgentSetting from '../agent/agent-setting'
|
||||
|
||||
type Props = {
|
||||
@ -35,26 +38,26 @@ type ItemProps = {
|
||||
isChecked: boolean
|
||||
description: string
|
||||
Icon: any
|
||||
onClick: (value: string) => void
|
||||
}
|
||||
|
||||
const SelectItem: FC<ItemProps> = ({ text, value, Icon, isChecked, description, onClick, disabled }) => {
|
||||
const SelectItem: FC<ItemProps> = ({ text, value, Icon, isChecked, description, disabled }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(disabled ? 'opacity-50' : 'cursor-pointer', isChecked ? 'border-2 border-indigo-600 shadow-sm' : 'border border-gray-100', 'mb-2 rounded-xl bg-gray-25 p-3 pr-4 hover:bg-gray-50')}
|
||||
onClick={() => !disabled && onClick(value)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-3 rounded-lg bg-indigo-50 p-1">
|
||||
<Icon className="size-4 text-indigo-600" />
|
||||
<FieldItem>
|
||||
<FieldLabel
|
||||
className={cn(disabled ? 'opacity-50' : 'cursor-pointer', isChecked ? 'border-2 border-indigo-600 shadow-sm' : 'border border-gray-100', 'mb-2 rounded-xl bg-gray-25 p-3 pr-4 hover:bg-gray-50')}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-3 rounded-lg bg-indigo-50 p-1">
|
||||
<Icon className="size-4 text-indigo-600" />
|
||||
</div>
|
||||
<div className="text-sm/5 font-medium text-gray-900">{text}</div>
|
||||
</div>
|
||||
<div className="text-sm/5 font-medium text-gray-900">{text}</div>
|
||||
<Radio value={value} disabled={disabled} />
|
||||
</div>
|
||||
<Radio isChecked={isChecked} />
|
||||
</div>
|
||||
<div className="ml-9 text-xs leading-[18px] font-normal text-gray-500">{description}</div>
|
||||
</div>
|
||||
<div className="ml-9 text-xs leading-[18px] font-normal text-gray-500">{description}</div>
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
)
|
||||
}
|
||||
|
||||
@ -127,25 +130,38 @@ const AssistantTypePicker: FC<Props> = ({
|
||||
alignOffset={-2}
|
||||
popupClassName="relative left-0.5 w-[480px] rounded-xl border border-black/8 bg-white p-6 shadow-lg"
|
||||
>
|
||||
<div className="mb-2 text-sm/5 font-semibold text-gray-900">{t('assistantType.name', { ns: 'appDebug' })}</div>
|
||||
<SelectItem
|
||||
Icon={BubbleText}
|
||||
value="chat"
|
||||
disabled={disabled}
|
||||
text={t('assistantType.chatAssistant.name', { ns: 'appDebug' })}
|
||||
description={t('assistantType.chatAssistant.description', { ns: 'appDebug' })}
|
||||
isChecked={!isAgent}
|
||||
onClick={handleChange}
|
||||
/>
|
||||
<SelectItem
|
||||
Icon={CuteRobot}
|
||||
value="agent"
|
||||
disabled={disabled}
|
||||
text={t('assistantType.agentAssistant.name', { ns: 'appDebug' })}
|
||||
description={t('assistantType.agentAssistant.description', { ns: 'appDebug' })}
|
||||
isChecked={isAgent}
|
||||
onClick={handleChange}
|
||||
/>
|
||||
<FieldRoot name="assistant_type" className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup
|
||||
value={isAgent ? 'agent' : 'chat'}
|
||||
onValueChange={handleChange}
|
||||
disabled={disabled}
|
||||
className="flex-col items-stretch gap-0"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend className="mb-2 py-0 text-sm/5 font-semibold text-gray-900">
|
||||
{t('assistantType.name', { ns: 'appDebug' })}
|
||||
</FieldsetLegend>
|
||||
<SelectItem
|
||||
Icon={BubbleText}
|
||||
value="chat"
|
||||
disabled={disabled}
|
||||
text={t('assistantType.chatAssistant.name', { ns: 'appDebug' })}
|
||||
description={t('assistantType.chatAssistant.description', { ns: 'appDebug' })}
|
||||
isChecked={!isAgent}
|
||||
/>
|
||||
<SelectItem
|
||||
Icon={CuteRobot}
|
||||
value="agent"
|
||||
disabled={disabled}
|
||||
text={t('assistantType.agentAssistant.name', { ns: 'appDebug' })}
|
||||
description={t('assistantType.agentAssistant.description', { ns: 'appDebug' })}
|
||||
isChecked={isAgent}
|
||||
/>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
{!disabled && agentConfigUI}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@ -8,10 +8,13 @@ import type {
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
|
||||
import { RadioControl, RadioRoot } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Radio from '@/app/components/base/radio/ui'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
@ -145,22 +148,30 @@ const FollowUpSettingModal = ({
|
||||
hideDebugWithMultipleModel
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">
|
||||
{t('feature.suggestedQuestionsAfterAnswer.modal.promptLabel', { ns: 'appDebug' })}
|
||||
</div>
|
||||
<div className="space-y-3" role="radiogroup" aria-label={t('feature.suggestedQuestionsAfterAnswer.modal.promptLabel', { ns: 'appDebug' }) || ''}>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={promptMode === PROMPT_MODE.default}
|
||||
<FieldRoot name="follow_up_prompt_mode" className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup<PromptMode>
|
||||
className="flex-col items-stretch gap-3"
|
||||
value={promptMode}
|
||||
onValueChange={setPromptMode}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend className="mb-1.5 py-0 system-sm-semibold-uppercase text-text-secondary">
|
||||
{t('feature.suggestedQuestionsAfterAnswer.modal.promptLabel', { ns: 'appDebug' })}
|
||||
</FieldsetLegend>
|
||||
<RadioRoot
|
||||
value={PROMPT_MODE.default}
|
||||
variant="unstyled"
|
||||
nativeButton
|
||||
render={<button type="button" />}
|
||||
className={cn(
|
||||
'w-full rounded-xl border p-4 text-left transition-colors',
|
||||
promptMode === PROMPT_MODE.default
|
||||
? 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg'
|
||||
: 'border-components-option-card-option-border bg-components-option-card-option-bg hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => setPromptMode(PROMPT_MODE.default)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
@ -171,9 +182,7 @@ const FollowUpSettingModal = ({
|
||||
{t('feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOptionDescription', { ns: 'appDebug' })}
|
||||
</div>
|
||||
</div>
|
||||
<div aria-hidden="true">
|
||||
<Radio isChecked={promptMode === PROMPT_MODE.default} />
|
||||
</div>
|
||||
<RadioControl aria-hidden="true" />
|
||||
</div>
|
||||
{promptMode === PROMPT_MODE.default && (
|
||||
<div className="mt-3 rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-3 py-2">
|
||||
@ -182,18 +191,18 @@ const FollowUpSettingModal = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={promptMode === PROMPT_MODE.custom}
|
||||
</RadioRoot>
|
||||
<RadioRoot
|
||||
value={PROMPT_MODE.custom}
|
||||
variant="unstyled"
|
||||
nativeButton
|
||||
render={<button type="button" />}
|
||||
className={cn(
|
||||
'w-full rounded-xl border p-4 text-left transition-colors',
|
||||
promptMode === PROMPT_MODE.custom
|
||||
? 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg'
|
||||
: 'border-components-option-card-option-border bg-components-option-card-option-bg hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => setPromptMode(PROMPT_MODE.custom)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
@ -204,9 +213,7 @@ const FollowUpSettingModal = ({
|
||||
{t('feature.suggestedQuestionsAfterAnswer.modal.customPromptOptionDescription', { ns: 'appDebug' })}
|
||||
</div>
|
||||
</div>
|
||||
<div aria-hidden="true">
|
||||
<Radio isChecked={promptMode === PROMPT_MODE.custom} />
|
||||
</div>
|
||||
<RadioControl aria-hidden="true" />
|
||||
</div>
|
||||
{promptMode === PROMPT_MODE.custom && (
|
||||
<Textarea
|
||||
@ -217,9 +224,9 @@ const FollowUpSettingModal = ({
|
||||
placeholder={t('feature.suggestedQuestionsAfterAnswer.modal.promptPlaceholder', { ns: 'appDebug' }) || ''}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</RadioRoot>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-end gap-2">
|
||||
<Button onClick={onCancel}>
|
||||
|
||||
@ -174,6 +174,11 @@ describe('BaseField', () => {
|
||||
onChange,
|
||||
})
|
||||
|
||||
const radioGroup = screen.getByRole('radiogroup', { name: 'Visibility' })
|
||||
expect(radioGroup).toHaveClass('flex')
|
||||
expect(radioGroup).toHaveClass('items-center')
|
||||
expect(radioGroup).not.toHaveClass('flex-col')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Private'))
|
||||
})
|
||||
@ -449,6 +454,11 @@ describe('BaseField', () => {
|
||||
expect(screen.getByText('O1')).toBeInTheDocument()
|
||||
expect(screen.getByText('O2')).toBeInTheDocument()
|
||||
expect(screen.getByText('O3')).toBeInTheDocument()
|
||||
|
||||
const radioGroup = screen.getByRole('radiogroup', { name: 'Vertical' })
|
||||
expect(radioGroup).toHaveClass('flex-col')
|
||||
expect(radioGroup).toHaveClass('items-stretch')
|
||||
expect(radioGroup).not.toHaveClass('items-center')
|
||||
})
|
||||
|
||||
it('should render radio UI when showRadioUI is true', () => {
|
||||
@ -463,7 +473,7 @@ describe('BaseField', () => {
|
||||
},
|
||||
})
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('radio-group')).toBeInTheDocument()
|
||||
expect(screen.getByRole('radiogroup', { name: 'UI Radio' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply disabled styles', () => {
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import type { AnyFieldApi } from '@tanstack/react-form'
|
||||
import type { FieldState, FormSchema, TypeWithI18N } from '@/app/components/base/form/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
|
||||
import { Radio } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -22,8 +26,6 @@ import { CheckboxList } from '@/app/components/base/checkbox-list'
|
||||
import { FormItemValidateStatusEnum, FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import RadioE from '@/app/components/base/radio/ui'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import { useTriggerPluginDynamicOptions } from '@/service/use-triggers'
|
||||
|
||||
@ -180,6 +182,8 @@ const BaseField = ({
|
||||
}, [options, renderI18nObject, watchedValues])
|
||||
|
||||
const value = useStore(field.form.store, s => s.values[field.name])
|
||||
const stringValue = typeof value === 'string' ? value : undefined
|
||||
const booleanValue = typeof value === 'boolean' ? value : undefined
|
||||
|
||||
const { data: dynamicOptionsData, isLoading: isDynamicOptionsLoading, error: dynamicOptionsError } = useTriggerPluginDynamicOptions(
|
||||
dynamicSelectParams || {
|
||||
@ -388,49 +392,82 @@ const BaseField = ({
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.radio && (
|
||||
<div
|
||||
className={cn(
|
||||
memorizedOptions.length < 3 ? 'flex items-center space-x-2' : 'space-y-2',
|
||||
)}
|
||||
data-testid="radio-group"
|
||||
>
|
||||
{
|
||||
memorizedOptions.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
<FieldRoot name={name} className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup
|
||||
value={stringValue}
|
||||
onValueChange={optionValue => handleChange(optionValue)}
|
||||
className={cn(
|
||||
'hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 flex-1 grow cursor-pointer items-center justify-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 system-sm-regular text-text-secondary',
|
||||
value === option.value && 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
inputClassName,
|
||||
memorizedOptions.length >= 3 && 'flex-col items-stretch',
|
||||
)}
|
||||
onClick={() => !disabled && handleChange(option.value)}
|
||||
>
|
||||
{
|
||||
formSchema.showRadioUI && (
|
||||
<RadioE
|
||||
className="mr-2"
|
||||
isChecked={value === option.value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{option.label}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend className="sr-only">{translatedLabel || name}</FieldsetLegend>
|
||||
{
|
||||
memorizedOptions.map(option => (
|
||||
<FieldItem key={option.value} className={cn('min-w-0', memorizedOptions.length < 3 && 'flex-1 grow')}>
|
||||
<FieldLabel
|
||||
className={cn(
|
||||
'hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 system-sm-regular text-text-secondary',
|
||||
value === option.value && 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
inputClassName,
|
||||
)}
|
||||
>
|
||||
{
|
||||
formSchema.showRadioUI && (
|
||||
<Radio
|
||||
className="mr-2"
|
||||
value={option.value}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{!formSchema.showRadioUI && (
|
||||
<Radio
|
||||
className="sr-only"
|
||||
value={option.value}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{option.label}
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
))
|
||||
}
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
{
|
||||
formItemType === FormTypeEnum.boolean && (
|
||||
<Radio.Group
|
||||
className="flex w-fit items-center"
|
||||
value={value}
|
||||
onChange={v => field.handleChange(v)}
|
||||
>
|
||||
<Radio value={true} className="mr-1!">True</Radio>
|
||||
<Radio value={false}>False</Radio>
|
||||
</Radio.Group>
|
||||
<FieldRoot name={name} className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup<boolean>
|
||||
className="w-fit gap-3"
|
||||
value={booleanValue}
|
||||
onValueChange={v => field.handleChange(v)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend className="sr-only">{translatedLabel || name}</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
|
||||
<Radio value={true} />
|
||||
True
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
|
||||
<Radio value={false} />
|
||||
False
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
{fieldState?.validateStatus && [FormItemValidateStatusEnum.Error, FormItemValidateStatusEnum.Warning].includes(fieldState?.validateStatus) && (
|
||||
|
||||
@ -2,11 +2,11 @@ import type { CSSProperties } from 'react'
|
||||
import type { NotionPageRow as NotionPageRowData, NotionPageSelectionMode } from './types'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Radio } from '@langgenius/dify-ui/radio'
|
||||
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import NotionIcon from '@/app/components/base/notion-icon'
|
||||
import Radio from '@/app/components/base/radio/ui'
|
||||
|
||||
type NotionPageRowProps = {
|
||||
checked: boolean
|
||||
@ -58,9 +58,9 @@ const NotionPageRow = ({
|
||||
: (
|
||||
<Radio
|
||||
className="mr-2 shrink-0"
|
||||
isChecked={checked}
|
||||
value={pageId}
|
||||
disabled={disabled}
|
||||
onCheck={() => onSelect(pageId)}
|
||||
aria-label={row.page.page_name}
|
||||
/>
|
||||
)}
|
||||
{!searchValue && row.hasChild && (
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import type { NotionPageRow, NotionPageSelectionMode } from './types'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PageRow from './page-row'
|
||||
|
||||
type VirtualPageListProps = {
|
||||
@ -32,6 +34,7 @@ const VirtualPageList = ({
|
||||
selectionMode,
|
||||
showPreview,
|
||||
}: VirtualPageListProps) => {
|
||||
const { t } = useTranslation()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
@ -44,6 +47,35 @@ const VirtualPageList = ({
|
||||
})
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems()
|
||||
const selectedPageId = checkedIds.values().next().value
|
||||
const rowNodes = virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index]!
|
||||
const pageId = row.page.page_id
|
||||
|
||||
return (
|
||||
<PageRow
|
||||
key={pageId}
|
||||
checked={checkedIds.has(pageId)}
|
||||
disabled={disabledValue.has(pageId)}
|
||||
isPreviewed={previewPageId === pageId}
|
||||
onPreview={onPreview}
|
||||
onSelect={onSelect}
|
||||
onToggle={onToggle}
|
||||
row={row}
|
||||
searchValue={searchValue}
|
||||
selectionMode={selectionMode}
|
||||
showPreview={showPreview}
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
left: 8,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
width: 'calc(100% - 16px)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -57,34 +89,18 @@ const VirtualPageList = ({
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index]!
|
||||
const pageId = row!.page.page_id
|
||||
|
||||
return (
|
||||
<PageRow
|
||||
key={pageId}
|
||||
checked={checkedIds.has(pageId)}
|
||||
disabled={disabledValue.has(pageId)}
|
||||
isPreviewed={previewPageId === pageId}
|
||||
onPreview={onPreview}
|
||||
onSelect={onSelect}
|
||||
onToggle={onToggle}
|
||||
row={row}
|
||||
searchValue={searchValue}
|
||||
selectionMode={selectionMode}
|
||||
showPreview={showPreview}
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
left: 8,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
width: 'calc(100% - 16px)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{selectionMode === 'single'
|
||||
? (
|
||||
<RadioGroup
|
||||
aria-label={t('dataSource.notion.selector.headerTitle', { ns: 'common' })}
|
||||
value={selectedPageId}
|
||||
onValueChange={onSelect}
|
||||
className="contents"
|
||||
>
|
||||
{rowNodes}
|
||||
</RadioGroup>
|
||||
)
|
||||
: rowNodes}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,137 +1,100 @@
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
// index.spec.tsx
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import RadioCard from '../index'
|
||||
|
||||
describe('RadioCard', () => {
|
||||
it('renders icon, title and description', () => {
|
||||
render(
|
||||
function renderSelectableCard({
|
||||
selected = false,
|
||||
onValueChange = vi.fn(),
|
||||
}: {
|
||||
selected?: boolean
|
||||
onValueChange?: (value: string) => void
|
||||
} = {}) {
|
||||
render(
|
||||
<RadioGroup
|
||||
aria-label="Options"
|
||||
value={selected ? 'card' : undefined}
|
||||
onValueChange={onValueChange}
|
||||
>
|
||||
<RadioCard
|
||||
value="card"
|
||||
icon={<span data-testid="icon">ICON</span>}
|
||||
title="Card Title"
|
||||
description="Some description"
|
||||
/>,
|
||||
)
|
||||
chosenConfig={<div>Config</div>}
|
||||
/>
|
||||
</RadioGroup>,
|
||||
)
|
||||
|
||||
return {
|
||||
radio: screen.getByRole('radio', { name: /Card Title/ }),
|
||||
onValueChange,
|
||||
}
|
||||
}
|
||||
|
||||
describe('RadioCard', () => {
|
||||
it('should render selectable card content and expose radio semantics', () => {
|
||||
const { radio } = renderSelectableCard()
|
||||
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('Card Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Some description')).toBeInTheDocument()
|
||||
expect(radio).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('calls onChosen when clicked', async () => {
|
||||
it('should emit RadioGroup value change when selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChosen = vi.fn()
|
||||
const onValueChange = vi.fn()
|
||||
const { radio } = renderSelectableCard({ onValueChange })
|
||||
|
||||
await user.click(radio)
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith('card', expect.any(Object))
|
||||
})
|
||||
|
||||
it('should show selected styles and configuration when checked', () => {
|
||||
const { radio } = renderSelectableCard({ selected: true })
|
||||
|
||||
expect(radio).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByText('Config')).toBeInTheDocument()
|
||||
expect(radio.parentElement).toHaveClass('has-[[data-checked]]:border-[1.5px]')
|
||||
expect(radio.parentElement).toHaveClass('has-[[data-checked]]:bg-components-option-card-option-selected-bg')
|
||||
})
|
||||
|
||||
it('should apply custom className to the card root and config wrapper', () => {
|
||||
render(
|
||||
<RadioCard
|
||||
icon={<span>i</span>}
|
||||
title="Clickable"
|
||||
description="desc"
|
||||
onChosen={onChosen}
|
||||
/>,
|
||||
<RadioGroup aria-label="Options" value="card">
|
||||
<RadioCard
|
||||
value="card"
|
||||
icon={<span>i</span>}
|
||||
title="Custom"
|
||||
description="desc"
|
||||
className="my-root-class"
|
||||
chosenConfig={<div>cfg</div>}
|
||||
chosenConfigWrapClassName="my-config-wrap"
|
||||
/>
|
||||
</RadioGroup>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Clickable/ }))
|
||||
expect(onChosen).toHaveBeenCalledTimes(1)
|
||||
const radio = screen.getByRole('radio', { name: /Custom/ })
|
||||
expect(radio.parentElement).toHaveClass('my-root-class')
|
||||
expect(screen.getByText('cfg').parentElement).toHaveClass('my-config-wrap')
|
||||
})
|
||||
|
||||
it('hides radio element when noRadio is true and still shows chosen-config area (wrapper)', () => {
|
||||
const { container } = render(
|
||||
it('should render noRadio card as static content without radio role', () => {
|
||||
render(
|
||||
<RadioCard
|
||||
noRadio
|
||||
icon={<span>i</span>}
|
||||
title="No Radio"
|
||||
description="desc"
|
||||
noRadio
|
||||
chosenConfig={<div>Static config</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
const radioWrapper = container.querySelector('.absolute.right-3.top-3')
|
||||
expect(radioWrapper).toBeNull()
|
||||
|
||||
// chosen-config area should appear because noRadio true triggers the block
|
||||
const chosenArea = container.querySelector('.mt-2')
|
||||
expect(chosenArea).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows radio checked styles when isChosen and shows chosenConfig', () => {
|
||||
const { container } = render(
|
||||
<RadioCard
|
||||
icon={<span>i</span>}
|
||||
title="Chosen"
|
||||
description="desc"
|
||||
isChosen
|
||||
chosenConfig={<div data-testid="chosen-config">config</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
// radio absolute wrapper exists
|
||||
const radioWrapper = container.querySelector('.absolute.right-3.top-3')
|
||||
expect(radioWrapper).toBeTruthy()
|
||||
|
||||
// inner circle div should have checked fragment in class list
|
||||
const inner = radioWrapper?.querySelector('div')
|
||||
expect(inner).toBeTruthy()
|
||||
expect(inner?.className).toContain('border-components-radio-border-checked')
|
||||
|
||||
// chosenConfig rendered
|
||||
expect(screen.getByTestId('chosen-config')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies custom className to root and merges chosenConfigWrapClassName', () => {
|
||||
const { container } = render(
|
||||
<RadioCard
|
||||
icon={<span>i</span>}
|
||||
title="Custom"
|
||||
description="desc"
|
||||
className="my-root-class"
|
||||
isChosen
|
||||
chosenConfig={<div>cfg</div>}
|
||||
chosenConfigWrapClassName="my-config-wrap"
|
||||
/>,
|
||||
)
|
||||
|
||||
const root = container.firstChild as HTMLElement
|
||||
expect(root).toBeTruthy()
|
||||
expect(root.className).toContain('my-root-class')
|
||||
expect(root.className).toContain('border-[1.5px]')
|
||||
expect(root.className).toContain('bg-components-option-card-option-selected-bg')
|
||||
|
||||
const chosenWrap = container.querySelector('.mt-2 .my-config-wrap')
|
||||
expect(chosenWrap).toBeTruthy()
|
||||
expect(chosenWrap?.textContent).toBe('cfg')
|
||||
})
|
||||
|
||||
it('does not render radio when noRadio true and still allows clicking on whole card', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChosen = vi.fn()
|
||||
|
||||
const { container } = render(
|
||||
<RadioCard
|
||||
icon={<span>i</span>}
|
||||
title="ClickNoRadio"
|
||||
description="desc"
|
||||
noRadio
|
||||
onChosen={onChosen}
|
||||
/>,
|
||||
)
|
||||
|
||||
// click title should trigger onChosen
|
||||
await user.click(screen.getByRole('button', { name: /ClickNoRadio/ }))
|
||||
expect(onChosen).toHaveBeenCalledTimes(1)
|
||||
|
||||
// radio area should be absent
|
||||
expect(container.querySelector('.absolute.right-3.top-3')).toBeNull()
|
||||
})
|
||||
|
||||
it('memo export renders correctly', () => {
|
||||
render(
|
||||
<RadioCard
|
||||
icon={<span>i</span>}
|
||||
title="Memo"
|
||||
description="desc"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Memo')).toBeInTheDocument()
|
||||
expect(screen.getByText('No Radio')).toBeInTheDocument()
|
||||
expect(screen.getByText('Static config')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('radio')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { RiCloudLine, RiCpuLine, RiDatabase2Line, RiLightbulbLine, RiRocketLine, RiShieldLine } from '@remixicon/react'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { RiDatabase2Line, RiFileList3Line, RiRocketLine } from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import RadioCard from '.'
|
||||
|
||||
@ -10,519 +11,89 @@ const meta = {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Radio card component for selecting options with rich content. Features icon, title, description, and optional configuration panel when selected.',
|
||||
component: 'Radio card for rich single-choice options. Put selectable cards inside `RadioGroup`; the card passes radio props through to `RadioRoot` and uses `RadioControl` for the visual dot.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
icon: {
|
||||
description: 'Icon element to display',
|
||||
},
|
||||
iconBgClassName: {
|
||||
control: 'text',
|
||||
description: 'Background color class for icon container',
|
||||
},
|
||||
title: {
|
||||
control: 'text',
|
||||
description: 'Card title',
|
||||
},
|
||||
description: {
|
||||
control: 'text',
|
||||
description: 'Card description',
|
||||
},
|
||||
isChosen: {
|
||||
control: 'boolean',
|
||||
description: 'Whether the card is selected',
|
||||
},
|
||||
noRadio: {
|
||||
control: 'boolean',
|
||||
description: 'Hide the radio button indicator',
|
||||
},
|
||||
args: {
|
||||
noRadio: true,
|
||||
icon: <RiRocketLine className="size-5 text-purple-600" />,
|
||||
title: 'Standard',
|
||||
description: 'Balanced defaults for most retrieval workflows.',
|
||||
},
|
||||
} satisfies Meta<typeof RadioCard>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Single card demo
|
||||
const RadioCardDemo = (args: any) => {
|
||||
const [isChosen, setIsChosen] = useState(args.isChosen || false)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<RadioCard
|
||||
{...args}
|
||||
isChosen={isChosen}
|
||||
onChosen={() => setIsChosen(!isChosen)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <RadioCardDemo {...args} />,
|
||||
args: {
|
||||
icon: <RiRocketLine className="size-5 text-purple-600" />,
|
||||
iconBgClassName: 'bg-purple-100',
|
||||
title: 'Quick Start',
|
||||
description: 'Get started quickly with default settings',
|
||||
isChosen: false,
|
||||
noRadio: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Selected state
|
||||
export const Selected: Story = {
|
||||
render: args => <RadioCardDemo {...args} />,
|
||||
args: {
|
||||
icon: <RiRocketLine className="size-5 text-purple-600" />,
|
||||
iconBgClassName: 'bg-purple-100',
|
||||
title: 'Quick Start',
|
||||
description: 'Get started quickly with default settings',
|
||||
isChosen: true,
|
||||
noRadio: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Without radio indicator
|
||||
export const NoRadio: Story = {
|
||||
render: args => <RadioCardDemo {...args} />,
|
||||
args: {
|
||||
icon: <RiRocketLine className="size-5 text-purple-600" />,
|
||||
iconBgClassName: 'bg-purple-100',
|
||||
title: 'Information Card',
|
||||
description: 'Card without radio indicator',
|
||||
noRadio: true,
|
||||
},
|
||||
}
|
||||
|
||||
// With configuration panel
|
||||
const WithConfigurationDemo = () => {
|
||||
const [isChosen, setIsChosen] = useState(true)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<RadioCard
|
||||
icon={<RiDatabase2Line className="size-5 text-blue-600" />}
|
||||
iconBgClassName="bg-blue-100"
|
||||
title="Database Storage"
|
||||
description="Store data in a managed database"
|
||||
isChosen={isChosen}
|
||||
onChosen={() => setIsChosen(!isChosen)}
|
||||
chosenConfig={(
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-600">Region:</label>
|
||||
<select className="rounded-sm border border-gray-300 px-2 py-1 text-xs">
|
||||
<option>US East</option>
|
||||
<option>EU West</option>
|
||||
<option>Asia Pacific</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-600">Size:</label>
|
||||
<select className="rounded-sm border border-gray-300 px-2 py-1 text-xs">
|
||||
<option>Small (10GB)</option>
|
||||
<option>Medium (50GB)</option>
|
||||
<option>Large (100GB)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithConfiguration: Story = {
|
||||
render: () => <WithConfigurationDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Multiple cards selection
|
||||
const MultipleCardsDemo = () => {
|
||||
function SelectableCardsDemo() {
|
||||
const [selected, setSelected] = useState('standard')
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: 'standard',
|
||||
icon: <RiRocketLine className="size-5 text-purple-600" />,
|
||||
iconBg: 'bg-purple-100',
|
||||
iconBgClassName: 'bg-purple-100',
|
||||
title: 'Standard',
|
||||
description: 'Perfect for most use cases',
|
||||
description: 'Balanced defaults for most retrieval workflows.',
|
||||
},
|
||||
{
|
||||
value: 'advanced',
|
||||
icon: <RiCpuLine className="size-5 text-blue-600" />,
|
||||
iconBg: 'bg-blue-100',
|
||||
icon: <RiDatabase2Line className="size-5 text-blue-600" />,
|
||||
iconBgClassName: 'bg-blue-100',
|
||||
title: 'Advanced',
|
||||
description: 'More features and customization',
|
||||
},
|
||||
{
|
||||
value: 'enterprise',
|
||||
icon: <RiShieldLine className="size-5 text-green-600" />,
|
||||
iconBg: 'bg-green-100',
|
||||
title: 'Enterprise',
|
||||
description: 'Full features with premium support',
|
||||
description: 'Expose extra controls when this option is selected.',
|
||||
chosenConfig: (
|
||||
<div className="rounded-lg bg-components-panel-bg-blur p-3 system-xs-regular text-text-tertiary">
|
||||
Additional configuration appears below the selected card.
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '450px' }} className="space-y-3">
|
||||
<RadioGroup
|
||||
aria-label="Retrieval mode"
|
||||
value={selected}
|
||||
onValueChange={setSelected}
|
||||
className="w-110 flex-col items-stretch gap-2"
|
||||
>
|
||||
{options.map(option => (
|
||||
<RadioCard
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
icon={option.icon}
|
||||
iconBgClassName={option.iconBg}
|
||||
iconBgClassName={option.iconBgClassName}
|
||||
title={option.title}
|
||||
description={option.description}
|
||||
isChosen={selected === option.value}
|
||||
onChosen={() => setSelected(option.value)}
|
||||
chosenConfig={option.chosenConfig}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
Selected:
|
||||
{' '}
|
||||
<span className="font-semibold">{selected}</span>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export const MultipleCards: Story = {
|
||||
render: () => <MultipleCardsDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Cloud provider selection
|
||||
const CloudProviderSelectionDemo = () => {
|
||||
const [provider, setProvider] = useState('aws')
|
||||
const [region, setRegion] = useState('us-east-1')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Select Cloud Provider</h3>
|
||||
<div className="space-y-3">
|
||||
<RadioCard
|
||||
icon={<RiCloudLine className="size-5 text-orange-600" />}
|
||||
iconBgClassName="bg-orange-100"
|
||||
title="Amazon Web Services"
|
||||
description="Industry-leading cloud infrastructure"
|
||||
isChosen={provider === 'aws'}
|
||||
onChosen={() => setProvider('aws')}
|
||||
chosenConfig={(
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-gray-700">Region</label>
|
||||
<select
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
value={region}
|
||||
onChange={e => setRegion(e.target.value)}
|
||||
>
|
||||
<option value="us-east-1">US East (N. Virginia)</option>
|
||||
<option value="us-west-2">US West (Oregon)</option>
|
||||
<option value="eu-west-1">EU (Ireland)</option>
|
||||
<option value="ap-southeast-1">Asia Pacific (Singapore)</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiCloudLine className="size-5 text-blue-600" />}
|
||||
iconBgClassName="bg-blue-100"
|
||||
title="Microsoft Azure"
|
||||
description="Enterprise-grade cloud platform"
|
||||
isChosen={provider === 'azure'}
|
||||
onChosen={() => setProvider('azure')}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiCloudLine className="size-5 text-red-600" />}
|
||||
iconBgClassName="bg-red-100"
|
||||
title="Google Cloud Platform"
|
||||
description="Scalable and reliable infrastructure"
|
||||
isChosen={provider === 'gcp'}
|
||||
onChosen={() => setProvider('gcp')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
export const SelectableCards: Story = {
|
||||
render: () => <SelectableCardsDemo />,
|
||||
}
|
||||
|
||||
export const CloudProviderSelection: Story = {
|
||||
render: () => <CloudProviderSelectionDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Deployment strategy
|
||||
const DeploymentStrategyDemo = () => {
|
||||
const [strategy, setStrategy] = useState('rolling')
|
||||
|
||||
return (
|
||||
<div style={{ width: '550px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold">Deployment Strategy</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">Choose how you want to deploy your application</p>
|
||||
<div className="space-y-3">
|
||||
<RadioCard
|
||||
icon={<RiRocketLine className="size-5 text-green-600" />}
|
||||
iconBgClassName="bg-green-100"
|
||||
title="Rolling Deployment"
|
||||
description="Gradually replace instances with zero downtime"
|
||||
isChosen={strategy === 'rolling'}
|
||||
onChosen={() => setStrategy('rolling')}
|
||||
chosenConfig={(
|
||||
<div className="rounded-lg bg-green-50 p-3 text-xs text-gray-700">
|
||||
✓ Recommended for production environments
|
||||
<br />
|
||||
✓ Minimal risk with automatic rollback
|
||||
<br />
|
||||
✓ Takes 5-10 minutes
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiCpuLine className="size-5 text-blue-600" />}
|
||||
iconBgClassName="bg-blue-100"
|
||||
title="Blue-Green Deployment"
|
||||
description="Switch between two identical environments"
|
||||
isChosen={strategy === 'blue-green'}
|
||||
onChosen={() => setStrategy('blue-green')}
|
||||
chosenConfig={(
|
||||
<div className="rounded-lg bg-blue-50 p-3 text-xs text-gray-700">
|
||||
✓ Instant rollback capability
|
||||
<br />
|
||||
✓ Requires double the resources
|
||||
<br />
|
||||
✓ Takes 2-5 minutes
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiLightbulbLine className="size-5 text-yellow-600" />}
|
||||
iconBgClassName="bg-yellow-100"
|
||||
title="Canary Deployment"
|
||||
description="Test with a small subset of users first"
|
||||
isChosen={strategy === 'canary'}
|
||||
onChosen={() => setStrategy('canary')}
|
||||
chosenConfig={(
|
||||
<div className="rounded-lg bg-yellow-50 p-3 text-xs text-gray-700">
|
||||
✓ Test changes with real traffic
|
||||
<br />
|
||||
✓ Gradual rollout reduces risk
|
||||
<br />
|
||||
✓ Takes 15-30 minutes
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<button className="mt-6 w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Deploy with
|
||||
{' '}
|
||||
{strategy}
|
||||
{' '}
|
||||
strategy
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const DeploymentStrategy: Story = {
|
||||
render: () => <DeploymentStrategyDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Storage options
|
||||
const StorageOptionsDemo = () => {
|
||||
const [storage, setStorage] = useState('ssd')
|
||||
|
||||
const storageOptions = [
|
||||
{
|
||||
value: 'ssd',
|
||||
icon: <RiDatabase2Line className="size-5 text-purple-600" />,
|
||||
iconBg: 'bg-purple-100',
|
||||
title: 'SSD Storage',
|
||||
description: 'Fast and reliable solid state drives',
|
||||
price: '$0.10/GB/month',
|
||||
speed: 'Up to 3000 IOPS',
|
||||
},
|
||||
{
|
||||
value: 'hdd',
|
||||
icon: <RiDatabase2Line className="size-5 text-gray-600" />,
|
||||
iconBg: 'bg-gray-100',
|
||||
title: 'HDD Storage',
|
||||
description: 'Cost-effective magnetic disk storage',
|
||||
price: '$0.05/GB/month',
|
||||
speed: 'Up to 500 IOPS',
|
||||
},
|
||||
{
|
||||
value: 'nvme',
|
||||
icon: <RiDatabase2Line className="size-5 text-red-600" />,
|
||||
iconBg: 'bg-red-100',
|
||||
title: 'NVMe Storage',
|
||||
description: 'Ultra-fast PCIe-based storage',
|
||||
price: '$0.20/GB/month',
|
||||
speed: 'Up to 10000 IOPS',
|
||||
},
|
||||
]
|
||||
|
||||
const selectedOption = storageOptions.find(opt => opt.value === storage)
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Storage Type</h3>
|
||||
<div className="space-y-3">
|
||||
{storageOptions.map(option => (
|
||||
<RadioCard
|
||||
key={option.value}
|
||||
icon={option.icon}
|
||||
iconBgClassName={option.iconBg}
|
||||
title={(
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{option.title}</span>
|
||||
<span className="text-xs font-normal text-gray-500">{option.price}</span>
|
||||
</div>
|
||||
)}
|
||||
description={`${option.description} - ${option.speed}`}
|
||||
isChosen={storage === option.value}
|
||||
onChosen={() => setStorage(option.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{selectedOption && (
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-4">
|
||||
<div className="text-sm text-gray-700">
|
||||
<strong>Selected:</strong>
|
||||
{' '}
|
||||
{selectedOption.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
{selectedOption.price}
|
||||
{' '}
|
||||
•
|
||||
{selectedOption.speed}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const StorageOptions: Story = {
|
||||
render: () => <StorageOptionsDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - API authentication method
|
||||
const APIAuthMethodDemo = () => {
|
||||
const [authMethod, setAuthMethod] = useState('api_key')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
|
||||
return (
|
||||
<div style={{ width: '550px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">API Authentication</h3>
|
||||
<div className="space-y-3">
|
||||
<RadioCard
|
||||
icon={<RiShieldLine className="size-5 text-blue-600" />}
|
||||
iconBgClassName="bg-blue-100"
|
||||
title="API Key"
|
||||
description="Simple authentication using a secret key"
|
||||
isChosen={authMethod === 'api_key'}
|
||||
onChosen={() => setAuthMethod('api_key')}
|
||||
chosenConfig={(
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-gray-700">Your API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="sk-..."
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">Keep your API key secure and never share it publicly</p>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiShieldLine className="size-5 text-green-600" />}
|
||||
iconBgClassName="bg-green-100"
|
||||
title="OAuth 2.0"
|
||||
description="Industry-standard authorization protocol"
|
||||
isChosen={authMethod === 'oauth'}
|
||||
onChosen={() => setAuthMethod('oauth')}
|
||||
chosenConfig={(
|
||||
<div className="rounded-lg bg-green-50 p-3">
|
||||
<p className="mb-2 text-xs text-gray-700">
|
||||
Configure OAuth 2.0 authentication for secure access
|
||||
</p>
|
||||
<button className="text-xs font-medium text-green-600 hover:underline">
|
||||
Configure OAuth Settings →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiShieldLine className="size-5 text-purple-600" />}
|
||||
iconBgClassName="bg-purple-100"
|
||||
title="JWT Token"
|
||||
description="JSON Web Token based authentication"
|
||||
isChosen={authMethod === 'jwt'}
|
||||
onChosen={() => setAuthMethod('jwt')}
|
||||
chosenConfig={(
|
||||
<div className="rounded-lg bg-purple-50 p-3 text-xs text-gray-700">
|
||||
JWT tokens provide stateless authentication with expiration and refresh capabilities
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const APIAuthMethod: Story = {
|
||||
render: () => <APIAuthMethodDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Interactive playground
|
||||
const PlaygroundDemo = () => {
|
||||
const [selected, setSelected] = useState('option1')
|
||||
|
||||
return (
|
||||
<div style={{ width: '450px' }} className="space-y-3">
|
||||
export const StaticInfoCard: Story = {
|
||||
render: () => (
|
||||
<div className="w-110">
|
||||
<RadioCard
|
||||
icon={<RiRocketLine className="size-5 text-purple-600" />}
|
||||
iconBgClassName="bg-purple-100"
|
||||
title="Option 1"
|
||||
description="First option with icon and description"
|
||||
isChosen={selected === 'option1'}
|
||||
onChosen={() => setSelected('option1')}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiDatabase2Line className="size-5 text-blue-600" />}
|
||||
iconBgClassName="bg-blue-100"
|
||||
title="Option 2"
|
||||
description="Second option with different styling"
|
||||
isChosen={selected === 'option2'}
|
||||
onChosen={() => setSelected('option2')}
|
||||
noRadio
|
||||
icon={<RiFileList3Line className="size-5 text-indigo-600" />}
|
||||
iconBgClassName="bg-indigo-100"
|
||||
title="Current Retrieval Method"
|
||||
description="This card summarizes the active method and is not a selectable radio option."
|
||||
chosenConfig={(
|
||||
<div className="rounded-sm bg-blue-50 p-2 text-xs text-gray-600">
|
||||
Additional configuration appears when selected
|
||||
<div className="flex gap-6 system-xs-regular text-text-tertiary">
|
||||
<span>Top K: 5</span>
|
||||
<span>Score: 0.8</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiCloudLine className="size-5 text-green-600" />}
|
||||
iconBgClassName="bg-green-100"
|
||||
title="Option 3"
|
||||
description="Third option to demonstrate selection"
|
||||
isChosen={selected === 'option3'}
|
||||
onChosen={() => setSelected('option3')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <PlaygroundDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
@ -1,76 +1,124 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { RadioRootProps } from '@langgenius/dify-ui/radio'
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { RadioControl, RadioRoot } from '@langgenius/dify-ui/radio'
|
||||
|
||||
type Props = {
|
||||
type BaseProps = {
|
||||
className?: string
|
||||
icon: React.ReactNode
|
||||
icon: ReactNode
|
||||
iconBgClassName?: string
|
||||
title: React.ReactNode
|
||||
title: ReactNode
|
||||
description: string
|
||||
noRadio?: boolean
|
||||
isChosen?: boolean
|
||||
onChosen?: () => void
|
||||
chosenConfig?: React.ReactNode
|
||||
chosenConfig?: ReactNode
|
||||
chosenConfigWrapClassName?: string
|
||||
}
|
||||
|
||||
const RadioCard: FC<Props> = ({
|
||||
icon,
|
||||
iconBgClassName = 'bg-[#F5F3FF]',
|
||||
title,
|
||||
description,
|
||||
noRadio,
|
||||
isChosen,
|
||||
onChosen = noop,
|
||||
chosenConfig,
|
||||
chosenConfigWrapClassName,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative cursor-pointer rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg p-3',
|
||||
isChosen && 'border-[1.5px] bg-components-option-card-option-selected-bg',
|
||||
type SelectableRadioCardProps = BaseProps & {
|
||||
noRadio?: false
|
||||
} & Omit<RadioRootProps<string>, 'children' | 'className' | 'variant' | 'render' | 'nativeButton'>
|
||||
|
||||
type StaticRadioCardProps = BaseProps & {
|
||||
noRadio: true
|
||||
value?: never
|
||||
checked?: never
|
||||
}
|
||||
|
||||
type Props = SelectableRadioCardProps | StaticRadioCardProps
|
||||
|
||||
function RadioCard(props: Props) {
|
||||
if (props.noRadio) {
|
||||
const {
|
||||
icon,
|
||||
iconBgClassName = 'bg-[#F5F3FF]',
|
||||
title,
|
||||
description,
|
||||
chosenConfig,
|
||||
chosenConfigWrapClassName,
|
||||
className,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'relative rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg p-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full gap-x-2 border-none bg-transparent p-0 text-left focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={onChosen}
|
||||
>
|
||||
<div className={cn(iconBgClassName, 'flex size-8 shrink-0 items-center justify-center rounded-lg shadow-md')}>
|
||||
{icon}
|
||||
<div className="flex w-full gap-x-2 text-left">
|
||||
<div className={cn(iconBgClassName, 'flex size-8 shrink-0 items-center justify-center rounded-lg shadow-md')}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 grow pr-8">
|
||||
<div className="mb-1 system-sm-semibold text-text-secondary">{title}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="mb-1 system-sm-semibold text-text-secondary">{title}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{description}</div>
|
||||
</div>
|
||||
{!noRadio && (
|
||||
<div className="absolute top-3 right-3">
|
||||
<div
|
||||
className={cn(
|
||||
'size-4 rounded-full border border-components-radio-border bg-components-radio-bg shadow-xs',
|
||||
isChosen && 'border-[5px] border-components-radio-border-checked',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{Boolean(chosenConfig) && (
|
||||
<div className="mt-2 flex gap-x-2">
|
||||
<div className="size-8 shrink-0"></div>
|
||||
<div className={cn(chosenConfigWrapClassName, 'grow')}>
|
||||
{chosenConfig}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{!!((isChosen && chosenConfig) || noRadio) && (
|
||||
<div className="mt-2 flex gap-x-2">
|
||||
<div className="size-8 shrink-0"></div>
|
||||
<div className={cn(chosenConfigWrapClassName, 'grow')}>
|
||||
{chosenConfig}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const {
|
||||
icon,
|
||||
iconBgClassName = 'bg-[#F5F3FF]',
|
||||
title,
|
||||
description,
|
||||
chosenConfig,
|
||||
chosenConfigWrapClassName,
|
||||
className,
|
||||
noRadio: _noRadio,
|
||||
...radioRootProps
|
||||
} = props
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className={cn(iconBgClassName, 'flex size-8 shrink-0 items-center justify-center rounded-lg shadow-md')}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 grow pr-8">
|
||||
<div className="mb-1 system-sm-semibold text-text-secondary">{title}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{description}</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
const rootClassName = cn(
|
||||
'group/radio-card relative rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg p-3 transition-colors',
|
||||
'has-[[data-checked]]:border-[1.5px] has-[[data-checked]]:bg-components-option-card-option-selected-bg',
|
||||
className,
|
||||
)
|
||||
const config = !!chosenConfig && (
|
||||
<div className="mt-2 hidden gap-x-2 group-has-data-checked/radio-card:flex">
|
||||
<div className="size-8 shrink-0"></div>
|
||||
<div className={cn(chosenConfigWrapClassName, 'grow')}>
|
||||
{chosenConfig}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={rootClassName}
|
||||
>
|
||||
<RadioRoot
|
||||
{...radioRootProps}
|
||||
variant="unstyled"
|
||||
nativeButton
|
||||
render={<button type="button" />}
|
||||
className="flex w-full cursor-pointer gap-x-2 border-none bg-transparent p-0 text-left outline-hidden focus-visible:ring-1 focus-visible:ring-components-input-border-active"
|
||||
>
|
||||
{content}
|
||||
<RadioControl className="absolute top-3 right-3" aria-hidden="true" />
|
||||
</RadioRoot>
|
||||
{config}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(RadioCard)
|
||||
|
||||
export default RadioCard
|
||||
|
||||
@ -1,137 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
// index.spec.tsx
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import RadioCard from '../index'
|
||||
|
||||
describe('RadioCard', () => {
|
||||
it('renders title and description', () => {
|
||||
render(
|
||||
<RadioCard
|
||||
title="Card Title"
|
||||
description="Card Description"
|
||||
isChosen={false}
|
||||
onChosen={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Card Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Card Description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders JSX title correctly', () => {
|
||||
render(
|
||||
<RadioCard
|
||||
title={<span data-testid="jsx-title">JSX Title</span>}
|
||||
description="Desc"
|
||||
isChosen={false}
|
||||
onChosen={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('jsx-title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders icon when provided', () => {
|
||||
render(
|
||||
<RadioCard
|
||||
title="With Icon"
|
||||
description="Desc"
|
||||
isChosen={false}
|
||||
onChosen={vi.fn()}
|
||||
icon={<span data-testid="icon">ICON</span>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders extra content when provided', () => {
|
||||
render(
|
||||
<RadioCard
|
||||
title="With Extra"
|
||||
description="Desc"
|
||||
isChosen={false}
|
||||
onChosen={vi.fn()}
|
||||
extra={<div data-testid="extra">Extra Content</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('extra')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onChosen when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChosen = vi.fn()
|
||||
|
||||
render(
|
||||
<RadioCard
|
||||
title="Clickable"
|
||||
description="Desc"
|
||||
isChosen={false}
|
||||
onChosen={onChosen}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Clickable'))
|
||||
expect(onChosen).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('applies active class when isChosen is true', () => {
|
||||
const { container: inactiveContainer } = render(
|
||||
<RadioCard
|
||||
title="Inactive"
|
||||
description="Desc"
|
||||
isChosen={false}
|
||||
onChosen={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const inactiveClassName = (inactiveContainer.firstChild as HTMLElement).className
|
||||
|
||||
const { container: activeContainer } = render(
|
||||
<RadioCard
|
||||
title="Active"
|
||||
description="Desc"
|
||||
isChosen
|
||||
onChosen={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const activeRoot = activeContainer.firstChild as HTMLElement
|
||||
expect(activeRoot.className).not.toBe(inactiveClassName)
|
||||
// Since it uses CSS modules, we expect the active class to be appended or changed
|
||||
// In index.tsx it's cn(s.item, isChosen && s.active)
|
||||
expect(activeRoot.className.length).toBeGreaterThan(inactiveClassName.length)
|
||||
expect(activeRoot.className).toContain(inactiveClassName)
|
||||
})
|
||||
|
||||
it('does not apply active styling logic when isChosen is false', () => {
|
||||
const { container } = render(
|
||||
<RadioCard
|
||||
title="Inactive"
|
||||
description="Desc"
|
||||
isChosen={false}
|
||||
onChosen={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const root = container.firstChild as HTMLElement
|
||||
expect(root).toBeTruthy()
|
||||
// It should have some classes but not the active one
|
||||
expect(root.className).not.toBe('')
|
||||
expect(root.className).not.toContain('active') // CSS modules usually append _active
|
||||
})
|
||||
|
||||
it('memo export renders correctly', () => {
|
||||
render(
|
||||
<RadioCard
|
||||
title="Memo"
|
||||
description="Desc"
|
||||
isChosen={false}
|
||||
onChosen={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Memo')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,45 +0,0 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import s from './style.module.css'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
title: string | React.JSX.Element | null
|
||||
description: string
|
||||
isChosen: boolean
|
||||
onChosen: () => void
|
||||
chosenConfig?: React.ReactNode
|
||||
icon?: React.JSX.Element
|
||||
extra?: React.ReactNode
|
||||
}
|
||||
|
||||
const RadioCard: FC<Props> = ({
|
||||
title,
|
||||
description,
|
||||
isChosen,
|
||||
onChosen,
|
||||
icon,
|
||||
extra,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(s.item, isChosen && s.active)}
|
||||
onClick={onChosen}
|
||||
>
|
||||
<div className="flex px-3 py-2">
|
||||
{icon}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm/5 font-medium text-gray-900">{title}</div>
|
||||
<div className={s.radio}></div>
|
||||
</div>
|
||||
<div className="text-xs leading-[18px] font-normal text-gray-500">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
{extra}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(RadioCard)
|
||||
@ -1,27 +0,0 @@
|
||||
@reference "../../../../styles/globals.css";
|
||||
|
||||
.item {
|
||||
@apply relative rounded-xl border border-gray-100 cursor-pointer;
|
||||
background-color: #fcfcfd;
|
||||
}
|
||||
|
||||
.item.active {
|
||||
border-width: 1.5px;
|
||||
border-color: #528BFF;
|
||||
box-shadow: 0px 1px 3px rgba(16, 24, 40, 0.1), 0px 1px 2px rgba(16, 24, 40, 0.06);
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background-color: #ffffff;
|
||||
border-color: #B2CCFF;
|
||||
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
|
||||
}
|
||||
|
||||
.radio {
|
||||
@apply w-4 h-4 border-[2px] border-gray-200 rounded-full;
|
||||
}
|
||||
|
||||
.item.active .radio {
|
||||
border-width: 5px;
|
||||
border-color: #155EEF;
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
// index.spec.tsx
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Group from '../component/group'
|
||||
import Radio from '../index'
|
||||
|
||||
describe('Radio (index)', () => {
|
||||
it('attaches Group as a property on the default export', () => {
|
||||
expect(Radio.Group).toBe(Group)
|
||||
})
|
||||
|
||||
it('renders Radio when used as a component', () => {
|
||||
render(<Radio>RootLabel</Radio>)
|
||||
expect(screen.getByText('RootLabel')).toBeInTheDocument()
|
||||
const label = screen.getByText('RootLabel')
|
||||
expect(label.tagName.toLowerCase()).toBe('label')
|
||||
})
|
||||
|
||||
it('Radio.Group provides context to nested Radio and group onChange is called on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const groupOnChange = vi.fn()
|
||||
|
||||
render(
|
||||
<Radio.Group value="val" onChange={groupOnChange}>
|
||||
<Radio value="val">InnerRadio</Radio>
|
||||
</Radio.Group>,
|
||||
)
|
||||
|
||||
const root = screen.getByText('InnerRadio').closest('div') as HTMLElement
|
||||
await user.click(root)
|
||||
expect(groupOnChange).toHaveBeenCalledTimes(1)
|
||||
expect(groupOnChange).toHaveBeenCalledWith('val')
|
||||
})
|
||||
|
||||
it('Radio.Group can render arbitrary children', () => {
|
||||
render(
|
||||
<Radio.Group value={undefined} onChange={() => {}}>
|
||||
<div data-testid="plain-child">child</div>
|
||||
</Radio.Group>,
|
||||
)
|
||||
expect(screen.getByTestId('plain-child')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,88 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
// radio-ui.spec.tsx
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import RadioUI from '../ui'
|
||||
|
||||
describe('RadioUI component', () => {
|
||||
it('renders with correct role and aria attributes', () => {
|
||||
render(<RadioUI isChecked />)
|
||||
|
||||
const radio = screen.getByRole('radio')
|
||||
expect(radio).toBeInTheDocument()
|
||||
expect(radio).toHaveAttribute('aria-checked', 'true')
|
||||
expect(radio).toHaveAttribute('aria-disabled', 'false')
|
||||
})
|
||||
|
||||
it('applies checked + enabled styles', () => {
|
||||
render(<RadioUI isChecked />)
|
||||
const radio = screen.getByRole('radio')
|
||||
expect(radio.className).toContain('border-[5px]')
|
||||
expect(radio.className).toContain('border-components-radio-border-checked')
|
||||
})
|
||||
|
||||
it('applies unchecked + enabled styles', () => {
|
||||
render(<RadioUI isChecked={false} />)
|
||||
const radio = screen.getByRole('radio')
|
||||
expect(radio.className).toContain('border-components-radio-border')
|
||||
})
|
||||
|
||||
it('applies checked + disabled styles', () => {
|
||||
render(<RadioUI isChecked disabled />)
|
||||
const radio = screen.getByRole('radio')
|
||||
expect(radio).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(radio.className).toContain(
|
||||
'border-components-radio-border-checked-disabled',
|
||||
)
|
||||
})
|
||||
|
||||
it('applies unchecked + disabled styles', () => {
|
||||
render(<RadioUI isChecked={false} disabled />)
|
||||
const radio = screen.getByRole('radio')
|
||||
expect(radio.className).toContain(
|
||||
'border-components-radio-border-disabled',
|
||||
)
|
||||
expect(radio.className).toContain(
|
||||
'bg-components-radio-bg-disabled',
|
||||
)
|
||||
})
|
||||
|
||||
it('calls onCheck when clicked if not disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleCheck = vi.fn()
|
||||
|
||||
render(<RadioUI isChecked={false} onCheck={handleCheck} />)
|
||||
|
||||
const radio = screen.getByRole('radio')
|
||||
await user.click(radio)
|
||||
|
||||
expect(handleCheck).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not call onCheck when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleCheck = vi.fn()
|
||||
|
||||
render(
|
||||
<RadioUI isChecked={false} disabled onCheck={handleCheck} />,
|
||||
)
|
||||
|
||||
const radio = screen.getByRole('radio')
|
||||
await user.click(radio)
|
||||
|
||||
expect(handleCheck).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('merges custom className', () => {
|
||||
render(
|
||||
<RadioUI isChecked={false} className="my-extra-class" />,
|
||||
)
|
||||
const radio = screen.getByRole('radio')
|
||||
expect(radio.className).toContain('my-extra-class')
|
||||
})
|
||||
|
||||
it('memo export renders correctly', () => {
|
||||
render(<RadioUI isChecked />)
|
||||
expect(screen.getByRole('radio')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,108 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useContextSelector } from 'use-context-selector'
|
||||
// Group.test.tsx
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import RadioGroupContext from '../../../context'
|
||||
import Group from '../index'
|
||||
|
||||
// small consumer that uses the same context as your component
|
||||
function ContextConsumer({ showButton = true }: { showButton?: boolean }) {
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const ctx = useContextSelector(RadioGroupContext, (v: any) => v)
|
||||
const value = ctx?.value
|
||||
const onChange = ctx?.onChange
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="radio-value">{String(value)}</span>
|
||||
{showButton && (
|
||||
<button
|
||||
data-testid="radio-change-btn"
|
||||
onClick={() => onChange?.('clicked-from-test')}
|
||||
>
|
||||
change
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Group component', () => {
|
||||
it('renders children and exposes provided value through context', () => {
|
||||
render(
|
||||
<Group value="initial-value">
|
||||
<ContextConsumer />
|
||||
</Group>,
|
||||
)
|
||||
|
||||
const valueNode = screen.getByTestId('radio-value')
|
||||
expect(valueNode).toBeInTheDocument()
|
||||
expect(valueNode).toHaveTextContent('initial-value')
|
||||
})
|
||||
|
||||
it('merges custom className with existing classes on root element', () => {
|
||||
const { container } = render(
|
||||
<Group value="v" className="my-extra-class">
|
||||
<ContextConsumer />
|
||||
</Group>,
|
||||
)
|
||||
|
||||
const root = container.firstChild as HTMLElement
|
||||
|
||||
expect(root).toBeInTheDocument()
|
||||
expect(root.className).toContain('my-extra-class')
|
||||
|
||||
// ensure it still has other classes (from cn + css module)
|
||||
expect(root.className.length).toBeGreaterThan('my-extra-class'.length)
|
||||
})
|
||||
|
||||
it('calls onChange from context when consumer triggers it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleChange = vi.fn()
|
||||
|
||||
render(
|
||||
<Group value="whatever" onChange={handleChange}>
|
||||
<ContextConsumer />
|
||||
</Group>,
|
||||
)
|
||||
|
||||
const btn = screen.getByTestId('radio-change-btn')
|
||||
await user.click(btn)
|
||||
expect(handleChange).toHaveBeenCalledTimes(1)
|
||||
expect(handleChange).toHaveBeenCalledWith('clicked-from-test')
|
||||
})
|
||||
|
||||
it('does not throw if onChange is not provided and consumer calls it', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Group value={0}>
|
||||
{/* the consumer will call onChange which is undefined */}
|
||||
<ContextConsumer />
|
||||
</Group>,
|
||||
)
|
||||
|
||||
const btn = screen.getByTestId('radio-change-btn')
|
||||
// clicking should not throw (if it threw the test would fail)
|
||||
await user.click(btn)
|
||||
// value still rendered correctly (verifies consumer reads numeric/false-y values too)
|
||||
expect(screen.getByTestId('radio-value')).toHaveTextContent('0')
|
||||
})
|
||||
|
||||
it('correctly passes boolean and numeric values through context', () => {
|
||||
render(
|
||||
<>
|
||||
<Group value={false}>
|
||||
<ContextConsumer />
|
||||
</Group>
|
||||
<Group value={123}>
|
||||
<ContextConsumer showButton={false} />
|
||||
</Group>
|
||||
</>,
|
||||
)
|
||||
|
||||
const nodes = screen.getAllByTestId('radio-value')
|
||||
// first should be "false", second "123"
|
||||
expect(nodes[0]).toHaveTextContent('false')
|
||||
expect(nodes[1]).toHaveTextContent('123')
|
||||
})
|
||||
})
|
||||
@ -1,24 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import RadioGroupContext from '../../context'
|
||||
import s from '../../style.module.css'
|
||||
|
||||
type TRadioGroupProps = {
|
||||
children?: ReactNode | ReactNode[]
|
||||
value?: string | number | boolean
|
||||
className?: string
|
||||
onChange?: (value: any) => void
|
||||
}
|
||||
|
||||
export default function Group({ children, value, onChange, className = '' }: TRadioGroupProps): React.JSX.Element {
|
||||
const onRadioChange = (value: any) => {
|
||||
onChange?.(value)
|
||||
}
|
||||
return (
|
||||
<div className={cn('flex items-center bg-workflow-block-parma-bg text-text-secondary', s.container, className)}>
|
||||
<RadioGroupContext.Provider value={{ value, onChange: onRadioChange }}>
|
||||
{children}
|
||||
</RadioGroupContext.Provider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,95 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
// index.spec.tsx
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import RadioGroupContext from '../../../context'
|
||||
import Radio from '../index'
|
||||
|
||||
describe('Radio component', () => {
|
||||
it('renders label children and assigns an id to the label', () => {
|
||||
const { container } = render(<Radio>My Label</Radio>)
|
||||
|
||||
const label = screen.getByText('My Label')
|
||||
expect(label).toBeInTheDocument()
|
||||
// label must be an HTMLLabelElement with an id assigned by useId
|
||||
expect(label.tagName.toLowerCase()).toBe('label')
|
||||
expect(label).toHaveAttribute('id')
|
||||
const root = container.firstChild as HTMLElement
|
||||
expect(root).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not render a label when children is falsey', () => {
|
||||
render(<Radio />)
|
||||
// there should be no <label> in the document
|
||||
const labels = screen.queryAllByRole('label')
|
||||
expect(labels.length).toBe(0)
|
||||
// also ensure no textual children
|
||||
expect(screen.queryByText(/./)).toBeNull()
|
||||
})
|
||||
|
||||
it('calls both local onChange and group onChange when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const localChange = vi.fn()
|
||||
const groupChange = vi.fn()
|
||||
|
||||
render(
|
||||
<RadioGroupContext.Provider value={{ value: null, onChange: groupChange }}>
|
||||
<Radio value="v1" onChange={localChange}>
|
||||
ClickMe
|
||||
</Radio>
|
||||
</RadioGroupContext.Provider>,
|
||||
)
|
||||
|
||||
const root = screen.getByText('ClickMe').closest('div') as HTMLElement
|
||||
await user.click(root)
|
||||
expect(localChange).toHaveBeenCalledTimes(1)
|
||||
expect(localChange).toHaveBeenCalledWith('v1')
|
||||
expect(groupChange).toHaveBeenCalledTimes(1)
|
||||
expect(groupChange).toHaveBeenCalledWith('v1')
|
||||
})
|
||||
|
||||
it('does not call onChange handlers when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const localChange = vi.fn()
|
||||
const groupChange = vi.fn()
|
||||
|
||||
render(
|
||||
<RadioGroupContext.Provider value={{ value: null, onChange: groupChange }}>
|
||||
<Radio value="v2" onChange={localChange} disabled>
|
||||
DisabledLabel
|
||||
</Radio>
|
||||
</RadioGroupContext.Provider>,
|
||||
)
|
||||
|
||||
const root = screen.getByText('DisabledLabel').closest('div') as HTMLElement
|
||||
await user.click(root)
|
||||
expect(localChange).not.toHaveBeenCalled()
|
||||
expect(groupChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses group value to determine checked state and applies checked class fragment', () => {
|
||||
const { container: c1 } = render(
|
||||
<RadioGroupContext.Provider value={{ value: 'yes', onChange: () => {} }}>
|
||||
<Radio value="yes">CheckedByGroup</Radio>
|
||||
</RadioGroupContext.Provider>,
|
||||
)
|
||||
const root1 = c1.firstChild as HTMLElement
|
||||
expect(root1).toBeTruthy()
|
||||
// component conditionally adds the 'bg-components-option-card-option-bg-hover' fragment when checked
|
||||
expect(root1.className).toContain('bg-components-option-card-option-bg-hover')
|
||||
|
||||
const { container: c2 } = render(<Radio checked>CheckedByProp</Radio>)
|
||||
const root2 = c2.firstChild as HTMLElement
|
||||
expect(root2).toBeTruthy()
|
||||
expect(root2.className).toContain('bg-components-option-card-option-bg-hover')
|
||||
})
|
||||
|
||||
it('merges custom className with component classes', () => {
|
||||
const { container } = render(<Radio className="my-custom-class">Label</Radio>)
|
||||
const root = container.firstChild as HTMLElement
|
||||
expect(root).toBeInTheDocument()
|
||||
expect(root.className).toContain('my-custom-class')
|
||||
// ensure other classes still exist (merged)
|
||||
expect(root.className.length).toBeGreaterThan('my-custom-class'.length)
|
||||
})
|
||||
})
|
||||
@ -1,67 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useId } from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import RadioGroupContext from '../../context'
|
||||
import s from '../../style.module.css'
|
||||
|
||||
export type IRadioProps = {
|
||||
className?: string
|
||||
labelClassName?: string
|
||||
children?: string | ReactNode
|
||||
checked?: boolean
|
||||
value?: string | number | boolean
|
||||
disabled?: boolean
|
||||
onChange?: (e?: IRadioProps['value']) => void
|
||||
}
|
||||
|
||||
export default function Radio({
|
||||
className = '',
|
||||
labelClassName,
|
||||
children = '',
|
||||
checked,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: IRadioProps): React.JSX.Element {
|
||||
const groupContext = useContext(RadioGroupContext)
|
||||
const labelId = useId()
|
||||
const handleChange = (e: IRadioProps['value']) => {
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
onChange?.(e)
|
||||
groupContext?.onChange(e)
|
||||
}
|
||||
|
||||
const isChecked = groupContext ? groupContext.value === value : checked
|
||||
const divClassName = `
|
||||
flex items-center py-1 relative
|
||||
px-7 cursor-pointer text-text-secondary rounded
|
||||
hover:bg-components-option-card-option-bg-hover hover:shadow-xs
|
||||
`
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
s.label,
|
||||
disabled ? s.disabled : '',
|
||||
isChecked ? 'bg-components-option-card-option-bg-hover shadow-xs' : '',
|
||||
divClassName,
|
||||
className,
|
||||
)}
|
||||
onClick={() => handleChange(value)}
|
||||
>
|
||||
{!!children && (
|
||||
<label
|
||||
className={
|
||||
cn(labelClassName, 'cursor-pointer text-sm')
|
||||
}
|
||||
id={labelId}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useContextSelector } from 'use-context-selector'
|
||||
// context.spec.tsx
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import RadioGroupContext from '../index'
|
||||
|
||||
function Consumer() {
|
||||
const value = useContextSelector(RadioGroupContext, v => v)
|
||||
return <div data-testid="ctx-value">{JSON.stringify(value)}</div>
|
||||
}
|
||||
|
||||
describe('RadioGroupContext', () => {
|
||||
it('provides null as default value when no provider is used', () => {
|
||||
render(<Consumer />)
|
||||
|
||||
const node = screen.getByTestId('ctx-value')
|
||||
expect(node).toBeInTheDocument()
|
||||
expect(node).toHaveTextContent('null')
|
||||
})
|
||||
|
||||
it('provides value from provider when wrapped', () => {
|
||||
const providedValue = { value: 'radio', onChange: () => {} }
|
||||
|
||||
render(
|
||||
<RadioGroupContext.Provider value={providedValue}>
|
||||
<Consumer />
|
||||
</RadioGroupContext.Provider>,
|
||||
)
|
||||
|
||||
const node = screen.getByTestId('ctx-value')
|
||||
expect(node).toBeInTheDocument()
|
||||
expect(node).toHaveTextContent(JSON.stringify(providedValue))
|
||||
})
|
||||
|
||||
it('updates when provider value changes', () => {
|
||||
const first = { value: 'first', onChange: () => {} }
|
||||
const second = { value: 'second', onChange: () => {} }
|
||||
|
||||
const { rerender } = render(
|
||||
<RadioGroupContext.Provider value={first}>
|
||||
<Consumer />
|
||||
</RadioGroupContext.Provider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('ctx-value')).toHaveTextContent(
|
||||
JSON.stringify(first),
|
||||
)
|
||||
|
||||
rerender(
|
||||
<RadioGroupContext.Provider value={second}>
|
||||
<Consumer />
|
||||
</RadioGroupContext.Provider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('ctx-value')).toHaveTextContent(
|
||||
JSON.stringify(second),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -1,6 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { createContext } from 'use-context-selector'
|
||||
|
||||
const RadioGroupContext = createContext<any>(null)
|
||||
export default RadioGroupContext
|
||||
@ -1,442 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { useState } from 'react'
|
||||
import Radio from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/Radio',
|
||||
component: Radio,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Radio component for single selection. Usually used with Radio.Group for multiple options.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
checked: {
|
||||
control: 'boolean',
|
||||
description: 'Checked state (for standalone radio)',
|
||||
},
|
||||
value: {
|
||||
control: 'text',
|
||||
description: 'Value of the radio option',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
children: {
|
||||
control: 'text',
|
||||
description: 'Label content',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Radio>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Single radio demo
|
||||
const SingleRadioDemo = (args: any) => {
|
||||
const [checked, setChecked] = useState(args.checked || false)
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<Radio
|
||||
{...args}
|
||||
checked={checked}
|
||||
onChange={() => setChecked(!checked)}
|
||||
>
|
||||
{args.children || 'Radio option'}
|
||||
</Radio>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default single radio
|
||||
export const Default: Story = {
|
||||
render: args => <SingleRadioDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: false,
|
||||
children: 'Single radio option',
|
||||
},
|
||||
}
|
||||
|
||||
// Checked state
|
||||
export const Checked: Story = {
|
||||
render: args => <SingleRadioDemo {...args} />,
|
||||
args: {
|
||||
checked: true,
|
||||
disabled: false,
|
||||
children: 'Selected option',
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const Disabled: Story = {
|
||||
render: args => <SingleRadioDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: true,
|
||||
children: 'Disabled option',
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled and checked
|
||||
export const DisabledChecked: Story = {
|
||||
render: args => <SingleRadioDemo {...args} />,
|
||||
args: {
|
||||
checked: true,
|
||||
disabled: true,
|
||||
children: 'Disabled selected option',
|
||||
},
|
||||
}
|
||||
|
||||
// Radio Group - Basic
|
||||
const RadioGroupDemo = () => {
|
||||
const [value, setValue] = useState('option1')
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<Radio.Group value={value} onChange={setValue}>
|
||||
<Radio value="option1">Option 1</Radio>
|
||||
<Radio value="option2">Option 2</Radio>
|
||||
<Radio value="option3">Option 3</Radio>
|
||||
</Radio.Group>
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
Selected:
|
||||
{' '}
|
||||
<span className="font-semibold">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const RadioGroup: Story = {
|
||||
render: () => <RadioGroupDemo />,
|
||||
}
|
||||
|
||||
// Radio Group - With descriptions
|
||||
const RadioGroupWithDescriptionsDemo = () => {
|
||||
const [value, setValue] = useState('basic')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }}>
|
||||
<h3 className="mb-3 text-sm font-medium text-gray-700">Select a plan</h3>
|
||||
<Radio.Group value={value} onChange={setValue}>
|
||||
<Radio value="basic">
|
||||
<div>
|
||||
<div className="font-medium">Basic Plan</div>
|
||||
<div className="text-xs text-gray-500">Free forever - Perfect for personal use</div>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="pro">
|
||||
<div>
|
||||
<div className="font-medium">Pro Plan</div>
|
||||
<div className="text-xs text-gray-500">$19/month - Advanced features for professionals</div>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="enterprise">
|
||||
<div>
|
||||
<div className="font-medium">Enterprise Plan</div>
|
||||
<div className="text-xs text-gray-500">Custom pricing - Full features and support</div>
|
||||
</div>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const RadioGroupWithDescriptions: Story = {
|
||||
render: () => <RadioGroupWithDescriptionsDemo />,
|
||||
}
|
||||
|
||||
// Radio Group - With disabled option
|
||||
const RadioGroupWithDisabledDemo = () => {
|
||||
const [value, setValue] = useState('available')
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<Radio.Group value={value} onChange={setValue}>
|
||||
<Radio value="available">Available option</Radio>
|
||||
<Radio value="disabled" disabled>Disabled option</Radio>
|
||||
<Radio value="another">Another available option</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const RadioGroupWithDisabled: Story = {
|
||||
render: () => <RadioGroupWithDisabledDemo />,
|
||||
}
|
||||
|
||||
// Radio Group - Vertical layout
|
||||
const VerticalLayoutDemo = () => {
|
||||
const [value, setValue] = useState('email')
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<h3 className="mb-3 text-sm font-medium text-gray-700">Notification preferences</h3>
|
||||
<Radio.Group value={value} onChange={setValue} className="flex-col gap-2">
|
||||
<Radio value="email">Email notifications</Radio>
|
||||
<Radio value="sms">SMS notifications</Radio>
|
||||
<Radio value="push">Push notifications</Radio>
|
||||
<Radio value="none">No notifications</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VerticalLayout: Story = {
|
||||
render: () => <VerticalLayoutDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Settings panel
|
||||
const SettingsPanelDemo = () => {
|
||||
const [theme, setTheme] = useState('light')
|
||||
const [language, setLanguage] = useState('en')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-6 text-lg font-semibold">Application Settings</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-medium text-gray-700">Theme</h4>
|
||||
<Radio.Group value={theme} onChange={setTheme} className="flex-col gap-2">
|
||||
<Radio value="light">Light mode</Radio>
|
||||
<Radio value="dark">Dark mode</Radio>
|
||||
<Radio value="auto">Auto (system preference)</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h4 className="mb-3 text-sm font-medium text-gray-700">Language</h4>
|
||||
<Radio.Group value={language} onChange={setLanguage} className="flex-col gap-2">
|
||||
<Radio value="en">English</Radio>
|
||||
<Radio value="zh">中文 (Chinese)</Radio>
|
||||
<Radio value="es">Español (Spanish)</Radio>
|
||||
<Radio value="fr">Français (French)</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-lg bg-blue-50 p-3">
|
||||
<div className="text-xs text-gray-600">
|
||||
<strong>Current settings:</strong>
|
||||
{' '}
|
||||
Theme:
|
||||
{theme}
|
||||
, Language:
|
||||
{language}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsPanel: Story = {
|
||||
render: () => <SettingsPanelDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Payment method selector
|
||||
const PaymentMethodSelectorDemo = () => {
|
||||
const [paymentMethod, setPaymentMethod] = useState('credit_card')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Payment Method</h3>
|
||||
<Radio.Group value={paymentMethod} onChange={setPaymentMethod} className="flex-col gap-3">
|
||||
<Radio value="credit_card">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Credit Card</div>
|
||||
<div className="text-xs text-gray-500">Visa, Mastercard, Amex</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">💳</div>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="paypal">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">PayPal</div>
|
||||
<div className="text-xs text-gray-500">Fast and secure</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">🅿️</div>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="bank_transfer">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Bank Transfer</div>
|
||||
<div className="text-xs text-gray-500">1-3 business days</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">🏦</div>
|
||||
</div>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
|
||||
<button className="mt-6 w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Continue with
|
||||
{' '}
|
||||
{paymentMethod.replace('_', ' ')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PaymentMethodSelector: Story = {
|
||||
render: () => <PaymentMethodSelectorDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Shipping options
|
||||
const ShippingOptionsDemo = () => {
|
||||
const [shipping, setShipping] = useState('standard')
|
||||
|
||||
const shippingCosts = {
|
||||
standard: 5.99,
|
||||
express: 14.99,
|
||||
overnight: 29.99,
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Shipping Method</h3>
|
||||
<Radio.Group value={shipping} onChange={setShipping} className="flex-col gap-3">
|
||||
<Radio value="standard">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Standard Shipping</div>
|
||||
<div className="text-xs text-gray-500">5-7 business days</div>
|
||||
</div>
|
||||
<div className="font-semibold text-gray-700">
|
||||
$
|
||||
{shippingCosts.standard}
|
||||
</div>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="express">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Express Shipping</div>
|
||||
<div className="text-xs text-gray-500">2-3 business days</div>
|
||||
</div>
|
||||
<div className="font-semibold text-gray-700">
|
||||
$
|
||||
{shippingCosts.express}
|
||||
</div>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="overnight">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Overnight Shipping</div>
|
||||
<div className="text-xs text-gray-500">Next business day</div>
|
||||
</div>
|
||||
<div className="font-semibold text-gray-700">
|
||||
$
|
||||
{shippingCosts.overnight}
|
||||
</div>
|
||||
</div>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
|
||||
<div className="mt-6 border-t border-gray-200 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Shipping cost:</span>
|
||||
<span className="text-lg font-semibold text-gray-900">
|
||||
$
|
||||
{shippingCosts[shipping as keyof typeof shippingCosts]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ShippingOptions: Story = {
|
||||
render: () => <ShippingOptionsDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Survey question
|
||||
const SurveyQuestionDemo = () => {
|
||||
const [satisfaction, setSatisfaction] = useState('')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-2 text-base font-semibold">Customer Satisfaction Survey</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">How satisfied are you with our service?</p>
|
||||
|
||||
<Radio.Group value={satisfaction} onChange={setSatisfaction} className="flex-col gap-2">
|
||||
<Radio value="very_satisfied">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>😄</span>
|
||||
<span>Very satisfied</span>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="satisfied">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>🙂</span>
|
||||
<span>Satisfied</span>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="neutral">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>😐</span>
|
||||
<span>Neutral</span>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="dissatisfied">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>😟</span>
|
||||
<span>Dissatisfied</span>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="very_dissatisfied">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>😢</span>
|
||||
<span>Very dissatisfied</span>
|
||||
</div>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
|
||||
<button
|
||||
className="mt-6 w-full rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!satisfaction}
|
||||
>
|
||||
Submit Feedback
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SurveyQuestion: Story = {
|
||||
render: () => <SurveyQuestionDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
const PlaygroundDemo = () => {
|
||||
const [value, setValue] = useState('option1')
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<Radio.Group value={value} onChange={setValue}>
|
||||
<Radio value="option1">Option 1</Radio>
|
||||
<Radio value="option2">Option 2</Radio>
|
||||
<Radio value="option3">Option 3</Radio>
|
||||
<Radio value="option4" disabled>Disabled option</Radio>
|
||||
</Radio.Group>
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
Selected:
|
||||
{' '}
|
||||
<span className="font-semibold">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <PlaygroundDemo />,
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
import type * as React from 'react'
|
||||
import type { IRadioProps } from './component/radio'
|
||||
import Group from './component/group'
|
||||
import RadioComps from './component/radio'
|
||||
|
||||
type CompoundedComponent = {
|
||||
Group: typeof Group
|
||||
} & React.ForwardRefExoticComponent<IRadioProps & React.RefAttributes<HTMLElement>>
|
||||
|
||||
const Radio = RadioComps as CompoundedComponent
|
||||
/**
|
||||
* Radio 组件出现一般是以一组的形式出现
|
||||
*/
|
||||
Radio.Group = Group
|
||||
export default Radio
|
||||
@ -1,13 +0,0 @@
|
||||
.container {
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
position: relative;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.label:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
|
||||
type Props = {
|
||||
isChecked: boolean
|
||||
disabled?: boolean
|
||||
onCheck?: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const RadioUI: FC<Props> = ({
|
||||
isChecked,
|
||||
disabled = false,
|
||||
onCheck,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
role="radio"
|
||||
aria-checked={isChecked}
|
||||
aria-disabled={disabled}
|
||||
className={cn(
|
||||
'size-4 rounded-full',
|
||||
isChecked && !disabled && 'border-[5px] border-components-radio-border-checked hover:border-components-radio-border-checked-hover',
|
||||
!isChecked && !disabled && 'border border-components-radio-border hover:border-components-radio-border-hover',
|
||||
isChecked && disabled && 'border-[5px] border-components-radio-border-checked-disabled',
|
||||
!isChecked && disabled && 'border border-components-radio-border-disabled bg-components-radio-bg-disabled',
|
||||
!disabled && 'bg-components-radio-bg shadow-xs shadow-shadow-shadow-3 hover:bg-components-radio-bg-hover',
|
||||
className,
|
||||
)}
|
||||
onClick={(event) => {
|
||||
if (disabled)
|
||||
return
|
||||
onCheck?.(event)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default React.memo(RadioUI)
|
||||
@ -1,21 +1,8 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { retrievalIcon } from '../../../create/icons'
|
||||
import RetrievalMethodInfo, { getIcon } from '../index'
|
||||
|
||||
// Mock RadioCard
|
||||
vi.mock('@/app/components/base/radio-card', () => ({
|
||||
default: ({ title, description, chosenConfig, icon }: { title: string, description: string, chosenConfig: ReactNode, icon: ReactNode }) => (
|
||||
<div data-testid="radio-card">
|
||||
<div data-testid="card-title">{title}</div>
|
||||
<div data-testid="card-description">{description}</div>
|
||||
<div data-testid="card-icon">{icon}</div>
|
||||
<div data-testid="chosen-config">{chosenConfig}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock icons
|
||||
vi.mock('../../../create/icons', () => ({
|
||||
retrievalIcon: {
|
||||
@ -45,11 +32,9 @@ describe('RetrievalMethodInfo', () => {
|
||||
it('should render correctly with full config', () => {
|
||||
const { container } = render(<RetrievalMethodInfo value={defaultConfig} />)
|
||||
|
||||
expect(screen.getByTestId('radio-card')).toBeInTheDocument()
|
||||
|
||||
// Check Title & Description (mocked i18n returns key prefixed with ns)
|
||||
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.semantic_search.title')
|
||||
expect(screen.getByTestId('card-description')).toHaveTextContent('dataset.retrieval.semantic_search.description')
|
||||
expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.retrieval.semantic_search.description')).toBeInTheDocument()
|
||||
|
||||
// Check Icon
|
||||
const icon = container.querySelector('img')
|
||||
@ -82,7 +67,7 @@ describe('RetrievalMethodInfo', () => {
|
||||
const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid }
|
||||
const { container, unmount } = render(<RetrievalMethodInfo value={hybridConfig} />)
|
||||
|
||||
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.hybrid_search.title')
|
||||
expect(screen.getByText('dataset.retrieval.hybrid_search.title')).toBeInTheDocument()
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'hybrid-icon.png')
|
||||
|
||||
unmount()
|
||||
@ -90,7 +75,7 @@ describe('RetrievalMethodInfo', () => {
|
||||
// Test FullText
|
||||
const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText }
|
||||
const { container: fullTextContainer } = render(<RetrievalMethodInfo value={fullTextConfig} />)
|
||||
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.full_text_search.title')
|
||||
expect(screen.getByText('dataset.retrieval.full_text_search.title')).toBeInTheDocument()
|
||||
expect(fullTextContainer.querySelector('img')).toHaveAttribute('src', 'fulltext-icon.png')
|
||||
})
|
||||
|
||||
|
||||
@ -103,25 +103,6 @@ vi.mock('@/app/components/base/param-item/score-threshold-item', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/radio-card', () => ({
|
||||
default: ({ isChosen, onChosen, title, description }: {
|
||||
isChosen: boolean
|
||||
onChosen: () => void
|
||||
title: string
|
||||
description: string
|
||||
}) => (
|
||||
<div
|
||||
data-testid="radio-card"
|
||||
data-chosen={isChosen}
|
||||
data-title={title}
|
||||
onClick={onChosen}
|
||||
>
|
||||
{title}
|
||||
<span data-testid="radio-description">{description}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/switch', () => ({
|
||||
Switch: ({ checked, onCheckedChange }: { checked: boolean, onCheckedChange?: (v: boolean) => void }) => (
|
||||
<button
|
||||
@ -477,8 +458,7 @@ describe('RetrievalParamConfig', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const radioCards = screen.getAllByTestId('radio-card')
|
||||
expect(radioCards).toHaveLength(2)
|
||||
expect(screen.getAllByRole('radio')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should have WeightedScore option', () => {
|
||||
@ -554,9 +534,7 @@ describe('RetrievalParamConfig', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const radioCards = screen.getAllByTestId('radio-card')
|
||||
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'dataset.weightedScore.title')
|
||||
fireEvent.click(weightedScoreCard!)
|
||||
fireEvent.click(screen.getByRole('radio', { name: /dataset\.weightedScore\.title/ }))
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalled()
|
||||
const calledWith = mockOnChange.mock.calls[0]![0]
|
||||
@ -573,9 +551,7 @@ describe('RetrievalParamConfig', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const radioCards = screen.getAllByTestId('radio-card')
|
||||
const rerankModelCard = radioCards.find(card => card.getAttribute('data-title') === 'common.modelProvider.rerankModel.key')
|
||||
fireEvent.click(rerankModelCard!)
|
||||
fireEvent.click(screen.getByRole('radio', { name: /common\.modelProvider\.rerankModel\.key/ }))
|
||||
|
||||
expect(mockOnChange).not.toHaveBeenCalled()
|
||||
})
|
||||
@ -605,9 +581,7 @@ describe('RetrievalParamConfig', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const radioCards = screen.getAllByTestId('radio-card')
|
||||
const rerankModelCard = radioCards.find(card => card.getAttribute('data-title') === 'common.modelProvider.rerankModel.key')
|
||||
fireEvent.click(rerankModelCard!)
|
||||
fireEvent.click(screen.getByRole('radio', { name: /common\.modelProvider\.rerankModel\.key/ }))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith('workflow.errorMsg.rerankModelRequired')
|
||||
})
|
||||
@ -827,9 +801,7 @@ describe('RetrievalParamConfig', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const radioCards = screen.getAllByTestId('radio-card')
|
||||
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'dataset.weightedScore.title')
|
||||
fireEvent.click(weightedScoreCard!)
|
||||
fireEvent.click(screen.getByRole('radio', { name: /dataset\.weightedScore\.title/ }))
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalled()
|
||||
const calledWith = mockOnChange.mock.calls[0]![0]
|
||||
@ -861,9 +833,7 @@ describe('RetrievalParamConfig', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const radioCards = screen.getAllByTestId('radio-card')
|
||||
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'dataset.weightedScore.title')
|
||||
fireEvent.click(weightedScoreCard!)
|
||||
fireEvent.click(screen.getByRole('radio', { name: /dataset\.weightedScore\.title/ }))
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalled()
|
||||
const calledWith = mockOnChange.mock.calls[0]![0]
|
||||
|
||||
@ -3,6 +3,7 @@ import type { FC } from 'react'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import * as React from 'react'
|
||||
@ -207,13 +208,17 @@ const RetrievalParamConfig: FC<Props> = ({
|
||||
{
|
||||
isHybridSearch && (
|
||||
<>
|
||||
<div className="mb-4 flex gap-2">
|
||||
<RadioGroup<RerankingModeEnum>
|
||||
aria-label={t('modelProvider.rerankModel.key', { ns: 'common' })}
|
||||
value={value.reranking_mode}
|
||||
onValueChange={handleChangeRerankMode}
|
||||
className="mb-4 flex gap-2"
|
||||
>
|
||||
{
|
||||
rerankingModeOptions.map(option => (
|
||||
<RadioCard
|
||||
key={option.value}
|
||||
isChosen={value.reranking_mode === option.value}
|
||||
onChosen={() => handleChangeRerankMode(option.value)}
|
||||
value={option.value}
|
||||
icon={(
|
||||
<img
|
||||
src={
|
||||
@ -230,7 +235,7 @@ const RetrievalParamConfig: FC<Props> = ({
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
{
|
||||
value.reranking_mode === RerankingModeEnum.WeightedScore && (
|
||||
<WeightedScore
|
||||
|
||||
@ -5,6 +5,7 @@ import type { ParentChildConfig } from '../hooks'
|
||||
import type { ParentMode, PreProcessingRule, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { RiSearchEyeLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
@ -115,36 +116,39 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
|
||||
</div>
|
||||
<Divider className="grow" bgStyle="gradient" />
|
||||
</div>
|
||||
<RadioCard
|
||||
className="mt-1"
|
||||
icon={<img src={Note.src} alt="" />}
|
||||
title={t('stepTwo.paragraph', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })}
|
||||
isChosen={parentChildConfig.chunkForContext === 'paragraph'}
|
||||
onChosen={() => onChunkForContextChange('paragraph')}
|
||||
chosenConfig={(
|
||||
<div className="flex gap-3">
|
||||
<DelimiterInput
|
||||
value={parentChildConfig.parent.delimiter}
|
||||
tooltip={t('stepTwo.parentChildDelimiterTip', { ns: 'datasetCreation' })!}
|
||||
onChange={e => onParentDelimiterChange(e.target.value)}
|
||||
/>
|
||||
<MaxLengthInput
|
||||
unit="characters"
|
||||
value={parentChildConfig.parent.maxLength}
|
||||
onChange={onParentMaxLengthChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<RadioCard
|
||||
className="mt-2"
|
||||
icon={<img src={FileList.src} alt="" />}
|
||||
title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })}
|
||||
onChosen={() => onChunkForContextChange('full-doc')}
|
||||
isChosen={parentChildConfig.chunkForContext === 'full-doc'}
|
||||
/>
|
||||
<RadioGroup<ParentMode>
|
||||
aria-label={t('stepTwo.parentChunkForContext', { ns: 'datasetCreation' })}
|
||||
value={parentChildConfig.chunkForContext}
|
||||
onValueChange={value => onChunkForContextChange(value)}
|
||||
className="mt-1 flex-col items-stretch gap-2"
|
||||
>
|
||||
<RadioCard
|
||||
value="paragraph"
|
||||
icon={<img src={Note.src} alt="" />}
|
||||
title={t('stepTwo.paragraph', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })}
|
||||
chosenConfig={(
|
||||
<div className="flex gap-3">
|
||||
<DelimiterInput
|
||||
value={parentChildConfig.parent.delimiter}
|
||||
tooltip={t('stepTwo.parentChildDelimiterTip', { ns: 'datasetCreation' })!}
|
||||
onChange={e => onParentDelimiterChange(e.target.value)}
|
||||
/>
|
||||
<MaxLengthInput
|
||||
unit="characters"
|
||||
value={parentChildConfig.parent.maxLength}
|
||||
onChange={onParentMaxLengthChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<RadioCard
|
||||
value="full-doc"
|
||||
icon={<img src={FileList.src} alt="" />}
|
||||
title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* Child chunk for retrieval */}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { OnlineDriveFileType } from '@/models/pipeline'
|
||||
@ -1378,7 +1379,11 @@ describe('Item', () => {
|
||||
|
||||
it('should show radio as checked when isSelected is true', () => {
|
||||
const props = createItemProps({ isSelected: true, isMultipleChoice: false })
|
||||
render(<ActualItem {...props} />)
|
||||
render(
|
||||
<RadioGroup aria-label="Files" value={props.file.id}>
|
||||
<ActualItem {...props} />
|
||||
</RadioGroup>,
|
||||
)
|
||||
const radio = getRadio()
|
||||
expect(radio).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
@ -1481,7 +1486,17 @@ describe('Item', () => {
|
||||
const onSelect = vi.fn()
|
||||
const file = createMockOnlineDriveFile()
|
||||
const props = createItemProps({ file, onSelect, isMultipleChoice: false })
|
||||
render(<ActualItem {...props} />)
|
||||
render(
|
||||
<RadioGroup
|
||||
aria-label="Files"
|
||||
onValueChange={(fileId) => {
|
||||
if (fileId === file.id)
|
||||
onSelect(file)
|
||||
}}
|
||||
>
|
||||
<ActualItem {...props} />
|
||||
</RadioGroup>,
|
||||
)
|
||||
const radio = getRadio()
|
||||
fireEvent.click(radio)
|
||||
expect(onSelect).toHaveBeenCalledWith(file)
|
||||
|
||||
@ -1,14 +1,9 @@
|
||||
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Item from '../item'
|
||||
|
||||
vi.mock('@/app/components/base/radio/ui', () => ({
|
||||
default: ({ isChecked, onCheck }: { isChecked: boolean, onCheck: () => void }) => (
|
||||
<input type="radio" data-testid="radio" checked={isChecked} onChange={onCheck} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../file-icon', () => ({
|
||||
default: () => <span data-testid="file-icon" />,
|
||||
}))
|
||||
@ -43,8 +38,13 @@ describe('Item', () => {
|
||||
})
|
||||
|
||||
it('should render radio for file type in single choice mode', () => {
|
||||
render(<Item {...defaultProps} isMultipleChoice={false} />)
|
||||
expect(screen.getByTestId('radio')).toBeInTheDocument()
|
||||
render(
|
||||
<RadioGroup aria-label="Files" value={defaultProps.file.id}>
|
||||
<Item {...defaultProps} isMultipleChoice={false} />
|
||||
</RadioGroup>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'test.pdf' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render checkbox for bucket type', () => {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { RiLoader2Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
@ -53,6 +54,25 @@ const List = ({
|
||||
const isPartialLoading = isLoading && fileList.length > 0
|
||||
const isEmptyFolder = !isLoading && fileList.length === 0 && keywords.length === 0
|
||||
const isSearchResultEmpty = !isLoading && fileList.length === 0 && keywords.length > 0
|
||||
const selectedFileId = selectedFileIds[0]
|
||||
const handleRadioChange = (fileId: string) => {
|
||||
const selectedFile = fileList.find(file => file.id === fileId)
|
||||
if (selectedFile)
|
||||
handleSelectFile(selectedFile)
|
||||
}
|
||||
const fileItems = fileList.map((file) => {
|
||||
const isSelected = selectedFileIds.includes(file.id)
|
||||
return (
|
||||
<Item
|
||||
key={file.id}
|
||||
file={file}
|
||||
isSelected={isSelected}
|
||||
onSelect={handleSelectFile}
|
||||
onOpen={handleOpenFolder}
|
||||
isMultipleChoice={supportBatchUpload}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="grow overflow-hidden p-1 pt-0">
|
||||
@ -73,21 +93,18 @@ const List = ({
|
||||
}
|
||||
{fileList.length > 0 && (
|
||||
<div className="flex h-full flex-col gap-y-px overflow-y-auto rounded-[10px] bg-background-section px-1 py-1.5">
|
||||
{
|
||||
fileList.map((file) => {
|
||||
const isSelected = selectedFileIds.includes(file.id)
|
||||
return (
|
||||
<Item
|
||||
key={file.id}
|
||||
file={file}
|
||||
isSelected={isSelected}
|
||||
onSelect={handleSelectFile}
|
||||
onOpen={handleOpenFolder}
|
||||
isMultipleChoice={supportBatchUpload}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
{supportBatchUpload
|
||||
? fileItems
|
||||
: (
|
||||
<RadioGroup
|
||||
aria-label={t('onlineDrive.breadcrumbs.allFiles', { ns: 'datasetPipeline' })}
|
||||
value={selectedFileId}
|
||||
onValueChange={handleRadioChange}
|
||||
className="contents"
|
||||
>
|
||||
{fileItems}
|
||||
</RadioGroup>
|
||||
)}
|
||||
{
|
||||
isPartialLoading && (
|
||||
<div
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { Radio } from '@langgenius/dify-ui/radio'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Radio from '@/app/components/base/radio/ui'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
import FileIcon from './file-icon'
|
||||
|
||||
@ -33,11 +33,6 @@ const Item = ({
|
||||
|
||||
const disabledTip = t('onlineDrive.notSupportedFileType', { ns: 'datasetPipeline' })
|
||||
|
||||
const handleSelect = useCallback((e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
onSelect(file)
|
||||
}, [file, onSelect])
|
||||
|
||||
const handleCheckboxSelect = useCallback(() => {
|
||||
onSelect(file)
|
||||
}, [file, onSelect])
|
||||
@ -70,12 +65,14 @@ const Item = ({
|
||||
</span>
|
||||
)}
|
||||
{!isBucket && !isMultipleChoice && (
|
||||
<Radio
|
||||
className="shrink-0"
|
||||
disabled={disabled}
|
||||
isChecked={isSelected}
|
||||
onCheck={handleSelect}
|
||||
/>
|
||||
<span onClick={event => event.stopPropagation()}>
|
||||
<Radio
|
||||
className="shrink-0"
|
||||
disabled={disabled}
|
||||
value={file.id}
|
||||
aria-label={name}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{disabled
|
||||
? (
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import CrawledResultItem from '../crawled-result-item'
|
||||
@ -9,12 +10,6 @@ vi.mock('@langgenius/dify-ui/button', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/radio/ui', () => ({
|
||||
default: ({ isChecked, onCheck }: { isChecked: boolean, onCheck: () => void }) => (
|
||||
<input type="radio" data-testid="radio" checked={isChecked} onChange={onCheck} />
|
||||
),
|
||||
}))
|
||||
|
||||
describe('CrawledResultItem', () => {
|
||||
const defaultProps = {
|
||||
payload: {
|
||||
@ -47,8 +42,13 @@ describe('CrawledResultItem', () => {
|
||||
})
|
||||
|
||||
it('should render radio in single choice mode', () => {
|
||||
render(<CrawledResultItem {...defaultProps} isMultipleChoice={false} />)
|
||||
expect(screen.getByTestId('radio')).toBeInTheDocument()
|
||||
render(
|
||||
<RadioGroup aria-label="Crawled pages" value={defaultProps.payload.source_url}>
|
||||
<CrawledResultItem {...defaultProps} isMultipleChoice={false} />
|
||||
</RadioGroup>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('radio', { name: /Test Page/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show preview button when showPreview is true', () => {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
@ -260,18 +261,24 @@ describe('CrawledResultItem', () => {
|
||||
|
||||
it('should toggle radio state when isMultipleChoice is false', () => {
|
||||
const mockOnCheckChange = vi.fn()
|
||||
const { container } = render(
|
||||
<CrawledResultItem
|
||||
{...defaultProps}
|
||||
isMultipleChoice={false}
|
||||
isChecked={false}
|
||||
onCheckChange={mockOnCheckChange}
|
||||
/>,
|
||||
render(
|
||||
<RadioGroup
|
||||
aria-label="Crawled pages"
|
||||
onValueChange={(sourceUrl) => {
|
||||
if (sourceUrl === defaultProps.payload.source_url)
|
||||
mockOnCheckChange(true)
|
||||
}}
|
||||
>
|
||||
<CrawledResultItem
|
||||
{...defaultProps}
|
||||
isMultipleChoice={false}
|
||||
isChecked={false}
|
||||
onCheckChange={mockOnCheckChange}
|
||||
/>
|
||||
</RadioGroup>,
|
||||
)
|
||||
|
||||
// Act - Radio uses size-4 rounded-full classes
|
||||
const radio = container.querySelector('.size-4.rounded-full')!
|
||||
fireEvent.click(radio)
|
||||
fireEvent.click(screen.getByRole('radio', { name: /Test Page Title/ }))
|
||||
|
||||
expect(mockOnCheckChange).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
@ -3,10 +3,9 @@ import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Radio } from '@langgenius/dify-ui/radio'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Radio from '@/app/components/base/radio/ui'
|
||||
|
||||
type CrawledResultItemProps = {
|
||||
payload: CrawlResultItemType
|
||||
@ -29,10 +28,6 @@ const CrawledResultItem = ({
|
||||
}: CrawledResultItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleCheckChange = useCallback(() => {
|
||||
onCheckChange(!isChecked)
|
||||
}, [isChecked, onCheckChange])
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'relative flex gap-x-2 rounded-lg p-2',
|
||||
@ -65,11 +60,10 @@ const CrawledResultItem = ({
|
||||
</label>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<label className="flex min-w-0 grow cursor-pointer gap-x-2">
|
||||
<Radio
|
||||
className="shrink-0"
|
||||
isChecked={isChecked}
|
||||
onCheck={handleCheckChange}
|
||||
value={payload.source_url}
|
||||
/>
|
||||
<div className="flex min-w-0 grow flex-col gap-y-0.5">
|
||||
<div
|
||||
@ -85,7 +79,7 @@ const CrawledResultItem = ({
|
||||
{payload.source_url}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
{showPreview && (
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
import type { CrawlResultItem } from '@/models/datasets'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -62,6 +63,26 @@ const CrawledResult = ({
|
||||
onPreview(list[index]!, index)
|
||||
}, [list, onPreview])
|
||||
|
||||
const selectedSourceUrl = checkedList[0]?.source_url
|
||||
const handleRadioChange = useCallback((sourceUrl: string) => {
|
||||
const selectedItem = list.find(item => item.source_url === sourceUrl)
|
||||
if (selectedItem)
|
||||
onSelectedChange([selectedItem])
|
||||
}, [list, onSelectedChange])
|
||||
|
||||
const resultItems = list.map((item, index) => (
|
||||
<CrawledResultItem
|
||||
key={item.source_url}
|
||||
payload={item}
|
||||
isChecked={checkedList.some(checkedItem => checkedItem.source_url === item.source_url)}
|
||||
onCheckChange={handleItemCheckChange(item)}
|
||||
isPreview={index === previewIndex}
|
||||
onPreview={handlePreview.bind(null, index)}
|
||||
showPreview={showPreview}
|
||||
isMultipleChoice={isMultipleChoice}
|
||||
/>
|
||||
))
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-2', className)}>
|
||||
<div className="pt-2 system-sm-medium text-text-primary">
|
||||
@ -81,20 +102,26 @@ const CrawledResult = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-y-px border-t border-divider-subtle bg-background-default-subtle p-2">
|
||||
{list.map((item, index) => (
|
||||
<CrawledResultItem
|
||||
key={item.source_url}
|
||||
payload={item}
|
||||
isChecked={checkedList.some(checkedItem => checkedItem.source_url === item.source_url)}
|
||||
onCheckChange={handleItemCheckChange(item)}
|
||||
isPreview={index === previewIndex}
|
||||
onPreview={handlePreview.bind(null, index)}
|
||||
showPreview={showPreview}
|
||||
isMultipleChoice={isMultipleChoice}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{isMultipleChoice
|
||||
? (
|
||||
<div className="flex flex-col gap-y-px border-t border-divider-subtle bg-background-default-subtle p-2">
|
||||
{resultItems}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<RadioGroup
|
||||
aria-label={t(`${I18N_PREFIX}.scrapTimeInfo`, {
|
||||
ns: 'datasetCreation',
|
||||
total: list.length,
|
||||
time: usedTime.toFixed(1),
|
||||
})}
|
||||
value={selectedSourceUrl}
|
||||
onValueChange={handleRadioChange}
|
||||
className="flex flex-col gap-y-px border-t border-divider-subtle bg-background-default-subtle p-2"
|
||||
>
|
||||
{resultItems}
|
||||
</RadioGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -47,12 +47,9 @@ describe('DocTypeSelector', () => {
|
||||
})
|
||||
|
||||
it('should render icon buttons for each doc type', () => {
|
||||
const { container } = render(<DocTypeSelector {...defaultProps} />)
|
||||
render(<DocTypeSelector {...defaultProps} />)
|
||||
|
||||
// Each doc type renders an IconButton wrapped in Radio
|
||||
const iconButtons = container.querySelectorAll('button[type="button"]')
|
||||
// 3 doc types + 1 confirm button = 4 buttons
|
||||
expect(iconButtons.length).toBeGreaterThanOrEqual(3)
|
||||
expect(screen.getAllByRole('radio')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should render confirm button disabled when tempDocType is empty', () => {
|
||||
|
||||
@ -3,9 +3,12 @@ import type { FC } from 'react'
|
||||
import type { DocType } from '@/models/datasets'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
|
||||
import { Radio } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import { useMetadataMap } from '@/hooks/use-metadata'
|
||||
import { CUSTOMIZABLE_DOC_TYPES } from '@/models/datasets'
|
||||
import s from '../style.module.css'
|
||||
@ -20,12 +23,12 @@ const IconButton: FC<{ type: DocType, isChecked: boolean }> = ({ type, isChecked
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<button type="button" className={cn(s.iconWrapper, 'group', isChecked ? s.iconCheck : '')}>
|
||||
<span className={cn(s.iconWrapper, 'group', isChecked ? s.iconCheck : '')}>
|
||||
<TypeIcon
|
||||
iconName={metadataMap[type].iconName || ''}
|
||||
className={`group-hover:bg-primary-600 ${isChecked ? 'bg-primary-600!' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
@ -53,6 +56,7 @@ const DocTypeSelector: FC<DocTypeSelectorProps> = ({
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const metadataMap = useMetadataMap()
|
||||
const isFirstTime = !docType && !documentType
|
||||
const currValue = tempDocType ?? documentType
|
||||
|
||||
@ -62,22 +66,44 @@ const DocTypeSelector: FC<DocTypeSelectorProps> = ({
|
||||
<div className={s.desc}>{t('metadata.desc', { ns: 'datasetDocuments' })}</div>
|
||||
)}
|
||||
<div className={s.operationWrapper}>
|
||||
{isFirstTime && (
|
||||
<span className={s.title}>{t('metadata.docTypeSelectTitle', { ns: 'datasetDocuments' })}</span>
|
||||
)}
|
||||
{documentType && (
|
||||
<>
|
||||
<span className={s.title}>{t('metadata.docTypeChangeTitle', { ns: 'datasetDocuments' })}</span>
|
||||
<span className={s.changeTip}>{t('metadata.docTypeSelectWarning', { ns: 'datasetDocuments' })}</span>
|
||||
</>
|
||||
)}
|
||||
<Radio.Group value={currValue ?? ''} onChange={onTempDocTypeChange} className={s.radioGroup}>
|
||||
{CUSTOMIZABLE_DOC_TYPES.map(type => (
|
||||
<Radio key={type} value={type} className={`${s.radio} ${currValue === type ? 'shadow-none' : ''}`}>
|
||||
<IconButton type={type} isChecked={currValue === type} />
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
<FieldRoot name="document_type" className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup
|
||||
value={currValue ?? ''}
|
||||
onValueChange={onTempDocTypeChange}
|
||||
className={s.radioGroup}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend className={s.title}>
|
||||
{isFirstTime
|
||||
? t('metadata.docTypeSelectTitle', { ns: 'datasetDocuments' })
|
||||
: t('metadata.docTypeChangeTitle', { ns: 'datasetDocuments' })}
|
||||
</FieldsetLegend>
|
||||
{documentType && (
|
||||
<span className={s.changeTip}>{t('metadata.docTypeSelectWarning', { ns: 'datasetDocuments' })}</span>
|
||||
)}
|
||||
{CUSTOMIZABLE_DOC_TYPES.map(type => (
|
||||
<FieldItem key={type}>
|
||||
<FieldLabel
|
||||
className={cn(
|
||||
s.radio,
|
||||
'focus-within:ring-2 focus-within:ring-components-input-border-hover focus-within:ring-offset-1 focus-within:outline-hidden',
|
||||
currValue === type && 'shadow-none',
|
||||
)}
|
||||
>
|
||||
<Radio
|
||||
value={type}
|
||||
aria-label={metadataMap[type].text}
|
||||
className="sr-only"
|
||||
/>
|
||||
<IconButton type={type} isChecked={currValue === type} />
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
))}
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
{isFirstTime && (
|
||||
<Button variant="primary" onClick={onConfirm} disabled={!tempDocType}>
|
||||
{t('metadata.firstMetaAction', { ns: 'datasetDocuments' })}
|
||||
|
||||
@ -13,11 +13,13 @@ import type {
|
||||
NodeOutPutVar,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
|
||||
import { Radio } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import RadioE from '@/app/components/base/radio/ui'
|
||||
import { AppSelector } from '@/app/components/plugins/plugin-detail-panel/app-selector'
|
||||
import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector'
|
||||
import MultipleToolSelector from '@/app/components/plugins/plugin-detail-panel/multiple-tool-selector'
|
||||
@ -218,41 +220,53 @@ function Form<
|
||||
|
||||
const disabled = isEditMode && (variable === '__model_type' || variable === '__model_name')
|
||||
const gridColumnsClassName = radioGridColumnsClassNames[options.length] ?? 'grid-cols-1'
|
||||
const selectedValue = typeof value[variable] === 'string' ? value[variable] : undefined
|
||||
const translatedLabel = label[language] || label.en_US
|
||||
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div className={cn(fieldLabelClassName, 'flex items-center py-2 system-sm-semibold text-text-secondary')}>
|
||||
{label[language] || label.en_US}
|
||||
{required && (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
<FieldRoot key={variable} name={variable} className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup
|
||||
value={selectedValue}
|
||||
onValueChange={val => handleFormChange(variable, val)}
|
||||
className={cn(itemClassName, 'grid gap-3 py-3', gridColumnsClassName)}
|
||||
/>
|
||||
)}
|
||||
{infotipContent}
|
||||
</div>
|
||||
<div className={cn('grid gap-3', gridColumnsClassName)}>
|
||||
>
|
||||
<FieldsetLegend className={cn(fieldLabelClassName, 'col-span-full flex items-center py-2 system-sm-semibold text-text-secondary')}>
|
||||
<span>{translatedLabel}</span>
|
||||
{required && (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
)}
|
||||
{infotipContent}
|
||||
</FieldsetLegend>
|
||||
{options.filter((option) => {
|
||||
if (option.show_on.length)
|
||||
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
|
||||
|
||||
return true
|
||||
}).map(option => (
|
||||
<div
|
||||
className={`
|
||||
<FieldItem key={`${variable}-${option.value}`} className="min-w-0">
|
||||
<FieldLabel
|
||||
className={`
|
||||
flex cursor-pointer items-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg px-3 py-2
|
||||
${value[variable] === option.value && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm'}
|
||||
${disabled && 'cursor-not-allowed! opacity-60'}
|
||||
`}
|
||||
onClick={() => handleFormChange(variable, option.value)}
|
||||
key={`${variable}-${option.value}`}
|
||||
>
|
||||
<RadioE isChecked={value[variable] === option.value} />
|
||||
>
|
||||
<Radio value={option.value} disabled={disabled} />
|
||||
|
||||
<div className="system-sm-regular text-text-secondary">{option.label[language] || option.label.en_US}</div>
|
||||
</div>
|
||||
<div className="system-sm-regular text-text-secondary">{option.label[language] || option.label.en_US}</div>
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
))}
|
||||
</div>
|
||||
{fieldMoreInfo?.(formSchema)}
|
||||
{validating && changeKey === variable && <ValidatingTip />}
|
||||
</div>
|
||||
<div className="col-span-full">
|
||||
{fieldMoreInfo?.(formSchema)}
|
||||
{validating && changeKey === variable && <ValidatingTip />}
|
||||
</div>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
@ -327,26 +341,44 @@ function Form<
|
||||
|
||||
if (show_on.length && !show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value))
|
||||
return null
|
||||
const booleanValue = typeof value[variable] === 'boolean' ? value[variable] : undefined
|
||||
const translatedLabel = label[language] || label.en_US
|
||||
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div className="flex items-center justify-between py-2 system-sm-semibold text-text-secondary">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={cn(fieldLabelClassName, 'flex items-center py-2 system-sm-semibold text-text-secondary')}>{label[language] || label.en_US}</span>
|
||||
{required && (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
<FieldRoot name={variable} className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup<boolean>
|
||||
className="flex items-center justify-between gap-3 py-2"
|
||||
value={booleanValue}
|
||||
onValueChange={val => handleFormChange(variable, val)}
|
||||
/>
|
||||
)}
|
||||
{infotipContent}
|
||||
</div>
|
||||
<Radio.Group
|
||||
className="flex items-center"
|
||||
value={value[variable]}
|
||||
onChange={val => handleFormChange(variable, val)}
|
||||
>
|
||||
<Radio value={true} className="mr-1!">True</Radio>
|
||||
<Radio value={false}>False</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<FieldsetLegend className={cn(fieldLabelClassName, 'flex items-center py-2 system-sm-semibold text-text-secondary')}>
|
||||
<span>{translatedLabel}</span>
|
||||
{required && (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
)}
|
||||
{infotipContent}
|
||||
</FieldsetLegend>
|
||||
<div className="flex items-center gap-3">
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
|
||||
<Radio value={true} />
|
||||
True
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
|
||||
<Radio value={false} />
|
||||
False
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</div>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
{fieldMoreInfo?.(formSchema)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -4,6 +4,10 @@ import type {
|
||||
NodeOutPutVar,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
|
||||
import { Radio } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@langgenius/dify-ui/select'
|
||||
import { Slider } from '@langgenius/dify-ui/slider'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
@ -11,7 +15,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import TagInput from '@/app/components/base/tag-input'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useLanguage } from '../hooks'
|
||||
@ -218,15 +221,35 @@ function ParameterItem({
|
||||
}
|
||||
|
||||
if (parameterRule.type === 'boolean') {
|
||||
const booleanValue = typeof renderValue === 'boolean' ? renderValue : undefined
|
||||
const translatedLabel = parameterRule.label[language] || parameterRule.label.en_US
|
||||
|
||||
return (
|
||||
<Radio.Group
|
||||
className="flex w-[150px] items-center"
|
||||
value={renderValue as boolean}
|
||||
onChange={handleRadioChange}
|
||||
>
|
||||
<Radio value={true} className="w-[70px] px-[18px]">True</Radio>
|
||||
<Radio value={false} className="w-[70px] px-[18px]">False</Radio>
|
||||
</Radio.Group>
|
||||
<FieldRoot name={parameterRule.name} className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup<boolean>
|
||||
className="w-[150px] gap-3"
|
||||
value={booleanValue}
|
||||
onValueChange={handleRadioChange}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend className="sr-only">{translatedLabel}</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex w-[70px] items-center gap-1.5 system-sm-regular text-text-secondary">
|
||||
<Radio value={true} />
|
||||
True
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex w-[70px] items-center gap-1.5 system-sm-regular text-text-secondary">
|
||||
<Radio value={false} />
|
||||
False
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -12,11 +12,14 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
|
||||
import { Radio } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Radio from '@/app/components/base/radio/ui'
|
||||
import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types'
|
||||
|
||||
type Props = {
|
||||
@ -30,22 +33,21 @@ type ItemProps = {
|
||||
text: string
|
||||
value: AuthType | AuthHeaderPrefix
|
||||
isChecked: boolean
|
||||
onClick: (value: AuthType | AuthHeaderPrefix) => void
|
||||
}
|
||||
|
||||
function SelectItem({ text, value, isChecked, onClick }: ItemProps) {
|
||||
function SelectItem({ text, value, isChecked }: ItemProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
isChecked ? 'border-2 border-util-colors-indigo-indigo-600 bg-components-panel-on-panel-item-bg shadow-sm' : 'border border-components-card-border',
|
||||
'mb-2 flex h-9 w-37.5 cursor-pointer items-center space-x-2 rounded-xl bg-components-panel-on-panel-item-bg pl-3 text-left outline-hidden hover:bg-components-panel-on-panel-item-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover',
|
||||
)}
|
||||
onClick={() => onClick(value)}
|
||||
>
|
||||
<Radio isChecked={isChecked} />
|
||||
<div className="system-sm-regular text-text-primary">{text}</div>
|
||||
</button>
|
||||
<FieldItem>
|
||||
<FieldLabel
|
||||
className={cn(
|
||||
isChecked ? 'border-2 border-util-colors-indigo-indigo-600 bg-components-panel-on-panel-item-bg shadow-sm' : 'border border-components-card-border',
|
||||
'mb-2 flex h-9 w-37.5 cursor-pointer items-center space-x-2 rounded-xl bg-components-panel-on-panel-item-bg pl-3 text-left outline-hidden hover:bg-components-panel-on-panel-item-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover',
|
||||
)}
|
||||
>
|
||||
<Radio value={value} />
|
||||
<div className="system-sm-regular text-text-primary">{text}</div>
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
)
|
||||
}
|
||||
|
||||
@ -57,6 +59,28 @@ export default function ConfigCredential({
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [tempCredential, setTempCredential] = useState<Credential>(credential)
|
||||
const handleAuthTypeChange = (value: AuthType) => {
|
||||
if (value === AuthType.none) {
|
||||
setTempCredential({ auth_type: value })
|
||||
return
|
||||
}
|
||||
|
||||
if (value === AuthType.apiKeyHeader) {
|
||||
setTempCredential({
|
||||
auth_type: value,
|
||||
api_key_header: tempCredential.api_key_header || 'Authorization',
|
||||
api_key_value: tempCredential.api_key_value || '',
|
||||
api_key_header_prefix: tempCredential.api_key_header_prefix || AuthHeaderPrefix.custom,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setTempCredential({
|
||||
auth_type: value,
|
||||
api_key_query_param: tempCredential.api_key_query_param || 'key',
|
||||
api_key_value: tempCredential.api_key_value || '',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@ -94,65 +118,68 @@ export default function ConfigCredential({
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 pt-2">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.authMethod.type', { ns: 'tools' })}</div>
|
||||
<div className="flex space-x-3">
|
||||
<FieldRoot name="auth_type" className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup<AuthType>
|
||||
className="space-x-3"
|
||||
value={tempCredential.auth_type}
|
||||
onValueChange={handleAuthTypeChange}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend className="py-2 system-sm-medium text-text-primary">
|
||||
{t('createTool.authMethod.type', { ns: 'tools' })}
|
||||
</FieldsetLegend>
|
||||
<SelectItem
|
||||
text={t('createTool.authMethod.types.none', { ns: 'tools' })}
|
||||
value={AuthType.none}
|
||||
isChecked={tempCredential.auth_type === AuthType.none}
|
||||
onClick={value => setTempCredential({
|
||||
auth_type: value as AuthType,
|
||||
})}
|
||||
/>
|
||||
<SelectItem
|
||||
text={t('createTool.authMethod.types.api_key_header', { ns: 'tools' })}
|
||||
value={AuthType.apiKeyHeader}
|
||||
isChecked={tempCredential.auth_type === AuthType.apiKeyHeader}
|
||||
onClick={value => setTempCredential({
|
||||
auth_type: value as AuthType,
|
||||
api_key_header: tempCredential.api_key_header || 'Authorization',
|
||||
api_key_value: tempCredential.api_key_value || '',
|
||||
api_key_header_prefix: tempCredential.api_key_header_prefix || AuthHeaderPrefix.custom,
|
||||
})}
|
||||
/>
|
||||
<SelectItem
|
||||
text={t('createTool.authMethod.types.api_key_query', { ns: 'tools' })}
|
||||
value={AuthType.apiKeyQuery}
|
||||
isChecked={tempCredential.auth_type === AuthType.apiKeyQuery}
|
||||
onClick={value => setTempCredential({
|
||||
auth_type: value as AuthType,
|
||||
api_key_query_param: tempCredential.api_key_query_param || 'key',
|
||||
api_key_value: tempCredential.api_key_value || '',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
{tempCredential.auth_type === AuthType.apiKeyHeader && (
|
||||
<>
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.authHeaderPrefix.title', { ns: 'tools' })}</div>
|
||||
<div className="flex space-x-3">
|
||||
<FieldRoot name="api_key_header_prefix" className="contents">
|
||||
<FieldsetRoot
|
||||
render={(
|
||||
<RadioGroup<AuthHeaderPrefix>
|
||||
className="space-x-3"
|
||||
value={tempCredential.api_key_header_prefix}
|
||||
onValueChange={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value })}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<FieldsetLegend className="py-2 system-sm-medium text-text-primary">
|
||||
{t('createTool.authHeaderPrefix.title', { ns: 'tools' })}
|
||||
</FieldsetLegend>
|
||||
<SelectItem
|
||||
text={t('createTool.authHeaderPrefix.types.basic', { ns: 'tools' })}
|
||||
value={AuthHeaderPrefix.basic}
|
||||
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.basic}
|
||||
onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })}
|
||||
/>
|
||||
<SelectItem
|
||||
text={t('createTool.authHeaderPrefix.types.bearer', { ns: 'tools' })}
|
||||
value={AuthHeaderPrefix.bearer}
|
||||
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.bearer}
|
||||
onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })}
|
||||
/>
|
||||
<SelectItem
|
||||
text={t('createTool.authHeaderPrefix.types.custom', { ns: 'tools' })}
|
||||
value={AuthHeaderPrefix.custom}
|
||||
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.custom}
|
||||
onClick={value => setTempCredential({ ...tempCredential, api_key_header_prefix: value as AuthHeaderPrefix })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FieldsetRoot>
|
||||
</FieldRoot>
|
||||
<div>
|
||||
<div className="flex items-center py-2 system-sm-medium text-text-primary">
|
||||
{t('createTool.authMethod.key', { ns: 'tools' })}
|
||||
|
||||
@ -547,4 +547,4 @@ Test examples in the project:
|
||||
[Testing Library Best Practices]: https://kentcdodds.com/blog/common-mistakes-with-react-testing-library
|
||||
[Vitest Documentation]: https://vitest.dev/guide
|
||||
[Vitest Mocking Guide]: https://vitest.dev/guide/mocking.html
|
||||
[index.spec.tsx]: ../app/components/base/radio/__tests__/index.spec.tsx
|
||||
[index.spec.tsx]: ../app/components/base/action-button/__tests__/index.spec.tsx
|
||||
|
||||
@ -374,13 +374,13 @@ Options:
|
||||
|
||||
Examples:
|
||||
# Analyze a component and generate test prompt
|
||||
pnpm analyze-component app/components/base/radio/index.tsx
|
||||
pnpm analyze-component app/components/base/action-button/index.tsx
|
||||
|
||||
# Output as JSON
|
||||
pnpm analyze-component app/components/base/radio/index.tsx --json
|
||||
pnpm analyze-component app/components/base/action-button/index.tsx --json
|
||||
|
||||
# Review existing test
|
||||
pnpm analyze-component app/components/base/radio/index.tsx --review
|
||||
pnpm analyze-component app/components/base/action-button/index.tsx --review
|
||||
|
||||
For complete testing guidelines, see: web/docs/test.md
|
||||
`)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user