mirror of
https://github.com/langgenius/dify.git
synced 2026-05-24 02:47:53 +08:00
Compare commits
1 Commits
codex/refi
...
fix/model-
| Author | SHA1 | Date | |
|---|---|---|---|
| 521545d52e |
@ -795,7 +795,7 @@ class ProviderManager:
|
||||
return [
|
||||
{
|
||||
"model": model_key[0],
|
||||
"model_type": ModelType.value_of(model_key[1]),
|
||||
"model_type": ModelType(model_key[1]),
|
||||
"available_model_credentials": [
|
||||
CredentialConfiguration(credential_id=cred.id, credential_name=cred.credential_name)
|
||||
for cred in creds
|
||||
|
||||
@ -66,7 +66,7 @@ class ModelLoadBalancingService:
|
||||
raise ValueError(f"Provider {provider} does not exist.")
|
||||
|
||||
# Enable model load balancing
|
||||
provider_configuration.enable_model_load_balancing(model=model, model_type=ModelType.value_of(model_type))
|
||||
provider_configuration.enable_model_load_balancing(model=model, model_type=ModelType(model_type))
|
||||
|
||||
def disable_model_load_balancing(self, tenant_id: str, provider: str, model: str, model_type: str):
|
||||
"""
|
||||
@ -87,7 +87,7 @@ class ModelLoadBalancingService:
|
||||
raise ValueError(f"Provider {provider} does not exist.")
|
||||
|
||||
# disable model load balancing
|
||||
provider_configuration.disable_model_load_balancing(model=model, model_type=ModelType.value_of(model_type))
|
||||
provider_configuration.disable_model_load_balancing(model=model, model_type=ModelType(model_type))
|
||||
|
||||
def get_load_balancing_configs(
|
||||
self, tenant_id: str, provider: str, model: str, model_type: str, config_from: str = ""
|
||||
@ -109,7 +109,7 @@ class ModelLoadBalancingService:
|
||||
raise ValueError(f"Provider {provider} does not exist.")
|
||||
|
||||
# Convert model type to ModelType
|
||||
model_type_enum = ModelType.value_of(model_type)
|
||||
model_type_enum = ModelType(model_type)
|
||||
|
||||
# Get provider model setting
|
||||
provider_model_setting = provider_configuration.get_provider_model_setting(
|
||||
@ -250,7 +250,7 @@ class ModelLoadBalancingService:
|
||||
raise ValueError(f"Provider {provider} does not exist.")
|
||||
|
||||
# Convert model type to ModelType
|
||||
model_type_enum = ModelType.value_of(model_type)
|
||||
model_type_enum = ModelType(model_type)
|
||||
|
||||
# Get load balancing configurations
|
||||
load_balancing_model_config = db.session.scalar(
|
||||
@ -338,7 +338,7 @@ class ModelLoadBalancingService:
|
||||
raise ValueError(f"Provider {provider} does not exist.")
|
||||
|
||||
# Convert model type to ModelType
|
||||
model_type_enum = ModelType.value_of(model_type)
|
||||
model_type_enum = ModelType(model_type)
|
||||
|
||||
if not isinstance(configs, list):
|
||||
raise ValueError("Invalid load balancing configs")
|
||||
@ -524,7 +524,7 @@ class ModelLoadBalancingService:
|
||||
raise ValueError(f"Provider {provider} does not exist.")
|
||||
|
||||
# Convert model type to ModelType
|
||||
model_type_enum = ModelType.value_of(model_type)
|
||||
model_type_enum = ModelType(model_type)
|
||||
|
||||
load_balancing_model_config = None
|
||||
if config_id:
|
||||
|
||||
@ -67,7 +67,7 @@ class ModelProviderService:
|
||||
provider_responses = []
|
||||
for provider_configuration in provider_configurations.values():
|
||||
if model_type:
|
||||
model_type_entity = ModelType.value_of(model_type)
|
||||
model_type_entity = ModelType(model_type)
|
||||
if model_type_entity not in provider_configuration.provider.supported_model_types:
|
||||
continue
|
||||
|
||||
@ -269,7 +269,7 @@ class ModelProviderService:
|
||||
"""
|
||||
provider_configuration = self._get_provider_configuration(tenant_id, provider)
|
||||
return provider_configuration.get_custom_model_credential(
|
||||
model_type=ModelType.value_of(model_type), model=model, credential_id=credential_id
|
||||
model_type=ModelType(model_type), model=model, credential_id=credential_id
|
||||
)
|
||||
|
||||
def validate_model_credentials(
|
||||
@ -287,7 +287,7 @@ class ModelProviderService:
|
||||
"""
|
||||
provider_configuration = self._get_provider_configuration(tenant_id, provider)
|
||||
provider_configuration.validate_custom_model_credentials(
|
||||
model_type=ModelType.value_of(model_type), model=model, credentials=credentials
|
||||
model_type=ModelType(model_type), model=model, credentials=credentials
|
||||
)
|
||||
|
||||
def create_model_credential(
|
||||
@ -312,7 +312,7 @@ class ModelProviderService:
|
||||
"""
|
||||
provider_configuration = self._get_provider_configuration(tenant_id, provider)
|
||||
provider_configuration.create_custom_model_credential(
|
||||
model_type=ModelType.value_of(model_type),
|
||||
model_type=ModelType(model_type),
|
||||
model=model,
|
||||
credentials=credentials,
|
||||
credential_name=credential_name,
|
||||
@ -342,7 +342,7 @@ class ModelProviderService:
|
||||
"""
|
||||
provider_configuration = self._get_provider_configuration(tenant_id, provider)
|
||||
provider_configuration.update_custom_model_credential(
|
||||
model_type=ModelType.value_of(model_type),
|
||||
model_type=ModelType(model_type),
|
||||
model=model,
|
||||
credentials=credentials,
|
||||
credential_id=credential_id,
|
||||
@ -362,7 +362,7 @@ class ModelProviderService:
|
||||
"""
|
||||
provider_configuration = self._get_provider_configuration(tenant_id, provider)
|
||||
provider_configuration.delete_custom_model_credential(
|
||||
model_type=ModelType.value_of(model_type), model=model, credential_id=credential_id
|
||||
model_type=ModelType(model_type), model=model, credential_id=credential_id
|
||||
)
|
||||
|
||||
def switch_active_custom_model_credential(
|
||||
@ -380,7 +380,7 @@ class ModelProviderService:
|
||||
"""
|
||||
provider_configuration = self._get_provider_configuration(tenant_id, provider)
|
||||
provider_configuration.switch_custom_model_credential(
|
||||
model_type=ModelType.value_of(model_type), model=model, credential_id=credential_id
|
||||
model_type=ModelType(model_type), model=model, credential_id=credential_id
|
||||
)
|
||||
|
||||
def add_model_credential_to_model_list(
|
||||
@ -398,7 +398,7 @@ class ModelProviderService:
|
||||
"""
|
||||
provider_configuration = self._get_provider_configuration(tenant_id, provider)
|
||||
provider_configuration.add_model_credential_to_model(
|
||||
model_type=ModelType.value_of(model_type), model=model, credential_id=credential_id
|
||||
model_type=ModelType(model_type), model=model, credential_id=credential_id
|
||||
)
|
||||
|
||||
def remove_model(self, tenant_id: str, provider: str, model_type: str, model: str):
|
||||
@ -412,7 +412,7 @@ class ModelProviderService:
|
||||
:return:
|
||||
"""
|
||||
provider_configuration = self._get_provider_configuration(tenant_id, provider)
|
||||
provider_configuration.delete_custom_model(model_type=ModelType.value_of(model_type), model=model)
|
||||
provider_configuration.delete_custom_model(model_type=ModelType(model_type), model=model)
|
||||
|
||||
def get_models_by_model_type(self, tenant_id: str, model_type: str) -> list[ProviderWithModelsResponse]:
|
||||
"""
|
||||
@ -426,7 +426,7 @@ class ModelProviderService:
|
||||
provider_configurations = self._get_provider_manager(tenant_id).get_configurations(tenant_id)
|
||||
|
||||
# Get provider available models
|
||||
models = provider_configurations.get_models(model_type=ModelType.value_of(model_type), only_active=True)
|
||||
models = provider_configurations.get_models(model_type=ModelType(model_type), only_active=True)
|
||||
|
||||
# Group models by provider
|
||||
provider_models: dict[str, list[ModelWithProviderEntity]] = {}
|
||||
@ -505,7 +505,7 @@ class ModelProviderService:
|
||||
:param model_type: model type
|
||||
:return:
|
||||
"""
|
||||
model_type_enum = ModelType.value_of(model_type)
|
||||
model_type_enum = ModelType(model_type)
|
||||
|
||||
try:
|
||||
result = self._get_provider_manager(tenant_id).get_default_model(
|
||||
@ -540,7 +540,7 @@ class ModelProviderService:
|
||||
:param model: model name
|
||||
:return:
|
||||
"""
|
||||
model_type_enum = ModelType.value_of(model_type)
|
||||
model_type_enum = ModelType(model_type)
|
||||
self._get_provider_manager(tenant_id).update_default_model_record(
|
||||
tenant_id=tenant_id, model_type=model_type_enum, provider=provider, model=model
|
||||
)
|
||||
@ -590,7 +590,7 @@ class ModelProviderService:
|
||||
:return:
|
||||
"""
|
||||
provider_configuration = self._get_provider_configuration(tenant_id, provider)
|
||||
provider_configuration.enable_model(model=model, model_type=ModelType.value_of(model_type))
|
||||
provider_configuration.enable_model(model=model, model_type=ModelType(model_type))
|
||||
|
||||
def disable_model(self, tenant_id: str, provider: str, model: str, model_type: str):
|
||||
"""
|
||||
@ -603,4 +603,4 @@ class ModelProviderService:
|
||||
:return:
|
||||
"""
|
||||
provider_configuration = self._get_provider_configuration(tenant_id, provider)
|
||||
provider_configuration.disable_model(model=model, model_type=ModelType.value_of(model_type))
|
||||
provider_configuration.disable_model(model=model, model_type=ModelType(model_type))
|
||||
|
||||
@ -207,16 +207,6 @@ describe('Select wrappers', () => {
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toContain('data-popup-open:bg-state-base-hover-alt')
|
||||
})
|
||||
|
||||
it('should include keyboard focus ring classes', async () => {
|
||||
const screen = await renderOpenSelect()
|
||||
|
||||
await expect.element(screen.getByRole('combobox', { name: 'city select' })).toHaveClass(
|
||||
'focus-visible:ring-1',
|
||||
'focus-visible:ring-components-input-border-active',
|
||||
'focus-visible:ring-inset',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SelectContent', () => {
|
||||
|
||||
@ -24,7 +24,6 @@ const selectTriggerVariants = cva(
|
||||
[
|
||||
'group flex w-full items-center border-0 bg-components-input-bg-normal text-left text-components-input-text-filled outline-hidden',
|
||||
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt',
|
||||
'focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
|
||||
'data-placeholder:text-components-input-text-placeholder',
|
||||
'data-readonly:cursor-default data-readonly:bg-transparent data-readonly:hover:bg-transparent',
|
||||
'data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled data-disabled:hover:bg-components-input-bg-disabled',
|
||||
|
||||
@ -49,19 +49,6 @@ describe('Switch', () => {
|
||||
await expect.element(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('should work in uncontrolled mode with defaultChecked prop', async () => {
|
||||
const onCheckedChange = vi.fn()
|
||||
const screen = await render(<Switch defaultChecked={false} onCheckedChange={onCheckedChange} />)
|
||||
const switchElement = screen.getByRole('switch')
|
||||
|
||||
await expect.element(switchElement).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
asHTMLElement(switchElement.element()).click()
|
||||
|
||||
expect(onCheckedChange).toHaveBeenCalledWith(true)
|
||||
await expect.element(switchElement).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('should not call onCheckedChange when disabled', async () => {
|
||||
const onCheckedChange = vi.fn()
|
||||
const screen = await render(<Switch checked={false} disabled onCheckedChange={onCheckedChange} />)
|
||||
@ -155,24 +142,6 @@ describe('Switch', () => {
|
||||
expect(screen.container.querySelector('span[aria-hidden="true"] i')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use checked data attributes to position spinner', async () => {
|
||||
const screen = await render(<Switch checked={false} loading size="md" />)
|
||||
const spinner = screen.container.querySelector('span[aria-hidden="true"]')
|
||||
|
||||
expect(spinner).toHaveClass(
|
||||
'left-[calc(50%+6px)]',
|
||||
'group-data-checked:left-[calc(50%-6px)]',
|
||||
)
|
||||
|
||||
await screen.rerender(<Switch checked={true} loading size="md" />)
|
||||
|
||||
await expect.element(screen.getByRole('switch')).toHaveAttribute('data-checked', '')
|
||||
expect(screen.container.querySelector('span[aria-hidden="true"]')).toHaveClass(
|
||||
'left-[calc(50%+6px)]',
|
||||
'group-data-checked:left-[calc(50%-6px)]',
|
||||
)
|
||||
})
|
||||
|
||||
it('should not show spinner for xs and sm sizes', async () => {
|
||||
const screen = await render(<Switch checked={false} loading size="xs" />)
|
||||
expect(screen.container.querySelector('span[aria-hidden="true"] i')).not.toBeInTheDocument()
|
||||
|
||||
@ -2,11 +2,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { useState, useTransition } from 'react'
|
||||
import { Switch, SwitchSkeleton } from '.'
|
||||
import {
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
FieldRoot,
|
||||
} from '../field'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Form/Switch',
|
||||
@ -15,7 +10,7 @@ const meta = {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Toggle switch primitive with controlled and uncontrolled state support, loading state, and skeleton placeholder.',
|
||||
component: 'Toggle switch built on Base UI with CVA variants, Figma-aligned design tokens, loading spinner, and skeleton placeholder. Import `Switch` and `SwitchSkeleton` from `@langgenius/dify-ui/switch`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -47,27 +42,20 @@ const meta = {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
type SwitchDemoProps = Partial<Omit<ComponentProps<typeof Switch>, 'checked' | 'defaultChecked' | 'onCheckedChange'>> & {
|
||||
checked?: boolean
|
||||
}
|
||||
|
||||
const SwitchDemo = (args: SwitchDemoProps) => {
|
||||
const SwitchDemo = (args: Partial<ComponentProps<typeof Switch>>) => {
|
||||
const [enabled, setEnabled] = useState(args.checked ?? false)
|
||||
|
||||
return (
|
||||
<FieldRoot name="autoRetry" className="w-72">
|
||||
<FieldLabel className="flex items-center justify-between gap-3">
|
||||
<span>Enable auto retry</span>
|
||||
<Switch
|
||||
{...args}
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
</FieldLabel>
|
||||
<FieldDescription>
|
||||
{enabled ? 'Failures will retry automatically.' : 'Failures require manual retry.'}
|
||||
</FieldDescription>
|
||||
</FieldRoot>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Switch
|
||||
{...args}
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
{enabled ? 'On' : 'Off'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -128,24 +116,24 @@ const AllStatesDemo = () => {
|
||||
<td className="py-3 font-medium text-gray-900">{size}</td>
|
||||
<td className="py-3">
|
||||
<div className="flex gap-2">
|
||||
<Switch size={size} checked={false} onCheckedChange={() => {}} aria-label={`${size} unchecked switch`} />
|
||||
<Switch size={size} checked={true} onCheckedChange={() => {}} aria-label={`${size} checked switch`} />
|
||||
<Switch size={size} checked={false} onCheckedChange={() => {}} />
|
||||
<Switch size={size} checked={true} onCheckedChange={() => {}} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<div className="flex gap-2">
|
||||
<Switch size={size} checked={false} disabled aria-label={`${size} disabled unchecked switch`} />
|
||||
<Switch size={size} checked={true} disabled aria-label={`${size} disabled checked switch`} />
|
||||
<Switch size={size} checked={false} disabled />
|
||||
<Switch size={size} checked={true} disabled />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<div className="flex gap-2">
|
||||
<Switch size={size} checked={false} loading aria-label={`${size} loading unchecked switch`} />
|
||||
<Switch size={size} checked={true} loading aria-label={`${size} loading checked switch`} />
|
||||
<Switch size={size} checked={false} loading />
|
||||
<Switch size={size} checked={true} loading />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<SwitchSkeleton size={size} aria-hidden="true" />
|
||||
<SwitchSkeleton size={size} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@ -160,7 +148,7 @@ export const AllStates: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Variant matrix for switch sizes and states.',
|
||||
story: 'Complete variant matrix: all sizes × all states, matching Figma design spec (node 2144:1210).',
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -176,30 +164,22 @@ const SizeComparisonDemo = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<FieldRoot name="extraSmallSwitch">
|
||||
<FieldLabel className="flex items-center gap-3">
|
||||
<Switch size="xs" checked={states.xs} onCheckedChange={v => setStates({ ...states, xs: v })} />
|
||||
Extra Small (xs) - 14x10
|
||||
</FieldLabel>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="smallSwitch">
|
||||
<FieldLabel className="flex items-center gap-3">
|
||||
<Switch size="sm" checked={states.sm} onCheckedChange={v => setStates({ ...states, sm: v })} />
|
||||
Small (sm) - 20x12
|
||||
</FieldLabel>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="regularSwitch">
|
||||
<FieldLabel className="flex items-center gap-3">
|
||||
<Switch size="md" checked={states.md} onCheckedChange={v => setStates({ ...states, md: v })} />
|
||||
Regular (md) - 28x16
|
||||
</FieldLabel>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="largeSwitch">
|
||||
<FieldLabel className="flex items-center gap-3">
|
||||
<Switch size="lg" checked={states.lg} onCheckedChange={v => setStates({ ...states, lg: v })} />
|
||||
Large (lg) - 36x20
|
||||
</FieldLabel>
|
||||
</FieldRoot>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="xs" checked={states.xs} onCheckedChange={v => setStates({ ...states, xs: v })} />
|
||||
<span className="text-sm text-gray-700">Extra Small (xs) — 14×10</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="sm" checked={states.sm} onCheckedChange={v => setStates({ ...states, sm: v })} />
|
||||
<span className="text-sm text-gray-700">Small (sm) — 20×12</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="md" checked={states.md} onCheckedChange={v => setStates({ ...states, md: v })} />
|
||||
<span className="text-sm text-gray-700">Regular (md) — 28×16</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="lg" checked={states.lg} onCheckedChange={v => setStates({ ...states, lg: v })} />
|
||||
<span className="text-sm text-gray-700">Large (lg) — 36×20</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -220,42 +200,30 @@ const LoadingDemo = () => {
|
||||
{loading ? 'Stop Loading' : 'Start Loading'}
|
||||
</button>
|
||||
<div className="space-y-3">
|
||||
<FieldRoot name="largeUncheckedLoading">
|
||||
<FieldLabel className="flex items-center gap-3">
|
||||
<Switch size="lg" checked={false} loading={loading} />
|
||||
Large unchecked
|
||||
</FieldLabel>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="largeCheckedLoading">
|
||||
<FieldLabel className="flex items-center gap-3">
|
||||
<Switch size="lg" checked={true} loading={loading} />
|
||||
Large checked
|
||||
</FieldLabel>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="regularUncheckedLoading">
|
||||
<FieldLabel className="flex items-center gap-3">
|
||||
<Switch size="md" checked={false} loading={loading} />
|
||||
Regular unchecked
|
||||
</FieldLabel>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="regularCheckedLoading">
|
||||
<FieldLabel className="flex items-center gap-3">
|
||||
<Switch size="md" checked={true} loading={loading} />
|
||||
Regular checked
|
||||
</FieldLabel>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="smallLoading">
|
||||
<FieldLabel className="flex items-center gap-3">
|
||||
<Switch size="sm" checked={false} loading={loading} />
|
||||
Small
|
||||
</FieldLabel>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="extraSmallLoading">
|
||||
<FieldLabel className="flex items-center gap-3">
|
||||
<Switch size="xs" checked={false} loading={loading} />
|
||||
Extra Small
|
||||
</FieldLabel>
|
||||
</FieldRoot>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="lg" checked={false} loading={loading} />
|
||||
<span className="text-sm text-gray-700">Large unchecked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="lg" checked={true} loading={loading} />
|
||||
<span className="text-sm text-gray-700">Large checked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="md" checked={false} loading={loading} />
|
||||
<span className="text-sm text-gray-700">Regular unchecked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="md" checked={true} loading={loading} />
|
||||
<span className="text-sm text-gray-700">Regular checked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="sm" checked={false} loading={loading} />
|
||||
<span className="text-sm text-gray-700">Small (no spinner)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="xs" checked={false} loading={loading} />
|
||||
<span className="text-sm text-gray-700">Extra Small (no spinner)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -266,7 +234,7 @@ export const Loading: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Loading state disables interaction and shows a spinner for md and lg sizes.',
|
||||
story: 'Loading state disables interaction and shows a spinning icon (i-ri-loader-2-line) for md/lg sizes. Spinner position mirrors the knob: appears on the opposite side of the checked state.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -274,76 +242,61 @@ export const Loading: Story = {
|
||||
|
||||
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
function useMockAutoRetrySettingQuery() {
|
||||
const MutationLoadingDemo = () => {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
|
||||
return {
|
||||
data: {
|
||||
enabled,
|
||||
},
|
||||
setData: setEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
function useMockUpdateAutoRetrySettingMutation({
|
||||
onSuccess,
|
||||
}: {
|
||||
onSuccess: (enabled: boolean) => void
|
||||
}) {
|
||||
const [requestCount, setRequestCount] = useState(0)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const mutate = (nextValue: boolean) => {
|
||||
const handleChange = (nextValue: boolean) => {
|
||||
if (isPending)
|
||||
return
|
||||
|
||||
startTransition(async () => {
|
||||
setRequestCount(current => current + 1)
|
||||
await wait(1200)
|
||||
onSuccess(nextValue)
|
||||
setEnabled(nextValue)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
requestCount,
|
||||
isPending,
|
||||
mutate,
|
||||
}
|
||||
}
|
||||
|
||||
const MutationLoadingDemo = () => {
|
||||
const autoRetrySetting = useMockAutoRetrySettingQuery()
|
||||
const updateAutoRetrySetting = useMockUpdateAutoRetrySettingMutation({
|
||||
onSuccess: autoRetrySetting.setData,
|
||||
})
|
||||
const statusText = updateAutoRetrySetting.isPending
|
||||
? 'Saving changes...'
|
||||
: autoRetrySetting.data.enabled
|
||||
? 'Auto retry is enabled.'
|
||||
: 'Auto retry is disabled.'
|
||||
|
||||
return (
|
||||
<div className="grid w-90 gap-3 rounded-lg border border-components-panel-border bg-components-panel-bg p-4 shadow-sm">
|
||||
<FieldRoot name="autoRetry">
|
||||
<FieldLabel className="flex items-center justify-between gap-4">
|
||||
<span className="system-sm-medium text-text-secondary">Enable auto retry</span>
|
||||
<Switch
|
||||
size="lg"
|
||||
checked={autoRetrySetting.data.enabled}
|
||||
loading={updateAutoRetrySetting.isPending}
|
||||
onCheckedChange={updateAutoRetrySetting.mutate}
|
||||
/>
|
||||
</FieldLabel>
|
||||
<FieldDescription>Retry failed workflow runs without manual intervention.</FieldDescription>
|
||||
</FieldRoot>
|
||||
<div className="w-[340px] space-y-4 rounded-2xl border border-components-panel-border bg-components-panel-bg p-4 shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-text-primary">Mutation Loading Guard</p>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
Click once to start a simulated mutate call. While the request is pending, the switch enters
|
||||
{' '}
|
||||
<code className="rounded-sm bg-state-base-hover px-1 py-0.5 text-[11px]">loading</code>
|
||||
{' '}
|
||||
and rejects duplicate clicks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-text-tertiary" aria-live="polite">
|
||||
{statusText}
|
||||
{' '}
|
||||
Save attempts:
|
||||
{' '}
|
||||
{updateAutoRetrySetting.requestCount}
|
||||
</span>
|
||||
<div className="flex items-center justify-between rounded-xl border border-components-panel-border-subtle bg-background-default-dodge px-3 py-2 shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-text-primary">Enable Auto Retry</p>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{isPending ? 'Saving…' : enabled ? 'Saved as on' : 'Saved as off'}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
size="lg"
|
||||
checked={enabled}
|
||||
loading={isPending}
|
||||
onCheckedChange={handleChange}
|
||||
aria-label="Enable Auto Retry"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-text-tertiary">
|
||||
<div className="rounded-lg bg-state-base-hover px-3 py-2">
|
||||
<div className="font-medium text-text-secondary">Committed Value</div>
|
||||
<div>{enabled ? 'On' : 'Off'}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-state-base-hover px-3 py-2">
|
||||
<div className="font-medium text-text-secondary">Mutate Count</div>
|
||||
<div>{requestCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -353,7 +306,7 @@ export const MutationLoadingGuard: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Controlled switch that enters loading while the change is saved.',
|
||||
story: 'Simulates a controlled switch backed by an async mutate call. The component keeps its previous committed value, sets `loading` during the request, and blocks duplicate clicks until the mutation resolves.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -362,19 +315,19 @@ export const MutationLoadingGuard: Story = {
|
||||
const SkeletonDemo = () => (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<SwitchSkeleton size="xs" aria-hidden="true" />
|
||||
<SwitchSkeleton size="xs" />
|
||||
<span className="text-sm text-gray-700">Extra Small skeleton</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<SwitchSkeleton size="sm" aria-hidden="true" />
|
||||
<SwitchSkeleton size="sm" />
|
||||
<span className="text-sm text-gray-700">Small skeleton</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<SwitchSkeleton size="md" aria-hidden="true" />
|
||||
<SwitchSkeleton size="md" />
|
||||
<span className="text-sm text-gray-700">Regular skeleton</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<SwitchSkeleton size="lg" aria-hidden="true" />
|
||||
<SwitchSkeleton size="lg" />
|
||||
<span className="text-sm text-gray-700">Large skeleton</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -385,7 +338,7 @@ export const Skeleton: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Non-interactive placeholders for switch loading layouts.',
|
||||
story: '`SwitchSkeleton` renders a non-interactive placeholder with `bg-text-quaternary opacity-20`. Exported from `@langgenius/dify-ui/switch` alongside `Switch`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -45,34 +45,26 @@ const switchThumbVariants = cva(
|
||||
|
||||
export type SwitchSize = NonNullable<VariantProps<typeof switchRootVariants>['size']>
|
||||
|
||||
const switchSpinnerVariants = cva(
|
||||
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
md: 'size-2 left-[calc(50%+6px)] group-data-checked:left-[calc(50%-6px)]',
|
||||
lg: 'size-2.5 left-[calc(50%+8px)] group-data-checked:left-[calc(50%-8px)]',
|
||||
},
|
||||
},
|
||||
const spinnerSizeConfig: Partial<Record<SwitchSize, {
|
||||
icon: string
|
||||
uncheckedPosition: string
|
||||
checkedPosition: string
|
||||
}>> = {
|
||||
md: {
|
||||
icon: 'size-2',
|
||||
uncheckedPosition: 'left-[calc(50%+6px)]',
|
||||
checkedPosition: 'left-[calc(50%-6px)]',
|
||||
},
|
||||
lg: {
|
||||
icon: 'size-2.5',
|
||||
uncheckedPosition: 'left-[calc(50%+8px)]',
|
||||
checkedPosition: 'left-[calc(50%-8px)]',
|
||||
},
|
||||
)
|
||||
|
||||
type ControlledSwitchProps = {
|
||||
checked: boolean
|
||||
defaultChecked?: never
|
||||
}
|
||||
|
||||
type UncontrolledSwitchProps = {
|
||||
checked?: never
|
||||
defaultChecked?: boolean
|
||||
}
|
||||
|
||||
type SwitchControlProps = ControlledSwitchProps | UncontrolledSwitchProps
|
||||
|
||||
export type SwitchProps
|
||||
= Omit<BaseSwitchNS.Root.Props, 'checked' | 'defaultChecked' | 'className' | 'size' | 'onCheckedChange'>
|
||||
= Omit<BaseSwitchNS.Root.Props, 'className' | 'size' | 'onCheckedChange'>
|
||||
& VariantProps<typeof switchRootVariants>
|
||||
& SwitchControlProps
|
||||
& {
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
loading?: boolean
|
||||
@ -89,6 +81,7 @@ export function Switch({
|
||||
...props
|
||||
}: SwitchProps) {
|
||||
const isDisabled = disabled || loading
|
||||
const spinner = loading && size ? spinnerSizeConfig[size] : undefined
|
||||
|
||||
return (
|
||||
<BaseSwitch.Root
|
||||
@ -102,10 +95,14 @@ export function Switch({
|
||||
<BaseSwitch.Thumb
|
||||
className={switchThumbVariants({ size })}
|
||||
/>
|
||||
{loading && (size === 'md' || size === 'lg')
|
||||
{spinner
|
||||
? (
|
||||
<span
|
||||
className={switchSpinnerVariants({ size })}
|
||||
className={cn(
|
||||
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2',
|
||||
spinner.icon,
|
||||
checked ? spinner.checkedPosition : spinner.uncheckedPosition,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i className="i-ri-loader-2-line size-full animate-spin text-text-tertiary motion-reduce:animate-none" />
|
||||
@ -134,8 +131,11 @@ const switchSkeletonVariants = cva(
|
||||
)
|
||||
|
||||
export type SwitchSkeletonProps
|
||||
= HTMLAttributes<HTMLDivElement>
|
||||
= Omit<HTMLAttributes<HTMLDivElement>, 'className'>
|
||||
& VariantProps<typeof switchSkeletonVariants>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SwitchSkeleton({
|
||||
size = 'md',
|
||||
|
||||
@ -41,7 +41,6 @@ describe('@langgenius/dify-ui/toast', () => {
|
||||
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveAttribute('aria-live', 'polite')
|
||||
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveClass('z-60')
|
||||
expect(screen.getByRole('region', { name: 'Notifications' }).element().firstElementChild).toHaveClass('top-4')
|
||||
expect(screen.getByText('Saved').element().closest('[class*="transition-opacity"]')).toHaveClass('motion-reduce:transition-none')
|
||||
expect(screen.getByRole('dialog').element()).not.toHaveClass('outline-hidden')
|
||||
expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument()
|
||||
expect(document.body.querySelector('button[aria-label="Close notification"][aria-hidden="true"]')).toBeInTheDocument()
|
||||
|
||||
@ -171,7 +171,7 @@ function ToastCard({
|
||||
aria-hidden="true"
|
||||
className={cn('absolute -inset-px bg-linear-to-r opacity-40', getToneGradientClasses(toastType))}
|
||||
/>
|
||||
<BaseToast.Content className="relative flex items-start gap-1 overflow-hidden p-3 transition-opacity duration-200 data-behind:opacity-0 data-expanded:opacity-100 motion-reduce:transition-none">
|
||||
<BaseToast.Content className="relative flex items-start gap-1 overflow-hidden p-3 transition-opacity duration-200 data-behind:opacity-0 data-expanded:opacity-100">
|
||||
<div className="flex shrink-0 items-center justify-center p-0.5">
|
||||
<ToastIcon type={toastType} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user