mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 02:28:30 +08:00
fix(dify-ui): preserve radio value generics (#38429)
This commit is contained in:
parent
e742e0ec17
commit
6dff7c0a85
@ -40,9 +40,13 @@ Flag:
|
||||
- JavaScript conditional class logic for visual states that the Dify UI/Base UI primitive already exposes through `data-*` attributes or CSS variables.
|
||||
- Controlled props added when uncontrolled DOM state or CSS variables would be enough.
|
||||
- Thin wrappers that rename Base UI parts without adding semantics.
|
||||
- Generic Base UI selection primitives wrapped without preserving their value generics, such as `Select.Root<Value, Multiple>`, `RadioGroup<Value>`, or `Radio.Root<Value>`.
|
||||
- Shared select/radio option components that type selected values as `string` while callers pass enums, unions, booleans, numbers, objects, or nullable placeholder values.
|
||||
|
||||
Prefer Base UI/Dify UI data attributes and CSS variables for visual state: `data-open`, `data-checked`, `data-disabled`, `data-highlighted`, `data-popup-open`, `group-data-*`, `peer-data-*`, `has-[:focus-visible]`, and primitive CSS variables such as anchor width or transform origin. Use JS conditional classes for product/business state that the primitive does not expose.
|
||||
|
||||
For non-string `Select` and `RadioGroup` values, prefer explicit domain generics at the root and at child value carriers. JSX children do not inherit the parent generic, so `RadioGroup<PromptMode>` should compose with `Radio<PromptMode>`, `RadioRoot<PromptMode>`, or option values from a typed collection. For `Select`, prefer the Base UI `items` collection pattern for typed value-to-label rendering, and flag string coercion helpers used only to recover display labels.
|
||||
|
||||
## Forms
|
||||
|
||||
Flag:
|
||||
|
||||
@ -69,6 +69,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
|
||||
- Avoid barrel files that only re-export secondary owners. `index.tsx` is acceptable for a route/tab entry component; import header controls, switches, sections, and row owners from their concrete owner files.
|
||||
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
|
||||
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them.
|
||||
- Preserve domain value types for selection components. Do not widen enum, union, boolean, numeric, object, or nullable select/radio values to `string`; keep wrappers and option value carriers typed from their feature option collection.
|
||||
- Avoid `common.tsx` buckets for shared UI. Use a feature-local `components/` folder with concrete filenames that describe the shared role.
|
||||
- Do not create type aliases that only rename another type. Use aliases only for real UI concepts, refinements, or reusable local contracts.
|
||||
- Name values by their domain role and backend API contract, especially persistent IDs and route params. Normalize framework or route params at the boundary.
|
||||
|
||||
@ -10,6 +10,8 @@ Shared design tokens, the `cn()` utility, CSS-first Tailwind styles, and headles
|
||||
- One component per folder: `src/<name>/index.tsx`, optional `index.stories.tsx` and `__tests__/index.spec.tsx`. Add a matching `./<name>` subpath to `package.json#exports`.
|
||||
- Props pattern: `Omit<BaseXxx.Root.Props, 'className' | ...> & VariantProps<typeof xxxVariants> & { /* custom */ }`.
|
||||
- Use plain `Omit<...>` only for non-union Base UI props. When a prop changes the valid shape of related props (for example `value` / `defaultValue`, `multiple` / `value`, or `clearable` / `onChange`), model that relationship with an explicit discriminated union or a distributive helper instead of flattening the props.
|
||||
- Preserve Base UI generic value contracts in wrappers. If the upstream primitive is generic, expose the same generic parameters and pass them through to the Base UI part, such as `Select.Root<Value, Multiple>`, `RadioGroup<Value>`, or `Radio.Root<Value>`.
|
||||
- Do not hard-code selection wrappers to `string` unless the upstream primitive is string-only. Select and radio wrappers that carry selected values should preserve the primitive's generic value contract.
|
||||
- When a component accepts a prop typed from a shared internal module, `export type` it from that component so consumers import it from the component subpath.
|
||||
- Prefer Base UI data attributes and CSS variables for visual states; do not mirror state in React solely to add classes.
|
||||
- When a Base UI API or selector contract is unclear, read the docs linked from `README.md` and the local `@base-ui/react` `.d.ts` files before coding.
|
||||
|
||||
@ -101,6 +101,23 @@ Use `FieldsetRoot` and `FieldsetLegend` when one field is represented by a group
|
||||
|
||||
`FieldsetRoot` provides the group semantics and legend relationship. It does not own the interactive state of the grouped control. Pass `disabled`, `value`, `defaultValue`, and change handlers to the actual group primitive (`CheckboxGroup`, radio group, slider root, etc.) instead of relying on the fieldset wrapper to manage them.
|
||||
|
||||
## Typed value contracts
|
||||
|
||||
Selection primitives should preserve the caller's domain value type instead of widening values to `string`. Use `Select<Value, Multiple>`, `RadioGroup<Value>`, `Radio<Value>`, and `RadioRoot<Value>` when the selected value is an enum, union, boolean, number, object, or nullable placeholder value.
|
||||
|
||||
Root-level generics type `value`, `defaultValue`, `onValueChange`, and collection props such as `items`, but JSX children do not automatically inherit the parent generic. For non-string radio groups, type the child item too:
|
||||
|
||||
```tsx
|
||||
<RadioGroup<PromptMode> value={promptMode} onValueChange={setPromptMode}>
|
||||
<RadioRoot<PromptMode> value={PROMPT_MODE.default} />
|
||||
<RadioRoot<PromptMode> value={PROMPT_MODE.custom} />
|
||||
</RadioGroup>
|
||||
```
|
||||
|
||||
For select labels and display values, prefer the Base UI `items` collection pattern so the root, value display, and item list share the same typed values. Avoid helpers that stringify values only to recover labels later; convert values to strings only at real boundaries such as form submission, URL/search params, or legacy APIs that require strings.
|
||||
|
||||
`CheckboxGroup` follows the Base UI contract and uses `string[]`. Do not add a generic checkbox-group wrapper unless the underlying primitive contract changes; if different business IDs need stronger separation, model that at the feature/domain type boundary.
|
||||
|
||||
For complex business forms, keep state ownership outside these primitives. TanStack Form, zod, server validation, dialog reset behavior, and schema-driven rendering belong to the feature layer in `web/`; they should pass `name`, `invalid`, `dirty`, `touched`, `value`, `onValueChange`, and errors into these primitives rather than replacing the field semantics. In this repo, `web/app/components/base/form` is the TanStack/schema runtime adapter; `packages/dify-ui` remains the primitive layer.
|
||||
|
||||
Migration rule for `web/`: if a UI has a save/submit action, do not leave it as unrelated `Input` and `Button` pieces. Give it a real submit boundary with `Form` or a native `<form>`, attach visible field names through the appropriate label primitive (`FieldLabel`, `SelectLabel`, `SliderLabel`, or `FieldsetLegend`), expose helper/error text through `FieldDescription` / `FieldError`, and keep non-submit buttons as `type="button"`.
|
||||
|
||||
@ -80,13 +80,13 @@ function BooleanInlineDemo() {
|
||||
<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} />
|
||||
<Radio<boolean> value={true} />
|
||||
True
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
|
||||
<Radio value={false} />
|
||||
<Radio<boolean> value={false} />
|
||||
False
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
|
||||
@ -15,7 +15,7 @@ export function RadioGroup<Value = string>({
|
||||
...props
|
||||
}: RadioGroupProps<Value>) {
|
||||
return (
|
||||
<BaseRadioGroup
|
||||
<BaseRadioGroup<Value>
|
||||
className={cn('flex items-center gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@ -55,6 +55,21 @@ function TestRadioOption({
|
||||
)
|
||||
}
|
||||
|
||||
function RadioTypeExamples() {
|
||||
return (
|
||||
<RadioGroup<boolean> value={true} onValueChange={() => {}}>
|
||||
<Radio<boolean> value={true} />
|
||||
<RadioRoot<boolean> value={false} />
|
||||
{/* @ts-expect-error boolean radio items should not accept string values */}
|
||||
<Radio<boolean> value="true" />
|
||||
{/* @ts-expect-error boolean radio roots should not accept string values */}
|
||||
<RadioRoot<boolean> value="false" />
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
void RadioTypeExamples
|
||||
|
||||
describe('Radio', () => {
|
||||
it('should render unchecked and checked radios with Base UI semantics', async () => {
|
||||
const screen = await render(
|
||||
|
||||
@ -36,7 +36,7 @@ export function RadioRoot<Value = string>({
|
||||
...props
|
||||
}: RadioRootProps<Value>) {
|
||||
return (
|
||||
<BaseRadio.Root
|
||||
<BaseRadio.Root<Value>
|
||||
className={cn(variant === 'control' && radioRootClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
@ -83,7 +83,7 @@ export type RadioProps<Value = string>
|
||||
export function Radio<Value = string>({
|
||||
...props
|
||||
}: RadioProps<Value>) {
|
||||
return <RadioRoot {...props} />
|
||||
return <RadioRoot<Value> {...props} />
|
||||
}
|
||||
|
||||
export type RadioSkeletonProps
|
||||
|
||||
@ -162,7 +162,7 @@ const FollowUpSettingModal = ({
|
||||
{t('feature.suggestedQuestionsAfterAnswer.modal.promptLabel', { ns: 'appDebug' })}
|
||||
</FieldsetLegend>
|
||||
<FieldItem>
|
||||
<RadioRoot
|
||||
<RadioRoot<PromptMode>
|
||||
value={PROMPT_MODE.default}
|
||||
variant="unstyled"
|
||||
nativeButton
|
||||
@ -195,7 +195,7 @@ const FollowUpSettingModal = ({
|
||||
</RadioRoot>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<RadioRoot
|
||||
<RadioRoot<PromptMode>
|
||||
value={PROMPT_MODE.custom}
|
||||
variant="unstyled"
|
||||
nativeButton
|
||||
|
||||
@ -4,6 +4,25 @@ import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import RadioCard from '../index'
|
||||
|
||||
type ExampleMode = 'standard' | 'advanced'
|
||||
|
||||
function RadioCardTypeExamples() {
|
||||
return (
|
||||
<RadioGroup<ExampleMode> value="standard" onValueChange={() => {}}>
|
||||
<RadioCard<ExampleMode>
|
||||
value="advanced"
|
||||
icon={<span>i</span>}
|
||||
title="Advanced"
|
||||
description="Typed option"
|
||||
/>
|
||||
{/* @ts-expect-error RadioCard values should stay within the selected RadioGroup value type */}
|
||||
<RadioCard<ExampleMode> value="invalid" icon={<span>i</span>} title="Invalid" description="Invalid option" />
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
void RadioCardTypeExamples
|
||||
|
||||
function renderSelectableCard({
|
||||
selected = false,
|
||||
onValueChange = vi.fn(),
|
||||
|
||||
@ -14,9 +14,9 @@ type BaseProps = {
|
||||
chosenConfigWrapClassName?: string
|
||||
}
|
||||
|
||||
type SelectableRadioCardProps = BaseProps & {
|
||||
type SelectableRadioCardProps<Value = string> = BaseProps & {
|
||||
noRadio?: false
|
||||
} & Omit<RadioRootProps<string>, 'children' | 'className' | 'variant' | 'render' | 'nativeButton'>
|
||||
} & Omit<RadioRootProps<Value>, 'children' | 'className' | 'variant' | 'render' | 'nativeButton'>
|
||||
|
||||
type StaticRadioCardProps = BaseProps & {
|
||||
noRadio: true
|
||||
@ -24,9 +24,9 @@ type StaticRadioCardProps = BaseProps & {
|
||||
checked?: never
|
||||
}
|
||||
|
||||
type Props = SelectableRadioCardProps | StaticRadioCardProps
|
||||
type Props<Value = string> = SelectableRadioCardProps<Value> | StaticRadioCardProps
|
||||
|
||||
function RadioCard(props: Props) {
|
||||
function RadioCard<Value = string>(props: Props<Value>) {
|
||||
if (props.noRadio) {
|
||||
const {
|
||||
icon,
|
||||
@ -106,7 +106,7 @@ function RadioCard(props: Props) {
|
||||
<div
|
||||
className={rootClassName}
|
||||
>
|
||||
<RadioRoot
|
||||
<RadioRoot<Value>
|
||||
{...radioRootProps}
|
||||
variant="unstyled"
|
||||
nativeButton
|
||||
|
||||
@ -234,7 +234,7 @@ const RetrievalParamConfig: FC<Props> = ({
|
||||
>
|
||||
{
|
||||
rerankingModeOptions.map(option => (
|
||||
<RadioCard
|
||||
<RadioCard<RerankingModeEnum>
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
icon={(
|
||||
|
||||
@ -122,7 +122,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
|
||||
onValueChange={value => onChunkForContextChange(value)}
|
||||
className="mt-1 flex-col items-stretch gap-2"
|
||||
>
|
||||
<RadioCard
|
||||
<RadioCard<ParentMode>
|
||||
value="paragraph"
|
||||
icon={<img src={Note.src} alt="" />}
|
||||
title={t('stepTwo.paragraph', { ns: 'datasetCreation' })}
|
||||
@ -142,7 +142,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<RadioCard
|
||||
<RadioCard<ParentMode>
|
||||
value="full-doc"
|
||||
icon={<img src={FileList.src} alt="" />}
|
||||
title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })}
|
||||
|
||||
@ -368,13 +368,13 @@ function Form<
|
||||
<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} />
|
||||
<Radio<boolean> value={true} />
|
||||
True
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
|
||||
<Radio value={false} />
|
||||
<Radio<boolean> value={false} />
|
||||
False
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
|
||||
@ -270,13 +270,13 @@ function ParameterItem({
|
||||
<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} />
|
||||
<Radio<boolean> 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} />
|
||||
<Radio<boolean> value={false} />
|
||||
False
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
|
||||
@ -71,7 +71,7 @@ export function PermissionQuickPanel({
|
||||
const optionLabel = t(`privilege.${option}`, { ns: 'plugin' })
|
||||
|
||||
return (
|
||||
<RadioRoot
|
||||
<RadioRoot<PermissionType>
|
||||
key={option}
|
||||
value={option}
|
||||
variant="unstyled"
|
||||
|
||||
@ -30,13 +30,13 @@ type Props = Readonly<{
|
||||
onHide: () => void
|
||||
}>
|
||||
|
||||
type ItemProps = {
|
||||
type ItemProps<Value = string> = {
|
||||
text: string
|
||||
value: AuthType | AuthHeaderPrefix
|
||||
value: Value
|
||||
isChecked: boolean
|
||||
}
|
||||
|
||||
function SelectItem({ text, value, isChecked }: ItemProps) {
|
||||
function SelectItem<Value = string>({ text, value, isChecked }: ItemProps<Value>) {
|
||||
return (
|
||||
<FieldItem>
|
||||
<FieldLabel
|
||||
@ -45,7 +45,7 @@ function SelectItem({ text, value, isChecked }: ItemProps) {
|
||||
'flex h-9 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl bg-components-panel-on-panel-item-bg px-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} />
|
||||
<Radio<Value> value={value} />
|
||||
<div className="min-w-0 truncate system-sm-regular text-text-primary">{text}</div>
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
@ -137,17 +137,17 @@ export default function ConfigCredential({
|
||||
<FieldsetLegend className="col-span-full py-2 system-sm-medium text-text-primary">
|
||||
{t('createTool.authMethod.type', { ns: 'tools' })}
|
||||
</FieldsetLegend>
|
||||
<SelectItem
|
||||
<SelectItem<AuthType>
|
||||
text={t('createTool.authMethod.types.none', { ns: 'tools' })}
|
||||
value={AuthType.none}
|
||||
isChecked={tempCredential.auth_type === AuthType.none}
|
||||
/>
|
||||
<SelectItem
|
||||
<SelectItem<AuthType>
|
||||
text={t('createTool.authMethod.types.api_key_header', { ns: 'tools' })}
|
||||
value={AuthType.apiKeyHeader}
|
||||
isChecked={tempCredential.auth_type === AuthType.apiKeyHeader}
|
||||
/>
|
||||
<SelectItem
|
||||
<SelectItem<AuthType>
|
||||
text={t('createTool.authMethod.types.api_key_query', { ns: 'tools' })}
|
||||
value={AuthType.apiKeyQuery}
|
||||
isChecked={tempCredential.auth_type === AuthType.apiKeyQuery}
|
||||
@ -169,17 +169,17 @@ export default function ConfigCredential({
|
||||
<FieldsetLegend className="col-span-full py-2 system-sm-medium text-text-primary">
|
||||
{t('createTool.authHeaderPrefix.title', { ns: 'tools' })}
|
||||
</FieldsetLegend>
|
||||
<SelectItem
|
||||
<SelectItem<AuthHeaderPrefix>
|
||||
text={t('createTool.authHeaderPrefix.types.basic', { ns: 'tools' })}
|
||||
value={AuthHeaderPrefix.basic}
|
||||
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.basic}
|
||||
/>
|
||||
<SelectItem
|
||||
<SelectItem<AuthHeaderPrefix>
|
||||
text={t('createTool.authHeaderPrefix.types.bearer', { ns: 'tools' })}
|
||||
value={AuthHeaderPrefix.bearer}
|
||||
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.bearer}
|
||||
/>
|
||||
<SelectItem
|
||||
<SelectItem<AuthHeaderPrefix>
|
||||
text={t('createTool.authHeaderPrefix.types.custom', { ns: 'tools' })}
|
||||
value={AuthHeaderPrefix.custom}
|
||||
isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.custom}
|
||||
|
||||
@ -150,7 +150,7 @@ function SearchMethodRadioCard({
|
||||
readonly && 'cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<RadioRoot
|
||||
<RadioRoot<RetrievalSearchMethodEnum>
|
||||
value={option.id}
|
||||
variant="unstyled"
|
||||
nativeButton
|
||||
@ -206,7 +206,7 @@ function HybridSearchModeRadioCard({
|
||||
}) {
|
||||
return (
|
||||
<FieldItem>
|
||||
<RadioRoot
|
||||
<RadioRoot<HybridSearchModeEnum>
|
||||
value={option.id}
|
||||
variant="unstyled"
|
||||
nativeButton
|
||||
|
||||
@ -500,7 +500,7 @@ export function AgentKnowledgeRetrievalDialog({
|
||||
}}
|
||||
>
|
||||
{queryModeOptions.map(mode => (
|
||||
<RadioRoot
|
||||
<RadioRoot<KnowledgeRetrievalQueryMode>
|
||||
key={mode}
|
||||
value={mode}
|
||||
variant="unstyled"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user