diff --git a/.agents/skills/frontend-code-review/references/dify-ui.md b/.agents/skills/frontend-code-review/references/dify-ui.md index b482b67d524..2e9ef7683cc 100644 --- a/.agents/skills/frontend-code-review/references/dify-ui.md +++ b/.agents/skills/frontend-code-review/references/dify-ui.md @@ -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`, `RadioGroup`, or `Radio.Root`. +- 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` should compose with `Radio`, `RadioRoot`, 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: diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index 53abd3adf37..d38bf53529b 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -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. diff --git a/packages/dify-ui/AGENTS.md b/packages/dify-ui/AGENTS.md index fddb0a1e98b..8800006f0a6 100644 --- a/packages/dify-ui/AGENTS.md +++ b/packages/dify-ui/AGENTS.md @@ -10,6 +10,8 @@ Shared design tokens, the `cn()` utility, CSS-first Tailwind styles, and headles - One component per folder: `src//index.tsx`, optional `index.stories.tsx` and `__tests__/index.spec.tsx`. Add a matching `./` subpath to `package.json#exports`. - Props pattern: `Omit & VariantProps & { /* 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`, `RadioGroup`, or `Radio.Root`. +- 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. diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index 1833eb4ecdf..6e805f46a50 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -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`, `RadioGroup`, `Radio`, and `RadioRoot` 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 + value={promptMode} onValueChange={setPromptMode}> + value={PROMPT_MODE.default} /> + value={PROMPT_MODE.custom} /> + +``` + +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 `
`, 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"`. diff --git a/packages/dify-ui/src/radio-group/index.stories.tsx b/packages/dify-ui/src/radio-group/index.stories.tsx index 6c0d39b3fff..6c87f6583a5 100644 --- a/packages/dify-ui/src/radio-group/index.stories.tsx +++ b/packages/dify-ui/src/radio-group/index.stories.tsx @@ -80,13 +80,13 @@ function BooleanInlineDemo() {
- + value={true} /> True - + value={false} /> False diff --git a/packages/dify-ui/src/radio-group/index.tsx b/packages/dify-ui/src/radio-group/index.tsx index 2e1dea0e0ac..c7bbec1d975 100644 --- a/packages/dify-ui/src/radio-group/index.tsx +++ b/packages/dify-ui/src/radio-group/index.tsx @@ -15,7 +15,7 @@ export function RadioGroup({ ...props }: RadioGroupProps) { return ( - className={cn('flex items-center gap-2', className)} {...props} /> diff --git a/packages/dify-ui/src/radio/__tests__/index.spec.tsx b/packages/dify-ui/src/radio/__tests__/index.spec.tsx index 4da714d2e8c..89797ff800e 100644 --- a/packages/dify-ui/src/radio/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/radio/__tests__/index.spec.tsx @@ -55,6 +55,21 @@ function TestRadioOption({ ) } +function RadioTypeExamples() { + return ( + value={true} onValueChange={() => {}}> + value={true} /> + value={false} /> + {/* @ts-expect-error boolean radio items should not accept string values */} + value="true" /> + {/* @ts-expect-error boolean radio roots should not accept string values */} + value="false" /> + + ) +} + +void RadioTypeExamples + describe('Radio', () => { it('should render unchecked and checked radios with Base UI semantics', async () => { const screen = await render( diff --git a/packages/dify-ui/src/radio/index.tsx b/packages/dify-ui/src/radio/index.tsx index 84944942deb..d4aee599e9a 100644 --- a/packages/dify-ui/src/radio/index.tsx +++ b/packages/dify-ui/src/radio/index.tsx @@ -36,7 +36,7 @@ export function RadioRoot({ ...props }: RadioRootProps) { return ( - className={cn(variant === 'control' && radioRootClassName, className)} {...props} /> @@ -83,7 +83,7 @@ export type RadioProps export function Radio({ ...props }: RadioProps) { - return + return {...props} /> } export type RadioSkeletonProps diff --git a/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx b/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx index 039a2469c8c..b754168ca72 100644 --- a/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/follow-up-setting-modal.tsx @@ -162,7 +162,7 @@ const FollowUpSettingModal = ({ {t('feature.suggestedQuestionsAfterAnswer.modal.promptLabel', { ns: 'appDebug' })} - value={PROMPT_MODE.default} variant="unstyled" nativeButton @@ -195,7 +195,7 @@ const FollowUpSettingModal = ({ - value={PROMPT_MODE.custom} variant="unstyled" nativeButton diff --git a/web/app/components/base/radio-card/__tests__/index.spec.tsx b/web/app/components/base/radio-card/__tests__/index.spec.tsx index 6561d8d8f60..1de0d508a2d 100644 --- a/web/app/components/base/radio-card/__tests__/index.spec.tsx +++ b/web/app/components/base/radio-card/__tests__/index.spec.tsx @@ -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 ( + value="standard" onValueChange={() => {}}> + + value="advanced" + icon={i} + title="Advanced" + description="Typed option" + /> + {/* @ts-expect-error RadioCard values should stay within the selected RadioGroup value type */} + value="invalid" icon={i} title="Invalid" description="Invalid option" /> + + ) +} + +void RadioCardTypeExamples + function renderSelectableCard({ selected = false, onValueChange = vi.fn(), diff --git a/web/app/components/base/radio-card/index.tsx b/web/app/components/base/radio-card/index.tsx index ef0949791ed..31d24637900 100644 --- a/web/app/components/base/radio-card/index.tsx +++ b/web/app/components/base/radio-card/index.tsx @@ -14,9 +14,9 @@ type BaseProps = { chosenConfigWrapClassName?: string } -type SelectableRadioCardProps = BaseProps & { +type SelectableRadioCardProps = BaseProps & { noRadio?: false -} & Omit, 'children' | 'className' | 'variant' | 'render' | 'nativeButton'> +} & Omit, '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 = SelectableRadioCardProps | StaticRadioCardProps -function RadioCard(props: Props) { +function RadioCard(props: Props) { if (props.noRadio) { const { icon, @@ -106,7 +106,7 @@ function RadioCard(props: Props) {
- {...radioRootProps} variant="unstyled" nativeButton diff --git a/web/app/components/datasets/common/retrieval-param-config/index.tsx b/web/app/components/datasets/common/retrieval-param-config/index.tsx index 64f07e59ec6..fcd91531431 100644 --- a/web/app/components/datasets/common/retrieval-param-config/index.tsx +++ b/web/app/components/datasets/common/retrieval-param-config/index.tsx @@ -234,7 +234,7 @@ const RetrievalParamConfig: FC = ({ > { rerankingModeOptions.map(option => ( - key={option.value} value={option.value} icon={( diff --git a/web/app/components/datasets/create/step-two/components/parent-child-options.tsx b/web/app/components/datasets/create/step-two/components/parent-child-options.tsx index 038fbf3e815..767a2e93310 100644 --- a/web/app/components/datasets/create/step-two/components/parent-child-options.tsx +++ b/web/app/components/datasets/create/step-two/components/parent-child-options.tsx @@ -122,7 +122,7 @@ export const ParentChildOptions: FC = ({ onValueChange={value => onChunkForContextChange(value)} className="mt-1 flex-col items-stretch gap-2" > - value="paragraph" icon={} title={t('stepTwo.paragraph', { ns: 'datasetCreation' })} @@ -142,7 +142,7 @@ export const ParentChildOptions: FC = ({
)} /> - value="full-doc" icon={} title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })} diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx index 8ddeb08ce7f..15b8d0a1e33 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx @@ -368,13 +368,13 @@ function Form<
- + value={true} /> True - + value={false} /> False diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx index 61f0192f921..69c42e13c51 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx @@ -270,13 +270,13 @@ function ParameterItem({ {translatedLabel} - + value={true} /> True - + value={false} /> False diff --git a/web/app/components/integrations/permission-quick-panel.tsx b/web/app/components/integrations/permission-quick-panel.tsx index 7dd39327019..bad0c9789e5 100644 --- a/web/app/components/integrations/permission-quick-panel.tsx +++ b/web/app/components/integrations/permission-quick-panel.tsx @@ -71,7 +71,7 @@ export function PermissionQuickPanel({ const optionLabel = t(`privilege.${option}`, { ns: 'plugin' }) return ( - key={option} value={option} variant="unstyled" diff --git a/web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx b/web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx index 0c5c7620635..4ecffa8a728 100644 --- a/web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx @@ -30,13 +30,13 @@ type Props = Readonly<{ onHide: () => void }> -type ItemProps = { +type ItemProps = { text: string - value: AuthType | AuthHeaderPrefix + value: Value isChecked: boolean } -function SelectItem({ text, value, isChecked }: ItemProps) { +function SelectItem({ text, value, isChecked }: ItemProps) { return ( - + value={value} />
{text}
@@ -137,17 +137,17 @@ export default function ConfigCredential({ {t('createTool.authMethod.type', { ns: 'tools' })} - text={t('createTool.authMethod.types.none', { ns: 'tools' })} value={AuthType.none} isChecked={tempCredential.auth_type === AuthType.none} /> - text={t('createTool.authMethod.types.api_key_header', { ns: 'tools' })} value={AuthType.apiKeyHeader} isChecked={tempCredential.auth_type === AuthType.apiKeyHeader} /> - 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({ {t('createTool.authHeaderPrefix.title', { ns: 'tools' })} - text={t('createTool.authHeaderPrefix.types.basic', { ns: 'tools' })} value={AuthHeaderPrefix.basic} isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.basic} /> - text={t('createTool.authHeaderPrefix.types.bearer', { ns: 'tools' })} value={AuthHeaderPrefix.bearer} isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.bearer} /> - text={t('createTool.authHeaderPrefix.types.custom', { ns: 'tools' })} value={AuthHeaderPrefix.custom} isChecked={tempCredential.api_key_header_prefix === AuthHeaderPrefix.custom} diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx index 7f80b26eae2..abe0b6333e7 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx @@ -150,7 +150,7 @@ function SearchMethodRadioCard({ readonly && 'cursor-not-allowed', )} > - value={option.id} variant="unstyled" nativeButton @@ -206,7 +206,7 @@ function HybridSearchModeRadioCard({ }) { return ( - value={option.id} variant="unstyled" nativeButton diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx index 497040367c2..c957a6f9867 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx @@ -500,7 +500,7 @@ export function AgentKnowledgeRetrievalDialog({ }} > {queryModeOptions.map(mode => ( - key={mode} value={mode} variant="unstyled"